code-context-control 2.40.0__py3-none-any.whl → 2.42.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/_hook_utils.py CHANGED
@@ -1,5 +1,12 @@
1
- """Shared utilities for C3 hook scripts — supports Claude Code and Gemini CLI."""
1
+ """Shared utilities for C3 hook scripts — supports Claude Code and Gemini CLI.
2
+
3
+ Also owns the consolidated enforcement state (.c3/enforcement_state.json):
4
+ a single file replacing the previous trio of last_c3_call.json,
5
+ unlocked_files.json, and ad-hoc writers spread across four hook scripts.
6
+ All hook reads/writes of enforcement state MUST go through this module.
7
+ """
2
8
  import json
9
+ import os
3
10
  import sys
4
11
  import traceback
5
12
  from datetime import datetime, timezone
@@ -8,6 +15,23 @@ from pathlib import Path
8
15
  # Max size of hook_errors.log before it is rotated (50 KB)
9
16
  _LOG_MAX_BYTES = 50 * 1024
10
17
 
18
+ # ── Consolidated enforcement state ───────────────────────────────────────────
19
+ # Canonical file (the ONLY file written from v2.42 on):
20
+ # {
21
+ # "session_id": "<claude session id or ''>",
22
+ # "last_c3_call": {"ts": "<ISO UTC>", "tool": "c3_search", "read_unlocked": true},
23
+ # "unlocked_files": {"<resolved path>": ["read", "edit"]}
24
+ # }
25
+ # Legacy files (READ as fallback for one release; never written anymore):
26
+ ENFORCEMENT_STATE_FILE = ".c3/enforcement_state.json"
27
+ LEGACY_SIGNAL_FILE = ".c3/last_c3_call.json"
28
+ LEGACY_UNLOCK_FILE = ".c3/unlocked_files.json"
29
+
30
+ # Critical state-layer warnings (e.g. corrupted state JSON) surfaced by the
31
+ # dispatcher as an additionalContext line so enforcement never silently stops
32
+ # enforcing. Drained via drain_state_warnings().
33
+ STATE_WARNINGS: list = []
34
+
11
35
 
12
36
  def log_hook_error(hook_name: str, exc: BaseException) -> None:
