llm-wiki-kit 0.1.4 → 0.1.6

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/src/templates.js CHANGED
@@ -1,15 +1,98 @@
1
1
  import { runtimeVersion } from './version.js';
2
2
 
3
3
  export function rootAgentsPolicy() {
4
- return `\n<!-- llm-wiki-kit:start -->\n## LLM Wiki Policy\n\nThis repository uses llm-wiki-kit as a hook-first living Markdown wiki for Codex and Claude Code.\n\n- This block supersedes older OMX/OMC/\`omx_wiki/\` LLM Wiki instructions for this repository.\n- Treat chat memory as temporary; durable project knowledge belongs in \`llm-wiki/\`.\n- \`llm-wiki/raw/\` stores immutable or redacted source material. Do not edit raw source files.\n- \`llm-wiki/wiki/\` stores LLM-maintained knowledge pages such as decisions, architecture, debugging, context, concepts, and queries.\n- Before non-trivial work, use the injected LLM Wiki context and consult \`llm-wiki/wiki/index.md\` when present.\n- Capture reusable decisions, debugging findings, verification commands, and open questions into the wiki.\n- Preserve useful work context, but do not store raw authentication values such as tokens, passwords, or private keys.\n- Mark inference explicitly and preserve contradictions instead of silently overwriting them.\n\n<!-- llm-wiki-kit:end -->\n`;
4
+ return `\n<!-- llm-wiki-kit:start -->\n## LLM Wiki Policy\n\nThis repository uses llm-wiki-kit as a hook-first living Markdown wiki for Codex and Claude Code.\n\n- This block supersedes older OMX/OMC/\`omx_wiki/\` LLM Wiki instructions for this repository.\n- Treat chat memory as temporary; durable project knowledge belongs in \`llm-wiki/\`.\n- \`llm-wiki/raw/\` stores immutable or redacted source material. Do not edit raw source files.\n- \`llm-wiki/wiki/\` stores LLM-maintained knowledge pages such as decisions, architecture, debugging, context, concepts, and queries.\n- \`llm-wiki/wiki/memory.md\` is the short hot index for the most reusable current facts; keep it concise and link to deeper pages.\n- Before non-trivial work, use the injected LLM Wiki context and consult \`llm-wiki/wiki/memory.md\` and \`llm-wiki/wiki/index.md\` when present.\n- Capture reusable decisions, debugging findings, verification commands, and open questions into the wiki.\n- Preserve useful work context, but do not store raw authentication values such as tokens, passwords, or private keys.\n- Mark inference explicitly and preserve contradictions instead of silently overwriting them.\n\n<!-- llm-wiki-kit:end -->\n`;
5
5
  }
6
6
 
