llm-wiki-kit 0.1.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 +132 -0
- package/bin/llm-wiki.js +7 -0
- package/docs/concepts.md +29 -0
- package/docs/integrations/claude-code.md +48 -0
- package/docs/integrations/codex.md +45 -0
- package/docs/operations.md +137 -0
- package/docs/research/baseline.md +18 -0
- package/docs/security.md +27 -0
- package/docs/troubleshooting.md +101 -0
- package/examples/hook-fixtures/stop.json +6 -0
- package/examples/hook-fixtures/user-prompt-submit.json +7 -0
- package/examples/minimal-project/AGENTS.md +10 -0
- package/install.sh +5 -0
- package/package.json +27 -0
- package/src/cli.js +183 -0
- package/src/constants.js +69 -0
- package/src/doctor.js +89 -0
- package/src/fs-utils.js +173 -0
- package/src/hook.js +133 -0
- package/src/install.js +164 -0
- package/src/migrate.js +57 -0
- package/src/project-state.js +231 -0
- package/src/project.js +204 -0
- package/src/redaction.js +68 -0
- package/src/state.js +73 -0
- package/src/templates.js +31 -0
- package/src/update.js +133 -0
- package/src/version.js +27 -0
package/src/install.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { chmod } from 'fs/promises';
|
|
2
|
+
import { join, resolve } from 'path';
|
|
3
|
+
import { CLAUDE_EVENTS, CODEX_EVENTS, KIT_NAME } from './constants.js';
|
|
4
|
+
import { backupFile, ensureDir, exists, homeDir, readJson, safeSymlink, writeJson } from './fs-utils.js';
|
|
5
|
+
import { inspectProjectState } from './project-state.js';
|
|
6
|
+
import { bootstrapProject } from './project.js';
|
|
7
|
+
import { binPath, detectInstallSource, packageRoot, runtimeVersion } from './version.js';
|
|
8
|
+
|
|
9
|
+
function shellQuote(value) {
|
|
10
|
+
return `"${String(value).replace(/(["\\$`])/g, '\\$1')}"`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function hookCommand(provider, eventName) {
|
|
14
|
+
return `${shellQuote(process.execPath)} ${shellQuote(binPath)} hook ${provider} ${eventName}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function addHook(hooks, eventName, command, options = {}) {
|
|
18
|
+
hooks[eventName] = Array.isArray(hooks[eventName]) ? hooks[eventName] : [];
|
|
19
|
+
const already = JSON.stringify(hooks[eventName]).includes(command);
|
|
20
|
+
if (already) return false;
|
|
21
|
+
const entry = {
|
|
22
|
+
hooks: [
|
|
23
|
+
{
|
|
24
|
+
type: 'command',
|
|
25
|
+
command,
|
|
26
|
+
timeout: options.timeout || 30,
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
};
|
|
30
|
+
if (options.matcher) entry.matcher = options.matcher;
|
|
31
|
+
hooks[eventName].push(entry);
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function removeKitHooks(hooks) {
|
|
36
|
+
let changed = false;
|
|
37
|
+
for (const [eventName, entries] of Object.entries(hooks || {})) {
|
|
38
|
+
if (!Array.isArray(entries)) continue;
|
|
39
|
+
const next = entries.filter((entry) => {
|
|
40
|
+
const serialized = JSON.stringify(entry);
|
|
41
|
+
return !serialized.includes(KIT_NAME) && !serialized.includes(binPath);
|
|
42
|
+
});
|
|
43
|
+
if (next.length !== entries.length) {
|
|
44
|
+
hooks[eventName] = next;
|
|
45
|
+
changed = true;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return changed;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function install(options = {}) {
|
|
52
|
+
const workspace = resolve(options.workspace || process.cwd());
|
|
53
|
+
await chmod(binPath, 0o755).catch(() => {});
|
|
54
|
+
const localBin = join(homeDir(), '.local', 'bin');
|
|
55
|
+
await ensureDir(localBin);
|
|
56
|
+
const localBinPath = join(localBin, 'llm-wiki');
|
|
57
|
+
if (await exists(localBinPath)) {
|
|
58
|
+
await backupFile(localBinPath, 'local-bin-llm-wiki');
|
|
59
|
+
}
|
|
60
|
+
await safeSymlink(binPath, localBinPath);
|
|
61
|
+
await bootstrapProject(workspace, { profile: options.profile || 'standard', recordState: true });
|
|
62
|
+
|
|
63
|
+
const codexHooksPath = join(homeDir(), '.codex', 'hooks.json');
|
|
64
|
+
const claudeSettingsPath = join(homeDir(), '.claude', 'settings.json');
|
|
65
|
+
const changed = [];
|
|
66
|
+
|
|
67
|
+
if (options.codex !== false) {
|
|
68
|
+
const codex = (await readJson(codexHooksPath, null)) || {};
|
|
69
|
+
codex.hooks = codex.hooks || {};
|
|
70
|
+
let codexChanged = false;
|
|
71
|
+
if (options.replaceHooks && removeKitHooks(codex.hooks)) {
|
|
72
|
+
codexChanged = true;
|
|
73
|
+
changed.push('codex:replaced');
|
|
74
|
+
}
|
|
75
|
+
for (const eventName of CODEX_EVENTS) {
|
|
76
|
+
const matcher = eventName === 'SessionStart' ? 'startup|resume|clear' : undefined;
|
|
77
|
+
if (addHook(codex.hooks, eventName, hookCommand('codex', eventName), { matcher })) {
|
|
78
|
+
codexChanged = true;
|
|
79
|
+
changed.push(`codex:${eventName}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (codexChanged) {
|
|
83
|
+
await backupFile(codexHooksPath, 'codex-hooks.json');
|
|
84
|
+
await writeJson(codexHooksPath, codex);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (options.claude !== false) {
|
|
89
|
+
const claude = (await readJson(claudeSettingsPath, null)) || {};
|
|
90
|
+
claude.hooks = claude.hooks || {};
|
|
91
|
+
let claudeChanged = false;
|
|
92
|
+
if (options.replaceHooks && removeKitHooks(claude.hooks)) {
|
|
93
|
+
claudeChanged = true;
|
|
94
|
+
changed.push('claude:replaced');
|
|
95
|
+
}
|
|
96
|
+
for (const eventName of CLAUDE_EVENTS) {
|
|
97
|
+
const matcher = eventName === 'SessionStart' ? 'startup|resume|clear' : undefined;
|
|
98
|
+
if (addHook(claude.hooks, eventName, hookCommand('claude', eventName), { matcher })) {
|
|
99
|
+
claudeChanged = true;
|
|
100
|
+
changed.push(`claude:${eventName}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (claudeChanged) {
|
|
104
|
+
await backupFile(claudeSettingsPath, 'claude-settings.json');
|
|
105
|
+
await writeJson(claudeSettingsPath, claude);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
workspace,
|
|
111
|
+
runtimeVersion: runtimeVersion(),
|
|
112
|
+
binPath,
|
|
113
|
+
localBin: join(localBin, 'llm-wiki'),
|
|
114
|
+
changed,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function uninstall(options = {}) {
|
|
119
|
+
const codexHooksPath = join(homeDir(), '.codex', 'hooks.json');
|
|
120
|
+
const claudeSettingsPath = join(homeDir(), '.claude', 'settings.json');
|
|
121
|
+
const changed = [];
|
|
122
|
+
|
|
123
|
+
if (options.codex !== false && await exists(codexHooksPath)) {
|
|
124
|
+
const codex = await readJson(codexHooksPath, {});
|
|
125
|
+
if (removeKitHooks(codex.hooks || {})) {
|
|
126
|
+
await backupFile(codexHooksPath, 'codex-hooks-before-uninstall.json');
|
|
127
|
+
await writeJson(codexHooksPath, codex);
|
|
128
|
+
changed.push('codex');
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (options.claude !== false && await exists(claudeSettingsPath)) {
|
|
133
|
+
const claude = await readJson(claudeSettingsPath, {});
|
|
134
|
+
if (removeKitHooks(claude.hooks || {})) {
|
|
135
|
+
await backupFile(claudeSettingsPath, 'claude-settings-before-uninstall.json');
|
|
136
|
+
await writeJson(claudeSettingsPath, claude);
|
|
137
|
+
changed.push('claude');
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return { changed };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function status(options = {}) {
|
|
145
|
+
const workspace = resolve(options.workspace || process.cwd());
|
|
146
|
+
const codexHooksPath = join(homeDir(), '.codex', 'hooks.json');
|
|
147
|
+
const claudeSettingsPath = join(homeDir(), '.claude', 'settings.json');
|
|
148
|
+
const codex = await readJson(codexHooksPath, {});
|
|
149
|
+
const claude = await readJson(claudeSettingsPath, {});
|
|
150
|
+
const codexInstalled = JSON.stringify(codex.hooks || {}).includes(binPath);
|
|
151
|
+
const claudeInstalled = JSON.stringify(claude.hooks || {}).includes(binPath);
|
|
152
|
+
return {
|
|
153
|
+
runtimeVersion: runtimeVersion(),
|
|
154
|
+
packageRoot,
|
|
155
|
+
installSource: detectInstallSource(),
|
|
156
|
+
binPath,
|
|
157
|
+
codexInstalled,
|
|
158
|
+
claudeInstalled,
|
|
159
|
+
hooksCurrent: codexInstalled && claudeInstalled,
|
|
160
|
+
codexHooksPath,
|
|
161
|
+
claudeSettingsPath,
|
|
162
|
+
project: await inspectProjectState(workspace),
|
|
163
|
+
};
|
|
164
|
+
}
|
package/src/migrate.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { basename, dirname, join } from 'path';
|
|
2
|
+
import { copyFile } from 'fs/promises';
|
|
3
|
+
import { ensureDir, exists, listMarkdownFiles, readText } from './fs-utils.js';
|
|
4
|
+
import { appendWikiLog, bootstrapProject } from './project.js';
|
|
5
|
+
|
|
6
|
+
async function isGeneratedKitAgents(path) {
|
|
7
|
+
if (!(await exists(path))) return false;
|
|
8
|
+
return (await readText(path)).includes('Generated by llm-wiki-kit');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async function isLegacyProceduresDir(path) {
|
|
12
|
+
if (!(await exists(path))) return false;
|
|
13
|
+
return (
|
|
14
|
+
await exists(join(path, 'autosave-hooks.md')) ||
|
|
15
|
+
await exists(join(path, 'record-live-qa.md')) ||
|
|
16
|
+
!(await exists(join(path, 'security.md')))
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function migrate(projectRoot) {
|
|
21
|
+
const sources = [
|
|
22
|
+
{ from: join(projectRoot, 'omx_wiki'), to: join(projectRoot, 'llm-wiki', 'wiki', 'context'), legacy: true },
|
|
23
|
+
{ from: join(projectRoot, 'llm-wiki', 'sources'), to: join(projectRoot, 'llm-wiki', 'wiki', 'sources'), legacy: true },
|
|
24
|
+
{ from: join(projectRoot, 'llm-wiki', 'templates'), to: join(projectRoot, 'llm-wiki', 'wiki', 'context'), legacy: true },
|
|
25
|
+
{ from: join(projectRoot, 'llm-wiki', 'procedures'), to: join(projectRoot, 'llm-wiki', 'wiki', 'context'), legacy: await isLegacyProceduresDir(join(projectRoot, 'llm-wiki', 'procedures')) },
|
|
26
|
+
];
|
|
27
|
+
const legacySources = [];
|
|
28
|
+
for (const source of sources) {
|
|
29
|
+
if (source.legacy && await exists(source.from)) legacySources.push(source);
|
|
30
|
+
}
|
|
31
|
+
const legacyFiles = [
|
|
32
|
+
{ from: join(projectRoot, 'llm-wiki', 'index.md'), to: join(projectRoot, 'llm-wiki', 'wiki', 'context', 'legacy-llm-wiki-index.md'), legacy: await exists(join(projectRoot, 'llm-wiki', 'index.md')) },
|
|
33
|
+
{ from: join(projectRoot, 'llm-wiki', 'log.md'), to: join(projectRoot, 'llm-wiki', 'wiki', 'context', 'legacy-llm-wiki-log.md'), legacy: await exists(join(projectRoot, 'llm-wiki', 'log.md')) },
|
|
34
|
+
{ from: join(projectRoot, 'llm-wiki', 'AGENTS.md'), to: join(projectRoot, 'llm-wiki', 'wiki', 'context', 'legacy-llm-wiki-agents.md'), legacy: await exists(join(projectRoot, 'llm-wiki', 'AGENTS.md')) && !(await isGeneratedKitAgents(join(projectRoot, 'llm-wiki', 'AGENTS.md'))) },
|
|
35
|
+
].filter((item) => item.legacy);
|
|
36
|
+
|
|
37
|
+
await bootstrapProject(projectRoot);
|
|
38
|
+
const copied = [];
|
|
39
|
+
for (const source of legacySources) {
|
|
40
|
+
const files = await listMarkdownFiles(source.from, 500);
|
|
41
|
+
await ensureDir(source.to);
|
|
42
|
+
for (const file of files) {
|
|
43
|
+
const dest = join(source.to, basename(file));
|
|
44
|
+
if (file === dest || await exists(dest)) continue;
|
|
45
|
+
await copyFile(file, dest);
|
|
46
|
+
copied.push(dest);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
for (const item of legacyFiles) {
|
|
50
|
+
if (!(await exists(item.from)) || await exists(item.to)) continue;
|
|
51
|
+
await ensureDir(dirname(item.to));
|
|
52
|
+
await copyFile(item.from, item.to);
|
|
53
|
+
copied.push(item.to);
|
|
54
|
+
}
|
|
55
|
+
await appendWikiLog(projectRoot, `migration copy-only completed: ${copied.length} files`);
|
|
56
|
+
return { copied };
|
|
57
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { dirname, join, relative } from 'path';
|
|
2
|
+
import { backupFile, ensureDir, exists, readJson, readText, sha256, writeJson, writeText } from './fs-utils.js';
|
|
3
|
+
import { runtimeVersion } from './version.js';
|
|
4
|
+
import { llmWikiAgents, procedure, rootAgentsPolicy } from './templates.js';
|
|
5
|
+
|
|
6
|
+
export const PROJECT_STATE_SCHEMA_VERSION = 1;
|
|
7
|
+
export const PROJECT_STATE_FILE = '.kit-state.json';
|
|
8
|
+
|
|
9
|
+
const PROCEDURE_NAMES = ['ingest.md', 'query.md', 'lint.md', 'security.md'];
|
|
10
|
+
const ROOT_POLICY_START = '<!-- llm-wiki-kit:start -->';
|
|
11
|
+
const ROOT_POLICY_END = '<!-- llm-wiki-kit:end -->';
|
|
12
|
+
|
|
13
|
+
function statePath(projectRoot) {
|
|
14
|
+
return join(projectRoot, 'llm-wiki', PROJECT_STATE_FILE);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function templateDescriptors() {
|
|
18
|
+
return [
|
|
19
|
+
{
|
|
20
|
+
id: 'root-agents-policy',
|
|
21
|
+
path: 'AGENTS.md',
|
|
22
|
+
content: rootAgentsPolicy(),
|
|
23
|
+
mode: 'marker',
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: 'llm-wiki-agents',
|
|
27
|
+
path: join('llm-wiki', 'AGENTS.md'),
|
|
28
|
+
content: llmWikiAgents(),
|
|
29
|
+
mode: 'generated-file',
|
|
30
|
+
marker: 'Generated by llm-wiki-kit',
|
|
31
|
+
},
|
|
32
|
+
...PROCEDURE_NAMES.map((name) => ({
|
|
33
|
+
id: `procedure-${name.replace(/\.md$/, '')}`,
|
|
34
|
+
path: join('llm-wiki', 'procedures', name),
|
|
35
|
+
content: procedure(name),
|
|
36
|
+
mode: 'generated-file',
|
|
37
|
+
})),
|
|
38
|
+
];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function emptyState() {
|
|
42
|
+
return {
|
|
43
|
+
schemaVersion: PROJECT_STATE_SCHEMA_VERSION,
|
|
44
|
+
lastRuntimeVersionApplied: null,
|
|
45
|
+
managedTemplates: {},
|
|
46
|
+
lastUpdatedAt: null,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function templateHash(content) {
|
|
51
|
+
return sha256(content);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function normalizeState(raw) {
|
|
55
|
+
return {
|
|
56
|
+
...emptyState(),
|
|
57
|
+
...(raw || {}),
|
|
58
|
+
managedTemplates: {
|
|
59
|
+
...((raw && raw.managedTemplates) || {}),
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function readProjectState(projectRoot) {
|
|
65
|
+
return normalizeState(await readJson(statePath(projectRoot), null));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function writeProjectState(projectRoot, state) {
|
|
69
|
+
const next = normalizeState(state);
|
|
70
|
+
next.schemaVersion = PROJECT_STATE_SCHEMA_VERSION;
|
|
71
|
+
next.lastUpdatedAt = new Date().toISOString();
|
|
72
|
+
await writeJson(statePath(projectRoot), next);
|
|
73
|
+
return next;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function replaceMarkedBlock(current, replacement) {
|
|
77
|
+
const start = current.indexOf(ROOT_POLICY_START);
|
|
78
|
+
const end = current.indexOf(ROOT_POLICY_END);
|
|
79
|
+
if (start === -1 || end === -1 || end < start) return null;
|
|
80
|
+
const afterEnd = end + ROOT_POLICY_END.length;
|
|
81
|
+
return `${current.slice(0, start)}${replacement.trimStart().trimEnd()}${current.slice(afterEnd)}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function hasGeneratedMarker(text, descriptor) {
|
|
85
|
+
return descriptor.marker ? text.includes(descriptor.marker) : false;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isRecordedManaged(state, descriptor, currentText) {
|
|
89
|
+
const recorded = state.managedTemplates?.[descriptor.id];
|
|
90
|
+
if (!recorded) return false;
|
|
91
|
+
if (descriptor.mode === 'marker') return true;
|
|
92
|
+
return !recorded.hash || recorded.hash === templateHash(currentText);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isKnownGeneratedContent(text, descriptor) {
|
|
96
|
+
if (descriptor.mode === 'marker') return replaceMarkedBlock(text, descriptor.content) !== null;
|
|
97
|
+
return templateHash(text) === templateHash(descriptor.content) || hasGeneratedMarker(text, descriptor);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function canPatchDescriptor(state, descriptor, currentText) {
|
|
101
|
+
if (descriptor.mode === 'marker') return replaceMarkedBlock(currentText, descriptor.content) !== null;
|
|
102
|
+
return isRecordedManaged(state, descriptor, currentText) || isKnownGeneratedContent(currentText, descriptor);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function desiredTextForDescriptor(descriptor, currentText) {
|
|
106
|
+
if (descriptor.mode === 'marker') return replaceMarkedBlock(currentText, descriptor.content);
|
|
107
|
+
return descriptor.content;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function inspectProjectState(projectRoot) {
|
|
111
|
+
const state = await readProjectState(projectRoot);
|
|
112
|
+
const runtime = runtimeVersion();
|
|
113
|
+
const files = [];
|
|
114
|
+
|
|
115
|
+
for (const descriptor of templateDescriptors()) {
|
|
116
|
+
const absolutePath = join(projectRoot, descriptor.path);
|
|
117
|
+
const fileExists = await exists(absolutePath);
|
|
118
|
+
const currentText = fileExists ? await readText(absolutePath) : '';
|
|
119
|
+
const desiredText = fileExists ? desiredTextForDescriptor(descriptor, currentText) : descriptor.content;
|
|
120
|
+
const desiredHash = templateHash(desiredText || descriptor.content);
|
|
121
|
+
const currentHash = fileExists ? templateHash(currentText) : null;
|
|
122
|
+
const recorded = state.managedTemplates[descriptor.id] || null;
|
|
123
|
+
const patchable = fileExists ? canPatchDescriptor(state, descriptor, currentText) : false;
|
|
124
|
+
const current = fileExists && desiredText !== null && currentHash === templateHash(desiredText);
|
|
125
|
+
|
|
126
|
+
files.push({
|
|
127
|
+
id: descriptor.id,
|
|
128
|
+
path: descriptor.path,
|
|
129
|
+
exists: fileExists,
|
|
130
|
+
patchable,
|
|
131
|
+
current,
|
|
132
|
+
currentHash,
|
|
133
|
+
desiredHash,
|
|
134
|
+
recordedHash: recorded?.hash || null,
|
|
135
|
+
recordedVersion: recorded?.version || null,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
statePath: statePath(projectRoot),
|
|
141
|
+
stateExists: await exists(statePath(projectRoot)),
|
|
142
|
+
schemaVersion: state.schemaVersion,
|
|
143
|
+
lastRuntimeVersionApplied: state.lastRuntimeVersionApplied,
|
|
144
|
+
runtimeUpToDateWithProject: state.lastRuntimeVersionApplied === runtime,
|
|
145
|
+
managedFilesCurrent: files.every((file) => file.current || !file.patchable),
|
|
146
|
+
managedFiles: files,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function recordManagedTemplates(projectRoot) {
|
|
151
|
+
const state = await readProjectState(projectRoot);
|
|
152
|
+
const runtime = runtimeVersion();
|
|
153
|
+
|
|
154
|
+
for (const descriptor of templateDescriptors()) {
|
|
155
|
+
const absolutePath = join(projectRoot, descriptor.path);
|
|
156
|
+
if (!(await exists(absolutePath))) continue;
|
|
157
|
+
const currentText = await readText(absolutePath);
|
|
158
|
+
if (!canPatchDescriptor(state, descriptor, currentText)) continue;
|
|
159
|
+
state.managedTemplates[descriptor.id] = {
|
|
160
|
+
path: descriptor.path,
|
|
161
|
+
version: runtime,
|
|
162
|
+
hash: templateHash(currentText),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
state.lastRuntimeVersionApplied = runtime;
|
|
167
|
+
return writeProjectState(projectRoot, state);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export async function applyProjectTemplateUpdate(projectRoot, options = {}) {
|
|
171
|
+
const state = await readProjectState(projectRoot);
|
|
172
|
+
const runtime = runtimeVersion();
|
|
173
|
+
const changed = [];
|
|
174
|
+
const skipped = [];
|
|
175
|
+
const unchanged = [];
|
|
176
|
+
|
|
177
|
+
for (const descriptor of templateDescriptors()) {
|
|
178
|
+
const absolutePath = join(projectRoot, descriptor.path);
|
|
179
|
+
if (!(await exists(absolutePath))) {
|
|
180
|
+
skipped.push({ id: descriptor.id, path: descriptor.path, reason: 'missing' });
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const currentText = await readText(absolutePath);
|
|
185
|
+
if (!canPatchDescriptor(state, descriptor, currentText)) {
|
|
186
|
+
skipped.push({ id: descriptor.id, path: descriptor.path, reason: 'not-managed' });
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const desiredText = desiredTextForDescriptor(descriptor, currentText);
|
|
191
|
+
if (desiredText === null) {
|
|
192
|
+
skipped.push({ id: descriptor.id, path: descriptor.path, reason: 'missing-marker' });
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (currentText === desiredText) {
|
|
197
|
+
unchanged.push({ id: descriptor.id, path: descriptor.path });
|
|
198
|
+
state.managedTemplates[descriptor.id] = {
|
|
199
|
+
path: descriptor.path,
|
|
200
|
+
version: runtime,
|
|
201
|
+
hash: templateHash(currentText),
|
|
202
|
+
};
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (!options.dryRun) {
|
|
207
|
+
await backupFile(absolutePath, relative(projectRoot, absolutePath));
|
|
208
|
+
await ensureDir(dirname(absolutePath));
|
|
209
|
+
await writeText(absolutePath, desiredText, 'utf8');
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
changed.push({ id: descriptor.id, path: descriptor.path });
|
|
213
|
+
state.managedTemplates[descriptor.id] = {
|
|
214
|
+
path: descriptor.path,
|
|
215
|
+
version: runtime,
|
|
216
|
+
hash: templateHash(desiredText),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
state.lastRuntimeVersionApplied = runtime;
|
|
221
|
+
if (!options.dryRun) await writeProjectState(projectRoot, state);
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
workspace: projectRoot,
|
|
225
|
+
runtimeVersion: runtime,
|
|
226
|
+
changed,
|
|
227
|
+
skipped,
|
|
228
|
+
unchanged,
|
|
229
|
+
dryRun: Boolean(options.dryRun),
|
|
230
|
+
};
|
|
231
|
+
}
|
package/src/project.js
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { join, relative } from 'path';
|
|
2
|
+
import {
|
|
3
|
+
appendText,
|
|
4
|
+
ensureDir,
|
|
5
|
+
exists,
|
|
6
|
+
listMarkdownFiles,
|
|
7
|
+
readText,
|
|
8
|
+
timeKst,
|
|
9
|
+
todayKst,
|
|
10
|
+
writeTextIfMissing,
|
|
11
|
+
} from './fs-utils.js';
|
|
12
|
+
import { LLM_WIKI_DIRS } from './constants.js';
|
|
13
|
+
import { redactText, summarizeForStorage } from './redaction.js';
|
|
14
|
+
import { gitignore, indexPage, llmWikiAgents, logPage, procedure, rootAgentsPolicy } from './templates.js';
|
|
15
|
+
import { recordManagedTemplates } from './project-state.js';
|
|
16
|
+
|
|
17
|
+
export async function bootstrapProject(projectRoot, options = {}) {
|
|
18
|
+
if (process.env.LLM_WIKI_KIT_DISABLE_BOOTSTRAP === '1') return { created: false };
|
|
19
|
+
const base = join(projectRoot, 'llm-wiki');
|
|
20
|
+
for (const dir of LLM_WIKI_DIRS) {
|
|
21
|
+
await ensureDir(join(base, dir));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const created = [];
|
|
25
|
+
const maybeCreate = async (path, content) => {
|
|
26
|
+
if (await writeTextIfMissing(path, content)) created.push(relative(projectRoot, path));
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
await maybeCreate(join(base, 'AGENTS.md'), llmWikiAgents());
|
|
30
|
+
await maybeCreate(join(base, 'wiki', 'index.md'), indexPage());
|
|
31
|
+
await maybeCreate(join(base, 'wiki', 'log.md'), logPage());
|
|
32
|
+
await maybeCreate(join(base, '.gitignore'), gitignore());
|
|
33
|
+
for (const name of ['ingest.md', 'query.md', 'lint.md', 'security.md']) {
|
|
34
|
+
await maybeCreate(join(base, 'procedures', name), procedure(name));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const rootAgents = join(projectRoot, 'AGENTS.md');
|
|
38
|
+
if (await exists(rootAgents)) {
|
|
39
|
+
const current = await readText(rootAgents);
|
|
40
|
+
if (!current.includes('<!-- llm-wiki-kit:start -->')) {
|
|
41
|
+
await appendText(rootAgents, rootAgentsPolicy());
|
|
42
|
+
created.push('AGENTS.md#llm-wiki-kit-policy');
|
|
43
|
+
}
|
|
44
|
+
} else {
|
|
45
|
+
await maybeCreate(rootAgents, `# Repository Instructions\n${rootAgentsPolicy()}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const claudeMd = join(projectRoot, 'CLAUDE.md');
|
|
49
|
+
if (!(await exists(claudeMd))) {
|
|
50
|
+
await maybeCreate(claudeMd, '@AGENTS.md\n');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (created.length > 0) {
|
|
54
|
+
await appendWikiLog(projectRoot, `bootstrap: created ${created.join(', ')}`);
|
|
55
|
+
}
|
|
56
|
+
if (created.length > 0 || options.recordState === true) {
|
|
57
|
+
await recordManagedTemplates(projectRoot);
|
|
58
|
+
}
|
|
59
|
+
return { created: created.length > 0, files: created };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function appendWikiLog(projectRoot, message) {
|
|
63
|
+
const line = `\n## ${todayKst()} ${timeKst()} KST\n\n- ${summarizeForStorage(message, 1000)}\n`;
|
|
64
|
+
await appendText(join(projectRoot, 'llm-wiki', 'wiki', 'log.md'), line);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function appendSessionEnvelope(projectRoot, eventName, payload) {
|
|
68
|
+
const day = todayKst();
|
|
69
|
+
const path = join(projectRoot, 'llm-wiki', 'raw', 'sessions', `${day}-events.jsonl`);
|
|
70
|
+
const safePayload = {
|
|
71
|
+
timestamp: new Date().toISOString(),
|
|
72
|
+
event: eventName,
|
|
73
|
+
provider: payload.__provider,
|
|
74
|
+
cwd: payload.cwd,
|
|
75
|
+
session_id: payload.session_id || payload.sessionId || payload.conversation_id || undefined,
|
|
76
|
+
transcript_path: payload.transcript_path ? '[REDACTED_PATH_PRESENT]' : undefined,
|
|
77
|
+
};
|
|
78
|
+
await appendText(path, `${JSON.stringify(safePayload)}\n`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function slugify(value, fallback = 'note') {
|
|
82
|
+
const ascii = String(value || '')
|
|
83
|
+
.normalize('NFKD')
|
|
84
|
+
.replace(/[^\p{Letter}\p{Number}]+/gu, '-')
|
|
85
|
+
.replace(/^-+|-+$/g, '')
|
|
86
|
+
.toLowerCase()
|
|
87
|
+
.slice(0, 80);
|
|
88
|
+
return ascii || fallback;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function appendLiveQa(projectRoot, entry) {
|
|
92
|
+
const day = todayKst();
|
|
93
|
+
const path = join(projectRoot, 'llm-wiki', 'outputs', 'questions', `${day}-live-qa.md`);
|
|
94
|
+
const block = [
|
|
95
|
+
`\n## ${timeKst()} KST - ${entry.topic || 'session turn'}`,
|
|
96
|
+
'',
|
|
97
|
+
'### Question',
|
|
98
|
+
entry.question || '(not captured)',
|
|
99
|
+
'',
|
|
100
|
+
'### Work',
|
|
101
|
+
entry.work || '(not captured)',
|
|
102
|
+
'',
|
|
103
|
+
'### Result',
|
|
104
|
+
entry.result || '(not captured)',
|
|
105
|
+
'',
|
|
106
|
+
'### Changed files',
|
|
107
|
+
entry.changedFiles || '(not captured)',
|
|
108
|
+
'',
|
|
109
|
+
'### Verification',
|
|
110
|
+
entry.verification || '(not captured)',
|
|
111
|
+
'',
|
|
112
|
+
'### Follow-up',
|
|
113
|
+
entry.followUp || '(none captured)',
|
|
114
|
+
'',
|
|
115
|
+
].join('\n');
|
|
116
|
+
await appendText(path, redactText(block, 12000));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function writeQueryPage(projectRoot, entry) {
|
|
120
|
+
if (!entry.question || entry.question.length < 8) return null;
|
|
121
|
+
const day = todayKst();
|
|
122
|
+
const slug = slugify(entry.question, 'query');
|
|
123
|
+
const path = join(projectRoot, 'llm-wiki', 'wiki', 'queries', `${day}-${slug}.md`);
|
|
124
|
+
if (await exists(path)) return path;
|
|
125
|
+
const content = `---\ntitle: "${entry.topic || slug}"\ntype: "query"\nsource_ids: []\nstatus: "draft"\nlast_updated: "${day}"\nconfidence: "medium"\n---\n\n# ${entry.topic || entry.question.slice(0, 80)}\n\n## Question\n${entry.question}\n\n## Answer Summary\n${entry.result || '(not captured)'}\n\n## Work Notes\n${entry.work || '(not captured)'}\n\n## Verification\n${entry.verification || '(not captured)'}\n\n## Related Pages\n- [[index]]\n\n## Change Log\n- ${day}: Captured automatically by llm-wiki-kit hook.\n`;
|
|
126
|
+
await writeTextIfMissing(path, redactText(content, 12000));
|
|
127
|
+
return path;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function writeDecisionPage(projectRoot, entry) {
|
|
131
|
+
const text = `${entry.question || ''}\n${entry.result || ''}`;
|
|
132
|
+
if (!/(decision|decided|결정|선택|채택|확정)/i.test(text)) return null;
|
|
133
|
+
const day = todayKst();
|
|
134
|
+
const slug = slugify(entry.topic || entry.question || 'decision', 'decision');
|
|
135
|
+
const path = join(projectRoot, 'llm-wiki', 'wiki', 'decisions', `${day}-${slug}.md`);
|
|
136
|
+
if (await exists(path)) return path;
|
|
137
|
+
const content = `---\ntitle: "${entry.topic || slug}"\ntype: "decision"\nsource_ids: []\nstatus: "draft"\nlast_updated: "${day}"\nconfidence: "medium"\n---\n\n# ${entry.topic || 'Decision'}\n\n## Decision\n${entry.result || '(captured from assistant response; review needed)'}\n\n## Context\n${entry.question || '(not captured)'}\n\n## Evidence\n${entry.work || '(not captured)'}\n\n## Verification\n${entry.verification || '(not captured)'}\n\n## Open Questions\n${entry.followUp || '(none captured)'}\n\n## Change Log\n- ${day}: Captured automatically by llm-wiki-kit hook.\n`;
|
|
138
|
+
await writeTextIfMissing(path, redactText(content, 12000));
|
|
139
|
+
return path;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function appendContextNote(projectRoot, eventName, text) {
|
|
143
|
+
const day = todayKst();
|
|
144
|
+
const path = join(projectRoot, 'llm-wiki', 'wiki', 'context', `${day}-session-context.md`);
|
|
145
|
+
const block = `\n## ${timeKst()} KST - ${eventName}\n\n${summarizeForStorage(text || '(no context)', 4000)}\n`;
|
|
146
|
+
await appendText(path, block);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function tokenize(text) {
|
|
150
|
+
return String(text || '')
|
|
151
|
+
.toLowerCase()
|
|
152
|
+
.replace(/[^\p{Letter}\p{Number}]+/gu, ' ')
|
|
153
|
+
.split(/\s+/)
|
|
154
|
+
.filter((token) => token.length >= 2)
|
|
155
|
+
.slice(0, 80);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export async function searchWiki(projectRoot, query, limit = 5) {
|
|
159
|
+
const wikiRoot = join(projectRoot, 'llm-wiki', 'wiki');
|
|
160
|
+
const files = await listMarkdownFiles(wikiRoot, 400);
|
|
161
|
+
const terms = tokenize(query);
|
|
162
|
+
if (terms.length === 0) return [];
|
|
163
|
+
const scored = [];
|
|
164
|
+
for (const file of files) {
|
|
165
|
+
const raw = await readText(file);
|
|
166
|
+
const content = raw.slice(0, 50000);
|
|
167
|
+
const lower = content.toLowerCase();
|
|
168
|
+
let score = 0;
|
|
169
|
+
for (const term of terms) {
|
|
170
|
+
if (lower.includes(term)) score += term.length > 3 ? 2 : 1;
|
|
171
|
+
}
|
|
172
|
+
if (score > 0) {
|
|
173
|
+
const rel = relative(join(projectRoot, 'llm-wiki'), file);
|
|
174
|
+
scored.push({
|
|
175
|
+
score,
|
|
176
|
+
path: rel,
|
|
177
|
+
snippet: content.replace(/\s+/g, ' ').slice(0, 350),
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return scored.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export async function buildContextBrief(projectRoot, eventName, query = '') {
|
|
185
|
+
const index = await readText(join(projectRoot, 'llm-wiki', 'wiki', 'index.md'));
|
|
186
|
+
const log = await readText(join(projectRoot, 'llm-wiki', 'wiki', 'log.md'));
|
|
187
|
+
const hits = query ? await searchWiki(projectRoot, query, 5) : [];
|
|
188
|
+
const parts = [
|
|
189
|
+
'LLM Wiki context from llm-wiki-kit:',
|
|
190
|
+
'- Treat chat memory as temporary; update project Markdown when knowledge should persist.',
|
|
191
|
+
'- Preserve raw/wiki separation. Do not store secrets, tokens, .env contents, private keys, or personal/customer identifiers.',
|
|
192
|
+
'- Prefer updating existing wiki pages over creating duplicate pages.',
|
|
193
|
+
'',
|
|
194
|
+
'Index excerpt:',
|
|
195
|
+
index.slice(0, 1200).trim(),
|
|
196
|
+
];
|
|
197
|
+
if (hits.length > 0) {
|
|
198
|
+
parts.push('', 'Relevant wiki pages:', ...hits.map((hit) => `- ${hit.path}: ${hit.snippet}`));
|
|
199
|
+
}
|
|
200
|
+
if (eventName === 'SessionStart') {
|
|
201
|
+
parts.push('', 'Recent log excerpt:', log.slice(-1000).trim());
|
|
202
|
+
}
|
|
203
|
+
return parts.join('\n').trim();
|
|
204
|
+
}
|