|
11 | 11 | from __future__ import annotations |
12 | 12 |
|
13 | 13 | import os |
| 14 | +import json |
| 15 | +import shlex |
14 | 16 | import shutil |
15 | 17 | import sys |
16 | 18 | from pathlib import Path |
@@ -234,6 +236,12 @@ def _audit_adapter( |
234 | 236 | # Unknown post_install action — just record |
235 | 237 | lines.append(f"post_install {action}: {st}") |
236 | 238 |
|
| 239 | + if adapter_name == "claude-code": |
| 240 | + hook_status, hook_lines = _audit_claude_hook_wiring(target_root) |
| 241 | + if hook_lines: |
| 242 | + lines.extend(hook_lines) |
| 243 | + status_overall = max(status_overall, hook_status, key=_status_rank) |
| 244 | + |
237 | 245 | # .agent/ brain still intact? |
238 | 246 | if not (target_root / ".agent" / "AGENTS.md").is_file(): |
239 | 247 | lines.append(".agent/AGENTS.md missing — brain not present") |
@@ -275,6 +283,91 @@ def _status_rank(s: str) -> int: |
275 | 283 | return {GREEN: 0, YELLOW: 1, RED: 2}[s] |
276 | 284 |
|
277 | 285 |
|
| 286 | +def _audit_claude_hook_wiring(target_root: Path) -> tuple[str, list[str]]: |
| 287 | + settings = target_root / ".claude" / "settings.json" |
| 288 | + if not settings.is_file(): |
| 289 | + return GREEN, [] |
| 290 | + try: |
| 291 | + data = json.loads(settings.read_text(encoding="utf-8")) |
| 292 | + except json.JSONDecodeError as e: |
| 293 | + return YELLOW, [f".claude/settings.json unreadable JSON: {e}"] |
| 294 | + |
| 295 | + referenced = _claude_hook_references(data) |
| 296 | + lines: list[str] = [] |
| 297 | + missing = [ |
| 298 | + rel |
| 299 | + for rel in sorted(referenced) |
| 300 | + if rel.startswith(".agent/") and not (target_root / rel).is_file() |
| 301 | + ] |
| 302 | + if missing: |
| 303 | + lines.append(f"missing hook command file(s): {', '.join(missing)}") |
| 304 | + |
| 305 | + hooks_dir = target_root / ".agent" / "harness" / "hooks" |
| 306 | + if hooks_dir.is_dir(): |
| 307 | + wired = {rel for rel in referenced if rel.startswith(".agent/harness/hooks/")} |
| 308 | + orphaned = [] |
| 309 | + for path in sorted(hooks_dir.glob("*.py")): |
| 310 | + if _ignore_claude_orphan_candidate(path.name): |
| 311 | + continue |
| 312 | + rel = path.relative_to(target_root).as_posix() |
| 313 | + if rel not in wired: |
| 314 | + orphaned.append(rel) |
| 315 | + if orphaned: |
| 316 | + lines.append( |
| 317 | + "orphaned hook files not referenced by .claude/settings.json: " |
| 318 | + + ", ".join(orphaned) |
| 319 | + ) |
| 320 | + return (YELLOW if lines else GREEN), lines |
| 321 | + |
| 322 | + |
| 323 | +def _claude_hook_references(settings: dict) -> set[str]: |
| 324 | + refs: set[str] = set() |
| 325 | + hooks = settings.get("hooks") or {} |
| 326 | + if not isinstance(hooks, dict): |
| 327 | + return refs |
| 328 | + for entries in hooks.values(): |
| 329 | + if not isinstance(entries, list): |
| 330 | + continue |
| 331 | + for entry in entries: |
| 332 | + if not isinstance(entry, dict): |
| 333 | + continue |
| 334 | + for hook in entry.get("hooks") or []: |
| 335 | + if not isinstance(hook, dict): |
| 336 | + continue |
| 337 | + command = hook.get("command") |
| 338 | + if isinstance(command, str): |
| 339 | + refs.update(_agent_paths_from_command(command)) |
| 340 | + return refs |
| 341 | + |
| 342 | + |
| 343 | +def _agent_paths_from_command(command: str) -> set[str]: |
| 344 | + try: |
| 345 | + tokens = shlex.split(command) |
| 346 | + except ValueError: |
| 347 | + tokens = command.split() |
| 348 | + refs: set[str] = set() |
| 349 | + for token in tokens: |
| 350 | + if ".agent/" not in token: |
| 351 | + continue |
| 352 | + rel = token[token.index(".agent/"):].strip(";,") |
| 353 | + if rel.endswith(".py"): |
| 354 | + refs.add(rel) |
| 355 | + return refs |
| 356 | + |
| 357 | + |
| 358 | +def _ignore_claude_orphan_candidate(filename: str) -> bool: |
| 359 | + if filename == "__init__.py" or filename.startswith("_"): |
| 360 | + return True |
| 361 | + if filename in { |
| 362 | + "on_failure.py", |
| 363 | + "post_execution.py", |
| 364 | + "pre_tool_call.py", |
| 365 | + "pi_post_tool.py", |
| 366 | + }: |
| 367 | + return True |
| 368 | + return False |
| 369 | + |
| 370 | + |
278 | 371 | def _summary(doc: dict, any_red: bool) -> str: |
279 | 372 | n = len(doc.get("adapters", {})) |
280 | 373 | if any_red: |
|
0 commit comments