code-context-control 2.28.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.
- cli/__init__.py +1 -0
- cli/_hook_utils.py +99 -0
- cli/c3.py +6152 -0
- cli/commands/__init__.py +1 -0
- cli/commands/common.py +312 -0
- cli/commands/parser.py +286 -0
- cli/docs.html +3178 -0
- cli/edits.html +878 -0
- cli/hook_auto_snapshot.py +142 -0
- cli/hook_c3_signal.py +61 -0
- cli/hook_c3read.py +116 -0
- cli/hook_edit_ledger.py +213 -0
- cli/hook_edit_unlock.py +170 -0
- cli/hook_filter.py +130 -0
- cli/hook_ghost_files.py +238 -0
- cli/hook_pretool_enforce.py +334 -0
- cli/hook_read.py +200 -0
- cli/hook_session_stats.py +62 -0
- cli/hook_terse_advisor.py +190 -0
- cli/hub.html +3764 -0
- cli/hub_server.py +1619 -0
- cli/mcp_proxy.py +428 -0
- cli/mcp_server.py +660 -0
- cli/server.py +2985 -0
- cli/tools/__init__.py +4 -0
- cli/tools/_helpers.py +65 -0
- cli/tools/agent.py +1165 -0
- cli/tools/compress.py +215 -0
- cli/tools/delegate.py +1184 -0
- cli/tools/edit.py +313 -0
- cli/tools/edits.py +118 -0
- cli/tools/filter.py +285 -0
- cli/tools/impact.py +163 -0
- cli/tools/memory.py +469 -0
- cli/tools/read.py +224 -0
- cli/tools/search.py +337 -0
- cli/tools/session.py +95 -0
- cli/tools/shell.py +193 -0
- cli/tools/status.py +306 -0
- cli/tools/validate.py +310 -0
- cli/ui/api.js +36 -0
- cli/ui/app.js +207 -0
- cli/ui/components/chat.js +758 -0
- cli/ui/components/dashboard.js +689 -0
- cli/ui/components/edits.js +220 -0
- cli/ui/components/instructions.js +481 -0
- cli/ui/components/memory.js +626 -0
- cli/ui/components/sessions.js +606 -0
- cli/ui/components/settings.js +1404 -0
- cli/ui/components/sidebar.js +156 -0
- cli/ui/icons.js +51 -0
- cli/ui/shared.js +119 -0
- cli/ui/theme.js +22 -0
- cli/ui.html +168 -0
- cli/ui_legacy.html +6797 -0
- cli/ui_nano.html +503 -0
- code_context_control-2.28.0.dist-info/METADATA +248 -0
- code_context_control-2.28.0.dist-info/RECORD +150 -0
- code_context_control-2.28.0.dist-info/WHEEL +5 -0
- code_context_control-2.28.0.dist-info/entry_points.txt +4 -0
- code_context_control-2.28.0.dist-info/licenses/LICENSE +201 -0
- code_context_control-2.28.0.dist-info/top_level.txt +5 -0
- core/__init__.py +75 -0
- core/config.py +269 -0
- core/ide.py +188 -0
- oracle/__init__.py +1 -0
- oracle/config.py +75 -0
- oracle/oracle.html +3900 -0
- oracle/oracle_server.py +663 -0
- oracle/services/__init__.py +1 -0
- oracle/services/c3_bridge.py +210 -0
- oracle/services/chat_engine.py +1103 -0
- oracle/services/chat_store.py +155 -0
- oracle/services/cross_memory.py +154 -0
- oracle/services/federated_graph.py +463 -0
- oracle/services/health_checker.py +117 -0
- oracle/services/insight_engine.py +307 -0
- oracle/services/memory_reader.py +106 -0
- oracle/services/memory_writer.py +182 -0
- oracle/services/ollama_bridge.py +332 -0
- oracle/services/project_scanner.py +87 -0
- oracle/services/review_agent.py +206 -0
- services/__init__.py +1 -0
- services/activity_log.py +93 -0
- services/agent_base.py +124 -0
- services/agents.py +1529 -0
- services/auto_memory.py +407 -0
- services/bench/__init__.py +6 -0
- services/bench/external/__init__.py +29 -0
- services/bench/external/aider_polyglot.py +405 -0
- services/bench/external/swe_bench.py +485 -0
- services/benchmark_dashboard.py +596 -0
- services/claude_md.py +785 -0
- services/compressor.py +592 -0
- services/context_snapshot.py +356 -0
- services/conversation_store.py +870 -0
- services/doc_index.py +537 -0
- services/e2e_benchmark.py +2884 -0
- services/e2e_evaluator.py +396 -0
- services/e2e_tasks.py +743 -0
- services/edit_ledger.py +459 -0
- services/embedding_index.py +341 -0
- services/error_reporting.py +123 -0
- services/file_memory.py +734 -0
- services/hub_service.py +585 -0
- services/indexer.py +712 -0
- services/memory.py +318 -0
- services/memory_consolidator.py +538 -0
- services/memory_graph.py +382 -0
- services/memory_grounder.py +304 -0
- services/memory_scorer.py +246 -0
- services/metrics.py +86 -0
- services/notifications.py +209 -0
- services/ollama_client.py +201 -0
- services/output_filter.py +488 -0
- services/parser.py +1238 -0
- services/project_manager.py +579 -0
- services/protocol.py +306 -0
- services/proxy_state.py +152 -0
- services/retrieval_broker.py +129 -0
- services/router.py +414 -0
- services/runtime.py +326 -0
- services/session_benchmark.py +1945 -0
- services/session_manager.py +1026 -0
- services/session_preloader.py +251 -0
- services/text_index.py +90 -0
- services/tool_classifier.py +176 -0
- services/transcript_index.py +340 -0
- services/validation_cache.py +155 -0
- services/vector_store.py +299 -0
- services/version_tracker.py +271 -0
- services/watcher.py +192 -0
- tui/__init__.py +0 -0
- tui/backend.py +59 -0
- tui/main.py +145 -0
- tui/screens/__init__.py +1 -0
- tui/screens/benchmark_view.py +109 -0
- tui/screens/claudemd_view.py +46 -0
- tui/screens/compress_view.py +52 -0
- tui/screens/index_view.py +74 -0
- tui/screens/init_view.py +82 -0
- tui/screens/mcp_view.py +73 -0
- tui/screens/optimize_view.py +41 -0
- tui/screens/pipe_view.py +46 -0
- tui/screens/projects_view.py +355 -0
- tui/screens/search_view.py +55 -0
- tui/screens/session_view.py +143 -0
- tui/screens/stats.py +158 -0
- tui/screens/ui_view.py +54 -0
- tui/theme.tcss +335 -0
services/edit_ledger.py
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
"""EditLedger — persistent, git-integrated audit trail of AI edits.
|
|
2
|
+
|
|
3
|
+
Storage: .c3/edit_ledger.jsonl (append-only)
|
|
4
|
+
|
|
5
|
+
Performance: in-memory caches for version map and seq counter avoid
|
|
6
|
+
repeated full-file scans. Git info is captured via a single combined
|
|
7
|
+
command instead of 3 separate subprocesses.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import threading
|
|
14
|
+
from collections import Counter
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class EditLedger:
|
|
20
|
+
"""Tracks every AI edit with version numbering and git context."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, project_path: str):
|
|
23
|
+
self.project_path = Path(project_path).resolve()
|
|
24
|
+
self.ledger_file = self.project_path / ".c3" / "edit_ledger.jsonl"
|
|
25
|
+
self.ledger_file.parent.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
self._git_root = self._detect_git_root()
|
|
27
|
+
# In-memory caches — loaded lazily on first use, updated on writes
|
|
28
|
+
self._version_cache: dict[str, int] | None = None # {file: max_version}
|
|
29
|
+
self._total_count: int | None = None
|
|
30
|
+
self._seq_counter: int = 0 # monotonic within process lifetime
|
|
31
|
+
self._write_lock = threading.Lock()
|
|
32
|
+
|
|
33
|
+
# ── Cache management ──────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
def _ensure_cache(self):
|
|
36
|
+
"""Load version cache from ledger file (once per process)."""
|
|
37
|
+
if self._version_cache is not None:
|
|
38
|
+
return
|
|
39
|
+
self._version_cache = {}
|
|
40
|
+
self._total_count = 0
|
|
41
|
+
if not self.ledger_file.exists():
|
|
42
|
+
return
|
|
43
|
+
for line in self.ledger_file.read_text(encoding="utf-8").splitlines():
|
|
44
|
+
if not line.strip():
|
|
45
|
+
continue
|
|
46
|
+
try:
|
|
47
|
+
entry = json.loads(line)
|
|
48
|
+
except (json.JSONDecodeError, ValueError):
|
|
49
|
+
continue
|
|
50
|
+
if "target_id" in entry: # skip patch entries
|
|
51
|
+
continue
|
|
52
|
+
self._total_count += 1
|
|
53
|
+
f = entry.get("file", "")
|
|
54
|
+
v_str = entry.get("version", "v0")
|
|
55
|
+
try:
|
|
56
|
+
v_num = int(v_str.lstrip("v"))
|
|
57
|
+
self._version_cache[f] = max(self._version_cache.get(f, 0), v_num)
|
|
58
|
+
except (ValueError, AttributeError):
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
# ── Public API ────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
def log_edit(self, file: str, change_type: str, summary: str,
|
|
64
|
+
lines_changed: list = None, tags: list = None,
|
|
65
|
+
session_id: str = None, include_git: bool = True,
|
|
66
|
+
detail: dict = None) -> dict:
|
|
67
|
+
"""Record an edit. Returns the entry dict.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
include_git: if False, skips git subprocess calls (faster).
|
|
71
|
+
"""
|
|
72
|
+
self._ensure_cache()
|
|
73
|
+
rel = file.replace("\\", "/")
|
|
74
|
+
now = datetime.now(timezone.utc)
|
|
75
|
+
self._seq_counter += 1
|
|
76
|
+
|
|
77
|
+
# Git info — single combined command when enabled
|
|
78
|
+
git_info = {"commit": "", "author": "", "subject": "", "dirty": False}
|
|
79
|
+
diff_summary = ""
|
|
80
|
+
if include_git and self._git_root:
|
|
81
|
+
git_info, diff_summary = self._git_combined(rel)
|
|
82
|
+
|
|
83
|
+
# Version from cache — O(1)
|
|
84
|
+
cur = self._version_cache.get(rel, 0)
|
|
85
|
+
new_v = cur + 1
|
|
86
|
+
self._version_cache[rel] = new_v
|
|
87
|
+
|
|
88
|
+
entry = {
|
|
89
|
+
"id": f"edit_{now.strftime('%Y%m%d_%H%M%S')}_{self._seq_counter:03d}",
|
|
90
|
+
"timestamp": now.isoformat(),
|
|
91
|
+
"session_id": session_id or "",
|
|
92
|
+
"file": rel,
|
|
93
|
+
"change_type": change_type,
|
|
94
|
+
"summary": summary,
|
|
95
|
+
"lines_changed": lines_changed,
|
|
96
|
+
"version": f"v{new_v}",
|
|
97
|
+
"git": git_info,
|
|
98
|
+
"diff_summary": diff_summary,
|
|
99
|
+
"tags": tags or [],
|
|
100
|
+
}
|
|
101
|
+
if detail:
|
|
102
|
+
entry["detail"] = detail
|
|
103
|
+
with open(self.ledger_file, "a", encoding="utf-8") as f:
|
|
104
|
+
f.write(json.dumps(entry) + "\n")
|
|
105
|
+
if self._total_count is not None:
|
|
106
|
+
self._total_count += 1
|
|
107
|
+
return entry
|
|
108
|
+
|
|
109
|
+
def get_history(self, file: str = None, limit: int = 50,
|
|
110
|
+
since: str = None) -> list:
|
|
111
|
+
"""Query edits, optionally filtered by file and/or time."""
|
|
112
|
+
results = self._load_merged(file_filter=file, since_filter=since)
|
|
113
|
+
return results[-limit:]
|
|
114
|
+
|
|
115
|
+
def get_file_versions(self, file: str) -> list:
|
|
116
|
+
"""All version entries for a specific file."""
|
|
117
|
+
return self.get_history(file=file, limit=10000)
|
|
118
|
+
|
|
119
|
+
def get_stats(self) -> dict:
|
|
120
|
+
"""Summary: total edits, files edited, by change_type."""
|
|
121
|
+
if not self.ledger_file.exists():
|
|
122
|
+
return {"total": 0, "by_type": {}, "files": 0, "most_edited": []}
|
|
123
|
+
type_counts = Counter()
|
|
124
|
+
file_counts = Counter()
|
|
125
|
+
total = 0
|
|
126
|
+
with open(self.ledger_file, encoding="utf-8") as f:
|
|
127
|
+
for line in f:
|
|
128
|
+
if not line.strip():
|
|
129
|
+
continue
|
|
130
|
+
try:
|
|
131
|
+
entry = json.loads(line)
|
|
132
|
+
except json.JSONDecodeError:
|
|
133
|
+
continue
|
|
134
|
+
if "target_id" in entry: # skip patch entries
|
|
135
|
+
continue
|
|
136
|
+
total += 1
|
|
137
|
+
type_counts[entry.get("change_type", "unknown")] += 1
|
|
138
|
+
file_counts[entry.get("file", "unknown")] += 1
|
|
139
|
+
return {
|
|
140
|
+
"total": total,
|
|
141
|
+
"by_type": dict(type_counts),
|
|
142
|
+
"files": len(file_counts),
|
|
143
|
+
"most_edited": [
|
|
144
|
+
{"file": f, "count": c}
|
|
145
|
+
for f, c in file_counts.most_common(10)
|
|
146
|
+
],
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
def get_version(self, file: str) -> str:
|
|
150
|
+
"""Current version string for a file (e.g. 'v3')."""
|
|
151
|
+
self._ensure_cache()
|
|
152
|
+
rel = file.replace("\\", "/")
|
|
153
|
+
v = self._version_cache.get(rel, 0)
|
|
154
|
+
return f"v{v}" if v > 0 else "v0"
|
|
155
|
+
|
|
156
|
+
def tag_edit(self, edit_id: str, tag: str) -> bool:
|
|
157
|
+
"""Add a tag to an existing edit. Rewrites the entry in-place."""
|
|
158
|
+
if not self.ledger_file.exists():
|
|
159
|
+
return False
|
|
160
|
+
lines = self.ledger_file.read_text(encoding="utf-8").splitlines()
|
|
161
|
+
found = False
|
|
162
|
+
new_lines = []
|
|
163
|
+
for line in lines:
|
|
164
|
+
if not line.strip():
|
|
165
|
+
new_lines.append(line)
|
|
166
|
+
continue
|
|
167
|
+
try:
|
|
168
|
+
entry = json.loads(line)
|
|
169
|
+
except json.JSONDecodeError:
|
|
170
|
+
new_lines.append(line)
|
|
171
|
+
continue
|
|
172
|
+
if entry.get("id") == edit_id:
|
|
173
|
+
tags = entry.get("tags", [])
|
|
174
|
+
if tag not in tags:
|
|
175
|
+
tags.append(tag)
|
|
176
|
+
entry["tags"] = tags
|
|
177
|
+
found = True
|
|
178
|
+
new_lines.append(json.dumps(entry))
|
|
179
|
+
else:
|
|
180
|
+
new_lines.append(line)
|
|
181
|
+
if found:
|
|
182
|
+
self.ledger_file.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
|
|
183
|
+
return found
|
|
184
|
+
|
|
185
|
+
# ── Private helpers ───────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
def _tail_entries(self, limit: int) -> list:
|
|
188
|
+
"""Read the last `limit` entries from the ledger efficiently."""
|
|
189
|
+
try:
|
|
190
|
+
raw = self.ledger_file.read_bytes()
|
|
191
|
+
except Exception:
|
|
192
|
+
return []
|
|
193
|
+
# Scan backwards for enough newlines
|
|
194
|
+
lines_found = []
|
|
195
|
+
pos = len(raw)
|
|
196
|
+
while pos > 0 and len(lines_found) < limit + 5: # extra buffer for blank lines
|
|
197
|
+
nl = raw.rfind(b"\n", 0, pos - 1)
|
|
198
|
+
chunk = raw[nl + 1:pos]
|
|
199
|
+
if chunk.strip():
|
|
200
|
+
lines_found.append(chunk)
|
|
201
|
+
pos = nl if nl >= 0 else 0
|
|
202
|
+
# Parse in forward order
|
|
203
|
+
results = []
|
|
204
|
+
for raw_line in reversed(lines_found):
|
|
205
|
+
try:
|
|
206
|
+
results.append(json.loads(raw_line))
|
|
207
|
+
except (json.JSONDecodeError, ValueError):
|
|
208
|
+
continue
|
|
209
|
+
return results[-limit:]
|
|
210
|
+
|
|
211
|
+
def _detect_git_root(self):
|
|
212
|
+
"""Find git root directory."""
|
|
213
|
+
try:
|
|
214
|
+
kwargs = {}
|
|
215
|
+
if sys.platform == "win32":
|
|
216
|
+
kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
|
|
217
|
+
result = subprocess.run(
|
|
218
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
219
|
+
cwd=self.project_path,
|
|
220
|
+
capture_output=True, text=True, timeout=3,
|
|
221
|
+
stdin=subprocess.DEVNULL,
|
|
222
|
+
**kwargs,
|
|
223
|
+
)
|
|
224
|
+
if result.returncode == 0:
|
|
225
|
+
return Path(result.stdout.strip()).resolve()
|
|
226
|
+
except Exception:
|
|
227
|
+
pass
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
def _git_combined(self, rel_path: str) -> tuple:
|
|
231
|
+
"""Capture git info + diff in a single subprocess call.
|
|
232
|
+
|
|
233
|
+
Returns (git_info_dict, diff_summary_str).
|
|
234
|
+
"""
|
|
235
|
+
info = {"commit": "", "author": "", "subject": "", "dirty": False}
|
|
236
|
+
diff_summary = ""
|
|
237
|
+
abs_path = (self.project_path / rel_path).resolve()
|
|
238
|
+
try:
|
|
239
|
+
git_rel = str(abs_path.relative_to(self._git_root))
|
|
240
|
+
except Exception:
|
|
241
|
+
return info, diff_summary
|
|
242
|
+
|
|
243
|
+
kwargs = {}
|
|
244
|
+
if sys.platform == "win32":
|
|
245
|
+
kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
|
|
246
|
+
|
|
247
|
+
# Single shell command: status + log + diff
|
|
248
|
+
# Using && chaining so we get all info in one subprocess
|
|
249
|
+
sep = "---C3SEP---"
|
|
250
|
+
cmd = (
|
|
251
|
+
f'git status --porcelain -- "{git_rel}" && '
|
|
252
|
+
f'echo {sep} && '
|
|
253
|
+
f'git log -1 --format="%H%x1f%an%x1f%s" -- "{git_rel}" && '
|
|
254
|
+
f'echo {sep} && '
|
|
255
|
+
f'git diff --numstat -- "{git_rel}"'
|
|
256
|
+
)
|
|
257
|
+
try:
|
|
258
|
+
proc = subprocess.Popen(
|
|
259
|
+
cmd, shell=True,
|
|
260
|
+
cwd=self._git_root,
|
|
261
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
262
|
+
stdin=subprocess.DEVNULL,
|
|
263
|
+
text=True, **kwargs,
|
|
264
|
+
)
|
|
265
|
+
try:
|
|
266
|
+
stdout, _ = proc.communicate(timeout=4)
|
|
267
|
+
except subprocess.TimeoutExpired:
|
|
268
|
+
if sys.platform == "win32":
|
|
269
|
+
subprocess.run(
|
|
270
|
+
["taskkill", "/F", "/T", "/PID", str(proc.pid)],
|
|
271
|
+
capture_output=True, **kwargs,
|
|
272
|
+
)
|
|
273
|
+
else:
|
|
274
|
+
proc.kill()
|
|
275
|
+
proc.communicate()
|
|
276
|
+
return info, diff_summary
|
|
277
|
+
parts = (stdout or "").split(sep)
|
|
278
|
+
# Part 0: status --porcelain output
|
|
279
|
+
if len(parts) > 0:
|
|
280
|
+
info["dirty"] = bool(parts[0].strip())
|
|
281
|
+
# Part 1: log output
|
|
282
|
+
if len(parts) > 1:
|
|
283
|
+
log_line = parts[1].strip()
|
|
284
|
+
fields = log_line.split("\x1f")
|
|
285
|
+
if len(fields) == 3:
|
|
286
|
+
info["commit"] = fields[0][:12]
|
|
287
|
+
info["author"] = fields[1]
|
|
288
|
+
info["subject"] = fields[2]
|
|
289
|
+
# Part 2: diff --numstat output
|
|
290
|
+
if len(parts) > 2:
|
|
291
|
+
diff_line = parts[2].strip()
|
|
292
|
+
if diff_line:
|
|
293
|
+
nums = diff_line.split("\t")
|
|
294
|
+
if len(nums) >= 2:
|
|
295
|
+
diff_summary = f"+{nums[0]} -{nums[1]}"
|
|
296
|
+
except Exception:
|
|
297
|
+
pass
|
|
298
|
+
|
|
299
|
+
return info, diff_summary
|
|
300
|
+
|
|
301
|
+
# ── Async enrichment ──────────────────────────────────────────────
|
|
302
|
+
|
|
303
|
+
def _load_merged(self, file_filter: str = None, since_filter: str = None) -> list:
|
|
304
|
+
"""Read all base entries with any appended patches merged in.
|
|
305
|
+
|
|
306
|
+
Patch entries are identified by having a 'target_id' field.
|
|
307
|
+
Returns entries sorted by timestamp ascending.
|
|
308
|
+
"""
|
|
309
|
+
if not self.ledger_file.exists():
|
|
310
|
+
return []
|
|
311
|
+
base: dict = {} # id → entry dict
|
|
312
|
+
patches: dict = {} # target_id → list of patch dicts
|
|
313
|
+
try:
|
|
314
|
+
for line in self.ledger_file.read_text(encoding="utf-8").splitlines():
|
|
315
|
+
if not line.strip():
|
|
316
|
+
continue
|
|
317
|
+
try:
|
|
318
|
+
entry = json.loads(line)
|
|
319
|
+
except (json.JSONDecodeError, ValueError):
|
|
320
|
+
continue
|
|
321
|
+
if "target_id" in entry:
|
|
322
|
+
patches.setdefault(entry["target_id"], []).append(entry)
|
|
323
|
+
else:
|
|
324
|
+
eid = entry.get("id")
|
|
325
|
+
if eid:
|
|
326
|
+
base[eid] = entry
|
|
327
|
+
except Exception:
|
|
328
|
+
return []
|
|
329
|
+
|
|
330
|
+
# Apply patches — each patch carries git enrichment and/or validation data
|
|
331
|
+
for target_id, patch_list in patches.items():
|
|
332
|
+
if target_id not in base:
|
|
333
|
+
continue
|
|
334
|
+
for patch in patch_list:
|
|
335
|
+
if "git" in patch:
|
|
336
|
+
base[target_id]["git"] = patch["git"]
|
|
337
|
+
base[target_id]["diff_summary"] = patch.get("diff_summary", "")
|
|
338
|
+
base[target_id].pop("git_pending", None)
|
|
339
|
+
if "valid" in patch:
|
|
340
|
+
base[target_id]["valid"] = patch["valid"]
|
|
341
|
+
if patch.get("errors"):
|
|
342
|
+
base[target_id]["lint_errors"] = patch["errors"]
|
|
343
|
+
|
|
344
|
+
norm_file = file_filter.replace("\\", "/") if file_filter else None
|
|
345
|
+
results = []
|
|
346
|
+
for entry in base.values():
|
|
347
|
+
if norm_file and entry.get("file") != norm_file:
|
|
348
|
+
continue
|
|
349
|
+
if since_filter and entry.get("timestamp", "") < since_filter:
|
|
350
|
+
continue
|
|
351
|
+
results.append(entry)
|
|
352
|
+
results.sort(key=lambda e: e.get("timestamp", ""))
|
|
353
|
+
return results
|
|
354
|
+
|
|
355
|
+
def enrich_pending(self, batch: int = 10) -> int:
|
|
356
|
+
"""Find hook-logged entries with git_pending=True and append git patches.
|
|
357
|
+
|
|
358
|
+
Called by EditLedgerEnricherAgent on a background timer.
|
|
359
|
+
Returns the number of entries enriched this cycle.
|
|
360
|
+
"""
|
|
361
|
+
if not self.ledger_file.exists() or not self._git_root:
|
|
362
|
+
return 0
|
|
363
|
+
pending = []
|
|
364
|
+
already_patched: set = set()
|
|
365
|
+
try:
|
|
366
|
+
for line in self.ledger_file.read_text(encoding="utf-8").splitlines():
|
|
367
|
+
if not line.strip():
|
|
368
|
+
continue
|
|
369
|
+
try:
|
|
370
|
+
entry = json.loads(line)
|
|
371
|
+
except (json.JSONDecodeError, ValueError):
|
|
372
|
+
continue
|
|
373
|
+
if "target_id" in entry and "git" in entry:
|
|
374
|
+
already_patched.add(entry["target_id"])
|
|
375
|
+
elif "target_id" not in entry and entry.get("git_pending") and entry.get("file"):
|
|
376
|
+
pending.append(entry)
|
|
377
|
+
except Exception:
|
|
378
|
+
return 0
|
|
379
|
+
|
|
380
|
+
to_enrich = [e for e in pending if e["id"] not in already_patched][:batch]
|
|
381
|
+
if not to_enrich:
|
|
382
|
+
return 0
|
|
383
|
+
|
|
384
|
+
patches = []
|
|
385
|
+
for entry in to_enrich:
|
|
386
|
+
git_info, diff_summary = self._git_combined(entry["file"])
|
|
387
|
+
patches.append({
|
|
388
|
+
"target_id": entry["id"],
|
|
389
|
+
"git": git_info,
|
|
390
|
+
"diff_summary": diff_summary,
|
|
391
|
+
"enriched_at": datetime.now(timezone.utc).isoformat(),
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
with self._write_lock:
|
|
395
|
+
try:
|
|
396
|
+
with open(self.ledger_file, "a", encoding="utf-8") as f:
|
|
397
|
+
for patch in patches:
|
|
398
|
+
f.write(json.dumps(patch) + "\n")
|
|
399
|
+
except Exception:
|
|
400
|
+
return 0
|
|
401
|
+
return len(patches)
|
|
402
|
+
|
|
403
|
+
def validate_pending(self, batch: int = 5, validation_cache=None) -> list:
|
|
404
|
+
"""Find recently-edited files without validation results and validate them.
|
|
405
|
+
|
|
406
|
+
Appends validate patches to the ledger and returns result dicts.
|
|
407
|
+
Called by EditLedgerEnricherAgent on a background timer.
|
|
408
|
+
"""
|
|
409
|
+
if not self.ledger_file.exists() or not validation_cache:
|
|
410
|
+
return []
|
|
411
|
+
pending = []
|
|
412
|
+
already_validated: set = set()
|
|
413
|
+
try:
|
|
414
|
+
for line in self.ledger_file.read_text(encoding="utf-8").splitlines():
|
|
415
|
+
if not line.strip():
|
|
416
|
+
continue
|
|
417
|
+
try:
|
|
418
|
+
entry = json.loads(line)
|
|
419
|
+
except (json.JSONDecodeError, ValueError):
|
|
420
|
+
continue
|
|
421
|
+
if "target_id" in entry and "valid" in entry:
|
|
422
|
+
already_validated.add(entry["target_id"])
|
|
423
|
+
elif "target_id" not in entry and entry.get("file"):
|
|
424
|
+
pending.append(entry)
|
|
425
|
+
except Exception:
|
|
426
|
+
return []
|
|
427
|
+
|
|
428
|
+
# Most recent first, skip already validated
|
|
429
|
+
to_validate = [e for e in reversed(pending) if e["id"] not in already_validated][:batch]
|
|
430
|
+
if not to_validate:
|
|
431
|
+
return []
|
|
432
|
+
|
|
433
|
+
results = []
|
|
434
|
+
patches = []
|
|
435
|
+
for entry in to_validate:
|
|
436
|
+
try:
|
|
437
|
+
result = validation_cache.validate_file(entry["file"])
|
|
438
|
+
except Exception:
|
|
439
|
+
result = None
|
|
440
|
+
if result is None:
|
|
441
|
+
continue
|
|
442
|
+
patch = {
|
|
443
|
+
"target_id": entry["id"],
|
|
444
|
+
"valid": result.get("valid", True),
|
|
445
|
+
"errors": result.get("errors", []),
|
|
446
|
+
"validated_at": datetime.now(timezone.utc).isoformat(),
|
|
447
|
+
}
|
|
448
|
+
patches.append(patch)
|
|
449
|
+
results.append({"id": entry["id"], "file": entry["file"], **patch})
|
|
450
|
+
|
|
451
|
+
if patches:
|
|
452
|
+
with self._write_lock:
|
|
453
|
+
try:
|
|
454
|
+
with open(self.ledger_file, "a", encoding="utf-8") as f:
|
|
455
|
+
for patch in patches:
|
|
456
|
+
f.write(json.dumps(patch) + "\n")
|
|
457
|
+
except Exception:
|
|
458
|
+
pass
|
|
459
|
+
return results
|