13
37
  """Append a timestamped error entry to .c3/hook_errors.log.
@@ -34,6 +58,161 @@ def log_hook_error(hook_name: str, exc: BaseException) -> None:
34
58
  except Exception:
35
59
  pass # Absolutely must not propagate
36
60
 
61
+ def drain_state_warnings() -> list:
62
+ """Return and clear accumulated critical state warnings.
63
+
64
+ Called by the dispatcher after each sub-hook so corruption events become
65
+ a visible "[c3:hook-error] ..." additionalContext line instead of a
66
+ silent enforcement gap.
67
+ """
68
+ warnings = STATE_WARNINGS[:]
69
+ STATE_WARNINGS.clear()
70
+ return warnings
71
+
72
+
73
+ def _empty_state(session_id: str = "") -> dict:
74
+ return {"session_id": session_id or "", "last_c3_call": None, "unlocked_files": {}}
75
+
76
+
77
+ def _atomic_write_json(path: Path, data: dict) -> None:
78
+ """Write JSON atomically: temp file in the same directory + os.replace."""
79
+ path.parent.mkdir(parents=True, exist_ok=True)
80
+ tmp = path.with_name(f"{path.name}.tmp{os.getpid()}")
81
+ tmp.write_text(json.dumps(data), encoding="utf-8")
82
+ os.replace(tmp, path)
83
+
84
+
85
+ def _read_legacy_state(base: Path) -> dict:
86
+ """Build a state view from the pre-v2.42 files (read-only fallback)."""
87
+ state = _empty_state()
88
+ signal_path = base / LEGACY_SIGNAL_FILE
89
+ if signal_path.exists():
90
+ try:
91
+ data = json.loads(signal_path.read_text(encoding="utf-8"))
92
+ if isinstance(data, dict) and data.get("timestamp"):
93
+ state["last_c3_call"] = {
94
+ "ts": str(data.get("timestamp")),
95
+ "tool": str(data.get("tool", "")),
96
+ "read_unlocked": bool(data.get("read_unlocked", False)),
97
+ }
98
+ except Exception:
99
+ pass # Legacy file corruption is not critical — new file supersedes it
100
+ unlock_path = base / LEGACY_UNLOCK_FILE
101
+ if unlock_path.exists():
102
+ try:
103
+ data = json.loads(unlock_path.read_text(encoding="utf-8"))
104
+ if isinstance(data, dict):
105
+ state["unlocked_files"] = {
106
+ str(k): list(v) for k, v in data.items() if isinstance(v, list)
107
+ }
108
+ except Exception:
109
+ pass
110
+ return state
111
+
112
+
113
+ def load_enforcement_state(project_path: Path | None = None, session_id: str = "") -> dict:
114
+ """Load consolidated enforcement state with legacy fallback + session scoping.
115
+
116
+ - Missing new file → read legacy last_c3_call.json / unlocked_files.json
117
+ (one-release migration path; writes only ever go to the new file).
118
+ - Corrupted new file → quarantine to *.corrupt, log, push a critical
119
+ warning to STATE_WARNINGS, and return empty state (fail-open to the
120
+ advisory path, never a hard-deny surprise).
121
+ - session_id mismatch → state written by another session is STALE:
122
+ return empty state for the current session.
123
+ """
124
+ base = project_path if project_path is not None else Path.cwd()
125
+ state_path = base / ENFORCEMENT_STATE_FILE
126
+ state = None
127
+ if state_path.exists():
128
+ try:
129
+ data = json.loads(state_path.read_text(encoding="utf-8"))
130
+ if not isinstance(data, dict):
131
+ raise ValueError("enforcement_state.json root is not an object")
132
+ state = _empty_state()
133
+ state["session_id"] = str(data.get("session_id") or "")
134
+ last_call = data.get("last_c3_call")
135
+ state["last_c3_call"] = last_call if isinstance(last_call, dict) else None
136
+ unlocked = data.get("unlocked_files")
137
+ state["unlocked_files"] = unlocked if isinstance(unlocked, dict) else {}
138
+ except Exception as exc:
139
+ log_hook_error("enforcement_state", exc)
140
+ try:
141
+ state_path.replace(state_path.with_name(state_path.name + ".corrupt"))
142
+ except Exception:
143
+ pass
144
+ STATE_WARNINGS.append(
145
+ "[c3:hook-error] enforcement_state: corrupted "
146
+ f"{ENFORCEMENT_STATE_FILE} quarantined ({type(exc).__name__}); "
147
+ "see .c3/hook_errors.log"
148
+ )
149
+ return _empty_state(session_id)
150
+ if state is None:
151
+ state = _read_legacy_state(base)
152
+ # Session scoping: hook payloads carry session_id; state from a different
153
+ # session must not grant unlocks (signal files used to survive /clear).
154
+ if session_id and state.get("session_id") and state["session_id"] != session_id:
155
+ return _empty_state(session_id)
156
+ return state
157
+
158
+
159
+ def save_enforcement_state(state: dict, project_path: Path | None = None) -> None:
160
+ """Atomically persist the consolidated enforcement state."""
161
+ base = project_path if project_path is not None else Path.cwd()
162
+ try:
163
+ _atomic_write_json(base / ENFORCEMENT_STATE_FILE, state)
164
+ except Exception as exc:
165
+ log_hook_error("enforcement_state", exc)
166
+
167
+
168
+ def record_c3_signal(
169
+ tool: str,
170
+ read_unlocked: bool,
171
+ session_id: str = "",
172
+ project_path: Path | None = None,
173
+ ) -> None:
174
+ """Record 'a c3_* tool just completed' in the consolidated state."""
175
+ state = load_enforcement_state(project_path, session_id=session_id)
176
+ if session_id:
177
+ state["session_id"] = session_id
178
+ state["last_c3_call"] = {
179
+ "ts": datetime.now(timezone.utc).isoformat(),
180
+ "tool": tool,
181
+ "read_unlocked": bool(read_unlocked),
182
+ }
183
+ save_enforcement_state(state, project_path)
184
+
185
+
186
+ def record_unlocked_files(
187
+ paths,
188
+ categories,
189
+ session_id: str = "",
190
+ project_path: Path | None = None,
191
+ ) -> None:
192
+ """Merge sticky per-file unlock categories into the consolidated state."""
193
+ cats_to_add = {c for c in categories if c}
194
+ if not cats_to_add:
195
+ return
196
+ state = load_enforcement_state(project_path, session_id=session_id)
197
+ if session_id:
198
+ state["session_id"] = session_id
199
+ changed = False
200
+ for fp in paths:
201
+ if not fp:
202
+ continue
203
+ try:
204
+ normalized = str(Path(fp).resolve())
205
+ except OSError:
206
+ continue
207
+ cats = set(state["unlocked_files"].get(normalized, []))
208
+ merged = sorted(cats | cats_to_add)
209
+ if merged != state["unlocked_files"].get(normalized):
210
+ state["unlocked_files"][normalized] = merged
211
+ changed = True
212
+ if changed:
213
+ save_enforcement_state(state, project_path)
214
+
215
+
37
216
  # Map Gemini CLI built-in tool names → canonical Claude Code equivalents
38
217
  GEMINI_TOOL_MAP = {
39
218
  "run_shell_command": "Bash",
@@ -84,34 +263,23 @@ def get_tool_input_path(data: dict) -> str:
84
263
  )
85
264
 
86
265
 
87
- def record_json_unlocks(editable: list, project_path: Path | None = None) -> None:
88
- """Record file paths as read+edit unlocked in .c3/unlocked_files.json.
266
+ def record_json_unlocks(
267
+ editable: list,
268
+ project_path: Path | None = None,
269
+ session_id: str = "",
270
+ ) -> None:
271
+ """Record file paths as read+edit unlocked in the enforcement state.
89
272
 
90
- This is the map that hook_pretool_enforce.py actually reads (the plain
91
- .txt unlock list is not consumed by any hook). Mirrors the behaviour of
92
- cli/hook_c3read._record_json_unlocks so c3_compress/c3_agent sticky
93
- unlocks reach the enforcer. Fails silently on I/O errors.
273
+ Compatibility wrapper kept for existing callers (hook_edit_unlock,
274
+ hook_c3read): unlocks now land in .c3/enforcement_state.json via the
275
+ consolidated state layer instead of the legacy unlocked_files.json.
276
+ Fails silently on I/O errors.
94
277
  """
