opentology 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -22,7 +22,7 @@ graph TB
22
22
  end
23
23
 
24
24
  subgraph MCP["MCP Server"]
25
- Tools[20 Tools]
25
+ Tools[23 Tools]
26
26
  Resource["opentology://schema"]
27
27
  end
28
28
 
@@ -97,7 +97,7 @@ graph LR
97
97
  | Docker required | No (embedded mode) | Yes | Yes |
98
98
  | RDFS reasoning | Automatic on push | Manual SPARQL CONSTRUCT | Not native |
99
99
  | SHACL validation | Built-in | Manual tooling | N/A |
100
- | AI integration | MCP server with 20 tools | None | Plugin ecosystem |
100
+ | AI integration | MCP server with 23 tools | None | Plugin ecosystem |
101
101
  | Query language | SPARQL (auto-prefixed) | SPARQL (raw) | Cypher |
102
102
  | Data format | Turtle files | Turtle/N-Triples | Property graph |
103
103
  | Project scoping | Automatic named graphs | Manual | Database-level |
@@ -106,7 +106,7 @@ graph LR
106
106
 
107
107
  **Core**
108
108
 
109
- - 16 CLI commands covering the full RDF lifecycle
109
+ - 18 CLI commands covering the full RDF lifecycle
110
110
  - Project-level configuration with `.opentology.json`
111
111
  - Named graph scoping -- queries are automatically scoped to your project
112
112
  - Two modes: HTTP (Oxigraph server) and embedded (WASM, zero Docker)
@@ -126,7 +126,7 @@ graph LR
126
126
 
127
127
  **AI Integration**
128
128
 
129
- - MCP server with 20 tools and 1 resource
129
+ - MCP server with 23 tools and 1 resource
130
130
  - `opentology://schema` resource auto-loads ontology overview
131
131
  - Works with Claude Code, Cursor, and any MCP-compatible client
132
132
 
@@ -181,8 +181,9 @@ For embedded mode, no additional setup is needed.
181
181
  | `opentology infer` | Run RDFS materialization (`--clear` to remove inferred triples) |
182
182
  | `opentology graph` | List, create, or drop named graphs |
183
183
  | `opentology prefix` | List, add, or remove project prefixes |
184
- | `opentology context` | Project context management (`init`, `load`, `status`, `scan`, `graph`) |
184
+ | `opentology context` | Project context management (`init`, `load`, `status`, `scan`, `impact`, `sync`, `graph`) |
185
185
  | `opentology viz` | Visualize ontology schema (`schema`) |
186
+ | `opentology doctor` | Check project health (config, store, context, hooks, dependencies) |
186
187
  | `opentology mcp` | Start the MCP server |
187
188
 
188
189
  ### MCP Integration
@@ -200,7 +201,7 @@ Add to your MCP client configuration (`.mcp.json`):
200
201
  }
201
202
  ```
202
203
 
203
- **20 Tools:**
204
+ **23 Tools:**
204
205
 
205
206
  | Tool | Description |
206
207
  |------|-------------|
@@ -223,7 +224,10 @@ Add to your MCP client configuration (`.mcp.json`):
223
224
  | `opentology_context_load` | Load project context |
224
225
  | `opentology_context_status` | Check context initialization status |
225
226
  | `opentology_context_scan` | Scan codebase (module or symbol-level) |
227
+ | `opentology_context_impact` | Analyze file modification impact (dependents, dependencies, related context) |
228
+ | `opentology_context_sync` | Auto-sync: recover missed sessions from git, rescan module graph |
226
229
  | `opentology_context_graph` | Start interactive graph visualization web UI |
230
+ | `opentology_doctor` | Check project health (config, store, hooks, dependencies) |
227
231
 
228
232
  **1 Resource:**
229
233
 
@@ -334,8 +338,8 @@ npm install web-tree-sitter tree-sitter-wasms
334
338
 
335
339
  ### Roadmap
336
340
 
337
- - [x] CLI with 16 commands
338
- - [x] MCP server with 20 tools and 1 resource
341
+ - [x] CLI with 18 commands
342
+ - [x] MCP server with 23 tools and 1 resource
339
343
  - [x] Schema introspection (MCP resource + tool)
340
344
  - [x] Complete CRUD (push --replace, drop, delete)
341
345
  - [x] SHACL validation (shape constraints on push)
@@ -379,7 +383,7 @@ graph TB
379
383
  end
380
384
 
381
385
  subgraph MCP["MCP Server"]
382
- Tools[20 Tools]
386
+ Tools[23 Tools]
383
387
  Resource["opentology://schema"]
384
388
  end
385
389
 
@@ -454,7 +458,7 @@ graph LR
454
458
  | Docker 필수 | 아니오 (임베디드 모드) | 예 | 예 |
455
459
  | RDFS 추론 | 푸시 시 자동 | SPARQL CONSTRUCT 수동 작성 | 네이티브 미지원 |
456
460
  | SHACL 검증 | 내장 | 별도 도구 필요 | 해당 없음 |
457
- | AI 연동 | MCP 서버 20개 도구 | 없음 | 플러그인 생태계 |
461
+ | AI 연동 | MCP 서버 23개 도구 | 없음 | 플러그인 생태계 |
458
462
  | 쿼리 언어 | SPARQL (접두사 자동 삽입) | SPARQL (수동) | Cypher |
459
463
  | 데이터 형식 | Turtle 파일 | Turtle/N-Triples | 속성 그래프 |
460
464
  | 프로젝트 구분 | Named Graph 자동 | 수동 관리 | 데이터베이스 단위 |
@@ -463,7 +467,7 @@ graph LR
463
467
 
464
468
  **핵심**
465
469
 
466
- - RDF 전체 생애주기를 다루는 16개 CLI 명령어
470
+ - RDF 전체 생애주기를 다루는 18개 CLI 명령어
467
471
  - `.opentology.json` 기반 프로젝트 설정
468
472
  - Named Graph 자동 스코핑 -- 쿼리가 프로젝트 그래프에 자동 한정
469
473
  - HTTP 모드(Oxigraph 서버)와 임베디드 모드(WASM, Docker 불필요) 지원
@@ -483,7 +487,7 @@ graph LR
483
487
 
484
488
  **AI 연동**
485
489
 
486
- - 20개 도구와 1개 리소스를 제공하는 MCP 서버
490
+ - 23개 도구와 1개 리소스를 제공하는 MCP 서버
487
491
  - `opentology://schema` 리소스로 온톨로지 개요 자동 로드
488
492
  - Claude Code, Cursor 등 MCP 호환 클라이언트와 연동
489
493
 
@@ -538,8 +542,9 @@ docker run -p 7878:7878 ghcr.io/oxigraph/oxigraph \
538
542
  | `opentology infer` | RDFS 물질화 실행 (`--clear`로 추론 트리플 제거) |
539
543
  | `opentology graph` | Named Graph 목록/생성/삭제 |
540
544
  | `opentology prefix` | 프로젝트 접두사 목록/추가/제거 |
541
- | `opentology context` | 프로젝트 컨텍스트 관리 (`init`, `load`, `status`, `scan`, `graph`) |
545
+ | `opentology context` | 프로젝트 컨텍스트 관리 (`init`, `load`, `status`, `scan`, `impact`, `sync`, `graph`) |
542
546
  | `opentology viz` | 온톨로지 스키마 시각화 (`schema`) |
547
+ | `opentology doctor` | 프로젝트 건강 진단 (설정, 스토어, 컨텍스트, 훅, 의존성) |
543
548
  | `opentology mcp` | MCP 서버 시작 |
544
549
 
545
550
  ### MCP 연동
@@ -557,7 +562,7 @@ MCP 클라이언트 설정 파일(`.mcp.json`)에 추가:
557
562
  }
