@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,362 @@
|
|
|
1
|
+
// wiki-compile — raw 로그에서 지식 추출/합성/비활성화 + index.md 재구축 + qmd 동기화.
|
|
2
|
+
// 원본: TTSTextViewer/.agents/skills/wiki-compile/scripts/compile.js (2026-07-24 loose 파일 분류 + CRLF fix 포함,
|
|
3
|
+
// 2026-08-21 list 판정 mtime→헤더 날짜 fix 역동기화)
|
|
4
|
+
// 변경점:
|
|
5
|
+
// - __dirname '../../../../' → findDocRoot()
|
|
6
|
+
// - 하드코딩 qmdCli 경로 → findQmd()
|
|
7
|
+
// - "TTSTextViewer 프로젝트의..." 문자열 → config.projectName (null이면 제네릭 문구)
|
|
8
|
+
// - COLLECTION/ COLLECTION_RAW → config.collections
|
|
9
|
+
// - 2026-08-29 (개선계획 1-6): compile list/index가 --json 봉투를 지원.
|
|
10
|
+
// {schemaVersion: 1, kind: 'compile-list'|'compile-index', ...}
|
|
11
|
+
// - 2026-08-29 (사각지대 수정): 같은 날 append가 미컴파일로 감지되지 않던 결함을
|
|
12
|
+
// 컴파일 상태 해시로 고친다. subagent 설계 리뷰(채택+수정 3건) 반영:
|
|
13
|
+
// ① list는 읽기전용 — 상태의 유일한 쓰기점은 compile index(전체 재생성, seed 특례 없음)
|
|
14
|
+
// ② lastCompiled 단조 가드(max) — 시계 오차 머신이 미래로 점프시키지 않게
|
|
15
|
+
// ③ 키 정렬 + LF 직렬화 — 머신 간 자동 머지 극대화. 충돌 해상도는 자명:
|
|
16
|
+
// 아무 쪽이나 남기고 `compile index` 1회면 현재 raw로부터 결정적으로 재생성된다.
|
|
17
|
+
// 날짜 절(date > lastCompiled)의 역할은 1차 탐지가 아니라 상태 유실 시의 안전망이다.
|
|
18
|
+
const crypto = require('crypto');
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
const { execSync } = require('child_process');
|
|
22
|
+
const { findDocRoot, loadConfig } = require('./find-doc-root');
|
|
23
|
+
const { findQmd } = require('./find-qmd');
|
|
24
|
+
|
|
25
|
+
const STATE_VERSION = 1;
|
|
26
|
+
|
|
27
|
+
// 정규화+해시는 읽기(list)와 쓰기(index)가 반드시 이 헬퍼 하나만 쓴다 —
|
|
28
|
+
// 구현 간 불일치로 인한 영구 오탐/누락을 원천 차단한다.
|
|
29
|
+
function contentHash(content) {
|
|
30
|
+
return crypto.createHash('sha256').update(content.replace(/\r\n/g, '\n')).digest('hex');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function loadCompileState(wikiRoot) {
|
|
34
|
+
const p = path.join(wikiRoot, 'compile-state.json');
|
|
35
|
+
if (!fs.existsSync(p)) return null;
|
|
36
|
+
try {
|
|
37
|
+
const state = JSON.parse(fs.readFileSync(p, 'utf8').replace(/\r/g, ''));
|
|
38
|
+
if (!state || state.schemaVersion !== STATE_VERSION) return null;
|
|
39
|
+
return state;
|
|
40
|
+
} catch {
|
|
41
|
+
return null; // 깨진 상태는 없음 취급 — 날짜 규칙(안전망)으로 폴백
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 상태 재생성 — compile index 말미에서만 호출된다 (컴파일 완료 선언의 기록).
|
|
46
|
+
function writeCompileState(wikiRoot, rawRoot, lastCompiled, previous) {
|
|
47
|
+
const files = {};
|
|
48
|
+
for (const f of fs.readdirSync(rawRoot).filter(f => f.endsWith('.md')).sort()) {
|
|
49
|
+
files[f] = contentHash(fs.readFileSync(path.join(rawRoot, f), 'utf8'));
|
|
50
|
+
}
|
|
51
|
+
const state = { schemaVersion: STATE_VERSION, lastCompiled, files };
|
|
52
|
+
fs.writeFileSync(path.join(wikiRoot, 'compile-state.json'), JSON.stringify(state, null, 2) + '\n');
|
|
53
|
+
if (!previous) {
|
|
54
|
+
console.log(`컴파일 상태를 생성했다 — 기존 ${Object.keys(files).length}개 파일을 컴파일된 것으로 간주 (lint가 의미 기반 백스톱).`);
|
|
55
|
+
}
|
|
56
|
+
return state;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// "새 로그" 데이터 수집 — 판정: 날짜(안전망) OR 해시 불일치(정밀).
|
|
60
|
+
// 날짜 판정은 파일 mtime이 아니라 로그 헤더 날짜(# YYYY-MM-DD)로 한다 —
|
|
61
|
+
// git checkout이 mtime을 머신마다 갈리는 문제(fd01a03) 재발 방지.
|
|
62
|
+
function collectNewLogs(docRoot) {
|
|
63
|
+
const wikiRoot = path.join(docRoot, 'wiki');
|
|
64
|
+
const rawRoot = path.join(docRoot, 'raw');
|
|
65
|
+
const state = loadCompileState(wikiRoot);
|
|
66
|
+
const stateMissing = state === null;
|
|
67
|
+
|
|
68
|
+
let lastUpdated = '0000-00-00';
|
|
69
|
+
if (state) {
|
|
70
|
+
lastUpdated = state.lastCompiled;
|
|
71
|
+
} else {
|
|
72
|
+
const indexPath = path.join(wikiRoot, 'index.md');
|
|
73
|
+
if (fs.existsSync(indexPath)) {
|
|
74
|
+
const match = fs.readFileSync(indexPath, 'utf8').match(/Last updated: (\d{4}-\d{2}-\d{2})/);
|
|
75
|
+
if (match) lastUpdated = match[1];
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const newLogs = [];
|
|
80
|
+
if (fs.existsSync(rawRoot)) {
|
|
81
|
+
for (const f of fs.readdirSync(rawRoot).filter(f => f.endsWith('.md')).sort()) {
|
|
82
|
+
const filePath = path.join(rawRoot, f);
|
|
83
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
84
|
+
const header = content.match(/^#\s+(\d{4}-\d{2}-\d{2})/m);
|
|
85
|
+
const date = header ? header[1] : fs.statSync(filePath).mtime.toISOString().split('T')[0];
|
|
86
|
+
|
|
87
|
+
let reason = null;
|
|
88
|
+
if (date > lastUpdated) {
|
|
89
|
+
reason = 'new-date';
|
|
90
|
+
} else if (!stateMissing) {
|
|
91
|
+
const stored = state.files[f];
|
|
92
|
+
if (stored === undefined) reason = 'no-entry';
|
|
93
|
+
else if (stored !== contentHash(content)) reason = 'modified';
|
|
94
|
+
}
|
|
95
|
+
if (reason) newLogs.push({ name: f, date, reason });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
newLogs.sort((a, b) => a.date.localeCompare(b.date) || a.name.localeCompare(b.name));
|
|
100
|
+
return { schemaVersion: 1, kind: 'compile-list', lastCompiled: lastUpdated, state: stateMissing ? 'missing' : 'ok', newLogs };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function renderList(data) {
|
|
104
|
+
console.log(`Last compiled date: ${data.lastCompiled}`);
|
|
105
|
+
if (data.state === 'missing') {
|
|
106
|
+
console.log('상태 파일 없음 — 날짜 규칙으로 판정한다 (compile index가 생성).');
|
|
107
|
+
}
|
|
108
|
+
console.log('Scanning for new raw logs...');
|
|
109
|
+
|
|
110
|
+
if (data.newLogs.length === 0) {
|
|
111
|
+
console.log('No new logs found.');
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
console.log('\nNew/Modified logs to process:');
|
|
116
|
+
data.newLogs.forEach(f => console.log(`- ${f.name} (${f.date})${f.reason !== 'new-date' ? ` [${f.reason}]` : ''}`));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function rebuildIndex() {
|
|
120
|
+
const docRoot = findDocRoot();
|
|
121
|
+
const wikiRoot = path.join(docRoot, 'wiki');
|
|
122
|
+
const rawRoot = path.join(docRoot, 'raw');
|
|
123
|
+
const indexPath = path.join(wikiRoot, 'index.md');
|
|
124
|
+
const config = loadConfig(docRoot);
|
|
125
|
+
|
|
126
|
+
if (!fs.existsSync(wikiRoot)) {
|
|
127
|
+
console.error(`Wiki directory not found: ${wikiRoot}`);
|
|
128
|
+
process.exit(1);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
console.log('Rebuilding Wiki Index...');
|
|
132
|
+
|
|
133
|
+
const conceptsDir = path.join(wikiRoot, 'concepts');
|
|
134
|
+
const patternsDir = path.join(wikiRoot, 'patterns');
|
|
135
|
+
const antipatternsDir = path.join(wikiRoot, 'antipatterns');
|
|
136
|
+
const answersDir = path.join(wikiRoot, 'answers');
|
|
137
|
+
|
|
138
|
+
// 서브디렉토리 분류는 디렉토리 자체가 분류 판정 (이미 물리적으로 정리됨).
|
|
139
|
+
let concepts = scanDirectory(conceptsDir);
|
|
140
|
+
let patterns = scanDirectory(patternsDir);
|
|
141
|
+
let antipatterns = scanDirectory(antipatternsDir);
|
|
142
|
+
// answers/ — 검색해서 종합한 유용한 답변의 아카이브 (개선계획 1-4).
|
|
143
|
+
let answers = scanDirectory(answersDir);
|
|
144
|
+
|
|
145
|
+
// loose 파일(루트 *.md)은 tags 기반으로 분류 — 파일 이동 없이 인덱스/검색에 반영.
|
|
146
|
+
// pattern 태그 → Patterns, anti-pattern 태그 → Anti-Patterns, 그 외 → Concepts.
|
|
147
|
+
// index.md, log.md는 메타 파일이므로 제외.
|
|
148
|
+
const looseFiles = fs.existsSync(wikiRoot)
|
|
149
|
+
? fs.readdirSync(wikiRoot)
|
|
150
|
+
.filter(f => f.endsWith('.md') && f !== 'index.md' && f !== 'log.md')
|
|
151
|
+
: [];
|
|
152
|
+
const looseEntries = looseFiles.map(f => scanEntry(path.join(wikiRoot, f)));
|
|
153
|
+
looseEntries.forEach(e => {
|
|
154
|
+
const tagList = e.tags || [];
|
|
155
|
+
if (tagList.includes('anti-pattern') || tagList.includes('antipattern')) {
|
|
156
|
+
antipatterns.push(e);
|
|
157
|
+
} else if (tagList.includes('pattern')) {
|
|
158
|
+
patterns.push(e);
|
|
159
|
+
} else {
|
|
160
|
+
concepts.push(e);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// 알파벳 정렬로 인덱스 안정성 확보 (재실행마다 동일 순서).
|
|
165
|
+
const sortById = (a, b) => a.id.localeCompare(b.id);
|
|
166
|
+
concepts.sort(sortById);
|
|
167
|
+
patterns.sort(sortById);
|
|
168
|
+
antipatterns.sort(sortById);
|
|
169
|
+
answers.sort(sortById);
|
|
170
|
+
|
|
171
|
+
// index.md 헤더 — projectName이 설정되면 포함, 없으면 제네릭 문구.
|
|
172
|
+
const headerLine = config.projectName
|
|
173
|
+
? `${config.projectName} 프로젝트의 구조화된 지식 베이스입니다. \`doc/raw/\` 로그에서 추출한 핵심 개념과 패턴을 정리했습니다.`
|
|
174
|
+
: `이 프로젝트의 구조화된 지식 베이스입니다. \`doc/raw/\` 로그에서 추출한 핵심 개념과 패턴을 정리했습니다.`;
|
|
175
|
+
|
|
176
|
+
let indexContent = `---\ntags: [index]\n\n# Wiki Index\n\n${headerLine}\n`;
|
|
177
|
+
|
|
178
|
+
indexContent = addSection(indexContent, 'Concepts', '개념', concepts);
|
|
179
|
+
indexContent = addSection(indexContent, 'Patterns', '패턴', patterns);
|
|
180
|
+
indexContent = addSection(indexContent, 'Anti-Patterns', '안티패턴', antipatterns);
|
|
181
|
+
indexContent = addSection(indexContent, 'Answers', '답변', answers);
|
|
182
|
+
|
|
183
|
+
indexContent += `\n---\n\n## Statistics\n\n`;
|
|
184
|
+
indexContent += `- Total concepts: ${concepts.length}\n`;
|
|
185
|
+
indexContent += `- Total patterns: ${patterns.length}\n`;
|
|
186
|
+
indexContent += `- Total anti-patterns: ${antipatterns.length}\n`;
|
|
187
|
+
indexContent += `- Total answers: ${answers.length}\n`;
|
|
188
|
+
|
|
189
|
+
// 컴파일 상태 — 여기가 유일한 쓰기점. lastCompiled 단조 가드(max)로 시계 오차
|
|
190
|
+
// 머신이 날짜를 미래로 점프시키지 않게 하고, index.md의 Last updated와 같은 값을 쓴다.
|
|
191
|
+
const previous = loadCompileState(wikiRoot);
|
|
192
|
+
const today = new Date().toISOString().split('T')[0];
|
|
193
|
+
const lastCompiled = previous && previous.lastCompiled > today ? previous.lastCompiled : today;
|
|
194
|
+
indexContent += `- Last updated: ${lastCompiled}\n`;
|
|
195
|
+
|
|
196
|
+
fs.writeFileSync(indexPath, indexContent);
|
|
197
|
+
console.log(`Index successfully rebuilt at ${indexPath}`);
|
|
198
|
+
writeCompileState(wikiRoot, rawRoot, lastCompiled, previous);
|
|
199
|
+
|
|
200
|
+
return { wikiRoot, rawRoot, config };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// 인덱스 섹션 표 하나. 별칭 열은 검색 어휘 그물 — frontmatter aliases가 여기 실려
|
|
204
|
+
// index.md 전체가 grep/QMD 검색 대상이 된다 (개선계획 1-2).
|
|
205
|
+
function addSection(indexContent, title, headerLabel, entries) {
|
|
206
|
+
indexContent += `\n---\n\n## ${title}\n\n| ${headerLabel} | 설명 | 별칭 |\n|------|------|------|\n`;
|
|
207
|
+
entries.forEach(e => {
|
|
208
|
+
indexContent += `| [[${e.id}]] | ${e.description} | ${(e.aliases || []).join(', ')} |\n`;
|
|
209
|
+
});
|
|
210
|
+
return indexContent;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// 첫 문장 추출 — inline code(백틱) 안의 마침표는 무시.
|
|
214
|
+
// 끝 마침표 = 마침표 뒤에 공백+문자(다음 문장 시작)가 오거나, 마침표가 줄 끝.
|
|
215
|
+
function firstSentence(line) {
|
|
216
|
+
let result = '';
|
|
217
|
+
let inBacktick = false;
|
|
218
|
+
for (let i = 0; i < line.length; i++) {
|
|
219
|
+
const ch = line[i];
|
|
220
|
+
if (ch === '`') { inBacktick = !inBacktick; result += ch; continue; }
|
|
221
|
+
result += ch;
|
|
222
|
+
if (ch === '.' && !inBacktick) {
|
|
223
|
+
const rest = line.slice(i + 1);
|
|
224
|
+
if (/^\s+[A-Z가-힣]/.test(rest) || i === line.length - 1) {
|
|
225
|
+
return result.trim();
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return result.trim();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function scanDirectory(dir) {
|
|
233
|
+
if (!fs.existsSync(dir)) return [];
|
|
234
|
+
return fs.readdirSync(dir)
|
|
235
|
+
.filter(f => f.endsWith('.md'))
|
|
236
|
+
.map(f => scanEntry(path.join(dir, f)));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// 단일 파일에서 {id, description, tags, aliases} 추출.
|
|
240
|
+
// description은 YAML description → blockquote 요약 → 첫 문장 순서로.
|
|
241
|
+
// tags는 loose 파일 분류를 위해, aliases는 인덱스 별칭 열을 위해 frontmatter에서 파싱.
|
|
242
|
+
function scanEntry(filePath) {
|
|
243
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
244
|
+
const id = path.basename(filePath, '.md');
|
|
245
|
+
let description = 'No description available.';
|
|
246
|
+
let tags = [];
|
|
247
|
+
let aliases = [];
|
|
248
|
+
|
|
249
|
+
// YAML frontmatter 추출 (description + tags 모두 이 블록에서).
|
|
250
|
+
// CRLF 대응: \r?\n 으로 줄바꿈 매칭 (윈도우 체크아웃 시 파일이 CRLF).
|
|
251
|
+
const yamlMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/m);
|
|
252
|
+
if (yamlMatch) {
|
|
253
|
+
const yaml = yamlMatch[1];
|
|
254
|
+
|
|
255
|
+
// description (명시적이면 가장 정확)
|
|
256
|
+
const descMatch = yaml.match(/^description:\s*(.+?)\r?$/m);
|
|
257
|
+
if (descMatch) {
|
|
258
|
+
description = descMatch[1].trim().replace(/^["']|["']$/g, '');
|
|
259
|
+
} else {
|
|
260
|
+
// description이 없으면 title 다음 첫 paragraph/blockquote에서 첫 문장.
|
|
261
|
+
const lines = content.split('\n');
|
|
262
|
+
const titleIndex = lines.findIndex(l => l.startsWith('# '));
|
|
263
|
+
if (titleIndex !== -1) {
|
|
264
|
+
for (let i = titleIndex + 1; i < lines.length; i++) {
|
|
265
|
+
const line = lines[i].trim();
|
|
266
|
+
if (line.startsWith('> ')) {
|
|
267
|
+
description = firstSentence(line.slice(2).trim());
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
if (line && !line.startsWith('#') && !line.startsWith('---')) {
|
|
271
|
+
description = firstSentence(line);
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// tags 파싱 — `[a, b, c]` 형태를 배열로.
|
|
279
|
+
const tagsMatch = yaml.match(/^tags:\s*\[(.*)\]\r?$/m);
|
|
280
|
+
if (tagsMatch) {
|
|
281
|
+
tags = tagsMatch[1].split(',').map(t => t.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// aliases 파싱 — 같은 개념의 다른 이름들. 인덱스 표에 실려 grep/QMD 검색 어휘가 된다
|
|
285
|
+
// (쓸 때와 찾을 때 어휘가 달라 못 찾는 문제의 두 번째 그물, 개선계획 1-2).
|
|
286
|
+
const aliasesMatch = yaml.match(/^aliases:\s*\[(.*)\]\r?$/m);
|
|
287
|
+
if (aliasesMatch) {
|
|
288
|
+
aliases = aliasesMatch[1].split(',').map(t => t.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return { id, description, tags, aliases };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function syncQmd(ctx) {
|
|
296
|
+
const { wikiRoot, rawRoot, config } = ctx;
|
|
297
|
+
const qmdCli = findQmd();
|
|
298
|
+
|
|
299
|
+
if (!qmdCli) {
|
|
300
|
+
console.log('QMD not found, skipping search index sync.');
|
|
301
|
+
console.log('(Optional) Install qmd for semantic search: npm i @tobilu/qmd');
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
try {
|
|
306
|
+
const listOutput = execSync(`node "${qmdCli}" collection list`, { encoding: 'utf8' });
|
|
307
|
+
|
|
308
|
+
// wiki 콜렉션: 컴파일된 위키. raw 콜렉션: 일일 raw 로그.
|
|
309
|
+
// search.js는 두 콜렉션을 모두 조회하므로, 둘 다 프로비저닝되어 있어야
|
|
310
|
+
// semantic search가 wiki + raw 양쪽을 커버함.
|
|
311
|
+
const collections = [
|
|
312
|
+
{ name: config.collections.wiki, path: wikiRoot },
|
|
313
|
+
{ name: config.collections.raw, path: rawRoot },
|
|
314
|
+
];
|
|
315
|
+
|
|
316
|
+
for (const { name, path: collPath } of collections) {
|
|
317
|
+
if (!listOutput.includes(name)) {
|
|
318
|
+
console.log(`Creating QMD collection '${name}'...`);
|
|
319
|
+
execSync(`node "${qmdCli}" collection add "${collPath}" --name ${name}`, { encoding: 'utf8', stdio: 'inherit' });
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
console.log('Updating QMD index...');
|
|
324
|
+
execSync(`node "${qmdCli}" update`, { encoding: 'utf8', stdio: 'inherit' });
|
|
325
|
+
|
|
326
|
+
console.log('Refreshing QMD embeddings...');
|
|
327
|
+
execSync(`node "${qmdCli}" embed`, { encoding: 'utf8', stdio: 'inherit' });
|
|
328
|
+
|
|
329
|
+
console.log('✓ QMD search index synced.');
|
|
330
|
+
return true;
|
|
331
|
+
} catch (error) {
|
|
332
|
+
console.error('QMD sync failed:', error.message);
|
|
333
|
+
console.error('Wiki index rebuilt successfully, but search index may be stale.');
|
|
334
|
+
return false;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function compile(command, options = {}) {
|
|
339
|
+
if (command === 'list') {
|
|
340
|
+
const data = collectNewLogs(findDocRoot());
|
|
341
|
+
// json 모드에서는 텍스트 렌더를 하지 않는다 — 봉투만이 계약이다.
|
|
342
|
+
if (options.json) console.log(JSON.stringify(data, null, 2));
|
|
343
|
+
else renderList(data);
|
|
344
|
+
} else if (command === 'index') {
|
|
345
|
+
const ctx = rebuildIndex();
|
|
346
|
+
const qmdSynced = syncQmd(ctx);
|
|
347
|
+
if (options.json) {
|
|
348
|
+
console.log(JSON.stringify({
|
|
349
|
+
schemaVersion: 1,
|
|
350
|
+
kind: 'compile-index',
|
|
351
|
+
indexPath: path.join(ctx.wikiRoot, 'index.md'),
|
|
352
|
+
qmdSynced,
|
|
353
|
+
}, null, 2));
|
|
354
|
+
}
|
|
355
|
+
} else {
|
|
356
|
+
console.log('Usage: llm-wiki compile <list|index>');
|
|
357
|
+
console.log(' list - show raw logs modified since last compile');
|
|
358
|
+
console.log(' index - rebuild wiki index.md and sync QMD search index');
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
module.exports = { compile };
|
package/lib/wiki-lint.js
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// wiki-lint — 위키 무결성 검사 (broken links, missing metadata, staleness, status 집계,
|
|
2
|
+
// 근거 역매칭, 미컴파일 개념).
|
|
3
|
+
// 원본: TTSTextViewer/.agents/skills/wiki-lint/scripts/lint.js
|
|
4
|
+
// 변경점:
|
|
5
|
+
// - __dirname '../../../../' → findDocRoot(). 나머지는 이미 범용.
|
|
6
|
+
// - 2026-08-29 (개선계획 1-1): 근거 역매칭 검증 — karpathy-llm-wiki check_evidence.py 방식.
|
|
7
|
+
// 위키 페이지의 `hash:xxx`(7~40 hex)와 `### Error` 인용 줄을 doc/raw/ 전체에서
|
|
8
|
+
// 문자 그대로 찾지 못하면 위반으로 보고한다. 구조화 필드라 file:line 추적 없이 가능.
|
|
9
|
+
// grounding 발행 비용을 늘리지 않는다(그들의 file:line 포기 교훈 존중).
|
|
10
|
+
// - 2026-08-29 (개선계획 1-3): raw 로그의 [[링크]] 빈도를 세어 N회 이상 언급됐는데
|
|
11
|
+
// 위키 페이지가 없는 개념을 "Uncompiled knowledge"로 보고한다.
|
|
12
|
+
// - 2026-08-29 (개선계획 1-6): 수집과 렌더를 분리. --json이면
|
|
13
|
+
// {schemaVersion: 1, kind: 'lint-report', ...} 봉투로 출력.
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
const { findDocRoot } = require('./find-doc-root');
|
|
17
|
+
|
|
18
|
+
const VALID_STATUSES = new Set(['active', 'deprecated', 'draft', 'superseded', 'resolved']);
|
|
19
|
+
const SIX_MONTHS_MS = 180 * 24 * 60 * 60 * 1000;
|
|
20
|
+
// 이 횟수 이상 raw에서 언급됐는데 위키 페이지가 없으면 컴파일 대기 지식으로 본다.
|
|
21
|
+
const UNCOMPILED_THRESHOLD = 2;
|
|
22
|
+
// ### Error 인용 줄 중 이 길이 미만은 노이즈(헤더, 짧은 토큰)라 비교에서 제외.
|
|
23
|
+
const MIN_QUOTE_LENGTH = 8;
|
|
24
|
+
|
|
25
|
+
function lint(options = {}) {
|
|
26
|
+
const docRoot = findDocRoot();
|
|
27
|
+
const wikiRoot = path.join(docRoot, 'wiki');
|
|
28
|
+
const rawRoot = path.join(docRoot, 'raw');
|
|
29
|
+
|
|
30
|
+
if (!fs.existsSync(wikiRoot)) {
|
|
31
|
+
console.error(`Wiki directory not found: ${wikiRoot}`);
|
|
32
|
+
console.error('Run `llm-wiki init` first to scaffold doc/wiki/.');
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const report = collectLintReport(wikiRoot, rawRoot);
|
|
37
|
+
|
|
38
|
+
if (options.json) {
|
|
39
|
+
console.log(JSON.stringify(report, null, 2));
|
|
40
|
+
return report;
|
|
41
|
+
}
|
|
42
|
+
renderLintReport(report);
|
|
43
|
+
return report;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function collectLintReport(wikiRoot, rawRoot) {
|
|
47
|
+
const report = {
|
|
48
|
+
schemaVersion: 1,
|
|
49
|
+
kind: 'lint-report',
|
|
50
|
+
generatedAt: new Date().toISOString(),
|
|
51
|
+
brokenLinks: [],
|
|
52
|
+
missingMetadata: [],
|
|
53
|
+
staleness: [],
|
|
54
|
+
evidenceViolations: [],
|
|
55
|
+
uncompiledKnowledge: [],
|
|
56
|
+
stats: { active: 0, deprecated: 0, draft: 0, superseded: 0, resolved: 0, unknown: 0, concepts: 0, patterns: 0, antipatterns: 0 }
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// raw 로그 전체를 하나의 문자열로 — 근거 역매칭의 비교 대상 코퍼스.
|
|
60
|
+
const rawFiles = fs.existsSync(rawRoot) ? getAllFiles(rawRoot).filter(f => f.endsWith('.md')) : [];
|
|
61
|
+
const rawCorpus = rawFiles.map(f => fs.readFileSync(f, 'utf8')).join('\n');
|
|
62
|
+
|
|
63
|
+
// 1. Scan Wiki
|
|
64
|
+
const allFiles = getAllFiles(wikiRoot).filter(f => f.endsWith('.md') && !f.endsWith('index.md'));
|
|
65
|
+
const fileMap = new Set(allFiles.map(f => normalizeLinkKey(path.basename(f, '.md'))));
|
|
66
|
+
|
|
67
|
+
allFiles.forEach(file => {
|
|
68
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
69
|
+
const relPath = path.relative(wikiRoot, file);
|
|
70
|
+
const isConcept = relPath.includes('concepts');
|
|
71
|
+
// antipatterns가 'patterns' 부분문자열에 걸리지 않도록 먼저 판정한다.
|
|
72
|
+
const isPattern = !relPath.includes('antipatterns') && relPath.includes('patterns');
|
|
73
|
+
const isAntipattern = relPath.includes('antipatterns');
|
|
74
|
+
|
|
75
|
+
if (isConcept) report.stats.concepts++;
|
|
76
|
+
if (isPattern) report.stats.patterns++;
|
|
77
|
+
if (isAntipattern) report.stats.antipatterns++;
|
|
78
|
+
|
|
79
|
+
// Check Metadata
|
|
80
|
+
const yamlMatch = content.match(/^---\r?\n([\s\S]+?)\r?\n---/);
|
|
81
|
+
if (yamlMatch) {
|
|
82
|
+
const header = yamlMatch[1];
|
|
83
|
+
const statusMatch = header.match(/status:\s*(\w+)/);
|
|
84
|
+
const createdMatch = header.match(/created:\s*(\d{4}-\d{2}-\d{2})/);
|
|
85
|
+
|
|
86
|
+
if (statusMatch) {
|
|
87
|
+
const status = statusMatch[1];
|
|
88
|
+
if (VALID_STATUSES.has(status)) {
|
|
89
|
+
report.stats[status]++;
|
|
90
|
+
} else {
|
|
91
|
+
report.stats.unknown++;
|
|
92
|
+
report.missingMetadata.push(`${relPath} (unknown status: ${status})`);
|
|
93
|
+
}
|
|
94
|
+
} else {
|
|
95
|
+
report.missingMetadata.push(`${relPath} (missing status)`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Staleness (Time-based)
|
|
99
|
+
if (createdMatch) {
|
|
100
|
+
const createdDate = new Date(createdMatch[1]);
|
|
101
|
+
if (new Date() - createdDate > SIX_MONTHS_MS) {
|
|
102
|
+
report.staleness.push(`${relPath} (Created: ${createdMatch[1]}, > 180 days old)`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
} else {
|
|
107
|
+
report.missingMetadata.push(`${relPath} (missing YAML header)`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Check Links — handle Obsidian syntax: [[target]], [[target|display]], [[path/target#anchor|display]]
|
|
111
|
+
// 링크-파일명 대응은 정규화 키로 비교한다 — 에이전트가 [[Title Case]]와
|
|
112
|
+
// kebab-case 파일명을 섞어 쓰면 오탐이 나기 때문 (2026-08-29 실측).
|
|
113
|
+
const links = content.match(/\[\[(.+?)\]\]/g);
|
|
114
|
+
if (links) {
|
|
115
|
+
links.forEach(link => {
|
|
116
|
+
const raw = link.slice(2, -2);
|
|
117
|
+
// Strip display text after pipe: [[target|display]] → target
|
|
118
|
+
let target = raw.split('|')[0];
|
|
119
|
+
// Strip anchor after #: [[target#section]] → target
|
|
120
|
+
target = target.split('#')[0];
|
|
121
|
+
// Strip path prefix: [[doc/learn/xxx]] → basename
|
|
122
|
+
target = path.basename(target);
|
|
123
|
+
|
|
124
|
+
if (target && !fileMap.has(normalizeLinkKey(target)) && normalizeLinkKey(target) !== 'index') {
|
|
125
|
+
report.brokenLinks.push(`${relPath} -> [[${raw}]]`);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 근거 역매칭 (개선계획 1-1) — raw 코퍼스가 있을 때만 검사.
|
|
131
|
+
if (rawCorpus) {
|
|
132
|
+
checkEvidence(content, relPath, rawCorpus, report);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// 미컴파일 개념 (개선계획 1-3) — raw [[링크]] 빈도 vs 위키 페이지 존재.
|
|
137
|
+
report.uncompiledKnowledge = findUncompiledKnowledge(rawFiles, fileMap);
|
|
138
|
+
|
|
139
|
+
return report;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function renderLintReport(report) {
|
|
143
|
+
console.log(`🩺 Wiki Health Report (${report.generatedAt.split('T')[0]})`);
|
|
144
|
+
console.log('===========================================');
|
|
145
|
+
console.log(`📊 Stats: Concepts=${report.stats.concepts}, Patterns=${report.stats.patterns}` +
|
|
146
|
+
(report.stats.antipatterns > 0 ? `, Anti-Patterns=${report.stats.antipatterns}` : ''));
|
|
147
|
+
console.log(`📊 Status: Active=${report.stats.active}, Deprecated=${report.stats.deprecated}, Draft=${report.stats.draft}` +
|
|
148
|
+
(report.stats.superseded > 0 ? `, Superseded=${report.stats.superseded}` : '') +
|
|
149
|
+
(report.stats.resolved > 0 ? `, Resolved=${report.stats.resolved}` : '') +
|
|
150
|
+
(report.stats.unknown > 0 ? `, Unknown=${report.stats.unknown}` : ''));
|
|
151
|
+
console.log('===========================================');
|
|
152
|
+
|
|
153
|
+
if (report.brokenLinks.length > 0) {
|
|
154
|
+
console.log(`\n⚠️ Broken Links (${report.brokenLinks.length}):`);
|
|
155
|
+
report.brokenLinks.forEach(l => console.log(` - ${l}`));
|
|
156
|
+
} else {
|
|
157
|
+
console.log('\n✅ No broken links found.');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (report.evidenceViolations.length > 0) {
|
|
161
|
+
console.log(`\n⚠️ Evidence Violations (${report.evidenceViolations.length}) — not found verbatim in doc/raw/:`);
|
|
162
|
+
report.evidenceViolations.forEach(v => console.log(` - ${v}`));
|
|
163
|
+
} else {
|
|
164
|
+
console.log('\n✅ No evidence violations (all hash refs and Error quotes found in raw logs).');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (report.uncompiledKnowledge.length > 0) {
|
|
168
|
+
console.log(`\n📝 Uncompiled Knowledge (${report.uncompiledKnowledge.length}) — mentioned ≥${UNCOMPILED_THRESHOLD}x in raw, no wiki page:`);
|
|
169
|
+
report.uncompiledKnowledge.forEach(u => console.log(` - [[${u.name}]] (${u.count} mentions)`));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (report.missingMetadata.length > 0) {
|
|
173
|
+
console.log(`\n⚠️ Missing/Invalid Metadata (${report.missingMetadata.length}):`);
|
|
174
|
+
report.missingMetadata.forEach(m => console.log(` - ${m}`));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (report.staleness.length > 0) {
|
|
178
|
+
console.log(`\n⏰ Stale Pages (> 180 days old, ${report.staleness.length}):`);
|
|
179
|
+
report.staleness.forEach(s => console.log(` - ${s}`));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 위키 페이지 한 장의 근거(해시 참조 + ### Error 인용)가 raw 코퍼스에
|
|
184
|
+
// 문자 그대로 존재하는지 검사해 위반을 report에 쌓는다.
|
|
185
|
+
function checkEvidence(content, relPath, rawCorpus, report) {
|
|
186
|
+
// (a) git hash 근거: `hash:fd01a03` 형태. 뒤집어서 raw에서 못 찾으면 유령 근거.
|
|
187
|
+
const hashRe = /hash:\s*`?([0-9a-fA-F]{7,40})`?/g;
|
|
188
|
+
let m;
|
|
189
|
+
while ((m = hashRe.exec(content)) !== null) {
|
|
190
|
+
const hex = m[1];
|
|
191
|
+
if (!rawCorpus.includes(hex)) {
|
|
192
|
+
report.evidenceViolations.push(`${relPath} — hash:${hex} not found in doc/raw/`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// (b) ### Error 섹션의 인용 줄. 마크다운 장식을 벗긴 뒤 원문 일치를 본다 —
|
|
197
|
+
// compile 때 요약으로 바뀌면 걸릴 문자열이 사라졌다는 신호가 된다.
|
|
198
|
+
for (const quote of extractErrorQuotes(content)) {
|
|
199
|
+
if (!rawCorpus.includes(quote)) {
|
|
200
|
+
report.evidenceViolations.push(`${relPath} — Error quote not in doc/raw/: "${truncate(quote, 80)}"`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ### Error 섹션 본문 줄들을 추출한다. 다음 ### 헤더에서 섹션이 끝난다.
|
|
206
|
+
function extractErrorQuotes(content) {
|
|
207
|
+
const quotes = [];
|
|
208
|
+
let inError = false;
|
|
209
|
+
for (const line of content.split(/\r?\n/)) {
|
|
210
|
+
if (/^###\s+/.test(line)) {
|
|
211
|
+
inError = /^###\s+Error\s*$/.test(line.trim());
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (!inError) continue;
|
|
215
|
+
// 마크다운 장식(리스트 마커, 인용, 감싼 백틱)을 벗겨 원문만 남긴다.
|
|
216
|
+
const cleaned = line.trim()
|
|
217
|
+
.replace(/^[-*]\s+/, '')
|
|
218
|
+
.replace(/^>\s*/, '')
|
|
219
|
+
.replace(/^`/, '')
|
|
220
|
+
.replace(/`$/, '')
|
|
221
|
+
.trim();
|
|
222
|
+
if (cleaned.length >= MIN_QUOTE_LENGTH) quotes.push(cleaned);
|
|
223
|
+
}
|
|
224
|
+
return quotes;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// [[링크]] 대상과 파일명의 동일성을 비교할 때 쓰는 정규화 키.
|
|
228
|
+
// 대소문자·공백/하이픈 차이는 에이전트 작성 드리프트라 같은 개념으로 본다.
|
|
229
|
+
function normalizeLinkKey(name) {
|
|
230
|
+
return String(name).trim().toLowerCase().replace(/\s+/g, '-');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// raw 로그 전체에서 [[링크]] 빈도를 세고, 위키에 페이지가 없는 잦은 개념을 돌려준다.
|
|
234
|
+
function findUncompiledKnowledge(rawFiles, fileMap) {
|
|
235
|
+
const linkCount = new Map();
|
|
236
|
+
for (const file of rawFiles) {
|
|
237
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
238
|
+
const links = content.match(/\[\[(.+?)\]\]/g) || [];
|
|
239
|
+
for (const link of links) {
|
|
240
|
+
let target = link.slice(2, -2).split('|')[0].split('#')[0];
|
|
241
|
+
target = path.basename(target);
|
|
242
|
+
if (!target) continue;
|
|
243
|
+
const key = normalizeLinkKey(target);
|
|
244
|
+
linkCount.set(key, { name: target, count: (linkCount.get(key)?.count || 0) + 1 });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return [...linkCount.entries()]
|
|
248
|
+
.filter(([key, v]) => v.count >= UNCOMPILED_THRESHOLD && !fileMap.has(key) && key !== 'index')
|
|
249
|
+
.map(([, v]) => v)
|
|
250
|
+
.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function truncate(s, n) {
|
|
254
|
+
return s.length > n ? s.slice(0, n) + '…' : s;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function getAllFiles(dir) {
|
|
258
|
+
let results = [];
|
|
259
|
+
const list = fs.readdirSync(dir);
|
|
260
|
+
list.forEach(file => {
|
|
261
|
+
file = path.join(dir, file);
|
|
262
|
+
const stat = fs.statSync(file);
|
|
263
|
+
if (stat && stat.isDirectory()) {
|
|
264
|
+
if (!file.includes('.obsidian')) results = results.concat(getAllFiles(file));
|
|
265
|
+
} else {
|
|
266
|
+
results.push(file);
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
return results;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
module.exports = { lint };
|