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