dotscope 0.1.0__py3-none-any.whl

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 (114) hide show
  1. dotscope/.scope +63 -0
  2. dotscope/__init__.py +3 -0
  3. dotscope/absorber.py +390 -0
  4. dotscope/assertions.py +128 -0
  5. dotscope/ast_analyzer.py +2 -0
  6. dotscope/backtest.py +2 -0
  7. dotscope/bench.py +141 -0
  8. dotscope/budget.py +3 -0
  9. dotscope/cache.py +2 -0
  10. dotscope/check/__init__.py +1 -0
  11. dotscope/check/acknowledge.py +2 -0
  12. dotscope/check/checker.py +3 -0
  13. dotscope/check/checks/__init__.py +1 -0
  14. dotscope/check/checks/antipattern.py +2 -0
  15. dotscope/check/checks/boundary.py +2 -0
  16. dotscope/check/checks/contracts.py +3 -0
  17. dotscope/check/checks/direction.py +2 -0
  18. dotscope/check/checks/intent.py +2 -0
  19. dotscope/check/checks/stability.py +2 -0
  20. dotscope/check/constraints.py +2 -0
  21. dotscope/check/models.py +15 -0
  22. dotscope/cli.py +1447 -0
  23. dotscope/composer.py +147 -0
  24. dotscope/constants.py +45 -0
  25. dotscope/context.py +60 -0
  26. dotscope/counterfactual.py +180 -0
  27. dotscope/debug.py +220 -0
  28. dotscope/discovery.py +104 -0
  29. dotscope/formatter.py +157 -0
  30. dotscope/graph.py +3 -0
  31. dotscope/health.py +212 -0
  32. dotscope/help.py +204 -0
  33. dotscope/history.py +6 -0
  34. dotscope/hooks.py +2 -0
  35. dotscope/ingest.py +858 -0
  36. dotscope/intent.py +618 -0
  37. dotscope/lessons.py +223 -0
  38. dotscope/matcher.py +104 -0
  39. dotscope/mcp_server.py +1081 -0
  40. dotscope/models/.scope +45 -0
  41. dotscope/models/__init__.py +7 -0
  42. dotscope/models/core.py +288 -0
  43. dotscope/models/history.py +73 -0
  44. dotscope/models/intent.py +213 -0
  45. dotscope/models/passes.py +58 -0
  46. dotscope/models/state.py +250 -0
  47. dotscope/models.py +9 -0
  48. dotscope/near_miss.py +3 -0
  49. dotscope/onboarding.py +2 -0
  50. dotscope/parser.py +387 -0
  51. dotscope/passes/.scope +105 -0
  52. dotscope/passes/__init__.py +1 -0
  53. dotscope/passes/ast_analyzer.py +508 -0
  54. dotscope/passes/backtest.py +198 -0
  55. dotscope/passes/budget_allocator.py +164 -0
  56. dotscope/passes/convention_compliance.py +40 -0
  57. dotscope/passes/convention_discovery.py +247 -0
  58. dotscope/passes/convention_parser.py +223 -0
  59. dotscope/passes/graph_builder.py +299 -0
  60. dotscope/passes/history_miner.py +336 -0
  61. dotscope/passes/incremental.py +149 -0
  62. dotscope/passes/lang/__init__.py +38 -0
  63. dotscope/passes/lang/_base.py +20 -0
  64. dotscope/passes/lang/_treesitter.py +93 -0
  65. dotscope/passes/lang/go.py +333 -0
  66. dotscope/passes/lang/javascript.py +348 -0
  67. dotscope/passes/lazy.py +152 -0
  68. dotscope/passes/semantic_diff.py +160 -0
  69. dotscope/passes/sentinel/__init__.py +1 -0
  70. dotscope/passes/sentinel/acknowledge.py +222 -0
  71. dotscope/passes/sentinel/checker.py +383 -0
  72. dotscope/passes/sentinel/checks/__init__.py +1 -0
  73. dotscope/passes/sentinel/checks/antipattern.py +84 -0
  74. dotscope/passes/sentinel/checks/boundary.py +46 -0
  75. dotscope/passes/sentinel/checks/contracts.py +148 -0
  76. dotscope/passes/sentinel/checks/convention.py +54 -0
  77. dotscope/passes/sentinel/checks/direction.py +71 -0
  78. dotscope/passes/sentinel/checks/intent.py +207 -0
  79. dotscope/passes/sentinel/checks/stability.py +66 -0
  80. dotscope/passes/sentinel/checks/voice.py +108 -0
  81. dotscope/passes/sentinel/constraints.py +472 -0
  82. dotscope/passes/sentinel/line_filter.py +88 -0
  83. dotscope/passes/sentinel/models.py +15 -0
  84. dotscope/passes/virtual.py +239 -0
  85. dotscope/passes/voice.py +162 -0
  86. dotscope/passes/voice_defaults.py +28 -0
  87. dotscope/passes/voice_discovery.py +245 -0
  88. dotscope/paths.py +32 -0
  89. dotscope/progress.py +44 -0
  90. dotscope/regression.py +147 -0
  91. dotscope/resolver.py +203 -0
  92. dotscope/scanner.py +246 -0
  93. dotscope/sessions.py +2 -0
  94. dotscope/storage/.scope +64 -0
  95. dotscope/storage/__init__.py +1 -0
  96. dotscope/storage/cache.py +114 -0
  97. dotscope/storage/claude_hooks.py +119 -0
  98. dotscope/storage/git_hooks.py +277 -0
  99. dotscope/storage/incremental_state.py +61 -0
  100. dotscope/storage/mcp_config.py +98 -0
  101. dotscope/storage/near_miss.py +183 -0
  102. dotscope/storage/onboarding.py +150 -0
  103. dotscope/storage/session_manager.py +195 -0
  104. dotscope/storage/timing.py +84 -0
  105. dotscope/timing.py +2 -0
  106. dotscope/tokens.py +53 -0
  107. dotscope/utility.py +123 -0
  108. dotscope/virtual.py +3 -0
  109. dotscope/visibility.py +664 -0
  110. dotscope-0.1.0.dist-info/METADATA +50 -0
  111. dotscope-0.1.0.dist-info/RECORD +114 -0
  112. dotscope-0.1.0.dist-info/WHEEL +4 -0
  113. dotscope-0.1.0.dist-info/entry_points.txt +3 -0
  114. dotscope-0.1.0.dist-info/licenses/LICENSE +21 -0