95
- base = project_path if project_path is not None else Path.cwd()
96
- json_path = base / ".c3" / "unlocked_files.json"
97
278
  try:
98
- existing: dict = {}
99
- if json_path.exists():
100
- try:
101
- existing = json.loads(json_path.read_text(encoding="utf-8"))
102
- if not isinstance(existing, dict):
103
- existing = {}
104
- except Exception:
105
- existing = {}
106
- for fp in editable:
107
- if not fp:
108
- continue
109
- normalized = str(Path(fp).resolve())
110
- cats = set(existing.get(normalized, []))
111
- cats.update({"read", "edit"})
112
- existing[normalized] = sorted(cats)
113
- json_path.parent.mkdir(parents=True, exist_ok=True)
114
- json_path.write_text(json.dumps(existing), encoding="utf-8")
279
+ record_unlocked_files(
280
+ editable, {"read", "edit"},
281
+ session_id=session_id, project_path=project_path,
282
+ )
115
283
  except Exception:
116
284
  pass
117
285
 
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.40.0"
88
+ __version__ = "2.42.0"
89
89
 
90
90
 
91
91
  def _command_deps() -> CommandDeps:
@@ -4448,7 +4448,7 @@ def _uninstall_mcp_all(project_path: str):
4448
4448
  # Remove hooks
4449
4449
  hooks = settings.get("hooks", {}).get("PostToolUse", [])
4450
4450
  new_hooks = []
