lat.md 0.6.0 → 0.7.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.
@@ -1,4 +1,4 @@
1
- import type { CliContext } from './context.js';
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 checkMdCmd(ctx: CliContext): Promise<void>;
23
- export declare function checkCodeRefsCmd(ctx: CliContext): Promise<void>;
24
- export declare function checkIndexCmd(ctx: CliContext): Promise<void>;
25
- export declare function checkAllCmd(ctx: CliContext): Promise<void>;
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>;
@@ -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(['.ts', '.tsx', '.js', '.jsx', '.py']);
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 allPaths) {
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 = allPaths
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
- function formatErrors(ctx, errors, startIdx = 0) {
274
- for (let i = 0; i < errors.length; i++) {
275
- const err = errors[i];
276
- if (i > 0 || startIdx > 0)
277
- console.error('');
278
- const loc = ctx.chalk.cyan(err.file + ':' + err.line);
279
- const [first, ...rest] = err.message.split('\n');
280
- console.error(`- ${loc}: ${ctx.chalk.red(first)}`);
281
- for (const line of rest) {
282
- console.error(` ${ctx.chalk.red(line)}`);
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 formatIndexErrors(ctx, errors, startIdx = 0) {
287
- for (let i = 0; i < errors.length; i++) {
288
- if (i > 0 || startIdx > 0)
289
- console.error('');
290
- const loc = ctx.chalk.cyan(errors[i].dir);
291
- const [first, ...rest] = errors[i].message.split('\n');
292
- console.error(`- ${loc}: ${ctx.chalk.red(first)}`);
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
- console.error(` ${ctx.chalk.red(line)}`);
362
+ lines.push(` ${s.red(line)}`);
295
363
  }
296
364
  }
365
+ return lines;
297
366
  }
298
- function formatErrorCount(ctx, count) {
299
- if (count > 0) {
300
- console.error(ctx.chalk.red(`\n${count} error${count === 1 ? '' : 's'} found`));
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 formatStats(ctx, stats) {
304
- const entries = Object.entries(stats).sort(([a], [b]) => a.localeCompare(b));
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
- export async function checkIndexCmd(ctx) {
327
- const errors = await checkIndex(ctx.latDir);
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
- formatStats(ctx, allFiles);
344
- formatErrors(ctx, allErrors);
345
- formatIndexErrors(ctx, indexErrors, allErrors.length);
346
- const totalErrors = allErrors.length + indexErrors.length;
347
- formatErrorCount(ctx, totalErrors);
348
- if (totalErrors > 0)
349
- process.exit(1);
350
- console.log(ctx.chalk.green('All checks passed'));
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
- console.log(ctx.chalk.yellow('Warning:') +
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
- ctx.chalk.cyan('lat init') +
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 { type ChalkInstance } from 'chalk';
2
- export type CliContext = {
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
- }): CliContext;
6
+ }): CmdContext;
@@ -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, color, chalk };
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
- export async function promptCmd(ctx, text) {
9
- const allSections = await loadAllSections(ctx.latDir);
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
- process.stdout.write(text);
13
- return;
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
- console.error(ctx.chalk.red(`No section found for [[${target}]] (no exact, substring, or fuzzy matches).`));
30
- console.error(ctx.chalk.dim('Ask the user to correct the reference.'));
31
- process.exit(1);
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.body) {
55
- output += ` * ${m.section.body}\n`;
58
+ if (m.section.firstParagraph) {
59
+ output += ` * ${m.section.firstParagraph}\n`;
56
60
  }
57
61
  }
58
62
  }
59
63
  output += '</lat-context>\n';
60
- process.stdout.write(output);
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
  }
@@ -9,9 +9,16 @@ export function readCursorRulesTemplate() {
9
9
  }
10
10
  export async function genCmd(target) {
11
11
  const normalized = target.toLowerCase();
12
- if (normalized !== 'agents.md' && normalized !== 'claude.md') {
13
- console.error(`Unknown target: ${target}. Supported: agents.md, claude.md`);
14
- process.exit(1);
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
+ }