From aa1ca7fcd6a9cd132b2cdbe8456ebb154597e9ae Mon Sep 17 00:00:00 2001 From: Windsor Date: Mon, 20 Apr 2026 20:33:29 -0700 Subject: [PATCH 1/7] feat: add skills ingestion system self.skills = SkillManager(self) loads SKILL.md files from .agents/skills/ (source of truth) and .wingman/skills/ (fallback). Features: - Frontmatter parsing (name, description, allowed-tools, argument-hint) - Argument substitution ($ARGUMENTS, $0, $1) - Inline shell execution (!`command` blocks) - /skills command lists available skills - Skills auto-register as /slash commands in dispatch - Skills reload on mount and directory change 7 skills loaded: commit, pr, pr-review, issue, promote, greentext, better-interface --- src/wingman/app.py | 3 + src/wingman/commands.py | 17 +++ src/wingman/config.py | 1 + src/wingman/skills.py | 298 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 319 insertions(+) create mode 100644 src/wingman/skills.py diff --git a/src/wingman/app.py b/src/wingman/app.py index 49846a9..8b3f43e 100644 --- a/src/wingman/app.py +++ b/src/wingman/app.py @@ -32,6 +32,7 @@ from .memory import load_memory from .panels import PanelMixin from .sessions import load_sessions +from .skills import SkillManager from .streaming import StreamingController from .tool_bridge import ToolBridgeMixin from .tools import ( @@ -87,6 +88,7 @@ def __init__(self): self.cmds = Commands(self) self.streaming = StreamingController(self) self.compaction = CompactionController(self) + self.skills = SkillManager(self) self.events = EventHandler(self) self.scroll_sensitivity_y = 0.6 self.client: AsyncDedalus | None = None @@ -133,6 +135,7 @@ def on_mount(self) -> None: self._init_client(api_key) else: self.push_screen(APIKeyScreen(), self.on_api_key_entered) + self.skills.load() # Fetch marketplace servers in background self._init_dynamic_data() # Monitor background processes for completion diff --git a/src/wingman/commands.py b/src/wingman/commands.py index efb9731..44c44f1 100644 --- a/src/wingman/commands.py +++ b/src/wingman/commands.py @@ -72,11 +72,16 @@ def dispatch(self, cmd: str) -> None: "memory": lambda: self.memory(arg), "export": lambda: self.export(arg), "import": lambda: self.import_file(arg), + "skills": lambda: self.skills_list(), } handler = handlers.get(command) if handler: handler() + elif self.app.skills.get(command): + prompt = self.app.skills.invoke(command, arg) + if prompt: + self.app.show_info(f"[dim]Skill: {command}[/]\n\n{prompt}") else: self.app.show_info(f"Unknown command: {command}") @@ -105,6 +110,18 @@ def feature(self) -> None: """Open feature request.""" self.app.open_github_issue("feature_request.yml") + def skills_list(self) -> None: + """List available skills.""" + skills = self.app.skills.list_skills() + if not skills: + self.app.show_info("[dim]No skills found. Add skills to .agents/skills/[/]") + return + lines = ["[bold #7aa2f7]Skills[/] (use /skill-name to invoke)\n"] + for s in skills: + hint = f" [dim]{s.argument_hint}[/]" if s.argument_hint else "" + lines.append(f" [#7aa2f7]/{s.name}[/]{hint} [dim]{s.description[:60]}[/]") + self.app.show_info("\n".join(lines)) + def ls(self, arg: str) -> None: """List files in working directory.""" panel = self.app.active_panel diff --git a/src/wingman/config.py b/src/wingman/config.py index 8621346..0243ae4 100644 --- a/src/wingman/config.py +++ b/src/wingman/config.py @@ -112,6 +112,7 @@ ("/clear", "Clear chat"), ("/help", "Show help"), ("/exit", "Quit Wingman"), + ("/skills", "List available skills"), ("/bug", "Report a bug"), ("/feature", "Request feature"), ] diff --git a/src/wingman/skills.py b/src/wingman/skills.py new file mode 100644 index 0000000..7a646d9 --- /dev/null +++ b/src/wingman/skills.py @@ -0,0 +1,298 @@ +"""Skill loading and execution for wingman. + +Loads SKILL.md files from ``.agents/skills/`` (source of truth) and +``.wingman/skills/`` (fallback), parses frontmatter metadata, and +expands skill prompts with argument substitution and shell execution. + +""" + +from __future__ import annotations + +import re +import subprocess +from dataclasses import dataclass, field +from pathlib import Path +from typing import TYPE_CHECKING + +import yaml + +if TYPE_CHECKING: + from .app import WingmanApp + +SKILL_DIRS = [".agents/skills", ".wingman/skills"] +SKILL_FILE = "SKILL.md" +FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL) +SHELL_INLINE_RE = re.compile(r"!\`([^`]+)\`") + + +# fmt: off +@dataclass(frozen=True, slots=True) +class Skill: + """Parsed skill definition from a SKILL.md file. + + """ + + name: str + description: str + content: str + path: Path + allowed_tools: list[str] = field(default_factory=list) + argument_hint: str | None = None + user_invocable: bool = True + disable_model_invoke: bool = False +# fmt: on + + +def parse_frontmatter(text: str) -> tuple[dict, str]: + """Split SKILL.md into frontmatter dict and markdown body. + + Args: + text: Raw file contents. + + Returns: + (frontmatter_dict, markdown_body) tuple. + + """ + match = FRONTMATTER_RE.match(text) + if not match: + return {}, text + raw_yaml = match.group(1) + body = text[match.end() :] + try: + meta = yaml.safe_load(raw_yaml) or {} + except yaml.YAMLError: + meta = {} + return meta, body + + +def parse_allowed_tools(value: str | list | None) -> list[str]: + """Normalize allowed-tools to a list of strings. + + Args: + value: Frontmatter value — string (comma-separated) or list. + + Returns: + List of tool name strings. + + """ + if not value: + return [] + if isinstance(value, list): + return [str(t).strip() for t in value] + return [t.strip() for t in str(value).split(",")] + + +def load_skill(skill_dir: Path) -> Skill | None: + """Load a single skill from a directory containing SKILL.md. + + Args: + skill_dir: Path to the skill directory. + + Returns: + Parsed Skill or None if SKILL.md is missing/invalid. + + """ + skill_file = skill_dir / SKILL_FILE + if not skill_file.is_file(): + return None + + text = skill_file.read_text(encoding="utf-8") + meta, body = parse_frontmatter(text) + + name = meta.get("name", skill_dir.name) + description = meta.get("description", "") + if isinstance(description, str): + description = " ".join(description.split()) + + return Skill( + name=name, + description=description, + content=body, + path=skill_dir, + allowed_tools=parse_allowed_tools(meta.get("allowed-tools")), + argument_hint=meta.get("argument-hint"), + user_invocable=meta.get("user-invocable", True) is not False, + disable_model_invoke=meta.get("disable-model-invocation", False) is True, + ) + + +def discover_skills(cwd: Path) -> dict[str, Skill]: + """Discover all skills from standard directories. + + Searches ``.agents/skills/`` first (source of truth), then + ``.wingman/skills/`` as fallback. First occurrence of a skill + name wins. + + Args: + cwd: Working directory to resolve relative skill paths from. + + Returns: + Dict mapping skill name to Skill. + + """ + skills: dict[str, Skill] = {} + for rel_dir in SKILL_DIRS: + base = cwd / rel_dir + if not base.is_dir(): + continue + for entry in sorted(base.iterdir()): + if not entry.is_dir(): + continue + skill = load_skill(entry) + if skill and skill.name not in skills: + skills[skill.name] = skill + return skills + + +def substitute_arguments(content: str, args: str) -> str: + """Replace ``$ARGUMENTS`` and positional ``$0``, ``$1`` placeholders. + + Args: + content: Skill markdown body. + args: Raw argument string from the user. + + Returns: + Content with placeholders replaced. + + """ + if not args: + return content + + result = content.replace("$ARGUMENTS", args) + + parts = args.split() + for i, part in enumerate(parts): + result = result.replace(f"${i}", part) + + if "$ARGUMENTS" not in content and "$0" not in content: + result += f"\n\n## Arguments\n\n{args}" + + return result + + +def execute_shell_commands(content: str) -> str: + """Execute inline ``!`command``` blocks and replace with output. + + Args: + content: Skill markdown with shell commands. + + Returns: + Content with command outputs inlined. + + """ + + def run_command(match: re.Match) -> str: + cmd = match.group(1) + try: + result = subprocess.run( + cmd, + shell=True, + capture_output=True, + text=True, + timeout=5, + check=False, + ) + output = result.stdout.strip() or result.stderr.strip() + return output or "(no output)" + except subprocess.TimeoutExpired: + return "(command timed out)" + except Exception as e: + return f"(error: {e})" + + return SHELL_INLINE_RE.sub(run_command, content) + + +def expand_skill(skill: Skill, args: str = "") -> str: + """Build the full prompt for a skill invocation. + + Reads SKILL.md, substitutes arguments, executes shell commands, + and prepends the skill directory path for file references. + + Args: + skill: Parsed skill definition. + args: User-provided arguments. + + Returns: + Fully expanded prompt string. + + """ + content = skill.content + content = substitute_arguments(content, args) + content = execute_shell_commands(content) + + header = f"Skill: {skill.name}" + if skill.path.is_dir(): + header += f"\nBase directory: {skill.path}" + + return f"{header}\n\n{content}" + + +class SkillManager: + """Loads, caches, and executes skills. + + Attached to the app as ``self.skills``. + + """ + + def __init__(self, app: WingmanApp) -> None: + self.app = app + self.registry: dict[str, Skill] = {} + + def load(self, cwd: Path | None = None) -> None: + """Load skills from the working directory. + + Args: + cwd: Directory to search for skills. Defaults to app's + active panel working dir or process cwd. + + """ + if cwd is None: + panel = self.app.active_panel + cwd = panel.working_dir if panel else Path.cwd() + self.registry = discover_skills(cwd) + + def list_skills(self) -> list[Skill]: + """Return all loaded user-invocable skills. + + Returns: + List of skills where user_invocable is True. + + """ + return [s for s in self.registry.values() if s.user_invocable] + + def get(self, name: str) -> Skill | None: + """Look up a skill by name. + + Args: + name: Skill name (e.g., "commit", "pr"). + + Returns: + Skill or None. + + """ + return self.registry.get(name) + + def invoke(self, name: str, args: str = "") -> str | None: + """Expand a skill and return its prompt. + + Args: + name: Skill name. + args: User arguments. + + Returns: + Expanded prompt string, or None if skill not found. + + """ + skill = self.get(name) + if not skill: + return None + return expand_skill(skill, args) + + def get_command_names(self) -> list[str]: + """Return skill names for slash-command registration. + + Returns: + List of skill names that are user-invocable. + + """ + return [s.name for s in self.list_skills()] From 70cef5abb08cf2d8fd5924992fec45dca55dcf43 Mon Sep 17 00:00:00 2001 From: Windsor Date: Mon, 20 Apr 2026 20:44:57 -0700 Subject: [PATCH 2/7] style: fix column alignment in Skill dataclass --- src/wingman/skills.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/wingman/skills.py b/src/wingman/skills.py index 7a646d9..755d95a 100644 --- a/src/wingman/skills.py +++ b/src/wingman/skills.py @@ -32,14 +32,14 @@ class Skill: """ - name: str - description: str - content: str - path: Path - allowed_tools: list[str] = field(default_factory=list) - argument_hint: str | None = None - user_invocable: bool = True - disable_model_invoke: bool = False + name: str + description: str + content: str + path: Path + allowed_tools: list[str] = field(default_factory=list) + argument_hint: str | None = None + user_invocable: bool = True + disable_model_invoke: bool = False # fmt: on From 739f61cef0642c57fe545339f1cb11c24a57669a Mon Sep 17 00:00:00 2001 From: Windsor Date: Mon, 20 Apr 2026 20:51:14 -0700 Subject: [PATCH 3/7] fix(skills): inject skill prompt into model context, not chat display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skills now send the expanded prompt to the model as a transient user message. The model follows the skill instructions (runs tools, etc.) and the response persists in conversation history. The skill prompt itself is stripped after the turn — only the invocation command (e.g., /commit) and the model's response remain in history. --- src/wingman/commands.py | 64 ++++++++++++++++++++++++++++++++++++++-- src/wingman/streaming.py | 8 ++++- 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/src/wingman/commands.py b/src/wingman/commands.py index 44c44f1..e1852cb 100644 --- a/src/wingman/commands.py +++ b/src/wingman/commands.py @@ -79,14 +79,72 @@ def dispatch(self, cmd: str) -> None: if handler: handler() elif self.app.skills.get(command): - prompt = self.app.skills.invoke(command, arg) - if prompt: - self.app.show_info(f"[dim]Skill: {command}[/]\n\n{prompt}") + self.invoke_skill(command, arg) else: self.app.show_info(f"Unknown command: {command}") # --- Simple commands --- + def invoke_skill(self, name: str, args: str) -> None: + """Expand a skill and send it to the model as a one-shot prompt. + + The expanded skill content is injected as a user message, + streamed to the model, and the model's response persists in + the conversation. The skill prompt itself is transient. + + Args: + name: Skill name. + args: User-provided arguments. + + """ + import time + + from .sessions import save_session + + prompt = self.app.skills.invoke(name, args) + if not prompt: + return + + panel = self.app.active_panel + if not panel: + return + + if panel._generating: + self.app.notify("Wait for response to complete", severity="warning", timeout=2) + return + + # Remove welcome message if present + try: + for child in panel.get_chat_container().children: + if "panel-welcome" in child.classes: + child.remove() + break + except Exception: + pass + + # Ensure session exists + if not panel.session_id: + panel.session_id = f"chat-{int(time.time() * 1000)}" + save_session(panel.session_id, []) + self.app.refresh_sessions() + self.app.update_status() + + # Show the skill invocation in chat (not the full prompt) + display = f"/{name}" + (f" {args}" if args else "") + panel.add_message("user", display) + + # Inject expanded prompt as a transient message for the model + panel.messages.append({"role": "user", "content": prompt, "_skill": True}) + + from .ui import Thinking + + chat = panel.get_chat_container() + thinking = Thinking(id="thinking") + chat.mount(thinking) + panel.get_scroll_container().scroll_end(animate=False) + + self.app.streaming.send_message(panel, prompt, thinking) + def ps(self) -> None: """List background processes.""" panel = self.app.active_panel diff --git a/src/wingman/streaming.py b/src/wingman/streaming.py index e82ab12..ba13ab4 100644 --- a/src/wingman/streaming.py +++ b/src/wingman/streaming.py @@ -109,6 +109,9 @@ async def send_message( with contextlib.suppress(Exception): thinking.remove() + # Remove transient skill prompts before saving + panel.messages = [m for m in panel.messages if not m.get("_skill")] + segments = get_segments(panel.panel_id) if segments: panel.messages.append({"role": "assistant", "segments": segments}) @@ -168,7 +171,8 @@ def build_messages( content_parts.append(f"\n[Tool: {cmd}]\n{output}\n") messages.append({"role": msg["role"], "content": "".join(content_parts)}) else: - messages.append(msg.copy()) + clean = {k: v for k, v in msg.items() if not k.startswith("_")} + messages.append(clean) if images and messages and messages[-1].get("role") == "user": messages[-1] = create_image_message_from_cache(text, images) @@ -309,6 +313,8 @@ def handle_error( for sw in self.app.query("StreamingText"): with contextlib.suppress(Exception): sw.remove() + # Clean up transient skill prompts + panel.messages = [m for m in panel.messages if not m.get("_skill")] segments = get_segments(panel.panel_id) if segments: panel.messages.append({"role": "assistant", "segments": segments}) From 80e1c855e60e982fa0df04c1beb2c70cc71248e9 Mon Sep 17 00:00:00 2001 From: Windsor Date: Mon, 20 Apr 2026 20:54:01 -0700 Subject: [PATCH 4/7] style: use descriptive loop variable names, not single letters --- src/wingman/commands.py | 8 ++++---- src/wingman/events.py | 6 +++--- src/wingman/skills.py | 4 ++-- src/wingman/streaming.py | 8 ++++---- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/wingman/commands.py b/src/wingman/commands.py index e1852cb..4ddf135 100644 --- a/src/wingman/commands.py +++ b/src/wingman/commands.py @@ -175,9 +175,9 @@ def skills_list(self) -> None: self.app.show_info("[dim]No skills found. Add skills to .agents/skills/[/]") return lines = ["[bold #7aa2f7]Skills[/] (use /skill-name to invoke)\n"] - for s in skills: - hint = f" [dim]{s.argument_hint}[/]" if s.argument_hint else "" - lines.append(f" [#7aa2f7]/{s.name}[/]{hint} [dim]{s.description[:60]}[/]") + for skill in skills: + hint = f" [dim]{skill.argument_hint}[/]" if skill.argument_hint else "" + lines.append(f" [#7aa2f7]/{skill.name}[/]{hint} [dim]{skill.description[:60]}[/]") self.app.show_info("\n".join(lines)) def ls(self, arg: str) -> None: @@ -501,7 +501,7 @@ def import_file(self, arg: str) -> None: if msg["role"] in ("user", "assistant") and msg.get("content"): content = msg["content"] if isinstance(content, list): - content = " ".join(p.get("text", "") for p in content if isinstance(p, dict)) + content = " ".join(part.get("text", "") for part in content if isinstance(part, dict)) panel.messages.append({"role": msg["role"], "content": content}) count += 1 self.app.update_status() diff --git a/src/wingman/events.py b/src/wingman/events.py index db0884b..c337547 100644 --- a/src/wingman/events.py +++ b/src/wingman/events.py @@ -242,9 +242,9 @@ def on_submit(self, event: Input.Submitted) -> None: from .ui import Thinking panel = None - for p in self.app.panels: - if p.panel_id in event.input.id: - panel = p + for candidate in self.app.panels: + if candidate.panel_id in event.input.id: + panel = candidate break if not panel: return diff --git a/src/wingman/skills.py b/src/wingman/skills.py index 755d95a..dedd2fa 100644 --- a/src/wingman/skills.py +++ b/src/wingman/skills.py @@ -258,7 +258,7 @@ def list_skills(self) -> list[Skill]: List of skills where user_invocable is True. """ - return [s for s in self.registry.values() if s.user_invocable] + return [skill for skill in self.registry.values() if skill.user_invocable] def get(self, name: str) -> Skill | None: """Look up a skill by name. @@ -295,4 +295,4 @@ def get_command_names(self) -> list[str]: List of skill names that are user-invocable. """ - return [s.name for s in self.list_skills()] + return [skill.name for skill in self.list_skills()] diff --git a/src/wingman/streaming.py b/src/wingman/streaming.py index ba13ab4..8f888bd 100644 --- a/src/wingman/streaming.py +++ b/src/wingman/streaming.py @@ -110,7 +110,7 @@ async def send_message( thinking.remove() # Remove transient skill prompts before saving - panel.messages = [m for m in panel.messages if not m.get("_skill")] + panel.messages = [msg for msg in panel.messages if not msg.get("_skill")] segments = get_segments(panel.panel_id) if segments: @@ -171,7 +171,7 @@ def build_messages( content_parts.append(f"\n[Tool: {cmd}]\n{output}\n") messages.append({"role": msg["role"], "content": "".join(content_parts)}) else: - clean = {k: v for k, v in msg.items() if not k.startswith("_")} + clean = {key: val for key, val in msg.items() if not key.startswith("_")} messages.append(clean) if images and messages and messages[-1].get("role") == "user": @@ -184,7 +184,7 @@ def build_messages( system_content += f"\n\n{instructions}" memory = load_memory() if memory.entries: - memory_text = "\n".join(e.content for e in memory.entries) + memory_text = "\n".join(entry.content for entry in memory.entries) system_content += f"\n\n## Project Memory\n{memory_text}" messages = [{"role": "system", "content": system_content}] + messages @@ -314,7 +314,7 @@ def handle_error( with contextlib.suppress(Exception): sw.remove() # Clean up transient skill prompts - panel.messages = [m for m in panel.messages if not m.get("_skill")] + panel.messages = [msg for msg in panel.messages if not msg.get("_skill")] segments = get_segments(panel.panel_id) if segments: panel.messages.append({"role": "assistant", "segments": segments}) From 715ac9d86c513a1b79137ed6606896571605d6ab Mon Sep 17 00:00:00 2001 From: Windsor Date: Mon, 20 Apr 2026 20:59:49 -0700 Subject: [PATCH 5/7] refactor: extract resolve_panel helper, fix remaining single-letter vars - ToolBridgeMixin: deduplicate panel lookup into resolve_panel() - panels.py, context.py: rename single-letter loop variables --- src/wingman/context.py | 2 +- src/wingman/panels.py | 6 +++--- src/wingman/tool_bridge.py | 34 ++++++++++++++++++---------------- 3 files changed, 22 insertions(+), 20 deletions(-) diff --git a/src/wingman/context.py b/src/wingman/context.py index ebb4a05..9a14c43 100644 --- a/src/wingman/context.py +++ b/src/wingman/context.py @@ -163,7 +163,7 @@ async def compact(self, client: AsyncDedalus) -> str: target_tokens = int(self.context_limit * COMPACT_TARGET) keep_recent = 4 - recent_tokens = sum(estimate_message_tokens(m) for m in self.messages[-keep_recent:]) + recent_tokens = sum(estimate_message_tokens(msg) for msg in self.messages[-keep_recent:]) while keep_recent < len(self.messages) - 2: next_msg = self.messages[-(keep_recent + 1)] diff --git a/src/wingman/panels.py b/src/wingman/panels.py index 49bb35e..366556e 100644 --- a/src/wingman/panels.py +++ b/src/wingman/panels.py @@ -50,10 +50,10 @@ def _refresh_welcome_art(self) -> None: def do_refresh(): force_compact = len(self.panels) > 1 - for p in self.panels: + for panel in self.panels: try: - p.query_one(".panel-welcome") - p._show_welcome(force_compact=force_compact) + panel.query_one(".panel-welcome") + panel._show_welcome(force_compact=force_compact) except Exception: pass diff --git a/src/wingman/tool_bridge.py b/src/wingman/tool_bridge.py index 0612788..a127f22 100644 --- a/src/wingman/tool_bridge.py +++ b/src/wingman/tool_bridge.py @@ -16,18 +16,27 @@ class ToolBridgeMixin: """ + def resolve_panel(self, panel_id: str | None = None): + """Find a panel by ID, falling back to the active panel. + + Args: + panel_id: Panel identifier, or None for active panel. + + Returns: + Panel instance or None. + + """ + if panel_id: + for panel in self.panels: + if panel.panel_id == panel_id: + return panel + return self.active_panel + def _mount_command_status(self, command: str, widget_id: str, panel_id: str | None = None) -> None: """Mount a command status widget in the chat.""" from .ui import CommandStatus - panel = None - if panel_id: - for p in self.panels: - if p.panel_id == panel_id: - panel = p - break - if not panel: - panel = self.active_panel + panel = self.resolve_panel(panel_id) if not panel: return chat = panel.get_chat_container() @@ -55,14 +64,7 @@ def _update_thinking_status(self, status: str | None, panel_id: str | None = Non """Update the thinking spinner status text.""" from .ui import Thinking - panel = None - if panel_id: - for p in self.panels: - if p.panel_id == panel_id: - panel = p - break - if not panel: - panel = self.active_panel + panel = self.resolve_panel(panel_id) if not panel: return try: From 6eea4098aa0194f3e3e0e6bdfbca2f71204f40d9 Mon Sep 17 00:00:00 2001 From: Windsor Date: Mon, 20 Apr 2026 21:18:11 -0700 Subject: [PATCH 6/7] feat: add simplify skill for code review and cleanup Three-phase review: identify changes, run parallel reuse/quality/ efficiency audits, then fix issues. Adapted for general Python codebases. --- .agents/skills/simplify/SKILL.md | 55 ++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 .agents/skills/simplify/SKILL.md diff --git a/.agents/skills/simplify/SKILL.md b/.agents/skills/simplify/SKILL.md new file mode 100644 index 0000000..ce5e951 --- /dev/null +++ b/.agents/skills/simplify/SKILL.md @@ -0,0 +1,55 @@ +--- +name: simplify +description: Review changed code for reuse, quality, and efficiency, then fix any issues found. +allowed-tools: Read, Grep, Glob, Bash, Edit +--- + +# Simplify: Code Review and Cleanup + +Review all changed files for reuse, quality, and efficiency. Fix any issues found. + +## Phase 1: Identify Changes + +Run `git diff` (or `git diff HEAD` if there are staged changes) to see what changed. If there are no git changes, review the most recently modified files that the user mentioned or that you edited earlier in this conversation. + +## Phase 2: Launch Three Review Agents in Parallel + +Use the Agent tool to launch all three agents concurrently in a single message. Pass each agent the full diff so it has the complete context. + +### Agent 1: Code Reuse Review + +For each change: + +1. **Search for existing utilities and helpers** that could replace newly written code. Look for similar patterns elsewhere in the codebase — common locations are utility directories, shared modules, and files adjacent to the changed ones. +2. **Flag any new function that duplicates existing functionality.** Suggest the existing function to use instead. +3. **Flag any inline logic that could use an existing utility** — hand-rolled string manipulation, manual path handling, custom environment checks, ad-hoc type guards, and similar patterns are common candidates. + +### Agent 2: Code Quality Review + +Review the same changes for hacky patterns: + +1. **Redundant state**: state that duplicates existing state, cached values that could be derived +2. **Parameter sprawl**: adding new parameters to a function instead of generalizing or restructuring existing ones +3. **Copy-paste with slight variation**: near-duplicate code blocks that should be unified with a shared abstraction +4. **Leaky abstractions**: exposing internal details that should be encapsulated, or breaking existing abstraction boundaries +5. **Stringly-typed code**: using raw strings where constants, enums, or typed structures already exist in the codebase +6. **Unnecessary nesting**: wrapper containers or indirection that add no value +7. **Unnecessary comments**: comments explaining WHAT the code does (well-named identifiers already do that), narrating the change, or referencing the task/caller — delete; keep only non-obvious WHY (hidden constraints, subtle invariants, workarounds) + +### Agent 3: Efficiency Review + +Review the same changes for efficiency: + +1. **Unnecessary work**: redundant computations, repeated file reads, duplicate network/API calls, N+1 patterns +2. **Missed concurrency**: independent operations run sequentially when they could run in parallel +3. **Hot-path bloat**: new blocking work added to startup or per-request hot paths +4. **Recurring no-op updates**: state updates inside polling loops or event handlers that fire unconditionally — add a change-detection guard so downstream consumers aren't notified when nothing changed +5. **Unnecessary existence checks**: pre-checking file/resource existence before operating (TOCTOU anti-pattern) — operate directly and handle the error +6. **Memory**: unbounded data structures, missing cleanup, event listener leaks +7. **Overly broad operations**: reading entire files when only a portion is needed, loading all items when filtering for one + +## Phase 3: Fix Issues + +Wait for all three agents to complete. Aggregate their findings and fix each issue directly. If a finding is a false positive or not worth addressing, note it and move on — do not argue with the finding, just skip it. + +When done, briefly summarize what was fixed (or confirm the code was already clean). From 80eedf1cf69771de3d1d78a8df9be26c8d36d0aa Mon Sep 17 00:00:00 2001 From: Windsor Date: Mon, 20 Apr 2026 21:27:04 -0700 Subject: [PATCH 7/7] refactor: rename ToolBridgeMixin to ToolsUIMixin --- src/wingman/app.py | 4 ++-- src/wingman/{tool_bridge.py => tools_ui.py} | 13 +++++-------- 2 files changed, 7 insertions(+), 10 deletions(-) rename src/wingman/{tool_bridge.py => tools_ui.py} (85%) diff --git a/src/wingman/app.py b/src/wingman/app.py index 8b3f43e..736ab04 100644 --- a/src/wingman/app.py +++ b/src/wingman/app.py @@ -34,7 +34,7 @@ from .sessions import load_sessions from .skills import SkillManager from .streaming import StreamingController -from .tool_bridge import ToolBridgeMixin +from .tools_ui import ToolsUIMixin from .tools import ( check_completed_processes, get_background_processes, @@ -54,7 +54,7 @@ ) -class WingmanApp(PanelMixin, ToolBridgeMixin, App): +class WingmanApp(PanelMixin, ToolsUIMixin, App): """Wingman - Your copilot for the terminal""" TITLE = "Wingman" diff --git a/src/wingman/tool_bridge.py b/src/wingman/tools_ui.py similarity index 85% rename from src/wingman/tool_bridge.py rename to src/wingman/tools_ui.py index a127f22..0c47a14 100644 --- a/src/wingman/tool_bridge.py +++ b/src/wingman/tools_ui.py @@ -1,18 +1,15 @@ -"""Tool-to-UI bridge mixin for wingman. +"""UI callbacks for the tool execution layer. -Thread-safe methods that the tool execution layer (tools.py) calls -to mount widgets, update command status, and show diff modals. +Methods that tools.py calls to show command status widgets, +update the thinking spinner, and display diff modals. """ from __future__ import annotations -class ToolBridgeMixin: - """Bridge between tool execution and TUI widgets. - - These methods are called from tools.py via the app instance - reference. They handle thread-safe widget mounting and updates. +class ToolsUIMixin: + """UI methods called by tools.py via the app instance reference. """