Skip to content

Commit 0233c8f

Browse files
gowshik450526511Gowshik TclaudeGowshik TGowshik T
authored
feat(WEB-4974): Per-tool-call project attribution across all coding-agent hooks (#214)
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Gowshik T <gowshik@Gowshiks-MacBook-Air.local> Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com> Co-authored-by: Gowshik T <gowshik@192.168.1.15> Co-authored-by: Gowshik T <gowshik@192.168.1.29>
1 parent a31537a commit 0233c8f

5 files changed

Lines changed: 834 additions & 48 deletions

File tree

augment/hooks/unbound.py

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1356,6 +1356,142 @@ def process_pre_tool_use(event: Dict, api_key: str) -> Dict:
13561356
return transform_response_for_claude(api_response)
13571357

13581358

1359+
def _strip_git_suffix(segment: str) -> str:
1360+
return segment[:-4] if segment.endswith('.git') else segment
1361+
1362+
1363+
def _github_remote_path(remote_url: Optional[str]) -> Optional[str]:
1364+
"""Path portion ("org/repo[.git]") of an SSH or HTTPS git remote URL.
1365+
None when the URL is empty or has no recognizable path."""
1366+
if not remote_url:
1367+
return None
1368+
url = remote_url.strip()
1369+
if '://' in url:
1370+
rest = url.split('://', 1)[1]
1371+
parts = rest.split('/', 1)
1372+
return parts[1] if len(parts) == 2 and parts[1] else None
1373+
if ':' in url:
1374+
rest = url.split(':', 1)[1]
1375+
return rest if rest else None
1376+
return None
1377+
1378+
1379+
def _get_project(cwd: Optional[str]) -> Optional[str]:
1380+
"""Lowercased "<org>/<repo>" for `cwd`'s git origin, attached to hook
1381+
requests for analytics. None when cwd is missing, not a git repo, has no
1382+
origin, or anything fails — fully fail-open (never raises)."""
1383+
try:
1384+
if not cwd:
1385+
return None
1386+
result = subprocess.run(
1387+
['git', '-C', cwd, 'remote', 'get-url', 'origin'],
1388+
capture_output=True, text=True, timeout=10,
1389+
)
1390+
if result.returncode != 0:
1391+
return None
1392+
path = _github_remote_path(result.stdout.strip())
1393+
if not path:
1394+
return None
1395+
parts = path.split('/')
1396+
if len(parts) < 2:
1397+
return None
1398+
org = _strip_git_suffix(parts[0])
1399+
repo = _strip_git_suffix(parts[1])
1400+
return f"{org.lower()}/{repo.lower()}" if org and repo else None
1401+
except Exception:
1402+
return None
1403+
1404+
1405+
# Canonical (post-AUGMENT_TOOL_FAMILY) tool names whose input carries a file
1406+
# path — used for per-tool-call project attribution on the Stop exchange.
1407+
_FILE_TOOLS = {'Read', 'Write', 'Edit', 'Delete'}
1408+
1409+
# Any absolute path inside a shell command (cd targets, git -C, pytest /x/…).
1410+
# Left boundary required so the slash inside a relative token (tests/webapp/)
1411+
# doesn't read as an absolute path.
1412+
_ABS_PATH_RE = re.compile(r'(?:^|[\s"\'=(])(/[^\s"\';|&<>()]+)')
1413+
# `cd <target>` occurrences — absolute, ~-rooted, or relative — used to track
1414+
# the shell's working directory across the turn's launch-process calls.
1415+
_CD_TARGET_RE = re.compile(r'(?:^|[;&|\n]\s*|\bthen\s+|\bdo\s+)cd\s+(["\']?)([^\s"\';|&]+)\1')
1416+
1417+
1418+
def _find_git_root(path: str) -> Optional[str]:
1419+
"""Nearest ancestor of `path` (inclusive) containing a `.git` entry
1420+
(directory, or file for linked worktrees). Pure filesystem stats — no
1421+
subprocess. None when outside any repo or on any error (fail-open)."""
1422+
try:
1423+
p = Path(path)
1424+
for parent in [p] + list(p.parents):
1425+
if (parent / '.git').exists():
1426+
return str(parent)
1427+
except Exception:
1428+
pass
1429+
return None
1430+
1431+
1432+
def _next_shell_dir(command: str, shell_dir: Optional[str]) -> Optional[str]:
1433+
"""Follow the last `cd` in `command` from `shell_dir`. Absolute and
1434+
~-rooted targets replace the dir; relative ones join onto it. Unchanged
1435+
when the command has no cd or on any error."""
1436+
try:
1437+
target = None
1438+
for m in _CD_TARGET_RE.finditer(command):
1439+
target = m.group(2)
1440+
if not target:
1441+
return shell_dir
1442+
if target.startswith('~'):
1443+
target = os.path.expanduser(target)
1444+
if target.startswith('/'):
1445+
return os.path.normpath(target)
1446+
if target == '-': # `cd -` — previous dir isn't tracked; keep as-is
1447+
return shell_dir
1448+
if shell_dir:
1449+
return os.path.normpath(os.path.join(shell_dir, target))
1450+
return shell_dir
1451+
except Exception:
1452+
return shell_dir
1453+
1454+
1455+
def _project_for_tool_use(tool_name: Optional[str], tool_input: Optional[Dict], shell_dir: Optional[str], root_projects: Dict[str, Optional[str]]) -> tuple:
1456+
"""Resolve the git project ("<org>/<repo>") a single tool call worked in.
1457+
File tools resolve from the tool's file path (Augment often sends
1458+
workspace-relative paths — those join onto the tracked shell dir); Bash
1459+
resolves from the first absolute path in the command, else the shell's
1460+
working directory tracked across the turn's `cd`s. Returns
1461+
(project, shell_dir) — shell_dir updated when the command changed
1462+
directory. `root_projects` caches the origin lookup so `git remote
1463+
get-url` runs at most once per distinct repo. (None, shell_dir) when
1464+
nothing resolves (fail-open)."""
1465+
try:
1466+
tool_input = tool_input or {}
1467+
candidates = []
1468+
if tool_name in _FILE_TOOLS:
1469+
path = tool_input.get('file_path')
1470+
if isinstance(path, str) and path:
1471+
if not path.startswith('/') and shell_dir:
1472+
path = os.path.normpath(os.path.join(shell_dir, path))
1473+
if path.startswith('/'):
1474+
candidates.append(os.path.dirname(path))
1475+
elif tool_name == 'Bash':
1476+
command = tool_input.get('command')
1477+
if isinstance(command, str):
1478+
candidates.extend(_ABS_PATH_RE.findall(command))
1479+
shell_dir = _next_shell_dir(command, shell_dir)
1480+
if not candidates and shell_dir:
1481+
candidates.append(shell_dir)
1482+
for candidate in candidates:
1483+
root = _find_git_root(candidate)
1484+
if not root:
1485+
continue
1486+
if root not in root_projects:
1487+
root_projects[root] = _get_project(root)
1488+
if root_projects[root]:
1489+
return root_projects[root], shell_dir
1490+
return None, shell_dir
1491+
except Exception:
1492+
return None, shell_dir
1493+
1494+
13591495
def _augment_posttooluse_to_exchange(ev: Dict, mcp_servers: Optional[Dict] = None) -> Optional[Dict]:
13601496
"""Map an Augment PostToolUse event to the Claude-Code-hooks tool_use shape the
13611497
backend analyzer consumes (type / tool_name / tool_input / tool_response).
@@ -1455,13 +1591,25 @@ def build_llm_exchange(event: Dict, post_tool_events: List[Dict], model: Optiona
14551591
assistant_response = (exchange.get('response_text')
14561592
or conversation.get('agentTextResponse') or '').strip()
14571593

1594+
# Per-tool-use project resolution state: the shell starts at the session
1595+
# cwd; origin lookups are cached per repo root across the turn.
1596+
cwd = event.get('cwd')
1597+
shell_dir = cwd
1598+
root_projects = {}
1599+
14581600
mcp_servers = read_augment_mcp_servers(event)
14591601
for log_entry in post_tool_events:
14601602
ev = log_entry.get('event', {}) if 'event' in log_entry else log_entry
14611603
if ev.get('hook_event_name') != 'PostToolUse':
14621604
continue
14631605
shaped = _augment_posttooluse_to_exchange(ev, mcp_servers)
14641606
if shaped:
1607+
# Attribute this tool call to the repo it worked in (file path /
1608+
# shell cwd tracking); rides on the tool_use entry so the backend
1609+
# can store per-call project on each analytics row.
1610+
tool_project, shell_dir = _project_for_tool_use(
1611+
shaped.get('tool_name'), shaped.get('tool_input'), shell_dir, root_projects)
1612+
shaped['project'] = tool_project
14651613
assistant_tool_uses.append(shaped)
14661614

14671615
if user_prompt:
@@ -1488,6 +1636,10 @@ def build_llm_exchange(event: Dict, post_tool_events: List[Dict], model: Optiona
14881636
'model': model,
14891637
'messages': messages,
14901638
'permission_mode': 'default',
1639+
'cwd': cwd,
1640+
# Turn-level fallback: rows without a per-call project (the user
1641+
# prompt row, or tool-less turns) inherit the session cwd's repo.
1642+
'project': _get_project(cwd),
14911643
'account_identity': build_account_identity(event, probe=True),
14921644
}
14931645

0 commit comments

Comments
 (0)