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
services/claude_md.py ADDED
@@ -0,0 +1,785 @@
1
+ """
2
+ CLAUDE.md Management Service
3
+
4
+ Provides intelligent CLAUDE.md lifecycle tools:
5
+ - generate: Create CLAUDE.md from live project data + session/memory insights
6
+ - check_staleness: Detect drift between CLAUDE.md and actual project state
7
+ - compact: Reduce bloated CLAUDE.md while preserving critical info
8
+ - get_promotion_candidates: Surface high-value facts/patterns for inclusion
9
+
10
+ All methods are read-only — they return content/reports but never write to disk.
11
+ """
12
+ import json
13
+ import re
14
+ from pathlib import Path
15
+ from typing import Optional
16
+
17
+ from core import count_tokens
18
+
19
+ # Default truncation limit (Claude Code truncates after 200 lines)
20
+ TRUNCATION_LIMIT = 200
21
+
22
+ C3_COMPACT_WORKFLOW = """\
23
+ ## C3 Tools — MANDATORY (enforced by hooks)
24
+ Native tools (Read, Grep, Glob, Edit, Write) are **blocked by PreToolUse hooks** unless a c3_* tool \
25
+ was called first. Do NOT attempt native tools without prior c3_* usage — they will be denied.
26
+
27
+ **Native tools are permitted ONLY when:**
28
+ 1. The c3_* tool failed or returned an error
29
+ 2. The c3_* tool returned insufficient scope for a targeted follow-up
30
+ When falling back, state which c3_* tool was attempted and why it was insufficient.
31
+
32
+ ## Workflow (follow this order — do not skip steps)
33
+ 1. **RECALL**: `c3_memory(action='recall')` — before any multi-step or context-dependent task. Large memory stores: use `index` first (compact list), then `fetch` for specific IDs
34
+ 2. **SEARCH FIRST**: `c3_search(action='code|files|semantic')` — before ANY file discovery or content search. Never start with Grep/Glob
35
+ 3. **MAP before READ**: `c3_compress(mode='map')` then `c3_read(symbols=...|lines=...)` — for ANY file read. Never start with native Read. Use `mode='ast'` for knowledge-graph overview (requires codebase-memory-mcp)
36
+ 4. **IMPACT** (shared symbols): `c3_impact(target='symbol')` — blast-radius check before editing any function/class used across files
37
+ 5. **EDIT via C3**: `c3_edit(file_path, old_string, new_string, summary)` — for ALL edits. Parallel across files; `edits=[]` batch for same file
38
+ 6. **FILTER**: `c3_filter(text=...)` — for terminal output >10 lines or log files
39
+ 6.5. **SHELL via C3**: `c3_shell(cmd, cwd='', timeout=60)` — for tests, git, build, scripts. Returns structured `{exit_code, stdout, stderr, duration_ms}`. Auto-filters stdout >30 lines; auto-logs git-mutating commands (commit/add/merge/rebase/reset/restore/checkout) to the edit ledger. Blocks fork bombs and `rm -rf /` or `~`; soft-warns on `--force`, `--no-verify`, `reset --hard`. Native Bash remains the fallback for interactive/TTY commands
40
+ 7. **VALIDATE**: `c3_validate(file_path)` — after edits or before reporting done. Runs deep type check (pyright/tsc) automatically if installed
41
+ 8. **LOG**: `c3_session(action='log')` for decisions. `c3_session(action='snapshot')` before /clear
42
+ 9. **DELEGATE**: `c3_delegate(task, backend='ollama|codex|gemini|claude|auto')` or `c3_agent(workflow=...)` for multi-model pipelines
43
+
44
+ ## Plan mode
45
+ In plan mode, all c3_* read tools (search, read, compress, filter, validate, status) work normally — skip edit/delegate steps.
46
+
47
+ ## Anti-patterns (DO NOT do these)
48
+ - Starting with native file search/read/grep without a prior c3_* call
49
+ - Using native Edit when c3_edit is available
50
+ - Reading entire files when c3_compress + c3_read would be more surgical
51
+ - Skipping c3_validate after making edits"""
52
+
53
+ # Ultra-compact workflow for nano mode (~250 tokens vs ~800 for full)
54
+ C3_NANO_WORKFLOW = """\
55
+ ## C3 Tools — MANDATORY
56
+ Native tools BLOCKED unless c3_* called first. State reason when falling back.
57
+ 1. c3_search(action='code|files|semantic') — BEFORE any search/grep/glob
58
+ 2. c3_compress(mode='map') then c3_read(symbols=...|lines=...) — BEFORE any file read
59
+ 3. c3_edit(file_path, old_string, new_string, summary) — for ALL edits; edits=[{...}] batch
60
+ 4. c3_filter(text='...') — output >10 lines
61
+ 5. c3_validate(file_path) — after edits
62
+ 6. c3_session(action='log'|'snapshot') — decisions / before /clear
63
+ Plan mode: all c3_* read tools work normally — skip edit/delegate steps.
64
+ DO NOT: start with native Read/Grep/Glob/Edit, skip c3_validate, read full files without c3_compress."""
65
+
66
+
67
+ class ClaudeMdManager:
68
+ """Manages instructions file generation, analysis, compaction, and insight promotion.
69
+
70
+ Supports multiple IDEs — instructions_file determines the output filename
71
+ (e.g. CLAUDE.md for Claude Code, .github/copilot-instructions.md for VS Code).
72
+ """
73
+
74
+ def __init__(self, project_path: str, session_mgr, indexer, memory,
75
+ instructions_file: str = "CLAUDE.md", line_limit: int = 200,
76
+ supports_hooks: bool = True, supports_clear: bool = True,
77
+ nano_mode: bool = False):
78
+ self.project_path = Path(project_path)
79
+ self.session_mgr = session_mgr
80
+ self.indexer = indexer
81
+ self.memory = memory
82
+ self.instructions_file = instructions_file
83
+ self.line_limit = line_limit
84
+ self.supports_hooks = supports_hooks
85
+ self.supports_clear = supports_clear
86
+ self._nano_mode = nano_mode
87
+
88
+ # ── Public API (one per MCP tool) ────────────────────────
89
+
90
+ def _build_c3_workflow(self, nano: bool = False) -> str:
91
+ """Build C3 workflow section.
92
+
93
+ nano=True: ~250 tokens (vs ~800 full). Use for IDEs where instructions space is limited.
94
+ Filters out hooks/snapshot/transcript lines for IDEs that don't support them.
95
+ """
96
+ if nano:
97
+ workflow = C3_NANO_WORKFLOW
98
+ else:
99
+ workflow = C3_COMPACT_WORKFLOW
100
+
101
+ # Strip features unsupported by this IDE to reduce irrelevant instruction tokens
102
+ if not self.supports_clear:
103
+ # Remove /clear and snapshot/restore references
104
+ lines = workflow.splitlines()
105
+ lines = [l for l in lines if '/clear' not in l and 'snapshot' not in l.lower()]
106
+ workflow = '\n'.join(lines)
107
+ if not self.supports_hooks:
108
+ # Remove hook-specific log lines (hooks are Claude Code / Gemini only)
109
+ lines = workflow.splitlines()
110
+ lines = [l for l in lines if 'PostToolUse' not in l and 'AfterTool' not in l]
111
+ workflow = '\n'.join(lines)
112
+
113
+ return workflow
114
+
115
+ def generate(self, include_sessions: bool = True, mode: str = "compact") -> dict:
116
+ """Generate token-efficient CLAUDE.md from live project data.
117
+
118
+ mode='compact' (default): full workflow + project tree + key facts (~2,000 tokens)
119
+ mode='nano': minimal mandate only (~250 tokens) — project tree/facts served via c3_memory
120
+
121
+ Optimized for minimal per-turn overhead:
122
+ - Compact C3 tool reference (~7 lines vs ~16)
123
+ - No session history (use c3_memory recall instead)
124
+ - Top 5 learned facts only (rest available via c3_memory)
125
+ - No shortcuts section (low value, costs tokens every turn)
126
+ """
127
+ if mode == "nano":
128
+ self._nano_mode = True
129
+
130
+ use_nano = getattr(self, '_nano_mode', False)
131
+
132
+ # Nano mode: return minimal mandate only — project tree/facts served via c3_memory on demand
133
+ if use_nano:
134
+ content = self._build_c3_workflow(nano=True)
135
+ metrics = self._count_metrics(content)
136
+ return {
137
+ "content": content,
138
+ "lines": metrics["lines"],
139
+ "tokens": metrics["tokens"],
140
+ "mode": "nano",
141
+ "truncation_warning": None,
142
+ }
143
+
144
+ parts = []
145
+
146
+ # C3 workflow instructions (compact)
147
+ parts.append(self._build_c3_workflow(nano=False))
148
+
149
+ # Project structure
150
+ parts.append("\n# Project Context\n")
151
+ parts.append(self.session_mgr._scan_project_structure())
152
+
153
+ # Tech stack
154
+ parts.append("\n## Tech Stack\n")
155
+ parts.append(self.session_mgr._detect_tech_stack())
156
+
157
+ # Key files (compact)
158
+ key_files = self._detect_key_files()
159
+ if key_files:
160
+ parts.append("\n## Key Files\n")
161
+ for kf in key_files[:5]:
162
+ parts.append(f"- `{kf['file']}` — {kf['reason']}")
163
+
164
+ # Top learned facts only (rest available via c3_memory recall)
165
+ promoted_facts = [
166
+ f for f in self.memory.facts
167
+ if f.get("relevance_count", 0) >= 3
168
+ ]
169
+ if promoted_facts:
170
+ parts.append("\n## Key Facts (use c3_memory for more)\n")
171
+ for f in promoted_facts[:5]:
172
+ parts.append(f"- {f['fact'][:120]}")
173
+
174
+ content = '\n'.join(parts)
175
+ metrics = self._count_metrics(content)
176
+
177
+ # Enforce line budget: progressively prune rather than silently truncate Key Facts
178
+ if self.line_limit and metrics["lines"] > self.line_limit:
179
+ # Pass 1: prune project structure to depth 1
180
+ pruned_parts = []
181
+ for part in parts:
182
+ if part.strip().startswith("```") and "\n" in part:
183
+ part = self._prune_structure_depth(part, max_depth=1)
184
+ pruned_parts.append(part)
185
+ content = '\n'.join(pruned_parts)
186
+ metrics = self._count_metrics(content)
187
+
188
+ if self.line_limit and metrics["lines"] > self.line_limit:
189
+ # Pass 2: drop key facts to 3
190
+ rebuilt = []
191
+ in_facts = False
192
+ facts_shown = 0
193
+ for line in content.splitlines():
194
+ if line.startswith("## Key Facts"):
195
+ in_facts = True
196
+ rebuilt.append(line)
197
+ continue
198
+ if in_facts and line.startswith("- "):
199
+ if facts_shown < 3:
200
+ rebuilt.append(line)
201
+ facts_shown += 1
202
+ continue
203
+ if in_facts and line.startswith("## "):
204
+ in_facts = False
205
+ rebuilt.append(line)
206
+ content = '\n'.join(rebuilt)
207
+ metrics = self._count_metrics(content)
208
+
209
+ return {
210
+ "content": content,
211
+ "lines": metrics["lines"],
212
+ "tokens": metrics["tokens"],
213
+ "truncation_warning": (
214
+ f"Content is {metrics['lines']} lines — exceeds limit of {self.line_limit}. "
215
+ "Run `c3 claudemd compact` to reduce further."
216
+ ) if self.line_limit and metrics["lines"] > self.line_limit else None,
217
+ }
218
+
219
+ def check_staleness(self) -> dict:
220
+ """Check existing CLAUDE.md for staleness and drift."""
221
+ current = self._read_current()
222
+ if current is None:
223
+ return {
224
+ "status": "missing",
225
+ "issues": [{
226
+ "severity": "error",
227
+ "message": f"No {self.instructions_file} found. Use CLI `c3 claudemd generate` to create one.",
228
+ }],
229
+ }
230
+
231
+ issues = []
232
+ sections = self._parse_sections(current)
233
+ metrics = self._count_metrics(current)
234
+
235
+ # Size warning (only if line_limit is set)
236
+ if self.line_limit and metrics["lines"] > self.line_limit:
237
+ issues.append({
238
+ "severity": "warning",
239
+ "message": (
240
+ f"{self.instructions_file} is {metrics['lines']} lines ({metrics['tokens']} tokens). "
241
+ f"Truncation may occur after {self.line_limit} lines. "
242
+ "Use CLI `c3 claudemd compact` to reduce."
243
+ ),
244
+ })
245
+
246
+ # Structure drift
247
+ structure_issues = self._diff_structure(current)
248
+ issues.extend(structure_issues)
249
+
250
+ # Tech stack drift
251
+ tech_issues = self._diff_tech_stack(current)
252
+ issues.extend(tech_issues)
253
+
254
+ # Session staleness
255
+ session_files = sorted(
256
+ (self.project_path / ".c3" / "sessions").glob("session_*.json"),
257
+ reverse=True,
258
+ ) if (self.project_path / ".c3" / "sessions").exists() else []
259
+
260
+ session_section = sections.get("Session History (Compressed)", "")
261
+ if session_files:
262
+ # Count sessions mentioned in CLAUDE.md
263
+ mentioned_ids = set(re.findall(r'Session:\s*(\d{8}_\d{6})', session_section))
264
+ total_sessions = len(session_files)
265
+ unmentioned = total_sessions - len(mentioned_ids)
266
+ if unmentioned > 3:
267
+ issues.append({
268
+ "severity": "info",
269
+ "message": f"{unmentioned} sessions not reflected in CLAUDE.md. Consider regenerating.",
270
+ })
271
+
272
+ if not issues:
273
+ issues.append({
274
+ "severity": "info",
275
+ "message": "CLAUDE.md looks up to date.",
276
+ })
277
+
278
+ return {
279
+ "status": "ok" if all(i["severity"] == "info" for i in issues) else "stale",
280
+ "lines": metrics["lines"],
281
+ "tokens": metrics["tokens"],
282
+ "issues": issues,
283
+ }
284
+
285
+ def compact(self, target_lines: int = 150) -> dict:
286
+ """Compact existing CLAUDE.md to fit within target line count."""
287
+ current = self._read_current()
288
+ if current is None:
289
+ return {"error": f"No {self.instructions_file} found on disk. Use CLI `c3 claudemd generate` to preview, then `c3 claudemd save` to persist before compacting."}
290
+
291
+ original_metrics = self._count_metrics(current)
292
+ sections = self._parse_sections(current)
293
+ lines = current.split('\n')
294
+
295
+ # If already under target, no compaction needed
296
+ if original_metrics["lines"] <= target_lines:
297
+ return {
298
+ "content": current,
299
+ "original_lines": original_metrics["lines"],
300
+ "compacted_lines": original_metrics["lines"],
301
+ "original_tokens": original_metrics["tokens"],
302
+ "compacted_tokens": original_metrics["tokens"],
303
+ "actions": ["Already under target — no compaction needed."],
304
+ }
305
+
306
+ actions = []
307
+
308
+ # Step 1: Compress session history — keep last 3, one-line summaries
309
+ if "Session History (Compressed)" in sections:
310
+ session_text = sections["Session History (Compressed)"]
311
+ compressed = self._compress_sessions(session_text, max_sessions=3)
312
+ if len(compressed.split('\n')) < len(session_text.split('\n')):
313
+ sections["Session History (Compressed)"] = compressed
314
+ actions.append("Trimmed session history to last 3 sessions with one-line summaries")
315
+
316
+ # Step 2: Deduplicate — remove exact duplicate lines (excluding blank lines and headers)
317
+ seen_lines = set()
318
+ deduped_sections = {}
319
+ for name, text in sections.items():
320
+ if name in ("User Notes", "C3 — Token-Saving Workflow (MUST FOLLOW)"):
321
+ deduped_sections[name] = text
322
+ continue
323
+ new_lines = []
324
+ for line in text.split('\n'):
325
+ stripped = line.strip()
326
+ if not stripped or stripped.startswith('#'):
327
+ new_lines.append(line)
328
+ elif stripped not in seen_lines:
329
+ seen_lines.add(stripped)
330
+ new_lines.append(line)
331
+ deduped_sections[name] = '\n'.join(new_lines)
332
+ dup_removed = sum(
333
+ len(sections[k].split('\n')) - len(deduped_sections[k].split('\n'))
334
+ for k in sections
335
+ )
336
+ if dup_removed > 0:
337
+ actions.append(f"Removed {dup_removed} duplicate lines")
338
+ sections = deduped_sections
339
+
340
+ # Step 3: Prune structure tree depth if still over target
341
+ content = self._reassemble_sections(sections)
342
+ current_lines = len(content.split('\n'))
343
+ if current_lines > target_lines and "Project Context (Auto-generated by C3)" in sections:
344
+ ctx_section = sections["Project Context (Auto-generated by C3)"]
345
+ pruned = self._prune_structure_depth(ctx_section, max_depth=2)
346
+ if len(pruned.split('\n')) < len(ctx_section.split('\n')):
347
+ sections["Project Context (Auto-generated by C3)"] = pruned
348
+ actions.append("Reduced project structure tree depth")
349
+
350
+ # Reassemble
351
+ content = self._reassemble_sections(sections)
352
+ compacted_metrics = self._count_metrics(content)
353
+
354
+ if not actions:
355
+ actions.append("No compaction opportunities found.")
356
+
357
+ return {
358
+ "content": content,
359
+ "original_lines": original_metrics["lines"],
360
+ "compacted_lines": compacted_metrics["lines"],
361
+ "original_tokens": original_metrics["tokens"],
362
+ "compacted_tokens": compacted_metrics["tokens"],
363
+ "actions": actions,
364
+ }
365
+
366
+ def get_promotion_candidates(self, min_relevance: int = 2) -> dict:
367
+ """Find facts and patterns worth promoting into CLAUDE.md."""
368
+ current = self._read_current()
369
+ current_text = current or ""
370
+ candidates = {
371
+ "Code Patterns & Conventions": [],
372
+ "Quick Reference Shortcuts": [],
373
+ "Key Files": [],
374
+ "Project Roadmap & Active Plans": [],
375
+ }
376
+
377
+ # High-relevance facts
378
+ for fact in self.memory.facts:
379
+ if fact.get("relevance_count", 0) < min_relevance:
380
+ continue
381
+ # Skip if already in CLAUDE.md
382
+ if fact["fact"] in current_text:
383
+ continue
384
+
385
+ category = fact.get("category", "general")
386
+ target = "Code Patterns & Conventions"
387
+ if category in ("shortcut", "reference", "alias"):
388
+ target = "Quick Reference Shortcuts"
389
+ elif category in ("file", "path", "entry_point"):
390
+ target = "Key Files"
391
+ elif category in ("plan", "roadmap", "todo"):
392
+ target = "Project Roadmap & Active Plans"
393
+
394
+ candidates[target].append({
395
+ "fact": fact["fact"],
396
+ "category": category,
397
+ "relevance_count": fact["relevance_count"],
398
+ "snippet": f"- [{category}] {fact['fact']}",
399
+ })
400
+
401
+ # Recurring decisions and plans from sessions
402
+ session_dir = self.project_path / ".c3" / "sessions"
403
+ if session_dir.exists():
404
+ decision_keywords = {} # keyword -> [session_ids]
405
+ active_plans = [] # List of unique plan strings
406
+ for sf in sorted(session_dir.glob("session_*.json"), reverse=True)[:20]:
407
+ try:
408
+ with open(sf, encoding='utf-8') as f:
409
+ s = json.load(f)
410
+ sid = s.get("id", "unknown")
411
+ for d in s.get("decisions", []):
412
+ text = d.get("decision", "")
413
+ # Plan detection
414
+ if "PLAN:" in text.upper():
415
+ plan_text = text.split("PLAN:", 1)[1].strip()
416
+ if plan_text and not any(p["fact"] == plan_text for p in active_plans):
417
+ active_plans.append({
418
+ "fact": plan_text,
419
+ "category": "active_plan",
420
+ "relevance_count": 1,
421
+ "snippet": f"- [PLAN] {plan_text}"
422
+ })
423
+
424
+ # Decision keyword extraction (5+ chars)
425
+ words = set(re.findall(r'[a-zA-Z]{5,}', text.lower()))
426
+ for w in words:
427
+ if w not in decision_keywords:
428
+ decision_keywords[w] = []
429
+ if sid not in decision_keywords[w]:
430
+ decision_keywords[w].append(sid)
431
+ except Exception:
432
+ continue
433
+
434
+ # Add unique plans to roadmap
435
+ for p in active_plans:
436
+ if p["fact"] not in current_text:
437
+ candidates["Project Roadmap & Active Plans"].append(p)
438
+
439
+ # Keywords appearing in 2+ sessions
440
+ recurring = {k: v for k, v in decision_keywords.items() if len(v) >= 2}
441
+ for keyword, session_ids in sorted(recurring.items(), key=lambda x: -len(x[1]))[:5]:
442
+ snippet = f"- Recurring decision keyword: \"{keyword}\" (across {len(session_ids)} sessions)"
443
+ if snippet not in current_text:
444
+ candidates["Code Patterns & Conventions"].append({
445
+ "fact": f"Recurring decision keyword: \"{keyword}\"",
446
+ "category": "recurring_decision",
447
+ "relevance_count": len(session_ids),
448
+ "snippet": snippet,
449
+ })
450
+
451
+ # Filter out empty groups
452
+ candidates = {k: v for k, v in candidates.items() if v}
453
+
454
+ total = sum(len(v) for v in candidates.values())
455
+ return {
456
+ "total_candidates": total,
457
+ "candidates": candidates,
458
+ "message": (
459
+ f"Found {total} promotion candidates across {len(candidates)} sections."
460
+ if total > 0
461
+ else "No promotion candidates found. Build more session history and facts first."
462
+ ),
463
+ }
464
+
465
+ # ── Shared helpers ───────────────────────────────────────
466
+
467
+ def _read_current(self) -> Optional[str]:
468
+ """Read existing instructions file from project root."""
469
+ path = self.project_path / self.instructions_file
470
+ if not path.exists():
471
+ return None
472
+ try:
473
+ return path.read_text(encoding="utf-8")
474
+ except Exception:
475
+ return None
476
+
477
+ def _parse_sections(self, content: str) -> dict:
478
+ """Split CLAUDE.md into named sections by # or ## headers."""
479
+ sections = {}
480
+ current_name = "_preamble"
481
+ current_lines = []
482
+
483
+ for line in content.split('\n'):
484
+ header_match = re.match(r'^(#{1,3})\s+(.+)', line)
485
+ if header_match:
486
+ # Save previous section
487
+ if current_lines or current_name != "_preamble":
488
+ sections[current_name] = '\n'.join(current_lines)
489
+ current_name = header_match.group(2).strip()
490
+ current_lines = []
491
+ else:
492
+ current_lines.append(line)
493
+
494
+ # Save last section
495
+ if current_lines or current_name != "_preamble":
496
+ sections[current_name] = '\n'.join(current_lines)
497
+
498
+ return sections
499
+
500
+ def _reassemble_sections(self, sections: dict) -> str:
501
+ """Reassemble sections into CLAUDE.md content."""
502
+ parts = []
503
+ for name, body in sections.items():
504
+ if name == "_preamble":
505
+ if body.strip():
506
+ parts.append(body)
507
+ else:
508
+ # Determine header level from body context (default ##)
509
+ level = "#"
510
+ if name in ("Project Context (Auto-generated by C3)",
511
+ "Session History (Compressed)", "User Notes"):
512
+ level = "#"
513
+ else:
514
+ level = "##"
515
+ parts.append(f"{level} {name}\n{body}")
516
+ return '\n\n'.join(parts)
517
+
518
+ def _count_metrics(self, content: str) -> dict:
519
+ """Count lines and tokens."""
520
+ lines = len(content.split('\n'))
521
+ tokens = count_tokens(content)
522
+ return {"lines": lines, "tokens": tokens}
523
+
524
+ # ── Generate helpers ─────────────────────────────────────
525
+
526
+ def _detect_enhanced_patterns(self) -> list:
527
+ """Detect patterns beyond what SessionManager finds — linting, test frameworks, monorepo."""
528
+ patterns = []
529
+ p = self.project_path
530
+
531
+ # Base patterns from session manager
532
+ base = self.session_mgr._detect_patterns()
533
+ if base and base != "No patterns auto-detected":
534
+ for line in base.split('\n'):
535
+ line = line.strip().lstrip('- ')
536
+ if line:
537
+ patterns.append(line)
538
+
539
+ # Linting / formatting
540
+ linting_indicators = {
541
+ ".eslintrc": "ESLint", ".eslintrc.js": "ESLint", ".eslintrc.json": "ESLint",
542
+ ".eslintrc.yml": "ESLint", "eslint.config.js": "ESLint (flat config)",
543
+ ".prettierrc": "Prettier", ".prettierrc.json": "Prettier",
544
+ "prettier.config.js": "Prettier",
545
+ ".flake8": "Flake8", "setup.cfg": "Python config (setup.cfg)",
546
+ "ruff.toml": "Ruff", ".ruff.toml": "Ruff",
547
+ ".stylelintrc": "Stylelint",
548
+ "biome.json": "Biome",
549
+ }
550
+ for filename, tool in linting_indicators.items():
551
+ if (p / filename).exists():
552
+ patterns.append(f"Uses {tool}")
553
+
554
+ # Check pyproject.toml for tool configs
555
+ pyproject = p / "pyproject.toml"
556
+ if pyproject.exists():
557
+ try:
558
+ text = pyproject.read_text(encoding="utf-8")
559
+ if "[tool.ruff" in text:
560
+ patterns.append("Uses Ruff (via pyproject.toml)")
561
+ if "[tool.black" in text:
562
+ patterns.append("Uses Black formatter")
563
+ if "[tool.pytest" in text or "[tool.pytest.ini_options" in text:
564
+ patterns.append("Uses pytest")
565
+ if "[tool.mypy" in text:
566
+ patterns.append("Uses mypy type checking")
567
+ except Exception:
568
+ pass
569
+
570
+ # Test frameworks
571
+ if (p / "jest.config.js").exists() or (p / "jest.config.ts").exists():
572
+ patterns.append("Uses Jest for testing")
573
+ if (p / "vitest.config.ts").exists() or (p / "vitest.config.js").exists():
574
+ patterns.append("Uses Vitest for testing")
575
+ if (p / "pytest.ini").exists() or (p / "conftest.py").exists():
576
+ patterns.append("Uses pytest")
577
+
578
+ # Monorepo indicators
579
+ if (p / "lerna.json").exists():
580
+ patterns.append("Monorepo (Lerna)")
581
+ if (p / "pnpm-workspace.yaml").exists():
582
+ patterns.append("Monorepo (pnpm workspaces)")
583
+ if (p / "turbo.json").exists():
584
+ patterns.append("Monorepo (Turborepo)")
585
+ pkg = p / "package.json"
586
+ if pkg.exists():
587
+ try:
588
+ with open(pkg, encoding='utf-8') as f:
589
+ data = json.load(f)
590
+ if "workspaces" in data:
591
+ patterns.append("Monorepo (npm/yarn workspaces)")
592
+ except Exception:
593
+ pass
594
+
595
+ # Deduplicate
596
+ seen = set()
597
+ unique = []
598
+ for pat in patterns:
599
+ key = pat.lower()
600
+ if key not in seen:
601
+ seen.add(key)
602
+ unique.append(pat)
603
+
604
+ return unique
605
+
606
+ def _detect_key_files(self) -> list:
607
+ """Identify key files from session history and conventional entry points."""
608
+ key_files = []
609
+ seen = set()
610
+
611
+ # Hot files from session history
612
+ session_dir = self.project_path / ".c3" / "sessions"
613
+ if session_dir.exists():
614
+ file_counts = {}
615
+ for sf in sorted(session_dir.glob("session_*.json"), reverse=True)[:20]:
616
+ try:
617
+ with open(sf, encoding='utf-8') as f:
618
+ s = json.load(f)
619
+ for ft in s.get("files_touched", []):
620
+ fname = ft.get("file", "")
621
+ if fname:
622
+ file_counts[fname] = file_counts.get(fname, 0) + 1
623
+ except Exception:
624
+ continue
625
+
626
+ for fname, count in sorted(file_counts.items(), key=lambda x: -x[1])[:5]:
627
+ if count >= 2 and fname not in seen:
628
+ key_files.append({"file": fname, "reason": f"edited in {count} sessions"})
629
+ seen.add(fname)
630
+
631
+ # Conventional entry points
632
+ entry_points = [
633
+ ("main.py", "Python entry point"),
634
+ ("app.py", "Application entry point"),
635
+ ("index.ts", "TypeScript entry point"),
636
+ ("index.js", "JavaScript entry point"),
637
+ ("src/index.ts", "Source entry point"),
638
+ ("src/index.js", "Source entry point"),
639
+ ("src/main.ts", "Source entry point"),
640
+ ("src/App.tsx", "React app root"),
641
+ ("cli/mcp_server.py", "MCP server entry"),
642
+ ]
643
+ for filepath, reason in entry_points:
644
+ if (self.project_path / filepath).exists() and filepath not in seen:
645
+ key_files.append({"file": filepath, "reason": reason})
646
+ seen.add(filepath)
647
+
648
+ return key_files
649
+
650
+ # ── Check helpers ────────────────────────────────────────
651
+
652
+ def _diff_structure(self, current_content: str) -> list:
653
+ """Find dirs mentioned in CLAUDE.md that don't exist, and new dirs not mentioned."""
654
+ issues = []
655
+
656
+ # Extract dir-like references from the code block
657
+ mentioned_dirs = set()
658
+ in_code_block = False
659
+ for line in current_content.split('\n'):
660
+ if line.strip().startswith('```'):
661
+ in_code_block = not in_code_block
662
+ continue
663
+ if in_code_block and line.strip().endswith('/'):
664
+ dirname = line.strip().rstrip('/')
665
+ if dirname:
666
+ mentioned_dirs.add(dirname)
667
+
668
+ # Scan actual top-level dirs
669
+ skip = {'node_modules', '.git', '__pycache__', '.c3', 'venv',
670
+ 'env', '.venv', 'dist', 'build', '.next', '.cache', '.claude'}
671
+ actual_dirs = set()
672
+ for item in self.project_path.iterdir():
673
+ if item.is_dir() and item.name not in skip and not item.name.startswith('.'):
674
+ actual_dirs.add(item.name)
675
+
676
+ # Compare (use base names only)
677
+ mentioned_basenames = {d.split('/')[-1] for d in mentioned_dirs if d}
678
+
679
+ missing_in_fs = mentioned_basenames - actual_dirs
680
+ new_in_fs = actual_dirs - mentioned_basenames
681
+
682
+ for d in missing_in_fs:
683
+ # Skip the project root name
684
+ if d == self.project_path.name:
685
+ continue
686
+ issues.append({
687
+ "severity": "warning",
688
+ "message": f"Directory '{d}' mentioned in CLAUDE.md but not found on disk.",
689
+ })
690
+
691
+ for d in new_in_fs:
692
+ issues.append({
693
+ "severity": "info",
694
+ "message": f"New directory '{d}' exists but is not in CLAUDE.md.",
695
+ })
696
+
697
+ return issues
698
+
699
+ def _diff_tech_stack(self, current_content: str) -> list:
700
+ """Compare tech stack in CLAUDE.md vs detected."""
701
+ issues = []
702
+ detected = self.session_mgr._detect_tech_stack()
703
+
704
+ if detected == "Could not auto-detect":
705
+ return issues
706
+
707
+ detected_set = {t.strip().lower() for t in detected.split(',')}
708
+
709
+ # Find the tech stack line in CLAUDE.md
710
+ sections = self._parse_sections(current_content)
711
+ claimed_text = sections.get("Tech Stack", "")
712
+ claimed_set = set()
713
+ for line in claimed_text.split('\n'):
714
+ line = line.strip().lstrip('- ')
715
+ if line:
716
+ for item in line.split(','):
717
+ item = item.strip().lower()
718
+ if item:
719
+ claimed_set.add(item)
720
+
721
+ new_tech = detected_set - claimed_set
722
+ for tech in new_tech:
723
+ issues.append({
724
+ "severity": "warning",
725
+ "message": f"Detected '{tech}' in project but not listed in CLAUDE.md Tech Stack.",
726
+ })
727
+
728
+ return issues
729
+
730
+ # ── Compact helpers ──────────────────────────────────────
731
+
732
+ def _compress_sessions(self, session_text: str, max_sessions: int = 3) -> str:
733
+ """Trim session history to last N sessions with one-line summaries."""
734
+ # Split into individual session blocks (## Session: ...)
735
+ blocks = re.split(r'(?=## Session:)', session_text)
736
+ blocks = [b.strip() for b in blocks if b.strip()]
737
+
738
+ if len(blocks) <= max_sessions:
739
+ return session_text
740
+
741
+ # Keep only last max_sessions, compress each to one line
742
+ kept = blocks[:max_sessions]
743
+ compressed_lines = []
744
+ for block in kept:
745
+ lines = block.split('\n')
746
+ header = lines[0] if lines else ""
747
+ # Extract summary if present
748
+ summary = ""
749
+ for line in lines[1:]:
750
+ if line.startswith("**Summary:**"):
751
+ summary = line.replace("**Summary:**", "").strip()
752
+ break
753
+ elif line.startswith("**When:**"):
754
+ date = line.replace("**When:**", "").strip()
755
+ summary = f"({date}) {summary}"
756
+ if summary:
757
+ compressed_lines.append(f"{header}\n**Summary:** {summary}\n")
758
+ else:
759
+ compressed_lines.append(f"{header}\n")
760
+
761
+ return '\n'.join(compressed_lines)
762
+
763
+ def _prune_structure_depth(self, section_text: str, max_depth: int = 2) -> str:
764
+ """Reduce project structure tree depth."""
765
+ lines = section_text.split('\n')
766
+ pruned = []
767
+ in_code_block = False
768
+
769
+ for line in lines:
770
+ if line.strip().startswith('```'):
771
+ in_code_block = not in_code_block
772
+ pruned.append(line)
773
+ continue
774
+
775
+ if in_code_block:
776
+ # Count indent level (2 spaces per level)
777
+ stripped = line.lstrip()
778
+ indent = len(line) - len(stripped)
779
+ depth = indent // 2
780
+ if depth <= max_depth:
781
+ pruned.append(line)
782
+ else:
783
+ pruned.append(line)
784
+
785
+ return '\n'.join(pruned)