lat.md 0.7.1 → 0.8.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/check.js +3 -14
- package/dist/src/cli/gen.d.ts +1 -0
- package/dist/src/cli/gen.js +7 -1
- package/dist/src/cli/hook.js +93 -6
- package/dist/src/cli/init.js +135 -14
- package/dist/src/cli/section.js +8 -4
- package/dist/src/cli/select-menu.d.ts +13 -0
- package/dist/src/cli/select-menu.js +98 -0
- package/dist/src/source-parser.d.ts +2 -0
- package/dist/src/source-parser.js +15 -11
- package/package.json +1 -1
- package/templates/pi-extension.ts +353 -0
package/dist/src/cli/check.js
CHANGED
|
@@ -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
|
|
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 = [...
|
|
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`;
|
package/dist/src/cli/gen.d.ts
CHANGED
package/dist/src/cli/gen.js
CHANGED
|
@@ -7,6 +7,9 @@ export function readAgentsTemplate() {
|
|
|
7
7
|
export function readCursorRulesTemplate() {
|
|
8
8
|
return readFileSync(join(findTemplatesDir(), 'cursor-rules.md'), 'utf-8');
|
|
9
9
|
}
|
|
10
|
+
export function readPiExtensionTemplate() {
|
|
11
|
+
return readFileSync(join(findTemplatesDir(), 'pi-extension.ts'), 'utf-8');
|
|
12
|
+
}
|
|
10
13
|
export async function genCmd(target) {
|
|
11
14
|
const normalized = target.toLowerCase();
|
|
12
15
|
switch (normalized) {
|
|
@@ -17,8 +20,11 @@ export async function genCmd(target) {
|
|
|
17
20
|
case 'cursor-rules.md':
|
|
18
21
|
process.stdout.write(readCursorRulesTemplate());
|
|
19
22
|
break;
|
|
23
|
+
case 'pi-extension.ts':
|
|
24
|
+
process.stdout.write(readPiExtensionTemplate());
|
|
25
|
+
break;
|
|
20
26
|
default:
|
|
21
|
-
console.error(`Unknown target: ${target}. Supported: agents.md, claude.md, cursor-rules.md`);
|
|
27
|
+
console.error(`Unknown target: ${target}. Supported: agents.md, claude.md, cursor-rules.md, pi-extension.ts`);
|
|
22
28
|
process.exit(1);
|
|
23
29
|
}
|
|
24
30
|
}
|
package/dist/src/cli/hook.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import {
|
|
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,46 @@ 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
|
+
/** If lat.md/ changes exceed this many lines, skip the ratio check entirely. */
|
|
118
|
+
const LATMD_UPPER_THRESHOLD = 50;
|
|
119
|
+
/** Run `git diff --numstat` and return { codeLines, latMdLines }. */
|
|
120
|
+
function analyzeDiff(projectRoot) {
|
|
121
|
+
let output;
|
|
122
|
+
try {
|
|
123
|
+
output = execSync('git diff HEAD --numstat', {
|
|
124
|
+
cwd: projectRoot,
|
|
125
|
+
encoding: 'utf-8',
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return { codeLines: 0, latMdLines: 0 };
|
|
130
|
+
}
|
|
131
|
+
let codeLines = 0;
|
|
132
|
+
let latMdLines = 0;
|
|
133
|
+
// Each line: "added\tremoved\tfile" (e.g. "42\t11\tsrc/cli/hook.ts")
|
|
134
|
+
for (const line of output.split('\n')) {
|
|
135
|
+
const parts = line.split('\t');
|
|
136
|
+
if (parts.length < 3)
|
|
137
|
+
continue;
|
|
138
|
+
const added = parseInt(parts[0], 10) || 0;
|
|
139
|
+
const removed = parseInt(parts[1], 10) || 0;
|
|
140
|
+
const file = parts[2];
|
|
141
|
+
const changed = added + removed;
|
|
142
|
+
if (file.startsWith('lat.md/')) {
|
|
143
|
+
latMdLines += changed;
|
|
144
|
+
}
|
|
145
|
+
else if (SOURCE_EXTENSIONS.has(extname(file))) {
|
|
146
|
+
codeLines += changed;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return { codeLines, latMdLines };
|
|
150
|
+
}
|
|
109
151
|
async function handleStop() {
|
|
110
|
-
// Only emit the reminder if we're in a project with lat.md
|
|
111
152
|
const latDir = findLatticeDir();
|
|
112
153
|
if (!latDir)
|
|
113
154
|
return;
|
|
@@ -121,11 +162,57 @@ async function handleStop() {
|
|
|
121
162
|
catch {
|
|
122
163
|
// If we can't parse stdin, treat as first attempt
|
|
123
164
|
}
|
|
124
|
-
//
|
|
125
|
-
|
|
165
|
+
// Always run lat check — even on second pass
|
|
166
|
+
const md = await checkMd(latDir);
|
|
167
|
+
const code = await checkCodeRefs(latDir);
|
|
168
|
+
const indexErrors = await checkIndex(latDir);
|
|
169
|
+
const sectionErrors = await checkSections(latDir);
|
|
170
|
+
const totalErrors = md.errors.length +
|
|
171
|
+
code.errors.length +
|
|
172
|
+
indexErrors.length +
|
|
173
|
+
sectionErrors.length;
|
|
174
|
+
const checkFailed = totalErrors > 0;
|
|
175
|
+
// Second pass — warn the user but don't block again
|
|
176
|
+
if (stopHookActive) {
|
|
177
|
+
if (checkFailed) {
|
|
178
|
+
console.error(`lat check is still failing (${totalErrors} error(s)). Run \`lat check\` to see details.`);
|
|
179
|
+
}
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const projectRoot = dirname(latDir);
|
|
183
|
+
// Analyze git diff — flag when lat.md/ changes are < 5% of code changes
|
|
184
|
+
const { codeLines, latMdLines } = analyzeDiff(projectRoot);
|
|
185
|
+
let needsSync = false;
|
|
186
|
+
if (codeLines >= DIFF_THRESHOLD && latMdLines < LATMD_UPPER_THRESHOLD) {
|
|
187
|
+
// Round up lat.md lines to 1 if nonzero (a tiny touch still counts)
|
|
188
|
+
const effectiveLatMd = latMdLines === 0 ? 0 : Math.max(latMdLines, 1);
|
|
189
|
+
needsSync = effectiveLatMd < codeLines * LATMD_RATIO;
|
|
190
|
+
}
|
|
191
|
+
// Nothing to do — let the agent stop cleanly
|
|
192
|
+
if (!checkFailed && !needsSync)
|
|
126
193
|
return;
|
|
127
194
|
const parts = [];
|
|
128
|
-
|
|
195
|
+
const syncMsg = latMdLines === 0
|
|
196
|
+
? 'The codebase has changes (' +
|
|
197
|
+
codeLines +
|
|
198
|
+
' lines) but `lat.md/` was not updated.'
|
|
199
|
+
: 'The codebase has changes (' +
|
|
200
|
+
codeLines +
|
|
201
|
+
' lines) but `lat.md/` may not be fully in sync (' +
|
|
202
|
+
latMdLines +
|
|
203
|
+
' lines changed).';
|
|
204
|
+
if (checkFailed && needsSync) {
|
|
205
|
+
parts.push('`lat check` found errors. ' + syncMsg + ' 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.');
|
|
206
|
+
}
|
|
207
|
+
else if (checkFailed) {
|
|
208
|
+
parts.push('`lat check` found ' +
|
|
209
|
+
totalErrors +
|
|
210
|
+
' error(s). Run `lat check`, fix the errors, and repeat until it passes.');
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
parts.push(syncMsg +
|
|
214
|
+
' Verify `lat.md/` is in sync — run `lat search` to find relevant sections. Run `lat check` at the end.');
|
|
215
|
+
}
|
|
129
216
|
outputStop(parts.join('\n'));
|
|
130
217
|
}
|
|
131
218
|
export async function hookCmd(agent, event) {
|
package/dist/src/cli/init.js
CHANGED
|
@@ -3,9 +3,10 @@ import { join, resolve } from 'node:path';
|
|
|
3
3
|
import { createInterface } from 'node:readline/promises';
|
|
4
4
|
import chalk from 'chalk';
|
|
5
5
|
import { findTemplatesDir } from './templates.js';
|
|
6
|
-
import { readAgentsTemplate, readCursorRulesTemplate } from './gen.js';
|
|
6
|
+
import { readAgentsTemplate, readCursorRulesTemplate, readPiExtensionTemplate, } from './gen.js';
|
|
7
7
|
import { getConfigPath, readConfig, writeConfig, } from '../config.js';
|
|
8
8
|
import { writeInitMeta, readFileHash, contentHash } from '../init-version.js';
|
|
9
|
+
import { selectMenu } from './select-menu.js';
|
|
9
10
|
async function confirm(rl, message) {
|
|
10
11
|
while (true) {
|
|
11
12
|
let answer;
|
|
@@ -35,16 +36,68 @@ async function prompt(rl, message) {
|
|
|
35
36
|
process.exit(130);
|
|
36
37
|
}
|
|
37
38
|
}
|
|
39
|
+
// ── Binary resolution ────────────────────────────────────────────────
|
|
40
|
+
/**
|
|
41
|
+
* Return the loader-related flags from `process.execArgv`, stripping
|
|
42
|
+
* `--eval`/`-e`/`--print`/`-p` and their value arguments (those only
|
|
43
|
+
* appear when the process was started with `node -e`/`-p`).
|
|
44
|
+
*/
|
|
45
|
+
function loaderExecArgs() {
|
|
46
|
+
const raw = process.execArgv;
|
|
47
|
+
const args = [];
|
|
48
|
+
for (let i = 0; i < raw.length; i++) {
|
|
49
|
+
if (raw[i] === '--eval' ||
|
|
50
|
+
raw[i] === '-e' ||
|
|
51
|
+
raw[i] === '--print' ||
|
|
52
|
+
raw[i] === '-p') {
|
|
53
|
+
i++; // skip the value argument
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
args.push(raw[i]);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return args;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Reconstruct the command prefix used to invoke this process.
|
|
63
|
+
*
|
|
64
|
+
* When running via a compiled JS entry point (e.g. the global `lat` binary),
|
|
65
|
+
* `process.argv[1]` is enough (e.g. `/usr/local/bin/lat`).
|
|
66
|
+
*
|
|
67
|
+
* When running via a TypeScript loader like tsx, the script itself can't be
|
|
68
|
+
* executed directly — we need to replay the same node flags that loaded tsx.
|
|
69
|
+
* We detect this by checking `process.execArgv` for tsx's `--import` loader
|
|
70
|
+
* and reconstruct: `node <execArgv...> <script>`.
|
|
71
|
+
*/
|
|
72
|
+
function resolveLatBin() {
|
|
73
|
+
const script = resolve(process.argv[1]);
|
|
74
|
+
// Not a .ts file — compiled JS or a wrapper script, use as-is.
|
|
75
|
+
if (!script.endsWith('.ts'))
|
|
76
|
+
return script;
|
|
77
|
+
// Running a .ts file: reconstruct `node <execArgv> <script>` so the
|
|
78
|
+
// same loader (tsx, ts-node, etc.) is used when the command is replayed.
|
|
79
|
+
const node = process.argv[0];
|
|
80
|
+
const execArgs = loaderExecArgs();
|
|
81
|
+
if (execArgs.length > 0) {
|
|
82
|
+
return [node, ...execArgs, script]
|
|
83
|
+
.map((a) => (a.includes(' ') ? `"${a}"` : a))
|
|
84
|
+
.join(' ');
|
|
85
|
+
}
|
|
86
|
+
// .ts file but no special loader flags — best-effort, just return the path
|
|
87
|
+
return script;
|
|
88
|
+
}
|
|
38
89
|
// ── Claude Code helpers ──────────────────────────────────────────────
|
|
39
90
|
/** Derive the hook command prefix from the currently running binary. */
|
|
40
91
|
function latHookCommand(event) {
|
|
41
|
-
return `${
|
|
92
|
+
return `${resolveLatBin()} hook claude ${event}`;
|
|
42
93
|
}
|
|
43
94
|
/** True if any command in this entry looks like it was installed by lat. */
|
|
44
95
|
function isLatHookEntry(entry) {
|
|
45
96
|
const bin = resolve(process.argv[1]);
|
|
46
97
|
return (entry.hooks?.some((h) => typeof h.command === 'string' &&
|
|
47
|
-
(/\blat\b/.test(h.command) ||
|
|
98
|
+
(/\blat\b/.test(h.command) ||
|
|
99
|
+
h.command.includes('hook claude ') ||
|
|
100
|
+
h.command.startsWith(bin + ' '))) ?? false);
|
|
48
101
|
}
|
|
49
102
|
/**
|
|
50
103
|
* Remove all lat-owned hook entries from settings, then add fresh ones.
|
|
@@ -124,10 +177,31 @@ function ensureGitignored(root, entry) {
|
|
|
124
177
|
* Derive the MCP server command from the currently running binary.
|
|
125
178
|
* If `lat init` was invoked as `/path/to/lat`, we emit
|
|
126
179
|
* `{ command: "/path/to/lat", args: ["mcp"] }` so the MCP client
|
|
127
|
-
* starts the same binary.
|
|
180
|
+
* starts the same binary. When running via tsx, emits
|
|
181
|
+
* `{ command: "node", args: ["--import", "tsx/loader", ..., "script.ts", "mcp"] }`.
|
|
128
182
|
*/
|
|
129
183
|
function mcpCommand() {
|
|
130
|
-
|
|
184
|
+
const script = resolve(process.argv[1]);
|
|
185
|
+
if (!script.endsWith('.ts')) {
|
|
186
|
+
return { command: script, args: ['mcp'] };
|
|
187
|
+
}
|
|
188
|
+
const raw = process.execArgv;
|
|
189
|
+
const execArgs = [];
|
|
190
|
+
for (let i = 0; i < raw.length; i++) {
|
|
191
|
+
if (raw[i] === '--eval' ||
|
|
192
|
+
raw[i] === '-e' ||
|
|
193
|
+
raw[i] === '--print' ||
|
|
194
|
+
raw[i] === '-p') {
|
|
195
|
+
i++;
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
execArgs.push(raw[i]);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (execArgs.length > 0) {
|
|
202
|
+
return { command: process.argv[0], args: [...execArgs, script, 'mcp'] };
|
|
203
|
+
}
|
|
204
|
+
return { command: script, args: ['mcp'] };
|
|
131
205
|
}
|
|
132
206
|
function hasMcpServer(configPath, key) {
|
|
133
207
|
if (!existsSync(configPath))
|
|
@@ -281,6 +355,20 @@ async function setupCopilot(root, latDir, hashes, ask) {
|
|
|
281
355
|
console.log(chalk.green(' MCP server') + ' registered in .vscode/mcp.json');
|
|
282
356
|
}
|
|
283
357
|
}
|
|
358
|
+
async function setupPi(root, latDir, hashes, ask) {
|
|
359
|
+
// AGENTS.md — Pi reads this natively
|
|
360
|
+
// (already created in the shared step if any non-Claude agent is selected)
|
|
361
|
+
// .pi/extensions/lat.ts — extension that registers tools + lifecycle hooks
|
|
362
|
+
console.log('');
|
|
363
|
+
console.log(chalk.dim(' The Pi extension registers lat tools and hooks into the agent lifecycle'));
|
|
364
|
+
console.log(chalk.dim(' to inject search context and validate lat.md/ before finishing.'));
|
|
365
|
+
const template = readPiExtensionTemplate().replace('__LAT_BIN__', resolveLatBin());
|
|
366
|
+
const hash = await writeTemplateFile(root, latDir, '.pi/extensions/lat.ts', template, 'pi-extension.ts', 'Extension (.pi/extensions/lat.ts)', ' ', ask);
|
|
367
|
+
if (hash)
|
|
368
|
+
hashes['.pi/extensions/lat.ts'] = hash;
|
|
369
|
+
// Ensure .pi is gitignored (extension contains local absolute paths)
|
|
370
|
+
ensureGitignored(root, '.pi');
|
|
371
|
+
}
|
|
284
372
|
// ── LLM key setup ───────────────────────────────────────────────────
|
|
285
373
|
async function setupLlmKey(rl) {
|
|
286
374
|
// Check env var first
|
|
@@ -389,15 +477,43 @@ export async function initCmd(targetDir) {
|
|
|
389
477
|
cpSync(templateDir, latDir, { recursive: true });
|
|
390
478
|
console.log(chalk.green('Created lat.md/'));
|
|
391
479
|
}
|
|
392
|
-
// Step 2: Which coding agents do you use?
|
|
393
|
-
console.log('');
|
|
394
|
-
console.log(chalk.bold('Which coding agents do you use?'));
|
|
480
|
+
// Step 2: Which coding agents do you use? (interactive select menu)
|
|
395
481
|
console.log('');
|
|
396
|
-
const
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
482
|
+
const allAgents = [
|
|
483
|
+
{ label: 'Claude Code', value: 'claude' },
|
|
484
|
+
{ label: 'Pi', value: 'pi' },
|
|
485
|
+
{ label: 'Cursor', value: 'cursor' },
|
|
486
|
+
{ label: 'VS Code Copilot', value: 'copilot' },
|
|
487
|
+
{ label: 'Codex / OpenCode', value: 'codex' },
|
|
488
|
+
];
|
|
489
|
+
const selectedAgents = [];
|
|
490
|
+
// Iterative selection: pick agents one at a time until "done"
|
|
491
|
+
while (true) {
|
|
492
|
+
const remaining = allAgents.filter((a) => !selectedAgents.includes(a.value));
|
|
493
|
+
const options = [
|
|
494
|
+
{
|
|
495
|
+
label: selectedAgents.length === 0
|
|
496
|
+
? "I don't use any of these"
|
|
497
|
+
: 'This is it: exit',
|
|
498
|
+
value: '__done__',
|
|
499
|
+
accent: true,
|
|
500
|
+
},
|
|
501
|
+
...remaining,
|
|
502
|
+
];
|
|
503
|
+
const isFirst = selectedAgents.length === 0;
|
|
504
|
+
const choice = await selectMenu(options, isFirst ? 'Which coding agent do you use?' : 'Add another agent?', isFirst ? 1 : 0);
|
|
505
|
+
if (!choice || choice === '__done__')
|
|
506
|
+
break;
|
|
507
|
+
selectedAgents.push(choice);
|
|
508
|
+
if (remaining.length === 1)
|
|
509
|
+
break; // all agents selected
|
|
510
|
+
}
|
|
511
|
+
const useClaudeCode = selectedAgents.includes('claude');
|
|
512
|
+
const usePi = selectedAgents.includes('pi');
|
|
513
|
+
const useCursor = selectedAgents.includes('cursor');
|
|
514
|
+
const useCopilot = selectedAgents.includes('copilot');
|
|
515
|
+
const useCodex = selectedAgents.includes('codex');
|
|
516
|
+
const anySelected = selectedAgents.length > 0;
|
|
401
517
|
if (!anySelected) {
|
|
402
518
|
console.log('');
|
|
403
519
|
console.log(chalk.dim('No agents selected. You can re-run') +
|
|
@@ -409,7 +525,7 @@ export async function initCmd(targetDir) {
|
|
|
409
525
|
const template = readAgentsTemplate();
|
|
410
526
|
const fileHashes = {};
|
|
411
527
|
// Step 3: AGENTS.md (shared by non-Claude agents)
|
|
412
|
-
const needsAgentsMd = useCursor || useCopilot || useCodex;
|
|
528
|
+
const needsAgentsMd = usePi || useCursor || useCopilot || useCodex;
|
|
413
529
|
if (needsAgentsMd) {
|
|
414
530
|
await setupAgentsMd(root, latDir, template, fileHashes, ask);
|
|
415
531
|
}
|
|
@@ -419,6 +535,11 @@ export async function initCmd(targetDir) {
|
|
|
419
535
|
console.log(chalk.bold('Setting up Claude Code...'));
|
|
420
536
|
await setupClaudeCode(root, latDir, template, fileHashes, ask);
|
|
421
537
|
}
|
|
538
|
+
if (usePi) {
|
|
539
|
+
console.log('');
|
|
540
|
+
console.log(chalk.bold('Setting up Pi...'));
|
|
541
|
+
await setupPi(root, latDir, fileHashes, ask);
|
|
542
|
+
}
|
|
422
543
|
if (useCursor) {
|
|
423
544
|
console.log('');
|
|
424
545
|
console.log(chalk.bold('Setting up Cursor...'));
|
package/dist/src/cli/section.js
CHANGED
|
@@ -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
|
|
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
|
|
30
|
-
|
|
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
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface SelectOption {
|
|
2
|
+
label: string;
|
|
3
|
+
value: string;
|
|
4
|
+
/** If true, this option uses a distinct highlight style (e.g. for "done" actions). */
|
|
5
|
+
accent?: boolean;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Display an interactive select menu with arrow-key navigation.
|
|
9
|
+
* Returns the selected option's value, or null if the user pressed Ctrl+C.
|
|
10
|
+
*
|
|
11
|
+
* @param defaultIndex - initial cursor position (defaults to 0)
|
|
12
|
+
*/
|
|
13
|
+
export declare function selectMenu(options: SelectOption[], prompt?: string, defaultIndex?: number): Promise<string | null>;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
/**
|
|
3
|
+
* Display an interactive select menu with arrow-key navigation.
|
|
4
|
+
* Returns the selected option's value, or null if the user pressed Ctrl+C.
|
|
5
|
+
*
|
|
6
|
+
* @param defaultIndex - initial cursor position (defaults to 0)
|
|
7
|
+
*/
|
|
8
|
+
export async function selectMenu(options, prompt, defaultIndex) {
|
|
9
|
+
if (options.length === 0)
|
|
10
|
+
return null;
|
|
11
|
+
if (!process.stdin.isTTY) {
|
|
12
|
+
// Non-interactive fallback: return first option
|
|
13
|
+
return options[0].value;
|
|
14
|
+
}
|
|
15
|
+
return new Promise((resolve) => {
|
|
16
|
+
let cursor = defaultIndex ?? 0;
|
|
17
|
+
const stdin = process.stdin;
|
|
18
|
+
// Save original stdin state
|
|
19
|
+
const wasRaw = stdin.isRaw;
|
|
20
|
+
stdin.setRawMode(true);
|
|
21
|
+
stdin.resume();
|
|
22
|
+
stdin.setEncoding('utf-8');
|
|
23
|
+
function render() {
|
|
24
|
+
process.stdout.write('\x1B[?25l'); // hide cursor
|
|
25
|
+
const lines = [];
|
|
26
|
+
if (prompt) {
|
|
27
|
+
lines.push(chalk.bold(prompt));
|
|
28
|
+
}
|
|
29
|
+
for (let i = 0; i < options.length; i++) {
|
|
30
|
+
const opt = options[i];
|
|
31
|
+
const selected = i === cursor;
|
|
32
|
+
const pointer = selected ? '❯' : ' ';
|
|
33
|
+
if (selected) {
|
|
34
|
+
if (opt.accent) {
|
|
35
|
+
lines.push(` ${pointer} ${chalk.bgRed.white.bold(` ${opt.label} `)}`);
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
lines.push(` ${pointer} ${chalk.bgCyan.black.bold(` ${opt.label} `)}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
lines.push(` ${pointer} ${chalk.dim(opt.label)}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
process.stdout.write(lines.join('\n') + '\n');
|
|
46
|
+
}
|
|
47
|
+
function clearRender() {
|
|
48
|
+
const totalLines = options.length + (prompt ? 1 : 0);
|
|
49
|
+
// Move up and clear each line
|
|
50
|
+
for (let i = 0; i < totalLines; i++) {
|
|
51
|
+
process.stdout.write('\x1B[A\x1B[2K');
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function cleanup() {
|
|
55
|
+
stdin.setRawMode(wasRaw ?? false);
|
|
56
|
+
stdin.pause();
|
|
57
|
+
process.stdout.write('\x1B[?25h'); // show cursor
|
|
58
|
+
stdin.removeListener('data', onData);
|
|
59
|
+
}
|
|
60
|
+
function onData(data) {
|
|
61
|
+
const key = data.toString();
|
|
62
|
+
// Ctrl+C
|
|
63
|
+
if (key === '\x03') {
|
|
64
|
+
clearRender();
|
|
65
|
+
cleanup();
|
|
66
|
+
console.log('');
|
|
67
|
+
process.exit(130);
|
|
68
|
+
}
|
|
69
|
+
// Enter
|
|
70
|
+
if (key === '\r' || key === '\n') {
|
|
71
|
+
clearRender();
|
|
72
|
+
cleanup();
|
|
73
|
+
const selected = options[cursor];
|
|
74
|
+
// Print the selection
|
|
75
|
+
if (prompt) {
|
|
76
|
+
console.log(chalk.bold(prompt) + ' ' + chalk.green(selected.label));
|
|
77
|
+
}
|
|
78
|
+
resolve(selected.value);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
// Arrow keys (escape sequences)
|
|
82
|
+
if (key === '\x1B[A' || key === 'k') {
|
|
83
|
+
// Up
|
|
84
|
+
clearRender();
|
|
85
|
+
cursor = (cursor - 1 + options.length) % options.length;
|
|
86
|
+
render();
|
|
87
|
+
}
|
|
88
|
+
else if (key === '\x1B[B' || key === 'j') {
|
|
89
|
+
// Down
|
|
90
|
+
clearRender();
|
|
91
|
+
cursor = (cursor + 1) % options.length;
|
|
92
|
+
render();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
stdin.on('data', onData);
|
|
96
|
+
render();
|
|
97
|
+
});
|
|
98
|
+
}
|
|
@@ -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
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import { Type } from "@sinclair/typebox";
|
|
2
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
3
|
+
import { keyHint } from "@mariozechner/pi-coding-agent";
|
|
4
|
+
import type { Theme } from "@mariozechner/pi-tui";
|
|
5
|
+
import { Box, Text } from "@mariozechner/pi-tui";
|
|
6
|
+
|
|
7
|
+
const PREVIEW_LINES = 4;
|
|
8
|
+
|
|
9
|
+
function collapsibleResult(
|
|
10
|
+
result: { content: Array<{ type: string; text?: string }> },
|
|
11
|
+
options: { expanded: boolean; isPartial: boolean },
|
|
12
|
+
theme: Theme,
|
|
13
|
+
) {
|
|
14
|
+
const text = result.content?.[0]?.type === "text" ? (result.content[0] as { type: "text"; text: string }).text : "";
|
|
15
|
+
if (!text) return new Text(theme.fg("dim", "(empty)"), 0, 0);
|
|
16
|
+
if (options.isPartial) return new Text(theme.fg("dim", "…"), 0, 0);
|
|
17
|
+
if (options.expanded) return new Text(text, 0, 0);
|
|
18
|
+
|
|
19
|
+
const lines = text.split("\n");
|
|
20
|
+
if (lines.length <= PREVIEW_LINES) return new Text(text, 0, 0);
|
|
21
|
+
|
|
22
|
+
const preview = lines.slice(0, PREVIEW_LINES).join("\n");
|
|
23
|
+
const remaining = lines.length - PREVIEW_LINES;
|
|
24
|
+
const hint = keyHint("expandTools", "to expand");
|
|
25
|
+
return new Text(
|
|
26
|
+
preview + "\n" +
|
|
27
|
+
theme.fg("dim", `… ${remaining} more lines (${hint})`),
|
|
28
|
+
0, 0,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Absolute path to the lat binary, injected by `lat init`. */
|
|
33
|
+
const LAT = "__LAT_BIN__";
|
|
34
|
+
|
|
35
|
+
function run(args: string[], cwd?: string): string {
|
|
36
|
+
const { execSync } = require("child_process") as typeof import("child_process");
|
|
37
|
+
return execSync(`${LAT} ${args.join(" ")}`, {
|
|
38
|
+
cwd: cwd ?? process.cwd(),
|
|
39
|
+
encoding: "utf-8",
|
|
40
|
+
timeout: 30_000,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function tryRun(args: string[]): string {
|
|
45
|
+
try {
|
|
46
|
+
return run(args);
|
|
47
|
+
} catch {
|
|
48
|
+
return "";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export default function (pi: ExtensionAPI) {
|
|
53
|
+
// ── Tools ──────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
pi.registerTool({
|
|
56
|
+
name: "lat_search",
|
|
57
|
+
label: "lat search",
|
|
58
|
+
description: "Semantic search across lat.md sections using embeddings",
|
|
59
|
+
promptSnippet: "Search lat.md documentation by meaning",
|
|
60
|
+
promptGuidelines: [
|
|
61
|
+
"Use before starting any task to find relevant design context",
|
|
62
|
+
"Search results include section IDs you can pass to lat_section",
|
|
63
|
+
],
|
|
64
|
+
parameters: Type.Object({
|
|
65
|
+
query: Type.String({ description: "Search query in natural language" }),
|
|
66
|
+
limit: Type.Optional(
|
|
67
|
+
Type.Number({ description: "Max results (default 5)", default: 5 }),
|
|
68
|
+
),
|
|
69
|
+
}),
|
|
70
|
+
async execute(_id, params) {
|
|
71
|
+
const args = ["search", JSON.stringify(params.query)];
|
|
72
|
+
if (params.limit) args.push("--limit", String(params.limit));
|
|
73
|
+
const output = tryRun(args);
|
|
74
|
+
return {
|
|
75
|
+
content: [{ type: "text", text: output || "No results found." }],
|
|
76
|
+
};
|
|
77
|
+
},
|
|
78
|
+
renderCall(args, theme) {
|
|
79
|
+
return new Text(
|
|
80
|
+
theme.fg("toolTitle", theme.bold("lat search ")) +
|
|
81
|
+
theme.fg("dim", `"${args.query}"`),
|
|
82
|
+
0, 0,
|
|
83
|
+
);
|
|
84
|
+
},
|
|
85
|
+
renderResult: collapsibleResult,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
pi.registerTool({
|
|
89
|
+
name: "lat_section",
|
|
90
|
+
label: "lat section",
|
|
91
|
+
description:
|
|
92
|
+
"Show full content of a lat.md section with outgoing/incoming refs",
|
|
93
|
+
promptSnippet: "Read a specific lat.md section",
|
|
94
|
+
parameters: Type.Object({
|
|
95
|
+
query: Type.String({
|
|
96
|
+
description:
|
|
97
|
+
'Section ID or name (e.g. "cli#init", "Tests#User login")',
|
|
98
|
+
}),
|
|
99
|
+
}),
|
|
100
|
+
async execute(_id, params) {
|
|
101
|
+
const output = tryRun(["section", JSON.stringify(params.query)]);
|
|
102
|
+
return {
|
|
103
|
+
content: [
|
|
104
|
+
{ type: "text", text: output || "Section not found." },
|
|
105
|
+
],
|
|
106
|
+
};
|
|
107
|
+
},
|
|
108
|
+
renderCall(args, theme) {
|
|
109
|
+
return new Text(
|
|
110
|
+
theme.fg("toolTitle", theme.bold("lat section ")) +
|
|
111
|
+
theme.fg("dim", `"${args.query}"`),
|
|
112
|
+
0, 0,
|
|
113
|
+
);
|
|
114
|
+
},
|
|
115
|
+
renderResult: collapsibleResult,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
pi.registerTool({
|
|
119
|
+
name: "lat_locate",
|
|
120
|
+
label: "lat locate",
|
|
121
|
+
description:
|
|
122
|
+
"Find a section by name (exact, subsection tail, or fuzzy match)",
|
|
123
|
+
promptSnippet: "Find a lat.md section by name",
|
|
124
|
+
parameters: Type.Object({
|
|
125
|
+
query: Type.String({ description: "Section name to locate" }),
|
|
126
|
+
}),
|
|
127
|
+
async execute(_id, params) {
|
|
128
|
+
const output = tryRun(["locate", JSON.stringify(params.query)]);
|
|
129
|
+
return {
|
|
130
|
+
content: [
|
|
131
|
+
{ type: "text", text: output || "No sections matching query." },
|
|
132
|
+
],
|
|
133
|
+
};
|
|
134
|
+
},
|
|
135
|
+
renderCall(args, theme) {
|
|
136
|
+
return new Text(
|
|
137
|
+
theme.fg("toolTitle", theme.bold("lat locate ")) +
|
|
138
|
+
theme.fg("dim", `"${args.query}"`),
|
|
139
|
+
0, 0,
|
|
140
|
+
);
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
pi.registerTool({
|
|
145
|
+
name: "lat_check",
|
|
146
|
+
label: "lat check",
|
|
147
|
+
description:
|
|
148
|
+
"Validate all wiki links and code refs in lat.md. Returns errors or 'All checks passed'",
|
|
149
|
+
promptSnippet: "Validate lat.md links and code refs",
|
|
150
|
+
parameters: Type.Object({}),
|
|
151
|
+
async execute() {
|
|
152
|
+
try {
|
|
153
|
+
const output = run(["check"]);
|
|
154
|
+
return { content: [{ type: "text", text: output }] };
|
|
155
|
+
} catch (err: unknown) {
|
|
156
|
+
const e = err as { stdout?: string; stderr?: string };
|
|
157
|
+
return {
|
|
158
|
+
content: [{ type: "text", text: e.stdout || e.stderr || "Check failed" }],
|
|
159
|
+
isError: true,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
renderCall(_args, theme) {
|
|
164
|
+
return new Text(
|
|
165
|
+
theme.fg("toolTitle", theme.bold("lat check")),
|
|
166
|
+
0, 0,
|
|
167
|
+
);
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
pi.registerTool({
|
|
172
|
+
name: "lat_expand",
|
|
173
|
+
label: "lat expand",
|
|
174
|
+
description:
|
|
175
|
+
"Expand [[refs]] in text to resolved file locations and context",
|
|
176
|
+
promptSnippet: "Resolve [[wiki links]] in text",
|
|
177
|
+
parameters: Type.Object({
|
|
178
|
+
text: Type.String({ description: "Text containing [[refs]] to expand" }),
|
|
179
|
+
}),
|
|
180
|
+
async execute(_id, params) {
|
|
181
|
+
const output = tryRun(["expand", JSON.stringify(params.text)]);
|
|
182
|
+
return {
|
|
183
|
+
content: [{ type: "text", text: output || params.text }],
|
|
184
|
+
};
|
|
185
|
+
},
|
|
186
|
+
renderCall(args, theme) {
|
|
187
|
+
const preview = args.text.length > 60 ? args.text.slice(0, 60) + "…" : args.text;
|
|
188
|
+
return new Text(
|
|
189
|
+
theme.fg("toolTitle", theme.bold("lat expand ")) +
|
|
190
|
+
theme.fg("dim", `"${preview}"`),
|
|
191
|
+
0, 0,
|
|
192
|
+
);
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
pi.registerTool({
|
|
197
|
+
name: "lat_refs",
|
|
198
|
+
label: "lat refs",
|
|
199
|
+
description: "Find what references a given section",
|
|
200
|
+
promptSnippet: "Find incoming references to a lat.md section",
|
|
201
|
+
parameters: Type.Object({
|
|
202
|
+
query: Type.String({
|
|
203
|
+
description: 'Section ID (e.g. "cli#init", "file#Section")',
|
|
204
|
+
}),
|
|
205
|
+
}),
|
|
206
|
+
async execute(_id, params) {
|
|
207
|
+
const output = tryRun(["refs", JSON.stringify(params.query)]);
|
|
208
|
+
return {
|
|
209
|
+
content: [{ type: "text", text: output || "No references found." }],
|
|
210
|
+
};
|
|
211
|
+
},
|
|
212
|
+
renderCall(args, theme) {
|
|
213
|
+
return new Text(
|
|
214
|
+
theme.fg("toolTitle", theme.bold("lat refs ")) +
|
|
215
|
+
theme.fg("dim", `"${args.query}"`),
|
|
216
|
+
0, 0,
|
|
217
|
+
);
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// ── Message renderers ────────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
pi.registerMessageRenderer("lat-reminder", (message, { expanded }, theme) => {
|
|
224
|
+
const box = new Box(1, 1, (t) => theme.bg("customMessageBg", t));
|
|
225
|
+
if (expanded) {
|
|
226
|
+
box.addChild(new Text(theme.fg("accent", "lat.md") + " " + message.content, 0, 0));
|
|
227
|
+
} else {
|
|
228
|
+
const hint = keyHint("expandTools", "to expand");
|
|
229
|
+
box.addChild(new Text(
|
|
230
|
+
theme.fg("accent", "lat.md") + " " +
|
|
231
|
+
theme.fg("dim", `Search lat.md before starting work. Keep lat.md/ in sync. (${hint})`),
|
|
232
|
+
0, 0,
|
|
233
|
+
));
|
|
234
|
+
}
|
|
235
|
+
return box;
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
pi.registerMessageRenderer("lat-check", (message, { expanded }, theme) => {
|
|
239
|
+
const box = new Box(1, 1, (t) => theme.bg("customMessageBg", t));
|
|
240
|
+
if (expanded) {
|
|
241
|
+
box.addChild(new Text(theme.fg("warning", "lat check") + " " + message.content, 0, 0));
|
|
242
|
+
} else {
|
|
243
|
+
const hint = keyHint("expandTools", "to expand");
|
|
244
|
+
const firstLine = message.content.split("\n")[0];
|
|
245
|
+
box.addChild(new Text(
|
|
246
|
+
theme.fg("warning", "lat check") + " " +
|
|
247
|
+
theme.fg("dim", `${firstLine} (${hint})`),
|
|
248
|
+
0, 0,
|
|
249
|
+
));
|
|
250
|
+
}
|
|
251
|
+
return box;
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// ── Lifecycle hooks ────────────────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
// Guard to prevent agent_end from firing twice per prompt (infinite loop)
|
|
257
|
+
let agentEndFired = false;
|
|
258
|
+
|
|
259
|
+
pi.on("before_agent_start", async () => {
|
|
260
|
+
agentEndFired = false;
|
|
261
|
+
|
|
262
|
+
const reminder = [
|
|
263
|
+
"Before starting work, run `lat_search` with one or more queries describing the user's intent.",
|
|
264
|
+
"ALWAYS do this, even when the task seems straightforward — search results may reveal critical design details, protocols, or constraints.",
|
|
265
|
+
"Use `lat_section` to read the full content of relevant matches.",
|
|
266
|
+
"Do not read files, write code, or run commands until you have searched.",
|
|
267
|
+
"",
|
|
268
|
+
"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.",
|
|
269
|
+
].join("\n");
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
message: {
|
|
273
|
+
customType: "lat-reminder",
|
|
274
|
+
content: reminder,
|
|
275
|
+
display: true,
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
pi.on("agent_end", async () => {
|
|
281
|
+
// Don't fire twice per prompt — prevents infinite loop
|
|
282
|
+
if (agentEndFired) return;
|
|
283
|
+
agentEndFired = true;
|
|
284
|
+
|
|
285
|
+
// Run lat check
|
|
286
|
+
let checkOutput: string;
|
|
287
|
+
let checkFailed = false;
|
|
288
|
+
try {
|
|
289
|
+
checkOutput = run(["check"]);
|
|
290
|
+
} catch (err: unknown) {
|
|
291
|
+
checkFailed = true;
|
|
292
|
+
checkOutput = (err as { stdout?: string }).stdout || "";
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Run git diff --numstat to check if lat.md/ is in sync
|
|
296
|
+
let needsSync = false;
|
|
297
|
+
let codeLines = 0;
|
|
298
|
+
try {
|
|
299
|
+
const { execSync } = require("child_process") as typeof import("child_process");
|
|
300
|
+
const numstat = execSync("git diff HEAD --numstat", {
|
|
301
|
+
encoding: "utf-8",
|
|
302
|
+
cwd: process.cwd(),
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
let latMdLines = 0;
|
|
306
|
+
for (const line of numstat.split("\n")) {
|
|
307
|
+
const parts = line.split("\t");
|
|
308
|
+
if (parts.length < 3) continue;
|
|
309
|
+
const added = parseInt(parts[0], 10) || 0;
|
|
310
|
+
const removed = parseInt(parts[1], 10) || 0;
|
|
311
|
+
const file = parts[2];
|
|
312
|
+
const changed = added + removed;
|
|
313
|
+
if (file.startsWith("lat.md/")) {
|
|
314
|
+
latMdLines += changed;
|
|
315
|
+
} else if (/\.(ts|tsx|js|jsx|py|rs|go|c|h)$/.test(file)) {
|
|
316
|
+
codeLines += changed;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (codeLines >= 5) {
|
|
321
|
+
const effectiveLatMd = latMdLines === 0 ? 0 : Math.max(latMdLines, 1);
|
|
322
|
+
needsSync = effectiveLatMd < codeLines * 0.05;
|
|
323
|
+
}
|
|
324
|
+
} catch {
|
|
325
|
+
// git not available or no HEAD — skip diff check
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (!checkFailed && !needsSync) return;
|
|
329
|
+
|
|
330
|
+
const parts: string[] = [];
|
|
331
|
+
if (checkFailed && needsSync) {
|
|
332
|
+
parts.push(
|
|
333
|
+
`\`lat check\` found errors AND the codebase has changes (${codeLines} lines) with no updates to \`lat.md/\`. Before finishing:`,
|
|
334
|
+
"",
|
|
335
|
+
"1. Update `lat.md/` to reflect your code changes — run `lat_search` to find relevant sections.",
|
|
336
|
+
"2. Run `lat_check` until it passes.",
|
|
337
|
+
);
|
|
338
|
+
} else if (checkFailed) {
|
|
339
|
+
parts.push(
|
|
340
|
+
"`lat check` failed. Run `lat_check`, fix the errors, and repeat until it passes.",
|
|
341
|
+
);
|
|
342
|
+
} else {
|
|
343
|
+
parts.push(
|
|
344
|
+
`The codebase has changes (${codeLines} 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.`,
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
pi.sendMessage(
|
|
349
|
+
{ customType: "lat-check", content: parts.join("\n"), display: true },
|
|
350
|
+
{ deliverAs: "followUp", triggerTurn: true },
|
|
351
|
+
);
|
|
352
|
+
});
|
|
353
|
+
}
|