4451
- c3_hook_files = {"hook_filter.py", "hook_read.py", "hook_c3read.py"}
4451
+ c3_hook_files = {"hook_filter.py", "hook_read.py", "hook_c3read.py", "hook_dispatch.py"}
4452
4452
  for h in hooks:
4453
4453
  if h.get("matcher") in ("Bash", "Read", "mcp__c3__c3_read"):
4454
4454
  h["hooks"] = [hook for hook in h.get("hooks", [])
@@ -5011,17 +5011,16 @@ def cmd_install_mcp(args):
5011
5011
  # file; "cmd /c …" returns "cmd: command not found"). The single-quoted paths are
5012
5012
  # correct — bash strips them and re-quotes for cmd.exe, preserving spaces/parens.
5013
5013
  _hook_prefix = "cmd.exe /c " if sys.platform == "win32" else ""
5014
- hook_filter_cmd = f"{_hook_prefix}{shlex.quote(sys.executable)} {shlex.quote(str(cli_dir / 'hook_filter.py'))}"
5015
- hook_read_cmd = f"{_hook_prefix}{shlex.quote(sys.executable)} {shlex.quote(str(cli_dir / 'hook_read.py'))}"
5016
- hook_c3read_cmd = f"{_hook_prefix}{shlex.quote(sys.executable)} {shlex.quote(str(cli_dir / 'hook_c3read.py'))}"
5017
- hook_enforce_cmd = f"{_hook_prefix}{shlex.quote(sys.executable)} {shlex.quote(str(cli_dir / 'hook_pretool_enforce.py'))}"
5018
- hook_edit_unlock_cmd = f"{_hook_prefix}{shlex.quote(sys.executable)} {shlex.quote(str(cli_dir / 'hook_edit_unlock.py'))}"
5019
- hook_edit_ledger_cmd = f"{_hook_prefix}{shlex.quote(sys.executable)} {shlex.quote(str(cli_dir / 'hook_edit_ledger.py'))}"
5020
- hook_ghost_files_cmd = f"{_hook_prefix}{shlex.quote(sys.executable)} {shlex.quote(str(cli_dir / 'hook_ghost_files.py'))}"
5021
- hook_session_stats_cmd = f"{_hook_prefix}{shlex.quote(sys.executable)} {shlex.quote(str(cli_dir / 'hook_session_stats.py'))}"
5022
- hook_auto_snapshot_cmd = f"{_hook_prefix}{shlex.quote(sys.executable)} {shlex.quote(str(cli_dir / 'hook_auto_snapshot.py'))}"
5023
- hook_terse_advisor_cmd = f"{_hook_prefix}{shlex.quote(sys.executable)} {shlex.quote(str(cli_dir / 'hook_terse_advisor.py'))}"
5024
- hook_c3_signal_cmd = f"{_hook_prefix}{shlex.quote(sys.executable)} {shlex.quote(str(cli_dir / 'hook_c3_signal.py'))}"
5014
+ # v2.42: single dispatcher script per hook event instead of N separate
5015
+ # per-hook commands. One interpreter spawn per event; the dispatcher
5016
+ # (cli/hook_dispatch.py) runs all applicable sub-hooks in-process.
5017
+ _dispatch_base = (
5018
+ f"{_hook_prefix}{shlex.quote(sys.executable)} "
5019
+ f"{shlex.quote(str(cli_dir / 'hook_dispatch.py'))}"
5020
+ )
5021
+ hook_pretool_cmd = f"{_dispatch_base} pretool"
5022
+ hook_posttool_cmd = f"{_dispatch_base} posttool"
5023
+ hook_stop_cmd = f"{_dispatch_base} stop"
5025
5024
 
5026
5025
  # Tool matcher names differ by IDE: Gemini uses snake_case built-in names.
5027
5026
  if profile.name == "gemini":
@@ -5045,130 +5044,48 @@ def cmd_install_mcp(args):
5045
5044
  extra_edit_matchers = ["MultiEdit", "NotebookEdit"]
5046
5045
 
5047
5046
  # ── PostToolUse hooks ──
5047
+ # Matcher set is unchanged from pre-v2.42; every matcher now points at
5048
+ # the single posttool dispatcher (which sub-hooks run for which tool
5049
+ # moved into cli/hook_dispatch.py). One spawn per event instead of
5050
+ # up to three.
5051
+ _post_matcher_names = [
5052
+ shell_matcher,
5053
+ read_matcher,
5054
+ "mcp__c3__c3_read",
5055
+ "mcp__c3__c3_shell",
5056
+ "mcp__c3__c3_search",
5057
+ "mcp__c3__c3_compress",
5058
+ "mcp__c3__c3_filter",
5059
+ "mcp__c3__c3_memory",
5060
+ "mcp__c3__c3_validate",
5061
+ "mcp__c3__c3_edit",
5062
+ "mcp__c3__c3_edits",
5063
+ "mcp__c3__c3_impact",
5064
+ "mcp__c3__c3_status",
5065
+ "mcp__c3__c3_delegate",
5066
+ "mcp__c3__c3_session",
5067
+ "mcp__c3__c3_agent",
5068
+ edit_matcher,
5069
+ write_matcher,
5070
+ *extra_edit_matchers,
5071
+ ]
5048
5072
  desired_post_hooks = [
5049
- {
5050
- "matcher": shell_matcher,
5051
- "hooks": [
5052
- {"type": "command", "command": hook_filter_cmd},
5053
- {"type": "command", "command": hook_ghost_files_cmd},
5054
- ]
5055
- },
5056
- {
5057
- "matcher": read_matcher,
5058
- "hooks": [
5059
- {"type": "command", "command": hook_read_cmd},
5060
- {"type": "command", "command": hook_ghost_files_cmd},
5061
- ]
5062
- },
5063
- {
5064
- "matcher": "mcp__c3__c3_read",
5065
- "hooks": [
5066
- {"type": "command", "command": hook_c3read_cmd},
5067
- {"type": "command", "command": hook_c3_signal_cmd},
5068
- {"type": "command", "command": hook_ghost_files_cmd},
5069
- ]
5070
- },
5071
- {
5072
- "matcher": "mcp__c3__c3_shell",
5073
- "hooks": [
5074
- {"type": "command", "command": hook_c3_signal_cmd},
5075
- {"type": "command", "command": hook_ghost_files_cmd},
5076
- ]
5077
- },
5078
- {
5079
- "matcher": "mcp__c3__c3_search",
5080
- "hooks": [{"type": "command", "command": hook_c3_signal_cmd}]
5081
- },
5082
- {
5083
- "matcher": "mcp__c3__c3_compress",
5084
- "hooks": [
5085
- {"type": "command", "command": hook_edit_unlock_cmd},
5086
- {"type": "command", "command": hook_c3_signal_cmd},
5087
- ]
5088
- },
5089
- {
5090
- "matcher": "mcp__c3__c3_filter",
5091
- "hooks": [{"type": "command", "command": hook_c3_signal_cmd}]
5092
- },
5093
- {
5094
- "matcher": "mcp__c3__c3_memory",
5095
- "hooks": [{"type": "command", "command": hook_c3_signal_cmd}]
5096
- },
5097
- {
5098
- "matcher": "mcp__c3__c3_validate",
5099
- "hooks": [{"type": "command", "command": hook_c3_signal_cmd}]
5100
- },
5101
- {
5102
- "matcher": "mcp__c3__c3_edit",
5103
- "hooks": [{"type": "command", "command": hook_c3_signal_cmd}]
5104
- },
5105
- {
5106
- "matcher": "mcp__c3__c3_edits",
5107
- "hooks": [{"type": "command", "command": hook_c3_signal_cmd}]
5108
- },
5109
- {
5110
- "matcher": "mcp__c3__c3_impact",
5111
- "hooks": [{"type": "command", "command": hook_c3_signal_cmd}]
5112
- },
5113
- {
5114
- "matcher": "mcp__c3__c3_status",
5115
- "hooks": [{"type": "command", "command": hook_c3_signal_cmd}]
5116
- },
5117
- {
5118
- "matcher": "mcp__c3__c3_delegate",
5119
- "hooks": [{"type": "command", "command": hook_c3_signal_cmd}]
5120
- },
5121
- {
5122
- "matcher": "mcp__c3__c3_session",
5123
- "hooks": [{"type": "command", "command": hook_c3_signal_cmd}]
5124
- },
5125
- {
5126
- "matcher": "mcp__c3__c3_agent",
5127
- "hooks": [
5128
- {"type": "command", "command": hook_edit_unlock_cmd},
5129
- {"type": "command", "command": hook_c3_signal_cmd},
5130
- ]
5131
- },
5132
- {
5133
- "matcher": edit_matcher,
5134
- "hooks": [{"type": "command", "command": hook_edit_ledger_cmd}]
5135
- },
5136
- {
5137
- "matcher": write_matcher,
5138
- "hooks": [{"type": "command", "command": hook_edit_ledger_cmd}]
5139
- },
5140
- *[
5141
- {"matcher": m, "hooks": [{"type": "command", "command": hook_edit_ledger_cmd}]}
5142
- for m in extra_edit_matchers
5143
- ],
5073
+ {"matcher": m, "hooks": [{"type": "command", "command": hook_posttool_cmd}]}
5074
+ for m in _post_matcher_names
5144
5075
  ]
5145
5076
 
5146
5077
  # ── PreToolUse hooks (enforcement — blocks native tools without prior c3_*) ──
5078
+ _pre_matcher_names = [
5079
+ read_matcher,
5080
+ grep_matcher,
5081
+ glob_matcher,
5082
+ edit_matcher,
5083
+ write_matcher,
5084
+ *extra_edit_matchers,
5085
+ ]
5147
5086
  desired_pre_hooks = [
5148
- {
5149
- "matcher": read_matcher,
5150
- "hooks": [{"type": "command", "command": hook_enforce_cmd}]
5151
- },
5152
- {
5153
- "matcher": grep_matcher,
5154
- "hooks": [{"type": "command", "command": hook_enforce_cmd}]
5155
- },
5156
- {
5157
- "matcher": glob_matcher,
5158
- "hooks": [{"type": "command", "command": hook_enforce_cmd}]
5159
- },
5160
- {
5161
- "matcher": edit_matcher,
5162
- "hooks": [{"type": "command", "command": hook_enforce_cmd}]
5163
- },
5164
- {
5165
- "matcher": write_matcher,
5166
- "hooks": [{"type": "command", "command": hook_enforce_cmd}]
5167
- },
5168
- *[
5169
- {"matcher": m, "hooks": [{"type": "command", "command": hook_enforce_cmd}]}
5170
- for m in extra_edit_matchers
5171
- ],
5087
+ {"matcher": m, "hooks": [{"type": "command", "command": hook_pretool_cmd}]}
5088
+ for m in _pre_matcher_names
5172
5089
  ]
5173
5090
 
5174
5091
  # Merge: replace existing C3 hooks (so re-running install-mcp updates commands),
@@ -5198,18 +5115,19 @@ def cmd_install_mcp(args):
5198
5115
  {
5199
5116
  "matcher": "",
5200
5117
  "hooks": [
5201
- {"type": "command", "command": hook_session_stats_cmd},
5202
- {"type": "command", "command": hook_auto_snapshot_cmd},
5203
- {"type": "command", "command": hook_terse_advisor_cmd},
5118
+ {"type": "command", "command": hook_stop_cmd},
5204
5119
  ]
5205
5120
  },
5206
5121
  ]
