code-context-control 2.36.0__py3-none-any.whl → 2.37.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/c3.py CHANGED
@@ -85,7 +85,7 @@ console = Console() if HAS_RICH else None
85
85
  # Config
86
86
  CONFIG_DIR = ".c3"
87
87
  CONFIG_FILE = ".c3/config.json"
88
- __version__ = "2.36.0"
88
+ __version__ = "2.37.0"
89
89
 
90
90
 
91
91
  def _command_deps() -> CommandDeps:
@@ -379,6 +379,44 @@ def _build_permission_tier(tier: str, include_mcp_wildcard: bool = False) -> dic
379
379
  }}
380
380
 
381
381
 
382
+ def _c3_managed_permission_entries() -> tuple[set, set]:
383
+ """Return (allow, deny) sets of every entry any C3 tier can emit.
384
+
385
+ Used to tell C3-managed permission rules apart from user-added ones so a
386
+ tier change replaces only the former and preserves the latter.
387
+ """
388
+ managed_allow: set = set()
389
+ managed_deny: set = set()
390
+ for _tier in PERMISSION_TIERS:
391
+ perms = _build_permission_tier(_tier, include_mcp_wildcard=True)["permissions"]
392
+ managed_allow.update(perms.get("allow", []))
393
+ managed_deny.update(perms.get("deny", []))
394
+ return managed_allow, managed_deny
395
+
396
+
397
+ def _merge_permission_tier(existing: dict, tier_perms: dict) -> dict:
398
+ """Merge a tier's permissions into existing ones, preserving user rules.
399
+
400
+ C3 owns every entry a tier can emit: those are replaced by the chosen tier.
401
+ Any other allow/deny entry the user added is kept, and non-list permission
402
+ keys (e.g. ``ask``, ``defaultMode``, ``additionalDirectories``) are left
403
+ untouched. Mirrors how hooks and .mcp.json preserve non-C3 content.
404
+ """
405
+ existing = existing if isinstance(existing, dict) else {}
406
+ managed = dict(zip(("allow", "deny"), _c3_managed_permission_entries()))
407
+ merged = dict(existing) # preserve unknown sub-keys (ask, defaultMode, ...)
408
+ for key in ("allow", "deny"):
409
+ user_custom = [e for e in (existing.get(key) or []) if e not in managed[key]]
410
+ out: list = []
411
+ seen: set = set()
412
+ for entry in user_custom + list(tier_perms.get(key) or []):
413
+ if entry not in seen:
414
+ seen.add(entry)
415
+ out.append(entry)
416
+ merged[key] = out
417
+ return merged
418
+
419
+
382
420
  def _detect_current_tier(settings_path) -> str | None:
383
421
  """Detect which permission tier is active in settings_path, or None.
384
422
 
@@ -456,9 +494,12 @@ def _apply_permission_tier(project_path: str, tier: str,
456
494
  settings_path = Path(project_path) / ".claude" / "settings.local.json"
457
495
  settings_path.parent.mkdir(parents=True, exist_ok=True)
458
496
  settings = _safe_read_json(settings_path, str(settings_path))
459
- settings["permissions"] = _build_permission_tier(
497
+ tier_perms = _build_permission_tier(
460
498
  tier, include_mcp_wildcard=include_mcp_wildcard
461
499
  )["permissions"]
500
+ settings["permissions"] = _merge_permission_tier(
501
+ settings.get("permissions") or {}, tier_perms
502
+ )
462
503
  # Persist chosen tier in .c3/config.json
463
504
  c3_config_path = Path(project_path) / ".c3" / "config.json"
464
505
  c3_config = _safe_read_json(c3_config_path, str(c3_config_path))
@@ -5139,10 +5180,23 @@ def cmd_install_mcp(args):
5139
5180
  },
5140
5181
  ]
5141
5182
  stop_event = "Stop"
5142
- # Replace any existing C3 stop hooks (matcher=""), keep user-added ones
5183
+ # Replace only C3's own stop hooks (identified by our hook scripts) and
5184
+ # keep every user-added stop hook — including matcher-less ones, which
5185
+ # are the normal shape for Stop hooks.
5186
+ _c3_stop_scripts = (
5187
+ "hook_session_stats.py", "hook_auto_snapshot.py", "hook_terse_advisor.py",
5188
+ )
5189
+
5190
+ def _is_c3_stop_hook(entry: dict) -> bool:
5191
+ return any(
5192
+ script in (hk.get("command") or "")
5193
+ for hk in entry.get("hooks", [])
5194
+ for script in _c3_stop_scripts
5195
+ )
5196
+
5143
5197
  existing_stop = [
5144
5198
  h for h in settings.get("hooks", {}).get(stop_event, [])
5145
- if h.get("matcher") # keep entries with a non-empty matcher
5199
+ if not _is_c3_stop_hook(h)
5146
5200
  ]
5147
5201
  existing_stop.extend(desired_stop_hooks)
5148
5202
  settings.setdefault("hooks", {})[stop_event] = existing_stop
@@ -5160,9 +5214,12 @@ def cmd_install_mcp(args):
5160
5214
  include_wildcard = bool(getattr(args, "include_mcp_wildcard", False))
5161
5215
  if perm_tier and profile.name == "claude-code":
5162
5216
  if perm_tier in PERMISSION_TIERS:
5163
- settings["permissions"] = _build_permission_tier(
5164
- perm_tier, include_mcp_wildcard=include_wildcard
5165
- )["permissions"]
5217
+ settings["permissions"] = _merge_permission_tier(
5218
+ settings.get("permissions") or {},
5219
+ _build_permission_tier(
5220
+ perm_tier, include_mcp_wildcard=include_wildcard
5221
+ )["permissions"],
5222
+ )
5166
5223
  # Persist tier choice in .c3/config.json
5167
5224
  _c3cfg = _safe_read_json(c3_config_path, str(c3_config_path))
5168
5225
  _c3cfg["permission_tier"] = perm_tier
cli/commands/common.py CHANGED
@@ -200,13 +200,9 @@ def cmd_claudemd(args, deps: CommandDeps):
200
200
  print(content)
201
201
  else:
202
202
  output_path = Path(project_path) / instructions_file
203
- output_path.parent.mkdir(parents=True, exist_ok=True)
204
- if output_path.exists():
205
- existing = output_path.read_text(encoding="utf-8", errors="replace")
206
- if "# User Notes" in existing:
207
- user_section = existing[existing.index("# User Notes"):]
208
- content += f"\n\n{user_section}"
209
- output_path.write_text(content, encoding="utf-8")
203
+ # Wrap in the C3 managed block; preserve user content outside it.
204
+ from services.claude_md import write_c3_instruction_doc
205
+ write_c3_instruction_doc(output_path, content)
210
206
  print(f"{instructions_file} saved to {output_path} ({tokens} tokens)")
211
207
 
212
208
  elif args.claudemd_cmd == "check":
@@ -188,6 +188,14 @@ c3 init</code></pre>
188
188
 
189
189
  <h3>Re-init / upgrade</h3>
190
190
  <p>Running <code>c3 init</code> on an existing project is safe — it merges new config without overwriting your customizations.</p>
191
+
192
+ <div class="callout callout-info">
193
+ <span class="callout-icon">🛡️</span>
194
+ <div class="callout-body">
195
+ <strong>Your hand-written content is preserved</strong>
196
+ Generated instruction files (<code>CLAUDE.md</code>, <code>AGENTS.md</code>, <code>GEMINI.md</code>) wrap C3 content in a <code>&lt;!-- C3:BEGIN … --&gt;</code> / <code>&lt;!-- C3:END --&gt;</code> block. Re-running init (or <code>c3 claudemd save</code> / the <strong>Compact</strong> action) rewrites only that block — anything you add outside it stays put. An existing hand-written file with no block is kept and the C3 block is appended below it.
197
+ </div>
198
+ </div>
191
199
  </section>
192
200
 
193
201
  <hr class="divider">
@@ -200,7 +208,7 @@ c3 init</code></pre>
200
208
 
201
209
  <h3>Claude Code (primary)</h3>
202
210
  <pre><code>c3 install-mcp claude</code></pre>
203
- <p>This writes to <code>.mcp.json</code> (project scope) and optionally configures PreToolUse / PostToolUse hooks in <code>.claude/settings.local.json</code>.</p>
211
+ <p>This writes to <code>.mcp.json</code> (project scope) and optionally configures PreToolUse / PostToolUse hooks in <code>.claude/settings.local.json</code>. Both are merged, not overwritten: C3 only touches its own <code>c3</code> server entry and its own hooks, leaving any other MCP servers, hooks, and top-level keys you've added in place.</p>
204
212
 
205
213
  <h3>VS Code Copilot</h3>
206
214
  <pre><code>c3 install-mcp vscode</code></pre>
@@ -358,6 +366,14 @@ c3_session(action='snapshot') <span class="com"># before /clear</span></code><
358
366
  The <code>standard</code> tier blocks destructive shell commands (rm -rf, git reset --hard, etc.) while allowing safe operations. Upgrade to <code>unrestricted</code> only when needed.
359
367
  </div>
360
368
  </div>
369
+
370
+ <div class="callout callout-info">
371
+ <span class="callout-icon">🛡️</span>
372
+ <div class="callout-body">
373
+ <strong>Your custom rules survive a tier change</strong>
374
+ Applying or switching a tier preserves <code>allow</code>/<code>deny</code> entries you added yourself, plus keys like <code>ask</code> and <code>defaultMode</code>. C3 only replaces the entries it manages, so you can mix a tier with project-specific permissions.
375
+ </div>
376
+ </div>
361
377
  </section>
362
378
 
363
379
  <hr class="divider">
cli/hub_server.py CHANGED
@@ -1206,7 +1206,7 @@ def api_projects_permissions_get():
1206
1206
  @app.route("/api/projects/permissions/apply", methods=["POST"])
1207
1207
  def api_projects_permissions_put():
1208
1208
  """Apply permission tier to a project. Body: {path, tier}"""
1209
- from cli.c3 import PERMISSION_TIERS, _build_permission_tier
1209
+ from cli.c3 import PERMISSION_TIERS, _build_permission_tier, _merge_permission_tier
1210
1210
  data = request.get_json(force=True) or {}
1211
1211
  path = (data.get("path") or "").strip()
1212
1212
  tier = (data.get("tier") or "").strip()
@@ -1228,7 +1228,9 @@ def api_projects_permissions_put():
1228
1228
  settings = json.load(f)
1229
1229
  except Exception:
1230
1230
  pass
1231
- settings["permissions"] = tier_perms["permissions"]
1231
+ settings["permissions"] = _merge_permission_tier(
1232
+ settings.get("permissions") or {}, tier_perms["permissions"]
1233
+ )
1232
1234
  with open(settings_path, "w", encoding="utf-8") as f:
1233
1235
  json.dump(settings, f, indent=2)
1234
1236
 
cli/server.py CHANGED
@@ -1121,16 +1121,11 @@ def api_claudemd_save():
1121
1121
  return jsonify({"error": "Generation produced empty content"}), 500
1122
1122
 
1123
1123
  output_path = PROJECT_PATH / claude_md_mgr.instructions_file
1124
- output_path.parent.mkdir(parents=True, exist_ok=True)
1125
1124
 
1126
- # Preserve user-written sections
1127
- if output_path.exists():
1128
- existing = output_path.read_text(encoding="utf-8", errors="replace")
1129
- if "# User Notes" in existing:
1130
- user_section = existing[existing.index("# User Notes"):]
1131
- content += f"\n\n{user_section}"
1125
+ # Wrap in the C3 managed block; never clobber user content outside it.
1126
+ from services.claude_md import write_c3_instruction_doc
1127
+ write_c3_instruction_doc(output_path, content)
1132
1128
 
1133
- output_path.write_text(content, encoding="utf-8")
1134
1129
  return jsonify({
1135
1130
  "path": str(output_path),
1136
1131
  "tokens": gen.get("tokens", 0),
@@ -2021,7 +2016,12 @@ def api_permissions_get():
2021
2016
  @app.route('/api/permissions', methods=['PUT'])
2022
2017
  def api_permissions_put():
2023
2018
  """Apply a permission tier. Body: {tier: "read-only"|"standard"|"permissive"}"""
2024
- from cli.c3 import PERMISSION_TIERS, _build_permission_tier, _safe_read_json
2019
+ from cli.c3 import (
2020
+ PERMISSION_TIERS,
2021
+ _build_permission_tier,
2022
+ _merge_permission_tier,
2023
+ _safe_read_json,
2024
+ )
2025
2025
  data = request.get_json() or {}
2026
2026
  tier = data.get("tier", "").strip()
2027
2027
  if tier not in PERMISSION_TIERS:
@@ -2031,7 +2031,9 @@ def api_permissions_put():
2031
2031
  settings_path = PROJECT_PATH / ".claude" / "settings.local.json"
2032
2032
  settings_path.parent.mkdir(parents=True, exist_ok=True)
2033
2033
  settings = _safe_read_json(settings_path, str(settings_path))
2034
- settings["permissions"] = tier_perms["permissions"]
2034
+ settings["permissions"] = _merge_permission_tier(
2035
+ settings.get("permissions") or {}, tier_perms["permissions"]
2036
+ )
2035
2037
  with open(settings_path, "w", encoding="utf-8") as f:
2036
2038
  json.dump(settings, f, indent=2)
2037
2039
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: code-context-control
3
- Version: 2.36.0
3
+ Version: 2.37.0
4
4
  Summary: Local code-intelligence layer for AI coding tools (Claude Code, Codex, Gemini, Copilot). Retrieve less, read less, edit safer.
5
5
  Author-email: Dimitri Tselenchuk <dtselenc@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -231,6 +231,8 @@ Every session you've ever run, with duration, decision count, file count, tool c
231
231
 
232
232
  Manage `CLAUDE.md`, `AGENTS.md` (Codex), `GEMINI.md`, and `.github/copilot-instructions.md` from one editor. Generate from project state, run a Health Check (drift detection vs the actual codebase), Compact stale sections, or Promote insights captured during sessions. **One source of truth** instead of four out-of-sync files.
233
233
 
234
+ C3-generated content is wrapped in a `<!-- C3:BEGIN … -->` / `<!-- C3:END -->` block. Regenerating (or `Compact`) only rewrites that block — **anything you write outside it is preserved**, so it's safe to keep your own notes in the same file.
235
+
234
236
  ### 7. Chat — browse prior AI conversations
235
237
 
236
238
  <p align="center">
@@ -370,6 +372,8 @@ c3 permissions show
370
372
  c3 permissions standard
371
373
  ```
372
374
 
375
+ Applying or switching a tier **preserves your own `allow`/`deny` rules** (and keys like `ask`/`defaultMode`) — only C3-managed entries are replaced. Likewise, C3 never clobbers your other entries in `.mcp.json` (only its own `c3` server) or the hooks you've added to `settings.local.json` (only its own hooks).
376
+
373
377
  ---
374
378
 
375
379
  ## Benchmarks
@@ -1,6 +1,6 @@
1
1
  cli/__init__.py,sha256=ec66drCZGNMRU4V6ov0zVhYZph1us12Vn8OvG_LJyRY,22
2
2
  cli/_hook_utils.py,sha256=1_hTA-Wz62xB8jnSAH4C5TfCkrwEP0g2kq_-oRfQLm4,3724
3
- cli/c3.py,sha256=wNzXp_GKTIenTJqgxmEL9kFRG8ukoyE0BHPVK1YPH2w,295249
3
+ cli/c3.py,sha256=X9HResiGmUELIVlz6uosit35OSCCKeJwuwr02gzuGBM,297611
4
4
  cli/docs.html,sha256=JgtBFUuUkvmYowPREYiGhhcRbB5e2UjkRc00MIF0hsU,143653
5
5
  cli/edits.html,sha256=UjAhoCmBmQ89cklGvJqzC6eyNP2tc8H6T-e01DVkLvE,43418
6
6
  cli/hook_auto_snapshot.py,sha256=amtliVDzKUQr6KBR0pdBA8vXghAV-gKr19jBaJVnP_w,5006
@@ -15,18 +15,18 @@ cli/hook_read.py,sha256=M5l_SU899O72tZe3j4YQJJKNb1-xulvKOj8XZjJzwYU,8021
15
15
  cli/hook_session_stats.py,sha256=a1OKi9kmiXRI2qieY_Uq14xRxdXQTQu9WVzDTUlI0GQ,1897
16
16
  cli/hook_terse_advisor.py,sha256=pD7Bap7OYOKqtYz7cX8nWSRLH7ook-tSD2Ov2MNp_sA,5907
17
17
  cli/hub.html,sha256=Hl-XPZGT1mMiKrbX9c5OsEw6mXEumwIB3vp1WlWaplM,183966
18
- cli/hub_server.py,sha256=b1O3hTDd051cBG15xU3wKWGiyNC1SWIFlndpVeKIBw4,60110
18
+ cli/hub_server.py,sha256=Zo---712ynP83XWB_aZb45b9XcQmzs9EGp8wwyRIGLU,60207
19
19
  cli/mcp_proxy.py,sha256=92htuT-p0j-cDTbyqlIJpGoQ85_Aw7UuB8L_Toi_u20,17511
20
20
  cli/mcp_server.py,sha256=iYUB6rfGjNuiNQP-GecuXyMVa1CEihtu4dV7PhRHWqg,32549
21
- cli/server.py,sha256=PkaUwLXPUOAjAmnYQuazMquqQ-qmnDuF1WO-1aHNgg4,120376
21
+ cli/server.py,sha256=tOgLZvB_i7RluK3qwORqhrhTz1DVL3VVAIWu9f55ysg,120292
22
22
  cli/ui.html,sha256=xcdt74nlFEXx-0Bx6-Okw-WSVZPAXL0iukxU0ytI6CA,5694
23
23
  cli/ui_legacy.html,sha256=cI8tC6RKmE2NIJOcsu7CY-zT4VznjcbD6NTjxb_fvUY,378460
24
24
  cli/ui_nano.html,sha256=UAwQ6bbTOXAoGq191AZ7slhngR9edJSa3IhqpynveDg,27740
25
25
  cli/commands/__init__.py,sha256=0Z8MABNzwSFJGT4Xv9R5AJVR8XxraTsuVTz5b0bShmo,38
26
- cli/commands/common.py,sha256=3fXTjFCIo7q-Tr6KtxfjuLPu-Apl7PeS5mN9B5-bqkc,11494
26
+ cli/commands/common.py,sha256=8_uYFWcJBXMscPmvQEMYOwYXZidwD7Of9j9fiD00g6Q,11272
27
27
  cli/commands/parser.py,sha256=unu8jkTocf3AoOSiEAlw_qta_2K7sQqg9BmluTzOKO4,22968
28
28
  cli/guide/bitbucket.html,sha256=5HrLDm6Ue-AJZ81bqWaSp2nSfxaRdEjHFY29P7plLXA,33350
29
- cli/guide/getting-started.html,sha256=t1CNa1nVcuUrinuTYJAVLmKhJFlDtO1-DjimWnABTgE,17461
29
+ cli/guide/getting-started.html,sha256=F3AaF2HVfeg6eKO25p_vPogr_i1hvkjIZVggIJFWTl0,18829
30
30
  cli/guide/index.html,sha256=sAk4Z7Xrlw7_F_Gbxn3lhZeT7hydrp1aENhMQ_9msgU,19861
31
31
  cli/guide/oracle.html,sha256=-5RtRTLV0JUVerjIayEftQdcqg8j_-X0ail8lVVJfOE,21897
32
32
  cli/guide/shared.css,sha256=Mmm7W6aYxrkDq2RPOcyXiQFYGR-yB3auwX1uIWi6C74,15967
@@ -64,7 +64,7 @@ cli/ui/components/memory.js,sha256=v5IsHTxLHpXX4xCsUaZ_UPprZEabdgP4jiWc298iV2U,2
64
64
  cli/ui/components/sessions.js,sha256=FIKtil76B8tCkAmcFV7hlj6GQ_DCJK2jCzvEmdK7NBE,30837
65
65
  cli/ui/components/settings.js,sha256=8LVTV2TQl9tcRXhXbtBEJOCBdiyk-x2QASoVYZUAuEA,71442
66
66
  cli/ui/components/sidebar.js,sha256=cAY_jwYB-o1X_wWn__VXlG4IegVObuE3NmVsuFWqxtg,7417
67
- code_context_control-2.36.0.dist-info/licenses/LICENSE,sha256=l8Kh5QCNWNvR6kIt8L0BUZvc2LAFiHv2c-FnsGnUZf4,11301
67
+ code_context_control-2.37.0.dist-info/licenses/LICENSE,sha256=l8Kh5QCNWNvR6kIt8L0BUZvc2LAFiHv2c-FnsGnUZf4,11301
68
68
  core/__init__.py,sha256=TSDCEcM4V7gcZVM3w2ykJaqEUch4Dkon-rivV17T73s,2501
69
69
  core/config.py,sha256=knSre16IF_6FZ-pctHWbA5mZdjCEJUF5rsdzZxWg4zU,13002
70
70
  core/ide.py,sha256=9LzsDVK2LL8RVpL40l6oNGiasZ3D8OCU_9i9A0gJKBo,6876
@@ -99,7 +99,7 @@ services/auto_memory.py,sha256=v__ZS1e68533_Yv491mZtvuZnheC63q6_uTvWhBw3Lw,14290
99
99
  services/benchmark_dashboard.py,sha256=iR-DnqnoKbqHMJ4d-ZkIvJBYfzwTa7r-jzO6j2BYDfQ,27711
100
100
  services/bitbucket_client.py,sha256=v8xGEcnIEmURvcg38XwmiCGh7-_QnjhAJEb0te_yZzQ,16107
101
101
  services/bitbucket_credentials.py,sha256=2qLA9pQMol4y95y4DJMNBsBBPUsJQCKbLFo2iiCnfvI,7364
102
- services/claude_md.py,sha256=iL0vUQw-5lxSQehNPvhlkUmcGPeMSCcZqP4OYG_qoYk,35092
102
+ services/claude_md.py,sha256=FiFDyeAQ10baRCYH74QAn-UixVi_rgn-_6yP70wCRS8,40278
103
103
  services/compressor.py,sha256=uSVyTYfvxFrRYupzyKj-HzkBP0RwARrGYFz_DnMSEaM,25169
104
104
  services/context_snapshot.py,sha256=s_klEr1SJYM9u-anMmnoemsYuIF_KUWBjz1zUo0wPgU,15662
105
105
  services/conversation_store.py,sha256=vPiMiKAE22RCBSSphgGH9Vx-lPV45SmttOwgVVWahL4,33398
@@ -132,7 +132,7 @@ services/retrieval_broker.py,sha256=9X67VZ_6AkbAzopHuuMFKmP4CGZLnW576kjSKMenBnw,
132
132
  services/router.py,sha256=Cz10nx2fKTbaGn14mSBePWIDrw5rdcs_1JFYXeik084,15626
133
133
  services/runtime.py,sha256=SOUizCDW1FFTDCoaZ1Njozjp25Bhah7lR1f0WYscaw0,11361
134
134
  services/session_benchmark.py,sha256=qw_vtDim1hvFdM8Me5EsgU9pTuJhzRjQmh6m7DDnXWY,98989
135
- services/session_manager.py,sha256=Px7RpTS6zDSuxj2O87o-7tkR8l-faMZxBX1gd5RLHfo,43837
135
+ services/session_manager.py,sha256=vWgvYGForO7W2wZzBC8QNaBQyUR2VIlVuC2wpmUqIOc,43735
136
136
  services/session_preloader.py,sha256=DsTAXMKVtrX9yu1sEFojYDi9-jkSAj1Ylt9JTy57Dow,9883
137
137
  services/text_index.py,sha256=r3o4CobTG9jAO9PWazgbWYLY9oi_FgEJ3xwEXrF4KM0,2783
138
138
  services/tool_classifier.py,sha256=Fgvq0ZcpnCskwtO8a3YI1MiecPNnw6UbPyJQIUwgfiQ,6512
@@ -163,8 +163,8 @@ tui/screens/search_view.py,sha256=MMHjVdlk3HZSuDBSvq8IGrqv_Mh5Us6YqXQ80bcWSMk,19
163
163
  tui/screens/session_view.py,sha256=eZ1eDwHTvPOck1wCCviixtOaCxIkBT_95ytNNNriGNA,5991
164
164
  tui/screens/stats.py,sha256=p81PjzdaIv7hllb8f45-rlVe4lJZwSdIMqu7e86_u5s,6223
165
165
  tui/screens/ui_view.py,sha256=1QJCgLh2YfgWIpvzRG1KOGXYEaOYX6ojN61Azjf2oX0,2125
166
- code_context_control-2.36.0.dist-info/METADATA,sha256=UlOzUNxejptWMtekvU7AguyY779ubiQabBrlJ5OKCnI,20668
167
- code_context_control-2.36.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
168
- code_context_control-2.36.0.dist-info/entry_points.txt,sha256=7kX_WUsDCF2hbXzvbNyscyaBb9AeA-DJY5v_5hN0DlU,93
169
- code_context_control-2.36.0.dist-info/top_level.txt,sha256=wRt41zBybVF3qAiNXHz9BURbkKvUvfhmWWtKMhaw6eE,29
170
- code_context_control-2.36.0.dist-info/RECORD,,
166
+ code_context_control-2.37.0.dist-info/METADATA,sha256=ztn-S1aTmKvYltI1Rw0hhPXNGl9t34fgqbVkd8tZmm0,21230
167
+ code_context_control-2.37.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
168
+ code_context_control-2.37.0.dist-info/entry_points.txt,sha256=7kX_WUsDCF2hbXzvbNyscyaBb9AeA-DJY5v_5hN0DlU,93
169
+ code_context_control-2.37.0.dist-info/top_level.txt,sha256=wRt41zBybVF3qAiNXHz9BURbkKvUvfhmWWtKMhaw6eE,29
170
+ code_context_control-2.37.0.dist-info/RECORD,,
services/claude_md.py CHANGED
@@ -66,6 +66,87 @@ Plan mode: all c3_* read tools work normally — skip edit/delegate steps.
66
66
  DO NOT: start with native Read/Grep/Glob/Edit, skip c3_validate, read full files without c3_compress."""
