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,334 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse hook: two-mode enforcement for native tools.
3
+
4
+ Read-class tools (Read/Grep/Glob/FindFiles/SearchText) are **ADVISORY** —
5
+ if no c3_* tool was used first, the call proceeds with a selection-time
6
+ hint injected via additionalContext. Drift is still cheap to recover from
7
+ for read-only operations.
8
+
9
+ Write-class tools (Edit/Write) are **BLOCKED** — file mutations must go
10
+ through c3_edit so the ledger captures every change. Hard-deny with
11
+ redirect message.
12
+
13
+ This replaces the previous all-blocking behavior. Rationale: blocking read
14
+ tools treats Claude adversarially and creates cliffs at every edge case
15
+ (new tool variants, Windows quirks). Advisory read + blocked write keeps
16
+ the ledger intact without strangling the model's own good judgment.
17
+
18
+ Supports both Claude Code and Gemini CLI via _hook_utils.
19
+ """
20
+ import json
21
+ import sys
22
+ from datetime import datetime, timezone
23
+ from pathlib import Path
24
+
25
+ from _hook_utils import log_hook_error, normalize_tool_name
26
+
27
+ # How many activity-log lines to scan backwards
28
+ LOOKBACK = 20 # Fix 1: increased from 3 — activity log only has c3_* entries
29
+
30
+ # Signal file written by hook_c3_signal.py after any c3_* tool completes
31
+ _SIGNAL_FILE = ".c3/last_c3_call.json"
32
+ _SIGNAL_MAX_AGE_SECS = 600 # 10 minutes
33
+
34
+ # Session-sticky unlock file: tracks files accessed via c3_* tools, per-category
35
+ _UNLOCK_FILE = ".c3/unlocked_files.json"
36
+
37
+ # Which unlock category each native tool requires
38
+ _TOOL_CATEGORY = {
39
+ "Read": "read", "Grep": "read", "Glob": "read",
40
+ "FindFiles": "read", "SearchText": "read", "Edit": "edit",
41
+ "Write": "edit",
42
+ }
43
+
44
+ # Which unlock category each c3 tool grants
45
+ _C3_GRANTS = {
46
+ "c3_search": "read", "c3_compress": "read", "c3_read": "read",
47
+ "c3_filter": "read", "c3_validate": "read", "c3_impact": "read",
48
+ "c3_edit": "edit", "c3_edits": "edit", "c3_agent": "both",
49
+ "c3_delegate": "read", "c3_session": "read", "c3_memory": "read",
50
+ "c3_status": "read",
51
+ }
52
+
53
+ # c3 tools that satisfy the "used c3 first" requirement per native tool
54
+ _PREREQS = {
55
+ "Read": {"c3_search", "c3_compress", "c3_read", "c3_filter",
56
+ "c3_validate", "c3_impact", "c3_edit", "c3_agent", "c3_delegate"},
57
+ "Grep": {"c3_search", "c3_compress", "c3_filter", "c3_validate",
58
+ "c3_impact", "c3_agent", "c3_delegate"},
59
+ "Glob": {"c3_search", "c3_filter", "c3_agent", "c3_delegate"},
60
+ "FindFiles": {"c3_search", "c3_filter", "c3_agent", "c3_delegate"},
61
+ "SearchText": {"c3_search", "c3_compress", "c3_read", "c3_filter",
62
+ "c3_impact", "c3_agent", "c3_delegate"},
63
+ "Edit": {"c3_edit", "c3_edits", "c3_agent"},
64
+ "Write": {"c3_edit", "c3_edits", "c3_agent"},
65
+ "MultiEdit": {"c3_edit", "c3_edits", "c3_agent"},
66
+ }
67
+
68
+ # Read-class tools: advisory (allow + nudge when no c3 used first).
69
+ # Write-class tools: blocked (ledger integrity).
70
+ _ADVISORY_TOOLS = {"Read", "Grep", "Glob", "FindFiles", "SearchText"}
71
+ _BLOCKED_TOOLS = {"Edit", "Write", "MultiEdit"}
72
+
73
+ # Redirect messages per native tool
74
+ _REDIRECTS = {
75
+ "Read": (
76
+ "Use c3_compress(file_path='...', mode='map') to map the file first, "
77
+ "then c3_read(file_path='...', symbols=['...']) for surgical extraction."
78
+ ),
79
+ "Grep": (
80
+ "Use c3_search(query='...', action='code') for pattern matching, "
81
+ "or c3_search(query='...', action='semantic') for concept search."
82
+ ),
83
+ "Glob": (
84
+ "Use c3_search(query='...', action='files') for file discovery."
85
+ ),
86
+ "FindFiles": (
87
+ "Use c3_search(query='...', action='files') for file discovery."
88
+ ),
89
+ "SearchText": (
90
+ "Use c3_search(query='...', action='code') for code search."
91
+ ),
92
+ "Edit": (
93
+ "Use c3_edit(file_path='...', old_string='...', new_string='...', summary='...') "
94
+ "for file edits — it reads, patches, writes, and logs in one step."
95
+ ),
96
+ "Write": (
97
+ "Use c3_edit(file_path='...', old_string='...', new_string='...', summary='...') "
98
+ "for file modifications. For new files, use native Write only after c3_search/c3_compress."
99
+ ),
100
+ }
101
+
102
+
103
+ def _tail_lines(path: Path, n: int) -> list[str]:
104
+ """Read last n lines of a file without loading the whole file.
105
+
106
+ Activity logs grow to megabytes over a session; the enforcer only inspects
107
+ the tail window, so reading the whole file on every native tool call was
108
+ pure overhead.
109
+ """
110
+ try:
111
+ with open(path, "rb") as f:
112
+ f.seek(0, 2)
113
+ size = f.tell()
114
+ if size == 0:
115
+ return []
116
+ block = 4096
117
+ chunks = []
118
+ seen_newlines = 0
119
+ pos = size
120
+ while pos > 0 and seen_newlines <= n:
121
+ read_size = min(block, pos)
122
+ pos -= read_size
123
+ f.seek(pos)
124
+ data = f.read(read_size)
125
+ seen_newlines += data.count(b"\n")
126
+ chunks.append(data)
127
+ blob = b"".join(reversed(chunks))
128
+ text = blob.decode("utf-8", errors="replace")
129
+ return text.splitlines()[-n:]
130
+ except Exception:
131
+ return []
132
+
133
+
134
+ def _load_unlocked(project_path: Path) -> dict:
135
+ """Load per-category unlock map: {normalized_path: ["read"], ...}."""
136
+ unlock_path = project_path / _UNLOCK_FILE
137
+ if not unlock_path.exists():
138
+ return {}
139
+ try:
140
+ data = json.loads(unlock_path.read_text(encoding="utf-8"))
141
+ return data if isinstance(data, dict) else {}
142
+ except Exception:
143
+ return {}
144
+
145
+
146
+ def _save_unlocked(project_path: Path, unlocked: dict):
147
+ """Persist the per-category unlock map."""
148
+ unlock_path = project_path / _UNLOCK_FILE
149
+ unlock_path.parent.mkdir(parents=True, exist_ok=True)
150
+ try:
151
+ unlock_path.write_text(json.dumps(unlocked), encoding="utf-8")
152
+ except Exception:
153
+ pass
154
+
155
+
156
+ def _record_unlock(project_path: Path, file_path: str, category: str):
157
+ """Add a file path to the sticky unlock map for the given category."""
158
+ if not file_path or not category:
159
+ return
160
+ unlocked = _load_unlocked(project_path)
161
+ normalized = str(Path(file_path).resolve()) if file_path else ""
162
+ if not normalized:
163
+ return
164
+ cats = set(unlocked.get(normalized, []))
165
+ if category == "both":
166
+ cats.update({"read", "edit"})
167
+ else:
168
+ cats.add(category)
169
+ unlocked[normalized] = sorted(cats)
170
+ _save_unlocked(project_path, unlocked)
171
+
172
+
173
+ def _is_file_unlocked(project_path: Path, file_path: str, category: str) -> bool:
174
+ """Check if a file is unlocked for the given operation category."""
175
+ if not file_path:
176
+ return False
177
+ unlocked = _load_unlocked(project_path)
178
+ normalized = str(Path(file_path).resolve()) if file_path else ""
179
+ cats = unlocked.get(normalized, [])
180
+ return category in cats or "both" in cats
181
+
182
+
183
+ def _check_signal_file(project_path: Path) -> tuple[bool, bool]:
184
+ """Read last_c3_call.json written by hook_c3_signal.py.
185
+
186
+ Returns (recent, read_unlocked):
187
+ recent: True if a c3_* tool completed within _SIGNAL_MAX_AGE_SECS
188
+ read_unlocked: True if that tool was c3_search/c3_compress/c3_filter
189
+ """
190
+ signal_path = project_path / _SIGNAL_FILE
191
+ if not signal_path.exists():
192
+ return False, False
193
+ try:
194
+ data = json.loads(signal_path.read_text(encoding="utf-8"))
195
+ ts = datetime.fromisoformat(data["timestamp"])
196
+ age = (datetime.now(timezone.utc) - ts).total_seconds()
197
+ recent = age <= _SIGNAL_MAX_AGE_SECS
198
+ return recent, bool(data.get("read_unlocked", False)) and recent
199
+ except Exception:
200
+ return False, False
201
+
202
+
203
+ def _check_c3_used(project_path: Path, tool_name: str, tool_input: dict) -> tuple[bool, str]:
204
+ """Check if a qualifying c3 tool was recently used.
205
+
206
+ Returns (allowed, via) where via is one of:
207
+ 'signal' -- fresh c3_* signal file (within 10 min) — no reminder
208
+ 'unlock' -- sticky file unlock only, no fresh signal — emit reminder
209
+ 'activity' -- activity log hit within last LOOKBACK entries — no reminder
210
+ '' -- not allowed
211
+ """
212
+ allowed = _PREREQS.get(tool_name, set())
213
+ if not allowed:
214
+ return True, "signal" # No prereqs defined → allow without reminder
215
+
216
+ native_target = (
217
+ tool_input.get("file_path", "")
218
+ or tool_input.get("path", "")
219
+ or tool_input.get("pattern", "")
220
+ or tool_input.get("query", "")
221
+ or ""
222
+ )
223
+ required_cat = _TOOL_CATEGORY.get(tool_name, "read")
224
+
225
+ # ── Fix 4: signal file — primary, fast, reliable ─────────────────────────
226
+ signal_recent, signal_read_unlocked = _check_signal_file(project_path)
227
+ if signal_recent:
228
+ # Fix 5: Grep/Glob without file path needs a read-unlocking tool
229
+ if not native_target and tool_name in ("Grep", "Glob", "FindFiles", "SearchText"):
230
+ if signal_read_unlocked:
231
+ return True, "signal"
232
+ # Signal exists but not read-unlocking (e.g. c3_memory) — fall through
233
+ else:
234
+ if native_target:
235
+ _record_unlock(project_path, native_target, required_cat)
236
+ return True, "signal"
237
+
238
+ # ── Sticky file unlock (per-file, persists across turns) ─────────────────
239
+ if native_target and _is_file_unlocked(project_path, native_target, required_cat):
240
+ return True, "unlock" # allowed but no fresh signal — emit reminder
241
+
242
+ # ── Fix 1: activity log scan (LOOKBACK increased to 20) ──────────────────
243
+ log_file = project_path / ".c3" / "activity_log.jsonl"
244
+ if not log_file.exists():
245
+ return False, ""
246
+
247
+ try:
248
+ lines = _tail_lines(log_file, LOOKBACK)
249
+ except Exception:
250
+ return False, ""
251
+
252
+ for line in reversed(lines):
253
+ try:
254
+ entry = json.loads(line)
255
+ except (json.JSONDecodeError, ValueError):
256
+ continue
257
+
258
+ if entry.get("type") != "tool_call":
259
+ continue
260
+
261
+ tool = entry.get("tool", "")
262
+ if tool not in allowed:
263
+ continue
264
+
265
+ if native_target:
266
+ grant = _C3_GRANTS.get(tool, required_cat)
267
+ _record_unlock(project_path, native_target, grant)
268
+ return True, "activity"
269
+
270
+ return False, ""
271
+
272
+
273
+ def main():
274
+ try:
275
+ raw = sys.stdin.read()
276
+ if not raw.strip():
277
+ return
278
+
279
+ data = json.loads(raw)
280
+ tool_name = normalize_tool_name(data.get("tool_name", ""))
281
+
282
+ if tool_name not in _PREREQS:
283
+ return # Not a tool we enforce — pass through
284
+
285
+ tool_input = data.get("tool_input", {})
286
+ project_path = Path.cwd()
287
+
288
+ allowed, via = _check_c3_used(project_path, tool_name, tool_input)
289
+
290
+ if allowed:
291
+ # Sticky-unlock only: gentle drift-guard nudge, still allow.
292
+ if via == "unlock":
293
+ print(json.dumps({
294
+ "additionalContext": (
295
+ f"[c3:drift-guard] {tool_name} allowed via sticky unlock "
296
+ f"— no recent c3_* call detected. "
297
+ f"Prefer c3_search/c3_compress to keep the ledger warm."
298
+ )
299
+ }))
300
+ return # satisfied prereq — allow
301
+
302
+ # No c3_* prereq met. Advisory vs blocked split.
303
+ redirect = _REDIRECTS.get(tool_name, "Prefer a c3_* tool.")
304
+
305
+ if tool_name in _ADVISORY_TOOLS:
306
+ # Read-class: allow, but inject a selection-time hint.
307
+ print(json.dumps({
308
+ "additionalContext": (
309
+ f"[c3:hint] Native `{tool_name}` is running without a prior c3_* call. "
310
+ f"For better index awareness next time: {redirect}"
311
+ )
312
+ }))
313
+ return
314
+
315
+ # Write-class: hard block. Ledger integrity matters more than flexibility.
316
+ reason = (
317
+ f"[c3:enforce] Native `{tool_name}` is blocked to preserve the edit ledger. "
318
+ f"{redirect}"
319
+ )
320
+ response = {
321
+ "hookSpecificOutput": {
322
+ "hookEventName": "PreToolUse",
323
+ "permissionDecision": "deny",
324
+ "permissionDecisionReason": reason,
325
+ }
326
+ }
327
+ print(json.dumps(response))
328
+
329
+ except Exception as _e:
330
+ log_hook_error("hook_pretool_enforce", _e)
331
+
332
+
333
+ if __name__ == "__main__":
334
+ main()
cli/hook_read.py ADDED
@@ -0,0 +1,200 @@
1
+ #!/usr/bin/env python3
2
+ """PostToolUse/AfterTool hook for Read/read_file/SearchText/FindFiles.
3
+
4
+ Checks if the model used required C3 tools before standard discovery or reads:
5
+ - Code/docs files: c3_search / c3_compress(mode='map') / c3_read
6
+ - Log/data files (.log/.txt/.jsonl): c3_filter(file_path='...')
7
+
8
+ If not, injects strong additionalContext guidance. Also queues the file
9
+ for async file memory indexing.
10
+
11
+ Supports both Claude Code (PostToolUse/Read) and Gemini CLI (AfterTool/read_file).
12
+ """
13
+
14
+ import json
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ sys.path.insert(0, str(Path(__file__).parent.parent))
19
+
20
+ from cli._hook_utils import emit_additional_context, get_tool_output, log_hook_error, normalize_tool_name # noqa: E402
21
+
22
+ CODE_PRE_READ_TOOLS = {"c3_search", "c3_compress", "c3_read"}
23
+ DATA_PRE_READ_TOOLS = {"c3_extract", "c3_filter"}
24
+ C3_TOOLS = CODE_PRE_READ_TOOLS | DATA_PRE_READ_TOOLS
25
+ LOOKBACK = 30
26
+
27
+
28
+ def _check_c3_used(project_path: Path, rel_path: str, allowed_tools=None) -> bool:
29
+ """Check activity log for recent C3 tool calls targeting this file."""
30
+ log_file = project_path / ".c3" / "activity_log.jsonl"
31
+ if not log_file.exists():
32
+ return False
33
+ allowed = set(allowed_tools or C3_TOOLS)
34
+
35
+ try:
36
+ lines = log_file.read_text(encoding="utf-8").strip().splitlines()
37
+ except Exception:
38
+ return False
39
+
40
+ for line in reversed(lines[-LOOKBACK:]):
41
+ try:
42
+ entry = json.loads(line)
43
+ except (json.JSONDecodeError, ValueError):
44
+ continue
45
+
46
+ if entry.get("type") != "tool_call":
47
+ continue
48
+
49
+ tool = entry.get("tool", "")
50
+ if tool not in allowed:
51
+ continue
52
+
53
+ args = entry.get("args", {})
54
+ tool_path = args.get("file_path", "") or args.get("query", "")
55
+ tool_path_norm = tool_path.replace("\\", "/").strip("/")
56
+ rel_norm = rel_path.replace("\\", "/").strip("/")
57
+
58
+ if rel_norm in tool_path_norm or tool_path_norm in rel_norm:
59
+ return True
60
+
61
+ if tool == "c3_search":
62
+ filename = Path(rel_path).name
63
+ if filename and filename in tool_path:
64
+ return True
65
+
66
+ return False
67
+
68
+
69
+ def main():
70
+ try:
71
+ raw = sys.stdin.read()
72
+ if not raw.strip():
73
+ return
74
+
75
+ data = json.loads(raw)
76
+
77
+ # Normalize Gemini tool names to Claude equivalents
78
+ tool_name = normalize_tool_name(data.get("tool_name", ""))
79
+ if tool_name not in ("Read", "FindFiles", "SearchText"):
80
+ return
81
+
82
+ result_text, is_gemini = get_tool_output(data)
83
+ if not result_text or not isinstance(result_text, str):
84
+ return
85
+
86
+ project_path = Path.cwd()
87
+
88
+ if tool_name == "FindFiles":
89
+ if not _check_c3_used(project_path, "", allowed_tools=["c3_search"]):
90
+ emit_additional_context(
91
+ "\u26a0\ufe0f [c3:enforce] Standard file discovery is fallback-only in this project.\n\n"
92
+ "Before `FindFiles`, use a core C3 discovery tool:\n"
93
+ " c3_search(query=\"<your pattern>\", action=\"files\")\n\n"
94
+ "Use `FindFiles` only after a C3 result narrows the target.",
95
+ is_gemini,
96
+ )
97
+ return
98
+
99
+ if tool_name == "SearchText":
100
+ if not _check_c3_used(project_path, "", allowed_tools=["c3_search", "c3_compress", "c3_read"]):
101
+ emit_additional_context(
102
+ "\u26a0\ufe0f [c3:enforce] Standard text search is fallback-only in this project.\n\n"
103
+ "Before `SearchText`, use a core C3 tool first:\n"
104
+ " c3_search(query=\"<symbol or concept>\", action=\"code\")\n"
105
+ " c3_compress(file_path=\"<candidate file>\", mode=\"map\")\n\n"
106
+ "Use `SearchText` only after C3 narrows the scope.",
107
+ is_gemini,
108
+ )
109
+ return
110
+
111
+ # Read tool: extract file_path from tool_input (Claude: file_path, Gemini: path)
112
+ tool_input = data.get("tool_input", {})
113
+ file_path = tool_input.get("file_path", "") or tool_input.get("path", "")
114
+ if not file_path:
115
+ return
116
+
117
+ line_count = result_text.count("\n") + 1
118
+
119
+ try:
120
+ rel_path = str(Path(file_path).resolve().relative_to(project_path.resolve()))
121
+ except ValueError:
122
+ rel_path = file_path
123
+ rel_path = rel_path.replace("\\", "/")
124
+
125
+ queue_path = project_path / ".c3" / "file_memory" / "_queue.txt"
126
+ queue_path.parent.mkdir(parents=True, exist_ok=True)
127
+ try:
128
+ with open(queue_path, "a", encoding="utf-8") as handle:
129
+ handle.write(rel_path + "\n")
130
+ except Exception:
131
+ pass
132
+
133
+ # Clear this file from edit-unlock pending list (Edit prerequisite satisfied)
134
+ pending_path = project_path / ".c3" / "edit_unlock_pending.txt"
135
+ try:
136
+ if pending_path.exists():
137
+ pending = set(
138
+ line.strip() for line in
139
+ pending_path.read_text(encoding="utf-8").splitlines()
140
+ if line.strip()
141
+ )
142
+ # Match against both the raw file_path and the rel_path
143
+ to_remove = set()
144
+ fp_norm = file_path.replace("\\", "/").strip("/")
145
+ rel_norm = rel_path.replace("\\", "/").strip("/")
146
+ for p in pending:
147
+ p_norm = p.replace("\\", "/").strip("/")
148
+ if p_norm == fp_norm or p_norm == rel_norm or fp_norm.endswith(p_norm) or rel_norm.endswith(p_norm):
149
+ to_remove.add(p)
150
+ if to_remove:
151
+ pending -= to_remove
152
+ pending_path.write_text(
153
+ "\n".join(sorted(pending)) + "\n" if pending else "",
154
+ encoding="utf-8",
155
+ )
156
+ except Exception:
157
+ pass
158
+
159
+ ext = Path(rel_path).suffix.lower()
160
+ code_and_doc_exts = {
161
+ ".py", ".js", ".ts", ".tsx", ".jsx", ".go", ".rs", ".java",
162
+ ".rb", ".c", ".cpp", ".h", ".cs", ".r", ".R",
163
+ ".html", ".css", ".json", ".yaml", ".yml", ".sql", ".md",
164
+ }
165
+ data_exts = {".log", ".txt", ".jsonl"}
166
+
167
+ if ext not in code_and_doc_exts and ext not in data_exts:
168
+ return
169
+
170
+ is_data_file = ext in data_exts
171
+ required_tools = DATA_PRE_READ_TOOLS if is_data_file else CODE_PRE_READ_TOOLS
172
+ if _check_c3_used(project_path, rel_path, allowed_tools=required_tools):
173
+ return
174
+
175
+ if is_data_file:
176
+ emit_additional_context(
177
+ f"\u26a0\ufe0f [c3:enforce] STOP. You read `{rel_path}` without running a core C3 data tool first.\n\n"
178
+ "STRICT PREREQUISITE for `.log`/`.txt`/`.jsonl`:\n"
179
+ f" 1. c3_filter(file_path=\"{rel_path}\", pattern=\"<optional pattern>\")\n"
180
+ " 2. Read only the extracted signal if needed\n\n"
181
+ "Use standard `Read` only after `c3_filter` narrows the result.",
182
+ is_gemini,
183
+ )
184
+ return
185
+
186
+ emit_additional_context(
187
+ f"\u26a0\ufe0f [c3:enforce] STOP. You read `{rel_path}` ({line_count} lines) without using a core C3 tool first.\n\n"
188
+ "Required workflow before standard `Read`:\n"
189
+ f" 1. c3_search(query=\"{Path(rel_path).name}\", action=\"code\") or c3_compress(file_path=\"{rel_path}\", mode=\"map\")\n"
190
+ f" 2. c3_read(file_path=\"{rel_path}\", symbols=['ClassName', 'func_name']) or c3_read(file_path=\"{rel_path}\", lines=[[start, end]])\n"
191
+ " 3. Use standard `Read` only for a narrow follow-up if C3 output is insufficient\n\n"
192
+ "Core C3 tools are mandatory here: `c3_search`, `c3_compress`, `c3_read`.",
193
+ is_gemini,
194
+ )
195
+ except Exception as _e:
196
+ log_hook_error("hook_read", _e)
197
+
198
+
199
+ if __name__ == "__main__":
200
+ main()
@@ -0,0 +1,62 @@
1
+ """Stop hook: capture Claude Code session token/cost stats to .c3/session_stats.jsonl.
2
+
3
+ Triggered by the Claude Code 'Stop' event. Receives JSON on stdin:
4
+ {
5
+ "session_id": "...",
6
+ "transcript_path": "...",
7
+ "stop_reason": "end_turn" | "max_turns" | ...,
8
+ "cost_usd": 0.042,
9
+ "usage": {
10
+ "input_tokens": 12400,
11
+ "output_tokens": 850,
12
+ "cache_creation_input_tokens": 0,
13
+ "cache_read_input_tokens": 11200
14
+ }
15
+ }
16
+
17
+ Appends one JSON line per session to .c3/session_stats.jsonl.
18
+ """
19
+ import json
20
+ import sys
21
+ from datetime import datetime, timezone
22
+ from pathlib import Path
23
+
24
+ sys.path.insert(0, str(Path(__file__).parent.parent))
25
+
26
+ from cli._hook_utils import log_hook_error # noqa: E402
27
+
28
+
29
+ def main() -> None:
30
+ try:
31
+ data = json.load(sys.stdin)
32
+ except Exception as exc:
33
+ log_hook_error("hook_session_stats", exc)
34
+ sys.exit(0)
35
+
36
+ try:
37
+ usage = data.get("usage") or {}
38
+ entry = {
39
+ "ts": datetime.now(timezone.utc).isoformat(),
40
+ "session_id": data.get("session_id"),
41
+ "stop_reason": data.get("stop_reason"),
42
+ "cost_usd": data.get("cost_usd"),
43
+ "input_tokens": usage.get("input_tokens", 0),
44
+ "output_tokens": usage.get("output_tokens", 0),
45
+ "cache_creation_tokens": usage.get("cache_creation_input_tokens", 0),
46
+ "cache_read_tokens": usage.get("cache_read_input_tokens", 0),
47
+ }
48
+
49
+ # Claude Code runs hooks from the project root, so .c3/ is relative to CWD.
50
+ stats_dir = Path(".c3")
51
+ if stats_dir.exists():
52
+ stats_path = stats_dir / "session_stats.jsonl"
53
+ with open(stats_path, "a", encoding="utf-8") as f:
54
+ f.write(json.dumps(entry) + "\n")
55
+ except Exception as exc:
56
+ log_hook_error("hook_session_stats", exc)
57
+
58
+ sys.exit(0)
59
+
60
+
61
+ if __name__ == "__main__":
62
+ main()