@kybird/llm-wiki 0.2.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/lib/kanban.js ADDED
@@ -0,0 +1,363 @@
1
+ // kanban 코어 — 카드당 파일 저장소 + 유도 뷰용 조회 유틸 (개선계획 2단계).
2
+ // 설계 근거: improvement-plan.md §3 (plan.md 3.5/3.7 확정 위에 세운 조합).
3
+ // - 저장 형태: 카드당 파일. 상태(frontmatter)와 내용(본문)이 같은 파일에 있고,
4
+ // 종결(done/superseded/abandoned)은 폴더가 말한다. board 뷰는 유도물이다.
5
+ // - 카드 쓰기는 CLI만 — 사람 손편집 round-trip(1,250줄 파서의 원인)을 처음부터 포기.
6
+ // 사람은 읽기만. 섹션 경계는 센티넬 주석(<!-- kanban:…:begin/end -->)으로 정확히 자른다.
7
+ // - 클레임 = 만료 있는 협동 락(기본 1h). 에이전트가 밤에 죽어도 클레임이 자연 만료되어
8
+ // 재집기 가능 — 스테일 락 청소가 필요 없다.
9
+ // - 파일 락은 잠금 폴더의 mkdir 원자성으로 (Windows 호환 — chmod/flock 안 씀).
10
+ // - 제목이 곧 식별자 (3.5 확정). 파일명은 제목을 슬러그화한 것.
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+
14
+ // 활성 카드의 상태 — cards/ 폴더 안에서 frontmatter status로 구분된다.
15
+ const ACTIVE_STATUSES = ['todo', 'doing', 'review'];
16
+ // 종결 상태는 폴더가 말한다 (plan.md 3.1: 완료/대체/폐기 세 갈래).
17
+ const TERMINAL_FOLDERS = ['done', 'superseded', 'abandoned'];
18
+
19
+ // 보드 기본 설정 — board.yml이 있으면 그 파일이 이긴다.
20
+ const DEFAULT_CONFIG = {
21
+ statuses: ACTIVE_STATUSES,
22
+ wipLimits: { doing: 2 },
23
+ claimTimeoutMinutes: 60, // 클레임 만료 — 밤샘 루프의 죽은 세션 자동 회수
24
+ };
25
+
26
+ // 잠금 폴더가 이보다 오래됐으면 프로세스가 죽은 것으로 보고 강탈한다.
27
+ const LOCK_STALE_MS = 15 * 1000;
28
+ // 활동 로그 상한 (kanban-md 방식) — 넘치면 반을 버린다.
29
+ const ACTIVITY_CAP = 10000;
30
+
31
+ // ── 경로 ────────────────────────────────────────────────────────────────
32
+
33
+ function kanbanPaths(docRoot) {
34
+ const kanbanDir = path.join(docRoot, 'kanban');
35
+ return {
36
+ kanbanDir,
37
+ cardsDir: path.join(kanbanDir, 'cards'),
38
+ doneDir: path.join(kanbanDir, 'done'),
39
+ supersededDir: path.join(kanbanDir, 'superseded'),
40
+ abandonedDir: path.join(kanbanDir, 'abandoned'),
41
+ activityPath: path.join(kanbanDir, 'activity.jsonl'),
42
+ boardYmlPath: path.join(kanbanDir, 'board.yml'),
43
+ lockDir: path.join(kanbanDir, '.lock'),
44
+ };
45
+ }
46
+
47
+ // board.yml 파싱 — 필요한 키만 뽑는 축소 파서 (statuses / wip_limits.doing /
48
+ // claim_timeout_minutes). 포맷이 굳기 전까지는 yaml 의존성을 붙이지 않는다.
49
+ function loadConfig(paths) {
50
+ const config = JSON.parse(JSON.stringify(DEFAULT_CONFIG));
51
+ if (!fs.existsSync(paths.boardYmlPath)) return config;
52
+ const lines = fs.readFileSync(paths.boardYmlPath, 'utf8').split(/\r?\n/);
53
+ let inWip = false;
54
+ for (const line of lines) {
55
+ const statusMatch = line.match(/^statuses:\s*\[(.*)\]/);
56
+ if (statusMatch) {
57
+ config.statuses = statusMatch[1].split(',').map(s => s.trim()).filter(Boolean);
58
+ continue;
59
+ }
60
+ const timeoutMatch = line.match(/^claim_timeout_minutes:\s*(\d+)/);
61
+ if (timeoutMatch) { config.claimTimeoutMinutes = Number(timeoutMatch[1]); continue; }
62
+ if (/^wip_limits:/.test(line)) { inWip = true; continue; }
63
+ if (inWip) {
64
+ const wipMatch = line.match(/^\s+(\w+):\s*(\d+)/);
65
+ if (wipMatch) config.wipLimits[wipMatch[1]] = Number(wipMatch[2]);
66
+ else if (line.trim() && !line.startsWith(' ')) inWip = false;
67
+ }
68
+ }
69
+ return config;
70
+ }
71
+
72
+ // ── 제목 ↔ 파일명 ───────────────────────────────────────────────────────
73
+
74
+ // 제목을 파일명으로. Windows 금지문자 제거, 공백→하이픈, 한글은 살린다.
75
+ // 제목 자체가 식별자(3.5)라 슬러그는 파일시스템 편의분일 뿐 — 카드 탐색은
76
+ // frontmatter title 기준으로 한다.
77
+ function slugify(title) {
78
+ return String(title).trim()
79
+ .replace(/[\\/:*?"<>|]/g, '-')
80
+ .replace(/\s+/g, '-')
81
+ .replace(/-+/g, '-')
82
+ .replace(/^-|-$/g, '')
83
+ .slice(0, 80) || 'untitled';
84
+ }
85
+
86
+ // ── 카드 파싱/직렬화 ─────────────────────────────────────────────────────
87
+
88
+ // frontmatter의 `key: value` / `key: [a, b]`만 다룬다. 중첩 없음.
89
+ function parseFrontmatter(content) {
90
+ const meta = {};
91
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
92
+ if (!match) return meta;
93
+ for (const line of match[1].split(/\r?\n/)) {
94
+ const kv = line.match(/^(\w+):\s*(.*)$/);
95
+ if (!kv) continue;
96
+ const [, key, rawValue] = kv;
97
+ const listMatch = rawValue.match(/^\[(.*)\]$/);
98
+ if (listMatch) {
99
+ meta[key] = listMatch[1].split(',').map(s => s.trim()).filter(Boolean);
100
+ } else {
101
+ meta[key] = rawValue.trim().replace(/^["']|["']$/g, '');
102
+ }
103
+ }
104
+ return meta;
105
+ }
106
+
107
+ function serializeFrontmatter(meta) {
108
+ const lines = ['---'];
109
+ for (const [key, value] of Object.entries(meta)) {
110
+ if (Array.isArray(value)) lines.push(`${key}: [${value.join(', ')}]`);
111
+ else if (value !== undefined && value !== '') lines.push(`${key}: ${value}`);
112
+ }
113
+ lines.push('---');
114
+ return lines.join('\n');
115
+ }
116
+
117
+ // 본문을 섹션들로. Goal/AC는 센티넬로 정확히 자르고, 나머지는 `## 이름` 헤더 단위.
118
+ // 반환: { goal, ac: [{checked, idx, text}], sections: Map<name, bodyText> }
119
+ function parseBody(content) {
120
+ const body = content.replace(/^---\r?\n[\s\S]*?\r?\n---/, '').replace(/^\r?\n/, '');
121
+ const card = { goal: '', ac: [], sections: new Map() };
122
+
123
+ const goalMatch = body.match(/<!-- kanban:goal:begin -->\r?\n([\s\S]*?)<!-- kanban:goal:end -->/);
124
+ if (goalMatch) card.goal = goalMatch[1].replace(/\r?\n$/, '');
125
+
126
+ const acMatch = body.match(/<!-- kanban:ac:begin -->\r?\n([\s\S]*?)<!-- kanban:ac:end -->/);
127
+ if (acMatch) {
128
+ for (const line of acMatch[1].split(/\r?\n/)) {
129
+ const ac = line.match(/^-\s+\[( |x)\]\s+#(\d+)\s+(.*)$/);
130
+ if (ac) card.ac.push({ checked: ac[1] === 'x', idx: Number(ac[2]), text: ac[3] });
131
+ }
132
+ }
133
+
134
+ // 센티넬 블록을 제거한 뒤 남은 `## 섹션`들을 수집 — Plan/Notes/Handoff/Result와
135
+ // 사용자가 CLI로 넣은 기타 섹션. 센티넬 헤더(## Goal, ## Acceptance Criteria)는 건너뛴다.
136
+ const withoutSentinels = body
137
+ .replace(/<!-- kanban:goal:begin -->[\s\S]*?<!-- kanban:goal:end -->\r?\n?/g, '')
138
+ .replace(/<!-- kanban:ac:begin -->[\s\S]*?<!-- kanban:ac:end -->\r?\n?/g, '');
139
+ let current = null;
140
+ let buffer = [];
141
+ const flush = () => {
142
+ if (current) card.sections.set(current, buffer.join('\n').replace(/\n+$/, ''));
143
+ };
144
+ for (const line of withoutSentinels.split(/\r?\n/)) {
145
+ const header = line.match(/^##\s+(.+?)\s*$/);
146
+ if (header) {
147
+ flush();
148
+ const name = header[1];
149
+ if (name === 'Goal' || name === 'Acceptance Criteria') { current = null; buffer = []; continue; }
150
+ current = name;
151
+ buffer = [];
152
+ } else if (current) {
153
+ buffer.push(line);
154
+ }
155
+ }
156
+ flush();
157
+ return card;
158
+ }
159
+
160
+ // Goal/AC는 센티넬과 함께, 나머지 섹션은 헤더만으로 직렬화.
161
+ // Notes는 append-only 저널이라 CLI가 줄을 덧붙인다 — 전체를 재조립하지 않는 섹션 단위
162
+ // 재작성을 위해 항상 고정 순서(Goal, AC, Plan, Notes, Handoff, Result, 기타)로 출력.
163
+ function serializeCard(meta, parsed) {
164
+ const out = [serializeFrontmatter(meta), ''];
165
+ out.push('## Goal');
166
+ out.push('<!-- kanban:goal:begin -->');
167
+ out.push(parsed.goal || '');
168
+ out.push('<!-- kanban:goal:end -->');
169
+ out.push('');
170
+ out.push('## Acceptance Criteria');
171
+ out.push('<!-- kanban:ac:begin -->');
172
+ for (const ac of parsed.ac) {
173
+ out.push(`- [${ac.checked ? 'x' : ' '}] #${ac.idx} ${ac.text}`);
174
+ }
175
+ out.push('<!-- kanban:ac:end -->');
176
+ out.push('');
177
+ const order = ['Plan', 'Notes', 'Handoff', 'Result'];
178
+ for (const name of order) {
179
+ out.push(`## ${name}`);
180
+ out.push(parsed.sections.get(name) || '');
181
+ out.push('');
182
+ }
183
+ // CLI가 모르는 섹션도 지우지 않는다 — 맨 뒤에 보존.
184
+ for (const [name, bodyText] of parsed.sections) {
185
+ if (order.includes(name)) continue;
186
+ out.push(`## ${name}`);
187
+ out.push(bodyText || '');
188
+ out.push('');
189
+ }
190
+ return out.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\n+$/, '\n');
191
+ }
192
+
193
+ // ── 카드 목록/탐색/이동 ──────────────────────────────────────────────────
194
+
195
+ function listCardsInDir(dir) {
196
+ if (!fs.existsSync(dir)) return [];
197
+ return fs.readdirSync(dir)
198
+ .filter(f => f.endsWith('.md'))
199
+ .map(f => {
200
+ const filePath = path.join(dir, f);
201
+ const content = fs.readFileSync(filePath, 'utf8');
202
+ return {
203
+ filePath,
204
+ fileName: f,
205
+ dir,
206
+ meta: parseFrontmatter(content),
207
+ card: parseBody(content),
208
+ content,
209
+ };
210
+ });
211
+ }
212
+
213
+ // 모든 카드(활성 + 종결 3폴더). 종결 카드도 handoff/supersede 같은 조회·참조 대상.
214
+ function listAllCards(paths) {
215
+ return [
216
+ ...listCardsInDir(paths.cardsDir),
217
+ ...listCardsInDir(paths.doneDir),
218
+ ...listCardsInDir(paths.supersededDir),
219
+ ...listCardsInDir(paths.abandonedDir),
220
+ ];
221
+ }
222
+
223
+ // 제목으로 카드 찾기 — frontmatter title이 곧 식별자(3.5). 슬러그 폴백 허용.
224
+ function findCard(paths, title) {
225
+ const cards = listAllCards(paths);
226
+ const byTitle = cards.find(c => c.meta.title === title);
227
+ if (byTitle) return byTitle;
228
+ const slug = slugify(title);
229
+ return cards.find(c => slugify(c.meta.title || c.fileName.replace(/\.md$/, '')) === slug);
230
+ }
231
+
232
+ function writeCard(cardObj, newMeta, newParsed) {
233
+ fs.writeFileSync(cardObj.filePath, serializeCard(newMeta || cardObj.meta, newParsed || cardObj.card));
234
+ }
235
+
236
+ // 종결 이동 — 파일 이동 + frontmatter status를 폴더와 일치시킨다.
237
+ function moveCardTo(cardObj, targetDir, statusValue) {
238
+ fs.mkdirSync(targetDir, { recursive: true });
239
+ const content = fs.readFileSync(cardObj.filePath, 'utf8');
240
+ const meta = parseFrontmatter(content);
241
+ meta.status = statusValue;
242
+ fs.writeFileSync(path.join(targetDir, cardObj.fileName), serializeCard(meta, parseBody(content)));
243
+ fs.unlinkSync(cardObj.filePath);
244
+ }
245
+
246
+ // ── 활동 로그 ────────────────────────────────────────────────────────────
247
+
248
+ // append-only 감사로그. 10k 줄 캡 — 넘치면 오래된 절반을 버린다 (kanban-md 방식).
249
+ function appendActivity(paths, entry) {
250
+ const line = JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n';
251
+ if (fs.existsSync(paths.activityPath)) {
252
+ const content = fs.readFileSync(paths.activityPath, 'utf8');
253
+ const lines = content.split('\n').filter(l => l.trim());
254
+ if (lines.length >= ACTIVITY_CAP) {
255
+ fs.writeFileSync(paths.activityPath, lines.slice(Math.floor(lines.length / 2)).join('\n') + '\n');
256
+ }
257
+ }
258
+ fs.appendFileSync(paths.activityPath, line);
259
+ }
260
+
261
+ function readActivity(paths) {
262
+ if (!fs.existsSync(paths.activityPath)) return [];
263
+ return fs.readFileSync(paths.activityPath, 'utf8').split('\n')
264
+ .filter(l => l.trim())
265
+ .map(l => { try { return JSON.parse(l); } catch { return null; } })
266
+ .filter(Boolean);
267
+ }
268
+
269
+ // ── 클레임 만료 (협동 락) ────────────────────────────────────────────────
270
+
271
+ function isClaimExpired(cardObj, config, now = Date.now()) {
272
+ if (!cardObj.meta.claimed_at) return true;
273
+ const claimedMs = Date.parse(String(cardObj.meta.claimed_at));
274
+ if (Number.isNaN(claimedMs)) return true;
275
+ return now - claimedMs > config.claimTimeoutMinutes * 60 * 1000;
276
+ }
277
+
278
+ // ── 파일 락 — 잠금 폴더 mkdir 원자성 (Windows 호환) ─────────────────────
279
+
280
+ // mkdir은 이미 존재하면 EEXIST로 실패하는 원자적 연산이라 락으로 쓴다.
281
+ // 락 폴더 안 lock.json에 {pid, at}을 남겨, LOCK_STALE_MS가 지나면 강탈 허용.
282
+ function withLock(paths, name, fn) {
283
+ const lockPath = path.join(paths.lockDir, name);
284
+ fs.mkdirSync(paths.lockDir, { recursive: true });
285
+
286
+ const acquire = (stolen = false) => {
287
+ try {
288
+ fs.mkdirSync(lockPath);
289
+ return true;
290
+ } catch (e) {
291
+ if (e.code !== 'EEXIST') throw e;
292
+ // 이미 락이 있다 — 오래됐으면 강탈 (한 번만)
293
+ const markerPath = path.join(lockPath, 'lock.json');
294
+ if (!stolen && fs.existsSync(markerPath)) {
295
+ try {
296
+ const { at } = JSON.parse(fs.readFileSync(markerPath, 'utf8'));
297
+ if (Date.now() - at > LOCK_STALE_MS) {
298
+ fs.rmSync(lockPath, { recursive: true, force: true });
299
+ return acquire(true);
300
+ }
301
+ } catch { /* 판정 불가 — 락 유지로 간주 */ }
302
+ }
303
+ return false;
304
+ }
305
+ };
306
+
307
+ if (!acquire()) {
308
+ throw new Error(`칸반 락 획득 실패: ${name} — 다른 세션이 보드를 고치는 중. 잠시 후 재시도.`);
309
+ }
310
+ try {
311
+ fs.writeFileSync(path.join(lockPath, 'lock.json'), JSON.stringify({ pid: process.pid, at: Date.now() }));
312
+ return fn();
313
+ } finally {
314
+ fs.rmSync(lockPath, { recursive: true, force: true });
315
+ }
316
+ }
317
+
318
+ // ── 스캐폴드 ─────────────────────────────────────────────────────────────
319
+
320
+ // doc/kanban/ 골격. init이 부르고, 이미 있으면 아무것도 덮지 않는다.
321
+ function scaffold(docRoot) {
322
+ const paths = kanbanPaths(docRoot);
323
+ for (const dir of [paths.kanbanDir, paths.cardsDir, paths.doneDir, paths.supersededDir, paths.abandonedDir]) {
324
+ fs.mkdirSync(dir, { recursive: true });
325
+ }
326
+ if (!fs.existsSync(paths.boardYmlPath)) {
327
+ fs.writeFileSync(paths.boardYmlPath, [
328
+ '# 칸반 보드 설정 — 이 파일이 정본. board 뷰는 유도물이다.',
329
+ 'statuses: [todo, doing, review] # cards/ 안의 활성 상태. 종결은 폴더(done/superseded/abandoned)가 말한다',
330
+ 'wip_limits:',
331
+ ' doing: 2 # 발산 방지 1차 방어 (개선계획 4-3)',
332
+ 'claim_timeout_minutes: 60 # 클레임 만료 — 죽은 세션의 카드를 자동으로 풀어준다',
333
+ '',
334
+ ].join('\n'));
335
+ }
336
+ if (!fs.existsSync(paths.activityPath)) {
337
+ fs.writeFileSync(paths.activityPath, '');
338
+ }
339
+ return paths;
340
+ }
341
+
342
+ module.exports = {
343
+ ACTIVE_STATUSES,
344
+ TERMINAL_FOLDERS,
345
+ DEFAULT_CONFIG,
346
+ kanbanPaths,
347
+ loadConfig,
348
+ slugify,
349
+ parseFrontmatter,
350
+ serializeFrontmatter,
351
+ parseBody,
352
+ serializeCard,
353
+ listCardsInDir,
354
+ listAllCards,
355
+ findCard,
356
+ writeCard,
357
+ moveCardTo,
358
+ appendActivity,
359
+ readActivity,
360
+ isClaimExpired,
361
+ withLock,
362
+ scaffold,
363
+ };