@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/LICENSE +21 -0
- package/README.md +168 -0
- package/TROUBLESHOOTING.md +103 -0
- package/bin/llm-wiki.js +102 -0
- package/lib/find-doc-root.js +93 -0
- package/lib/find-qmd.js +52 -0
- package/lib/init.js +236 -0
- package/lib/kanban-cmd.js +828 -0
- package/lib/kanban.js +363 -0
- package/lib/wiki-compile.js +362 -0
- package/lib/wiki-lint.js +272 -0
- package/lib/wiki-search.js +186 -0
- package/package.json +40 -0
- package/skills/kanban-plan/SKILL.md +71 -0
- package/skills/wiki-compile/SKILL.md +115 -0
- package/skills/wiki-lint/SKILL.md +57 -0
- package/skills/wiki-log/SKILL.md +157 -0
- package/skills/wiki-search/SKILL.md +39 -0
- package/skills/work-loop/SKILL.md +88 -0
- package/templates/doc/raw/.gitkeep +2 -0
- package/templates/doc/wiki/index.md +36 -0
- package/templates/githooks/pre-commit +77 -0
- package/templates/scripts/sync_agent_docs.bat +33 -0
- package/templates/scripts/sync_agent_docs.sh +25 -0
- package/templates/scripts/sync_skills.bat +26 -0
- package/templates/scripts/sync_skills.sh +40 -0
|
@@ -0,0 +1,828 @@
|
|
|
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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
|
+
};
|