From 8e49fb58aa454b594600bacf8a0dee54a3073cba Mon Sep 17 00:00:00 2001 From: Gowshik T Date: Sun, 12 Jul 2026 16:09:15 +0530 Subject: [PATCH 1/4] Augment hook events with project context and cwd for analytics Add cwd and project (GitHub org/repo) to PreToolUse, UserPromptSubmit, and StopEvent hook requests. Includes helper functions to resolve GitHub origin from git remotes. Enables better analytics, policy scoping, and debugging by tracking which repository and working context triggered each event. Co-Authored-By: Claude Haiku 4.5 --- claude-code/hooks/unbound.py | 100 +++++++++++++++++++++++++++++++++-- 1 file changed, 95 insertions(+), 5 deletions(-) diff --git a/claude-code/hooks/unbound.py b/claude-code/hooks/unbound.py index c5fa43a..e3f8b9f 100644 --- a/claude-code/hooks/unbound.py +++ b/claude-code/hooks/unbound.py @@ -1387,6 +1387,8 @@ def process_pre_tool_use(event: Dict, api_key: str) -> Dict: 'unbound_app_label': _unbound_app_label(event), 'model': model, 'event_name': 'tool_use', + 'cwd': event.get('cwd'), + 'project': _get_project(event.get('cwd')), 'pre_tool_use_data': { 'command': command, 'tool_name': tool_name, @@ -1471,6 +1473,85 @@ def process_pre_tool_use(event: Dict, api_key: str) -> Dict: return transform_response_for_claude(api_response) +def _github_remote_path(remote_url: str) -> Optional[str]: + """The 'ORG/repo' path of a GitHub remote URL, with any trailing slash + stripped. Handles the SSH scp form (git@github.com:ORG/repo.git) and the + HTTPS/scheme form (https://github.com/ORG/repo.git). None if the URL is empty + or unparseable. The per-segment '.git' strip is left to the callers.""" + if not remote_url: + return None + url = remote_url.strip() + if '://' in url: + # scheme://[user@]host/ORG/repo(.git) + after_scheme = url.split('://', 1)[1] + path = after_scheme.split('/', 1)[1] if '/' in after_scheme else '' + elif ':' in url: + # scp-like: [user@]host:ORG/repo(.git) + path = url.split(':', 1)[1] + else: + return None + path = path.strip('/') + return path or None + + +def _strip_git_suffix(segment: str) -> str: + return segment[:-4] if segment.endswith('.git') else segment + + +def _parse_github_org(remote_url: str) -> Optional[str]: + """Org/owner (first path segment after the host) of a GitHub remote URL. + Returns None if the URL is empty or unparseable.""" + path = _github_remote_path(remote_url) + if not path: + return None + org = _strip_git_suffix(path.split('/', 1)[0]) + return org or None + + +def _parse_github_repo(remote_url: str) -> Optional[str]: + """Repo name (second path segment) of a GitHub remote URL, with a trailing + '.git' stripped. None if absent or unparseable.""" + path = _github_remote_path(remote_url) + if not path: + return None + parts = path.split('/') + if len(parts) < 2: + return None + repo = _strip_git_suffix(parts[1]) + return repo or None + + +def _get_git_origin_org_repo(cwd: str) -> tuple: + """Lowercased (org, repo) of `cwd`'s `origin` remote, or (None, None) when + `cwd` is not a git repo or has no `origin` (a clean non-zero git exit). Raises + only when git cannot be executed at all (missing binary / timeout) so the + caller can tell an honest 'non-compliant' apart from an internal failure and + fail-open.""" + result = subprocess.run( + ['git', '-C', cwd, 'remote', 'get-url', 'origin'], + capture_output=True, text=True, timeout=10, + ) + if result.returncode != 0: + return (None, None) + url = result.stdout.strip() + org = _parse_github_org(url) + repo = _parse_github_repo(url) + return (org.lower() if org else None, repo.lower() if repo else None) + + +def _get_project(cwd: Optional[str]) -> Optional[str]: + """Lowercased "/" for `cwd`'s git origin, attached to hook + requests for analytics. None when cwd is missing, not a git repo, has no + origin, or anything fails — fully fail-open (never raises).""" + try: + if not cwd: + return None + org, repo = _get_git_origin_org_repo(cwd) + return f"{org}/{repo}" if org and repo else None + except Exception: + return None + + def process_user_prompt_submit(event: Dict, api_key: str) -> Dict: """Process UserPromptSubmit event for policy checking.""" session_id = event.get('session_id') @@ -1482,12 +1563,15 @@ def process_user_prompt_submit(event: Dict, api_key: str) -> Dict: 'unbound_app_label': _unbound_app_label(event), 'model': model, 'event_name': 'user_prompt', + 'cwd': event.get('cwd'), + 'project': _get_project(event.get('cwd')), 'account_identity': build_account_identity(), 'messages': [{'role': 'user', 'content': prompt}] if prompt else [] } api_response = send_to_hook_api(request_body, api_key) - return transform_response_for_claude_prompt(api_response) + response = transform_response_for_claude_prompt(api_response) + return response def build_llm_exchange(events: List[Dict], stop_assistant_message: Optional[str] = None, transcript_assistant_messages: Optional[List[str]] = None, model: Optional[str] = None, usage: Optional[Dict] = None, request_initialized: Optional[str] = None, request_completed: Optional[str] = None) -> Optional[Dict]: @@ -1497,6 +1581,7 @@ def build_llm_exchange(events: List[Dict], stop_assistant_message: Optional[str] user_prompt = None session_id = None permission_mode = None + cwd = None for log_entry in events: event = log_entry.get('event', {}) if 'event' in log_entry else log_entry @@ -1508,6 +1593,9 @@ def build_llm_exchange(events: List[Dict], stop_assistant_message: Optional[str] if not permission_mode: permission_mode = event.get('permission_mode') + if not cwd: + cwd = event.get('cwd') + if hook_event_name == 'UserPromptSubmit': prompt = event.get('prompt') if prompt: @@ -1566,6 +1654,8 @@ def build_llm_exchange(events: List[Dict], stop_assistant_message: Optional[str] 'model': model, 'messages': messages, 'permission_mode': permission_mode, + 'cwd': cwd, + 'project': _get_project(cwd), 'account_identity': build_account_identity(probe=True), } @@ -2200,16 +2290,16 @@ def main(): 'session_id': event.get('session_id'), 'event': event } - + append_to_audit_log(log_entry) - + if hook_event_name == 'Stop' and session_id: process_stop_event(event, api_key) - + cleanup_old_logs() print('{"suppressOutput": true}', flush=True) - + except Exception as e: # Still return empty JSON object to Claude Code to indicate completion log_error(f"Exception in main: {str(e)}", 'general') From a64168c4894f573636afb2a3fa53e5b9d99d80be Mon Sep 17 00:00:00 2001 From: Gowshik T Date: Mon, 13 Jul 2026 11:51:21 +0530 Subject: [PATCH 2/4] Use session-level cwd for consistent project context Use cwd captured at session start (_get_session_cwd) instead of per-request event.get('cwd') for more stable project tracking across the entire conversation. Co-Authored-By: Claude Haiku 4.5 --- claude-code/hooks/unbound.py | 42 +++++++++++++++++++++++++----------- 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/claude-code/hooks/unbound.py b/claude-code/hooks/unbound.py index e3f8b9f..ae126f1 100644 --- a/claude-code/hooks/unbound.py +++ b/claude-code/hooks/unbound.py @@ -1382,13 +1382,16 @@ def process_pre_tool_use(event: Dict, api_key: str) -> Dict: approval_key = f"{tool_name}:{command}" is_retry = _is_approval_retry(approval_key) + # Use cwd captured at session start instead of per-request + session_cwd = _get_session_cwd(session_id) + request_body = { 'conversation_id': session_id, 'unbound_app_label': _unbound_app_label(event), 'model': model, 'event_name': 'tool_use', - 'cwd': event.get('cwd'), - 'project': _get_project(event.get('cwd')), + 'cwd': session_cwd, + 'project': _get_project(session_cwd), 'pre_tool_use_data': { 'command': command, 'tool_name': tool_name, @@ -1558,13 +1561,16 @@ def process_user_prompt_submit(event: Dict, api_key: str) -> Dict: model = event.get('model') or _get_session_model(session_id) or 'auto' prompt = event.get('prompt', '') + # Use cwd captured at session start instead of per-request + session_cwd = _get_session_cwd(session_id) + request_body = { 'conversation_id': session_id, 'unbound_app_label': _unbound_app_label(event), 'model': model, 'event_name': 'user_prompt', - 'cwd': event.get('cwd'), - 'project': _get_project(event.get('cwd')), + 'cwd': session_cwd, + 'project': _get_project(session_cwd), 'account_identity': build_account_identity(), 'messages': [{'role': 'user', 'content': prompt}] if prompt else [] } @@ -1574,14 +1580,14 @@ def process_user_prompt_submit(event: Dict, api_key: str) -> Dict: return response -def build_llm_exchange(events: List[Dict], stop_assistant_message: Optional[str] = None, transcript_assistant_messages: Optional[List[str]] = None, model: Optional[str] = None, usage: Optional[Dict] = None, request_initialized: Optional[str] = None, request_completed: Optional[str] = None) -> Optional[Dict]: +def build_llm_exchange(events: List[Dict], stop_assistant_message: Optional[str] = None, transcript_assistant_messages: Optional[List[str]] = None, model: Optional[str] = None, usage: Optional[Dict] = None, request_initialized: Optional[str] = None, request_completed: Optional[str] = None, session_cwd: Optional[str] = None) -> Optional[Dict]: messages = [] assistant_tool_uses = [] user_prompt = None session_id = None permission_mode = None - cwd = None + cwd = session_cwd # Use the cwd passed in from session state for log_entry in events: event = log_entry.get('event', {}) if 'event' in log_entry else log_entry @@ -1593,9 +1599,6 @@ def build_llm_exchange(events: List[Dict], stop_assistant_message: Optional[str] if not permission_mode: permission_mode = event.get('permission_mode') - if not cwd: - cwd = event.get('cwd') - if hook_event_name == 'UserPromptSubmit': prompt = event.get('prompt') if prompt: @@ -1784,6 +1787,7 @@ def process_stop_event(event: Dict, api_key: str): usage=transcript_usage, request_initialized=user_prompt_timestamp, request_completed=request_completed, + session_cwd=_get_session_cwd(session_id), ) if exchange: @@ -2230,14 +2234,23 @@ def _dispatch_discovery() -> None: log_error(f"discovery gate failed: {e}", 'discovery_gate') +# Session-level state: store cwd per session to avoid re-extracting on every request +_SESSION_CWD_CACHE = {} + + +def _get_session_cwd(session_id: Optional[str]) -> Optional[str]: + """Get the working directory captured at session start.""" + return _SESSION_CWD_CACHE.get(session_id) if session_id else None + + def main(): global _cached_api_key api_key = get_api_key() _cached_api_key = api_key - + try: input_data = sys.stdin.read().strip() - + if not input_data: print('{"suppressOutput": true}', flush=True) return @@ -2249,13 +2262,16 @@ def main(): return hook_event_name = event.get('hook_event_name') + session_id = event.get('session_id') - # SessionStart fires once per session — natural TTL gate for the - # debounced discovery scan dispatch. + # SessionStart fires once per session — capture cwd once here for reuse if hook_event_name == "SessionStart": _device_serial() # warm the (slow) serial probe + cache once per session _check_self_update() _dispatch_discovery() + # Capture cwd at session start and cache it for the session lifetime + if session_id and 'cwd' in event: + _SESSION_CWD_CACHE[session_id] = event.get('cwd') print("{}") return session_id = event.get('session_id') From 69719ed189e4f82a2f14481eff4b0a9590375e05 Mon Sep 17 00:00:00 2001 From: Gowshik T Date: Wed, 15 Jul 2026 13:21:59 +0530 Subject: [PATCH 3/4] feat(WEB-4974): Per-tool-call project attribution across all coding-agent hooks - claude-code/codex/copilot/cursor: resolve the git project (org/repo) per tool call (file path / shell-dir tracking) and ride it on each tool_use entry; send turn-level cwd + project on the Stop exchange - augment: add the same per-call resolver (_project_for_tool_use, .git walk, cached origin lookups) and cwd/project on the Stop exchange Co-Authored-By: Claude Fable 5 --- augment/hooks/unbound.py | 152 +++++++++++++++++++++++++++ claude-code/hooks/unbound.py | 141 +++++++++++++++++++------ codex/hooks/unbound.py | 164 ++++++++++++++++++++++++++++-- copilot/hooks/unbound.py | 192 ++++++++++++++++++++++++++++++++--- cursor/unbound.py | 151 +++++++++++++++++++++++++-- 5 files changed, 736 insertions(+), 64 deletions(-) mode change 100644 => 100755 cursor/unbound.py diff --git a/augment/hooks/unbound.py b/augment/hooks/unbound.py index 63cb562..7a6b01f 100644 --- a/augment/hooks/unbound.py +++ b/augment/hooks/unbound.py @@ -1322,6 +1322,142 @@ def process_pre_tool_use(event: Dict, api_key: str) -> Dict: return transform_response_for_claude(api_response) +def _strip_git_suffix(segment: str) -> str: + return segment[:-4] if segment.endswith('.git') else segment + + +def _github_remote_path(remote_url: Optional[str]) -> Optional[str]: + """Path portion ("org/repo[.git]") of an SSH or HTTPS git remote URL. + None when the URL is empty or has no recognizable path.""" + if not remote_url: + return None + url = remote_url.strip() + if '://' in url: + rest = url.split('://', 1)[1] + parts = rest.split('/', 1) + return parts[1] if len(parts) == 2 and parts[1] else None + if ':' in url: + rest = url.split(':', 1)[1] + return rest if rest else None + return None + + +def _get_project(cwd: Optional[str]) -> Optional[str]: + """Lowercased "/" for `cwd`'s git origin, attached to hook + requests for analytics. None when cwd is missing, not a git repo, has no + origin, or anything fails — fully fail-open (never raises).""" + try: + if not cwd: + return None + result = subprocess.run( + ['git', '-C', cwd, 'remote', 'get-url', 'origin'], + capture_output=True, text=True, timeout=10, + ) + if result.returncode != 0: + return None + path = _github_remote_path(result.stdout.strip()) + if not path: + return None + parts = path.split('/') + if len(parts) < 2: + return None + org = _strip_git_suffix(parts[0]) + repo = _strip_git_suffix(parts[1]) + return f"{org.lower()}/{repo.lower()}" if org and repo else None + except Exception: + return None + + +# Canonical (post-AUGMENT_TOOL_FAMILY) tool names whose input carries a file +# path — used for per-tool-call project attribution on the Stop exchange. +_FILE_TOOLS = {'Read', 'Write', 'Edit', 'Delete'} + +# Any absolute path inside a shell command (cd targets, git -C, pytest /x/…). +# Left boundary required so the slash inside a relative token (tests/webapp/) +# doesn't read as an absolute path. +_ABS_PATH_RE = re.compile(r'(?:^|[\s"\'=(])(/[^\s"\';|&<>()]+)') +# `cd ` occurrences — absolute, ~-rooted, or relative — used to track +# the shell's working directory across the turn's launch-process calls. +_CD_TARGET_RE = re.compile(r'(?:^|[;&|]\s*|\bthen\s+|\bdo\s+)cd\s+(["\']?)([^\s"\';|&]+)\1') + + +def _find_git_root(path: str) -> Optional[str]: + """Nearest ancestor of `path` (inclusive) containing a `.git` entry + (directory, or file for linked worktrees). Pure filesystem stats — no + subprocess. None when outside any repo or on any error (fail-open).""" + try: + p = Path(path) + for parent in [p] + list(p.parents): + if (parent / '.git').exists(): + return str(parent) + except Exception: + pass + return None + + +def _next_shell_dir(command: str, shell_dir: Optional[str]) -> Optional[str]: + """Follow the last `cd` in `command` from `shell_dir`. Absolute and + ~-rooted targets replace the dir; relative ones join onto it. Unchanged + when the command has no cd or on any error.""" + try: + target = None + for m in _CD_TARGET_RE.finditer(command): + target = m.group(2) + if not target: + return shell_dir + if target.startswith('~'): + target = os.path.expanduser(target) + if target.startswith('/'): + return os.path.normpath(target) + if target == '-': # `cd -` — previous dir isn't tracked; keep as-is + return shell_dir + if shell_dir: + return os.path.normpath(os.path.join(shell_dir, target)) + return shell_dir + except Exception: + return shell_dir + + +def _project_for_tool_use(tool_name: Optional[str], tool_input: Optional[Dict], shell_dir: Optional[str], root_projects: Dict[str, Optional[str]]) -> tuple: + """Resolve the git project ("/") a single tool call worked in. + File tools resolve from the tool's file path (Augment often sends + workspace-relative paths — those join onto the tracked shell dir); Bash + resolves from the first absolute path in the command, else the shell's + working directory tracked across the turn's `cd`s. Returns + (project, shell_dir) — shell_dir updated when the command changed + directory. `root_projects` caches the origin lookup so `git remote + get-url` runs at most once per distinct repo. (None, shell_dir) when + nothing resolves (fail-open).""" + try: + tool_input = tool_input or {} + candidates = [] + if tool_name in _FILE_TOOLS: + path = tool_input.get('file_path') + if isinstance(path, str) and path: + if not path.startswith('/') and shell_dir: + path = os.path.normpath(os.path.join(shell_dir, path)) + if path.startswith('/'): + candidates.append(os.path.dirname(path)) + elif tool_name == 'Bash': + command = tool_input.get('command') + if isinstance(command, str): + candidates.extend(_ABS_PATH_RE.findall(command)) + shell_dir = _next_shell_dir(command, shell_dir) + if not candidates and shell_dir: + candidates.append(shell_dir) + for candidate in candidates: + root = _find_git_root(candidate) + if not root: + continue + if root not in root_projects: + root_projects[root] = _get_project(root) + if root_projects[root]: + return root_projects[root], shell_dir + return None, shell_dir + except Exception: + return None, shell_dir + + def _augment_posttooluse_to_exchange(ev: Dict, mcp_servers: Optional[Dict] = None) -> Optional[Dict]: """Map an Augment PostToolUse event to the Claude-Code-hooks tool_use shape the backend analyzer consumes (type / tool_name / tool_input / tool_response). @@ -1421,6 +1557,12 @@ def build_llm_exchange(event: Dict, post_tool_events: List[Dict], model: Optiona assistant_response = (exchange.get('response_text') or conversation.get('agentTextResponse') or '').strip() + # Per-tool-use project resolution state: the shell starts at the session + # cwd; origin lookups are cached per repo root across the turn. + cwd = event.get('cwd') + shell_dir = cwd + root_projects = {} + mcp_servers = read_augment_mcp_servers(event) for log_entry in post_tool_events: ev = log_entry.get('event', {}) if 'event' in log_entry else log_entry @@ -1428,6 +1570,12 @@ def build_llm_exchange(event: Dict, post_tool_events: List[Dict], model: Optiona continue shaped = _augment_posttooluse_to_exchange(ev, mcp_servers) if shaped: + # Attribute this tool call to the repo it worked in (file path / + # shell cwd tracking); rides on the tool_use entry so the backend + # can store per-call project on each analytics row. + tool_project, shell_dir = _project_for_tool_use( + shaped.get('tool_name'), shaped.get('tool_input'), shell_dir, root_projects) + shaped['project'] = tool_project assistant_tool_uses.append(shaped) if user_prompt: @@ -1454,6 +1602,10 @@ def build_llm_exchange(event: Dict, post_tool_events: List[Dict], model: Optiona 'model': model, 'messages': messages, 'permission_mode': 'default', + 'cwd': cwd, + # Turn-level fallback: rows without a per-call project (the user + # prompt row, or tool-less turns) inherit the session cwd's repo. + 'project': _get_project(cwd), 'account_identity': build_account_identity(event, probe=True), } diff --git a/claude-code/hooks/unbound.py b/claude-code/hooks/unbound.py index ae126f1..4f015f3 100644 --- a/claude-code/hooks/unbound.py +++ b/claude-code/hooks/unbound.py @@ -1382,16 +1382,11 @@ def process_pre_tool_use(event: Dict, api_key: str) -> Dict: approval_key = f"{tool_name}:{command}" is_retry = _is_approval_retry(approval_key) - # Use cwd captured at session start instead of per-request - session_cwd = _get_session_cwd(session_id) - request_body = { 'conversation_id': session_id, 'unbound_app_label': _unbound_app_label(event), 'model': model, 'event_name': 'tool_use', - 'cwd': session_cwd, - 'project': _get_project(session_cwd), 'pre_tool_use_data': { 'command': command, 'tool_name': tool_name, @@ -1555,22 +1550,109 @@ def _get_project(cwd: Optional[str]) -> Optional[str]: return None +# Per-repo observation tiers for the end-of-turn exchange. The hook reports +# raw facts (which repos the turn touched, and how); the attribution policy +# lives server-side where it can be tuned without redeploying hooks. +_WRITE_TOOLS = {'Edit', 'Write', 'NotebookEdit'} +_READ_TOOLS = {'Read', 'Grep', 'Glob'} + +# Any absolute path inside a Bash command (cd targets, git -C, pytest /x/…). +# Left boundary required so the slash inside a relative token (tests/webapp/) +# doesn't read as an absolute path. +_ABS_PATH_RE = re.compile(r'(?:^|[\s"\'=(])(/[^\s"\';|&<>()]+)') +# `cd ` occurrences — absolute, ~-rooted, or relative — used to track +# the persistent shell's working directory across the turn's Bash calls. +_CD_TARGET_RE = re.compile(r'(?:^|[;&|]\s*|\bthen\s+|\bdo\s+)cd\s+(["\']?)([^\s"\';|&]+)\1') + + +def _find_git_root(path: str) -> Optional[str]: + """Nearest ancestor of `path` (inclusive) containing a `.git` entry + (directory, or file for linked worktrees). Pure filesystem stats — no + subprocess. None when outside any repo or on any error (fail-open).""" + try: + p = Path(path) + for parent in [p] + list(p.parents): + if (parent / '.git').exists(): + return str(parent) + except Exception: + pass + return None + + +def _next_shell_dir(command: str, shell_dir: Optional[str]) -> Optional[str]: + """Follow the last `cd` in `command` from `shell_dir`, mirroring the + persistent shell's directory between Bash calls. Absolute and ~-rooted + targets replace the dir; relative ones join onto it. Unchanged when the + command has no cd or on any error.""" + try: + target = None + for m in _CD_TARGET_RE.finditer(command): + target = m.group(2) + if not target: + return shell_dir + if target.startswith('~'): + target = os.path.expanduser(target) + if target.startswith('/'): + return os.path.normpath(target) + if target == '-': # `cd -` — previous dir isn't tracked; keep as-is + return shell_dir + if shell_dir: + return os.path.normpath(os.path.join(shell_dir, target)) + return shell_dir + except Exception: + return shell_dir + + +def _project_for_tool_use(tool_name: Optional[str], tool_input: Optional[Dict], shell_dir: Optional[str], root_projects: Dict[str, Optional[str]]) -> tuple: + """Resolve the git project ("/") a single tool call worked in. + Writes/reads resolve from the tool's file path; Bash resolves from the + first absolute path in the command, else the shell's working directory + tracked across the turn's `cd`s. Returns (project, shell_dir) — shell_dir + updated when the command changed directory. `root_projects` caches the + origin lookup so `git remote get-url` runs at most once per distinct repo. + (None, shell_dir) when nothing resolves (fail-open).""" + try: + tool_input = tool_input or {} + candidates = [] + if tool_name in _WRITE_TOOLS: + path = tool_input.get('file_path') or tool_input.get('notebook_path') + if isinstance(path, str) and path.startswith('/'): + candidates.append(os.path.dirname(path)) + elif tool_name in _READ_TOOLS: + path = tool_input.get('file_path') or tool_input.get('path') + if isinstance(path, str) and path.startswith('/'): + candidates.append(os.path.dirname(path) if tool_name == 'Read' else path) + elif tool_name == 'Bash': + command = tool_input.get('command') + if isinstance(command, str): + candidates.extend(_ABS_PATH_RE.findall(command)) + shell_dir = _next_shell_dir(command, shell_dir) + if not candidates and shell_dir: + candidates.append(shell_dir) + for candidate in candidates: + root = _find_git_root(candidate) + if not root: + continue + if root not in root_projects: + root_projects[root] = _get_project(root) + if root_projects[root]: + return root_projects[root], shell_dir + return None, shell_dir + except Exception: + return None, shell_dir + + def process_user_prompt_submit(event: Dict, api_key: str) -> Dict: """Process UserPromptSubmit event for policy checking.""" session_id = event.get('session_id') model = event.get('model') or _get_session_model(session_id) or 'auto' prompt = event.get('prompt', '') - # Use cwd captured at session start instead of per-request - session_cwd = _get_session_cwd(session_id) - request_body = { 'conversation_id': session_id, 'unbound_app_label': _unbound_app_label(event), 'model': model, 'event_name': 'user_prompt', - 'cwd': session_cwd, - 'project': _get_project(session_cwd), 'account_identity': build_account_identity(), 'messages': [{'role': 'user', 'content': prompt}] if prompt else [] } @@ -1580,14 +1662,17 @@ def process_user_prompt_submit(event: Dict, api_key: str) -> Dict: return response -def build_llm_exchange(events: List[Dict], stop_assistant_message: Optional[str] = None, transcript_assistant_messages: Optional[List[str]] = None, model: Optional[str] = None, usage: Optional[Dict] = None, request_initialized: Optional[str] = None, request_completed: Optional[str] = None, session_cwd: Optional[str] = None) -> Optional[Dict]: +def build_llm_exchange(events: List[Dict], stop_assistant_message: Optional[str] = None, transcript_assistant_messages: Optional[List[str]] = None, model: Optional[str] = None, usage: Optional[Dict] = None, request_initialized: Optional[str] = None, request_completed: Optional[str] = None, cwd: Optional[str] = None) -> Optional[Dict]: messages = [] assistant_tool_uses = [] user_prompt = None session_id = None permission_mode = None - cwd = session_cwd # Use the cwd passed in from session state + # Per-tool-use project resolution state: the persistent shell starts at + # the session cwd; origin lookups are cached per repo root. + shell_dir = cwd + root_projects = {} for log_entry in events: event = log_entry.get('event', {}) if 'event' in log_entry else log_entry @@ -1603,22 +1688,28 @@ def build_llm_exchange(events: List[Dict], stop_assistant_message: Optional[str] prompt = event.get('prompt') if prompt: user_prompt = prompt - + elif hook_event_name == 'PostToolUse': tool_name = event.get('tool_name') tool_input = event.get('tool_input', {}) tool_response = event.get('tool_response', {}) - + if 'content' in tool_response and 'content' in tool_input: if tool_response['content'] == tool_input['content']: tool_response = {k: v for k, v in tool_response.items() if k != 'content'} - + + # Attribute this tool call to the repo it worked in (file path / + # Bash cwd tracking); rides on the tool_use entry so the backend + # can store per-call project on each analytics row. + tool_project, shell_dir = _project_for_tool_use(tool_name, tool_input, shell_dir, root_projects) + assistant_tool_uses.append({ 'type': 'PostToolUse', 'tool_name': tool_name, 'tool_input': tool_input, 'tool_response': tool_response, - 'tool_use_id': event.get('tool_use_id') + 'tool_use_id': event.get('tool_use_id'), + 'project': tool_project }) if user_prompt: @@ -1658,6 +1749,8 @@ def build_llm_exchange(events: List[Dict], stop_assistant_message: Optional[str] 'messages': messages, 'permission_mode': permission_mode, 'cwd': cwd, + # Turn-level fallback: rows without a per-call project (the user + # prompt row, or tool-less turns) inherit the session cwd's repo. 'project': _get_project(cwd), 'account_identity': build_account_identity(probe=True), } @@ -1787,7 +1880,7 @@ def process_stop_event(event: Dict, api_key: str): usage=transcript_usage, request_initialized=user_prompt_timestamp, request_completed=request_completed, - session_cwd=_get_session_cwd(session_id), + cwd=event.get('cwd'), ) if exchange: @@ -2234,15 +2327,6 @@ def _dispatch_discovery() -> None: log_error(f"discovery gate failed: {e}", 'discovery_gate') -# Session-level state: store cwd per session to avoid re-extracting on every request -_SESSION_CWD_CACHE = {} - - -def _get_session_cwd(session_id: Optional[str]) -> Optional[str]: - """Get the working directory captured at session start.""" - return _SESSION_CWD_CACHE.get(session_id) if session_id else None - - def main(): global _cached_api_key api_key = get_api_key() @@ -2264,14 +2348,11 @@ def main(): hook_event_name = event.get('hook_event_name') session_id = event.get('session_id') - # SessionStart fires once per session — capture cwd once here for reuse + # SessionStart fires once per session — TTL gate for discovery and housekeeping if hook_event_name == "SessionStart": _device_serial() # warm the (slow) serial probe + cache once per session _check_self_update() _dispatch_discovery() - # Capture cwd at session start and cache it for the session lifetime - if session_id and 'cwd' in event: - _SESSION_CWD_CACHE[session_id] = event.get('cwd') print("{}") return session_id = event.get('session_id') diff --git a/codex/hooks/unbound.py b/codex/hooks/unbound.py index e618b9c..334517d 100644 --- a/codex/hooks/unbound.py +++ b/codex/hooks/unbound.py @@ -1042,14 +1042,128 @@ def cleanup_old_logs(): save_logs(logs[-AUDIT_LOG_TOTAL_LIMIT:]) -def parse_codex_transcript_for_tools(transcript_path: str, user_prompt_timestamp: Optional[str] = None) -> List[Dict]: +def _strip_git_suffix(segment: str) -> str: + return segment[:-4] if segment.endswith('.git') else segment + + +def _github_remote_path(remote_url: str) -> Optional[str]: + """Path portion ("org/repo[.git]") of an SSH or HTTPS git remote URL. + None when the URL is empty or has no recognizable path.""" + if not remote_url: + return None + url = remote_url.strip() + if '://' in url: + rest = url.split('://', 1)[1] + parts = rest.split('/', 1) + return parts[1] if len(parts) == 2 and parts[1] else None + if ':' in url: + rest = url.split(':', 1)[1] + return rest if rest else None + return None + + +def _get_project(cwd: Optional[str]) -> Optional[str]: + """Lowercased "/" for `cwd`'s git origin, attached to hook + requests for analytics. None when cwd is missing, not a git repo, has no + origin, or anything fails — fully fail-open (never raises).""" + try: + if not cwd: + return None + result = subprocess.run( + ['git', '-C', cwd, 'remote', 'get-url', 'origin'], + capture_output=True, text=True, timeout=10, + ) + if result.returncode != 0: + return None + path = _github_remote_path(result.stdout.strip()) + if not path: + return None + parts = path.split('/') + if len(parts) < 2: + return None + org = _strip_git_suffix(parts[0]) + repo = _strip_git_suffix(parts[1]) + return f"{org.lower()}/{repo.lower()}" if org and repo else None + except Exception: + return None + + +def _find_git_root(path: str) -> Optional[str]: + """Nearest ancestor of `path` (inclusive) containing a `.git` entry + (directory, or file for linked worktrees). Pure filesystem stats — no + subprocess. None when outside any repo or on any error (fail-open).""" + try: + p = Path(path) + for parent in [p] + list(p.parents): + if (parent / '.git').exists(): + return str(parent) + except Exception: + pass + return None + + +# Any absolute path inside a shell command; left boundary required so the +# slash inside a relative token (tests/webapp/) doesn't read as absolute. +_ABS_PATH_RE = re.compile(r'(?:^|[\s"\'=(])(/[^\s"\';|&<>()]+)') +# `cd ` occurrences — absolute, ~-rooted, or relative — used to track +# the shell's working directory across the turn's exec_command calls. +_CD_TARGET_RE = re.compile(r'(?:^|[;&|]\s*|\bthen\s+|\bdo\s+)cd\s+(["\']?)([^\s"\';|&]+)\1') + + +def _next_shell_dir(command: str, shell_dir: Optional[str]) -> Optional[str]: + """Follow the last `cd` in `command` from `shell_dir`. Absolute and + ~-rooted targets replace the dir; relative ones join onto it. Unchanged + when the command has no cd or on any error.""" + try: + target = None + for m in _CD_TARGET_RE.finditer(command): + target = m.group(2) + if not target: + return shell_dir + if target.startswith('~'): + target = os.path.expanduser(target) + if target.startswith('/'): + return os.path.normpath(target) + if target == '-': + return shell_dir + if shell_dir: + return os.path.normpath(os.path.join(shell_dir, target)) + return shell_dir + except Exception: + return shell_dir + + +def _project_for_paths(candidates: List[Optional[str]], root_projects: Dict[str, Optional[str]]) -> Optional[str]: + """First project ("/") resolved from `candidates` paths. + `root_projects` caches origin lookups so `git remote get-url` runs at + most once per distinct repo. None when nothing resolves (fail-open).""" + try: + for candidate in candidates: + if not candidate: + continue + root = _find_git_root(candidate) + if not root: + continue + if root not in root_projects: + root_projects[root] = _get_project(root) + if root_projects[root]: + return root_projects[root] + except Exception: + pass + return None + + +def parse_codex_transcript_for_tools(transcript_path: str, user_prompt_timestamp: Optional[str] = None, session_cwd: Optional[str] = None) -> List[Dict]: """Parse Codex transcript for function_call/function_call_output pairs. Codex transcripts use response_item entries with: - type: 'function_call' (contains name, arguments with cmd) - type: 'function_call_output' (contains output) - Converts to PostToolUse format matching Claude Code hooks for backend compatibility. + Converts to PostToolUse format matching Claude Code hooks for backend + compatibility. Each entry gets a per-call `project` ("/") + resolved from the command's workdir / absolute paths / the shell dir + tracked across the turn's `cd`s (seeded with `session_cwd`). """ tool_uses = [] if not transcript_path or not os.path.exists(transcript_path): @@ -1096,7 +1210,11 @@ def parse_codex_transcript_for_tools(transcript_path: str, user_prompt_timestamp except json.JSONDecodeError: continue - # Match calls with outputs and convert to PostToolUse format + # Match calls with outputs and convert to PostToolUse format. + # shell_dir mirrors the persistent shell across the turn's commands + # (seeded with the session cwd); root_projects caches origin lookups. + shell_dir = session_cwd + root_projects: Dict[str, Optional[str]] = {} for call_id, call_data in function_calls.items(): name = call_data.get('name', '') args = call_data.get('arguments', {}) @@ -1107,7 +1225,23 @@ def parse_codex_transcript_for_tools(transcript_path: str, user_prompt_timestamp # are handled generically as fallback for future Codex tool support. if name == 'exec_command': tool_name = 'Bash' - tool_input = {'command': args.get('cmd', '')} + command = args.get('cmd', '') + if isinstance(command, list): + command = ' '.join(str(c) for c in command) + tool_input = {'command': command} + # Attribute the call to the repo it ran in: explicit workdir + # first, then absolute paths in the command, then the tracked + # shell dir. + candidates = [] + workdir = args.get('workdir') + if isinstance(workdir, str) and workdir: + candidates.append(workdir) + if isinstance(command, str): + candidates.extend(_ABS_PATH_RE.findall(command)) + shell_dir = _next_shell_dir(command, shell_dir) + if not candidates and shell_dir: + candidates.append(shell_dir) + project = _project_for_paths(candidates, root_projects) # Parse exec_command output format to extract clean stdout and exit_code stdout = output exit_code = 0 @@ -1125,13 +1259,20 @@ def parse_codex_transcript_for_tools(transcript_path: str, user_prompt_timestamp tool_name = name tool_input = args if isinstance(args, dict) else {'command': str(args)} tool_response = {'stdout': output} + # Resolve from any absolute paths in the arguments (e.g. + # apply_patch file paths), falling back to the shell dir. + candidates = _ABS_PATH_RE.findall(json.dumps(tool_input)) + if not candidates and shell_dir: + candidates = [shell_dir] + project = _project_for_paths(candidates, root_projects) tool_uses.append({ 'type': 'PostToolUse', 'tool_name': tool_name, 'tool_input': tool_input, 'tool_response': tool_response, - 'tool_use_id': call_id + 'tool_use_id': call_id, + 'project': project }) except Exception: @@ -1221,8 +1362,11 @@ def process_stop_event(event: Dict, api_key: str): messages = [{'role': 'user', 'content': user_prompt}] - # Parse tool uses from Codex transcript (function_call/function_call_output pairs) - assistant_tool_uses = parse_codex_transcript_for_tools(transcript_path, user_prompt_timestamp) + # Parse tool uses from Codex transcript (function_call/function_call_output + # pairs); the session cwd seeds shell-dir tracking for per-call project + # attribution. + cwd = event.get('cwd') + assistant_tool_uses = parse_codex_transcript_for_tools(transcript_path, user_prompt_timestamp, session_cwd=cwd) assistant_msg = { 'role': 'assistant', @@ -1239,7 +1383,11 @@ def process_stop_event(event: Dict, api_key: str): 'conversation_id': session_id or 'unknown', 'model': event.get('model', 'auto'), 'messages': messages, - 'permission_mode': permission_mode or 'default' + 'permission_mode': permission_mode or 'default', + 'cwd': cwd, + # Turn-level fallback: rows without a per-call project (the user + # prompt row, or tool-less turns) inherit the session cwd's repo. + 'project': _get_project(cwd) } usage = parse_codex_transcript_for_usage(transcript_path, user_prompt_timestamp) diff --git a/copilot/hooks/unbound.py b/copilot/hooks/unbound.py index 781f4b1..ed43c3e 100644 --- a/copilot/hooks/unbound.py +++ b/copilot/hooks/unbound.py @@ -1208,6 +1208,117 @@ def process_user_prompt_submit(event, api_key): return transform_response_for_copilot_prompt(api_response) +def _strip_git_suffix(segment): + return segment[:-4] if segment.endswith('.git') else segment + + +def _github_remote_path(remote_url): + """Path portion ("org/repo[.git]") of an SSH or HTTPS git remote URL. + None when the URL is empty or has no recognizable path.""" + if not remote_url: + return None + url = remote_url.strip() + if '://' in url: + rest = url.split('://', 1)[1] + parts = rest.split('/', 1) + return parts[1] if len(parts) == 2 and parts[1] else None + if ':' in url: + rest = url.split(':', 1)[1] + return rest if rest else None + return None + + +def _get_project(cwd): + """Lowercased "/" for `cwd`'s git origin, attached to hook + requests for analytics. None when cwd is missing, not a git repo, has no + origin, or anything fails — fully fail-open (never raises).""" + try: + if not cwd: + return None + result = subprocess.run( + ['git', '-C', cwd, 'remote', 'get-url', 'origin'], + capture_output=True, text=True, timeout=10, + ) + if result.returncode != 0: + return None + path = _github_remote_path(result.stdout.strip()) + if not path: + return None + parts = path.split('/') + if len(parts) < 2: + return None + org = _strip_git_suffix(parts[0]) + repo = _strip_git_suffix(parts[1]) + return f"{org.lower()}/{repo.lower()}" if org and repo else None + except Exception: + return None + + +def _find_git_root(path): + """Nearest ancestor of `path` (inclusive) containing a `.git` entry + (directory, or file for linked worktrees). Pure filesystem stats — no + subprocess. None when outside any repo or on any error (fail-open).""" + try: + p = Path(path) + for parent in [p] + list(p.parents): + if (parent / '.git').exists(): + return str(parent) + except Exception: + pass + return None + + +# Any absolute path inside a shell command; left boundary required so the +# slash inside a relative token (tests/webapp/) doesn't read as absolute. +_ABS_PATH_RE = re.compile(r'(?:^|[\s"\'=(])(/[^\s"\';|&<>()]+)') +# `cd ` occurrences — absolute, ~-rooted, or relative — used to track +# the shell's working directory across the turn's shell commands. +_CD_TARGET_RE = re.compile(r'(?:^|[;&|]\s*|\bthen\s+|\bdo\s+)cd\s+(["\']?)([^\s"\';|&]+)\1') + + +def _next_shell_dir(command, shell_dir): + """Follow the last `cd` in `command` from `shell_dir`. Absolute and + ~-rooted targets replace the dir; relative ones join onto it. Unchanged + when the command has no cd or on any error.""" + try: + target = None + for m in _CD_TARGET_RE.finditer(command): + target = m.group(2) + if not target: + return shell_dir + if target.startswith('~'): + target = os.path.expanduser(target) + if target.startswith('/'): + return os.path.normpath(target) + if target == '-': + return shell_dir + if shell_dir: + return os.path.normpath(os.path.join(shell_dir, target)) + return shell_dir + except Exception: + return shell_dir + + +def _project_for_paths(candidates, root_projects): + """First project ("/") resolved from `candidates` paths. + `root_projects` caches origin lookups so `git remote get-url` runs at + most once per distinct repo. None when nothing resolves (fail-open).""" + try: + for candidate in candidates: + if not candidate: + continue + root = _find_git_root(candidate) + if not root: + continue + if root not in root_projects: + root_projects[root] = _get_project(root) + if root_projects[root]: + return root_projects[root] + except Exception: + pass + return None + + def _normalize_arguments(arguments): """Copilot tool arguments may be a dict or a JSON string. Always return a dict.""" if isinstance(arguments, dict): @@ -1233,38 +1344,67 @@ def _extract_patch_target_path(args): return m.group(1).strip() if m else '' -def map_copilot_tool(name, args, result_content): +def map_copilot_tool(name, args, result_content, shell_state=None, root_projects=None): """Map a Copilot tool call to a cursor-style tool_use entry. Returns None for internal orchestration tools (intentionally not emitted). + + When `shell_state` ({'dir': } tracked across the turn) and + `root_projects` (per-repo origin cache) are provided, each entry gets a + per-call `project` ("/") — file entries resolve from their + file path (relative paths joined onto the shell dir), shell entries from + absolute paths in the command or the tracked shell dir. """ if name in INTERNAL_TOOLS: return None - if name in SHELL_TOOLS: - entry = { - 'type': 'afterShellExecution', - 'command': args.get('command') or args.get('input') or args.get('text') or '', - 'output': result_content or '', - } - elif name in TERMINAL_LIKE_TOOLS: + shell_state = shell_state if shell_state is not None else {} + root_projects = root_projects if root_projects is not None else {} + + def _abs(path): + if not isinstance(path, str) or not path: + return None + if path.startswith('/'): + return path + base = shell_state.get('dir') + return os.path.normpath(os.path.join(base, path)) if base else None + + project = None + if name in SHELL_TOOLS or name in TERMINAL_LIKE_TOOLS: + if name in SHELL_TOOLS: + command = args.get('command') or args.get('input') or args.get('text') or '' + else: + command = TERMINAL_LIKE_TOOLS[name](args) entry = { 'type': 'afterShellExecution', - 'command': TERMINAL_LIKE_TOOLS[name](args), + 'command': command, 'output': result_content or '', } + candidates = [] + if isinstance(command, str): + candidates.extend(_ABS_PATH_RE.findall(command)) + shell_state['dir'] = _next_shell_dir(command, shell_state.get('dir')) + if not candidates and shell_state.get('dir'): + candidates.append(shell_state['dir']) + project = _project_for_paths(candidates, root_projects) elif name in READ_TOOLS: + file_path = args.get('filePath') or args.get('path') or args.get('file_path') or '' entry = { 'type': 'beforeReadFile', - 'file_path': args.get('filePath') or args.get('path') or args.get('file_path') or '', + 'file_path': file_path, 'content': result_content or '', } + abs_path = _abs(file_path) + project = _project_for_paths([os.path.dirname(abs_path)] if abs_path else [], root_projects) elif name in WRITE_TOOLS or name in EDIT_TOOLS: + file_path = (args.get('filePath') or args.get('path') or args.get('file_path') + or _extract_patch_target_path(args) or '') entry = { 'type': 'afterFileEdit', - 'file_path': (args.get('filePath') or args.get('path') or args.get('file_path') - or _extract_patch_target_path(args) or ''), + 'file_path': file_path, 'content': args.get('content') or args.get('file_text') or result_content or '', } + abs_path = _abs(file_path) + project = _project_for_paths([os.path.dirname(abs_path)] if abs_path else [], root_projects) else: entry = { 'type': 'afterMCPExecution', @@ -1272,13 +1412,25 @@ def map_copilot_tool(name, args, result_content): 'tool_input': args, 'result_json': result_content or '', } + try: + candidates = _ABS_PATH_RE.findall(json.dumps(args)) + except Exception: + candidates = [] + project = _project_for_paths(candidates, root_projects) + # Drop empty-string values. - return {k: v for k, v in entry.items() if v != ''} + mapped = {k: v for k, v in entry.items() if v != ''} + if project: + mapped['project'] = project + return mapped -def build_exchange_from_transcript(transcript_path, fallback_session_id, session_start_model=None): +def build_exchange_from_transcript(transcript_path, fallback_session_id, session_start_model=None, cwd=None): """Parse a Copilot JSONL transcript into a cursor-style LLM exchange. + `cwd` (the hook event's working directory) seeds shell-dir tracking for + per-call project attribution and rides on the exchange. + Reads defensively — blank or unparseable lines are skipped, never raised.""" if not transcript_path or not os.path.exists(transcript_path): return None @@ -1379,9 +1531,14 @@ def _register(call_id): call['result'] = result.get('content') tool_use = [] + # Per-call project attribution state: the shell starts at the session + # cwd; origin lookups are cached once per repo across the turn. + shell_state = {'dir': cwd} + root_projects = {} for call_id in tool_calls: call = tool_data[call_id] - mapped = map_copilot_tool(call['name'], call['arguments'], call['result']) + mapped = map_copilot_tool(call['name'], call['arguments'], call['result'], + shell_state=shell_state, root_projects=root_projects) # `is not None` (not truthiness): None means a consciously-dropped internal # tool; an empty-but-valid dict should still be appended. if mapped is not None: @@ -1403,6 +1560,10 @@ def _register(call_id): 'conversation_id': conversation_id, 'model': model or session_start_model or 'auto', 'messages': messages, + 'cwd': cwd, + # Turn-level fallback: rows without a per-call project (the user + # prompt row, or tool-less turns) inherit the session cwd's repo. + 'project': _get_project(cwd), } @@ -1833,6 +1994,7 @@ def main(): exchange = build_exchange_from_transcript( event.get('transcript_path'), session_id, session_start_model=get_session_start_model(session_id), + cwd=event.get('cwd'), ) if exchange: # Turn boundaries from event-fire times diff --git a/cursor/unbound.py b/cursor/unbound.py old mode 100644 new mode 100755 index a266fd9..91a943a --- a/cursor/unbound.py +++ b/cursor/unbound.py @@ -1093,6 +1093,91 @@ def _i(key): } +def _strip_git_suffix(segment): + return segment[:-4] if segment.endswith('.git') else segment + + +def _github_remote_path(remote_url): + """Path portion ("org/repo[.git]") of an SSH or HTTPS git remote URL. + None when the URL is empty or has no recognizable path.""" + if not remote_url: + return None + url = remote_url.strip() + if '://' in url: + rest = url.split('://', 1)[1] + parts = rest.split('/', 1) + return parts[1] if len(parts) == 2 and parts[1] else None + if ':' in url: + rest = url.split(':', 1)[1] + return rest if rest else None + return None + + +def _get_project(cwd): + """Lowercased "/" for `cwd`'s git origin, attached to hook + requests for analytics. None when cwd is missing, not a git repo, has no + origin, or anything fails — fully fail-open (never raises).""" + try: + if not cwd: + return None + result = subprocess.run( + ['git', '-C', cwd, 'remote', 'get-url', 'origin'], + capture_output=True, text=True, timeout=10, + ) + if result.returncode != 0: + return None + path = _github_remote_path(result.stdout.strip()) + if not path: + return None + parts = path.split('/') + if len(parts) < 2: + return None + org = _strip_git_suffix(parts[0]) + repo = _strip_git_suffix(parts[1]) + return f"{org.lower()}/{repo.lower()}" if org and repo else None + except Exception: + return None + + +def _find_git_root(path): + """Nearest ancestor of `path` (inclusive) containing a `.git` entry + (directory, or file for linked worktrees). Pure filesystem stats — no + subprocess. None when outside any repo or on any error (fail-open).""" + try: + p = Path(path) + for parent in [p] + list(p.parents): + if (parent / '.git').exists(): + return str(parent) + except Exception: + pass + return None + + +# Any absolute path inside a shell command; left boundary required so the +# slash inside a relative token (tests/webapp/) doesn't read as absolute. +_ABS_PATH_RE = re.compile(r'(?:^|[\s"\'=(])(/[^\s"\';|&<>()]+)') + + +def _project_for_paths(candidates, root_projects): + """First project ("/") resolved from `candidates` paths. + `root_projects` caches origin lookups so `git remote get-url` runs at + most once per distinct repo. None when nothing resolves (fail-open).""" + try: + for candidate in candidates: + if not candidate: + continue + root = _find_git_root(candidate) + if not root: + continue + if root not in root_projects: + root_projects[root] = _get_project(root) + if root_projects[root]: + return root_projects[root] + except Exception: + pass + return None + + def build_llm_exchange(events, api_key=None): """Build standard LLM exchange format from events.""" messages = [] @@ -1107,11 +1192,20 @@ def build_llm_exchange(events, api_key=None): request_initialized = None request_completed = None usage = None + # Working-dir context for per-entry project attribution: every Cursor + # event carries workspace_roots; shell events carry an explicit cwd. + workspace_cwd = None + root_projects = {} for log_entry in events: event = log_entry.get('event', {}) hook_event_name = event.get('hook_event_name') + if not workspace_cwd: + roots = event.get('workspace_roots') + if isinstance(roots, list) and roots and isinstance(roots[0], str): + workspace_cwd = roots[0] + if not conversation_id: conversation_id = event.get('conversation_id') @@ -1133,11 +1227,15 @@ def build_llm_exchange(events, api_key=None): usage = _cursor_usage_from_event(event) or usage elif hook_event_name == 'beforeReadFile': + file_path = event.get('file_path') assistant_tool_uses.append({ 'type': hook_event_name, - 'file_path': event.get('file_path'), + 'file_path': file_path, 'content': event.get('content', ''), - 'attachments': event.get('attachments', []) + 'attachments': event.get('attachments', []), + 'project': _project_for_paths( + [os.path.dirname(file_path)] if isinstance(file_path, str) and file_path.startswith('/') else [], + root_projects) }) elif hook_event_name == 'postToolUse': @@ -1145,30 +1243,57 @@ def build_llm_exchange(events, api_key=None): if tool_name not in EXCHANGE_NATIVE_TOOLS: continue - + tool_output = event.get('tool_output', '') + # Attribute the call to the repo it worked in: the event's own + # cwd when present, else absolute paths inside tool_input. + candidates = [] + if isinstance(event.get('cwd'), str): + candidates.append(event['cwd']) + tool_input = event.get('tool_input') + if isinstance(tool_input, dict): + for value in tool_input.values(): + if isinstance(value, str) and value.startswith('/'): + candidates.append(os.path.dirname(value)) + assistant_tool_uses.append({ 'type': hook_event_name, 'tool_name': tool_name, - 'tool_input': event.get('tool_input'), + 'tool_input': tool_input, 'tool_output': tool_output, 'duration': event.get('duration'), - 'tool_use_id': event.get('tool_use_id') + 'tool_use_id': event.get('tool_use_id'), + 'project': _project_for_paths(candidates or [workspace_cwd], root_projects) }) - + elif hook_event_name == 'afterFileEdit': + file_path = event.get('file_path') assistant_tool_uses.append({ 'type': hook_event_name, - 'file_path': event.get('file_path'), - 'edits': event.get('edits', []) + 'file_path': file_path, + 'edits': event.get('edits', []), + 'project': _project_for_paths( + [os.path.dirname(file_path)] if isinstance(file_path, str) and file_path.startswith('/') else [], + root_projects) }) - + elif hook_event_name == 'afterShellExecution': + command = event.get('command') + # The event's cwd is where the command actually ran; absolute + # paths in the command and the workspace root are fallbacks. + candidates = [] + if isinstance(event.get('cwd'), str): + candidates.append(event['cwd']) + if isinstance(command, str): + candidates.extend(_ABS_PATH_RE.findall(command)) + if not candidates and workspace_cwd: + candidates.append(workspace_cwd) assistant_tool_uses.append({ 'type': hook_event_name, - 'command': event.get('command'), - 'output': event.get('output', '') + 'command': command, + 'output': event.get('output', ''), + 'project': _project_for_paths(candidates, root_projects) }) elif hook_event_name == 'afterMCPExecution': @@ -1202,6 +1327,10 @@ def build_llm_exchange(events, api_key=None): 'conversation_id': conversation_id, 'model': model, 'messages': messages, + 'cwd': workspace_cwd, + # Turn-level fallback: rows without a per-call project (the user + # prompt row, or tool-less turns) inherit the workspace repo. + 'project': _project_for_paths([workspace_cwd], root_projects), 'account_identity': build_account_identity({'user_email': user_email}, probe=True) } From b94abcce3567c93810071bd6fdb2bf9037f2f562 Mon Sep 17 00:00:00 2001 From: Gowshik T Date: Wed, 15 Jul 2026 13:58:26 +0530 Subject: [PATCH 4/4] fix(WEB-4974): Track cd across multi-line shell commands The cd-target regex boundary lacked \n, so "git add .\ncd /repoB\nmake" never updated the tracked shell dir and later calls were attributed to the old repo. Applied to claude-code, codex, copilot, and augment hooks. Co-Authored-By: Claude Fable 5 --- augment/hooks/unbound.py | 2 +- claude-code/hooks/unbound.py | 2 +- codex/hooks/unbound.py | 2 +- copilot/hooks/unbound.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/augment/hooks/unbound.py b/augment/hooks/unbound.py index 25f2f79..63d783a 100644 --- a/augment/hooks/unbound.py +++ b/augment/hooks/unbound.py @@ -1376,7 +1376,7 @@ def _get_project(cwd: Optional[str]) -> Optional[str]: _ABS_PATH_RE = re.compile(r'(?:^|[\s"\'=(])(/[^\s"\';|&<>()]+)') # `cd ` occurrences — absolute, ~-rooted, or relative — used to track # the shell's working directory across the turn's launch-process calls. -_CD_TARGET_RE = re.compile(r'(?:^|[;&|]\s*|\bthen\s+|\bdo\s+)cd\s+(["\']?)([^\s"\';|&]+)\1') +_CD_TARGET_RE = re.compile(r'(?:^|[;&|\n]\s*|\bthen\s+|\bdo\s+)cd\s+(["\']?)([^\s"\';|&]+)\1') def _find_git_root(path: str) -> Optional[str]: diff --git a/claude-code/hooks/unbound.py b/claude-code/hooks/unbound.py index 420b954..2ef5a5a 100644 --- a/claude-code/hooks/unbound.py +++ b/claude-code/hooks/unbound.py @@ -1560,7 +1560,7 @@ def _get_project(cwd: Optional[str]) -> Optional[str]: _ABS_PATH_RE = re.compile(r'(?:^|[\s"\'=(])(/[^\s"\';|&<>()]+)') # `cd ` occurrences — absolute, ~-rooted, or relative — used to track # the persistent shell's working directory across the turn's Bash calls. -_CD_TARGET_RE = re.compile(r'(?:^|[;&|]\s*|\bthen\s+|\bdo\s+)cd\s+(["\']?)([^\s"\';|&]+)\1') +_CD_TARGET_RE = re.compile(r'(?:^|[;&|\n]\s*|\bthen\s+|\bdo\s+)cd\s+(["\']?)([^\s"\';|&]+)\1') def _find_git_root(path: str) -> Optional[str]: diff --git a/codex/hooks/unbound.py b/codex/hooks/unbound.py index 3f97373..be78a43 100644 --- a/codex/hooks/unbound.py +++ b/codex/hooks/unbound.py @@ -1105,7 +1105,7 @@ def _find_git_root(path: str) -> Optional[str]: _ABS_PATH_RE = re.compile(r'(?:^|[\s"\'=(])(/[^\s"\';|&<>()]+)') # `cd ` occurrences — absolute, ~-rooted, or relative — used to track # the shell's working directory across the turn's exec_command calls. -_CD_TARGET_RE = re.compile(r'(?:^|[;&|]\s*|\bthen\s+|\bdo\s+)cd\s+(["\']?)([^\s"\';|&]+)\1') +_CD_TARGET_RE = re.compile(r'(?:^|[;&|\n]\s*|\bthen\s+|\bdo\s+)cd\s+(["\']?)([^\s"\';|&]+)\1') def _next_shell_dir(command: str, shell_dir: Optional[str]) -> Optional[str]: diff --git a/copilot/hooks/unbound.py b/copilot/hooks/unbound.py index 81e8314..cb7d41c 100644 --- a/copilot/hooks/unbound.py +++ b/copilot/hooks/unbound.py @@ -1367,7 +1367,7 @@ def _find_git_root(path): _ABS_PATH_RE = re.compile(r'(?:^|[\s"\'=(])(/[^\s"\';|&<>()]+)') # `cd ` occurrences — absolute, ~-rooted, or relative — used to track # the shell's working directory across the turn's shell commands. -_CD_TARGET_RE = re.compile(r'(?:^|[;&|]\s*|\bthen\s+|\bdo\s+)cd\s+(["\']?)([^\s"\';|&]+)\1') +_CD_TARGET_RE = re.compile(r'(?:^|[;&|\n]\s*|\bthen\s+|\bdo\s+)cd\s+(["\']?)([^\s"\';|&]+)\1') def _next_shell_dir(command, shell_dir):