lat.md 0.6.0 → 0.7.1
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 +3 -2
- package/dist/src/cli/check.d.ts +7 -5
- package/dist/src/cli/check.js +186 -65
- package/dist/src/cli/context.d.ts +3 -8
- package/dist/src/cli/context.js +13 -1
- package/dist/src/cli/expand.d.ts +7 -0
- package/dist/src/cli/{prompt.js → expand.js} +44 -13
- package/dist/src/cli/gen.js +11 -4
- package/dist/src/cli/hook.d.ts +1 -0
- package/dist/src/cli/hook.js +147 -0
- package/dist/src/cli/index.js +77 -28
- package/dist/src/cli/init.js +148 -120
- package/dist/src/cli/locate.d.ts +2 -2
- package/dist/src/cli/locate.js +9 -4
- package/dist/src/cli/refs.d.ts +20 -4
- package/dist/src/cli/refs.js +63 -39
- package/dist/src/cli/search.d.ts +25 -3
- package/dist/src/cli/search.js +82 -48
- package/dist/src/cli/section.d.ts +26 -0
- package/dist/src/cli/section.js +138 -0
- package/dist/src/code-refs.js +2 -1
- package/dist/src/config.js +3 -2
- package/dist/src/context.d.ts +21 -0
- package/dist/src/context.js +11 -0
- package/dist/src/format.d.ts +5 -3
- package/dist/src/format.js +24 -19
- package/dist/src/init-version.d.ts +10 -0
- package/dist/src/init-version.js +49 -0
- package/dist/src/lattice.d.ts +1 -2
- package/dist/src/lattice.js +5 -8
- package/dist/src/mcp/server.js +26 -279
- package/dist/src/parser.js +2 -0
- package/dist/src/source-parser.js +389 -2
- package/package.json +2 -1
- package/templates/AGENTS.md +36 -5
- package/templates/cursor-rules.md +9 -4
- package/templates/lat-prompt-hook.sh +2 -2
- package/dist/src/cli/prompt.d.ts +0 -2
package/README.md
CHANGED
|
@@ -33,7 +33,7 @@ After installing, run `lat init` in the repo you want to use lat in.
|
|
|
33
33
|
|
|
34
34
|
## How it works
|
|
35
35
|
|
|
36
|
-
Run `lat init` to scaffold a `lat.md/` directory, then write markdown files describing your architecture, business logic, test specs — whatever matters. Link between sections using `[[file#Section#Subsection]]` syntax. Annotate source code with `// @lat: [[section-id]]` (or `# @lat: [[section-id]]` in Python) comments to tie implementation back to concepts.
|
|
36
|
+
Run `lat init` to scaffold a `lat.md/` directory, then write markdown files describing your architecture, business logic, test specs — whatever matters. Link between sections using `[[file#Section#Subsection]]` syntax. Link to source code symbols with `[[src/auth.ts#validateToken]]`. Annotate source code with `// @lat: [[section-id]]` (or `# @lat: [[section-id]]` in Python) comments to tie implementation back to concepts.
|
|
37
37
|
|
|
38
38
|
```
|
|
39
39
|
my-project/
|
|
@@ -53,9 +53,10 @@ my-project/
|
|
|
53
53
|
npx lat.md init # scaffold a lat.md/ directory
|
|
54
54
|
npx lat.md check # validate all wiki links and code refs
|
|
55
55
|
npx lat.md locate "OAuth Flow" # find sections by name (exact, fuzzy)
|
|
56
|
+
npx lat.md section "auth#OAuth Flow" # show a section with its links and refs
|
|
56
57
|
npx lat.md refs "auth#OAuth Flow" # find what references a section
|
|
57
58
|
npx lat.md search "how do we auth?" # semantic search via embeddings
|
|
58
|
-
npx lat.md
|
|
59
|
+
npx lat.md expand "fix [[OAuth Flow]]" # expand [[refs]] in a prompt for agents
|
|
59
60
|
```
|
|
60
61
|
|
|
61
62
|
## Configuration
|
package/dist/src/cli/check.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { CmdContext, CmdResult } from '../context.js';
|
|
2
2
|
export type CheckError = {
|
|
3
3
|
file: string;
|
|
4
4
|
line: number;
|
|
@@ -19,7 +19,9 @@ export type IndexError = {
|
|
|
19
19
|
snippet?: string;
|
|
20
20
|
};
|
|
21
21
|
export declare function checkIndex(latticeDir: string): Promise<IndexError[]>;
|
|
22
|
-
export declare function
|
|
23
|
-
export declare function
|
|
24
|
-
export declare function
|
|
25
|
-
export declare function
|
|
22
|
+
export declare function checkSections(latticeDir: string): Promise<CheckError[]>;
|
|
23
|
+
export declare function checkAllCommand(ctx: CmdContext): Promise<CmdResult>;
|
|
24
|
+
export declare function checkMdCommand(ctx: CmdContext): Promise<CmdResult>;
|
|
25
|
+
export declare function checkCodeRefsCommand(ctx: CmdContext): Promise<CmdResult>;
|
|
26
|
+
export declare function checkIndexCommand(ctx: CmdContext): Promise<CmdResult>;
|
|
27
|
+
export declare function checkSectionsCommand(ctx: CmdContext): Promise<CmdResult>;
|
package/dist/src/cli/check.js
CHANGED
|
@@ -4,6 +4,7 @@ import { basename, dirname, extname, join, relative } from 'node:path';
|
|
|
4
4
|
import { listLatticeFiles, loadAllSections, extractRefs, flattenSections, parseFrontmatter, parseSections, buildFileIndex, resolveRef, } from '../lattice.js';
|
|
5
5
|
import { scanCodeRefs } from '../code-refs.js';
|
|
6
6
|
import { walkEntries } from '../walk.js';
|
|
7
|
+
import { INIT_VERSION, readInitVersion } from '../init-version.js';
|
|
7
8
|
function filePart(id) {
|
|
8
9
|
const h = id.indexOf('#');
|
|
9
10
|
return h === -1 ? id : id.slice(0, h);
|
|
@@ -32,7 +33,17 @@ function countByExt(paths) {
|
|
|
32
33
|
return stats;
|
|
33
34
|
}
|
|
34
35
|
/** Source file extensions recognized for code wiki links. */
|
|
35
|
-
const SOURCE_EXTS = new Set([
|
|
36
|
+
const SOURCE_EXTS = new Set([
|
|
37
|
+
'.ts',
|
|
38
|
+
'.tsx',
|
|
39
|
+
'.js',
|
|
40
|
+
'.jsx',
|
|
41
|
+
'.py',
|
|
42
|
+
'.rs',
|
|
43
|
+
'.go',
|
|
44
|
+
'.c',
|
|
45
|
+
'.h',
|
|
46
|
+
]);
|
|
36
47
|
function isSourcePath(target) {
|
|
37
48
|
const hashIdx = target.indexOf('#');
|
|
38
49
|
const filePart = hashIdx === -1 ? target : target.slice(0, hashIdx);
|
|
@@ -45,6 +56,14 @@ function isSourcePath(target) {
|
|
|
45
56
|
*/
|
|
46
57
|
async function tryResolveSourceRef(target, projectRoot) {
|
|
47
58
|
if (!isSourcePath(target)) {
|
|
59
|
+
// Check if it looks like a file path with an unsupported extension
|
|
60
|
+
const hashIdx = target.indexOf('#');
|
|
61
|
+
const filePart = hashIdx === -1 ? target : target.slice(0, hashIdx);
|
|
62
|
+
const ext = extname(filePart);
|
|
63
|
+
if (ext && hashIdx !== -1) {
|
|
64
|
+
const supported = [...SOURCE_EXTS].sort().join(', ');
|
|
65
|
+
return `broken link [[${target}]] — unsupported file extension "${ext}". Supported: ${supported}`;
|
|
66
|
+
}
|
|
48
67
|
return `broken link [[${target}]] — no matching section found`;
|
|
49
68
|
}
|
|
50
69
|
const hashIdx = target.indexOf('#');
|
|
@@ -199,9 +218,22 @@ function indexSnippet(entries) {
|
|
|
199
218
|
export async function checkIndex(latticeDir) {
|
|
200
219
|
const errors = [];
|
|
201
220
|
const allPaths = await walkEntries(latticeDir);
|
|
221
|
+
// Flag non-.md files — only markdown belongs in lat.md/
|
|
222
|
+
for (const p of allPaths) {
|
|
223
|
+
const name = p.includes('/') ? p.slice(p.lastIndexOf('/') + 1) : p;
|
|
224
|
+
if (!name.endsWith('.md')) {
|
|
225
|
+
const relDir = basename(latticeDir) + '/';
|
|
226
|
+
errors.push({
|
|
227
|
+
dir: relDir,
|
|
228
|
+
message: `"${p}" is not a .md file — only markdown files belong in lat.md/`,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
// Only .md files participate in index validation
|
|
233
|
+
const mdPaths = allPaths.filter((p) => p.endsWith('.md'));
|
|
202
234
|
// Collect all directories to check (including root, represented as '')
|
|
203
235
|
const dirs = new Set(['']);
|
|
204
|
-
for (const p of
|
|
236
|
+
for (const p of mdPaths) {
|
|
205
237
|
const parts = p.split('/');
|
|
206
238
|
// Add every directory prefix
|
|
207
239
|
for (let i = 1; i < parts.length; i++) {
|
|
@@ -217,7 +249,7 @@ export async function checkIndex(latticeDir) {
|
|
|
217
249
|
const indexRelPath = dir === '' ? indexFileName : dir + '/' + indexFileName;
|
|
218
250
|
// Get the immediate children of this directory
|
|
219
251
|
const prefix = dir === '' ? '' : dir + '/';
|
|
220
|
-
const childPaths =
|
|
252
|
+
const childPaths = mdPaths
|
|
221
253
|
.filter((p) => p.startsWith(prefix) && p !== indexRelPath)
|
|
222
254
|
.map((p) => p.slice(prefix.length));
|
|
223
255
|
const children = immediateEntries(childPaths);
|
|
@@ -270,84 +302,124 @@ export async function checkIndex(latticeDir) {
|
|
|
270
302
|
}
|
|
271
303
|
return errors;
|
|
272
304
|
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
305
|
+
// --- Section structure validation ---
|
|
306
|
+
/** Max characters for the first paragraph of a section (excluding [[wiki links]]). */
|
|
307
|
+
const MAX_BODY_LENGTH = 250;
|
|
308
|
+
/** Count body text length excluding `[[...]]` wiki link markers and content. */
|
|
309
|
+
function bodyTextLength(body) {
|
|
310
|
+
return body.replace(/\[\[[^\]]*\]\]/g, '').length;
|
|
311
|
+
}
|
|
312
|
+
export async function checkSections(latticeDir) {
|
|
313
|
+
const projectRoot = dirname(latticeDir);
|
|
314
|
+
const files = await listLatticeFiles(latticeDir);
|
|
315
|
+
const errors = [];
|
|
316
|
+
for (const file of files) {
|
|
317
|
+
const content = await readFile(file, 'utf-8');
|
|
318
|
+
const sections = parseSections(file, content, projectRoot);
|
|
319
|
+
const flat = flattenSections(sections);
|
|
320
|
+
const relPath = relative(process.cwd(), file);
|
|
321
|
+
for (const section of flat) {
|
|
322
|
+
if (!section.firstParagraph) {
|
|
323
|
+
errors.push({
|
|
324
|
+
file: relPath,
|
|
325
|
+
line: section.startLine,
|
|
326
|
+
target: section.id,
|
|
327
|
+
message: `section "${section.id}" has no leading paragraph. ` +
|
|
328
|
+
`Every section must start with a brief overview (≤${MAX_BODY_LENGTH} chars) ` +
|
|
329
|
+
`summarizing what it documents — this powers search snippets and command output.`,
|
|
330
|
+
});
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
const len = bodyTextLength(section.firstParagraph);
|
|
334
|
+
if (len > MAX_BODY_LENGTH) {
|
|
335
|
+
errors.push({
|
|
336
|
+
file: relPath,
|
|
337
|
+
line: section.startLine,
|
|
338
|
+
target: section.id,
|
|
339
|
+
message: `section "${section.id}" leading paragraph is ${len} characters ` +
|
|
340
|
+
`(max ${MAX_BODY_LENGTH}, excluding [[wiki links]]). ` +
|
|
341
|
+
`Keep the first paragraph brief — it serves as the section's summary ` +
|
|
342
|
+
`in search results and command output. Use subsequent paragraphs for details.`,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
283
345
|
}
|
|
284
346
|
}
|
|
347
|
+
return errors;
|
|
348
|
+
}
|
|
349
|
+
// --- Formatting helpers (shared by all check commands) ---
|
|
350
|
+
function formatFileStats(files, s) {
|
|
351
|
+
const entries = Object.entries(files).sort(([a], [b]) => a.localeCompare(b));
|
|
352
|
+
return s.dim(`Scanned ${entries.map(([ext, n]) => `${n} ${ext}`).join(', ')}`);
|
|
285
353
|
}
|
|
286
|
-
function
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
const loc =
|
|
291
|
-
const [first, ...rest] =
|
|
292
|
-
|
|
354
|
+
function formatCheckErrors(errors, s) {
|
|
355
|
+
const lines = [];
|
|
356
|
+
for (const err of errors) {
|
|
357
|
+
lines.push('');
|
|
358
|
+
const loc = s.cyan(err.file + ':' + err.line);
|
|
359
|
+
const [first, ...rest] = err.message.split('\n');
|
|
360
|
+
lines.push(`- ${loc}: ${s.red(first)}`);
|
|
293
361
|
for (const line of rest) {
|
|
294
|
-
|
|
362
|
+
lines.push(` ${s.red(line)}`);
|
|
295
363
|
}
|
|
296
364
|
}
|
|
365
|
+
return lines;
|
|
297
366
|
}
|
|
298
|
-
function
|
|
299
|
-
|
|
300
|
-
|
|
367
|
+
function formatCheckIndexErrors(errors, s) {
|
|
368
|
+
const lines = [];
|
|
369
|
+
for (const err of errors) {
|
|
370
|
+
lines.push('');
|
|
371
|
+
const loc = s.cyan(err.dir);
|
|
372
|
+
const [first, ...rest] = err.message.split('\n');
|
|
373
|
+
lines.push(`- ${loc}: ${s.red(first)}`);
|
|
374
|
+
for (const line of rest) {
|
|
375
|
+
lines.push(` ${s.red(line)}`);
|
|
376
|
+
}
|
|
301
377
|
}
|
|
378
|
+
return lines;
|
|
302
379
|
}
|
|
303
|
-
function
|
|
304
|
-
|
|
305
|
-
const parts = entries.map(([ext, n]) => `${n} ${ext}`);
|
|
306
|
-
console.log(ctx.chalk.dim(`Scanned ${parts.join(', ')}`));
|
|
307
|
-
}
|
|
308
|
-
export async function checkMdCmd(ctx) {
|
|
309
|
-
const { errors, files } = await checkMd(ctx.latDir);
|
|
310
|
-
formatStats(ctx, files);
|
|
311
|
-
formatErrors(ctx, errors);
|
|
312
|
-
formatErrorCount(ctx, errors.length);
|
|
313
|
-
if (errors.length > 0)
|
|
314
|
-
process.exit(1);
|
|
315
|
-
console.log(ctx.chalk.green('md: All links OK'));
|
|
316
|
-
}
|
|
317
|
-
export async function checkCodeRefsCmd(ctx) {
|
|
318
|
-
const { errors, files } = await checkCodeRefs(ctx.latDir);
|
|
319
|
-
formatStats(ctx, files);
|
|
320
|
-
formatErrors(ctx, errors);
|
|
321
|
-
formatErrorCount(ctx, errors.length);
|
|
322
|
-
if (errors.length > 0)
|
|
323
|
-
process.exit(1);
|
|
324
|
-
console.log(ctx.chalk.green('code-refs: All references OK'));
|
|
380
|
+
function formatErrorCount(count, s) {
|
|
381
|
+
return s.red(`\n${count} error${count === 1 ? '' : 's'} found`);
|
|
325
382
|
}
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
formatIndexErrors(ctx, errors);
|
|
329
|
-
formatErrorCount(ctx, errors.length);
|
|
330
|
-
if (errors.length > 0)
|
|
331
|
-
process.exit(1);
|
|
332
|
-
console.log(ctx.chalk.green('index: All directory index files OK'));
|
|
333
|
-
}
|
|
334
|
-
export async function checkAllCmd(ctx) {
|
|
383
|
+
// --- Unified command functions ---
|
|
384
|
+
export async function checkAllCommand(ctx) {
|
|
335
385
|
const md = await checkMd(ctx.latDir);
|
|
336
386
|
const code = await checkCodeRefs(ctx.latDir);
|
|
337
387
|
const indexErrors = await checkIndex(ctx.latDir);
|
|
388
|
+
const sectionErrors = await checkSections(ctx.latDir);
|
|
338
389
|
const allErrors = [...md.errors, ...code.errors];
|
|
339
390
|
const allFiles = { ...md.files };
|
|
340
391
|
for (const [ext, n] of Object.entries(code.files)) {
|
|
341
392
|
allFiles[ext] = (allFiles[ext] || 0) + n;
|
|
342
393
|
}
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
const
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
394
|
+
const s = ctx.styler;
|
|
395
|
+
const lines = [formatFileStats(allFiles, s)];
|
|
396
|
+
// Init version warning first — user should fix setup before addressing errors
|
|
397
|
+
const storedVersion = readInitVersion(ctx.latDir);
|
|
398
|
+
if (storedVersion === null) {
|
|
399
|
+
lines.push('', s.yellow('Warning:') +
|
|
400
|
+
' No init version recorded — run ' +
|
|
401
|
+
s.cyan('lat init') +
|
|
402
|
+
' to set up agent hooks and configuration.');
|
|
403
|
+
}
|
|
404
|
+
else if (storedVersion < INIT_VERSION) {
|
|
405
|
+
lines.push('', s.yellow('Warning:') +
|
|
406
|
+
' Your setup is outdated (v' +
|
|
407
|
+
storedVersion +
|
|
408
|
+
' → v' +
|
|
409
|
+
INIT_VERSION +
|
|
410
|
+
'). Re-run ' +
|
|
411
|
+
s.cyan('lat init') +
|
|
412
|
+
' to update agent hooks and configuration.');
|
|
413
|
+
}
|
|
414
|
+
lines.push(...formatCheckErrors(allErrors, s));
|
|
415
|
+
lines.push(...formatCheckIndexErrors(indexErrors, s));
|
|
416
|
+
lines.push(...formatCheckErrors(sectionErrors, s));
|
|
417
|
+
const totalErrors = allErrors.length + indexErrors.length + sectionErrors.length;
|
|
418
|
+
if (totalErrors > 0) {
|
|
419
|
+
lines.push(formatErrorCount(totalErrors, s));
|
|
420
|
+
return { output: lines.join('\n'), isError: true };
|
|
421
|
+
}
|
|
422
|
+
lines.push(s.green('All checks passed'));
|
|
351
423
|
const { getLlmKey } = await import('../config.js');
|
|
352
424
|
let hasKey = false;
|
|
353
425
|
try {
|
|
@@ -357,10 +429,59 @@ export async function checkAllCmd(ctx) {
|
|
|
357
429
|
// key resolution failed (e.g. empty file) — treat as missing
|
|
358
430
|
}
|
|
359
431
|
if (!hasKey) {
|
|
360
|
-
|
|
432
|
+
lines.push(s.yellow('Warning:') +
|
|
361
433
|
' No LLM key found — semantic search (lat search) will not work.' +
|
|
362
434
|
' Provide a key via LAT_LLM_KEY, LAT_LLM_KEY_FILE, LAT_LLM_KEY_HELPER, or run ' +
|
|
363
|
-
|
|
435
|
+
s.cyan('lat init') +
|
|
364
436
|
' to configure.');
|
|
365
437
|
}
|
|
438
|
+
return { output: lines.join('\n') };
|
|
439
|
+
}
|
|
440
|
+
export async function checkMdCommand(ctx) {
|
|
441
|
+
const { errors, files } = await checkMd(ctx.latDir);
|
|
442
|
+
const s = ctx.styler;
|
|
443
|
+
const lines = [formatFileStats(files, s)];
|
|
444
|
+
lines.push(...formatCheckErrors(errors, s));
|
|
445
|
+
if (errors.length > 0) {
|
|
446
|
+
lines.push(formatErrorCount(errors.length, s));
|
|
447
|
+
return { output: lines.join('\n'), isError: true };
|
|
448
|
+
}
|
|
449
|
+
lines.push(s.green('md: All links OK'));
|
|
450
|
+
return { output: lines.join('\n') };
|
|
451
|
+
}
|
|
452
|
+
export async function checkCodeRefsCommand(ctx) {
|
|
453
|
+
const { errors, files } = await checkCodeRefs(ctx.latDir);
|
|
454
|
+
const s = ctx.styler;
|
|
455
|
+
const lines = [formatFileStats(files, s)];
|
|
456
|
+
lines.push(...formatCheckErrors(errors, s));
|
|
457
|
+
if (errors.length > 0) {
|
|
458
|
+
lines.push(formatErrorCount(errors.length, s));
|
|
459
|
+
return { output: lines.join('\n'), isError: true };
|
|
460
|
+
}
|
|
461
|
+
lines.push(s.green('code-refs: All references OK'));
|
|
462
|
+
return { output: lines.join('\n') };
|
|
463
|
+
}
|
|
464
|
+
export async function checkIndexCommand(ctx) {
|
|
465
|
+
const errors = await checkIndex(ctx.latDir);
|
|
466
|
+
const s = ctx.styler;
|
|
467
|
+
const lines = [];
|
|
468
|
+
lines.push(...formatCheckIndexErrors(errors, s));
|
|
469
|
+
if (errors.length > 0) {
|
|
470
|
+
lines.push(formatErrorCount(errors.length, s));
|
|
471
|
+
return { output: lines.join('\n'), isError: true };
|
|
472
|
+
}
|
|
473
|
+
lines.push(s.green('index: All directory index files OK'));
|
|
474
|
+
return { output: lines.join('\n') };
|
|
475
|
+
}
|
|
476
|
+
export async function checkSectionsCommand(ctx) {
|
|
477
|
+
const errors = await checkSections(ctx.latDir);
|
|
478
|
+
const s = ctx.styler;
|
|
479
|
+
const lines = [];
|
|
480
|
+
lines.push(...formatCheckErrors(errors, s));
|
|
481
|
+
if (errors.length > 0) {
|
|
482
|
+
lines.push(formatErrorCount(errors.length, s));
|
|
483
|
+
return { output: lines.join('\n'), isError: true };
|
|
484
|
+
}
|
|
485
|
+
lines.push(s.green('sections: All sections have valid leading paragraphs'));
|
|
486
|
+
return { output: lines.join('\n') };
|
|
366
487
|
}
|
|
@@ -1,11 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
export type
|
|
3
|
-
latDir: string;
|
|
4
|
-
projectRoot: string;
|
|
5
|
-
color: boolean;
|
|
6
|
-
chalk: ChalkInstance;
|
|
7
|
-
};
|
|
1
|
+
import type { CmdContext } from '../context.js';
|
|
2
|
+
export type { CmdContext };
|
|
8
3
|
export declare function resolveContext(opts: {
|
|
9
4
|
dir?: string;
|
|
10
5
|
color?: boolean;
|
|
11
|
-
}):
|
|
6
|
+
}): CmdContext;
|
package/dist/src/cli/context.js
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
import { dirname } from 'node:path';
|
|
2
2
|
import chalk from 'chalk';
|
|
3
3
|
import { findLatticeDir } from '../lattice.js';
|
|
4
|
+
function makeChalkStyler() {
|
|
5
|
+
return {
|
|
6
|
+
bold: (s) => chalk.bold(s),
|
|
7
|
+
dim: (s) => chalk.dim(s),
|
|
8
|
+
red: (s) => chalk.red(s),
|
|
9
|
+
cyan: (s) => chalk.cyan(s),
|
|
10
|
+
white: (s) => chalk.white(s),
|
|
11
|
+
green: (s) => chalk.green(s),
|
|
12
|
+
yellow: (s) => chalk.yellow(s),
|
|
13
|
+
boldWhite: (s) => chalk.bold.white(s),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
4
16
|
export function resolveContext(opts) {
|
|
5
17
|
const color = opts.color !== false;
|
|
6
18
|
if (!color) {
|
|
@@ -13,5 +25,5 @@ export function resolveContext(opts) {
|
|
|
13
25
|
process.exit(1);
|
|
14
26
|
}
|
|
15
27
|
const projectRoot = dirname(latDir);
|
|
16
|
-
return { latDir, projectRoot,
|
|
28
|
+
return { latDir, projectRoot, styler: makeChalkStyler(), mode: 'cli' };
|
|
17
29
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { CmdContext, CmdResult } from '../context.js';
|
|
2
|
+
/**
|
|
3
|
+
* Resolve [[refs]] in text and return the expanded output.
|
|
4
|
+
* Returns null if there are no wiki links, or if resolution fails.
|
|
5
|
+
*/
|
|
6
|
+
export declare function expandPrompt(ctx: CmdContext, text: string): Promise<string | null>;
|
|
7
|
+
export declare function expandCommand(ctx: CmdContext, text: string): Promise<CmdResult>;
|
|
@@ -5,14 +5,17 @@ function formatLocation(section, projectRoot) {
|
|
|
5
5
|
const relPath = relative(process.cwd(), join(projectRoot, section.filePath));
|
|
6
6
|
return `${relPath}:${section.startLine}-${section.endLine}`;
|
|
7
7
|
}
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
/**
|
|
9
|
+
* Resolve [[refs]] in text and return the expanded output.
|
|
10
|
+
* Returns null if there are no wiki links, or if resolution fails.
|
|
11
|
+
*/
|
|
12
|
+
export async function expandPrompt(ctx, text) {
|
|
10
13
|
const refs = [...text.matchAll(WIKI_LINK_RE)];
|
|
11
|
-
if (refs.length === 0)
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
}
|
|
14
|
+
if (refs.length === 0)
|
|
15
|
+
return null;
|
|
16
|
+
const allSections = await loadAllSections(ctx.latDir);
|
|
15
17
|
const resolved = new Map();
|
|
18
|
+
const errors = [];
|
|
16
19
|
for (const match of refs) {
|
|
17
20
|
const target = match[1];
|
|
18
21
|
if (resolved.has(target))
|
|
@@ -24,12 +27,13 @@ export async function promptCmd(ctx, text) {
|
|
|
24
27
|
best: matches[0],
|
|
25
28
|
alternatives: matches.slice(1),
|
|
26
29
|
});
|
|
27
|
-
continue;
|
|
28
30
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
else {
|
|
32
|
+
errors.push(`No section found for [[${target}]]`);
|
|
33
|
+
}
|
|
32
34
|
}
|
|
35
|
+
if (errors.length > 0)
|
|
36
|
+
return null;
|
|
33
37
|
// Replace [[refs]] inline
|
|
34
38
|
let output = text.replace(WIKI_LINK_RE, (_match, target) => {
|
|
35
39
|
const ref = resolved.get(target);
|
|
@@ -51,11 +55,38 @@ export async function promptCmd(ctx, text) {
|
|
|
51
55
|
const reason = isExact ? '' : ` (${m.reason})`;
|
|
52
56
|
output += ` * [[${m.section.id}]]${reason}\n`;
|
|
53
57
|
output += ` * ${formatLocation(m.section, ctx.projectRoot)}\n`;
|
|
54
|
-
if (m.section.
|
|
55
|
-
output += ` * ${m.section.
|
|
58
|
+
if (m.section.firstParagraph) {
|
|
59
|
+
output += ` * ${m.section.firstParagraph}\n`;
|
|
56
60
|
}
|
|
57
61
|
}
|
|
58
62
|
}
|
|
59
63
|
output += '</lat-context>\n';
|
|
60
|
-
|
|
64
|
+
return output;
|
|
65
|
+
}
|
|
66
|
+
export async function expandCommand(ctx, text) {
|
|
67
|
+
const result = await expandPrompt(ctx, text);
|
|
68
|
+
if (result === null) {
|
|
69
|
+
const refs = [...text.matchAll(WIKI_LINK_RE)];
|
|
70
|
+
if (refs.length === 0) {
|
|
71
|
+
return { output: text };
|
|
72
|
+
}
|
|
73
|
+
// Resolution failed — find which ref is broken
|
|
74
|
+
const allSections = await loadAllSections(ctx.latDir);
|
|
75
|
+
for (const match of refs) {
|
|
76
|
+
const target = match[1];
|
|
77
|
+
const matches = findSections(allSections, target);
|
|
78
|
+
if (matches.length === 0) {
|
|
79
|
+
const s = ctx.styler;
|
|
80
|
+
return {
|
|
81
|
+
output: s.red(`No section found for [[${target}]]`) +
|
|
82
|
+
' (no exact, substring, or fuzzy matches).\n' +
|
|
83
|
+
s.dim('Ask the user to correct the reference.'),
|
|
84
|
+
isError: true,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// All refs matched individually but expansion still failed — shouldn't happen
|
|
89
|
+
return { output: text };
|
|
90
|
+
}
|
|
91
|
+
return { output: result };
|
|
61
92
|
}
|
package/dist/src/cli/gen.js
CHANGED
|
@@ -9,9 +9,16 @@ export function readCursorRulesTemplate() {
|
|
|
9
9
|
}
|
|
10
10
|
export async function genCmd(target) {
|
|
11
11
|
const normalized = target.toLowerCase();
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
switch (normalized) {
|
|
13
|
+
case 'agents.md':
|
|
14
|
+
case 'claude.md':
|
|
15
|
+
process.stdout.write(readAgentsTemplate());
|
|
16
|
+
break;
|
|
17
|
+
case 'cursor-rules.md':
|
|
18
|
+
process.stdout.write(readCursorRulesTemplate());
|
|
19
|
+
break;
|
|
20
|
+
default:
|
|
21
|
+
console.error(`Unknown target: ${target}. Supported: agents.md, claude.md, cursor-rules.md`);
|
|
22
|
+
process.exit(1);
|
|
15
23
|
}
|
|
16
|
-
process.stdout.write(readAgentsTemplate());
|
|
17
24
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function hookCmd(agent: string, event: string): Promise<void>;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { dirname } from 'node:path';
|
|
2
|
+
import { findLatticeDir } from '../lattice.js';
|
|
3
|
+
import { plainStyler } from '../context.js';
|
|
4
|
+
import { expandPrompt } from './expand.js';
|
|
5
|
+
import { runSearch } from './search.js';
|
|
6
|
+
import { getSection, formatSectionOutput } from './section.js';
|
|
7
|
+
import { getLlmKey } from '../config.js';
|
|
8
|
+
function outputPromptSubmit(context) {
|
|
9
|
+
process.stdout.write(JSON.stringify({
|
|
10
|
+
hookSpecificOutput: {
|
|
11
|
+
hookEventName: 'UserPromptSubmit',
|
|
12
|
+
additionalContext: context,
|
|
13
|
+
},
|
|
14
|
+
}));
|
|
15
|
+
}
|
|
16
|
+
function outputStop(reason) {
|
|
17
|
+
process.stdout.write(JSON.stringify({
|
|
18
|
+
decision: 'block',
|
|
19
|
+
reason,
|
|
20
|
+
}));
|
|
21
|
+
}
|
|
22
|
+
async function readStdin() {
|
|
23
|
+
const chunks = [];
|
|
24
|
+
for await (const chunk of process.stdin) {
|
|
25
|
+
chunks.push(chunk);
|
|
26
|
+
}
|
|
27
|
+
return Buffer.concat(chunks).toString('utf-8');
|
|
28
|
+
}
|
|
29
|
+
function hasWikiLinks(text) {
|
|
30
|
+
return /\[\[[^\]]+\]\]/.test(text);
|
|
31
|
+
}
|
|
32
|
+
function makeHookCtx(latDir) {
|
|
33
|
+
return {
|
|
34
|
+
latDir,
|
|
35
|
+
projectRoot: dirname(latDir),
|
|
36
|
+
styler: plainStyler,
|
|
37
|
+
mode: 'cli',
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
async function searchAndExpand(ctx, userPrompt) {
|
|
41
|
+
let key;
|
|
42
|
+
try {
|
|
43
|
+
key = getLlmKey();
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
if (!key)
|
|
49
|
+
return null;
|
|
50
|
+
const result = await runSearch(ctx.latDir, userPrompt, key, 5);
|
|
51
|
+
if (result.matches.length === 0)
|
|
52
|
+
return null;
|
|
53
|
+
const parts = [
|
|
54
|
+
`Search results for the user prompt (${result.matches.length} matches):`,
|
|
55
|
+
'',
|
|
56
|
+
];
|
|
57
|
+
for (const match of result.matches) {
|
|
58
|
+
const sectionResult = await getSection(ctx, match.section.id);
|
|
59
|
+
if (sectionResult.kind === 'found') {
|
|
60
|
+
parts.push(formatSectionOutput(ctx, sectionResult));
|
|
61
|
+
parts.push('');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return parts.join('\n');
|
|
65
|
+
}
|
|
66
|
+
async function handleUserPromptSubmit() {
|
|
67
|
+
let userPrompt = '';
|
|
68
|
+
try {
|
|
69
|
+
const raw = await readStdin();
|
|
70
|
+
const input = JSON.parse(raw);
|
|
71
|
+
userPrompt = input.user_prompt ?? '';
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// If we can't parse stdin, still emit the reminder
|
|
75
|
+
}
|
|
76
|
+
const parts = [];
|
|
77
|
+
parts.push("Before starting work, run `lat search` with one or more queries describing the user's intent.", 'ALWAYS do this, even when the task seems straightforward — search results may reveal critical design details, protocols, or constraints.', 'Use `lat section` to read the full content of relevant matches.', 'Do not read files, write code, or run commands until you have searched.');
|
|
78
|
+
const latDir = findLatticeDir();
|
|
79
|
+
if (latDir && userPrompt) {
|
|
80
|
+
const ctx = makeHookCtx(latDir);
|
|
81
|
+
// If the user prompt contains [[refs]], resolve them inline
|
|
82
|
+
if (hasWikiLinks(userPrompt)) {
|
|
83
|
+
try {
|
|
84
|
+
const expanded = await expandPrompt(ctx, userPrompt);
|
|
85
|
+
if (expanded) {
|
|
86
|
+
parts.push('', 'Expanded user prompt with resolved [[refs]]:', expanded);
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
parts.push('', 'NOTE: The user prompt contains [[refs]] but they could not be resolved. Ask the user to correct them.');
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
parts.push('', 'NOTE: The user prompt contains [[refs]] but resolution failed. Run `lat expand` on the prompt text manually.');
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// Search for relevant sections and include their full content
|
|
97
|
+
try {
|
|
98
|
+
const searchContext = await searchAndExpand(ctx, userPrompt);
|
|
99
|
+
if (searchContext) {
|
|
100
|
+
parts.push('', searchContext);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
// Search failed (no key, index error, etc.) — agent can search manually
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
outputPromptSubmit(parts.join('\n'));
|
|
108
|
+
}
|
|
109
|
+
async function handleStop() {
|
|
110
|
+
// Only emit the reminder if we're in a project with lat.md
|
|
111
|
+
const latDir = findLatticeDir();
|
|
112
|
+
if (!latDir)
|
|
113
|
+
return;
|
|
114
|
+
// Read stdin to check if we already blocked once
|
|
115
|
+
let stopHookActive = false;
|
|
116
|
+
try {
|
|
117
|
+
const raw = await readStdin();
|
|
118
|
+
const input = JSON.parse(raw);
|
|
119
|
+
stopHookActive = input.stop_hook_active ?? false;
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
// If we can't parse stdin, treat as first attempt
|
|
123
|
+
}
|
|
124
|
+
// Don't block twice — avoids infinite loop
|
|
125
|
+
if (stopHookActive)
|
|
126
|
+
return;
|
|
127
|
+
const parts = [];
|
|
128
|
+
parts.push('Before finishing, verify:', '- Did you update `lat.md/`? Run `lat search` with a query describing what you changed to find relevant sections that may need updating.', '- Did you run `lat check` and confirm all links and code refs pass?', 'If you made code changes but did not update lat.md/, do that now.');
|
|
129
|
+
outputStop(parts.join('\n'));
|
|
130
|
+
}
|
|
131
|
+
export async function hookCmd(agent, event) {
|
|
132
|
+
if (agent !== 'claude') {
|
|
133
|
+
console.error(`Unknown agent: ${agent}. Supported: claude`);
|
|
134
|
+
process.exit(1);
|
|
135
|
+
}
|
|
136
|
+
switch (event) {
|
|
137
|
+
case 'UserPromptSubmit':
|
|
138
|
+
await handleUserPromptSubmit();
|
|
139
|
+
break;
|
|
140
|
+
case 'Stop':
|
|
141
|
+
await handleStop();
|
|
142
|
+
break;
|
|
143
|
+
default:
|
|
144
|
+
console.error(`Unknown hook event: ${event}. Supported: UserPromptSubmit, Stop`);
|
|
145
|
+
process.exit(1);
|
|
146
|
+
}
|
|
147
|
+
}
|