7
7
  export function llmWikiAgents() {
8
- return `# LLM Wiki Agent Rules\n\nGenerated by llm-wiki-kit ${runtimeVersion()}.\n\n## Purpose\nMaintain a living Markdown LLM Wiki from immutable source files and redacted Codex/Claude Code session events.\nThese rules supersede older OMX/OMC/\`omx_wiki/\` LLM Wiki rules for this project.\n\n## Directories\n- \`raw/\`: immutable or redacted source material. Never edit original source captures.\n- \`wiki/\`: AI-maintained knowledge pages.\n- \`outputs/\`: live Q&A summaries, reports, and generated briefs.\n- \`procedures/\`: detailed operating rules for ingest, query, lint, and security.\n\n## Core Rules\n- Never modify \`raw/\` source material except to append redacted session envelopes generated by hooks.\n- Do not state unsupported claims as facts.\n- Mark inference explicitly.\n- Add \`source_ids\` or file references for important claims.\n- Update \`wiki/index.md\` and \`wiki/log.md\` whenever new durable knowledge is created.\n- Preserve contradictions in \`Contradictions\` or \`Open Questions\` instead of overwriting them.\n- Preserve useful work context and redact authentication values before writing durable notes.\n\n## Page Format\nUse YAML frontmatter when creating wiki pages:\n\n\`\`\`yaml\n---\ntitle: \"\"\ntype: \"source | concept | entity | decision | architecture | debugging | context | query | session-log | convention\"\nsource_ids: []\nstatus: \"draft | reviewed | stale\"\nlast_updated: \"YYYY-MM-DD\"\nconfidence: \"high | medium | low\"\n---\n\`\`\`\n\n## Operations\n- ingest: read new raw files, create or update wiki pages, then update \`wiki/index.md\` and \`wiki/log.md\`.\n- query: start from \`wiki/index.md\`, read relevant wiki pages, answer with source references, and save reusable answers.\n- lint: find stale pages, orphan pages, broken wiki links, missing sources, duplicate concepts, contradictions, and missing links.\n`;
8
+ return `# LLM Wiki Agent Rules
9
+
10
+ Generated by llm-wiki-kit ${runtimeVersion()}.
11
+
12
+ ## Purpose
13
+ Maintain a living Markdown LLM Wiki from immutable source files and redacted Codex/Claude Code session events.
14
+ These rules supersede older OMX/OMC/\`omx_wiki/\` LLM Wiki rules for this project.
15
+
16
+ ## Directories
17
+ - \`raw/\`: immutable or redacted source material. Never edit original source captures.
18
+ - \`wiki/\`: AI-maintained knowledge pages. \`wiki/memory.md\` is the short hot index injected into hook context.
19
+ - \`outputs/\`: live Q&A summaries, reports, and generated briefs.
20
+ - \`procedures/\`: detailed operating rules for ingest, query, lint, and security.
21
+
22
+ ## Core Rules
23
+ - Never modify \`raw/\` source material except to append redacted session envelopes generated by hooks.
24
+ - Do not state unsupported claims as facts.
25
+ - Mark inference explicitly.
26
+ - Add \`source_ids\` or file references for important claims.
27
+ - Update \`wiki/memory.md\`, \`wiki/index.md\`, and \`wiki/log.md\` whenever new durable knowledge changes the active project map.
28
+ - Preserve contradictions in \`Contradictions\` or \`Open Questions\` instead of overwriting them.
29
+ - Preserve useful work context and redact authentication values before writing durable notes.
30
+
31
+ ## Page Format
32
+ Use YAML frontmatter when creating wiki pages:
33
+
34
+ \`\`\`yaml
35
+ ---
36
+ title: ""
37
+ type: "source | concept | entity | decision | architecture | debugging | context | query | session-log | convention"
38
+ source_ids: []
39
+ status: "draft | reviewed | stale"
40
+ last_updated: "YYYY-MM-DD"
41
+ confidence: "high | medium | low"
42
+ memory_type: "semantic | episodic | procedural"
43
+ importance: 1
44
+ last_verified: "YYYY-MM-DD | unknown"
45
+ supersedes: []
46
+ superseded_by: []
47
+ ---
48
+ \`\`\`
49
+
50
+ ## Operations
51
+ - ingest: read \`wiki/memory.md\` and \`wiki/index.md\`, ingest new raw files, create or update wiki pages, then update the active map and \`wiki/log.md\`.
52
+ - query: start from \`llm-wiki context "<query>"\` or \`wiki/memory.md\`, read relevant wiki pages, answer with source references, and save reusable answers.
53
+ - lint: run \`llm-wiki lint --workspace <project>\` to find stale pages, orphan pages, broken wiki/Markdown links, unsafe source IDs, secret-like content, missing sources, duplicate concepts, contradictions, and missing links.
54
+ - consolidate: run \`llm-wiki consolidate --workspace <project>\` after meaningful wiki growth to refresh generated memory/index blocks without overwriting hand-written notes. Default query/context/session-log pages are excluded unless explicitly durable.
55
+ `;
9
56
  }
10
57
 
