@axplusb/kepler 1.0.5 → 1.0.10

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.
@@ -5,15 +5,16 @@
5
5
  * This bridge translates those into OCC tool calls and wraps the results.
6
6
  *
7
7
  * Safety guardrails integrated — prevents destructive operations on source code.
8
- * 20 tools mapped across file, search, shell, validation, and Git operations.
8
+ * Tools are mapped across file, search, shell, validation, and Git operations.
9
9
  */
10
10
 
11
11
  import { createToolRegistry } from '../tools/registry.mjs';
12
12
  import { filterOutput } from './output-filter.mjs';
13
13
  import { validatePath, validateDelete, validateShellCommand, validateWrite } from './safety.mjs';
14
14
  import { classifyCommand, isExitCodeError } from '../permissions/command-classifier.mjs';
15
- import { ContextRetriever } from '../context/retriever.mjs';
16
15
  import { analyzeCode } from '../context/ast-parser.mjs';
16
+ import { ProjectRegistry } from '../tools/project-overview.mjs';
17
+ import { SkillsLoader } from '../skills/loader.mjs';
17
18
  import * as fs from 'node:fs';
18
19
  import * as path from 'node:path';
19
20
  import { execSync } from 'node:child_process';
@@ -21,29 +22,36 @@ import { execSync } from 'node:child_process';
21
22
  /**
22
23
  * Create a tool executor that bridges Tarang tool names to OCC tools.
23
24
  * @param {Object} [options]
24
- * @param {ContextRetriever} [options.retriever] - BM25 retriever for search_code
25
+ * @param {ProjectRegistry} [options.projectRegistry] - session-owned project registry
25
26
  * @returns {{ execute(name, args): Promise<Object>, listTools(): string[] }}
26
27
  */
