@kybird/llm-wiki 0.2.1 → 0.3.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-cmd.js CHANGED
@@ -1,828 +1,1129 @@
1
- // kanban 명령 계층 — 카드 쓰기는 전부 이 모듈의 CLI 경로로만 일어난다.
2
- // 사람은 카드를 읽기만 하고, 에이전트는 이 명령들로만 고친다 (개선계획 §3.2).
3
- // 서브커맨드:
4
- // card new "<제목>" [--goal …] [--ac … …] [--depends a,b]
5
- // card show <제목>
6
- // card edit <제목> [--goal …] [--ac … …] [--add-ac …] [--check-ac 1,2] [--note …] [--plan …]
7
- // pick [--claim 이름] 원자적 집기 (락 안에서)
8
- // handoff <제목> --question "…" review로 park + 클레임 반납
9
- // done <제목> --result "…" 완료 Result 없으면 거부
10
- // supersede <제목> --by a,b 대체 부모는 superseded/로 소멸
11
- // abandon <제목> --reason "…" 폐기사유 없으면 거부
12
- // board [--json] 유도 (컬럼/WIP/대기/만료 클레임)
13
- const fs = require('fs');
14
- const path = require('path');
15
- const { execSync } = require('child_process');
16
- const { findDocRoot, loadConfig } = require('./find-doc-root');
17
- const kanban = require('./kanban');
18
-
19
- // ── 공용 ────────────────────────────────────────────────────────────────
20
-
21
- function parseArgs(rest) {
22
- const positional = [];
23
- const flags = {};
24
- for (let i = 0; i < rest.length; i++) {
25
- const arg = rest[i];
26
- if (arg.startsWith('--')) {
27
- let key; let value;
28
- const eq = arg.indexOf('=');
29
- if (eq !== -1) {
30
- key = arg.slice(2, eq);
31
- value = arg.slice(eq + 1);
32
- } else {
33
- key = arg.slice(2);
34
- value = rest[i + 1];
35
- i++;
36
- }
37
- value = value === undefined ? true : value;
38
- // 같은 플래그 반복(--ac a --ac b)은 배열로 누적.
39
- if (flags[key] !== undefined) {
40
- flags[key] = [].concat(flags[key], value);
41
- } else {
42
- flags[key] = value;
43
- }
44
- } else {
45
- positional.push(arg);
46
- }
47
- }
48
- return { positional, flags };
49
- }
50
-
51
- function asArray(v) {
52
- return v === undefined ? [] : [].concat(v);
53
- }
54
-
55
- // 로컬 타임존 ISO (분 단위) — 2026-08-29T23:14+09:00 형태. 클레임 만료 판정은
56
- // Date.parse가 오프셋을 이해하므로 이 형태로 충분하다.
57
- function localIso(date = new Date()) {
58
- const pad = n => String(n).padStart(2, '0');
59
- const offsetMin = -date.getTimezoneOffset();
60
- const sign = offsetMin >= 0 ? '+' : '-';
61
- const abs = Math.abs(offsetMin);
62
- return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
63
- `T${pad(date.getHours())}:${pad(date.getMinutes())}${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`;
64
- }
65
-
66
- function fail(message) {
67
- console.error(`✗ ${message}`);
68
- process.exit(1);
69
- }
70
-
71
- // 보드가 스캐폴드돼 있어야 한다 — 없으면 init으로 안내.
72
- function requireBoard() {
73
- const docRoot = findDocRoot();
74
- const paths = kanban.kanbanPaths(docRoot);
75
- if (!fs.existsSync(paths.kanbanDir)) {
76
- fail('doc/kanban/ 이 없다. 먼저 `llm-wiki init`을 실행하라.');
77
- }
78
- return { docRoot, paths, config: kanban.loadConfig(paths) };
79
- }
80
-
81
- function nowIso() { return localIso(); }
82
-
83
- // ── card new / show / edit ──────────────────────────────────────────────
84
-
85
- function cardNew(paths, config, { positional, flags }) {
86
- const title = positional[0];
87
- if (!title) fail('사용법: llm-wiki card new "<제목>" [--goal …] [--ac …]');
88
-
89
- const fileName = `${kanban.slugify(title)}.md`;
90
- const filePath = path.join(paths.cardsDir, fileName);
91
- if (fs.existsSync(filePath)) {
92
- fail(`같은 제목 카드가 이미 있다: ${filePath} — 제목이 곧 식별자다(3.5). 다른 제목을 쓰거나 supersede로 대체하라.`);
93
- }
94
-
95
- // ordinal: float, 스텝 1000, 컬럼(todo) 순서 (Backlog.md 방식).
96
- const ordinals = kanban.listCardsInDir(paths.cardsDir).map(c => Number(c.meta.ordinal) || 0);
97
- const ordinal = ordinals.length ? Math.max(...ordinals) + 1000 : 1000;
98
-
99
- const dependsOn = asArray(flags.depends).flatMap(s => String(s).split(',')).map(s => s.trim()).filter(Boolean);
100
- const acTexts = asArray(flags.ac);
101
-
102
- const meta = {
103
- title,
104
- status: 'todo',
105
- ordinal,
106
- created: new Date().toISOString().split('T')[0],
107
- };
108
- if (dependsOn.length) meta.depends_on = dependsOn;
109
- // 시간 게이트 — 이 날짜 전에는 pick이 집지 않는다 (조건만족시 진행, plan.md 9번 원칙의 기계적 절반).
110
- if (flags['not-before']) meta.not_before = String(flags['not-before']);
111
-
112
- const parsed = {
113
- goal: flags.goal ? String(flags.goal) : '',
114
- ac: acTexts.map((text, i) => ({ checked: false, idx: i + 1, text })),
115
- sections: new Map([['Plan', ''], ['Notes', ''], ['Handoff', ''], ['Result', '']]),
116
- };
117
-
118
- fs.writeFileSync(filePath, kanban.serializeCard(meta, parsed));
119
- kanban.appendActivity(paths, { action: 'created', title });
120
- console.log(`Card created: ${filePath}`);
121
- console.log(` ordinal: ${ordinal}${dependsOn.length ? `, depends_on: ${dependsOn.join(', ')}` : ''}`);
122
- }
123
-
124
- function cardShow(paths, { positional }) {
125
- const title = positional[0];
126
- if (!title) fail('사용법: llm-wiki card show <제목>');
127
- const cardObj = kanban.findCard(paths, title);
128
- if (!cardObj) fail(`카드를 못 찾았다: ${title}`);
129
- console.log(fs.readFileSync(cardObj.filePath, 'utf8'));
130
- }
131
-
132
- function cardEdit(paths, { positional, flags }) {
133
- const title = positional[0];
134
- if (!title) {
135
- fail('사용법: llm-wiki card edit <제목> [--goal …] [--ac …] [--add-ac …] [--check-ac 1,2] [--note …] [--plan …]');
136
- }
137
- const cardObj = kanban.findCard(paths, title);
138
- if (!cardObj) fail(`카드를 찾았다: ${title}`);
139
-
140
- const meta = { ...cardObj.meta };
141
- const parsed = { goal: cardObj.card.goal, ac: cardObj.card.ac.map(a => ({ ...a })), sections: new Map(cardObj.card.sections) };
142
-
143
- if (flags.goal !== undefined) parsed.goal = String(flags.goal);
144
- if (flags.plan !== undefined) parsed.sections.set('Plan', String(flags.plan));
145
-
146
- // --ac는 목록 전체 교체, --add-ac는 뒤에 붙이기(번호는 최대+1 — 재정렬 후에도 #N 안정).
147
- const replaceAc = asArray(flags.ac);
148
- if (replaceAc.length) {
149
- parsed.ac = replaceAc.map((text, i) => ({ checked: false, idx: i + 1, text: String(text) }));
150
- }
151
- const addAc = asArray(flags['add-ac']);
152
- let nextIdx = parsed.ac.reduce((m, a) => Math.max(m, a.idx), 0) + 1;
153
- for (const text of addAc) parsed.ac.push({ checked: false, idx: nextIdx++, text: String(text) });
154
-
155
- // --check-ac 1,2 AC는 객관적 증거로만 체크하라는 건 에이전트의 규율(루프 스킬)이고
156
- // CLI는 번호의 안정성(#N)만 보장한다.
157
- if (flags['check-ac'] !== undefined) {
158
- const idxs = String(flags['check-ac']).split(',').map(s => Number(s.trim()));
159
- for (const idx of idxs) {
160
- const ac = parsed.ac.find(a => a.idx === idx);
161
- if (!ac) fail(`AC #${idx} 없다 번호는 재정렬해도 유지된다.`);
162
- ac.checked = true;
163
- }
164
- }
165
-
166
- // Notes는 append-only 저널 — 타임스탬프와 함께 덧붙인다.
167
- const notes = asArray(flags.note);
168
- if (notes.length) {
169
- const existing = parsed.sections.get('Notes') || '';
170
- const stamped = notes.map(n => `- ${nowIso()} — ${n}`).join('\n');
171
- parsed.sections.set('Notes', existing ? `${existing}\n${stamped}` : stamped);
172
- }
173
-
174
- // --renew-claim — 긴 카드는 타임아웃 전에 클레임을 갱신한다 (루프 스킬 규칙 6).
175
- // 클레임은 만료 있는 협동 락이므로, 살아있는 작업자는 주기적으로 시각을 다시 심는다.
176
- if (flags['renew-claim']) {
177
- if (!meta.claimed_by) fail(`클레임이 없는 카드다: ${title} renew가 아니라 pick으로 집어라.`);
178
- meta.claimed_at = nowIso();
179
- }
180
-
181
- kanban.writeCard(cardObj, meta, parsed);
182
- console.log(`Card edited: ${cardObj.filePath}`);
183
- }
184
-
185
- // QA 루프의 되돌림 원시형 (개선계획 4-3) — 증거 빈약한 완료를 doing으로 역류시킨다.
186
- // 카드 수가 일시적으로 늘어나는 것이 곡선의 진짜를 만든다 (plan.md 2.4).
187
- function reopen(options = {}) {
188
- const { paths } = requireBoard();
189
- const { positional, flags } = parseArgs(options.rest || []);
190
- const title = positional[0];
191
- const why = flags.why || flags.reason;
192
- if (!title || !why) fail('사용법: llm-wiki reopen <제목> --why "…" — 되돌림 사유가 기록되지 않으면 QA가 아니다.');
193
-
194
- const cardObj = kanban.findCard(paths, title);
195
- if (!cardObj) fail(`카드를 찾았다: ${title}`);
196
- if (cardObj.meta.status !== 'done') fail(`done 카드만 되돌린다 (현재: ${cardObj.meta.status}): ${cardObj.filePath}`);
197
-
198
- const content = fs.readFileSync(cardObj.filePath, 'utf8');
199
- const meta = kanban.parseFrontmatter(content);
200
- const parsed = kanban.parseBody(content);
201
- meta.status = 'doing';
202
- delete meta.claimed_by;
203
- delete meta.claimed_at;
204
- const notes = parsed.sections.get('Notes') || '';
205
- const line = `- ${nowIso()} REVERTED: ${why}`;
206
- parsed.sections.set('Notes', notes ? `${notes}\n${line}` : line);
207
-
208
- fs.writeFileSync(path.join(paths.cardsDir, cardObj.fileName), kanban.serializeCard(meta, parsed));
209
- fs.unlinkSync(cardObj.filePath);
210
- kanban.appendActivity(paths, { action: 'reverted', title: meta.title, detail: String(why) });
211
- console.log(`Reverted to doing: ${path.join(paths.cardsDir, cardObj.fileName)}`);
212
- }
213
-
214
- // ── pick 원자적 집기 ───────────────────────────────────────────────────
215
-
216
- // 준비 필터(미클레임/만료/의존 충족/WIP 여유) ordinal 정렬 → 클레임 → doing.
217
- // 과정을 안에서 — 동시 pick 빈틈을 처음부터 막고 출발한다 (kanban-md 교훈).
218
- function pick(options = {}) {
219
- const { paths, config } = requireBoard();
220
- const { flags } = parseArgs(options.rest || []);
221
- const claimName = flags.claim ? String(flags.claim) : 'unnamed-agent';
222
-
223
- const result = kanban.withLock(paths, 'pick', () => {
224
- const active = kanban.listCardsInDir(paths.cardsDir);
225
- const doing = active.filter(c => c.meta.status === 'doing');
226
- const wipLimit = config.wipLimits.doing !== undefined ? config.wipLimits.doing : Infinity;
227
-
228
- if (doing.length >= wipLimit) {
229
- return { picked: null, reason: `wip-limit`, detail: `doing WIP 상한 도달 (${doing.length}/${wipLimit}) — 하던 카드를 먼저 종결하라.` };
230
- }
231
-
232
- // 종결 = done 또는 superseded 폴더에 존재. abandoned 의존은 미충족(길이 틀렸다는 신호).
233
- const resolvedTitles = new Set([
234
- ...kanban.listCardsInDir(paths.doneDir).map(c => c.meta.title),
235
- ...kanban.listCardsInDir(paths.supersededDir).map(c => c.meta.title),
236
- ]);
237
-
238
- const blocked = [];
239
- const candidates = [];
240
- const today = new Date().toISOString().split('T')[0];
241
- for (const cardObj of active) {
242
- const status = cardObj.meta.status;
243
- // review는 사람 판정 대기 — pick이 집지 않는다.
244
- if (status === 'review') continue;
245
-
246
- // 시간 게이트 not_before가 미래면 아직 시작하지 않는 카드다.
247
- if (cardObj.meta.not_before && String(cardObj.meta.not_before) > today) {
248
- blocked.push({ title: cardObj.meta.title, notBefore: cardObj.meta.not_before });
249
- continue;
250
- }
251
-
252
- // 클레임이 살아있는 doing은 남의 작업. 만료됐으면 재집기 후보 — 밤에 죽은
253
- // 세션의 카드를 자동 회수하는 것이 협동 락의 존재 이유다.
254
- if (cardObj.meta.claimed_by && !kanban.isClaimExpired(cardObj, config)) {
255
- blocked.push({ title: cardObj.meta.title, claimedBy: cardObj.meta.claimed_by });
256
- continue;
257
- }
258
-
259
- if (status === 'todo') {
260
- const deps = Array.isArray(cardObj.meta.depends_on) ? cardObj.meta.depends_on : [];
261
- const unmet = deps.filter(dep => !resolvedTitles.has(dep));
262
- if (unmet.length) { blocked.push({ title: cardObj.meta.title, unmet }); continue; }
263
- }
264
- candidates.push(cardObj);
265
- }
266
-
267
- candidates.sort((a, b) => (Number(a.meta.ordinal) || 0) - (Number(b.meta.ordinal) || 0));
268
- const chosen = candidates[0];
269
- if (!chosen) {
270
- return { picked: null, reason: blocked.length ? 'blocked-or-claimed' : 'empty', detail: blocked };
271
- }
272
-
273
- const meta = { ...chosen.meta };
274
- meta.status = 'doing';
275
- meta.claimed_by = claimName;
276
- meta.claimed_at = nowIso();
277
- kanban.writeCard(chosen, meta);
278
- kanban.appendActivity(paths, { action: 'claimed', title: meta.title, actor: claimName });
279
- return { picked: { path: chosen.filePath, title: meta.title }, reason: null };
280
- });
281
-
282
- if (options.json) {
283
- console.log(JSON.stringify({ schemaVersion: 1, kind: 'kanban-pick', ...result }, null, 2));
284
- return;
285
- }
286
- if (!result.picked) {
287
- console.log(`No pickable card (${result.reason}).`);
288
- if (Array.isArray(result.detail)) {
289
- for (const b of result.detail) {
290
- if (b.notBefore) console.log(` - ${b.title} — 시작 예정: ${b.notBefore} 이후 (not_before)`);
291
- else if (b.unmet && b.unmet.length) console.log(` - ${b.title} 의존 미충족: ${b.unmet.join(', ')}`);
292
- else if (b.claimedBy) console.log(` - ${b.title} 클레임 중: ${b.claimedBy}`);
293
- }
294
- } else if (result.detail) {
295
- console.log(` ${result.detail}`);
296
- }
297
- return;
298
- }
299
- console.log(`PICKED: ${result.picked.path}`);
300
- console.log(fs.readFileSync(result.picked.path, 'utf8'));
301
- }
302
-
303
- // ── 종결 3갈래 + handoff ────────────────────────────────────────────────
304
-
305
- // 활성 카드만 상태 전이 대상. 종결 카드는 이미 끝난 이력이다.
306
- function requireActiveCard(paths, title) {
307
- const cardObj = kanban.findCard(paths, title);
308
- if (!cardObj) fail(`카드를 못 찾았다: ${title}`);
309
- if (!kanban.ACTIVE_STATUSES.includes(cardObj.meta.status)) {
310
- fail(`카드는 이미 종결됐다 (${cardObj.meta.status}): ${cardObj.filePath}`);
311
- }
312
- return cardObj;
313
- }
314
-
315
- function handoff(options = {}) {
316
- const { paths } = requireBoard();
317
- const { positional, flags } = parseArgs(options.rest || []);
318
- const title = positional[0];
319
- const question = flags.question;
320
- if (!title || !question) fail('사용법: llm-wiki handoff <제목> --question "…"');
321
-
322
- const cardObj = requireActiveCard(paths, title);
323
- const meta = { ...cardObj.meta };
324
- const parsed = { ...cardObj.card, ac: cardObj.card.ac.map(a => ({ ...a })), sections: new Map(cardObj.card.sections) };
325
-
326
- meta.status = 'review';
327
- delete meta.claimed_by; // 클레임 반납park하고 다음 카드로
328
- delete meta.claimed_at;
329
-
330
- const existing = parsed.sections.get('Handoff') || '';
331
- const line = `- ${nowIso()} — QUESTION: ${question}`;
332
- parsed.sections.set('Handoff', existing ? `${existing}\n${line}` : line);
333
-
334
- kanban.writeCard(cardObj, meta, parsed);
335
- kanban.appendActivity(paths, { action: 'handoff', title: meta.title, detail: String(question) });
336
- console.log(`Handed off (parked for human judgment): ${cardObj.filePath}`);
337
- }
338
-
339
- function doneCard(options = {}) {
340
- const { paths } = requireBoard();
341
- const { positional, flags } = parseArgs(options.rest || []);
342
- const title = positional[0];
343
- const result = flags.result;
344
- if (!title || !result) fail('사용법: llm-wiki done <제목> --result "…" — Result 없는 완료는 거부한다.');
345
-
346
- const cardObj = requireActiveCard(paths, title);
347
- const meta = { ...cardObj.meta };
348
- const parsed = { ...cardObj.card, ac: cardObj.card.ac.map(a => ({ ...a })), sections: new Map(cardObj.card.sections) };
349
-
350
- const unchecked = parsed.ac.filter(a => !a.checked);
351
- if (unchecked.length) {
352
- console.error(`⚠ AC ${unchecked.map(a => `#${a.idx}`).join(', ')} 체크 안 됐다 — 증거로 체크했는지 스스로 검증하라 (QA 루프가 되돌릴 수 있다).`);
353
- }
354
-
355
- const existing = parsed.sections.get('Result') || '';
356
- const line = `- ${nowIso()}${result}`;
357
- parsed.sections.set('Result', existing ? `${existing}\n${line}` : line);
358
-
359
- delete meta.claimed_by;
360
- delete meta.claimed_at;
361
- kanban.writeCard(cardObj, meta, parsed);
362
- kanban.moveCardTo(cardObj, paths.doneDir, 'done');
363
- kanban.appendActivity(paths, { action: 'done', title: meta.title });
364
- console.log(`Done: ${path.join(paths.doneDir, cardObj.fileName)}`);
365
- }
366
-
367
- // 대체 카드가 저 카드들이 됨. 부모는 완료가 아니라 소멸(3.1).
368
- function supersede(options = {}) {
369
- const { paths } = requireBoard();
370
- const { positional, flags } = parseArgs(options.rest || []);
371
- const title = positional[0];
372
- const by = asArray(flags.by).flatMap(s => String(s).split(',')).map(s => s.trim()).filter(Boolean);
373
- if (!title || !by.length) fail('사용법: llm-wiki supersede <제목> --by 자식1,자식2');
374
-
375
- const parent = requireActiveCard(paths, title);
376
- for (const child of by) {
377
- const childCard = kanban.findCard(paths, child);
378
- if (!childCard) fail(`자식 카드를 먼저 만들어라 (card new): ${child}`);
379
- if (!kanban.ACTIVE_STATUSES.includes(childCard.meta.status)) {
380
- fail(`자식 카드가 이미 종결됐다 (${childCard.meta.status}): ${child}`);
381
- }
382
- }
383
-
384
- const meta = { ...parent.meta, superseded_by: by };
385
- kanban.writeCard(parent, meta);
386
- kanban.moveCardTo(parent, paths.supersededDir, 'superseded');
387
- kanban.appendActivity(paths, { action: 'superseded', title: parent.meta.title, detail: `by ${by.join(', ')}` });
388
- console.log(`Superseded: ${parent.meta.title} [${by.join(', ')}]`);
389
- console.log(` parent: ${path.join(paths.supersededDir, parent.fileName)}`);
390
- }
391
-
392
- // 폐기 — 사유가 가장 값비싼 정보다(3.3). 없으면 거부하고, 기록은 지우지 않는다.
393
- function abandon(options = {}) {
394
- const { docRoot, paths } = requireBoard();
395
- const { positional, flags } = parseArgs(options.rest || []);
396
- const title = positional[0];
397
- const reason = flags.reason;
398
- if (!title || !reason) fail('사용법: llm-wiki abandon <제목> --reason "…" — 폐기 사유가 안티패턴의 원재료다.');
399
-
400
- const cardObj = requireActiveCard(paths, title);
401
- const meta = { ...cardObj.meta, discard_reason: String(reason) };
402
- kanban.writeCard(cardObj, meta);
403
- kanban.moveCardTo(cardObj, paths.abandonedDir, 'abandoned');
404
- kanban.appendActivity(paths, { action: 'abandoned', title: cardObj.meta.title, detail: String(reason) });
405
-
406
- // 4-2: 폐기 사유를 raw에 자동 기록 (기본 on — --no-raw-log로 끈다).
407
- if (!flags['no-raw-log']) {
408
- try {
409
- const rawPath = appendAbandonRaw(docRoot, cardObj.meta.title, String(reason), path.relative(process.cwd(), path.join(paths.abandonedDir, cardObj.fileName)));
410
- if (rawPath) console.log(`폐기 사유를 raw에 기록했다 (안티패턴 원재료): ${rawPath}`);
411
- } catch (e) {
412
- console.error(`⚠ raw 기록 실패 (폐기 자체는 완료): ${e.message}`);
413
- }
414
- }
415
-
416
- console.log(`Abandoned: ${path.join(paths.abandonedDir, cardObj.fileName)}`);
417
- }
418
-
419
- // ── board — 유도 뷰 ──────────────────────────────────────────────────────
420
-
421
- // 카드가 정본이고 출력은 유도물이다 (3.7 원칙 2 갱신을 강제하지 않는다).
422
- function collectBoard() {
423
- const { paths, config } = requireBoard();
424
- const active = kanban.listCardsInDir(paths.cardsDir);
425
- const byStatus = status => active
426
- .filter(c => c.meta.status === status)
427
- .sort((a, b) => (Number(a.meta.ordinal) || 0) - (Number(b.meta.ordinal) || 0));
428
-
429
- const view = {
430
- schemaVersion: 1,
431
- kind: 'kanban-board',
432
- generatedAt: new Date().toISOString(),
433
- wip: { doing: { used: byStatus('doing').length, limit: config.wipLimits.doing } },
434
- columns: {
435
- todo: byStatus('todo').map(c => viewCard(c, config)),
436
- doing: byStatus('doing').map(c => viewCard(c, config)),
437
- review: byStatus('review').map(c => viewCard(c, config)),
438
- },
439
- terminal: {
440
- done: kanban.listCardsInDir(paths.doneDir).length,
441
- superseded: kanban.listCardsInDir(paths.supersededDir).length,
442
- abandoned: kanban.listCardsInDir(paths.abandonedDir).length,
443
- },
444
- // 종결 적체 — 완료가 쌓이는 모습이 수렴의 증거다 (HTML 보드용 최근 목록).
445
- terminalRecent: [
446
- ...kanban.listCardsInDir(paths.doneDir).map(c => ({ title: c.meta.title, kind: 'done', m: fs.statSync(c.filePath).mtimeMs })),
447
- ...kanban.listCardsInDir(paths.supersededDir).map(c => ({ title: c.meta.title, kind: 'superseded', m: fs.statSync(c.filePath).mtimeMs })),
448
- ...kanban.listCardsInDir(paths.abandonedDir).map(c => ({ title: c.meta.title, kind: 'abandoned', m: fs.statSync(c.filePath).mtimeMs })),
449
- ]
450
- .sort((a, b) => b.m - a.m)
451
- .slice(0, 8)
452
- .map(({ title, kind }) => ({ title, kind })),
453
- // 의존 충족 판정용 종결 제목들 — 텍스트/HTML 렌더러가 공유한다.
454
- resolved: [...kanban.listCardsInDir(paths.doneDir), ...kanban.listCardsInDir(paths.supersededDir)]
455
- .map(c => c.meta.title),
456
- };
457
- return { view, paths, config };
458
- }
459
-
460
- function boardView(options = {}) {
461
- const { view, paths, config } = collectBoard();
462
-
463
- if (options.json) {
464
- console.log(JSON.stringify(view, null, 2));
465
- return;
466
- }
467
- if (options.html) {
468
- const htmlPath = path.join(paths.kanbanDir, 'board.html');
469
- fs.writeFileSync(htmlPath, renderBoardHtml(view));
470
- console.log(`Board written: ${htmlPath}`);
471
- console.log(' 유도물 서버 없이 브라우저에서 열면 된다. 갱신은 다시 `llm-wiki board --html`.');
472
- return;
473
- }
474
- renderBoard(view, config);
475
- }
476
-
477
- function viewCard(cardObj, config) {
478
- const expired = cardObj.meta.status === 'doing' && kanban.isClaimExpired(cardObj, config);
479
- // 대기 사유 handoff가 남긴 마지막 QUESTION을 끌어낸다. 대기 큐가 제목만
480
- // 나열하면 아침의 사람이 카드를 하나씩 열어야 하므로(3단계 완료 기준 ②).
481
- const handoff = cardObj.card.sections.get('Handoff') || '';
482
- const qLine = [...handoff.split(/\r?\n/)].reverse().find(l => l.includes('QUESTION:'));
483
- return {
484
- title: cardObj.meta.title,
485
- ordinal: Number(cardObj.meta.ordinal) || 0,
486
- claimedBy: cardObj.meta.claimed_by || null,
487
- claimExpired: expired,
488
- notBefore: cardObj.meta.not_before || null,
489
- question: qLine ? qLine.split('QUESTION:')[1].trim() : null,
490
- dependsOn: cardObj.meta.depends_on || [],
491
- };
492
- }
493
-
494
- function renderBoard(view, config) {
495
- const line = '────────────────────────────────────────';
496
- console.log(`Kanban board (${view.generatedAt.split('T')[0]})정본은 doc/kanban/ 카드 파일들`);
497
- console.log(line);
498
-
499
- const wip = view.wip.doing;
500
- console.log(`DOING (${wip.used}/${wip.limit}${wip.used >= wip.limit ? ' — WIP 상한' : ''})`);
501
- if (!view.columns.doing.length) console.log(' (비어 있음)');
502
- for (const c of view.columns.doing) {
503
- console.log(` • ${c.title}${c.claimExpired ? ' [클레임 만료 — 재집기 가능]' : ` [${c.claimedBy}]`}`);
504
- }
505
-
506
- console.log(line);
507
- console.log(`REVIEW (사람 판정 대기 — ${view.columns.review.length})`);
508
- if (!view.columns.review.length) console.log(' (비어 있음)');
509
- for (const c of view.columns.review) {
510
- console.log(` • ${c.title}`);
511
- if (c.question) console.log(` ↳ ${c.question}`);
512
- }
513
-
514
- console.log(line);
515
- console.log(`TODO (${view.columns.todo.length}) ordinal 순`);
516
- const resolved = new Set(view.resolved);
517
- if (!view.columns.todo.length) console.log(' (비어 있음)');
518
- for (const c of view.columns.todo) {
519
- const unmet = c.dependsOn.filter(dep => !resolved.has(dep));
520
- const gate = c.notBefore && c.notBefore > new Date().toISOString().split('T')[0];
521
- console.log(` • ${c.title}${gate ? ` [시작 예정 — ${c.notBefore}]` : ''}${unmet.length ? ` [대기 — 의존: ${unmet.join(', ')}]` : ''}`);
522
- }
523
-
524
- console.log(line);
525
- console.log(`종결: done ${view.terminal.done} · superseded ${view.terminal.superseded} · abandoned ${view.terminal.abandoned}`);
526
- }
527
-
528
- // ── 정적 HTML 보드 — 사람용 시각화 (유도 뷰) ─────────────────────────────
529
- // 서버 없음, file://로 열린다. 드래그 이동이 있는 웹 뷰는 포맷이 굳은 뒤의
530
- // 확정 대상(plan.md) 그때까지는 읽기 전용이 원칙이다.
531
- function escapeHtml(s) {
532
- return String(s).replace(/[&<>"']/g, ch => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[ch]));
533
- }
534
-
535
- function renderBoardHtml(view) {
536
- const today = new Date().toISOString().split('T')[0];
537
- const resolved = new Set(view.resolved);
538
-
539
- const badges = c => {
540
- const out = [];
541
- if (c.claimExpired) out.push('<span class="badge warn">클레임 만료</span>');
542
- else if (c.claimedBy) out.push(`<span class="badge">${escapeHtml(c.claimedBy)}</span>`);
543
- if (c.notBefore && c.notBefore > today) out.push(`<span class="badge gate">시작 예정 ${escapeHtml(c.notBefore)}</span>`);
544
- const unmet = c.dependsOn.filter(dep => !resolved.has(dep));
545
- if (unmet.length) out.push(`<span class="badge gate">의존 대기: ${unmet.map(escapeHtml).join(', ')}</span>`);
546
- return out.join(' ');
547
- };
548
- const cardDiv = c => `<div class="card"><div class="t">${escapeHtml(c.title)}</div>${c.question ? `<div class="q">↳ ${escapeHtml(c.question)}</div>` : ''}${badges(c)}</div>`;
549
- const kindLabel = { done: '완료', superseded: '대체', abandoned: '폐기' };
550
- const terminalList = view.terminalRecent.length
551
- ? view.terminalRecent
552
- .map(t => `<div class="card"><span class="badge t-${t.kind}">${kindLabel[t.kind]}</span><span class="tt">${escapeHtml(t.title)}</span></div>`)
553
- .join('\n')
554
- : '<div class="empty">비어 있음</div>';
555
- const column = (label, cards) => `
556
- <section>
557
- <h2>${label} <span class="count">${cards.length}</span></h2>
558
- ${cards.map(cardDiv).join('\n') || '<div class="empty">비어 있음</div>'}
559
- </section>`;
560
- const terminalSection = `
561
- <section>
562
- <h2>종결 적체 <span class="count">최근 ${view.terminalRecent.length}</span></h2>
563
- ${terminalList}
564
- </section>`;
565
-
566
- return `<!doctype html>
567
- <html lang="ko">
568
- <head>
569
- <meta charset="utf-8">
570
- <title>llm-wiki board</title>
571
- <style>
572
- body { font-family: -apple-system, 'Segoe UI', 'Malgun Gothic', sans-serif; margin: 24px; background: #f6f7f9; color: #1c2733; }
573
- header h1 { font-size: 20px; margin: 0 0 4px; }
574
- header p { margin: 0 0 20px; color: #5b6a78; font-size: 13px; }
575
- main { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
576
- section { background: #fff; border: 1px solid #e2e6ea; border-radius: 10px; padding: 12px; }
577
- h2 { font-size: 14px; margin: 0 0 10px; text-transform: uppercase; letter-spacing: .04em; color: #40505f; }
578
- .count { color: #8a97a3; font-weight: 400; }
579
- .card { background: #f2f4f7; border: 1px solid #dde2e8; border-radius: 8px; padding: 8px 10px; margin-bottom: 8px; }
580
- .card .t { font-size: 14px; font-weight: 600; margin-bottom: 4px; }
581
- .q { font-size: 12px; color: #5b6a78; margin: 2px 0 4px; }
582
- .badge { display: inline-block; font-size: 11px; background: #e3e8ee; color: #44525f; border-radius: 999px; padding: 2px 8px; margin: 2px 4px 0 0; }
583
- .badge.warn { background: #fdeccf; color: #7a4d05; }
584
- .badge.gate { background: #dbe7fd; color: #1c4d8f; }
585
- .empty { color: #98a3ad; font-size: 13px; }
586
- .tt { font-size: 13px; color: #1c2733; }
587
- .t-done { background: #6ee7a0; }
588
- .t-superseded { background: #c9a6ff; }
589
- .t-abandoned { background: #ff8f8f; }
590
- footer { margin-top: 18px; color: #5b6a78; font-size: 13px; }
591
- </style>
592
- </head>
593
- <body>
594
- <header>
595
- <h1>Kanban board</h1>
596
- <p>생성: ${escapeHtml(view.generatedAt)} · WIP doing ${view.wip.doing.used}/${view.wip.doing.limit} · 이 파일은 유도물 — 정본은 doc/kanban/ 카드 파일들</p>
597
- </header>
598
- <main style="grid-template-columns: repeat(4, 1fr)">
599
- ${column('Doing', view.columns.doing)}
600
- ${column('Review (사람 판정 대기)', view.columns.review)}
601
- ${column('Todo', view.columns.todo)}
602
- ${terminalSection}
603
- </main>
604
- <footer>종결: done ${view.terminal.done} · superseded ${view.terminal.superseded} · abandoned ${view.terminal.abandoned} — 갱신은 <code>llm-wiki board --html</code></footer>
605
- </body>
606
- </html>
607
- `;
608
- }
609
-
610
- // review(대기) todo 복귀 — 조건이 충족됐거나 사람이 판정을 내렸을 때.
611
- // 조건부 카드(Activation/not_before)의 진행 스위치다. 대기에서 저절로 풀리는 건
612
- // not_before(기계 판정)뿐이고, 관측형 조건은 이 명령으로 명시적으로 푼다.
613
- function resume(options = {}) {
614
- const { paths } = requireBoard();
615
- const { positional, flags } = parseArgs(options.rest || []);
616
- const title = positional[0];
617
- if (!title) fail('사용법: llm-wiki resume <제목> [--note "…"]');
618
-
619
- const cardObj = kanban.findCard(paths, title);
620
- if (!cardObj) fail(`카드를 찾았다: ${title}`);
621
- if (cardObj.meta.status !== 'review') fail(`review 카드만 복귀시킨다 (현재: ${cardObj.meta.status}): ${cardObj.filePath}`);
622
-
623
- const meta = { ...cardObj.meta, status: 'todo' };
624
- const parsed = { ...cardObj.card, ac: cardObj.card.ac.map(a => ({ ...a })), sections: new Map(cardObj.card.sections) };
625
- const line = `- ${nowIso()} — RESUMED: ${flags.note ? String(flags.note) : '대기 조건 충족 또는 사람 판정'}`;
626
- const notes = parsed.sections.get('Notes') || '';
627
- parsed.sections.set('Notes', notes ? `${notes}\n${line}` : line);
628
-
629
- kanban.writeCard(cardObj, meta, parsed);
630
- kanban.appendActivity(paths, { action: 'resumed', title: meta.title, detail: flags.note ? String(flags.note) : undefined });
631
- console.log(`Resumed to todo: ${cardObj.filePath}`);
632
- }
633
-
634
- // ── board report — 계기판 (개선계획 4-1) ─────────────────────────────────
635
-
636
- // 계기판은 카드 수가 아니라 완료:폐기 비율이다 (plan.md 3.2 확정 — 폐기도 곡선을
637
- // 떨어뜨린다. 30장이 1장이 됐을 때 다 끝나서인지 다 갖다버려서인지 카드 수만으로는
638
- // 모른다). 아침의 사람이 보는 한 화면.
639
- function boardReport(options = {}) {
640
- const { docRoot, paths, config } = requireBoard();
641
-
642
- const done = kanban.listCardsInDir(paths.doneDir);
643
- const superseded = kanban.listCardsInDir(paths.supersededDir);
644
- const abandoned = kanban.listCardsInDir(paths.abandonedDir);
645
- const active = kanban.listCardsInDir(paths.cardsDir);
646
- const activity = kanban.readActivity(paths);
647
-
648
- const resolved = done.length + abandoned.length;
649
- const doneShare = resolved > 0 ? Math.round((done.length / resolved) * 100) : null;
650
-
651
- const expired = active.filter(c => c.meta.status === 'doing' && kanban.isClaimExpired(c, config));
652
- const review = active.filter(c => c.meta.status === 'review');
653
- const reverted = activity.filter(a => a.action === 'reverted');
654
-
655
- // 카드 추이 — activity.jsonl 기반 일별 집계 (최근 14일).
656
- const days = [];
657
- for (let i = 13; i >= 0; i--) {
658
- const d = new Date(); d.setDate(d.getDate() - i);
659
- days.push(d.toISOString().split('T')[0]);
660
- }
661
- const trend = days.map(day => {
662
- const on = action => activity.filter(a => a.action === action && String(a.ts).startsWith(day)).length;
663
- const created = on('created');
664
- const closed = on('done') + on('superseded') + on('abandoned');
665
- return { day, created, done: on('done'), superseded: on('superseded'), abandoned: on('abandoned'), reverted: on('reverted'), closed };
666
- }).filter(t => t.created || t.closed || t.reverted);
667
-
668
- const report = {
669
- schemaVersion: 1,
670
- kind: 'kanban-board-report',
671
- generatedAt: new Date().toISOString(),
672
- convergence: { done: done.length, abandoned: abandoned.length, superseded: superseded.length, doneSharePercent: doneShare },
673
- active: { todo: active.filter(c => c.meta.status === 'todo').length, doing: active.filter(c => c.meta.status === 'doing').length, review: review.length },
674
- waitingQueue: review.map(c => viewCard(c, config)).map(c => ({ title: c.title, question: c.question })),
675
- expiredClaims: expired.map(c => c.meta.title),
676
- revertCount: reverted.length,
677
- recentReverts: reverted.slice(-5),
678
- trend,
679
- };
680
-
681
- if (options.json) {
682
- console.log(JSON.stringify(report, null, 2));
683
- return;
684
- }
685
-
686
- const line = '────────────────────────────────────────';
687
- console.log(`📊 Board report (${report.generatedAt.split('T')[0]})`);
688
- console.log(line);
689
-
690
- if (doneShare === null) {
691
- console.log('완료:폐기 비율 — 아직 종결된 카드가 없다.');
692
- } else {
693
- const alarm = doneShare < 50 ? ' ⚠ 폐기가 완료보다 많다 — 같은 벽에 반복 부딪히는 중인지 raw/안티패턴을 확인하라.' : '';
694
- console.log(`완료:폐기 비율 — done ${done.length} : abandoned ${abandoned.length} (superseded ${superseded.length}) → 완료 점유율 ${doneShare}%${alarm}`);
695
- }
696
-
697
- console.log(`활성: todo ${report.active.todo} · doing ${report.active.doing} (WIP ${config.wipLimits.doing}) · review ${report.active.review}`);
698
- console.log(`되돌림(QA): ${report.revertCount}건 — 가짜 완료를 잡아낸 신호다.`);
699
-
700
- if (report.expiredClaims.length) {
701
- console.log(`만료 클레임 (${report.expiredClaims.length}) — 재집기 가능:`);
702
- report.expiredClaims.forEach(t => console.log(` ${t}`));
703
- }
704
-
705
- console.log(line);
706
- console.log(`대기 (사람 판정 대기 ${report.waitingQueue.length})`);
707
- if (!report.waitingQueue.length) console.log(' (비어 있음 — 밤샘 동안 판단 질문이 없었다는 뜻)');
708
- for (const w of report.waitingQueue) {
709
- console.log(` • ${w.title}`);
710
- if (w.question) console.log(` ↳ ${w.question}`);
711
- }
712
-
713
- if (trend.length) {
714
- console.log(line);
715
- console.log('카드 수 추이 (activity.jsonl 기반, 변화 있던 날만):');
716
- for (const t of trend) {
717
- console.log(` ${t.day} created ${t.created} · done ${t.done} · superseded ${t.superseded} · abandoned ${t.abandoned} · reverted ${t.reverted}`);
718
- }
719
- }
720
- }
721
-
722
- // ── 폐기→안티패턴 파이프라인 (개선계획 4-2) ──────────────────────────────
723
-
724
- // 폐기 사유를 raw 로그에 discovery 케이스로 기록한다 — 주기 compile이 이걸
725
- // antipatterns/ 승격 후보로 삼는다. "폐기 사유가 지워지지 않고 위키로 흐르는 것"이
726
- // plan.md 3.3의 실행이다. 사유는 이 시스템에서 가장 값비싼 정보다.
727
- function appendAbandonRaw(docRoot, title, reason, cardPath) {
728
- const rawDir = path.join(docRoot, 'raw');
729
- if (!fs.existsSync(rawDir)) return null;
730
-
731
- const today = new Date().toISOString().split('T')[0];
732
- const rawPath = path.join(rawDir, `${today}.md`);
733
- let caseNum = 1;
734
- let header = `# ${today}\n`;
735
- if (fs.existsSync(rawPath)) {
736
- const content = fs.readFileSync(rawPath, 'utf8');
737
- header = '';
738
- caseNum = (content.match(/^## Case \d+:/gm) || []).length + 1;
739
- }
740
- const entry = [
741
- '',
742
- `## Case ${caseNum}: [폐기] ${title}`,
743
- '',
744
- '### Grounding',
745
- `- Evidence: ${cardPath}`,
746
- '- Confidence: 4/5',
747
- '',
748
- '### Discovery',
749
- '이 길은 아니었다 — 카드가 폐기됐다. 폐기 사유(안티패턴 원재료):',
750
- '',
751
- String(reason),
752
- '',
753
- '### Analysis',
754
- '- Why non-obvious: 다음 세션이 같은 벽에 다시 부딪히지 않게 하는 기록이다.',
755
- '- Action taken: 주기 compile에서 안티패턴 승격 후보로 검토할 것.',
756
- '',
757
- '### Related Knowledge',
758
- `- **Anti-Patterns**: [[${kanban.slugify(title)}]]`,
759
- '',
760
- ].join('\n');
761
-
762
- if (header) fs.writeFileSync(rawPath, header);
763
- fs.appendFileSync(rawPath, entry);
764
- return rawPath;
765
- }
766
-
767
- // ── board video 활동 로그 재생 타임랩스 (work-loop 종료 산출물) ─────────
768
- // activity.jsonl의 모든 이벤트는 타임스탬프가 있으므로 보드의 변화를 재현할 수
769
- // 있다. 타임라인 JSON을 남기고, video/의 Remotion 프로젝트가 있으면 함께 렌더한다.
770
- // 렌더는 headless Chrome 기반 CPU 작업이다 (GPU 불필요).
771
- function boardVideo(options = {}) {
772
- const { paths, config } = requireBoard();
773
- const activity = kanban.readActivity(paths);
774
- const cfg = loadConfig(findDocRoot());
775
- const repo = cfg.projectName || path.basename(process.cwd());
776
-
777
- const timeline = {
778
- schemaVersion: 1,
779
- kind: 'board-timeline',
780
- repo,
781
- wip: config.wipLimits.doing !== undefined ? config.wipLimits.doing : null,
782
- generatedAt: new Date().toISOString(),
783
- events: activity.map(a => ({ ts: a.ts, action: a.action, title: a.title, actor: a.actor, detail: a.detail })),
784
- };
785
- const timelinePath = path.join(paths.kanbanDir, 'board-timeline.json');
786
- fs.writeFileSync(timelinePath, JSON.stringify(timeline, null, 2) + '\n');
787
- console.log(`Timeline written: ${timelinePath} (${timeline.events.length} events)`);
788
-
789
- const videoProject = path.join(process.cwd(), 'video');
790
- if (!fs.existsSync(path.join(videoProject, 'package.json'))) {
791
- console.log('video/ Remotion 프로젝트가 없다 — 타임라인만 남긴다 (렌더는 video/ 프로젝트에서).');
792
- return;
793
- }
794
-
795
- console.log('Rendering board timelapse (headless Chrome — 첫 실행은 Chrome 내려받기로 몇 분 걸린다)...');
796
- execSync(
797
- 'npx remotion render src/index.ts BoardTimelapse ../doc/kanban/board-timelapse.mp4 --props=../doc/kanban/board-timeline.json',
798
- { cwd: videoProject, stdio: 'inherit', timeout: 570000 }
799
- );
800
- console.log('Video written: doc/kanban/board-timelapse.mp4');
801
- }
802
-
803
- // ── 디스패치 ─────────────────────────────────────────────────────────────
804
-
805
- function dispatchCard(rest) {
806
- const { paths } = requireBoard();
807
- const parsedArgs = parseArgs(rest);
808
- const sub = parsedArgs.positional.shift();
809
- if (sub === 'new') cardNew(paths, kanban.loadConfig(paths), parsedArgs);
810
- else if (sub === 'show') cardShow(paths, parsedArgs);
811
- else if (sub === 'edit') cardEdit(paths, parsedArgs);
812
- else fail('사용법: llm-wiki card <new|show|edit> …');
813
- }
814
-
815
- module.exports = {
816
- dispatchCard,
817
- pick,
818
- handoff,
819
- doneCard,
820
- supersede,
821
- abandon,
822
- reopen,
823
- resume,
824
- boardView,
825
- boardReport,
826
- boardVideo,
827
- scaffold: kanban.scaffold,
828
- };
1
+ // kanban 명령 계층 — 카드 쓰기는 전부 이 모듈의 CLI 경로로만 일어난다.
2
+ // 사람은 카드를 읽기만 하고, 에이전트는 이 명령들로만 고친다 (개선계획 §3.2).
3
+ // 서브커맨드:
4
+ // card new "<제목>" [--goal …] [--ac … …] [--depends a,b] [--not-before YYYY-MM-DD]
5
+ // card show <제목>
6
+ // card edit <제목> [--goal …] [--plan …] [--ac … …] [--add-ac …] [--check-ac 1,2]
7
+ // [--note …] [--renew-claim] [--depends a,b] [--add-depends a,b]
8
+ // [--remove-depends a,b]
9
+ // pick [--claim 이름] [--card 제목] 원자적 집기 (락 안에서)
10
+ // handoff <제목> --question "…" review로 park + 클레임 반납
11
+ // done <제목> --result "…" 완료Result 없으면 거부
12
+ // supersede <제목> --by a,b 대체 부모는 superseded/로 소멸
13
+ // abandon <제목> --reason "…" 폐기 — 사유 없으면 거부
14
+ // board [--json] 유도 (컬럼/WIP/대기/만료 클레임)
15
+ // card new/edit은 모르는 플래그·값 없는 플래그·바꿀 것 없는 호출을 실패로 끝낸다 —
16
+ // no-op을 성공으로 보고하면 에이전트가 다음 단계를 거짓 전제 위에 세운다.
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const { execSync } = require('child_process');
20
+ const { findDocRoot, loadConfig } = require('./find-doc-root');
21
+ const kanban = require('./kanban');
22
+ const { localToday } = require('./local-today');
23
+
24
+ // ── 공용 ────────────────────────────────────────────────────────────────
25
+
26
+ function parseArgs(rest) {
27
+ const positional = [];
28
+ const flags = {};
29
+ for (let i = 0; i < rest.length; i++) {
30
+ const arg = rest[i];
31
+ if (arg.startsWith('--')) {
32
+ let key; let value;
33
+ const eq = arg.indexOf('=');
34
+ if (eq !== -1) {
35
+ key = arg.slice(2, eq);
36
+ value = arg.slice(eq + 1);
37
+ } else {
38
+ key = arg.slice(2);
39
+ // 다음 토큰이 플래그면 값을 삼키지 않는다 — `done --result --yes`가
40
+ // result='--yes'로 기록되던 결함(2026-09-09 리뷰 5-2). 값 자체가 --로
41
+ // 시작해야 하면 `--result=--x` 등호 형식으로 쓴다.
42
+ if (rest[i + 1] !== undefined && !rest[i + 1].startsWith('--')) {
43
+ value = rest[i + 1];
44
+ i++;
45
+ } else {
46
+ value = true; // 값 없는 플래그 — 필수 문자열 플래그에 이 값이 오면 거부된다.
47
+ }
48
+ }
49
+ // 같은 플래그 반복(--ac a --ac b)은 배열로 누적.
50
+ if (flags[key] !== undefined) {
51
+ flags[key] = [].concat(flags[key], value);
52
+ } else {
53
+ flags[key] = value;
54
+ }
55
+ } else {
56
+ positional.push(arg);
57
+ }
58
+ }
59
+ return { positional, flags };
60
+ }
61
+
62
+ function asArray(v) {
63
+ return v === undefined ? [] : [].concat(v);
64
+ }
65
+
66
+ // 필수 문자열 플래그 검증 — 값 없는 `--result` 등은 parseArgs가 true를 심는다.
67
+ // 그대로 쓰면 기록에 문자열 'true'가 남는다(2026-09-09 리뷰 5-2). 문자열만 통과.
68
+ function flagString(v) {
69
+ return typeof v === 'string' && v.trim() ? v : undefined;
70
+ }
71
+
72
+ // 선택 문자열 플래그 — 배열 원소 중 문자열만 (값 없는 반복 플래그의 true 제거).
73
+ function stringArray(v) {
74
+ return asArray(v).filter(s => typeof s === 'string');
75
+ }
76
+
77
+ // 플래그 명세 검증 (card new/edit 공용) — 모르는 플래그와 값 없는 필수 플래그를
78
+ // 조용히 삼키면 no-op이 성공으로 보고된다(2026-09-10 접수 결함 A). 주 사용자는
79
+ // 종료 코드와 출력으로만 결과를 판단하는 에이전트다 — 오타 하나가 조용한 성공이
80
+ // 되면 다음 단계가 "걸렸다"를 전제로 진행된다.
81
+ // spec: { 이름: 'string' | 'flag' } — 'string'은 값 필수(반복 허용), 'flag'는 값 없는 스위치.
82
+ function validateFlags(flags, spec) {
83
+ const unknown = Object.keys(flags).filter(k => !(k in spec));
84
+ if (unknown.length) {
85
+ fail(`모르는 플래그: ${unknown.map(k => `--${k}`).join(', ')} — 쓸 수 있는 플래그: ${Object.keys(spec).map(k => `--${k}`).join(', ')}`);
86
+ }
87
+ for (const key of Object.keys(spec)) {
88
+ if (spec[key] !== 'string' || flags[key] === undefined) continue;
89
+ if ([].concat(flags[key]).some(v => typeof v !== 'string' || !String(v).trim())) {
90
+ fail(`--${key} 값이 없다 — \`--${key} "<값>"\` 또는 \`--${key}=값\` 형태로 줘야 한다.`);
91
+ }
92
+ }
93
+ }
94
+
95
+ // 쉼표 목록 플래그("--depends a,b" [a, b]) validateFlags가 있음을 보증한다.
96
+ function csvList(v) {
97
+ return stringArray(v).flatMap(s => String(s).split(',')).map(s => s.trim()).filter(Boolean);
98
+ }
99
+
100
+ // 로컬 타임존 ISO (분 단위) — 2026-08-29T23:14+09:00 형태. 클레임 만료 판정은
101
+ // Date.parse가 오프셋을 이해하므로 이 형태로 충분하다.
102
+ function localIso(date = new Date()) {
103
+ const pad = n => String(n).padStart(2, '0');
104
+ const offsetMin = -date.getTimezoneOffset();
105
+ const sign = offsetMin >= 0 ? '+' : '-';
106
+ const abs = Math.abs(offsetMin);
107
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
108
+ `T${pad(date.getHours())}:${pad(date.getMinutes())}${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`;
109
+ }
110
+
111
+ function fail(message) {
112
+ console.error(`✗ ${message}`);
113
+ process.exit(1);
114
+ }
115
+
116
+ // 보드가 스캐폴드돼 있어야 한다 — 없으면 init으로 안내. 에러는 실제 탐색 루트와
117
+ // LLM_WIKI_ROOT 값을 함께 보고한다 — 오버라이드가 다른 곳을 보고 있었다는 단서.
118
+ function requireBoard() {
119
+ const docRoot = findDocRoot();
120
+ const paths = kanban.kanbanPaths(docRoot);
121
+ if (!fs.existsSync(paths.kanbanDir)) {
122
+ fail(`doc/kanban/ 이 없다 (${paths.kanbanDir}${process.env.LLM_WIKI_ROOT ? ` — LLM_WIKI_ROOT=${process.env.LLM_WIKI_ROOT}` : ''}). 먼저 \`llm-wiki init\`을 실행하라.`);
123
+ }
124
+ return { docRoot, paths, config: kanban.loadConfig(paths) };
125
+ }
126
+
127
+ function nowIso() { return localIso(); }
128
+
129
+ // ── depends_on 검증 (card new/edit 공용) ──────────────────────────────────
130
+ // 의존성은 보통 카드를 만들 때가 아니라 계획하다가 알게 된다 — card new 때만 걸 수
131
+ // 있으면 선행 카드가 늦게 생기는 정상 흐름을 기록할 길이 없다(2026-09-10 결함 B).
132
+ // 여기는 쓰는 규칙만 정한다 — pick이 읽는 규칙(resolved 판정) 바꾸지 않는다.
133
+
134
+ // start에서 target까지 depends_on 간선을 따라 닿는 경로 — 순환 보고용. 없으면 null.
135
+ function depCyclePath(graph, start, target) {
136
+ const seen = new Set();
137
+ const walk = (node, trail) => {
138
+ if (node === target) return trail;
139
+ if (seen.has(node)) return null;
140
+ seen.add(node);
141
+ for (const next of graph.get(node) || []) {
142
+ const found = walk(next, [...trail, next]);
143
+ if (found) return found;
144
+ }
145
+ return null;
146
+ };
147
+ return walk(start, [start]);
148
+ }
149
+
150
+ // 새로 거는 의존성(deps)만 검증한다 — 이미 있던 것의 제거는 레거시의 끊어진 간선을
151
+ // 정리하는 길이어야 한다. 카드는 4폴더를 딱 한 번 로드한다(findCard 1회와 같은
152
+ // 비용) 안에서 도니 반복 로드를 피한다.
153
+ function validateDepTargets(paths, title, deps) {
154
+ if (deps.includes(title)) {
155
+ fail(`자기 자신을 의존성으로 넣을 없다: ${title}`);
156
+ }
157
+ const all = kanban.listAllCards(paths);
158
+ const known = new Set(all.map(c => c.meta.title));
159
+ const missing = deps.filter(d => !known.has(d));
160
+ if (missing.length) {
161
+ fail(`의존성 카드가 없다: ${missing.join(', ')} 끊어진 의존성은 pick을 영구히 막는다. card new로 먼저 만들어라.`);
162
+ }
163
+ // 순환은 활성(cards/) 카드의 간선으로만 판정한다 — 종결 카드를 의존성으로 넣는 것은
164
+ // 허용되고(pick의 resolved 판정: done/superseded는 이미 해소), 종결 카드 자신의
165
+ // depends_on은 아무도 기다리지 않는 죽은 이력이라 순환을 만들지 않는다.
166
+ const graph = new Map(
167
+ all
168
+ .filter(c => kanban.ACTIVE_STATUSES.includes(c.meta.status))
169
+ .map(c => [c.meta.title, Array.isArray(c.meta.depends_on) ? c.meta.depends_on : []])
170
+ );
171
+ for (const d of deps) {
172
+ const cyc = depCyclePath(graph, d, title);
173
+ if (cyc) fail(`순환 의존성이다: ${[title, ...cyc].join(' → ')} — 순환은 두 카드를 다 영구히 못 집게 만든다.`);
174
+ }
175
+ }
176
+
177
+ // ── card new / show / edit ──────────────────────────────────────────────
178
+
179
+ const CARD_NEW_FLAGS = { goal: 'string', ac: 'string', depends: 'string', 'not-before': 'string' };
180
+ const CARD_EDIT_FLAGS = {
181
+ goal: 'string', plan: 'string',
182
+ ac: 'string', 'add-ac': 'string', 'check-ac': 'string',
183
+ note: 'string', 'renew-claim': 'flag',
184
+ depends: 'string', 'add-depends': 'string', 'remove-depends': 'string',
185
+ };
186
+
187
+ // 각 명령의 사용법 문자열 — fail(인자 오류) bin의 --help 가드가 같은 문자열을
188
+ // 쓴다(2026-09-11·12 사고: `pick --help`를 확인하려다 실제로 카드를 집었다). 두
189
+ // 군데로 갈라지면 어느 한쪽만 갱신돼 거짓 사용법을 가르치게 된다.
190
+ const USAGE = {
191
+ 'card new': '사용법: llm-wiki card new "<제목>" [--goal …] [--ac …] [--depends a,b] [--not-before YYYY-MM-DD]',
192
+ 'card show': '사용법: llm-wiki card show <제목>',
193
+ 'card edit': '사용법: llm-wiki card edit <제목> [--goal …] [--plan …] [--ac …] [--add-ac …] [--check-ac 1,2] [--note …] [--renew-claim] [--depends a,b] [--add-depends a,b] [--remove-depends a,b]',
194
+ card: '사용법: llm-wiki card <new|show|edit> …',
195
+ handoff: '사용법: llm-wiki handoff <제목> --question "…"',
196
+ done: '사용법: llm-wiki done <제목> --result "…" Result 없는 완료는 거부한다.',
197
+ supersede: '사용법: llm-wiki supersede <제목> --by 자식1,자식2',
198
+ abandon: '사용법: llm-wiki abandon <제목> --reason "…" — 폐기 사유가 안티패턴의 원재료다.',
199
+ reopen: '사용법: llm-wiki reopen <제목> --why "…" — 되돌림 사유가 기록되지 않으면 QA가 아니다.',
200
+ resume: '사용법: llm-wiki resume <제목> [--note "…"]',
201
+ pick: '사용법: llm-wiki pick [--claim <이름>] [--card "<제목>"] — --card 없으면 ordinal 최저를 집는다',
202
+ };
203
+
204
+ // 카드를 집거나 끝내는 명령의 플래그 명세 — card new/edit과 같은 규칙으로
205
+ // validateFlags를 태운다(2026-09-12 사고: 없는 pick --card 플래그가 검증 없이
206
+ // 위임 프롬프트에 들어가 무시됐고 다른 카드가 집혔다). spec은 지금 코드가 실제로
207
+ // 읽는 플래그만 담는다 — 임의 플래그 추가가 아니라 오타·조용한 no-op 차단이 목적.
208
+ const PICK_FLAGS = { claim: 'string', card: 'string' };
209
+ const HANDOFF_FLAGS = { question: 'string' };
210
+ const DONE_FLAGS = { result: 'string' };
211
+ const SUPERSEDE_FLAGS = { by: 'string' };
212
+ const ABANDON_FLAGS = { reason: 'string', 'no-raw-log': 'flag' };
213
+ const REOPEN_FLAGS = { why: 'string', reason: 'string' };
214
+ const RESUME_FLAGS = { note: 'string' };
215
+
216
+ function cardNew(paths, config, { positional, flags }) {
217
+ return kanban.withLock(paths, kanban.BOARD_LOCK_NAME, () => {
218
+ const title = positional[0];
219
+ if (!title) fail(USAGE['card new']);
220
+ validateFlags(flags, CARD_NEW_FLAGS);
221
+
222
+ const fileName = `${kanban.slugify(title)}.md`;
223
+ const filePath = path.join(paths.cardsDir, fileName);
224
+ if (fs.existsSync(filePath)) {
225
+ fail(`같은 제목 카드가 이미 있다: ${filePath} — 제목이 곧 식별자다(3.5). 다른 제목을 쓰거나 supersede로 대체하라.`);
226
+ }
227
+ // cards/뿐 아니라 종결 3폴더까지 검사 — 예전엔 cards/만 보아 같은 제목 재생성을
228
+ // 허용했고, 그 카드의 종결이 아카이브 원본을 덮어썼다(2026-09-09 리뷰 5-1-3).
229
+ const existingAnywhere = kanban.findCard(paths, title);
230
+ if (existingAnywhere) {
231
+ fail(`같은 제목 카드가 이미 있다: ${existingAnywhere.filePath} — 제목이 곧 식별자다(3.5). 종결 기록과 충돌하므로 다른 제목을 쓰거나 기존 카드를 reopen/resume하라.`);
232
+ }
233
+ // 슬러그 충돌도 같은 파일명이 된다 — slugify가 '/', ':' 등을 같은 '-'로 접으므로
234
+ // 'a/b'와 'a:b'는 같은 파일에 쓴다. 두 번째 생성이 첫 카드를 조용히 덮어쓰는
235
+ // 일을 만들지 않는다(2026-09-09 리뷰 5-2).
236
+ const slugClash = ['cardsDir', 'doneDir', 'supersededDir', 'abandonedDir']
237
+ .map(k => paths[k])
238
+ .filter(dir => fs.existsSync(dir))
239
+ .some(dir => fs.readdirSync(dir).includes(fileName));
240
+ if (slugClash) {
241
+ fail(`같은 파일명(${fileName})이 이미 있다 — 제목이 달라도 슬러그가 같으면 한 파일을 공유해 덮어쓴다. 제목을 달리하라.`);
242
+ }
243
+
244
+ // ordinal: float, 스텝 1000, 컬럼(todo) 순서 (Backlog.md 방식).
245
+ const ordinals = kanban.listCardsInDir(paths.cardsDir).map(c => Number(c.meta.ordinal) || 0);
246
+ const ordinal = ordinals.length ? Math.max(...ordinals) + 1000 : 1000;
247
+
248
+ const dependsOn = csvList(flags.depends);
249
+ // 의존성 검증은 파일을 쓰기 전에 — 결함 B의 쓰기 규칙을 card new에도 같이
250
+ // 적용한다(어디는 엄격하고 어디는 관대하면 규칙을 못 배운다).
251
+ if (dependsOn.length) validateDepTargets(paths, title, dependsOn);
252
+ const acTexts = stringArray(flags.ac);
253
+
254
+ const meta = {
255
+ title,
256
+ status: 'todo',
257
+ ordinal,
258
+ created: localToday(),
259
+ };
260
+ if (dependsOn.length) meta.depends_on = dependsOn;
261
+ // 시간 게이트 — 이 날짜 전에는 pick이 집지 않는다 (조건만족시 진행, plan.md 9번 원칙의 기계적 절반).
262
+ if (typeof flags['not-before'] === 'string') meta.not_before = flags['not-before'];
263
+
264
+ const parsed = {
265
+ goal: flagString(flags.goal) || '',
266
+ ac: acTexts.map((text, i) => ({ checked: false, idx: i + 1, text: String(text) })),
267
+ sections: new Map([['Plan', ''], ['Notes', ''], ['Handoff', ''], ['Result', '']]),
268
+ };
269
+
270
+ fs.writeFileSync(filePath, kanban.serializeCard(meta, parsed));
271
+ kanban.appendActivity(paths, { action: 'created', title });
272
+ console.log(`Card created: ${filePath}`);
273
+ console.log(` ordinal: ${ordinal}${dependsOn.length ? `, depends_on: ${dependsOn.join(', ')}` : ''}`);
274
+ });
275
+ }
276
+
277
+ function cardShow(paths, { positional }) {
278
+ const title = positional[0];
279
+ if (!title) fail(USAGE['card show']);
280
+ const cardObj = kanban.findCard(paths, title);
281
+ if (!cardObj) fail(`카드를 못 찾았다: ${title}`);
282
+ console.log(fs.readFileSync(cardObj.filePath, 'utf8'));
283
+ }
284
+
285
+ function cardEdit(paths, { positional, flags }) {
286
+ return kanban.withLock(paths, kanban.BOARD_LOCK_NAME, () => {
287
+ const title = positional[0];
288
+ if (!title) {
289
+ fail(USAGE['card edit']);
290
+ }
291
+ // 검증은 파일을 건드리기 전에 모르는 플래그(오타)·값 없는 플래그·바꿀 없는
292
+ // 호출은 전부 조용한 no-op이 성공으로 보고되던 길이다(결함 A).
293
+ validateFlags(flags, CARD_EDIT_FLAGS);
294
+ if (!Object.keys(CARD_EDIT_FLAGS).some(k => flags[k] !== undefined)) {
295
+ fail('바꿀 것이 지정되지 않았다 — 최소한 하나의 플래그가 필요하다.');
296
+ }
297
+ const cardObj = kanban.findCard(paths, title);
298
+ if (!cardObj) fail(`카드를 못 찾았다: ${title}`);
299
+
300
+ const meta = { ...cardObj.meta };
301
+ const parsed = { goal: cardObj.card.goal, ac: cardObj.card.ac.map(a => ({ ...a })), sections: new Map(cardObj.card.sections) };
302
+ const changes = [];
303
+
304
+ if (typeof flags.goal === 'string') { parsed.goal = flags.goal; changes.push('goal'); }
305
+ if (typeof flags.plan === 'string') { parsed.sections.set('Plan', flags.plan); changes.push('plan'); }
306
+
307
+ // --ac는 목록 전체 교체, --add-ac는 뒤에 붙이기(번호는 최대+1 — 재정렬 후에도 #N 안정).
308
+ const replaceAc = stringArray(flags.ac);
309
+ if (replaceAc.length) {
310
+ parsed.ac = replaceAc.map((text, i) => ({ checked: false, idx: i + 1, text: String(text) }));
311
+ changes.push(`ac=${replaceAc.length}`);
312
+ }
313
+ const addAc = stringArray(flags['add-ac']);
314
+ if (addAc.length) {
315
+ let nextIdx = parsed.ac.reduce((m, a) => Math.max(m, a.idx), 0) + 1;
316
+ for (const text of addAc) parsed.ac.push({ checked: false, idx: nextIdx++, text: String(text) });
317
+ changes.push(`ac +${addAc.length}`);
318
+ }
319
+
320
+ // --check-ac 1,2 AC는 객관적 증거로만 체크하라는 에이전트의 규율(루프 스킬)이고
321
+ // CLI는 번호의 안전성(#N)만 보장한다.
322
+ if (flags['check-ac'] !== undefined) {
323
+ const idxs = asArray(flags['check-ac']).map(String).join(',').split(',').map(s => Number(s.trim()));
324
+ for (const idx of idxs) {
325
+ if (!Number.isInteger(idx) || idx < 1) fail(`--check-ac 는 AC 번호(쉼표 구분)를 받는다 — 받은 값: ${String(flags['check-ac'])}`);
326
+ const ac = parsed.ac.find(a => a.idx === idx);
327
+ if (!ac) fail(`AC #${idx} 가 없다 번호는 재정렬해도 유지된다.`);
328
+ ac.checked = true;
329
+ }
330
+ changes.push(`check-ac #${idxs.join(', #')}`);
331
+ }
332
+
333
+ // Notes는 append-only 저널 — 타임스탬프와 함께 덧붙인다.
334
+ const notes = stringArray(flags.note);
335
+ if (notes.length) {
336
+ const existing = parsed.sections.get('Notes') || '';
337
+ const stamped = notes.map(n => `- ${nowIso()} — ${n}`).join('\n');
338
+ parsed.sections.set('Notes', existing ? `${existing}\n${stamped}` : stamped);
339
+ changes.push(`note +${notes.length}`);
340
+ }
341
+
342
+ // depends_on 편집 — --depends 전체 교체 → --add-depends 합집합 → --remove-depends
343
+ // 차집합 순서(ac 계열 어법). 종결 카드의 의존성은 pick이 읽지 않는 이력이다.
344
+ const replaceDeps = csvList(flags.depends);
345
+ const addDeps = csvList(flags['add-depends']);
346
+ const removeDeps = csvList(flags['remove-depends']);
347
+ if (replaceDeps.length || addDeps.length || removeDeps.length) {
348
+ if (!kanban.ACTIVE_STATUSES.includes(cardObj.meta.status)) {
349
+ fail(`종결 카드(${cardObj.meta.status})의 의존성은 바꾸지 않는다 — pick이 이미 기다리지 않는 이력이다: ${cardObj.filePath}`);
350
+ }
351
+ const before = Array.isArray(meta.depends_on) ? [...new Set(meta.depends_on)] : [];
352
+ let next = replaceDeps.length ? [...new Set(replaceDeps)] : [...before];
353
+ for (const d of [...new Set(addDeps)]) {
354
+ if (!next.includes(d)) next.push(d);
355
+ }
356
+ // 제거 대상이 목록에 없으면 오타일 확률이 높다 조용히 성공하면 "걸었다"고
357
+ // 믿은 채 진행된다(check-ac가 없는 번호를 거부하는 것과 같은 규칙).
358
+ const absent = removeDeps.filter(d => !next.includes(d));
359
+ if (absent.length) {
360
+ fail(`제거할 의존성이 이 카드에 없다: ${absent.join(', ')} — 현재 depends_on: [${next.join(', ') || '없음'}]`);
361
+ }
362
+ next = next.filter(d => !removeDeps.includes(d));
363
+ // 검증은 새로 들어온 간선에만 이미 있던 의존성의 제거(레거시 정리)
364
+ // 대상 카드가 살아있어야 하는 게 아니다.
365
+ const introduced = next.filter(d => !before.includes(d));
366
+ validateDepTargets(paths, title, introduced);
367
+ const removed = before.filter(d => !next.includes(d));
368
+ if (introduced.length) changes.push(`depends +${introduced.join(', ')}`);
369
+ if (removed.length) changes.push(`depends -${removed.join(', ')}`);
370
+ if (next.length) meta.depends_on = next;
371
+ else delete meta.depends_on;
372
+ }
373
+
374
+ // --renew-claim — 긴 카드는 타임아웃 전에 클레임을 갱신한다 (루프 스킬 규칙 6).
375
+ // 클레임은 만료 있는 협동 락이므로, 살아있는 작업자는 주기적으로 시각을 다시 심는다.
376
+ // 안에서 만료를 재검증한다 — 만료된 클레임의 갱신은 다른 세션이 이미 재집어
377
+ // 작업 중인 카드의 시각을 되살려 이중 작업을 만든다. 갱신 거부 → 재집기(pick) 유도.
378
+ if (flags['renew-claim']) {
379
+ if (!meta.claimed_by) fail(`클레임이 없는 카드다: ${title} — renew가 아니라 pick으로 집어라.`);
380
+ const config = kanban.loadConfig(paths);
381
+ if (kanban.isClaimExpired({ meta }, config)) {
382
+ fail(`클레임이 만료됐다 (${meta.claimed_at}) — 다른 세션이 재집었을 수 있다. pick으로 다시 집어라.`);
383
+ }
384
+ meta.claimed_at = nowIso();
385
+ changes.push('renew-claim');
386
+ }
387
+
388
+ // 성공 문구는 실제로 무엇이 바뀌었는지 말한다. 플래그는 있었지만 결과가 동일하면
389
+ // (멱등 재시도) 성공을 주장하지 않고 파일도 다시 쓰지 않는다.
390
+ if (!changes.length) {
391
+ console.log(`변경 없음 — 이미 그 상태다: ${cardObj.filePath}`);
392
+ return;
393
+ }
394
+ kanban.writeCard(cardObj, meta, parsed);
395
+ console.log(`Card edited: ${cardObj.filePath} (${changes.join(', ')})`);
396
+ });
397
+ }
398
+
399
+ // QA 루프의 되돌림 원시형 (개선계획 4-3) — 증거 빈약한 완료를 doing으로 역류시킨다.
400
+ // 카드 수가 일시적으로 늘어나는 것이 곡선의 진짜를 만든다 (plan.md 2.4).
401
+ function reopen(options = {}) {
402
+ const { paths } = requireBoard();
403
+ const { positional, flags } = parseArgs(options.rest || []);
404
+ const title = positional[0];
405
+ const why = flagString(flags.why) || flagString(flags.reason);
406
+ if (!title || !why) fail(USAGE.reopen);
407
+ validateFlags(flags, REOPEN_FLAGS);
408
+
409
+ return kanban.withLock(paths, kanban.BOARD_LOCK_NAME, () => {
410
+ const cardObj = kanban.findCard(paths, title);
411
+ if (!cardObj) fail(`카드를 못 찾았다: ${title}`);
412
+ if (cardObj.meta.status !== 'done') fail(`done 카드만 되돌린다 (현재: ${cardObj.meta.status}): ${cardObj.filePath}`);
413
+
414
+ const content = fs.readFileSync(cardObj.filePath, 'utf8');
415
+ const meta = kanban.parseFrontmatter(content);
416
+ const parsed = kanban.parseBody(content);
417
+ meta.status = 'doing';
418
+ delete meta.claimed_by;
419
+ delete meta.claimed_at;
420
+ const notes = parsed.sections.get('Notes') || '';
421
+ const line = `- ${nowIso()}REVERTED: ${why}`;
422
+ parsed.sections.set('Notes', notes ? `${notes}\n${line}` : line);
423
+
424
+ fs.writeFileSync(path.join(paths.cardsDir, cardObj.fileName), kanban.serializeCard(meta, parsed));
425
+ fs.unlinkSync(cardObj.filePath);
426
+ kanban.appendActivity(paths, { action: 'reverted', title: meta.title, detail: String(why) });
427
+ console.log(`Reverted to doing: ${path.join(paths.cardsDir, cardObj.fileName)}`);
428
+ });
429
+ }
430
+
431
+ // ── pick — 원자적 집기 ───────────────────────────────────────────────────
432
+
433
+ // 준비 필터(미클레임/만료/의존 충족/WIP 여유) ordinal 정렬 → 클레임 → doing.
434
+ // 전 과정을 락 안에서 — 동시 pick 빈틈을 처음부터 막고 출발한다 (kanban-md 교훈).
435
+
436
+ // 지정 집기(--card) 게이트 판정 — 통상 pick의 후보 판정과 같은 규칙(review
437
+ // not_before → 클레임 → todo 의존) 지정 카드 한 장에 적용한다. --card는 우선권이
438
+ // 아니라 지정이다: 하나라도 걸리면 집지 않고 이 사유를 낸다. 통과면 null.
439
+ function pickGateBlock(cardObj, resolvedTitles, config) {
440
+ const status = cardObj.meta.status;
441
+ if (kanban.TERMINAL_FOLDERS.includes(status)) {
442
+ return `이미 종결됐다 (${status}): ${cardObj.filePath}`;
443
+ }
444
+ if (!kanban.ACTIVE_STATUSES.includes(status)) {
445
+ return `활성 카드가 아니다 (status: ${status || '없음'}): ${cardObj.filePath}`;
446
+ }
447
+ if (status === 'review') {
448
+ return '사람 판정 대기(review) resume으로 todo에 복귀시킨 집어라';
449
+ }
450
+ if (cardObj.meta.not_before && String(cardObj.meta.not_before) > localToday()) {
451
+ return `시작 예정: ${cardObj.meta.not_before} 이후 (not_before)`;
452
+ }
453
+ if (cardObj.meta.claimed_by && !kanban.isClaimExpired(cardObj, config)) {
454
+ return `클레임 중: ${cardObj.meta.claimed_by}`;
455
+ }
456
+ if (status === 'todo') {
457
+ const deps = Array.isArray(cardObj.meta.depends_on) ? cardObj.meta.depends_on : [];
458
+ const unmet = deps.filter(dep => !resolvedTitles.has(dep));
459
+ if (unmet.length) return `의존 미충족: ${unmet.join(', ')}`;
460
+ }
461
+ return null;
462
+ }
463
+
464
+ function pick(options = {}) {
465
+ const { paths, config } = requireBoard();
466
+ const { flags } = parseArgs(options.rest || []);
467
+ validateFlags(flags, PICK_FLAGS);
468
+ const claimName = typeof flags.claim === 'string' && flags.claim ? flags.claim : 'unnamed-agent';
469
+ const wantTitle = flagString(flags.card) || null;
470
+
471
+ const result = kanban.withLock(paths, kanban.BOARD_LOCK_NAME, () => {
472
+ // cards/의 모든 .md가 카드는 아니다(README 등) — 활성 status가 없는 파일은
473
+ // 후보에서 제외한다. 예전엔 이를 집어 재직렬화하며 `## ` 앞 본문을 지웠다(5-1-4).
474
+ const active = kanban.listCardsInDir(paths.cardsDir)
475
+ .filter(c => kanban.ACTIVE_STATUSES.includes(c.meta.status));
476
+ const doing = active.filter(c => c.meta.status === 'doing');
477
+ const wipLimit = config.wipLimits.doing !== undefined ? config.wipLimits.doing : Infinity;
478
+
479
+ if (doing.length >= wipLimit) {
480
+ return { picked: null, reason: `wip-limit`, detail: `doing WIP 상한 도달 (${doing.length}/${wipLimit}) 하던 카드를 먼저 종결하라.` };
481
+ }
482
+
483
+ // 종결 = done 또는 superseded 폴더에 존재. abandoned 의존은 미충족(길이 틀렸다는 신호).
484
+ const resolvedTitles = new Set([
485
+ ...kanban.listCardsInDir(paths.doneDir).map(c => c.meta.title),
486
+ ...kanban.listCardsInDir(paths.supersededDir).map(c => c.meta.title),
487
+ ]);
488
+
489
+ const blocked = [];
490
+ const candidates = [];
491
+ if (wantTitle) {
492
+ // 지정 집기 — 매칭은 findCard 규칙 그대로(제목이 곧 식별자, 3.5). 없는 제목은
493
+ // 인자 오류로 실패하고, 게이트에 막히면 이유를 내고 파일을 그대로 둔다.
494
+ const target = kanban.findCard(paths, wantTitle);
495
+ if (!target) {
496
+ fail(`--card 대상 카드를 못 찾았다: ${wantTitle} — board로 제목을 확인하라.`);
497
+ }
498
+ const why = pickGateBlock(target, resolvedTitles, config);
499
+ if (why) return { picked: null, reason: 'blocked', detail: `${target.meta.title} — ${why}` };
500
+ candidates.push(target);
501
+ } else {
502
+ const today = localToday(); // not_before 게이트 — 현지 날짜(5-1-5)
503
+ for (const cardObj of active) {
504
+ const status = cardObj.meta.status;
505
+ // review는 사람 판정 대기 — pick이 집지 않는다.
506
+ if (status === 'review') continue;
507
+
508
+ // 시간 게이트 — not_before가 미래면 아직 시작하지 않는 카드다.
509
+ if (cardObj.meta.not_before && String(cardObj.meta.not_before) > today) {
510
+ blocked.push({ title: cardObj.meta.title, notBefore: cardObj.meta.not_before });
511
+ continue;
512
+ }
513
+
514
+ // 클레임이 살아있는 doing은 남의 작업. 만료됐으면 재집기 후보 — 밤에 죽은
515
+ // 세션의 카드를 자동 회수하는 것이 협동 락의 존재 이유다.
516
+ if (cardObj.meta.claimed_by && !kanban.isClaimExpired(cardObj, config)) {
517
+ blocked.push({ title: cardObj.meta.title, claimedBy: cardObj.meta.claimed_by });
518
+ continue;
519
+ }
520
+
521
+ if (status === 'todo') {
522
+ const deps = Array.isArray(cardObj.meta.depends_on) ? cardObj.meta.depends_on : [];
523
+ const unmet = deps.filter(dep => !resolvedTitles.has(dep));
524
+ if (unmet.length) { blocked.push({ title: cardObj.meta.title, unmet }); continue; }
525
+ }
526
+ candidates.push(cardObj);
527
+ }
528
+ }
529
+
530
+ candidates.sort((a, b) => (Number(a.meta.ordinal) || 0) - (Number(b.meta.ordinal) || 0));
531
+ const chosen = candidates[0];
532
+ if (!chosen) {
533
+ return { picked: null, reason: blocked.length ? 'blocked-or-claimed' : 'empty', detail: blocked };
534
+ }
535
+
536
+ const meta = { ...chosen.meta };
537
+ meta.status = 'doing';
538
+ meta.claimed_by = claimName;
539
+ meta.claimed_at = nowIso();
540
+ kanban.writeCard(chosen, meta);
541
+ kanban.appendActivity(paths, { action: 'claimed', title: meta.title, actor: claimName });
542
+ return { picked: { path: chosen.filePath, title: meta.title }, reason: null };
543
+ });
544
+
545
+ if (options.json) {
546
+ console.log(JSON.stringify({ schemaVersion: 1, kind: 'kanban-pick', ...result }, null, 2));
547
+ return;
548
+ }
549
+ if (!result.picked) {
550
+ console.log(`No pickable card (${result.reason}).`);
551
+ if (Array.isArray(result.detail)) {
552
+ for (const b of result.detail) {
553
+ if (b.notBefore) console.log(` - ${b.title} — 시작 예정: ${b.notBefore} 이후 (not_before)`);
554
+ else if (b.unmet && b.unmet.length) console.log(` - ${b.title} — 의존 미충족: ${b.unmet.join(', ')}`);
555
+ else if (b.claimedBy) console.log(` - ${b.title} 클레임 중: ${b.claimedBy}`);
556
+ }
557
+ } else if (result.detail) {
558
+ console.log(` ${result.detail}`);
559
+ }
560
+ return;
561
+ }
562
+ console.log(`PICKED: ${result.picked.path}`);
563
+ console.log(fs.readFileSync(result.picked.path, 'utf8'));
564
+ }
565
+
566
+ // ── 종결 3갈래 + handoff ────────────────────────────────────────────────
567
+
568
+ // 활성 카드만 상태 전이 대상. 종결 카드는 이미 끝난 이력이다.
569
+ function requireActiveCard(paths, title) {
570
+ const cardObj = kanban.findCard(paths, title);
571
+ if (!cardObj) fail(`카드를 못 찾았다: ${title}`);
572
+ if (!kanban.ACTIVE_STATUSES.includes(cardObj.meta.status)) {
573
+ fail(`카드는 이미 종결됐다 (${cardObj.meta.status}): ${cardObj.filePath}`);
574
+ }
575
+ return cardObj;
576
+ }
577
+
578
+ function handoff(options = {}) {
579
+ const { paths } = requireBoard();
580
+ const { positional, flags } = parseArgs(options.rest || []);
581
+ const title = positional[0];
582
+ const question = flagString(flags.question);
583
+ if (!title || !question) fail(USAGE.handoff);
584
+ validateFlags(flags, HANDOFF_FLAGS);
585
+
586
+ return kanban.withLock(paths, kanban.BOARD_LOCK_NAME, () => {
587
+ const cardObj = requireActiveCard(paths, title);
588
+ const meta = { ...cardObj.meta };
589
+ const parsed = { ...cardObj.card, ac: cardObj.card.ac.map(a => ({ ...a })), sections: new Map(cardObj.card.sections) };
590
+
591
+ meta.status = 'review';
592
+ delete meta.claimed_by; // 클레임 반납 — park하고 다음 카드로
593
+ delete meta.claimed_at;
594
+
595
+ const existing = parsed.sections.get('Handoff') || '';
596
+ const line = `- ${nowIso()} QUESTION: ${question}`;
597
+ parsed.sections.set('Handoff', existing ? `${existing}\n${line}` : line);
598
+
599
+ kanban.writeCard(cardObj, meta, parsed);
600
+ kanban.appendActivity(paths, { action: 'handoff', title: meta.title, detail: String(question) });
601
+ console.log(`Handed off (parked for human judgment): ${cardObj.filePath}`);
602
+ });
603
+ }
604
+
605
+ function doneCard(options = {}) {
606
+ const { paths } = requireBoard();
607
+ const { positional, flags } = parseArgs(options.rest || []);
608
+ const title = positional[0];
609
+ const result = flagString(flags.result);
610
+ if (!title || !result) fail(USAGE.done);
611
+ validateFlags(flags, DONE_FLAGS);
612
+
613
+ return kanban.withLock(paths, kanban.BOARD_LOCK_NAME, () => {
614
+ const cardObj = requireActiveCard(paths, title);
615
+ const meta = { ...cardObj.meta };
616
+ const parsed = { ...cardObj.card, ac: cardObj.card.ac.map(a => ({ ...a })), sections: new Map(cardObj.card.sections) };
617
+
618
+ const unchecked = parsed.ac.filter(a => !a.checked);
619
+ if (unchecked.length) {
620
+ console.error(`⚠ AC ${unchecked.map(a => `#${a.idx}`).join(', ')} 가 체크 안 됐다 — 증거로 체크했는지 스스로 검증하라 (QA 루프가 되돌릴 수 있다).`);
621
+ }
622
+
623
+ const existing = parsed.sections.get('Result') || '';
624
+ const line = `- ${nowIso()} ${result}`;
625
+ parsed.sections.set('Result', existing ? `${existing}\n${line}` : line);
626
+
627
+ delete meta.claimed_by;
628
+ delete meta.claimed_at;
629
+ kanban.writeCard(cardObj, meta, parsed);
630
+ try {
631
+ kanban.moveCardTo(cardObj, paths.doneDir, 'done');
632
+ } catch (e) {
633
+ fail(e.message); // 카드는 활성으로 남는다 — Result는 이미 반영돼 있다.
634
+ }
635
+ kanban.appendActivity(paths, { action: 'done', title: meta.title });
636
+ console.log(`Done: ${path.join(paths.doneDir, cardObj.fileName)}`);
637
+ });
638
+ }
639
+
640
+ // 대체 카드가 카드들이 됨. 부모는 완료가 아니라 소멸(3.1).
641
+ function supersede(options = {}) {
642
+ const { paths } = requireBoard();
643
+ const { positional, flags } = parseArgs(options.rest || []);
644
+ const title = positional[0];
645
+ const by = stringArray(flags.by).flatMap(s => s.split(',')).map(s => s.trim()).filter(Boolean);
646
+ if (!title || !by.length) fail(USAGE.supersede);
647
+ validateFlags(flags, SUPERSEDE_FLAGS);
648
+
649
+ return kanban.withLock(paths, kanban.BOARD_LOCK_NAME, () => {
650
+ const parent = requireActiveCard(paths, title);
651
+ for (const child of by) {
652
+ const childCard = kanban.findCard(paths, child);
653
+ if (!childCard) fail(`자식 카드를 먼저 만들어라 (card new): ${child}`);
654
+ if (!kanban.ACTIVE_STATUSES.includes(childCard.meta.status)) {
655
+ fail(`자식 카드가 이미 종결됐다 (${childCard.meta.status}): ${child}`);
656
+ }
657
+ }
658
+
659
+ const meta = { ...parent.meta, superseded_by: by };
660
+ kanban.writeCard(parent, meta);
661
+ try {
662
+ kanban.moveCardTo(parent, paths.supersededDir, 'superseded');
663
+ } catch (e) {
664
+ fail(e.message);
665
+ }
666
+ kanban.appendActivity(paths, { action: 'superseded', title: parent.meta.title, detail: `by ${by.join(', ')}` });
667
+ console.log(`Superseded: ${parent.meta.title} → [${by.join(', ')}]`);
668
+ console.log(` parent: ${path.join(paths.supersededDir, parent.fileName)}`);
669
+ });
670
+ }
671
+
672
+ // 폐기 사유가 가장 값비싼 정보다(3.3). 없으면 거부하고, 기록은 지우지 않는다.
673
+ function abandon(options = {}) {
674
+ const { docRoot, paths } = requireBoard();
675
+ const { positional, flags } = parseArgs(options.rest || []);
676
+ const title = positional[0];
677
+ const reason = flagString(flags.reason);
678
+ if (!title || !reason) fail(USAGE.abandon);
679
+ validateFlags(flags, ABANDON_FLAGS);
680
+
681
+ return kanban.withLock(paths, kanban.BOARD_LOCK_NAME, () => {
682
+ const cardObj = requireActiveCard(paths, title);
683
+ const meta = { ...cardObj.meta, discard_reason: String(reason) };
684
+ kanban.writeCard(cardObj, meta);
685
+ try {
686
+ kanban.moveCardTo(cardObj, paths.abandonedDir, 'abandoned');
687
+ } catch (e) {
688
+ fail(e.message);
689
+ }
690
+ kanban.appendActivity(paths, { action: 'abandoned', title: cardObj.meta.title, detail: String(reason) });
691
+
692
+ // 4-2: 폐기 사유를 raw에 자동 기록 (기본 on — --no-raw-log로 끈다).
693
+ if (!flags['no-raw-log']) {
694
+ try {
695
+ const rawPath = appendAbandonRaw(docRoot, cardObj.meta.title, String(reason), path.relative(process.cwd(), path.join(paths.abandonedDir, cardObj.fileName)));
696
+ if (rawPath) console.log(`폐기 사유를 raw에 기록했다 (안티패턴 원재료): ${rawPath}`);
697
+ } catch (e) {
698
+ console.error(`⚠ raw 기록 실패 (폐기 자체는 완료): ${e.message}`);
699
+ }
700
+ }
701
+
702
+ console.log(`Abandoned: ${path.join(paths.abandonedDir, cardObj.fileName)}`);
703
+ });
704
+ }
705
+
706
+ // ── board 유도 ──────────────────────────────────────────────────────
707
+
708
+ // 카드가 정본이고 이 출력은 유도물이다 (3.7 원칙 2 — 갱신을 강제하지 않는다).
709
+ function collectBoard() {
710
+ const { paths, config } = requireBoard();
711
+ const active = kanban.listCardsInDir(paths.cardsDir);
712
+ const byStatus = status => active
713
+ .filter(c => c.meta.status === status)
714
+ .sort((a, b) => (Number(a.meta.ordinal) || 0) - (Number(b.meta.ordinal) || 0));
715
+
716
+ const view = {
717
+ schemaVersion: 1,
718
+ kind: 'kanban-board',
719
+ generatedAt: new Date().toISOString(),
720
+ wip: { doing: { used: byStatus('doing').length, limit: config.wipLimits.doing } },
721
+ columns: {
722
+ todo: byStatus('todo').map(c => viewCard(c, config)),
723
+ doing: byStatus('doing').map(c => viewCard(c, config)),
724
+ review: byStatus('review').map(c => viewCard(c, config)),
725
+ },
726
+ terminal: {
727
+ done: kanban.listCardsInDir(paths.doneDir).length,
728
+ superseded: kanban.listCardsInDir(paths.supersededDir).length,
729
+ abandoned: kanban.listCardsInDir(paths.abandonedDir).length,
730
+ },
731
+ // 종결 적체 완료가 쌓이는 모습이 수렴의 증거다 (HTML 보드용 최근 목록).
732
+ terminalRecent: [
733
+ ...kanban.listCardsInDir(paths.doneDir).map(c => ({ title: c.meta.title, kind: 'done', m: fs.statSync(c.filePath).mtimeMs })),
734
+ ...kanban.listCardsInDir(paths.supersededDir).map(c => ({ title: c.meta.title, kind: 'superseded', m: fs.statSync(c.filePath).mtimeMs })),
735
+ ...kanban.listCardsInDir(paths.abandonedDir).map(c => ({ title: c.meta.title, kind: 'abandoned', m: fs.statSync(c.filePath).mtimeMs })),
736
+ ]
737
+ .sort((a, b) => b.m - a.m)
738
+ .slice(0, 8)
739
+ .map(({ title, kind }) => ({ title, kind })),
740
+ // 의존 충족 판정용 종결 제목들 — 텍스트/HTML 렌더러가 공유한다.
741
+ resolved: [...kanban.listCardsInDir(paths.doneDir), ...kanban.listCardsInDir(paths.supersededDir)]
742
+ .map(c => c.meta.title),
743
+ };
744
+ return { view, paths, config };
745
+ }
746
+
747
+ function boardView(options = {}) {
748
+ const { view, paths, config } = collectBoard();
749
+
750
+ if (options.json) {
751
+ console.log(JSON.stringify(view, null, 2));
752
+ return;
753
+ }
754
+ if (options.html) {
755
+ const htmlPath = path.join(paths.kanbanDir, 'board.html');
756
+ fs.writeFileSync(htmlPath, renderBoardHtml(view));
757
+ console.log(`Board written: ${htmlPath}`);
758
+ console.log(' 유도물 — 서버 없이 브라우저에서 열면 된다. 갱신은 다시 `llm-wiki board --html`.');
759
+ return;
760
+ }
761
+ renderBoard(view, config);
762
+ }
763
+
764
+ function viewCard(cardObj, config) {
765
+ const expired = cardObj.meta.status === 'doing' && kanban.isClaimExpired(cardObj, config);
766
+ // 대기 사유 — handoff가 남긴 마지막 QUESTION을 끌어낸다. 대기 큐가 제목만
767
+ // 나열하면 아침의 사람이 카드를 하나씩 열어야 하므로(3단계 완료 기준 ).
768
+ const handoff = cardObj.card.sections.get('Handoff') || '';
769
+ const qLine = [...handoff.split(/\r?\n/)].reverse().find(l => l.includes('QUESTION:'));
770
+ return {
771
+ title: cardObj.meta.title,
772
+ ordinal: Number(cardObj.meta.ordinal) || 0,
773
+ claimedBy: cardObj.meta.claimed_by || null,
774
+ claimExpired: expired,
775
+ notBefore: cardObj.meta.not_before || null,
776
+ question: qLine ? qLine.split('QUESTION:')[1].trim() : null,
777
+ dependsOn: cardObj.meta.depends_on || [],
778
+ };
779
+ }
780
+
781
+ function renderBoard(view, config) {
782
+ const line = '────────────────────────────────────────';
783
+ console.log(`Kanban board (${localToday()}) 정본은 doc/kanban/ 카드 파일들`);
784
+ console.log(line);
785
+
786
+ const wip = view.wip.doing;
787
+ console.log(`DOING (${wip.used}/${wip.limit}${wip.used >= wip.limit ? ' — WIP 상한' : ''})`);
788
+ if (!view.columns.doing.length) console.log(' (비어 있음)');
789
+ for (const c of view.columns.doing) {
790
+ console.log(` ${c.title}${c.claimExpired ? ' [클레임 만료 — 재집기 가능]' : ` [${c.claimedBy}]`}`);
791
+ }
792
+
793
+ console.log(line);
794
+ console.log(`REVIEW (사람 판정 대기 — ${view.columns.review.length})`);
795
+ if (!view.columns.review.length) console.log(' (비어 있음)');
796
+ for (const c of view.columns.review) {
797
+ console.log(` • ${c.title}`);
798
+ if (c.question) console.log(` ↳ ${c.question}`);
799
+ }
800
+
801
+ console.log(line);
802
+ console.log(`TODO (${view.columns.todo.length}) — ordinal 순`);
803
+ const resolved = new Set(view.resolved);
804
+ if (!view.columns.todo.length) console.log(' (비어 있음)');
805
+ for (const c of view.columns.todo) {
806
+ const unmet = c.dependsOn.filter(dep => !resolved.has(dep));
807
+ const gate = c.notBefore && c.notBefore > localToday();
808
+ console.log(` ${c.title}${gate ? ` [시작 예정 — ${c.notBefore}]` : ''}${unmet.length ? ` [대기 — 의존: ${unmet.join(', ')}]` : ''}`);
809
+ }
810
+
811
+ console.log(line);
812
+ console.log(`종결: done ${view.terminal.done} · superseded ${view.terminal.superseded} · abandoned ${view.terminal.abandoned}`);
813
+ }
814
+
815
+ // ── 정적 HTML 보드 — 사람용 시각화 (유도 뷰) ─────────────────────────────
816
+ // 서버 없음, file://로 열린다. 드래그 이동이 있는 웹 뷰는 포맷이 굳은 뒤의
817
+ // 확정 대상(plan.md) — 그때까지는 읽기 전용이 원칙이다.
818
+ function escapeHtml(s) {
819
+ return String(s).replace(/[&<>"']/g, ch => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[ch]));
820
+ }
821
+
822
+ function renderBoardHtml(view) {
823
+ const today = localToday();
824
+ const resolved = new Set(view.resolved);
825
+
826
+ const badges = c => {
827
+ const out = [];
828
+ if (c.claimExpired) out.push('<span class="badge warn">클레임 만료</span>');
829
+ else if (c.claimedBy) out.push(`<span class="badge">${escapeHtml(c.claimedBy)}</span>`);
830
+ if (c.notBefore && c.notBefore > today) out.push(`<span class="badge gate">시작 예정 ${escapeHtml(c.notBefore)}</span>`);
831
+ const unmet = c.dependsOn.filter(dep => !resolved.has(dep));
832
+ if (unmet.length) out.push(`<span class="badge gate">의존 대기: ${unmet.map(escapeHtml).join(', ')}</span>`);
833
+ return out.join(' ');
834
+ };
835
+ const cardDiv = c => `<div class="card"><div class="t">${escapeHtml(c.title)}</div>${c.question ? `<div class="q">↳ ${escapeHtml(c.question)}</div>` : ''}${badges(c)}</div>`;
836
+ const kindLabel = { done: '완료', superseded: '대체', abandoned: '폐기' };
837
+ const terminalList = view.terminalRecent.length
838
+ ? view.terminalRecent
839
+ .map(t => `<div class="card"><span class="badge t-${t.kind}">${kindLabel[t.kind]}</span><span class="tt">${escapeHtml(t.title)}</span></div>`)
840
+ .join('\n')
841
+ : '<div class="empty">비어 있음</div>';
842
+ const column = (label, cards) => `
843
+ <section>
844
+ <h2>${label} <span class="count">${cards.length}</span></h2>
845
+ ${cards.map(cardDiv).join('\n') || '<div class="empty">비어 있음</div>'}
846
+ </section>`;
847
+ const terminalSection = `
848
+ <section>
849
+ <h2>종결 적체 <span class="count">최근 ${view.terminalRecent.length}</span></h2>
850
+ ${terminalList}
851
+ </section>`;
852
+
853
+ return `<!doctype html>
854
+ <html lang="ko">
855
+ <head>
856
+ <meta charset="utf-8">
857
+ <title>llm-wiki board</title>
858
+ <style>
859
+ body { font-family: -apple-system, 'Segoe UI', 'Malgun Gothic', sans-serif; margin: 24px; background: #f6f7f9; color: #1c2733; }
860
+ header h1 { font-size: 20px; margin: 0 0 4px; }
861
+ header p { margin: 0 0 20px; color: #5b6a78; font-size: 13px; }
862
+ main { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
863
+ section { background: #fff; border: 1px solid #e2e6ea; border-radius: 10px; padding: 12px; }
864
+ h2 { font-size: 14px; margin: 0 0 10px; text-transform: uppercase; letter-spacing: .04em; color: #40505f; }
865
+ .count { color: #8a97a3; font-weight: 400; }
866
+ .card { background: #f2f4f7; border: 1px solid #dde2e8; border-radius: 8px; padding: 8px 10px; margin-bottom: 8px; }
867
+ .card .t { font-size: 14px; font-weight: 600; margin-bottom: 4px; }
868
+ .q { font-size: 12px; color: #5b6a78; margin: 2px 0 4px; }
869
+ .badge { display: inline-block; font-size: 11px; background: #e3e8ee; color: #44525f; border-radius: 999px; padding: 2px 8px; margin: 2px 4px 0 0; }
870
+ .badge.warn { background: #fdeccf; color: #7a4d05; }
871
+ .badge.gate { background: #dbe7fd; color: #1c4d8f; }
872
+ .empty { color: #98a3ad; font-size: 13px; }
873
+ .tt { font-size: 13px; color: #1c2733; }
874
+ .t-done { background: #6ee7a0; }
875
+ .t-superseded { background: #c9a6ff; }
876
+ .t-abandoned { background: #ff8f8f; }
877
+ footer { margin-top: 18px; color: #5b6a78; font-size: 13px; }
878
+ </style>
879
+ </head>
880
+ <body>
881
+ <header>
882
+ <h1>Kanban board</h1>
883
+ <p>생성: ${escapeHtml(view.generatedAt)} · WIP doing ${view.wip.doing.used}/${view.wip.doing.limit} · 이 파일은 유도물 — 정본은 doc/kanban/ 카드 파일들</p>
884
+ </header>
885
+ <main style="grid-template-columns: repeat(4, 1fr)">
886
+ ${column('Doing', view.columns.doing)}
887
+ ${column('Review (사람 판정 대기)', view.columns.review)}
888
+ ${column('Todo', view.columns.todo)}
889
+ ${terminalSection}
890
+ </main>
891
+ <footer>종결: done ${view.terminal.done} · superseded ${view.terminal.superseded} · abandoned ${view.terminal.abandoned} — 갱신은 <code>llm-wiki board --html</code></footer>
892
+ </body>
893
+ </html>
894
+ `;
895
+ }
896
+
897
+ // review(대기) → todo 복귀 — 조건이 충족됐거나 사람이 판정을 내렸을 때.
898
+ // 조건부 카드(Activation/not_before)의 진행 스위치다. 대기에서 저절로 풀리는 건
899
+ // not_before(기계 판정)뿐이고, 관측형 조건은 이 명령으로 명시적으로 푼다.
900
+ function resume(options = {}) {
901
+ const { paths } = requireBoard();
902
+ const { positional, flags } = parseArgs(options.rest || []);
903
+ const title = positional[0];
904
+ if (!title) fail(USAGE.resume);
905
+ validateFlags(flags, RESUME_FLAGS);
906
+
907
+ return kanban.withLock(paths, kanban.BOARD_LOCK_NAME, () => {
908
+ const cardObj = kanban.findCard(paths, title);
909
+ if (!cardObj) fail(`카드를 못 찾았다: ${title}`);
910
+ if (cardObj.meta.status !== 'review') fail(`review 카드만 복귀시킨다 (현재: ${cardObj.meta.status}): ${cardObj.filePath}`);
911
+
912
+ const meta = { ...cardObj.meta, status: 'todo' };
913
+ const parsed = { ...cardObj.card, ac: cardObj.card.ac.map(a => ({ ...a })), sections: new Map(cardObj.card.sections) };
914
+ const line = `- ${nowIso()} — RESUMED: ${typeof flags.note === 'string' && flags.note ? flags.note : '대기 조건 충족 또는 사람 판정'}`;
915
+ const notes = parsed.sections.get('Notes') || '';
916
+ parsed.sections.set('Notes', notes ? `${notes}\n${line}` : line);
917
+
918
+ kanban.writeCard(cardObj, meta, parsed);
919
+ kanban.appendActivity(paths, { action: 'resumed', title: meta.title, detail: typeof flags.note === 'string' ? flags.note : undefined });
920
+ console.log(`Resumed to todo: ${cardObj.filePath}`);
921
+ });
922
+ }
923
+
924
+ // ── board report — 계기판 (개선계획 4-1) ─────────────────────────────────
925
+
926
+ // 계기판은 카드 수가 아니라 완료:폐기 비율이다 (plan.md 3.2 확정 — 폐기도 곡선을
927
+ // 떨어뜨린다. 30장이 1장이 됐을 때 다 끝나서인지 다 갖다버려서인지 카드 수만으로는
928
+ // 모른다). 아침의 사람이 보는 한 화면.
929
+ function boardReport(options = {}) {
930
+ const { docRoot, paths, config } = requireBoard();
931
+
932
+ const done = kanban.listCardsInDir(paths.doneDir);
933
+ const superseded = kanban.listCardsInDir(paths.supersededDir);
934
+ const abandoned = kanban.listCardsInDir(paths.abandonedDir);
935
+ const active = kanban.listCardsInDir(paths.cardsDir);
936
+ const activity = kanban.readActivity(paths);
937
+
938
+ const resolved = done.length + abandoned.length;
939
+ const doneShare = resolved > 0 ? Math.round((done.length / resolved) * 100) : null;
940
+
941
+ const expired = active.filter(c => c.meta.status === 'doing' && kanban.isClaimExpired(c, config));
942
+ const review = active.filter(c => c.meta.status === 'review');
943
+ const reverted = activity.filter(a => a.action === 'reverted');
944
+
945
+ // 카드 수 추이 — activity.jsonl 기반 일별 집계 (최근 14일).
946
+ const days = [];
947
+ for (let i = 13; i >= 0; i--) {
948
+ const d = new Date(); d.setDate(d.getDate() - i);
949
+ days.push(localToday(d));
950
+ }
951
+ const trend = days.map(day => {
952
+ const on = action => activity.filter(a => a.action === action && String(a.ts).startsWith(day)).length;
953
+ const created = on('created');
954
+ const closed = on('done') + on('superseded') + on('abandoned');
955
+ return { day, created, done: on('done'), superseded: on('superseded'), abandoned: on('abandoned'), reverted: on('reverted'), closed };
956
+ }).filter(t => t.created || t.closed || t.reverted);
957
+
958
+ const report = {
959
+ schemaVersion: 1,
960
+ kind: 'kanban-board-report',
961
+ generatedAt: new Date().toISOString(),
962
+ convergence: { done: done.length, abandoned: abandoned.length, superseded: superseded.length, doneSharePercent: doneShare },
963
+ active: { todo: active.filter(c => c.meta.status === 'todo').length, doing: active.filter(c => c.meta.status === 'doing').length, review: review.length },
964
+ waitingQueue: review.map(c => viewCard(c, config)).map(c => ({ title: c.title, question: c.question })),
965
+ expiredClaims: expired.map(c => c.meta.title),
966
+ revertCount: reverted.length,
967
+ recentReverts: reverted.slice(-5),
968
+ trend,
969
+ };
970
+
971
+ if (options.json) {
972
+ console.log(JSON.stringify(report, null, 2));
973
+ return;
974
+ }
975
+
976
+ const line = '────────────────────────────────────────';
977
+ console.log(`📊 Board report (${report.generatedAt.split('T')[0]})`);
978
+ console.log(line);
979
+
980
+ if (doneShare === null) {
981
+ console.log('완료:폐기 비율 — 아직 종결된 카드가 없다.');
982
+ } else {
983
+ const alarm = doneShare < 50 ? ' ⚠ 폐기가 완료보다 많다 — 같은 벽에 반복 부딪히는 중인지 raw/안티패턴을 확인하라.' : '';
984
+ console.log(`완료:폐기 비율 — done ${done.length} : abandoned ${abandoned.length} (superseded ${superseded.length}) → 완료 점유율 ${doneShare}%${alarm}`);
985
+ }
986
+
987
+ console.log(`활성: todo ${report.active.todo} · doing ${report.active.doing} (WIP ${config.wipLimits.doing}) · review ${report.active.review}`);
988
+ console.log(`되돌림(QA): ${report.revertCount}건 — 가짜 완료를 잡아낸 신호다.`);
989
+
990
+ if (report.expiredClaims.length) {
991
+ console.log(`만료 클레임 (${report.expiredClaims.length}) — 재집기 가능:`);
992
+ report.expiredClaims.forEach(t => console.log(` • ${t}`));
993
+ }
994
+
995
+ console.log(line);
996
+ console.log(`대기 큐 (사람 판정 대기 — ${report.waitingQueue.length})`);
997
+ if (!report.waitingQueue.length) console.log(' (비어 있음 — 밤샘 동안 판단 질문이 없었다는 뜻)');
998
+ for (const w of report.waitingQueue) {
999
+ console.log(` • ${w.title}`);
1000
+ if (w.question) console.log(` ↳ ${w.question}`);
1001
+ }
1002
+
1003
+ if (trend.length) {
1004
+ console.log(line);
1005
+ console.log('카드 수 추이 (activity.jsonl 기반, 변화 있던 날만):');
1006
+ for (const t of trend) {
1007
+ console.log(` ${t.day} created ${t.created} · done ${t.done} · superseded ${t.superseded} · abandoned ${t.abandoned} · reverted ${t.reverted}`);
1008
+ }
1009
+ }
1010
+ }
1011
+
1012
+ // ── 폐기→안티패턴 파이프라인 (개선계획 4-2) ──────────────────────────────
1013
+
1014
+ // 폐기 사유를 raw 로그에 discovery 케이스로 기록한다 — 주기 compile이 이걸
1015
+ // antipatterns/ 승격 후보로 삼는다. "폐기 사유가 지워지지 않고 위키로 흐르는 것"이
1016
+ // plan.md 3.3의 실행이다. 사유는 이 시스템에서 가장 값비싼 정보다.
1017
+ function appendAbandonRaw(docRoot, title, reason, cardPath) {
1018
+ const rawDir = path.join(docRoot, 'raw');
1019
+ if (!fs.existsSync(rawDir)) return null;
1020
+
1021
+ const today = localToday(); // raw 로그 파일명은 현지 날짜(header-date-over-mtime)
1022
+ const rawPath = path.join(rawDir, `${today}.md`);
1023
+ let caseNum = 1;
1024
+ let header = `# ${today}\n`;
1025
+ if (fs.existsSync(rawPath)) {
1026
+ const content = fs.readFileSync(rawPath, 'utf8');
1027
+ header = '';
1028
+ caseNum = (content.match(/^## Case \d+:/gm) || []).length + 1;
1029
+ }
1030
+ const entry = [
1031
+ '',
1032
+ `## Case ${caseNum}: [폐기] ${title}`,
1033
+ '',
1034
+ '### Grounding',
1035
+ `- Evidence: ${cardPath}`,
1036
+ '- Confidence: 4/5',
1037
+ '',
1038
+ '### Discovery',
1039
+ '이 길은 아니었다 — 카드가 폐기됐다. 폐기 사유(안티패턴 원재료):',
1040
+ '',
1041
+ String(reason),
1042
+ '',
1043
+ '### Analysis',
1044
+ '- Why non-obvious: 다음 세션이 같은 벽에 다시 부딪히지 않게 하는 기록이다.',
1045
+ '- Action taken: 주기 compile에서 안티패턴 승격 후보로 검토할 것.',
1046
+ '',
1047
+ '### Related Knowledge',
1048
+ `- **Anti-Patterns**: [[${kanban.slugify(title)}]]`,
1049
+ '',
1050
+ ].join('\n');
1051
+
1052
+ if (header) fs.writeFileSync(rawPath, header);
1053
+ fs.appendFileSync(rawPath, entry);
1054
+ return rawPath;
1055
+ }
1056
+
1057
+ // ── board video — 활동 로그 재생 타임랩스 (work-loop 종료 산출물) ─────────
1058
+ // activity.jsonl의 모든 이벤트는 타임스탬프가 있으므로 보드의 변화를 재현할 수
1059
+ // 있다. 타임라인 JSON을 남기고, video/의 Remotion 프로젝트가 있으면 함께 렌더한다.
1060
+ // 렌더는 headless Chrome 기반 CPU 작업이다 (GPU 불필요).
1061
+ function boardVideo(options = {}) {
1062
+ const { paths, config } = requireBoard();
1063
+ const activity = kanban.readActivity(paths);
1064
+ const cfg = loadConfig(findDocRoot());
1065
+ const repo = cfg.projectName || path.basename(process.cwd());
1066
+
1067
+ const timeline = {
1068
+ schemaVersion: 1,
1069
+ kind: 'board-timeline',
1070
+ repo,
1071
+ wip: config.wipLimits.doing !== undefined ? config.wipLimits.doing : null,
1072
+ generatedAt: new Date().toISOString(),
1073
+ events: activity.map(a => ({ ts: a.ts, action: a.action, title: a.title, actor: a.actor, detail: a.detail })),
1074
+ };
1075
+ const timelinePath = path.join(paths.kanbanDir, 'board-timeline.json');
1076
+ fs.writeFileSync(timelinePath, JSON.stringify(timeline, null, 2) + '\n');
1077
+ console.log(`Timeline written: ${timelinePath} (${timeline.events.length} events)`);
1078
+
1079
+ const videoProject = path.join(process.cwd(), 'video');
1080
+ if (!fs.existsSync(path.join(videoProject, 'package.json'))) {
1081
+ console.log('video/ Remotion 프로젝트가 없다 — 타임라인(board-timeline.json)만 남긴다.');
1082
+ console.log(' 렌더 프로젝트는 npm 패키지에 포함되지 않는다(llm-wiki 레포의 video/ 참고).');
1083
+ return;
1084
+ }
1085
+
1086
+ console.log('Rendering board timelapse (headless Chrome — 첫 실행은 Chrome 내려받기로 몇 분 걸린다)...');
1087
+ // kanbanDir 기준 절대경로로 렌더 — ../doc/ 하드코딩은 LLM_WIKI_ROOT 오버라이드와
1088
+ // 다른 doc 루트에서 엉뚱한 곳을 읽게 한다(2026-09-09 리뷰 5-2). 따옴표로 감싸
1089
+ // 경로의 공백도 안전하게.
1090
+ const mp4Path = path.join(paths.kanbanDir, 'board-timelapse.mp4');
1091
+ execSync(
1092
+ `npx remotion render src/index.ts BoardTimelapse "${mp4Path}" --props="${timelinePath}"`,
1093
+ { cwd: videoProject, stdio: 'inherit', timeout: 570000 }
1094
+ );
1095
+ console.log(`Video written: ${mp4Path}`);
1096
+ }
1097
+
1098
+ // ── 디스패치 ─────────────────────────────────────────────────────────────
1099
+
1100
+ function dispatchCard(rest) {
1101
+ const { paths } = requireBoard();
1102
+ const parsedArgs = parseArgs(rest);
1103
+ const sub = parsedArgs.positional.shift();
1104
+ if (sub === 'new') cardNew(paths, kanban.loadConfig(paths), parsedArgs);
1105
+ else if (sub === 'show') cardShow(paths, parsedArgs);
1106
+ else if (sub === 'edit') cardEdit(paths, parsedArgs);
1107
+ else fail(USAGE.card);
1108
+ }
1109
+
1110
+ module.exports = {
1111
+ // wait(kanban-wait)가 같은 플래그 규칙으로 재사용한다 — 계층 간 단일 파서.
1112
+ parseArgs,
1113
+ validateFlags,
1114
+ fail,
1115
+ // bin의 --help 가드가 fail()과 같은 사용법 문자열을 쓴다(한 곳 관리).
1116
+ USAGE,
1117
+ dispatchCard,
1118
+ pick,
1119
+ handoff,
1120
+ doneCard,
1121
+ supersede,
1122
+ abandon,
1123
+ reopen,
1124
+ resume,
1125
+ boardView,
1126
+ boardReport,
1127
+ boardVideo,
1128
+ scaffold: kanban.scaffold,
1129
+ };