558
563
  ```
559
564
 
560
- **20개 도구:**
565
+ **23개 도구:**
561
566
 
562
567
  | 도구 | 설명 |
563
568
  |------|------|
@@ -580,7 +585,10 @@ MCP 클라이언트 설정 파일(`.mcp.json`)에 추가:
580
585
  | `opentology_context_load` | 프로젝트 컨텍스트 로드 |
581
586
  | `opentology_context_status` | 컨텍스트 초기화 상태 확인 |
582
587
  | `opentology_context_scan` | 코드베이스 스캔 (모듈/심볼 수준) |
588
+ | `opentology_context_impact` | 파일 수정 영향 분석 (의존 모듈, 관련 컨텍스트) |
589
+ | `opentology_context_sync` | 자동 동기화: git에서 누락 세션 복구, 모듈 그래프 재스캔 |
583
590
  | `opentology_context_graph` | 인터랙티브 그래프 시각화 웹 UI 시작 |
591
+ | `opentology_doctor` | 프로젝트 건강 진단 (설정, 스토어, 훅, 의존성) |
584
592
 
585
593
  **1개 리소스:**
586
594
 
@@ -691,8 +699,8 @@ npm install web-tree-sitter tree-sitter-wasms
691
699
 
692
700
  ### 로드맵
693
701
 
694
- - [x] 16개 CLI 명령어
695
- - [x] 20개 도구와 1개 리소스를 갖춘 MCP 서버
702
+ - [x] 18개 CLI 명령어
703
+ - [x] 23개 도구와 1개 리소스를 갖춘 MCP 서버
696
704
  - [x] 스키마 조회 (MCP 리소스 + 도구)
697
705
  - [x] 완전한 CRUD (push --replace, drop, delete)
698
706
  - [x] SHACL 검증 (푸시 시 형상 제약 자동 검증)
@@ -1,13 +1,25 @@
1
1
  import pc from 'picocolors';
2
- import { existsSync, mkdirSync, writeFileSync, unlinkSync, readdirSync } from 'node:fs';
2
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, unlinkSync, readdirSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
+ import { createInterface } from 'node:readline';
4
5
  import { loadConfig, saveConfig } from '../lib/config.js';
5
6
  import { createReadyAdapter } from '../lib/store-factory.js';
6
7
  import { startGraphServer } from '../lib/graph-server.js';
8
+ import { syncContext } from '../lib/context-sync.js';
7
9
  import { OTX_BOOTSTRAP_TURTLE } from '../templates/otx-ontology.js';
8
10
  import { generateContextSection, updateClaudeMd } from '../templates/claude-md-context.js';
9
11
  import { generateHookScript } from '../templates/session-start-hook.js';
12
+ import { generatePreEditHookScript } from '../templates/pre-edit-hook.js';
10
13
  import { generateSlashCommands } from '../templates/slash-commands.js';
14
+ function ask(question) {
15
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
16
+ return new Promise((resolve) => {
17
+ rl.question(question, (answer) => {
18
+ rl.close();
19
+ resolve(answer.trim().toLowerCase());
20
+ });
21
+ });
22
+ }
11
23
  export function registerContext(program) {
12
24
  const context = program
13
25
  .command('context')
@@ -87,6 +99,16 @@ export function registerContext(program) {
87
99
  else {
88
100
  console.log(pc.dim(' Hook script already exists — skipped (use --force to regenerate)'));
89
101
  }
102
+ // Step 3b: Write pre-edit hook script
103
+ const preEditHookPath = join(hookDir, 'pre-edit.mjs');
104
+ if (!existsSync(preEditHookPath) || opts.force) {
105
+ mkdirSync(hookDir, { recursive: true });
106
+ writeFileSync(preEditHookPath, generatePreEditHookScript(), 'utf-8');
107
+ console.log(pc.green(` Generated hook script: .opentology/hooks/pre-edit.mjs`));
108
+ }
109
+ else {
110
+ console.log(pc.dim(' Pre-edit hook already exists — skipped'));
111
+ }
90
112
  // Step 4: Update CLAUDE.md
91
113
  const claudeMdPath = join(process.cwd(), 'CLAUDE.md');
92
114
  const section = generateContextSection(config.projectId, config.graphUri);
@@ -139,18 +161,51 @@ export function registerContext(program) {
139
161
  }
140
162
  // Step 6: Save config LAST (atomic commit point)
141
163
  saveConfig(config);
142
- // Print hook registration instructions
143
- console.log('');
144
- console.log(pc.bold('Add this to your project .claude/settings.json:'));
164
+ // Step 7: Register hooks in .claude/settings.json
165
+ const settingsDir = join(process.cwd(), '.claude');
166
+ const settingsPath = join(settingsDir, 'settings.json');
167
+ mkdirSync(settingsDir, { recursive: true });
168
+ let settings = {};
169
+ if (existsSync(settingsPath)) {
170
+ try {
171
+ settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
172
+ }
173
+ catch { /* start fresh */ }
174
+ }
175
+ const hooks = (settings.hooks ?? {});
176
+ // Always register SessionStart hook
177
+ const sessionStartCmd = 'node .opentology/hooks/session-start.mjs';
178
+ if (!hooks.SessionStart)
179
+ hooks.SessionStart = [];
180
+ const hasSessionHook = hooks.SessionStart.some((h) => h.command === sessionStartCmd);
181
+ if (!hasSessionHook) {
182
+ hooks.SessionStart.push({ type: 'command', command: sessionStartCmd });
183
+ console.log(pc.green(' Registered SessionStart hook in .claude/settings.json'));
184
+ }
185
+ // Ask about PreToolUse impact hook
145
186
  console.log('');
146
- console.log(JSON.stringify({
147
- hooks: {
148
- SessionStart: [{
149
- type: 'command',
150
- command: 'node .opentology/hooks/session-start.mjs',
151
- }],
152
- },
153
- }, null, 2));
187
+ const enableImpact = await ask(pc.bold('Enable PreToolUse impact analysis hook? ') +
188
+ pc.dim('(auto-shows dependents before Edit/Write)') +
189
+ ' [Y/n] ');
190
+ if (enableImpact !== 'n' && enableImpact !== 'no') {
191
+ const preEditCmd = 'node .opentology/hooks/pre-edit.mjs';
192
+ if (!hooks.PreToolUse)
193
+ hooks.PreToolUse = [];
194
+ const hasPreEditHook = hooks.PreToolUse.some((h) => h.command === preEditCmd);
195
+ if (!hasPreEditHook) {
196
+ hooks.PreToolUse.push({
197
+ type: 'command',
198
+ command: preEditCmd,
199
+ matcher: { tool_name: 'Edit|Write' },
200
+ });
201
+ }
202
+ console.log(pc.green(' Registered PreToolUse impact hook in .claude/settings.json'));
203
+ }
204
+ else {
205
+ console.log(pc.dim(' Skipped PreToolUse hook registration'));
206
+ }
207
+ settings.hooks = hooks;
208
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
154
209
  console.log('');
155
210
  console.log(pc.dim('Consider adding .opentology/hooks/ to version control so team members share the hook.'));
156
211
  }
@@ -367,6 +422,138 @@ export function registerContext(program) {
367
422
  console.log('CLAUDE.md: ' + (hasMarkers ? pc.green('markers present') : pc.yellow('markers missing')));
368
423
  }
369
424
  });
425
+ // --- context impact ---
426
+ context
427
+ .command('impact')
428
+ .description('Analyze impact of modifying a file (dependents, dependencies, related context)')
429
+ .requiredOption('--file <path>', 'Relative file path to analyze')
430
+ .option('--format <type>', 'Output format: table, json', 'table')
431
+ .action(async (opts) => {
432
+ let config;
433
+ try {
434
+ config = loadConfig();
435
+ }
436
+ catch {
437
+ console.error(pc.red('Error: No .opentology.json found. Run `opentology init` first.'));
438
+ process.exit(1);
439
+ }
440
+ const graphs = config.graphs ?? {};
441
+ if (!graphs['context']) {
442
+ console.error(pc.red('Error: Context not initialized. Run `opentology context init` first.'));
443
+ process.exit(1);
444
+ }
445
+ const contextUri = graphs['context'];
446
+ const OTX = 'https://opentology.dev/vocab#';
447
+ const moduleUriStr = `urn:module:${opts.file}`;
448
+ try {
449
+ const adapter = await createReadyAdapter(config);
450
+ // Dependents (reverse deps)
451
+ const dependentsResult = await adapter.sparqlQuery(`
452
+ SELECT ?dependent WHERE {
453
+ GRAPH <${contextUri}> {
454
+ ?dependent <${OTX}dependsOn> <${moduleUriStr}> .
455
+ }
456
+ }`);
457
+ const dependents = (dependentsResult.results?.bindings ?? []).map((b) => b.dependent?.value?.replace('urn:module:', '') ?? '').filter(Boolean);
458
+ // Dependencies
459
+ const depsResult = await adapter.sparqlQuery(`
460
+ SELECT ?dep WHERE {
461
+ GRAPH <${contextUri}> {
462
+ <${moduleUriStr}> <${OTX}dependsOn> ?dep .
463
+ }
464
+ }`);
465
+ const dependencies = (depsResult.results?.bindings ?? []).map((b) => b.dep?.value?.replace('urn:module:', '') ?? '').filter(Boolean);
466
+ // Related context entries
467
+ let related = [];
468
+ try {
469
+ const relatedResult = await adapter.sparqlQuery(`
470
+ SELECT ?type ?title ?status ?date WHERE {
471
+ GRAPH <${contextUri}> {
472
+ ?s <${OTX}body> ?body .
473
+ ?s a ?type .
474
+ ?s <${OTX}title> ?title .
475
+ OPTIONAL { ?s <${OTX}status> ?status }
476
+ OPTIONAL { ?s <${OTX}date> ?date }
477
+ FILTER(CONTAINS(?body, "${opts.file}"))
478
+ }
479
+ } LIMIT 10`);
480
+ related = (relatedResult.results?.bindings ?? []).map((b) => ({
481
+ type: b.type?.value?.replace(OTX, '') ?? '',
482
+ title: b.title?.value ?? '',
483
+ status: b.status?.value,
484
+ date: b.date?.value,
485
+ }));
486
+ }
487
+ catch {
488
+ // FILTER/CONTAINS may not be supported
489
+ }
490
+ const impact = dependents.length === 0 ? 'low' : dependents.length <= 3 ? 'medium' : 'high';
491
+ if (opts.format === 'json') {
492
+ console.log(JSON.stringify({ filePath: opts.file, dependents, dependencies, related, impact }, null, 2));
493
+ }
494
+ else {
495
+ console.log(pc.bold(`Impact Analysis: ${opts.file}`));
496
+ console.log(`Impact level: ${impact === 'high' ? pc.red(impact) : impact === 'medium' ? pc.yellow(impact) : pc.green(impact)}`);
497
+ console.log('');
498
+ if (dependents.length > 0) {
499
+ console.log(pc.bold(`Dependents (${dependents.length}):`));
500
+ for (const d of dependents)
501
+ console.log(` ${d}`);
502
+ console.log('');
503
+ }
504
+ if (dependencies.length > 0) {
505
+ console.log(pc.bold(`Dependencies (${dependencies.length}):`));
506
+ for (const d of dependencies)
507
+ console.log(` ${d}`);
508
+ console.log('');
509
+ }
510
+ if (related.length > 0) {
511
+ console.log(pc.bold('Related context:'));
512
+ for (const r of related) {
513
+ console.log(` [${r.type}] ${r.title}${r.status ? ` (${r.status})` : ''}`);
514
+ }
515
+ }
516
+ if (dependents.length === 0 && dependencies.length === 0) {
517
+ console.log(pc.dim('No module dependencies found. Run `opentology context scan` to populate.'));
518
+ }
519
+ }
520
+ }
521
+ catch (err) {
522
+ console.error(pc.red(`Error: ${err.message}`));
523
+ process.exit(1);
524
+ }
525
+ });
526
+ // --- context sync ---
527
+ context
528
+ .command('sync')
529
+ .description('Auto-sync context graph: recover missed sessions from git log, rescan module dependencies')
530
+ .option('--format <type>', 'Output format: table, json', 'table')
531
+ .action(async (opts) => {
532
+ let config;
533
+ try {
534
+ config = loadConfig();
535
+ }
536
+ catch {
537
+ console.error(pc.red('Error: No .opentology.json found. Run `opentology init` first.'));
538
+ process.exit(1);
539
+ }
540
+ try {
541
+ const result = await syncContext(config, process.cwd());
542
+ if (opts.format === 'json') {
543
+ console.log(JSON.stringify(result, null, 2));
544
+ }
545
+ else {
546
+ console.log(pc.bold('Context Sync'));
547
+ for (const action of result.actions) {
548
+ console.log(` ${pc.green('•')} ${action}`);
549
+ }
550
+ }
551
+ }
552
+ catch (err) {
553
+ console.error(pc.red(`Error: ${err.message}`));
554
+ process.exit(1);
555
+ }
556
+ });
370
557
  // --- context graph ---
371
558
  context
372
559
  .command('graph')
@@ -0,0 +1,9 @@
1
+ import { Command } from 'commander';
2
+ interface CheckResult {
3
+ name: string;
4
+ status: 'ok' | 'warn' | 'fail';
5
+ message: string;
6
+ }
7
+ export declare function runDoctor(): Promise<CheckResult[]>;
8
+ export declare function registerDoctor(program: Command): void;
9
+ export {};
@@ -0,0 +1,132 @@
1
+ import pc from 'picocolors';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { configExists, loadConfig } from '../lib/config.js';
5
+ import { createReadyAdapter } from '../lib/store-factory.js';
6
+ export async function runDoctor() {
7
+ const results = [];
8
+ // 1. Config check
9
+ if (!configExists()) {
10
+ results.push({ name: 'Config', status: 'fail', message: 'No .opentology.json found. Run `opentology init` first.' });
11
+ return results; // Can't proceed without config
12
+ }
13
+ const config = loadConfig();
14
+ results.push({ name: 'Config', status: 'ok', message: `Project: ${config.projectId} (${config.mode} mode)` });
15
+ // 2. Store connectivity
16
+ try {
17
+ const adapter = await createReadyAdapter(config);
18
+ const r = await adapter.sparqlQuery(`SELECT (COUNT(*) AS ?c) WHERE { GRAPH <${config.graphUri}> { ?s ?p ?o } }`);
19
+ const count = r.results?.bindings?.[0]?.c?.value ?? '0';
20
+ results.push({ name: 'Store', status: 'ok', message: `Connected — ${count} triples in default graph` });
21
+ }
22
+ catch (err) {
23
+ results.push({ name: 'Store', status: 'fail', message: `Cannot connect: ${err.message}` });
24
+ }
25
+ // 3. Context initialization
26
+ const graphs = config.graphs ?? {};
27
+ if (graphs['context'] && graphs['sessions']) {
28
+ results.push({ name: 'Context', status: 'ok', message: `context: ${graphs['context']}` });
29
+ }
30
+ else {
31
+ results.push({ name: 'Context', status: 'warn', message: 'Not initialized. Run `opentology context init`.' });
32
+ }
33
+ // 4. Hook scripts
34
+ const hookDir = join(process.cwd(), '.opentology', 'hooks');
35
+ const sessionHook = join(hookDir, 'session-start.mjs');
36
+ const preEditHook = join(hookDir, 'pre-edit.mjs');
37
+ const hooksExist = existsSync(sessionHook) && existsSync(preEditHook);
38
+ if (hooksExist) {
39
+ results.push({ name: 'Hooks', status: 'ok', message: 'session-start.mjs + pre-edit.mjs present' });
40
+ }
41
+ else {
42
+ results.push({ name: 'Hooks', status: 'warn', message: 'Hook scripts missing. Run `opentology context init`.' });
43
+ }
44
+ // 5. Hook registration in .claude/settings.json
45
+ const settingsPath = join(process.cwd(), '.claude', 'settings.json');
46
+ if (existsSync(settingsPath)) {
47
+ try {
48
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
49
+ const hooks = settings.hooks ?? {};
50
+ const hasSession = (hooks.SessionStart ?? []).some((h) => h.command?.includes('session-start.mjs'));
51
+ const hasPreEdit = (hooks.PreToolUse ?? []).some((h) => h.command?.includes('pre-edit.mjs'));
52
+ if (hasSession && hasPreEdit) {
53
+ results.push({ name: 'Settings', status: 'ok', message: 'Both hooks registered in .claude/settings.json' });
54
+ }
55
+ else {
56
+ const missing = [];
57
+ if (!hasSession)
58
+ missing.push('SessionStart');
59
+ if (!hasPreEdit)
60
+ missing.push('PreToolUse');
61
+ results.push({ name: 'Settings', status: 'warn', message: `Missing hooks: ${missing.join(', ')}. Run \`opentology context init\`.` });
62
+ }
63
+ }
64
+ catch {
65
+ results.push({ name: 'Settings', status: 'warn', message: 'Cannot parse .claude/settings.json' });
66
+ }
67
+ }
68
+ else {
69
+ results.push({ name: 'Settings', status: 'warn', message: 'No .claude/settings.json found. Run `opentology context init`.' });
70
+ }
71
+ // 6. CLAUDE.md
72
+ const claudeMdPath = join(process.cwd(), 'CLAUDE.md');
73
+ if (existsSync(claudeMdPath)) {
74
+ const content = readFileSync(claudeMdPath, 'utf-8');
75
+ if (content.includes('OPENTOLOGY:CONTEXT:BEGIN') || content.includes('OpenTology')) {
76
+ results.push({ name: 'CLAUDE.md', status: 'ok', message: 'Context section present' });
77
+ }
78
+ else {
79
+ results.push({ name: 'CLAUDE.md', status: 'warn', message: 'Exists but no OpenTology context section' });
80
+ }
81
+ }
82
+ else {
83
+ results.push({ name: 'CLAUDE.md', status: 'warn', message: 'Not found. Run `opentology context init`.' });
84
+ }
85
+ // 7. Optional dependencies
86
+ const optDeps = [
87
+ { name: 'ts-morph', desc: 'TypeScript deep scan' },
88
+ { name: 'web-tree-sitter', desc: 'Multi-language deep scan' },
89
+ ];
90
+ for (const dep of optDeps) {
91
+ try {
92
+ await import(dep.name);
93
+ results.push({ name: dep.name, status: 'ok', message: dep.desc });
94
+ }
95
+ catch {
96
+ results.push({ name: dep.name, status: 'warn', message: `Not installed (optional — ${dep.desc})` });
97
+ }
98
+ }
99
+ return results;
100
+ }
101
+ export function registerDoctor(program) {
102
+ program
103
+ .command('doctor')
104
+ .description('Check project health: config, store, context, hooks, dependencies')
105
+ .option('--format <type>', 'Output format: table, json', 'table')
106
+ .action(async (opts) => {
107
+ const results = await runDoctor();
108
+ if (opts.format === 'json') {
109
+ console.log(JSON.stringify(results, null, 2));
110
+ return;
111
+ }
112
+ console.log(pc.bold('\nOpenTology Doctor\n'));
113
+ for (const r of results) {
114
+ const icon = r.status === 'ok' ? pc.green('✓') : r.status === 'warn' ? pc.yellow('!') : pc.red('✗');
115
+ const msg = r.status === 'fail' ? pc.red(r.message) : r.status === 'warn' ? pc.yellow(r.message) : r.message;
116
+ console.log(` ${icon} ${pc.bold(r.name)}: ${msg}`);
117
+ }
118
+ const fails = results.filter((r) => r.status === 'fail').length;
119
+ const warns = results.filter((r) => r.status === 'warn').length;
120
+ console.log('');
121
+ if (fails > 0) {
122
+ console.log(pc.red(` ${fails} error(s), ${warns} warning(s)`));
123
+ process.exit(1);
124
+ }
125
+ else if (warns > 0) {
126
+ console.log(pc.yellow(` ${warns} warning(s), everything else looks good`));
127
+ }
128
+ else {
129
+ console.log(pc.green(' All checks passed!'));
130
+ }
131
+ });
132
+ }
package/dist/index.js CHANGED
@@ -16,10 +16,11 @@ import { registerInfer } from './commands/infer.js';
16
16
  import { registerPrefix } from './commands/prefix.js';
