lat.md 0.2.3 → 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/dist/src/cli/init.js +69 -1
- package/dist/src/cli/locate.js +4 -5
- package/dist/src/cli/prompt.js +30 -36
- package/dist/src/cli/refs.js +24 -13
- package/dist/src/cli/search.js +3 -4
- package/dist/src/format.d.ts +3 -5
- package/dist/src/format.js +9 -9
- package/dist/src/lattice.d.ts +5 -1
- package/dist/src/lattice.js +138 -18
- package/package.json +1 -1
- package/templates/lat-prompt-hook.sh +16 -0
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,20 +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
|
-
const sectionIds = new Set(flat.map((s) => s.id.toLowerCase()));
|
|
17
|
-
const fileIndex = buildFileIndex(allSections);
|
|
18
10
|
const refs = [...text.matchAll(WIKI_LINK_RE)];
|
|
19
11
|
if (refs.length === 0) {
|
|
20
12
|
process.stdout.write(text);
|
|
@@ -25,41 +17,43 @@ export async function promptCmd(ctx, text) {
|
|
|
25
17
|
const target = match[1];
|
|
26
18
|
if (resolved.has(target))
|
|
27
19
|
continue;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
36
|
-
const fuzzy = findSections(allSections, target);
|
|
37
|
-
if (fuzzy.length === 1) {
|
|
38
|
-
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
|
+
});
|
|
39
27
|
continue;
|
|
40
28
|
}
|
|
41
|
-
if (fuzzy.length > 1) {
|
|
42
|
-
console.error(ctx.chalk.red(`Ambiguous reference [[${target}]].`));
|
|
43
|
-
console.error(ctx.chalk.dim('\nCould match:\n'));
|
|
44
|
-
for (const m of fuzzy) {
|
|
45
|
-
console.error(' ' + m.id);
|
|
46
|
-
}
|
|
47
|
-
console.error(ctx.chalk.dim('\nAsk the user which section they meant.'));
|
|
48
|
-
process.exit(1);
|
|
49
|
-
}
|
|
50
29
|
console.error(ctx.chalk.red(`No section found for [[${target}]] (no exact, substring, or fuzzy matches).`));
|
|
51
30
|
console.error(ctx.chalk.dim('Ask the user to correct the reference.'));
|
|
52
31
|
process.exit(1);
|
|
53
32
|
}
|
|
54
33
|
// Replace [[refs]] inline
|
|
55
34
|
let output = text.replace(WIKI_LINK_RE, (_match, target) => {
|
|
56
|
-
const
|
|
57
|
-
return `[[${section.id}]]`;
|
|
35
|
+
const ref = resolved.get(target);
|
|
36
|
+
return `[[${ref.best.section.id}]]`;
|
|
58
37
|
});
|
|
59
|
-
// Append context block
|
|
38
|
+
// Append context block as nested outliner
|
|
60
39
|
output += '\n\n<lat-context>\n';
|
|
61
|
-
for (const
|
|
62
|
-
|
|
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
|
+
}
|
|
63
57
|
}
|
|
64
58
|
output += '</lat-context>\n';
|
|
65
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
3
|
import { listLatticeFiles, loadAllSections, findSections, extractRefs, flattenSections, buildFileIndex, resolveRef, } from '../lattice.js';
|
|
4
|
-
import {
|
|
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);
|
|
@@ -22,13 +22,18 @@ export async function refsCmd(ctx, query, scope) {
|
|
|
22
22
|
if (matches.length > 0) {
|
|
23
23
|
console.error(ctx.chalk.dim('\nDid you mean:\n'));
|
|
24
24
|
for (const m of matches) {
|
|
25
|
-
console.error(
|
|
25
|
+
console.error(ctx.chalk.dim('*') +
|
|
26
|
+
' ' +
|
|
27
|
+
ctx.chalk.white(m.section.id) +
|
|
28
|
+
' ' +
|
|
29
|
+
ctx.chalk.dim(`(${m.reason})`));
|
|
26
30
|
}
|
|
27
31
|
}
|
|
28
32
|
process.exit(1);
|
|
29
33
|
}
|
|
30
34
|
const targetId = exactMatch.id.toLowerCase();
|
|
31
|
-
|
|
35
|
+
const mdMatches = [];
|
|
36
|
+
const codeLines = [];
|
|
32
37
|
if (scope === 'md' || scope === 'md+code') {
|
|
33
38
|
const files = await listLatticeFiles(ctx.latDir);
|
|
34
39
|
const matchingFromSections = new Set();
|
|
@@ -44,11 +49,8 @@ export async function refsCmd(ctx, query, scope) {
|
|
|
44
49
|
}
|
|
45
50
|
if (matchingFromSections.size > 0) {
|
|
46
51
|
const referrers = flat.filter((s) => matchingFromSections.has(s.id.toLowerCase()));
|
|
47
|
-
for (
|
|
48
|
-
|
|
49
|
-
console.log('');
|
|
50
|
-
console.log(formatSectionPreview(referrers[i], ctx.latDir));
|
|
51
|
-
hasOutput = true;
|
|
52
|
+
for (const s of referrers) {
|
|
53
|
+
mdMatches.push({ section: s, reason: 'wiki link' });
|
|
52
54
|
}
|
|
53
55
|
}
|
|
54
56
|
}
|
|
@@ -58,15 +60,24 @@ export async function refsCmd(ctx, query, scope) {
|
|
|
58
60
|
for (const ref of codeRefs) {
|
|
59
61
|
const { resolved: codeResolved } = resolveRef(ref.target, sectionIds, fileIndex);
|
|
60
62
|
if (codeResolved.toLowerCase() === targetId) {
|
|
61
|
-
|
|
62
|
-
console.log('');
|
|
63
|
-
console.log(` ${ref.file}:${ref.line}`);
|
|
64
|
-
hasOutput = true;
|
|
63
|
+
codeLines.push(`${ref.file}:${ref.line}`);
|
|
65
64
|
}
|
|
66
65
|
}
|
|
67
66
|
}
|
|
68
|
-
if (
|
|
67
|
+
if (mdMatches.length === 0 && codeLines.length === 0) {
|
|
69
68
|
console.error(ctx.chalk.red(`No references to "${exactMatch.id}" found`));
|
|
70
69
|
process.exit(1);
|
|
71
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
|
+
}
|
|
72
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/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
|
@@ -45,5 +45,9 @@ export type ResolveResult = {
|
|
|
45
45
|
* `suggested` is set to that candidate so the caller can propose a fix.
|
|
46
46
|
*/
|
|
47
47
|
export declare function resolveRef(target: string, sectionIds: Set<string>, fileIndex: Map<string, string[]>): ResolveResult;
|
|
48
|
-
export
|
|
48
|
+
export type SectionMatch = {
|
|
49
|
+
section: Section;
|
|
50
|
+
reason: string;
|
|
51
|
+
};
|
|
52
|
+
export declare function findSections(sections: Section[], query: string): SectionMatch[];
|
|
49
53
|
export declare function extractRefs(filePath: string, content: string, latticeDir?: string): Ref[];
|
package/dist/src/lattice.js
CHANGED
|
@@ -254,44 +254,164 @@ export function resolveRef(target, sectionIds, fileIndex) {
|
|
|
254
254
|
const MAX_DISTANCE_RATIO = 0.4;
|
|
255
255
|
export function findSections(sections, query) {
|
|
256
256
|
const flat = flattenSections(sections);
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
const
|
|
260
|
-
const
|
|
261
|
-
const { resolved } = resolveRef(query, sectionIds, fileIndex);
|
|
262
|
-
const q = resolved.toLowerCase();
|
|
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('#');
|
|
263
261
|
// Tier 1: exact full-id match
|
|
264
262
|
const exact = flat.filter((s) => s.id.toLowerCase() === q);
|
|
265
|
-
|
|
266
|
-
|
|
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
|
+
}
|
|
267
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
|
+
]);
|
|
268
306
|
const subsection = isFullPath
|
|
269
307
|
? []
|
|
270
|
-
: 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
|
+
: [];
|
|
271
344
|
// Tier 3: fuzzy match by edit distance on each segment tail and full id
|
|
272
|
-
const
|
|
273
|
-
...
|
|
274
|
-
...
|
|
345
|
+
const seenAll = new Set([
|
|
346
|
+
...seenSub,
|
|
347
|
+
...subsequence.map((m) => m.section.id),
|
|
275
348
|
]);
|
|
276
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();
|
|
277
357
|
for (const s of flat) {
|
|
278
|
-
if (
|
|
358
|
+
if (seenAll.has(s.id))
|
|
279
359
|
continue;
|
|
280
360
|
const candidates = [s.id, ...tailSegments(s.id)];
|
|
281
361
|
let best = Infinity;
|
|
362
|
+
let bestCandidate = '';
|
|
282
363
|
for (const c of candidates) {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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) {
|
|
286
387
|
best = d;
|
|
388
|
+
bestCandidate = c;
|
|
287
389
|
}
|
|
288
390
|
}
|
|
289
391
|
if (best < Infinity) {
|
|
290
|
-
fuzzy.push({ section: s, distance: best });
|
|
392
|
+
fuzzy.push({ section: s, distance: best, matched: bestCandidate });
|
|
291
393
|
}
|
|
292
394
|
}
|
|
293
395
|
fuzzy.sort((a, b) => a.distance - b.distance);
|
|
294
|
-
|
|
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
|
+
];
|
|
295
415
|
}
|
|
296
416
|
export function extractRefs(filePath, content, latticeDir) {
|
|
297
417
|
const tree = parse(stripFrontmatter(content));
|
package/package.json
CHANGED
|
@@ -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
|