Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<!-- C3:BEGIN … -->` / `<!-- C3:END -->` 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 <tier>`,
`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,
Expand Down
24 changes: 12 additions & 12 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<!-- 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.

### 7. Chat — browse prior AI conversations

<p align="center">
Expand Down Expand Up @@ -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
Expand Down
71 changes: 64 additions & 7 deletions cli/c3.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
10 changes: 3 additions & 7 deletions cli/commands/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
18 changes: 17 additions & 1 deletion cli/guide/getting-started.html
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,14 @@ <h3>What init creates</h3>

<h3>Re-init / upgrade</h3>
<p>Running <code>c3 init</code> on an existing project is safe — it merges new config without overwriting your customizations.</p>

<div class="callout callout-info">
<span class="callout-icon">🛡️</span>
<div class="callout-body">
<strong>Your hand-written content is preserved</strong>
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.
</div>
</div>
</section>

<hr class="divider">
Expand All @@ -200,7 +208,7 @@ <h3>Re-init / upgrade</h3>

<h3>Claude Code (primary)</h3>
<pre><code>c3 install-mcp claude</code></pre>
<p>This writes to <code>.mcp.json</code> (project scope) and optionally configures PreToolUse / PostToolUse hooks in <code>.claude/settings.local.json</code>.</p>
<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>

<h3>VS Code Copilot</h3>
<pre><code>c3 install-mcp vscode</code></pre>
Expand Down Expand Up @@ -358,6 +366,14 @@ <h3>Hooks (Claude Code only)</h3>
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.
</div>
</div>

<div class="callout callout-info">
<span class="callout-icon">🛡️</span>
<div class="callout-body">
<strong>Your custom rules survive a tier change</strong>
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.
</div>
</div>
</section>

<hr class="divider">
Expand Down
6 changes: 4 additions & 2 deletions cli/hub_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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)

Expand Down
22 changes: 12 additions & 10 deletions cli/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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:
Expand All @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading
Loading