17
17
  import { registerContext } from './commands/context.js';
18
18
  import { registerViz } from './commands/viz.js';
19
+ import { registerDoctor } from './commands/doctor.js';
19
20
  const program = new Command();
20
21
  program
21
22
  .name('opentology')
22
- .version('0.1.0')
23
+ .version('0.2.2')
23
24
  .description('CLI-managed RDF/SPARQL infrastructure — Supabase for RDF');
24
25
  registerInit(program);
25
26
  registerValidate(program);
@@ -37,4 +38,5 @@ registerInfer(program);
37
38
  registerPrefix(program);
38
39
  registerContext(program);
39
40
  registerViz(program);
41
+ registerDoctor(program);
40
42
  program.parse(process.argv);
@@ -0,0 +1,15 @@
1
+ import { OpenTologyConfig } from './config.js';
2
+ export interface SyncResult {
3
+ sessionsRecovered: number;
4
+ modulesUpdated: boolean;
5
+ moduleStats: {
6
+ modules: number;
7
+ edges: number;
8
+ } | null;
9
+ actions: string[];
10
+ }
11
+ /**
12
+ * Core sync logic: recover missed sessions and rescan modules.
13
+ * Persists via .ttl files for embedded mode compatibility.
14
+ */
15
+ export declare function syncContext(config: OpenTologyConfig, projectRoot: string): Promise<SyncResult>;
@@ -0,0 +1,229 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { addTrackedFile, saveConfig } from './config.js';
5
+ import { createReadyAdapter } from './store-factory.js';
6
+ import { extractDependencyGraph } from './codebase-scanner.js';
7
+ const OTX = 'https://opentology.dev/vocab#';
8
+ const XSD = 'http://www.w3.org/2001/XMLSchema#';
9
+ /**
10
+ * Get the most recent session date from the sessions graph.
11
+ * Returns ISO date string (YYYY-MM-DD) or null if no sessions exist.
12
+ */
13
+ async function getLastSessionDate(adapter, sessionsUri) {
14
+ const r = await adapter.sparqlQuery(`
15
+ SELECT ?date WHERE {
16
+ GRAPH <${sessionsUri}> {
17
+ ?s a <${OTX}Session> ; <${OTX}date> ?date .
18
+ }
19
+ } ORDER BY DESC(?date) LIMIT 1
20
+ `);
21
+ const binding = r.results?.bindings?.[0];
22
+ return binding?.date?.value ?? null;
23
+ }
24
+ /**
25
+ * Get git commits since a given date, grouped by date.
26
+ * Returns map of date -> commit messages.
27
+ */
28
+ function getCommitsSince(projectRoot, sinceDate) {
29
+ const dateMap = new Map();
30
+ try {
31
+ const args = ['log', '--format=%ad|%s', '--date=short'];
32
+ if (sinceDate) {
33
+ args.push(`--after=${sinceDate}`);
34
+ }
35
+ else {
36
+ args.push('--since=7 days ago');
37
+ }
38
+ const output = execFileSync('git', args, {
39
+ cwd: projectRoot,
40
+ encoding: 'utf-8',
41
+ timeout: 5000,
42
+ stdio: ['pipe', 'pipe', 'pipe'],
43
+ }).trim();
44
+ if (!output)
45
+ return dateMap;
46
+ for (const line of output.split('\n')) {
47
+ const sepIdx = line.indexOf('|');
48
+ if (sepIdx === -1)
49
+ continue;
50
+ const date = line.slice(0, sepIdx).trim();
51
+ const msg = line.slice(sepIdx + 1).trim();
52
+ if (!date || !msg)
53
+ continue;
54
+ if (!dateMap.has(date))
55
+ dateMap.set(date, []);
56
+ dateMap.get(date).push(msg);
57
+ }
58
+ }
59
+ catch {
60
+ // git not available or not a repo
61
+ }
62
+ return dateMap;
63
+ }
64
+ /**
65
+ * Check if any source files changed since a given date.
66
+ */
67
+ function hasSourceChanges(projectRoot, sinceDate) {
68
+ if (!sinceDate)
69
+ return true;
70
+ try {
71
+ const output = execFileSync('git', [
72
+ 'log', '--oneline', `--after=${sinceDate}`,
73
+ '--', '*.ts', '*.js', '*.tsx', '*.jsx', '*.py', '*.go', '*.rs', '*.java', '*.swift',
74
+ ], {
75
+ cwd: projectRoot,
76
+ encoding: 'utf-8',
77
+ timeout: 5000,
78
+ stdio: ['pipe', 'pipe', 'pipe'],
79
+ }).trim();
80
+ return output.length > 0;
81
+ }
82
+ catch {
83
+ return true;
84
+ }
85
+ }
86
+ /**
87
+ * Generate Turtle content for auto-recovered sessions.
88
+ */
89
+ function generateSessionTurtle(commitsByDate) {
90
+ const lines = [
91
+ `@prefix otx: <${OTX}> .`,
92
+ `@prefix xsd: <${XSD}> .`,
93
+ '',
94
+ ];
95
+ for (const [date, messages] of commitsByDate) {
96
+ const title = messages.length === 1
97
+ ? messages[0]
98
+ : `${messages.length} commits`;
99
+ const body = messages.join('\n');
100
+ const uri = `urn:session:${date}-auto`;
101
+ lines.push(`<${uri}> a otx:Session ;`);
102
+ lines.push(` otx:title "${escapeTurtle(title)}" ;`);
103
+ lines.push(` otx:date "${date}"^^xsd:date ;`);
104
+ lines.push(` otx:body "${escapeTurtle(body)}" .`);
105
+ lines.push('');
106
+ }
107
+ return lines.join('\n');
108
+ }
109
+ /**
110
+ * Generate Turtle content for module dependency graph.
111
+ */
112
+ function generateModuleTurtle(modules, edges) {
113
+ const lines = [
114
+ `@prefix otx: <${OTX}> .`,
115
+ '',
116
+ ];
117
+ for (const mod of modules) {
118
+ lines.push(`<urn:module:${mod}> a otx:Module .`);
119
+ }
120
+ lines.push('');
121
+ for (const edge of edges) {
122
+ lines.push(`<urn:module:${edge.from}> otx:dependsOn <urn:module:${edge.to}> .`);
123
+ }
124
+ return lines.join('\n');
125
+ }
126
+ /**
127
+ * Core sync logic: recover missed sessions and rescan modules.
128
+ * Persists via .ttl files for embedded mode compatibility.
129
+ */
130
+ export async function syncContext(config, projectRoot) {
131
+ const graphs = config.graphs ?? {};
132
+ const contextUri = graphs['context'];
133
+ const sessionsUri = graphs['sessions'];
134
+ if (!contextUri || !sessionsUri) {
135
+ throw new Error('Context not initialized. Run `opentology context init` first.');
136
+ }
137
+ const adapter = await createReadyAdapter(config);
138
+ const actions = [];
139
+ let sessionsRecovered = 0;
140
+ let modulesUpdated = false;
141
+ let moduleStats = null;
142
+ const syncDir = join(projectRoot, '.opentology', 'sync');
143
+ mkdirSync(syncDir, { recursive: true });
144
+ // === Step 1: Recover missed sessions from git log ===
145
+ const lastDate = await getLastSessionDate(adapter, sessionsUri);
146
+ const commitsByDate = getCommitsSince(projectRoot, lastDate);
147
+ if (commitsByDate.size > 0) {
148
+ const turtle = generateSessionTurtle(commitsByDate);
149
+ const sessionsFile = join(syncDir, 'auto-sessions.ttl');
150
+ // Append to existing auto-sessions file or create new
151
+ if (existsSync(sessionsFile)) {
152
+ const existing = readFileSync(sessionsFile, 'utf-8');
153
+ // Only add new sessions (check URIs)
154
+ const newEntries = [];
155
+ for (const [date] of commitsByDate) {
156
+ const uri = `urn:session:${date}-auto`;
157
+ if (!existing.includes(uri)) {
158
+ newEntries.push(date);
159
+ }
160
+ }
161
+ if (newEntries.length > 0) {
162
+ const newMap = new Map();
163
+ for (const date of newEntries) {
164
+ newMap.set(date, commitsByDate.get(date));
165
+ }
166
+ if (newMap.size > 0) {
167
+ const newTurtle = generateSessionTurtle(newMap);
168
+ // Remove prefix lines from appended content
169
+ const body = newTurtle.split('\n').filter(l => !l.startsWith('@prefix') && l.trim() !== '').join('\n');
170
+ writeFileSync(sessionsFile, existing.trimEnd() + '\n\n' + body + '\n', 'utf-8');
171
+ await adapter.insertTurtle(sessionsUri, newTurtle);
172
+ sessionsRecovered = newMap.size;
173
+ }
174
+ }
175
+ }
176
+ else {
177
+ writeFileSync(sessionsFile, turtle, 'utf-8');
178
+ await adapter.insertTurtle(sessionsUri, turtle);
179
+ sessionsRecovered = commitsByDate.size;
180
+ }
181
+ if (sessionsRecovered > 0) {
182
+ // Track the file for embedded persistence
183
+ const relPath = '.opentology/sync/auto-sessions.ttl';
184
+ addTrackedFile(config, sessionsUri, relPath);
185
+ const totalCommits = [...commitsByDate.values()].reduce((s, m) => s + m.length, 0);
186
+ actions.push(`Recovered ${sessionsRecovered} session(s) from ${totalCommits} git commit(s)`);
187
+ }
188
+ else {
189
+ actions.push('No missed sessions to recover');
190
+ }
191
+ }
192
+ else {
193
+ actions.push('No missed sessions to recover');
194
+ }
195
+ // === Step 2: Rescan module dependency graph if source files changed ===
196
+ const shouldRescan = hasSourceChanges(projectRoot, lastDate);
197
+ if (shouldRescan) {
198
+ try {
199
+ const dg = await extractDependencyGraph(projectRoot, null);
200
+ if (dg.modules.length > 0) {
201
+ const turtle = generateModuleTurtle(dg.modules, dg.edges);
202
+ const modulesFile = join(syncDir, 'modules.ttl');
203
+ // Replace modules file entirely (fresh scan)
204
+ writeFileSync(modulesFile, turtle, 'utf-8');
205
+ // Drop old module triples, then insert fresh
206
+ await adapter.sparqlUpdate(`DELETE { GRAPH <${contextUri}> { ?s ?p ?o } } WHERE { GRAPH <${contextUri}> { ?s a <${OTX}Module> . ?s ?p ?o } }`);
207
+ await adapter.sparqlUpdate(`DELETE { GRAPH <${contextUri}> { ?s <${OTX}dependsOn> ?o } } WHERE { GRAPH <${contextUri}> { ?s <${OTX}dependsOn> ?o } }`);
208
+ await adapter.insertTurtle(contextUri, turtle);
209
+ const relPath = '.opentology/sync/modules.ttl';
210
+ addTrackedFile(config, contextUri, relPath);
211
+ moduleStats = { modules: dg.modules.length, edges: dg.edges.length };
212
+ modulesUpdated = true;
213
+ actions.push(`Rescanned modules: ${dg.modules.length} modules, ${dg.edges.length} edges`);
214
+ }
215
+ }
216
+ catch {
217
+ actions.push('Module rescan failed (non-fatal)');
218
+ }
219
+ }
220
+ else {
221
+ actions.push('No source changes detected — module rescan skipped');
222
+ }
223
+ // Save config with tracked files
224
+ saveConfig(config);
225
+ return { sessionsRecovered, modulesUpdated, moduleStats, actions };
226
+ }
227
+ function escapeTurtle(s) {
228
+ return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
229
+ }
@@ -70,8 +70,9 @@ function html(config) {
70
70
  <div id="app">
71
71
  <div id="sidebar">
72
72
  <h1>OpenTology — ${projectId}</h1>
73
- <div id="graph-list">
74
- <select id="graphSelect"><option value="">Loading graphs...</option></select>
73
+ <div id="graph-list" style="display:flex;gap:6px;align-items:center;">
74
+ <select id="graphSelect" style="flex:1;"><option value="">Loading graphs...</option></select>
75
+ <button id="refreshBtn" onclick="refreshAll()" title="Refresh graph data" style="padding:5px 10px;background:#0550ae;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:12px;white-space:nowrap;">&#8635; Refresh</button>
75
76
  </div>
76
77
  <div class="stats" id="stats"></div>
77
78
  <div id="query-box">
@@ -189,6 +190,30 @@ function html(config) {
189
190
  loadGraph(sel.value);
190
191
  }
191
192
 
193
+ async function refreshAll() {
194
+ const btn = document.getElementById('refreshBtn');
195
+ btn.textContent = '\\u21bb ...';
196
+ btn.disabled = true;
197
+ try {
198
+ const gs = await fetch('/api/graphs').then(r => r.json());
199
+ const sel = document.getElementById('graphSelect');
200
+ const prev = sel.value;
201
+ sel.innerHTML = '';
202
+ for (const g of gs) {
203
+ sel.innerHTML += '<option value="' + g.uri + '"' + (g.uri === prev ? ' selected' : '') + '>' + g.name + ' (' + g.triples + ')</option>';
204
+ }
205
+ try {
206
+ const schema = await fetch('/api/schema').then(r => r.json());
207
+ classSet = new Set();
208
+ (schema.classes || []).forEach(c => classSet.add(c));
209
+ } catch {}
210
+ await runQuery();
211
+ } finally {
212
+ btn.innerHTML = '&#8635; Refresh';
213
+ btn.disabled = false;
214
+ }
215
+ }
216
+
192
217
  async function loadGraph(graphUri) {
193
218
  const q = graphUri
194
219
  ? 'SELECT ?s ?p ?o WHERE { GRAPH <' + graphUri + '> { ?s ?p ?o } } LIMIT 500'
@@ -10,12 +10,15 @@ import { validateTurtle } from '../lib/validator.js';
10
10
  import { discoverShapes, validateWithShacl, hasShapes } from '../lib/shacl.js';
11
11
  import { materializeInferences, clearInferences } from '../lib/reasoner.js';
12
12
  import { fromSchemaData, toMermaid, toDot } from '../lib/visualizer.js';
13
- import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
13
+ import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
14
14
  import { join } from 'node:path';
15
15
  import { OTX_BOOTSTRAP_TURTLE } from '../templates/otx-ontology.js';
16
16
  import { generateContextSection, updateClaudeMd } from '../templates/claude-md-context.js';
17
17
  import { generateHookScript } from '../templates/session-start-hook.js';
18
+ import { generatePreEditHookScript } from '../templates/pre-edit-hook.js';
18
19
  import { generateSlashCommands } from '../templates/slash-commands.js';
20
+ import { runDoctor } from '../commands/doctor.js';
21
+ import { syncContext } from '../lib/context-sync.js';
19
22
  import { scanCodebase } from '../lib/codebase-scanner.js';
20
23
  import { startGraphServer } from '../lib/graph-server.js';
21
24
  export const MAX_TRIPLES_PER_PUSH = 100;
@@ -337,14 +340,39 @@ async function handleContextScan(args) {
337
340
  : 'Deep scan completed but triple push failed. Use push manually with the generated triples.',
338
341
  };
339
342
  }
340
- // Default: module-level scan (existing behavior)
343
+ // Default: module-level scan now auto-pushes module triples like context_init
341
344
  const maxBytes = args.maxSnapshotBytes ?? 15360;
342
345
  const snapshot = await scanCodebase(process.cwd(), maxBytes);
346
+ let moduleStats = null;
347
+ try {
348
+ const config = loadConfig();
349
+ const contextUri = `${config.graphUri}/context`;
350
+ const adapter = await createReadyAdapter(config);
351
+ if (snapshot.dependencyGraph && snapshot.dependencyGraph.modules.length > 0) {
352
+ const dg = snapshot.dependencyGraph;
353
+ // Scoped delete: clear stale module triples before re-insert
354
+ await adapter.sparqlUpdate(`DELETE WHERE { GRAPH <${contextUri}> { ?m a <https://opentology.dev/vocab#Module> . ?m ?p ?o } }`);
355
+ // Insert fresh triples
356
+ const sparqlTriples = [];
357
+ for (const mod of dg.modules) {
358
+ sparqlTriples.push(`<urn:module:${mod}> a <https://opentology.dev/vocab#Module> .`);
359
+ }
360
+ for (const edge of dg.edges) {
361
+ sparqlTriples.push(`<urn:module:${edge.from}> <https://opentology.dev/vocab#dependsOn> <urn:module:${edge.to}> .`);
362
+ }
363
+ await adapter.sparqlUpdate(`INSERT DATA { GRAPH <${contextUri}> {\n${sparqlTriples.join('\n')}\n} }`);
364
+ moduleStats = { modules: dg.modules.length, edges: dg.edges.length };
365
+ }
366
+ }
367
+ catch {
368
+ // Non-fatal: module triple push is best-effort
369
+ }
343
370
  return {
344
371
  codebaseSnapshot: snapshot,
345
- _hint: snapshot.dependencyGraph && snapshot.dependencyGraph.modules.length > 0
346
- ? 'Analyze codebaseSnapshot and push Knowledge triples via push. Module dependency triples (otx:Module + otx:dependsOn) are available in dependencyGraph — push them to the context graph as-is.'
347
- : 'Analyze codebaseSnapshot and push Knowledge triples via push. No dependency graph was auto-extracted (non-JS/TS project or parsing issue). Inspect key source files manually and push otx:Module + otx:dependsOn triples for the important modules you identify.',
372
+ moduleStats,
373
+ _hint: moduleStats
374
+ ? `Module triples auto-pushed: ${moduleStats.modules} modules, ${moduleStats.edges} edges. Query with: SELECT ?m WHERE { ?m a otx:Module }`
375
+ : 'No dependency graph auto-extracted or push failed. Inspect key source files manually.',
348
376
  };
349
377
  }
