diff --git a/augment/hooks/unbound.py b/augment/hooks/unbound.py index b213caf..47af2ca 100644 --- a/augment/hooks/unbound.py +++ b/augment/hooks/unbound.py @@ -1356,6 +1356,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'(?:^|[;&|\n]\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). @@ -1455,6 +1591,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 @@ -1462,6 +1604,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: @@ -1488,6 +1636,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 ca0be9c..124573b 100644 --- a/claude-code/hooks/unbound.py +++ b/claude-code/hooks/unbound.py @@ -1620,6 +1620,177 @@ 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 + + +# 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'(?:^|[;&|\n]\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') @@ -1636,16 +1807,21 @@ def process_user_prompt_submit(event: Dict, api_key: str) -> Dict: } 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]: +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 + # 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 @@ -1661,22 +1837,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': resolve_tool_use_id(event) + 'tool_use_id': resolve_tool_use_id(event), + 'project': tool_project }) if user_prompt: @@ -1715,6 +1897,10 @@ def build_llm_exchange(events: List[Dict], stop_assistant_message: Optional[str] 'model': model, '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), } @@ -1843,6 +2029,7 @@ def process_stop_event(event: Dict, api_key: str): usage=transcript_usage, request_initialized=user_prompt_timestamp, request_completed=request_completed, + cwd=event.get('cwd'), ) if exchange: @@ -2226,10 +2413,10 @@ 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 @@ -2241,9 +2428,9 @@ 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 — 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() @@ -2296,16 +2483,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') diff --git a/codex/hooks/unbound.py b/codex/hooks/unbound.py index 0d06500..8fa8468 100644 --- a/codex/hooks/unbound.py +++ b/codex/hooks/unbound.py @@ -1072,14 +1072,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'(?:^|[;&|\n]\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): @@ -1126,7 +1240,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', {}) @@ -1137,7 +1255,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 @@ -1155,13 +1289,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: @@ -1251,8 +1392,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) for item in assistant_tool_uses: if not item.get('tool_use_id'): @@ -1276,7 +1420,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 d64dd16..6a41744 100644 --- a/copilot/hooks/unbound.py +++ b/copilot/hooks/unbound.py @@ -1334,6 +1334,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'(?:^|[;&|\n]\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): @@ -1361,38 +1472,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', @@ -1400,14 +1540,26 @@ 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, - already_forwarded=None): + cwd=None, already_forwarded=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. Copilot fires a Stop per agent turn but the transcript slice below spans every @@ -1517,12 +1669,25 @@ 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 = {} forwarded_now = set() for call_id in tool_calls: - if call_id in already_forwarded: - continue # sent on an earlier Stop of this session — don't resend call = tool_data[call_id] - mapped = map_copilot_tool(call['name'], call['arguments'], call['result']) + if call_id in already_forwarded: + # Sent on an earlier Stop of this session — don't resend, but still + # follow any `cd` so later calls' project attribution keeps tracking + # the shell's working directory across the whole slice. + if call['name'] in SHELL_TOOLS and isinstance(call.get('arguments'), dict): + command = (call['arguments'].get('command') or call['arguments'].get('input') + or call['arguments'].get('text') or '') + if isinstance(command, str): + shell_state['dir'] = _next_shell_dir(command, shell_state.get('dir')) + continue + mapped = map_copilot_tool(call['name'], call['arguments'], call['result'], + shell_state=shell_state, root_projects=root_projects) # Advance the watermark for EVERY handled call, mapped or not: an internal tool # maps to None (nothing to send) but must still be recorded, else a turn of only # internal tools is reparsed on every later Stop and never records progress. @@ -1558,6 +1723,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), }, forwarded_now, text_sig @@ -1927,6 +2096,7 @@ def main(): exchange, forwarded_now, text_sig = build_exchange_from_transcript( event.get('transcript_path'), session_id, session_start_model=get_session_start_model(session_id), + cwd=event.get('cwd'), already_forwarded=already_forwarded, ) # Send only when there is something new -- new tool calls OR new assistant diff --git a/cursor/unbound.py b/cursor/unbound.py old mode 100644 new mode 100755 index ee6f51b..8ef2314 --- a/cursor/unbound.py +++ b/cursor/unbound.py @@ -1147,6 +1147,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 = [] @@ -1161,11 +1246,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') @@ -1187,12 +1281,16 @@ 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', []), - 'tool_use_id': _resolve_tool_use_id(event) + 'tool_use_id': _resolve_tool_use_id(event), + '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': @@ -1200,32 +1298,59 @@ 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': _resolve_tool_use_id(event) + 'tool_use_id': _resolve_tool_use_id(event), + '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'), + 'file_path': file_path, 'edits': event.get('edits', []), - 'tool_use_id': _resolve_tool_use_id(event) + 'tool_use_id': _resolve_tool_use_id(event), + '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'), + 'command': command, 'output': event.get('output', ''), - 'tool_use_id': _resolve_tool_use_id(event) + 'tool_use_id': _resolve_tool_use_id(event), + 'project': _project_for_paths(candidates, root_projects) }) elif hook_event_name == 'afterMCPExecution': @@ -1260,6 +1385,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) }