lat.md 0.7.0 → 0.7.2

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 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 prompt "fix [[OAuth Flow]]" # expand [[refs]] in a prompt for agents
59
+ npx lat.md expand "fix [[OAuth Flow]]" # expand [[refs]] in a prompt for agents
59
60
  ```
60
61
 
61
62
  ## Configuration
@@ -3,6 +3,7 @@ import { existsSync } from 'node:fs';
3
3
  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
+ import { SOURCE_EXTENSIONS } from '../source-parser.js';
6
7
  import { walkEntries } from '../walk.js';
7
8
  import { INIT_VERSION, readInitVersion } from '../init-version.js';
8
9
  function filePart(id) {
@@ -32,23 +33,11 @@ function countByExt(paths) {
32
33
  }
33
34
  return stats;
34
35
  }
35
- /** Source file extensions recognized for code wiki links. */
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
- ]);
47
36
  function isSourcePath(target) {
48
37
  const hashIdx = target.indexOf('#');
49
38
  const filePart = hashIdx === -1 ? target : target.slice(0, hashIdx);
50
39
  const ext = extname(filePart);
51
- return SOURCE_EXTS.has(ext);
40
+ return SOURCE_EXTENSIONS.has(ext);
52
41
  }
53
42
  /**
54
43
  * Try resolving a wiki link target as a source code reference.
@@ -61,7 +50,7 @@ async function tryResolveSourceRef(target, projectRoot) {
61
50
  const filePart = hashIdx === -1 ? target : target.slice(0, hashIdx);
62
51
  const ext = extname(filePart);
63
52
  if (ext && hashIdx !== -1) {
64
- const supported = [...SOURCE_EXTS].sort().join(', ');
53
+ const supported = [...SOURCE_EXTENSIONS].sort().join(', ');
65
54
  return `broken link [[${target}]] — unsupported file extension "${ext}". Supported: ${supported}`;
66
55
  }
67
56
  return `broken link [[${target}]] — no matching section found`;
@@ -1,10 +1,13 @@
1
- import { dirname } from 'node:path';
1
+ import { execSync } from 'node:child_process';
2
+ import { dirname, extname } from 'node:path';
2
3
  import { findLatticeDir } from '../lattice.js';
3
4
  import { plainStyler } from '../context.js';
4
5
  import { expandPrompt } from './expand.js';
5
6
  import { runSearch } from './search.js';
6
7
  import { getSection, formatSectionOutput } from './section.js';
7
8
  import { getLlmKey } from '../config.js';
9
+ import { checkMd, checkCodeRefs, checkIndex, checkSections } from './check.js';
10
+ import { SOURCE_EXTENSIONS } from '../source-parser.js';
8
11
  function outputPromptSubmit(context) {
9
12
  process.stdout.write(JSON.stringify({
10
13
  hookSpecificOutput: {
@@ -74,7 +77,7 @@ async function handleUserPromptSubmit() {
74
77
  // If we can't parse stdin, still emit the reminder
75
78
  }
76
79
  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.');
80
+ 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.', '', 'Remember: `lat.md/` must stay in sync with the codebase. If you change code, update the relevant sections in `lat.md/` and run `lat check` before finishing.');
78
81
  const latDir = findLatticeDir();
79
82
  if (latDir && userPrompt) {
80
83
  const ctx = makeHookCtx(latDir);
@@ -106,8 +109,44 @@ async function handleUserPromptSubmit() {
106
109
  }
107
110
  outputPromptSubmit(parts.join('\n'));
108
111
  }
112
+ /** Minimum diff size (in lines) to consider "significant" code change. */
113
+ /** Minimum code change size (lines) before we consider flagging lat.md/ sync. */
114
+ const DIFF_THRESHOLD = 5;
115
+ /** lat.md/ changes below this ratio of code changes trigger a sync reminder. */
116
+ const LATMD_RATIO = 0.05;
117
+ /** Run `git diff --numstat` and return { codeLines, latMdLines }. */
118
+ function analyzeDiff(projectRoot) {
119
+ let output;
120
+ try {
121
+ output = execSync('git diff HEAD --numstat', {
122
+ cwd: projectRoot,
123
+ encoding: 'utf-8',
124
+ });
125
+ }
126
+ catch {
127
+ return { codeLines: 0, latMdLines: 0 };
128
+ }
129
+ let codeLines = 0;
130
+ let latMdLines = 0;
131
+ // Each line: "added\tremoved\tfile" (e.g. "42\t11\tsrc/cli/hook.ts")
132
+ for (const line of output.split('\n')) {
133
+ const parts = line.split('\t');
134
+ if (parts.length < 3)
135
+ continue;
136
+ const added = parseInt(parts[0], 10) || 0;
137
+ const removed = parseInt(parts[1], 10) || 0;
138
+ const file = parts[2];
139
+ const changed = added + removed;
140
+ if (file.startsWith('lat.md/')) {
141
+ latMdLines += changed;
142
+ }
143
+ else if (SOURCE_EXTENSIONS.has(extname(file))) {
144
+ codeLines += changed;
145
+ }
146
+ }
147
+ return { codeLines, latMdLines };
148
+ }
109
149
  async function handleStop() {
110
- // Only emit the reminder if we're in a project with lat.md
111
150
  const latDir = findLatticeDir();
112
151
  if (!latDir)
113
152
  return;
@@ -121,11 +160,52 @@ async function handleStop() {
121
160
  catch {
122
161
  // If we can't parse stdin, treat as first attempt
123
162
  }
124
- // Don't block twiceavoids infinite loop
125
- if (stopHookActive)
163
+ // Always run lat check even on second pass
164
+ const md = await checkMd(latDir);
165
+ const code = await checkCodeRefs(latDir);
166
+ const indexErrors = await checkIndex(latDir);
167
+ const sectionErrors = await checkSections(latDir);
168
+ const totalErrors = md.errors.length +
169
+ code.errors.length +
170
+ indexErrors.length +
171
+ sectionErrors.length;
172
+ const checkFailed = totalErrors > 0;
173
+ // Second pass — warn the user but don't block again
174
+ if (stopHookActive) {
175
+ if (checkFailed) {
176
+ console.error(`lat check is still failing (${totalErrors} error(s)). Run \`lat check\` to see details.`);
177
+ }
178
+ return;
179
+ }
180
+ const projectRoot = dirname(latDir);
181
+ // Analyze git diff — flag when lat.md/ changes are < 5% of code changes
182
+ const { codeLines, latMdLines } = analyzeDiff(projectRoot);
183
+ let needsSync = false;
184
+ if (codeLines >= DIFF_THRESHOLD) {
185
+ // Round up lat.md lines to 1 if there are more than 5 code lines changed
186
+ // (a tiny lat.md touch still counts as effort)
187
+ const effectiveLatMd = latMdLines === 0 ? 0 : Math.max(latMdLines, 1);
188
+ needsSync = effectiveLatMd < codeLines * LATMD_RATIO;
189
+ }
190
+ // Nothing to do — let the agent stop cleanly
191
+ if (!checkFailed && !needsSync)
126
192
  return;