350
378
  async function handleContextInit(args) {
@@ -388,6 +416,13 @@ async function handleContextInit(args) {
388
416
  writeFileSync(hookPath, generateHookScript(), 'utf-8');
389
417
  actions.push('Generated hook: .opentology/hooks/session-start.mjs');
390
418
  }
419
+ // Generate pre-edit hook script
420
+ const preEditHookPath = join(hookDir, 'pre-edit.mjs');
421
+ if (!existsSync(preEditHookPath) || force) {
422
+ mkdirSync(hookDir, { recursive: true });
423
+ writeFileSync(preEditHookPath, generatePreEditHookScript(), 'utf-8');
424
+ actions.push('Generated hook: .opentology/hooks/pre-edit.mjs');
425
+ }
391
426
  // Update CLAUDE.md
392
427
  const claudeMdPath = join(process.cwd(), 'CLAUDE.md');
393
428
  const section = generateContextSection(config.projectId, config.graphUri);
@@ -415,6 +450,44 @@ async function handleContextInit(args) {
415
450
  actions.push(`Generated ${slashCreated} slash commands in .claude/commands/`);
416
451
  }
417
452
  saveConfig(config);
453
+ // Auto-register hooks in .claude/settings.json (non-interactive, both hooks)
454
+ const settingsDir = join(process.cwd(), '.claude');
455
+ const settingsPath = join(settingsDir, 'settings.json');
456
+ mkdirSync(settingsDir, { recursive: true });
457
+ let settings = {};
458
+ if (existsSync(settingsPath)) {
459
+ try {
460
+ settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
461
+ }
462
+ catch { /* start fresh */ }
463
+ }
464
+ const hooks = (settings.hooks ?? {});
465
+ let hooksChanged = false;
466
+ const sessionStartCmd = 'node .opentology/hooks/session-start.mjs';
467
+ if (!hooks.SessionStart)
468
+ hooks.SessionStart = [];
469
+ const hasSessionHook = hooks.SessionStart.some((h) => h.command === sessionStartCmd);
470
+ if (!hasSessionHook) {
471
+ hooks.SessionStart.push({ type: 'command', command: sessionStartCmd });
472
+ hooksChanged = true;
473
+ }
474
+ const preEditCmd = 'node .opentology/hooks/pre-edit.mjs';
475
+ if (!hooks.PreToolUse)
476
+ hooks.PreToolUse = [];
477
+ const hasPreEditHook = hooks.PreToolUse.some((h) => h.command === preEditCmd);
478
+ if (!hasPreEditHook) {
479
+ hooks.PreToolUse.push({
480
+ type: 'command',
481
+ command: preEditCmd,
482
+ matcher: { tool_name: 'Edit|Write' },
483
+ });
484
+ hooksChanged = true;
485
+ }
486
+ if (hooksChanged) {
487
+ settings.hooks = hooks;
488
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
489
+ actions.push('Auto-registered hooks in .claude/settings.json');
490
+ }
418
491
  // Auto-push Module triples from dependency graph
419
492
  let moduleStats = null;
420
493
  try {
@@ -448,14 +521,7 @@ async function handleContextInit(args) {
448
521
  actions,
449
522
  moduleStats,
450
523
  dependencyHint,
451
- hookSnippet: {
452
- hooks: {
453
- SessionStart: [{
454
- type: 'command',
455
- command: 'node .opentology/hooks/session-start.mjs',
456
- }],
457
- },
458
- },
524
+ hooksAutoInstalled: hooksChanged,
459
525
  };
460
526
  }
461
527
  async function handleContextLoad() {
@@ -553,6 +619,71 @@ async function handleContextLoad() {
553
619
  delete output.warnings;
554
620
  return output;
555
621
  }
622
+ async function handleContextImpact(args) {
623
+ const filePath = args.filePath;
624
+ if (!filePath)
625
+ throw new Error('filePath is required');
626
+ const config = loadConfig();
627
+ const contextUri = `${config.graphUri}/context`;
628
+ const adapter = await createReadyAdapter(config);
629
+ const OTX = 'https://opentology.dev/vocab#';
630
+ const moduleUriStr = `urn:module:${filePath}`;
631
+ // 1. Modules that depend on this file (dependents / reverse deps)
632
+ const dependentsQuery = `
633
+ SELECT ?dependent WHERE {
634
+ GRAPH <${contextUri}> {
635
+ ?dependent <${OTX}dependsOn> <${moduleUriStr}> .
636
+ }
637
+ }`;
638
+ const dependentsResult = await adapter.sparqlQuery(dependentsQuery);
639
+ const dependents = (dependentsResult.results?.bindings ?? []).map((b) => b.dependent?.value?.replace('urn:module:', '') ?? '').filter(Boolean);
640
+ // 2. Modules this file depends on (dependencies)
641
+ const depsQuery = `
642
+ SELECT ?dep WHERE {
643
+ GRAPH <${contextUri}> {
644
+ <${moduleUriStr}> <${OTX}dependsOn> ?dep .
645
+ }
646
+ }`;
647
+ const depsResult = await adapter.sparqlQuery(depsQuery);
648
+ const dependencies = (depsResult.results?.bindings ?? []).map((b) => b.dep?.value?.replace('urn:module:', '') ?? '').filter(Boolean);
649
+ // 3. Related decisions, issues, knowledge mentioning this file
650
+ const relatedQuery = `
651
+ SELECT ?type ?title ?status ?date WHERE {
652
+ GRAPH <${contextUri}> {
653
+ ?s <${OTX}body> ?body .
654
+ ?s a ?type .
655
+ ?s <${OTX}title> ?title .
656
+ OPTIONAL { ?s <${OTX}status> ?status }
657
+ OPTIONAL { ?s <${OTX}date> ?date }
658
+ FILTER(CONTAINS(?body, "${filePath}"))
659
+ }
660
+ } LIMIT 10`;
661
+ let related = [];
662
+ try {
663
+ const relatedResult = await adapter.sparqlQuery(relatedQuery);
664
+ related = (relatedResult.results?.bindings ?? []).map((b) => ({
665
+ type: b.type?.value?.replace(OTX, '') ?? '',
666
+ title: b.title?.value ?? '',
667
+ status: b.status?.value,
668
+ date: b.date?.value,
669
+ }));
670
+ }
671
+ catch {
672
+ // FILTER/CONTAINS may not be supported — skip gracefully
673
+ }
674
+ const hasDeps = dependents.length > 0 || dependencies.length > 0;
675
+ return {
676
+ filePath,
677
+ moduleUri: moduleUriStr,
678
+ dependents,
679
+ dependencies,
680
+ related,
681
+ impact: dependents.length === 0 ? 'low' : dependents.length <= 3 ? 'medium' : 'high',
682
+ _hint: hasDeps
683
+ ? `This file has ${dependents.length} dependent(s) and ${dependencies.length} dependency(ies). Review dependents before making breaking changes.`
684
+ : 'No module dependencies found in the graph. Run context_scan first to populate module triples.',
685
+ };
686
+ }
556
687
  async function handleContextStatus() {
557
688
  const config = loadConfig();
558
689
  const graphs = config.graphs ?? {};
@@ -1063,6 +1194,36 @@ export async function startMcpServer() {
1063
1194
  },
1064
1195
  },
1065
1196
  },
1197
+ {
1198
+ name: 'context_impact',
1199
+ description: 'Analyze the impact of modifying a file. Returns modules that depend on the target, modules it depends on, and related decisions/issues/knowledge from the context graph. Use this BEFORE editing files to understand the blast radius of changes.',
1200
+ inputSchema: {
1201
+ type: 'object',
1202
+ properties: {
1203
+ filePath: {
1204
+ type: 'string',
1205
+ description: 'Relative file path to analyze (e.g., "src/lib/store-factory.ts")',
1206
+ },
1207
+ },
1208
+ required: ['filePath'],
1209
+ },
1210
+ },
1211
+ {
1212
+ name: 'context_sync',
1213
+ description: 'Auto-sync context graph: recover missed sessions from git log and rescan module dependency graph. Call this at session start to ensure the graph is up to date. Idempotent — safe to call multiple times.',
1214
+ inputSchema: {
1215
+ type: 'object',
1216
+ properties: {},
1217
+ },
1218
+ },
1219
+ {
1220
+ name: 'doctor',
1221
+ description: 'Check project health: config, store connectivity, context initialization, hook scripts, Claude Code settings, and optional dependencies. Returns a list of checks with ok/warn/fail status.',
1222
+ inputSchema: {
1223
+ type: 'object',
1224
+ properties: {},
1225
+ },
1226
+ },
1066
1227
  ],
