code-context-control 2.118.2__py3-none-any.whl → 2.120.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 +9 -19
- cli/c3.py +85 -124
- cli/commands/parser.py +19 -0
- cli/hook_codex_lifecycle.py +60 -0
- cli/hook_dispatch.py +60 -5
- cli/hook_edit_ledger.py +3 -3
- cli/hook_session_stats.py +26 -0
- cli/mcp_proxy.py +7 -6
- cli/mcp_server.py +30 -45
- cli/tools/compress.py +51 -3
- cli/tools/delegate.py +104 -64
- cli/tools/read.py +19 -3
- cli/tools/status.py +16 -6
- {code_context_control-2.118.2.dist-info → code_context_control-2.120.0.dist-info}/METADATA +8 -1
- {code_context_control-2.118.2.dist-info → code_context_control-2.120.0.dist-info}/RECORD +36 -30
- core/codex_patch.py +46 -0
- core/host.py +37 -0
- core/ide.py +7 -7
- core/mcp_toml.py +39 -0
- services/access_guard.py +1 -0
- services/artifact_defs.py +1 -0
- services/atomic_json.py +9 -4
- services/bench/map_eval.py +999 -0
- services/claude_md.py +4 -0
- services/codex_integration.py +143 -0
- services/codex_transcript.py +78 -0
- services/compressor.py +4 -1
- services/conversation_store.py +38 -0
- services/file_memory.py +25 -5
- services/runtime.py +2 -0
- services/session_manager.py +8 -5
- services/telemetry.py +147 -8
- {code_context_control-2.118.2.dist-info → code_context_control-2.120.0.dist-info}/WHEEL +0 -0
- {code_context_control-2.118.2.dist-info → code_context_control-2.120.0.dist-info}/entry_points.txt +0 -0
- {code_context_control-2.118.2.dist-info → code_context_control-2.120.0.dist-info}/licenses/LICENSE +0 -0
- {code_context_control-2.118.2.dist-info → code_context_control-2.120.0.dist-info}/top_level.txt +0 -0
cli/_hook_utils.py
CHANGED
|
@@ -375,30 +375,20 @@ HOST_GEMINI = "gemini"
|
|
|
375
375
|
HOST_CODEX = "codex"
|
|
376
376
|
|
|
377
377
|
|
|
378
|
-
def detect_host(data: dict) -> str:
|
|
379
|
-
"""
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
Codex MUST be checked before Gemini: Codex declares `tool_response` as
|
|
386
|
-
free-form, so an MCP tool that returns an object would otherwise trip the
|
|
387
|
-
isinstance-dict test and be mistaken for Gemini.
|
|
388
|
-
|
|
389
|
-
The CODEX_* env fallback covers a future Codex that drops `turn_id`. A
|
|
390
|
-
false positive there degrades gracefully — Claude Code also accepts
|
|
391
|
-
hookSpecificOutput.additionalContext — so guessing "codex" is the safe
|
|
392
|
-
direction to be wrong in.
|
|
393
|
-
"""
|
|
378
|
+
def detect_host(data: dict, explicit_host: str | None = None) -> str:
|
|
379
|
+
"""Use the hook's explicit host or wire identity, never its parent's env."""
|
|
380
|
+
named = explicit_host or (data.get("_c3_host") if isinstance(data, dict) else None)
|
|
381
|
+
aliases = {"claude-code": HOST_CLAUDE, "claude": HOST_CLAUDE,
|
|
382
|
+
"codex": HOST_CODEX, "gemini": HOST_GEMINI, "antigravity": HOST_GEMINI}
|
|
383
|
+
if named in aliases:
|
|
384
|
+
return aliases[named]
|
|
394
385
|
if not isinstance(data, dict):
|
|
395
386
|
return HOST_CLAUDE
|
|
396
387
|
if data.get("turn_id"):
|
|
397
388
|
return HOST_CODEX
|
|
398
|
-
|
|
389
|
+
response = data.get("tool_response")
|
|
390
|
+
if isinstance(response, dict) and ("llmContent" in response or "returnDisplay" in response):
|
|
399
391
|
return HOST_GEMINI
|
|
400
|
-
if os.environ.get("CODEX_THREAD_ID") or os.environ.get("CODEX_MANAGED_BY_NPM"):
|
|
401
|
-
return HOST_CODEX
|
|
402
392
|
return HOST_CLAUDE
|
|
403
393
|
|
|
404
394
|
|
cli/c3.py
CHANGED
|
@@ -92,7 +92,7 @@ console = Console() if HAS_RICH else None
|
|
|
92
92
|
# Config
|
|
93
93
|
CONFIG_DIR = ".c3"
|
|
94
94
|
CONFIG_FILE = ".c3/config.json"
|
|
95
|
-
__version__ = "2.
|
|
95
|
+
__version__ = "2.120.0"
|
|
96
96
|
|
|
97
97
|
|
|
98
98
|
def _compress_file_cli(compressor, path, mode="smart", **kw):
|
|
@@ -1570,6 +1570,42 @@ def cmd_shell_eval(args):
|
|
|
1570
1570
|
sys.exit(1)
|
|
1571
1571
|
|
|
1572
1572
|
|
|
1573
|
+
def cmd_map_eval(args):
|
|
1574
|
+
"""Run the file-map gold suite and compare it to its baseline.
|
|
1575
|
+
|
|
1576
|
+
Renders the hand-annotated fixtures through the renderer the MCP tool
|
|
1577
|
+
uses and grades symbol recall, signatures, ranges and determinism. Exit
|
|
1578
|
+
status 1 when the baseline is violated (a must_pass case failed, an
|
|
1579
|
+
aggregate fell under its floor or rose over its ceiling, a complete map
|
|
1580
|
+
grew). See docs/map-eval.md.
|
|
1581
|
+
"""
|
|
1582
|
+
from services.bench import map_eval as me
|
|
1583
|
+
|
|
1584
|
+
try:
|
|
1585
|
+
report = me.run_suite(args.suite, baseline=args.baseline)
|
|
1586
|
+
except FileNotFoundError as e:
|
|
1587
|
+
raise RuntimeError(str(e))
|
|
1588
|
+
|
|
1589
|
+
if args.update_baseline:
|
|
1590
|
+
path = args.baseline or me.BUNDLED_BASELINES.get(report.suite)
|
|
1591
|
+
if not path:
|
|
1592
|
+
raise RuntimeError(
|
|
1593
|
+
f"suite {report.suite!r} has no bundled baseline; pass --baseline PATH")
|
|
1594
|
+
floors = json.loads(args.floors) if args.floors else None
|
|
1595
|
+
ceilings = json.loads(args.ceilings) if args.ceilings else None
|
|
1596
|
+
me.write_baseline(report, path, floors=floors, ceilings=ceilings)
|
|
1597
|
+
report.baseline_violations, report.baseline_warnings = me.compare_to_baseline(
|
|
1598
|
+
report, me.load_baseline(path))
|
|
1599
|
+
print(f"baseline written: {path}")
|
|
1600
|
+
|
|
1601
|
+
if args.json:
|
|
1602
|
+
print(json.dumps(report.to_dict(), indent=2))
|
|
1603
|
+
else:
|
|
1604
|
+
print(report.render())
|
|
1605
|
+
if report.baseline_violations:
|
|
1606
|
+
sys.exit(1)
|
|
1607
|
+
|
|
1608
|
+
|
|
1573
1609
|
def _benchmark_extract_preview(full_path: Path, compressor: CodeCompressor, pattern: str = "", max_lines: int = 50) -> str:
|
|
1574
1610
|
"""Approximate c3_filter behavior for local benchmarking without MCP startup."""
|
|
1575
1611
|
import re as _re
|
|
@@ -4298,17 +4334,9 @@ _C3_HOOKLESS_WORKFLOW = _adapt_workflow_for_ide(
|
|
|
4298
4334
|
|
|
4299
4335
|
_COPILOT_INSTRUCTIONS_CONTENT = _VSCODE_SESSION_INIT + "\n\n" + _C3_HOOKLESS_WORKFLOW
|
|
4300
4336
|
|
|
4301
|
-
|
|
4337
|
+
from services.codex_integration import CODEX_WORKFLOW
|
|
4302
4338
|
|
|
4303
|
-
|
|
4304
|
-
This project uses project-scoped MCP servers. Ensure your `.codex/config.toml` includes:
|
|
4305
|
-
```toml
|
|
4306
|
-
[mcp_servers.c3]
|
|
4307
|
-
command = "c3-mcp"
|
|
4308
|
-
args = ["--project", "."]
|
|
4309
|
-
enabled = true
|
|
4310
|
-
```
|
|
4311
|
-
"""
|
|
4339
|
+
_AGENTS_MD_CONTENT = CODEX_WORKFLOW
|
|
4312
4340
|
|
|
4313
4341
|
_TERSE_SKILL_CONTENT = """\
|
|
4314
4342
|
# /terse — Terse Output Mode
|
|
@@ -4426,50 +4454,13 @@ def _toml_escape_str(value: str) -> str:
|
|
|
4426
4454
|
|
|
4427
4455
|
|
|
4428
4456
|
def _upsert_toml_section(toml_path: Path, section: str, entries: dict) -> None:
|
|
4429
|
-
|
|
4430
|
-
|
|
4431
|
-
|
|
4432
|
-
|
|
4433
|
-
|
|
4434
|
-
|
|
4435
|
-
|
|
4436
|
-
|
|
4437
|
-
# Strip existing section (header + its key=value lines). Also strip any
|
|
4438
|
-
# dotted child subtables (e.g. "[mcp_servers.c3.env]" under
|
|
4439
|
-
# "[mcp_servers.c3]") so they are not orphaned beneath the re-appended
|
|
4440
|
-
# section, which would corrupt the file on re-run.
|
|
4441
|
-
child_prefix = f"{header[:-1]}." # "[mcp_servers.c3]" -> "[mcp_servers.c3."
|
|
4442
|
-
lines = content.splitlines()
|
|
4443
|
-
new_lines: list[str] = []
|
|
4444
|
-
skip = False
|
|
4445
|
-
for line in lines:
|
|
4446
|
-
stripped = line.strip()
|
|
4447
|
-
if stripped == header:
|
|
4448
|
-
skip = True
|
|
4449
|
-
continue
|
|
4450
|
-
if skip and stripped.startswith("["):
|
|
4451
|
-
if not stripped.startswith(child_prefix):
|
|
4452
|
-
skip = False
|
|
4453
|
-
if not skip:
|
|
4454
|
-
new_lines.append(line)
|
|
4455
|
-
|
|
4456
|
-
content = "\n".join(new_lines).rstrip()
|
|
4457
|
-
|
|
4458
|
-
# Build new section
|
|
4459
|
-
section_lines = [f"\n\n{header}"]
|
|
4460
|
-
for k, v in entries.items():
|
|
4461
|
-
if isinstance(v, list):
|
|
4462
|
-
# Escape each list item individually
|
|
4463
|
-
items = ", ".join(f'"{_toml_escape_str(x)}"' for x in v)
|
|
4464
|
-
section_lines.append(f'"{k}" = [{items}]')
|
|
4465
|
-
elif isinstance(v, bool):
|
|
4466
|
-
section_lines.append(f'"{k}" = {"true" if v else "false"}')
|
|
4467
|
-
else:
|
|
4468
|
-
section_lines.append(f'"{k}" = "{_toml_escape_str(v)}"')
|
|
4469
|
-
section_lines.append("")
|
|
4470
|
-
|
|
4471
|
-
toml_path.parent.mkdir(parents=True, exist_ok=True)
|
|
4472
|
-
toml_path.write_text(content + "\n".join(section_lines), encoding="utf-8")
|
|
4457
|
+
from core.mcp_toml import merge_toml_section
|
|
4458
|
+
owned = dict(entries)
|
|
4459
|
+
defaults = {}
|
|
4460
|
+
for key in ("enabled", "startup_timeout_sec", "tool_timeout_sec"):
|
|
4461
|
+
if key in owned:
|
|
4462
|
+
defaults[key] = owned.pop(key)
|
|
4463
|
+
merge_toml_section(toml_path, section, owned, defaults)
|
|
4473
4464
|
|
|
4474
4465
|
|
|
4475
4466
|
def _toml_section_bool_value(toml_path: Path, section: str, key: str) -> bool | None:
|
|
@@ -4555,20 +4546,10 @@ def _ensure_instruction_workflow(instructions_path: Path, template: str,
|
|
|
4555
4546
|
def _ensure_codex_agents_workflow(agents_md_path: Path,
|
|
4556
4547
|
project_path: str = "") -> str:
|
|
4557
4548
|
"""Ensure AGENTS.md contains the mandatory C3 workflow for Codex sessions."""
|
|
4558
|
-
|
|
4559
|
-
|
|
4560
|
-
|
|
4561
|
-
|
|
4562
|
-
"Anti-patterns",
|
|
4563
|
-
"c3_search",
|
|
4564
|
-
"c3_read",
|
|
4565
|
-
"c3_validate",
|
|
4566
|
-
"c3_edit",
|
|
4567
|
-
"c3_memory",
|
|
4568
|
-
"c3_filter",
|
|
4569
|
-
]
|
|
4570
|
-
return _ensure_instruction_workflow(agents_md_path, _AGENTS_MD_CONTENT,
|
|
4571
|
-
required_markers, project_path)
|
|
4549
|
+
from services.claude_md import write_c3_instruction_doc
|
|
4550
|
+
existing = agents_md_path.read_text(encoding="utf-8") if agents_md_path.exists() else None
|
|
4551
|
+
rendered = write_c3_instruction_doc(agents_md_path, _AGENTS_MD_CONTENT, project_path or None)
|
|
4552
|
+
return "written" if existing is None else ("kept" if rendered == existing else "updated")
|
|
4572
4553
|
|
|
4573
4554
|
|
|
4574
4555
|
def _ensure_vscode_instructions_workflow(instructions_path: Path,
|
|
@@ -4654,6 +4635,7 @@ def _ensure_project_session_configs(target: Path, server_script: str, primary_pr
|
|
|
4654
4635
|
else:
|
|
4655
4636
|
mcp_command = "python"
|
|
4656
4637
|
server_args = [server_script_posix, "--project", target.as_posix()]
|
|
4638
|
+
server_args += ["--host", "codex"]
|
|
4657
4639
|
|
|
4658
4640
|
if primary_profile != "codex":
|
|
4659
4641
|
codex_path = target / ".codex" / "config.toml"
|
|
@@ -4674,18 +4656,20 @@ def _ensure_project_session_configs(target: Path, server_script: str, primary_pr
|
|
|
4674
4656
|
|
|
4675
4657
|
def _ensure_global_session_fallbacks(server_script: str, c3_mcp_exe: str | None = None,
|
|
4676
4658
|
primary_profile: str | None = None) -> None:
|
|
4677
|
-
"""
|
|
4659
|
+
"""Install an explicitly requested user-global Codex fallback.
|
|
4678
4660
|
|
|
4679
4661
|
These fallback entries omit `--project` so the MCP server can resolve the
|
|
4680
4662
|
active working directory dynamically when a session starts in a project that
|
|
4681
4663
|
does not yet have a project-local Codex config file.
|
|
4682
4664
|
"""
|
|
4665
|
+
if primary_profile not in (None, "codex"):
|
|
4666
|
+
return
|
|
4683
4667
|
server_script_posix = Path(server_script).as_posix()
|
|
4684
4668
|
# With the installed entry point, no script path is needed; --project stays
|
|
4685
4669
|
# omitted so the server resolves the working directory at session start.
|
|
4686
|
-
fallback_args = [] if c3_mcp_exe else [server_script_posix]
|
|
4670
|
+
fallback_args = (["--host", "codex"] if c3_mcp_exe else [server_script_posix, "--host", "codex"])
|
|
4687
4671
|
|
|
4688
|
-
codex_path = Path.home() / ".codex" / "config.toml"
|
|
4672
|
+
codex_path = Path(os.environ.get("CODEX_HOME") or Path.home() / ".codex") / "config.toml"
|
|
4689
4673
|
try:
|
|
4690
4674
|
codex_state = "updated" if codex_path.exists() else "written"
|
|
4691
4675
|
_upsert_toml_section(
|
|
@@ -4701,27 +4685,6 @@ def _ensure_global_session_fallbacks(server_script: str, c3_mcp_exe: str | None
|
|
|
4701
4685
|
except PermissionError:
|
|
4702
4686
|
print(f"Warning: Could not update {codex_path} (global fallback skipped)")
|
|
4703
4687
|
|
|
4704
|
-
# Antigravity shares the ~/.gemini home dir but reads its own MCP config.
|
|
4705
|
-
# When Antigravity is the primary profile, the main install flow already
|
|
4706
|
-
# wrote this file — here we only keep it fresh for codex installs
|
|
4707
|
-
# on machines that have Antigravity (its config dir exists).
|
|
4708
|
-
antigravity_path = Path.home() / ".gemini" / "antigravity" / "mcp_config.json"
|
|
4709
|
-
if primary_profile != "antigravity" and antigravity_path.parent.is_dir():
|
|
4710
|
-
try:
|
|
4711
|
-
ag_state = _upsert_json_mcp_server(
|
|
4712
|
-
antigravity_path,
|
|
4713
|
-
"mcpServers",
|
|
4714
|
-
"c3",
|
|
4715
|
-
{
|
|
4716
|
-
"command": c3_mcp_exe or sys.executable,
|
|
4717
|
-
"args": fallback_args,
|
|
4718
|
-
},
|
|
4719
|
-
)
|
|
4720
|
-
print(f"{ag_state.capitalize()} {antigravity_path} (global fallback)")
|
|
4721
|
-
except PermissionError:
|
|
4722
|
-
print(f"Warning: Could not update {antigravity_path} (global fallback skipped)")
|
|
4723
|
-
|
|
4724
|
-
|
|
4725
4688
|
# Every hook script C3 has ever registered in a settings file — the v2.42+
|
|
4726
4689
|
# dispatcher plus the pre-v2.42 per-hook scripts — so uninstall recognises an
|
|
4727
4690
|
# entry written by any C3 version. A user hook is anything not naming one.
|
|
@@ -4926,7 +4889,7 @@ def _uninstall_mcp_all(project_path: str, include_global: bool = True):
|
|
|
4926
4889
|
except Exception as e:
|
|
4927
4890
|
print(f" Warning: Could not update {mcp_config_path}: {e}")
|
|
4928
4891
|
|
|
4929
|
-
#
|
|
4892
|
+
# Per-host hooks and settings; remove only C3-owned handlers.
|
|
4930
4893
|
if profile.supports_hooks and profile.settings_path:
|
|
4931
4894
|
settings_path = target / profile.settings_path
|
|
4932
4895
|
if settings_path.exists():
|
|
@@ -5475,9 +5438,9 @@ def cmd_install_mcp(args):
|
|
|
5475
5438
|
# Keep the script path as a single arg; 'python' keeps the source fallback
|
|
5476
5439
|
# portable across platforms.
|
|
5477
5440
|
if c3_mcp_exe:
|
|
5478
|
-
new_entry = {"command": c3_mcp_exe, "args": ["--project", "."]}
|
|
5441
|
+
new_entry = {"command": c3_mcp_exe, "args": ["--project", ".", "--host", profile.name]}
|
|
5479
5442
|
else:
|
|
5480
|
-
new_entry = {"command": "python", "args": [server_script, "--project", "."]}
|
|
5443
|
+
new_entry = {"command": "python", "args": [server_script, "--project", ".", "--host", profile.name]}
|
|
5481
5444
|
if profile.needs_type_field:
|
|
5482
5445
|
new_entry["type"] = "stdio"
|
|
5483
5446
|
|
|
@@ -5489,24 +5452,7 @@ def cmd_install_mcp(args):
|
|
|
5489
5452
|
mcp_config_path = target / profile.config_path
|
|
5490
5453
|
mcp_config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
5491
5454
|
|
|
5492
|
-
#
|
|
5493
|
-
if profile.config_path != ".mcp.json":
|
|
5494
|
-
mcp_json_legacy = target / ".mcp.json"
|
|
5495
|
-
if mcp_json_legacy.exists():
|
|
5496
|
-
try:
|
|
5497
|
-
with open(mcp_json_legacy, 'r', encoding="utf-8") as f:
|
|
5498
|
-
legacy_data = json.load(f)
|
|
5499
|
-
if "mcpServers" in legacy_data and "c3" in legacy_data["mcpServers"]:
|
|
5500
|
-
del legacy_data["mcpServers"]["c3"]
|
|
5501
|
-
if not legacy_data["mcpServers"]:
|
|
5502
|
-
mcp_json_legacy.unlink()
|
|
5503
|
-
print(f" Removed obsolete {mcp_json_legacy}")
|
|
5504
|
-
else:
|
|
5505
|
-
with open(mcp_json_legacy, 'w', encoding="utf-8") as f:
|
|
5506
|
-
json.dump(legacy_data, f, indent=2)
|
|
5507
|
-
print(f" Removed C3 from obsolete {mcp_json_legacy}")
|
|
5508
|
-
except Exception:
|
|
5509
|
-
pass
|
|
5455
|
+
# Integrations coexist: only the selected host owns this install.
|
|
5510
5456
|
|
|
5511
5457
|
try:
|
|
5512
5458
|
if profile.config_format == "toml":
|
|
@@ -5518,6 +5464,9 @@ def cmd_install_mcp(args):
|
|
|
5518
5464
|
if profile.name == "codex":
|
|
5519
5465
|
# Codex supports explicit enable/disable per server.
|
|
5520
5466
|
toml_entries["enabled"] = True
|
|
5467
|
+
toml_entries["startup_timeout_sec"] = 30
|
|
5468
|
+
toml_entries["tool_timeout_sec"] = 60
|
|
5469
|
+
toml_entries["args"] += ["--host", "codex"]
|
|
5521
5470
|
_upsert_toml_section(
|
|
5522
5471
|
mcp_config_path,
|
|
5523
5472
|
f"{profile.config_key}.c3",
|
|
@@ -5548,8 +5497,7 @@ def cmd_install_mcp(args):
|
|
|
5548
5497
|
# instead of letting it surface as anonymous out-of-band drift.
|
|
5549
5498
|
from services.artifact_defs import note_pending_write
|
|
5550
5499
|
note_pending_write(target, profile.config_path, "install_mcp")
|
|
5551
|
-
if profile.name
|
|
5552
|
-
_ensure_project_session_configs(target, server_script, primary_profile=profile.name, c3_mcp_exe=c3_mcp_exe)
|
|
5500
|
+
if getattr(args, "global_fallback", False) and profile.name == "codex":
|
|
5553
5501
|
_ensure_global_session_fallbacks(server_script, c3_mcp_exe=c3_mcp_exe, primary_profile=profile.name)
|
|
5554
5502
|
|
|
5555
5503
|
# ── Persist IDE choice to .c3/config.json ──
|
|
@@ -5559,13 +5507,18 @@ def cmd_install_mcp(args):
|
|
|
5559
5507
|
|
|
5560
5508
|
c3_config = _safe_read_json(c3_config_path, ".c3/config.json")
|
|
5561
5509
|
|
|
5562
|
-
c3_config
|
|
5510
|
+
c3_config.setdefault("ide", ide_name)
|
|
5511
|
+
c3_config["installed_ides"] = sorted(set(c3_config.get("installed_ides", [])) | {ide_name})
|
|
5563
5512
|
c3_config["mcp"] = {"mode": mcp_mode}
|
|
5564
5513
|
with open(c3_config_path, 'w', encoding="utf-8") as f:
|
|
5565
5514
|
json.dump(c3_config, f, indent=2)
|
|
5566
5515
|
|
|
5567
5516
|
# ── Install hooks (Claude Code) ──
|
|
5568
|
-
if profile.
|
|
5517
|
+
if profile.name == "codex":
|
|
5518
|
+
from services.codex_integration import install_hooks
|
|
5519
|
+
hook_state = install_hooks(target, sys.executable, cli_dir / "hook_dispatch.py")
|
|
5520
|
+
print(f"Codex hooks installed; active: {hook_state['active']}. {hook_state['activation']}")
|
|
5521
|
+
if profile.name == "claude-code" and profile.supports_hooks and profile.settings_path:
|
|
5569
5522
|
settings_dir = target / Path(profile.settings_path).parent
|
|
5570
5523
|
settings_dir.mkdir(parents=True, exist_ok=True)
|
|
5571
5524
|
settings_path = target / profile.settings_path
|
|
@@ -5851,7 +5804,7 @@ def cmd_install_mcp(args):
|
|
|
5851
5804
|
print(f"Kept {agents_md_path} (C3 workflow present)")
|
|
5852
5805
|
|
|
5853
5806
|
# Warn about a common conflict: global Codex config disables c3.
|
|
5854
|
-
global_codex_cfg = Path.home() / ".codex" / "config.toml"
|
|
5807
|
+
global_codex_cfg = Path(os.environ.get("CODEX_HOME") or Path.home() / ".codex") / "config.toml"
|
|
5855
5808
|
global_enabled = _toml_section_bool_value(global_codex_cfg, "mcp_servers.c3", "enabled")
|
|
5856
5809
|
if global_enabled is False:
|
|
5857
5810
|
print(f"Warning: {global_codex_cfg} has [mcp_servers.c3] enabled = false.")
|
|
@@ -5860,10 +5813,11 @@ def cmd_install_mcp(args):
|
|
|
5860
5813
|
_sync_project_instruction_docs(str(target), sm)
|
|
5861
5814
|
|
|
5862
5815
|
# ── User-global C3 enforcement ──────────────────────────────
|
|
5863
|
-
|
|
5864
|
-
|
|
5865
|
-
|
|
5866
|
-
|
|
5816
|
+
if profile.name == "claude-code":
|
|
5817
|
+
try:
|
|
5818
|
+
_ensure_global_claude_md()
|
|
5819
|
+
except Exception as e:
|
|
5820
|
+
print(f"Warning: Could not update global CLAUDE.md: {e}")
|
|
5867
5821
|
|
|
5868
5822
|
# ── Default Access Guard rules (first run only) ─────────────
|
|
5869
5823
|
from services import access_guard
|
|
@@ -5874,7 +5828,7 @@ def cmd_install_mcp(args):
|
|
|
5874
5828
|
print(" (remove with: c3 access remove <glob> --kind deny --global)")
|
|
5875
5829
|
|
|
5876
5830
|
# ── Install /terse skill for supported IDEs ──────────────────
|
|
5877
|
-
if profile.name
|
|
5831
|
+
if profile.name == "claude-code":
|
|
5878
5832
|
try:
|
|
5879
5833
|
_ensure_terse_skill(profile.name)
|
|
5880
5834
|
except Exception as e:
|
|
@@ -9292,6 +9246,11 @@ def _launch_tui() -> None:
|
|
|
9292
9246
|
pass
|
|
9293
9247
|
|
|
9294
9248
|
|
|
9249
|
+
def cmd_doctor(args):
|
|
9250
|
+
from services.codex_integration import diagnose
|
|
9251
|
+
print(json.dumps(diagnose(Path(args.project_path).resolve()), indent=2))
|
|
9252
|
+
|
|
9253
|
+
|
|
9295
9254
|
def main():
|
|
9296
9255
|
# Force UTF-8 on the CLI streams so server-supplied text (PR titles, branch
|
|
9297
9256
|
# names, diffs) and our own glyphs render cleanly on Windows cp1252 consoles
|
|
@@ -9325,6 +9284,7 @@ def main():
|
|
|
9325
9284
|
return
|
|
9326
9285
|
|
|
9327
9286
|
commands = {
|
|
9287
|
+
"doctor": cmd_doctor,
|
|
9328
9288
|
"init": cmd_init,
|
|
9329
9289
|
"index": cmd_index,
|
|
9330
9290
|
"compress": cmd_compress,
|
|
@@ -9337,6 +9297,7 @@ def main():
|
|
|
9337
9297
|
"stats": cmd_stats,
|
|
9338
9298
|
"search-eval": cmd_search_eval,
|
|
9339
9299
|
"shell-eval": cmd_shell_eval,
|
|
9300
|
+
"map-eval": cmd_map_eval,
|
|
9340
9301
|
"benchmark": cmd_benchmark,
|
|
9341
9302
|
"session-benchmark": cmd_session_benchmark,
|
|
9342
9303
|
"benchmark-e2e": cmd_benchmark_e2e,
|
cli/commands/parser.py
CHANGED
|
@@ -17,6 +17,10 @@ def build_parser(version: str, parse_cli_ide_arg):
|
|
|
17
17
|
)
|
|
18
18
|
subparsers = parser.add_subparsers(dest="command")
|
|
19
19
|
|
|
20
|
+
p_doctor = subparsers.add_parser("doctor", help="Inspect native Codex integration without a model call")
|
|
21
|
+
p_doctor.add_argument("project_path", nargs="?", default=".")
|
|
22
|
+
p_doctor.add_argument("--ide", choices=["codex"], default="codex")
|
|
23
|
+
|
|
20
24
|
p_init = subparsers.add_parser("init", help="Initialize C3 for a project")
|
|
21
25
|
p_init.add_argument("project_path", nargs="?", default=".")
|
|
22
26
|
p_init.add_argument("--force", action="store_true", help="Skip prompts and apply update non-interactively")
|
|
@@ -72,6 +76,19 @@ def build_parser(version: str, parse_cli_ide_arg):
|
|
|
72
76
|
help='With --update-baseline: JSON object of metric ceilings, e.g. \'{"bytes_p95": 18432}\'')
|
|
73
77
|
p_shell_eval.add_argument("--json", action="store_true", help="Emit the full report as JSON")
|
|
74
78
|
|
|
79
|
+
p_map_eval = subparsers.add_parser(
|
|
80
|
+
"map-eval", help="Run the file-map gold suite (docs/map-eval.md)")
|
|
81
|
+
p_map_eval.add_argument("--suite", default="fixture",
|
|
82
|
+
help="'fixture' (hand-annotated sources, CI gate) or a .jsonl path")
|
|
83
|
+
p_map_eval.add_argument("--baseline", default=None, help="Baseline JSON to compare against / write")
|
|
84
|
+
p_map_eval.add_argument("--update-baseline", action="store_true",
|
|
85
|
+
help="Write aggregates + per-case status to the baseline (floors/ceilings are kept)")
|
|
86
|
+
p_map_eval.add_argument("--floors", default=None,
|
|
87
|
+
help='With --update-baseline: JSON object of metric floors, e.g. \'{"symbol_recall_mean": 0.9}\'')
|
|
88
|
+
p_map_eval.add_argument("--ceilings", default=None,
|
|
89
|
+
help='With --update-baseline: JSON object of metric ceilings, e.g. \'{"tokens_p95": 4000}\'')
|
|
90
|
+
p_map_eval.add_argument("--json", action="store_true", help="Emit the full report as JSON")
|
|
91
|
+
|
|
75
92
|
p_compress = subparsers.add_parser("compress", help="Compress a file")
|
|
76
93
|
p_compress.add_argument("file", help="File to compress")
|
|
77
94
|
p_compress.add_argument("--mode", choices=["map", "dense_map", "smart", "diff"], default="smart")
|
|
@@ -137,6 +154,7 @@ def build_parser(version: str, parse_cli_ide_arg):
|
|
|
137
154
|
p_install_mcp.add_argument("targets", nargs="*", help="Optional project path and/or IDE shorthand (for example: `claude` or `. codex`)")
|
|
138
155
|
p_install_mcp.add_argument("--ide", default="auto", type=parse_cli_ide_arg, metavar="{auto,claude,vscode,cursor,codex,antigravity}", help="Target IDE (default: auto-detect)")
|
|
139
156
|
p_install_mcp.add_argument("--mcp-mode", choices=["direct", "proxy"], default="direct", help="MCP entrypoint mode (default: direct)")
|
|
157
|
+
p_install_mcp.add_argument("--global-fallback", action="store_true", help="Also install the machine-wide Codex fallback")
|
|
140
158
|
p_install_mcp.add_argument("--permissions", choices=["read-only", "c3-strict", "standard", "permissive"], default=None, help="Apply Claude Code permission tier (Claude Code only)")
|
|
141
159
|
p_install_mcp.add_argument("--include-mcp-wildcard", action="store_true", help="Add mcp__* wildcard so non-C3 MCP servers don't prompt per-call")
|
|
142
160
|
|
|
@@ -144,6 +162,7 @@ def build_parser(version: str, parse_cli_ide_arg):
|
|
|
144
162
|
p_mcp_install.add_argument("targets", nargs="*", help="Optional project path and/or IDE shorthand")
|
|
145
163
|
p_mcp_install.add_argument("--ide", default="auto", type=parse_cli_ide_arg, metavar="{auto,claude,vscode,cursor,codex,antigravity}", help="Target IDE (default: auto-detect)")
|
|
146
164
|
p_mcp_install.add_argument("--mcp-mode", choices=["direct", "proxy"], default="direct", help="MCP entrypoint mode (default: direct)")
|
|
165
|
+
p_mcp_install.add_argument("--global-fallback", action="store_true", help="Also install the machine-wide Codex fallback")
|
|
147
166
|
p_mcp_install.add_argument("--permissions", choices=["read-only", "c3-strict", "standard", "permissive"], default=None, help="Apply Claude Code permission tier (Claude Code only)")
|
|
148
167
|
p_mcp_install.add_argument("--include-mcp-wildcard", action="store_true", help="Add mcp__* wildcard so non-C3 MCP servers don't prompt per-call")
|
|
149
168
|
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Thread-bound C3 handoff context for Codex lifecycle events."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def checkpoint(base: Path, data: dict) -> None:
|
|
8
|
+
"""Persist only the current thread's handoff, without ending its C3 session."""
|
|
9
|
+
host_id = str(data.get("host_session_id") or "")
|
|
10
|
+
if not host_id or data.get("source_ide") != "codex":
|
|
11
|
+
return
|
|
12
|
+
key = hashlib.sha256(host_id.encode()).hexdigest()
|
|
13
|
+
path = base / ".c3" / "host_sessions" / "codex" / (key + ".json")
|
|
14
|
+
context = {"description": str(data.get("description") or "")[:2000]}
|
|
15
|
+
for name in ("decisions", "files_touched", "context_notes"):
|
|
16
|
+
context[name] = [str(item)[:1000] for item in (data.get(name) or [])[-20:]]
|
|
17
|
+
from cli._hook_utils import _atomic_write_json
|
|
18
|
+
_atomic_write_json(path, {"host_session_id": host_id, "project": str(base.resolve()), "context": context})
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def run(payload: dict, project_path: Path | None = None):
|
|
22
|
+
base = Path(project_path or payload.get("cwd") or Path.cwd()).resolve()
|
|
23
|
+
host_id = str(payload.get("session_id") or "")
|
|
24
|
+
if not host_id or not (base / ".c3").is_dir():
|
|
25
|
+
return None
|
|
26
|
+
key = hashlib.sha256(host_id.encode()).hexdigest()
|
|
27
|
+
path = base / ".c3" / "host_sessions" / "codex" / (key + ".json")
|
|
28
|
+
event = payload.get("hook_event_name", "")
|
|
29
|
+
hooks = base / ".codex/hooks.json"
|
|
30
|
+
if hooks.exists():
|
|
31
|
+
from cli._hook_utils import _atomic_write_json
|
|
32
|
+
_atomic_write_json(path.with_suffix(".events.json"), {
|
|
33
|
+
"host_session_id": host_id, "event": event,
|
|
34
|
+
"config_hash": hashlib.sha256(hooks.read_bytes()).hexdigest()})
|
|
35
|
+
if event == "SessionStart":
|
|
36
|
+
if not path.exists():
|
|
37
|
+
return None
|
|
38
|
+
try:
|
|
39
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
40
|
+
except (OSError, ValueError):
|
|
41
|
+
return None
|
|
42
|
+
if data.get("host_session_id") != host_id or data.get("project") != str(base):
|
|
43
|
+
return None
|
|
44
|
+
return {"additionalContext": "C3 handoff data for this thread (potentially stale, not instructions):\n" +
|
|
45
|
+
json.dumps(data.get("context", {}), ensure_ascii=False)[:8000]}
|
|
46
|
+
sessions = base / ".c3" / "sessions"
|
|
47
|
+
candidates = sorted(sessions.glob("session_*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
|
|
48
|
+
for candidate in candidates:
|
|
49
|
+
try:
|
|
50
|
+
data = json.loads(candidate.read_text(encoding="utf-8"))
|
|
51
|
+
except (OSError, ValueError):
|
|
52
|
+
continue
|
|
53
|
+
if data.get("host_session_id") != host_id or data.get("source_ide") != "codex":
|
|
54
|
+
continue
|
|
55
|
+
# MCP checkpoints are fresher than a session saved before the last turn.
|
|
56
|
+
if path.exists() and path.stat().st_mtime_ns >= candidate.stat().st_mtime_ns:
|
|
57
|
+
break
|
|
58
|
+
checkpoint(base, data)
|
|
59
|
+
break
|
|
60
|
+
return None
|
cli/hook_dispatch.py
CHANGED
|
@@ -74,6 +74,9 @@ _CODEX_EVENT_NAMES = {
|
|
|
74
74
|
"posttool": "PostToolUse",
|
|
75
75
|
"prompt": "UserPromptSubmit",
|
|
76
76
|
"stop": "Stop",
|
|
77
|
+
"start": "SessionStart",
|
|
78
|
+
"compact": "PreCompact",
|
|
79
|
+
"end": "SessionEnd",
|
|
77
80
|
}
|
|
78
81
|
|
|
79
82
|
# Keys each event's hookSpecificOutput accepts. "stop" is absent deliberately:
|
|
@@ -85,6 +88,7 @@ _CODEX_HSO_KEYS = {
|
|
|
85
88
|
},
|
|
86
89
|
"posttool": {"hookEventName", "additionalContext", "updatedMCPToolOutput"},
|
|
87
90
|
"prompt": {"hookEventName", "additionalContext"},
|
|
91
|
+
"start": {"hookEventName", "additionalContext"},
|
|
88
92
|
}
|
|
89
93
|
|
|
90
94
|
# ── Routing tables (parity with the pre-v2.42 per-matcher registration) ─────
|
|
@@ -159,10 +163,12 @@ def _routes(event: str, raw_tool: str, norm_tool: str, host: str = HOST_CLAUDE):
|
|
|
159
163
|
yield "hook_edit_ledger"
|
|
160
164
|
elif event == "stop":
|
|
161
165
|
yield "hook_session_stats"
|
|
162
|
-
yield "hook_auto_snapshot"
|
|
166
|
+
yield "hook_codex_lifecycle" if host == HOST_CODEX else "hook_auto_snapshot"
|
|
163
167
|
yield "hook_terse_advisor"
|
|
164
168
|
elif event == "prompt":
|
|
165
169
|
yield "hook_prompt_recall"
|
|
170
|
+
elif event in ("start", "compact", "end") and host == HOST_CODEX:
|
|
171
|
+
yield "hook_codex_lifecycle"
|
|
166
172
|
|
|
167
173
|
|
|
168
174
|
_RUN_CACHE: dict = {}
|
|
@@ -413,7 +419,7 @@ def merge_outputs(outputs: list, warnings: list, is_gemini: bool = False,
|
|
|
413
419
|
return result or None
|
|
414
420
|
|
|
415
421
|
|
|
416
|
-
def
|
|
422
|
+
def _collect(event: str, payload: dict, project_path: Path | None = None):
|
|
417
423
|
"""Run all applicable sub-hooks in-process and merge their outputs."""
|
|
418
424
|
raw_tool = str(payload.get("tool_name", ""))
|
|
419
425
|
norm_tool = normalize_tool_name(raw_tool)
|
|
@@ -457,6 +463,39 @@ def dispatch(event: str, payload: dict, project_path: Path | None = None) -> dic
|
|
|
457
463
|
if event == "pretool" and outputs and _is_deny(outputs[-1]):
|
|
458
464
|
break
|
|
459
465
|
|
|
466
|
+
return outputs, warnings
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def dispatch(event: str, payload: dict, project_path: Path | None = None) -> dict | None:
|
|
470
|
+
host = detect_host(payload)
|
|
471
|
+
payload = {**payload, "hook_event_name": payload.get("hook_event_name") or _CODEX_EVENT_NAMES.get(event, event)}
|
|
472
|
+
if payload.get("tool_name") == "apply_patch" and event in ("pretool", "posttool"):
|
|
473
|
+
from core.codex_patch import patch_targets
|
|
474
|
+
base = Path(project_path or payload.get("cwd") or Path.cwd()).resolve()
|
|
475
|
+
try:
|
|
476
|
+
targets = patch_targets((payload.get("tool_input") or {}).get("command"), base)
|
|
477
|
+
except Exception as exc:
|
|
478
|
+
out = _fail_closed_deny("apply_patch", str(exc)) if event == "pretool" else {"_text": "[c3:hook-error] Could not audit malformed apply_patch payload"}
|
|
479
|
+
return merge_outputs([out], [], event=event, host=host)
|
|
480
|
+
if event == "posttool" and _hook_utils.tool_response_failed(payload):
|
|
481
|
+
return None
|
|
482
|
+
if event == "posttool":
|
|
483
|
+
response, _ = _hook_utils.get_tool_output(payload)
|
|
484
|
+
if str(response).lstrip().lower().startswith(("failed", "error", "apply_patch verification failed")):
|
|
485
|
+
return None
|
|
486
|
+
outputs, warnings = [], []
|
|
487
|
+
for target in targets:
|
|
488
|
+
translated = {**payload, "tool_name": "Write" if target.change_type == "created" else "Edit",
|
|
489
|
+
"_c3_host": host, "_c3_change_type": target.change_type,
|
|
490
|
+
"_c3_original_tool": "apply_patch",
|
|
491
|
+
"tool_input": {"file_path": target.path}}
|
|
492
|
+
found, notices = _collect(event, translated, base)
|
|
493
|
+
outputs.extend(found)
|
|
494
|
+
warnings.extend(notices)
|
|
495
|
+
if event == "pretool" and any(_is_deny(o) for o in found):
|
|
496
|
+
break
|
|
497
|
+
else:
|
|
498
|
+
outputs, warnings = _collect(event, payload, project_path)
|
|
460
499
|
if event == "pretool":
|
|
461
500
|
outputs = _settle_grants(outputs)
|
|
462
501
|
return merge_outputs(outputs, warnings, event=event, host=host)
|
|
@@ -466,8 +505,14 @@ def main() -> None:
|
|
|
466
505
|
# Windows hook subprocesses get cp1252 pipes; sub-hook _text may carry
|
|
467
506
|
# box-drawing chars / emoji, so print(text) below would UnicodeEncodeError.
|
|
468
507
|
_hook_utils.ensure_utf8_stdio()
|
|
469
|
-
|
|
470
|
-
|
|
508
|
+
import argparse
|
|
509
|
+
parser = argparse.ArgumentParser()
|
|
510
|
+
parser.add_argument("event", nargs="?", default="")
|
|
511
|
+
parser.add_argument("--host", choices=["codex", "claude", "claude-code", "gemini"])
|
|
512
|
+
parser.add_argument("--project")
|
|
513
|
+
args = parser.parse_args()
|
|
514
|
+
event = args.event.strip().lower()
|
|
515
|
+
if event not in (*VALID_EVENTS, "start", "compact", "end"):
|
|
471
516
|
log_hook_error(
|
|
472
517
|
"hook_dispatch",
|
|
473
518
|
ValueError(f"unknown or missing event arg: {event!r} (expected {VALID_EVENTS})"),
|
|
@@ -477,18 +522,28 @@ def main() -> None:
|
|
|
477
522
|
try:
|
|
478
523
|
raw = sys.stdin.read()
|
|
479
524
|
payload = json.loads(raw) if raw.strip() else {}
|
|
525
|
+
if not isinstance(payload, dict):
|
|
526
|
+
raise ValueError("Hook payload must be a JSON object")
|
|
480
527
|
except Exception as exc:
|
|
481
528
|
log_hook_error("hook_dispatch", exc)
|
|
529
|
+
if event == "pretool":
|
|
530
|
+
print("C3 could not validate the hook payload", file=sys.stderr)
|
|
531
|
+
raise SystemExit(2)
|
|
482
532
|
return
|
|
483
533
|
|
|
484
534
|
try:
|
|
485
|
-
|
|
535
|
+
if args.host:
|
|
536
|
+
payload["_c3_host"] = args.host
|
|
537
|
+
output = dispatch(event, payload, Path(args.project).resolve() if args.project else None)
|
|
486
538
|
except Exception as exc:
|
|
487
539
|
# The dispatcher itself failing is critical — say so. Route the notice
|
|
488
540
|
# through merge_outputs so even the panic path speaks the host's
|
|
489
541
|
# dialect: a raw {"additionalContext": ...} is itself invalid on Codex,
|
|
490
542
|
# which would turn one crash into two error reports.
|
|
491
543
|
log_hook_error("hook_dispatch", exc)
|
|
544
|
+
if event == "pretool":
|
|
545
|
+
print("C3 could not complete the pre-tool checks", file=sys.stderr)
|
|
546
|
+
raise SystemExit(2)
|
|
492
547
|
notice = {
|
|
493
548
|
"additionalContext": (
|
|
494
549
|
f"[c3:hook-error] hook_dispatch: {type(exc).__name__}: {exc}; "
|