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
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Stop hook: auto-snapshot + auto-memory on session end.
|
|
2
|
+
|
|
3
|
+
Triggered by the Claude Code 'Stop' event. Fires after hook_session_stats.
|
|
4
|
+
Looks up the running C3 UI server via ~/.c3/registry.json and calls
|
|
5
|
+
POST /api/auto-snapshot to capture context before it is lost.
|
|
6
|
+
|
|
7
|
+
Falls back to a lightweight file-based snapshot if the server is unreachable.
|
|
8
|
+
"""
|
|
9
|
+
import json
|
|
10
|
+
import sys
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from urllib.error import URLError
|
|
14
|
+
from urllib.request import Request, urlopen
|
|
15
|
+
|
|
16
|
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
17
|
+
|
|
18
|
+
from cli._hook_utils import log_hook_error # noqa: E402
|
|
19
|
+
|
|
20
|
+
_REGISTRY_FILE = Path.home() / ".c3" / "registry.json"
|
|
21
|
+
_TIMEOUT_SECS = 4
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _find_server_port(project_path: str) -> int | None:
|
|
25
|
+
"""Look up the C3 UI server port for this project from the global registry."""
|
|
26
|
+
try:
|
|
27
|
+
if not _REGISTRY_FILE.exists():
|
|
28
|
+
return None
|
|
29
|
+
with open(_REGISTRY_FILE, encoding="utf-8") as f:
|
|
30
|
+
entries = json.load(f)
|
|
31
|
+
resolved = str(Path(project_path).resolve())
|
|
32
|
+
for entry in entries:
|
|
33
|
+
if str(Path(entry.get("project_path", "")).resolve()) == resolved:
|
|
34
|
+
return entry.get("port")
|
|
35
|
+
except Exception:
|
|
36
|
+
pass
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _call_server(port: int, stop_hook_data: dict) -> bool:
|
|
41
|
+
"""POST to the running C3 server's auto-snapshot endpoint."""
|
|
42
|
+
try:
|
|
43
|
+
url = f"http://127.0.0.1:{port}/api/auto-snapshot"
|
|
44
|
+
body = json.dumps({
|
|
45
|
+
"session_id": stop_hook_data.get("session_id", ""),
|
|
46
|
+
"stop_reason": stop_hook_data.get("stop_reason", ""),
|
|
47
|
+
}).encode("utf-8")
|
|
48
|
+
req = Request(url, data=body, headers={"Content-Type": "application/json"})
|
|
49
|
+
resp = urlopen(req, timeout=_TIMEOUT_SECS)
|
|
50
|
+
return resp.status == 200
|
|
51
|
+
except (URLError, OSError):
|
|
52
|
+
return False
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _fallback_snapshot(stop_hook_data: dict) -> None:
|
|
56
|
+
"""Lightweight file-based snapshot when the UI server is not running."""
|
|
57
|
+
c3_dir = Path(".c3")
|
|
58
|
+
if not c3_dir.exists():
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
snap_dir = c3_dir / "snapshots"
|
|
62
|
+
snap_dir.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
|
|
64
|
+
# Read the latest session file for context
|
|
65
|
+
sessions_dir = c3_dir / "sessions"
|
|
66
|
+
session_data = {}
|
|
67
|
+
if sessions_dir.exists():
|
|
68
|
+
session_files = sorted(sessions_dir.glob("session_*.json"), reverse=True)
|
|
69
|
+
if session_files:
|
|
70
|
+
try:
|
|
71
|
+
with open(session_files[0], encoding="utf-8") as f:
|
|
72
|
+
session_data = json.load(f)
|
|
73
|
+
except Exception:
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
# Read budget if available
|
|
77
|
+
budget = {}
|
|
78
|
+
budget_file = c3_dir / "context_budget.json"
|
|
79
|
+
if budget_file.exists():
|
|
80
|
+
try:
|
|
81
|
+
with open(budget_file, encoding="utf-8") as f:
|
|
82
|
+
budget = json.load(f)
|
|
83
|
+
except Exception:
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
snap_id = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
|
|
87
|
+
snapshot = {
|
|
88
|
+
"schema_version": 3,
|
|
89
|
+
"snapshot_id": snap_id,
|
|
90
|
+
"created": datetime.now(timezone.utc).isoformat(),
|
|
91
|
+
"session_id": stop_hook_data.get("session_id", session_data.get("id", "")),
|
|
92
|
+
"task_description": session_data.get("description", "auto-snapshot on stop"),
|
|
93
|
+
"trigger": "stop_hook",
|
|
94
|
+
"stop_reason": stop_hook_data.get("stop_reason", ""),
|
|
95
|
+
"working_files": [ft["file"] for ft in session_data.get("files_touched", [])[:8]
|
|
96
|
+
if isinstance(ft, dict) and ft.get("file")],
|
|
97
|
+
"decisions": session_data.get("decisions", []),
|
|
98
|
+
"files_touched": session_data.get("files_touched", []),
|
|
99
|
+
"context_notes": session_data.get("context_notes", []),
|
|
100
|
+
"context_budget": {
|
|
101
|
+
"response_tokens": budget.get("response_tokens", 0),
|
|
102
|
+
"call_count": budget.get("call_count", 0),
|
|
103
|
+
},
|
|
104
|
+
"state": {
|
|
105
|
+
"task_description": session_data.get("description", ""),
|
|
106
|
+
"working_files": [ft["file"] for ft in session_data.get("files_touched", [])[:8]
|
|
107
|
+
if isinstance(ft, dict) and ft.get("file")],
|
|
108
|
+
"decisions": session_data.get("decisions", []),
|
|
109
|
+
"files_touched": session_data.get("files_touched", []),
|
|
110
|
+
"context_notes": session_data.get("context_notes", []),
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
snap_path = snap_dir / f"snap_{snap_id}.json"
|
|
115
|
+
with open(snap_path, "w", encoding="utf-8") as f:
|
|
116
|
+
json.dump(snapshot, f, indent=2)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def main() -> None:
|
|
120
|
+
try:
|
|
121
|
+
data = json.load(sys.stdin)
|
|
122
|
+
except Exception as exc:
|
|
123
|
+
log_hook_error("hook_auto_snapshot", exc)
|
|
124
|
+
sys.exit(0)
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
project_path = str(Path.cwd())
|
|
128
|
+
port = _find_server_port(project_path)
|
|
129
|
+
|
|
130
|
+
if port and _call_server(port, data):
|
|
131
|
+
sys.exit(0)
|
|
132
|
+
|
|
133
|
+
# Server not running or unreachable — fallback
|
|
134
|
+
_fallback_snapshot(data)
|
|
135
|
+
except Exception as exc:
|
|
136
|
+
log_hook_error("hook_auto_snapshot", exc)
|
|
137
|
+
|
|
138
|
+
sys.exit(0)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
if __name__ == "__main__":
|
|
142
|
+
main()
|
cli/hook_c3_signal.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""PostToolUse hook: record c3_* tool call signal for enforcement.
|
|
2
|
+
|
|
3
|
+
Fires after: c3_search, c3_compress, c3_filter, c3_memory, c3_validate,
|
|
4
|
+
c3_session, c3_status, c3_impact, c3_agent, c3_shell.
|
|
5
|
+
|
|
6
|
+
Writes .c3/last_c3_call.json:
|
|
7
|
+
{
|
|
8
|
+
"timestamp": "...", ISO UTC timestamp
|
|
9
|
+
"tool": "c3_search", short tool name (without mcp__c3__ prefix)
|
|
10
|
+
"read_unlocked": true/false true for search/compress/filter
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
hook_pretool_enforce.py reads this file as the primary recency check.
|
|
14
|
+
It replaces the fragile LOOKBACK-3 activity-log scan in long sessions.
|
|
15
|
+
"""
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
22
|
+
|
|
23
|
+
from cli._hook_utils import log_hook_error # noqa: E402
|
|
24
|
+
|
|
25
|
+
# Tools that unlock generic read operations (Grep/Glob without a file path)
|
|
26
|
+
_READ_UNLOCK_TOOLS = {"c3_search", "c3_compress", "c3_filter", "c3_read", "c3_impact", "c3_validate"}
|
|
27
|
+
|
|
28
|
+
_SIGNAL_FILE = ".c3/last_c3_call.json"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def main() -> None:
|
|
32
|
+
try:
|
|
33
|
+
raw = sys.stdin.read()
|
|
34
|
+
if not raw.strip():
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
data = json.loads(raw)
|
|
38
|
+
raw_tool = data.get("tool_name", "")
|
|
39
|
+
|
|
40
|
+
# Strip mcp__c3__ prefix → short name (e.g. "c3_search")
|
|
41
|
+
short_name = raw_tool.replace("mcp__c3__", "") if "mcp__c3__" in raw_tool else raw_tool
|
|
42
|
+
|
|
43
|
+
if not short_name.startswith("c3_"):
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
signal = {
|
|
47
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
48
|
+
"tool": short_name,
|
|
49
|
+
"read_unlocked": short_name in _READ_UNLOCK_TOOLS,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
signal_path = Path.cwd() / _SIGNAL_FILE
|
|
53
|
+
signal_path.parent.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
signal_path.write_text(json.dumps(signal, indent=2), encoding="utf-8")
|
|
55
|
+
|
|
56
|
+
except Exception as exc:
|
|
57
|
+
log_hook_error("hook_c3_signal", exc)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
if __name__ == "__main__":
|
|
61
|
+
main()
|
cli/hook_c3read.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PostToolUse/AfterTool hook for mcp__c3__c3_read.
|
|
3
|
+
|
|
4
|
+
After c3_read completes on a code/config file, records a sticky unlock so
|
|
5
|
+
the enforcement hook allows future native Edit calls on those files.
|
|
6
|
+
|
|
7
|
+
Directs the model to use c3_edit (preferred) or native Edit (unlocked).
|
|
8
|
+
|
|
9
|
+
Supports both Claude Code (PostToolUse) and Gemini CLI (AfterTool).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
_JSON_UNLOCK_FILE = ".c3/unlocked_files.json"
|
|
17
|
+
|
|
18
|
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
19
|
+
|
|
20
|
+
from cli._hook_utils import emit_additional_context, log_hook_error # noqa: E402
|
|
21
|
+
|
|
22
|
+
EDITABLE_EXTS = {
|
|
23
|
+
".py", ".js", ".ts", ".tsx", ".jsx", ".go", ".rs", ".java",
|
|
24
|
+
".rb", ".c", ".cpp", ".h", ".cs", ".html", ".css",
|
|
25
|
+
".json", ".yaml", ".yml", ".toml", ".sql", ".md", ".txt",
|
|
26
|
+
".sh", ".bat", ".ps1",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
UNLOCK_FILE = ".c3/unlocked_files.txt"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _record_unlocks(editable: list[str]):
|
|
33
|
+
"""Record file paths as unlocked for the enforcement hook."""
|
|
34
|
+
unlock_path = Path.cwd() / UNLOCK_FILE
|
|
35
|
+
try:
|
|
36
|
+
existing = set(
|
|
37
|
+
line.strip() for line in
|
|
38
|
+
unlock_path.read_text(encoding="utf-8").splitlines()
|
|
39
|
+
if line.strip()
|
|
40
|
+
) if unlock_path.exists() else set()
|
|
41
|
+
for fp in editable:
|
|
42
|
+
existing.add(str(Path(fp).resolve()))
|
|
43
|
+
unlock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
unlock_path.write_text(
|
|
45
|
+
"\n".join(sorted(existing)) + "\n",
|
|
46
|
+
encoding="utf-8",
|
|
47
|
+
)
|
|
48
|
+
except Exception:
|
|
49
|
+
pass
|
|
50
|
+
# Fix 2: also write to unlocked_files.json (read by hook_pretool_enforce.py)
|
|
51
|
+
_record_json_unlocks(editable)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _record_json_unlocks(editable: list[str]):
|
|
55
|
+
"""Sync c3_read unlocks into unlocked_files.json for hook_pretool_enforce.py."""
|
|
56
|
+
json_path = Path.cwd() / _JSON_UNLOCK_FILE
|
|
57
|
+
try:
|
|
58
|
+
existing: dict = {}
|
|
59
|
+
if json_path.exists():
|
|
60
|
+
try:
|
|
61
|
+
existing = json.loads(json_path.read_text(encoding="utf-8"))
|
|
62
|
+
if not isinstance(existing, dict):
|
|
63
|
+
existing = {}
|
|
64
|
+
except Exception:
|
|
65
|
+
existing = {}
|
|
66
|
+
for fp in editable:
|
|
67
|
+
normalized = str(Path(fp).resolve())
|
|
68
|
+
cats = set(existing.get(normalized, []))
|
|
69
|
+
cats.update({"read", "edit"})
|
|
70
|
+
existing[normalized] = sorted(cats)
|
|
71
|
+
json_path.parent.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
json_path.write_text(json.dumps(existing), encoding="utf-8")
|
|
73
|
+
except Exception:
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def main():
|
|
78
|
+
try:
|
|
79
|
+
raw = sys.stdin.read()
|
|
80
|
+
if not raw.strip():
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
data = json.loads(raw)
|
|
84
|
+
if data.get("tool_name") != "mcp__c3__c3_read":
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
# Detect IDE format: Gemini wraps tool_response in a dict
|
|
88
|
+
is_gemini = isinstance(data.get("tool_response", ""), dict)
|
|
89
|
+
|
|
90
|
+
tool_input = data.get("tool_input", {})
|
|
91
|
+
file_path = (tool_input.get("file_path") or "").strip()
|
|
92
|
+
if not file_path:
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
# Support comma-separated multi-file reads
|
|
96
|
+
paths = [p.strip() for p in file_path.split(",") if p.strip()]
|
|
97
|
+
editable = [p for p in paths if Path(p).suffix.lower() in EDITABLE_EXTS]
|
|
98
|
+
if not editable:
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
# Record sticky unlocks so Edit is allowed without Read(limit=1)
|
|
102
|
+
_record_unlocks(editable)
|
|
103
|
+
|
|
104
|
+
files_str = ", ".join(f'"{p}"' for p in editable)
|
|
105
|
+
emit_additional_context(
|
|
106
|
+
f"[c3:edit-ready] {len(editable)} file(s) unlocked for editing: {files_str}. "
|
|
107
|
+
f"Use c3_edit(file_path=..., old_string=..., new_string=..., summary=...) — preferred. "
|
|
108
|
+
f"Native Edit is also unlocked for these files.",
|
|
109
|
+
is_gemini,
|
|
110
|
+
)
|
|
111
|
+
except Exception as _e:
|
|
112
|
+
log_hook_error("hook_c3read", _e)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
if __name__ == "__main__":
|
|
116
|
+
main()
|
cli/hook_edit_ledger.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""PostToolUse hook: auto-log Edit/Write tool calls to the Edit Ledger.
|
|
2
|
+
|
|
3
|
+
Intercepts Edit and Write tool calls, extracts the file path, and appends
|
|
4
|
+
an entry to .c3/edit_ledger.jsonl — same format as EditLedger.log_edit().
|
|
5
|
+
|
|
6
|
+
Performance: logs immediately with git_pending=True (no subprocess).
|
|
7
|
+
Git info and syntax validation are enriched asynchronously by
|
|
8
|
+
EditLedgerEnricherAgent running in the MCP server background.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
# Add project root to path for imports
|
|
17
|
+
_project_root = str(Path(__file__).resolve().parent.parent)
|
|
18
|
+
if _project_root not in sys.path:
|
|
19
|
+
sys.path.insert(0, _project_root)
|
|
20
|
+
|
|
21
|
+
from cli._hook_utils import get_tool_input_path, log_hook_error, normalize_tool_name # noqa: E402
|
|
22
|
+
from core.config import load_hybrid_config # noqa: E402
|
|
23
|
+
|
|
24
|
+
EDITABLE_EXTS = {
|
|
25
|
+
".py", ".js", ".ts", ".tsx", ".jsx", ".go", ".rs", ".java",
|
|
26
|
+
".rb", ".c", ".cpp", ".h", ".cs", ".html", ".css",
|
|
27
|
+
".json", ".yaml", ".yml", ".toml", ".sql", ".md", ".txt",
|
|
28
|
+
".sh", ".bat", ".ps1", ".r",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
# How many tail lines to scan for version/seq (avoids full-file parse)
|
|
32
|
+
_TAIL_SCAN = 50
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _tail_lines(ledger_file: Path, n: int = _TAIL_SCAN) -> list[str]:
|
|
36
|
+
"""Read the last n non-empty lines from the ledger file efficiently."""
|
|
37
|
+
try:
|
|
38
|
+
raw = ledger_file.read_bytes()
|
|
39
|
+
except Exception:
|
|
40
|
+
return []
|
|
41
|
+
lines = []
|
|
42
|
+
pos = len(raw)
|
|
43
|
+
while pos > 0 and len(lines) < n:
|
|
44
|
+
nl = raw.rfind(b"\n", 0, pos - 1)
|
|
45
|
+
chunk = raw[nl + 1:pos]
|
|
46
|
+
if chunk.strip():
|
|
47
|
+
lines.append(chunk.decode("utf-8", errors="replace"))
|
|
48
|
+
pos = nl if nl >= 0 else 0
|
|
49
|
+
return list(reversed(lines))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _next_version(ledger_file: Path, rel_path: str) -> str:
|
|
53
|
+
"""Get next version for a file by scanning only the tail of the ledger."""
|
|
54
|
+
max_v = 0
|
|
55
|
+
for line in _tail_lines(ledger_file, _TAIL_SCAN):
|
|
56
|
+
try:
|
|
57
|
+
entry = json.loads(line)
|
|
58
|
+
except (json.JSONDecodeError, ValueError):
|
|
59
|
+
continue
|
|
60
|
+
if entry.get("file") == rel_path:
|
|
61
|
+
v_str = entry.get("version", "v0")
|
|
62
|
+
try:
|
|
63
|
+
max_v = max(max_v, int(v_str.lstrip("v")))
|
|
64
|
+
except (ValueError, AttributeError):
|
|
65
|
+
pass
|
|
66
|
+
# If nothing found in tail, file might have older entries — do a quick check
|
|
67
|
+
if max_v == 0 and ledger_file.exists():
|
|
68
|
+
try:
|
|
69
|
+
for line in ledger_file.read_text(encoding="utf-8").splitlines():
|
|
70
|
+
if not line.strip():
|
|
71
|
+
continue
|
|
72
|
+
try:
|
|
73
|
+
entry = json.loads(line)
|
|
74
|
+
except (json.JSONDecodeError, ValueError):
|
|
75
|
+
continue
|
|
76
|
+
if entry.get("file") == rel_path:
|
|
77
|
+
v_str = entry.get("version", "v0")
|
|
78
|
+
try:
|
|
79
|
+
max_v = max(max_v, int(v_str.lstrip("v")))
|
|
80
|
+
except (ValueError, AttributeError):
|
|
81
|
+
pass
|
|
82
|
+
except Exception:
|
|
83
|
+
pass
|
|
84
|
+
return f"v{max_v + 1}"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _next_seq(ledger_file: Path, now: datetime) -> int:
|
|
88
|
+
"""Sequence number — scan only tail lines for same-second collisions."""
|
|
89
|
+
prefix = f"edit_{now.strftime('%Y%m%d_%H%M%S')}_"
|
|
90
|
+
max_seq = 0
|
|
91
|
+
for line in _tail_lines(ledger_file, 10): # Same-second entries are always recent
|
|
92
|
+
try:
|
|
93
|
+
entry = json.loads(line)
|
|
94
|
+
except (json.JSONDecodeError, ValueError):
|
|
95
|
+
continue
|
|
96
|
+
eid = entry.get("id", "")
|
|
97
|
+
if eid.startswith(prefix):
|
|
98
|
+
try:
|
|
99
|
+
max_seq = max(max_seq, int(eid[len(prefix):]))
|
|
100
|
+
except ValueError:
|
|
101
|
+
pass
|
|
102
|
+
return max_seq + 1
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _extract_summary(tool_name: str, tool_input: dict) -> str:
|
|
107
|
+
"""Build a short summary from tool input."""
|
|
108
|
+
if tool_name == "Edit":
|
|
109
|
+
old = (tool_input.get("old_string") or "")[:60]
|
|
110
|
+
new = (tool_input.get("new_string") or "")[:60]
|
|
111
|
+
if old and new:
|
|
112
|
+
return f"Replaced: {old!r} → {new!r}"
|
|
113
|
+
return "Edit"
|
|
114
|
+
elif tool_name == "Write":
|
|
115
|
+
return "File written"
|
|
116
|
+
return "Modified"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _detect_change_type(tool_name: str, tool_input: dict) -> str:
|
|
120
|
+
if tool_name == "Write":
|
|
121
|
+
return "created" if tool_input.get("_is_new") else "modified"
|
|
122
|
+
return "modified"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def main():
|
|
126
|
+
try:
|
|
127
|
+
raw = sys.stdin.read()
|
|
128
|
+
if not raw.strip():
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
data = json.loads(raw)
|
|
132
|
+
tool_name = normalize_tool_name(data.get("tool_name", ""))
|
|
133
|
+
|
|
134
|
+
if tool_name not in ("Edit", "Write", "NotebookEdit"):
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
file_path = get_tool_input_path(data)
|
|
138
|
+
if not file_path:
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
# Filter to editable extensions
|
|
142
|
+
if Path(file_path).suffix.lower() not in EDITABLE_EXTS:
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
project_path = Path.cwd()
|
|
146
|
+
c3_dir = project_path / ".c3"
|
|
147
|
+
if not c3_dir.exists():
|
|
148
|
+
return # Not a C3 project
|
|
149
|
+
|
|
150
|
+
# Load config and check if edit ledger is enabled
|
|
151
|
+
config = load_hybrid_config(str(project_path))
|
|
152
|
+
ledger_cfg = config.get("edit_ledger", {})
|
|
153
|
+
if not ledger_cfg.get("enabled", True):
|
|
154
|
+
return
|
|
155
|
+
tracking_level = ledger_cfg.get("tracking_level", "standard")
|
|
156
|
+
|
|
157
|
+
ledger_file = c3_dir / "edit_ledger.jsonl"
|
|
158
|
+
|
|
159
|
+
# Make file path relative
|
|
160
|
+
try:
|
|
161
|
+
rel = str(Path(file_path).resolve().relative_to(project_path.resolve()))
|
|
162
|
+
except ValueError:
|
|
163
|
+
rel = file_path
|
|
164
|
+
rel = rel.replace("\\", "/")
|
|
165
|
+
|
|
166
|
+
now = datetime.now(timezone.utc)
|
|
167
|
+
tool_input = data.get("tool_input", {})
|
|
168
|
+
change_type = _detect_change_type(tool_name, tool_input)
|
|
169
|
+
|
|
170
|
+
# Git info is enriched asynchronously by EditLedgerEnricherAgent.
|
|
171
|
+
# Mark as pending so the enricher knows to process this entry.
|
|
172
|
+
git_pending = tracking_level != "minimal"
|
|
173
|
+
|
|
174
|
+
entry = {
|
|
175
|
+
"id": f"edit_{now.strftime('%Y%m%d_%H%M%S')}_{_next_seq(ledger_file, now):03d}",
|
|
176
|
+
"timestamp": now.isoformat(),
|
|
177
|
+
"session_id": "",
|
|
178
|
+
"file": rel,
|
|
179
|
+
"change_type": change_type,
|
|
180
|
+
"summary": change_type if tracking_level == "minimal" else _extract_summary(tool_name, tool_input),
|
|
181
|
+
"lines_changed": None,
|
|
182
|
+
"version": _next_version(ledger_file, rel),
|
|
183
|
+
"git": {},
|
|
184
|
+
"diff_summary": "",
|
|
185
|
+
"git_pending": git_pending,
|
|
186
|
+
"tags": ["auto"] if ledger_cfg.get("auto_tag", True) else [],
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
# Detailed mode: include code snippets for richer diffs
|
|
190
|
+
if tracking_level == "detailed":
|
|
191
|
+
detail = {}
|
|
192
|
+
old_str = tool_input.get("old_string")
|
|
193
|
+
new_str = tool_input.get("new_string")
|
|
194
|
+
if old_str is not None:
|
|
195
|
+
detail["old_string"] = old_str[:200]
|
|
196
|
+
if new_str is not None:
|
|
197
|
+
detail["new_string"] = new_str[:200]
|
|
198
|
+
if detail:
|
|
199
|
+
entry["detail"] = detail
|
|
200
|
+
|
|
201
|
+
with open(ledger_file, "a", encoding="utf-8") as f:
|
|
202
|
+
f.write(json.dumps(entry) + "\n")
|
|
203
|
+
|
|
204
|
+
print(f"[c3:ledger] {rel} {entry['version']} auto-logged. "
|
|
205
|
+
f"Call c3_edits(action='log', file='{rel}', summary='...', tags='...') "
|
|
206
|
+
f"to add a semantic summary.")
|
|
207
|
+
|
|
208
|
+
except Exception as _e:
|
|
209
|
+
log_hook_error("hook_edit_ledger", _e)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
if __name__ == "__main__":
|
|
213
|
+
main()
|