27
- export function createToolExecutor({ retriever } = {}) {
28
+ export function createToolExecutor({
29
+ projectRegistry = new ProjectRegistry(),
30
+ skillsLoader = new SkillsLoader().load(process.cwd()),
31
+ } = {}) {
28
32
  const occRegistry = createToolRegistry();
33
+ const skillTool = occRegistry.get('Skill');
34
+ if (skillTool) skillTool._skillsLoader = skillsLoader;
29
35
  let _searchCodeUsed = false; // tracks if search_code was called (for read_file nudge)
30
36
 
31
- // Capture CWD at creation time OCC Bash tool mutates process.cwd() after shell commands
32
- const PROJECT_CWD = process.cwd();
37
+ function resolvePath(p, args = {}, options = {}) {
38
+ return projectRegistry.resolvePath(p, args.project_id, options);
39
+ }
33
40
 
34
- /**
35
- * Resolve a path relative to project CWD, with traversal protection.
36
- */
37
- function resolvePath(p) {
38
- if (!p) return PROJECT_CWD;
39
- const resolved = path.resolve(PROJECT_CWD, p);
40
- // Prevent path traversal outside CWD's parent
41
- const cwd = process.cwd();
42
- const cwdParent = path.dirname(cwd);
43
- if (!resolved.startsWith(cwdParent)) {
44
- throw new Error(`Path traversal blocked: ${p}`);
45
- }
46
- return resolved;
41
+ function projectRootFor(filePath) {
42
+ const project = projectRegistry.projectForPath(filePath);
43
+ if (!project) throw new Error(`No registered project contains path: ${filePath}`);
44
+ return project.resource.root;
45
+ }
46
+
47
+ function commandCwd(args = {}) {
48
+ return resolvePath(args.cwd || null, args);
49
+ }
50
+
51
+ function updateProjectIndex(filePath) {
52
+ try {
53
+ projectRegistry.projectForPath(filePath)?.retriever.updateFile(filePath);
54
+ } catch { /* best effort */ }
47
55
  }
48
56
 
49
57
  /**
@@ -105,6 +113,25 @@ export function createToolExecutor({ retriever } = {}) {
105
113
  }
106
114
  }
107
115
 
116
+ // ── Post-edit verification hint ──────────────────────────────
117
+ // Appended to edit_file/write_file results so the model knows
118
+ // exactly how to verify. Uses detected project commands.
119
+
120
+ function verificationHint(filePath) {
121
+ const project = projectRegistry.projectForPath(filePath);
122
+ const commands = project?.resource?.commands || {};
123
+ const parts = [];
124
+ if (commands.test) {
125
+ parts.push(`Run tests: ${commands.test}`);
126
+ }
127
+ if (parts.length === 0) {
128
+ const ext = path.extname(filePath);
129
+ if (ext === '.py') parts.push('Run tests: python -m pytest');
130
+ else if (['.js', '.ts', '.tsx', '.mjs'].includes(ext)) parts.push('Run tests: npm test');
131
+ }
132
+ return parts.length ? `\n--- Verify ---\n${parts.join('\n')}` : '';
133
+ }
134
+
108
135
  // ── Tool mapping table ──────────────────────────────────────
109
136
 
110
137
  const toolMap = {
@@ -115,7 +142,7 @@ export function createToolExecutor({ retriever } = {}) {
115
142
  if (!shellCheck.safe) {
116
143
  return {
117
144
  success: false,
118
- output: `BLOCKED: ${shellCheck.reason}. Your current working directory is ${process.cwd()} — search within it, not from filesystem root.`,
145
+ output: `BLOCKED: ${shellCheck.reason}. Work only inside a registered project root.`,
119
146
  _tool: 'shell', _blocked: true,
120
147
  };
121
148
  }
@@ -136,13 +163,14 @@ export function createToolExecutor({ retriever } = {}) {
136
163
  args._riskReason = classification.reason || shellCheck.reason;
137
164
  }
138
165
  args._classification = classification.classification; // 'safe' or 'contained'
166
+ const cwd = commandCwd(args);
139
167
 
140
168
  // Pre-check: if command is rm/unlink, verify targets exist first
141
169
  const rmMatch = (args.command || '').match(/^rm\s+(?:-\w+\s+)*(.+)$/);
142
170
  if (rmMatch) {
143
171
  const targets = rmMatch[1].split(/\s+/).filter(t => !t.startsWith('-'));
144
172
  const missing = targets.filter(t => {
145
- try { return !fs.existsSync(path.resolve(process.cwd(), t)); } catch { return true; }
173
+ try { return !fs.existsSync(path.resolve(cwd, t)); } catch { return true; }
146
174
  });
147
175
  if (missing.length > 0 && missing.length === targets.length) {
148
176
  return {
@@ -159,6 +187,7 @@ export function createToolExecutor({ retriever } = {}) {
159
187
  command: args.command,
160
188
  timeout: args.timeout,
161
189
  description: args.description || `Run: ${(args.command || '').slice(0, 50)}`,
190
+ cwd,
162
191
  });
163
192
  const rawOutput = typeof result === 'string' ? result : String(result);
164
193
  const exitMatch = rawOutput.match(/Exit code: (\d+)/);
@@ -182,7 +211,7 @@ export function createToolExecutor({ retriever } = {}) {
182
211
 
183
212
  // 2. read_file → Read (with smart truncation for large files)
184
213
  read_file: async (args) => {
185
- const filePath = resolvePath(args.file_path || args.path);
214
+ const filePath = resolvePath(args.file_path || args.path, args);
186
215
  const hasLineRange = args.start_line || args.end_line || args.offset || args.limit;
187
216
 
188
217
  // Nudge: if reading shallow overview files, remind agent to search deeper
@@ -242,10 +271,10 @@ export function createToolExecutor({ retriever } = {}) {
242
271
  write_file: async (args) => {
243
272
  const rawPath = args.file_path || args.path;
244
273
  if (!rawPath || rawPath === 'file' || rawPath.length < 3) {
245
- return { success: false, output: `Error: Invalid file path "${rawPath || ''}". Use an ABSOLUTE path like "${process.cwd()}/src/main.py"`, _tool: 'write_file' };
274
+ return { success: false, output: `Error: Invalid file path "${rawPath || ''}". Register the project, then use an absolute path.`, _tool: 'write_file' };
246
275
  }
247
- const filePath = resolvePath(rawPath);
248
- const writeCheck = validateWrite(filePath, args.content);
276
+ const filePath = resolvePath(rawPath, args, { allowMissing: true });
277
+ const writeCheck = validateWrite(filePath, args.content, projectRootFor(filePath));
249
278
  if (!writeCheck.safe) {
250
279
  return { success: false, output: `🛡️ BLOCKED: ${writeCheck.reason}`, _tool: 'write_file', _blocked: true };
251
280
  }
@@ -260,14 +289,19 @@ export function createToolExecutor({ retriever } = {}) {
260
289
  content: args.content,
261
290
  });
262
291
  const wrapped = wrapResult(result, 'write_file');
292
+ updateProjectIndex(filePath);
263
293
 
264
294
  // Auto-lint the written file
265
295
  const lintOutput = autoLint(filePath);
266
296
  if (lintOutput) {
267
- wrapped.output += `\n\n--- Lint result ---\n${lintOutput}`;
297
+ wrapped.output += `\n\n--- Lint ---\n${lintOutput}`;
268
298
  wrapped.lint = lintOutput;
269
299
  }
270
300
 
301
+ // Nudge: tell the model how to verify
302
+ const hint = verificationHint(filePath);
303
+ if (hint) wrapped.output += hint;
304
+
271
305
  return wrapped;
272
306
  },
273
307
 
@@ -287,10 +321,10 @@ export function createToolExecutor({ retriever } = {}) {
287
321
  errors.push('Missing path in file entry');
288
322
  continue;
289
323
  }
290
- const filePath = resolvePath(rawPath);
324
+ const filePath = resolvePath(rawPath, file, { allowMissing: true });
291
325
  const content = file.content || '';
292
326
 
293
- const writeCheck = validateWrite(filePath, content);
327
+ const writeCheck = validateWrite(filePath, content, projectRootFor(filePath));
294
328
  if (!writeCheck.safe) {
295
329
  errors.push(`${rawPath}: BLOCKED — ${writeCheck.reason}`);
296
330
  continue;
@@ -309,6 +343,7 @@ export function createToolExecutor({ retriever } = {}) {
309
343
  } catch { /* file may not exist yet */ }
310
344
 
311
345
  await occRegistry.call('Write', { file_path: filePath, content });
346
+ updateProjectIndex(filePath);
312
347
  results.push(rawPath);
313
348
  } catch (err) {
314
349
  errors.push(`${rawPath}: ${err.message}`);
@@ -335,7 +370,11 @@ export function createToolExecutor({ retriever } = {}) {
335
370
  // 4. edit_file → Edit + auto-lint + auto-fallback to sed
336
371
  edit_file: async (args) => {
337
372
  const rawPath = args.file_path || args.path;
338
- const filePath = resolvePath(rawPath);
373
+ const filePath = resolvePath(rawPath, args);
374
+ const writeCheck = validateWrite(filePath, args.replace, projectRootFor(filePath));
375
+ if (!writeCheck.safe) {
376
+ return { success: false, output: `BLOCKED: ${writeCheck.reason}`, _tool: 'edit_file', _blocked: true };
377
+ }
339
378
  // OCC Edit requires Read first
340
379
  try {
341
380
  await occRegistry.call('Read', { file_path: filePath, limit: 1 });
@@ -366,7 +405,11 @@ content = content.replace(old, new, 1)
366
405
  with open('${filePath}', 'w') as f: f.write(content)
367
406
  print('OK: replaced')
368
407
  "`;
369
- const fallbackResult = execSync(pyCmd, { encoding: 'utf-8', timeout: 5000, cwd: PROJECT_CWD });
408
+ const fallbackResult = execSync(pyCmd, {
409
+ encoding: 'utf-8',
410
+ timeout: 5000,
411
+ cwd: projectRootFor(filePath),
412
+ });
370
413
  result = `Edited ${filePath} (via fallback): ${fallbackResult.trim()}`;
371
414
  } catch (sedErr) {
372
415
  return { success: false, output: `edit_file failed: ${editErr?.message || 'unknown'}. Fallback also failed: ${sedErr?.message || 'unknown'}. Try shell(sed) manually.`, _tool: 'edit_file' };
@@ -374,14 +417,19 @@ print('OK: replaced')
374
417
  }
375
418
 
376
419
  const wrapped = wrapResult(result, 'edit_file');
420
+ updateProjectIndex(filePath);
377
421
 
378
422
  // Auto-lint the edited file
379
423
  const lintOutput = autoLint(filePath);
380
424
  if (lintOutput) {
381
- wrapped.output += `\n\n--- Lint result ---\n${lintOutput}`;
425
+ wrapped.output += `\n\n--- Lint ---\n${lintOutput}`;
382
426
  wrapped.lint = lintOutput;
383
427
  }
384
428
 
429
+ // Nudge: tell the model how to verify
430
+ const hint = verificationHint(filePath);
431
+ if (hint) wrapped.output += hint;
432
+
385
433
  return wrapped;
386
434
  },
387
435
 
@@ -389,7 +437,7 @@ print('OK: replaced')
389
437
  list_files: async (args) => {
390
438
  const result = await occRegistry.call('Glob', {
391
439
  pattern: args.pattern || '**/*',
392
- path: args.path ? resolvePath(args.path) : undefined,
440
+ path: resolvePath(args.path || null, args),
393
441
  });
394
442
  const output = typeof result === 'string' ? result : String(result);
395
443
  const files = output.split('\n').filter(Boolean);
@@ -407,7 +455,24 @@ print('OK: replaced')
407
455
  const query = args.query || args.pattern;
408
456
  if (!query) return { success: false, output: 'query required', _tool: 'search_code' };
409
457
 
410
- const searchPath = args.path ? resolvePath(args.path) : PROJECT_CWD;
458
+ let project;
459
+ if (args.project_id) {
460
+ project = projectRegistry.get(args.project_id);
461
+ if (!project) {
462
+ return { success: false, output: `Unknown project_id: ${args.project_id}`, _tool: 'search_code' };
463
+ }
464
+ } else if (args.path) {
465
+ project = projectRegistry.projectForPath(resolvePath(args.path, args));
466
+ } else if (projectRegistry.resources().length === 1) {
467
+ project = projectRegistry.get(projectRegistry.resources()[0].project_id);
468
+ } else {
469
+ return {
470
+ success: false,
471
+ output: 'search_code requires project_id when multiple or no projects are registered',
472
+ _tool: 'search_code',
473
+ };
474
+ }
475
+ const searchPath = args.path ? resolvePath(args.path, args) : project.resource.root;
411
476
  const parts = [];
412
477
 
413
478
  // Layer 1: ripgrep — exact text matches with context
@@ -420,9 +485,9 @@ print('OK: replaced')
420
485
  } catch { /* rg not found or no results */ }
421
486
 
422
487
  // Layer 2: BM25 — semantic relevance (finds related code even without exact match)
423
- if (retriever) {
424
- if (!retriever.index) retriever.loadIndex();
425
- const chunks = retriever.retrieve(query, 5);
488
+ if (project?.retriever) {
489
+ if (!project.retriever.index) project.retriever.loadIndex();
490
+ const chunks = project.retriever.retrieve(query, 5);
426
491
  if (chunks.length > 0) {
427
492
  const bm25Output = chunks.map(c => {
428
493
  const score = c.score?.toFixed(2) || '?';
@@ -461,7 +526,7 @@ print('OK: replaced')
461
526
  if (query.includes('*') || query.includes('?')) {
462
527
  const result = await occRegistry.call('Glob', {
463
528
  pattern: query,
464
- path: args.path ? resolvePath(args.path) : undefined,
529
+ path: resolvePath(args.path || null, args),
465
530
  });
466
531
  const output = typeof result === 'string' ? result : String(result);
467
532
  return {
@@ -475,7 +540,7 @@ print('OK: replaced')
475
540
  // For text patterns: grep with context lines (like grep -n -C 3)
476
541
  const result = await occRegistry.call('Grep', {
477
542
  pattern: query,
478
- path: args.path ? resolvePath(args.path) : undefined,
543
+ path: resolvePath(args.path || null, args),
479
544
  output_mode: 'content',
480
545
  '-n': true,
481
546
  '-C': 3,
@@ -495,12 +560,12 @@ print('OK: replaced')
495
560
  const pattern = args.pattern;
496
561
  if (!pattern) return { success: false, output: 'pattern required', _tool: 'grep' };
497
562
 
498
- const searchPath = args.path ? resolvePath(args.path) : PROJECT_CWD;
563
+ const searchPath = resolvePath(args.path || null, args);
499
564
  const includeFlag = args.include ? `--glob "${args.include}"` : '';
500
565
 
501
566
  try {
502
567
  const cmd = `rg -n -C 2 --max-count 10 --max-filesize 500K ${includeFlag} -e ${JSON.stringify(pattern)} ${JSON.stringify(searchPath)} 2>/dev/null | head -80`;
503
- const output = execSync(cmd, { encoding: 'utf-8', timeout: 15000, cwd: PROJECT_CWD }).trim();
568
+ const output = execSync(cmd, { encoding: 'utf-8', timeout: 15000, cwd: searchPath }).trim();
504
569
  if (output) {
505
570
  return { success: true, output, _tool: 'grep' };
506
571
  }
@@ -521,7 +586,7 @@ print('OK: replaced')
521
586
  const results = [];
522
587
  for (const p of paths) {
523
588
  try {
524
- const filePath = resolvePath(p);
589
+ const filePath = resolvePath(p, args);
525
590
  const content = fs.readFileSync(filePath, 'utf-8');
526
591
  const lines = content.split('\n').length;
527
592
 
@@ -547,12 +612,13 @@ print('OK: replaced')
547
612
  // 9. delete_file + safety check
548
613
  delete_file: async (args) => {
549
614
  try {
550
- const filePath = resolvePath(args.file_path || args.path);
551
- const delCheck = validateDelete(filePath);
615
+ const filePath = resolvePath(args.file_path || args.path, args);
616
+ const delCheck = validateDelete(filePath, projectRootFor(filePath));
552
617
  if (!delCheck.safe) {
553
618
  return { success: false, output: `🛡️ BLOCKED: ${delCheck.reason}`, _tool: 'delete_file', _blocked: true };
554
619
  }
555
620
  fs.unlinkSync(filePath);
621
+ updateProjectIndex(filePath);
556
622
  return { success: true, message: `Deleted ${args.path}`, _tool: 'delete_file' };
557
623
  } catch (err) {
558
624
  return { success: false, output: `Error: ${err.message}`, _tool: 'delete_file' };
@@ -562,7 +628,7 @@ print('OK: replaced')
562
628
  // 10. get_file_info
563
629
  get_file_info: async (args) => {
564
630
  try {
565
- const filePath = resolvePath(args.file_path || args.path);
631
+ const filePath = resolvePath(args.file_path || args.path, args);
566
632
  const stat = fs.statSync(filePath);
567
633
  return {
568
634
  success: true,
@@ -580,14 +646,14 @@ print('OK: replaced')
580
646
  // 11. validate_file (syntax check)
581
647
  validate_file: async (args) => {
582
648
  try {
583
- const filePath = resolvePath(args.path);
649
+ const filePath = resolvePath(args.path, args);
584
650
  const ext = path.extname(filePath);
585
651
  let cmd;
586
652
  if (ext === '.py') cmd = `python3 -m py_compile "${filePath}"`;
587
653
  else if (ext === '.js' || ext === '.mjs') cmd = `node --check "${filePath}"`;
588
654
  else return { success: true, valid: true, message: 'No validator for this file type', _tool: 'validate_file' };
589
655
 
590
- execSync(cmd, { stdio: 'pipe' });
656
+ execSync(cmd, { stdio: 'pipe', cwd: projectRootFor(filePath) });
591
657
  return { success: true, valid: true, _tool: 'validate_file' };
592
658
  } catch (err) {
593
659
  return { success: true, valid: false, errors: err.stderr?.toString() || err.message, _tool: 'validate_file' };
@@ -599,12 +665,13 @@ print('OK: replaced')
599
665
  try {
600
666
  let cmd = args.command;
601
667
  if (!cmd) {
602
- if (fs.existsSync('package.json')) cmd = 'npm run build';
603
- else if (fs.existsSync('Makefile')) cmd = 'make';
604
- else if (fs.existsSync('Cargo.toml')) cmd = 'cargo build';
668
+ const cwd = commandCwd(args);
669
+ if (fs.existsSync(path.join(cwd, 'package.json'))) cmd = 'npm run build';
670
+ else if (fs.existsSync(path.join(cwd, 'Makefile'))) cmd = 'make';
671
+ else if (fs.existsSync(path.join(cwd, 'Cargo.toml'))) cmd = 'cargo build';
605
672
  else return { success: false, output: 'No build system detected', _tool: 'validate_build' };
606
673
  }
607
- const output = execSync(cmd, { stdio: 'pipe', timeout: 120_000 }).toString();
674
+ const output = execSync(cmd, { stdio: 'pipe', timeout: 120_000, cwd: commandCwd(args) }).toString();
608
675
  return { success: true, output, _tool: 'validate_build' };
609
676
  } catch (err) {
610
677
  return { success: false, output: err.stderr?.toString() || err.message, _tool: 'validate_build' };
@@ -614,7 +681,9 @@ print('OK: replaced')
614
681
  // 13. validate_structure
615
682
  validate_structure: async (args) => {
616
683
  const expected = args.expected || [];
617
- const missing = expected.filter(f => !fs.existsSync(resolvePath(f)));
684
+ const missing = expected.filter(f =>
685
+ !fs.existsSync(resolvePath(f, args, { allowMissing: true }))
686
+ );
618
687
  return {
619
688
  success: missing.length === 0,
620
689
  missing,
@@ -626,14 +695,14 @@ print('OK: replaced')
626
695
  // 14. lint_check
627
696
  lint_check: async (args) => {
628
697
  try {
629
- const filePath = resolvePath(args.file_path || args.path);
698
+ const filePath = resolvePath(args.file_path || args.path, args);
630
699
  const ext = path.extname(filePath);
631
700
  let cmd;
632
701
  if (ext === '.py') cmd = `python3 -m ruff check "${filePath}" 2>&1 || true`;
633
702
  else if (['.js', '.mjs', '.ts', '.tsx'].includes(ext)) cmd = `npx eslint "${filePath}" 2>&1 || true`;
634
703
  else return { success: true, issues: [], message: 'No linter for this file type', _tool: 'lint_check' };
635
704
 
636
- const output = execSync(cmd, { stdio: 'pipe', timeout: 30_000 }).toString();
705
+ const output = execSync(cmd, { stdio: 'pipe', timeout: 30_000, cwd: projectRootFor(filePath) }).toString();
637
706
  return { success: true, output, issues: output.split('\n').filter(Boolean), _tool: 'lint_check' };
638
707
  } catch (err) {
639
708
  return { success: false, output: err.message, _tool: 'lint_check' };
@@ -645,7 +714,7 @@ print('OK: replaced')
645
714
  try {
646
715
  const cmd = args.command || 'npm test';
647
716
  const output = execSync(cmd, {
648
- stdio: 'pipe', timeout: 120_000, cwd: process.cwd(),
717
+ stdio: 'pipe', timeout: 120_000, cwd: commandCwd(args),
649
718
  encoding: 'utf-8',
650
719
  }).toString();
651
720
  return { success: true, output: output.slice(-3000), _tool: 'run_tests' };
@@ -660,7 +729,7 @@ print('OK: replaced')
660
729
  try {
661
730
  const filePath = args.file_path ? `-- "${args.file_path}"` : '';
662
731
  const output = execSync(`git diff ${filePath}`, {
663
- stdio: 'pipe', timeout: 10_000, cwd: process.cwd(), encoding: 'utf-8',
732
+ stdio: 'pipe', timeout: 10_000, cwd: commandCwd(args), encoding: 'utf-8',
664
733
  }).toString();
665
734
  return { success: true, output: output.slice(-5000) || '(no changes)', _tool: 'git_diff' };
666
735
  } catch (err) {
@@ -672,7 +741,7 @@ print('OK: replaced')
672
741
  git_status: async (args) => {
673
742
  try {
674
743
  const output = execSync('git status --short', {
675
- stdio: 'pipe', timeout: 10_000, cwd: process.cwd(), encoding: 'utf-8',
744
+ stdio: 'pipe', timeout: 10_000, cwd: commandCwd(args), encoding: 'utf-8',
676
745
  }).toString();
677
746
  return { success: true, output: output || '(clean)', _tool: 'git_status' };
678
747
  } catch (err) {
@@ -684,7 +753,7 @@ print('OK: replaced')
684
753
  // Returns function signatures, classes, imports instead of raw file contents
685
754
  // 10x more token-efficient than read_file
686
755
  analyze_code: async (args) => {
687
- const filePath = resolvePath(args.file_path || args.path);
756
+ const filePath = resolvePath(args.file_path || args.path, args);
688
757
  const result = analyzeCode(filePath, {
689
758
  startLine: args.start_line,
690
759
  endLine: args.end_line,
@@ -696,6 +765,49 @@ print('OK: replaced')
696
765
  _tool: 'analyze_code',
697
766
  };
698
767
  },
768
+
769
+ // Project overview — on-demand index + skeleton
770
+ get_project_overview: async (args) => {
771
+ const projectPath = args.path || args.project_path;
772
+ const result = await projectRegistry.register(projectPath);
773
+ return {
774
+ success: true,
775
+ output: result.output,
776
+ project_resource: result.resource,
777
+ already_registered: result.already_registered,
778
+ _tool: 'get_project_overview',
779
+ };
780
+ },
781
+
782
+ // Portable skills — metadata first, full content only on demand.
783
+ skills_list: async (args) => ({
784
+ success: true,
785
+ output: JSON.stringify(skillsLoader.list({
786
+ query: args.query || '',
787
+ source: args.source || '',
788
+ scope: args.scope || '',
789
+ }), null, 2),
790
+ skills: skillsLoader.list({
791
+ query: args.query || '',
792
+ source: args.source || '',
793
+ scope: args.scope || '',
794
+ }),
795
+ _tool: 'skills_list',
796
+ }),
797
+
798
+ skill_view: async (args) => {
799
+ const skill = skillsLoader.view(
800
+ args.name,
801
+ args.path || null,
802
+ { sourceId: args.source_id || null },
803
+ );
804
+ return {
805
+ success: true,
806
+ output: JSON.stringify(skill, null, 2),
807
+ skill,
808
+ _tool: 'skill_view',
809
+ };
810
+ },
699
811
  };
700
812
 
701
813
  return {
@@ -721,5 +833,28 @@ print('OK: replaced')
721
833
  listTools() {
722
834
  return Object.keys(toolMap);
723
835
  },
836
+
837
+ getProjectResources() {
838
+ return projectRegistry.resources();
839
+ },
840
+
841
+ getAgentContext() {
842
+ const global = projectRegistry.getGlobalContext();
843
+ return {
844
+ identity: global.identity,
845
+ preferences: global.preferences,
846
+ global_skills: skillsLoader.list(),
847
+ source: 'cli',
848
+ };
849
+ },
850
+
851
+ reloadSkills(cwd = process.cwd()) {
852
+ skillsLoader.load(cwd);
853
+ return skillsLoader.list();
854
+ },
855
+
856
+ resetProjects() {
857
+ projectRegistry.reset();
858
+ },
724
859
  };
725
860
  }