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,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()
|