1067
1228
  }));
1068
1229
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
@@ -1133,6 +1294,15 @@ export async function startMcpServer() {
1133
1294
  result = { url: `http://localhost:${srv.port}`, port: srv.port, message: `Graph server running at http://localhost:${srv.port}` };
1134
1295
  break;
1135
1296
  }
1297
+ case 'context_impact':
1298
+ result = await handleContextImpact(args);
1299
+ break;
1300
+ case 'context_sync':
1301
+ result = await syncContext(loadConfig(), process.cwd());
1302
+ break;
1303
+ case 'doctor':
1304
+ result = await runDoctor();
1305
+ break;
1136
1306
  default:
1137
1307
  throw new Error(`Unknown tool: ${name}`);
1138
1308
  }
@@ -0,0 +1 @@
1
+ export declare function generatePreEditHookScript(): string;
@@ -0,0 +1,101 @@
1
+ export function generatePreEditHookScript() {
2
+ return `#!/usr/bin/env node
3
+ // Generated by: opentology context init
4
+ // PreToolUse hook — queries impact analysis before Edit/Write
5
+ import { existsSync } from 'node:fs';
6
+ import { execFileSync } from 'node:child_process';
7
+ import { join, resolve, dirname } from 'node:path';
8
+
9
+ const TIMEOUT = parseInt(process.env.OPENTOLOGY_HOOK_TIMEOUT || '3000', 10);
10
+
11
+ function findProjectRoot(startDir) {
12
+ let dir = resolve(startDir);
13
+ while (dir !== dirname(dir)) {
14
+ if (existsSync(join(dir, '.opentology.json'))) return dir;
15
+ dir = dirname(dir);
16
+ }
17
+ return null;
18
+ }
19
+
20
+ function findBin(projectRoot) {
21
+ const local = join(projectRoot, 'node_modules', '.bin', 'opentology');
22
+ if (existsSync(local)) return { bin: local, args: [] };
23
+ return { bin: 'npx', args: ['opentology'] };
24
+ }
25
+
26
+ function run(projectRoot, cmdArgs) {
27
+ const { bin, args } = findBin(projectRoot);
28
+ return execFileSync(bin, [...args, ...cmdArgs], {
29
+ cwd: projectRoot,
30
+ timeout: TIMEOUT,
31
+ encoding: 'utf-8',
32
+ stdio: ['pipe', 'pipe', 'pipe'],
33
+ });
34
+ }
35
+
36
+ try {
37
+ const projectRoot = findProjectRoot(process.cwd());
38
+ if (!projectRoot) process.exit(0);
39
+
40
+ // Parse tool input from stdin (Claude Code passes JSON via stdin for hooks)
41
+ let input = '';
42
+ try {
43
+ input = require('fs').readFileSync('/dev/stdin', 'utf-8');
44
+ } catch {
45
+ process.exit(0);
46
+ }
47
+
48
+ let toolInput;
49
+ try {
50
+ toolInput = JSON.parse(input);
51
+ } catch {
52
+ process.exit(0);
53
+ }
54
+
55
+ // Extract file path from Edit or Write tool arguments
56
+ const filePath = toolInput?.tool_input?.file_path;
57
+ if (!filePath) process.exit(0);
58
+
59
+ // Convert absolute path to project-relative path
60
+ const relative = filePath.startsWith(projectRoot)
61
+ ? filePath.slice(projectRoot.length + 1)
62
+ : filePath;
63
+
64
+ // Skip non-source files
65
+ if (relative.startsWith('node_modules/') || relative.startsWith('.')) process.exit(0);
66
+
67
+ const raw = run(projectRoot, ['context', 'impact', '--file', relative, '--format', 'json']);
68
+ const data = JSON.parse(raw);
69
+
70
+ // Only output if there are meaningful dependencies
71
+ if (data.dependents?.length > 0 || data.related?.length > 0) {
72
+ const lines = [];
73
+ lines.push(\`[Impact Analysis] \${relative}\`);
74
+
75
+ if (data.dependents.length > 0) {
76
+ lines.push(\`Dependents (\${data.dependents.length}): \${data.dependents.slice(0, 5).join(', ')}\${data.dependents.length > 5 ? '...' : ''}\`);
77
+ }
78
+
79
+ if (data.dependencies?.length > 0) {
80
+ lines.push(\`Dependencies (\${data.dependencies.length}): \${data.dependencies.slice(0, 5).join(', ')}\${data.dependencies.length > 5 ? '...' : ''}\`);
81
+ }
82
+
83
+ if (data.related?.length > 0) {
84
+ lines.push('Related:');
85
+ for (const r of data.related.slice(0, 3)) {
86
+ lines.push(\` - [\${r.type}] \${r.title}\${r.status ? ' (' + r.status + ')' : ''}\`);
87
+ }
88
+ }
89
+
90
+ if (data.impact === 'high') {
91
+ lines.push('\\u26a0\\ufe0f HIGH IMPACT — multiple modules depend on this file. Review dependents before making breaking changes.');
92
+ }
93
+
94
+ console.log(lines.join('\\n'));
95
+ }
96
+ } catch {
97
+ // Silent exit — do not block editing
98
+ process.exit(0);
99
+ }
100
+ `;
101
+ }
@@ -74,6 +74,26 @@ try {
74
74
  const projectRoot = findProjectRoot(process.cwd());
75
75
  if (!projectRoot) process.exit(0);
76
76
 
77
+ // Step 1: Auto-sync — recover missed sessions and rescan modules
78
+ let syncSummary = '';
79
+ try {
80
+ const syncRaw = run(projectRoot, ['context', 'sync', '--format', 'json']);
81
+ const syncData = JSON.parse(syncRaw);
82
+ const parts = [];
83
+ if (syncData.sessionsRecovered > 0) {
84
+ parts.push(\`\${syncData.sessionsRecovered} session(s) recovered from git\`);
85
+ }
86
+ if (syncData.modulesUpdated) {
87
+ parts.push(\`modules rescanned (\${syncData.moduleStats?.modules ?? 0} modules)\`);
88
+ }
89
+ if (parts.length > 0) {
90
+ syncSummary = 'Auto-sync: ' + parts.join(', ') + '.';
91
+ }
92
+ } catch {
93
+ // Sync failed — continue with load
94
+ }
95
+
96
+ // Step 2: Load context
77
97
  const raw = run(projectRoot, ['context', 'load', '--format', 'json']);
78
98
  const data = JSON.parse(raw);
79
99
 
@@ -82,8 +102,15 @@ try {
82
102
  (data.recentDecisions?.length > 0);
83
103
 
84
104
  if (hasData) {
85
- console.log(formatOutput(data));
105
+ const output = formatOutput(data);
106
+ if (syncSummary) {
107
+ console.log(syncSummary + '\\n');
108
+ }
109
+ console.log(output);
86
110
  } else {
111
+ if (syncSummary) {
112
+ console.log(syncSummary);
113
+ }
87
114
  console.log(\`OpenTology context active for \${data.projectId}. No session data yet.\`);
88
115
  }
89
116
  } catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opentology",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "CLI-managed RDF/SPARQL infrastructure — Supabase for RDF",
5
5
  "type": "module",
6
6
  "bin": {