dotscope/mcp_server.py ADDED
@@ -0,0 +1,1081 @@
1
+ """MCP server for dotscope — the primary agent-facing interface.
2
+
3
+ Exposes scope resolution, matching, and context as MCP tools
4
+ that any MCP-compatible agent can call.
5
+
6
+ Install: pip install dotscope[mcp]
7
+ Run: dotscope-mcp (stdio transport)
8
+
9
+ Configure in Claude Desktop or similar:
10
+ {
11
+ "mcpServers": {
12
+ "dotscope": {
13
+ "command": "dotscope-mcp"
14
+ }
15
+ }
16
+ }
17
+ """
18
+
19
+
20
+ import json
21
+ import os
22
+ import sys
23
+ from typing import Optional
24
+
25
+
26
+ def main():
27
+ """MCP server entry point."""
28
+ try:
29
+ from mcp.server.fastmcp import FastMCP
30
+ except ImportError:
31
+ print(
32
+ "MCP server requires the 'mcp' package.\n"
33
+ "Install with: pip install dotscope[mcp]",
34
+ file=sys.stderr,
35
+ )
36
+ sys.exit(1)
37
+
38
+ # Parse --root argument if provided
39
+ import argparse
40
+ parser = argparse.ArgumentParser(add_help=False)
41
+ parser.add_argument("--root", default=None, help="Repository root path")
42
+ known, _remaining = parser.parse_known_args()
43
+ _cli_root = known.root
44
+
45
+ mcp = FastMCP("dotscope")
46
+
47
+ # Session-level tracker (lives across tool calls in a single MCP session)
48
+ from .visibility import SessionTracker
49
+ tracker = SessionTracker()
50
+ _root = None # Will be set below
51
+
52
+ # Load cached data from .dotscope/ for attribution hints + session stats
53
+ _repo_tokens = 0
54
+ _cached_history = None
55
+ _cached_graph_hubs = {}
56
+ try:
57
+ from .discovery import find_repo_root
58
+ from .parser import parse_scopes_index
59
+ from .storage.cache import load_cached_history, load_cached_graph_hubs
60
+ _root = find_repo_root(_cli_root)
61
+ if _root:
62
+ _idx_path = os.path.join(_root, ".scopes")
63
+ if os.path.exists(_idx_path):
64
+ _idx = parse_scopes_index(_idx_path)
65
+ _repo_tokens = _idx.total_repo_tokens
66
+ _cached_history = load_cached_history(_root)
67
+ _cached_graph_hubs = load_cached_graph_hubs(_root)
68
+ tracker.set_repo_root(_root)
69
+ except Exception:
70
+ pass
71
+
72
+ # Print session summary on server shutdown
73
+ import atexit
74
+
75
+ def _print_session_summary():
76
+ summary = tracker.format_terminal()
77
+ if summary:
78
+ print(summary, file=sys.stderr)
79
+
80
+ def _save_session_scopes():
81
+ try:
82
+ from .storage.near_miss import save_session_scopes
83
+ scopes = list(tracker._stats.unique_scopes)
84
+ if scopes and _root:
85
+ save_session_scopes(_root, scopes)
86
+ except Exception:
87
+ pass
88
+
89
+ atexit.register(_print_session_summary)
90
+ atexit.register(_save_session_scopes)
91
+
92
+ @mcp.tool()
93
+ def resolve_scope(
94
+ scope: str,
95
+ budget: Optional[int] = None,
96
+ follow_related: bool = True,
97
+ format: str = "json",
98
+ task: Optional[str] = None,
99
+ ) -> str:
100
+ """Resolve a scope expression to a file list with architectural context.
101
+
102
+ Scope expressions support composition:
103
+ - "auth" — single scope
104
+ - "auth+payments" — merge two scopes (union of files)
105
+ - "auth-tests" — subtract (auth files minus test scope files)
106
+ - "auth&api" — intersect (only files in both)
107
+ - "auth@context" — context only, no files
108
+
109
+ If budget is set, returns the most relevant files fitting within
110
+ that token count. Context is always included first, then files are
111
+ ranked by historical utility and loaded until the budget is exhausted.
112
+
113
+ Response includes scope_accuracy when observation data exists.
114
+
115
+ Args:
116
+ scope: Scope name, path, or composition expression
117
+ budget: Max tokens for context + files (None = no limit)
118
+ follow_related: Whether to follow related scope references
119
+ format: Output format — "json", "plain", or "cursor"
120
+ """
121
+ import time as _time
122
+ _resolve_start = _time.perf_counter()
123
+
124
+ from pathlib import Path
125
+ from .composer import compose
126
+ from .passes.budget_allocator import apply_budget
127
+ from .discovery import find_repo_root
128
+ from .formatter import format_resolved
129
+
130
+ root = find_repo_root()
131
+ dot_dir = Path(root) / ".dotscope" if root else None
132
+ resolved = compose(scope, root=root, follow_related=follow_related)
133
+
134
+ # Wire 1: inject lessons and invariants into context
135
+ if dot_dir and dot_dir.exists():
136
+ try:
137
+ from .lessons import load_lessons, load_invariants, format_lessons_for_context
138
+ module = scope.split("+")[0].split("-")[0].split("&")[0].split("@")[0]
139
+ lessons = load_lessons(dot_dir, module)
140
+ invariants = load_invariants(dot_dir, module)
141
+ enrichment = format_lessons_for_context(lessons, invariants)
142
+ if enrichment:
143
+ resolved.context = resolved.context + "\n\n" + enrichment
144
+ except Exception:
145
+ pass # Enrichment failures never block resolution
146
+
147
+ # Wire 3: load utility scores for budget ranking
148
+ utility_scores = None
149
+ if dot_dir and dot_dir.exists():
150
+ try:
151
+ from .utility import load_utility_scores
152
+ utility_scores = load_utility_scores(dot_dir)
153
+ except Exception:
154
+ pass
155
+
156
+ # Load assertions for budget enforcement
157
+ required_files = None
158
+ assertions = []
159
+ try:
160
+ from .assertions import load_assertions, get_required_files
161
+ module = scope.split("+")[0].split("-")[0].split("&")[0].split("@")[0]
162
+ assertions = load_assertions(root, module)
163
+ required_files = get_required_files(assertions, module) or None
164
+ except Exception:
165
+ pass
166
+
167
+ if budget is not None:
168
+ try:
169
+ resolved = apply_budget(resolved, budget, utility_scores=utility_scores,
170
+ required_files=required_files)
171
+ except Exception as exc:
172
+ # ContextExhaustionError — return as structured error
173
+ if hasattr(exc, "to_dict"):
174
+ return json.dumps(exc.to_dict(), indent=2)
175
+ raise
176
+
177
+ # Track session (MCP calls only — compose stays pure)
178
+ session_id = None
179
+ try:
180
+ from .storage.session_manager import SessionManager
181
+ mgr = SessionManager(root)
182
+ mgr.ensure_initialized()
183
+ task_str = f"resolve {scope}" + (f" (budget={budget})" if budget else "")
184
+ session_id = mgr.create_session(scope, task_str, resolved.files, resolved.context)
185
+ resolved.context = f"# dotscope-session: {session_id}\n{resolved.context}"
186
+ # Onboarding
187
+ from .storage.onboarding import mark_milestone, increment_counter
188
+ mark_milestone(root, "first_session")
189
+ increment_counter(root, "sessions_completed")
190
+ except Exception:
191
+ pass # Session tracking failures never block resolution
192
+
193
+ # Record timing
194
+ try:
195
+ import time as _time
196
+ _resolve_end = _time.perf_counter()
197
+ except Exception:
198
+ pass
199
+
200
+ output = format_resolved(resolved, fmt=format, root=root)
201
+
202
+ # Enrich JSON responses with visibility metadata
203
+ if format == "json":
204
+ try:
205
+ data = json.loads(output)
206
+
207
+ # Feature 2: Attribution hints (with provenance)
208
+ from .visibility import (
209
+ extract_attribution_hints, build_accuracy,
210
+ check_health_nudges,
211
+ )
212
+ module = scope.split("+")[0].split("-")[0].split("&")[0].split("@")[0]
213
+ contracts = (
214
+ _cached_history.implicit_contracts
215
+ if _cached_history else None
216
+ )
217
+ data["attribution_hints"] = extract_attribution_hints(
218
+ resolved.context,
219
+ implicit_contracts=contracts,
220
+ graph_hubs=_cached_graph_hubs,
221
+ scope_directory=module,
222
+ )
223
+
224
+ # Constraints (prophylactic enforcement)
225
+ try:
226
+ from .passes.sentinel.constraints import build_constraints
227
+ from .intent import load_conventions, load_intents
228
+ invariants = {}
229
+ inv_path = os.path.join(root, ".dotscope", "invariants.json")
230
+ if os.path.exists(inv_path):
231
+ with open(inv_path, "r", encoding="utf-8") as _f:
232
+ invariants = json.loads(_f.read())
233
+ scopes_data = {}
234
+ from .passes.sentinel.checker import _load_scopes_with_antipatterns
235
+ scopes_data = _load_scopes_with_antipatterns(root)
236
+ intents = load_intents(root)
237
+ conventions = load_conventions(root)
238
+ constraints = build_constraints(
239
+ module, root, invariants, scopes_data, intents,
240
+ graph_hubs=_cached_graph_hubs, task=task,
241
+ conventions=conventions,
242
+ )
243
+ if constraints:
244
+ data["constraints"] = [
245
+ {
246
+ "category": c.category,
247
+ "message": c.message,
248
+ "file": c.file,
249
+ "confidence": c.confidence,
250
+ }
251
+ for c in constraints
252
+ ]
253
+
254
+ # Routing guidance: positive-frame "what to do"
255
+ from .passes.sentinel.constraints import build_routing_guidance
256
+ vc = None
257
+ try:
258
+ from .intent import load_voice_config
259
+ vc = load_voice_config(root)
260
+ except Exception:
261
+ pass
262
+ routing = build_routing_guidance(
263
+ module, conventions=conventions, voice_config=vc,
264
+ repo_root=root,
265
+ )
266
+ if routing:
267
+ data["routing"] = [
268
+ {
269
+ "category": r.category,
270
+ "message": r.message,
271
+ "confidence": r.confidence,
272
+ }
273
+ for r in routing
274
+ ]
275
+
276
+ # Gap 2: Adjacent scope routing
277
+ from .passes.sentinel.constraints import build_adjacent_routing
278
+ scopes_index = {}
279
+ try:
280
+ from .scanner import load_scopes_index
281
+ scopes_index = load_scopes_index(root)
282
+ except Exception:
283
+ pass
284
+ adjacent = build_adjacent_routing(
285
+ module, graph_hubs=_cached_graph_hubs,
286
+ all_scopes=scopes_index, conventions=conventions,
287
+ )
288
+ if adjacent:
289
+ data["routing_adjacent"] = [
290
+ {
291
+ "scope": r.metadata.get("adjacent_scope", ""),
292
+ "message": r.message,
293
+ }
294
+ for r in adjacent
295
+ ]
296
+
297
+ except Exception:
298
+ pass
299
+
300
+ # Gap 4: Last observation feedback
301
+ try:
302
+ obs_path = os.path.join(root, ".dotscope", "last_observation.json")
303
+ if os.path.exists(obs_path):
304
+ with open(obs_path, "r", encoding="utf-8") as _f:
305
+ last_obs = json.loads(_f.read())
306
+ if last_obs.get("scope") == module or not last_obs.get("scope"):
307
+ data["last_observation"] = last_obs
308
+ except Exception:
309
+ pass
310
+
311
+ # Voice injection
312
+ try:
313
+ from .intent import load_voice_config
314
+ vc = load_voice_config(root)
315
+ if vc:
316
+ from .passes.voice import build_voice_response
317
+ data["voice"] = build_voice_response(
318
+ vc, root, resolved.files, conventions,
319
+ )
320
+ except Exception:
321
+ pass
322
+
323
+ # Track for session summary (inject _repo_tokens, then strip)
324
+ data["_repo_tokens"] = _repo_tokens
325
+ tracker.record_resolve(module, data)
326
+ data.pop("_repo_tokens", None)
327
+
328
+ # Features requiring observation data
329
+ if dot_dir and dot_dir.exists():
330
+ from .storage.session_manager import SessionManager
331
+ mgr = SessionManager(root)
332
+ sessions = mgr.get_sessions(limit=200)
333
+ scope_session_ids = {
334
+ s.session_id for s in sessions
335
+ if scope in s.scope_expr
336
+ }
337
+ observations = [
338
+ o for o in mgr.get_observations(limit=200)
339
+ if o.session_id in scope_session_ids
340
+ ]
341
+
342
+ # Unified accuracy (merges scope_accuracy + recent_learning)
343
+ accuracy = build_accuracy(observations, scope)
344
+ if accuracy:
345
+ data["accuracy"] = accuracy
346
+
347
+ # Feature 4: Health nudges
348
+ nudges = check_health_nudges(
349
+ observations, scope, repo_root=root,
350
+ )
351
+ # Check for needs_full_ingest marker
352
+ _marker = os.path.join(root, ".dotscope", "needs_full_ingest")
353
+ if os.path.exists(_marker):
354
+ nudges = nudges or []
355
+ nudges.append(
356
+ "Full re-ingest recommended: run `dotscope ingest .`"
357
+ )
358
+ if nudges:
359
+ data["health_warnings"] = nudges
360
+
361
+ # Feature 5: Near-misses (from persistent storage)
362
+ try:
363
+ from .storage.near_miss import load_recent_near_misses
364
+ nms = load_recent_near_misses(root, module)
365
+ if nms:
366
+ data["near_misses"] = nms
367
+ except Exception:
368
+ pass
369
+
370
+ # Output assertions (ensure_context_contains, ensure_constraints)
371
+ if assertions:
372
+ try:
373
+ from .assertions import check_output_assertions
374
+ module = scope.split("+")[0].split("-")[0].split("&")[0].split("@")[0]
375
+ err = check_output_assertions(
376
+ resolved.context,
377
+ data.get("constraints", []),
378
+ assertions, module,
379
+ )
380
+ if err:
381
+ return json.dumps(err.to_dict(), indent=2)
382
+ except Exception:
383
+ pass
384
+
385
+ output = json.dumps(data, indent=2)
386
+ except Exception:
387
+ pass # Visibility metadata is best-effort, never blocks
388
+
389
+ # Record timing
390
+ try:
391
+ elapsed_ms = (_time.perf_counter() - _resolve_start) * 1000
392
+ from .storage.timing import record_timing
393
+ if root:
394
+ record_timing(root, "resolve", elapsed_ms)
395
+ except Exception:
396
+ pass
397
+
398
+ return output
399
+
400
+ @mcp.tool()
401
+ def match_scope(task: str) -> str:
402
+ """Find the most relevant scope(s) for a task description.
403
+
404
+ Uses keyword overlap between the task and scope keywords/tags/descriptions.
405
+ Returns a ranked list with confidence scores.
406
+
407
+ Args:
408
+ task: Natural language description of what you're working on
409
+ """
410
+ from .discovery import find_repo_root, load_index, find_all_scopes
411
+ from .matcher import match_task
412
+ from .parser import parse_scope_file
413
+
414
+ root = find_repo_root()
415
+ if root is None:
416
+ return json.dumps({"error": "Could not find repository root"})
417
+
418
+ index = load_index(root)
419
+ scope_files = find_all_scopes(root)
420
+
421
+ scopes = []
422
+ if index:
423
+ for name, entry in index.scopes.items():
424
+ scopes.append((name, entry.keywords, entry.description or ""))
425
+ else:
426
+ for sf in scope_files:
427
+ try:
428
+ config = parse_scope_file(sf)
429
+ name = os.path.relpath(os.path.dirname(sf), root)
430
+ scopes.append((name, config.tags, config.description))
431
+ except (ValueError, IOError):
432
+ continue
433
+
434
+ matches = match_task(task, scopes)
435
+
436
+ return json.dumps({
437
+ "matches": [
438
+ {"scope": name, "confidence": round(score, 3)}
439
+ for name, score in matches
440
+ ],
441
+ "task": task,
442
+ }, indent=2)
443
+
444
+ @mcp.tool()
445
+ def get_context(scope: str, section: Optional[str] = None) -> str:
446
+ """Get architectural context for a scope without loading any files.
447
+
448
+ This is the knowledge that isn't in the code itself: invariants,
449
+ gotchas, conventions, architectural decisions.
450
+
451
+ Args:
452
+ scope: Scope name or path
453
+ section: Optional section name to filter (e.g., "invariants", "gotchas")
454
+ """
455
+ from .discovery import find_scope, find_repo_root
456
+ from .context import query_context
457
+
458
+ root = find_repo_root()
459
+ config = find_scope(scope, root)
460
+ if config is None:
461
+ return json.dumps({"error": f"Scope not found: {scope}"})
462
+
463
+ result = query_context(config.context, section)
464
+ return json.dumps({
465
+ "scope": scope,
466
+ "section": section,
467
+ "context": result,
468
+ "description": config.description,
469
+ }, indent=2)
470
+
471
+ @mcp.tool()
472
+ def list_scopes() -> str:
473
+ """List all available scopes with descriptions, tags, and token estimates.
474
+
475
+ Searches the .scopes index and/or walks the directory tree for .scope files.
476
+ """
477
+ from .discovery import find_repo_root, load_index, find_all_scopes
478
+ from .parser import parse_scope_file
479
+
480
+ root = find_repo_root()
481
+ if root is None:
482
+ return json.dumps({"error": "Could not find repository root"})
483
+
484
+ scopes = []
485
+ index = load_index(root)
486
+
487
+ if index:
488
+ for name, entry in index.scopes.items():
489
+ scopes.append({
490
+ "name": name,
491
+ "path": entry.path,
492
+ "keywords": entry.keywords,
493
+ "description": entry.description,
494
+ })
495
+ else:
496
+ for sf in find_all_scopes(root):
497
+ try:
498
+ config = parse_scope_file(sf)
499
+ scopes.append({
500
+ "name": os.path.relpath(os.path.dirname(sf), root),
501
+ "path": os.path.relpath(sf, root),
502
+ "tags": config.tags,
503
+ "description": config.description,
504
+ "tokens_estimate": config.tokens_estimate,
505
+ })
506
+ except (ValueError, IOError):
507
+ continue
508
+
509
+ return json.dumps({"scopes": scopes, "count": len(scopes)}, indent=2)
510
+
511
+ @mcp.tool()
512
+ def validate_scopes() -> str:
513
+ """Validate all .scope files for broken paths and common issues.
514
+
515
+ Checks:
516
+ - Include paths exist
517
+ - Related scope files exist
518
+ - Description is not empty
519
+ - Context field is present (the most valuable part)
520
+ """
521
+ from .discovery import find_repo_root, find_all_scopes
522
+ from .parser import parse_scope_file
523
+
524
+ root = find_repo_root()
525
+ if root is None:
526
+ return json.dumps({"error": "Could not find repository root"})
527
+
528
+ issues = []
529
+ for sf in find_all_scopes(root):
530
+ rel = os.path.relpath(sf, root)
531
+ try:
532
+ config = parse_scope_file(sf)
533
+ except ValueError as e:
534
+ issues.append({"scope": rel, "severity": "error", "message": str(e)})
535
+ continue
536
+
537
+ for inc in config.includes:
538
+ full = os.path.normpath(os.path.join(root, inc))
539
+ if not os.path.exists(full.rstrip("/")):
540
+ issues.append({
541
+ "scope": rel, "severity": "error",
542
+ "message": f"include path not found: {inc}",
543
+ })
544
+
545
+ if not config.context_str.strip():
546
+ issues.append({
547
+ "scope": rel, "severity": "warning",
548
+ "message": "no context — this is the most valuable part",
549
+ })
550
+
551
+ return json.dumps({"issues": issues, "count": len(issues)}, indent=2)
552
+
553
+ @mcp.tool()
554
+ def scope_health() -> str:
555
+ """Report on scope health: staleness, coverage gaps, and import drift.
556
+
557
+ Staleness: files changed since .scope was last modified
558
+ Coverage: directories with no .scope file
559
+ Drift: imports in scoped files that aren't in the includes list
560
+ """
561
+ from .health import full_health_report
562
+ from .discovery import find_repo_root
563
+
564
+ root = find_repo_root()
565
+ if root is None:
566
+ return json.dumps({"error": "Could not find repository root"})
567
+
568
+ report = full_health_report(root)
569
+ return json.dumps({
570
+ "scopes_checked": report.scopes_checked,
571
+ "coverage_pct": round(report.coverage_pct, 1),
572
+ "directories_covered": report.directories_covered,
573
+ "directories_total": report.directories_total,
574
+ "issues": [
575
+ {
576
+ "scope": i.scope_path,
577
+ "severity": i.severity,
578
+ "category": i.category,
579
+ "message": i.message,
580
+ }
581
+ for i in report.issues
582
+ ],
583
+ "error_count": len(report.errors),
584
+ "warning_count": len(report.warnings),
585
+ }, indent=2)
586
+
587
+ @mcp.tool()
588
+ def ingest_codebase(
589
+ directory: str = ".",
590
+ mine_history: bool = True,
591
+ absorb_docs: bool = True,
592
+ dry_run: bool = False,
593
+ ) -> str:
594
+ """Reverse-engineer .scope files from an existing codebase.
595
+
596
+ Analyzes the dependency graph, mines git history, and absorbs existing
597
+ documentation to produce complete .scope files for every detected module.
598
+
599
+ This is how dotscope enters any codebase — no manual .scope writing needed.
600
+
601
+ Args:
602
+ directory: Repository root to ingest (default: current directory)
603
+ mine_history: Whether to analyze git history for change patterns
604
+ absorb_docs: Whether to scan for README, docstrings, signal comments
605
+ dry_run: If True, return the plan without writing files
606
+ """
607
+ from .ingest import ingest
608
+
609
+ root = os.path.abspath(directory)
610
+ plan = ingest(
611
+ root,
612
+ mine_history=mine_history,
613
+ absorb=absorb_docs,
614
+ dry_run=dry_run,
615
+ quiet=True,
616
+ )
617
+
618
+ # Discovery data for programmatic consumers
619
+ from .ingest import (
620
+ _is_cross_module, _find_hub_discoveries, _find_volatility_surprises,
621
+ )
622
+ cross_module_contracts = []
623
+ if plan.history and plan.history.implicit_contracts:
624
+ cross_module_contracts = [
625
+ ic for ic in plan.history.implicit_contracts
626
+ if _is_cross_module(ic.trigger_file, ic.coupled_file)
627
+ and ic.confidence >= 0.65
628
+ ]
629
+ hubs = _find_hub_discoveries(plan.graph) if plan.graph else []
630
+ surprises = (
631
+ _find_volatility_surprises(plan.history) if plan.history else []
632
+ )
633
+
634
+ # Token reduction
635
+ real_scopes = [
636
+ ps for ps in plan.scopes
637
+ if not ps.directory.startswith("virtual/")
638
+ ]
639
+ token_reduction = None
640
+ if plan.total_repo_tokens > 0 and real_scopes:
641
+ avg = sum(
642
+ s.config.tokens_estimate or 0 for s in real_scopes
643
+ ) / max(len(real_scopes), 1)
644
+ token_reduction = round(
645
+ (1 - avg / plan.total_repo_tokens) * 100, 1
646
+ )
647
+
648
+ return json.dumps({
649
+ "scopes_planned": len(plan.scopes),
650
+ "scopes": [
651
+ {
652
+ "directory": ps.directory,
653
+ "description": ps.config.description,
654
+ "confidence": round(ps.confidence, 3),
655
+ "includes_count": len(ps.config.includes),
656
+ "token_estimate": ps.config.tokens_estimate,
657
+ "signals": ps.signals,
658
+ "has_context": bool(ps.config.context_str.strip()),
659
+ }
660
+ for ps in plan.scopes
661
+ ],
662
+ "dry_run": dry_run,
663
+ "graph_summary": plan.graph_summary,
664
+ "total_repo_files": plan.total_repo_files,
665
+ "total_repo_tokens": plan.total_repo_tokens,
666
+ "token_reduction_pct": token_reduction,
667
+ "discoveries": {
668
+ "implicit_contracts": len(cross_module_contracts),
669
+ "cross_cutting_hubs": len(hubs),
670
+ "volatility_surprises": len(surprises),
671
+ },
672
+ }, indent=2)
673
+
674
+ @mcp.tool()
675
+ def impact_analysis(file_path: str) -> str:
676
+ """Predict the blast radius of changes to a specific file.
677
+
678
+ Returns which files import this file (direct dependents),
679
+ transitive dependents, and which scopes are affected.
680
+
681
+ Args:
682
+ file_path: Path to the file to analyze (relative to repo root)
683
+ """
684
+ from .passes.graph_builder import build_graph
685
+ from .discovery import find_repo_root
686
+
687
+ root = find_repo_root()
688
+ if root is None:
689
+ return json.dumps({"error": "Could not find repository root"})
690
+
691
+ graph = build_graph(root)
692
+ target = os.path.relpath(os.path.abspath(file_path), root)
693
+ node = graph.files.get(target)
694
+
695
+ if not node:
696
+ return json.dumps({"error": f"File not found in graph: {target}"})
697
+
698
+ # Transitive dependents
699
+ transitive = set()
700
+ for direct in node.imported_by:
701
+ dep_node = graph.files.get(direct)
702
+ if dep_node:
703
+ for t in dep_node.imported_by:
704
+ if t != target:
705
+ transitive.add(t)
706
+
707
+ affected_modules = set()
708
+ for f in list(node.imported_by) + list(transitive):
709
+ parts = f.split("/")
710
+ if len(parts) > 1:
711
+ affected_modules.add(parts[0])
712
+
713
+ total = 1 + len(node.imported_by) + len(transitive)
714
+ risk = "low" if total <= 3 else ("medium" if total <= 10 else "high")
715
+
716
+ return json.dumps({
717
+ "file": target,
718
+ "imports": node.imports,
719
+ "imported_by": node.imported_by,
720
+ "transitive_dependents": sorted(transitive),
721
+ "affected_modules": sorted(affected_modules),
722
+ "blast_radius": total,
723
+ "risk": risk,
724
+ }, indent=2)
725
+
726
+ @mcp.tool()
727
+ def backtest_scopes_tool(commits: int = 50) -> str:
728
+ """Validate existing scopes against git history.
729
+
730
+ Replays recent commits and measures whether each scope's includes
731
+ would have covered the files actually changed. Reports recall
732
+ per scope and suggests missing includes.
733
+
734
+ Args:
735
+ commits: Number of recent commits to test against
736
+ """
737
+ from .passes.backtest import backtest_scopes as _backtest
738
+ from .discovery import find_repo_root, find_all_scopes
739
+ from .parser import parse_scope_file
740
+
741
+ root = find_repo_root()
742
+ if root is None:
743
+ return json.dumps({"error": "Could not find repository root"})
744
+
745
+ configs = []
746
+ for sf in find_all_scopes(root):
747
+ try:
748
+ configs.append(parse_scope_file(sf))
749
+ except (ValueError, IOError):
750
+ continue
751
+
752
+ if not configs:
753
+ return json.dumps({"error": "No .scope files found"})
754
+
755
+ report = _backtest(root, configs, n_commits=commits)
756
+ return json.dumps({
757
+ "total_commits": report.total_commits,
758
+ "overall_recall": report.overall_recall,
759
+ "results": [
760
+ {
761
+ "scope": r.scope_path,
762
+ "recall": r.recall,
763
+ "commits_tested": r.total_commits,
764
+ "fully_covered": r.fully_covered,
765
+ "missing_includes": [
766
+ {"path": m.path, "appearances": m.appearances}
767
+ for m in r.missing_includes
768
+ ],
769
+ }
770
+ for r in report.results
771
+ ],
772
+ }, indent=2)
773
+
774
+ @mcp.tool()
775
+ def scope_observations(scope: str, limit: int = 20) -> str:
776
+ """View observation history for a scope (recall/precision trends).
777
+
778
+ Shows how well this scope's predictions matched actual agent behavior.
779
+
780
+ Args:
781
+ scope: Scope name
782
+ limit: Max observations to return
783
+ """
784
+ from .storage.session_manager import SessionManager
785
+ from .discovery import find_repo_root
786
+
787
+ root = find_repo_root()
788
+ if root is None:
789
+ return json.dumps({"error": "Could not find repository root"})
790
+
791
+ mgr = SessionManager(root)
792
+ sessions = [s for s in mgr.get_sessions(limit=200) if scope in s.scope_expr]
793
+ session_ids = {s.session_id for s in sessions}
794
+ observations = [
795
+ o for o in mgr.get_observations(limit=200)
796
+ if o.session_id in session_ids
797
+ ][:limit]
798
+
799
+ return json.dumps({
800
+ "scope": scope,
801
+ "total_sessions": len(sessions),
802
+ "total_observations": len(observations),
803
+ "observations": [
804
+ {
805
+ "commit": o.commit_hash[:8],
806
+ "recall": o.recall,
807
+ "precision": o.precision,
808
+ "gaps": o.touched_not_predicted[:5],
809
+ }
810
+ for o in observations
811
+ ],
812
+ }, indent=2)
813
+
814
+ @mcp.tool()
815
+ def scope_lessons(scope: str) -> str:
816
+ """Get machine-generated lessons for a scope without full resolution.
817
+
818
+ Returns patterns learned from observation data: files consistently
819
+ needed but missing, files included but never used, hotspots.
820
+
821
+ Args:
822
+ scope: Scope name
823
+ """
824
+ from .storage.session_manager import SessionManager
825
+ from .lessons import generate_lessons
826
+ from .discovery import find_repo_root
827
+
828
+ root = find_repo_root()
829
+ if root is None:
830
+ return json.dumps({"error": "Could not find repository root"})
831
+
832
+ mgr = SessionManager(root)
833
+ sessions = mgr.get_sessions(limit=200)
834
+ observations = mgr.get_observations(limit=200)
835
+ lessons = generate_lessons(sessions, observations, module=scope)
836
+
837
+ return json.dumps({
838
+ "scope": scope,
839
+ "lessons": [
840
+ {
841
+ "trigger": item.trigger,
842
+ "lesson": item.lesson_text,
843
+ "confidence": item.confidence,
844
+ }
845
+ for item in lessons
846
+ ],
847
+ }, indent=2)
848
+
849
+ @mcp.tool()
850
+ def suggest_scope_changes(scope: str) -> str:
851
+ """Suggest changes to a scope based on observation data.
852
+
853
+ Recommends includes to add (frequently needed but missing)
854
+ and includes to deprioritize (resolved but never modified).
855
+
856
+ Args:
857
+ scope: Scope name
858
+ """
859
+ from .storage.session_manager import SessionManager
860
+ from .utility import compute_utility_scores
861
+ from .discovery import find_repo_root
862
+
863
+ root = find_repo_root()
864
+ if root is None:
865
+ return json.dumps({"error": "Could not find repository root"})
866
+
867
+ mgr = SessionManager(root)
868
+ sessions = mgr.get_sessions(limit=200)
869
+ observations = mgr.get_observations(limit=200)
870
+
871
+ if not observations:
872
+ return json.dumps({"error": "No observations yet"})
873
+
874
+ scores = compute_utility_scores(sessions, observations)
875
+
876
+ add = []
877
+ deprioritize = []
878
+
879
+ for path, score in scores.items():
880
+ if scope in path or any(scope in s.scope_expr for s in sessions if path in s.predicted_files):
881
+ if score.resolve_count >= 5 and score.utility_ratio == 0:
882
+ deprioritize.append({"path": path, "resolved": score.resolve_count})
883
+ if score.touch_count >= 3 and score.resolve_count == 0:
884
+ add.append({"path": path, "touched": score.touch_count})
885
+
886
+ return json.dumps({
887
+ "scope": scope,
888
+ "suggest_add": add,
889
+ "suggest_deprioritize": deprioritize,
890
+ }, indent=2)
891
+
892
+ @mcp.tool()
893
+ def session_summary() -> str:
894
+ """Get a summary of the current MCP session's dotscope usage.
895
+
896
+ Call this at the end of a task to see how many scopes were resolved,
897
+ tokens served, and reduction achieved. Helps the developer understand
898
+ what dotscope contributed to the session.
899
+ """
900
+ summary = tracker.summary()
901
+ # Also print to stderr for terminal visibility
902
+ terminal = tracker.format_terminal()
903
+ if terminal:
904
+ print(terminal, file=sys.stderr)
905
+ return json.dumps(summary, indent=2)
906
+
907
+ @mcp.tool()
908
+ def dotscope_check(
909
+ diff: Optional[str] = None,
910
+ session_id: Optional[str] = None,
911
+ ) -> str:
912
+ """Check proposed changes against codebase rules and architectural intent.
913
+
914
+ Call before committing. Returns holds (must address), notes (informational),
915
+ and proposed fixes for each hold.
916
+
917
+ If no diff provided, checks current git staged changes.
918
+ If session_id provided, uses that session for boundary checking.
919
+ """
920
+ from .passes.sentinel.checker import check_diff, check_staged
921
+ from .discovery import find_repo_root
922
+
923
+ root = find_repo_root()
924
+ if root is None:
925
+ return json.dumps({"error": "Could not find repository root"})
926
+
927
+ if diff:
928
+ report = check_diff(diff, root, session_id=session_id)
929
+ else:
930
+ report = check_staged(root, session_id=session_id)
931
+
932
+ def _fmt_result(r):
933
+ d = {
934
+ "category": r.category.value,
935
+ "severity": r.severity.value,
936
+ "message": r.message,
937
+ "file": r.file,
938
+ }
939
+ if r.suggestion:
940
+ d["suggestion"] = r.suggestion
941
+ if r.acknowledge_id:
942
+ d["acknowledge_id"] = r.acknowledge_id
943
+ if r.proposed_fix:
944
+ d["proposed_fix"] = {
945
+ "file": r.proposed_fix.file,
946
+ "reason": r.proposed_fix.reason,
947
+ "predicted_sections": r.proposed_fix.predicted_sections,
948
+ "proposed_diff": r.proposed_fix.proposed_diff,
949
+ "confidence": r.proposed_fix.confidence,
950
+ }
951
+ return d
952
+
953
+ return json.dumps({
954
+ "passed": report.passed,
955
+ "guards": [_fmt_result(r) for r in report.guards],
956
+ "nudges": [_fmt_result(r) for r in report.nudges],
957
+ "notes": [
958
+ {
959
+ "category": r.category.value,
960
+ "severity": r.severity.value,
961
+ "message": r.message,
962
+ "file": r.file,
963
+ }
964
+ for r in report.notes
965
+ ],
966
+ "holds": [_fmt_result(r) for r in report.guards], # backwards compat
967
+ "files_checked": report.files_checked,
968
+ }, indent=2)
969
+
970
+ @mcp.tool()
971
+ def dotscope_debug(
972
+ session_id: Optional[str] = None,
973
+ ) -> str:
974
+ """Debug why an agent session produced a bad outcome.
975
+
976
+ Bisects the context, files, and constraints that were served
977
+ to identify the root cause. Returns diagnosis and recommendations.
978
+
979
+ If no session_id, debugs the most recent session with low recall.
980
+ """
981
+ from .debug import debug_session, list_bad_sessions
982
+ from .discovery import find_repo_root
983
+
984
+ root = find_repo_root()
985
+ if root is None:
986
+ return json.dumps({"error": "Could not find repository root"})
987
+
988
+ if not session_id:
989
+ bad = list_bad_sessions(root, limit=1)
990
+ if bad:
991
+ session_id = bad[0]["session_id"]
992
+ else:
993
+ return json.dumps({"error": "No sessions with low recall found"})
994
+
995
+ result = debug_session(session_id, root)
996
+ if result is None:
997
+ return json.dumps({"error": f"Session {session_id} not found or recall >= 80%"})
998
+
999
+ return json.dumps({
1000
+ "session_id": result.session_id,
1001
+ "diagnosis": result.diagnosis,
1002
+ "files_that_mattered": result.files_that_mattered,
1003
+ "files_that_didnt_help": result.files_that_didnt_help,
1004
+ "missing_files": result.missing_files,
1005
+ "constraints_violated": result.constraints_violated,
1006
+ "recommendations": result.recommendations,
1007
+ }, indent=2)
1008
+
1009
+ @mcp.tool()
1010
+ def dotscope_acknowledge(
1011
+ ids: str,
1012
+ reason: str,
1013
+ ) -> str:
1014
+ """Acknowledge a hold and proceed.
1015
+
1016
+ Records the acknowledgment. Repeated acknowledgments of the same
1017
+ constraint cause its confidence to decay over time.
1018
+
1019
+ Args:
1020
+ ids: Comma-separated acknowledge IDs from dotscope_check holds
1021
+ reason: Why this acknowledgment is correct
1022
+ """
1023
+ from .passes.sentinel.acknowledge import record_acknowledgment
1024
+ from .discovery import find_repo_root
1025
+
1026
+ root = find_repo_root()
1027
+ if root is None:
1028
+ return json.dumps({"error": "Could not find repository root"})
1029
+
1030
+ ack_ids = [i.strip() for i in ids.split(",") if i.strip()]
1031
+ recorded = []
1032
+ for ack_id in ack_ids:
1033
+ entry = record_acknowledgment(root, ack_id, reason)
1034
+ recorded.append(entry)
1035
+
1036
+ return json.dumps({
1037
+ "acknowledged": len(recorded),
1038
+ "ids": ack_ids,
1039
+ "reason": reason,
1040
+ }, indent=2)
1041
+
1042
+ @mcp.tool()
1043
+ def match_conventions_by_path(
1044
+ filepath: str,
1045
+ ) -> str:
1046
+ """What conventions apply to a file path?
1047
+
1048
+ Takes a file path (can be a file that doesn't exist yet) and returns
1049
+ matching conventions with their rules. Use this before creating a new
1050
+ file to understand what patterns it should follow.
1051
+
1052
+ Args:
1053
+ filepath: Path to check (relative to repo root)
1054
+ """
1055
+ from .discovery import find_repo_root
1056
+ from .intent import load_conventions
1057
+
1058
+ root = find_repo_root()
1059
+ if root is None:
1060
+ return json.dumps({"error": "Could not find repository root"})
1061
+
1062
+ conventions = load_conventions(root)
1063
+ if not conventions:
1064
+ return json.dumps({"matches": [], "message": "No conventions defined"})
1065
+
1066
+ from .passes.sentinel.constraints import match_conventions_by_path as _match
1067
+ matches = _match(filepath, conventions)
1068
+
1069
+ if not matches:
1070
+ return json.dumps({
1071
+ "matches": [],
1072
+ "message": f"No conventions match {filepath}",
1073
+ })
1074
+
1075
+ return json.dumps({"matches": matches}, indent=2)
1076
+
1077
+ mcp.run(transport="stdio")
1078
+
1079
+
1080
+ if __name__ == "__main__":
1081
+ main()