11
58
  export function indexPage() {
12
- return `# LLM Wiki Index\n\nGenerated by llm-wiki-kit.\n\n## Overview\n\nThis is the navigation map for the project living wiki. Keep it short and useful.\n\n## Main Areas\n\n- [Sources](sources/) - source summaries and source IDs.\n- [Concepts](concepts/) - reusable concepts and terminology.\n- [Entities](entities/) - people, systems, modules, vendors, services, and tools.\n- [Decisions](decisions/) - decisions and rationale.\n- [Architecture](architecture/) - system structure and data/control flow.\n- [Debugging](debugging/) - root causes, fixes, verification evidence.\n- [Context](context/) - session continuity and project memory.\n- [Queries](queries/) - durable answers to useful questions.\n\n## Operating Notes\n\n- Start from this index before broad project questions.\n- Read 3-7 relevant pages first; inspect raw sources only when precision matters.\n- Add links using \`[[page-or-topic]]\` when pages become related.\n`;
59
+ return `# LLM Wiki Index\n\nGenerated by llm-wiki-kit.\n\n## Overview\n\nThis is the navigation map for the project living wiki. Keep it short and useful.\n\n## Main Areas\n\n- [Memory](memory.md) - short hot index and durable entry points.\n- [Sources](sources/) - source summaries and source IDs.\n- [Concepts](concepts/) - reusable concepts and terminology.\n- [Entities](entities/) - people, systems, modules, vendors, services, and tools.\n- [Decisions](decisions/) - decisions and rationale.\n- [Architecture](architecture/) - system structure and data/control flow.\n- [Debugging](debugging/) - root causes, fixes, verification evidence.\n- [Context](context/) - session continuity and project memory.\n- [Queries](queries/) - durable answers to useful questions.\n\n## Operating Notes\n\n- Start from memory and this index before broad project questions.\n- Read 3-7 relevant pages first; inspect raw sources only when precision matters.\n- Add links using \`[[page-or-topic]]\` when pages become related.\n\n<!-- llm-wiki-kit:index-start -->\n## Generated Page Map\n\nRun \`llm-wiki consolidate --workspace <project>\` to refresh this generated block.\n<!-- llm-wiki-kit:index-end -->\n`;
60
+ }
61
+
62
+ export function memoryPage() {
63
+ return `---
64
+ title: "LLM Wiki Memory"
65
+ type: "context"
66
+ source_ids: []
67
+ status: "draft"
68
+ last_updated: "unknown"
69
+ confidence: "medium"
70
+ memory_type: "semantic"
71
+ importance: 5
72
+ last_verified: "unknown"
73
+ supersedes: []
74
+ superseded_by: []
75
+ ---
76
+
77
+ # LLM Wiki Memory
78
+
79
+ Short hot index for project memory. Keep this page compact enough for hook context. Link to deeper pages instead of copying long notes.
80
+
81
+ ## Current Focus
82
+
83
+ - Add the current project focus here when it becomes durable.
84
+
85
+ ## Durable Entry Points
86
+
87
+ - [Index](index.md)
88
+ - [Log](log.md)
89
+
90
+ <!-- llm-wiki-kit:memory-start -->
91
+ ## Generated Memory Map
92
+
93
+ Run \`llm-wiki consolidate --workspace <project>\` to refresh this generated block.
94
+ <!-- llm-wiki-kit:memory-end -->
95
+ `;
13
96
  }
14
97
 
