diff --git a/clawteam/board/collector.py b/clawteam/board/collector.py index 5ea418fb..abb2bf44 100644 --- a/clawteam/board/collector.py +++ b/clawteam/board/collector.py @@ -91,6 +91,23 @@ def collect_team(self, team_name: str) -> dict: except Exception: pass + # Conflict/overlap data + conflict_data = {} + try: + from clawteam.workspace.conflicts import detect_overlaps + overlaps = detect_overlaps(team_name) + conflict_data = { + "overlaps": [ + {"file": o["file"], "agents": o["agents"], "severity": o["severity"]} + for o in overlaps + ], + "totalOverlaps": len(overlaps), + "highSeverity": sum(1 for o in overlaps if o["severity"] == "high"), + "mediumSeverity": sum(1 for o in overlaps if o["severity"] == "medium"), + } + except Exception: + pass + return { "team": { "name": config.name, @@ -105,6 +122,7 @@ def collect_team(self, team_name: str) -> dict: "taskSummary": summary, "messages": all_messages, "cost": cost_data, + "conflicts": conflict_data, } def collect_overview(self) -> list[dict]: diff --git a/clawteam/board/gource.py b/clawteam/board/gource.py new file mode 100644 index 00000000..16216d8c --- /dev/null +++ b/clawteam/board/gource.py @@ -0,0 +1,408 @@ +"""Gource visualization integration for ClawTeam. + +Generates Gource custom log format from ClawTeam events and git history, +and launches Gource visualizations of team activity. + +Gource custom log format: timestamp|username|type|path + - timestamp: unix timestamp + - username: agent name + - type: A (add), M (modify), D (delete) + - path: virtual file path representing the event +""" + +from __future__ import annotations + +import shutil +import subprocess +import time +from datetime import datetime, timezone +from io import TextIOBase +from pathlib import Path + +from clawteam.board.collector import BoardCollector + +# --------------------------------------------------------------------------- +# Color mapping for agents +# --------------------------------------------------------------------------- + +# Gource user colors (hex without #) +AGENT_COLORS = [ + "00FF00", # green + "FF6600", # orange + "00CCFF", # cyan + "FF00FF", # magenta + "FFFF00", # yellow + "FF3333", # red + "66FF66", # light green + "9966FF", # purple + "FF9999", # pink + "33FFCC", # teal +] + + +def _agent_color(index: int) -> str: + return AGENT_COLORS[index % len(AGENT_COLORS)] + + +def _virtual_path(*parts: str) -> str: + components: list[str] = [] + for part in parts: + if not part: + continue + for component in str(part).replace("\\", "/").split("/"): + if not component or component == ".": + continue + if components and components[-1] == component: + continue + components.append(component) + return "/" + "/".join(components) + + +# --------------------------------------------------------------------------- +# ClawTeam event log → Gource custom log +# --------------------------------------------------------------------------- + + +def _parse_iso(ts: str) -> int: + """Parse ISO timestamp string to unix timestamp.""" + try: + dt = datetime.fromisoformat(ts.replace("Z", "+00:00")) + return int(dt.timestamp()) + except Exception: + return int(datetime.now(timezone.utc).timestamp()) + + +def generate_event_log(team_name: str) -> list[str]: + """Generate Gource custom log lines from ClawTeam events. + + Maps ClawTeam events to virtual paths: + - Task status changes → /tasks/{status}/{task_subject} + - Messages → /messages/{from_agent}/{to} + - Member joins → /team/{agent_name} + + Returns sorted list of 'timestamp|username|type|path' strings. + """ + collector = BoardCollector() + try: + data = collector.collect_team(team_name) + except ValueError: + return [] + + lines: list[str] = [] + inbox_aliases: dict[str, str] = {} + + # Member joins as additions + for member in data.get("members", []): + name = member["name"] + inbox_aliases[name] = name + user = member.get("user", "") + if user: + inbox_aliases[f"{user}_{name}"] = name + joined = member.get("joinedAt", "") + if joined: + ts = _parse_iso(joined) + lines.append(f"{ts}|{name}|A|{_virtual_path('team', name)}") + + # Tasks as file operations + for status, tasks in data.get("tasks", {}).items(): + for task in tasks: + owner = task.get("owner", "system") + subject = task.get("subject", "untitled").replace("/", "_") + task_id = task.get("id", "unknown") + updated = task.get("updatedAt", task.get("createdAt", "")) + created = task.get("createdAt", "") + + if created: + ts = _parse_iso(created) + creator = owner or "system" + lines.append(f"{ts}|{creator}|A|{_virtual_path('tasks', 'pending', f'{task_id}_{subject}')}") + + if updated and status != "pending": + ts = _parse_iso(updated) + gource_type = "M" if status in ("in_progress", "blocked") else "A" + agent = owner or "system" + lines.append( + f"{ts}|{agent}|{gource_type}|{_virtual_path('tasks', status, f'{task_id}_{subject}')}" + ) + + # Messages as modifications + for msg in data.get("messages", []): + raw_from = msg.get("from") or msg.get("fromAgent") or "unknown" + from_agent = inbox_aliases.get(raw_from, raw_from) + raw_to = msg.get("to") or "broadcast" + to = inbox_aliases.get(raw_to, raw_to) + ts_str = msg.get("timestamp", "") + msg_type = msg.get("type", "message") + if ts_str: + ts = _parse_iso(ts_str) + lines.append(f"{ts}|{from_agent}|M|{_virtual_path('messages', from_agent, to, msg_type)}") + + # Sort by timestamp + lines.sort(key=lambda line: int(line.split("|")[0])) + return lines + + +# --------------------------------------------------------------------------- +# Git log → Gource log (via context layer) +# --------------------------------------------------------------------------- + + +def generate_git_log(team_name: str, repo_path: str | None = None) -> list[str]: + """Combine git logs from all agent branches into unified Gource log. + + Uses the context layer's cross_branch_log() and file_owners() instead + of reading git logs directly, making Gource a view on top of context. + + Each agent's file paths are prefixed with their agent name to show + parallel work in different areas of the visualization tree. + """ + try: + from clawteam.workspace.context import cross_branch_log, file_owners + except ImportError: + return [] + + try: + entries = cross_branch_log(team_name, limit=500, repo=repo_path) + except Exception: + return [] + + lines: list[str] = [] + for entry in entries: + agent = entry.get("agent", "unknown") + ts_str = entry.get("timestamp", "") + ts = _parse_iso(ts_str) + for fpath in entry.get("files", []): + # Classify as M (modify) by default; context layer doesn't + # distinguish A/M/D per-file, so use "M" for all. + lines.append(f"{ts}|{agent}|M|{_virtual_path(agent, fpath)}") + + # Enrich with file-owner coloring: mark multi-owner files + try: + owners = file_owners(team_name, repo=repo_path) + now_ts = int(datetime.now(timezone.utc).timestamp()) + for fname, agents in owners.items(): + if len(agents) > 1: + # Add a synthetic entry so Gource shows shared files + for agent in agents: + lines.append(f"{now_ts}|{agent}|M|{_virtual_path('shared', fname)}") + except Exception: + pass + + # Sort by timestamp + lines.sort(key=lambda line: int(line.split("|")[0])) + return lines + + +def generate_combined_log(team_name: str, repo_path: str | None = None) -> list[str]: + """Combine both ClawTeam event log and git history into one Gource log.""" + events = generate_event_log(team_name) + git_lines = generate_git_log(team_name, repo_path) + combined = events + git_lines + combined.sort(key=lambda line: int(line.split("|")[0])) + return combined + + +def collect_live_log_lines( + seen_lines: set[str], + team_name: str, + *, + combine_worktrees: bool = True, + repo_path: str | None = None, +) -> list[str]: + """Return newly observed log lines for live streaming. + + This is intentionally side-effect free with respect to ClawTeam state. + It only polls current event/git views and de-duplicates against a local + in-memory cursor owned by the `board gource --live` command. + """ + all_lines = ( + generate_combined_log(team_name, repo_path) + if combine_worktrees + else generate_event_log(team_name) + ) + new_lines = [line for line in all_lines if line not in seen_lines] + new_lines.sort(key=lambda line: int(line.split("|")[0])) + return new_lines + + +def append_log_lines(stream: TextIOBase, lines: list[str]) -> None: + """Append custom-log lines to a live Gource input stream.""" + if not lines: + return + stream.write("\n".join(lines) + "\n") + stream.flush() + + +def stream_gource_live( + proc: subprocess.Popen, + team_name: str, + *, + combine_worktrees: bool = True, + repo_path: str | None = None, + poll_interval: float = 2.0, +) -> None: + """Feed Gource custom log lines to a running process via STDIN.""" + if proc.stdin is None: + raise RuntimeError("Live gource process missing stdin pipe") + + seen_lines: set[str] = set() + while proc.poll() is None: + new_lines = collect_live_log_lines( + seen_lines, + team_name, + combine_worktrees=combine_worktrees, + repo_path=repo_path, + ) + if new_lines: + append_log_lines(proc.stdin, new_lines) + seen_lines.update(new_lines) + time.sleep(poll_interval) + + +# --------------------------------------------------------------------------- +# Gource user color config generation +# --------------------------------------------------------------------------- + + +def generate_user_colors(team_name: str) -> str: + """Generate Gource --user-image-dir compatible color config. + + Returns content for a user colors file mapping agent names to colors. + Format: username=color (one per line). + """ + collector = BoardCollector() + try: + data = collector.collect_team(team_name) + except ValueError: + return "" + + lines: list[str] = [] + for i, member in enumerate(data.get("members", [])): + name = member["name"] + color = _agent_color(i) + lines.append(f"{name}={color}") + + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# Launch Gource +# --------------------------------------------------------------------------- + + +def find_gource() -> str | None: + """Find gource binary. Returns path or None.""" + from clawteam.config import load_config + + cfg = load_config() + custom_path = getattr(cfg, "gource_path", "") + if custom_path and Path(custom_path).is_file(): + return custom_path + return shutil.which("gource") + + +def launch_gource( + log_file: Path | None = None, + title: str = "", + resolution: str = "", + seconds_per_day: float = 0, + extra_args: list[str] | None = None, + export_path: str | None = None, + live_stream: bool = False, +) -> subprocess.Popen | None: + """Launch Gource with the given custom log file. + + If export_path is provided, pipes through FFmpeg to produce an MP4. + Returns the Popen object, or None if gource is not found. + """ + gource_bin = find_gource() + if not gource_bin: + return None + + # Load config defaults + from clawteam.config import load_config + + cfg = load_config() + if not resolution: + resolution = getattr(cfg, "gource_resolution", "1280x720") + if not seconds_per_day: + seconds_per_day = getattr(cfg, "gource_seconds_per_day", 0.5) + + cmd = [ + gource_bin, + "-" if live_stream else str(log_file), + "--log-format", + "custom", + "--seconds-per-day", + str(seconds_per_day), + "--auto-skip-seconds", + "0.5", + "--file-idle-time", + "0", + "--max-files", + "0", + "--highlight-users", + "--multi-sampling", + ] + if live_stream: + cmd.append("--realtime") + + if resolution: + parts = resolution.split("x") + if len(parts) == 2: + cmd.extend(["--viewport", f"{parts[0]}x{parts[1]}"]) + + if title: + cmd.extend(["--title", title]) + + if extra_args: + cmd.extend(extra_args) + + if export_path: + # Pipe PPM stream to FFmpeg for video export + ffmpeg_bin = shutil.which("ffmpeg") + if not ffmpeg_bin: + return None + + cmd.extend(["--output-ppm-stream", "-"]) + + gource_proc = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + ) + + ffmpeg_cmd = [ + ffmpeg_bin, + "-y", # overwrite + "-r", + "60", + "-f", + "image2pipe", + "-vcodec", + "ppm", + "-i", + "-", + "-vcodec", + "libx264", + "-preset", + "medium", + "-pix_fmt", + "yuv420p", + "-crf", + "18", + export_path, + ] + + ffmpeg_proc = subprocess.Popen( + ffmpeg_cmd, + stdin=gource_proc.stdout, + ) + # Allow gource_proc to receive SIGPIPE if ffmpeg exits + if gource_proc.stdout: + gource_proc.stdout.close() + return ffmpeg_proc + else: + popen_kwargs: dict[str, object] = {} + if live_stream: + popen_kwargs.update({"stdin": subprocess.PIPE, "text": True}) + return subprocess.Popen(cmd, **popen_kwargs) diff --git a/clawteam/board/renderer.py b/clawteam/board/renderer.py index c6a2c0ac..1a47816f 100644 --- a/clawteam/board/renderer.py +++ b/clawteam/board/renderer.py @@ -49,6 +49,8 @@ def render_overview(self, teams: list[dict]) -> None: def render_team_board_live(self, collector, team_name: str, interval: float = 2.0) -> None: """Render a live-refreshing team board. Ctrl+C to stop.""" running = True + notify_counter = 0 + notify_interval = 5 # auto_notify every N refresh cycles def _handle_signal(signum, frame): nonlocal running @@ -70,6 +72,19 @@ def _handle_signal(signum, frame): live.update(renderable) break live.update(renderable) + + # Periodically run conflict auto-notification + notify_counter += 1 + if notify_counter >= notify_interval: + notify_counter = 0 + try: + from clawteam.team.mailbox import MailboxManager + from clawteam.workspace.conflicts import auto_notify + mailbox = MailboxManager(team_name) + auto_notify(team_name, mailbox) + except Exception: + pass + time.sleep(interval) finally: signal.signal(signal.SIGINT, old_sigint) @@ -132,8 +147,30 @@ def _build_team_board(self, data: dict) -> Group: # 3. Task board (4-column kanban) parts.append(self._build_task_kanban(tasks, summary)) + # 4. Conflict warnings (if any) + conflicts = data.get("conflicts", {}) + if conflicts.get("totalOverlaps", 0) > 0: + parts.append(self._build_conflict_panel(conflicts)) + return Group(*parts) + def _build_conflict_panel(self, conflicts: dict) -> Panel: + """Build a panel showing file overlap / conflict warnings.""" + overlaps = conflicts.get("overlaps", []) + high = conflicts.get("highSeverity", 0) + medium = conflicts.get("mediumSeverity", 0) + + lines: list[str] = [] + for o in overlaps: + severity = o["severity"] + style = "red bold" if severity == "high" else "yellow" + agents = ", ".join(o["agents"]) + lines.append(f"[{style}]{severity.upper()}[/{style}] `{o['file']}` — {agents}") + + body = "\n".join(lines) if lines else "[dim](none)[/dim]" + title = f"Conflict Warnings ({high} high, {medium} medium)" + return Panel(body, title=title, border_style="red" if high > 0 else "yellow") + def _build_task_kanban(self, tasks: dict, summary: dict) -> Panel: """Build the 4-column kanban task board.""" columns_cfg = [ diff --git a/clawteam/cli/commands.py b/clawteam/cli/commands.py index 9bc9305a..c84e6d11 100644 --- a/clawteam/cli/commands.py +++ b/clawteam/cli/commands.py @@ -91,12 +91,9 @@ def _output(data: dict | list, human_fn=None): @config_app.command("show") def config_show(): """Show all configuration settings and their sources.""" - from clawteam.config import get_effective + from clawteam.config import ClawTeamConfig, get_effective - keys = [ - "data_dir", "user", "default_team", - "transport", "workspace", "default_backend", "skip_permissions", - ] + keys = list(ClawTeamConfig.model_fields.keys()) data = {} for k in keys: val, source = get_effective(k) @@ -117,7 +114,10 @@ def _human(d): @config_app.command("set") def config_set( - key: str = typer.Argument(..., help="Config key (e.g. data_dir, user, transport, workspace, default_backend, skip_permissions)"), + key: str = typer.Argument( + ..., + help="Config key (e.g. data_dir, user, transport, workspace, default_backend, skip_permissions, gource_path)", + ), value: str = typer.Argument(..., help="Config value"), ): """Persistently set a configuration value.""" @@ -144,7 +144,10 @@ def config_set( @config_app.command("get") def config_get( - key: str = typer.Argument(..., help="Config key (e.g. data_dir, user, transport, workspace, default_backend, skip_permissions)"), + key: str = typer.Argument( + ..., + help="Config key (e.g. data_dir, user, transport, workspace, default_backend, skip_permissions, gource_path)", + ), ): """Get the effective value of a config key.""" from clawteam.config import ClawTeamConfig, get_effective @@ -1741,6 +1744,10 @@ def spawn_agent( """Spawn a new agent process with identity + task as its initial prompt. Defaults: tmux backend, claude command, git worktree isolation, skip-permissions on. + + Backends: + tmux - Launch in tmux windows (visual monitoring) + subprocess - Launch as background processes """ from clawteam.config import get_effective from clawteam.spawn import get_backend @@ -1814,6 +1821,7 @@ def spawn_agent( user=_os.environ.get("CLAWTEAM_USER", ""), workspace_dir=cwd or "", workspace_branch=ws_branch, + repo_path=repo, ) # Session resume: inject --resume flag for claude commands @@ -2039,6 +2047,137 @@ def board_attach( console.print(f"[green]OK[/green] {result}") +@board_app.command("gource") +def board_gource( + team: str = typer.Argument(..., help="Team name"), + export: Optional[str] = typer.Option(None, "--export", help="Export video to file (requires FFmpeg)"), + log_only: bool = typer.Option(False, "--log-only", help="Output Gource custom log to stdout without launching"), + live: bool = typer.Option(False, "--live", help="Stream new activity into Gource in realtime"), + interval: float = typer.Option(2.0, "--interval", min=0.2, help="Polling interval in seconds for --live"), + combine_worktrees: bool = typer.Option(True, "--combine-worktrees/--events-only", help="Combine git worktree logs with event log"), + repo: Optional[str] = typer.Option(None, "--repo", help="Git repo path for worktree discovery"), + resolution: Optional[str] = typer.Option(None, "--resolution", "-r", help="Viewport resolution (e.g. 1920x1080)"), + seconds_per_day: Optional[float] = typer.Option(None, "--speed", "-s", help="Seconds per day (lower = faster)"), +): + """Launch Gource visualization of team activity. + + Visualizes ClawTeam events (task changes, messages, agent joins) and + optionally combines git history from all agent worktrees into a unified + Gource animation showing parallel collaboration. + """ + import tempfile + + from clawteam.board.gource import ( + append_log_lines, + collect_live_log_lines, + find_gource, + generate_combined_log, + generate_event_log, + launch_gource, + stream_gource_live, + ) + + if live and export: + _output( + {"error": "--live cannot be used with --export"}, + lambda d: console.print(f"[red]{d['error']}[/red]"), + ) + raise typer.Exit(1) + + # Generate log lines + if combine_worktrees: + lines = generate_combined_log(team, repo) + else: + lines = generate_event_log(team) + + if not lines: + _output( + {"error": f"No activity found for team '{team}'"}, + lambda d: console.print(f"[yellow]{d['error']}[/yellow]"), + ) + raise typer.Exit(1) + + # --log-only: just print the custom log + if log_only: + for line in lines: + print(line) + return + + # Check gource is available + gource_bin = find_gource() + if not gource_bin: + _output( + {"error": "Gource not found. Install it (https://gource.io/) or set gource_path in config."}, + lambda d: console.print(f"[red]{d['error']}[/red]"), + ) + raise typer.Exit(1) + + # Write log to temp file + with tempfile.NamedTemporaryFile(mode="w", suffix=".log", delete=False, prefix="clawteam-gource-") as f: + f.write("\n".join(lines) + "\n") + log_path = Path(f.name) + + try: + title = f"ClawTeam: {team}" + proc = launch_gource( + log_file=None if live else log_path, + title=title, + resolution=resolution or "", + seconds_per_day=seconds_per_day or 0, + export_path=export, + live_stream=live, + ) + if proc is None: + _output( + {"error": "Failed to launch Gource" + (" (FFmpeg required for export)" if export else "")}, + lambda d: console.print(f"[red]{d['error']}[/red]"), + ) + raise typer.Exit(1) + + if export: + console.print(f"Exporting Gource visualization to [cyan]{export}[/cyan]...") + proc.wait() + console.print(f"[green]OK[/green] Video saved to {export}") + elif live: + if proc.stdin is None: + console.print("[red]Failed to open live Gource stream.[/red]") + raise typer.Exit(1) + console.print( + f"Gource live stream launched for team [cyan]{team}[/cyan]. " + "Close the window or press Ctrl+C to stop." + ) + seed_lines = collect_live_log_lines( + set(), + team, + combine_worktrees=combine_worktrees, + repo_path=repo, + ) + append_log_lines(proc.stdin, seed_lines) + try: + stream_gource_live( + proc, + team, + combine_worktrees=combine_worktrees, + repo_path=repo, + poll_interval=interval, + ) + except KeyboardInterrupt: + if proc.poll() is None: + proc.terminate() + finally: + if proc.stdin is not None: + proc.stdin.close() + proc.wait() + else: + console.print(f"Gource launched for team [cyan]{team}[/cyan]. Close the window to exit.") + proc.wait() + finally: + try: + log_path.unlink() + except OSError: + pass + + # ============================================================================ # Workspace Commands # ============================================================================ @@ -2189,6 +2328,154 @@ def workspace_status( console.print(stat) +# ============================================================================ +# Context Commands (git context layer) +# ============================================================================ + +context_app = typer.Typer(help="Git context: diffs, file ownership, conflicts, cross-branch log") +app.add_typer(context_app, name="context") + + +@context_app.command("diff") +def context_diff( + team: str = typer.Argument(..., help="Team name"), + agent: str = typer.Argument(..., help="Agent name"), + repo: Optional[str] = typer.Option(None, "--repo", help="Git repo path"), +): + """Show diff statistics for an agent's branch vs. base.""" + from clawteam.workspace.context import agent_diff + + try: + data = agent_diff(team, agent, repo) + except Exception as e: + _output({"error": str(e)}, lambda d: console.print(f"[red]{d['error']}[/red]")) + raise typer.Exit(1) + + def _human(d): + console.print(f"[bold]{d['summary']}[/bold]") + if d["diff_stat"]: + console.print(d["diff_stat"]) + + _output(data, _human) + + +@context_app.command("files") +def context_files( + team: str = typer.Argument(..., help="Team name"), + repo: Optional[str] = typer.Option(None, "--repo", help="Git repo path"), +): + """Show file ownership map — which agents modify which files.""" + from clawteam.workspace.context import file_owners + + try: + data = file_owners(team, repo) + except Exception as e: + _output({"error": str(e)}, lambda d: console.print(f"[red]{d['error']}[/red]")) + raise typer.Exit(1) + + def _human(d): + if not d: + console.print("[dim]No modified files found.[/dim]") + return + table = Table(title=f"File Ownership — {team}") + table.add_column("File", style="cyan") + table.add_column("Agents") + for fname, agents in sorted(d.items()): + style = "bold red" if len(agents) > 1 else "" + table.add_row(fname, ", ".join(agents), style=style) + console.print(table) + + _output(data, _human) + + +@context_app.command("conflicts") +def context_conflicts( + team: str = typer.Argument(..., help="Team name"), + repo: Optional[str] = typer.Option(None, "--repo", help="Git repo path"), +): + """Detect file overlaps across agent branches.""" + from clawteam.workspace.conflicts import detect_overlaps + + try: + data = detect_overlaps(team, repo) + except Exception as e: + _output({"error": str(e)}, lambda d: console.print(f"[red]{d['error']}[/red]")) + raise typer.Exit(1) + + def _human(d): + if not d: + console.print("[green]No overlaps detected.[/green]") + return + table = Table(title=f"File Overlaps — {team}") + table.add_column("File", style="cyan") + table.add_column("Agents") + table.add_column("Severity") + severity_styles = {"high": "bold red", "medium": "yellow", "low": "dim"} + for item in d: + sev = item["severity"] + table.add_row( + item["file"], + ", ".join(item["agents"]), + f"[{severity_styles.get(sev, '')}]{sev}[/{severity_styles.get(sev, '')}]", + ) + console.print(table) + + _output(data, _human) + + +@context_app.command("log") +def context_log( + team: str = typer.Argument(..., help="Team name"), + limit: int = typer.Option(50, "--limit", "-n", help="Max entries"), + repo: Optional[str] = typer.Option(None, "--repo", help="Git repo path"), +): + """Unified cross-branch commit log for all agents.""" + from clawteam.workspace.context import cross_branch_log + + try: + data = cross_branch_log(team, limit=limit, repo=repo) + except Exception as e: + _output({"error": str(e)}, lambda d: console.print(f"[red]{d['error']}[/red]")) + raise typer.Exit(1) + + def _human(d): + if not d: + console.print("[dim]No commits found.[/dim]") + return + for entry in d: + ts = entry["timestamp"][:19] + console.print( + f"[dim]{ts}[/dim] [cyan]{entry['agent']}[/cyan] " + f"[yellow]{entry['hash'][:8]}[/yellow] {entry['message']}" + ) + if entry["files"]: + for f in entry["files"]: + console.print(f" {f}") + + _output(data, _human) + + +@context_app.command("inject") +def context_inject( + team: str = typer.Argument(..., help="Team name"), + agent: str = typer.Argument(..., help="Target agent name"), + repo: Optional[str] = typer.Option(None, "--repo", help="Git repo path"), +): + """Generate context block for injection into an agent's prompt.""" + from clawteam.workspace.context import inject_context + + try: + text = inject_context(team, agent, repo) + except Exception as e: + _output({"error": str(e)}, lambda d: console.print(f"[red]{d['error']}[/red]")) + raise typer.Exit(1) + + if _json_output: + _output({"context": text}, None) + else: + console.print(text) + + # ============================================================================ # Template Commands # ============================================================================ diff --git a/clawteam/config.py b/clawteam/config.py index fc03f8d1..c7ad1592 100644 --- a/clawteam/config.py +++ b/clawteam/config.py @@ -17,6 +17,9 @@ class ClawTeamConfig(BaseModel): workspace: str = "auto" # "auto" | "always" | "never" | "" default_backend: str = "tmux" # "tmux" | "subprocess" skip_permissions: bool = True # pass --dangerously-skip-permissions to claude + gource_path: str = "" # custom path to gource binary (auto-detected if empty) + gource_resolution: str = "1280x720" # default viewport resolution + gource_seconds_per_day: float = 0.5 # animation speed def config_path() -> Path: @@ -58,6 +61,9 @@ def get_effective(key: str) -> tuple[str, str]: "workspace": "CLAWTEAM_WORKSPACE", "default_backend": "CLAWTEAM_DEFAULT_BACKEND", "skip_permissions": "CLAWTEAM_SKIP_PERMISSIONS", + "gource_path": "CLAWTEAM_GOURCE_PATH", + "gource_resolution": "CLAWTEAM_GOURCE_RESOLUTION", + "gource_seconds_per_day": "CLAWTEAM_GOURCE_SECONDS_PER_DAY", } defaults = ClawTeamConfig() cfg = load_config() diff --git a/clawteam/spawn/adapters.py b/clawteam/spawn/adapters.py new file mode 100644 index 00000000..4c998bc7 --- /dev/null +++ b/clawteam/spawn/adapters.py @@ -0,0 +1,105 @@ +"""Runtime adapters for agent-specific command preparation.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from clawteam.spawn.command_validation import normalize_spawn_command + + +@dataclass(frozen=True) +class PreparedCommand: + """Prepared native CLI command plus any post-launch prompt injection.""" + + normalized_command: list[str] + final_command: list[str] + post_launch_prompt: str | None = None + + +class NativeCliAdapter: + """Adapter for direct CLI runtimes such as claude, codex, gemini, kimi, nanobot.""" + + def prepare_command( + self, + command: list[str], + *, + prompt: str | None = None, + cwd: str | None = None, + skip_permissions: bool = False, + interactive: bool = False, + ) -> PreparedCommand: + normalized_command = normalize_spawn_command(command) + final_command = list(normalized_command) + post_launch_prompt = None + + if skip_permissions: + if is_claude_command(normalized_command): + final_command.append("--dangerously-skip-permissions") + elif is_codex_command(normalized_command): + final_command.append("--dangerously-bypass-approvals-and-sandbox") + elif is_gemini_command(normalized_command): + final_command.append("--yolo") + elif is_kimi_command(normalized_command): + final_command.append("--yolo") + + if is_kimi_command(normalized_command): + if cwd and not command_has_workspace_arg(normalized_command): + final_command.extend(["-w", cwd]) + if prompt: + final_command.extend(["--print", "-p", prompt]) + elif is_nanobot_command(normalized_command): + if cwd and not command_has_workspace_arg(normalized_command): + final_command.extend(["-w", cwd]) + if prompt: + final_command.extend(["-m", prompt]) + elif prompt: + if interactive and is_claude_command(normalized_command): + post_launch_prompt = prompt + elif is_codex_command(normalized_command): + final_command.append(prompt) + else: + final_command.extend(["-p", prompt]) + + return PreparedCommand( + normalized_command=normalized_command, + final_command=final_command, + post_launch_prompt=post_launch_prompt, + ) + + +def command_basename(command: list[str]) -> str: + """Return the normalized executable basename for a command.""" + if not command: + return "" + return Path(command[0]).name.lower() + + +def is_claude_command(command: list[str]) -> bool: + """Check if the command is a Claude CLI invocation.""" + return command_basename(command) in ("claude", "claude-code") + + +def is_codex_command(command: list[str]) -> bool: + """Check if the command is a Codex CLI invocation.""" + return command_basename(command) in ("codex", "codex-cli") + + +def is_nanobot_command(command: list[str]) -> bool: + """Check if the command is a nanobot CLI invocation.""" + return command_basename(command) == "nanobot" + + +def is_gemini_command(command: list[str]) -> bool: + """Check if the command is a Gemini CLI invocation.""" + return command_basename(command) == "gemini" + + +def is_kimi_command(command: list[str]) -> bool: + """Check if the command is a Kimi CLI invocation.""" + return command_basename(command) == "kimi" + + +def command_has_workspace_arg(command: list[str]) -> bool: + """Return True when a command already specifies a workspace.""" + return "-w" in command or "--workspace" in command diff --git a/clawteam/spawn/prompt.py b/clawteam/spawn/prompt.py index af0817e5..68b6d3da 100644 --- a/clawteam/spawn/prompt.py +++ b/clawteam/spawn/prompt.py @@ -1,4 +1,4 @@ -"""Agent prompt builder — identity + task only. +"""Agent prompt builder — identity + task + context awareness. Coordination knowledge (how to use clawteam CLI) is provided by the ClawTeam Skill, not duplicated here. @@ -7,6 +7,23 @@ from __future__ import annotations +def _build_context_block(team_name: str, agent_name: str, repo: str | None = None) -> str: + """Build a context awareness block from the workspace context layer. + + Includes recent changes from teammates, file overlap warnings, + and upstream dependency context. Returns empty string if context + layer is unavailable or no relevant context exists. + """ + try: + from clawteam.workspace.context import inject_context + ctx = inject_context(team_name, agent_name, repo) + if ctx and "No cross-agent context" not in ctx: + return ctx + except Exception: + pass + return "" + + def build_agent_prompt( agent_name: str, agent_id: str, @@ -17,8 +34,9 @@ def build_agent_prompt( user: str = "", workspace_dir: str = "", workspace_branch: str = "", + repo_path: str | None = None, ) -> str: - """Build agent prompt: identity + task + optional workspace info.""" + """Build agent prompt: identity + task + context + coordination.""" lines = [ "## Identity\n", f"- Name: {agent_name}", @@ -39,10 +57,23 @@ def build_agent_prompt( f"- Branch: {workspace_branch}", "- This is an isolated git worktree. Your changes do not affect the main branch.", ]) + lines.extend([ "", "## Task\n", task, + ]) + + # Inject cross-agent context awareness + context_block = _build_context_block(team_name, agent_name, repo_path) + if context_block: + lines.extend([ + "", + "## Context\n", + context_block, + ]) + + lines.extend([ "", "## Coordination Protocol\n", f"- Use `clawteam task list {team_name} --owner {agent_name}` to see your tasks.", diff --git a/clawteam/spawn/subprocess_backend.py b/clawteam/spawn/subprocess_backend.py index cc390075..ca76a481 100644 --- a/clawteam/spawn/subprocess_backend.py +++ b/clawteam/spawn/subprocess_backend.py @@ -6,18 +6,10 @@ import shlex import subprocess +from clawteam.spawn.adapters import NativeCliAdapter from clawteam.spawn.base import SpawnBackend from clawteam.spawn.cli_env import build_spawn_path, resolve_clawteam_executable -from clawteam.spawn.command_validation import ( - command_has_workspace_arg, - is_claude_command, - is_codex_command, - is_gemini_command, - is_kimi_command, - is_nanobot_command, - normalize_spawn_command, - validate_spawn_command, -) +from clawteam.spawn.command_validation import validate_spawn_command class SubprocessBackend(SpawnBackend): @@ -25,6 +17,7 @@ class SubprocessBackend(SpawnBackend): def __init__(self): self._processes: dict[str, subprocess.Popen] = {} + self._adapter = NativeCliAdapter() def spawn( self, @@ -63,38 +56,21 @@ def spawn( if os.path.isabs(clawteam_bin): spawn_env.setdefault("CLAWTEAM_BIN", clawteam_bin) - normalized_command = normalize_spawn_command(command) + prepared = self._adapter.prepare_command( + command, + prompt=prompt, + cwd=cwd, + skip_permissions=skip_permissions, + interactive=False, + ) + normalized_command = prepared.normalized_command + validation_command = normalized_command + final_command = list(prepared.final_command) - command_error = validate_spawn_command(normalized_command, path=spawn_env["PATH"], cwd=cwd) + command_error = validate_spawn_command(validation_command, path=spawn_env["PATH"], cwd=cwd) if command_error: return command_error - final_command = list(normalized_command) - if skip_permissions: - if is_claude_command(normalized_command): - final_command.append("--dangerously-skip-permissions") - elif is_codex_command(normalized_command): - final_command.append("--dangerously-bypass-approvals-and-sandbox") - elif is_gemini_command(normalized_command): - final_command.append("--yolo") - elif is_kimi_command(normalized_command): - final_command.append("--yolo") - if is_kimi_command(normalized_command): - if cwd and not command_has_workspace_arg(normalized_command): - final_command.extend(["-w", cwd]) - if prompt: - final_command.extend(["--print", "-p", prompt]) - elif is_nanobot_command(normalized_command): - if cwd and not command_has_workspace_arg(normalized_command): - final_command.extend(["-w", cwd]) - if prompt: - final_command.extend(["-m", prompt]) - elif prompt: - if is_codex_command(normalized_command): - final_command.append(prompt) - else: - final_command.extend(["-p", prompt]) - # Wrap with on-exit hook so task status updates immediately on exit cmd_str = " ".join(shlex.quote(c) for c in final_command) exit_cmd = shlex.quote(clawteam_bin) if os.path.isabs(clawteam_bin) else "clawteam" @@ -121,7 +97,7 @@ def spawn( agent_name=agent_name, backend="subprocess", pid=process.pid, - command=list(normalized_command), + command=list(final_command), ) return f"Agent '{agent_name}' spawned as subprocess (pid={process.pid})" diff --git a/clawteam/spawn/tmux_backend.py b/clawteam/spawn/tmux_backend.py index e5fb7d38..6c080fe2 100644 --- a/clawteam/spawn/tmux_backend.py +++ b/clawteam/spawn/tmux_backend.py @@ -9,18 +9,17 @@ import tempfile import time -from clawteam.spawn.base import SpawnBackend -from clawteam.spawn.cli_env import build_spawn_path, resolve_clawteam_executable -from clawteam.spawn.command_validation import ( - command_has_workspace_arg, +from clawteam.spawn.adapters import ( + NativeCliAdapter, is_claude_command, is_codex_command, is_gemini_command, is_kimi_command, is_nanobot_command, - normalize_spawn_command, - validate_spawn_command, ) +from clawteam.spawn.base import SpawnBackend +from clawteam.spawn.cli_env import build_spawn_path, resolve_clawteam_executable +from clawteam.spawn.command_validation import validate_spawn_command class TmuxBackend(SpawnBackend): @@ -32,6 +31,7 @@ class TmuxBackend(SpawnBackend): def __init__(self): self._agents: dict[str, str] = {} # agent_name -> tmux target + self._adapter = NativeCliAdapter() def spawn( self, @@ -50,64 +50,42 @@ def spawn( session_name = f"clawteam-{team_name}" clawteam_bin = resolve_clawteam_executable() - env_vars = { + env_vars = os.environ.copy() + env_vars.update({ "CLAWTEAM_AGENT_ID": agent_id, "CLAWTEAM_AGENT_NAME": agent_name, "CLAWTEAM_AGENT_TYPE": agent_type, "CLAWTEAM_TEAM_NAME": team_name, "CLAWTEAM_AGENT_LEADER": "0", - } - # Propagate user if set - user = os.environ.get("CLAWTEAM_USER", "") - if user: - env_vars["CLAWTEAM_USER"] = user - # Propagate transport if set - transport = os.environ.get("CLAWTEAM_TRANSPORT", "") - if transport: - env_vars["CLAWTEAM_TRANSPORT"] = transport + }) if cwd: env_vars["CLAWTEAM_WORKSPACE_DIR"] = cwd + # Inject context awareness flags + env_vars["CLAWTEAM_CONTEXT_ENABLED"] = "1" if env: env_vars.update(env) env_vars["PATH"] = build_spawn_path(env_vars.get("PATH", os.environ.get("PATH"))) if os.path.isabs(clawteam_bin): env_vars.setdefault("CLAWTEAM_BIN", clawteam_bin) - normalized_command = normalize_spawn_command(command) + prepared = self._adapter.prepare_command( + command, + prompt=prompt, + cwd=cwd, + skip_permissions=skip_permissions, + interactive=True, + ) + normalized_command = prepared.normalized_command + validation_command = normalized_command + final_command = list(prepared.final_command) + post_launch_prompt = prepared.post_launch_prompt - command_error = validate_spawn_command(normalized_command, path=env_vars["PATH"], cwd=cwd) + command_error = validate_spawn_command(validation_command, path=env_vars["PATH"], cwd=cwd) if command_error: return command_error export_str = "; ".join(f"export {k}={shlex.quote(v)}" for k, v in env_vars.items()) - # Build the command (without prompt — we'll send it via send-keys) - final_command = list(normalized_command) - if skip_permissions: - if is_claude_command(normalized_command): - final_command.append("--dangerously-skip-permissions") - elif is_codex_command(normalized_command): - final_command.append("--dangerously-bypass-approvals-and-sandbox") - elif is_gemini_command(normalized_command): - final_command.append("--yolo") - elif is_kimi_command(normalized_command): - final_command.append("--yolo") - - if is_kimi_command(normalized_command): - if cwd and not command_has_workspace_arg(normalized_command): - final_command.extend(["-w", cwd]) - if prompt: - final_command.extend(["--print", "-p", prompt]) - elif is_nanobot_command(normalized_command): - if cwd and not command_has_workspace_arg(normalized_command): - final_command.extend(["-w", cwd]) - if prompt: - final_command.extend(["-m", prompt]) - elif prompt and is_codex_command(normalized_command): - final_command.append(prompt) - elif prompt and is_gemini_command(normalized_command): - final_command.extend(["-p", prompt]) - cmd_str = " ".join(shlex.quote(c) for c in final_command) # Append on-exit hook: runs immediately when agent process exits exit_cmd = shlex.quote(clawteam_bin) if os.path.isabs(clawteam_bin) else "clawteam" @@ -166,7 +144,7 @@ def spawn( # Send the prompt as input to the interactive claude session # (codex prompt is passed as positional arg above, so skip here) - if prompt and is_claude_command(normalized_command): + if post_launch_prompt and is_claude_command(normalized_command): # Wait for Claude Code to finish startup and show input prompt. # Bedrock-backed instances can take 10+ seconds to initialize. _wait_for_claude_ready(target, timeout_seconds=30) @@ -175,7 +153,7 @@ def spawn( with tempfile.NamedTemporaryFile( mode="w", suffix=".txt", delete=False, prefix="clawteam-prompt-" ) as f: - f.write(prompt) + f.write(post_launch_prompt) tmp_path = f.name subprocess.run( ["tmux", "load-buffer", "-b", f"prompt-{agent_name}", tmp_path], @@ -243,7 +221,7 @@ def spawn( backend="tmux", tmux_target=target, pid=pane_pid, - command=list(normalized_command), + command=list(final_command), ) return f"Agent '{agent_name}' spawned in tmux ({target})" @@ -326,7 +304,6 @@ def attach_all(team_name: str) -> str: subprocess.run(["tmux", "attach-session", "-t", session]) return result - def _confirm_workspace_trust_if_prompted( target: str, command: list[str], diff --git a/clawteam/workspace/conflicts.py b/clawteam/workspace/conflicts.py new file mode 100644 index 00000000..9991c615 --- /dev/null +++ b/clawteam/workspace/conflicts.py @@ -0,0 +1,292 @@ +"""Conflict detection and overlap warnings for multi-agent git workspaces.""" + +from __future__ import annotations + +from pathlib import Path + +from clawteam.workspace import git +from clawteam.workspace.context import _agent_branch, _base_branch, _ws_manager, file_owners + +# --------------------------------------------------------------------------- +# detect_overlaps +# --------------------------------------------------------------------------- + + +def detect_overlaps(team_name: str, repo: str | None = None) -> list[dict]: + """Detect files modified by multiple agents. + + Returns list of dicts with keys: file, agents, severity. + Severity: + - high: agents changed the same lines + - medium: agents changed the same file (different lines) + - low: agents changed files in the same directory + """ + owners = file_owners(team_name, repo) + mgr = _ws_manager(repo) + + overlaps: list[dict] = [] + for fname, agents in owners.items(): + if len(agents) < 2: + continue + + # Determine severity by checking if changed lines overlap + severity = _compute_severity(fname, agents, team_name, mgr) + overlaps.append( + { + "file": fname, + "agents": agents, + "severity": severity, + } + ) + + # Sort: high first + order = {"high": 0, "medium": 1, "low": 2} + overlaps.sort(key=lambda o: order.get(o["severity"], 3)) + return overlaps + + +def _changed_lines( + fname: str, + branch: str, + base: str, + repo_root: Path, +) -> set[int]: + """Return set of line numbers changed by branch for a specific file.""" + try: + diff_raw = git._run( + ["diff", "-U0", f"{base}...{branch}", "--", fname], + cwd=repo_root, + check=False, + ) + except Exception: + return set() + + lines: set[int] = set() + for line in diff_raw.splitlines(): + # Parse @@ -a,b +c,d @@ hunks + if line.startswith("@@"): + # Extract the +c,d portion (new-file lines) + parts = line.split("+") + if len(parts) >= 2: + hunk = parts[1].split(" ")[0].split("@@")[0] + if "," in hunk: + start, count = hunk.split(",", 1) + start = int(start) + count = int(count) + else: + start = int(hunk) + count = 1 + lines.update(range(start, start + count)) + return lines + + +def _compute_severity( + fname: str, + agents: list[str], + team_name: str, + mgr, +) -> str: + """Compute overlap severity for a file touched by multiple agents.""" + # Collect changed lines per agent + agent_lines: dict[str, set[int]] = {} + for agent_name in agents: + ws = mgr.get_workspace(team_name, agent_name) + if ws is None: + continue + branch = ws.branch_name + base = ws.base_branch + agent_lines[agent_name] = _changed_lines( + fname, + branch, + base, + mgr.repo_root, + ) + + # Check pairwise overlap + agent_list = list(agent_lines.keys()) + for i in range(len(agent_list)): + for j in range(i + 1, len(agent_list)): + a_lines = agent_lines[agent_list[i]] + b_lines = agent_lines[agent_list[j]] + if a_lines & b_lines: + return "high" + + return "medium" + + +# --------------------------------------------------------------------------- +# check_conflicts +# --------------------------------------------------------------------------- + + +def check_conflicts( + team_name: str, + agent_a: str, + agent_b: str, + repo: str | None = None, +) -> list[dict]: + """Check for conflicts between two specific agents. + + Returns list of dicts with: file, conflict_markers (bool), details. + """ + mgr = _ws_manager(repo) + branch_a = _agent_branch(team_name, agent_a) + branch_b = _agent_branch(team_name, agent_b) + base_a = _base_branch(team_name, agent_a, mgr) + + # Find files changed by both + try: + files_a_raw = git._run( + ["diff", "--name-only", f"{base_a}...{branch_a}"], + cwd=mgr.repo_root, + check=False, + ) + files_a = set(files_a_raw.splitlines()) if files_a_raw else set() + except Exception: + files_a = set() + + base_b = _base_branch(team_name, agent_b, mgr) + try: + files_b_raw = git._run( + ["diff", "--name-only", f"{base_b}...{branch_b}"], + cwd=mgr.repo_root, + check=False, + ) + files_b = set(files_b_raw.splitlines()) if files_b_raw else set() + except Exception: + files_b = set() + + common_files = files_a & files_b + if not common_files: + return [] + + results: list[dict] = [] + for fname in sorted(common_files): + lines_a = _changed_lines(fname, branch_a, base_a, mgr.repo_root) + lines_b = _changed_lines(fname, branch_b, base_b, mgr.repo_root) + overlap = lines_a & lines_b + results.append( + { + "file": fname, + "conflict_markers": bool(overlap), + "details": ( + f"Lines {sorted(overlap)[:10]}{'...' if len(overlap) > 10 else ''} " + f"changed by both agents" + if overlap + else f"Different lines modified (A: {len(lines_a)}, B: {len(lines_b)})" + ), + } + ) + + return results + + +# --------------------------------------------------------------------------- +# auto_notify +# --------------------------------------------------------------------------- + + +def auto_notify(team_name: str, mailbox_mgr, repo: str | None = None) -> int: + """Scan for overlaps and send warning messages to affected agents. + + Returns number of warnings sent. + """ + overlaps = detect_overlaps(team_name, repo) + if not overlaps: + return 0 + + count = 0 + for overlap in overlaps: + if overlap["severity"] == "low": + continue # Only warn on medium/high + agents = overlap["agents"] + fname = overlap["file"] + severity = overlap["severity"] + for agent in agents: + others = [a for a in agents if a != agent] + content = ( + f"[context-warning] File overlap ({severity}): `{fname}` " + f"is also being modified by {', '.join(others)}. " + f"Consider coordinating to avoid merge conflicts." + ) + try: + mailbox_mgr.send( + from_agent="context-agent", + to=agent, + content=content, + ) + count += 1 + except Exception: + pass + return count + + +# --------------------------------------------------------------------------- +# suggest_rebase +# --------------------------------------------------------------------------- + + +def suggest_rebase( + team_name: str, + agent_name: str, + repo: str | None = None, +) -> str | None: + """Suggest whether an agent should rebase onto the base branch. + + Returns a suggestion string, or None if no rebase is needed. + """ + mgr = _ws_manager(repo) + branch = _agent_branch(team_name, agent_name) + base = _base_branch(team_name, agent_name, mgr) + + # Count how many commits are on base that aren't on the agent's branch + try: + behind_raw = git._run( + ["rev-list", "--count", f"{branch}..{base}"], + cwd=mgr.repo_root, + check=False, + ) + behind = int(behind_raw) if behind_raw.strip().isdigit() else 0 + except Exception: + behind = 0 + + if behind == 0: + return None + + # Check for overlapping files with merged changes + try: + base_files_raw = git._run( + ["diff", "--name-only", f"{branch}..{base}"], + cwd=mgr.repo_root, + check=False, + ) + base_files = set(base_files_raw.splitlines()) if base_files_raw else set() + except Exception: + base_files = set() + + try: + agent_files_raw = git._run( + ["diff", "--name-only", f"{base}..{branch}"], + cwd=mgr.repo_root, + check=False, + ) + agent_files = set(agent_files_raw.splitlines()) if agent_files_raw else set() + except Exception: + agent_files = set() + + overlapping = base_files & agent_files + if overlapping: + return ( + f"Rebase recommended: {agent_name}'s branch is {behind} commit(s) behind " + f"'{base}', and {len(overlapping)} file(s) overlap with upstream changes: " + f"{', '.join(sorted(overlapping)[:5])}{'...' if len(overlapping) > 5 else ''}. " + f"Run: git rebase {base}" + ) + elif behind > 5: + return ( + f"Rebase suggested: {agent_name}'s branch is {behind} commit(s) behind " + f"'{base}'. No file overlaps detected, but rebasing will keep the branch current. " + f"Run: git rebase {base}" + ) + + return None diff --git a/clawteam/workspace/context.py b/clawteam/workspace/context.py new file mode 100644 index 00000000..a030f870 --- /dev/null +++ b/clawteam/workspace/context.py @@ -0,0 +1,302 @@ +"""Git context layer — provides cross-agent awareness of changes, file ownership, and overlap.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from clawteam.team.models import get_data_dir +from clawteam.workspace import git +from clawteam.workspace.manager import WorkspaceManager, _load_registry + + +def _registry_repo_root(team_name: str) -> str | None: + path = get_data_dir() / "workspaces" / team_name / "workspace-registry.json" + if not path.exists(): + return None + try: + data = json.loads(path.read_text(encoding="utf-8")) + except Exception: + return None + repo_root = data.get("repo_root") + if not isinstance(repo_root, str) or not repo_root: + return None + return repo_root + + +def _resolve_repo_path(team_name: str, repo: str | None = None) -> str | None: + return repo or _registry_repo_root(team_name) + + +def _ws_manager(team_name: str, repo: str | None = None) -> WorkspaceManager: + resolved_repo = _resolve_repo_path(team_name, repo) + path = Path(resolved_repo) if resolved_repo else None + mgr = WorkspaceManager.try_create(path) + if mgr is None: + raise RuntimeError("Not inside a git repository") + return mgr + + +def _agent_branch(team_name: str, agent_name: str) -> str: + return f"clawteam/{team_name}/{agent_name}" + + +def _base_branch(team_name: str, agent_name: str, mgr: WorkspaceManager) -> str: + ws = mgr.get_workspace(team_name, agent_name) + return ws.base_branch if ws else mgr.base_branch + + +# --------------------------------------------------------------------------- +# agent_diff +# --------------------------------------------------------------------------- + +def agent_diff(team_name: str, agent_name: str, repo: str | None = None) -> dict: + """Return diff statistics for an agent's branch vs. its base. + + Keys: files_changed, insertions, deletions, diff_stat, commit_count, summary + """ + mgr = _ws_manager(team_name, repo) + branch = _agent_branch(team_name, agent_name) + base = _base_branch(team_name, agent_name, mgr) + root = mgr.repo_root + + # numstat gives machine-readable per-file stats + try: + numstat_raw = git._run( + ["diff", "--numstat", f"{base}...{branch}"], cwd=root, check=False, + ) + except Exception: + numstat_raw = "" + + files_changed: list[str] = [] + insertions = 0 + deletions = 0 + for line in numstat_raw.splitlines(): + parts = line.split("\t") + if len(parts) == 3: + ins, dels, fname = parts + files_changed.append(fname) + if ins != "-": + insertions += int(ins) + if dels != "-": + deletions += int(dels) + + # Stat for human display + try: + diff_stat = git._run( + ["diff", "--stat", f"{base}...{branch}"], cwd=root, check=False, + ) + except Exception: + diff_stat = "" + + # Commit count + try: + count_raw = git._run( + ["rev-list", "--count", f"{base}..{branch}"], cwd=root, check=False, + ) + commit_count = int(count_raw) if count_raw.strip().isdigit() else 0 + except Exception: + commit_count = 0 + + summary = ( + f"{agent_name}: {len(files_changed)} file(s), " + f"+{insertions}/-{deletions}, {commit_count} commit(s)" + ) + return { + "files_changed": files_changed, + "insertions": insertions, + "deletions": deletions, + "diff_stat": diff_stat, + "commit_count": commit_count, + "summary": summary, + } + + +# --------------------------------------------------------------------------- +# file_owners +# --------------------------------------------------------------------------- + +def file_owners(team_name: str, repo: str | None = None) -> dict[str, list[str]]: + """Map each modified file to the list of agents that touched it.""" + mgr = _ws_manager(team_name, repo) + registry = _load_registry(team_name, str(mgr.repo_root)) + owners: dict[str, list[str]] = {} + + for ws in registry.workspaces: + branch = ws.branch_name + base = ws.base_branch + try: + numstat = git._run( + ["diff", "--numstat", f"{base}...{branch}"], + cwd=mgr.repo_root, + check=False, + ) + except Exception: + continue + for line in numstat.splitlines(): + parts = line.split("\t") + if len(parts) == 3: + fname = parts[2] + owners.setdefault(fname, []) + if ws.agent_name not in owners[fname]: + owners[fname].append(ws.agent_name) + return owners + + +# --------------------------------------------------------------------------- +# cross_branch_log +# --------------------------------------------------------------------------- + +def cross_branch_log( + team_name: str, limit: int = 50, repo: str | None = None, +) -> list[dict]: + """Unified commit log across all agent branches, newest first.""" + mgr = _ws_manager(team_name, repo) + registry = _load_registry(team_name, str(mgr.repo_root)) + entries: list[dict] = [] + + for ws in registry.workspaces: + branch = ws.branch_name + base = ws.base_branch + try: + log_raw = git._run( + [ + "log", + "--format=%H|%s|%aI", + "--name-only", + f"{base}..{branch}", + ], + cwd=mgr.repo_root, + check=False, + ) + except Exception: + continue + + current: dict | None = None + for line in log_raw.splitlines(): + if "|" in line and len(line.split("|")) >= 3: + if current is not None: + entries.append(current) + parts = line.split("|", 2) + current = { + "agent": ws.agent_name, + "hash": parts[0], + "message": parts[1], + "timestamp": parts[2], + "files": [], + } + elif line.strip() and current is not None: + current["files"].append(line.strip()) + if current is not None: + entries.append(current) + + # Sort by timestamp descending, take limit + entries.sort(key=lambda e: e["timestamp"], reverse=True) + return entries[:limit] + + +# --------------------------------------------------------------------------- +# agent_summary +# --------------------------------------------------------------------------- + +def agent_summary(team_name: str, agent_name: str, repo: str | None = None) -> str: + """Human-readable summary of an agent's git activity.""" + diff = agent_diff(team_name, agent_name, repo) + lines = [ + f"Agent: {agent_name}", + f"Branch: {_agent_branch(team_name, agent_name)}", + f"Commits: {diff['commit_count']}", + f"Files changed: {len(diff['files_changed'])}", + f"Insertions: +{diff['insertions']} Deletions: -{diff['deletions']}", + ] + if diff["files_changed"]: + lines.append("Modified files:") + for f in diff["files_changed"]: + lines.append(f" - {f}") + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# inject_context +# --------------------------------------------------------------------------- + +def inject_context( + team_name: str, target_agent: str, repo: str | None = None, +) -> str: + """Build a context block for injection into an agent's prompt. + + Includes: + - Other agents' recent changes on files the target agent also touches + - File overlap warnings + - Upstream dependency diffs (if task has blocked_by) + """ + # Files the target agent is modifying + target_diff = agent_diff(team_name, target_agent, repo) + target_files = set(target_diff["files_changed"]) + + sections: list[str] = [] + + # --- Section 1: Other agents' changes on overlapping files --- + owners = file_owners(team_name, repo) + overlaps: dict[str, list[str]] = {} + for fname, agents in owners.items(): + if fname in target_files and len(agents) > 1: + others = [a for a in agents if a != target_agent] + if others: + overlaps[fname] = others + + if overlaps: + overlap_lines = ["## File Overlap Warnings"] + for fname, agents in overlaps.items(): + overlap_lines.append(f"- `{fname}` also modified by: {', '.join(agents)}") + sections.append("\n".join(overlap_lines)) + + # --- Section 2: Recent changes from other agents on related files --- + log = cross_branch_log(team_name, limit=20, repo=repo) + related: list[str] = [] + for entry in log: + if entry["agent"] == target_agent: + continue + common = target_files & set(entry["files"]) + if common: + related.append( + f"- [{entry['agent']}] {entry['hash'][:8]} {entry['message']} " + f"(files: {', '.join(common)})" + ) + if related: + sections.append("## Recent Related Changes\n" + "\n".join(related)) + + # --- Section 3: Upstream dependency diffs --- + try: + from clawteam.team.tasks import TaskStore + + store = TaskStore(team_name) + tasks = store.list_tasks(owner=target_agent) + dep_ids: set[str] = set() + for t in tasks: + dep_ids.update(t.blocked_by) + + if dep_ids: + # Find which agents own those upstream tasks + all_tasks = store.list_tasks() + dep_agents: set[str] = set() + for t in all_tasks: + if t.id in dep_ids and t.owner and t.owner != target_agent: + dep_agents.add(t.owner) + + if dep_agents: + dep_lines = ["## Upstream Dependency Changes"] + for dep_agent in sorted(dep_agents): + dep_diff = agent_diff(team_name, dep_agent, repo) + dep_lines.append( + f"- {dep_agent}: {dep_diff['summary']}" + ) + sections.append("\n".join(dep_lines)) + except Exception: + pass # Tasks may not exist yet + + if not sections: + return "No cross-agent context to inject — working in isolation." + + header = f"# Git Context for {target_agent}\n" + return header + "\n\n".join(sections) diff --git a/tests/test_context.py b/tests/test_context.py new file mode 100644 index 00000000..087ab65e --- /dev/null +++ b/tests/test_context.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import json + +from clawteam.workspace.context import _resolve_repo_path + + +def test_resolve_repo_path_uses_workspace_registry(isolated_data_dir): + repo_root = isolated_data_dir / "demo-repo" + repo_root.mkdir() + registry_path = ( + isolated_data_dir + / "workspaces" + / "demo-team" + / "workspace-registry.json" + ) + registry_path.parent.mkdir(parents=True, exist_ok=True) + registry_path.write_text( + json.dumps( + { + "team_name": "demo-team", + "repo_root": str(repo_root), + "workspaces": [], + } + ), + encoding="utf-8", + ) + + assert _resolve_repo_path("demo-team") == str(repo_root) + + +def test_resolve_repo_path_prefers_explicit_repo(isolated_data_dir): + registry_path = ( + isolated_data_dir + / "workspaces" + / "demo-team" + / "workspace-registry.json" + ) + registry_path.parent.mkdir(parents=True, exist_ok=True) + registry_path.write_text( + json.dumps( + { + "team_name": "demo-team", + "repo_root": "/tmp/registry-repo", + "workspaces": [], + } + ), + encoding="utf-8", + ) + + assert _resolve_repo_path("demo-team", "/tmp/explicit-repo") == "/tmp/explicit-repo" diff --git a/tests/test_gource.py b/tests/test_gource.py new file mode 100644 index 00000000..0ff9ed81 --- /dev/null +++ b/tests/test_gource.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +from clawteam.board.gource import ( + append_log_lines, + collect_live_log_lines, + generate_event_log, + generate_git_log, + launch_gource, +) + + +def test_collect_live_log_lines_returns_only_unseen(monkeypatch): + monkeypatch.setattr( + "clawteam.board.gource.generate_combined_log", + lambda team, repo=None: [ + "1|alice|A|/tasks/1", + "2|bob|M|/tasks/2", + ], + ) + + seen = {"1|alice|A|/tasks/1"} + assert collect_live_log_lines(seen, "demo") == ["2|bob|M|/tasks/2"] + + +def test_append_log_lines_writes_and_flushes(): + class DummyStream: + def __init__(self): + self.data = "" + self.flushed = False + + def write(self, text): + self.data += text + + def flush(self): + self.flushed = True + + stream = DummyStream() + append_log_lines(stream, ["1|alice|A|/a", "2|bob|M|/b"]) + + assert stream.data == "1|alice|A|/a\n2|bob|M|/b\n" + assert stream.flushed is True + + +def test_launch_gource_live_stream_uses_stdin(monkeypatch): + captured: dict[str, object] = {} + + class DummyProcess: + def __init__(self): + self.stdin = object() + + monkeypatch.setattr("clawteam.board.gource.find_gource", lambda: "/usr/bin/gource") + + def fake_popen(cmd, **kwargs): + captured["cmd"] = cmd + captured["kwargs"] = kwargs + return DummyProcess() + + monkeypatch.setattr("clawteam.board.gource.subprocess.Popen", fake_popen) + + proc = launch_gource( + log_file=None, + title="Demo", + live_stream=True, + ) + + assert proc is not None + assert captured["cmd"][1] == "-" + assert "--realtime" in captured["cmd"] + assert captured["kwargs"]["stdin"] is not None + assert captured["kwargs"]["text"] is True + + +def test_generate_event_log_uses_message_sender_and_member_aliases(monkeypatch): + monkeypatch.setattr( + "clawteam.board.gource.BoardCollector.collect_team", + lambda self, team: { + "members": [ + {"name": "leader", "user": "alice", "joinedAt": "2026-03-20T08:40:03+00:00"}, + {"name": "backend", "joinedAt": "2026-03-20T08:40:40+00:00"}, + ], + "tasks": {"pending": [], "in_progress": [], "completed": [], "blocked": []}, + "messages": [ + { + "from": "alice_leader", + "to": "backend", + "type": "message", + "timestamp": "2026-03-20T08:41:23+00:00", + } + ], + }, + ) + + lines = generate_event_log("demo") + + assert any("|leader|M|/messages/leader/backend/message" in line for line in lines) + assert all("/messages/unknown/" not in line for line in lines) + + +def test_generate_git_log_normalizes_duplicate_path_segments(monkeypatch): + monkeypatch.setattr( + "clawteam.workspace.context.cross_branch_log", + lambda team, limit=500, repo=None: [ + { + "agent": "backend", + "timestamp": "2026-03-20T08:41:22+00:00", + "files": ["backend/app.py", "shared/api-contract.md"], + } + ], + ) + monkeypatch.setattr( + "clawteam.workspace.context.file_owners", + lambda team, repo=None: {"shared/api-contract.md": ["frontend", "backend"]}, + ) + + lines = generate_git_log("demo") + + assert any("|backend|M|/backend/app.py" in line for line in lines) + assert any("|backend|M|/shared/api-contract.md" in line for line in lines) + assert all("/backend/backend/" not in line for line in lines) + assert all("/shared/shared/" not in line for line in lines) diff --git a/tests/test_spawn_backends.py b/tests/test_spawn_backends.py index 311a3f42..74d68c8a 100644 --- a/tests/test_spawn_backends.py +++ b/tests/test_spawn_backends.py @@ -57,6 +57,8 @@ def fake_popen(cmd, **kwargs): def test_tmux_backend_exports_spawn_path_for_agent_commands(monkeypatch, tmp_path): monkeypatch.setenv("PATH", "/usr/bin:/bin") + monkeypatch.setenv("CLAWTEAM_DATA_DIR", "/tmp/clawteam-data") + monkeypatch.setenv("GOOGLE_CLOUD_PROJECT", "demo-project") clawteam_bin = tmp_path / "venv" / "bin" / "clawteam" clawteam_bin.parent.mkdir(parents=True) clawteam_bin.write_text("#!/bin/sh\n") @@ -109,6 +111,8 @@ def fake_which(name, path=None): full_cmd = new_session[-1] assert f"export PATH={clawteam_bin.parent}:/usr/bin:/bin" in full_cmd assert f"export CLAWTEAM_BIN={clawteam_bin}" in full_cmd + assert "export CLAWTEAM_DATA_DIR=/tmp/clawteam-data" in full_cmd + assert "export GOOGLE_CLOUD_PROJECT=demo-project" in full_cmd assert f"{clawteam_bin} lifecycle on-exit --team demo-team --agent worker1" in full_cmd diff --git a/tests/test_spawn_cli.py b/tests/test_spawn_cli.py index 9a0b1b9a..bc7f9494 100644 --- a/tests/test_spawn_cli.py +++ b/tests/test_spawn_cli.py @@ -66,3 +66,37 @@ def test_launch_cli_passes_skip_permissions_from_config(monkeypatch, tmp_path): assert result.exit_code == 0 assert backend.calls assert all(call["skip_permissions"] is True for call in backend.calls) + + +def test_spawn_cli_rejects_removed_acpx_backend(monkeypatch, tmp_path): + monkeypatch.setenv("CLAWTEAM_DATA_DIR", str(tmp_path)) + TeamManager.create_team( + name="demo", + leader_name="leader", + leader_id="leader001", + ) + + runner = CliRunner() + result = runner.invoke( + app, + ["spawn", "acpx", "claude", "--team", "demo", "--agent-name", "alice", "--no-workspace"], + env={"CLAWTEAM_DATA_DIR": str(tmp_path)}, + ) + + assert result.exit_code == 1 + assert "Unknown spawn backend: acpx. Available: subprocess, tmux" in result.output + + +def test_launch_cli_rejects_removed_acpx_backend(monkeypatch, tmp_path): + monkeypatch.setenv("CLAWTEAM_DATA_DIR", str(tmp_path)) + monkeypatch.chdir(tmp_path) + + runner = CliRunner() + result = runner.invoke( + app, + ["launch", "hedge-fund", "--backend", "acpx", "--team", "fund1", "--goal", "Analyze AAPL"], + env={"CLAWTEAM_DATA_DIR": str(tmp_path)}, + ) + + assert result.exit_code == 1 + assert "Unknown spawn backend: acpx. Available: subprocess, tmux" in result.output