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
cli/hook_edit_unlock.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PostToolUse hook for c3_read, c3_compress, and c3_agent.
|
|
3
|
+
|
|
4
|
+
Tracks which editable files have been explored via C3 tools but not yet
|
|
5
|
+
natively Read. Emits a batched nudge so the model can unlock Edit for all
|
|
6
|
+
pending files in one message with parallel Read(limit=1) calls.
|
|
7
|
+
|
|
8
|
+
Supports both Claude Code (PostToolUse) and Gemini CLI (AfterTool).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
16
|
+
|
|
17
|
+
from cli._hook_utils import emit_additional_context, log_hook_error # noqa: E402
|
|
18
|
+
|
|
19
|
+
EDITABLE_EXTS = {
|
|
20
|
+
".py", ".js", ".ts", ".tsx", ".jsx", ".go", ".rs", ".java",
|
|
21
|
+
".rb", ".c", ".cpp", ".h", ".cs", ".html", ".css",
|
|
22
|
+
".json", ".yaml", ".yml", ".toml", ".sql", ".md", ".txt",
|
|
23
|
+
".sh", ".bat", ".ps1", ".r",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
HANDLED_TOOLS = {
|
|
27
|
+
"mcp__c3__c3_read",
|
|
28
|
+
"mcp__c3__c3_compress",
|
|
29
|
+
"mcp__c3__c3_agent",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
PENDING_FILE = ".c3/edit_unlock_pending.txt"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _get_pending_path() -> Path:
|
|
36
|
+
return Path.cwd() / PENDING_FILE
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _load_pending() -> set:
|
|
40
|
+
p = _get_pending_path()
|
|
41
|
+
if not p.exists():
|
|
42
|
+
return set()
|
|
43
|
+
try:
|
|
44
|
+
return set(line.strip() for line in p.read_text(encoding="utf-8").splitlines() if line.strip())
|
|
45
|
+
except Exception:
|
|
46
|
+
return set()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _save_pending(paths: set):
|
|
50
|
+
p = _get_pending_path()
|
|
51
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
try:
|
|
53
|
+
p.write_text("\n".join(sorted(paths)) + "\n" if paths else "", encoding="utf-8")
|
|
54
|
+
except Exception:
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _extract_files_from_tool(tool_name: str, tool_input: dict, tool_response: str) -> list:
|
|
59
|
+
"""Extract file paths that were touched by the tool call."""
|
|
60
|
+
files = []
|
|
61
|
+
|
|
62
|
+
if tool_name in ("mcp__c3__c3_read", "mcp__c3__c3_compress"):
|
|
63
|
+
file_path = (tool_input.get("file_path") or "").strip()
|
|
64
|
+
if file_path:
|
|
65
|
+
# Support comma-separated paths
|
|
66
|
+
files.extend(p.strip() for p in file_path.split(",") if p.strip())
|
|
67
|
+
|
|
68
|
+
elif tool_name == "mcp__c3__c3_agent":
|
|
69
|
+
# Extract scope (comma-separated file paths) from agent workflows
|
|
70
|
+
scope = (tool_input.get("scope") or "").strip()
|
|
71
|
+
if scope:
|
|
72
|
+
candidates = [p.strip() for p in scope.split(",") if p.strip()]
|
|
73
|
+
# Only include if they look like file paths
|
|
74
|
+
files.extend(p for p in candidates if "." in p)
|
|
75
|
+
|
|
76
|
+
# Also parse file paths from the response for review_changes/investigate
|
|
77
|
+
if isinstance(tool_response, str):
|
|
78
|
+
for line in tool_response.split("\n"):
|
|
79
|
+
line = line.strip()
|
|
80
|
+
# Match "## path/to/file.py" from compress output
|
|
81
|
+
if line.startswith("## ") and "." in line:
|
|
82
|
+
candidate = line[3:].split(" ")[0].strip()
|
|
83
|
+
if Path(candidate).suffix.lower() in EDITABLE_EXTS:
|
|
84
|
+
files.append(candidate)
|
|
85
|
+
|
|
86
|
+
return files
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def main():
|
|
90
|
+
try:
|
|
91
|
+
raw = sys.stdin.read()
|
|
92
|
+
if not raw.strip():
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
data = json.loads(raw)
|
|
96
|
+
tool_name = data.get("tool_name", "")
|
|
97
|
+
|
|
98
|
+
if tool_name not in HANDLED_TOOLS:
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
# Detect IDE format
|
|
102
|
+
is_gemini = isinstance(data.get("tool_response", ""), dict)
|
|
103
|
+
|
|
104
|
+
tool_input = data.get("tool_input", {})
|
|
105
|
+
tool_response = data.get("tool_response", "")
|
|
106
|
+
if isinstance(tool_response, dict):
|
|
107
|
+
tool_response = str(tool_response.get("llmContent", ""))
|
|
108
|
+
|
|
109
|
+
# Extract file paths touched by this tool
|
|
110
|
+
touched = _extract_files_from_tool(tool_name, tool_input, tool_response)
|
|
111
|
+
|
|
112
|
+
# Filter to editable extensions only
|
|
113
|
+
editable = [p for p in touched if Path(p).suffix.lower() in EDITABLE_EXTS]
|
|
114
|
+
if not editable:
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
# Load existing pending set and add new files
|
|
118
|
+
pending = _load_pending()
|
|
119
|
+
new_files = [p for p in editable if p not in pending]
|
|
120
|
+
if not new_files:
|
|
121
|
+
# All files already pending — skip duplicate nudge
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
pending.update(new_files)
|
|
125
|
+
_save_pending(pending)
|
|
126
|
+
|
|
127
|
+
# Record sticky unlocks so enforcement hook allows future native
|
|
128
|
+
# tool calls on these files without requiring a fresh c3_* call
|
|
129
|
+
unlock_path = Path.cwd() / ".c3" / "unlocked_files.txt"
|
|
130
|
+
try:
|
|
131
|
+
existing = set(
|
|
132
|
+
line.strip() for line in
|
|
133
|
+
unlock_path.read_text(encoding="utf-8").splitlines()
|
|
134
|
+
if line.strip()
|
|
135
|
+
) if unlock_path.exists() else set()
|
|
136
|
+
for fp in editable:
|
|
137
|
+
resolved = str(Path(fp).resolve())
|
|
138
|
+
existing.add(resolved)
|
|
139
|
+
unlock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
140
|
+
unlock_path.write_text(
|
|
141
|
+
"\n".join(sorted(existing)) + "\n",
|
|
142
|
+
encoding="utf-8",
|
|
143
|
+
)
|
|
144
|
+
except Exception:
|
|
145
|
+
pass
|
|
146
|
+
|
|
147
|
+
# Emit batched nudge with all pending files
|
|
148
|
+
# Prefer c3_edit (no unlock needed). Native Edit is also unlocked via sticky file set.
|
|
149
|
+
if len(pending) == 1:
|
|
150
|
+
fp = next(iter(pending))
|
|
151
|
+
msg = (
|
|
152
|
+
f'[c3:edit-ready] "{fp}" unlocked for editing. '
|
|
153
|
+
f'Use c3_edit(file_path="{fp}", old_string=..., new_string=...) — preferred. '
|
|
154
|
+
f'Native Edit also unlocked (Read(limit=1) first if Claude Code requires it).'
|
|
155
|
+
)
|
|
156
|
+
else:
|
|
157
|
+
files_list = ", ".join(f'"{p}"' for p in sorted(pending))
|
|
158
|
+
msg = (
|
|
159
|
+
f"[c3:edit-ready] {len(pending)} files unlocked for editing: {files_list}. "
|
|
160
|
+
f"Use c3_edit(file_path=...) for each — preferred. "
|
|
161
|
+
f"Native Edit also unlocked (Read(limit=1) first if Claude Code requires it)."
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
emit_additional_context(msg, is_gemini)
|
|
165
|
+
except Exception as _e:
|
|
166
|
+
log_hook_error("hook_edit_unlock", _e)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
if __name__ == "__main__":
|
|
170
|
+
main()
|
cli/hook_filter.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PostToolUse/AfterTool hook for Bash/run_shell_command — filter noisy terminal output via C3.
|
|
3
|
+
|
|
4
|
+
Reads tool result JSON from stdin. If filtering yields meaningful savings, writes a
|
|
5
|
+
replacement `tool_result` (Claude) or `additionalContext` (Gemini). Otherwise emits
|
|
6
|
+
compact hints to encourage token-safe follow-up actions.
|
|
7
|
+
|
|
8
|
+
Claude Code — register in .claude/settings.local.json:
|
|
9
|
+
"hooks": {"PostToolUse": [{"matcher": "Bash", "hooks": [{"type": "command", "command": "python cli/hook_filter.py"}]}]}
|
|
10
|
+
|
|
11
|
+
Gemini CLI — register in .gemini/settings.json:
|
|
12
|
+
"hooks": {"AfterTool": [{"matcher": "run_shell_command", "hooks": [{"type": "command", "command": "python cli/hook_filter.py"}]}]}
|
|
13
|
+
"""
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
# Lines threshold to suggest c3_delegate summarize
|
|
20
|
+
LONG_OUTPUT_LINES = 80
|
|
21
|
+
# Lines threshold to nudge explicit c3_filter usage
|
|
22
|
+
FILTER_HINT_LINES = 20
|
|
23
|
+
|
|
24
|
+
# Patterns that indicate an error/failure worth diagnosing
|
|
25
|
+
_ERROR_PATTERNS = re.compile(
|
|
26
|
+
r"(Traceback \(most recent call last\)|\bError:|\bException:|\bFAILED\b|\bERROR\b)",
|
|
27
|
+
re.MULTILINE,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# Add project root to path
|
|
31
|
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
32
|
+
|
|
33
|
+
from cli._hook_utils import ( # noqa: E402
|
|
34
|
+
emit_additional_context,
|
|
35
|
+
emit_filtered_output,
|
|
36
|
+
get_tool_output,
|
|
37
|
+
log_hook_error,
|
|
38
|
+
normalize_tool_name,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def main():
|
|
43
|
+
try:
|
|
44
|
+
raw = sys.stdin.read()
|
|
45
|
+
if not raw.strip():
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
data = json.loads(raw)
|
|
49
|
+
|
|
50
|
+
# Normalize Gemini tool names to Claude equivalents
|
|
51
|
+
tool_name = normalize_tool_name(data.get("tool_name", ""))
|
|
52
|
+
if tool_name != "Bash":
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
output, is_gemini = get_tool_output(data)
|
|
56
|
+
if not output or not isinstance(output, str):
|
|
57
|
+
return
|
|
58
|
+
line_count = output.count("\n") + 1
|
|
59
|
+
|
|
60
|
+
from core.config import load_hybrid_config
|
|
61
|
+
from services.output_filter import OutputFilter
|
|
62
|
+
|
|
63
|
+
# Determine project path from cwd
|
|
64
|
+
project_path = str(Path.cwd())
|
|
65
|
+
config = load_hybrid_config(project_path)
|
|
66
|
+
|
|
67
|
+
if config.get("HYBRID_DISABLE_TIER1"):
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
# Filter medium/large outputs; short outputs still get hints.
|
|
71
|
+
if len(output) >= 200:
|
|
72
|
+
filt = OutputFilter(config)
|
|
73
|
+
result = filt.filter(output, use_llm=True)
|
|
74
|
+
|
|
75
|
+
# Only replace if meaningful savings (>10%)
|
|
76
|
+
if result["savings_pct"] > 10:
|
|
77
|
+
# Store original for c3_raw retrieval
|
|
78
|
+
raw_cache = Path(project_path) / ".c3" / "last_raw_output.txt"
|
|
79
|
+
raw_cache.parent.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
raw_cache.write_text(output, encoding="utf-8")
|
|
81
|
+
|
|
82
|
+
filtered = result["filtered"]
|
|
83
|
+
if not filtered.startswith("[c3:filter"):
|
|
84
|
+
prefix = f"[c3:filter:pass{result['pass_used']}|{result['savings_pct']}%saved] "
|
|
85
|
+
filtered = prefix + filtered
|
|
86
|
+
|
|
87
|
+
emit_filtered_output(filtered, is_gemini)
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
# Output was not replaced - provide hints.
|
|
91
|
+
hints = _build_hints(output, line_count=line_count)
|
|
92
|
+
if hints:
|
|
93
|
+
emit_additional_context(hints, is_gemini)
|
|
94
|
+
|
|
95
|
+
except Exception as _e:
|
|
96
|
+
log_hook_error("hook_filter", _e)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _build_hints(output: str, line_count: int | None = None) -> str:
|
|
100
|
+
"""Return [c3:hint] lines suggesting token-safe next actions."""
|
|
101
|
+
hints = []
|
|
102
|
+
if line_count is None:
|
|
103
|
+
line_count = output.count("\n") + 1
|
|
104
|
+
|
|
105
|
+
if line_count >= FILTER_HINT_LINES:
|
|
106
|
+
hints.append(
|
|
107
|
+
f"[c3:hint:filter] Output is {line_count} lines. "
|
|
108
|
+
"Run c3_filter(text='<raw output>') before further analysis to reduce token noise."
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Error/traceback detected - suggest diagnose
|
|
112
|
+
if _ERROR_PATTERNS.search(output):
|
|
113
|
+
hints.append(
|
|
114
|
+
"[c3:hint:delegate] Error output detected. "
|
|
115
|
+
"Use c3_delegate(task_type='diagnose', task='<describe the error>') "
|
|
116
|
+
"to root-cause it with a local LLM and save Claude tokens."
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
# Very long output - suggest summarize
|
|
120
|
+
if line_count >= LONG_OUTPUT_LINES and not _ERROR_PATTERNS.search(output):
|
|
121
|
+
hints.append(
|
|
122
|
+
f"[c3:hint:delegate] Output is {line_count} lines. "
|
|
123
|
+
"Use c3_delegate(task_type='summarize', task='summarize this output', context='<paste key lines>')."
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
return "\n".join(hints)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
if __name__ == "__main__":
|
|
130
|
+
main()
|
cli/hook_ghost_files.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PostToolUse hook for Bash: detect and warn about ghost files.
|
|
3
|
+
|
|
4
|
+
Ghost files are 0-byte (or near-0) files created in the project root when
|
|
5
|
+
shell metacharacters in Bash commands are misinterpreted — e.g., Python type
|
|
6
|
+
annotations like `-> dict` becoming `> dict` (output redirect), or pip
|
|
7
|
+
specifiers like `flask>=3.0.0` becoming `> =3.0.0`.
|
|
8
|
+
|
|
9
|
+
Runs after every Bash tool call. Scans the project root (non-recursively) for
|
|
10
|
+
files that match ghost-file heuristics and emits a warning + auto-deletes them.
|
|
11
|
+
|
|
12
|
+
Supports both Claude Code (PostToolUse) and Gemini CLI (AfterTool).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
21
|
+
|
|
22
|
+
from cli._hook_utils import emit_additional_context, log_hook_error # noqa: E402
|
|
23
|
+
|
|
24
|
+
# ── Ghost-file detection heuristics ──────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
# Python builtin / typing names that should never be standalone files
|
|
27
|
+
_PYTHON_TYPE_NAMES = {
|
|
28
|
+
"dict", "str", "int", "float", "bool", "list", "set", "tuple",
|
|
29
|
+
"bytes", "bytearray", "complex", "frozenset", "memoryview",
|
|
30
|
+
"type", "object", "range", "slice", "property", "classmethod",
|
|
31
|
+
"staticmethod", "super", "None", "True", "False", "Ellipsis",
|
|
32
|
+
# typing module
|
|
33
|
+
"Any", "Union", "Optional", "List", "Dict", "Set", "Tuple",
|
|
34
|
+
"Callable", "Iterator", "Generator", "Sequence", "Mapping",
|
|
35
|
+
"Awaitable", "Coroutine", "AsyncIterator", "AsyncGenerator",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
# Max file size to consider a ghost (bytes). Genuine files are usually larger.
|
|
39
|
+
_MAX_GHOST_SIZE = 4096
|
|
40
|
+
|
|
41
|
+
# Version-number pattern: 3.0.0, 1.2, 10.20.30 — usually from pip specifiers.
|
|
42
|
+
import re as _re
|
|
43
|
+
|
|
44
|
+
_VERSION_RE = _re.compile(r"^\d+(\.\d+)+[`'\"$|]*$")
|
|
45
|
+
|
|
46
|
+
# Extensions that are definitely NOT ghost files
|
|
47
|
+
_SAFE_EXTENSIONS = {
|
|
48
|
+
".py", ".js", ".ts", ".tsx", ".jsx", ".go", ".rs", ".java",
|
|
49
|
+
".rb", ".c", ".cpp", ".h", ".cs", ".html", ".css", ".scss",
|
|
50
|
+
".json", ".yaml", ".yml", ".toml", ".sql", ".md", ".txt",
|
|
51
|
+
".sh", ".bat", ".ps1", ".r", ".xml", ".csv", ".ini", ".cfg",
|
|
52
|
+
".lock", ".log", ".png", ".jpg", ".jpeg", ".gif", ".svg",
|
|
53
|
+
".ico", ".woff", ".woff2", ".ttf", ".eot", ".map", ".gz",
|
|
54
|
+
".zip", ".tar", ".whl", ".egg", ".pyc", ".pyo", ".so",
|
|
55
|
+
".dll", ".exe", ".bin", ".dat", ".db", ".sqlite", ".gitignore",
|
|
56
|
+
".gitattributes", ".editorconfig", ".prettierrc", ".eslintrc",
|
|
57
|
+
".flake8", ".mypy", ".env", ".dockerignore", ".pdf",
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# Known legitimate root-level files (no extension) in typical projects
|
|
61
|
+
_SAFE_NAMES = {
|
|
62
|
+
"Makefile", "Dockerfile", "Procfile", "Vagrantfile", "Gemfile",
|
|
63
|
+
"Rakefile", "Brewfile", "Pipfile", "LICENSE", "CHANGELOG",
|
|
64
|
+
"CONTRIBUTING", "AUTHORS", "CODEOWNERS", "Makefile.am",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _is_ghost_file(path: Path) -> bool:
|
|
69
|
+
"""Return True if a file in the project root looks like a ghost."""
|
|
70
|
+
name = path.name
|
|
71
|
+
|
|
72
|
+
# Skip dotfiles/directories
|
|
73
|
+
if name.startswith("."):
|
|
74
|
+
return False
|
|
75
|
+
|
|
76
|
+
# Skip directories
|
|
77
|
+
if path.is_dir():
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
suffix = path.suffix.lower()
|
|
81
|
+
|
|
82
|
+
# Skip files with safe extensions (real code/config files)
|
|
83
|
+
if suffix and suffix in _SAFE_EXTENSIONS:
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
# Skip known legitimate extensionless files
|
|
87
|
+
if name in _SAFE_NAMES:
|
|
88
|
+
return False
|
|
89
|
+
|
|
90
|
+
# Skip files that are too large to be ghosts
|
|
91
|
+
try:
|
|
92
|
+
size = path.stat().st_size
|
|
93
|
+
except OSError:
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
if size > _MAX_GHOST_SIZE:
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
# Treat a "suffix" as real only if it matches a letter-led pattern
|
|
100
|
+
# (e.g. ".py", ".json"). Things like ".0" or ".0`" from a version
|
|
101
|
+
# string like "3.0.0`" are NOT real extensions — they're shell-redirect
|
|
102
|
+
# artifacts, and were escaping the 0-byte-extensionless filter below.
|
|
103
|
+
real_suffix = suffix and suffix[1:2].isalpha()
|
|
104
|
+
|
|
105
|
+
# ── Positive signals ─────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
# Bare Python type name (e.g., "dict", "str")
|
|
108
|
+
if name in _PYTHON_TYPE_NAMES:
|
|
109
|
+
return True
|
|
110
|
+
|
|
111
|
+
# Partial type annotation (e.g., "tuple[float", "dict[str")
|
|
112
|
+
if "[" in name and not real_suffix:
|
|
113
|
+
return True
|
|
114
|
+
|
|
115
|
+
# Partial function-call syntax (e.g., "parseApiResponse(await")
|
|
116
|
+
# — fragments of JS/Python that Bash tokenized as a filename.
|
|
117
|
+
if ("(" in name or ")" in name) and not real_suffix:
|
|
118
|
+
return True
|
|
119
|
+
|
|
120
|
+
# Shell redirect artifact: starts with = (e.g., "=3.0.0" from >=3.0.0)
|
|
121
|
+
if name.startswith("="):
|
|
122
|
+
return True
|
|
123
|
+
|
|
124
|
+
# Starts with > or < (rare but possible)
|
|
125
|
+
if name.startswith(">") or name.startswith("<"):
|
|
126
|
+
return True
|
|
127
|
+
|
|
128
|
+
# Trailing backtick or other metacharacter — command-substitution leakage
|
|
129
|
+
if name.endswith("`") or name.endswith("$") or name.endswith("|"):
|
|
130
|
+
return True
|
|
131
|
+
|
|
132
|
+
# Version-like name (e.g., "3.0.0", "3.0.0`") without a real extension
|
|
133
|
+
# — classic `pip install foo>=3.0.0` ghost.
|
|
134
|
+
if size == 0 and not real_suffix and _VERSION_RE.match(name):
|
|
135
|
+
return True
|
|
136
|
+
|
|
137
|
+
# 0-byte file with no real extension and not in safe names
|
|
138
|
+
if size == 0 and not real_suffix:
|
|
139
|
+
return True
|
|
140
|
+
|
|
141
|
+
return False
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def scan_ghost_files(project_root: Path) -> list[dict]:
|
|
145
|
+
"""Scan project root for ghost files. Returns list of {path, name, size, reason}."""
|
|
146
|
+
ghosts = []
|
|
147
|
+
try:
|
|
148
|
+
for entry in project_root.iterdir():
|
|
149
|
+
if not entry.is_file():
|
|
150
|
+
continue
|
|
151
|
+
if not _is_ghost_file(entry):
|
|
152
|
+
continue
|
|
153
|
+
size = entry.stat().st_size
|
|
154
|
+
name = entry.name
|
|
155
|
+
|
|
156
|
+
# Determine reason
|
|
157
|
+
if name in _PYTHON_TYPE_NAMES:
|
|
158
|
+
reason = "Python type name"
|
|
159
|
+
elif "[" in name:
|
|
160
|
+
reason = "partial type annotation"
|
|
161
|
+
elif "(" in name or ")" in name:
|
|
162
|
+
reason = "partial function-call syntax"
|
|
163
|
+
elif name.startswith("="):
|
|
164
|
+
reason = "pip version redirect (>=X.Y.Z)"
|
|
165
|
+
elif name.startswith(">") or name.startswith("<"):
|
|
166
|
+
reason = "shell redirect artifact"
|
|
167
|
+
elif name.endswith("`") or name.endswith("$") or name.endswith("|"):
|
|
168
|
+
reason = "shell metacharacter leak"
|
|
169
|
+
elif _VERSION_RE.match(name):
|
|
170
|
+
reason = "version-number leak (pip specifier)"
|
|
171
|
+
elif size == 0:
|
|
172
|
+
reason = "0-byte extensionless file"
|
|
173
|
+
else:
|
|
174
|
+
reason = "suspicious extensionless file"
|
|
175
|
+
|
|
176
|
+
ghosts.append({
|
|
177
|
+
"path": str(entry),
|
|
178
|
+
"name": name,
|
|
179
|
+
"size": size,
|
|
180
|
+
"reason": reason,
|
|
181
|
+
})
|
|
182
|
+
except OSError:
|
|
183
|
+
pass
|
|
184
|
+
return ghosts
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def cleanup_ghost_files(ghosts: list[dict]) -> list[str]:
|
|
188
|
+
"""Delete ghost files. Returns list of successfully deleted names."""
|
|
189
|
+
deleted = []
|
|
190
|
+
for g in ghosts:
|
|
191
|
+
try:
|
|
192
|
+
os.remove(g["path"])
|
|
193
|
+
deleted.append(g["name"])
|
|
194
|
+
except OSError:
|
|
195
|
+
pass
|
|
196
|
+
return deleted
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def main():
|
|
200
|
+
try:
|
|
201
|
+
raw = sys.stdin.read()
|
|
202
|
+
if not raw.strip():
|
|
203
|
+
return
|
|
204
|
+
|
|
205
|
+
data = json.loads(raw)
|
|
206
|
+
tool_name = data.get("tool_name", "")
|
|
207
|
+
|
|
208
|
+
# Only trigger on Bash (Claude Code) or run_shell_command (Gemini)
|
|
209
|
+
if tool_name not in ("Bash", "run_shell_command"):
|
|
210
|
+
return
|
|
211
|
+
|
|
212
|
+
is_gemini = isinstance(data.get("tool_response", ""), dict)
|
|
213
|
+
project_root = Path.cwd()
|
|
214
|
+
|
|
215
|
+
ghosts = scan_ghost_files(project_root)
|
|
216
|
+
if not ghosts:
|
|
217
|
+
return
|
|
218
|
+
|
|
219
|
+
# Auto-delete ghost files
|
|
220
|
+
deleted = cleanup_ghost_files(ghosts)
|
|
221
|
+
|
|
222
|
+
if deleted:
|
|
223
|
+
names = ", ".join(f'"{n}"' for n in deleted)
|
|
224
|
+
msg = (
|
|
225
|
+
f"[c3:ghost-cleanup] Deleted {len(deleted)} ghost file(s) from project root: {names}. "
|
|
226
|
+
f"These were created by shell metacharacter misinterpretation in Bash commands "
|
|
227
|
+
f"(e.g., `> dict` from `-> dict` in Python type annotations, "
|
|
228
|
+
f"or `> =3.0.0` from `>=3.0.0` in pip specifiers). "
|
|
229
|
+
f"Tip: quote Bash commands carefully to avoid shell redirects."
|
|
230
|
+
)
|
|
231
|
+
emit_additional_context(msg, is_gemini)
|
|
232
|
+
|
|
233
|
+
except Exception as _e:
|
|
234
|
+
log_hook_error("hook_ghost_files", _e)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
if __name__ == "__main__":
|
|
238
|
+
main()
|