77import shutil
88import subprocess
99from pathlib import Path
10+ from typing import cast
1011
1112from ucode .agent_updates import available_npm_package_update
1213from ucode .config_io import (
@@ -63,19 +64,27 @@ def _resolve_web_search_model(state: dict) -> str | None:
6364WEB_SEARCH_MCP_NAME = "web_search"
6465_CLAUDE_MODEL_RE = re .compile (r"^databricks-claude-(opus|sonnet)-(\d+)-(\d+)(.*)$" )
6566
66- # Env keys consumed by the MLflow Claude tracing plugin . Written into the
67- # settings `env` block; the plugin runtime (installed separately) reads them .
67+ # Env keys the MLflow Stop hook reads to route traces . Written into the
68+ # settings `env` block alongside the hook itself .
6869CLAUDE_TRACING_ENV_KEYS = (
6970 "MLFLOW_CLAUDE_TRACING_ENABLED" ,
7071 "MLFLOW_TRACKING_URI" ,
7172 "MLFLOW_EXPERIMENT_ID" ,
73+ "MLFLOW_TRACING_SQL_WAREHOUSE_ID" ,
7274)
73- CLAUDE_TRACING_MARKETPLACE = "mlflow/mlflow"
74- CLAUDE_TRACING_PLUGIN = "mlflow-tracing@mlflow-plugins"
75- # The plugin runtime shells out to the `mlflow` CLI, so it must be on PATH at
76- # this minimum version. ucode installs/upgrades it via `uv tool`.
77- MLFLOW_CLI_SPEC = "mlflow[databricks]>=3.4"
78- MINIMUM_MLFLOW_VERSION = (3 , 4 )
75+ CLAUDE_TRACING_STOP_HOOK_SUFFIX = " autolog claude stop-hook"
76+ # Tracing is driven by an `mlflow autolog claude stop-hook` Stop hook, run by
77+ # the `mlflow` CLI on each session end. Pin to 3.11.x: 3.12 dropped the Unity
78+ # Catalog trace-write path, so traces silently land in the classic store
79+ # instead of the experiment's UC table. ucode installs this via `uv tool` at
80+ # `configure tracing` time (where UV_INDEX_URL is set), then writes the hook
81+ # with the resolved absolute path — so the hook needs no uv or index at run
82+ # time, and can't be shadowed by a project venv's mlflow.
83+ MLFLOW_CLI_SPEC = "mlflow[databricks]>=3.11,<3.12"
84+ MINIMUM_MLFLOW_VERSION = (3 , 11 )
85+ # Upper bound (exclusive) — an installed mlflow at or above this is too new and
86+ # must be replaced, not just left alone.
87+ MAXIMUM_MLFLOW_VERSION = (3 , 12 )
7988
8089
8190def _web_search_mcp_entry (workspace : str , search_model : str , profile : str | None = None ) -> dict :
@@ -215,18 +224,31 @@ def write_tool_config(state: dict, model: str) -> dict:
215224 profile = state .get ("profile" ),
216225 )
217226 tracing_env_vars = tracing_env (state , "claude" )
227+ stop_hook_command = claude_tracing_stop_hook_command () if tracing_env_vars else None
218228 if tracing_env_vars :
219229 overlay ["env" ]["MLFLOW_CLAUDE_TRACING_ENABLED" ] = "true"
220230 overlay ["env" ].update (tracing_env_vars )
221231 managed_keys = managed_keys + [["env" , key ] for key in CLAUDE_TRACING_ENV_KEYS ]
232+ if stop_hook_command :
233+ managed_keys = managed_keys + [["hooks" , "Stop" ]]
234+ else :
235+ print_warning (
236+ "MLflow tracing env was written, but the `mlflow` CLI could not be located "
237+ "to install the Claude Stop hook — traces won't be emitted. Re-run "
238+ "`ucode configure tracing`."
239+ )
222240
223241 existing = read_json_safe (CLAUDE_SETTINGS_PATH )
224242 merged = deep_merge_dict (existing , overlay )
243+ if tracing_env_vars and stop_hook_command :
244+ _upsert_tracing_stop_hook (merged , stop_hook_command )
225245 if not tracing_env_vars :
226246 env_block = merged .get ("env" )
227247 if isinstance (env_block , dict ):
228248 for key in CLAUDE_TRACING_ENV_KEYS :
229249 env_block .pop (key , None )
250+ # Strip only ucode's tracing Stop hook so user hooks stay intact.
251+ _remove_tracing_stop_hook (merged )
230252 write_json_file (CLAUDE_SETTINGS_PATH , merged )
231253
232254 if web_search_model :
@@ -237,15 +259,67 @@ def write_tool_config(state: dict, model: str) -> dict:
237259 return state
238260
239261
262+ def _is_tracing_stop_hook (hook : object ) -> bool :
263+ if not isinstance (hook , dict ):
264+ return False
265+ hook = cast (dict , hook )
266+ if hook .get ("type" ) != "command" :
267+ return False
268+ command = hook .get ("command" )
269+ return isinstance (command , str ) and command .endswith (CLAUDE_TRACING_STOP_HOOK_SUFFIX )
270+
271+
272+ def _remove_tracing_stop_hook (settings : dict ) -> None :
273+ hooks = settings .get ("hooks" )
274+ if not isinstance (hooks , dict ):
275+ return
276+ stop_entries = hooks .get ("Stop" )
277+ if not isinstance (stop_entries , list ):
278+ return
279+
280+ cleaned_entries = []
281+ for entry in stop_entries :
282+ if not isinstance (entry , dict ):
283+ cleaned_entries .append (entry )
284+ continue
285+ hook_list = entry .get ("hooks" )
286+ if not isinstance (hook_list , list ):
287+ cleaned_entries .append (entry )
288+ continue
289+ cleaned_hooks = [hook for hook in hook_list if not _is_tracing_stop_hook (hook )]
290+ if cleaned_hooks :
291+ cleaned_entry = dict (entry )
292+ cleaned_entry ["hooks" ] = cleaned_hooks
293+ cleaned_entries .append (cleaned_entry )
294+
295+ if cleaned_entries :
296+ hooks ["Stop" ] = cleaned_entries
297+ else :
298+ hooks .pop ("Stop" , None )
299+ if not hooks :
300+ settings .pop ("hooks" , None )
301+
302+
303+ def _upsert_tracing_stop_hook (settings : dict , command : str ) -> None :
304+ _remove_tracing_stop_hook (settings )
305+ hooks = settings .get ("hooks" )
306+ if not isinstance (hooks , dict ):
307+ hooks = {}
308+ settings ["hooks" ] = hooks
309+ stop_entries = hooks .get ("Stop" )
310+ if not isinstance (stop_entries , list ):
311+ stop_entries = []
312+ hooks ["Stop" ] = stop_entries
313+ stop_entries .append ({"hooks" : [{"type" : "command" , "command" : command }]})
314+
315+
240316def ensure_tracing_runtime () -> bool :
241- """Ensure Claude's MLflow tracing runtime is ready: an `mlflow` CLI >= 3.4 on
242- PATH (the plugin shells out to it) and the MLflow Claude plugin installed .
317+ """Ensure the MLflow tracing runtime is ready: a pinned `mlflow` CLI (3.11.x)
318+ installed via `uv tool`, whose absolute path the Stop hook will call .
243319
244- Best-effort — warns and returns False if a piece can't be set up, so
320+ Best-effort — warns and returns False if it can't be set up, so
245321 `ucode configure tracing` can still finish for other agents."""
246- if not _ensure_mlflow_cli ():
247- return False
248- return _install_claude_tracing_plugin ()
322+ return _ensure_mlflow_cli ()
249323
250324
251325def _parse_mlflow_version (text : str ) -> tuple [int , int ] | None :
@@ -255,37 +329,76 @@ def _parse_mlflow_version(text: str) -> tuple[int, int] | None:
255329 return int (match .group (1 )), int (match .group (2 ))
256330
257331
332+ def _uv_tool_mlflow_path () -> str | None :
333+ """Absolute path to the `mlflow` installed by `uv tool`, or None.
334+
335+ Resolved from `uv tool dir --bin` rather than ``shutil.which`` so a project
336+ venv's (possibly wrong-versioned) mlflow can't shadow the one ucode pins —
337+ the Stop hook must always run the uv-tool copy."""
338+ if not shutil .which ("uv" ):
339+ return None
340+ try :
341+ result = subprocess .run (
342+ ["uv" , "tool" , "dir" , "--bin" ],
343+ check = False ,
344+ capture_output = True ,
345+ text = True ,
346+ timeout = 30 ,
347+ )
348+ except (OSError , subprocess .TimeoutExpired ):
349+ return None
350+ bin_dir = (result .stdout or "" ).strip ()
351+ if result .returncode != 0 or not bin_dir :
352+ return None
353+ candidate = Path (bin_dir ) / "mlflow"
354+ return str (candidate ) if candidate .exists () else None
355+
356+
258357def _installed_mlflow_version () -> tuple [int , int ] | None :
259- """The (major, minor) of the `mlflow` CLI on PATH, or None if absent."""
260- if not shutil .which ("mlflow" ):
358+ """The (major, minor) of the uv-tool `mlflow`, or None if absent."""
359+ path = _uv_tool_mlflow_path ()
360+ if not path :
261361 return None
262362 try :
263363 result = subprocess .run (
264- ["mlflow" , "--version" ], check = False , capture_output = True , text = True , timeout = 30
364+ [path , "--version" ], check = False , capture_output = True , text = True , timeout = 30
265365 )
266366 except (OSError , subprocess .TimeoutExpired ):
267367 return None
268368 return _parse_mlflow_version (result .stdout or result .stderr or "" )
269369
270370
371+ def claude_tracing_stop_hook_command () -> str | None :
372+ """The Stop hook command string: the absolute uv-tool `mlflow` invoking its
373+ `autolog claude stop-hook` handler. None when mlflow isn't installed.
374+
375+ Using the absolute path means the hook needs neither `uv` nor a package
376+ index at run time (the minimal env Claude runs hooks in lacks UV_INDEX_URL),
377+ and can't be shadowed by another mlflow on PATH."""
378+ path = _uv_tool_mlflow_path ()
379+ if not path :
380+ return None
381+ return f"{ path } autolog claude stop-hook"
382+
383+
271384def _ensure_mlflow_cli () -> bool :
272- """Ensure an `mlflow` CLI >= 3.4 is on PATH, installing or upgrading it via
273- `uv tool` when needed."""
385+ """Ensure the pinned `mlflow` CLI (3.11.x) is installed via `uv tool`,
386+ installing or replacing an out-of-range version when needed."""
274387 current = _installed_mlflow_version ()
275- if current and current >= MINIMUM_MLFLOW_VERSION :
388+ if current and MINIMUM_MLFLOW_VERSION <= current < MAXIMUM_MLFLOW_VERSION :
276389 return True
277390
278391 if not shutil .which ("uv" ):
279- verb = "upgrade " if current else "install"
392+ verb = "replace " if current else "install"
280393 print_warning (
281- f"Claude tracing needs the `mlflow` CLI >= 3.4 on PATH , but `uv` is not "
282- f'available to { verb } it. Run `uv tool install "{ MLFLOW_CLI_SPEC } "` '
283- f'(or `pip install " { MLFLOW_CLI_SPEC } "`), then re-run `ucode configure tracing`.'
394+ f"Claude tracing needs the `mlflow` CLI ( { MLFLOW_CLI_SPEC } ) , but `uv` is not "
395+ f'available to { verb } it. Run `uv tool install "{ MLFLOW_CLI_SPEC } "`, then '
396+ " re-run `ucode configure tracing`."
284397 )
285398 return False
286399
287- print_note (f"{ 'Upgrading ' if current else 'Installing' } the mlflow CLI ({ MLFLOW_CLI_SPEC } )..." )
288- # --force replaces an existing (older ) uv-managed mlflow tool in place.
400+ print_note (f"{ 'Replacing ' if current else 'Installing' } the mlflow CLI ({ MLFLOW_CLI_SPEC } )..." )
401+ # --force replaces an existing (out-of-range ) uv-managed mlflow in place.
289402 cmd = ["uv" , "tool" , "install" , MLFLOW_CLI_SPEC ]
290403 if current :
291404 cmd .append ("--force" )
@@ -295,54 +408,16 @@ def _ensure_mlflow_cli() -> bool:
295408 print_warning (f"Could not install the mlflow CLI automatically: { exc } " )
296409 return False
297410
298- if not shutil . which ( "mlflow" ):
411+ if not _uv_tool_mlflow_path ( ):
299412 print_warning (
300- "Installed mlflow, but `mlflow` is still not on PATH. Ensure your uv tool "
301- "bin directory (e.g. ~/.local/bin) is on PATH, then re -run `ucode configure tracing`."
413+ "Installed mlflow via `uv tool`, but its binary could not be located. "
414+ "Re -run `ucode configure tracing`."
302415 )
303416 return False
304417 print_success ("mlflow CLI ready" )
305418 return True
306419
307420
308- def _install_claude_tracing_plugin () -> bool :
309- binary = SPEC ["binary" ]
310- if not shutil .which (binary ):
311- print_warning ("`claude` is not installed; skipping MLflow tracing plugin install." )
312- return False
313- commands = [
314- [
315- binary ,
316- "plugin" ,
317- "marketplace" ,
318- "add" ,
319- CLAUDE_TRACING_MARKETPLACE ,
320- "--sparse" ,
321- ".claude-plugin" ,
322- ],
323- [binary , "plugin" , "install" , CLAUDE_TRACING_PLUGIN ],
324- ]
325- for cmd in commands :
326- try :
327- result = subprocess .run (cmd , check = False , capture_output = True , text = True , timeout = 120 )
328- except (OSError , subprocess .TimeoutExpired ) as exc :
329- print_warning (f"Could not install the Claude MLflow plugin: { exc } " )
330- return False
331- if result .returncode != 0 :
332- output = (result .stderr or result .stdout or "" ).strip ()
333- last = output .splitlines ()[- 1 ] if output else f"exit { result .returncode } "
334- # `marketplace add` / `install` are idempotent; treat "already
335- # added/installed" as success and keep going. Best-effort match
336- # against stderr — an upstream wording change would degrade this
337- # to a noisy warning on re-runs, but never corrupts state.
338- if "already" in last .lower ():
339- continue
340- print_warning (f"Claude MLflow plugin step failed: { last } " )
341- return False
342- print_success ("Claude MLflow tracing plugin installed" )
343- return True
344-
345-
346421def default_model (state : dict ) -> str | None :
347422 claude_models = state .get ("claude_models" ) or {}
348423 return claude_models .get ("opus" ) or claude_models .get ("sonnet" ) or claude_models .get ("haiku" )
0 commit comments