lat.md 0.1.4 → 0.3.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/README.md +4 -1
- package/dist/src/cli/check.d.ts +7 -0
- package/dist/src/cli/check.js +184 -15
- package/dist/src/cli/index.js +8 -0
- package/dist/src/cli/init.js +69 -1
- package/dist/src/cli/locate.js +4 -5
- package/dist/src/cli/prompt.js +30 -32
- package/dist/src/cli/refs.js +35 -19
- package/dist/src/cli/search.js +3 -4
- package/dist/src/code-refs.d.ts +2 -1
- package/dist/src/code-refs.js +4 -8
- package/dist/src/format.d.ts +3 -5
- package/dist/src/format.js +9 -9
- package/dist/src/lattice.d.ts +28 -3
- package/dist/src/lattice.js +229 -28
- package/dist/src/walk.d.ts +13 -0
- package/dist/src/walk.js +32 -0
- package/package.json +1 -1
- package/templates/AGENTS.md +1 -1
- package/templates/init/lat.md +1 -0
- package/templates/lat-prompt-hook.sh +16 -0
- package/dist/src/cli.d.ts +0 -2
- package/dist/src/cli.js +0 -23
- package/templates/init/README.md +0 -5
package/README.md
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# lat.md
|
|
2
2
|
|
|
3
3
|
[](https://github.com/1st1/lat.md/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/lat.md)
|
|
4
5
|
|
|
5
6
|
A knowledge graph for your codebase, written in markdown.
|
|
6
7
|
|
|
@@ -26,7 +27,9 @@ The result is a structured knowledge base that:
|
|
|
26
27
|
npm install -g lat.md
|
|
27
28
|
```
|
|
28
29
|
|
|
29
|
-
Or use directly with `npx lat.md <command>`.
|
|
30
|
+
Or use directly with `npx lat.md@latest <command>`.
|
|
31
|
+
|
|
32
|
+
For semantic search (`lat search`), set the `LAT_LLM_KEY` environment variable with an OpenAI (`sk-...`) or Vercel AI Gateway (`vck_...`) API key.
|
|
30
33
|
|
|
31
34
|
## How it works
|
|
32
35
|
|
package/dist/src/cli/check.d.ts
CHANGED
|
@@ -13,6 +13,13 @@ export type CheckResult = {
|
|
|
13
13
|
};
|
|
14
14
|
export declare function checkMd(latticeDir: string): Promise<CheckResult>;
|
|
15
15
|
export declare function checkCodeRefs(latticeDir: string): Promise<CheckResult>;
|
|
16
|
+
export type IndexError = {
|
|
17
|
+
dir: string;
|
|
18
|
+
message: string;
|
|
19
|
+
snippet?: string;
|
|
20
|
+
};
|
|
21
|
+
export declare function checkIndex(latticeDir: string): Promise<IndexError[]>;
|
|
16
22
|
export declare function checkMdCmd(ctx: CliContext): Promise<void>;
|
|
17
23
|
export declare function checkCodeRefsCmd(ctx: CliContext): Promise<void>;
|
|
24
|
+
export declare function checkIndexCmd(ctx: CliContext): Promise<void>;
|
|
18
25
|
export declare function checkAllCmd(ctx: CliContext): Promise<void>;
|
package/dist/src/cli/check.js
CHANGED
|
@@ -1,7 +1,27 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
|
-
import { extname, join, relative } from 'node:path';
|
|
3
|
-
import { listLatticeFiles, loadAllSections, extractRefs, flattenSections, parseFrontmatter, parseSections, } from '../lattice.js';
|
|
2
|
+
import { basename, extname, join, relative } from 'node:path';
|
|
3
|
+
import { listLatticeFiles, loadAllSections, extractRefs, flattenSections, parseFrontmatter, parseSections, buildFileIndex, resolveRef, } from '../lattice.js';
|
|
4
4
|
import { scanCodeRefs } from '../code-refs.js';
|
|
5
|
+
import { walkEntries } from '../walk.js';
|
|
6
|
+
function filePart(id) {
|
|
7
|
+
const h = id.indexOf('#');
|
|
8
|
+
return h === -1 ? id : id.slice(0, h);
|
|
9
|
+
}
|
|
10
|
+
/** Format an ambiguous-ref error as structured markdown-like text. */
|
|
11
|
+
function ambiguousMessage(target, candidates, suggested) {
|
|
12
|
+
const shortName = filePart(target);
|
|
13
|
+
const fileList = candidates.map((c) => ` - "${filePart(c)}.md"`).join('\n');
|
|
14
|
+
const lines = [];
|
|
15
|
+
if (suggested) {
|
|
16
|
+
lines.push(`ambiguous link '[[${target}]]' — did you mean '[[${suggested}]]'?`);
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
const options = candidates.map((a) => `'[[${a}]]'`).join(', ');
|
|
20
|
+
lines.push(`ambiguous link '[[${target}]]' — multiple paths match, use either of: ${options}`);
|
|
21
|
+
}
|
|
22
|
+
lines.push(` The short path "${shortName}" is ambiguous — ${candidates.length} files match:`, fileList, ` Please fix the link to use a fully qualified path.`);
|
|
23
|
+
return lines.join('\n');
|
|
24
|
+
}
|
|
5
25
|
function countByExt(paths) {
|
|
6
26
|
const stats = {};
|
|
7
27
|
for (const p of paths) {
|
|
@@ -15,14 +35,23 @@ export async function checkMd(latticeDir) {
|
|
|
15
35
|
const allSections = await loadAllSections(latticeDir);
|
|
16
36
|
const flat = flattenSections(allSections);
|
|
17
37
|
const sectionIds = new Set(flat.map((s) => s.id.toLowerCase()));
|
|
38
|
+
const fileIndex = buildFileIndex(allSections);
|
|
18
39
|
const errors = [];
|
|
19
40
|
for (const file of files) {
|
|
20
41
|
const content = await readFile(file, 'utf-8');
|
|
21
|
-
const refs = extractRefs(file, content);
|
|
42
|
+
const refs = extractRefs(file, content, latticeDir);
|
|
22
43
|
const relPath = relative(process.cwd(), file);
|
|
23
44
|
for (const ref of refs) {
|
|
24
|
-
const
|
|
25
|
-
if (
|
|
45
|
+
const { resolved, ambiguous, suggested } = resolveRef(ref.target, sectionIds, fileIndex);
|
|
46
|
+
if (ambiguous) {
|
|
47
|
+
errors.push({
|
|
48
|
+
file: relPath,
|
|
49
|
+
line: ref.line,
|
|
50
|
+
target: ref.target,
|
|
51
|
+
message: ambiguousMessage(ref.target, ambiguous, suggested),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
else if (!sectionIds.has(resolved.toLowerCase())) {
|
|
26
55
|
errors.push({
|
|
27
56
|
file: relPath,
|
|
28
57
|
line: ref.line,
|
|
@@ -39,13 +68,22 @@ export async function checkCodeRefs(latticeDir) {
|
|
|
39
68
|
const allSections = await loadAllSections(latticeDir);
|
|
40
69
|
const flat = flattenSections(allSections);
|
|
41
70
|
const sectionIds = new Set(flat.map((s) => s.id.toLowerCase()));
|
|
71
|
+
const fileIndex = buildFileIndex(allSections);
|
|
42
72
|
const scan = await scanCodeRefs(projectRoot);
|
|
43
73
|
const errors = [];
|
|
44
74
|
const mentionedSections = new Set();
|
|
45
75
|
for (const ref of scan.refs) {
|
|
46
|
-
const
|
|
47
|
-
mentionedSections.add(
|
|
48
|
-
if (
|
|
76
|
+
const { resolved, ambiguous, suggested } = resolveRef(ref.target, sectionIds, fileIndex);
|
|
77
|
+
mentionedSections.add(resolved.toLowerCase());
|
|
78
|
+
if (ambiguous) {
|
|
79
|
+
errors.push({
|
|
80
|
+
file: ref.file,
|
|
81
|
+
line: ref.line,
|
|
82
|
+
target: ref.target,
|
|
83
|
+
message: ambiguousMessage(ref.target, ambiguous, suggested),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
else if (!sectionIds.has(resolved.toLowerCase())) {
|
|
49
87
|
errors.push({
|
|
50
88
|
file: ref.file,
|
|
51
89
|
line: ref.line,
|
|
@@ -60,7 +98,7 @@ export async function checkCodeRefs(latticeDir) {
|
|
|
60
98
|
const fm = parseFrontmatter(content);
|
|
61
99
|
if (!fm.requireCodeMention)
|
|
62
100
|
continue;
|
|
63
|
-
const sections = parseSections(file, content);
|
|
101
|
+
const sections = parseSections(file, content, latticeDir);
|
|
64
102
|
const fileSections = flattenSections(sections);
|
|
65
103
|
const leafSections = fileSections.filter((s) => s.children.length === 0);
|
|
66
104
|
const relPath = relative(process.cwd(), file);
|
|
@@ -77,12 +115,129 @@ export async function checkCodeRefs(latticeDir) {
|
|
|
77
115
|
}
|
|
78
116
|
return { errors, files: countByExt(scan.files) };
|
|
79
117
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
118
|
+
/**
|
|
119
|
+
* Extract the immediate (first-level) entries from walkEntries results.
|
|
120
|
+
* Returns unique file and directory names visible in a given directory.
|
|
121
|
+
*/
|
|
122
|
+
function immediateEntries(walkedPaths) {
|
|
123
|
+
const entries = new Set();
|
|
124
|
+
for (const p of walkedPaths) {
|
|
125
|
+
const slash = p.indexOf('/');
|
|
126
|
+
entries.add(slash === -1 ? p : p.slice(0, slash));
|
|
83
127
|
}
|
|
84
|
-
|
|
85
|
-
|
|
128
|
+
return [...entries].sort();
|
|
129
|
+
}
|
|
130
|
+
/** Parse bullet items from an index file. Matches `- **name** — description` */
|
|
131
|
+
function parseIndexEntries(content) {
|
|
132
|
+
const names = new Set();
|
|
133
|
+
const re = /^- \*\*(.+?)\*\*/gm;
|
|
134
|
+
let match;
|
|
135
|
+
while ((match = re.exec(content)) !== null) {
|
|
136
|
+
names.add(match[1]);
|
|
137
|
+
}
|
|
138
|
+
return names;
|
|
139
|
+
}
|
|
140
|
+
/** Generate a bullet-list snippet for the given entry names. */
|
|
141
|
+
function indexSnippet(entries) {
|
|
142
|
+
return entries.map((e) => `- **${e}** — <describe>`).join('\n');
|
|
143
|
+
}
|
|
144
|
+
export async function checkIndex(latticeDir) {
|
|
145
|
+
const errors = [];
|
|
146
|
+
const allPaths = await walkEntries(latticeDir);
|
|
147
|
+
// Collect all directories to check (including root, represented as '')
|
|
148
|
+
const dirs = new Set(['']);
|
|
149
|
+
for (const p of allPaths) {
|
|
150
|
+
const parts = p.split('/');
|
|
151
|
+
// Add every directory prefix
|
|
152
|
+
for (let i = 1; i < parts.length; i++) {
|
|
153
|
+
dirs.add(parts.slice(0, i).join('/'));
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
for (const dir of dirs) {
|
|
157
|
+
// Determine the index file name and its expected path.
|
|
158
|
+
// The index file shares the directory's name — for `lat.md/` it's `lat.md`,
|
|
159
|
+
// for a subdir `api/` it's `api.md`.
|
|
160
|
+
const dirName = dir === '' ? basename(latticeDir) : dir.split('/').pop();
|
|
161
|
+
const indexFileName = dirName.endsWith('.md') ? dirName : dirName + '.md';
|
|
162
|
+
const indexRelPath = dir === '' ? indexFileName : dir + '/' + indexFileName;
|
|
163
|
+
// Get the immediate children of this directory
|
|
164
|
+
const prefix = dir === '' ? '' : dir + '/';
|
|
165
|
+
const childPaths = allPaths
|
|
166
|
+
.filter((p) => p.startsWith(prefix) && p !== indexRelPath)
|
|
167
|
+
.map((p) => p.slice(prefix.length));
|
|
168
|
+
const children = immediateEntries(childPaths);
|
|
169
|
+
if (children.length === 0)
|
|
170
|
+
continue;
|
|
171
|
+
// Check if the index file exists
|
|
172
|
+
const indexFullPath = join(latticeDir, indexRelPath);
|
|
173
|
+
let content;
|
|
174
|
+
try {
|
|
175
|
+
content = await readFile(indexFullPath, 'utf-8');
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
const relDir = dir === '' ? basename(latticeDir) + '/' : dir + '/';
|
|
179
|
+
errors.push({
|
|
180
|
+
dir: relDir,
|
|
181
|
+
message: `missing index file "${indexRelPath}" — create it with a directory listing:\n\n${indexSnippet(children)}`,
|
|
182
|
+
snippet: indexSnippet(children),
|
|
183
|
+
});
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
// Parse existing entries and validate
|
|
187
|
+
const listed = parseIndexEntries(content);
|
|
188
|
+
const relDir = dir === '' ? basename(latticeDir) + '/' : dir + '/';
|
|
189
|
+
const missing = [];
|
|
190
|
+
for (const child of children) {
|
|
191
|
+
if (!listed.has(child)) {
|
|
192
|
+
missing.push(child);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (missing.length > 0) {
|
|
196
|
+
errors.push({
|
|
197
|
+
dir: relDir,
|
|
198
|
+
message: `"${indexRelPath}" is missing entries — add:\n\n${indexSnippet(missing)}`,
|
|
199
|
+
snippet: indexSnippet(missing),
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
for (const name of listed) {
|
|
203
|
+
if (!children.includes(name) && name !== indexFileName) {
|
|
204
|
+
errors.push({
|
|
205
|
+
dir: relDir,
|
|
206
|
+
message: `"${indexRelPath}" lists "${name}" but it does not exist`,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return errors;
|
|
212
|
+
}
|
|
213
|
+
function formatErrors(ctx, errors, startIdx = 0) {
|
|
214
|
+
for (let i = 0; i < errors.length; i++) {
|
|
215
|
+
const err = errors[i];
|
|
216
|
+
if (i > 0 || startIdx > 0)
|
|
217
|
+
console.error('');
|
|
218
|
+
const loc = ctx.chalk.cyan(err.file + ':' + err.line);
|
|
219
|
+
const [first, ...rest] = err.message.split('\n');
|
|
220
|
+
console.error(`- ${loc}: ${ctx.chalk.red(first)}`);
|
|
221
|
+
for (const line of rest) {
|
|
222
|
+
console.error(` ${ctx.chalk.red(line)}`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
function formatIndexErrors(ctx, errors, startIdx = 0) {
|
|
227
|
+
for (let i = 0; i < errors.length; i++) {
|
|
228
|
+
if (i > 0 || startIdx > 0)
|
|
229
|
+
console.error('');
|
|
230
|
+
const loc = ctx.chalk.cyan(errors[i].dir);
|
|
231
|
+
const [first, ...rest] = errors[i].message.split('\n');
|
|
232
|
+
console.error(`- ${loc}: ${ctx.chalk.red(first)}`);
|
|
233
|
+
for (const line of rest) {
|
|
234
|
+
console.error(` ${ctx.chalk.red(line)}`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
function formatErrorCount(ctx, count) {
|
|
239
|
+
if (count > 0) {
|
|
240
|
+
console.error(ctx.chalk.red(`\n${count} error${count === 1 ? '' : 's'} found`));
|
|
86
241
|
}
|
|
87
242
|
}
|
|
88
243
|
function formatStats(ctx, stats) {
|
|
@@ -94,6 +249,7 @@ export async function checkMdCmd(ctx) {
|
|
|
94
249
|
const { errors, files } = await checkMd(ctx.latDir);
|
|
95
250
|
formatStats(ctx, files);
|
|
96
251
|
formatErrors(ctx, errors);
|
|
252
|
+
formatErrorCount(ctx, errors.length);
|
|
97
253
|
if (errors.length > 0)
|
|
98
254
|
process.exit(1);
|
|
99
255
|
console.log(ctx.chalk.green('md: All links OK'));
|
|
@@ -102,13 +258,23 @@ export async function checkCodeRefsCmd(ctx) {
|
|
|
102
258
|
const { errors, files } = await checkCodeRefs(ctx.latDir);
|
|
103
259
|
formatStats(ctx, files);
|
|
104
260
|
formatErrors(ctx, errors);
|
|
261
|
+
formatErrorCount(ctx, errors.length);
|
|
105
262
|
if (errors.length > 0)
|
|
106
263
|
process.exit(1);
|
|
107
264
|
console.log(ctx.chalk.green('code-refs: All references OK'));
|
|
108
265
|
}
|
|
266
|
+
export async function checkIndexCmd(ctx) {
|
|
267
|
+
const errors = await checkIndex(ctx.latDir);
|
|
268
|
+
formatIndexErrors(ctx, errors);
|
|
269
|
+
formatErrorCount(ctx, errors.length);
|
|
270
|
+
if (errors.length > 0)
|
|
271
|
+
process.exit(1);
|
|
272
|
+
console.log(ctx.chalk.green('index: All directory index files OK'));
|
|
273
|
+
}
|
|
109
274
|
export async function checkAllCmd(ctx) {
|
|
110
275
|
const md = await checkMd(ctx.latDir);
|
|
111
276
|
const code = await checkCodeRefs(ctx.latDir);
|
|
277
|
+
const indexErrors = await checkIndex(ctx.latDir);
|
|
112
278
|
const allErrors = [...md.errors, ...code.errors];
|
|
113
279
|
const allFiles = { ...md.files };
|
|
114
280
|
for (const [ext, n] of Object.entries(code.files)) {
|
|
@@ -116,7 +282,10 @@ export async function checkAllCmd(ctx) {
|
|
|
116
282
|
}
|
|
117
283
|
formatStats(ctx, allFiles);
|
|
118
284
|
formatErrors(ctx, allErrors);
|
|
119
|
-
|
|
285
|
+
formatIndexErrors(ctx, indexErrors, allErrors.length);
|
|
286
|
+
const totalErrors = allErrors.length + indexErrors.length;
|
|
287
|
+
formatErrorCount(ctx, totalErrors);
|
|
288
|
+
if (totalErrors > 0)
|
|
120
289
|
process.exit(1);
|
|
121
290
|
console.log(ctx.chalk.green('All checks passed'));
|
|
122
291
|
}
|
package/dist/src/cli/index.js
CHANGED
|
@@ -79,6 +79,14 @@ check
|
|
|
79
79
|
const { checkCodeRefsCmd } = await import('./check.js');
|
|
80
80
|
await checkCodeRefsCmd(ctx);
|
|
81
81
|
});
|
|
82
|
+
check
|
|
83
|
+
.command('index')
|
|
84
|
+
.description('Validate directory index files in lat.md')
|
|
85
|
+
.action(async () => {
|
|
86
|
+
const ctx = resolveContext(program.opts());
|
|
87
|
+
const { checkIndexCmd } = await import('./check.js');
|
|
88
|
+
await checkIndexCmd(ctx);
|
|
89
|
+
});
|
|
82
90
|
program
|
|
83
91
|
.command('prompt')
|
|
84
92
|
.description('Expand [[refs]] in a prompt to lat.md section locations')
|
package/dist/src/cli/init.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, cpSync, mkdirSync, writeFileSync, symlinkSync, } from 'node:fs';
|
|
1
|
+
import { existsSync, cpSync, mkdirSync, writeFileSync, readFileSync, copyFileSync, chmodSync, symlinkSync, } from 'node:fs';
|
|
2
2
|
import { join, resolve } from 'node:path';
|
|
3
3
|
import { createInterface } from 'node:readline/promises';
|
|
4
4
|
import chalk from 'chalk';
|
|
@@ -13,6 +13,49 @@ async function confirm(rl, message) {
|
|
|
13
13
|
return true;
|
|
14
14
|
}
|
|
15
15
|
}
|
|
16
|
+
const HOOK_COMMAND = '.claude/hooks/lat-prompt-hook.sh';
|
|
17
|
+
/**
|
|
18
|
+
* Check if .claude/settings.json already has the lat-prompt hook configured.
|
|
19
|
+
*/
|
|
20
|
+
function hasLatHook(settingsPath) {
|
|
21
|
+
if (!existsSync(settingsPath))
|
|
22
|
+
return false;
|
|
23
|
+
try {
|
|
24
|
+
const settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
|
25
|
+
const entries = settings?.hooks?.UserPromptSubmit;
|
|
26
|
+
if (!Array.isArray(entries))
|
|
27
|
+
return false;
|
|
28
|
+
return entries.some((entry) => entry.hooks?.some((h) => h.command === HOOK_COMMAND));
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Add the lat-prompt hook to .claude/settings.json, preserving existing config.
|
|
36
|
+
*/
|
|
37
|
+
function addLatHook(settingsPath) {
|
|
38
|
+
let settings = {};
|
|
39
|
+
if (existsSync(settingsPath)) {
|
|
40
|
+
try {
|
|
41
|
+
settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// Corrupted file — start fresh
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (!settings.hooks || typeof settings.hooks !== 'object') {
|
|
48
|
+
settings.hooks = {};
|
|
49
|
+
}
|
|
50
|
+
const hooks = settings.hooks;
|
|
51
|
+
if (!Array.isArray(hooks.UserPromptSubmit)) {
|
|
52
|
+
hooks.UserPromptSubmit = [];
|
|
53
|
+
}
|
|
54
|
+
hooks.UserPromptSubmit.push({
|
|
55
|
+
hooks: [{ type: 'command', command: HOOK_COMMAND }],
|
|
56
|
+
});
|
|
57
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
58
|
+
}
|
|
16
59
|
export async function initCmd(targetDir) {
|
|
17
60
|
const root = resolve(targetDir ?? process.cwd());
|
|
18
61
|
const latDir = join(root, 'lat.md');
|
|
@@ -60,6 +103,31 @@ export async function initCmd(targetDir) {
|
|
|
60
103
|
console.log(`\n${existing} already exists. Run ${chalk.cyan('lat gen agents.md')} to preview the template,` +
|
|
61
104
|
` then incorporate its content or overwrite as needed.`);
|
|
62
105
|
}
|
|
106
|
+
// Step 3: Claude Code prompt hook
|
|
107
|
+
const claudeDir = join(root, '.claude');
|
|
108
|
+
const hooksDir = join(claudeDir, 'hooks');
|
|
109
|
+
const hookPath = join(hooksDir, 'lat-prompt-hook.sh');
|
|
110
|
+
const settingsPath = join(claudeDir, 'settings.json');
|
|
111
|
+
if (hasLatHook(settingsPath)) {
|
|
112
|
+
console.log(chalk.green('Claude Code hook') + ' already configured');
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
console.log('');
|
|
116
|
+
console.log(chalk.bold('Claude Code hook') +
|
|
117
|
+
' — adds a per-prompt reminder for the agent to consult lat.md');
|
|
118
|
+
console.log(chalk.dim(' Creates .claude/hooks/lat-prompt-hook.sh and registers it in .claude/settings.json'));
|
|
119
|
+
console.log(chalk.dim(' On every prompt, the agent is instructed to run `lat search` and `lat prompt` before working.'));
|
|
120
|
+
if (await ask('Set up Claude Code prompt hook?')) {
|
|
121
|
+
mkdirSync(hooksDir, { recursive: true });
|
|
122
|
+
const templateHook = join(findTemplatesDir(), 'lat-prompt-hook.sh');
|
|
123
|
+
copyFileSync(templateHook, hookPath);
|
|
124
|
+
chmodSync(hookPath, 0o755);
|
|
125
|
+
addLatHook(settingsPath);
|
|
126
|
+
console.log(chalk.green('Created .claude/hooks/lat-prompt-hook.sh'));
|
|
127
|
+
console.log(chalk.green('Updated .claude/settings.json') +
|
|
128
|
+
' with UserPromptSubmit hook');
|
|
129
|
+
}
|
|
130
|
+
}
|
|
63
131
|
}
|
|
64
132
|
finally {
|
|
65
133
|
rl?.close();
|
package/dist/src/cli/locate.js
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import { loadAllSections, findSections } from '../lattice.js';
|
|
2
2
|
import { formatResultList } from '../format.js';
|
|
3
3
|
export async function locateCmd(ctx, query) {
|
|
4
|
+
const stripped = query.replace(/^\[\[|\]\]$/g, '');
|
|
4
5
|
const sections = await loadAllSections(ctx.latDir);
|
|
5
|
-
const matches = findSections(sections,
|
|
6
|
+
const matches = findSections(sections, stripped);
|
|
6
7
|
if (matches.length === 0) {
|
|
7
|
-
console.error(ctx.chalk.red(`No sections matching "${
|
|
8
|
+
console.error(ctx.chalk.red(`No sections matching "${stripped}" (no exact, substring, or fuzzy matches)`));
|
|
8
9
|
process.exit(1);
|
|
9
10
|
}
|
|
10
|
-
console.log(formatResultList(`Sections matching "${
|
|
11
|
-
numbered: true,
|
|
12
|
-
}));
|
|
11
|
+
console.log(formatResultList(`Sections matching "${stripped}":`, matches, ctx.latDir));
|
|
13
12
|
}
|
package/dist/src/cli/prompt.js
CHANGED
|
@@ -1,18 +1,12 @@
|
|
|
1
1
|
import { relative } from 'node:path';
|
|
2
|
-
import { loadAllSections, findSections,
|
|
2
|
+
import { loadAllSections, findSections, } from '../lattice.js';
|
|
3
3
|
const WIKI_LINK_RE = /\[\[([^\]]+)\]\]/g;
|
|
4
|
-
function
|
|
4
|
+
function formatLocation(section, latDir) {
|
|
5
5
|
const relPath = relative(process.cwd(), latDir + '/' + section.file + '.md');
|
|
6
|
-
|
|
7
|
-
let text = `[${section.id}](${loc})`;
|
|
8
|
-
if (section.body) {
|
|
9
|
-
text += `: ${section.body}`;
|
|
10
|
-
}
|
|
11
|
-
return text;
|
|
6
|
+
return `${relPath}:${section.startLine}-${section.endLine}`;
|
|
12
7
|
}
|
|
13
8
|
export async function promptCmd(ctx, text) {
|
|
14
9
|
const allSections = await loadAllSections(ctx.latDir);
|
|
15
|
-
const flat = flattenSections(allSections);
|
|
16
10
|
const refs = [...text.matchAll(WIKI_LINK_RE)];
|
|
17
11
|
if (refs.length === 0) {
|
|
18
12
|
process.stdout.write(text);
|
|
@@ -23,39 +17,43 @@ export async function promptCmd(ctx, text) {
|
|
|
23
17
|
const target = match[1];
|
|
24
18
|
if (resolved.has(target))
|
|
25
19
|
continue;
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
if (fuzzy.length === 1) {
|
|
34
|
-
resolved.set(target, fuzzy[0]);
|
|
20
|
+
const matches = findSections(allSections, target);
|
|
21
|
+
if (matches.length >= 1) {
|
|
22
|
+
resolved.set(target, {
|
|
23
|
+
target,
|
|
24
|
+
best: matches[0],
|
|
25
|
+
alternatives: matches.slice(1),
|
|
26
|
+
});
|
|
35
27
|
continue;
|
|
36
28
|
}
|
|
37
|
-
if (fuzzy.length > 1) {
|
|
38
|
-
console.error(ctx.chalk.red(`Ambiguous reference [[${target}]].`));
|
|
39
|
-
console.error(ctx.chalk.dim('\nCould match:\n'));
|
|
40
|
-
for (const m of fuzzy) {
|
|
41
|
-
console.error(' ' + m.id);
|
|
42
|
-
}
|
|
43
|
-
console.error(ctx.chalk.dim('\nAsk the user which section they meant.'));
|
|
44
|
-
process.exit(1);
|
|
45
|
-
}
|
|
46
29
|
console.error(ctx.chalk.red(`No section found for [[${target}]] (no exact, substring, or fuzzy matches).`));
|
|
47
30
|
console.error(ctx.chalk.dim('Ask the user to correct the reference.'));
|
|
48
31
|
process.exit(1);
|
|
49
32
|
}
|
|
50
33
|
// Replace [[refs]] inline
|
|
51
34
|
let output = text.replace(WIKI_LINK_RE, (_match, target) => {
|
|
52
|
-
const
|
|
53
|
-
return `[[${section.id}]]`;
|
|
35
|
+
const ref = resolved.get(target);
|
|
36
|
+
return `[[${ref.best.section.id}]]`;
|
|
54
37
|
});
|
|
55
|
-
// Append context block
|
|
38
|
+
// Append context block as nested outliner
|
|
56
39
|
output += '\n\n<lat-context>\n';
|
|
57
|
-
for (const
|
|
58
|
-
|
|
40
|
+
for (const ref of resolved.values()) {
|
|
41
|
+
const isExact = ref.best.reason === 'exact match';
|
|
42
|
+
const all = isExact ? [ref.best] : [ref.best, ...ref.alternatives];
|
|
43
|
+
if (isExact) {
|
|
44
|
+
output += `* \`[[${ref.target}]]\` is referring to:\n`;
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
output += `* \`[[${ref.target}]]\` might be referring to either of the following:\n`;
|
|
48
|
+
}
|
|
49
|
+
for (const m of all) {
|
|
50
|
+
const reason = isExact ? '' : ` (${m.reason})`;
|
|
51
|
+
output += ` * [[${m.section.id}]]${reason}\n`;
|
|
52
|
+
output += ` * ${formatLocation(m.section, ctx.latDir)}\n`;
|
|
53
|
+
if (m.section.body) {
|
|
54
|
+
output += ` * ${m.section.body}\n`;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
59
57
|
}
|
|
60
58
|
output += '</lat-context>\n';
|
|
61
59
|
process.stdout.write(output);
|
package/dist/src/cli/refs.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import { listLatticeFiles, loadAllSections, findSections, extractRefs, flattenSections, } from '../lattice.js';
|
|
4
|
-
import {
|
|
3
|
+
import { listLatticeFiles, loadAllSections, findSections, extractRefs, flattenSections, buildFileIndex, resolveRef, } from '../lattice.js';
|
|
4
|
+
import { formatResultList } from '../format.js';
|
|
5
5
|
import { scanCodeRefs } from '../code-refs.js';
|
|
6
6
|
export async function refsCmd(ctx, query, scope) {
|
|
7
7
|
const allSections = await loadAllSections(ctx.latDir);
|
|
@@ -10,41 +10,47 @@ export async function refsCmd(ctx, query, scope) {
|
|
|
10
10
|
console.error(ctx.chalk.red(`No section matching "${query}" (no exact, substring, or fuzzy matches)`));
|
|
11
11
|
process.exit(1);
|
|
12
12
|
}
|
|
13
|
-
//
|
|
14
|
-
const q = query.toLowerCase();
|
|
13
|
+
// Resolve short refs and require exact match
|
|
15
14
|
const flat = flattenSections(allSections);
|
|
15
|
+
const sectionIds = new Set(flat.map((s) => s.id.toLowerCase()));
|
|
16
|
+
const fileIndex = buildFileIndex(allSections);
|
|
17
|
+
const { resolved } = resolveRef(query, sectionIds, fileIndex);
|
|
18
|
+
const q = resolved.toLowerCase();
|
|
16
19
|
const exactMatch = flat.find((s) => s.id.toLowerCase() === q);
|
|
17
20
|
if (!exactMatch) {
|
|
18
21
|
console.error(ctx.chalk.red(`No section "${query}" found.`));
|
|
19
22
|
if (matches.length > 0) {
|
|
20
23
|
console.error(ctx.chalk.dim('\nDid you mean:\n'));
|
|
21
24
|
for (const m of matches) {
|
|
22
|
-
console.error(
|
|
25
|
+
console.error(ctx.chalk.dim('*') +
|
|
26
|
+
' ' +
|
|
27
|
+
ctx.chalk.white(m.section.id) +
|
|
28
|
+
' ' +
|
|
29
|
+
ctx.chalk.dim(`(${m.reason})`));
|
|
23
30
|
}
|
|
24
31
|
}
|
|
25
32
|
process.exit(1);
|
|
26
33
|
}
|
|
27
34
|
const targetId = exactMatch.id.toLowerCase();
|
|
28
|
-
|
|
35
|
+
const mdMatches = [];
|
|
36
|
+
const codeLines = [];
|
|
29
37
|
if (scope === 'md' || scope === 'md+code') {
|
|
30
38
|
const files = await listLatticeFiles(ctx.latDir);
|
|
31
39
|
const matchingFromSections = new Set();
|
|
32
40
|
for (const file of files) {
|
|
33
41
|
const content = await readFile(file, 'utf-8');
|
|
34
|
-
const fileRefs = extractRefs(file, content);
|
|
42
|
+
const fileRefs = extractRefs(file, content, ctx.latDir);
|
|
35
43
|
for (const ref of fileRefs) {
|
|
36
|
-
|
|
44
|
+
const { resolved: refResolved } = resolveRef(ref.target, sectionIds, fileIndex);
|
|
45
|
+
if (refResolved.toLowerCase() === targetId) {
|
|
37
46
|
matchingFromSections.add(ref.fromSection.toLowerCase());
|
|
38
47
|
}
|
|
39
48
|
}
|
|
40
49
|
}
|
|
41
50
|
if (matchingFromSections.size > 0) {
|
|
42
51
|
const referrers = flat.filter((s) => matchingFromSections.has(s.id.toLowerCase()));
|
|
43
|
-
for (
|
|
44
|
-
|
|
45
|
-
console.log('');
|
|
46
|
-
console.log(formatSectionPreview(referrers[i], ctx.latDir));
|
|
47
|
-
hasOutput = true;
|
|
52
|
+
for (const s of referrers) {
|
|
53
|
+
mdMatches.push({ section: s, reason: 'wiki link' });
|
|
48
54
|
}
|
|
49
55
|
}
|
|
50
56
|
}
|
|
@@ -52,16 +58,26 @@ export async function refsCmd(ctx, query, scope) {
|
|
|
52
58
|
const projectRoot = join(ctx.latDir, '..');
|
|
53
59
|
const { refs: codeRefs } = await scanCodeRefs(projectRoot);
|
|
54
60
|
for (const ref of codeRefs) {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
console.log(` ${ref.file}:${ref.line}`);
|
|
59
|
-
hasOutput = true;
|
|
61
|
+
const { resolved: codeResolved } = resolveRef(ref.target, sectionIds, fileIndex);
|
|
62
|
+
if (codeResolved.toLowerCase() === targetId) {
|
|
63
|
+
codeLines.push(`${ref.file}:${ref.line}`);
|
|
60
64
|
}
|
|
61
65
|
}
|
|
62
66
|
}
|
|
63
|
-
if (
|
|
67
|
+
if (mdMatches.length === 0 && codeLines.length === 0) {
|
|
64
68
|
console.error(ctx.chalk.red(`No references to "${exactMatch.id}" found`));
|
|
65
69
|
process.exit(1);
|
|
66
70
|
}
|
|
71
|
+
if (mdMatches.length > 0) {
|
|
72
|
+
console.log(formatResultList(`References to "${exactMatch.id}":`, mdMatches, ctx.latDir));
|
|
73
|
+
}
|
|
74
|
+
if (codeLines.length > 0) {
|
|
75
|
+
if (mdMatches.length > 0)
|
|
76
|
+
console.log('');
|
|
77
|
+
console.log(ctx.chalk.bold('Code references:'));
|
|
78
|
+
console.log('');
|
|
79
|
+
for (const line of codeLines) {
|
|
80
|
+
console.log(`${ctx.chalk.dim('*')} ${line}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
67
83
|
}
|
package/dist/src/cli/search.js
CHANGED
|
@@ -44,10 +44,9 @@ export async function searchCmd(ctx, query, opts) {
|
|
|
44
44
|
const byId = new Map(flat.map((s) => [s.id, s]));
|
|
45
45
|
const matched = results
|
|
46
46
|
.map((r) => byId.get(r.id))
|
|
47
|
-
.filter((s) => !!s)
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}));
|
|
47
|
+
.filter((s) => !!s)
|
|
48
|
+
.map((s) => ({ section: s, reason: 'semantic match' }));
|
|
49
|
+
console.log(formatResultList(`Search results for "${query}":`, matched, ctx.latDir));
|
|
51
50
|
}
|
|
52
51
|
finally {
|
|
53
52
|
await closeDb(db);
|
package/dist/src/code-refs.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
/** Walk project files
|
|
1
|
+
/** Walk project files for code-ref scanning. Uses walkEntries for .gitignore
|
|
2
|
+
* support, then additionally skips .md files, lat.md/, .claude/, and sub-projects. */
|
|
2
3
|
export declare function walkFiles(dir: string): Promise<string[]>;
|
|
3
4
|
export declare const LAT_REF_RE: RegExp;
|
|
4
5
|
export type CodeRef = {
|
package/dist/src/code-refs.js
CHANGED
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import { join, relative } from 'node:path';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
import { walkEntries } from './walk.js';
|
|
4
|
+
/** Walk project files for code-ref scanning. Uses walkEntries for .gitignore
|
|
5
|
+
* support, then additionally skips .md files, lat.md/, .claude/, and sub-projects. */
|
|
6
6
|
export async function walkFiles(dir) {
|
|
7
|
-
const entries = await
|
|
8
|
-
path: dir,
|
|
9
|
-
ignoreFiles: ['.gitignore'],
|
|
10
|
-
});
|
|
7
|
+
const entries = await walkEntries(dir);
|
|
11
8
|
// Collect directories that contain their own lat.md/ (sub-projects)
|
|
12
9
|
const subProjects = new Set();
|
|
13
10
|
for (const e of entries) {
|
|
@@ -17,7 +14,6 @@ export async function walkFiles(dir) {
|
|
|
17
14
|
}
|
|
18
15
|
return entries
|
|
19
16
|
.filter((e) => !e.endsWith('.md') &&
|
|
20
|
-
!e.startsWith('.git/') &&
|
|
21
17
|
!e.startsWith('lat.md/') &&
|
|
22
18
|
!e.startsWith('.claude/') &&
|
|
23
19
|
![...subProjects].some((prefix) => e.startsWith(prefix)))
|
package/dist/src/format.d.ts
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
import type { Section } from './lattice.js';
|
|
1
|
+
import type { Section, SectionMatch } from './lattice.js';
|
|
2
2
|
export declare function formatSectionId(id: string): string;
|
|
3
3
|
export declare function formatSectionPreview(section: Section, latticeDir: string, opts?: {
|
|
4
|
-
|
|
5
|
-
}): string;
|
|
6
|
-
export declare function formatResultList(header: string, sections: Section[], latticeDir: string, opts?: {
|
|
7
|
-
numbered?: boolean;
|
|
4
|
+
reason?: string;
|
|
8
5
|
}): string;
|
|
6
|
+
export declare function formatResultList(header: string, matches: SectionMatch[], latticeDir: string): string;
|
package/dist/src/format.js
CHANGED
|
@@ -9,28 +9,28 @@ export function formatSectionId(id) {
|
|
|
9
9
|
}
|
|
10
10
|
export function formatSectionPreview(section, latticeDir, opts) {
|
|
11
11
|
const relPath = relative(process.cwd(), latticeDir + '/' + section.file + '.md');
|
|
12
|
-
const
|
|
13
|
-
const
|
|
12
|
+
const kind = section.id.includes('#') ? 'Section' : 'File';
|
|
13
|
+
const reasonSuffix = opts?.reason ? ' ' + chalk.dim(`(${opts.reason})`) : '';
|
|
14
14
|
const lines = [
|
|
15
|
-
`${
|
|
16
|
-
|
|
15
|
+
`${chalk.dim('*')} ${chalk.dim(kind + ':')} [[${formatSectionId(section.id)}]]${reasonSuffix}`,
|
|
16
|
+
` ${chalk.dim('Defined in')} ${chalk.cyan(relPath)}${chalk.dim(`:${section.startLine}-${section.endLine}`)}`,
|
|
17
17
|
];
|
|
18
18
|
if (section.body) {
|
|
19
19
|
const truncated = section.body.length > 200
|
|
20
20
|
? section.body.slice(0, 200) + '...'
|
|
21
21
|
: section.body;
|
|
22
22
|
lines.push('');
|
|
23
|
-
lines.push(
|
|
23
|
+
lines.push(` ${chalk.dim('>')} ${truncated}`);
|
|
24
24
|
}
|
|
25
25
|
return lines.join('\n');
|
|
26
26
|
}
|
|
27
|
-
export function formatResultList(header,
|
|
27
|
+
export function formatResultList(header, matches, latticeDir) {
|
|
28
28
|
const lines = ['', chalk.bold(header), ''];
|
|
29
|
-
for (let i = 0; i <
|
|
29
|
+
for (let i = 0; i < matches.length; i++) {
|
|
30
30
|
if (i > 0)
|
|
31
31
|
lines.push('');
|
|
32
|
-
lines.push(formatSectionPreview(
|
|
33
|
-
|
|
32
|
+
lines.push(formatSectionPreview(matches[i].section, latticeDir, {
|
|
33
|
+
reason: matches[i].reason,
|
|
34
34
|
}));
|
|
35
35
|
}
|
|
36
36
|
lines.push('');
|
package/dist/src/lattice.d.ts
CHANGED
|
@@ -21,8 +21,33 @@ export declare function parseFrontmatter(content: string): LatFrontmatter;
|
|
|
21
21
|
export declare function stripFrontmatter(content: string): string;
|
|
22
22
|
export declare function findLatticeDir(from?: string): string | null;
|
|
23
23
|
export declare function listLatticeFiles(latticeDir: string): Promise<string[]>;
|
|
24
|
-
export declare function parseSections(filePath: string, content: string): Section[];
|
|
24
|
+
export declare function parseSections(filePath: string, content: string, latticeDir?: string): Section[];
|
|
25
25
|
export declare function loadAllSections(latticeDir: string): Promise<Section[]>;
|
|
26
26
|
export declare function flattenSections(sections: Section[]): Section[];
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
/**
|
|
28
|
+
* Build an index mapping bare file stems to their full vault-relative paths.
|
|
29
|
+
* Used by resolveRef to allow short references when a stem is unambiguous.
|
|
30
|
+
*/
|
|
31
|
+
export declare function buildFileIndex(sections: Section[]): Map<string, string[]>;
|
|
32
|
+
export type ResolveResult = {
|
|
33
|
+
resolved: string;
|
|
34
|
+
ambiguous: string[] | null;
|
|
35
|
+
/** When ambiguous but exactly one candidate has the section, suggest it. */
|
|
36
|
+
suggested: string | null;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Resolve a potentially short reference to its canonical full-path form.
|
|
40
|
+
* If the file segment of the ref is a bare stem that uniquely maps to one
|
|
41
|
+
* full path, expands it. Otherwise returns the ref unchanged.
|
|
42
|
+
*
|
|
43
|
+
* When ambiguous (multiple files share the stem), returns all candidates.
|
|
44
|
+
* If exactly one candidate actually contains the referenced section,
|
|
45
|
+
* `suggested` is set to that candidate so the caller can propose a fix.
|
|
46
|
+
*/
|
|
47
|
+
export declare function resolveRef(target: string, sectionIds: Set<string>, fileIndex: Map<string, string[]>): ResolveResult;
|
|
48
|
+
export type SectionMatch = {
|
|
49
|
+
section: Section;
|
|
50
|
+
reason: string;
|
|
51
|
+
};
|
|
52
|
+
export declare function findSections(sections: Section[], query: string): SectionMatch[];
|
|
53
|
+
export declare function extractRefs(filePath: string, content: string, latticeDir?: string): Ref[];
|
package/dist/src/lattice.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { dirname, join, basename, resolve } from 'node:path';
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join, basename, relative, resolve } from 'node:path';
|
|
3
3
|
import { existsSync, statSync } from 'node:fs';
|
|
4
4
|
import { parse } from './parser.js';
|
|
5
|
+
import { walkEntries } from './walk.js';
|
|
5
6
|
import { visit } from 'unist-util-visit';
|
|
6
7
|
export function parseFrontmatter(content) {
|
|
7
8
|
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
@@ -31,7 +32,7 @@ export function findLatticeDir(from) {
|
|
|
31
32
|
}
|
|
32
33
|
}
|
|
33
34
|
export async function listLatticeFiles(latticeDir) {
|
|
34
|
-
const entries = await
|
|
35
|
+
const entries = await walkEntries(latticeDir);
|
|
35
36
|
return entries
|
|
36
37
|
.filter((e) => e.endsWith('.md'))
|
|
37
38
|
.sort()
|
|
@@ -63,9 +64,11 @@ function lastLine(content) {
|
|
|
63
64
|
// If trailing newline, count doesn't include empty last line
|
|
64
65
|
return lines[lines.length - 1] === '' ? lines.length - 1 : lines.length;
|
|
65
66
|
}
|
|
66
|
-
export function parseSections(filePath, content) {
|
|
67
|
+
export function parseSections(filePath, content, latticeDir) {
|
|
67
68
|
const tree = parse(stripFrontmatter(content));
|
|
68
|
-
const file =
|
|
69
|
+
const file = latticeDir
|
|
70
|
+
? relative(latticeDir, filePath).replace(/\.md$/, '')
|
|
71
|
+
: basename(filePath, '.md');
|
|
69
72
|
const roots = [];
|
|
70
73
|
const stack = [];
|
|
71
74
|
const flat = [];
|
|
@@ -128,14 +131,28 @@ export function parseSections(filePath, content) {
|
|
|
128
131
|
}
|
|
129
132
|
return roots;
|
|
130
133
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
134
|
+
const sectionsCache = new Map();
|
|
135
|
+
export function loadAllSections(latticeDir) {
|
|
136
|
+
const noCache = !!process.env._LAT_TEST_DISABLE_FS_CACHE;
|
|
137
|
+
const key = resolve(latticeDir);
|
|
138
|
+
if (!noCache) {
|
|
139
|
+
const cached = sectionsCache.get(key);
|
|
140
|
+
if (cached)
|
|
141
|
+
return cached;
|
|
137
142
|
}
|
|
138
|
-
|
|
143
|
+
const result = (async () => {
|
|
144
|
+
const files = await listLatticeFiles(latticeDir);
|
|
145
|
+
const all = [];
|
|
146
|
+
for (const file of files) {
|
|
147
|
+
const content = await readFile(file, 'utf-8');
|
|
148
|
+
all.push(...parseSections(file, content, latticeDir));
|
|
149
|
+
}
|
|
150
|
+
return all;
|
|
151
|
+
})();
|
|
152
|
+
if (!noCache) {
|
|
153
|
+
sectionsCache.set(key, result);
|
|
154
|
+
}
|
|
155
|
+
return result;
|
|
139
156
|
}
|
|
140
157
|
export function flattenSections(sections) {
|
|
141
158
|
const result = [];
|
|
@@ -176,47 +193,231 @@ function tailSegments(id) {
|
|
|
176
193
|
}
|
|
177
194
|
return tails;
|
|
178
195
|
}
|
|
196
|
+
/**
|
|
197
|
+
* Build an index mapping bare file stems to their full vault-relative paths.
|
|
198
|
+
* Used by resolveRef to allow short references when a stem is unambiguous.
|
|
199
|
+
*/
|
|
200
|
+
export function buildFileIndex(sections) {
|
|
201
|
+
const flat = flattenSections(sections);
|
|
202
|
+
const index = new Map();
|
|
203
|
+
for (const s of flat) {
|
|
204
|
+
const stem = s.file.includes('/') ? s.file.split('/').pop() : null;
|
|
205
|
+
if (stem) {
|
|
206
|
+
if (!index.has(stem))
|
|
207
|
+
index.set(stem, new Set());
|
|
208
|
+
index.get(stem).add(s.file);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const result = new Map();
|
|
212
|
+
for (const [stem, paths] of index) {
|
|
213
|
+
result.set(stem, [...paths]);
|
|
214
|
+
}
|
|
215
|
+
return result;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Resolve a potentially short reference to its canonical full-path form.
|
|
219
|
+
* If the file segment of the ref is a bare stem that uniquely maps to one
|
|
220
|
+
* full path, expands it. Otherwise returns the ref unchanged.
|
|
221
|
+
*
|
|
222
|
+
* When ambiguous (multiple files share the stem), returns all candidates.
|
|
223
|
+
* If exactly one candidate actually contains the referenced section,
|
|
224
|
+
* `suggested` is set to that candidate so the caller can propose a fix.
|
|
225
|
+
*/
|
|
226
|
+
export function resolveRef(target, sectionIds, fileIndex) {
|
|
227
|
+
// Already matches a known section — no resolution needed
|
|
228
|
+
if (sectionIds.has(target.toLowerCase())) {
|
|
229
|
+
return { resolved: target, ambiguous: null, suggested: null };
|
|
230
|
+
}
|
|
231
|
+
// Extract the file segment (before first #) and try resolving it
|
|
232
|
+
const hashIdx = target.indexOf('#');
|
|
233
|
+
const filePart = hashIdx === -1 ? target : target.slice(0, hashIdx);
|
|
234
|
+
const rest = hashIdx === -1 ? '' : target.slice(hashIdx);
|
|
235
|
+
const candidates = fileIndex.get(filePart) ?? [];
|
|
236
|
+
if (candidates.length === 1) {
|
|
237
|
+
const expanded = candidates[0] + rest;
|
|
238
|
+
if (sectionIds.has(expanded.toLowerCase())) {
|
|
239
|
+
return { resolved: expanded, ambiguous: null, suggested: null };
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
else if (candidates.length > 1) {
|
|
243
|
+
// Multiple files share this stem — ambiguous at the filename level
|
|
244
|
+
const all = candidates.map((c) => c + rest);
|
|
245
|
+
const valid = candidates.filter((c) => sectionIds.has((c + rest).toLowerCase()));
|
|
246
|
+
return {
|
|
247
|
+
resolved: target,
|
|
248
|
+
ambiguous: all,
|
|
249
|
+
suggested: valid.length === 1 ? valid[0] + rest : null,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
return { resolved: target, ambiguous: null, suggested: null };
|
|
253
|
+
}
|
|
179
254
|
const MAX_DISTANCE_RATIO = 0.4;
|
|
180
255
|
export function findSections(sections, query) {
|
|
181
256
|
const flat = flattenSections(sections);
|
|
182
|
-
|
|
183
|
-
const
|
|
257
|
+
// Leading # means "search for a heading", strip it
|
|
258
|
+
const normalized = query.startsWith('#') ? query.slice(1) : query;
|
|
259
|
+
const q = normalized.toLowerCase();
|
|
260
|
+
const isFullPath = normalized.includes('#');
|
|
184
261
|
// Tier 1: exact full-id match
|
|
185
262
|
const exact = flat.filter((s) => s.id.toLowerCase() === q);
|
|
186
|
-
|
|
187
|
-
|
|
263
|
+
const exactMatches = exact.map((s) => ({
|
|
264
|
+
section: s,
|
|
265
|
+
reason: 'exact match',
|
|
266
|
+
}));
|
|
267
|
+
if (exactMatches.length > 0 && isFullPath)
|
|
268
|
+
return exactMatches;
|
|
269
|
+
// Tier 1b: file stem expansion
|
|
270
|
+
// For bare names: "locate" → matches root section of "tests/locate.md"
|
|
271
|
+
// For paths with #: "setup#Install" → expands to "guides/setup#Install"
|
|
272
|
+
const fileIndex = buildFileIndex(sections);
|
|
273
|
+
const stemMatches = [];
|
|
274
|
+
if (isFullPath) {
|
|
275
|
+
// Expand file stem in the file part of the query
|
|
276
|
+
const hashIdx = normalized.indexOf('#');
|
|
277
|
+
const filePart = normalized.slice(0, hashIdx);
|
|
278
|
+
const rest = normalized.slice(hashIdx);
|
|
279
|
+
const paths = fileIndex.get(filePart) ?? [];
|
|
280
|
+
for (const p of paths) {
|
|
281
|
+
const expanded = (p + rest).toLowerCase();
|
|
282
|
+
const s = flat.find((s) => s.id.toLowerCase() === expanded && !exact.includes(s));
|
|
283
|
+
if (s)
|
|
284
|
+
stemMatches.push({
|
|
285
|
+
section: s,
|
|
286
|
+
reason: `file stem expanded: ${filePart} → ${p}`,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
if (stemMatches.length > 0)
|
|
290
|
+
return [...exactMatches, ...stemMatches];
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
// Bare name: match file root sections via stem index
|
|
294
|
+
const paths = fileIndex.get(normalized) ?? [];
|
|
295
|
+
for (const p of paths) {
|
|
296
|
+
const s = flat.find((s) => s.id.toLowerCase() === p.toLowerCase() && !exact.includes(s));
|
|
297
|
+
if (s)
|
|
298
|
+
stemMatches.push({ section: s, reason: 'file stem match' });
|
|
299
|
+
}
|
|
300
|
+
}
|
|
188
301
|
// Tier 2: exact match on trailing segments (subsection name match)
|
|
302
|
+
const seen = new Set([
|
|
303
|
+
...exact.map((s) => s.id),
|
|
304
|
+
...stemMatches.map((m) => m.section.id),
|
|
305
|
+
]);
|
|
189
306
|
const subsection = isFullPath
|
|
190
307
|
? []
|
|
191
|
-
: flat
|
|
308
|
+
: flat
|
|
309
|
+
.filter((s) => {
|
|
310
|
+
if (seen.has(s.id))
|
|
311
|
+
return false;
|
|
312
|
+
return tailSegments(s.id).some((tail) => tail.toLowerCase() === q);
|
|
313
|
+
})
|
|
314
|
+
.map((s) => ({ section: s, reason: 'section name match' }));
|
|
315
|
+
// Tier 2b: subsequence match — query segments are a subsequence of section id segments
|
|
316
|
+
// e.g. "Markdown#Resolution Rules" matches "markdown#Wiki Links#Resolution Rules"
|
|
317
|
+
const seenSub = new Set([...seen, ...subsection.map((m) => m.section.id)]);
|
|
318
|
+
const qParts = q.split('#');
|
|
319
|
+
const subsequence = qParts.length >= 2
|
|
320
|
+
? flat
|
|
321
|
+
.filter((s) => {
|
|
322
|
+
if (seenSub.has(s.id))
|
|
323
|
+
return false;
|
|
324
|
+
const sParts = s.id.toLowerCase().split('#');
|
|
325
|
+
if (sParts.length <= qParts.length)
|
|
326
|
+
return false;
|
|
327
|
+
let qi = 0;
|
|
328
|
+
for (const sp of sParts) {
|
|
329
|
+
if (sp === qParts[qi])
|
|
330
|
+
qi++;
|
|
331
|
+
if (qi === qParts.length)
|
|
332
|
+
return true;
|
|
333
|
+
}
|
|
334
|
+
return false;
|
|
335
|
+
})
|
|
336
|
+
.map((s) => {
|
|
337
|
+
const skipped = s.id.split('#').length - qParts.length;
|
|
338
|
+
return {
|
|
339
|
+
section: s,
|
|
340
|
+
reason: `path match, ${skipped} intermediate ${skipped === 1 ? 'section' : 'sections'} skipped`,
|
|
341
|
+
};
|
|
342
|
+
})
|
|
343
|
+
: [];
|
|
192
344
|
// Tier 3: fuzzy match by edit distance on each segment tail and full id
|
|
193
|
-
const
|
|
194
|
-
...
|
|
195
|
-
...
|
|
345
|
+
const seenAll = new Set([
|
|
346
|
+
...seenSub,
|
|
347
|
+
...subsequence.map((m) => m.section.id),
|
|
196
348
|
]);
|
|
197
349
|
const fuzzy = [];
|
|
350
|
+
// For full-path queries, extract the file and heading parts so we can
|
|
351
|
+
// fuzzy-match only the heading portion when the file part matches exactly.
|
|
352
|
+
// This prevents the shared file prefix from inflating similarity scores
|
|
353
|
+
// (e.g. "cli#locat" would otherwise fuzzy-match "cli#prompt").
|
|
354
|
+
const qHashIdx = normalized.indexOf('#');
|
|
355
|
+
const qFile = qHashIdx === -1 ? null : normalized.slice(0, qHashIdx).toLowerCase();
|
|
356
|
+
const qHeading = qHashIdx === -1 ? null : normalized.slice(qHashIdx + 1).toLowerCase();
|
|
198
357
|
for (const s of flat) {
|
|
199
|
-
if (
|
|
358
|
+
if (seenAll.has(s.id))
|
|
200
359
|
continue;
|
|
201
360
|
const candidates = [s.id, ...tailSegments(s.id)];
|
|
202
361
|
let best = Infinity;
|
|
362
|
+
let bestCandidate = '';
|
|
203
363
|
for (const c of candidates) {
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
364
|
+
let d;
|
|
365
|
+
let maxLen;
|
|
366
|
+
const cl = c.toLowerCase();
|
|
367
|
+
const cHashIdx = cl.indexOf('#');
|
|
368
|
+
// When both query and candidate have # and their file parts match,
|
|
369
|
+
// compare only the heading portions to avoid file-prefix inflation
|
|
370
|
+
if (qFile && qHeading && cHashIdx !== -1) {
|
|
371
|
+
const cFile = cl.slice(0, cHashIdx);
|
|
372
|
+
const cHeading = cl.slice(cHashIdx + 1);
|
|
373
|
+
if (cFile === qFile) {
|
|
374
|
+
d = levenshtein(cHeading, qHeading);
|
|
375
|
+
maxLen = Math.max(cHeading.length, qHeading.length);
|
|
376
|
+
}
|
|
377
|
+
else {
|
|
378
|
+
d = levenshtein(cl, q);
|
|
379
|
+
maxLen = Math.max(c.length, q.length);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
d = levenshtein(cl, q);
|
|
384
|
+
maxLen = Math.max(c.length, q.length);
|
|
385
|
+
}
|
|
386
|
+
if (maxLen > 0 && d / maxLen <= MAX_DISTANCE_RATIO && d < best) {
|
|
207
387
|
best = d;
|
|
388
|
+
bestCandidate = c;
|
|
208
389
|
}
|
|
209
390
|
}
|
|
210
391
|
if (best < Infinity) {
|
|
211
|
-
fuzzy.push({ section: s, distance: best });
|
|
392
|
+
fuzzy.push({ section: s, distance: best, matched: bestCandidate });
|
|
212
393
|
}
|
|
213
394
|
}
|
|
214
395
|
fuzzy.sort((a, b) => a.distance - b.distance);
|
|
215
|
-
|
|
396
|
+
const fuzzyMatches = fuzzy.map((f) => ({
|
|
397
|
+
section: f.section,
|
|
398
|
+
reason: f.matched.toLowerCase() === f.section.id.toLowerCase()
|
|
399
|
+
? `fuzzy match, distance ${f.distance}`
|
|
400
|
+
: `fuzzy match on "${f.matched}", distance ${f.distance}`,
|
|
401
|
+
}));
|
|
402
|
+
// Sort results: shallower depth first, then fewer path segments
|
|
403
|
+
const sortKey = (s) => {
|
|
404
|
+
const pathDepth = (s.file.match(/\//g) || []).length;
|
|
405
|
+
return s.depth * 100 + pathDepth;
|
|
406
|
+
};
|
|
407
|
+
const sortedStems = [...stemMatches].sort((a, b) => sortKey(a.section) - sortKey(b.section));
|
|
408
|
+
return [
|
|
409
|
+
...exactMatches,
|
|
410
|
+
...sortedStems,
|
|
411
|
+
...subsection,
|
|
412
|
+
...subsequence,
|
|
413
|
+
...fuzzyMatches,
|
|
414
|
+
];
|
|
216
415
|
}
|
|
217
|
-
export function extractRefs(filePath, content) {
|
|
416
|
+
export function extractRefs(filePath, content, latticeDir) {
|
|
218
417
|
const tree = parse(stripFrontmatter(content));
|
|
219
|
-
const file =
|
|
418
|
+
const file = latticeDir
|
|
419
|
+
? relative(latticeDir, filePath).replace(/\.md$/, '')
|
|
420
|
+
: basename(filePath, '.md');
|
|
220
421
|
const refs = [];
|
|
221
422
|
// Build a flat list of sections to determine enclosing section for each wiki link
|
|
222
423
|
const flat = [];
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Walk a directory tree respecting .gitignore rules. Returns relative paths
|
|
3
|
+
* of all non-ignored files, excluding .git/ and dotfiles (e.g. .gitignore).
|
|
4
|
+
*
|
|
5
|
+
* Results are memoized by resolved directory path — safe because CLI commands
|
|
6
|
+
* don't modify the filesystem during a run. Set _LAT_TEST_DISABLE_FS_CACHE=1
|
|
7
|
+
* to bypass caching in tests that mutate the filesystem mid-run.
|
|
8
|
+
*
|
|
9
|
+
* This is the single entry point for all directory walking in lat.md — both
|
|
10
|
+
* code-ref scanning and lat.md/ index validation use it so .gitignore rules
|
|
11
|
+
* are consistently honored.
|
|
12
|
+
*/
|
|
13
|
+
export declare function walkEntries(dir: string): Promise<string[]>;
|
package/dist/src/walk.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
// @ts-expect-error -- no type declarations
|
|
3
|
+
import walk from 'ignore-walk';
|
|
4
|
+
const cache = new Map();
|
|
5
|
+
/**
|
|
6
|
+
* Walk a directory tree respecting .gitignore rules. Returns relative paths
|
|
7
|
+
* of all non-ignored files, excluding .git/ and dotfiles (e.g. .gitignore).
|
|
8
|
+
*
|
|
9
|
+
* Results are memoized by resolved directory path — safe because CLI commands
|
|
10
|
+
* don't modify the filesystem during a run. Set _LAT_TEST_DISABLE_FS_CACHE=1
|
|
11
|
+
* to bypass caching in tests that mutate the filesystem mid-run.
|
|
12
|
+
*
|
|
13
|
+
* This is the single entry point for all directory walking in lat.md — both
|
|
14
|
+
* code-ref scanning and lat.md/ index validation use it so .gitignore rules
|
|
15
|
+
* are consistently honored.
|
|
16
|
+
*/
|
|
17
|
+
export function walkEntries(dir) {
|
|
18
|
+
const noCache = !!process.env._LAT_TEST_DISABLE_FS_CACHE;
|
|
19
|
+
if (!noCache) {
|
|
20
|
+
const cached = cache.get(resolve(dir));
|
|
21
|
+
if (cached)
|
|
22
|
+
return cached;
|
|
23
|
+
}
|
|
24
|
+
const result = walk({
|
|
25
|
+
path: dir,
|
|
26
|
+
ignoreFiles: ['.gitignore'],
|
|
27
|
+
}).then((entries) => entries.filter((e) => !e.startsWith('.git/') && !e.startsWith('.')));
|
|
28
|
+
if (!noCache) {
|
|
29
|
+
cache.set(resolve(dir), result);
|
|
30
|
+
}
|
|
31
|
+
return result;
|
|
32
|
+
}
|
package/package.json
CHANGED
package/templates/AGENTS.md
CHANGED
|
@@ -33,7 +33,7 @@ If `lat search` fails because `LAT_LLM_KEY` is not set, explain to the user that
|
|
|
33
33
|
|
|
34
34
|
# Syntax primer
|
|
35
35
|
|
|
36
|
-
- **Section ids**: `file
|
|
36
|
+
- **Section ids**: `path/to/file#Heading#SubHeading` — full form uses vault-relative path (e.g. `tests/search#RAG Replay Tests`). Short form uses bare file name when unique (e.g. `search#RAG Replay Tests`, `cli#search#Indexing`).
|
|
37
37
|
- **Wiki links**: `[[target]]` or `[[target|alias]]` — cross-references between sections
|
|
38
38
|
- **Code refs**: `// @lat: [[section-id]]` (JS/TS) or `# @lat: [[section-id]]` (Python) — ties source code to concepts
|
|
39
39
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
This directory defines the high-level concepts, business logic, and architecture of this project using markdown. It is managed by [lat.md](https://www.npmjs.com/package/lat.md) — a tool that anchors source code to these definitions. Install the `lat` command with `npm i -g lat.md` and run `lat --help`.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# lat.md prompt hook for Claude Code
|
|
3
|
+
# Injected by `lat init` — reminds the agent to consult lat.md before working.
|
|
4
|
+
#
|
|
5
|
+
# This hook fires on every user prompt submission. It adds a short instruction
|
|
6
|
+
# telling the agent to run `lat search` and `lat prompt` so that lat.md context
|
|
7
|
+
# is consulted before any code changes.
|
|
8
|
+
|
|
9
|
+
cat <<'HOOK_JSON'
|
|
10
|
+
{
|
|
11
|
+
"hookSpecificOutput": {
|
|
12
|
+
"hookEventName": "UserPromptSubmit",
|
|
13
|
+
"additionalContext": "Before starting work on this task:\n1. Run `lat search` with a query relevant to the task and read the results to understand the design intent.\n2. If the prompt contains [[refs]], run `lat prompt` on the full prompt text to resolve them.\n3. After completing work, run `lat check` to validate all links and code refs.\nDo not skip these steps."
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
HOOK_JSON
|
package/dist/src/cli.d.ts
DELETED
package/dist/src/cli.js
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { findLatticeDir, loadAllSections, findSections } from './lattice.js';
|
|
3
|
-
const args = process.argv.slice(2);
|
|
4
|
-
const command = args[0];
|
|
5
|
-
if (command !== 'locate' || args.length < 2) {
|
|
6
|
-
console.error('Usage: lat locate <query>');
|
|
7
|
-
process.exit(1);
|
|
8
|
-
}
|
|
9
|
-
const query = args[1];
|
|
10
|
-
const latticeDir = findLatticeDir();
|
|
11
|
-
if (!latticeDir) {
|
|
12
|
-
console.error('No .lattice directory found');
|
|
13
|
-
process.exit(1);
|
|
14
|
-
}
|
|
15
|
-
const sections = await loadAllSections(latticeDir);
|
|
16
|
-
const matches = findSections(sections, query);
|
|
17
|
-
if (matches.length === 0) {
|
|
18
|
-
console.error(`No sections matching "${query}"`);
|
|
19
|
-
process.exit(1);
|
|
20
|
-
}
|
|
21
|
-
for (const m of matches) {
|
|
22
|
-
console.log(m.id);
|
|
23
|
-
}
|
package/templates/init/README.md
DELETED
|
@@ -1,5 +0,0 @@
|
|
|
1
|
-
This directory defines the high-level concepts, business logic, and architecture of this project using markdown.
|
|
2
|
-
|
|
3
|
-
It is managed by [lat.md](https://www.npmjs.com/package/lat.md) — a tool that anchors source code to these definitions.
|
|
4
|
-
|
|
5
|
-
Install the `lat` command with `npm i -g lat.md` and run `lat --help`.
|