5207
5122
  stop_event = "Stop"
5208
5123
  # Replace only C3's own stop hooks (identified by our hook scripts) and
5209
5124
  # keep every user-added stop hook — including matcher-less ones, which
5210
- # are the normal shape for Stop hooks.
5125
+ # are the normal shape for Stop hooks. The pre-v2.42 script names stay
5126
+ # in this tuple so re-running install-mcp migrates old per-hook
5127
+ # entries to the dispatcher.
5211
5128
  _c3_stop_scripts = (
5212
5129
  "hook_session_stats.py", "hook_auto_snapshot.py", "hook_terse_advisor.py",
5130
+ "hook_dispatch.py",
5213
5131
  )
5214
5132
 
5215
5133
  def _is_c3_stop_hook(entry: dict) -> bool:
@@ -5259,9 +5177,9 @@ def cmd_install_mcp(args):
5259
5177
  json.dump(settings, f, indent=2)
5260
5178
 
5261
5179
  print(f"Wrote {settings_path}")
5262
- print(f" Hooks ({hook_event}): {shell_matcher} (filter+ghost) + {read_matcher}/{edit_matcher}/{write_matcher} (ledger) + c3_read/c3_compress/c3_agent (unlock)")
5263
- print(f" Hooks ({pre_event}): {read_matcher}/{grep_matcher}/{glob_matcher}/{edit_matcher}/{write_matcher} (c3 enforcement)")
5264
- print(" Hooks (Stop): session_stats + auto_snapshot")
5180
+ print(f" Hooks ({hook_event}): dispatcher (1 spawn/event) filter/ghost/read-guard/ledger/unlock/signal via cli/hook_dispatch.py posttool")
5181
+ print(f" Hooks ({pre_event}): dispatcher — {read_matcher}/{grep_matcher}/{glob_matcher}/{edit_matcher}/{write_matcher} (c3 enforcement)")
5182
+ print(" Hooks (Stop): dispatcher — session_stats + auto_snapshot + terse_advisor")
5265
5183
  if profile.name == "claude-code":
