Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion lat.md/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ Sets up `copilot-instructions.md` and registers the MCP server for VS Code Copil
Sets up an OpenCode plugin that registers lat tools as native OpenCode tools and hooks into the session lifecycle.

- `AGENTS.md` — shared instruction file (created in the shared step)
- `.opencode/plugins/lat.ts` — TypeScript plugin generated from `templates/opencode-plugin.ts` with the lat invocation command injected. Uses `@opencode-ai/plugin` to register six tools (`lat_search`, `lat_section`, `lat_locate`, `lat_check`, `lat_expand`, `lat_refs`) that shell out to the `lat` CLI. Hooks into `session.idle` (runs `lat check` + diff analysis, logs a warning via `client.app.log` if something needs fixing).
- `.opencode/plugins/lat.ts` — TypeScript plugin generated from `templates/opencode-plugin.ts` with the lat invocation command injected. Uses `@opencode-ai/plugin` to register six tools (`lat_search`, `lat_section`, `lat_locate`, `lat_check`, `lat_expand`, `lat_refs`) that shell out to the `lat` CLI with piped stdio so subprocess output does not pollute the OpenCode TUI. It watches `session.idle` via the plugin `event` hook, runs `lat check` plus diff analysis, and logs a warning via `client.app.log` if something needs fixing.
- `.agents/skills/lat-md/SKILL.md` — skill spec for authoring `lat.md/` files, placed in the cross-agent standard skills directory
- `.opencode` directory added to `.gitignore` (plugin contains local absolute paths)

Expand Down
18 changes: 18 additions & 0 deletions lat.md/tests/gen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
lat:
require-code-mention: true
---

# Gen

Regression tests for built-in template generation. These tests keep shipped agent templates aligned with the current runtime contracts.

Tests in `tests/gen.test.ts`.

## OpenCode plugin template uses event hook

The generated OpenCode plugin listens for `session.idle` through the plugin `event` callback so it matches the current OpenCode plugin API instead of relying on an older hook shape.

## OpenCode plugin template pipes child process output

The generated OpenCode plugin runs `lat` and `git` child processes with piped stdio so subprocess output stays buffered and does not leak into the OpenCode TUI.
1 change: 1 addition & 0 deletions lat.md/tests/tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,5 @@ Shared patterns for writing and organizing tests in this project.
- [[check-sections]] — Validating section leading paragraphs
- [[section]] — getSection core function and formatSectionOutput formatter
- [[hook]] — Stop hook conditional blocking and diff analysis
- [[gen]] — Built-in template generation regression checks
- [[ts-fallback]] — Pure-TypeScript code-ref scanner fallback without ripgrep
251 changes: 150 additions & 101 deletions templates/opencode-plugin.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,53 @@
import { type Plugin, tool } from "@opencode-ai/plugin"
import { execSync } from "child_process"
import { type Plugin, tool } from '@opencode-ai/plugin';
import { spawnSync } from 'child_process';

/** Absolute path to the lat binary, injected by `lat init`. */
const LAT = "__LAT_BIN__"
const LAT = '__LAT_BIN__';