15
98
  export function logPage() {
@@ -18,10 +101,10 @@ export function logPage() {
18
101
 
19
102
  export function procedure(name) {
20
103
  const procedures = {
21
- 'ingest.md': `# Ingest Procedure\n\n1. Read \`wiki/index.md\` first.\n2. Inspect new material under \`raw/inbox/\` or \`raw/sources/\`.\n3. Create or update \`wiki/sources/<slug>.md\` for each source.\n4. Update related concept, entity, decision, architecture, debugging, or context pages.\n5. Prefer updating existing pages over creating duplicates.\n6. Add source references and confidence.\n7. Update \`wiki/index.md\` and append to \`wiki/log.md\`.\n`,
22
- 'query.md': `# Query Procedure\n\n1. Start from \`wiki/index.md\`.\n2. Search \`wiki/\` for relevant pages.\n3. Read the smallest useful set of pages first.\n4. Use raw sources only when exact evidence matters.\n5. Separate verified facts from inference.\n6. Save reusable answers into \`wiki/queries/\` and link them from related pages.\n`,
23
- 'lint.md': `# Lint Procedure\n\nCheck for stale pages, orphan pages, broken wiki links, missing sources, duplicate concepts, unsupported claims, and unresolved contradictions. Prefer producing a review report before automatic edits.\n`,
24
- 'security.md': `# Security Procedure\n\n- Preserve useful work context for the local project wiki.\n- Do not block reads or tool calls only because they look sensitive.\n- Redact authentication values such as tokens, passwords, and private keys before writing hook payloads or summaries.\n- Full raw transcript capture is disabled by default and must be explicitly enabled by project policy.\n`,
104
+ 'ingest.md': `# Ingest Procedure\n\n1. Read \`wiki/memory.md\` and \`wiki/index.md\` first.\n2. Inspect new material under \`raw/inbox/\` or \`raw/sources/\`.\n3. Create or update \`wiki/sources/<slug>.md\` for each source.\n4. Update related concept, entity, decision, architecture, debugging, or context pages.\n5. Prefer updating existing pages over creating duplicates.\n6. Add source references, confidence, memory type, importance, and verification status.\n7. Update \`wiki/memory.md\` or run \`llm-wiki consolidate\` when durable entry points change.\n8. Update \`wiki/index.md\` and append to \`wiki/log.md\`.\n`,
105
+ 'query.md': `# Query Procedure\n\n1. Start from \`llm-wiki context \"<query>\"\` or read \`wiki/memory.md\` and \`wiki/index.md\`.\n2. Use \`--limit\` or \`--no-expand\` when you need a narrower context pack.\n3. Search \`wiki/\` for relevant pages.\n4. Read the smallest useful set of pages first.\n5. Use raw sources only when exact evidence matters.\n6. Separate verified facts from inference.\n7. Save reusable answers into \`wiki/queries/\` and link them from related pages.\n`,
106
+ 'lint.md': `# Lint Procedure\n\nRun \`llm-wiki lint --workspace <project>\` to check for stale pages, orphan pages, broken wiki/Markdown links, unsafe source IDs, secret-like content, missing sources, duplicate concepts, unsupported claims, and unresolved contradictions. Prefer producing a review report before automatic edits.\n`,
107
+ 'security.md': `# Security Procedure\n\n- Preserve useful work context for the local project wiki.\n- Do not block reads or tool calls only because they look sensitive.\n- Redact authentication values such as tokens, passwords, bearer credentials, and private keys before writing hook payloads, summaries, or context packs.\n- Run \`llm-wiki lint --workspace <project>\` when sensitive material might have entered wiki pages; secret-like content is reported as an error.\n- Full raw transcript capture is disabled by default and must be explicitly enabled by project policy.\n`,
25
108
  };
26
109
  return procedures[name] || '';
27
110
  }
package/src/update.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { spawnSync } from 'child_process';
2
- import { resolve } from 'path';
3
- import { appendWikiLog, bootstrapProject } from './project.js';
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,6 +35,10 @@ 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
  }
@@ -60,6 +65,7 @@ export async function postUpdate(options = {}) {
60
65
  ...options,
61
66
  workspace,
62
67
  replaceHooks: true,
68
+ noProject: true,
63
69
  });
64
70
 