127
193
  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.');
194
+ if (checkFailed && needsSync) {
195
+ parts.push('`lat check` found errors AND the codebase has changes (' +
196
+ codeLines +
197
+ ' lines) with no updates to `lat.md/`. Before finishing:', '', '1. Update `lat.md/` to reflect your code changes — run `lat search` to find relevant sections.', '2. Run `lat check` until it passes.');
198
+ }
199
+ else if (checkFailed) {
200
+ parts.push('`lat check` found ' +
201
+ totalErrors +
202
+ ' error(s). Run `lat check`, fix the errors, and repeat until it passes.');
203
+ }
204
+ else {
205
+ parts.push('The codebase has changes (' +
206
+ codeLines +
207
+ ' lines) but `lat.md/` was not updated. Update `lat.md/` to be in sync with the changes — run `lat search` to find relevant sections. Run `lat check` at the end.');
208
+ }
129
209
  outputStop(parts.join('\n'));
130
210
  }
131
211
  export async function hookCmd(agent, event) {
@@ -97,7 +97,7 @@ export async function refsCommand(ctx, query, scope) {
97
97
  parts.push(formatResultList(ctx, `References to "${target.id}":`, mdRefs));
98
98
  }
99
99
  if (codeRefs.length > 0) {
100
- parts.push(s.bold('Code references:') +
100
+ parts.push('## Code references:' +
101
101
  '\n\n' +
102
102
  codeRefs.map((l) => `${s.dim('*')} ${l}`).join('\n'));
103
103
  }
@@ -3,7 +3,7 @@ import { detectProvider } from '../search/provider.js';
3
3
  import { indexSections } from '../search/index.js';
4
4
  import { searchSections } from '../search/search.js';
5
5
  import { loadAllSections, flattenSections, } from '../lattice.js';
6
- import { formatResultList } from '../format.js';
6
+ import { formatResultList, formatNavHints } from '../format.js';
7
7
  async function withDb(latDir, key, progress, fn) {
8
8
  const provider = detectProvider(key);
9
9
  const db = openDb(latDir);
@@ -94,14 +94,8 @@ export async function searchCommand(ctx, query, opts, progress) {
94
94
  if (result.matches.length === 0) {
95
95
  return { output: 'No results found.' };
96
96
  }
97
- const navHints = ctx.mode === 'cli'
98
- ? '- `lat section "section#id"` \u2014 show full content with outgoing/incoming refs\n' +
99
- '- `lat search "new query"` \u2014 search for something else'
100
- : '- `lat_section` \u2014 show full content with outgoing/incoming refs\n' +
101
- '- `lat_search` \u2014 search for something else';
102
97
  return {
103
98
  output: formatResultList(ctx, `Search results for "${query}":`, result.matches) +
104
- '\nTo navigate further:\n' +
105
- navHints,
99
+ formatNavHints(ctx),
106
100
  };
107
101
  }
@@ -1,7 +1,7 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { join, relative } from 'node:path';
3
3
  import { loadAllSections, findSections, flattenSections, extractRefs, buildFileIndex, resolveRef, listLatticeFiles, } from '../lattice.js';
4
- import { formatSectionId } from '../format.js';
4
+ import { formatSectionId, formatNavHints } from '../format.js';
5
5
  /**
6
6
  * Look up a section by id, return its content, outgoing wiki link targets,
7
7
  * and incoming references from other sections.
@@ -22,13 +22,12 @@ export async function getSection(ctx, query) {
22
22
  return { kind: 'no-match', suggestions: matches };
23
23
  }
24
24
  const section = top.section;
25
- // Read raw content between startLine and endLine
25
+ // Read raw content between startLine and the end of the last descendant
26
26
  const absPath = join(ctx.projectRoot, section.filePath);
27
27
  const fileContent = await readFile(absPath, 'utf-8');
28
28
  const lines = fileContent.split('\n');
29
- const content = lines
30
- .slice(section.startLine - 1, section.endLine)
31
- .join('\n');
29
+ const end = fullEndLine(section);
30
+ const content = lines.slice(section.startLine - 1, end).join('\n');
32
31
  // Find outgoing wiki link targets within this section's content
33
32
  const flat = flattenSections(allSections);
34
33
  const sectionIds = new Set(flat.map((s) => s.id.toLowerCase()));
@@ -73,6 +72,11 @@ export async function getSection(ctx, query) {
73
72
  }
74
73
  return { kind: 'found', section, content, outgoingRefs, incomingRefs };
75
74
  }
75
+ function fullEndLine(section) {
76
+ if (section.children.length === 0)
77
+ return section.endLine;
78
+ return fullEndLine(section.children[section.children.length - 1]);
79
+ }
76
80
  function truncate(s, max) {
77
81
  return s.length > max ? s.slice(0, max) + '...' : s;
78
82
  }
@@ -84,13 +88,17 @@ export function formatSectionOutput(ctx, result) {
84
88
  const { section, content, outgoingRefs, incomingRefs } = result;
85
89
  const relPath = relative(process.cwd(), join(ctx.projectRoot, section.filePath));
86
90
  const loc = `${s.cyan(relPath)}${s.dim(`:${section.startLine}-${section.endLine}`)}`;
91
+ const quoted = content
92
+ .split('\n')
93
+ .map((line) => (line ? `> ${line}` : '>'))
94
+ .join('\n');
87
95
  const parts = [
88
96
  `${s.bold('[[' + formatSectionId(section.id, s) + ']]')} (${loc})`,
89
97
  '',
90
- content,
98
+ quoted,
91
99
  ];
92
100
  if (outgoingRefs.length > 0) {
93
- parts.push('', s.bold('This section references:'), '');
101
+ parts.push('', '## This section references:', '');
94
102
  for (const ref of outgoingRefs) {
95
103
  const body = ref.resolved.firstParagraph
96
104
  ? ` ${s.dim('—')} ${truncate(ref.resolved.firstParagraph, 120)}`
@@ -99,7 +107,7 @@ export function formatSectionOutput(ctx, result) {
99
107
  }
100
108
  }
101
109
  if (incomingRefs.length > 0) {
102
- parts.push('', s.bold('Referenced by:'), '');
110
+ parts.push('', '## Referenced by:', '');
103
111
  for (const ref of incomingRefs) {
104
112
  const body = ref.section.firstParagraph
105
113
  ? ` ${s.dim('—')} ${truncate(ref.section.firstParagraph, 120)}`
@@ -107,6 +115,7 @@ export function formatSectionOutput(ctx, result) {
107
115
  parts.push(`${s.dim('*')} [[${formatSectionId(ref.section.id, s)}]]${body}`);
108
116
  }
109
117
  }
118
+ parts.push(formatNavHints(ctx));
110
119
  return parts.join('\n');
111
120
  }
112
121
  export async function sectionCommand(ctx, query) {
@@ -5,3 +5,4 @@ export declare function formatSectionPreview(ctx: CmdContext, section: Section,
5
5
  reason?: string;
6
6
  }): string;
7
7
  export declare function formatResultList(ctx: CmdContext, header: string, matches: SectionMatch[]): string;
8
+ export declare function formatNavHints(ctx: CmdContext): string;
@@ -21,7 +21,7 @@ export function formatSectionPreview(ctx, section, opts) {
21
21
  return lines.join('\n');
22
22
  }
23
23
  export function formatResultList(ctx, header, matches) {
24
- const lines = ['', ctx.styler.bold(header), ''];
24
+ const lines = ['', `## ${header}`, ''];
25
25
  for (let i = 0; i < matches.length; i++) {
26
26
  if (i > 0)
27
27
  lines.push('');
@@ -32,3 +32,12 @@ export function formatResultList(ctx, header, matches) {
32
32
  lines.push('');
33
33
  return lines.join('\n');
34
34
  }
35
+ export function formatNavHints(ctx) {
36
+ const s = ctx.styler;
37
+ const hints = ctx.mode === 'cli'
38
+ ? `${s.dim('*')} \`lat section "section#id"\` \u2014 show full content with outgoing/incoming refs\n` +
39
+ `${s.dim('*')} \`lat search "new query"\` \u2014 search for something else`
40
+ : `${s.dim('*')} \`lat_section\` \u2014 show full content with outgoing/incoming refs\n` +
41
+ `${s.dim('*')} \`lat_search\` \u2014 search for something else`;
42
+ return `\n## To navigate further:\n\n${hints}`;
43
+ }
@@ -19,7 +19,6 @@ export type LatFrontmatter = {
19
19
  requireCodeMention?: boolean;
20
20
  };
21
21
  export declare function parseFrontmatter(content: string): LatFrontmatter;
22
- export declare function stripFrontmatter(content: string): string;
23
22
  export declare function findLatticeDir(from?: string): string | null;
24
23
  export declare function findProjectRoot(from?: string): string | null;
25
24
  export declare function listLatticeFiles(latticeDir: string): Promise<string[]>;
@@ -15,9 +15,6 @@ export function parseFrontmatter(content) {
15
15
  }
16
16
  return result;
17
17
  }
18
- export function stripFrontmatter(content) {
19
- return content.replace(/^---\n[\s\S]*?\n---\n*/, '');
20
- }
21
18
  export function findLatticeDir(from) {
22
19
  let dir = resolve(from ?? process.cwd());
23
20
  while (true) {
@@ -69,7 +66,7 @@ function lastLine(content) {
69
66
  return lines[lines.length - 1] === '' ? lines.length - 1 : lines.length;
70
67
  }
71
68
  export function parseSections(filePath, content, projectRoot) {
72
- const tree = parse(stripFrontmatter(content));
69
+ const tree = parse(content);
73
70
  const file = projectRoot
74
71
  ? relative(projectRoot, filePath).replace(/\.md$/, '')
75
72
  : basename(filePath, '.md');
@@ -522,7 +519,7 @@ export function findSections(sections, query) {
522
519
  ];
523
520
  }
524
521
  export function extractRefs(filePath, content, projectRoot) {
525
- const tree = parse(stripFrontmatter(content));
522
+ const tree = parse(content);
526
523
  const file = projectRoot
527
524
  ? relative(projectRoot, filePath).replace(/\.md$/, '')
528
525
  : basename(filePath, '.md');
@@ -1,9 +1,11 @@
1
1
  import { unified } from 'unified';
2
2
  import remarkParse from 'remark-parse';
3
3
  import remarkStringify from 'remark-stringify';
4
+ import remarkFrontmatter from 'remark-frontmatter';
4
5
  import { wikiLinkSyntax, wikiLinkFromMarkdown, wikiLinkToMarkdown, } from './extensions/wiki-link/index.js';
5
6
  const processor = unified()
6
7
  .use(remarkParse)
8
+ .use(remarkFrontmatter)
7
9
  .use(remarkStringify)
8
10
  .data('micromarkExtensions', [wikiLinkSyntax()])
9
11
  .data('fromMarkdownExtensions', [wikiLinkFromMarkdown()])
@@ -7,6 +7,8 @@ export type SourceSymbol = {
7
7
  endLine: number;
8
8
  signature: string;
9
9
  };
10
+ /** All source file extensions that lat can parse (derived from grammarMap). */
11
+ export declare const SOURCE_EXTENSIONS: ReadonlySet<string>;
10
12
  export declare function parseSourceSymbols(filePath: string, content: string): Promise<SourceSymbol[]>;
11
13
  /**
12
14
  * Check whether a source file path (relative to projectRoot) has a given symbol.
@@ -21,18 +21,22 @@ async function ensureParser() {
21
21
  }
22
22
  return parserInstance;
23
23
  }
24
+ /** Extension → tree-sitter WASM grammar mapping. This is the single source of
25
+ * truth for which source file extensions lat supports. */
26
+ const grammarMap = {
27
+ '.ts': 'tree-sitter-typescript.wasm',
28
+ '.tsx': 'tree-sitter-tsx.wasm',
29
+ '.js': 'tree-sitter-javascript.wasm',
30
+ '.jsx': 'tree-sitter-javascript.wasm',
31
+ '.py': 'tree-sitter-python.wasm',
32
+ '.rs': 'tree-sitter-rust.wasm',
33
+ '.go': 'tree-sitter-go.wasm',
34
+ '.c': 'tree-sitter-c.wasm',
35
+ '.h': 'tree-sitter-c.wasm',
36
+ };
37
+ /** All source file extensions that lat can parse (derived from grammarMap). */
38
+ export const SOURCE_EXTENSIONS = new Set(Object.keys(grammarMap));
24
39
  async function getLanguage(ext) {
25
- const grammarMap = {
26
- '.ts': 'tree-sitter-typescript.wasm',
27
- '.tsx': 'tree-sitter-tsx.wasm',
28
- '.js': 'tree-sitter-javascript.wasm',
29
- '.jsx': 'tree-sitter-javascript.wasm',
30
- '.py': 'tree-sitter-python.wasm',
31
- '.rs': 'tree-sitter-rust.wasm',
32
- '.go': 'tree-sitter-go.wasm',
33
- '.c': 'tree-sitter-c.wasm',
34
- '.h': 'tree-sitter-c.wasm',
35
- };
36
40
  const wasmFile = grammarMap[ext];
37
41
  if (!wasmFile)
38
42
  return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lat.md",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "description": "A knowledge graph for your codebase, written in markdown",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.30.2",
@@ -55,6 +55,7 @@
55
55
  "commander": "^14.0.3",
56
56
  "ignore-walk": "^8.0.0",
57
57
  "mdast-util-to-markdown": "^2.1.0",
58
+ "remark-frontmatter": "^5.0.0",
58
59
  "remark-parse": "^11.0.0",
59
60
  "remark-stringify": "^11.0.0",
60
61
  "unified": "^11.0.0",