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.
- dotscope/.scope +63 -0
- dotscope/__init__.py +3 -0
- dotscope/absorber.py +390 -0
- dotscope/assertions.py +128 -0
- dotscope/ast_analyzer.py +2 -0
- dotscope/backtest.py +2 -0
- dotscope/bench.py +141 -0
- dotscope/budget.py +3 -0
- dotscope/cache.py +2 -0
- dotscope/check/__init__.py +1 -0
- dotscope/check/acknowledge.py +2 -0
- dotscope/check/checker.py +3 -0
- dotscope/check/checks/__init__.py +1 -0
- dotscope/check/checks/antipattern.py +2 -0
- dotscope/check/checks/boundary.py +2 -0
- dotscope/check/checks/contracts.py +3 -0
- dotscope/check/checks/direction.py +2 -0
- dotscope/check/checks/intent.py +2 -0
- dotscope/check/checks/stability.py +2 -0
- dotscope/check/constraints.py +2 -0
- dotscope/check/models.py +15 -0
- dotscope/cli.py +1447 -0
- dotscope/composer.py +147 -0
- dotscope/constants.py +45 -0
- dotscope/context.py +60 -0
- dotscope/counterfactual.py +180 -0
- dotscope/debug.py +220 -0
- dotscope/discovery.py +104 -0
- dotscope/formatter.py +157 -0
- dotscope/graph.py +3 -0
- dotscope/health.py +212 -0
- dotscope/help.py +204 -0
- dotscope/history.py +6 -0
- dotscope/hooks.py +2 -0
- dotscope/ingest.py +858 -0
- dotscope/intent.py +618 -0
- dotscope/lessons.py +223 -0
- dotscope/matcher.py +104 -0
- dotscope/mcp_server.py +1081 -0
- dotscope/models/.scope +45 -0
- dotscope/models/__init__.py +7 -0
- dotscope/models/core.py +288 -0
- dotscope/models/history.py +73 -0
- dotscope/models/intent.py +213 -0
- dotscope/models/passes.py +58 -0
- dotscope/models/state.py +250 -0
- dotscope/models.py +9 -0
- dotscope/near_miss.py +3 -0
- dotscope/onboarding.py +2 -0
- dotscope/parser.py +387 -0
- dotscope/passes/.scope +105 -0
- dotscope/passes/__init__.py +1 -0
- dotscope/passes/ast_analyzer.py +508 -0
- dotscope/passes/backtest.py +198 -0
- dotscope/passes/budget_allocator.py +164 -0
- dotscope/passes/convention_compliance.py +40 -0
- dotscope/passes/convention_discovery.py +247 -0
- dotscope/passes/convention_parser.py +223 -0
- dotscope/passes/graph_builder.py +299 -0
- dotscope/passes/history_miner.py +336 -0
- dotscope/passes/incremental.py +149 -0
- dotscope/passes/lang/__init__.py +38 -0
- dotscope/passes/lang/_base.py +20 -0
- dotscope/passes/lang/_treesitter.py +93 -0
- dotscope/passes/lang/go.py +333 -0
- dotscope/passes/lang/javascript.py +348 -0
- dotscope/passes/lazy.py +152 -0
- dotscope/passes/semantic_diff.py +160 -0
- dotscope/passes/sentinel/__init__.py +1 -0
- dotscope/passes/sentinel/acknowledge.py +222 -0
- dotscope/passes/sentinel/checker.py +383 -0
- dotscope/passes/sentinel/checks/__init__.py +1 -0
- dotscope/passes/sentinel/checks/antipattern.py +84 -0
- dotscope/passes/sentinel/checks/boundary.py +46 -0
- dotscope/passes/sentinel/checks/contracts.py +148 -0
- dotscope/passes/sentinel/checks/convention.py +54 -0
- dotscope/passes/sentinel/checks/direction.py +71 -0
- dotscope/passes/sentinel/checks/intent.py +207 -0
- dotscope/passes/sentinel/checks/stability.py +66 -0
- dotscope/passes/sentinel/checks/voice.py +108 -0
- dotscope/passes/sentinel/constraints.py +472 -0
- dotscope/passes/sentinel/line_filter.py +88 -0
- dotscope/passes/sentinel/models.py +15 -0
- dotscope/passes/virtual.py +239 -0
- dotscope/passes/voice.py +162 -0
- dotscope/passes/voice_defaults.py +28 -0
- dotscope/passes/voice_discovery.py +245 -0
- dotscope/paths.py +32 -0
- dotscope/progress.py +44 -0
- dotscope/regression.py +147 -0
- dotscope/resolver.py +203 -0
- dotscope/scanner.py +246 -0
- dotscope/sessions.py +2 -0
- dotscope/storage/.scope +64 -0
- dotscope/storage/__init__.py +1 -0
- dotscope/storage/cache.py +114 -0
- dotscope/storage/claude_hooks.py +119 -0
- dotscope/storage/git_hooks.py +277 -0
- dotscope/storage/incremental_state.py +61 -0
- dotscope/storage/mcp_config.py +98 -0
- dotscope/storage/near_miss.py +183 -0
- dotscope/storage/onboarding.py +150 -0
- dotscope/storage/session_manager.py +195 -0
- dotscope/storage/timing.py +84 -0
- dotscope/timing.py +2 -0
- dotscope/tokens.py +53 -0
- dotscope/utility.py +123 -0
- dotscope/virtual.py +3 -0
- dotscope/visibility.py +664 -0
- dotscope-0.1.0.dist-info/METADATA +50 -0
- dotscope-0.1.0.dist-info/RECORD +114 -0
- dotscope-0.1.0.dist-info/WHEEL +4 -0
- dotscope-0.1.0.dist-info/entry_points.txt +3 -0
- 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()
|