klyro 1.0.17 → 1.0.20

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.
Files changed (40) hide show
  1. package/dist/agent/runtime.d.ts +7 -0
  2. package/dist/agent/runtime.js +43 -1
  3. package/dist/checkpoints/store.d.ts +28 -1
  4. package/dist/checkpoints/store.js +46 -2
  5. package/dist/cli/args.d.ts +7 -0
  6. package/dist/cli/args.js +10 -0
  7. package/dist/cli/eval.d.ts +32 -0
  8. package/dist/cli/eval.js +82 -3
  9. package/dist/cli/repl.js +16 -2
  10. package/dist/cli/run.d.ts +5 -0
  11. package/dist/cli/run.js +4 -0
  12. package/dist/context/klyro-md.d.ts +11 -0
  13. package/dist/context/klyro-md.js +59 -4
  14. package/dist/eval/baseline.d.ts +47 -0
  15. package/dist/eval/baseline.js +55 -0
  16. package/dist/eval/harness.d.ts +14 -2
  17. package/dist/eval/harness.js +40 -2
  18. package/dist/index.js +5 -2
  19. package/dist/tools/git/git-blame.d.ts +5 -0
  20. package/dist/tools/git/git-blame.js +22 -0
  21. package/dist/tools/git/git-diff.js +3 -20
  22. package/dist/tools/git/git-log.js +5 -19
  23. package/dist/tools/git/git-show.d.ts +5 -0
  24. package/dist/tools/git/git-show.js +22 -0
  25. package/dist/tools/git/git-status.js +4 -21
  26. package/dist/tools/git/run-git.d.ts +5 -0
  27. package/dist/tools/git/run-git.js +18 -0
  28. package/dist/tools/lsp/diagnostics.d.ts +50 -0
  29. package/dist/tools/lsp/diagnostics.js +109 -3
  30. package/dist/tools/registry.js +7 -1
  31. package/dist/tools/search/glob.js +8 -3
  32. package/dist/tools/search/grep.js +10 -3
  33. package/dist/tools/search/ignore.d.ts +8 -0
  34. package/dist/tools/search/ignore.js +49 -0
  35. package/dist/tools/search/search-files.js +10 -3
  36. package/dist/tui/app.js +37 -1
  37. package/dist/tui/app.test.js +17 -3
  38. package/dist/tui/transcript-commands.d.ts +14 -0
  39. package/dist/tui/transcript-commands.js +31 -0
  40. package/package.json +1 -1
@@ -0,0 +1,55 @@
1
+ /**
2
+ * 5.5b — eval baseline writer. Records the environment alongside suite
3
+ * results so regressions can be attributed (model/node/platform/commit).
4
+ *
5
+ * The tracked `evals/results/baseline.json` is refreshed by `klyro eval
6
+ * --suite <name>` (best-effort; suite failures never fail the write path).
7
+ */
8
+ import * as fsp from 'node:fs/promises';
9
+ import * as path from 'node:path';
10
+ import { execFileSync } from 'node:child_process';
11
+ /** Best-effort `git rev-parse HEAD` (never throws). Exported for tests. */
12
+ export function gitCommit(cwd = process.cwd()) {
13
+ try {
14
+ const out = execFileSync('git', ['rev-parse', 'HEAD'], {
15
+ cwd,
16
+ stdio: ['ignore', 'pipe', 'ignore'],
17
+ }).toString().trim();
18
+ return out || 'unknown';
19
+ }
20
+ catch {
21
+ return 'unknown';
22
+ }
23
+ }
24
+ /** Collect the environment half of a baseline record. Exported for tests. */
25
+ export function collectBaselineMetadata(opts = {}) {
26
+ return {
27
+ model: opts.model ?? process.env.KLYRO_MODEL ?? 'mock',
28
+ node: process.version,
29
+ platform: process.platform,
30
+ commit: gitCommit(opts.cwd),
31
+ timestamp: new Date().toISOString(),
32
+ };
33
+ }
34
+ /** Combine metadata + suite counts into a full baseline record. */
35
+ export function buildEvalBaseline(meta, suite) {
36
+ return {
37
+ ...meta,
38
+ suite: suite.suite,
39
+ total: suite.total,
40
+ passed: suite.passed,
41
+ failed: suite.failed,
42
+ passRate: suite.total === 0 ? 0 : suite.passed / suite.total,
43
+ durationMs: suite.durationMs,
44
+ };
45
+ }
46
+ /**
47
+ * Write `baseline.json` into `dir` (created if needed). Returns the path.
48
+ * Never throws for missing dirs — the caller wraps in try/catch anyway.
49
+ */
50
+ export async function writeEvalBaseline(dir, baseline) {
51
+ await fsp.mkdir(dir, { recursive: true });
52
+ const out = path.join(dir, 'baseline.json');
53
+ await fsp.writeFile(out, JSON.stringify(baseline, null, 2) + '\n');
54
+ return out;
55
+ }
@@ -77,10 +77,21 @@ export interface FileFixture {
77
77
  script?: StreamEvent[][];
78
78
  }
