llm-wiki-kit 0.1.6 → 0.1.8

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/doctor.js CHANGED
@@ -9,27 +9,27 @@ function nodeMajor() {
9
9
  return Number.parseInt(process.versions.node.split('.')[0], 10);
10
10
  }
11
11
 
12
- export async function runDoctor() {
12
+ export async function runDoctor(options = {}) {
13
13
  const checks = [];
14
- const add = (name, ok, detail = '') => checks.push({ name, ok, detail });
15
- const stat = await status();
14
+ const add = (id, name, ok, detail = '') => checks.push({ id, name, ok, detail });
15
+ const stat = await status(options);
16
16
 
17
- add('node >= 20', nodeMajor() >= 20, process.version);
18
- add('runtime version detected', Boolean(stat.runtimeVersion), stat.runtimeVersion || 'unknown');
19
- add('llm-wiki bin exists', await exists(stat.binPath), stat.binPath);
20
- add('runtime installed from npm', stat.installSource === 'npm', `${stat.installSource}; npm install -g does not update source checkouts`);
21
- add('llm-wiki command resolves to current runtime', stat.commandMatchesRuntime, stat.commandPath ? `command=${stat.commandPath}; runtime=${stat.binPath}` : 'command not found on PATH');
22
- add('Codex hook installed', stat.codexInstalled, stat.codexHooksPath);
23
- add('Claude hook installed', stat.claudeInstalled, stat.claudeSettingsPath);
24
- add('project templates current', stat.project.managedFilesCurrent, stat.project.statePath);
25
- add('codex command available', spawnSync('codex', ['--version'], { encoding: 'utf8' }).status === 0, 'codex --version');
26
- add('claude command available', spawnSync('claude', ['--version'], { encoding: 'utf8' }).status === 0, 'claude --version');
27
- add('state directory writable', await canWrite(join(kitDataDir(), '.doctor')), kitDataDir());
28
- add('docs present', await docsPresent(), 'README.md and docs/');
29
- add('sample hook roundtrip', await sampleHookRoundtrip(stat.binPath), 'UserPromptSubmit fixture');
17
+ add('node', 'node >= 20', nodeMajor() >= 20, process.version);
18
+ add('runtime-version', 'runtime version detected', Boolean(stat.runtimeVersion), stat.runtimeVersion || 'unknown');
19
+ add('runtime-bin', 'llm-wiki bin exists', await exists(stat.binPath), stat.binPath);
20
+ add('install-source', 'runtime installed from npm', stat.installSource === 'npm', `${stat.installSource}; npm install -g does not update source checkouts`);
21
+ add('command-path', 'llm-wiki command resolves to current runtime', stat.commandMatchesRuntime, stat.commandPath ? `command=${stat.commandPath}; runtime=${stat.binPath}` : 'command not found on PATH');
22
+ add('codex-hook', 'Codex hook installed', stat.codexInstalled, stat.codexHooksPath);
23
+ add('claude-hook', 'Claude hook installed', stat.claudeInstalled, stat.claudeSettingsPath);
24
+ add('project-templates', 'project templates current', stat.project.managedFilesCurrent, stat.project.statePath);
25
+ add('codex-command', 'codex command available', spawnSync('codex', ['--version'], { encoding: 'utf8' }).status === 0, 'codex --version');
26
+ add('claude-command', 'claude command available', spawnSync('claude', ['--version'], { encoding: 'utf8' }).status === 0, 'claude --version');
27
+ add('state-writable', 'state directory writable', await canWrite(join(kitDataDir(), '.doctor')), kitDataDir());
28
+ add('docs', 'docs present', await docsPresent(), 'README.md and docs/');
29
+ add('sample-hook', 'sample hook roundtrip', await sampleHookRoundtrip(stat.binPath), 'UserPromptSubmit fixture');
30
30
 
31
31
  const allOk = checks.every((check) => check.ok);
32
- return { ok: allOk, checks };
32
+ return { ok: allOk, checks, workspace: stat.workspace, status: stat };
33
33
  }
34
34
 
35
35
  async function docsPresent() {
@@ -84,8 +84,38 @@ export function formatDoctor(result) {
84
84
  lines.push(`- ${check.ok ? 'PASS' : 'WARN'} ${check.name}${check.detail ? ` (${check.detail})` : ''}`);
85
85
  }
86
86
  if (!result.ok) {
87
- lines.push('');
88
- lines.push('Run ./install.sh --workspace /apps --profile standard to install hooks, then restart Codex/Claude Code sessions.');
87
+ const remediation = doctorRemediation(result);
88
+ if (remediation.length > 0) {
89
+ lines.push('', 'Suggested fix:');
90
+ for (const item of remediation) lines.push(`- ${item}`);
91
+ }
89
92
  }
90
93
  return lines.join('\n');
91
94
  }
95
+
96
+ function failed(result, id) {
97
+ return (result.checks || []).some((check) => check.id === id && !check.ok);
98
+ }
99
+
100
+ function doctorRemediation(result) {
101
+ const workspace = result.workspace || process.cwd();
102
+ const suggestions = [];
103
+ if (failed(result, 'install-source')) {
104
+ suggestions.push(`normal install: npm install -g llm-wiki-kit@latest && llm-wiki install --workspace ${workspace} --profile standard`);
105
+ suggestions.push(`source checkout development: ./install.sh --workspace ${workspace} --profile standard`);
106
+ }
107
+ if (failed(result, 'command-path')) {
108
+ const local = result.status?.localBin;
109
+ if (result.status?.installSource === 'npm' && local?.exists && local.managed) {
110
+ suggestions.push(`remove stale managed local shim if it still shadows npm: rm -f ${local.path} && hash -r`);
111
+ }
112
+ suggestions.push(`reconnect command and hooks: llm-wiki install --workspace ${workspace} --profile standard`);
113
+ }
114
+ if (failed(result, 'codex-hook') || failed(result, 'claude-hook')) {
115
+ suggestions.push(`install hooks: llm-wiki install --workspace ${workspace} --profile standard, then restart Codex/Claude Code sessions`);
116
+ }
117
+ if (failed(result, 'project-templates')) {
118
+ suggestions.push(`refresh managed project templates: llm-wiki post-update --workspace ${workspace}`);
119
+ }
120
+ return [...new Set(suggestions)];
121
+ }
package/src/hook.js CHANGED
@@ -3,7 +3,8 @@ import { bootstrapProject, appendContextNote, appendLiveQa, appendSessionEnvelop
3
3
  import { applyProjectTemplateUpdate, inspectProjectState } from './project-state.js';
4
4
  import { recordProject } from './projects.js';
5
5
  import { summarizeForStorage } from './redaction.js';
6
- import { buildEntryFromState, rememberQuestion, rememberTool } from './state.js';
6
+ import { buildEntryFromState, clearTurnState, rememberQuestion, rememberTool } from './state.js';
7
+ import { relative } from 'path';
7
8
 
8
9
  async function readStdinJson() {
9
10
  const chunks = [];
@@ -45,7 +46,9 @@ async function autoUpdateManagedProject(projectRoot, eventName) {
45
46
  const state = await inspectProjectState(projectRoot);
46
47
  if (state.runtimeUpToDateWithProject && state.managedFilesCurrent) return;
47
48
  const result = await applyProjectTemplateUpdate(projectRoot);
48
- await appendWikiLog(projectRoot, `llm-wiki-kit auto-update applied runtime ${result.runtimeVersion}; changed templates: ${result.changed.length}; skipped templates: ${result.skipped.length}`);
49
+ if (result.changed.length > 0) {
50
+ await appendWikiLog(projectRoot, `llm-wiki-kit auto-update applied runtime ${result.runtimeVersion}; changed templates: ${result.changed.length}; skipped templates: ${result.skipped.length}`);
51
+ }
49
52
  }
50
53
 
51
54
  export async function handleHook(provider, explicitEvent) {
@@ -96,7 +99,10 @@ export async function handleHook(provider, explicitEvent) {
96
99
  await appendLiveQa(projectRoot, entry);
97
100
  const queryPath = await writeQueryPage(projectRoot, entry);
98
101
  const decisionPath = await writeDecisionPage(projectRoot, entry);
99
- await appendWikiLog(projectRoot, `captured ${eventName}${queryPath ? `; query=${queryPath}` : ''}${decisionPath ? `; decision=${decisionPath}` : ''}`);
102
+ await appendWikiLog(projectRoot, `captured ${eventName}${queryPath ? `; query=${relative(projectRoot, queryPath)}` : ''}${decisionPath ? `; decision=${relative(projectRoot, decisionPath)}` : ''}`);
103
+ }
104
+ if (eventName === 'Stop' || eventName === 'SessionEnd') {
105
+ await clearTurnState(projectRoot, payload).catch(() => {});
100
106
  }
101
107
  return {};
102
108
  }
package/src/install.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { realpathSync } from 'fs';
2
- import { chmod } from 'fs/promises';
2
+ import { chmod, lstat, readlink, unlink } from 'fs/promises';
3
3
  import { spawnSync } from 'child_process';
4
4
  import { join, resolve } from 'path';
5
5
  import { CLAUDE_EVENTS, CODEX_EVENTS, KIT_NAME } from './constants.js';
6
- import { backupFile, ensureDir, exists, homeDir, readJson, safeSymlink, writeJson } from './fs-utils.js';
6
+ import { backupFile, exists, homeDir, readJson, safeSymlink, writeJson } from './fs-utils.js';
7
7
  import { inspectProjectState } from './project-state.js';
8
8
  import { bootstrapProject } from './project.js';
9
9
  import { recordProject } from './projects.js';
@@ -34,6 +34,91 @@ function realpathOrOriginal(path) {
34
34
  }
35
35
  }
36
36
 
37
+ function sameResolvedPath(left, right) {
38
+ const resolvedLeft = realpathOrOriginal(left);
39
+ const resolvedRight = realpathOrOriginal(right);
40
+ return Boolean(resolvedLeft && resolvedRight && resolvedLeft === resolvedRight);
41
+ }
42
+
43
+ function samePath(left, right) {
44
+ if (!left || !right) return false;
45
+ return resolve(left) === resolve(right);
46
+ }
47
+
48
+ function isKitPath(path) {
49
+ return String(path || '').replace(/\\/g, '/').includes('/llm-wiki-kit/');
50
+ }
51
+
52
+ async function inspectLocalBin(localBinPath) {
53
+ try {
54
+ const stat = await lstat(localBinPath);
55
+ const symlink = stat.isSymbolicLink();
56
+ const target = symlink ? await readlink(localBinPath).catch(() => null) : null;
57
+ const resolved = realpathOrOriginal(localBinPath);
58
+ return {
59
+ path: localBinPath,
60
+ exists: true,
61
+ symlink,
62
+ target,
63
+ resolved,
64
+ managed: symlink && (isKitPath(target) || isKitPath(resolved)),
65
+ matchesRuntime: sameResolvedPath(localBinPath, binPath),
66
+ };
67
+ } catch {
68
+ return {
69
+ path: localBinPath,
70
+ exists: false,
71
+ symlink: false,
72
+ target: null,
73
+ resolved: null,
74
+ managed: false,
75
+ matchesRuntime: false,
76
+ };
77
+ }
78
+ }
79
+
80
+ async function reconcileLocalBin(localBinPath) {
81
+ const commandPathsBefore = llmWikiCommandPaths();
82
+ const localBefore = await inspectLocalBin(localBinPath);
83
+ const alternateRuntimeCommand = commandPathsBefore.some((path) => (
84
+ !samePath(path, localBinPath) && sameResolvedPath(path, binPath)
85
+ ));
86
+ let action = 'kept';
87
+
88
+ if (alternateRuntimeCommand) {
89
+ if (localBefore.exists && localBefore.managed) {
90
+ await backupFile(localBinPath, 'local-bin-llm-wiki');
91
+ await unlink(localBinPath).catch(() => {});
92
+ action = 'removed-shadowing-shim';
93
+ } else if (localBefore.exists) {
94
+ action = 'left-unmanaged-local-bin';
95
+ } else {
96
+ action = 'skipped-npm-command-available';
97
+ }
98
+ } else if (localBefore.matchesRuntime) {
99
+ action = 'kept-current-shim';
100
+ } else {
101
+ if (localBefore.exists) {
102
+ await backupFile(localBinPath, 'local-bin-llm-wiki');
103
+ action = 'replaced-local-shim';
104
+ } else {
105
+ action = 'created-local-shim';
106
+ }
107
+ await safeSymlink(binPath, localBinPath);
108
+ }
109
+
110
+ const commandPathsAfter = llmWikiCommandPaths();
111
+ const localAfter = await inspectLocalBin(localBinPath);
112
+ return {
113
+ ...localAfter,
114
+ action,
115
+ alternateRuntimeCommand,
116
+ commandPathsBefore,
117
+ commandPathsAfter,
118
+ commandPath: commandPathsAfter[0] || null,
119
+ };
120
+ }
121
+
37
122
  function addHook(hooks, eventName, command, options = {}) {
38
123
  hooks[eventName] = Array.isArray(hooks[eventName]) ? hooks[eventName] : [];
39
124
  const already = JSON.stringify(hooks[eventName]).includes(command);
@@ -72,12 +157,8 @@ export async function install(options = {}) {
72
157
  const workspace = resolve(options.workspace || process.cwd());
73
158
  await chmod(binPath, 0o755).catch(() => {});
74
159
  const localBin = join(homeDir(), '.local', 'bin');
75
- await ensureDir(localBin);
76
160
  const localBinPath = join(localBin, 'llm-wiki');
77
- if (await exists(localBinPath)) {
78
- await backupFile(localBinPath, 'local-bin-llm-wiki');
79
- }
80
- await safeSymlink(binPath, localBinPath);
161
+ const localBinResult = await reconcileLocalBin(localBinPath);
81
162
  if (!options.noProject) {
82
163
  await bootstrapProject(workspace, { profile: options.profile || 'standard', recordState: true });
83
164
  await recordProject(workspace, 'install');
@@ -133,7 +214,12 @@ export async function install(options = {}) {
133
214
  workspace,
134
215
  runtimeVersion: runtimeVersion(),
135
216
  binPath,
136
- localBin: join(localBin, 'llm-wiki'),
217
+ localBin: localBinPath,
218
+ localBinAction: localBinResult.action,
219
+ localBinManaged: localBinResult.managed,
220
+ localBinMatchesRuntime: localBinResult.matchesRuntime,
221
+ commandPath: localBinResult.commandPath,
222
+ commandPaths: localBinResult.commandPathsAfter,
137
223
  changed,
138
224
  };
139
225
  }
@@ -176,7 +262,10 @@ export async function status(options = {}) {
176
262
  const commandPath = commandPaths[0] || null;
177
263
  const resolvedCommandPath = realpathOrOriginal(commandPath);
178
264
  const resolvedBinPath = realpathOrOriginal(binPath);
265
+ const localBinPath = join(homeDir(), '.local', 'bin', 'llm-wiki');
266
+ const localBin = await inspectLocalBin(localBinPath);
179
267
  return {
268
+ workspace,
180
269
  runtimeVersion: runtimeVersion(),
181
270
  packageRoot,
182
271
  installSource: detectInstallSource(),
@@ -184,6 +273,7 @@ export async function status(options = {}) {
184
273
  commandPath,
185
274
  commandPaths,
186
275
  commandMatchesRuntime: Boolean(resolvedCommandPath && resolvedBinPath && resolvedCommandPath === resolvedBinPath),
276
+ localBin,
187
277
  codexInstalled,
188
278
  claudeInstalled,
189
279
  hooksCurrent: codexInstalled && claudeInstalled,
@@ -27,6 +27,14 @@ function templateDescriptors() {
27
27
  path: join('llm-wiki', 'AGENTS.md'),
28
28
  content: llmWikiAgents(),
29
29
  mode: 'generated-file',
30
+ legacyContents: legacyLlmWikiAgentsContents(),
31
+ legacySignals: [
32
+ /^# LLM Wiki Agent Rules/m,
33
+ /Generated by llm-wiki-kit/m,
34
+ /## Purpose/m,
35
+ /## Core Rules/m,
36
+ /## Operations/m,
37
+ ],
30
38
  },
31
39
  {
32
40
  id: 'wiki-memory',
@@ -39,10 +47,179 @@ function templateDescriptors() {
39
47
  path: join('llm-wiki', 'procedures', name),
40
48
  content: procedure(name),
41
49
  mode: 'generated-file',
50
+ legacyContents: legacyProcedureContents(name),
51
+ legacySignals: legacyProcedureSignals(name),
42
52
  })),
43
53
  ];
44
54
  }
45
55
 
56
+ function legacyLlmWikiAgentsContents() {
57
+ return [
58
+ `# LLM Wiki Agent Rules
59
+
60
+ Generated by llm-wiki-kit <version>.
61
+
62
+ ## Purpose
63
+ Maintain a living Markdown LLM Wiki from immutable source files and redacted Codex/Claude Code session events.
64
+ These rules supersede older OMX/OMC/\`omx_wiki/\` LLM Wiki rules for this project.
65
+
66
+ ## Directories
67
+ - \`raw/\`: immutable or redacted source material. Never edit original source captures.
68
+ - \`wiki/\`: AI-maintained knowledge pages.
69
+ - \`outputs/\`: live Q&A summaries, reports, and generated briefs.
70
+ - \`procedures/\`: detailed operating rules for ingest, query, lint, and security.
71
+
72
+ ## Core Rules
73
+ - Never modify \`raw/\` source material except to append redacted session envelopes generated by hooks.
74
+ - Do not state unsupported claims as facts.
75
+ - Mark inference explicitly.
76
+ - Add \`source_ids\` or file references for important claims.
77
+ - Update \`wiki/index.md\` and \`wiki/log.md\` whenever new durable knowledge is created.
78
+ - Preserve contradictions in \`Contradictions\` or \`Open Questions\` instead of overwriting them.
79
+ - Preserve useful work context and redact authentication values before writing durable notes.
80
+
81
+ ## Page Format
82
+ Use YAML frontmatter when creating wiki pages:
83
+
84
+ \`\`\`yaml
85
+ ---
86
+ title: ""
87
+ type: "source | concept | entity | decision | architecture | debugging | context | query | session-log | convention"
88
+ source_ids: []
89
+ status: "draft | reviewed | stale"
90
+ last_updated: "YYYY-MM-DD"
91
+ confidence: "high | medium | low"
92
+ ---
93
+ \`\`\`
94
+
95
+ ## Operations
96
+ - ingest: read new raw files, create or update wiki pages, then update \`wiki/index.md\` and \`wiki/log.md\`.
97
+ - query: start from \`wiki/index.md\`, read relevant wiki pages, answer with source references, and save reusable answers.
98
+ - lint: find stale pages, orphan pages, broken wiki links, missing sources, duplicate concepts, contradictions, and missing links.`,
99
+ `# LLM Wiki Agent Rules
100
+
101
+ Generated by llm-wiki-kit <version>.
102
+
103
+ ## Purpose
104
+ Maintain a living Markdown LLM Wiki from immutable source files and redacted Codex/Claude Code session events.
105
+ These rules supersede older OMX/OMC/\`omx_wiki/\` LLM Wiki rules for this project.
106
+
107
+ ## Directories
108
+ - \`raw/\`: immutable or redacted source material. Never edit original source captures.
109
+ - \`wiki/\`: AI-maintained knowledge pages. \`wiki/memory.md\` is the short hot index injected into hook context.
110
+ - \`outputs/\`: live Q&A summaries, reports, and generated briefs.
111
+ - \`procedures/\`: detailed operating rules for ingest, query, lint, and security.
112
+
113
+ ## Core Rules
114
+ - Never modify \`raw/\` source material except to append redacted session envelopes generated by hooks.
115
+ - Do not state unsupported claims as facts.
116
+ - Mark inference explicitly.
117
+ - Add \`source_ids\` or file references for important claims.
118
+ - Update \`wiki/memory.md\`, \`wiki/index.md\`, and \`wiki/log.md\` whenever new durable knowledge changes the active project map.
119
+ - Preserve contradictions in \`Contradictions\` or \`Open Questions\` instead of overwriting them.
120
+ - Preserve useful work context and redact authentication values before writing durable notes.
121
+
122
+ ## Page Format
123
+ Use YAML frontmatter when creating wiki pages:
124
+
125
+ \`\`\`yaml
126
+ ---
127
+ title: ""
128
+ type: "source | concept | entity | decision | architecture | debugging | context | query | session-log | convention"
129
+ source_ids: []
130
+ status: "draft | reviewed | stale"
131
+ last_updated: "YYYY-MM-DD"
132
+ confidence: "high | medium | low"
133
+ memory_type: "semantic | episodic | procedural"
134
+ importance: 1
135
+ last_verified: "YYYY-MM-DD | unknown"
136
+ supersedes: []
137
+ superseded_by: []
138
+ ---
139
+ \`\`\`
140
+
141
+ ## Operations
142
+ - 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\`.
143
+ - query: start from \`llm-wiki context "<query>"\` or \`wiki/memory.md\`, read relevant wiki pages, answer with source references, and save reusable answers.
144
+ - 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.
145
+ - 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.`,
146
+ ];
147
+ }
148
+
149
+ function legacyProcedureContents(name) {
150
+ const early = {
151
+ 'ingest.md': `# Ingest Procedure
152
+
153
+ 1. Read \`wiki/index.md\` first.
154
+ 2. Inspect new material under \`raw/inbox/\` or \`raw/sources/\`.
155
+ 3. Create or update \`wiki/sources/<slug>.md\` for each source.
156
+ 4. Update related concept, entity, decision, architecture, debugging, or context pages.
157
+ 5. Prefer updating existing pages over creating duplicates.
158
+ 6. Add source references and confidence.
159
+ 7. Update \`wiki/index.md\` and append to \`wiki/log.md\`.`,
160
+ 'query.md': `# Query Procedure
161
+
162
+ 1. Start from \`wiki/index.md\`.
163
+ 2. Search \`wiki/\` for relevant pages.
164
+ 3. Read the smallest useful set of pages first.
165
+ 4. Use raw sources only when exact evidence matters.
166
+ 5. Separate verified facts from inference.
167
+ 6. Save reusable answers into \`wiki/queries/\` and link them from related pages.`,
168
+ 'lint.md': `# Lint Procedure
169
+
170
+ Check 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.`,
171
+ 'security.md': `# Security Procedure
172
+
173
+ - Never store credentials, tokens, private keys, \`.env\` contents, customer identifiers, or private personal data.
174
+ - Redact before writing hook payloads or summaries.
175
+ - Full raw transcript capture is disabled by default and must be explicitly enabled by project policy.
176
+ - If a file or prompt looks secret-bearing, do not persist it and ask for confirmation before reading further.`,
177
+ };
178
+ const layered = {
179
+ 'ingest.md': `# Ingest Procedure
180
+
181
+ 1. Read \`wiki/memory.md\` and \`wiki/index.md\` first.
182
+ 2. Inspect new material under \`raw/inbox/\` or \`raw/sources/\`.
183
+ 3. Create or update \`wiki/sources/<slug>.md\` for each source.
184
+ 4. Update related concept, entity, decision, architecture, debugging, or context pages.
185
+ 5. Prefer updating existing pages over creating duplicates.
186
+ 6. Add source references, confidence, memory type, importance, and verification status.
187
+ 7. Update \`wiki/memory.md\` or run \`llm-wiki consolidate\` when durable entry points change.
188
+ 8. Update \`wiki/index.md\` and append to \`wiki/log.md\`.`,
189
+ 'query.md': `# Query Procedure
190
+
191
+ 1. Start from \`llm-wiki context "<query>"\` or read \`wiki/memory.md\` and \`wiki/index.md\`.
192
+ 2. Use \`--limit\` or \`--no-expand\` when you need a narrower context pack.
193
+ 3. Search \`wiki/\` for relevant pages.
194
+ 4. Read the smallest useful set of pages first.
195
+ 5. Use raw sources only when exact evidence matters.
196
+ 6. Separate verified facts from inference.
197
+ 7. Save reusable answers into \`wiki/queries/\` and link them from related pages.`,
198
+ 'lint.md': `# Lint Procedure
199
+
200
+ Run \`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.`,
201
+ 'security.md': `# Security Procedure
202
+
203
+ - Preserve useful work context for the local project wiki.
204
+ - Do not block reads or tool calls only because they look sensitive.
205
+ - Redact authentication values such as tokens, passwords, bearer credentials, and private keys before writing hook payloads, summaries, or context packs.
206
+ - Run \`llm-wiki lint --workspace <project>\` when sensitive material might have entered wiki pages; secret-like content is reported as an error.
207
+ - Full raw transcript capture is disabled by default and must be explicitly enabled by project policy.`,
208
+ };
209
+ return [early[name], layered[name]].filter(Boolean);
210
+ }
211
+
212
+ function legacyProcedureSignals(name) {
213
+ const common = [/^# .+ Procedure/m];
214
+ const byName = {
215
+ 'ingest.md': [/^# Ingest Procedure/m, /wiki\/memory\.md/, /raw\/inbox|raw\/sources/],
216
+ 'query.md': [/^# Query Procedure/m, /llm-wiki context/, /Save reusable answers|reusable answers/],
217
+ 'lint.md': [/^# Lint Procedure/m, /llm-wiki lint/, /stale pages|orphan pages/],
218
+ 'security.md': [/^# Security Procedure/m, /Redact authentication values|token|private keys/, /raw transcript capture/],
219
+ };
220
+ return byName[name] || common;
221
+ }
222
+
46
223
  function emptyState() {
47
224
  return {
48
225
  schemaVersion: PROJECT_STATE_SCHEMA_VERSION,
@@ -79,18 +256,24 @@ export async function writeProjectState(projectRoot, state) {
79
256
  }
80
257
 
81
258
  function replaceMarkedBlock(current, replacement) {
259
+ if (markerStatus(current) !== 'complete') return null;
82
260
  const start = current.indexOf(ROOT_POLICY_START);
83
261
  const end = current.indexOf(ROOT_POLICY_END);
84
- if (start === -1 || end === -1 || end < start) return null;
85
262
  const afterEnd = end + ROOT_POLICY_END.length;
86
263
  return `${current.slice(0, start)}${replacement.trimStart().trimEnd()}${current.slice(afterEnd)}`;
87
264
  }
88
265
 
266
+ function markerCount(current, marker) {
267
+ return current.split(marker).length - 1;
268
+ }
269
+
89
270
  function markerStatus(current) {
90
271
  const start = current.indexOf(ROOT_POLICY_START);
91
272
  const end = current.indexOf(ROOT_POLICY_END);
92
- if (start === -1 && end === -1) return 'missing';
93
- if (start === -1 || end === -1 || end < start) return 'malformed';
273
+ const starts = markerCount(current, ROOT_POLICY_START);
274
+ const ends = markerCount(current, ROOT_POLICY_END);
275
+ if (starts === 0 && ends === 0) return 'missing';
276
+ if (starts !== 1 || ends !== 1 || end < start) return 'malformed';
94
277
  return 'complete';
95
278
  }
96
279
 
@@ -108,10 +291,39 @@ function isKnownGeneratedContent(text, descriptor) {
108
291
  return templateHash(text) === templateHash(descriptor.content);
109
292
  }
110
293
 
294
+ function normalizeGeneratedText(text) {
295
+ return String(text || '')
296
+ .replace(/\r\n/g, '\n')
297
+ .replace(/Generated by llm-wiki-kit [^\n]+/g, 'Generated by llm-wiki-kit <version>.')
298
+ .trim();
299
+ }
300
+
301
+ function isLegacyGeneratedContent(text, descriptor) {
302
+ if (descriptor.mode !== 'generated-file') return false;
303
+ if (!Array.isArray(descriptor.legacyContents) || descriptor.legacyContents.length === 0) return false;
304
+ const normalized = normalizeGeneratedText(text);
305
+ return descriptor.legacyContents.some((content) => normalizeGeneratedText(content) === normalized);
306
+ }
307
+
308
+ function hasLegacyGeneratedSignal(text, descriptor) {
309
+ if (!Array.isArray(descriptor.legacySignals) || descriptor.legacySignals.length === 0) return false;
310
+ return descriptor.legacySignals.every((pattern) => pattern.test(text));
311
+ }
312
+
313
+ function hasKitManagedSignal(text, descriptor) {
314
+ if (descriptor.mode === 'marker') return markerStatus(text) !== 'missing';
315
+ if (descriptor.mode === 'generated-file') {
316
+ return isLegacyGeneratedContent(text, descriptor) || hasLegacyGeneratedSignal(text, descriptor) || /Generated by llm-wiki-kit/.test(text);
317
+ }
318
+ return false;
319
+ }
320
+
111
321
  function canPatchDescriptor(state, descriptor, currentText, fileExists = true) {
112
322
  if (descriptor.mode === 'create-only') return !fileExists;
113
323
  if (descriptor.mode === 'marker') return replaceMarkedBlock(currentText, descriptor.content) !== null;
114
- return isRecordedManaged(state, descriptor, currentText) || isKnownGeneratedContent(currentText, descriptor);
324
+ return isRecordedManaged(state, descriptor, currentText) ||
325
+ isKnownGeneratedContent(currentText, descriptor) ||
326
+ isLegacyGeneratedContent(currentText, descriptor);
115
327
  }
116
328
 
117
329
  function desiredTextForDescriptor(descriptor, currentText, fileExists = true) {
@@ -125,6 +337,10 @@ async function createOnlyParentExists(projectRoot, descriptor) {
125
337
  return exists(dirname(join(projectRoot, descriptor.path)));
126
338
  }
127
339
 
340
+ async function descriptorParentExists(projectRoot, descriptor) {
341
+ return exists(dirname(join(projectRoot, descriptor.path)));
342
+ }
343
+
128
344
  export async function inspectProjectState(projectRoot) {
129
345
  const state = await readProjectState(projectRoot);
130
346
  const runtime = runtimeVersion();
@@ -139,10 +355,16 @@ export async function inspectProjectState(projectRoot) {
139
355
  const currentHash = fileExists ? templateHash(currentText) : null;
140
356
  const recorded = state.managedTemplates[descriptor.id] || null;
141
357
  const markerState = descriptor.mode === 'marker' && fileExists ? markerStatus(currentText) : null;
142
- const parentExists = await createOnlyParentExists(projectRoot, descriptor);
358
+ const parentExists = await descriptorParentExists(projectRoot, descriptor);
143
359
  const patchable = descriptor.mode === 'create-only' && !fileExists && !parentExists
144
360
  ? false
145
- : (markerState === 'malformed' || canPatchDescriptor(state, descriptor, currentText, fileExists));
361
+ : (!fileExists && descriptor.mode === 'generated-file'
362
+ ? parentExists
363
+ : (markerState === 'malformed' ? false : canPatchDescriptor(state, descriptor, currentText, fileExists)));
364
+ const needsAttention = (fileExists || recorded) &&
365
+ !patchable &&
366
+ currentText !== (desiredText || '') &&
367
+ (fileExists ? hasKitManagedSignal(currentText, descriptor) : descriptor.mode !== 'create-only');
146
368
  const current = descriptor.mode === 'create-only'
147
369
  ? fileExists
148
370
  : fileExists && desiredText !== null && currentHash === templateHash(desiredText);
@@ -153,11 +375,12 @@ export async function inspectProjectState(projectRoot) {
153
375
  exists: fileExists,
154
376
  patchable,
155
377
  current,
378
+ needsAttention,
156
379
  currentHash,
157
380
  desiredHash,
158
381
  recordedHash: recorded?.hash || null,
159
382
  recordedVersion: recorded?.version || null,
160
- reason: markerState === 'malformed' ? 'malformed-marker' : null,
383
+ reason: markerState === 'malformed' ? 'malformed-marker' : (!fileExists ? 'missing' : null),
161
384
  });
162
385
  }
163
386
 
@@ -167,11 +390,31 @@ export async function inspectProjectState(projectRoot) {
167
390
  schemaVersion: state.schemaVersion,
168
391
  lastRuntimeVersionApplied: state.lastRuntimeVersionApplied,
169
392
  runtimeUpToDateWithProject: state.lastRuntimeVersionApplied === runtime,
170
- managedFilesCurrent: files.every((file) => file.current || !file.patchable),
393
+ managedFilesCurrent: files.every((file) => file.current || (!file.patchable && !file.needsAttention)),
171
394
  managedFiles: files,
172
395
  };
173
396
  }
174
397
 
398
+ export function formatProjectMaintenanceContext(inspection) {
399
+ const files = inspection?.managedFiles || [];
400
+ const attention = files.filter((file) => file.needsAttention);
401
+ const outdated = files.filter((file) => !file.current && file.patchable);
402
+ if (attention.length === 0 && outdated.length === 0) return '';
403
+
404
+ const lines = [
405
+ 'LLM Wiki maintenance note:',
406
+ '- 이전 버전의 llm-wiki-kit 규칙/문서가 남아 있을 수 있다.',
407
+ '- 확실히 kit가 관리하는 영역은 자동 갱신된다. 사용자 편집 가능성이 있는 문서는 덮어쓰지 말고 기존 내용을 보존한 채 현재 규칙에 맞게 자연스럽게 정리한다.',
408
+ ];
409
+ if (outdated.length > 0) {
410
+ lines.push(`- 자동 갱신 대상: ${outdated.map((file) => file.path).join(', ')}`);
411
+ }
412
+ if (attention.length > 0) {
413
+ lines.push(`- agent 확인 필요: ${attention.map((file) => file.path).join(', ')}`);
414
+ }
415
+ return lines.join('\n');
416
+ }
417
+
175
418
  export async function recordManagedTemplates(projectRoot) {
176
419
  const state = await readProjectState(projectRoot);
177
420
  const runtime = runtimeVersion();
@@ -211,7 +454,7 @@ export async function applyProjectTemplateUpdate(projectRoot, options = {}) {
211
454
  const absolutePath = join(projectRoot, descriptor.path);
212
455
  if (!(await exists(absolutePath))) {
213
456
  if (descriptor.mode === 'create-only') {
214
- if (!(await createOnlyParentExists(projectRoot, descriptor))) {
457
+ if (!(await descriptorParentExists(projectRoot, descriptor))) {
215
458
  skipped.push({ id: descriptor.id, path: descriptor.path, reason: 'missing-wiki' });
216
459
  continue;
217
460
  }
@@ -227,6 +470,19 @@ export async function applyProjectTemplateUpdate(projectRoot, options = {}) {
227
470
  };
228
471
  continue;
229
472
  }
473
+ if (descriptor.mode === 'generated-file' && await descriptorParentExists(projectRoot, descriptor)) {
474
+ if (!options.dryRun) {
475
+ await ensureDir(dirname(absolutePath));
476
+ await writeText(absolutePath, descriptor.content, 'utf8');
477
+ }
478
+ changed.push({ id: descriptor.id, path: descriptor.path });
479
+ state.managedTemplates[descriptor.id] = {
480
+ path: descriptor.path,
481
+ version: runtime,
482
+ hash: templateHash(descriptor.content),
483
+ };
484
+ continue;
485
+ }
230
486
  skipped.push({ id: descriptor.id, path: descriptor.path, reason: 'missing' });
231
487
  continue;
232
488
  }
package/src/project.js CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  import { LLM_WIKI_DIRS } from './constants.js';
12
12
  import { normalizeForStorage, redactText, summarizeForStorage } from './redaction.js';
13
13
  import { gitignore, indexPage, llmWikiAgents, logPage, memoryPage, procedure, rootAgentsPolicy } from './templates.js';
14
- import { recordManagedTemplates } from './project-state.js';
14
+ import { formatProjectMaintenanceContext, inspectProjectState, recordManagedTemplates } from './project-state.js';
15
15
  import { buildContextPack, formatContextPack, searchWiki as searchWikiWithIndex } from './wiki-search.js';
16
16
 
17
17
  export async function bootstrapProject(projectRoot, options = {}) {
@@ -117,7 +117,7 @@ export async function appendLiveQa(projectRoot, entry) {
117
117
  entry.verification || '(not captured)',
118
118
  '',
119
119
  '### Follow-up',
120
- entry.followUp || '(none captured)',
120
+ entry.followUp || '다음 작업에서 이 turn의 reusable fact가 있으면 기존 wiki 문서에 합치고, 일회성 기록은 이 Q&A에만 보존한다.',
121
121
  '',
122
122
  ].join('\n');
123
123
  await appendText(path, redactText(block, 12000));
@@ -163,5 +163,8 @@ export async function buildContextBrief(projectRoot, eventName, query = '') {
163
163
  includeLog: eventName === 'SessionStart',
164
164
  limit: 5,
165
165
  });
166
- return formatContextPack(pack);
166
+ const maintenance = await inspectProjectState(projectRoot)
167
+ .then(formatProjectMaintenanceContext)
168
+ .catch(() => '');
169
+ return [formatContextPack(pack), maintenance].filter(Boolean).join('\n\n');
167
170
  }