llm-wiki-kit 0.1.5 → 0.1.7
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/README.md +39 -16
- package/docs/concepts.md +16 -3
- package/docs/integrations/claude-code.md +2 -0
- package/docs/integrations/codex.md +2 -2
- package/docs/operations.md +101 -5
- package/docs/research/baseline.md +5 -1
- package/docs/research/future-large-scale.md +27 -0
- package/docs/security.md +2 -0
- package/docs/troubleshooting.md +30 -1
- package/package.json +4 -1
- package/src/cli.js +52 -3
- package/src/consolidate.js +203 -0
- package/src/hook.js +9 -3
- package/src/install.js +4 -2
- package/src/project-state.js +335 -14
- package/src/project.js +16 -57
- package/src/state.js +5 -0
- package/src/templates.js +194 -9
- package/src/update.js +34 -5
- package/src/wiki-lint.js +281 -0
- package/src/wiki-model.js +263 -0
- package/src/wiki-search.js +246 -0
package/src/templates.js
CHANGED
|
@@ -1,31 +1,216 @@
|
|
|
1
1
|
import { runtimeVersion } from './version.js';
|
|
2
2
|
|
|
3
3
|
export function rootAgentsPolicy() {
|
|
4
|
-
return
|
|
4
|
+
return `
|
|
5
|
+
<!-- llm-wiki-kit:start -->
|
|
6
|
+
## LLM Wiki Policy
|
|
7
|
+
|
|
8
|
+
This repository uses llm-wiki-kit as a hook-first living Markdown wiki for Codex and Claude Code.
|
|
9
|
+
|
|
10
|
+
- This block supersedes older OMX/OMC/\`omx_wiki/\` LLM Wiki instructions for this repository.
|
|
11
|
+
- 평소처럼 Codex 또는 Claude Code를 사용한다. 사용자가 별도 \`llm-wiki\` 명령을 외워서 실행해야 하는 흐름을 만들지 않는다.
|
|
12
|
+
- 채팅 기억은 임시로 본다. 오래 남길 프로젝트 지식은 \`llm-wiki/\` Markdown에 남긴다.
|
|
13
|
+
- \`llm-wiki/raw/\`는 원본 또는 redacted 근거 저장소다. hook envelope append 외에는 원본 capture를 수정하지 않는다.
|
|
14
|
+
- \`llm-wiki/wiki/\`는 agent가 관리하는 지식층이다. 결정, 구조, 디버깅, 개념, 절차, 맥락을 여기에 정리한다.
|
|
15
|
+
- \`llm-wiki/wiki/memory.md\`는 짧은 핵심 기억이다. 긴 설명 대신 현재 상태와 중요한 문서 링크만 유지한다.
|
|
16
|
+
- 새 문서를 만들기 전에 기존 wiki 문서를 먼저 찾아 갱신한다. 반복해서 쓸 사실은 \`outputs/questions/\`에만 두지 말고 적절한 wiki 문서에 합친다.
|
|
17
|
+
- 일회성 작업 기록은 \`llm-wiki/outputs/questions/\`에 보존하고, 재사용 가능한 결론은 \`wiki/architecture/\`, \`wiki/debugging/\`, \`wiki/decisions/\`, \`wiki/concepts/\`, \`procedures/\`에 반영한다.
|
|
18
|
+
- 검증 명령, 근거 파일, 불확실한 점을 함께 남긴다. 추론은 추론이라고 표시하고, 모순은 지우지 말고 Open Questions 또는 Contradictions에 남긴다.
|
|
19
|
+
- 인증값, token, password, private key, \`.env\` 원문은 저장하지 않는다. 필요한 경우 redacted summary만 남긴다.
|
|
20
|
+
|
|
21
|
+
<!-- llm-wiki-kit:end -->
|
|
22
|
+
`;
|
|
5
23
|
}
|
|
6
24
|
|
|
7
25
|
export function llmWikiAgents() {
|
|
8
|
-
return `# LLM Wiki Agent Rules
|
|
26
|
+
return `# LLM Wiki Agent Rules
|
|
27
|
+
|
|
28
|
+
Generated by llm-wiki-kit ${runtimeVersion()}.
|
|
29
|
+
|
|
30
|
+
## Purpose
|
|
31
|
+
Codex와 Claude Code를 평소처럼 사용하는 동안 living Markdown LLM Wiki를 자연스럽게 유지한다.
|
|
32
|
+
사용자가 많은 \`llm-wiki\` 명령을 직접 실행하는 방식이 아니라, agent가 작업 중 필요한 wiki 조회와 정리를 수행하는 것이 기본이다.
|
|
33
|
+
이 규칙은 오래된 OMX/OMC/\`omx_wiki/\` 규칙을 대체한다.
|
|
34
|
+
|
|
35
|
+
## Directories
|
|
36
|
+
- \`raw/\`: 원본 또는 redacted 근거 저장소. hook이 redacted envelope를 append하는 경우 외에는 원본 capture를 수정하지 않는다.
|
|
37
|
+
- \`wiki/\`: agent가 관리하는 정식 지식 문서. \`wiki/memory.md\`는 hook context에 들어가는 짧은 핵심 기억이다.
|
|
38
|
+
- \`outputs/\`: live Q&A, 보고서, 긴 생성물 저장소. 일회성 기록은 여기에 둔다.
|
|
39
|
+
- \`procedures/\`: ingest, query, lint, security 같은 운영 규칙.
|
|
40
|
+
|
|
41
|
+
## Core Rules
|
|
42
|
+
- 사용자는 Claude Code/Codex를 평소처럼 사용한다. agent가 필요한 wiki 조회와 정리를 자연스럽게 수행한다.
|
|
43
|
+
- \`raw/\` 원본은 수정하지 않는다. 안전한 hook envelope append만 예외다.
|
|
44
|
+
- 근거 없는 내용을 사실처럼 쓰지 않는다. 추론은 명시한다.
|
|
45
|
+
- 중요한 주장에는 \`source_ids\`, 파일 경로, 검증 명령 중 하나 이상을 남긴다.
|
|
46
|
+
- 오래 남길 내용이 생기면 새 문서부터 만들지 말고 기존 \`wiki/\` 문서를 먼저 찾아 갱신한다.
|
|
47
|
+
- 반복해서 쓸 지식은 \`outputs/questions/\`에만 두지 말고 \`wiki/architecture/\`, \`wiki/debugging/\`, \`wiki/decisions/\`, \`wiki/concepts/\`, \`procedures/\`에 합친다.
|
|
48
|
+
- \`wiki/memory.md\`는 짧게 유지한다. 긴 설명 대신 현재 상태와 중요한 문서 링크를 둔다.
|
|
49
|
+
- 모순은 덮어쓰지 말고 \`Contradictions\` 또는 \`Open Questions\`에 보존한다.
|
|
50
|
+
- 인증값, token, password, private key, \`.env\` 원문은 wiki에 저장하지 않는다.
|
|
51
|
+
|
|
52
|
+
## Page Format
|
|
53
|
+
Use YAML frontmatter when creating wiki pages:
|
|
54
|
+
|
|
55
|
+
\`\`\`yaml
|
|
56
|
+
---
|
|
57
|
+
title: ""
|
|
58
|
+
type: "source | concept | entity | decision | architecture | debugging | context | query | session-log | convention"
|
|
59
|
+
source_ids: []
|
|
60
|
+
status: "draft | reviewed | stale | archived"
|
|
61
|
+
last_updated: "YYYY-MM-DD"
|
|
62
|
+
confidence: "high | medium | low"
|
|
63
|
+
memory_type: "semantic | episodic | procedural"
|
|
64
|
+
importance: 1
|
|
65
|
+
last_verified: "YYYY-MM-DD | unknown"
|
|
66
|
+
supersedes: []
|
|
67
|
+
superseded_by: []
|
|
68
|
+
---
|
|
69
|
+
\`\`\`
|
|
70
|
+
|
|
71
|
+
## Operations
|
|
72
|
+
- ingest: \`wiki/memory.md\`와 \`wiki/index.md\`를 먼저 읽고, 새 근거를 확인한 뒤 기존 wiki 문서를 우선 갱신한다.
|
|
73
|
+
- query: hook이 주입한 context를 우선 사용한다. 수동 명령은 점검용이며 일반 사용 흐름의 필수 단계가 아니다.
|
|
74
|
+
- lint: agent가 필요할 때 wiki 건강 상태를 점검하는 보조 도구다. 사용자가 매번 직접 실행해야 하는 명령이 아니다.
|
|
75
|
+
- consolidate: agent가 \`memory.md\`/\`index.md\` generated block을 안전하게 갱신할 때 쓰는 보조 도구다. 손글씨 영역과 정식 문서 본문은 덮어쓰지 않는다.
|
|
76
|
+
`;
|
|
9
77
|
}
|
|
10
78
|
|
|
11
79
|
export function indexPage() {
|
|
12
|
-
return `# LLM Wiki Index
|
|
80
|
+
return `# LLM Wiki Index
|
|
81
|
+
|
|
82
|
+
Generated by llm-wiki-kit.
|
|
83
|
+
|
|
84
|
+
## Overview
|
|
85
|
+
|
|
86
|
+
이 파일은 living wiki의 짧은 지도다. 자세한 설명을 길게 쓰기보다, agent가 다음 작업에서 빠르게 찾아갈 entry point를 유지한다.
|
|
87
|
+
|
|
88
|
+
## Main Areas
|
|
89
|
+
|
|
90
|
+
- [Memory](memory.md) - 짧은 핵심 기억과 주요 entry point.
|
|
91
|
+
- [Sources](sources/) - source summary와 source id.
|
|
92
|
+
- [Concepts](concepts/) - 재사용 가능한 개념과 용어.
|
|
93
|
+
- [Entities](entities/) - 시스템, 모듈, 도구, 서비스, 주요 객체.
|
|
94
|
+
- [Decisions](decisions/) - 결정과 근거.
|
|
95
|
+
- [Architecture](architecture/) - 구조와 데이터/제어 흐름.
|
|
96
|
+
- [Debugging](debugging/) - 원인, 수정, 검증 evidence.
|
|
97
|
+
- [Context](context/) - 세션 연속성과 프로젝트 맥락.
|
|
98
|
+
- [Queries](queries/) - 재사용 가능한 질문 답변. 일회성 기록은 outputs/questions에 둔다.
|
|
99
|
+
|
|
100
|
+
## Operating Notes
|
|
101
|
+
|
|
102
|
+
- 넓은 질문은 memory와 이 index에서 시작한다.
|
|
103
|
+
- 새 문서를 만들기 전에 관련 기존 문서 3-7개를 먼저 확인한다.
|
|
104
|
+
- 오래 쓸 결론은 기존 정식 문서에 합치고, 일회성 기록은 outputs/questions에 둔다.
|
|
105
|
+
- 관련 페이지가 생기면 \`[[page-or-topic]]\` 링크를 추가한다.
|
|
106
|
+
|
|
107
|
+
<!-- llm-wiki-kit:index-start -->
|
|
108
|
+
## Generated Page Map
|
|
109
|
+
|
|
110
|
+
Agent가 필요할 때 \`llm-wiki consolidate --workspace <project>\`로 이 generated block을 갱신한다.
|
|
111
|
+
<!-- llm-wiki-kit:index-end -->
|
|
112
|
+
`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function memoryPage() {
|
|
116
|
+
return `---
|
|
117
|
+
title: "LLM Wiki Memory"
|
|
118
|
+
type: "context"
|
|
119
|
+
source_ids: []
|
|
120
|
+
status: "draft"
|
|
121
|
+
last_updated: "unknown"
|
|
122
|
+
confidence: "medium"
|
|
123
|
+
memory_type: "semantic"
|
|
124
|
+
importance: 5
|
|
125
|
+
last_verified: "unknown"
|
|
126
|
+
supersedes: []
|
|
127
|
+
superseded_by: []
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
# LLM Wiki Memory
|
|
131
|
+
|
|
132
|
+
짧은 핵심 기억이다. hook context에 들어갈 만큼 작게 유지하고, 긴 설명을 복사하지 말고 깊은 문서로 링크한다.
|
|
133
|
+
|
|
134
|
+
## Current Focus
|
|
135
|
+
|
|
136
|
+
- 오래 남길 현재 프로젝트 초점을 여기에 짧게 추가한다.
|
|
137
|
+
|
|
138
|
+
## Durable Entry Points
|
|
139
|
+
|
|
140
|
+
- [Index](index.md)
|
|
141
|
+
- [Log](log.md)
|
|
142
|
+
|
|
143
|
+
<!-- llm-wiki-kit:memory-start -->
|
|
144
|
+
## Generated Memory Map
|
|
145
|
+
|
|
146
|
+
Agent가 필요할 때 \`llm-wiki consolidate --workspace <project>\`로 이 generated block을 갱신한다.
|
|
147
|
+
<!-- llm-wiki-kit:memory-end -->
|
|
148
|
+
`;
|
|
13
149
|
}
|
|
14
150
|
|
|
15
151
|
export function logPage() {
|
|
16
|
-
return `# LLM Wiki Log
|
|
152
|
+
return `# LLM Wiki Log
|
|
153
|
+
|
|
154
|
+
Append-only operating history for this project wiki.
|
|
155
|
+
`;
|
|
17
156
|
}
|
|
18
157
|
|
|
19
158
|
export function procedure(name) {
|
|
20
159
|
const procedures = {
|
|
21
|
-
'ingest.md': `# Ingest Procedure
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
160
|
+
'ingest.md': `# Ingest Procedure
|
|
161
|
+
|
|
162
|
+
1. \`wiki/memory.md\`와 \`wiki/index.md\`를 먼저 읽는다.
|
|
163
|
+
2. 새 근거가 있으면 \`raw/inbox/\` 또는 \`raw/sources/\`를 확인한다.
|
|
164
|
+
3. source별 요약이 필요하면 \`wiki/sources/<slug>.md\`를 만들거나 갱신한다.
|
|
165
|
+
4. 새 문서부터 만들지 말고 관련 concept/entity/decision/architecture/debugging/context 문서를 먼저 찾아 갱신한다.
|
|
166
|
+
5. 중복 문서를 만들지 않는다. 같은 사실이 이미 있으면 기존 문서에 합친다.
|
|
167
|
+
6. 중요한 주장에는 source reference, confidence, memory type, importance, verification status를 남긴다.
|
|
168
|
+
7. durable entry point가 바뀌면 \`wiki/memory.md\`를 짧게 갱신하거나 agent가 \`llm-wiki consolidate\`를 사용한다.
|
|
169
|
+
8. 의미 있는 변화는 \`wiki/log.md\`에 짧게 남긴다.
|
|
170
|
+
`,
|
|
171
|
+
'query.md': `# Query Procedure
|
|
172
|
+
|
|
173
|
+
1. hook이 주입한 context를 먼저 사용한다. 수동으로 확인할 때만 \`llm-wiki context "<query>"\`를 쓴다.
|
|
174
|
+
2. \`wiki/memory.md\`와 \`wiki/index.md\`에서 시작한다.
|
|
175
|
+
3. 관련 \`wiki/\` 문서를 최소한으로 읽는다.
|
|
176
|
+
4. 정확한 근거가 필요할 때만 raw source를 확인한다.
|
|
177
|
+
5. 검증된 사실과 추론을 분리한다.
|
|
178
|
+
6. 일회성 답변은 \`outputs/questions/\`에 남기고, 재사용 가능한 답변은 기존 \`wiki/queries/\` 또는 관련 정식 문서에 연결한다.
|
|
179
|
+
7. 반복해서 쓸 결론은 \`wiki/architecture/\`, \`wiki/debugging/\`, \`wiki/decisions/\`, \`wiki/concepts/\`, \`procedures/\`에 합친다.
|
|
180
|
+
`,
|
|
181
|
+
'lint.md': `# Lint Procedure
|
|
182
|
+
|
|
183
|
+
\`llm-wiki lint --workspace <project>\`는 agent가 필요할 때 쓰는 wiki 건강 점검 도구다. 사용자가 매번 실행해야 하는 명령이 아니다.
|
|
184
|
+
|
|
185
|
+
점검 대상: stale page, orphan page, broken wiki/Markdown link, unsafe source id, secret-like content, missing source, duplicate concept/title, unsupported claim, unresolved contradiction, outdated managed rule.
|
|
186
|
+
|
|
187
|
+
자동 수정은 확실히 kit가 관리하는 영역에만 적용한다. 사용자 편집 가능성이 있는 문서는 덮어쓰지 말고 다음 작업 context에 정리 필요성을 올린다.
|
|
188
|
+
`,
|
|
189
|
+
'security.md': `# Security Procedure
|
|
190
|
+
|
|
191
|
+
- 로컬 프로젝트 wiki에 유용한 작업 맥락은 보존한다.
|
|
192
|
+
- 입력이 민감해 보인다는 이유만으로 read/tool call을 막지 않는다.
|
|
193
|
+
- token, password, bearer credential, private key, \`.env\` 원문 같은 인증값은 hook payload, summary, context pack, wiki page에 저장하기 전에 redaction한다.
|
|
194
|
+
- 민감정보가 wiki에 들어갔을 가능성이 있으면 agent가 \`llm-wiki lint --workspace <project>\`를 사용해 점검한다.
|
|
195
|
+
- full raw transcript capture는 기본 비활성이다. 프로젝트가 명시적으로 opt-in하고 redaction 경로가 있을 때만 허용한다.
|
|
196
|
+
`,
|
|
25
197
|
};
|
|
26
198
|
return procedures[name] || '';
|
|
27
199
|
}
|
|
28
200
|
|
|
29
201
|
export function gitignore() {
|
|
30
|
-
return `# llm-wiki-kit safety defaults
|
|
202
|
+
return `# llm-wiki-kit safety defaults
|
|
203
|
+
raw/sessions/*.jsonl
|
|
204
|
+
raw/sessions/*.ndjson
|
|
205
|
+
raw/private/
|
|
206
|
+
raw_private/
|
|
207
|
+
secrets/
|
|
208
|
+
*.key
|
|
209
|
+
*.pem
|
|
210
|
+
*.p12
|
|
211
|
+
*.pfx
|
|
212
|
+
.env
|
|
213
|
+
.env.*
|
|
214
|
+
.cache/
|
|
215
|
+
`;
|
|
31
216
|
}
|
package/src/update.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawnSync } from 'child_process';
|
|
2
|
-
import { resolve } from 'path';
|
|
3
|
-
import {
|
|
2
|
+
import { join, resolve } from 'path';
|
|
3
|
+
import { exists } from './fs-utils.js';
|
|
4
|
+
import { appendWikiLog } from './project.js';
|
|
4
5
|
import { install } from './install.js';
|
|
5
6
|
import { applyProjectTemplateUpdate, inspectProjectState } from './project-state.js';
|
|
6
7
|
import { knownProjectRoots } from './projects.js';
|
|
@@ -34,10 +35,37 @@ function assertCommandOk(result, label) {
|
|
|
34
35
|
throw new Error(`${label} failed: ${detail}`);
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
async function hasProjectWiki(projectRoot) {
|
|
39
|
+
return exists(join(projectRoot, 'llm-wiki', 'wiki'));
|
|
40
|
+
}
|
|
41
|
+
|
|
37
42
|
export function parseRegistryVersion(output) {
|
|
38
43
|
return String(output || '').trim().split(/\s+/).at(-1) || '';
|
|
39
44
|
}
|
|
40
45
|
|
|
46
|
+
export function compareVersions(a, b) {
|
|
47
|
+
const parse = (value) => {
|
|
48
|
+
const [core, prerelease = ''] = String(value || '').trim().replace(/^v/, '').split('-', 2);
|
|
49
|
+
return {
|
|
50
|
+
nums: core.split('.').slice(0, 3).map((part) => {
|
|
51
|
+
const parsed = Number.parseInt(part, 10);
|
|
52
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
53
|
+
}),
|
|
54
|
+
prerelease,
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
const left = parse(a);
|
|
58
|
+
const right = parse(b);
|
|
59
|
+
for (let i = 0; i < 3; i += 1) {
|
|
60
|
+
const diff = (left.nums[i] || 0) - (right.nums[i] || 0);
|
|
61
|
+
if (diff !== 0) return diff > 0 ? 1 : -1;
|
|
62
|
+
}
|
|
63
|
+
if (left.prerelease && !right.prerelease) return -1;
|
|
64
|
+
if (!left.prerelease && right.prerelease) return 1;
|
|
65
|
+
if (left.prerelease === right.prerelease) return 0;
|
|
66
|
+
return left.prerelease > right.prerelease ? 1 : -1;
|
|
67
|
+
}
|
|
68
|
+
|
|
41
69
|
export async function checkForUpdate(options = {}) {
|
|
42
70
|
const target = options.to || 'latest';
|
|
43
71
|
const installedVersion = runtimeVersion();
|
|
@@ -50,7 +78,7 @@ export async function checkForUpdate(options = {}) {
|
|
|
50
78
|
installedVersion,
|
|
51
79
|
latestVersion,
|
|
52
80
|
target,
|
|
53
|
-
updateAvailable: latestVersion
|
|
81
|
+
updateAvailable: compareVersions(latestVersion, installedVersion) > 0,
|
|
54
82
|
};
|
|
55
83
|
}
|
|
56
84
|
|
|
@@ -60,6 +88,7 @@ export async function postUpdate(options = {}) {
|
|
|
60
88
|
...options,
|
|
61
89
|
workspace,
|
|
62
90
|
replaceHooks: true,
|
|
91
|
+
noProject: true,
|
|
63
92
|
});
|
|
64
93
|
|
|
65
94
|
if (options.all) {
|
|
@@ -69,7 +98,7 @@ export async function postUpdate(options = {}) {
|
|
|
69
98
|
const projectResult = options.noProject
|
|
70
99
|
? null
|
|
71
100
|
: await applyProjectTemplateUpdate(projectRoot);
|
|
72
|
-
if (!options.noProject) {
|
|
101
|
+
if (!options.noProject && await hasProjectWiki(projectRoot)) {
|
|
73
102
|
await appendWikiLog(projectRoot, `llm-wiki-kit post-update applied runtime ${runtimeVersion()}; changed templates: ${projectResult.changed.length}`);
|
|
74
103
|
}
|
|
75
104
|
projects.push({
|
|
@@ -89,7 +118,7 @@ export async function postUpdate(options = {}) {
|
|
|
89
118
|
? null
|
|
90
119
|
: await applyProjectTemplateUpdate(workspace);
|
|
91
120
|
|
|
92
|
-
if (!options.noProject) {
|
|
121
|
+
if (!options.noProject && await hasProjectWiki(workspace)) {
|
|
93
122
|
await appendWikiLog(workspace, `llm-wiki-kit post-update applied runtime ${runtimeVersion()}; changed templates: ${projectResult.changed.length}`);
|
|
94
123
|
}
|
|
95
124
|
|
package/src/wiki-lint.js
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { readdir } from 'fs/promises';
|
|
2
|
+
import { dirname, isAbsolute, join, parse, relative, resolve, sep } from 'path';
|
|
3
|
+
import { exists, readText } from './fs-utils.js';
|
|
4
|
+
import { inspectProjectState } from './project-state.js';
|
|
5
|
+
import { hasSecretLikeText } from './redaction.js';
|
|
6
|
+
import {
|
|
7
|
+
buildAliasMap,
|
|
8
|
+
buildWikiGraph,
|
|
9
|
+
collectWikiPages,
|
|
10
|
+
normalizeTarget,
|
|
11
|
+
resolveWikiLink,
|
|
12
|
+
wikiRoot,
|
|
13
|
+
} from './wiki-model.js';
|
|
14
|
+
|
|
15
|
+
const VALID_TYPES = new Set([
|
|
16
|
+
'source',
|
|
17
|
+
'concept',
|
|
18
|
+
'entity',
|
|
19
|
+
'decision',
|
|
20
|
+
'architecture',
|
|
21
|
+
'debugging',
|
|
22
|
+
'context',
|
|
23
|
+
'query',
|
|
24
|
+
'session-log',
|
|
25
|
+
'convention',
|
|
26
|
+
]);
|
|
27
|
+
const VALID_STATUS = new Set(['draft', 'reviewed', 'stale', 'archived']);
|
|
28
|
+
const VALID_CONFIDENCE = new Set(['high', 'medium', 'low']);
|
|
29
|
+
const VALID_MEMORY_TYPES = new Set(['semantic', 'episodic', 'procedural']);
|
|
30
|
+
const CORE_PAGES = new Set(['wiki/index.md', 'wiki/log.md', 'wiki/memory.md']);
|
|
31
|
+
|
|
32
|
+
function issue(severity, code, path, message) {
|
|
33
|
+
return { severity, code, path, message };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isDateLike(value) {
|
|
37
|
+
return value === 'unknown' || /^\d{4}-\d{2}-\d{2}$/.test(String(value || ''));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function relativeMarkdownTarget(page, link) {
|
|
41
|
+
return resolve(dirname(page.absolutePath), link.path);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isOutsideProject(projectRoot, absolutePath) {
|
|
45
|
+
const root = resolve(projectRoot);
|
|
46
|
+
const target = resolve(absolutePath);
|
|
47
|
+
const rel = relative(root, target);
|
|
48
|
+
return rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function invalidSourceId(sourceId) {
|
|
52
|
+
const id = String(sourceId || '').trim().replace(/\\/g, '/');
|
|
53
|
+
if (!id) return true;
|
|
54
|
+
if (id.startsWith('/') || /^[a-z][a-z0-9+.-]*:/i.test(id)) return true;
|
|
55
|
+
return id.split('/').includes('..');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function sourceCandidates(projectRoot, sourceId) {
|
|
59
|
+
const id = String(sourceId || '').trim();
|
|
60
|
+
if (!id) return [];
|
|
61
|
+
const rawSources = join(projectRoot, 'llm-wiki', 'raw', 'sources');
|
|
62
|
+
const rawInbox = join(projectRoot, 'llm-wiki', 'raw', 'inbox');
|
|
63
|
+
const wikiSources = join(projectRoot, 'llm-wiki', 'wiki', 'sources');
|
|
64
|
+
const candidates = [
|
|
65
|
+
join(rawSources, id),
|
|
66
|
+
join(rawSources, `${id}.md`),
|
|
67
|
+
join(rawSources, `${id}.txt`),
|
|
68
|
+
join(rawInbox, id),
|
|
69
|
+
join(rawInbox, `${id}.md`),
|
|
70
|
+
join(wikiSources, id),
|
|
71
|
+
join(wikiSources, `${id}.md`),
|
|
72
|
+
];
|
|
73
|
+
if (id.includes('/') || id.includes('\\')) {
|
|
74
|
+
candidates.push(join(projectRoot, id));
|
|
75
|
+
candidates.push(join(projectRoot, 'llm-wiki', id));
|
|
76
|
+
}
|
|
77
|
+
return candidates.map((candidate) => resolve(candidate))
|
|
78
|
+
.filter((candidate) => !isOutsideProject(projectRoot, candidate));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function sourceExists(projectRoot, sourceId) {
|
|
82
|
+
for (const candidate of sourceCandidates(projectRoot, sourceId)) {
|
|
83
|
+
if (await pathExistsNormalized(candidate)) return true;
|
|
84
|
+
}
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function titleKey(page) {
|
|
89
|
+
return normalizeTarget(page.title);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isOrphanCandidate(page) {
|
|
93
|
+
if (CORE_PAGES.has(page.rel)) return false;
|
|
94
|
+
if (page.rel.startsWith('wiki/queries/')) return false;
|
|
95
|
+
if (page.rel.startsWith('wiki/context/')) return false;
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function pathExistsNormalized(absolutePath) {
|
|
100
|
+
if (await exists(absolutePath)) return true;
|
|
101
|
+
const target = resolve(absolutePath);
|
|
102
|
+
const parsed = parse(target);
|
|
103
|
+
const segments = target.slice(parsed.root.length).split(sep).filter(Boolean);
|
|
104
|
+
let current = parsed.root || sep;
|
|
105
|
+
for (const segment of segments) {
|
|
106
|
+
let entries = [];
|
|
107
|
+
try {
|
|
108
|
+
entries = await readdir(current);
|
|
109
|
+
} catch {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
const match = entries.find((entry) => entry.normalize('NFC') === segment.normalize('NFC'));
|
|
113
|
+
if (!match) return false;
|
|
114
|
+
current = join(current, match);
|
|
115
|
+
}
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function runLint(projectRoot, options = {}) {
|
|
120
|
+
const issues = [];
|
|
121
|
+
const root = wikiRoot(projectRoot);
|
|
122
|
+
if (!(await exists(root))) {
|
|
123
|
+
issues.push(issue('error', 'missing-wiki', 'llm-wiki/wiki', 'wiki directory does not exist'));
|
|
124
|
+
return lintResult(projectRoot, [], issues);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const pages = await collectWikiPages(projectRoot, {
|
|
128
|
+
maxFiles: options.maxFiles || 1000,
|
|
129
|
+
maxChars: options.maxChars || 75000,
|
|
130
|
+
});
|
|
131
|
+
const byRel = new Map(pages.map((page) => [page.rel, page]));
|
|
132
|
+
const aliasMap = buildAliasMap(pages);
|
|
133
|
+
const graph = buildWikiGraph(pages);
|
|
134
|
+
|
|
135
|
+
for (const rel of ['wiki/index.md', 'wiki/log.md', 'wiki/memory.md']) {
|
|
136
|
+
if (!byRel.has(rel)) {
|
|
137
|
+
issues.push(issue(rel === 'wiki/memory.md' ? 'warning' : 'error', 'missing-core-page', rel, 'core wiki page is missing'));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
for (const [alias, matches] of aliasMap.entries()) {
|
|
142
|
+
if (matches.length > 1 && alias.length > 0) {
|
|
143
|
+
issues.push(issue('warning', 'duplicate-alias', matches.join(', '), `alias "${alias}" maps to multiple pages`));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const titles = new Map();
|
|
148
|
+
for (const page of pages) {
|
|
149
|
+
const key = titleKey(page);
|
|
150
|
+
if (!key) continue;
|
|
151
|
+
if (!titles.has(key)) titles.set(key, []);
|
|
152
|
+
titles.get(key).push(page.rel);
|
|
153
|
+
}
|
|
154
|
+
for (const [key, matches] of titles.entries()) {
|
|
155
|
+
if (matches.length > 1) {
|
|
156
|
+
issues.push(issue('warning', 'duplicate-title', matches.join(', '), `title "${key}" appears on multiple pages`));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
for (const page of pages) {
|
|
161
|
+
if (hasSecretLikeText(page.content)) {
|
|
162
|
+
issues.push(issue('error', 'secret-like-content', page.rel, 'page contains token, credential, private-key, or secret-like text'));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (!CORE_PAGES.has(page.rel) && !page.hasFrontmatter) {
|
|
166
|
+
issues.push(issue('warning', 'missing-frontmatter', page.rel, 'curated wiki page should have YAML frontmatter'));
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (page.hasFrontmatter) {
|
|
171
|
+
if (!page.type) issues.push(issue('warning', 'missing-type', page.rel, 'frontmatter is missing type'));
|
|
172
|
+
if (page.type && !VALID_TYPES.has(page.type)) issues.push(issue('error', 'invalid-type', page.rel, `invalid type: ${page.type}`));
|
|
173
|
+
if (!page.status) issues.push(issue('warning', 'missing-status', page.rel, 'frontmatter is missing status'));
|
|
174
|
+
if (page.status && !VALID_STATUS.has(page.status)) issues.push(issue('error', 'invalid-status', page.rel, `invalid status: ${page.status}`));
|
|
175
|
+
if (!page.confidence) issues.push(issue('warning', 'missing-confidence', page.rel, 'frontmatter is missing confidence'));
|
|
176
|
+
if (page.confidence && !VALID_CONFIDENCE.has(page.confidence)) issues.push(issue('error', 'invalid-confidence', page.rel, `invalid confidence: ${page.confidence}`));
|
|
177
|
+
if (page.frontmatter.memory_type && !VALID_MEMORY_TYPES.has(page.frontmatter.memory_type)) {
|
|
178
|
+
issues.push(issue('error', 'invalid-memory-type', page.rel, `invalid memory_type: ${page.frontmatter.memory_type}`));
|
|
179
|
+
}
|
|
180
|
+
if (page.frontmatter.importance !== undefined) {
|
|
181
|
+
const importance = Number(page.frontmatter.importance);
|
|
182
|
+
if (!Number.isInteger(importance) || importance < 1 || importance > 5) {
|
|
183
|
+
issues.push(issue('error', 'invalid-importance', page.rel, 'importance must be an integer from 1 to 5'));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (page.frontmatter.last_updated && !isDateLike(page.frontmatter.last_updated)) {
|
|
187
|
+
issues.push(issue('warning', 'invalid-last-updated', page.rel, `last_updated should be YYYY-MM-DD or unknown: ${page.frontmatter.last_updated}`));
|
|
188
|
+
}
|
|
189
|
+
if (page.frontmatter.last_verified && !isDateLike(page.frontmatter.last_verified)) {
|
|
190
|
+
issues.push(issue('warning', 'invalid-last-verified', page.rel, `last_verified should be YYYY-MM-DD or unknown: ${page.frontmatter.last_verified}`));
|
|
191
|
+
}
|
|
192
|
+
if (page.status === 'stale') {
|
|
193
|
+
issues.push(issue('warning', 'stale-page', page.rel, 'page is marked stale'));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
for (const link of page.wikilinks) {
|
|
198
|
+
const resolved = resolveWikiLink(aliasMap, link.raw, page);
|
|
199
|
+
const matches = aliasMap.get(normalizeTarget(link.target)) || [];
|
|
200
|
+
if (!resolved && matches.length === 0) {
|
|
201
|
+
issues.push(issue('error', 'broken-wikilink', page.rel, `unresolved wikilink: [[${link.raw}]]`));
|
|
202
|
+
} else if (!resolved && matches.length > 1) {
|
|
203
|
+
issues.push(issue('error', 'ambiguous-wikilink', page.rel, `ambiguous wikilink: [[${link.raw}]] -> ${matches.join(', ')}`));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
for (const link of page.markdownLinks) {
|
|
208
|
+
const target = relativeMarkdownTarget(page, link);
|
|
209
|
+
if (isOutsideProject(projectRoot, target)) {
|
|
210
|
+
issues.push(issue('warning', 'outside-project-link', page.rel, `markdown link points outside project: ${link.raw}`));
|
|
211
|
+
} else if (!(await pathExistsNormalized(target))) {
|
|
212
|
+
issues.push(issue('error', 'broken-markdown-link', page.rel, `missing markdown link target: ${link.raw}`));
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
for (const sourceId of page.sourceIds) {
|
|
217
|
+
if (invalidSourceId(sourceId)) {
|
|
218
|
+
issues.push(issue('error', 'invalid-source-id', page.rel, 'source_id points outside the project or uses unsupported syntax'));
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (!(await sourceExists(projectRoot, sourceId))) {
|
|
222
|
+
issues.push(issue('warning', 'missing-source', page.rel, `source_id has no matching raw/wiki source file: ${sourceId}`));
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const memoryText = await readText(join(projectRoot, 'llm-wiki', 'wiki', 'memory.md'), '');
|
|
228
|
+
if (Buffer.byteLength(memoryText, 'utf8') > 25000) {
|
|
229
|
+
issues.push(issue('warning', 'memory-too-large', 'wiki/memory.md', 'memory.md is larger than the hook excerpt budget'));
|
|
230
|
+
}
|
|
231
|
+
for (const page of pages.filter(isOrphanCandidate)) {
|
|
232
|
+
const backlinks = graph.backlinks.get(page.rel);
|
|
233
|
+
if (!backlinks || backlinks.size === 0) {
|
|
234
|
+
issues.push(issue('warning', 'orphan-page', page.rel, 'page is not linked from memory/index and has no backlinks'));
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const projectState = await inspectProjectState(projectRoot).catch(() => null);
|
|
239
|
+
for (const file of projectState?.managedFiles || []) {
|
|
240
|
+
if (file.needsAttention) {
|
|
241
|
+
issues.push(issue('warning', 'outdated-managed-rule', file.path, 'previous llm-wiki-kit rule file appears customized; preserve user text and align it with current natural-use rules'));
|
|
242
|
+
} else if (!file.current && file.patchable) {
|
|
243
|
+
issues.push(issue('warning', 'outdated-managed-template', file.path, 'managed llm-wiki-kit rule/template can be automatically updated on the next hook or post-update run'));
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return lintResult(projectRoot, pages, issues);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function lintResult(projectRoot, pages, issues) {
|
|
251
|
+
const errorCount = issues.filter((item) => item.severity === 'error').length;
|
|
252
|
+
const warningCount = issues.filter((item) => item.severity === 'warning').length;
|
|
253
|
+
return {
|
|
254
|
+
workspace: projectRoot,
|
|
255
|
+
ok: errorCount === 0,
|
|
256
|
+
pages: pages.length,
|
|
257
|
+
issueCount: issues.length,
|
|
258
|
+
errorCount,
|
|
259
|
+
warningCount,
|
|
260
|
+
issues,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function formatLintResult(result) {
|
|
265
|
+
const lines = [
|
|
266
|
+
'llm-wiki lint',
|
|
267
|
+
`- workspace: ${result.workspace}`,
|
|
268
|
+
`- pages: ${result.pages}`,
|
|
269
|
+
`- errors: ${result.errorCount}`,
|
|
270
|
+
`- warnings: ${result.warningCount}`,
|
|
271
|
+
];
|
|
272
|
+
if (result.issues.length === 0) {
|
|
273
|
+
lines.push('- result: ok');
|
|
274
|
+
return lines.join('\n');
|
|
275
|
+
}
|
|
276
|
+
lines.push('', 'Issues:');
|
|
277
|
+
for (const item of result.issues) {
|
|
278
|
+
lines.push(`- ${item.severity} ${item.code} ${item.path}: ${item.message}`);
|
|
279
|
+
}
|
|
280
|
+
return lines.join('\n');
|
|
281
|
+
}
|