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/composer.py ADDED
@@ -0,0 +1,147 @@
1
+ """Scope composition algebra.
2
+
3
+ Supports expressions like:
4
+ auth+payments — merge (union of files, concatenate context)
5
+ auth-tests — subtract (remove files matching subtracted scope)
6
+ auth&api — intersect (only files in both)
7
+ auth@context — modifier (context only, no files)
8
+
9
+ Operators bind left-to-right, no precedence. @ is a suffix modifier.
10
+ """
11
+
12
+
13
+ import re
14
+ from dataclasses import dataclass
15
+ from enum import Enum
16
+ from typing import List, Optional
17
+
18
+ from .discovery import find_scope, find_repo_root
19
+ from .models import ResolvedScope
20
+ from .resolver import resolve
21
+ from .tokens import estimate_scope_tokens, estimate_context_tokens
22
+
23
+
24
+ class Op(Enum):
25
+ MERGE = "+"
26
+ SUBTRACT = "-"
27
+ INTERSECT = "&"
28
+
29
+
30
+ @dataclass
31
+ class ScopeRef:
32
+ """A reference to a scope with optional modifier."""
33
+ name: str
34
+ context_only: bool = False
35
+
36
+
37
+ @dataclass
38
+ class ScopeOp:
39
+ """An operation in a scope expression."""
40
+ operator: Optional[Op] # None for the first operand
41
+ ref: ScopeRef
42
+
43
+
44
+ def parse_expression(expr: str) -> List[ScopeOp]:
45
+ """Parse a scope expression into a list of operations.
46
+
47
+ Examples:
48
+ "auth" → [ScopeOp(None, ScopeRef("auth"))]
49
+ "auth+payments" → [ScopeOp(None, "auth"), ScopeOp(MERGE, "payments")]
50
+ "auth-tests" → [ScopeOp(None, "auth"), ScopeOp(SUBTRACT, "tests")]
51
+ "auth@context" → [ScopeOp(None, ScopeRef("auth", context_only=True))]
52
+ """
53
+ expr = expr.strip()
54
+ if not expr:
55
+ raise ValueError("Empty scope expression")
56
+
57
+ # Tokenize on operators, keeping the operators
58
+ tokens = re.split(r"([+\-&])", expr)
59
+ tokens = [t.strip() for t in tokens if t.strip()]
60
+
61
+ ops: List[ScopeOp] = []
62
+ current_op: Optional[Op] = None
63
+
64
+ for token in tokens:
65
+ if token in ("+", "-", "&"):
66
+ current_op = Op(token)
67
+ else:
68
+ ref = _parse_ref(token)
69
+ ops.append(ScopeOp(operator=current_op, ref=ref))
70
+ current_op = None
71
+
72
+ if not ops:
73
+ raise ValueError(f"Invalid scope expression: {expr}")
74
+
75
+ return ops
76
+
77
+
78
+ def _parse_ref(token: str) -> ScopeRef:
79
+ """Parse a scope reference, handling @modifier."""
80
+ if "@" in token:
81
+ name, modifier = token.split("@", 1)
82
+ if modifier == "context":
83
+ return ScopeRef(name=name, context_only=True)
84
+ else:
85
+ raise ValueError(f"Unknown modifier @{modifier}. Supported: @context")
86
+ return ScopeRef(name=token)
87
+
88
+
89
+ def compose(
90
+ expr: str,
91
+ root: Optional[str] = None,
92
+ follow_related: bool = True,
93
+ ) -> ResolvedScope:
94
+ """Resolve a scope expression to a ResolvedScope.
95
+
96
+ This is the main entry point for scope composition.
97
+ """
98
+ if root is None:
99
+ root = find_repo_root()
100
+ if root is None:
101
+ raise ValueError("Could not find repository root. No .scopes, .git, or .scope found.")
102
+
103
+ ops = parse_expression(expr)
104
+ result: Optional[ResolvedScope] = None
105
+
106
+ for op in ops:
107
+ # Resolve the scope reference
108
+ config = find_scope(op.ref.name, root)
109
+ if config is None:
110
+ # Lazy ingest: generate scope on demand
111
+ from .passes.lazy import lazy_ingest_module
112
+ config = lazy_ingest_module(root, op.ref.name)
113
+ if config is None:
114
+ raise ValueError(f"Scope not found: {op.ref.name}")
115
+
116
+ resolved = resolve(config, follow_related=follow_related, root=root)
117
+
118
+ # Apply @context modifier
119
+ if op.ref.context_only:
120
+ resolved = ResolvedScope(
121
+ files=[],
122
+ context=resolved.context,
123
+ token_estimate=estimate_context_tokens(resolved.context),
124
+ scope_chain=resolved.scope_chain,
125
+ truncated=False,
126
+ )
127
+
128
+ # Apply operator
129
+ if result is None:
130
+ result = resolved
131
+ elif op.operator == Op.MERGE:
132
+ result = result.merge(resolved)
133
+ elif op.operator == Op.SUBTRACT:
134
+ result = result.subtract(resolved)
135
+ # Recalculate tokens after subtraction
136
+ result.token_estimate = (
137
+ estimate_scope_tokens(result.files)
138
+ + estimate_context_tokens(result.context)
139
+ )
140
+ elif op.operator == Op.INTERSECT:
141
+ result = result.intersect(resolved)
142
+ result.token_estimate = (
143
+ estimate_scope_tokens(result.files)
144
+ + estimate_context_tokens(result.context)
145
+ )
146
+
147
+ return result or ResolvedScope()
dotscope/constants.py ADDED
@@ -0,0 +1,45 @@
1
+ """Shared constants — single source of truth for all modules."""
2
+
3
+ # Directories to always skip when walking a codebase
4
+ SKIP_DIRS = frozenset({
5
+ ".git",
6
+ "node_modules",
7
+ "__pycache__",
8
+ "venv",
9
+ ".venv",
10
+ "env",
11
+ ".env",
12
+ "dist",
13
+ "build",
14
+ ".tox",
15
+ ".mypy_cache",
16
+ ".ruff_cache",
17
+ ".eggs",
18
+ ".pytest_cache",
19
+ })
20
+
21
+ # Source file extensions
22
+ SOURCE_EXTS = frozenset({
23
+ ".py", ".js", ".ts", ".tsx", ".jsx",
24
+ ".go", ".rs", ".rb", ".java", ".kt",
25
+ ".swift", ".c", ".cpp", ".cs", ".php",
26
+ })
27
+
28
+ # Extension → language name
29
+ LANG_MAP = {
30
+ ".py": "Python",
31
+ ".js": "JavaScript",
32
+ ".ts": "TypeScript",
33
+ ".tsx": "TypeScript",
34
+ ".jsx": "JavaScript",
35
+ ".go": "Go",
36
+ ".rs": "Rust",
37
+ ".rb": "Ruby",
38
+ ".java": "Java",
39
+ ".kt": "Kotlin",
40
+ ".swift": "Swift",
41
+ ".c": "C",
42
+ ".cpp": "C++",
43
+ ".cs": "C#",
44
+ ".php": "PHP",
45
+ }
dotscope/context.py ADDED
@@ -0,0 +1,60 @@
1
+ """Structured context parsing and querying.
2
+
3
+ Convention: ## Section Name headers within a context block create named sections.
4
+ Agents can query specific sections (e.g., "gotchas", "invariants").
5
+ """
6
+
7
+
8
+ import re
9
+ from typing import Optional
10
+
11
+ from .models import StructuredContext
12
+
13
+
14
+ def parse_context(raw: str) -> StructuredContext:
15
+ """Parse a context string into a StructuredContext with optional sections.
16
+
17
+ Sections are delimited by ## headers within the context block:
18
+ ## Invariants
19
+ Some invariant text...
20
+
21
+ ## Gotchas
22
+ Watch out for...
23
+
24
+ If no ## headers are found, the entire string is the raw context with no sections.
25
+ """
26
+ if not raw or not raw.strip():
27
+ return StructuredContext(raw="", sections={})
28
+
29
+ raw = raw.strip()
30
+ sections: dict[str, str] = {}
31
+
32
+ # Split on ## headers
33
+ parts = re.split(r"^##\s+(.+)$", raw, flags=re.MULTILINE)
34
+
35
+ # parts[0] is text before any header (preamble)
36
+ # Then alternating: header_name, content, header_name, content, ...
37
+ if len(parts) <= 1:
38
+ # No sections found — the whole thing is raw context
39
+ return StructuredContext(raw=raw, sections={})
40
+
41
+ preamble = parts[0].strip()
42
+ i = 1
43
+ while i < len(parts) - 1:
44
+ section_name = parts[i].strip()
45
+ section_content = parts[i + 1].strip()
46
+ sections[section_name] = section_content
47
+ i += 2
48
+
49
+ # If there's a preamble, add it as a special section
50
+ if preamble:
51
+ sections["_preamble"] = preamble
52
+
53
+ return StructuredContext(raw=raw, sections=sections)
54
+
55
+
56
+ def query_context(ctx: Optional[StructuredContext], section: Optional[str] = None) -> str:
57
+ """Query context, optionally filtering to a named section."""
58
+ if ctx is None:
59
+ return ""
60
+ return ctx.query(section)
@@ -0,0 +1,180 @@
1
+ """Counterfactual detection: what didn't happen because dotscope was there.
2
+
3
+ Three sources:
4
+ 1. Anti-patterns avoided (from near-miss data)
5
+ 2. Contracts honored (agent modified both sides of a coupled pair)
6
+ 3. Intents respected (agent didn't violate declared architectural direction)
7
+
8
+ Only surfaces counterfactuals where the constraint was in the resolve response
9
+ the agent received. Coincidences don't count.
10
+ """
11
+
12
+ import re
13
+ from typing import Dict, List, Optional, Set
14
+
15
+ from .models.state import Counterfactual # noqa: F401
16
+
17
+
18
+ def compute_counterfactuals(
19
+ constraints_served: List[dict],
20
+ modified_files: Set[str],
21
+ diff_text: str,
22
+ near_misses: Optional[List] = None,
23
+ invariants: Optional[dict] = None,
24
+ intents: Optional[list] = None,
25
+ ) -> List[Counterfactual]:
26
+ """Compute what dotscope prevented during this session.
27
+
28
+ Args:
29
+ constraints_served: Constraints the agent received via resolve_scope
30
+ modified_files: Files the agent actually modified
31
+ diff_text: Combined diff of all commits in this session
32
+ near_misses: Near-miss detections from observation
33
+ invariants: Cached invariants (contracts, stabilities)
34
+ intents: Architectural intents
35
+ """
36
+ results: List[Counterfactual] = []
37
+
38
+ # 1. Anti-patterns avoided (from near-miss data)
39
+ if near_misses:
40
+ for nm in near_misses:
41
+ event = nm.get("event", "") if isinstance(nm, dict) else getattr(nm, "event", "")
42
+ scope = nm.get("scope", "") if isinstance(nm, dict) else getattr(nm, "scope", "")
43
+ if event:
44
+ results.append(Counterfactual(
45
+ type="anti_pattern_avoided",
46
+ description=event,
47
+ source=f"{scope} scope context" if scope else "scope context",
48
+ severity="high",
49
+ ))
50
+
51
+ # 2. Contracts honored — agent modified both sides of a coupled pair
52
+ if invariants and modified_files:
53
+ served_contracts = _extract_served_contracts(constraints_served)
54
+ for contract in invariants.get("contracts", []):
55
+ trigger = contract.get("trigger_file", "")
56
+ coupled = contract.get("coupled_file", "")
57
+ confidence = contract.get("confidence", 0.0)
58
+
59
+ if confidence < 0.65:
60
+ continue
61
+
62
+ # Both modified AND the contract was in the constraints the agent saw
63
+ if (trigger in modified_files
64
+ and coupled in modified_files
65
+ and _contract_was_served(trigger, coupled, served_contracts)):
66
+ results.append(Counterfactual(
67
+ type="contract_honored",
68
+ description=f"Agent included {coupled} alongside {trigger}",
69
+ source=f"implicit contract ({confidence:.0%} co-change)",
70
+ severity="high",
71
+ ))
72
+
73
+ # 3. Intents respected — agent didn't violate declared direction
74
+ if intents and modified_files and diff_text:
75
+ served_intents = _extract_served_intents(constraints_served)
76
+ for intent in intents:
77
+ if not isinstance(intent, dict):
78
+ directive = getattr(intent, "directive", "")
79
+ modules = getattr(intent, "modules", [])
80
+ else:
81
+ directive = intent.get("directive", "")
82
+ modules = intent.get("modules", [])
83
+
84
+ if directive != "decouple" or len(modules) < 2:
85
+ continue
86
+
87
+ # Was this intent served to the agent?
88
+ if not _intent_was_served(directive, modules, served_intents):
89
+ continue
90
+
91
+ # Did the agent touch either module without new coupling?
92
+ touched_modules = set()
93
+ for f in modified_files:
94
+ for m in modules:
95
+ if f.startswith(m):
96
+ touched_modules.add(m)
97
+
98
+ if len(touched_modules) >= 1 and not _has_new_coupling(modules, diff_text):
99
+ mod_str = " and ".join(m.rstrip("/") for m in modules)
100
+ results.append(Counterfactual(
101
+ type="intent_respected",
102
+ description=f"Agent avoided new coupling between {mod_str}",
103
+ source=f"intent: decouple {' '.join(modules)}",
104
+ severity="medium",
105
+ ))
106
+
107
+ return results
108
+
109
+
110
+ def format_counterfactuals_terminal(counterfactuals: List[Counterfactual]) -> str:
111
+ """Format counterfactuals for terminal display."""
112
+ if not counterfactuals:
113
+ return ""
114
+
115
+ lines = ["", " What dotscope prevented:"]
116
+ for cf in counterfactuals:
117
+ lines.append(f" {cf.description}")
118
+ lines.append(f" <- {cf.source}")
119
+ return "\n".join(lines)
120
+
121
+
122
+ # ---------------------------------------------------------------------------
123
+ # Internals
124
+ # ---------------------------------------------------------------------------
125
+
126
+ def _extract_served_contracts(constraints: List[dict]) -> List[dict]:
127
+ """Extract contract constraints from what was served."""
128
+ return [c for c in constraints if c.get("category") == "contract"]
129
+
130
+
131
+ def _extract_served_intents(constraints: List[dict]) -> List[dict]:
132
+ """Extract intent constraints from what was served."""
133
+ return [c for c in constraints if c.get("category") == "intent"]
134
+
135
+
136
+ def _contract_was_served(
137
+ trigger: str, coupled: str, served: List[dict]
138
+ ) -> bool:
139
+ """Check if a specific contract was in the served constraints."""
140
+ for c in served:
141
+ msg = c.get("message", "")
142
+ if trigger in msg and coupled in msg:
143
+ return True
144
+ if coupled in msg and trigger in msg:
145
+ return True
146
+ return False
147
+
148
+
149
+ def _intent_was_served(
150
+ directive: str, modules: List[str], served: List[dict]
151
+ ) -> bool:
152
+ """Check if a specific intent was in the served constraints."""
153
+ for c in served:
154
+ msg = c.get("message", "")
155
+ if directive in msg and any(m.rstrip("/") in msg for m in modules):
156
+ return True
157
+ return False
158
+
159
+
160
+ def _has_new_coupling(modules: List[str], diff_text: str) -> bool:
161
+ """Check if the diff introduces new imports between the listed modules."""
162
+ import_re = re.compile(r'(?:from\s+(\S+)\s+import|import\s+(\S+))')
163
+ current_file = ""
164
+
165
+ for line in diff_text.splitlines():
166
+ if line.startswith("diff --git"):
167
+ parts = line.split(" b/", 1)
168
+ current_file = parts[1] if len(parts) > 1 else ""
169
+ elif line.startswith("+") and not line.startswith("+++"):
170
+ m = import_re.search(line)
171
+ if not m:
172
+ continue
173
+ imported = (m.group(1) or m.group(2) or "").split(".")[0]
174
+ imported_mod = imported + "/"
175
+
176
+ file_mod = current_file.split("/")[0] + "/" if "/" in current_file else ""
177
+ if file_mod in modules and imported_mod in modules and file_mod != imported_mod:
178
+ return True
179
+
180
+ return False
dotscope/debug.py ADDED
@@ -0,0 +1,220 @@
1
+ """Context bisection: debug why an agent session produced a bad outcome.
2
+
3
+ Deterministic. No LLM calls. Bisects files, context sections, and
4
+ constraints to identify the root cause.
5
+ """
6
+
7
+ import json
8
+ import os
9
+ from typing import Dict, List, Optional, Tuple
10
+
11
+ from .models.state import BisectionResult # noqa: F401
12
+
13
+
14
+ def debug_session(
15
+ session_id: str,
16
+ repo_root: str,
17
+ ) -> Optional[BisectionResult]:
18
+ """Bisect a session to find root cause of bad outcome."""
19
+ session = _load_session(repo_root, session_id)
20
+ observation = _load_observation(repo_root, session_id)
21
+
22
+ if not session or not observation:
23
+ return None
24
+
25
+ recall = observation.get("recall", 1.0)
26
+ if recall >= 0.8:
27
+ return None # Nothing to debug
28
+
29
+ resolved_files = set(session.get("predicted_files", []))
30
+ actual_files = set(observation.get("actual_files_modified", []))
31
+
32
+ # Bisect files
33
+ files_that_mattered = sorted(resolved_files & actual_files)
34
+ files_that_didnt_help = sorted(resolved_files - actual_files)
35
+ missing_files = sorted(actual_files - resolved_files)
36
+
37
+ # Bisect context
38
+ context = session.get("context", "")
39
+ sections = _parse_sections(context)
40
+ relevant = []
41
+ irrelevant = []
42
+ for name, text in sections.items():
43
+ if any(f in text or os.path.basename(f) in text for f in actual_files):
44
+ relevant.append(name)
45
+ else:
46
+ irrelevant.append(name)
47
+
48
+ # Bisect constraints
49
+ constraints = session.get("constraints_served", [])
50
+ honored = []
51
+ violated = []
52
+ for c in constraints:
53
+ if _constraint_violated(c, observation):
54
+ violated.append(c)
55
+ else:
56
+ honored.append(c)
57
+
58
+ # Diagnose
59
+ diagnosis, recommendations = _diagnose(
60
+ missing_files, violated, files_that_didnt_help,
61
+ )
62
+
63
+ return BisectionResult(
64
+ session_id=session_id,
65
+ files_that_mattered=files_that_mattered,
66
+ files_that_didnt_help=files_that_didnt_help,
67
+ context_sections_relevant=relevant,
68
+ context_sections_irrelevant=irrelevant,
69
+ constraints_honored=honored,
70
+ constraints_violated=violated,
71
+ missing_files=missing_files,
72
+ diagnosis=diagnosis,
73
+ recommendations=recommendations,
74
+ )
75
+
76
+
77
+ def list_bad_sessions(repo_root: str, limit: int = 10) -> List[dict]:
78
+ """List sessions with low recall for debugging."""
79
+ from .sessions import SessionManager
80
+ mgr = SessionManager(repo_root)
81
+ observations = mgr.get_observations(limit=200)
82
+ sessions = mgr.get_sessions(limit=200)
83
+ session_map = {s.session_id: s for s in sessions}
84
+
85
+ bad = []
86
+ for obs in observations:
87
+ if obs.recall < 0.8:
88
+ s = session_map.get(obs.session_id)
89
+ bad.append({
90
+ "session_id": obs.session_id,
91
+ "scope": s.scope_expr if s else "unknown",
92
+ "recall": obs.recall,
93
+ "gaps": obs.touched_not_predicted[:3],
94
+ })
95
+
96
+ return bad[:limit]
97
+
98
+
99
+ def format_debug_report(result: BisectionResult) -> str:
100
+ """Format bisection result for terminal output."""
101
+ lines = [f"dotscope debug: session {result.session_id}\n"]
102
+
103
+ lines.append(" File Bisection")
104
+ if result.files_that_mattered:
105
+ lines.append(f" Files that mattered: {', '.join(result.files_that_mattered)}")
106
+ if result.files_that_didnt_help:
107
+ lines.append(f" Files that didn't help: {', '.join(result.files_that_didnt_help)}")
108
+ if result.missing_files:
109
+ lines.append(f" Missing files: {', '.join(result.missing_files)}")
110
+ lines.append("")
111
+
112
+ if result.context_sections_relevant or result.context_sections_irrelevant:
113
+ lines.append(" Context Bisection")
114
+ for s in result.context_sections_relevant:
115
+ lines.append(f" Relevant: {s}")
116
+ for s in result.context_sections_irrelevant:
117
+ lines.append(f" Irrelevant: {s}")
118
+ lines.append("")
119
+
120
+ if result.constraints_violated:
121
+ lines.append(" Constraints Violated")
122
+ for c in result.constraints_violated:
123
+ lines.append(f" {c.get('message', 'unknown')}")
124
+ lines.append("")
125
+
126
+ lines.append(f" Diagnosis: {result.diagnosis}")
127
+ for rec in result.recommendations:
128
+ lines.append(f" -> {rec}")
129
+
130
+ return "\n".join(lines)
131
+
132
+
133
+ # ---------------------------------------------------------------------------
134
+ # Internals
135
+ # ---------------------------------------------------------------------------
136
+
137
+ def _load_session(repo_root: str, session_id: str) -> Optional[dict]:
138
+ """Load a session file."""
139
+ path = os.path.join(repo_root, ".dotscope", "sessions", f"{session_id}.json")
140
+ if not os.path.exists(path):
141
+ return None
142
+ with open(path, "r", encoding="utf-8") as f:
143
+ return json.load(f)
144
+
145
+
146
+ def _load_observation(repo_root: str, session_id: str) -> Optional[dict]:
147
+ """Find observation matching a session."""
148
+ from .sessions import SessionManager
149
+ mgr = SessionManager(repo_root)
150
+ for obs in mgr.get_observations(limit=200):
151
+ if obs.session_id == session_id:
152
+ return {
153
+ "actual_files_modified": obs.actual_files_modified,
154
+ "recall": obs.recall,
155
+ "touched_not_predicted": obs.touched_not_predicted,
156
+ }
157
+ return None
158
+
159
+
160
+ def _parse_sections(context: str) -> Dict[str, str]:
161
+ """Parse context into named sections (## headers)."""
162
+ sections: Dict[str, str] = {}
163
+ current = "main"
164
+ lines: List[str] = []
165
+
166
+ for line in context.splitlines():
167
+ if line.startswith("## "):
168
+ if lines:
169
+ sections[current] = "\n".join(lines)
170
+ current = line[3:].strip()
171
+ lines = []
172
+ else:
173
+ lines.append(line)
174
+
175
+ if lines:
176
+ sections[current] = "\n".join(lines)
177
+ return sections
178
+
179
+
180
+ def _constraint_violated(constraint: dict, observation: dict) -> bool:
181
+ """Check if a served constraint was violated in the observation."""
182
+ msg = constraint.get("message", "").lower()
183
+ actual = set(observation.get("actual_files_modified", []))
184
+
185
+ # Contract: check if both sides were modified
186
+ if "modify" in msg and "review" in msg:
187
+ for f in actual:
188
+ if f.lower() in msg:
189
+ # One side modified — check if the other was too
190
+ return True # Simplified: if contract mentioned, check presence
191
+
192
+ return False
193
+
194
+
195
+ def _diagnose(
196
+ missing_files: List[str],
197
+ constraints_violated: List[dict],
198
+ files_unused: List[str],
199
+ ) -> Tuple[str, List[str]]:
200
+ """Determine root cause and recommendations."""
201
+ recommendations = []
202
+
203
+ if missing_files:
204
+ for f in missing_files[:3]:
205
+ recommendations.append(f"Add {f} to scope includes or add assertion ensure_includes: [{f}]")
206
+ return "resolution_gap", recommendations
207
+
208
+ if constraints_violated and not missing_files:
209
+ for c in constraints_violated:
210
+ recommendations.append(
211
+ f"Agent ignored constraint: {c.get('message', '')[:60]}. "
212
+ f"Consider strengthening to HOLD."
213
+ )
214
+ return "agent_ignored", recommendations
215
+
216
+ if not missing_files and not constraints_violated:
217
+ recommendations.append("Add anti-patterns or intent directives for the patterns that caused the bad commit")
218
+ return "constraint_gap", recommendations
219
+
220
+ return "context_conflict", ["Review scope context for contradictory guidance"]