5266
5184
  print(" Claude MCP prompt settings enabled for this project")
5267
5185
  if perm_tier and profile.name == "claude-code":
cli/hook_auto_snapshot.py CHANGED
@@ -52,9 +52,11 @@ def _call_server(port: int, stop_hook_data: dict) -> bool:
52
52
  return False
53
53
 
54
54
 
55
- def _fallback_snapshot(stop_hook_data: dict) -> None:
55
+ def _fallback_snapshot(stop_hook_data: dict, base: Path | None = None) -> None:
56
56
  """Lightweight file-based snapshot when the UI server is not running."""
57
- c3_dir = Path(".c3")
57
+ if base is None:
58
+ base = Path.cwd()
59
+ c3_dir = base / ".c3"
58
60
  if not c3_dir.exists():
59
61
  return
60
62
 
@@ -116,6 +118,19 @@ def _fallback_snapshot(stop_hook_data: dict) -> None:
116
118
  json.dump(snapshot, f, indent=2)
117
119
 
118
120
 
121
+ def run(payload: dict, project_path: Path | None = None):
122
+ """Core logic — importable by the dispatcher and tests. Returns None."""
123
+ base = project_path if project_path is not None else Path.cwd()
124
+ port = _find_server_port(str(base))
125
+
126
+ if port and _call_server(port, payload):
127
+ return None
128
+
129
+ # Server not running or unreachable — fallback
130
+ _fallback_snapshot(payload, base)
131
+ return None
132
+
133
+
119
134
  def main() -> None:
120
135
  try:
121
136
  data = json.load(sys.stdin)
@@ -124,14 +139,7 @@ def main() -> None:
124
139
  sys.exit(0)
125
140
 
126
141
  try:
127
- project_path = str(Path.cwd())
128
- port = _find_server_port(project_path)
129
-
130
- if port and _call_server(port, data):
131
- sys.exit(0)
132
-
133
- # Server not running or unreachable — fallback
134
- _fallback_snapshot(data)
142
+ run(data)
135
143
  except Exception as exc:
136
144
  log_hook_error("hook_auto_snapshot", exc)
137
145
 
cli/hook_c3_signal.py CHANGED
@@ -1,58 +1,65 @@
1
1
  """PostToolUse hook: record c3_* tool call signal for enforcement.
2
2
 
3
3
  Fires after: c3_search, c3_compress, c3_filter, c3_memory, c3_validate,
4
- c3_session, c3_status, c3_impact, c3_agent, c3_shell.
4
+ c3_session, c3_status, c3_impact, c3_agent, c3_shell,
5
+ c3_read, c3_edit, c3_edits, c3_delegate.
5
6
 
6
- Writes .c3/last_c3_call.json:
7
+ Writes the "last_c3_call" section of .c3/enforcement_state.json (via the
8
+ consolidated state layer in cli/_hook_utils.py):
7
9
  {
8
- "timestamp": "...", ISO UTC timestamp
9
- "tool": "c3_search", short tool name (without mcp__c3__ prefix)
10
- "read_unlocked": true/false true for search/compress/filter
10
+ "session_id": "...",
11
+ "last_c3_call": {
12
+ "ts": "...", ISO UTC timestamp
13
+ "tool": "c3_search", short tool name (without mcp__c3__ prefix)
14
+ "read_unlocked": true/false true for search/compress/filter/read/impact/validate
15
+ },
16
+ ...
11
17
  }
12
18
 
13
- hook_pretool_enforce.py reads this file as the primary recency check.
14
- It replaces the fragile LOOKBACK-3 activity-log scan in long sessions.
19
+ hook_pretool_enforce.py reads this as the primary recency check. Pre-v2.42
20
+ installs wrote .c3/last_c3_call.json; that file is still read as a fallback
21
+ for one release but is no longer written.
15
22
  """