65
71
  if (options.all) {
@@ -69,7 +75,7 @@ export async function postUpdate(options = {}) {
69
75
  const projectResult = options.noProject
70
76
  ? null
71
77
  : await applyProjectTemplateUpdate(projectRoot);
72
- if (!options.noProject) {
78
+ if (!options.noProject && await hasProjectWiki(projectRoot)) {
73
79
  await appendWikiLog(projectRoot, `llm-wiki-kit post-update applied runtime ${runtimeVersion()}; changed templates: ${projectResult.changed.length}`);
74
80
  }
75
81
  projects.push({
@@ -89,7 +95,7 @@ export async function postUpdate(options = {}) {
89
95
  ? null
90
96
  : await applyProjectTemplateUpdate(workspace);
91
97
 
92
- if (!options.noProject) {
98
+ if (!options.noProject && await hasProjectWiki(workspace)) {
93
99
  await appendWikiLog(workspace, `llm-wiki-kit post-update applied runtime ${runtimeVersion()}; changed templates: ${projectResult.changed.length}`);
94
100
  }
95
101
 
@@ -0,0 +1,271 @@
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 { hasSecretLikeText } from './redaction.js';
5
+ import {
6
+ buildAliasMap,
7
+ buildWikiGraph,
8
+ collectWikiPages,
9
+ normalizeTarget,
10
+ resolveWikiLink,
11
+ wikiRoot,
12
+ } from './wiki-model.js';
13
+
14
+ const VALID_TYPES = new Set([
15
+ 'source',
16
+ 'concept',
17
+ 'entity',
18
+ 'decision',
19
+ 'architecture',
20
+ 'debugging',
21
+ 'context',
22
+ 'query',
23
+ 'session-log',
24
+ 'convention',
25
+ ]);
26
+ const VALID_STATUS = new Set(['draft', 'reviewed', 'stale']);
27
+ const VALID_CONFIDENCE = new Set(['high', 'medium', 'low']);
28
+ const VALID_MEMORY_TYPES = new Set(['semantic', 'episodic', 'procedural']);
29
+ const CORE_PAGES = new Set(['wiki/index.md', 'wiki/log.md', 'wiki/memory.md']);
30
+
31
+ function issue(severity, code, path, message) {
32
+ return { severity, code, path, message };
33
+ }
34
+
35
+ function isDateLike(value) {
36
+ return value === 'unknown' || /^\d{4}-\d{2}-\d{2}$/.test(String(value || ''));
37
+ }
38
+
39
+ function relativeMarkdownTarget(page, link) {
40
+ return resolve(dirname(page.absolutePath), link.path);
41
+ }
42
+
43
+ function isOutsideProject(projectRoot, absolutePath) {
44
+ const root = resolve(projectRoot);
45
+ const target = resolve(absolutePath);
46
+ const rel = relative(root, target);
47
+ return rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel);
48
+ }
49
+
50
+ function invalidSourceId(sourceId) {
51
+ const id = String(sourceId || '').trim().replace(/\\/g, '/');
52
+ if (!id) return true;
53
+ if (id.startsWith('/') || /^[a-z][a-z0-9+.-]*:/i.test(id)) return true;
54
+ return id.split('/').includes('..');
55
+ }
56
+
57
+ function sourceCandidates(projectRoot, sourceId) {
58
+ const id = String(sourceId || '').trim();
59
+ if (!id) return [];
60
+ const rawSources = join(projectRoot, 'llm-wiki', 'raw', 'sources');
61
+ const rawInbox = join(projectRoot, 'llm-wiki', 'raw', 'inbox');
62
+ const wikiSources = join(projectRoot, 'llm-wiki', 'wiki', 'sources');
63
+ const candidates = [
64
+ join(rawSources, id),
65
+ join(rawSources, `${id}.md`),
66
+ join(rawSources, `${id}.txt`),
67
+ join(rawInbox, id),
68
+ join(rawInbox, `${id}.md`),
69
+ join(wikiSources, id),
70
+ join(wikiSources, `${id}.md`),
71
+ ];
72
+ if (id.includes('/') || id.includes('\\')) {
73
+ candidates.push(join(projectRoot, id));
74
+ candidates.push(join(projectRoot, 'llm-wiki', id));
75
+ }
76
+ return candidates.map((candidate) => resolve(candidate))
77
+ .filter((candidate) => !isOutsideProject(projectRoot, candidate));
78
+ }
79
+
80
+ async function sourceExists(projectRoot, sourceId) {
81
+ for (const candidate of sourceCandidates(projectRoot, sourceId)) {
82
+ if (await pathExistsNormalized(candidate)) return true;
83
+ }
84
+ return false;
85
+ }
86
+
87
+ function titleKey(page) {
88
+ return normalizeTarget(page.title);
89
+ }
90
+
91
+ function isOrphanCandidate(page) {
92
+ if (CORE_PAGES.has(page.rel)) return false;
93
+ if (page.rel.startsWith('wiki/queries/')) return false;
94
+ if (page.rel.startsWith('wiki/context/')) return false;
95
+ return true;
96
+ }
97
+
98
+ async function pathExistsNormalized(absolutePath) {
99
+ if (await exists(absolutePath)) return true;
100
+ const target = resolve(absolutePath);
101
+ const parsed = parse(target);
102
+ const segments = target.slice(parsed.root.length).split(sep).filter(Boolean);
103
+ let current = parsed.root || sep;
104
+ for (const segment of segments) {
105
+ let entries = [];
106
+ try {
107
+ entries = await readdir(current);
108
+ } catch {
109
+ return false;
110
+ }
111
+ const match = entries.find((entry) => entry.normalize('NFC') === segment.normalize('NFC'));
112
+ if (!match) return false;
113
+ current = join(current, match);
114
+ }
115
+ return true;
116
+ }
117
+
118
+ export async function runLint(projectRoot, options = {}) {
119
+ const issues = [];
120
+ const root = wikiRoot(projectRoot);
121
+ if (!(await exists(root))) {
122
+ issues.push(issue('error', 'missing-wiki', 'llm-wiki/wiki', 'wiki directory does not exist'));
123
+ return lintResult(projectRoot, [], issues);
124
+ }
125
+
126
+ const pages = await collectWikiPages(projectRoot, {
127
+ maxFiles: options.maxFiles || 1000,
128
+ maxChars: options.maxChars || 75000,
129
+ });
130
+ const byRel = new Map(pages.map((page) => [page.rel, page]));
131
+ const aliasMap = buildAliasMap(pages);
132
+ const graph = buildWikiGraph(pages);
133
+
134
+ for (const rel of ['wiki/index.md', 'wiki/log.md', 'wiki/memory.md']) {
135
+ if (!byRel.has(rel)) {
136
+ issues.push(issue(rel === 'wiki/memory.md' ? 'warning' : 'error', 'missing-core-page', rel, 'core wiki page is missing'));
137
+ }
138
+ }
139
+
140
+ for (const [alias, matches] of aliasMap.entries()) {
141
+ if (matches.length > 1 && alias.length > 0) {
142
+ issues.push(issue('warning', 'duplicate-alias', matches.join(', '), `alias "${alias}" maps to multiple pages`));
143
+ }
144
+ }
145
+
146
+ const titles = new Map();
147
+ for (const page of pages) {
148
+ const key = titleKey(page);
149
+ if (!key) continue;
150
+ if (!titles.has(key)) titles.set(key, []);
151
+ titles.get(key).push(page.rel);
152
+ }
153
+ for (const [key, matches] of titles.entries()) {
154
+ if (matches.length > 1) {
155
+ issues.push(issue('warning', 'duplicate-title', matches.join(', '), `title "${key}" appears on multiple pages`));
156
+ }
157
+ }
158
+
159
+ for (const page of pages) {
160
+ if (hasSecretLikeText(page.content)) {
161
+ issues.push(issue('error', 'secret-like-content', page.rel, 'page contains token, credential, private-key, or secret-like text'));
162
+ }
163
+
164
+ if (!CORE_PAGES.has(page.rel) && !page.hasFrontmatter) {
165
+ issues.push(issue('warning', 'missing-frontmatter', page.rel, 'curated wiki page should have YAML frontmatter'));
166
+ continue;
167
+ }
168
+
169
+ if (page.hasFrontmatter) {
170
+ if (!page.type) issues.push(issue('warning', 'missing-type', page.rel, 'frontmatter is missing type'));
171
+ if (page.type && !VALID_TYPES.has(page.type)) issues.push(issue('error', 'invalid-type', page.rel, `invalid type: ${page.type}`));
172
+ if (!page.status) issues.push(issue('warning', 'missing-status', page.rel, 'frontmatter is missing status'));
173
+ if (page.status && !VALID_STATUS.has(page.status)) issues.push(issue('error', 'invalid-status', page.rel, `invalid status: ${page.status}`));
174
+ if (!page.confidence) issues.push(issue('warning', 'missing-confidence', page.rel, 'frontmatter is missing confidence'));
175
+ if (page.confidence && !VALID_CONFIDENCE.has(page.confidence)) issues.push(issue('error', 'invalid-confidence', page.rel, `invalid confidence: ${page.confidence}`));
176
+ if (page.frontmatter.memory_type && !VALID_MEMORY_TYPES.has(page.frontmatter.memory_type)) {
177
+ issues.push(issue('error', 'invalid-memory-type', page.rel, `invalid memory_type: ${page.frontmatter.memory_type}`));
178
+ }
179
+ if (page.frontmatter.importance !== undefined) {
180
+ const importance = Number(page.frontmatter.importance);
181
+ if (!Number.isInteger(importance) || importance < 1 || importance > 5) {
182
+ issues.push(issue('error', 'invalid-importance', page.rel, 'importance must be an integer from 1 to 5'));
183
+ }
184
+ }
185
+ if (page.frontmatter.last_updated && !isDateLike(page.frontmatter.last_updated)) {
186
+ issues.push(issue('warning', 'invalid-last-updated', page.rel, `last_updated should be YYYY-MM-DD or unknown: ${page.frontmatter.last_updated}`));
187
+ }
188
+ if (page.frontmatter.last_verified && !isDateLike(page.frontmatter.last_verified)) {
189
+ issues.push(issue('warning', 'invalid-last-verified', page.rel, `last_verified should be YYYY-MM-DD or unknown: ${page.frontmatter.last_verified}`));
190
+ }
191
+ if (page.status === 'stale') {
192
+ issues.push(issue('warning', 'stale-page', page.rel, 'page is marked stale'));
193
+ }
194
+ }
195
+
196
+ for (const link of page.wikilinks) {
197
+ const resolved = resolveWikiLink(aliasMap, link.raw, page);
198
+ const matches = aliasMap.get(normalizeTarget(link.target)) || [];
199
+ if (!resolved && matches.length === 0) {
200
+ issues.push(issue('error', 'broken-wikilink', page.rel, `unresolved wikilink: [[${link.raw}]]`));
201
+ } else if (!resolved && matches.length > 1) {
202
+ issues.push(issue('error', 'ambiguous-wikilink', page.rel, `ambiguous wikilink: [[${link.raw}]] -> ${matches.join(', ')}`));
203
+ }
204
+ }
205
+
206
+ for (const link of page.markdownLinks) {
207
+ const target = relativeMarkdownTarget(page, link);
208
+ if (isOutsideProject(projectRoot, target)) {
209
+ issues.push(issue('warning', 'outside-project-link', page.rel, `markdown link points outside project: ${link.raw}`));
210
+ } else if (!(await pathExistsNormalized(target))) {
211
+ issues.push(issue('error', 'broken-markdown-link', page.rel, `missing markdown link target: ${link.raw}`));
212
+ }
213
+ }
214
+
215
+ for (const sourceId of page.sourceIds) {
216
+ if (invalidSourceId(sourceId)) {
217
+ issues.push(issue('error', 'invalid-source-id', page.rel, 'source_id points outside the project or uses unsupported syntax'));
218
+ continue;
219
+ }
220
+ if (!(await sourceExists(projectRoot, sourceId))) {
221
+ issues.push(issue('warning', 'missing-source', page.rel, `source_id has no matching raw/wiki source file: ${sourceId}`));
222
+ }
223
+ }
224
+ }
225
+
226
+ const memoryText = await readText(join(projectRoot, 'llm-wiki', 'wiki', 'memory.md'), '');
227
+ if (Buffer.byteLength(memoryText, 'utf8') > 25000) {
228
+ issues.push(issue('warning', 'memory-too-large', 'wiki/memory.md', 'memory.md is larger than the hook excerpt budget'));
229
+ }
230
+ for (const page of pages.filter(isOrphanCandidate)) {
231
+ const backlinks = graph.backlinks.get(page.rel);
232
+ if (!backlinks || backlinks.size === 0) {
233
+ issues.push(issue('warning', 'orphan-page', page.rel, 'page is not linked from memory/index and has no backlinks'));
234
+ }
235
+ }
236
+
237
+ return lintResult(projectRoot, pages, issues);
238
+ }
239
+
240
+ function lintResult(projectRoot, pages, issues) {
241
+ const errorCount = issues.filter((item) => item.severity === 'error').length;
242
+ const warningCount = issues.filter((item) => item.severity === 'warning').length;
243
+ return {
244
+ workspace: projectRoot,
245
+ ok: errorCount === 0,
246
+ pages: pages.length,
247
+ issueCount: issues.length,
248
+ errorCount,
249
+ warningCount,
250
+ issues,
251
+ };
252
+ }
253
+
254
+ export function formatLintResult(result) {
255
+ const lines = [
256
+ 'llm-wiki lint',
257
+ `- workspace: ${result.workspace}`,
258
+ `- pages: ${result.pages}`,
259
+ `- errors: ${result.errorCount}`,
260
+ `- warnings: ${result.warningCount}`,
261
+ ];
262
+ if (result.issues.length === 0) {
263
+ lines.push('- result: ok');
264
+ return lines.join('\n');
265
+ }
266
+ lines.push('', 'Issues:');
267
+ for (const item of result.issues) {
268
+ lines.push(`- ${item.severity} ${item.code} ${item.path}: ${item.message}`);
269
+ }
270
+ return lines.join('\n');
271
+ }