sanjang 0.3.4 โ†’ 0.3.5

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.
@@ -77,11 +77,17 @@
77
77
  </div>
78
78
  <div style="flex:1"></div>
79
79
  <button class="btn btn-sub btn-sm" id="ws-terminal-btn" onclick="wsOpenTerminal()">๐Ÿ’ป ํ„ฐ๋ฏธ๋„</button>
80
+ <button class="btn btn-sub btn-sm" id="ws-compare-btn" onclick="toggleCompare()" title="์›๋ณธ๊ณผ ๋น„๊ต">๐Ÿ”€ ๋น„๊ต</button>
80
81
  <button class="btn btn-sub btn-sm" onclick="togglePanel()">โ›ฐ ํŒจ๋„</button>
81
82
  </div>
82
83
 
83
- <!-- Preview โ€” full screen -->
84
- <div class="ws-preview-full" id="ws-preview"></div>
84
+ <!-- Preview โ€” full screen with optional split -->
85
+ <div class="ws-preview-container" id="ws-preview-container">
86
+ <div class="ws-preview-full" id="ws-preview"></div>
87
+ <div class="ws-preview-main hidden" id="ws-preview-main">
88
+ <div class="ws-preview-label">๐Ÿ”๏ธ ์›๋ณธ (main)</div>
89
+ </div>
90
+ </div>
85
91
 
86
92
  <!-- Slide panel โ€” right side -->
87
93
  <div class="ws-panel" id="ws-panel">
@@ -100,6 +106,12 @@
100
106
  <div id="ws-changes"></div>
101
107
  </details>
102
108
  </div>
109
+ <div class="workspace-section ws-report-section" id="ws-report-section" style="display:none">
110
+ <h3>๐Ÿ“‹ ๋ณ€๊ฒฝ ๋ฆฌํฌํŠธ</h3>
111
+ <div class="ws-report-summary" id="ws-report-summary"></div>
112
+ <div class="ws-report-warnings" id="ws-report-warnings"></div>
113
+ <div class="ws-report-categories" id="ws-report-categories"></div>
114
+ </div>
103
115
  <div class="workspace-section">
104
116
  <h3>๐Ÿ“œ ์„ธ์ด๋ธŒ ๊ธฐ๋ก</h3>
105
117
  <div id="ws-actions"></div>
@@ -109,6 +121,7 @@
109
121
  <div class="ws-browser-error-panel" id="ws-browser-errors">
110
122
  <span style="color:var(--text-muted);font-size:12px">์—๋Ÿฌ ์—†์Œ</span>
111
123
  </div>
124
+ <button class="btn btn-fix" id="ws-fix-btn" onclick="copyFixPrompt()" style="display:none">๐Ÿฉน ๊ณ ์ณ์ค˜ โ€” ํด๋ฆฝ๋ณด๋“œ ๋ณต์‚ฌ</button>
112
125
  </details>
113
126
  <details class="workspace-section ws-log-details">
114
127
  <summary>๐Ÿ“œ ๋กœ๊ทธ</summary>
@@ -219,6 +232,7 @@
219
232
  <p id="ship-file-count" style="font-size:13px;color:var(--text-muted);margin-bottom:12px"></p>
220
233
 
221
234
  <div class="form-group">
235
+ <div id="ship-report-preview" class="ship-report-preview" style="display:none"></div>
222
236
  <label class="form-label" for="ship-message">๋ญ˜ ๋ฐ”๊ฟจ๋‚˜์š”? (ํ•œ ์ค„๋กœ)</label>
223
237
  <input
224
238
  class="form-input"
@@ -1090,6 +1090,23 @@ header h1::before {
1090
1090
  word-break: break-all;
1091
1091
  }
1092
1092
 