79
79
  export declare function loadFileFixture(dir: string): Promise<FileFixture>;
80
- export declare function runFileFixture(fixture: FileFixture, _opts?: {
80
+ export declare function sanitizedFixtureEnv(base?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
81
+ /**
82
+ * 5.4b — guard: fixture workdirs must stay inside `os.tmpdir()` (the
83
+ * harness relies on that isolation). Throws otherwise unless the caller
84
+ * explicitly passes `allowWorkdirOutsideTmp`. Exported for tests.
85
+ */
86
+ export declare function assertTmpWorkdir(dir: string, opts?: {
87
+ allowWorkdirOutsideTmp?: boolean;
88
+ }): void;
89
+ export interface FileFixtureOptions {
81
90
  runs?: number;
82
91
  parallel?: number;
83
- }): Promise<TaskResult>;
92
+ allowWorkdirOutsideTmp?: boolean;
93
+ }
94
+ export declare function runFileFixture(fixture: FileFixture, _opts?: FileFixtureOptions): Promise<TaskResult>;
84
95
  /**
85
96
  * Agent-driven fixture: seed a tmp repo, run the fixture's canned script
86
97
  * through the REAL runtime + tools, then assert outcomes with check.sh
@@ -90,6 +101,7 @@ export declare function runFileFixture(fixture: FileFixture, _opts?: {
90
101
  export declare function runAgentFixture(fixture: FileFixture, opts?: {
91
102
  judgeAdapter?: ProviderAdapter;
92
103
  judgeModel?: string;
104
+ allowWorkdirOutsideTmp?: boolean;
93
105
  }): Promise<TaskResult>;
94
106
  /** Hook the harness up to a session + audit log so task runs are durable. */ export declare function runHarnessWithPersistence(tasks: ScriptedTask[], opts: {
95
107
  storeDir: string;
@@ -177,10 +177,46 @@ export async function loadFileFixture(dir) {
177
177
  catch { /* no script → static fixture */ }
178
178
  return { dir, task: task.trim(), checkSh, meta, ...(repo ? { repo } : {}), ...(script ? { script } : {}) };
179
179
  }
180
+ /**
181
+ * 5.4b — sanitized env for `check.sh`: PATH + home/locale + temp vars
182
+ * (+ Windows shell vars so the shell can spawn). Everything else —
183
+ * secrets, `KLYRO_*` config, caller sentinels — is dropped. Exported for tests.
184
+ */
185
+ const FIXTURE_ENV_ALLOW = new Set([
186
+ 'PATH', 'PATHEXT', 'HOME', 'LANG', 'LC_ALL', 'TMPDIR', 'TEMP', 'TMP',
187
+ 'SYSTEMROOT', 'SYSTEMDRIVE', 'COMSPEC',
188
+ ]);
189
+ export function sanitizedFixtureEnv(base = process.env) {
190
+ const out = {};
191
+ for (const k of FIXTURE_ENV_ALLOW) {
192
+ const v = base[k];
193
+ if (v !== undefined)
194
+ out[k] = v;
195
+ }
196
+ return out;
197
+ }
198
+ /**
199
+ * 5.4b — guard: fixture workdirs must stay inside `os.tmpdir()` (the
200
+ * harness relies on that isolation). Throws otherwise unless the caller
201
+ * explicitly passes `allowWorkdirOutsideTmp`. Exported for tests.
202
+ */
203
+ export function assertTmpWorkdir(dir, opts = {}) {
204
+ if (opts.allowWorkdirOutsideTmp)
205
+ return;
206
+ const root = path.resolve(os.tmpdir());
207
+ const resolved = path.resolve(dir);
208
+ if (resolved !== root && !resolved.startsWith(root + path.sep)) {
209
+ throw new Error(`fixture workdir escapes os.tmpdir(): ${resolved} (pass allowWorkdirOutsideTmp to override)`);
210
+ }
211
+ }
180
212
  export async function runFileFixture(fixture, _opts = {}) {
181
213
  const start = Date.now();
182
214
  const tmp = path.join(os.tmpdir(), 'klyro-eval-file-' + Math.random().toString(36).slice(2));
215
+ assertTmpWorkdir(tmp, _opts);
183
216
  await fs.mkdir(tmp, { recursive: true });
217
+ // Restrictive creation (umask-style): the tmp workdir is owner-only.
218
+ // Best-effort — chmod is a no-op for ACLs on Windows but harmless.
219
+ await fs.chmod(tmp, 0o700).catch(() => undefined);
184
220
  // Copy repo if exists
185
221
  if (fixture.repo) {
186
222
  const src = path.isAbsolute(fixture.repo) ? fixture.repo : path.join(fixture.dir, fixture.repo);
@@ -192,7 +228,7 @@ export async function runFileFixture(fixture, _opts = {}) {
192
228
  // Run check.sh via shell
193
229
  const { spawn } = await import('node:child_process');
194
230
  const result = await new Promise((resolve) => {
195
- const child = spawn('bash', ['-c', fixture.checkSh], { cwd: tmp, shell: false });
231
+ const child = spawn('bash', ['-c', fixture.checkSh], { cwd: tmp, shell: false, env: sanitizedFixtureEnv() });
196
232
  const sink = cappedOutput(MAX_CHECK_BYTES);
197
233
  child.stdout?.on('data', (b) => sink.push(b));
198
234
  child.stderr?.on('data', (b) => sink.push(b));
@@ -226,7 +262,9 @@ export async function runAgentFixture(fixture, opts = {}) {
226
262
  return { id, status: 'fail', details: 'agent fixture needs script.json', durationMs: Date.now() - start };
227
263
  }
228
264
  const tmp = path.join(os.tmpdir(), 'klyro-eval-agent-' + Math.random().toString(36).slice(2));
265
+ assertTmpWorkdir(tmp, opts);
229
266
  await fs.mkdir(tmp, { recursive: true });
267
+ await fs.chmod(tmp, 0o700).catch(() => undefined);
230
268
  try {
231
269
  if (fixture.repo) {
232
270
  const src = path.isAbsolute(fixture.repo) ? fixture.repo : path.join(fixture.dir, fixture.repo);
@@ -250,7 +288,7 @@ export async function runAgentFixture(fixture, opts = {}) {
250
288
  // Structural pass — now assert real filesystem outcomes.
251
289
  const { spawn } = await import('node:child_process');
252
290
  const out = await new Promise((resolve) => {
253
- const child = spawn('bash', ['-c', fixture.checkSh], { cwd: tmp, shell: false });
291
+ const child = spawn('bash', ['-c', fixture.checkSh], { cwd: tmp, shell: false, env: sanitizedFixtureEnv() });
254
292
  const sink = cappedOutput(MAX_CHECK_BYTES);
255
293
  child.stdout?.on('data', (b) => sink.push(b));
256
294
  child.stderr?.on('data', (b) => sink.push(b));
package/dist/index.js CHANGED
@@ -350,6 +350,7 @@ async function main() {
350
350
  .option('--agent <name>', 'Run under an orchestrator context enabling spawn_agent/task_list/task_get (explorer|implementer|tester|reviewer)')
351
351
  .option('--max-depth <n>', 'Max spawn depth for child agents (default 1)', (v) => parsePositiveInt('--max-depth', v))
352
352
  .option('--bare', 'Deterministic runs: skip MCP, hooks, memory/KLYRO.md/context, persistence')
353
+ .option('--auto-answer <text>', 'Headless answer for ask_user prompts (sets KLYRO_AUTO_ANSWER)')
353
354
  .action(async (prompt, opts) => {
354
355
  const model = opts.model ?? process.env.KLYRO_MODEL;
355
356
  if (!model) {
@@ -410,6 +411,7 @@ async function main() {
410
411
  agent: opts.agent,
411
412
  maxDepth: opts.maxDepth,
412
413
  bare: !!opts.bare,
414
+ autoAnswer: opts.autoAnswer,
413
415
  });
414
416
  process.exit(code);
415
417
  }
@@ -443,17 +445,18 @@ async function main() {
443
445
  .option('--model <id>', 'Model for eval')
444
446
  .option('--judge-model <id>', 'Live model id for grading judge.rubric (needs endpoint + key)')
445
447
  .option('--cwd <path>', 'Shared scenario workdir (default: isolated tmp per scenario)')
448
+ .option('--auto-answer <text>', 'Headless answer for ask_user prompts (sets KLYRO_AUTO_ANSWER)')
446
449
  .action(async (input, opts) => {
447
450
  const output = (opts.output ?? 'human');
448
451
  if (opts.suite) {
449
- const code = await runEval({ inputPath: input ?? '-', output, suite: opts.suite, filter: opts.filter, runs: opts.runs, parallel: opts.parallel, model: opts.model, judgeModel: opts.judgeModel });
452
+ const code = await runEval({ inputPath: input ?? '-', output, suite: opts.suite, filter: opts.filter, runs: opts.runs, parallel: opts.parallel, model: opts.model, judgeModel: opts.judgeModel, cwd: opts.cwd, autoAnswer: opts.autoAnswer });
450
453
  process.exit(code);
451
454
  }
452
455
  if (!input) {
453
456
  process.stderr.write('klyro eval: missing input (provide <input> or --suite)\n');
454
457
  process.exit(2);
455
458
  }
456
- const code = await runEval({ inputPath: input, output, suite: opts.suite, filter: opts.filter, runs: opts.runs, parallel: opts.parallel, model: opts.model, judgeModel: opts.judgeModel, cwd: opts.cwd });
459
+ const code = await runEval({ inputPath: input, output, suite: opts.suite, filter: opts.filter, runs: opts.runs, parallel: opts.parallel, model: opts.model, judgeModel: opts.judgeModel, cwd: opts.cwd, autoAnswer: opts.autoAnswer });
457
460
  process.exit(code);
458
461
  });
459
462
  program
@@ -0,0 +1,5 @@
1
+ export declare const gitBlameTool: import("../types.js").Tool<{
2
+ path: string;
3
+ }, {
4
+ readonly blame: string;
5
+ }>;
@@ -0,0 +1,22 @@
1
+ import { z } from 'zod';
2
+ import { defineTool } from '../types.js';
3
+ import { safe } from '../normalize.js';
4
+ import { runGit } from './run-git.js';
5
+ const InputSchema = z.object({
6
+ path: z.string().min(1).describe('File path to blame'),
7
+ });
8
+ export const gitBlameTool = defineTool({
9
+ name: 'git_blame',
10
+ description: 'Show git blame for a file, line by line. Read-only.',
11
+ inputSchema: InputSchema,
12
+ permission: 'read',
13
+ isConcurrencySafe: true,
14
+ execute: async (input, ctx) => {
15
+ return safe(async () => {
16
+ const res = await runGit(['blame', '--', input.path], ctx.cwd);
17
+ if (res.code !== 0)
18
+ throw new Error(res.err || `git blame failed`);
19
+ return { blame: res.out };
20
+ });
21
+ },
22
+ });
@@ -2,31 +2,14 @@
2
2
  * git_diff — show unstaged (`git diff`) or staged (`git diff --cached`)
3
3
  * changes plus a compact per-file stat. Read-only.
4
4
  */
5
- import { spawn } from 'node:child_process';
6
5
  import { z } from 'zod';
7
6
  import { defineTool } from '../types.js';
8
7
  import { safe, TOOL_ERROR_CODES } from '../normalize.js';
8
+ import { runGit } from './run-git.js';
9
9
  const InputSchema = z.object({
10
10
  staged: z.boolean().optional().describe('Show staged changes instead of unstaged (default false)'),
11
11
  path: z.string().optional().describe('Limit to a specific path'),
12
12
  });
13
- function run(args, cwd, timeoutMs = 15_000) {
14
- return new Promise((resolve) => {
15
- const child = spawn('git', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, shell: false });
16
- const out = [];
17
- const err = [];
18
- const timer = setTimeout(() => { try {
19
- child.kill('SIGKILL');
20
- }
21
- catch { /* noop */ } }, timeoutMs);
22
- child.stdout.on('data', (b) => out.push(b));
23
- child.stderr.on('data', (b) => err.push(b));
24
- child.on('close', (code) => {
25
- clearTimeout(timer);
26
- resolve({ code: code ?? 1, out: Buffer.concat(out).toString('utf-8'), err: Buffer.concat(err).toString('utf-8') });
27
- });
28
- });
29
- }
30
13
  export const gitDiffTool = defineTool({
31
14
  name: 'git_diff',
32
15
  description: 'Show the unified diff of working-tree changes (unstaged by default) or staged changes. Read-only. Returns the diff text, a per-file stat, and the list of changed file paths.',
@@ -37,7 +20,7 @@ export const gitDiffTool = defineTool({
37
20
  const args = [...base, '--no-color'];
38
21
  if (input.path)
39
22
  args.push('--', input.path);
40
- const diff = await run(args, ctx.cwd);
23
+ const diff = await runGit(args, ctx.cwd);
41
24
  if (diff.code !== 0) {
42
25
  return {
43
26
  ok: false,
@@ -47,7 +30,7 @@ export const gitDiffTool = defineTool({
47
30
  const statArgs = [...base, '--stat', '--no-color'];
48
31
  if (input.path)
49
32
  statArgs.push('--', input.path);
50
- const stat = await run(statArgs, ctx.cwd);
33
+ const stat = await runGit(statArgs, ctx.cwd);
51
34
  // Parse changed file paths from `diff --git a/... b/...` lines.
52
35
  const patchedFiles = [];
53
36
  const newline = /\r?\n/;
@@ -1,7 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { defineTool } from '../types.js';
3
3
  import { safe } from '../normalize.js';
4
- import { spawn } from 'node:child_process';
4
+ import { runGit } from './run-git.js';
5
5
  const InputSchema = z.object({
6
6
  limit: z.number().int().min(1).max(100).optional().describe('Number of commits'),
7
7
  path: z.string().optional().describe('Filter by path'),
@@ -17,24 +17,10 @@ export const gitLogTool = defineTool({
17
17
  const args = ['log', '--oneline', `-${input.limit ?? 20}`];
18
18
  if (input.path)
19
19
  args.push('--', input.path);
20
- const out = await runGit(args, ctx.cwd);
21
- return { log: out };
20
+ const res = await runGit(args, ctx.cwd);
21
+ if (res.code !== 0)
22
+ throw new Error(res.err || `git log failed`);
23
+ return { log: res.out };
22
24
  });
23
25
  },
24
26
  });
25
- function runGit(args, cwd) {
26
- return new Promise((resolve, reject) => {
27
- const child = spawn('git', args, { cwd, shell: false, windowsHide: true });
28
- let out = '';
29
- let err = '';
30
- child.stdout.on('data', (b) => { out += b.toString(); });
31
- child.stderr.on('data', (b) => { err += b.toString(); });
32
- child.on('error', reject);
33
- child.on('close', (code) => {
34
- if (code === 0)
35
- resolve(out);
36
- else
37
- reject(new Error(err || `git ${args[0]} failed`));
38
- });
39
- });
40
- }
@@ -0,0 +1,5 @@
1
+ export declare const gitShowTool: import("../types.js").Tool<{
2
+ target: string;
3
+ }, {
4
+ readonly content: string;
5
+ }>;
@@ -0,0 +1,22 @@
1
+ import { z } from 'zod';
2
+ import { defineTool } from '../types.js';
3
+ import { safe } from '../normalize.js';
4
+ import { runGit } from './run-git.js';
5
+ const InputSchema = z.object({
6
+ target: z.string().min(1).describe('Commit hash, branch name, or tag to show'),
7
+ });
8
+ export const gitShowTool = defineTool({
9
+ name: 'git_show',
10
+ description: 'Show the patch/content of a specific commit. Read-only.',
11
+ inputSchema: InputSchema,
12
+ permission: 'read',
13
+ isConcurrencySafe: true,
14
+ execute: async (input, ctx) => {
15
+ return safe(async () => {
16
+ const res = await runGit(['show', '--no-color', input.target], ctx.cwd);
17
+ if (res.code !== 0)
18
+ throw new Error(res.err || `git show failed`);
19
+ return { content: res.out };
20
+ });
21
+ },
22
+ });
@@ -4,37 +4,20 @@
4
4
  * - `git log -n 10 --oneline` for recent history
5
5
  * The agent needs working-tree state AND recent history to plan edits.
6
6
  */
7
- import { spawn } from 'node:child_process';
8
7
  import { z } from 'zod';
9
8
  import { defineTool } from '../types.js';
10
9
  import { safe } from '../normalize.js';
10
+ import { runGit } from './run-git.js';
11
11
  const InputSchema = z.object({
12
12
  showLog: z.boolean().optional().describe('Also include the last 10 commit messages (default true)'),
13
13
  });
14
- function run(args, cwd, timeoutMs = 15_000) {
15
- return new Promise((resolve) => {
16
- const child = spawn('git', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, shell: false });
17
- const out = [];
18
- const err = [];
19
- const timer = setTimeout(() => { try {
20
- child.kill('SIGKILL');
21
- }
22
- catch { /* noop */ } }, timeoutMs);
23
- child.stdout.on('data', (b) => out.push(b));
24
- child.stderr.on('data', (b) => err.push(b));
25
- child.on('close', (code) => {
26
- clearTimeout(timer);
27
- resolve({ code: code ?? 1, out: Buffer.concat(out).toString('utf-8'), err: Buffer.concat(err).toString('utf-8') });
28
- });
29
- });
30
- }
31
14
  export const gitStatusTool = defineTool({
32
15
  name: 'git_status',
33
16
  description: 'Show working-tree status (porcelain) and, optionally, the last 10 commit messages and current branch. Read-only.',
34
17
  inputSchema: InputSchema,
35
18
  execute: async (input, ctx) => {
36
19
  return safe(async () => {
37
- const status = await run(['status', '--porcelain', '--untracked-files=all'], ctx.cwd);
20
+ const status = await runGit(['status', '--porcelain', '--untracked-files=all'], ctx.cwd);
38
21
  if (status.code !== 0) {
39
22
  // Not a git repo or git unavailable
40
23
  return {
@@ -43,11 +26,11 @@ export const gitStatusTool = defineTool({
43
26
  };
44
27
  }
45
28
  const result = { porcelain: status.out || '(clean)' };
46
- const branch = await run(['branch', '--show-current'], ctx.cwd);
29
+ const branch = await runGit(['branch', '--show-current'], ctx.cwd);
47
30
  if (branch.code === 0 && branch.out.trim())
48
31
  result.branch = branch.out.trim();
49
32
  if (input.showLog !== false) {
50
- const log = await run(['log', '-n', '10', '--oneline', '--no-show-signature'], ctx.cwd);
33
+ const log = await runGit(['log', '-n', '10', '--oneline', '--no-show-signature'], ctx.cwd);
51
34
  if (log.code === 0 && log.out.trim())
52
35
  result.recentCommits = log.out.trim();
53
36
  }
@@ -0,0 +1,5 @@
1
+ export declare function runGit(args: string[], cwd: string, timeoutMs?: number): Promise<{
2
+ code: number;
3
+ out: string;
4
+ err: string;
5
+ }>;
@@ -0,0 +1,18 @@
1
+ import { spawn } from 'node:child_process';
2
+ export function runGit(args, cwd, timeoutMs = 15_000) {
3
+ return new Promise((resolve) => {
4
+ const child = spawn('git', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, shell: false });
5
+ const out = [];
6
+ const err = [];
7
+ const timer = setTimeout(() => { try {
8
+ child.kill('SIGKILL');
9
+ }
10
+ catch { /* noop */ } }, timeoutMs);
11
+ child.stdout.on('data', (b) => out.push(b));
12
+ child.stderr.on('data', (b) => err.push(b));
13
+ child.on('close', (code) => {
14
+ clearTimeout(timer);
15
+ resolve({ code: code ?? 1, out: Buffer.concat(out).toString('utf-8'), err: Buffer.concat(err).toString('utf-8') });
16
+ });
17
+ });
18
+ }
@@ -49,3 +49,53 @@ export declare const lspGotoDefinitionTool: import("../types.js").Tool<{
49
49
  readonly candidates: number;
50
50
  readonly note?: undefined;
51
51
  }>;
52
+ export declare const lspReferencesTool: import("../types.js").Tool<{
53
+ path: string;
54
+ line: number;
55
+ character?: number | undefined;
56
+ }, {
57
+ readonly enabled: false;
58
+ readonly references: readonly [];
59
+ readonly note: "LSP off via KLYRO_LSP=0";
60
+ } | {
61
+ readonly enabled: true;
62
+ readonly references: readonly [];
63
+ readonly note: `unreadable file: ${string}`;
64
+ } | {
65
+ readonly enabled: true;
66
+ readonly references: readonly [];
67
+ readonly note: "no identifier under cursor";
68
+ } | {
69
+ readonly enabled: true;
70
+ readonly references: {
71
+ file: string;
72
+ line: number;
73
+ kind: string;
74
+ name: string;
75
+ }[];
76
+ readonly note?: undefined;
77
+ }>;
78
+ export declare const lspOutlineTool: import("../types.js").Tool<{
79
+ path: string;
80
+ }, {
81
+ readonly enabled: false;
82
+ readonly outline: readonly [];
83
+ readonly note: "LSP off via KLYRO_LSP=0";
84
+ } | {
85
+ readonly enabled: true;
86
+ readonly outline: readonly [];
87
+ readonly note: `unreadable file: ${string}`;
88
+ } | {
89
+ readonly enabled: true;
90
+ readonly outline: {
91
+ name: string;
92
+ kind: string;
93
+ line: number;
94
+ children: Array<{
95
+ name: string;
96
+ kind: string;
97
+ line: number;
98
+ }>;
99
+ }[];
100
+ readonly note?: undefined;
101
+ }>;
@@ -5,8 +5,10 @@
5
5
  * project has one and filters the output to the requested path (or returns
6
6
  * a capped full-project list). Other languages report "unsupported".
7
7
  * `lsp_goto_definition` resolves the identifier under (line, character) to
8
- * its definition via the regex repo-map symbol index. Set KLYRO_LSP=0 to
9
- * force both tools off.
8
+ * its definition via the regex repo-map symbol index.
9
+ * `lsp_references` finds all references to the identifier under (line, character).
10
+ * `lsp_outline` returns a hierarchical symbol outline for a file.
11
+ * Set KLYRO_LSP=0 to force all tools off.
10
12
  */
11
13
  import { spawnSync } from 'node:child_process';
12
14
  import * as fs from 'node:fs/promises';
@@ -15,7 +17,7 @@ import { z } from 'zod';
15
17
  import { defineTool } from '../types.js';
16
18
  import { safe } from '../normalize.js';
17
19
  import { resolveWithinCwd } from '../../policy/path-guard.js';
18
- import { buildRepoMap } from '../../context/repo-map.js';
20
+ import { buildRepoMap, extractSymbols } from '../../context/repo-map.js';
19
21
  function isOff() {
20
22
  return process.env.KLYRO_LSP === '0';
21
23
  }
@@ -104,3 +106,107 @@ export const lspGotoDefinitionTool = defineTool({
104
106
  return { enabled: true, location: best, candidates: hits.length };
105
107
  }),
106
108
  });
109
+ export const lspReferencesTool = defineTool({
110
+ name: 'lsp_references',
111
+ description: 'Find all references to the identifier under (line, character) via the repo symbol index',
112
+ inputSchema: z.object({ path: z.string().min(1), line: z.number().int().min(1), character: z.number().int().min(0).optional() }),
113
+ permission: 'read',
114
+ isConcurrencySafe: true,
115
+ execute: async (input, ctx) => safe(async () => {
116
+ if (isOff())
117
+ return { enabled: false, references: [], note: 'LSP off via KLYRO_LSP=0' };
118
+ const { resolved } = resolveWithinCwd(ctx.cwd, input.path, ctx.agentAllowedPaths);
119
+ let content;
120
+ try {
121
+ content = await fs.readFile(resolved, 'utf-8');
122
+ }
123
+ catch {
124
+ return { enabled: true, references: [], note: `unreadable file: ${input.path}` };
125
+ }
126
+ const lines = content.split('\n');
127
+ const lineText = lines[input.line - 1] ?? '';
128
+ const col = Math.min(input.character ?? 0, lineText.length);
129
+ const wordAt = (() => {
130
+ const isWord = (c) => /[A-Za-z0-9_$]/.test(c);
131
+ let s = col;
132
+ let e = col;
133
+ while (s > 0 && isWord(lineText[s - 1]))
134
+ s--;
135
+ while (e < lineText.length && isWord(lineText[e]))
136
+ e++;
137
+ const w = lineText.slice(s, e);
138
+ return w || null;
139
+ })();
140
+ if (!wordAt)
141
+ return { enabled: true, references: [], note: 'no identifier under cursor' };
142
+ const files = await buildRepoMap({ cwd: ctx.cwd, maxFiles: 300 });
143
+ const refs = [];
144
+ const seen = new Set();
145
+ for (const f of files) {
146
+ for (const s of f.symbols) {
147
+ if (s.name === wordAt) {
148
+ const key = `${f.path}:${s.line}:${s.name}`;
149
+ if (!seen.has(key)) {
150
+ seen.add(key);
151
+ refs.push({ file: f.path, line: s.line, kind: s.kind, name: s.name });
152
+ }
153
+ }
154
+ }
155
+ }
156
+ return { enabled: true, references: refs.slice(0, 100) };
157
+ }),
158
+ });
159
+ export const lspOutlineTool = defineTool({
160
+ name: 'lsp_outline',
161
+ description: 'Hierarchical symbol outline for a file via the repo symbol index',
162
+ inputSchema: z.object({ path: z.string().min(1) }),
163
+ permission: 'read',
164
+ isConcurrencySafe: true,
165
+ execute: async (input, ctx) => safe(async () => {
166
+ if (isOff())
167
+ return { enabled: false, outline: [], note: 'LSP off via KLYRO_LSP=0' };
168
+ const { resolved } = resolveWithinCwd(ctx.cwd, input.path, ctx.agentAllowedPaths);
169
+ // Symbol-extract this file directly rather than via buildRepoMap: the repo
170
+ // map is a whole-tree walk capped by maxFiles, so a single-file query can
171
+ // never be served from it (it would return whichever file the walk reached
172
+ // first, and the caller's path would usually be absent).
173
+ let content;
174
+ try {
175
+ content = await fs.readFile(resolved, 'utf-8');
176
+ }
177
+ catch {
178
+ return { enabled: true, outline: [], note: `unreadable file: ${input.path}` };
179
+ }
180
+ const symbols = extractSymbols(content, path.extname(resolved).toLowerCase());
181
+ // Build simple hierarchy: classes/interfaces as parents, methods/fields as children
182
+ const outline = [];
183
+ for (const sym of symbols) {
184
+ if (['class', 'interface', 'type'].includes(sym.kind)) {
185
+ outline.push({ name: sym.name, kind: sym.kind, line: sym.line, children: [] });
186
+ }
187
+ }
188
+ for (const sym of symbols) {
189
+ if (['method', 'function', 'property', 'field', 'const'].includes(sym.kind)) {
190
+ // Assign to the nearest preceding class/interface
191
+ let parentIdx = -1;
192
+ for (let i = outline.length - 1; i >= 0; i--) {
193
+ const parent = outline[i];
194
+ if (parent && parent.line < sym.line) {
195
+ parentIdx = i;
196
+ break;
197
+ }
198
+ }
199
+ if (parentIdx >= 0) {
200
+ const parentEntry = outline[parentIdx];
201
+ if (parentEntry) {
202
+ parentEntry.children.push({ name: sym.name, kind: sym.kind, line: sym.line });
203
+ }
204
+ }
205
+ else {
206
+ outline.push({ name: sym.name, kind: sym.kind, line: sym.line, children: [] });
207
+ }
208
+ }
209
+ }
210
+ return { enabled: true, outline };
211
+ }),
212
+ });
@@ -16,13 +16,15 @@ import { shellExecTool } from './shell/shell-exec.js';
16
16
  import { gitStatusTool } from './git/git-status.js';
17
17
  import { gitDiffTool } from './git/git-diff.js';
18
18
  import { gitLogTool } from './git/git-log.js';
19
+ import { gitBlameTool } from './git/git-blame.js';
20
+ import { gitShowTool } from './git/git-show.js';
19
21
  import { runVerifyTool } from './verify/run-verify.js';
20
22
  import { todoWriteTool } from './plan/todo-write.js';
21
23
  import { askUserTool } from './plan/ask-user.js';
22
24
  import { repoMapTool } from './repo-map.js';
23
25
  import { importsOfTool, importersOfTool } from './search/imports.js';
24
26
  import { findSymbolTool } from './symbols/find-symbol.js';
25
- import { lspDiagnosticsTool, lspGotoDefinitionTool } from './lsp/diagnostics.js';
27
+ import { lspDiagnosticsTool, lspGotoDefinitionTool, lspReferencesTool, lspOutlineTool } from './lsp/diagnostics.js';
26
28
  import { expandResultTool } from './expand-result.js';
27
29
  import { memoryWriteTool } from './memory-write.js';
28
30
  import { webFetchTool } from './web/web-fetch.js';
@@ -104,6 +106,8 @@ export const builtinRegistry = () => {
104
106
  r.register(gitStatusTool);
105
107
  r.register(gitDiffTool);
106
108
  r.register(gitLogTool);
109
+ r.register(gitBlameTool);
110
+ r.register(gitShowTool);
107
111
  r.register(runVerifyTool);
108
112
  r.register(todoWriteTool);
109
113
  r.register(askUserTool);
@@ -113,6 +117,8 @@ export const builtinRegistry = () => {
113
117
  r.register(findSymbolTool);
114
118
  r.register(lspDiagnosticsTool);
115
119
  r.register(lspGotoDefinitionTool);
120
+ r.register(lspReferencesTool);
121
+ r.register(lspOutlineTool);
116
122
  r.register(expandResultTool);
117
123
  r.register(memoryWriteTool);
118
124
  r.register(webFetchTool);