From b1e059f0247425390979c9152b41ea7cd2dac43c Mon Sep 17 00:00:00 2001 From: drknowhow Date: Sun, 14 Jun 2026 06:42:26 -0400 Subject: [PATCH 1/2] feat: non-destructive config generation (v2.37.0) Make instruction-doc regeneration and permission-tier application non-destructive: C3 now owns only its own slice and preserves user-written content everywhere. Instruction docs (CLAUDE.md / AGENTS.md / GEMINI.md): wrap C3 content in C3:BEGIN / C3:END comment markers; merge instead of overwrite across save_claude_md, the Hub save endpoint, and `c3 claudemd save`; ClaudeMdManager.compact() now compacts only the inner block and re-wraps it so the markers and surrounding user content survive. settings.local.json: permission tiers now merge into existing permissions (preserving user-added allow/deny plus keys like ask and defaultMode) across all four appliers (_apply_permission_tier, install-mcp --permissions, per-project server, and Hub). install-mcp now preserves user-added Stop hooks, replacing only C3's own. .mcp.json was already safe (only the c3 server entry is replaced). Bump version to 2.37.0, add a CHANGELOG entry, document the behavior in README and the in-app guide, and add tests (test_claude_md_merge.py, permission-merge cases, and an install-mcp preservation test). Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 45 ++++++++ CLAUDE.md | 24 ++-- README.md | 4 + cli/c3.py | 71 ++++++++++-- cli/commands/common.py | 10 +- cli/guide/getting-started.html | 18 ++- cli/hub_server.py | 6 +- cli/server.py | 22 ++-- pyproject.toml | 2 +- services/claude_md.py | 159 +++++++++++++++++++++++---- services/session_manager.py | 19 ++-- tests/test_claude_md_merge.py | 157 ++++++++++++++++++++++++++ tests/test_install_mcp_entrypoint.py | 48 ++++++++ tests/test_permissions.py | 42 +++++-- 14 files changed, 548 insertions(+), 79 deletions(-) create mode 100644 tests/test_claude_md_merge.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b57b57..7ad94d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,51 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.37.0] - 2026-06-14 + +Non-destructive config generation — regenerating instruction docs and applying permission +tiers no longer clobber content you wrote by hand. + +### Added + +- **C3-managed block markers for instruction docs.** Generated `CLAUDE.md` / `AGENTS.md` / + `GEMINI.md` content is now wrapped in `` / `` sentinels + (with a visible `# C3 — Managed Instructions` heading). Shared helpers + `wrap_c3_block` / `merge_c3_block` / `write_c3_instruction_doc` in `services/claude_md.py` + back every write path. +- **`_merge_permission_tier`** (`cli/c3.py`) — merges a permission tier into existing + `settings.local.json` permissions, preserving user-added `allow`/`deny` rules and + non-list keys (`ask`, `defaultMode`, `additionalDirectories`) while replacing only the + entries C3 manages. + +### Changed + +- **Instruction docs are merged, not overwritten.** `c3 init` / `c3 install-mcp` and the + `c3 claudemd save` / Hub save paths now replace only the C3-managed block and keep + everything outside it. A pre-existing hand-written file (no markers) is preserved and the + C3 block is appended; legacy marker-less C3 files are migrated in place (trailing + `# User Notes` still preserved). Mirrors the long-standing global `~/.claude/CLAUDE.md` + merge behaviour. +- **`claudemd compact` preserves the managed block.** Compaction now operates on the inner + C3 body only and re-wraps it, so the markers, the `# C3` heading, and any user content + outside the block survive. +- **Permission tiers are merged across every apply path** — `c3 permissions `, + `c3 install-mcp --permissions`, and the Hub / per-project UI endpoints — so switching or + re-applying a tier no longer wipes custom permission rules. Tier-owned entries are still + replaced authoritatively; `deny` rules continue to win over `allow`. + +### Fixed + +- **`install-mcp` no longer drops user `Stop` hooks.** Only C3's own stop hooks (identified + by their hook scripts) are replaced; user-added stop hooks — including the common + matcher-less shape — are preserved alongside C3's. + +### Documentation + +- Documented the C3-managed instruction block and the merge/preserve semantics for + `CLAUDE.md` / `AGENTS.md` / `GEMINI.md`, `settings.local.json` (hooks + permissions), and + `.mcp.json` across `README.md` and the in-app guide. + ## [2.36.0] - 2026-06-13 Installation & upgrade simplification — a pure `pip`/`pipx` install is now self-contained, diff --git a/CLAUDE.md b/CLAUDE.md index 65cb494..dde613c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,8 +93,15 @@ claude-companion - v2/ hook_session_stats.py ... +9 more commands/ (3 files) + guide/ (7 files) tools/ (18 files) ui/ (5 files) + code_context_control.egg-info/ + SOURCES.txt + dependency_links.txt + entry_points.txt + requires.txt + top_level.txt commercial/ info_01_efficiency.json info_02_hierarchy.json @@ -108,17 +115,10 @@ claude-companion - v2/ __init__.py config.py ide.py + mcp_toml.py web_security.py docs/ screenshots/ (11 files) - guide/ - bitbucket.html - getting-started.html - index.html - oracle.html - shared.css - tools.html - workflow.html oracle/ __init__.py config.py @@ -149,7 +149,7 @@ claude-companion - v2/ doc_index.py e2e_benchmark.py e2e_evaluator.py - ... +34 more + ... +35 more bench/ (1 files) tests/ test_aider_polyglot.py @@ -164,10 +164,10 @@ claude-companion - v2/ test_enforcement_flip.py test_federated_graph.py test_ghost_files.py + test_git_branch_awareness.py test_hub_server_smoke.py - test_mcp_server_smoke.py - test_memory_graph_api.py - ... +18 more + test_install_mcp_entrypoint.py + ... +24 more tui/ __init__.py backend.py diff --git a/README.md b/README.md index 7d16bdd..078f497 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,8 @@ Every session you've ever run, with duration, decision count, file count, tool c 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. +C3-generated content is wrapped in a `` / `` 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. + ### 7. Chat — browse prior AI conversations

