sanjang 0.3.6 → 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.
@@ -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
  }
@@ -1,4 +1,9 @@
1
1
  import { execSync } from "node:child_process";
2
+ import { claudeSync } from "./ai.js";
3
+ /** Maximum character length for AI-generated guide messages. */
4
+ const MAX_AI_GUIDE_LENGTH = 300;
5
+ /** Number of log lines to send to AI for guide generation. */
6
+ const AI_GUIDE_LOG_TAIL = 30;
2
7
  function tryExec(cmd) {
3
8
  try {
4
9
  return execSync(cmd, { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim();
@@ -17,7 +22,28 @@ function checkPortConflict(processInfo) {
17
22
  guide: hit ? '"중지" → "시작"을 눌러보세요. 계속되면 "삭제" 후 다시 만들어보세요.' : null,
18
23
  };
19
24
  }
20
- function checkFrontendExit(processInfo) {
25
+ const FALLBACK_GUIDE_MODULE = '필요한 패키지가 없어요. "처음부터 다시"를 눌러 의존성을 다시 설치해보세요.';
26
+ const FALLBACK_GUIDE_GENERIC = '서버가 에러로 종료됐어요. "처음부터 다시"를 누르거나, "디버그" 버튼으로 로그를 복사해서 Claude에게 물어보세요.';
27
+ function generateAiGuide(logLines) {
28
+ const tail = logLines.slice(-AI_GUIDE_LOG_TAIL).join("\n");
29
+ const prompt = `프론트엔드 개발 서버가 에러로 종료되었습니다. 아래 로그를 분석해서 비개발자에게 한 줄로 문제를 설명하고, 해결을 위해 어떤 버튼을 눌러야 하는지 알려주세요.
30
+
31
+ 사용 가능한 버튼: "처음부터 다시", "중지", "시작", "삭제", "디버그"
32
+
33
+ 로그 (마지막 30줄):
34
+ ${tail}
35
+
36
+ 규칙:
37
+ - 반드시 한국어로, 한 줄로 답변하세요.
38
+ - "~해보세요" 체로 끝내세요.
39
+ - 버튼 이름은 큰따옴표로 감싸세요.
40
+ - 마크다운이나 코드 블록 없이 순수 텍스트만 출력하세요.`;
41
+ const output = claudeSync(prompt, { model: "haiku", outputFormat: "text" });
42
+ if (!output || output.length > MAX_AI_GUIDE_LENGTH || output.includes("```"))
43
+ return null;
44
+ return output;
45
+ }
46
+ async function checkFrontendExit(processInfo) {
21
47
  const { feExitCode, feLogs } = processInfo;
22
48
  if (feExitCode === null || feExitCode === 0) {
23
49
  return {
@@ -27,16 +53,25 @@ function checkFrontendExit(processInfo) {
27
53
  guide: null,
28
54
  };
29
55
  }
30
- const tail = (feLogs ?? []).join("").slice(-500);
56
+ const logLines = feLogs ?? [];
57
+ const tail = logLines.join("").slice(-500);
31
58
  const isModuleError = /MODULE_NOT_FOUND|Cannot find module/i.test(tail);
32
- const guide = isModuleError
33
- ? '필요한 패키지가 없어요. "처음부터 다시"를 눌러 의존성을 다시 설치해보세요.'
34
- : '서버가 에러로 종료됐어요. "처음부터 다시"를 누르거나, "디버그" 버튼으로 로그를 복사해서 Claude에게 물어보세요.';
59
+ // Fast path: MODULE_NOT_FOUND has a clear fix, no AI needed
60
+ if (isModuleError) {
61
+ return {
62
+ name: "frontend-exit",
63
+ status: "error",
64
+ detail: `Frontend가 비정상 종료되었습니다 (코드 ${feExitCode}).`,
65
+ guide: FALLBACK_GUIDE_MODULE,
66
+ };
67
+ }
68
+ // AI path: ask Claude for a tailored guide, fallback to generic message
69
+ const aiGuide = generateAiGuide(logLines);
35
70
  return {
36
71
  name: "frontend-exit",
37
72
  status: "error",
38
73
  detail: `Frontend가 비정상 종료되었습니다 (코드 ${feExitCode}).`,
39
- guide,
74
+ guide: aiGuide ?? FALLBACK_GUIDE_GENERIC,
40
75
  };
41
76
  }
42
77
  function checkFePort(pg) {
@@ -50,5 +85,5 @@ function checkFePort(pg) {
50
85
  };
51
86
  }
52
87
  export async function buildDiagnostics(pg, processInfo) {
53
- return [checkPortConflict(processInfo), checkFrontendExit(processInfo), checkFePort(pg)];
88
+ return [checkPortConflict(processInfo), await checkFrontendExit(processInfo), checkFePort(pg)];
54
89
  }
@@ -4,9 +4,14 @@ export interface HealAction {
4
4
  auto: boolean;
5
5
  }
6
6
  export declare function diagnoseFromLogs(logs: string[]): HealAction[];
7
+ export interface AiDiagnosis {
8
+ cause: string;
9
+ action: "reinstall" | "copy-env" | "restart";
10
+ detail: string;
11
+ }
7
12
  export interface HealResult {
8
13
  action: HealAction;
9
14
  success: boolean;
10
15
  detail?: string;
11
16
  }
12
- export declare function executeHeal(action: HealAction, campPath: string, projectRoot: string, setupCommand: string | null, copyFiles: string[]): HealResult;
17
+ export declare function executeHeal(action: HealAction, campPath: string, projectRoot: string, setupCommand: string | null, copyFiles: string[], logs?: string[]): HealResult;
@@ -1,6 +1,9 @@
1
1
  import { execSync } from "node:child_process";
2
2
  import { copyFileSync, existsSync, mkdirSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
+ import { claudeSync, extractJson } from "./ai.js";
5
+ /** Number of log lines to send to AI for diagnosis. */
6
+ const AI_DIAGNOSIS_LOG_TAIL = 30;
4
7
  const PATTERNS = [
5
8
  {
6
9
  test: /Cannot find module|MODULE_NOT_FOUND/i,
@@ -43,6 +46,8 @@ const PATTERNS = [
43
46
  }),
44
47
  },
45
48
  ];
49
+ /** Broad regex to detect error signals in logs (used for ask-ai fallback). */
50
+ const ERROR_SIGNAL = /\b(error|ERR!|FATAL|ENOSPC|EPERM|EACCES|TypeError|ReferenceError|SyntaxError|Segmentation fault|panic|Unhandled|uncaught|failed to|build failed)\b/i;
46
51
  export function diagnoseFromLogs(logs) {
47
52
  const combined = logs.join("\n");
48
53
  const actions = [];
@@ -56,9 +61,51 @@ export function diagnoseFromLogs(logs) {
56
61
  }
57
62
  }
58
63
  }
64
+ // Fallback: if no known pattern matched but logs contain error signals, ask AI
65
+ if (actions.length === 0 && ERROR_SIGNAL.test(combined)) {
66
+ actions.push({
67
+ type: "ask-ai",
68
+ message: "알려진 패턴이 아닙니다. AI에게 진단을 요청합니다.",
69
+ auto: true,
70
+ });
71
+ }
59
72
  return actions;
60
73
  }
61
- export function executeHeal(action, campPath, projectRoot, setupCommand, copyFiles) {
74
+ function askAiForDiagnosis(logs, campPathStr) {
75
+ const tail = logs.slice(-AI_DIAGNOSIS_LOG_TAIL).join("\n");
76
+ const prompt = `You are a dev server error diagnostician. Analyze these logs and respond with ONLY a JSON object (no markdown, no code fences).
77
+
78
+ Logs (last 30 lines from a dev server at ${campPathStr}):
79
+ ${tail}
80
+
81
+ Respond with exactly this JSON structure:
82
+ {"cause":"<root cause in Korean>","action":"<one of: reinstall|copy-env|restart>","detail":"<fix explanation in Korean>"}
83
+
84
+ Rules:
85
+ - action "reinstall": dependency issues (missing modules, version mismatch)
86
+ - action "copy-env": missing env files or env variables
87
+ - action "restart": port conflicts, zombie processes, or any other issue
88
+ - Pick the closest matching action. Do NOT suggest shell commands.`;
89
+ const raw = claudeSync(prompt, { model: "haiku", timeout: 30_000, outputFormat: "text" });
90
+ if (!raw)
91
+ return null;
92
+ const parsed = extractJson(raw);
93
+ if (!parsed)
94
+ return null;
95
+ const validActions = ["reinstall", "copy-env", "restart"];
96
+ if (typeof parsed.cause !== "string" ||
97
+ typeof parsed.action !== "string" ||
98
+ !validActions.includes(parsed.action) ||
99
+ typeof parsed.detail !== "string") {
100
+ return null;
101
+ }
102
+ return {
103
+ cause: parsed.cause,
104
+ action: parsed.action,
105
+ detail: parsed.detail,
106
+ };
107
+ }
108
+ export function executeHeal(action, campPath, projectRoot, setupCommand, copyFiles, logs) {
62
109
  switch (action.type) {
63
110
  case "reinstall": {
64
111
  if (!setupCommand)
@@ -101,8 +148,26 @@ export function executeHeal(action, campPath, projectRoot, setupCommand, copyFil
101
148
  case "restart":
102
149
  // Restart is handled by the caller (stop + start)
103
150
  return { action, success: true, detail: "재시작을 시도합니다." };
104
- case "ask-ai":
105
- return { action, success: false, detail: "자동 수정할 수 없습니다." };
151
+ case "ask-ai": {
152
+ if (!logs || logs.length === 0) {
153
+ return { action, success: false, detail: "자동 수정할 수 없습니다." };
154
+ }
155
+ const diagnosis = askAiForDiagnosis(logs, campPath);
156
+ if (!diagnosis) {
157
+ return { action, success: false, detail: "AI 진단에 실패했습니다. 로그를 직접 확인해주세요." };
158
+ }
159
+ // Reuse existing heal logic for known action types
160
+ if (diagnosis.action === "reinstall") {
161
+ const sub = { type: "reinstall", message: diagnosis.detail, auto: true };
162
+ return executeHeal(sub, campPath, projectRoot, setupCommand, copyFiles);
163
+ }
164
+ if (diagnosis.action === "copy-env") {
165
+ const sub = { type: "copy-env", message: diagnosis.detail, auto: true };
166
+ return executeHeal(sub, campPath, projectRoot, setupCommand, copyFiles);
167
+ }
168
+ // diagnosis.action === "restart"
169
+ return { action, success: true, detail: `AI 진단: ${diagnosis.cause}\n→ ${diagnosis.detail}` };
170
+ }
106
171
  default:
107
172
  return { action, success: false, detail: "알 수 없는 액션입니다." };
108
173
  }
@@ -4,7 +4,8 @@
4
4
  * Uses `claude -p` to generate a human-readable PR description from the diff.
5
5
  * Falls back to a simple file-count summary when the CLI is unavailable.
6
6
  */
7
- import { spawn, spawnSync } from "node:child_process";
7
+ import { spawn } from "node:child_process";
8
+ import { isClaudeAvailable } from "./ai.js";
8
9
  const TIMEOUT_MS = 30_000;
9
10
  /**
10
11
  * Run a command asynchronously with a timeout.
@@ -63,13 +64,6 @@ export function parseDiffStatSummary(diffStat) {
63
64
  }
64
65
  return parts.join(" ");
65
66
  }
66
- /**
67
- * Check if claude CLI is available on PATH.
68
- */
69
- function isClaudeAvailable() {
70
- const result = spawnSync("which", ["claude"], { stdio: "pipe" });
71
- return result.status === 0;
72
- }
73
67
  /**
74
68
  * Generate a PR description for the given worktree path.
75
69
  *
@@ -4,6 +4,7 @@ interface StashEntry {
4
4
  isSanjangSnapshot: boolean;
5
5
  date: string;
6
6
  }
7
+ export declare function generateSnapshotLabel(campName: string): Promise<string>;
7
8
  export declare function saveSnapshot(name: string, label: string): Promise<void>;
8
9
  export declare function restoreSnapshot(name: string, index: number): Promise<void>;
9
10
  export declare function listSnapshots(name: string): Promise<StashEntry[]>;
@@ -1,6 +1,30 @@
1
1
  import { simpleGit } from "simple-git";
2
+ import { claudeSync } from "./ai.js";
2
3
  import { campPath } from "./worktree.js";
3
4
  const STASH_PREFIX = "sanjang-snapshot:";
5
+ /** Maximum character length for snapshot labels. */
6
+ const MAX_SNAPSHOT_LABEL_LENGTH = 30;
7
+ export async function generateSnapshotLabel(campName) {
8
+ const cwd = campPath(campName);
9
+ const git = simpleGit(cwd);
10
+ const now = new Date();
11
+ const timestamp = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")} ${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
12
+ try {
13
+ const diffStat = await git.raw(["diff", "--stat", "HEAD"]);
14
+ if (!diffStat?.trim()) {
15
+ return `변경 없음 (${timestamp})`;
16
+ }
17
+ const fileCount = diffStat.trim().split("\n").length;
18
+ const prompt = `다음은 git diff --stat 결과입니다. 이 변경사항을 15자 이내 한국어로 요약해주세요. 요약만 출력하고 다른 설명은 하지 마세요.\n\n${diffStat}`;
19
+ const label = claudeSync(prompt, { model: "haiku", timeout: 10_000, cwd });
20
+ if (label)
21
+ return label.slice(0, MAX_SNAPSHOT_LABEL_LENGTH);
22
+ return `${fileCount}개 파일 변경 (${timestamp})`;
23
+ }
24
+ catch {
25
+ return `스냅샷 (${timestamp})`;
26
+ }
27
+ }
4
28
  export async function saveSnapshot(name, label) {
5
29
  const git = simpleGit(campPath(name));
6
30
  await git.raw(["stash", "push", "--include-untracked", "-m", `${STASH_PREFIX}${label}`]);
@@ -4,11 +4,17 @@
4
4
  * Aggregates open issues, PRs, and recent git activity to surface
5
5
  * actionable suggestions on the dashboard — no LLM required.
6
6
  */
7
+ export type SuggestionType = "issue" | "pr" | "recent" | "ai-recommended";
7
8
  export interface Suggestion {
8
- type: "issue" | "pr" | "recent";
9
+ type: SuggestionType;
9
10
  title: string;
10
11
  detail?: string;
11
12
  action?: string;
13
+ reason?: string;
14
+ }
15
+ export interface SuggestOptions {
16
+ /** Enable AI-powered priority recommendations (default: false) */
17
+ ai?: boolean;
12
18
  }
13
19
  /**
14
20
  * Suggest tasks the user might work on next.
@@ -17,5 +23,8 @@ export interface Suggestion {
17
23
  * If `gh` CLI is unavailable, returns git-based suggestions only.
18
24
  *
19
25
  * Results are sorted by relevance: PRs (이어하기) > Issues (이슈) > Recent (최근 작업).
26
+ *
27
+ * When `options.ai` is true, uses Claude (haiku) to generate top-3 priority
28
+ * recommendations prepended to the results. Falls back gracefully on failure.
20
29
  */
21
- export declare function suggestTasks(projectRoot: string): Promise<Suggestion[]>;
30
+ export declare function suggestTasks(projectRoot: string, options?: SuggestOptions): Promise<Suggestion[]>;
@@ -5,6 +5,7 @@
5
5
  * actionable suggestions on the dashboard — no LLM required.
6
6
  */
7
7
  import { spawn } from "node:child_process";
8
+ import { claudeSync, extractJson } from "./ai.js";
8
9
  // ---------------------------------------------------------------------------
9
10
  // Helpers
10
11
  // ---------------------------------------------------------------------------
@@ -83,6 +84,32 @@ async function fetchRecentCommits(cwd) {
83
84
  };
84
85
  });
85
86
  }
87
+ function getAiRecommendations(suggestions) {
88
+ if (suggestions.length === 0)
89
+ return [];
90
+ const summary = suggestions
91
+ .map((s) => `[${s.type}] ${s.title}${s.detail ? ` (${s.detail})` : ""}`)
92
+ .join("\n");
93
+ const prompt = `You are a dev productivity assistant. Given the developer's current work items below, pick the top 3 they should work on first. For each, explain in one line why it's the priority.
94
+
95
+ Work items:
96
+ ${summary}
97
+
98
+ Respond ONLY with a JSON array (no markdown, no code fences):
99
+ [{ "title": "exact title from list", "reason": "one-line reason" }]`;
100
+ const raw = claudeSync(prompt, { model: "haiku" });
101
+ if (!raw)
102
+ return [];
103
+ const parsed = extractJson(raw);
104
+ if (!Array.isArray(parsed))
105
+ return [];
106
+ return parsed
107
+ .filter((item) => typeof item === "object" &&
108
+ item !== null &&
109
+ typeof item.title === "string" &&
110
+ typeof item.reason === "string")
111
+ .slice(0, 3);
112
+ }
86
113
  // ---------------------------------------------------------------------------
87
114
  // Public API
88
115
  // ---------------------------------------------------------------------------
@@ -93,8 +120,11 @@ async function fetchRecentCommits(cwd) {
93
120
  * If `gh` CLI is unavailable, returns git-based suggestions only.
94
121
  *
95
122
  * Results are sorted by relevance: PRs (이어하기) > Issues (이슈) > Recent (최근 작업).
123
+ *
124
+ * When `options.ai` is true, uses Claude (haiku) to generate top-3 priority
125
+ * recommendations prepended to the results. Falls back gracefully on failure.
96
126
  */
97
- export async function suggestTasks(projectRoot) {
127
+ export async function suggestTasks(projectRoot, options) {
98
128
  const results = [];
99
129
  // gh-dependent fetches — tolerate failure (gh not installed / no repo)
100
130
  const [issues, prs] = await Promise.allSettled([fetchIssues(projectRoot), fetchMyPrs(projectRoot)]);
@@ -114,5 +144,24 @@ export async function suggestTasks(projectRoot) {
114
144
  catch {
115
145
  // No git history — return whatever we have
116
146
  }
147
+ // AI-powered priority recommendations (opt-in)
148
+ if (options?.ai) {
149
+ const recommendations = getAiRecommendations(results);
150
+ if (recommendations.length > 0) {
151
+ const aiSuggestions = recommendations.map((rec) => {
152
+ // Try to find the original suggestion to preserve action/detail
153
+ const original = results.find((s) => s.title === rec.title);
154
+ return {
155
+ type: "ai-recommended",
156
+ title: rec.title,
157
+ detail: original?.detail,
158
+ action: original?.action,
159
+ reason: rec.reason,
160
+ };
161
+ });
162
+ // Prepend AI recommendations before the regular results
163
+ return [...aiSuggestions, ...results];
164
+ }
165
+ }
117
166
  return results;
118
167
  }
@@ -3,12 +3,15 @@ export interface BranchInfo {
3
3
  remote: boolean;
4
4
  local: boolean;
5
5
  date: string;
6
- category?: "default" | "feature" | "fix" | "other";
6
+ category?: "default" | "feature" | "fix" | "camp" | "other";
7
+ description?: string;
7
8
  }
8
9
  export declare function setProjectRoot(root: string): void;
9
10
  export declare function getProjectRoot(): string;
10
11
  export declare function campPath(name: string): string;
11
- export declare function listBranches(): Promise<BranchInfo[]>;
12
+ export declare function listBranches(options?: {
13
+ enrich?: boolean;
14
+ }): Promise<BranchInfo[]>;
12
15
  export declare function invalidateBranchCache(): void;
13
16
  export declare function startBranchRefresh(intervalMs?: number): ReturnType<typeof setInterval>;
14
17
  export declare function addWorktree(name: string, branch: string): Promise<void>;
@@ -1,5 +1,6 @@
1
1
  import { join } from "node:path";
2
2
  import { simpleGit } from "simple-git";
3
+ import { claudeSync, extractJson } from "./ai.js";
3
4
  import { getCampsDir } from "./state.js";
4
5
  let projectRoot = null;
5
6
  export function setProjectRoot(root) {
@@ -61,12 +62,52 @@ async function parseBranches() {
61
62
  else if (b.name.startsWith("fix/") || b.name.startsWith("hotfix/")) {
62
63
  b.category = "fix";
63
64
  }
65
+ else if (b.name.startsWith("camp/")) {
66
+ b.category = "camp";
67
+ }
64
68
  else {
65
69
  b.category = "other";
66
70
  }
67
71
  }
68
72
  return branches;
69
73
  }
74
+ async function enrichBranchDescriptions(branches) {
75
+ const needDesc = branches.filter((b) => !b.description && b.category !== "default");
76
+ if (needDesc.length === 0)
77
+ return;
78
+ // Collect recent commit messages per branch (parallel)
79
+ const commitMap = {};
80
+ const logResults = await Promise.allSettled(needDesc.map(async (b) => {
81
+ const ref = b.local ? b.name : `origin/${b.name}`;
82
+ const log = await git().raw(["log", ref, "--oneline", "-5", "--no-merges"]);
83
+ return { name: b.name, log: log.trim() };
84
+ }));
85
+ for (const result of logResults) {
86
+ if (result.status === "fulfilled" && result.value.log) {
87
+ commitMap[result.value.name] = result.value.log;
88
+ }
89
+ }
90
+ if (Object.keys(commitMap).length === 0)
91
+ return;
92
+ const prompt = [
93
+ "아래는 git 브랜치별 최근 커밋 메시지입니다.",
94
+ "각 브랜치가 무슨 작업을 하는 브랜치인지 한 줄(20자 이내)로 설명해주세요.",
95
+ "JSON 형식으로만 응답: { \"브랜치명\": \"설명\", ... }",
96
+ "",
97
+ JSON.stringify(commitMap, null, 2),
98
+ ].join("\n");
99
+ const raw = claudeSync(prompt, { model: "haiku" });
100
+ if (!raw)
101
+ return;
102
+ const descriptions = extractJson(raw);
103
+ if (!descriptions)
104
+ return;
105
+ for (const b of needDesc) {
106
+ if (descriptions[b.name]) {
107
+ b.description = descriptions[b.name];
108
+ }
109
+ }
110
+ }
70
111
  async function refreshBranches() {
71
112
  try {
72
113
  await git().fetch(["--prune"]);
@@ -79,11 +120,18 @@ async function refreshBranches() {
79
120
  _branchCacheTime = Date.now();
80
121
  return branches;
81
122
  }
82
- export async function listBranches() {
123
+ export async function listBranches(options) {
124
+ let branches;
83
125
  if (_branchCache && Date.now() - _branchCacheTime < BRANCH_CACHE_TTL) {
84
- return _branchCache;
126
+ branches = _branchCache;
127
+ }
128
+ else {
129
+ branches = await refreshBranches();
85
130
  }
86
- return refreshBranches();
131
+ if (options?.enrich) {
132
+ await enrichBranchDescriptions(branches);
133
+ }
134
+ return branches;
87
135
  }
88
136
  export function invalidateBranchCache() {
89
137
  _branchCacheTime = 0;