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/passes/lazy.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Lazy ingest: generate a single scope on demand when resolve can't find one.
|
|
2
|
+
|
|
3
|
+
When an agent (or CLI) resolves a module that hasn't been ingested yet,
|
|
4
|
+
this module builds a minimal scope from a partial graph and filtered history.
|
|
5
|
+
Full ingest fills in transitive dependencies and cross-module contracts later.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
from ..absorber import absorb_docs
|
|
14
|
+
from ..context import parse_context
|
|
15
|
+
from ..history import HistoryAnalysis, analyze_history
|
|
16
|
+
from ..models.core import ScopeConfig
|
|
17
|
+
from ..models.passes import PlannedScope
|
|
18
|
+
from ..parser import serialize_scope
|
|
19
|
+
from ..progress import ProgressEmitter
|
|
20
|
+
from ..tokens import estimate_scope_tokens
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def lazy_ingest_module(
|
|
24
|
+
root: str,
|
|
25
|
+
module_name: str,
|
|
26
|
+
quiet: bool = True,
|
|
27
|
+
) -> Optional[ScopeConfig]:
|
|
28
|
+
"""Ingest a single module on demand.
|
|
29
|
+
|
|
30
|
+
Returns the ScopeConfig if successful, None if module dir doesn't exist.
|
|
31
|
+
"""
|
|
32
|
+
module_name = module_name.rstrip("/")
|
|
33
|
+
module_path = os.path.join(root, module_name)
|
|
34
|
+
if not os.path.isdir(module_path):
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
progress = ProgressEmitter(quiet=quiet)
|
|
38
|
+
|
|
39
|
+
# Collect module files
|
|
40
|
+
from .graph_builder import _collect_source_files, build_partial_graph
|
|
41
|
+
all_files = _collect_source_files(root)
|
|
42
|
+
module_files = [
|
|
43
|
+
(rel, lang) for rel, lang in all_files
|
|
44
|
+
if rel.startswith(module_name + "/") or rel.startswith(module_name + os.sep)
|
|
45
|
+
]
|
|
46
|
+
if not module_files:
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
# Partial graph: module files + one level of imports
|
|
50
|
+
progress.start(f"lazy ingest {module_name}/ (graph)")
|
|
51
|
+
graph = build_partial_graph(root, module_files)
|
|
52
|
+
progress.finish(f"{len(graph.files)} files")
|
|
53
|
+
|
|
54
|
+
# Filtered history: recent commits touching this module
|
|
55
|
+
progress.start(f"lazy ingest {module_name}/ (history)")
|
|
56
|
+
history = HistoryAnalysis()
|
|
57
|
+
try:
|
|
58
|
+
history = analyze_history(
|
|
59
|
+
root, max_commits=50, paths=[module_name + "/"],
|
|
60
|
+
)
|
|
61
|
+
except Exception:
|
|
62
|
+
pass
|
|
63
|
+
progress.finish(f"{history.commits_analyzed} commits")
|
|
64
|
+
|
|
65
|
+
# Synthesize one scope
|
|
66
|
+
scope_path = os.path.join(root, module_name, ".scope")
|
|
67
|
+
includes = [module_name + "/"]
|
|
68
|
+
|
|
69
|
+
# Add cross-module imports from the partial graph
|
|
70
|
+
for path, node in graph.files.items():
|
|
71
|
+
if path.startswith(module_name + "/"):
|
|
72
|
+
for imp in node.imports:
|
|
73
|
+
if not imp.startswith(module_name + "/") and imp not in includes:
|
|
74
|
+
includes.append(imp)
|
|
75
|
+
|
|
76
|
+
# Context from history
|
|
77
|
+
context_parts = []
|
|
78
|
+
if history.implicit_contracts:
|
|
79
|
+
context_parts.append("## Implicit Contracts (from git history)")
|
|
80
|
+
for ic in history.implicit_contracts[:5]:
|
|
81
|
+
context_parts.append(f"- {ic.description}")
|
|
82
|
+
|
|
83
|
+
# Stability
|
|
84
|
+
stability_lines = []
|
|
85
|
+
for rel, _ in module_files:
|
|
86
|
+
fh = history.file_histories.get(rel)
|
|
87
|
+
if fh and fh.stability and fh.commit_count >= 3:
|
|
88
|
+
stability_lines.append(
|
|
89
|
+
f"- {os.path.basename(rel)}: {fh.stability} ({fh.commit_count} commits)"
|
|
90
|
+
)
|
|
91
|
+
if stability_lines:
|
|
92
|
+
context_parts.append("## Stability")
|
|
93
|
+
context_parts.extend(stability_lines[:10])
|
|
94
|
+
|
|
95
|
+
if not context_parts:
|
|
96
|
+
context_parts.append(
|
|
97
|
+
f"{module_name} module — {len(module_files)} files."
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
context = parse_context("\n".join(context_parts))
|
|
101
|
+
|
|
102
|
+
# Tags
|
|
103
|
+
tags = [module_name.lower()]
|
|
104
|
+
|
|
105
|
+
# Token estimate
|
|
106
|
+
full_paths = [os.path.join(root, rel) for rel, _ in module_files]
|
|
107
|
+
token_est = estimate_scope_tokens(full_paths)
|
|
108
|
+
|
|
109
|
+
config = ScopeConfig(
|
|
110
|
+
path=scope_path,
|
|
111
|
+
description=f"{module_name} module ({len(module_files)} files)",
|
|
112
|
+
includes=includes,
|
|
113
|
+
excludes=[f"{module_name}/__pycache__/", "*.pyc"],
|
|
114
|
+
context=context,
|
|
115
|
+
related=[],
|
|
116
|
+
owners=[],
|
|
117
|
+
tags=tags,
|
|
118
|
+
tokens_estimate=token_est,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# Write scope file
|
|
122
|
+
os.makedirs(os.path.dirname(scope_path), exist_ok=True)
|
|
123
|
+
content = serialize_scope(config)
|
|
124
|
+
with open(scope_path, "w", encoding="utf-8") as f:
|
|
125
|
+
f.write(content)
|
|
126
|
+
|
|
127
|
+
# Update .scopes index
|
|
128
|
+
from ..ingest import append_to_index
|
|
129
|
+
planned = PlannedScope(
|
|
130
|
+
directory=module_name,
|
|
131
|
+
config=config,
|
|
132
|
+
confidence=0.5,
|
|
133
|
+
signals=["lazy: on-demand ingest"],
|
|
134
|
+
)
|
|
135
|
+
try:
|
|
136
|
+
append_to_index(root, planned)
|
|
137
|
+
except Exception:
|
|
138
|
+
pass # Index update is best-effort
|
|
139
|
+
|
|
140
|
+
# Mark that a full re-ingest is needed
|
|
141
|
+
dot_dir = os.path.join(root, ".dotscope")
|
|
142
|
+
os.makedirs(dot_dir, exist_ok=True)
|
|
143
|
+
marker = os.path.join(dot_dir, "needs_full_ingest")
|
|
144
|
+
Path(marker).touch()
|
|
145
|
+
|
|
146
|
+
if not quiet:
|
|
147
|
+
print(
|
|
148
|
+
f"dotscope: {module_name}/ scoped on demand",
|
|
149
|
+
file=sys.stderr,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
return config
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Semantic diff: translate git diff into convention-level structural changes."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
from typing import Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
from ..models import ConventionNode, ConventionRule, FileAnalysis, SemanticDiffReport
|
|
8
|
+
from .convention_parser import parse_conventions
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def semantic_diff(
|
|
12
|
+
diff_text: str,
|
|
13
|
+
repo_root: str,
|
|
14
|
+
conventions: List[ConventionRule],
|
|
15
|
+
) -> SemanticDiffReport:
|
|
16
|
+
"""Translate a git diff into convention-level changes.
|
|
17
|
+
|
|
18
|
+
Parses the AST at two points in time:
|
|
19
|
+
1. HEAD commit (using `git show HEAD:<file>` for each modified file)
|
|
20
|
+
2. Working directory (current files on disk)
|
|
21
|
+
|
|
22
|
+
Compares the ConventionNode graphs to determine structural changes.
|
|
23
|
+
"""
|
|
24
|
+
modified_files = _extract_modified_files(diff_text)
|
|
25
|
+
if not modified_files or not conventions:
|
|
26
|
+
return SemanticDiffReport()
|
|
27
|
+
|
|
28
|
+
# Parse conventions at HEAD
|
|
29
|
+
head_ast = {}
|
|
30
|
+
for filepath in modified_files:
|
|
31
|
+
source = _git_show_head(repo_root, filepath)
|
|
32
|
+
if source:
|
|
33
|
+
analysis = _parse_source(source, filepath)
|
|
34
|
+
if analysis:
|
|
35
|
+
head_ast[filepath] = analysis
|
|
36
|
+
|
|
37
|
+
nodes_before = parse_conventions(head_ast, conventions)
|
|
38
|
+
|
|
39
|
+
# Parse conventions at working directory
|
|
40
|
+
working_ast = {}
|
|
41
|
+
for filepath in modified_files:
|
|
42
|
+
full_path = os.path.join(repo_root, filepath)
|
|
43
|
+
if os.path.exists(full_path):
|
|
44
|
+
try:
|
|
45
|
+
with open(full_path, "r", encoding="utf-8") as f:
|
|
46
|
+
source = f.read()
|
|
47
|
+
analysis = _parse_source(source, filepath)
|
|
48
|
+
if analysis:
|
|
49
|
+
working_ast[filepath] = analysis
|
|
50
|
+
except (IOError, UnicodeDecodeError):
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
nodes_after = parse_conventions(working_ast, conventions)
|
|
54
|
+
|
|
55
|
+
before_map = {(n.file_path, n.name): n for n in nodes_before}
|
|
56
|
+
after_map = {(n.file_path, n.name): n for n in nodes_after}
|
|
57
|
+
|
|
58
|
+
added = []
|
|
59
|
+
removed = []
|
|
60
|
+
modified = []
|
|
61
|
+
|
|
62
|
+
for key, node in after_map.items():
|
|
63
|
+
if key not in before_map:
|
|
64
|
+
added.append(node)
|
|
65
|
+
elif before_map[key].violations != node.violations:
|
|
66
|
+
modified.append((before_map[key], node))
|
|
67
|
+
|
|
68
|
+
for key, node in before_map.items():
|
|
69
|
+
if key not in after_map:
|
|
70
|
+
removed.append(node)
|
|
71
|
+
|
|
72
|
+
all_upheld = all(not n.violations for n in after_map.values())
|
|
73
|
+
|
|
74
|
+
return SemanticDiffReport(
|
|
75
|
+
added=added,
|
|
76
|
+
removed=removed,
|
|
77
|
+
modified=modified,
|
|
78
|
+
all_conventions_upheld=all_upheld,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def format_semantic_diff(report: SemanticDiffReport) -> str:
|
|
83
|
+
"""Format a SemanticDiffReport for terminal output."""
|
|
84
|
+
lines = ["Semantic Diff:"]
|
|
85
|
+
|
|
86
|
+
for node in report.added:
|
|
87
|
+
lines.append(f" [ADDED] {node.name}: {node.file_path}")
|
|
88
|
+
for node in report.removed:
|
|
89
|
+
lines.append(f" [REMOVED] {node.name}: {node.file_path}")
|
|
90
|
+
for before, after in report.modified:
|
|
91
|
+
lines.append(f" [MODIFIED] {after.name}: {after.file_path}")
|
|
92
|
+
for v in after.violations:
|
|
93
|
+
lines.append(f" ! {v}")
|
|
94
|
+
for dep in report.dependency_changes:
|
|
95
|
+
lines.append(f" [MODIFIED] Dependency: {dep}")
|
|
96
|
+
|
|
97
|
+
lines.append("")
|
|
98
|
+
if report.all_conventions_upheld:
|
|
99
|
+
lines.append(" Conventions: All upheld")
|
|
100
|
+
else:
|
|
101
|
+
violation_count = sum(
|
|
102
|
+
len(n.violations) for n in
|
|
103
|
+
[node for _, node in report.modified] +
|
|
104
|
+
report.added
|
|
105
|
+
)
|
|
106
|
+
lines.append(f" Conventions: {violation_count} violation(s)")
|
|
107
|
+
|
|
108
|
+
if report.counterfactual:
|
|
109
|
+
lines.append("")
|
|
110
|
+
lines.append(f" {report.counterfactual}")
|
|
111
|
+
|
|
112
|
+
return "\n".join(lines)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _extract_modified_files(diff_text: str) -> List[str]:
|
|
116
|
+
"""Extract file paths from unified diff."""
|
|
117
|
+
files = []
|
|
118
|
+
for line in diff_text.splitlines():
|
|
119
|
+
if line.startswith("diff --git"):
|
|
120
|
+
parts = line.split(" b/", 1)
|
|
121
|
+
if len(parts) > 1:
|
|
122
|
+
filepath = parts[1]
|
|
123
|
+
if filepath not in files:
|
|
124
|
+
files.append(filepath)
|
|
125
|
+
return files
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _git_show_head(repo_root: str, filepath: str) -> Optional[str]:
|
|
129
|
+
"""Get file content at HEAD."""
|
|
130
|
+
try:
|
|
131
|
+
result = subprocess.run(
|
|
132
|
+
["git", "show", f"HEAD:{filepath}"],
|
|
133
|
+
cwd=repo_root, capture_output=True, text=True, timeout=5,
|
|
134
|
+
)
|
|
135
|
+
if result.returncode == 0:
|
|
136
|
+
return result.stdout
|
|
137
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
138
|
+
pass
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _parse_source(source: str, filepath: str) -> Optional[FileAnalysis]:
|
|
143
|
+
"""Parse source code into FileAnalysis."""
|
|
144
|
+
try:
|
|
145
|
+
from .ast_analyzer import analyze_file
|
|
146
|
+
import tempfile
|
|
147
|
+
# Write to temp file for analyze_file (it reads from disk)
|
|
148
|
+
ext = os.path.splitext(filepath)[1]
|
|
149
|
+
lang = {".py": "python", ".js": "javascript", ".ts": "typescript", ".go": "go"}.get(ext)
|
|
150
|
+
if not lang:
|
|
151
|
+
return None
|
|
152
|
+
with tempfile.NamedTemporaryFile(mode="w", suffix=ext, delete=False, encoding="utf-8") as tf:
|
|
153
|
+
tf.write(source)
|
|
154
|
+
tf.flush()
|
|
155
|
+
try:
|
|
156
|
+
return analyze_file(tf.name, lang)
|
|
157
|
+
finally:
|
|
158
|
+
os.unlink(tf.name)
|
|
159
|
+
except Exception:
|
|
160
|
+
return None
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Architectural enforcement for dotscope."""
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Acknowledgment recording and confidence decay."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
from typing import Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
# Defaults (overridable via .dotscope/config.yaml)
|
|
9
|
+
DECAY_RATE = 0.1
|
|
10
|
+
DECAY_THRESHOLD = 3
|
|
11
|
+
DECAY_WINDOW_DAYS = 30
|
|
12
|
+
MIN_CONFIDENCE = 0.3
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def record_acknowledgment(
|
|
16
|
+
repo_root: str,
|
|
17
|
+
ack_id: str,
|
|
18
|
+
reason: str,
|
|
19
|
+
session_id: Optional[str] = None,
|
|
20
|
+
) -> dict:
|
|
21
|
+
"""Record an acknowledgment in .dotscope/acknowledgments.jsonl."""
|
|
22
|
+
dot_dir = os.path.join(repo_root, ".dotscope")
|
|
23
|
+
os.makedirs(dot_dir, exist_ok=True)
|
|
24
|
+
|
|
25
|
+
entry = {
|
|
26
|
+
"id": ack_id,
|
|
27
|
+
"reason": reason,
|
|
28
|
+
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
29
|
+
"session_id": session_id or "",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
path = os.path.join(dot_dir, "acknowledgments.jsonl")
|
|
33
|
+
with open(path, "a", encoding="utf-8") as f:
|
|
34
|
+
f.write(json.dumps(entry) + "\n")
|
|
35
|
+
|
|
36
|
+
return entry
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def load_acknowledgments(repo_root: str) -> List[dict]:
|
|
40
|
+
"""Load all acknowledgments."""
|
|
41
|
+
path = os.path.join(repo_root, ".dotscope", "acknowledgments.jsonl")
|
|
42
|
+
if not os.path.exists(path):
|
|
43
|
+
return []
|
|
44
|
+
|
|
45
|
+
entries = []
|
|
46
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
47
|
+
for line in f:
|
|
48
|
+
line = line.strip()
|
|
49
|
+
if line:
|
|
50
|
+
try:
|
|
51
|
+
entries.append(json.loads(line))
|
|
52
|
+
except json.JSONDecodeError:
|
|
53
|
+
continue
|
|
54
|
+
return entries
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def is_acknowledged(repo_root: str, ack_id: str) -> bool:
|
|
58
|
+
"""Check if a hold has been acknowledged.
|
|
59
|
+
|
|
60
|
+
Returns True if previously acknowledged AND confidence has not
|
|
61
|
+
decayed below the HOLD threshold (0.5). Constraints acknowledged
|
|
62
|
+
3+ times within 30 days lose confidence — below 0.5 they become
|
|
63
|
+
NOTEs instead of HOLDs, so this returns False (not "acknowledged"
|
|
64
|
+
as a HOLD pass-through).
|
|
65
|
+
"""
|
|
66
|
+
acks = load_acknowledgments(repo_root)
|
|
67
|
+
if not any(a["id"] == ack_id for a in acks):
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
# Apply decay: if confidence drops below 0.5, the constraint
|
|
71
|
+
# should remain active (as a NOTE), not be silently passed
|
|
72
|
+
confidence = compute_decayed_confidence(1.0, ack_id, acks)
|
|
73
|
+
return confidence >= 0.5
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def compute_decayed_confidence(
|
|
77
|
+
base_confidence: float,
|
|
78
|
+
ack_id: str,
|
|
79
|
+
acknowledgments: List[dict],
|
|
80
|
+
) -> float:
|
|
81
|
+
"""Apply confidence decay based on repeated acknowledgments.
|
|
82
|
+
|
|
83
|
+
After DECAY_THRESHOLD acknowledgments within DECAY_WINDOW_DAYS,
|
|
84
|
+
each additional acknowledgment drops confidence by DECAY_RATE.
|
|
85
|
+
Never drops below MIN_CONFIDENCE.
|
|
86
|
+
"""
|
|
87
|
+
now = time.time()
|
|
88
|
+
window_seconds = DECAY_WINDOW_DAYS * 86400
|
|
89
|
+
|
|
90
|
+
recent = [
|
|
91
|
+
a for a in acknowledgments
|
|
92
|
+
if a.get("id") == ack_id and _parse_timestamp(a.get("timestamp", "")) > now - window_seconds
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
count = len(recent)
|
|
96
|
+
if count <= DECAY_THRESHOLD:
|
|
97
|
+
return base_confidence
|
|
98
|
+
|
|
99
|
+
excess = count - DECAY_THRESHOLD
|
|
100
|
+
decayed = base_confidence - (excess * DECAY_RATE)
|
|
101
|
+
return max(decayed, MIN_CONFIDENCE)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
# Gap 3: NUDGE escalation — repeated nudges become guards
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
ESCALATION_THRESHOLD = 3 # nudge fires this many times → escalate to GUARD
|
|
109
|
+
ESCALATION_WINDOW_DAYS = 30
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def record_nudge_occurrence(repo_root: str, check_id: str) -> None:
|
|
113
|
+
"""Track that a nudge fired. Used for escalation tracking."""
|
|
114
|
+
if not check_id:
|
|
115
|
+
return
|
|
116
|
+
dot_dir = os.path.join(repo_root, ".dotscope")
|
|
117
|
+
os.makedirs(dot_dir, exist_ok=True)
|
|
118
|
+
|
|
119
|
+
entry = {
|
|
120
|
+
"id": check_id,
|
|
121
|
+
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
path = os.path.join(dot_dir, "nudge_occurrences.jsonl")
|
|
125
|
+
with open(path, "a", encoding="utf-8") as f:
|
|
126
|
+
f.write(json.dumps(entry) + "\n")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def record_nudge_resolution(repo_root: str, check_id: str) -> None:
|
|
130
|
+
"""Record that a nudge's underlying issue was fixed.
|
|
131
|
+
|
|
132
|
+
Resets the escalation counter so the nudge doesn't instantly
|
|
133
|
+
re-escalate if the issue recurs later.
|
|
134
|
+
"""
|
|
135
|
+
if not check_id:
|
|
136
|
+
return
|
|
137
|
+
dot_dir = os.path.join(repo_root, ".dotscope")
|
|
138
|
+
os.makedirs(dot_dir, exist_ok=True)
|
|
139
|
+
|
|
140
|
+
entry = {
|
|
141
|
+
"id": check_id,
|
|
142
|
+
"resolved": True,
|
|
143
|
+
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
path = os.path.join(dot_dir, "nudge_occurrences.jsonl")
|
|
147
|
+
with open(path, "a", encoding="utf-8") as f:
|
|
148
|
+
f.write(json.dumps(entry) + "\n")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def is_escalated(repo_root: str, check_id: str) -> bool:
|
|
152
|
+
"""Has this nudge been ignored enough times to escalate to GUARD?
|
|
153
|
+
|
|
154
|
+
3+ occurrences within 30 days *since the last resolution* means
|
|
155
|
+
the agent is ignoring the guidance. Escalate.
|
|
156
|
+
|
|
157
|
+
If the issue was resolved (nudge stopped firing) and then recurred,
|
|
158
|
+
the counter restarts from the resolution point.
|
|
159
|
+
"""
|
|
160
|
+
if not check_id:
|
|
161
|
+
return False
|
|
162
|
+
path = os.path.join(repo_root, ".dotscope", "nudge_occurrences.jsonl")
|
|
163
|
+
if not os.path.exists(path):
|
|
164
|
+
return False
|
|
165
|
+
|
|
166
|
+
now = time.time()
|
|
167
|
+
window = ESCALATION_WINDOW_DAYS * 86400
|
|
168
|
+
|
|
169
|
+
# Find the last resolution timestamp for this check
|
|
170
|
+
last_resolved = 0.0
|
|
171
|
+
count = 0
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
175
|
+
for line in f:
|
|
176
|
+
line = line.strip()
|
|
177
|
+
if not line:
|
|
178
|
+
continue
|
|
179
|
+
try:
|
|
180
|
+
entry = json.loads(line)
|
|
181
|
+
if entry.get("id") != check_id:
|
|
182
|
+
continue
|
|
183
|
+
ts = _parse_timestamp(entry.get("timestamp", ""))
|
|
184
|
+
if entry.get("resolved"):
|
|
185
|
+
last_resolved = max(last_resolved, ts)
|
|
186
|
+
except json.JSONDecodeError:
|
|
187
|
+
continue
|
|
188
|
+
except IOError:
|
|
189
|
+
return False
|
|
190
|
+
|
|
191
|
+
# Count occurrences after last resolution and within window
|
|
192
|
+
try:
|
|
193
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
194
|
+
for line in f:
|
|
195
|
+
line = line.strip()
|
|
196
|
+
if not line:
|
|
197
|
+
continue
|
|
198
|
+
try:
|
|
199
|
+
entry = json.loads(line)
|
|
200
|
+
if entry.get("id") != check_id:
|
|
201
|
+
continue
|
|
202
|
+
if entry.get("resolved"):
|
|
203
|
+
continue
|
|
204
|
+
ts = _parse_timestamp(entry.get("timestamp", ""))
|
|
205
|
+
if ts > last_resolved and ts > now - window:
|
|
206
|
+
count += 1
|
|
207
|
+
except json.JSONDecodeError:
|
|
208
|
+
continue
|
|
209
|
+
except IOError:
|
|
210
|
+
return False
|
|
211
|
+
|
|
212
|
+
return count >= ESCALATION_THRESHOLD
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _parse_timestamp(ts: str) -> float:
|
|
216
|
+
"""Parse ISO timestamp to epoch seconds."""
|
|
217
|
+
try:
|
|
218
|
+
from datetime import datetime
|
|
219
|
+
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
220
|
+
return dt.timestamp()
|
|
221
|
+
except (ValueError, AttributeError):
|
|
222
|
+
return 0.0
|