@@ -308,6 +310,8 @@ c3 permissions show c3 permissions standard ``` +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). + --- ## Benchmarks diff --git a/cli/c3.py b/cli/c3.py index 9c9805a..4988e8a 100644 --- a/cli/c3.py +++ b/cli/c3.py @@ -85,7 +85,7 @@ # Config CONFIG_DIR = ".c3" CONFIG_FILE = ".c3/config.json" -__version__ = "2.36.0" +__version__ = "2.37.0" def _command_deps() -> CommandDeps: @@ -379,6 +379,44 @@ def _build_permission_tier(tier: str, include_mcp_wildcard: bool = False) -> dic }} +def _c3_managed_permission_entries() -> tuple[set, set]: + """Return (allow, deny) sets of every entry any C3 tier can emit. + + Used to tell C3-managed permission rules apart from user-added ones so a + tier change replaces only the former and preserves the latter. + """ + managed_allow: set = set() + managed_deny: set = set() + for _tier in PERMISSION_TIERS: + perms = _build_permission_tier(_tier, include_mcp_wildcard=True)["permissions"] + managed_allow.update(perms.get("allow", [])) + managed_deny.update(perms.get("deny", [])) + return managed_allow, managed_deny + + +def _merge_permission_tier(existing: dict, tier_perms: dict) -> dict: + """Merge a tier's permissions into existing ones, preserving user rules. + + C3 owns every entry a tier can emit: those are replaced by the chosen tier. + Any other allow/deny entry the user added is kept, and non-list permission + keys (e.g. ``ask``, ``defaultMode``, ``additionalDirectories``) are left + untouched. Mirrors how hooks and .mcp.json preserve non-C3 content. + """ + existing = existing if isinstance(existing, dict) else {} + managed = dict(zip(("allow", "deny"), _c3_managed_permission_entries())) + merged = dict(existing) # preserve unknown sub-keys (ask, defaultMode, ...) + for key in ("allow", "deny"): + user_custom = [e for e in (existing.get(key) or []) if e not in managed[key]] + out: list = [] + seen: set = set() + for entry in user_custom + list(tier_perms.get(key) or []): + if entry not in seen: + seen.add(entry) + out.append(entry) + merged[key] = out + return merged + + def _detect_current_tier(settings_path) -> str | None: """Detect which permission tier is active in settings_path, or None. @@ -456,9 +494,12 @@ def _apply_permission_tier(project_path: str, tier: str, settings_path = Path(project_path) / ".claude" / "settings.local.json" settings_path.parent.mkdir(parents=True, exist_ok=True) settings = _safe_read_json(settings_path, str(settings_path)) - settings["permissions"] = _build_permission_tier( + tier_perms = _build_permission_tier( tier, include_mcp_wildcard=include_mcp_wildcard )["permissions"] + settings["permissions"] = _merge_permission_tier( + settings.get("permissions") or {}, tier_perms + ) # Persist chosen tier in .c3/config.json c3_config_path = Path(project_path) / ".c3" / "config.json" c3_config = _safe_read_json(c3_config_path, str(c3_config_path)) @@ -5139,10 +5180,23 @@ def cmd_install_mcp(args): }, ] stop_event = "Stop" - # Replace any existing C3 stop hooks (matcher=""), keep user-added ones + # Replace only C3's own stop hooks (identified by our hook scripts) and + # keep every user-added stop hook — including matcher-less ones, which + # are the normal shape for Stop hooks. + _c3_stop_scripts = ( + "hook_session_stats.py", "hook_auto_snapshot.py", "hook_terse_advisor.py", + ) + + def _is_c3_stop_hook(entry: dict) -> bool: + return any( + script in (hk.get("command") or "") + for hk in entry.get("hooks", []) + for script in _c3_stop_scripts + ) + existing_stop = [ h for h in settings.get("hooks", {}).get(stop_event, []) - if h.get("matcher") # keep entries with a non-empty matcher + if not _is_c3_stop_hook(h) ] existing_stop.extend(desired_stop_hooks) settings.setdefault("hooks", {})[stop_event] = existing_stop @@ -5160,9 +5214,12 @@ def cmd_install_mcp(args): include_wildcard = bool(getattr(args, "include_mcp_wildcard", False)) if perm_tier and profile.name == "claude-code": if perm_tier in PERMISSION_TIERS: - settings["permissions"] = _build_permission_tier( - perm_tier, include_mcp_wildcard=include_wildcard - )["permissions"] + settings["permissions"] = _merge_permission_tier( + settings.get("permissions") or {}, + _build_permission_tier( + perm_tier, include_mcp_wildcard=include_wildcard + )["permissions"], + ) # Persist tier choice in .c3/config.json _c3cfg = _safe_read_json(c3_config_path, str(c3_config_path)) _c3cfg["permission_tier"] = perm_tier diff --git a/cli/commands/common.py b/cli/commands/common.py index 85f035d..75bb93a 100644 --- a/cli/commands/common.py +++ b/cli/commands/common.py @@ -200,13 +200,9 @@ def cmd_claudemd(args, deps: CommandDeps): print(content) else: output_path = Path(project_path) / instructions_file - output_path.parent.mkdir(parents=True, exist_ok=True) - if output_path.exists(): - existing = output_path.read_text(encoding="utf-8", errors="replace") - if "# User Notes" in existing: - user_section = existing[existing.index("# User Notes"):] - content += f"\n\n{user_section}" - output_path.write_text(content, encoding="utf-8") + # Wrap in the C3 managed block; preserve user content outside it. + from services.claude_md import write_c3_instruction_doc + write_c3_instruction_doc(output_path, content) print(f"{instructions_file} saved to {output_path} ({tokens} tokens)") elif args.claudemd_cmd == "check": diff --git a/cli/guide/getting-started.html b/cli/guide/getting-started.html index 9b9b4ab..8fe5bb8 100644 --- a/cli/guide/getting-started.html +++ b/cli/guide/getting-started.html @@ -188,6 +188,14 @@

What init creates

Re-init / upgrade

Running c3 init on an existing project is safe — it merges new config without overwriting your customizations.

+ +
+ 🛡️ +
+ Your hand-written content is preserved + Generated instruction files (CLAUDE.md, AGENTS.md, GEMINI.md) wrap C3 content in a <!-- C3:BEGIN … --> / <!-- C3:END --> block. Re-running init (or c3 claudemd save / the Compact 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. +
+

@@ -200,7 +208,7 @@

Re-init / upgrade

Claude Code (primary)

c3 install-mcp claude
-

This writes to .mcp.json (project scope) and optionally configures PreToolUse / PostToolUse hooks in .claude/settings.local.json.

+

This writes to .mcp.json (project scope) and optionally configures PreToolUse / PostToolUse hooks in .claude/settings.local.json. Both are merged, not overwritten: C3 only touches its own c3 server entry and its own hooks, leaving any other MCP servers, hooks, and top-level keys you've added in place.

VS Code Copilot

c3 install-mcp vscode
@@ -358,6 +366,14 @@

Hooks (Claude Code only)

The standard tier blocks destructive shell commands (rm -rf, git reset --hard, etc.) while allowing safe operations. Upgrade to unrestricted only when needed. + +
+ 🛡️ +
+ Your custom rules survive a tier change + Applying or switching a tier preserves allow/deny entries you added yourself, plus keys like ask and defaultMode. C3 only replaces the entries it manages, so you can mix a tier with project-specific permissions. +
+

diff --git a/cli/hub_server.py b/cli/hub_server.py index eb1158a..0dd7047 100644 --- a/cli/hub_server.py +++ b/cli/hub_server.py @@ -1206,7 +1206,7 @@ def api_projects_permissions_get(): @app.route("/api/projects/permissions/apply", methods=["POST"]) def api_projects_permissions_put(): """Apply permission tier to a project. Body: {path, tier}""" - from cli.c3 import PERMISSION_TIERS, _build_permission_tier + from cli.c3 import PERMISSION_TIERS, _build_permission_tier, _merge_permission_tier data = request.get_json(force=True) or {} path = (data.get("path") or "").strip() tier = (data.get("tier") or "").strip() @@ -1228,7 +1228,9 @@ def api_projects_permissions_put(): settings = json.load(f) except Exception: pass - settings["permissions"] = tier_perms["permissions"] + settings["permissions"] = _merge_permission_tier( + settings.get("permissions") or {}, tier_perms["permissions"] + ) with open(settings_path, "w", encoding="utf-8") as f: json.dump(settings, f, indent=2) diff --git a/cli/server.py b/cli/server.py index 3fa2bde..50dd75d 100644 --- a/cli/server.py +++ b/cli/server.py @@ -1121,16 +1121,11 @@ def api_claudemd_save(): return jsonify({"error": "Generation produced empty content"}), 500 output_path = PROJECT_PATH / claude_md_mgr.instructions_file - output_path.parent.mkdir(parents=True, exist_ok=True) - # Preserve user-written sections - if output_path.exists(): - existing = output_path.read_text(encoding="utf-8", errors="replace") - if "# User Notes" in existing: - user_section = existing[existing.index("# User Notes"):] - content += f"\n\n{user_section}" + # Wrap in the C3 managed block; never clobber user content outside it. + from services.claude_md import write_c3_instruction_doc + write_c3_instruction_doc(output_path, content) - output_path.write_text(content, encoding="utf-8") return jsonify({ "path": str(output_path), "tokens": gen.get("tokens", 0), @@ -2021,7 +2016,12 @@ def api_permissions_get(): @app.route('/api/permissions', methods=['PUT']) def api_permissions_put(): """Apply a permission tier. Body: {tier: "read-only"|"standard"|"permissive"}""" - from cli.c3 import PERMISSION_TIERS, _build_permission_tier, _safe_read_json + from cli.c3 import ( + PERMISSION_TIERS, + _build_permission_tier, + _merge_permission_tier, + _safe_read_json, + ) data = request.get_json() or {} tier = data.get("tier", "").strip() if tier not in PERMISSION_TIERS: @@ -2031,7 +2031,9 @@ def api_permissions_put(): settings_path = PROJECT_PATH / ".claude" / "settings.local.json" settings_path.parent.mkdir(parents=True, exist_ok=True) settings = _safe_read_json(settings_path, str(settings_path)) - settings["permissions"] = tier_perms["permissions"] + settings["permissions"] = _merge_permission_tier( + settings.get("permissions") or {}, tier_perms["permissions"] + ) with open(settings_path, "w", encoding="utf-8") as f: json.dump(settings, f, indent=2) diff --git a/pyproject.toml b/pyproject.toml index 8be2f48..e93d084 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "code-context-control" -version = "2.36.0" +version = "2.37.0" description = "Local code-intelligence layer for AI coding tools (Claude Code, Codex, Gemini, Copilot). Retrieve less, read less, edit safer." readme = "README.md" requires-python = ">=3.10" diff --git a/services/claude_md.py b/services/claude_md.py index e492652..7e6caeb 100644 --- a/services/claude_md.py +++ b/services/claude_md.py @@ -66,6 +66,87 @@ DO NOT: start with native Read/Grep/Glob/Edit, skip c3_validate, read full files without c3_compress.""" +# --- C3-managed instruction block --------------------------------------------- +# C3-generated content for project instruction docs (CLAUDE.md / AGENTS.md / +# GEMINI.md / copilot-instructions.md) is wrapped in these sentinels so that +# regenerating the docs never clobbers user-written content. Mirrors the +# non-destructive merge used for the global ~/.claude/CLAUDE.md. +C3_BLOCK_BEGIN = ( + "" +) +C3_BLOCK_END = "" +C3_BLOCK_HEADING = "# C3 — Managed Instructions" + +# First line of every legacy (marker-less) C3 instruction doc. Used to detect +# and replace pre-marker C3 content instead of leaving it duplicated above the +# new block. Both the compact and nano workflows start with this heading. +C3_LEGACY_FIRST_LINE = "## C3 Tools" + + +def wrap_c3_block(content: str) -> str: + """Wrap C3-generated instruction ``content`` in the managed-section markers.""" + body = content.strip() + return f"{C3_BLOCK_BEGIN}\n{C3_BLOCK_HEADING}\n\n{body}\n{C3_BLOCK_END}" + + +def merge_c3_block(existing: str, new_block: str) -> str: + """Merge a freshly wrapped C3 block into ``existing`` file content. + + Non-destructive, mirroring the global ~/.claude/CLAUDE.md behaviour: + + 1. If the C3 markers are already present, replace only the marked region and + preserve everything the user wrote before and after it. + 2. Legacy (marker-less) C3 docs are recognised by their leading + ``## C3 Tools`` heading and replaced wholesale, while any trailing + ``# User Notes`` section (the pre-marker convention) is preserved. + 3. A genuine, user-authored file with neither markers nor the legacy + signature is never overwritten — the C3 block is appended below it. + """ + new_block = new_block.strip() + + # 1. Markers present → surgical in-place replacement. + if C3_BLOCK_BEGIN in existing and C3_BLOCK_END in existing: + start = existing.index(C3_BLOCK_BEGIN) + end = existing.index(C3_BLOCK_END) + len(C3_BLOCK_END) + before = existing[:start].rstrip() + after = existing[end:].lstrip() + parts = [p for p in (before, new_block, after) if p] + return "\n\n".join(parts) + "\n" + + # 2. Legacy marker-less C3 doc → replace head, keep trailing user notes. + if existing.lstrip().startswith(C3_LEGACY_FIRST_LINE): + tail = "" + if "# User Notes" in existing: + tail = existing[existing.index("# User Notes"):].strip() + parts = [new_block] + ([tail] if tail else []) + return "\n\n".join(parts) + "\n" + + # 3. Genuine user-authored file → preserve fully, append the C3 block. + head = existing.rstrip() + parts = [head, new_block] if head else [new_block] + return "\n\n".join(parts) + "\n" + + +def write_c3_instruction_doc(path, content: str) -> str: + """Write a C3-generated instruction doc without clobbering user content. + + Wraps ``content`` in the C3 managed block and merges it into any existing + file via :func:`merge_c3_block`. Returns the exact text written to disk. + """ + p = Path(path) + p.parent.mkdir(parents=True, exist_ok=True) + block = wrap_c3_block(content) + if p.exists(): + existing = p.read_text(encoding="utf-8", errors="replace") + final = merge_c3_block(existing, block) + else: + final = block.rstrip() + "\n" + p.write_text(final, encoding="utf-8") + return final + + class ClaudeMdManager: """Manages instructions file generation, analysis, compaction, and insight promotion. @@ -285,14 +366,17 @@ def check_staleness(self) -> dict: } def compact(self, target_lines: int = 150) -> dict: - """Compact existing CLAUDE.md to fit within target line count.""" + """Compact existing CLAUDE.md to fit within target line count. + + When the file uses the C3 managed-block markers, only the inner C3 + body is compacted and the block is re-wrapped, so the markers, the + ``# C3`` heading, and any user content outside the block survive. + """ current = self._read_current() if current is None: 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."} original_metrics = self._count_metrics(current) - sections = self._parse_sections(current) - lines = current.split('\n') # If already under target, no compaction needed if original_metrics["lines"] <= target_lines: @@ -305,6 +389,54 @@ def compact(self, target_lines: int = 150) -> dict: "actions": ["Already under target — no compaction needed."], } + # Isolate the C3 managed block so its markers, the # C3 heading, and + # any surrounding user content are preserved verbatim across compaction. + before = after = "" + inner = current + has_block = C3_BLOCK_BEGIN in current and C3_BLOCK_END in current + if has_block: + start = current.index(C3_BLOCK_BEGIN) + end = current.index(C3_BLOCK_END) + len(C3_BLOCK_END) + before = current[:start] + after = current[end:] + inner = current[start + len(C3_BLOCK_BEGIN):end - len(C3_BLOCK_END)].strip() + if inner.startswith(C3_BLOCK_HEADING): + inner = inner[len(C3_BLOCK_HEADING):].lstrip("\n") + + compacted_inner, actions = self._compact_sections(inner, target_lines) + + if has_block: + pieces = [] + if before.strip(): + pieces.append(before.strip()) + pieces.append(wrap_c3_block(compacted_inner)) + if after.strip(): + pieces.append(after.strip()) + content = "\n\n".join(pieces) + "\n" + else: + content = compacted_inner + + compacted_metrics = self._count_metrics(content) + + if not actions: + actions.append("No compaction opportunities found.") + + return { + "content": content, + "original_lines": original_metrics["lines"], + "compacted_lines": compacted_metrics["lines"], + "original_tokens": original_metrics["tokens"], + "compacted_tokens": compacted_metrics["tokens"], + "actions": actions, + } + + def _compact_sections(self, text: str, target_lines: int) -> tuple: + """Section-based compaction of an instruction-doc body. + + Returns ``(content, actions)``. Operates purely on ``text``; callers + handle any C3 managed-block wrapping. + """ + sections = self._parse_sections(text) actions = [] # Step 1: Compress session history — keep last 3, one-line summaries @@ -318,12 +450,12 @@ def compact(self, target_lines: int = 150) -> dict: # Step 2: Deduplicate — remove exact duplicate lines (excluding blank lines and headers) seen_lines = set() deduped_sections = {} - for name, text in sections.items(): + for name, sect_text in sections.items(): if name in ("User Notes", "C3 — Token-Saving Workflow (MUST FOLLOW)"): - deduped_sections[name] = text + deduped_sections[name] = sect_text continue new_lines = [] - for line in text.split('\n'): + for line in sect_text.split('\n'): stripped = line.strip() if not stripped or stripped.startswith('#'): new_lines.append(line) @@ -350,20 +482,7 @@ def compact(self, target_lines: int = 150) -> dict: actions.append("Reduced project structure tree depth") # Reassemble - content = self._reassemble_sections(sections) - compacted_metrics = self._count_metrics(content) - - if not actions: - actions.append("No compaction opportunities found.") - - return { - "content": content, - "original_lines": original_metrics["lines"], - "compacted_lines": compacted_metrics["lines"], - "original_tokens": original_metrics["tokens"], - "compacted_tokens": compacted_metrics["tokens"], - "actions": actions, - } + return self._reassemble_sections(sections), actions def get_promotion_candidates(self, min_relevance: int = 2) -> dict: """Find facts and patterns worth promoting into CLAUDE.md.""" diff --git a/services/session_manager.py b/services/session_manager.py index 9aeac99..650cc5d 100644 --- a/services/session_manager.py +++ b/services/session_manager.py @@ -465,19 +465,14 @@ def save_claude_md(self, instructions_file: str = "CLAUDE.md", template: str = " else: content = auto_content + # Wrap C3-generated content in the managed block so regenerating the + # doc never clobbers user-written content outside it (mirrors global + # ~/.claude/CLAUDE.md). Legacy files and bare user files are preserved. + from services.claude_md import write_c3_instruction_doc + output_path = self.project_path / instructions_file - output_path.parent.mkdir(parents=True, exist_ok=True) - - # Check for existing file - if output_path.exists(): - existing = output_path.read_text(encoding="utf-8") - # Preserve user-written sections - if "# User Notes" in existing: - user_section = existing[existing.index("# User Notes"):] - content += f"\n\n{user_section}" - - output_path.write_text(content, encoding="utf-8") - tokens = count_tokens(content) + final = write_c3_instruction_doc(output_path, content) + tokens = count_tokens(final) return { "path": str(output_path), diff --git a/tests/test_claude_md_merge.py b/tests/test_claude_md_merge.py new file mode 100644 index 0000000..bdabd18 --- /dev/null +++ b/tests/test_claude_md_merge.py @@ -0,0 +1,157 @@ +"""Tests for non-destructive C3 instruction-doc merging. + +Covers wrap_c3_block / merge_c3_block / write_c3_instruction_doc — the helpers +that let `c3 install-mcp` / `c3 init` regenerate CLAUDE.md (and AGENTS.md / +GEMINI.md) without clobbering user-written content. Mirrors the behaviour of +the global ~/.claude/CLAUDE.md merge. +""" +import tempfile +import unittest +from pathlib import Path + +from services.claude_md import ( + C3_BLOCK_BEGIN, + C3_BLOCK_END, + C3_BLOCK_HEADING, + ClaudeMdManager, + merge_c3_block, + wrap_c3_block, + write_c3_instruction_doc, +) + +C3_CONTENT = "## C3 Tools — MANDATORY\nUse c3_* tools.\n\n# Project Context\nstuff" + + +class TestWrapC3Block(unittest.TestCase): + def test_wrap_adds_markers_and_heading(self): + block = wrap_c3_block(C3_CONTENT) + self.assertTrue(block.startswith(C3_BLOCK_BEGIN)) + self.assertTrue(block.rstrip().endswith(C3_BLOCK_END)) + self.assertIn(C3_BLOCK_HEADING, block) + self.assertIn("## C3 Tools — MANDATORY", block) + + +class TestMergeC3Block(unittest.TestCase): + def test_replaces_marked_region_in_place(self): + existing = ( + "# My personal header\nkeep me above\n\n" + + wrap_c3_block("OLD C3 BODY") + + "\n\n# My footer\nkeep me below\n" + ) + merged = merge_c3_block(existing, wrap_c3_block("NEW C3 BODY")) + self.assertIn("# My personal header", merged) + self.assertIn("keep me above", merged) + self.assertIn("# My footer", merged) + self.assertIn("keep me below", merged) + self.assertIn("NEW C3 BODY", merged) + self.assertNotIn("OLD C3 BODY", merged) + # Exactly one managed block remains (no duplication). + self.assertEqual(merged.count(C3_BLOCK_BEGIN), 1) + self.assertEqual(merged.count(C3_BLOCK_END), 1) + + def test_legacy_doc_is_replaced_and_user_notes_preserved(self): + legacy = ( + "## C3 Tools — MANDATORY\nold workflow text\n\n" + "# Project Context\nold context\n\n" + "# User Notes\nmy precious notes\n" + ) + merged = merge_c3_block(legacy, wrap_c3_block(C3_CONTENT)) + self.assertIn("# User Notes", merged) + self.assertIn("my precious notes", merged) + self.assertNotIn("old workflow text", merged) + self.assertNotIn("old context", merged) + self.assertEqual(merged.count(C3_BLOCK_BEGIN), 1) + + def test_genuine_user_file_is_appended_not_destroyed(self): + user_file = "# Our House Rules\nAlways write tests.\nNo force pushes.\n" + merged = merge_c3_block(user_file, wrap_c3_block(C3_CONTENT)) + self.assertIn("# Our House Rules", merged) + self.assertIn("Always write tests.", merged) + self.assertIn("No force pushes.", merged) + self.assertIn(C3_BLOCK_BEGIN, merged) + # User content stays above the appended C3 block. + self.assertLess(merged.index("Our House Rules"), merged.index(C3_BLOCK_BEGIN)) + + +class TestWriteC3InstructionDoc(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.path = Path(self.tmp.name) / "CLAUDE.md" + + def tearDown(self): + self.tmp.cleanup() + + def test_creates_wrapped_file_when_missing(self): + write_c3_instruction_doc(self.path, C3_CONTENT) + text = self.path.read_text(encoding="utf-8") + self.assertIn(C3_BLOCK_BEGIN, text) + self.assertIn(C3_BLOCK_END, text) + self.assertIn("## C3 Tools — MANDATORY", text) + + def test_idempotent_regeneration_preserves_user_content(self): + # 1. Initial generation. + write_c3_instruction_doc(self.path, C3_CONTENT) + # 2. User edits below the block. + text = self.path.read_text(encoding="utf-8") + self.path.write_text(text + "\n# User Notes\nhand-written\n", encoding="utf-8") + # 3. Regenerate with new C3 content. + write_c3_instruction_doc(self.path, "## C3 Tools — MANDATORY\nv2 workflow") + final = self.path.read_text(encoding="utf-8") + self.assertIn("hand-written", final) + self.assertIn("v2 workflow", final) + # No duplicated managed block after repeated regeneration. + self.assertEqual(final.count(C3_BLOCK_BEGIN), 1) + self.assertEqual(final.count(C3_BLOCK_END), 1) + + +class TestCompactPreservesMarkers(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.project = Path(self.tmp.name) + self.path = self.project / "CLAUDE.md" + + def tearDown(self): + self.tmp.cleanup() + + def _manager(self): + # compact() only touches the on-disk file + pure text helpers, so the + # session_mgr / indexer / memory collaborators are unused here. + return ClaudeMdManager(str(self.project), None, None, None, + instructions_file="CLAUDE.md") + + def test_compact_keeps_markers_and_outer_user_content(self): + inner = ( + "## C3 Tools — MANDATORY\nline A\nline A\nline B\n\n" + "# Project Context\nctx line\n" + ) + self.path.write_text( + "# My Header\nkeep above\n\n" + + wrap_c3_block(inner) + + "\n\n# User Notes\nkeep below\n", + encoding="utf-8", + ) + result = self._manager().compact(target_lines=3) + content = result["content"] + # Markers + managed heading survive. + self.assertEqual(content.count(C3_BLOCK_BEGIN), 1) + self.assertEqual(content.count(C3_BLOCK_END), 1) + self.assertIn(C3_BLOCK_HEADING, content) + # User content outside the block survives. + self.assertIn("My Header", content) + self.assertIn("keep above", content) + self.assertIn("keep below", content) + # Compaction still happened inside the block (duplicate line removed). + self.assertEqual(content.count("line A"), 1) + + def test_compact_without_markers_still_works(self): + self.path.write_text( + "## C3 Tools — MANDATORY\ndup\ndup\nother\n# Project Context\nx\n", + encoding="utf-8", + ) + result = self._manager().compact(target_lines=2) + self.assertNotIn(C3_BLOCK_BEGIN, result["content"]) + self.assertIn("## C3 Tools", result["content"]) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_install_mcp_entrypoint.py b/tests/test_install_mcp_entrypoint.py index 3946a8a..359facf 100644 --- a/tests/test_install_mcp_entrypoint.py +++ b/tests/test_install_mcp_entrypoint.py @@ -69,6 +69,54 @@ def test_falls_back_to_source_when_no_entry_point(self): self.assertTrue(entry["args"][0].endswith("mcp_server.py")) self.assertEqual(entry["args"][1:], ["--project", "."]) + def test_install_preserves_existing_user_config(self): + """install-mcp must not clobber the user's .mcp.json or settings.local.json.""" + # Pre-existing user content that must survive install. + (self.project / ".mcp.json").write_text(json.dumps({ + "mcpServers": {"myserver": {"command": "foo", "args": []}}, + "someTopKey": 123, + }), encoding="utf-8") + claude_dir = self.project / ".claude" + claude_dir.mkdir() + (claude_dir / "settings.local.json").write_text(json.dumps({ + "permissions": {"allow": ["Bash(mytool:*)"], "deny": []}, + "hooks": { + "PostToolUse": [ + {"matcher": "MyCustomTool", + "hooks": [{"type": "command", "command": "echo hi"}]} + ], + "Stop": [ + {"matcher": "", + "hooks": [{"type": "command", "command": "echo userstop"}]} + ], + }, + }), encoding="utf-8") + + with mock.patch("shutil.which", return_value=None): + self._run_install() + + # .mcp.json: other servers + top-level keys preserved, c3 added. + mcp = json.loads((self.project / ".mcp.json").read_text(encoding="utf-8")) + self.assertIn("myserver", mcp["mcpServers"]) + self.assertIn("c3", mcp["mcpServers"]) + self.assertEqual(mcp.get("someTopKey"), 123) + + settings = json.loads((claude_dir / "settings.local.json").read_text(encoding="utf-8")) + # Plain install (no --permissions) leaves user permissions untouched. + self.assertIn("Bash(mytool:*)", settings["permissions"]["allow"]) + # User PostToolUse hook (custom matcher) preserved. + self.assertTrue( + any(h.get("matcher") == "MyCustomTool" for h in settings["hooks"]["PostToolUse"]) + ) + # User Stop hook (empty matcher) preserved alongside C3's own stop hook. + stop_cmds = [ + hk.get("command", "") + for h in settings["hooks"]["Stop"] + for hk in h.get("hooks", []) + ] + self.assertTrue(any("echo userstop" in c for c in stop_cmds)) + self.assertTrue(any("hook_auto_snapshot.py" in c for c in stop_cmds)) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_permissions.py b/tests/test_permissions.py index 3e9f13f..ed9e43f 100644 --- a/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -169,27 +169,55 @@ def test_clean_no_stale_returns_zero(self): class TestApplyPreservesOtherKeys(unittest.TestCase): - def test_apply_preserves_hooks_and_other_keys(self): + def _setup_project(self, tmpdir: str) -> Path: + project = Path(tmpdir) + (project / ".claude").mkdir() + (project / ".c3").mkdir() + return project + + def test_apply_preserves_user_custom_and_other_keys(self): with tempfile.TemporaryDirectory() as tmpdir: - project = Path(tmpdir) - (project / ".claude").mkdir() - (project / ".c3").mkdir() + project = self._setup_project(tmpdir) settings = project / ".claude" / "settings.local.json" settings.write_text(json.dumps({ "enableAllProjectMcpServers": True, "hooks": {"PostToolUse": [{"matcher": "*", "hooks": []}]}, - "permissions": {"allow": ["old_entry"], "deny": []}, + "permissions": { + "allow": ["Bash(mycustomtool:*)"], # user-custom — must survive + "deny": ["Edit(*)"], # stale tier entry — dropped under standard + "ask": ["Bash(git push:*)"], # non-allow/deny key — must survive + }, }), encoding="utf-8") _apply_permission_tier(str(project), "standard") data = json.loads(settings.read_text(encoding="utf-8")) + # Other top-level keys preserved self.assertTrue(data["enableAllProjectMcpServers"]) self.assertIn("hooks", data) - # Permissions replaced, not merged - self.assertNotIn("old_entry", data["permissions"]["allow"]) + perms = data["permissions"] + # User-custom allow entry preserved (not a C3-managed rule) + self.assertIn("Bash(mycustomtool:*)", perms["allow"]) + # Non-allow/deny permission sub-key preserved + self.assertEqual(perms.get("ask"), ["Bash(git push:*)"]) + # Stale C3-managed deny entry not in standard → removed + self.assertNotIn("Edit(*)", perms["deny"]) + # Chosen tier's entries applied + self.assertIn("Bash(rm -rf *)", perms["deny"]) # Tier stored in .c3/config.json cfg = json.loads((project / ".c3" / "config.json").read_text(encoding="utf-8")) self.assertEqual(cfg["permission_tier"], "standard") + def test_tier_switch_drops_previous_tier_entries(self): + with tempfile.TemporaryDirectory() as tmpdir: + project = self._setup_project(tmpdir) + settings = project / ".claude" / "settings.local.json" + _apply_permission_tier(str(project), "c3-strict") + mid = json.loads(settings.read_text(encoding="utf-8")) + self.assertIn("Read(*)", mid["permissions"]["deny"]) # strict denies Read + _apply_permission_tier(str(project), "permissive") + data = json.loads(settings.read_text(encoding="utf-8")) + # Switching tiers removes the previous tier's managed deny entries + self.assertNotIn("Read(*)", data["permissions"]["deny"]) + if __name__ == "__main__": unittest.main() From 57de91c4eb103d69b0ebd82e380ff7d53a843562 Mon Sep 17 00:00:00 2001 From: drknowhow Date: Sun, 14 Jun 2026 06:52:04 -0400 Subject: [PATCH 2/2] style: add trailing newline to test_claude_md_merge.py (ruff W292) Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_claude_md_merge.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_claude_md_merge.py b/tests/test_claude_md_merge.py index bdabd18..42996f7 100644 --- a/tests/test_claude_md_merge.py +++ b/tests/test_claude_md_merge.py @@ -154,4 +154,4 @@ def test_compact_without_markers_still_works(self): if __name__ == "__main__": - unittest.main() \ No newline at end of file + unittest.main()