1093
+ .btn-fix {
1094
+ display: block;
1095
+ width: 100%;
1096
+ margin-top: 8px;
1097
+ padding: 8px 12px;
1098
+ background: linear-gradient(135deg, #ef4444 0%, #f97316 100%);
1099
+ color: #fff;
1100
+ border: none;
1101
+ border-radius: 6px;
1102
+ font-size: 13px;
1103
+ font-weight: 600;
1104
+ cursor: pointer;
1105
+ transition: opacity 0.15s, transform 0.1s;
1106
+ }
1107
+ .btn-fix:hover { opacity: 0.9; transform: translateY(-1px); }
1108
+ .btn-fix:active { transform: translateY(0); }
1109
+
1093
1110
  .ws-error-badge {
1094
1111
  background: #ef4444;
1095
1112
  color: white;
@@ -1707,11 +1724,8 @@ header.hidden {
1707
1724
  margin-top: 8px;
1708
1725
  }
1709
1726
  .ws-commit-item {
1710
- display: flex;
1711
- justify-content: space-between;
1712
- align-items: baseline;
1713
- gap: 8px;
1714
- padding: 4px 0;
1727
+ display: block;
1728
+ padding: 0;
1715
1729
  font-size: 13px;
1716
1730
  border-bottom: 1px solid var(--border);
1717
1731
  }
@@ -1720,6 +1734,10 @@ header.hidden {
1720
1734
  }
1721
1735
  .ws-commit-msg {
1722
1736
  color: var(--text-primary);
1737
+ white-space: nowrap;
1738
+ overflow: hidden;
1739
+ text-overflow: ellipsis;
1740
+ min-width: 0;
1723
1741
  }
1724
1742
  .ws-commit-date {
1725
1743
  color: var(--text-muted);
@@ -2096,6 +2114,9 @@ header.hidden {
2096
2114
  font-size: 11px;
2097
2115
  color: #e4e8f0;
2098
2116
  white-space: nowrap;
2117
+ max-width: min(320px, calc(100vw - 120px));
2118
+ overflow: hidden;
2119
+ text-overflow: ellipsis;
2099
2120
  animation: bubble-float 3s ease-in-out infinite;
2100
2121
  }
2101
2122
 
@@ -2169,3 +2190,60 @@ header.hidden {
2169
2190
  font-size: 11px;
2170
2191
  color: var(--text-muted);
2171
2192
  }
2193
+
2194
+ /* Change Report */
2195
+ .ws-report-section { border-top: 1px solid var(--border); padding-top: 12px; }
2196
+ .ws-report-desc { font-size: 14px; line-height: 1.6; color: var(--text); margin-bottom: 8px; white-space: pre-line; }
2197
+ .ws-report-warning { display: flex; align-items: center; gap: 6px; padding: 6px 8px; margin-bottom: 4px; background: rgba(255, 180, 0, 0.08); border-radius: 6px; font-size: 13px; color: var(--text-muted); }
2198
+ .ws-report-warning-icon { font-size: 14px; flex-shrink: 0; }
2199
+ .ws-report-categories { display: flex; flex-direction: column; gap: 10px; margin-top: 8px; }
2200
+ .ws-report-cat-group { }
2201
+ .ws-report-cat-header { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
2202
+ .ws-report-cat-label { font-size: 13px; font-weight: 600; color: var(--text); }
2203
+ .ws-report-cat-count { font-size: 11px; color: var(--text-muted); background: var(--surface); padding: 1px 6px; border-radius: 8px; }
2204
+ .ws-report-cat-items { list-style: none; margin: 0; padding: 0; }
2205
+ .ws-report-cat-items li { font-size: 13px; color: var(--text-muted); padding: 2px 0 2px 16px; position: relative; line-height: 1.5; }
2206
+ .ws-report-cat-items li::before { content: 'โ€ข'; position: absolute; left: 4px; color: var(--text-muted); }
2207
+ .ws-report-nav-item { cursor: pointer; transition: color 0.15s, background 0.15s; border-radius: 4px; }
2208
+ .ws-report-nav-item:hover { color: var(--accent) !important; background: color-mix(in srgb, var(--accent) 10%, transparent); }
2209
+ .ws-report-nav-hint { font-size: 11px; color: var(--accent); opacity: 0; transition: opacity 0.15s; margin-left: 4px; }
2210
+ .ws-report-nav-item:hover .ws-report-nav-hint { opacity: 1; }
2211
+ .ws-report-saved { opacity: 0.7; }
2212
+ .ws-report-saved-desc { font-size: 13px; color: var(--text-muted); }
2213
+
2214
+ /* Commit report (details/summary dropdown) */
2215
+ details.ws-commit-item { border-radius: 6px; margin-bottom: 4px; }
2216
+ details.ws-commit-item > summary.ws-commit-summary { display: flex; align-items: center; gap: 6px; cursor: pointer; list-style: none; padding: 6px 8px; border-radius: 6px; flex-wrap: nowrap; }
2217
+ details.ws-commit-item > summary.ws-commit-summary::-webkit-details-marker { display: none; }
2218
+ details.ws-commit-item > summary.ws-commit-summary:hover { background: var(--surface); }
2219
+ .ws-commit-arrow { font-size: 10px; color: var(--text-muted); flex-shrink: 0; transition: transform 0.15s; display: inline-block; }
2220
+ details.ws-commit-item[open] .ws-commit-arrow { transform: rotate(90deg); }
2221
+ .ws-commit-report { margin: 4px 8px 8px 8px; padding: 10px 12px; background: var(--surface); border-radius: 6px; font-size: 12px; width: auto; }
2222
+ .ws-commit-report-loading, .ws-commit-report-empty { color: var(--text-muted); }
2223
+ .ws-commit-cat { margin-bottom: 6px; }
2224
+ .ws-commit-cat:last-child { margin-bottom: 0; }
2225
+ .ws-commit-cat-label { font-size: 11px; font-weight: 600; color: var(--text-muted); display: block; margin-bottom: 2px; }
2226
+ .ws-commit-cat-item { color: var(--text-muted); padding-left: 12px; position: relative; line-height: 1.5; }
2227
+ .ws-commit-cat-item::before { content: 'โ€ข'; position: absolute; left: 3px; color: var(--text-muted); }
2228
+
2229
+ /* Split-view compare */
2230
+ .ws-preview-container { position: relative; flex: 1; display: flex; }
2231
+ .ws-preview-container .ws-preview-full { flex: 1; }
2232
+ .ws-preview-main { display: none; flex: 1; flex-direction: column; border-left: 2px solid var(--accent); position: relative; }
2233
+ .ws-preview-main.hidden { display: none !important; }
2234
+ .ws-split-view .ws-preview-full,
2235
+ .ws-split-view .ws-preview-main { flex: 1; display: flex; flex-direction: column; }
2236
+ .ws-preview-label { position: absolute; top: 8px; left: 8px; z-index: 10; background: var(--surface); padding: 2px 8px; border-radius: 4px; font-size: 11px; color: var(--text-muted); pointer-events: none; }
2237
+ .ws-preview-loading { display: flex; align-items: center; justify-content: center; flex: 1; color: var(--text-muted); font-size: 14px; }
2238
+ .btn-active { background: var(--accent) !important; color: white !important; }
2239
+ .ws-split-view .ws-preview-full { position: relative; }
2240
+ .ws-split-view .ws-preview-full::before {
2241
+ content: 'โ›บ ๋‚ด ์บ ํ”„';
2242
+ position: absolute; top: 8px; left: 8px; z-index: 10;
2243
+ background: var(--surface); padding: 2px 8px; border-radius: 4px;
2244
+ font-size: 11px; color: var(--text-muted); pointer-events: none;
2245
+ }
2246
+
2247
+ /* Ship report preview */
2248
+ .ship-report-preview { margin-bottom: 12px; padding: 10px; background: var(--surface); border-radius: 6px; font-size: 13px; line-height: 1.5; }
2249
+ .ship-report-desc { margin-bottom: 8px; }
@@ -14,7 +14,7 @@ for (let i = 0; i < args.length; i++) {
14
14
  i++;
15
15
  }
16
16
  if (args[i] === "--port" && args[i + 1]) {
17
- port = parseInt(args[i + 1]);
17
+ port = parseInt(args[i + 1], 10);
18
18
  i++;
19
19
  }
20
20
  if (args[i] === "--force") {
@@ -53,8 +53,8 @@ if (command === "init") {
53
53
  rl.question(" ์–ด๋–ค ์•ฑ์„ ๋„์šธ๊นŒ์š”? [๋ฒˆํ˜ธ]: ", resolve);
54
54
  });
55
55
  rl.close();
56
- const idx = parseInt(answer) - 1;
57
- if (idx < 0 || idx >= apps.length || isNaN(idx)) {
56
+ const idx = parseInt(answer, 10) - 1;
57
+ if (idx < 0 || idx >= apps.length || Number.isNaN(idx)) {
58
58
  console.error("โ›ฐ ์ž˜๋ชป๋œ ์„ ํƒ์ž…๋‹ˆ๋‹ค.");
59
59
  process.exit(1);
60
60
  }
@@ -147,10 +147,12 @@ else {
147
147
  console.log("");
148
148
  const { createInterface } = await import("node:readline");
149
149
  const rl = createInterface({ input: process.stdin, output: process.stdout });
150
- const answer = await new Promise((r) => { rl.question(" ์–ด๋–ค ์•ฑ์„ ๋„์šธ๊นŒ์š”? [๋ฒˆํ˜ธ]: ", r); });
150
+ const answer = await new Promise((r) => {
151
+ rl.question(" ์–ด๋–ค ์•ฑ์„ ๋„์šธ๊นŒ์š”? [๋ฒˆํ˜ธ]: ", r);
152
+ });
151
153
  rl.close();
152
- const idx = parseInt(answer) - 1;
153
- if (idx < 0 || idx >= apps.length || isNaN(idx)) {
154
+ const idx = parseInt(answer, 10) - 1;
155
+ if (idx < 0 || idx >= apps.length || Number.isNaN(idx)) {
154
156
  console.error("โ›ฐ ์ž˜๋ชป๋œ ์„ ํƒ์ž…๋‹ˆ๋‹ค.");
155
157
  process.exit(1);
156
158
  }
@@ -230,13 +230,11 @@ function detectTurboMainApp(root) {
230
230
  const port = portMatch?.[1] ? parseInt(portMatch[1], 10) : 3000;
231
231
  candidates.push({ name: entry.name, port });
232
232
  }
233
- catch {
234
- continue;
235
- }
233
+ catch { }
236
234
  }
237
235
  }
238
236
  // Prefer app with explicit port, then first candidate
239
- return candidates.find(c => c.port !== 3000) ?? candidates[0] ?? null;
237
+ return candidates.find((c) => c.port !== 3000) ?? candidates[0] ?? null;
240
238
  }
241
239
  function detectPackageManager(root) {
242
240
  if (existsSync(join(root, "bun.lockb")) || existsSync(join(root, "bun.lock")))
@@ -272,7 +270,7 @@ export function generateConfig(projectRoot, options = {}) {
272
270
  // Exclude .env.example, .env.test, .env.template
273
271
  (f) => !f.includes("example") && !f.includes("template") && !f.includes(".test"));
274
272
  // Detect potential issues
275
- const issues = detectSetupIssues(detectRoot);
273
+ const _issues = detectSetupIssues(detectRoot);
276
274
  const lines = [
277
275
  "export default {",
278
276
  ` // ${detected.framework} detected`,
@@ -0,0 +1,27 @@
1
+ import type { ChangeReport, ChangeReportFile, ChangeReportWarning } from "../types.ts";
2
+ type FileCategory = "ui" | "api" | "config" | "test" | "docs" | "other";
3
+ /**
4
+ * Classify a file path into one of the known categories.
5
+ * Rules are checked in priority order: test > ui > api > docs > config > other
6
+ */
7
+ export declare function categorizeFile(filePath: string): FileCategory;
8
+ /**
9
+ * Detect warnings from a list of categorized files.
10
+ * Returns deduplicated warnings (one per type).
11
+ */
12
+ export declare function detectWarnings(files: ChangeReportFile[]): ChangeReportWarning[];
13
+ /**
14
+ * Build a ChangeReport from raw file list (path + status).
15
+ * summary and humanDescription are set to null โ€” call generateReportSummary to enrich.
16
+ */
17
+ export declare function buildChangeReport(rawFiles: {
18
+ path: string;
19
+ status: ChangeReportFile["status"];
20
+ }[]): ChangeReport;
21
+ /**
22
+ * Enrich a ChangeReport with AI-generated category-level descriptions.
23
+ * Each category gets human-readable bullet points explaining what changed.
24
+ * Tries `claude -p --model haiku` and falls back to file-based summary.
25
+ */
26
+ export declare function generateReportSummary(diffStat: string, diff: string, report: ChangeReport): ChangeReport;
27
+ export {};
@@ -0,0 +1,233 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { basename, extname } from "node:path";
3
+ /**
4
+ * Classify a file path into one of the known categories.
5
+ * Rules are checked in priority order: test > ui > api > docs > config > other
6
+ */
7
+ export function categorizeFile(filePath) {
8
+ const name = basename(filePath);
9
+ const ext = extname(filePath).toLowerCase();
10
+ const lower = filePath.toLowerCase();
11
+ // 1. test
12
+ if (/\.(test|spec)\.[^.]+$/.test(lower))
13
+ return "test";
14
+ if (/[/\\](test|tests|__tests__|__mocks__|fixtures)[/\\]/.test(lower))
15
+ return "test";
16
+ if (/^(test|tests|__tests__|__mocks__|fixtures)[/\\]/.test(lower))
17
+ return "test";
18
+ // 2. ui
19
+ const uiExts = new Set([
20
+ ".css",
21
+ ".scss",
22
+ ".less",
23
+ ".sass",
24
+ ".styl",
25
+ ".html",
26
+ ".htm",
27
+ ".svg",
28
+ ".tsx",
29
+ ".jsx",
30
+ ".vue",
31
+ ".svelte",
32
+ ]);
33
+ if (uiExts.has(ext))
34
+ return "ui";
35
+ if (/[/\\](pages|views|components|layouts|styles|public)[/\\]/.test(lower))
36
+ return "ui";
37
+ if (/^(pages|views|components|layouts|styles|public)[/\\]/.test(lower))
38
+ return "ui";
39
+ // 3. api
40
+ if (/[/\\](api|routes|controllers|handlers|middleware|graphql|resolvers|schema)[/\\]/.test(lower))
41
+ return "api";
42
+ if (/^(api|routes|controllers|handlers|middleware|graphql|resolvers|schema)[/\\]/.test(lower))
43
+ return "api";
44
+ // files named "server" (any extension)
45
+ if (/[/\\]server\.[^/\\]+$/.test(lower) || /^server\.[^/\\]+$/.test(lower))
46
+ return "api";
47
+ // 4. docs
48
+ if (ext === ".md")
49
+ return "docs";
50
+ if (/[/\\]docs[/\\]/.test(lower) || /^docs[/\\]/.test(lower))
51
+ return "docs";
52
+ if (/^(README|CHANGELOG|LICENSE|CONTRIBUTING)(\.|$)/i.test(name))
53
+ return "docs";
54
+ // 5. config
55
+ const configPrefixes = [
56
+ "package",
57
+ "tsconfig",
58
+ "jest.config",
59
+ "vite.config",
60
+ "next.config",
61
+ "webpack.config",
62
+ "biome",
63
+ ".eslint",
64
+ ".prettier",
65
+ ".babel",
66
+ ];
67
+ const nameLower = name.toLowerCase();
68
+ if (configPrefixes.some((p) => nameLower.startsWith(p.toLowerCase())))
69
+ return "config";
70
+ if (name.startsWith("."))
71
+ return "config";
72
+ if ([".json", ".yaml", ".yml", ".toml"].includes(ext))
73
+ return "config";
74
+ // 6. other
75
+ return "other";
76
+ }
77
+ /**
78
+ * Detect warnings from a list of categorized files.
79
+ * Returns deduplicated warnings (one per type).
80
+ */
81
+ export function detectWarnings(files) {
82
+ const seen = new Set();
83
+ const warnings = [];
84
+ const add = (type, message, file) => {
85
+ if (seen.has(type))
86
+ return;
87
+ seen.add(type);
88
+ warnings.push({ type, message, file });
89
+ };
90
+ for (const f of files) {
91
+ const p = f.path.toLowerCase();
92
+ const name = basename(f.path);
93
+ // env
94
+ if (/\.env($|\.)/.test(name.toLowerCase())) {
95
+ add("env", "ํ™˜๊ฒฝ ๋ณ€์ˆ˜ ํŒŒ์ผ์ด ๋ณ€๊ฒฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค", f.path);
96
+ }
97
+ // db
98
+ if (/migrat|schema|\.sql|prisma/.test(p)) {
99
+ add("db", "๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์Šคํ‚ค๋งˆ ๋˜๋Š” ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜์ด ๋ณ€๊ฒฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค", f.path);
100
+ }
101
+ // infra
102
+ if (/dockerfile|docker-compose|\.github\/|deploy\/|[/\\]k8s[/\\]|^k8s[/\\]|terraform|infrastructure/.test(p)) {
103
+ add("infra", "์ธํ”„๋ผ ์„ค์ •์ด ๋ณ€๊ฒฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค", f.path);
104
+ }
105
+ // config (package manager files)
106
+ if (/package\.json|package-lock|yarn\.lock|pnpm-lock/.test(p)) {
107
+ add("config", "ํŒจํ‚ค์ง€ ์˜์กด์„ฑ์ด ๋ณ€๊ฒฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค", f.path);
108
+ }
109
+ // security
110
+ if (/auth|security|token|secret|credential|password/.test(p)) {
111
+ add("security", "๋ณด์•ˆ ๊ด€๋ จ ํŒŒ์ผ์ด ๋ณ€๊ฒฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค", f.path);
112
+ }
113
+ }
114
+ return warnings;
115
+ }
116
+ /**
117
+ * Build a ChangeReport from raw file list (path + status).
118
+ * summary and humanDescription are set to null โ€” call generateReportSummary to enrich.
119
+ */
120
+ export function buildChangeReport(rawFiles) {
121
+ const files = rawFiles.map((f) => ({
122
+ path: f.path,
123
+ status: f.status,
124
+ category: categorizeFile(f.path),
125
+ }));
126
+ const byCategory = {};
127
+ for (const f of files) {
128
+ const cat = f.category;
129
+ if (!byCategory[cat])
130
+ byCategory[cat] = [];
131
+ byCategory[cat].push(f);
132
+ }
133
+ const warnings = detectWarnings(files);
134
+ // ๊ธฐ๋ณธ ์นดํ…Œ๊ณ ๋ฆฌ ์„ค๋ช… โ€” AI ์—†์ด๋„ ์ฆ‰์‹œ ํ‘œ์‹œ
135
+ const categoryDetails = {};
136
+ for (const [cat, catFiles] of Object.entries(byCategory)) {
137
+ categoryDetails[cat] = catFiles.map((f) => {
138
+ const name = f.path.split("/").pop() || f.path;
139
+ return f.status === "์ƒˆ ํŒŒ์ผ" ? `${name} ์ถ”๊ฐ€๋จ` : `${name} ์ˆ˜์ •๋จ`;
140
+ });
141
+ }
142
+ return {
143
+ files,
144
+ totalCount: files.length,
145
+ byCategory,
146
+ warnings,
147
+ summary: null,
148
+ humanDescription: null,
149
+ categoryDetails,
150
+ };
151
+ }
152
+ /**
153
+ * Enrich a ChangeReport with AI-generated category-level descriptions.
154
+ * Each category gets human-readable bullet points explaining what changed.
155
+ * Tries `claude -p --model haiku` and falls back to file-based summary.
156
+ */
157
+ export function generateReportSummary(diffStat, diff, report) {
158
+ const categoryNames = {
159
+ ui: "ํ™”๋ฉด",
160
+ api: "์„œ๋ฒ„/API",
161
+ config: "์„ค์ •",
162
+ test: "ํ…Œ์ŠคํŠธ",
163
+ docs: "๋ฌธ์„œ",
164
+ other: "๊ธฐํƒ€",
165
+ };
166
+ const categoryList = Object.keys(report.byCategory)
167
+ .map((cat) => `${categoryNames[cat] || cat}: ${(report.byCategory[cat] ?? []).length}๊ฐœ`)
168
+ .join(", ");
169
+ const fallbackSummary = `${categoryList} ๋ณ€๊ฒฝ`;
170
+ // Build per-category diff sections for the prompt
171
+ const categoryDiffSections = Object.entries(report.byCategory)
172
+ .map(([cat, files]) => {
173
+ const fileList = files.map((f) => ` ${f.status} ${f.path}`).join("\n");
174
+ return `[${categoryNames[cat] || cat}]\n${fileList}`;
175
+ })
176
+ .join("\n\n");
177
+ const prompt = `๋„ˆ๋Š” ๋น„๊ฐœ๋ฐœ์ž์—๊ฒŒ ์ฝ”๋“œ ๋ณ€๊ฒฝ์‚ฌํ•ญ์„ ์„ค๋ช…ํ•˜๋Š” ๋„์šฐ๋ฏธ์•ผ.
178
+ ์•„๋ž˜ git diff๋ฅผ ๋ถ„์„ํ•˜๊ณ , ์นดํ…Œ๊ณ ๋ฆฌ๋ณ„๋กœ "์‹ค์ œ๋กœ ๋ญ๊ฐ€ ๋ฐ”๋€Œ์—ˆ๋Š”์ง€"๋ฅผ ์„ค๋ช…ํ•ด.
179
+
180
+ ๊ทœ์น™:
181
+ - ํŒŒ์ผ๋ช…์ด ์•„๋‹ˆ๋ผ ์‚ฌ์šฉ์ž ๊ด€์ ์—์„œ ๋ญ๊ฐ€ ๋ฐ”๋€Œ์—ˆ๋Š”์ง€ ์„ค๋ช… (์˜ˆ: "๋กœ๊ทธ์ธ ๋ฒ„ํŠผ์ด ๋ณด๋ผ์ƒ‰์œผ๋กœ ๋ฐ”๋€Œ์—ˆ์–ด์š”")
182
+ - ๊ฐ ํ•ญ๋ชฉ์€ ํ•œ๊ตญ์–ด, '~ํ–ˆ์–ด์š”/~๋์–ด์š”' ์ฒด, ํ•œ ์ค„
183
+ - ์ƒˆ ํŒŒ์ผ์ด๋ฉด "์ถ”๊ฐ€๋์–ด์š”", ์ˆ˜์ •์ด๋ฉด ๊ตฌ์ฒด์ ์œผ๋กœ ๋ญ๊ฐ€ ๋ฐ”๋€Œ์—ˆ๋Š”์ง€
184
+
185
+ ์นดํ…Œ๊ณ ๋ฆฌ๋ณ„ ํŒŒ์ผ:
186
+ ${categoryDiffSections}
187
+
188
+ diff:
189
+ ${diff.slice(0, 4000)}
190
+
191
+ JSON์œผ๋กœ๋งŒ ์‘๋‹ตํ•ด (๋‹ค๋ฅธ ํ…์ŠคํŠธ ์—†์ด):
192
+ {"summary": "์ „์ฒด ํ•œ ์ค„ ์š”์•ฝ (30์ž ์ด๋‚ด)", "categories": {"ui": ["์„ค๋ช…1", "์„ค๋ช…2"], "api": ["์„ค๋ช…1"], ...}}
193
+
194
+ 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
+ }
214
+ }
215
+ }
216
+ catch {
217
+ // Fall through to fallback
218
+ }
219
+ // Fallback: ํŒŒ์ผ ๊ธฐ๋ฐ˜ ์„ค๋ช… ์ƒ์„ฑ
220
+ const fallbackDetails = {};
221
+ for (const [cat, files] of Object.entries(report.byCategory)) {
222
+ fallbackDetails[cat] = files.map((f) => {
223
+ const name = f.path.split("/").pop() || f.path;
224
+ return f.status === "์ƒˆ ํŒŒ์ผ" ? `${name} ํŒŒ์ผ์ด ์ถ”๊ฐ€๋์–ด์š”` : `${name} ํŒŒ์ผ์ด ์ˆ˜์ •๋์–ด์š”`;
225
+ });
226
+ }
227
+ return {
228
+ ...report,
229
+ summary: fallbackSummary,
230
+ humanDescription: null,
231
+ categoryDetails: fallbackDetails,
232
+ };
233
+ }
@@ -14,9 +14,7 @@ function checkPortConflict(processInfo) {
14
14
  name: "port-conflict",
15
15
  status: hit ? "error" : "ok",
16
16
  detail: hit ? "๋‹ค๋ฅธ ํ”„๋กœ๊ทธ๋žจ๊ณผ ์ถฉ๋Œ์ด ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค." : "์ •์ƒ.",
17
- guide: hit
18
- ? '"์ค‘์ง€" โ†’ "์‹œ์ž‘"์„ ๋ˆŒ๋Ÿฌ๋ณด์„ธ์š”. ๊ณ„์†๋˜๋ฉด "์‚ญ์ œ" ํ›„ ๋‹ค์‹œ ๋งŒ๋“ค์–ด๋ณด์„ธ์š”.'
19
- : null,
17
+ guide: hit ? '"์ค‘์ง€" โ†’ "์‹œ์ž‘"์„ ๋ˆŒ๋Ÿฌ๋ณด์„ธ์š”. ๊ณ„์†๋˜๋ฉด "์‚ญ์ œ" ํ›„ ๋‹ค์‹œ ๋งŒ๋“ค์–ด๋ณด์„ธ์š”.' : null,
20
18
  };
21
19
  }
22
20
  function checkFrontendExit(processInfo) {
@@ -47,9 +45,7 @@ function checkFePort(pg) {
47
45
  return {
48
46
  name: "fe-status",
49
47
  status: output?.length ? "ok" : "warn",
50
- detail: output?.length
51
- ? "Frontend ์„œ๋ฒ„๊ฐ€ ์‹คํ–‰ ์ค‘์ž…๋‹ˆ๋‹ค."
52
- : "Frontend ์„œ๋ฒ„๊ฐ€ ์‘๋‹ตํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.",
48
+ detail: output?.length ? "Frontend ์„œ๋ฒ„๊ฐ€ ์‹คํ–‰ ์ค‘์ž…๋‹ˆ๋‹ค." : "Frontend ์„œ๋ฒ„๊ฐ€ ์‘๋‹ตํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.",
53
49
  guide: !output?.length ? '"์‹œ์ž‘" ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ๋ณด์„ธ์š”.' : null,
54
50
  };
55
51
  }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Main branch dev server manager.
3
+ * Runs a dev server from the project root for side-by-side comparison preview.
4
+ */
5
+ import type { SanjangConfig } from "../types.ts";
6
+ interface MainServerState {
7
+ status: "stopped" | "starting" | "running" | "error";
8
+ port: number | null;
9
+ error: string | null;
10
+ }
11
+ export declare function getMainServerState(): MainServerState;
12
+ export declare function getMainServerLogs(): string[];
13
+ export declare function startMainServer(projectRoot: string, config: SanjangConfig, onReady?: (port: number) => void): Promise<void>;
14
+ export declare function stopMainServer(): void;
15
+ export {};
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Main branch dev server manager.
3
+ * Runs a dev server from the project root for side-by-side comparison preview.
4
+ */
5
+ import { spawn } from "node:child_process";
6
+ import { createConnection } from "node:net";
7
+ let state = { status: "stopped", port: null, error: null };
8
+ let proc = null;
9
+ let logs = [];
10
+ export function getMainServerState() {
11
+ return { ...state };
12
+ }
13
+ export function getMainServerLogs() {
14
+ return [...logs];
15
+ }
16
+ export async function startMainServer(projectRoot, config, onReady) {
17
+ if (state.status === "running" || state.status === "starting")
18
+ return;
19
+ state = { status: "starting", port: null, error: null };
20
+ logs = [];
21
+ const basePort = config.dev.port + 100;
22
+ const cmdParts = config.dev.command.split(/\s+/);
23
+ const cmd = cmdParts[0];
24
+ const args = cmdParts.slice(1);
25
+ if (config.dev.portFlag) {
26
+ args.push(config.dev.portFlag, String(basePort));
27
+ }
28
+ const cwd = config.dev.cwd
29
+ ? config.dev.cwd.startsWith("/")
30
+ ? config.dev.cwd
31
+ : `${projectRoot}/${config.dev.cwd}`
32
+ : projectRoot;
33
+ proc = spawn(cmd, args, {
34
+ cwd,
35
+ stdio: ["ignore", "pipe", "pipe"],
36
+ env: { ...process.env, ...config.dev.env, FORCE_COLOR: "0" },
37
+ shell: true,
38
+ });
39
+ proc.stdout?.on("data", (chunk) => {
40
+ const line = chunk.toString();
41
+ logs.push(line);
42
+ if (logs.length > 100)
43
+ logs.shift();
44
+ });
45
+ proc.stderr?.on("data", (chunk) => {
46
+ const line = chunk.toString();
47
+ logs.push(line);
48
+ if (logs.length > 100)
49
+ logs.shift();
50
+ });
51
+ proc.on("close", (code) => {
52
+ if (state.status !== "stopped") {
53
+ state = { status: "stopped", port: null, error: code ? `exit ${code}` : null };
54
+ }
55
+ proc = null;
56
+ });
57
+ proc.on("error", (err) => {
58
+ state = { status: "error", port: null, error: err.message };
59
+ proc = null;
60
+ });
61
+ const detectedPort = await detectMainPort(logs, basePort, 15_000);
62
+ if (detectedPort) {
63
+ state = { status: "running", port: detectedPort, error: null };
64
+ onReady?.(detectedPort);
65
+ }
66
+ else {
67
+ state = { status: "error", port: null, error: "ํฌํŠธ๋ฅผ ๊ฐ์ง€ํ•˜์ง€ ๋ชปํ–ˆ์–ด์š”" };
68
+ }
69
+ }
70
+ export function stopMainServer() {
71
+ state = { status: "stopped", port: null, error: null };
72
+ if (proc) {
73
+ proc.kill("SIGTERM");
74
+ proc = null;
75
+ }
76
+ logs = [];
77
+ }
78
+ function detectMainPort(logLines, _fallbackPort, timeoutMs) {
79
+ return new Promise((resolve) => {
80
+ const deadline = Date.now() + timeoutMs;
81
+ const portRe = /https?:\/\/localhost:(\d+)/;
82
+ function check() {
83
+ for (const line of logLines) {
84
+ const match = portRe.exec(line);
85
+ if (match?.[1]) {
86
+ const port = parseInt(match[1], 10);
87
+ const sock = createConnection({ port, host: "localhost" });
88
+ sock.once("connect", () => {
89
+ sock.destroy();
90
+ resolve(port);
91
+ });
92
+ sock.once("error", () => {
93
+ sock.destroy();
94
+ if (Date.now() < deadline)
95
+ setTimeout(check, 1000);
96
+ else
97
+ resolve(port);
98
+ });
99
+ return;
100
+ }
101
+ }
102
+ if (Date.now() >= deadline) {
103
+ resolve(null);
104
+ }
105
+ else {
106
+ setTimeout(check, 1000);
107
+ }
108
+ }
109
+ check();
110
+ });
111
+ }
@@ -5,10 +5,19 @@ import { spawnSync } from "node:child_process";
5
5
  */
6
6
  export function aiSlugify(description) {
7
7
  try {
8
- const result = spawnSync("claude", ["-p", "--model", "haiku", `Convert this task description to a kebab-case English slug. Rules: 3-5 words, max 30 chars, lowercase, descriptive (not just one word), no explanation, output ONLY the slug.\n\nExample: "๋กœ๊ทธ์ธ ๋ฒ„ํŠผ ์ƒ‰์ƒ ๋ณ€๊ฒฝ" โ†’ "login-button-color-change"\nExample: "๋Œ€์‹œ๋ณด๋“œ ์ฐจํŠธ ์ถ”๊ฐ€" โ†’ "dashboard-chart-add"\n\nTask: "${description}"`], { encoding: "utf8", stdio: "pipe", timeout: 10_000 });
8
+ const result = spawnSync("claude", [
9
+ "-p",
10
+ "--model",
11
+ "haiku",
12
+ `Convert this task description to a kebab-case English slug. Rules: 3-5 words, max 30 chars, lowercase, descriptive (not just one word), no explanation, output ONLY the slug.\n\nExample: "๋กœ๊ทธ์ธ ๋ฒ„ํŠผ ์ƒ‰์ƒ ๋ณ€๊ฒฝ" โ†’ "login-button-color-change"\nExample: "๋Œ€์‹œ๋ณด๋“œ ์ฐจํŠธ ์ถ”๊ฐ€" โ†’ "dashboard-chart-add"\n\nTask: "${description}"`,
13
+ ], { encoding: "utf8", stdio: "pipe", timeout: 10_000 });
9
14
  if (result.status !== 0)
10
15
  return null;
11
- const slug = (result.stdout ?? "").trim().toLowerCase().replace(/[^a-z0-9-]/g, "").replace(/^-+|-+$/g, "");
16
+ const slug = (result.stdout ?? "")
17
+ .trim()
18
+ .toLowerCase()
19
+ .replace(/[^a-z0-9-]/g, "")
20
+ .replace(/^-+|-+$/g, "");
12
21
  if (!slug || slug.length > 30)
13
22
  return null;
14
23
  return slug;
@@ -27,7 +27,7 @@ export function buildFallbackPrBody({ message, actions, diffStat }) {
27
27
  export function buildClaudePrPrompt({ message, diffStat, diff }) {
28
28
  return [
29
29
  "You are writing a GitHub Pull Request description.",
30
- 'The author described the change as: "' + message + '"',
30
+ `The author described the change as: "${message}"`,
31
31
  "",
32
32
  "Here is the diff stat:",
33
33
  diffStat || "(no stat)",