16
23
  import json
17
24
  import sys
18
- from datetime import datetime, timezone
19
25
  from pathlib import Path
20
26
 
21
27
  sys.path.insert(0, str(Path(__file__).parent.parent))
22
28
 
23
- from cli._hook_utils import log_hook_error # noqa: E402
29
+ from cli._hook_utils import log_hook_error, record_c3_signal # noqa: E402
24
30
 
25
31
  # Tools that unlock generic read operations (Grep/Glob without a file path)
26
32
  _READ_UNLOCK_TOOLS = {"c3_search", "c3_compress", "c3_filter", "c3_read", "c3_impact", "c3_validate"}
27
33
 
28
- _SIGNAL_FILE = ".c3/last_c3_call.json"
29
34
 
35
+ def run(payload: dict, project_path: Path | None = None):
36
+ """Record the c3_* signal in the consolidated enforcement state.
30
37
 
31
- def main() -> None:
32
- try:
33
- raw = sys.stdin.read()
34
- if not raw.strip():
35
- return
36
-
37
- data = json.loads(raw)
38
- raw_tool = data.get("tool_name", "")
38
+ Returns None this hook produces no output for the model.
39
+ """
40
+ raw_tool = payload.get("tool_name", "")
39
41
 
40
- # Strip mcp__c3__ prefix → short name (e.g. "c3_search")
41
- short_name = raw_tool.replace("mcp__c3__", "") if "mcp__c3__" in raw_tool else raw_tool
42
+ # Strip mcp__c3__ prefix → short name (e.g. "c3_search")
43
+ short_name = raw_tool.replace("mcp__c3__", "") if "mcp__c3__" in raw_tool else raw_tool
42
44
 
43
- if not short_name.startswith("c3_"):
44
- return
45
+ if not short_name.startswith("c3_"):
46
+ return None
45
47
 
46
- signal = {
47
- "timestamp": datetime.now(timezone.utc).isoformat(),
48
- "tool": short_name,
49
- "read_unlocked": short_name in _READ_UNLOCK_TOOLS,
50
- }
48
+ record_c3_signal(
49
+ short_name,
50
+ short_name in _READ_UNLOCK_TOOLS,
51
+ session_id=str(payload.get("session_id") or ""),
52
+ project_path=project_path,
53
+ )
54
+ return None
51
55
 
52
- signal_path = Path.cwd() / _SIGNAL_FILE
53
- signal_path.parent.mkdir(parents=True, exist_ok=True)
54
- signal_path.write_text(json.dumps(signal, indent=2), encoding="utf-8")
55
56
 
57
+ def main() -> None:
58
+ try:
59
+ raw = sys.stdin.read()
60
+ if not raw.strip():
61
+ return
62
+ run(json.loads(raw))
56
63
  except Exception as exc:
57
64
  log_hook_error("hook_c3_signal", exc)
58
65