function run(args: string[]): string {
return execSync(`${LAT} ${args.join(" ")}`, {
cwd: process.cwd(),
encoding: "utf-8",
function quote(arg: string): string {
return JSON.stringify(arg);
}

function projectRoot(directory: string, worktree: string): string {
return worktree && worktree !== '/' ? worktree : directory;
}

function command(command: string, cwd: string) {
const result = spawnSync(command, {
cwd,
encoding: 'utf-8',
timeout: 30_000,
})
maxBuffer: 1024 * 1024,
shell: true,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});

if (result.error) throw result.error;
if (result.status === 0) return result.stdout ?? '';

const error = new Error(
result.stderr ||
result.stdout ||
`Command failed with exit code ${result.status ?? 'unknown'}`,
) as Error & {
stdout?: string;
stderr?: string;
};
error.stdout = result.stdout ?? '';
error.stderr = result.stderr ?? '';
throw error;
}

function tryRun(args: string[]): string {
function run(args: string[], cwd: string): string {
return command([LAT, ...args.map(quote)].join(' '), cwd);
}

function tryRun(args: string[], cwd: string): string {
try {
return run(args)
return run(args, cwd);
} catch {
return ""
return '';
}
}

Expand All @@ -25,149 +56,167 @@ export const LatPlugin: Plugin = async (ctx) => {
tool: {
lat_search: tool({
description:
"Semantic search across lat.md sections using embeddings. Use before starting any task to find relevant design context.",
'Semantic search across lat.md sections using embeddings. Use before starting any task to find relevant design context.',
args: {
query: tool.schema.string("Search query in natural language"),
query: tool.schema.string('Search query in natural language'),
limit: tool.schema.optional(
tool.schema.number("Max results (default 5)"),
tool.schema.number('Max results (default 5)'),
),
},
async execute(args) {
const cliArgs = ["search", JSON.stringify(args.query)]
if (args.limit) cliArgs.push("--limit", String(args.limit))
const output = tryRun(cliArgs)
return output || "No results found."
async execute(args, context) {
const cliArgs = ['search', args.query];
if (args.limit !== undefined)
cliArgs.push('--limit', String(args.limit));
const output = tryRun(
cliArgs,
projectRoot(context.directory, context.worktree),
);
return output || 'No results found.';
},
}),

lat_section: tool({
description:
"Show full content of a lat.md section with outgoing/incoming refs",
'Show full content of a lat.md section with outgoing/incoming refs',
args: {
query: tool.schema.string(
'Section ID or name (e.g. "cli#init", "Tests#User login")',
),
},
async execute(args) {
const output = tryRun(["section", JSON.stringify(args.query)])
return output || "Section not found."
async execute(args, context) {
const output = tryRun(
['section', args.query],
projectRoot(context.directory, context.worktree),
);
return output || 'Section not found.';
},
}),

lat_locate: tool({
description:
"Find a section by name (exact, subsection tail, or fuzzy match)",
'Find a section by name (exact, subsection tail, or fuzzy match)',
args: {
query: tool.schema.string("Section name to locate"),
query: tool.schema.string('Section name to locate'),
},
async execute(args) {
const output = tryRun(["locate", JSON.stringify(args.query)])
return output || "No sections matching query."
async execute(args, context) {
const output = tryRun(
['locate', args.query],
projectRoot(context.directory, context.worktree),
);
return output || 'No sections matching query.';
},
}),

lat_check: tool({
description:
"Validate all wiki links and code refs in lat.md. Returns errors or 'All checks passed'",
args: {},
async execute() {
async execute(_args, context) {
try {
return run(["check"])
return run(
['check'],
projectRoot(context.directory, context.worktree),
);
} catch (err: unknown) {
const e = err as { stdout?: string; stderr?: string }
return e.stdout || e.stderr || "Check failed"
const e = err as { stdout?: string; stderr?: string };
return e.stdout || e.stderr || 'Check failed';
}
},
}),

lat_expand: tool({
description:
"Expand [[refs]] in text to resolved file locations and context",
'Expand [[refs]] in text to resolved file locations and context',
args: {
text: tool.schema.string("Text containing [[refs]] to expand"),
text: tool.schema.string('Text containing [[refs]] to expand'),
},
async execute(args) {
const output = tryRun(["expand", JSON.stringify(args.text)])
return output || args.text
async execute(args, context) {
const output = tryRun(
['expand', args.text],
projectRoot(context.directory, context.worktree),
);
return output || args.text;
},
}),

lat_refs: tool({
description:
"Find what references a given section via wiki links or @lat code comments",
'Find what references a given section via wiki links or @lat code comments',
args: {
query: tool.schema.string(
'Section ID (e.g. "cli#init", "file#Section")',
),
},
async execute(args) {
const output = tryRun(["refs", JSON.stringify(args.query)])
return output || "No references found."
async execute(args, context) {
const output = tryRun(
['refs', args.query],
projectRoot(context.directory, context.worktree),
);
return output || 'No references found.';
},
}),
},

hooks: {
"session.idle": async () => {
let checkFailed = false
let checkOutput = ""
try {
checkOutput = run(["check"])
} catch (err: unknown) {
checkFailed = true
checkOutput = (err as { stdout?: string }).stdout || ""
}

// Check git diff for lat.md/ sync status
let needsSync = false
let codeLines = 0
try {
const numstat = execSync("git diff HEAD --numstat", {
encoding: "utf-8",
cwd: process.cwd(),
})

let latMdLines = 0
for (const line of numstat.split("\n")) {
const parts = line.split("\t")
if (parts.length < 3) continue
const added = parseInt(parts[0], 10) || 0
const removed = parseInt(parts[1], 10) || 0
const file = parts[2]
const changed = added + removed
if (file.startsWith("lat.md/")) {
latMdLines += changed
} else if (/\.(ts|tsx|js|jsx|py|rs|go|c|h)$/.test(file)) {
codeLines += changed
}
}

if (codeLines >= 5) {
const effectiveLatMd =
latMdLines === 0 ? 0 : Math.max(latMdLines, 1)
needsSync = effectiveLatMd < codeLines * 0.05
event: async ({ event }) => {
if (event.type !== 'session.idle') return;

const cwd = projectRoot(ctx.directory, ctx.worktree);

let checkFailed = false;
let checkOutput = '';
try {
checkOutput = run(['check'], cwd);
} catch (err: unknown) {
checkFailed = true;
const error = err as { stdout?: string; stderr?: string };
checkOutput = error.stdout || error.stderr || '';
}

// Check git diff for lat.md/ sync status
let needsSync = false;
let codeLines = 0;
try {
const numstat = command('git diff HEAD --numstat', cwd);

let latMdLines = 0;
for (const line of numstat.split('\n')) {
const parts = line.split('\t');
if (parts.length < 3) continue;
const added = parseInt(parts[0], 10) || 0;
const removed = parseInt(parts[1], 10) || 0;
const file = parts[2];
const changed = added + removed;
if (file.startsWith('lat.md/')) {
latMdLines += changed;
} else if (/\.(ts|tsx|js|jsx|py|rs|go|c|h)$/.test(file)) {
codeLines += changed;
}
} catch {
// git not available or no HEAD — skip diff check
}

if (!checkFailed && !needsSync) return

const message =
checkFailed && needsSync
? `lat check failed and lat.md/ may be out of sync (${codeLines} code lines changed). Run lat_check, fix errors, and update lat.md/.`
: checkFailed
? `lat check failed. Run lat_check and fix the errors.`
: `lat.md/ may be out of sync — ${codeLines} code lines changed but lat.md/ was not updated. Update lat.md/ and run lat_check.`

await ctx.client.app.log({
body: {
service: "lat.md",
level: "warn",
message,
},
})
},
if (codeLines >= 5) {
const effectiveLatMd = latMdLines === 0 ? 0 : Math.max(latMdLines, 1);
needsSync = effectiveLatMd < codeLines * 0.05;
}
} catch {
// git not available or no HEAD — skip diff check
}

if (!checkFailed && !needsSync) return;

const message =
checkFailed && needsSync
? `lat check failed and lat.md/ may be out of sync (${codeLines} code lines changed). Run lat_check, fix errors, and update lat.md/.`
: checkFailed
? `lat check failed. Run lat_check and fix the errors.`
: `lat.md/ may be out of sync — ${codeLines} code lines changed but lat.md/ was not updated. Update lat.md/ and run lat_check.`;

await ctx.client.app.log({
body: {
service: 'lat.md',
level: 'warn',
message,
},
});
},
}
}
};
};
17 changes: 17 additions & 0 deletions tests/gen.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import { readOpenCodePluginTemplate } from '../src/cli/gen.js';

describe('gen', () => {
// @lat: [[tests/gen#OpenCode plugin template uses event hook]]
it('renders the OpenCode plugin template with a session idle event handler', () => {
const template = readOpenCodePluginTemplate();
expect(template).toContain('event: async ({ event }) => {');
expect(template).toContain("event.type !== 'session.idle'");
});

// @lat: [[tests/gen#OpenCode plugin template pipes child process output]]
it('renders the OpenCode plugin template with piped child stdio', () => {
const template = readOpenCodePluginTemplate();
expect(template).toContain("stdio: ['ignore', 'pipe', 'pipe']");
});
});