67
67
 
68
68
 
69
+ # --- C3-managed instruction block ---------------------------------------------
70
+ # C3-generated content for project instruction docs (CLAUDE.md / AGENTS.md /
71
+ # GEMINI.md / copilot-instructions.md) is wrapped in these sentinels so that
72
+ # regenerating the docs never clobbers user-written content. Mirrors the
73
+ # non-destructive merge used for the global ~/.claude/CLAUDE.md.
74
+ C3_BLOCK_BEGIN = (
75
+ "<!-- C3:BEGIN — auto-generated by C3. Do NOT edit inside this block; it is "
76
+ "regenerated on every `c3 install-mcp` / `c3 init`. Your content OUTSIDE the "
77
+ "block is preserved. -->"
78
+ )
79
+ C3_BLOCK_END = "<!-- C3:END -->"
80
+ C3_BLOCK_HEADING = "# C3 — Managed Instructions"
81
+
82
+ # First line of every legacy (marker-less) C3 instruction doc. Used to detect
83
+ # and replace pre-marker C3 content instead of leaving it duplicated above the
84
+ # new block. Both the compact and nano workflows start with this heading.
85
+ C3_LEGACY_FIRST_LINE = "## C3 Tools"
86
+
87
+
88
+ def wrap_c3_block(content: str) -> str:
89
+ """Wrap C3-generated instruction ``content`` in the managed-section markers."""
90
+ body = content.strip()
91
+ return f"{C3_BLOCK_BEGIN}\n{C3_BLOCK_HEADING}\n\n{body}\n{C3_BLOCK_END}"
92
+
93
+
94
+ def merge_c3_block(existing: str, new_block: str) -> str:
95
+ """Merge a freshly wrapped C3 block into ``existing`` file content.
96
+
97
+ Non-destructive, mirroring the global ~/.claude/CLAUDE.md behaviour:
98
+
99
+ 1. If the C3 markers are already present, replace only the marked region and
100
+ preserve everything the user wrote before and after it.
101
+ 2. Legacy (marker-less) C3 docs are recognised by their leading
102
+ ``## C3 Tools`` heading and replaced wholesale, while any trailing
103
+ ``# User Notes`` section (the pre-marker convention) is preserved.
104
+ 3. A genuine, user-authored file with neither markers nor the legacy
105
+ signature is never overwritten — the C3 block is appended below it.
106
+ """
107
+ new_block = new_block.strip()
108
+
109
+ # 1. Markers present → surgical in-place replacement.
110
+ if C3_BLOCK_BEGIN in existing and C3_BLOCK_END in existing:
111
+ start = existing.index(C3_BLOCK_BEGIN)
112
+ end = existing.index(C3_BLOCK_END) + len(C3_BLOCK_END)
113
+ before = existing[:start].rstrip()
114
+ after = existing[end:].lstrip()
115
+ parts = [p for p in (before, new_block, after) if p]
116
+ return "\n\n".join(parts) + "\n"
117
+
118
+ # 2. Legacy marker-less C3 doc → replace head, keep trailing user notes.
119
+ if existing.lstrip().startswith(C3_LEGACY_FIRST_LINE):
120
+ tail = ""
121
+ if "# User Notes" in existing:
122
+ tail = existing[existing.index("# User Notes"):].strip()
123
+ parts = [new_block] + ([tail] if tail else [])
124
+ return "\n\n".join(parts) + "\n"
125
+
126
+ # 3. Genuine user-authored file → preserve fully, append the C3 block.
127
+ head = existing.rstrip()
128
+ parts = [head, new_block] if head else [new_block]
129
+ return "\n\n".join(parts) + "\n"
130
+
131
+
132
+ def write_c3_instruction_doc(path, content: str) -> str:
133
+ """Write a C3-generated instruction doc without clobbering user content.
134
+
135
+ Wraps ``content`` in the C3 managed block and merges it into any existing
136
+ file via :func:`merge_c3_block`. Returns the exact text written to disk.
137
+ """
138
+ p = Path(path)
139
+ p.parent.mkdir(parents=True, exist_ok=True)
140
+ block = wrap_c3_block(content)
141
+ if p.exists():
142
+ existing = p.read_text(encoding="utf-8", errors="replace")
143
+ final = merge_c3_block(existing, block)
144
+ else:
145
+ final = block.rstrip() + "\n"
146
+ p.write_text(final, encoding="utf-8")
147
+ return final
148
+
149
+
69
150
  class ClaudeMdManager:
70
151
  """Manages instructions file generation, analysis, compaction, and insight promotion.
71
152
 
@@ -285,14 +366,17 @@ class ClaudeMdManager:
285
366
  }
286
367
 
287
368
  def compact(self, target_lines: int = 150) -> dict:
288
- """Compact existing CLAUDE.md to fit within target line count."""
369
+ """Compact existing CLAUDE.md to fit within target line count.
370
+
371
+ When the file uses the C3 managed-block markers, only the inner C3
372
+ body is compacted and the block is re-wrapped, so the markers, the
373
+ ``# C3`` heading, and any user content outside the block survive.
374
+ """
289
375
  current = self._read_current()
290
376
  if current is None:
291
377
  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."}
292
378
 
293
379
  original_metrics = self._count_metrics(current)
294
- sections = self._parse_sections(current)
295
- lines = current.split('\n')
296
380
 
297
381
  # If already under target, no compaction needed
298
382
  if original_metrics["lines"] <= target_lines:
@@ -305,6 +389,54 @@ class ClaudeMdManager:
305
389
  "actions": ["Already under target — no compaction needed."],
306
390
  }
307
391
 
392
+ # Isolate the C3 managed block so its markers, the # C3 heading, and
393
+ # any surrounding user content are preserved verbatim across compaction.
394
+ before = after = ""
395
+ inner = current
396
+ has_block = C3_BLOCK_BEGIN in current and C3_BLOCK_END in current
397
+ if has_block:
398
+ start = current.index(C3_BLOCK_BEGIN)
399
+ end = current.index(C3_BLOCK_END) + len(C3_BLOCK_END)
400
+ before = current[:start]
401
+ after = current[end:]
402
+ inner = current[start + len(C3_BLOCK_BEGIN):end - len(C3_BLOCK_END)].strip()
403
+ if inner.startswith(C3_BLOCK_HEADING):
404
+ inner = inner[len(C3_BLOCK_HEADING):].lstrip("\n")
405
+
406
+ compacted_inner, actions = self._compact_sections(inner, target_lines)
407
+
408
+ if has_block:
409
+ pieces = []
410
+ if before.strip():
411
+ pieces.append(before.strip())
412
+ pieces.append(wrap_c3_block(compacted_inner))
413
+ if after.strip():
414
+ pieces.append(after.strip())
415
+ content = "\n\n".join(pieces) + "\n"
416
+ else:
417
+ content = compacted_inner
418
+
419
+ compacted_metrics = self._count_metrics(content)
420
+
421
+ if not actions:
422
+ actions.append("No compaction opportunities found.")
423
+
424
+ return {
425
+ "content": content,
426
+ "original_lines": original_metrics["lines"],
427
+ "compacted_lines": compacted_metrics["lines"],
428
+ "original_tokens": original_metrics["tokens"],
429
+ "compacted_tokens": compacted_metrics["tokens"],
430
+ "actions": actions,
431
+ }
432
+
433
+ def _compact_sections(self, text: str, target_lines: int) -> tuple:
434
+ """Section-based compaction of an instruction-doc body.
435
+
436
+ Returns ``(content, actions)``. Operates purely on ``text``; callers
437
+ handle any C3 managed-block wrapping.
438
+ """
439
+ sections = self._parse_sections(text)
308
440
  actions = []
309
441
 
310
442
  # Step 1: Compress session history — keep last 3, one-line summaries
@@ -318,12 +450,12 @@ class ClaudeMdManager:
318
450
  # Step 2: Deduplicate — remove exact duplicate lines (excluding blank lines and headers)
319
451
  seen_lines = set()
320
452
  deduped_sections = {}
321
- for name, text in sections.items():
453
+ for name, sect_text in sections.items():
322
454
  if name in ("User Notes", "C3 — Token-Saving Workflow (MUST FOLLOW)"):
323
- deduped_sections[name] = text
455
+ deduped_sections[name] = sect_text
324
456
  continue
325
457
  new_lines = []
326
- for line in text.split('\n'):
458
+ for line in sect_text.split('\n'):
327
459
  stripped = line.strip()
328
460
  if not stripped or stripped.startswith('#'):
329
461
  new_lines.append(line)
@@ -350,20 +482,7 @@ class ClaudeMdManager:
350
482
  actions.append("Reduced project structure tree depth")
351
483
 
352
484
  # Reassemble
353
- content = self._reassemble_sections(sections)
354
- compacted_metrics = self._count_metrics(content)
355
-
356
- if not actions:
357
- actions.append("No compaction opportunities found.")
358
-
359
- return {
360
- "content": content,
361
- "original_lines": original_metrics["lines"],
362
- "compacted_lines": compacted_metrics["lines"],
363
- "original_tokens": original_metrics["tokens"],
364
- "compacted_tokens": compacted_metrics["tokens"],
365
- "actions": actions,
366
- }
485
+ return self._reassemble_sections(sections), actions
367
486
 
368
487
  def get_promotion_candidates(self, min_relevance: int = 2) -> dict:
369
488
  """Find facts and patterns worth promoting into CLAUDE.md."""
@@ -465,19 +465,14 @@ class SessionManager:
465
465
  else:
466
466
  content = auto_content
467
467
 
468
+ # Wrap C3-generated content in the managed block so regenerating the
469
+ # doc never clobbers user-written content outside it (mirrors global
470
+ # ~/.claude/CLAUDE.md). Legacy files and bare user files are preserved.
471
+ from services.claude_md import write_c3_instruction_doc
472
+
468
473
  output_path = self.project_path / instructions_file
469
- output_path.parent.mkdir(parents=True, exist_ok=True)
470
-
471
- # Check for existing file
472
- if output_path.exists():
473
- existing = output_path.read_text(encoding="utf-8")
474
- # Preserve user-written sections
475
- if "# User Notes" in existing:
476
- user_section = existing[existing.index("# User Notes"):]
477
- content += f"\n\n{user_section}"
478
-
479
- output_path.write_text(content, encoding="utf-8")
480
- tokens = count_tokens(content)
474
+ final = write_c3_instruction_doc(output_path, content)
475
+ tokens = count_tokens(final)
481
476
 
482
477
  return {
483
478
  "path": str(output_path),