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/cli.py ADDED
@@ -0,0 +1,1447 @@
1
+ """CLI entry point for dotscope."""
2
+
3
+
4
+ import argparse
5
+ import os
6
+ import sys
7
+
8
+
9
+ def _safe_print(text, **kwargs):
10
+ """Print with ASCII fallback for Windows cp1252 terminals."""
11
+ try:
12
+ print(text, **kwargs)
13
+ except UnicodeEncodeError:
14
+ print(text.encode("ascii", errors="replace").decode("ascii"), **kwargs)
15
+
16
+
17
+ def main(argv=None):
18
+ # Intercept help before argparse touches it
19
+ args_list = argv if argv is not None else sys.argv[1:]
20
+ if not args_list or args_list == ["help"] or args_list == ["--help"] or args_list == ["-h"]:
21
+ from .help import print_help
22
+ print_help()
23
+ return
24
+ if len(args_list) >= 2 and args_list[1] in ("--help", "-h"):
25
+ from .help import print_help, HELP_COMMANDS
26
+ cmd = args_list[0]
27
+ if cmd in HELP_COMMANDS:
28
+ print_help(cmd)
29
+ return
30
+ # Fall through to argparse for unknown commands
31
+
32
+ parser = argparse.ArgumentParser(
33
+ prog="dotscope",
34
+ description="Directory-scoped context boundaries for AI coding agents",
35
+ add_help=False,
36
+ )
37
+ parser.add_argument("--version", action="version", version=f"%(prog)s {_version()}")
38
+ parser.add_argument("-h", "--help", action="store_true", dest="show_help")
39
+
40
+ sub = parser.add_subparsers(dest="command")
41
+
42
+ # --- resolve ---
43
+ p_resolve = sub.add_parser("resolve", help="Resolve a scope expression to files")
44
+ p_resolve.add_argument("scope", help="Scope name, path, or expression (e.g., auth+payments)")
45
+ p_resolve.add_argument("--budget", type=int, default=None, help="Max tokens (context + files)")
46
+ p_resolve.add_argument("--tokens", action="store_true", help="Show per-file token counts")
47
+ p_resolve.add_argument("--json", action="store_true", help="Output as JSON")
48
+ p_resolve.add_argument("--cursor", action="store_true", help="Output as .cursorrules format")
49
+ p_resolve.add_argument("--no-related", action="store_true", help="Don't follow related scopes")
50
+ p_resolve.add_argument("--task", default=None, help="Task description for relevance ranking")
51
+
52
+ # --- context ---
53
+ p_context = sub.add_parser("context", help="Print context for a scope")
54
+ p_context.add_argument("scope", help="Scope name or path")
55
+ p_context.add_argument("--section", default=None, help="Filter to a named section")
56
+
57
+ # --- match ---
58
+ p_match = sub.add_parser("match", help="Match a task description to scope(s)")
59
+ p_match.add_argument("task", help="Task description string")
60
+
61
+ # --- init ---
62
+ p_init = sub.add_parser("init", help="One command: ingest, install hooks, configure agents")
63
+ p_init.add_argument("path", nargs="?", default=".", help="Repository root")
64
+ p_init.add_argument("--quiet", action="store_true", help="Suppress progress (for CI)")
65
+
66
+ # --- validate ---
67
+ sub.add_parser("validate", help="Check all .scope files for broken paths")
68
+
69
+ # --- stats ---
70
+ sub.add_parser("stats", help="Token savings report across all scopes")
71
+
72
+ # --- tree ---
73
+ sub.add_parser("tree", help="Visual tree of all scopes and relationships")
74
+
75
+ # --- health ---
76
+ sub.add_parser("health", help="Scope health: staleness, coverage, drift")
77
+
78
+ # --- ingest ---
79
+ p_ingest = sub.add_parser("ingest", help="Reverse-engineer .scope files from an existing codebase")
80
+ p_ingest.add_argument("--dir", default=".", help="Repository root to ingest")
81
+ p_ingest.add_argument("--no-history", action="store_true", help="Skip git history mining")
82
+ p_ingest.add_argument("--no-docs", action="store_true", help="Skip doc absorption")
83
+ p_ingest.add_argument("--dry-run", action="store_true", help="Plan only, don't write files")
84
+ p_ingest.add_argument("--max-commits", type=int, default=500, help="Max git commits to analyze")
85
+ p_ingest.add_argument("--quiet", action="store_true", help="Suppress progress output (for CI)")
86
+ p_ingest.add_argument("--voice", choices=["prescriptive", "adaptive"], default=None,
87
+ help="Override voice mode (prescriptive for new, adaptive for existing)")
88
+
89
+ # --- voice ---
90
+ p_voice = sub.add_parser("voice", help="View and manage code voice")
91
+ p_voice.add_argument("--upgrade", metavar="RULE", help="Upgrade enforcement for a rule (typing, bare_excepts)")
92
+ p_voice.add_argument("--reset", action="store_true", help="Reset voice to defaults")
93
+ p_voice.add_argument("--json", action="store_true", help="Machine-readable output")
94
+
95
+ # --- impact ---
96
+ p_impact = sub.add_parser("impact", help="Predict blast radius of changes to a file")
97
+ p_impact.add_argument("file", help="File path to analyze impact for")
98
+
99
+ # --- backtest ---
100
+ p_backtest = sub.add_parser("backtest", help="Validate scopes against git history")
101
+ p_backtest.add_argument("--commits", type=int, default=50, help="Number of commits to test against")
102
+
103
+ # --- observe ---
104
+ p_observe = sub.add_parser("observe", help="Record observation for a commit (called by post-commit hook)")
105
+ p_observe.add_argument("commit", help="Commit hash to observe")
106
+
107
+ # --- incremental ---
108
+ p_incremental = sub.add_parser("incremental", help="Incremental scope update (called by post-commit hook)")
109
+ p_incremental.add_argument("commit", help="Commit hash")
110
+
111
+ # --- hook ---
112
+ p_hook = sub.add_parser("hook", help="Manage git hooks")
113
+ hook_sub = p_hook.add_subparsers(dest="hook_action")
114
+ hook_sub.add_parser("install", help="Install post-commit observer hook")
115
+ hook_sub.add_parser("uninstall", help="Remove post-commit observer hook")
116
+ hook_sub.add_parser("status", help="Check if hook is installed")
117
+ hook_sub.add_parser("claude", help="Install Claude Code pre-commit enforcement")
118
+
119
+ # --- utility ---
120
+ p_utility = sub.add_parser("utility", help="Show utility scores for a scope")
121
+ p_utility.add_argument("scope", help="Scope name")
122
+
123
+ # --- virtual ---
124
+ sub.add_parser("virtual", help="Detect and show virtual (cross-cutting) scopes")
125
+
126
+ # --- lessons ---
127
+ p_lessons = sub.add_parser("lessons", help="Show lessons for a scope")
128
+ p_lessons.add_argument("scope", help="Scope name")
129
+
130
+ # --- invariants ---
131
+ p_invariants = sub.add_parser("invariants", help="Show observed invariants for a scope")
132
+ p_invariants.add_argument("scope", help="Scope name")
133
+
134
+ # --- rebuild ---
135
+ sub.add_parser("rebuild", help="Rebuild derived state from event logs")
136
+
137
+ # --- check ---
138
+ p_check = sub.add_parser("check", help="Validate a diff against codebase rules")
139
+ p_check.add_argument("--diff", default=None, help="Path to diff file (default: staged changes)")
140
+ p_check.add_argument("--session", default=None, help="Session ID for boundary checking")
141
+ p_check.add_argument("--acknowledge", action="append", default=[], help="Acknowledge a hold by ID")
142
+ p_check.add_argument("--backtest", action="store_true", help="Replay recent commits against checks")
143
+ p_check.add_argument("--commits", type=int, default=10, help="Commits to replay in backtest mode")
144
+ p_check.add_argument("--json", dest="json_output", action="store_true", help="Output as JSON")
145
+
146
+ # --- intent ---
147
+ p_intent = sub.add_parser("intent", help="Manage architectural intents")
148
+ intent_sub = p_intent.add_subparsers(dest="intent_action")
149
+ p_intent_add = intent_sub.add_parser("add", help="Add an architectural intent")
150
+ p_intent_add.add_argument("directive", choices=["decouple", "deprecate", "freeze", "consolidate"])
151
+ p_intent_add.add_argument("targets", nargs="+", help="Modules or files")
152
+ p_intent_add.add_argument("--reason", default="", help="Why this intent exists")
153
+ p_intent_add.add_argument("--replacement", default=None, help="Replacement (for deprecate)")
154
+ p_intent_add.add_argument("--target", default=None, help="Consolidation target")
155
+ p_intent_list = intent_sub.add_parser("list", help="List all intents")
156
+ p_intent_rm = intent_sub.add_parser("remove", help="Remove an intent by ID")
157
+ p_intent_rm.add_argument("id", help="Intent ID to remove")
158
+
159
+ # --- conventions ---
160
+ p_conv = sub.add_parser("conventions", help="List or discover conventions")
161
+ p_conv.add_argument("--discover", action="store_true", help="Discover conventions from codebase")
162
+ p_conv.add_argument("--accept", action="store_true", help="Accept discovered conventions")
163
+ p_conv.add_argument("--json", dest="json_output", action="store_true", help="Output as JSON")
164
+
165
+ # --- diff ---
166
+ p_diff = sub.add_parser("diff", help="Semantic diff against conventions")
167
+ p_diff.add_argument("ref", nargs="?", default=None, help="Git ref to diff against")
168
+ p_diff.add_argument("--staged", action="store_true", help="Diff staged changes")
169
+ p_diff.add_argument("--json", dest="json_output", action="store_true", help="Output as JSON")
170
+
171
+ # --- test-compiler ---
172
+ p_tc = sub.add_parser("test-compiler", help="Replay frozen sessions as regression tests")
173
+ p_tc.add_argument("--scope", default=None, help="Filter to a specific scope")
174
+
175
+ # --- bench ---
176
+ p_bench = sub.add_parser("bench", help="Performance and accuracy benchmarks")
177
+ p_bench.add_argument("--json", dest="json_output", action="store_true", help="JSON output")
178
+
179
+ # --- debug ---
180
+ p_debug = sub.add_parser("debug", help="Bisect a bad session to find root cause")
181
+ p_debug.add_argument("session_id", nargs="?", default=None, help="Session ID to debug")
182
+ p_debug.add_argument("--last", action="store_true", help="Debug most recent bad session")
183
+ p_debug.add_argument("--list", dest="list_bad", action="store_true", help="List bad sessions")
184
+
185
+ args = parser.parse_args(argv)
186
+
187
+ if args.command is None or getattr(args, "show_help", False):
188
+ from .help import print_help
189
+ print_help()
190
+ return
191
+
192
+ try:
193
+ handler = {
194
+ "resolve": _cmd_resolve,
195
+ "context": _cmd_context,
196
+ "match": _cmd_match,
197
+ "init": _cmd_init,
198
+ "validate": _cmd_validate,
199
+ "stats": _cmd_stats,
200
+ "tree": _cmd_tree,
201
+ "health": _cmd_health,
202
+ "ingest": _cmd_ingest,
203
+ "impact": _cmd_impact,
204
+ "backtest": _cmd_backtest,
205
+ "observe": _cmd_observe,
206
+ "incremental": _cmd_incremental,
207
+ "hook": _cmd_hook,
208
+ "utility": _cmd_utility,
209
+ "virtual": _cmd_virtual,
210
+ "lessons": _cmd_lessons,
211
+ "invariants": _cmd_invariants,
212
+ "rebuild": _cmd_rebuild,
213
+ "check": _cmd_check,
214
+ "intent": _cmd_intent,
215
+ "conventions": _cmd_conventions,
216
+ "diff": _cmd_diff,
217
+ "voice": _cmd_voice,
218
+ "test-compiler": _cmd_test_compiler,
219
+ "bench": _cmd_bench,
220
+ "debug": _cmd_debug,
221
+ }[args.command]
222
+ handler(args)
223
+ except (ValueError, FileNotFoundError) as e:
224
+ print(f"Error: {e}", file=sys.stderr)
225
+ sys.exit(1)
226
+
227
+
228
+ # ---------------------------------------------------------------------------
229
+ # Command handlers
230
+ # ---------------------------------------------------------------------------
231
+
232
+ def _cmd_resolve(args):
233
+ from .composer import compose
234
+ from .passes.budget_allocator import apply_budget
235
+ from .discovery import find_repo_root
236
+ from .formatter import format_resolved
237
+
238
+ root = find_repo_root()
239
+ follow_related = not args.no_related
240
+
241
+ resolved = compose(args.scope, root=root, follow_related=follow_related)
242
+
243
+ if args.budget:
244
+ resolved = apply_budget(resolved, args.budget, task=args.task)
245
+
246
+ fmt = "json" if args.json else ("cursor" if args.cursor else "plain")
247
+ print(format_resolved(resolved, fmt=fmt, root=root, show_tokens=args.tokens))
248
+
249
+
250
+ def _cmd_context(args):
251
+ from .discovery import find_scope, find_repo_root
252
+ from .context import query_context
253
+
254
+ root = find_repo_root()
255
+ config = find_scope(args.scope, root)
256
+ if config is None:
257
+ raise ValueError(f"Scope not found: {args.scope}")
258
+
259
+ result = query_context(config.context, args.section)
260
+ if result:
261
+ print(result)
262
+ else:
263
+ section_msg = f" (section: {args.section})" if args.section else ""
264
+ print(f"No context found for scope '{args.scope}'{section_msg}", file=sys.stderr)
265
+
266
+
267
+ def _cmd_match(args):
268
+ from .matcher import match_task
269
+ from .discovery import find_repo_root, load_index, find_all_scopes
270
+ from .parser import parse_scope_file
271
+
272
+ root = find_repo_root()
273
+ if root is None:
274
+ raise ValueError("Could not find repository root")
275
+
276
+ index = load_index(root)
277
+ scope_files = find_all_scopes(root)
278
+
279
+ # Build scope list for matching
280
+ scopes = []
281
+ if index:
282
+ for name, entry in index.scopes.items():
283
+ scopes.append((name, entry.keywords, entry.description or ""))
284
+ else:
285
+ for sf in scope_files:
286
+ try:
287
+ config = parse_scope_file(sf)
288
+ name = os.path.relpath(os.path.dirname(sf), root)
289
+ scopes.append((name, config.tags, config.description))
290
+ except (ValueError, IOError):
291
+ continue
292
+
293
+ matches = match_task(args.task, scopes)
294
+
295
+ if not matches:
296
+ print("No matching scopes found.", file=sys.stderr)
297
+ return
298
+
299
+ for name, score in matches:
300
+ print(f"Matched: {name} (confidence: {score:.2f})")
301
+
302
+
303
+ def _cmd_init(args):
304
+ """One command: ingest, hooks, MCP config, counterfactual demo."""
305
+ root = os.path.abspath(getattr(args, "path", None) or ".")
306
+ quiet = getattr(args, "quiet", False)
307
+
308
+ # 1. Ingest
309
+ from .ingest import ingest
310
+ result = ingest(root, quiet=quiet)
311
+
312
+ # 2. Install hooks
313
+ try:
314
+ from .storage.git_hooks import install_hook
315
+ install_hook(root)
316
+ if not quiet:
317
+ print("dotscope: hooks installed", file=sys.stderr)
318
+ except Exception as e:
319
+ print(f"dotscope: hook install failed: {e}", file=sys.stderr)
320
+
321
+ # 3. Auto-configure MCP for detected IDEs
322
+ try:
323
+ from .storage.mcp_config import configure_mcp
324
+ configured = configure_mcp(root)
325
+ if configured and not quiet:
326
+ print(f"dotscope: MCP configured for {', '.join(configured)}", file=sys.stderr)
327
+ except Exception as e:
328
+ print(f"dotscope: MCP config failed: {e}", file=sys.stderr)
329
+
330
+ # 4. Backtest as counterfactual demo
331
+ if not quiet:
332
+ try:
333
+ from .passes.backtest import backtest_scopes
334
+ report = backtest_scopes(root, commits=50)
335
+ if report and report.get("results"):
336
+ total_violations = 0
337
+ for scope_result in report["results"]:
338
+ total_violations += scope_result.get("missed_files", 0)
339
+
340
+ # Extract stats from IngestPlan object
341
+ stats = {
342
+ "scopes_written": len(result.scopes) if hasattr(result, "scopes") else 0,
343
+ "contracts_found": (
344
+ len(result.history.implicit_contracts)
345
+ if hasattr(result, "history") and result.history else 0
346
+ ),
347
+ "conventions_found": (
348
+ len(result.discovered_conventions) if hasattr(result, "discovered_conventions") else 0
349
+ ),
350
+ }
351
+ _print_counterfactual(stats, report, total_violations)
352
+ else:
353
+ _print_summary(result)
354
+ except Exception:
355
+ # Backtest may fail on repos with few commits
356
+ _print_summary(result)
357
+
358
+
359
+ def _print_counterfactual(ingest_result, backtest, violations):
360
+ """Format init output as a counterfactual story."""
361
+ scopes = ingest_result.get("scopes_written", 0) if isinstance(ingest_result, dict) else 0
362
+ contracts = ingest_result.get("contracts_found", 0) if isinstance(ingest_result, dict) else 0
363
+ conventions = ingest_result.get("conventions_found", 0) if isinstance(ingest_result, dict) else 0
364
+
365
+ recall = backtest.get("overall_recall", 0)
366
+
367
+ lines = [""]
368
+ lines.append(f" {scopes} scopes, {contracts} contracts, {conventions} conventions, {recall:.0%} recall")
369
+ lines.append("")
370
+
371
+ if violations > 0:
372
+ lines.append(f" What dotscope would have caught in your last 50 commits:")
373
+ lines.append(f" {violations} files that agents would have missed")
374
+ lines.append("")
375
+ lines.append(" Your agents are ready.")
376
+ lines.append("")
377
+
378
+ print("\n".join(lines), file=sys.stderr)
379
+
380
+
381
+ def _print_summary(ingest_result):
382
+ """Fallback when backtest isn't available."""
383
+ print("", file=sys.stderr)
384
+ print(" Your agents are ready.", file=sys.stderr)
385
+ print("", file=sys.stderr)
386
+
387
+
388
+ def _cmd_validate(args):
389
+ from .discovery import find_repo_root, find_all_scopes
390
+ from .parser import parse_scope_file
391
+
392
+ root = find_repo_root()
393
+ if root is None:
394
+ raise ValueError("Could not find repository root")
395
+
396
+ scope_files = find_all_scopes(root)
397
+ if not scope_files:
398
+ print("No .scope files found.")
399
+ return
400
+
401
+ errors = 0
402
+ warnings = 0
403
+
404
+ for sf in scope_files:
405
+ rel = os.path.relpath(sf, root)
406
+ try:
407
+ config = parse_scope_file(sf)
408
+ except ValueError as e:
409
+ print(f"ERROR {rel}: {e}")
410
+ errors += 1
411
+ continue
412
+
413
+ scope_dir = os.path.dirname(sf)
414
+
415
+ from .paths import path_exists, strip_inline_comment
416
+
417
+ for inc in config.includes:
418
+ if not path_exists(root, inc):
419
+ print(f"ERROR {rel}: include path not found: {inc}")
420
+ errors += 1
421
+
422
+ for related in config.related:
423
+ clean = strip_inline_comment(related)
424
+ if not path_exists(scope_dir, clean) and not path_exists(root, clean):
425
+ print(f"WARN {rel}: related scope not found: {clean}")
426
+ warnings += 1
427
+
428
+ if not config.description.strip():
429
+ print(f"WARN {rel}: empty description")
430
+ warnings += 1
431
+
432
+ if not config.context_str.strip():
433
+ print(f"WARN {rel}: no context (this is the most valuable part)")
434
+ warnings += 1
435
+
436
+ print(f"\nChecked {len(scope_files)} scope(s): {errors} error(s), {warnings} warning(s)")
437
+ if errors > 0:
438
+ sys.exit(1)
439
+
440
+
441
+ def _cmd_stats(args):
442
+ from .discovery import find_repo_root, find_all_scopes
443
+ from .parser import parse_scope_file
444
+ from .resolver import resolve
445
+ from .tokens import estimate_file_tokens
446
+ from .formatter import format_stats
447
+
448
+ root = find_repo_root()
449
+ if root is None:
450
+ raise ValueError("Could not find repository root")
451
+
452
+ total_files = 0
453
+ total_tokens = 0
454
+ skip_dirs = {".git", "node_modules", "__pycache__", "venv", ".venv", "dist", "build"}
455
+
456
+ for dirpath, dirnames, filenames in os.walk(root):
457
+ dirnames[:] = [d for d in dirnames if d not in skip_dirs]
458
+ for f in filenames:
459
+ full = os.path.join(dirpath, f)
460
+ total_files += 1
461
+ total_tokens += estimate_file_tokens(full)
462
+
463
+ # Resolve each scope
464
+ scope_stats = []
465
+ for sf in find_all_scopes(root):
466
+ try:
467
+ config = parse_scope_file(sf)
468
+ resolved = resolve(config, follow_related=False, root=root)
469
+ name = os.path.relpath(os.path.dirname(sf), root) + "/"
470
+ scope_stats.append((name, len(resolved.files), resolved.token_estimate))
471
+ except (ValueError, IOError):
472
+ continue
473
+
474
+ print(format_stats(scope_stats, total_files, total_tokens))
475
+
476
+
477
+ def _cmd_tree(args):
478
+ from .discovery import find_repo_root, find_all_scopes
479
+ from .parser import parse_scope_file
480
+ from .formatter import format_tree
481
+
482
+ root = find_repo_root()
483
+ if root is None:
484
+ raise ValueError("Could not find repository root")
485
+
486
+ scopes = []
487
+ for sf in find_all_scopes(root):
488
+ try:
489
+ config = parse_scope_file(sf)
490
+ scopes.append((sf, config))
491
+ except (ValueError, IOError):
492
+ scopes.append((sf, None))
493
+
494
+ print(format_tree(scopes, root))
495
+
496
+
497
+ def _cmd_health(args):
498
+ from .health import full_health_report
499
+ from .discovery import find_repo_root
500
+
501
+ root = find_repo_root()
502
+ if root is None:
503
+ raise ValueError("Could not find repository root")
504
+
505
+ report = full_health_report(root)
506
+
507
+ if not report.issues:
508
+ print(f"All {report.scopes_checked} scope(s) healthy. "
509
+ f"Coverage: {report.coverage_pct:.0f}% "
510
+ f"({report.directories_covered}/{report.directories_total} directories)")
511
+ return
512
+
513
+ for issue in report.issues:
514
+ rel = os.path.relpath(issue.scope_path, root) if issue.scope_path else "repo"
515
+ tag = issue.severity.upper()
516
+ print(f"{tag:5} [{issue.category}] {rel}: {issue.message}")
517
+
518
+ print(f"\n{report.scopes_checked} scope(s) checked, "
519
+ f"{len(report.errors)} error(s), {len(report.warnings)} warning(s)")
520
+ print(f"Coverage: {report.coverage_pct:.0f}% "
521
+ f"({report.directories_covered}/{report.directories_total} directories)")
522
+
523
+
524
+ def _cmd_ingest(args):
525
+ from .ingest import ingest, format_ingest_report
526
+
527
+ root = os.path.abspath(args.dir)
528
+ plan = ingest(
529
+ root,
530
+ mine_history=not args.no_history,
531
+ absorb=not args.no_docs,
532
+ max_commits=args.max_commits,
533
+ dry_run=args.dry_run,
534
+ quiet=args.quiet,
535
+ voice_override=getattr(args, "voice", None),
536
+ )
537
+
538
+ report = format_ingest_report(plan)
539
+ try:
540
+ print(report)
541
+ except UnicodeEncodeError:
542
+ # Windows terminals with cp1252 — write as safe ASCII
543
+ print(report.encode("ascii", errors="replace").decode("ascii"))
544
+
545
+ if args.dry_run:
546
+ print("Dry run: no files written. Remove --dry-run to write scope files.")
547
+ else:
548
+ # Onboarding: mark milestone + show next step + vc tip
549
+ try:
550
+ from .storage.onboarding import (
551
+ mark_milestone, next_step, version_control_tip, mark_vc_tip_shown,
552
+ )
553
+ mark_milestone(root, "first_ingest")
554
+ tip = version_control_tip(mark_milestone(root, "first_ingest"))
555
+ if tip:
556
+ print(f"\n{tip}")
557
+ mark_vc_tip_shown(root)
558
+ ns = next_step(mark_milestone(root, "first_ingest"))
559
+ if ns:
560
+ print(f"\n{ns}")
561
+ except Exception:
562
+ pass
563
+
564
+
565
+ def _cmd_impact(args):
566
+ from .passes.graph_builder import build_graph, transitive_dependents
567
+ from .discovery import find_repo_root
568
+
569
+ root = find_repo_root()
570
+ if root is None:
571
+ raise ValueError("Could not find repository root")
572
+
573
+ target = os.path.relpath(os.path.abspath(args.file), root)
574
+ graph = build_graph(root)
575
+ node = graph.files.get(target)
576
+
577
+ print(f"Impact analysis for: {target}")
578
+ print()
579
+
580
+ if node and node.imports:
581
+ print(f"Direct imports ({len(node.imports)}):")
582
+ for imp in node.imports:
583
+ _safe_print(f" -> {imp}")
584
+
585
+ if node and node.imported_by:
586
+ print(f"\nDirect dependents ({len(node.imported_by)}):")
587
+ for imp_by in node.imported_by:
588
+ _safe_print(f" <- {imp_by}")
589
+
590
+ all_dependents = transitive_dependents(graph, target)
591
+ transitive_only = all_dependents - set(node.imported_by if node else [])
592
+
593
+ if transitive_only:
594
+ print(f"\nTransitive dependents ({len(transitive_only)}):")
595
+ for t in sorted(transitive_only):
596
+ _safe_print(f" <- <- {t}")
597
+
598
+ affected_modules = set()
599
+ for f in all_dependents:
600
+ parts = f.split("/")
601
+ if len(parts) > 1:
602
+ affected_modules.add(parts[0])
603
+
604
+ if affected_modules:
605
+ print(f"\nAffected modules: {', '.join(sorted(affected_modules))}")
606
+
607
+ total = 1 + len(all_dependents)
608
+ risk = "LOW" if total <= 3 else ("MEDIUM" if total <= 10 else "HIGH")
609
+ print(f"\nBlast radius: {total} file(s), risk: {risk}")
610
+
611
+
612
+ def _cmd_observe(args):
613
+ from pathlib import Path
614
+ from .storage.session_manager import SessionManager
615
+ from .discovery import find_repo_root
616
+ from .visibility import format_observation_delta
617
+
618
+ root = find_repo_root()
619
+ if root is None:
620
+ raise ValueError("Could not find repository root")
621
+
622
+ mgr = SessionManager(root)
623
+ obs = mgr.record_observation(args.commit)
624
+
625
+ if obs:
626
+ # Find the session to get scope_expr
627
+ sessions = mgr.get_sessions(limit=50)
628
+ scope_expr = "unknown"
629
+ for s in sessions:
630
+ if s.session_id == obs.session_id:
631
+ scope_expr = s.scope_expr
632
+ break
633
+
634
+ # Onboarding: mark first observation + increment counter
635
+ try:
636
+ from .storage.onboarding import mark_milestone, increment_counter
637
+ mark_milestone(root, "first_observation")
638
+ increment_counter(root, "observations_recorded")
639
+ except Exception:
640
+ pass
641
+
642
+ delta = format_observation_delta(obs, scope_expr)
643
+ try:
644
+ print(delta, file=sys.stderr)
645
+ except UnicodeEncodeError:
646
+ print(delta.encode("ascii", errors="replace").decode("ascii"),
647
+ file=sys.stderr)
648
+
649
+ # Update utility scores after observation
650
+ try:
651
+ from .utility import compute_utility_scores, save_utility_scores
652
+ all_sessions = mgr.get_sessions(limit=500)
653
+ all_obs = mgr.get_observations(limit=500)
654
+ scores = compute_utility_scores(all_sessions, all_obs)
655
+ save_utility_scores(Path(root) / ".dotscope", scores)
656
+ except Exception:
657
+ pass # Utility update is best-effort
658
+
659
+ # Near-miss detection using structured warning pairs
660
+ try:
661
+ import subprocess
662
+ from .storage.near_miss import (
663
+ detect_near_misses as detect_nms,
664
+ store_near_misses, load_session_scopes,
665
+ )
666
+ from .discovery import find_scope
667
+ from .parser import parse_scope_file
668
+
669
+ # Get scopes from session or current observation
670
+ scope_name = scope_expr.split("+")[0].split("-")[0].split("@")[0]
671
+ session_scopes = load_session_scopes(root) or [scope_name]
672
+
673
+ # Build context map for all session scopes
674
+ scope_contexts = {}
675
+ for sn in session_scopes:
676
+ cfg_path = find_scope(sn, root=root)
677
+ if cfg_path:
678
+ cfg = parse_scope_file(cfg_path)
679
+ scope_contexts[sn] = cfg.context_str
680
+
681
+ if scope_contexts:
682
+ diff_result = subprocess.run(
683
+ ["git", "diff", obs.commit_hash + "~1", obs.commit_hash],
684
+ cwd=root, capture_output=True, text=True, timeout=5,
685
+ )
686
+ if diff_result.returncode == 0 and diff_result.stdout:
687
+ nms = detect_nms(diff_result.stdout, scope_contexts)
688
+ for nm in nms:
689
+ print(
690
+ f"\ndotscope: near-miss detected\n"
691
+ f" {nm.event}\n"
692
+ f" Scope context: \"{nm.context_used}\"\n"
693
+ f" {nm.potential_impact}",
694
+ file=sys.stderr,
695
+ )
696
+ if nms:
697
+ store_near_misses(root, nms)
698
+ except Exception:
699
+ pass # Near-miss detection is best-effort
700
+ else:
701
+ # No session matched — check if any scopes exist
702
+ try:
703
+ from .discovery import load_index
704
+ idx = load_index(root)
705
+ if idx:
706
+ print(
707
+ "dotscope: observation recorded\n"
708
+ " Changed files don't match any recent session\n"
709
+ " This is normal for work done outside agent sessions",
710
+ file=sys.stderr,
711
+ )
712
+ else:
713
+ print(
714
+ "dotscope: observation recorded\n"
715
+ " No .scopes index found"
716
+ " -- consider running `dotscope ingest .`",
717
+ file=sys.stderr,
718
+ )
719
+ except Exception:
720
+ print(f"No matching session for commit {args.commit[:8]}",
721
+ file=sys.stderr)
722
+
723
+
724
+ def _cmd_incremental(args):
725
+ """Incremental scope update from a single commit."""
726
+ import subprocess
727
+ from .discovery import find_repo_root
728
+ from .passes.incremental import incremental_update
729
+
730
+ root = find_repo_root()
731
+ if root is None:
732
+ return
733
+
734
+ # Get changed files from the commit
735
+ try:
736
+ result = subprocess.run(
737
+ ["git", "diff-tree", "--no-commit-id", "--name-status", "-r", args.commit],
738
+ cwd=root, capture_output=True, text=True, timeout=10,
739
+ )
740
+ if result.returncode != 0:
741
+ return
742
+ except Exception:
743
+ return
744
+
745
+ changed = []
746
+ added = []
747
+ deleted = []
748
+ for line in result.stdout.strip().splitlines():
749
+ parts = line.split("\t", 1)
750
+ if len(parts) != 2:
751
+ continue
752
+ status, filepath = parts[0].strip(), parts[1].strip()
753
+ changed.append(filepath)
754
+ if status == "A":
755
+ added.append(filepath)
756
+ elif status == "D":
757
+ deleted.append(filepath)
758
+
759
+ if changed:
760
+ try:
761
+ incremental_update(root, changed, added, deleted, args.commit)
762
+ except Exception:
763
+ pass # Incremental update is best-effort
764
+
765
+
766
+ def _cmd_hook(args):
767
+ from .storage.git_hooks import install_hook, uninstall_hook, hook_status
768
+ from .discovery import find_repo_root
769
+
770
+ root = find_repo_root()
771
+ if root is None:
772
+ raise ValueError("Could not find repository root")
773
+
774
+ if args.hook_action == "install":
775
+ result = install_hook(root)
776
+ print(result)
777
+ elif args.hook_action == "uninstall":
778
+ removed = uninstall_hook(root)
779
+ print("Hooks removed." if removed else "No hooks found.")
780
+ elif args.hook_action == "status":
781
+ print(hook_status(root))
782
+ elif args.hook_action == "claude":
783
+ from .storage.claude_hooks import install_claude_hook
784
+ result = install_claude_hook(root)
785
+ print(result)
786
+ else:
787
+ print("Usage: dotscope hook {install|uninstall|status|claude}")
788
+
789
+
790
+ def _cmd_backtest(args):
791
+ from .passes.backtest import backtest_scopes, format_backtest_report
792
+ from .discovery import find_repo_root, find_all_scopes
793
+ from .parser import parse_scope_file
794
+
795
+ root = find_repo_root()
796
+ if root is None:
797
+ raise ValueError("Could not find repository root")
798
+
799
+ scope_files = find_all_scopes(root)
800
+ if not scope_files:
801
+ print("No .scope files found. Run 'dotscope ingest' first.")
802
+ return
803
+
804
+ configs = []
805
+ for sf in scope_files:
806
+ try:
807
+ configs.append(parse_scope_file(sf))
808
+ except (ValueError, IOError):
809
+ continue
810
+
811
+ report = backtest_scopes(root, configs, n_commits=args.commits)
812
+ print(format_backtest_report(report))
813
+
814
+
815
+ def _cmd_utility(args):
816
+ from .discovery import find_repo_root
817
+ from .storage.session_manager import SessionManager
818
+ from .utility import compute_utility_scores
819
+
820
+ root = find_repo_root()
821
+ if root is None:
822
+ raise ValueError("Could not find repository root")
823
+
824
+ mgr = SessionManager(root)
825
+ sessions = mgr.get_sessions(limit=200)
826
+ observations = mgr.get_observations(limit=200)
827
+
828
+ if not observations:
829
+ print("No observations yet. Install the hook and make some commits first.")
830
+ print(" dotscope hook install")
831
+ return
832
+
833
+ scores = compute_utility_scores(sessions, observations)
834
+ # Filter to scope
835
+ scope_prefix = args.scope + "/"
836
+ relevant = {k: v for k, v in scores.items() if scope_prefix in k or args.scope in k}
837
+
838
+ if not relevant:
839
+ print(f"No utility data for scope '{args.scope}'")
840
+ return
841
+
842
+ print(f"Utility scores for {args.scope} ({len(relevant)} files):\n")
843
+ for path, score in sorted(relevant.items(), key=lambda x: -x[1].utility_ratio):
844
+ filled = int(score.utility_ratio * 10)
845
+ bar = "#" * filled + "." * (10 - filled)
846
+ _safe_print(f" {os.path.basename(path)}: {bar} {score.utility_ratio:.0%} "
847
+ f"({score.touch_count}/{score.resolve_count})")
848
+
849
+
850
+ def _cmd_virtual(args):
851
+ from .discovery import find_repo_root
852
+ from .passes.graph_builder import build_graph
853
+ from .passes.virtual import detect_virtual_scopes, format_virtual_scopes
854
+
855
+ root = find_repo_root()
856
+ if root is None:
857
+ raise ValueError("Could not find repository root")
858
+
859
+ graph = build_graph(root)
860
+ scopes = detect_virtual_scopes(graph)
861
+ print(format_virtual_scopes(scopes, root))
862
+
863
+
864
+ def _cmd_lessons(args):
865
+ from .discovery import find_repo_root
866
+ from .storage.session_manager import SessionManager
867
+ from .lessons import generate_lessons
868
+
869
+ root = find_repo_root()
870
+ if root is None:
871
+ raise ValueError("Could not find repository root")
872
+
873
+ mgr = SessionManager(root)
874
+ sessions = mgr.get_sessions(limit=200)
875
+ observations = mgr.get_observations(limit=200)
876
+
877
+ if not observations:
878
+ print("No observations yet. Install the hook and make some commits first.")
879
+ return
880
+
881
+ lessons = generate_lessons(sessions, observations, module=args.scope)
882
+ if not lessons:
883
+ print(f"No lessons for scope '{args.scope}' yet.")
884
+ return
885
+
886
+ print(f"Lessons for {args.scope}:\n")
887
+ for lesson in lessons:
888
+ filled = int(lesson.confidence * 5)
889
+ conf = "#" * filled + "." * (5 - filled)
890
+ _safe_print(f" [{conf}] {lesson.lesson_text}")
891
+ _safe_print(f" {lesson.observation}")
892
+ print()
893
+
894
+
895
+ def _cmd_invariants(args):
896
+ from .discovery import find_repo_root
897
+ from .passes.graph_builder import build_graph
898
+ from .lessons import detect_invariants
899
+ from .passes.history_miner import analyze_history
900
+
901
+ root = find_repo_root()
902
+ if root is None:
903
+ raise ValueError("Could not find repository root")
904
+
905
+ graph = build_graph(root)
906
+ history = analyze_history(root, max_commits=500)
907
+ all_modules = [m.directory for m in graph.modules]
908
+
909
+ invariants = detect_invariants(
910
+ graph.edges, args.scope, all_modules, history.commits_analyzed
911
+ )
912
+
913
+ if not invariants:
914
+ print(f"No invariants detected for scope '{args.scope}'")
915
+ return
916
+
917
+ high = [inv for inv in invariants if inv.confidence >= 0.8]
918
+ low = [inv for inv in invariants if inv.confidence < 0.8]
919
+
920
+ if high:
921
+ print(f"Strong boundaries for {args.scope}:\n")
922
+ for inv in high:
923
+ print(f" {inv.boundary}: no imports ({inv.commit_count} commits observed)")
924
+
925
+ if low:
926
+ print("\nWeak boundaries:\n")
927
+ for inv in low[:5]:
928
+ print(f" {inv.boundary}: no imports (low confidence, {inv.commit_count} commits)")
929
+
930
+
931
+ def _cmd_rebuild(args):
932
+ from .discovery import find_repo_root
933
+ from .storage.session_manager import SessionManager
934
+ from .utility import rebuild_utility
935
+ from .lessons import generate_lessons, save_lessons, detect_invariants, save_invariants
936
+ from .passes.graph_builder import build_graph
937
+
938
+ root = find_repo_root()
939
+ if root is None:
940
+ raise ValueError("Could not find repository root")
941
+
942
+ mgr = SessionManager(root)
943
+ mgr.ensure_initialized()
944
+ sessions = mgr.get_sessions(limit=1000)
945
+ observations = mgr.get_observations(limit=1000)
946
+ dot_dir = mgr.dot_dir
947
+
948
+ print("Rebuilding utility scores...", file=sys.stderr)
949
+ scores = rebuild_utility(dot_dir, sessions, observations)
950
+ print(f" {len(scores)} file scores computed")
951
+
952
+ print("Rebuilding lessons...", file=sys.stderr)
953
+ graph = build_graph(root)
954
+ all_modules = [m.directory for m in graph.modules]
955
+ for mod in all_modules:
956
+ lessons = generate_lessons(sessions, observations, module=mod)
957
+ if lessons:
958
+ save_lessons(dot_dir, mod, lessons)
959
+ print(f" {mod}: {len(lessons)} lesson(s)")
960
+
961
+ print("Rebuilding invariants...", file=sys.stderr)
962
+ from .passes.history_miner import analyze_history
963
+ history = analyze_history(root, max_commits=500)
964
+ for mod in all_modules:
965
+ invariants = detect_invariants(graph.edges, mod, all_modules, history.commits_analyzed)
966
+ if invariants:
967
+ save_invariants(dot_dir, mod, invariants)
968
+ print(f" {mod}: {len(invariants)} invariant(s)")
969
+
970
+ print("Done.")
971
+
972
+
973
+ def _cmd_check(args):
974
+ import json as json_mod
975
+ from .discovery import find_repo_root
976
+ from .passes.sentinel.checker import check_diff, check_staged, format_terminal
977
+
978
+ root = find_repo_root()
979
+ if root is None:
980
+ raise ValueError("Could not find repository root")
981
+
982
+ if args.backtest:
983
+ _cmd_check_backtest(root, args.commits, args.json_output)
984
+ return
985
+
986
+ if args.diff:
987
+ with open(args.diff, "r", encoding="utf-8") as f:
988
+ diff_text = f.read()
989
+ report = check_diff(
990
+ diff_text, root,
991
+ session_id=args.session,
992
+ acknowledge_ids=args.acknowledge,
993
+ )
994
+ else:
995
+ report = check_staged(root, session_id=args.session)
996
+
997
+ if args.json_output:
998
+ data = {
999
+ "passed": report.passed,
1000
+ "holds": [
1001
+ {
1002
+ "category": r.category.value,
1003
+ "severity": r.severity.value,
1004
+ "message": r.message,
1005
+ "file": r.file,
1006
+ "suggestion": r.suggestion,
1007
+ "acknowledge_id": r.acknowledge_id,
1008
+ "proposed_fix": {
1009
+ "file": r.proposed_fix.file,
1010
+ "reason": r.proposed_fix.reason,
1011
+ "predicted_sections": r.proposed_fix.predicted_sections,
1012
+ "confidence": r.proposed_fix.confidence,
1013
+ } if r.proposed_fix else None,
1014
+ }
1015
+ for r in report.holds
1016
+ ],
1017
+ "notes": [
1018
+ {
1019
+ "category": r.category.value,
1020
+ "severity": r.severity.value,
1021
+ "message": r.message,
1022
+ "file": r.file,
1023
+ }
1024
+ for r in report.notes
1025
+ ],
1026
+ "files_checked": report.files_checked,
1027
+ }
1028
+ print(json_mod.dumps(data, indent=2))
1029
+ else:
1030
+ output = format_terminal(report)
1031
+ try:
1032
+ print(output)
1033
+ except UnicodeEncodeError:
1034
+ print(output.encode("ascii", errors="replace").decode("ascii"))
1035
+
1036
+ if not report.passed:
1037
+ sys.exit(1)
1038
+
1039
+
1040
+ def _cmd_check_backtest(root, n_commits, json_output):
1041
+ """Replay recent commits against checks to validate enforcement."""
1042
+ import json as json_mod
1043
+ import subprocess
1044
+ from .passes.sentinel.checker import check_diff
1045
+
1046
+ try:
1047
+ result = subprocess.run(
1048
+ ["git", "log", f"--max-count={n_commits}", "--pretty=format:%H|%s"],
1049
+ cwd=root, capture_output=True, text=True, timeout=15,
1050
+ )
1051
+ if result.returncode != 0:
1052
+ print("Could not read git log", file=sys.stderr)
1053
+ return
1054
+ except (subprocess.TimeoutExpired, FileNotFoundError):
1055
+ print("git not available", file=sys.stderr)
1056
+ return
1057
+
1058
+ commits = []
1059
+ for line in result.stdout.strip().splitlines():
1060
+ if "|" in line:
1061
+ h, msg = line.split("|", 1)
1062
+ commits.append((h.strip(), msg.strip()))
1063
+
1064
+ if not commits:
1065
+ print("No commits found")
1066
+ return
1067
+
1068
+ print(f"dotscope: replaying last {len(commits)} commits\n")
1069
+
1070
+ clean = 0
1071
+ total_holds = 0
1072
+ total_notes = 0
1073
+
1074
+ for commit_hash, message in commits:
1075
+ try:
1076
+ diff_result = subprocess.run(
1077
+ ["git", "diff", commit_hash + "~1", commit_hash],
1078
+ cwd=root, capture_output=True, text=True, timeout=10,
1079
+ )
1080
+ if diff_result.returncode != 0 or not diff_result.stdout:
1081
+ continue
1082
+ except (subprocess.TimeoutExpired, FileNotFoundError):
1083
+ continue
1084
+
1085
+ report = check_diff(diff_result.stdout, root)
1086
+
1087
+ if report.passed and not report.notes:
1088
+ clean += 1
1089
+ continue
1090
+
1091
+ print(f" commit {commit_hash[:7]} \"{message}\"")
1092
+ for r in report.holds:
1093
+ print(f" HOLD {r.category.value}")
1094
+ print(f" {r.message}")
1095
+ total_holds += 1
1096
+ for r in report.notes:
1097
+ print(f" NOTE {r.category.value}")
1098
+ print(f" {r.message}")
1099
+ total_notes += 1
1100
+ print()
1101
+
1102
+ print(f" {clean} commits clean, {total_holds} hold(s), {total_notes} note(s)")
1103
+ if total_holds:
1104
+ print(f" dotscope would have caught {total_holds} issue(s) before they shipped")
1105
+
1106
+ # Onboarding: mark backtest milestone + show next step
1107
+ try:
1108
+ from .storage.onboarding import mark_milestone, next_step
1109
+ state = mark_milestone(root, "first_backtest")
1110
+ ns = next_step(state)
1111
+ if ns:
1112
+ print(f"\n{ns}")
1113
+ except Exception:
1114
+ pass
1115
+
1116
+
1117
+ def _cmd_test_compiler(args):
1118
+ from .discovery import find_repo_root
1119
+ from .regression import load_regressions, replay_regression, format_replay_report
1120
+
1121
+ root = find_repo_root()
1122
+ if root is None:
1123
+ raise ValueError("Could not find repository root")
1124
+
1125
+ cases = load_regressions(root)
1126
+ if args.scope:
1127
+ cases = [c for c in cases if args.scope in c.scope_expr]
1128
+
1129
+ if not cases:
1130
+ print("No regression cases found.")
1131
+ print("Sessions are auto-frozen after successful observations (recall >= 80%).")
1132
+ return
1133
+
1134
+ results = []
1135
+ for case in cases:
1136
+ try:
1137
+ result = replay_regression(case, root)
1138
+ results.append(result)
1139
+ except Exception as e:
1140
+ print(f" {case.id}: error: {e}", file=sys.stderr)
1141
+
1142
+ print(format_replay_report(results))
1143
+ regressions = sum(1 for r in results if r.is_regression)
1144
+ if regressions:
1145
+ sys.exit(1)
1146
+
1147
+
1148
+ def _cmd_bench(args):
1149
+ import json as json_mod
1150
+ from .discovery import find_repo_root
1151
+ from .bench import run_bench, format_bench_report
1152
+
1153
+ root = find_repo_root()
1154
+ if root is None:
1155
+ raise ValueError("Could not find repository root")
1156
+
1157
+ report = run_bench(root)
1158
+
1159
+ if hasattr(args, "json_output") and args.json_output:
1160
+ from dataclasses import asdict
1161
+ print(json_mod.dumps(asdict(report), indent=2))
1162
+ else:
1163
+ print(format_bench_report(report))
1164
+
1165
+
1166
+ def _cmd_debug(args):
1167
+ from .discovery import find_repo_root
1168
+ from .debug import debug_session, list_bad_sessions, format_debug_report
1169
+
1170
+ root = find_repo_root()
1171
+ if root is None:
1172
+ raise ValueError("Could not find repository root")
1173
+
1174
+ if hasattr(args, "list_bad") and args.list_bad:
1175
+ bad = list_bad_sessions(root)
1176
+ if not bad:
1177
+ print("No sessions with low recall found.")
1178
+ return
1179
+ for s in bad:
1180
+ gaps = ", ".join(s["gaps"][:2]) if s["gaps"] else "none"
1181
+ print(f" {s['session_id'][:12]} {s['scope']} recall: {s['recall']:.0%} gaps: {gaps}")
1182
+ return
1183
+
1184
+ session_id = args.session_id
1185
+ if hasattr(args, "last") and args.last:
1186
+ bad = list_bad_sessions(root, limit=1)
1187
+ if bad:
1188
+ session_id = bad[0]["session_id"]
1189
+ else:
1190
+ print("No sessions with low recall found.")
1191
+ return
1192
+
1193
+ if not session_id:
1194
+ print("Usage: dotscope debug <session_id> or dotscope debug --last")
1195
+ return
1196
+
1197
+ result = debug_session(session_id, root)
1198
+ if result is None:
1199
+ print(f"Session {session_id} not found or has good recall (>= 80%).")
1200
+ return
1201
+
1202
+ print(format_debug_report(result))
1203
+
1204
+
1205
+ def _cmd_intent(args):
1206
+ from .discovery import find_repo_root
1207
+ from .intent import load_intents, add_intent, remove_intent
1208
+
1209
+ root = find_repo_root()
1210
+ if root is None:
1211
+ raise ValueError("Could not find repository root")
1212
+
1213
+ if args.intent_action == "add":
1214
+ intent = add_intent(
1215
+ root,
1216
+ directive=args.directive,
1217
+ targets=args.targets,
1218
+ reason=args.reason,
1219
+ replacement=args.replacement,
1220
+ target=args.target,
1221
+ )
1222
+ print(f"Added intent: {intent.directive} (id: {intent.id})")
1223
+
1224
+ elif args.intent_action == "list":
1225
+ intents = load_intents(root)
1226
+ if not intents:
1227
+ print("No intents defined. Use 'dotscope intent add' to declare architectural direction.")
1228
+ return
1229
+ for intent in intents:
1230
+ targets = ", ".join(intent.modules + intent.files)
1231
+ print(f" [{intent.id}] {intent.directive} {targets}")
1232
+ if intent.reason:
1233
+ print(f" {intent.reason}")
1234
+ print(f" set by {intent.set_by} on {intent.set_at}")
1235
+ print()
1236
+
1237
+ elif args.intent_action == "remove":
1238
+ removed = remove_intent(root, args.id)
1239
+ print("Removed." if removed else f"Intent not found: {args.id}")
1240
+
1241
+ else:
1242
+ print("Usage: dotscope intent {add|list|remove}")
1243
+
1244
+
1245
+ def _cmd_voice(args):
1246
+ import json as json_mod
1247
+ from .discovery import find_repo_root
1248
+ from .intent import load_voice_config
1249
+
1250
+ root = find_repo_root(".")
1251
+ voice = load_voice_config(root)
1252
+
1253
+ if voice is None:
1254
+ print("No voice discovered. Run `dotscope ingest .` first.", file=sys.stderr)
1255
+ return
1256
+
1257
+ if getattr(args, "json", False):
1258
+ print(json_mod.dumps(voice, indent=2, default=str))
1259
+ return
1260
+
1261
+ if getattr(args, "upgrade", None):
1262
+ rule = args.upgrade
1263
+ enforce = voice.get("enforce", {})
1264
+ current = enforce.get(rule)
1265
+ if current is False:
1266
+ enforce[rule] = "note"
1267
+ elif current == "note":
1268
+ enforce[rule] = "hold"
1269
+ else:
1270
+ print(f"{rule}: already at highest enforcement level", file=sys.stderr)
1271
+ return
1272
+
1273
+ # Save back
1274
+ from .models.intent import DiscoveredVoice
1275
+ dv = DiscoveredVoice(
1276
+ mode=voice.get("mode", "adaptive"),
1277
+ rules=voice.get("rules", {}),
1278
+ stats=voice.get("stats", {}),
1279
+ enforce=enforce,
1280
+ )
1281
+ from .intent import save_voice_config
1282
+ save_voice_config(root, dv)
1283
+ print(f"{rule}: upgraded to {enforce[rule]}")
1284
+ return
1285
+
1286
+ # Default: show voice config
1287
+ print(f"Mode: {voice.get('mode', 'adaptive')}")
1288
+ print()
1289
+ rules = voice.get("rules", {})
1290
+ if rules:
1291
+ for key, val in rules.items():
1292
+ val_short = val.strip().splitlines()[0] if val else ""
1293
+ print(f" {key}: {val_short}")
1294
+ print()
1295
+ enforce = voice.get("enforce", {})
1296
+ if enforce:
1297
+ print("Enforcement:")
1298
+ for key, val in enforce.items():
1299
+ label = str(val) if val is not False else "off"
1300
+ print(f" {key}: {label}")
1301
+ stats = voice.get("stats", {})
1302
+ if stats:
1303
+ print()
1304
+ print("Stats:")
1305
+ for key, val in stats.items():
1306
+ if val is not None:
1307
+ print(f" {key}: {val}")
1308
+
1309
+
1310
+ def _cmd_conventions(args):
1311
+ import json as json_mod
1312
+ from .discovery import find_repo_root
1313
+ root = find_repo_root(os.getcwd()) or os.getcwd()
1314
+
1315
+ if args.discover:
1316
+ from .passes.graph_builder import build_graph
1317
+ from .passes.convention_discovery import discover_conventions
1318
+ from .passes.convention_parser import parse_conventions
1319
+ from .passes.convention_compliance import compute_compliance
1320
+
1321
+ print("Analyzing codebase...", file=sys.stderr)
1322
+ graph = build_graph(root)
1323
+ if not graph.apis:
1324
+ print("No source files found to analyze.", file=sys.stderr)
1325
+ return
1326
+
1327
+ discovered = discover_conventions(graph.apis, graph)
1328
+ if not discovered:
1329
+ print("No conventions discovered.", file=sys.stderr)
1330
+ return
1331
+
1332
+ nodes = parse_conventions(graph.apis, discovered)
1333
+ for conv in discovered:
1334
+ conv.compliance = compute_compliance(conv, nodes, graph.apis)
1335
+
1336
+ viable = [c for c in discovered if c.compliance >= 0.5]
1337
+
1338
+ print(f"\nDiscovered {len(viable)} conventions:\n")
1339
+ for conv in viable:
1340
+ print(f' "{conv.name}" -- {conv.description}')
1341
+ if conv.rules.get("required_methods"):
1342
+ print(f" Required methods: {', '.join(conv.rules['required_methods'])}")
1343
+ if conv.rules.get("prohibited_imports"):
1344
+ print(f" Prohibited imports: {', '.join(conv.rules['prohibited_imports'])}")
1345
+ print(f" Compliance: {conv.compliance:.0%}")
1346
+ print()
1347
+
1348
+ if args.accept:
1349
+ from .intent import save_conventions
1350
+ save_conventions(root, viable)
1351
+ print(f"Accepted {len(viable)} conventions. Written to intent.yaml.")
1352
+ else:
1353
+ print("Run with --accept to persist, or edit manually in intent.yaml.")
1354
+ return
1355
+
1356
+ # List existing conventions
1357
+ from .intent import load_conventions
1358
+ conventions = load_conventions(root)
1359
+
1360
+ if not conventions:
1361
+ print("No conventions defined. Run 'dotscope conventions --discover' to find patterns.")
1362
+ return
1363
+
1364
+ if getattr(args, "json_output", False):
1365
+ data = [
1366
+ {
1367
+ "name": c.name,
1368
+ "source": c.source,
1369
+ "description": c.description,
1370
+ "compliance": c.compliance,
1371
+ "rules": c.rules,
1372
+ }
1373
+ for c in conventions
1374
+ ]
1375
+ print(json_mod.dumps(data, indent=2))
1376
+ else:
1377
+ print(f"{len(conventions)} conventions:\n")
1378
+ for conv in conventions:
1379
+ severity = "HOLD" if conv.compliance >= 0.80 else "NOTE" if conv.compliance >= 0.50 else "RETIRED"
1380
+ print(f" [{severity}] {conv.name} ({conv.compliance:.0%} compliance)")
1381
+ if conv.description:
1382
+ print(f" {conv.description}")
1383
+ print()
1384
+
1385
+
1386
+ def _cmd_diff(args):
1387
+ import json as json_mod
1388
+ import subprocess
1389
+ from .discovery import find_repo_root
1390
+ root = find_repo_root(os.getcwd()) or os.getcwd()
1391
+
1392
+ # Get diff text
1393
+ if args.staged:
1394
+ result = subprocess.run(
1395
+ ["git", "diff", "--cached"], cwd=root,
1396
+ capture_output=True, text=True, timeout=10,
1397
+ )
1398
+ diff_text = result.stdout
1399
+ elif args.ref:
1400
+ result = subprocess.run(
1401
+ ["git", "diff", args.ref], cwd=root,
1402
+ capture_output=True, text=True, timeout=10,
1403
+ )
1404
+ diff_text = result.stdout
1405
+ else:
1406
+ result = subprocess.run(
1407
+ ["git", "diff"], cwd=root,
1408
+ capture_output=True, text=True, timeout=10,
1409
+ )
1410
+ diff_text = result.stdout
1411
+
1412
+ if not diff_text:
1413
+ print("No changes to diff.")
1414
+ return
1415
+
1416
+ from .intent import load_conventions
1417
+ from .passes.semantic_diff import semantic_diff, format_semantic_diff
1418
+
1419
+ conventions = load_conventions(root)
1420
+ if not conventions:
1421
+ print("No conventions defined. Run 'dotscope conventions --discover' first.")
1422
+ return
1423
+
1424
+ report = semantic_diff(diff_text, root, conventions)
1425
+
1426
+ if getattr(args, "json_output", False):
1427
+ data = {
1428
+ "added": [{"name": n.name, "file": n.file_path} for n in report.added],
1429
+ "removed": [{"name": n.name, "file": n.file_path} for n in report.removed],
1430
+ "modified": [
1431
+ {"name": a.name, "file": a.file_path, "violations": a.violations}
1432
+ for _, a in report.modified
1433
+ ],
1434
+ "all_upheld": report.all_conventions_upheld,
1435
+ }
1436
+ print(json_mod.dumps(data, indent=2))
1437
+ else:
1438
+ print(format_semantic_diff(report))
1439
+
1440
+
1441
+ def _version():
1442
+ from . import __version__
1443
+ return __version__
1444
+
1445
+
1446
+ if __name__ == "__main__":
1447
+ main()