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.
Files changed (150) hide show
  1. cli/__init__.py +1 -0
  2. cli/_hook_utils.py +99 -0
  3. cli/c3.py +6152 -0
  4. cli/commands/__init__.py +1 -0
  5. cli/commands/common.py +312 -0
  6. cli/commands/parser.py +286 -0
  7. cli/docs.html +3178 -0
  8. cli/edits.html +878 -0
  9. cli/hook_auto_snapshot.py +142 -0
  10. cli/hook_c3_signal.py +61 -0
  11. cli/hook_c3read.py +116 -0
  12. cli/hook_edit_ledger.py +213 -0
  13. cli/hook_edit_unlock.py +170 -0
  14. cli/hook_filter.py +130 -0
  15. cli/hook_ghost_files.py +238 -0
  16. cli/hook_pretool_enforce.py +334 -0
  17. cli/hook_read.py +200 -0
  18. cli/hook_session_stats.py +62 -0
  19. cli/hook_terse_advisor.py +190 -0
  20. cli/hub.html +3764 -0
  21. cli/hub_server.py +1619 -0
  22. cli/mcp_proxy.py +428 -0
  23. cli/mcp_server.py +660 -0
  24. cli/server.py +2985 -0
  25. cli/tools/__init__.py +4 -0
  26. cli/tools/_helpers.py +65 -0
  27. cli/tools/agent.py +1165 -0
  28. cli/tools/compress.py +215 -0
  29. cli/tools/delegate.py +1184 -0
  30. cli/tools/edit.py +313 -0
  31. cli/tools/edits.py +118 -0
  32. cli/tools/filter.py +285 -0
  33. cli/tools/impact.py +163 -0
  34. cli/tools/memory.py +469 -0
  35. cli/tools/read.py +224 -0
  36. cli/tools/search.py +337 -0
  37. cli/tools/session.py +95 -0
  38. cli/tools/shell.py +193 -0
  39. cli/tools/status.py +306 -0
  40. cli/tools/validate.py +310 -0
  41. cli/ui/api.js +36 -0
  42. cli/ui/app.js +207 -0
  43. cli/ui/components/chat.js +758 -0
  44. cli/ui/components/dashboard.js +689 -0
  45. cli/ui/components/edits.js +220 -0
  46. cli/ui/components/instructions.js +481 -0
  47. cli/ui/components/memory.js +626 -0
  48. cli/ui/components/sessions.js +606 -0
  49. cli/ui/components/settings.js +1404 -0
  50. cli/ui/components/sidebar.js +156 -0
  51. cli/ui/icons.js +51 -0
  52. cli/ui/shared.js +119 -0
  53. cli/ui/theme.js +22 -0
  54. cli/ui.html +168 -0
  55. cli/ui_legacy.html +6797 -0
  56. cli/ui_nano.html +503 -0
  57. code_context_control-2.28.0.dist-info/METADATA +248 -0
  58. code_context_control-2.28.0.dist-info/RECORD +150 -0
  59. code_context_control-2.28.0.dist-info/WHEEL +5 -0
  60. code_context_control-2.28.0.dist-info/entry_points.txt +4 -0
  61. code_context_control-2.28.0.dist-info/licenses/LICENSE +201 -0
  62. code_context_control-2.28.0.dist-info/top_level.txt +5 -0
  63. core/__init__.py +75 -0
  64. core/config.py +269 -0
  65. core/ide.py +188 -0
  66. oracle/__init__.py +1 -0
  67. oracle/config.py +75 -0
  68. oracle/oracle.html +3900 -0
  69. oracle/oracle_server.py +663 -0
  70. oracle/services/__init__.py +1 -0
  71. oracle/services/c3_bridge.py +210 -0
  72. oracle/services/chat_engine.py +1103 -0
  73. oracle/services/chat_store.py +155 -0
  74. oracle/services/cross_memory.py +154 -0
  75. oracle/services/federated_graph.py +463 -0
  76. oracle/services/health_checker.py +117 -0
  77. oracle/services/insight_engine.py +307 -0
  78. oracle/services/memory_reader.py +106 -0
  79. oracle/services/memory_writer.py +182 -0
  80. oracle/services/ollama_bridge.py +332 -0
  81. oracle/services/project_scanner.py +87 -0
  82. oracle/services/review_agent.py +206 -0
  83. services/__init__.py +1 -0
  84. services/activity_log.py +93 -0
  85. services/agent_base.py +124 -0
  86. services/agents.py +1529 -0
  87. services/auto_memory.py +407 -0
  88. services/bench/__init__.py +6 -0
  89. services/bench/external/__init__.py +29 -0
  90. services/bench/external/aider_polyglot.py +405 -0
  91. services/bench/external/swe_bench.py +485 -0
  92. services/benchmark_dashboard.py +596 -0
  93. services/claude_md.py +785 -0
  94. services/compressor.py +592 -0
  95. services/context_snapshot.py +356 -0
  96. services/conversation_store.py +870 -0
  97. services/doc_index.py +537 -0
  98. services/e2e_benchmark.py +2884 -0
  99. services/e2e_evaluator.py +396 -0
  100. services/e2e_tasks.py +743 -0
  101. services/edit_ledger.py +459 -0
  102. services/embedding_index.py +341 -0
  103. services/error_reporting.py +123 -0
  104. services/file_memory.py +734 -0
  105. services/hub_service.py +585 -0
  106. services/indexer.py +712 -0
  107. services/memory.py +318 -0
  108. services/memory_consolidator.py +538 -0
  109. services/memory_graph.py +382 -0
  110. services/memory_grounder.py +304 -0
  111. services/memory_scorer.py +246 -0
  112. services/metrics.py +86 -0
  113. services/notifications.py +209 -0
  114. services/ollama_client.py +201 -0
  115. services/output_filter.py +488 -0
  116. services/parser.py +1238 -0
  117. services/project_manager.py +579 -0
  118. services/protocol.py +306 -0
  119. services/proxy_state.py +152 -0
  120. services/retrieval_broker.py +129 -0
  121. services/router.py +414 -0
  122. services/runtime.py +326 -0
  123. services/session_benchmark.py +1945 -0
  124. services/session_manager.py +1026 -0
  125. services/session_preloader.py +251 -0
  126. services/text_index.py +90 -0
  127. services/tool_classifier.py +176 -0
  128. services/transcript_index.py +340 -0
  129. services/validation_cache.py +155 -0
  130. services/vector_store.py +299 -0
  131. services/version_tracker.py +271 -0
  132. services/watcher.py +192 -0
  133. tui/__init__.py +0 -0
  134. tui/backend.py +59 -0
  135. tui/main.py +145 -0
  136. tui/screens/__init__.py +1 -0
  137. tui/screens/benchmark_view.py +109 -0
  138. tui/screens/claudemd_view.py +46 -0
  139. tui/screens/compress_view.py +52 -0
  140. tui/screens/index_view.py +74 -0
  141. tui/screens/init_view.py +82 -0
  142. tui/screens/mcp_view.py +73 -0
  143. tui/screens/optimize_view.py +41 -0
  144. tui/screens/pipe_view.py +46 -0
  145. tui/screens/projects_view.py +355 -0
  146. tui/screens/search_view.py +55 -0
  147. tui/screens/session_view.py +143 -0
  148. tui/screens/stats.py +158 -0
  149. tui/screens/ui_view.py +54 -0
  150. 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()
@@ -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()