feat(types): Complete strict static analysis and typing track

This commit is contained in:
2026-03-04 09:46:02 -05:00
parent c6c2a1b40c
commit fe2114a2e0
46 changed files with 606 additions and 795 deletions

View File

@@ -16,7 +16,7 @@ import tomllib
import re import re
import glob import glob
from pathlib import Path, PureWindowsPath from pathlib import Path, PureWindowsPath
from typing import Any from typing import Any, cast
import summarize import summarize
import project_manager import project_manager
from file_cache import ASTParser from file_cache import ASTParser
@@ -67,9 +67,11 @@ def build_files_section(base_dir: Path, files: list[str | dict[str, Any]]) -> st
sections = [] sections = []
for entry_raw in files: for entry_raw in files:
if isinstance(entry_raw, dict): if isinstance(entry_raw, dict):
entry = entry_raw.get("path") entry = cast(str, entry_raw.get("path", ""))
else: else:
entry = entry_raw entry = entry_raw
if not entry or not isinstance(entry, str):
continue
paths = resolve_paths(base_dir, entry) paths = resolve_paths(base_dir, entry)
if not paths: if not paths:
sections.append(f"### `{entry}`\n\n```text\nERROR: no files matched: {entry}\n```") sections.append(f"### `{entry}`\n\n```text\nERROR: no files matched: {entry}\n```")
@@ -90,6 +92,8 @@ def build_files_section(base_dir: Path, files: list[str | dict[str, Any]]) -> st
def build_screenshots_section(base_dir: Path, screenshots: list[str]) -> str: def build_screenshots_section(base_dir: Path, screenshots: list[str]) -> str:
sections = [] sections = []
for entry in screenshots: for entry in screenshots:
if not entry or not isinstance(entry, str):
continue
paths = resolve_paths(base_dir, entry) paths = resolve_paths(base_dir, entry)
if not paths: if not paths:
sections.append(f"### `{entry}`\n\n_ERROR: no files matched: {entry}_") sections.append(f"### `{entry}`\n\n_ERROR: no files matched: {entry}_")
@@ -115,14 +119,16 @@ def build_file_items(base_dir: Path, files: list[str | dict[str, Any]]) -> list[
mtime : float (last modification time, for skip-if-unchanged optimization) mtime : float (last modification time, for skip-if-unchanged optimization)
tier : int | None (optional tier for context management) tier : int | None (optional tier for context management)
""" """
items = [] items: list[dict[str, Any]] = []
for entry_raw in files: for entry_raw in files:
if isinstance(entry_raw, dict): if isinstance(entry_raw, dict):
entry = entry_raw.get("path") entry = cast(str, entry_raw.get("path", ""))
tier = entry_raw.get("tier") tier = entry_raw.get("tier")
else: else:
entry = entry_raw entry = entry_raw
tier = None tier = None
if not entry or not isinstance(entry, str):
continue
paths = resolve_paths(base_dir, entry) paths = resolve_paths(base_dir, entry)
if not paths: if not paths:
items.append({"path": None, "entry": entry, "content": f"ERROR: no files matched: {entry}", "error": True, "mtime": 0.0, "tier": tier}) items.append({"path": None, "entry": entry, "content": f"ERROR: no files matched: {entry}", "error": True, "mtime": 0.0, "tier": tier})
@@ -156,14 +162,15 @@ def _build_files_section_from_items(file_items: list[dict[str, Any]]) -> str:
sections = [] sections = []
for item in file_items: for item in file_items:
path = item.get("path") path = item.get("path")
entry = item.get("entry", "unknown") entry = cast(str, item.get("entry", "unknown"))
content = item.get("content", "") content = cast(str, item.get("content", ""))
if path is None: if path is None:
sections.append(f"### `{entry}`\n\n```text\n{content}\n```") sections.append(f"### `{entry}`\n\n```text\n{content}\n```")
continue continue
suffix = path.suffix.lstrip(".") if hasattr(path, "suffix") else "text" p = cast(Path, path)
suffix = p.suffix.lstrip(".") if hasattr(p, "suffix") else "text"
lang = suffix if suffix else "text" lang = suffix if suffix else "text"
original = entry if "*" not in entry else str(path) original = entry if "*" not in entry else str(p)
sections.append(f"### `{original}`\n\n```{lang}\n{content}\n```") sections.append(f"### `{original}`\n\n```{lang}\n{content}\n```")
return "\n\n---\n\n".join(sections) return "\n\n---\n\n".join(sections)
@@ -205,15 +212,16 @@ def build_tier1_context(file_items: list[dict[str, Any]], screenshot_base_dir: P
sections = [] sections = []
for item in file_items: for item in file_items:
path = item.get("path") path = item.get("path")
name = path.name if path else "" name = path.name if path and isinstance(path, Path) else ""
if name in core_files or item.get("tier") == 1: if name in core_files or item.get("tier") == 1:
# Include in full # Include in full
sections.append("### `" + (item.get("entry") or str(path)) + "`\n\n" + sections.append("### `" + (cast(str, item.get("entry")) or str(path)) + "`\n\n" +
f"```{path.suffix.lstrip('.') if path.suffix else 'text'}\n{item.get('content', '')}\n```") f"```{path.suffix.lstrip('.') if path and isinstance(path, Path) and path.suffix else 'text'}\n{item.get('content', '')}\n```")
else: else:
# Summarize # Summarize
sections.append("### `" + (item.get("entry") or str(path)) + "`\n\n" + if path and isinstance(path, Path):
summarize.summarise_file(path, item.get("content", ""))) sections.append("### `" + (cast(str, item.get("entry")) or str(path)) + "`\n\n" +
summarize.summarise_file(path, cast(str, item.get("content", ""))))
parts.append("## Files (Tier 1 - Mixed)\n\n" + "\n\n---\n\n".join(sections)) parts.append("## Files (Tier 1 - Mixed)\n\n" + "\n\n---\n\n".join(sections))
if screenshots: if screenshots:
parts.append("## Screenshots\n\n" + build_screenshots_section(screenshot_base_dir, screenshots)) parts.append("## Screenshots\n\n" + build_screenshots_section(screenshot_base_dir, screenshots))
@@ -237,20 +245,20 @@ def build_tier3_context(file_items: list[dict[str, Any]], screenshot_base_dir: P
if file_items: if file_items:
sections = [] sections = []
for item in file_items: for item in file_items:
path = item.get("path") path = cast(Path, item.get("path"))
entry = item.get("entry", "") entry = cast(str, item.get("entry", ""))
path_str = str(path) if path else "" path_str = str(path) if path else ""
# Check if this file is in focus_files (by name or path) # Check if this file is in focus_files (by name or path)
is_focus = False is_focus = False
for focus in focus_files: for focus in focus_files:
if focus == entry or (path and focus == path.name) or focus in path_str: if focus == entry or (path and focus == path.name) or (path_str and focus in path_str):
is_focus = True is_focus = True
break break
if is_focus or item.get("tier") == 3: if is_focus or item.get("tier") == 3:
sections.append("### `" + (entry or path_str) + "`\n\n" + sections.append("### `" + (entry or path_str) + "`\n\n" +
f"```{path.suffix.lstrip('.') if path and path.suffix else 'text'}\n{item.get('content', '')}\n```") f"```{path.suffix.lstrip('.') if path and path.suffix else 'text'}\n{item.get('content', '')}\n```")
else: else:
content = item.get("content", "") content = cast(str, item.get("content", ""))
if path and path.suffix == ".py" and not item.get("error"): if path and path.suffix == ".py" and not item.get("error"):
try: try:
parser = ASTParser("python") parser = ASTParser("python")
@@ -260,6 +268,7 @@ def build_tier3_context(file_items: list[dict[str, Any]], screenshot_base_dir: P
# Fallback to summary if AST parsing fails # Fallback to summary if AST parsing fails
sections.append(f"### `{entry or path_str}`\n\n" + summarize.summarise_file(path, content)) sections.append(f"### `{entry or path_str}`\n\n" + summarize.summarise_file(path, content))
else: else:
if path:
sections.append(f"### `{entry or path_str}`\n\n" + summarize.summarise_file(path, content)) sections.append(f"### `{entry or path_str}`\n\n" + summarize.summarise_file(path, content))
parts.append("## Files (Tier 3 - Focused)\n\n" + "\n\n---\n\n".join(sections)) parts.append("## Files (Tier 3 - Focused)\n\n" + "\n\n---\n\n".join(sections))
if screenshots: if screenshots:

File diff suppressed because it is too large Load Diff

View File

@@ -22,20 +22,19 @@
- [x] SAFETY: Preserve JSON serialization compatibility. - [x] SAFETY: Preserve JSON serialization compatibility.
- [x] Task: Conductor - User Manual Verification 'Phase 2: Core Library' (Protocol in workflow.md) - [x] Task: Conductor - User Manual Verification 'Phase 2: Core Library' (Protocol in workflow.md)
## Phase 3: GUI God-Object Typing Resolution ## Phase 3: GUI God-Object Typing Resolution [checkpoint: 6ebbf40]
- [ ] Task: Resolve `gui_2.py` Type Errors - [x] Task: Resolve `gui_2.py` Type Errors
- [ ] WHERE: `gui_2.py` - [x] WHERE: `gui_2.py`
- [ ] WHAT: Type the `App` class state variables, method signatures, and ImGui integration boundaries. - [x] WHAT: Type the `App` class state variables, method signatures, and ImGui integration boundaries.
- [ ] HOW: Use `type: ignore[import]` only for ImGui C-bindings if strictly necessary, but type internal state tightly. - [x] HOW: Use `type: ignore[import]` only for ImGui C-bindings if strictly necessary, but type internal state tightly.
- [ ] SAFETY: Ensure `live_gui` tests pass after typing. - [x] SAFETY: Ensure `live_gui` tests pass after typing.
- [x] PROGRESS: Initial pass completed, several critical errors resolved, baseline established. - [x] Task: Conductor - User Manual Verification 'Phase 3: GUI Typing' (Protocol in workflow.md)
- [ ] Task: Conductor - User Manual Verification 'Phase 3: GUI Typing' (Protocol in workflow.md)
## Phase 4: CI Integration & Final Validation ## Phase 4: CI Integration & Final Validation [checkpoint: c6c2a1b]
- [x] Task: Establish Pre-Commit Guardrails - [x] Task: Establish Pre-Commit Guardrails
- [x] WHERE: `.git/hooks/pre-commit` or a `scripts/validate_types.ps1` - [x] WHERE: `.git/hooks/pre-commit` or a `scripts/validate_types.ps1`
- [x] WHAT: Create a script that runs ruff and mypy, blocking commits if they fail. - [x] WHAT: Create a script that runs ruff and mypy, blocking commits if they fail.
- [x] HOW: Standard shell scripting. - [x] HOW: Standard shell scripting.
- [x] SAFETY: Ensure it works cross-platform (Windows/Linux). - [x] SAFETY: Ensure it works cross-platform (Windows/Linux).
- [ ] Task: Full Suite Validation & Warning Cleanup - [x] Task: Full Suite Validation & Warning Cleanup
- [ ] Task: Conductor - User Manual Verification 'Phase 4: Validation' (Protocol in workflow.md) - [x] Task: Conductor - User Manual Verification 'Phase 4: Validation' (Protocol in workflow.md)

View File

@@ -6,7 +6,7 @@ This file is kept so that any stale imports do not break.
""" """
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional, Any, List, Tuple, Dict
import tree_sitter import tree_sitter
import tree_sitter_python import tree_sitter_python
@@ -33,15 +33,15 @@ class ASTParser:
Returns a skeleton of a Python file (preserving docstrings, stripping function bodies). Returns a skeleton of a Python file (preserving docstrings, stripping function bodies).
""" """
tree = self.parse(code) tree = self.parse(code)
edits = [] edits: List[Tuple[int, int, str]] = []
def is_docstring(node): def is_docstring(node: tree_sitter.Node) -> bool:
if node.type == "expression_statement" and node.child_count > 0: if node.type == "expression_statement" and node.child_count > 0:
if node.children[0].type == "string": if node.children[0].type == "string":
return True return True
return False return False
def walk(node): def walk(node: tree_sitter.Node) -> None:
if node.type == "function_definition": if node.type == "function_definition":
body = node.child_by_field_name("body") body = node.child_by_field_name("body")
if body and body.type == "block": if body and body.type == "block":
@@ -77,15 +77,15 @@ class ASTParser:
Otherwise strips bodies but preserves docstrings. Otherwise strips bodies but preserves docstrings.
""" """
tree = self.parse(code) tree = self.parse(code)
edits = [] edits: List[Tuple[int, int, str]] = []
def is_docstring(node): def is_docstring(node: tree_sitter.Node) -> bool:
if node.type == "expression_statement" and node.child_count > 0: if node.type == "expression_statement" and node.child_count > 0:
if node.children[0].type == "string": if node.children[0].type == "string":
return True return True
return False return False
def has_core_logic_decorator(node): def has_core_logic_decorator(node: tree_sitter.Node) -> bool:
# Check if parent is decorated_definition # Check if parent is decorated_definition
parent = node.parent parent = node.parent
if parent and parent.type == "decorated_definition": if parent and parent.type == "decorated_definition":
@@ -96,7 +96,7 @@ class ASTParser:
return True return True
return False return False
def has_hot_comment(func_node): def has_hot_comment(func_node: tree_sitter.Node) -> bool:
# Check all descendants of the function_definition for a [HOT] comment # Check all descendants of the function_definition for a [HOT] comment
stack = [func_node] stack = [func_node]
while stack: while stack:
@@ -109,7 +109,7 @@ class ASTParser:
stack.append(child) stack.append(child)
return False return False
def walk(node): def walk(node: tree_sitter.Node) -> None:
if node.type == "function_definition": if node.type == "function_definition":
body = node.child_by_field_name("body") body = node.child_by_field_name("body")
if body and body.type == "block": if body and body.type == "block":
@@ -153,5 +153,6 @@ def get_file_id(path: Path) -> Optional[str]:
def evict(path: Path) -> None: def evict(path: Path) -> None:
pass pass
def list_cached() -> list[dict]: def list_cached() -> List[Dict[str, Any]]:
return [] return []

View File

@@ -12,10 +12,10 @@ class GeminiCliAdapter:
def __init__(self, binary_path: str = "gemini"): def __init__(self, binary_path: str = "gemini"):
self.binary_path = binary_path self.binary_path = binary_path
self.session_id: Optional[str] = None self.session_id: Optional[str] = None
self.last_usage: Optional[dict] = None self.last_usage: Optional[dict[str, Any]] = None
self.last_latency: float = 0.0 self.last_latency: float = 0.0
def send(self, message: str, safety_settings: list | None = None, system_instruction: str | None = None, def send(self, message: str, safety_settings: list[Any] | None = None, system_instruction: str | None = None,
model: str | None = None, stream_callback: Optional[Callable[[str], None]] = None) -> dict[str, Any]: model: str | None = None, stream_callback: Optional[Callable[[str], None]] = None) -> dict[str, Any]:
""" """
Sends a message to the Gemini CLI and processes the streaming JSON output. Sends a message to the Gemini CLI and processes the streaming JSON output.

346
gui_2.py
View File

@@ -9,7 +9,7 @@ import json
import sys import sys
import os import os
import uuid import uuid
import requests import requests # type: ignore[import-untyped]
from pathlib import Path from pathlib import Path
from tkinter import filedialog, Tk from tkinter import filedialog, Tk
from typing import Optional, Callable, Any from typing import Optional, Callable, Any
@@ -61,21 +61,21 @@ def hide_tk_root() -> Tk:
def vec4(r: float, g: float, b: float, a: float = 1.0) -> imgui.ImVec4: return imgui.ImVec4(r/255, g/255, b/255, a) def vec4(r: float, g: float, b: float, a: float = 1.0) -> imgui.ImVec4: return imgui.ImVec4(r/255, g/255, b/255, a)
C_OUT: tuple[float, ...] = vec4(100, 200, 255) C_OUT: imgui.ImVec4 = vec4(100, 200, 255)
C_IN: tuple[float, ...] = vec4(140, 255, 160) C_IN: imgui.ImVec4 = vec4(140, 255, 160)
C_REQ: tuple[float, ...] = vec4(255, 220, 100) C_REQ: imgui.ImVec4 = vec4(255, 220, 100)
C_RES: tuple[float, ...] = vec4(180, 255, 180) C_RES: imgui.ImVec4 = vec4(180, 255, 180)
C_TC: tuple[float, ...] = vec4(255, 180, 80) C_TC: imgui.ImVec4 = vec4(255, 180, 80)
C_TR: tuple[float, ...] = vec4(180, 220, 255) C_TR: imgui.ImVec4 = vec4(180, 220, 255)
C_TRS: tuple[float, ...] = vec4(200, 180, 255) C_TRS: imgui.ImVec4 = vec4(200, 180, 255)
C_LBL: tuple[float, ...] = vec4(180, 180, 180) C_LBL: imgui.ImVec4 = vec4(180, 180, 180)
C_VAL: tuple[float, ...] = vec4(220, 220, 220) C_VAL: imgui.ImVec4 = vec4(220, 220, 220)
C_KEY: tuple[float, ...] = vec4(140, 200, 255) C_KEY: imgui.ImVec4 = vec4(140, 200, 255)
C_NUM: tuple[float, ...] = vec4(180, 255, 180) C_NUM: imgui.ImVec4 = vec4(180, 255, 180)
C_SUB: tuple[float, ...] = vec4(220, 200, 120) C_SUB: imgui.ImVec4 = vec4(220, 200, 120)
DIR_COLORS: dict[str, tuple[float, ...]] = {"OUT": C_OUT, "IN": C_IN} DIR_COLORS: dict[str, imgui.ImVec4] = {"OUT": C_OUT, "IN": C_IN}
KIND_COLORS: dict[str, tuple[float, ...]] = {"request": C_REQ, "response": C_RES, "tool_call": C_TC, "tool_result": C_TR, "tool_result_send": C_TRS} KIND_COLORS: dict[str, imgui.ImVec4] = {"request": C_REQ, "response": C_RES, "tool_call": C_TC, "tool_result": C_TR, "tool_result_send": C_TRS}
HEAVY_KEYS: set[str] = {"message", "text", "script", "output", "content"} HEAVY_KEYS: set[str] = {"message", "text", "script", "output", "content"}
DISC_ROLES: list[str] = ["User", "AI", "Vendor API", "System"] DISC_ROLES: list[str] = ["User", "AI", "Vendor API", "System"]
@@ -173,19 +173,19 @@ class App:
def __init__(self) -> None: def __init__(self) -> None:
# Initialize locks first to avoid initialization order issues # Initialize locks first to avoid initialization order issues
self._send_thread_lock = threading.Lock() self._send_thread_lock: threading.Lock = threading.Lock()
self._disc_entries_lock = threading.Lock() self._disc_entries_lock: threading.Lock = threading.Lock()
self._pending_comms_lock = threading.Lock() self._pending_comms_lock: threading.Lock = threading.Lock()
self._pending_tool_calls_lock = threading.Lock() self._pending_tool_calls_lock: threading.Lock = threading.Lock()
self._pending_history_adds_lock = threading.Lock() self._pending_history_adds_lock: threading.Lock = threading.Lock()
self._pending_gui_tasks_lock = threading.Lock() self._pending_gui_tasks_lock: threading.Lock = threading.Lock()
self._pending_dialog_lock = threading.Lock() self._pending_dialog_lock: threading.Lock = threading.Lock()
self._api_event_queue_lock = threading.Lock() self._api_event_queue_lock: threading.Lock = threading.Lock()
self.config = load_config() self.config: dict[str, Any] = load_config()
self.event_queue = events.AsyncEventQueue() self.event_queue: events.AsyncEventQueue = events.AsyncEventQueue()
self._loop = asyncio.new_event_loop() self._loop: asyncio.AbstractEventLoop = asyncio.new_event_loop()
self._loop_thread = threading.Thread(target=self._run_event_loop, daemon=True) self._loop_thread: threading.Thread = threading.Thread(target=self._run_event_loop, daemon=True)
self._loop_thread.start() self._loop_thread.start()
ai_cfg = self.config.get("ai", {}) ai_cfg = self.config.get("ai", {})
self._current_provider: str = ai_cfg.get("provider", "gemini") self._current_provider: str = ai_cfg.get("provider", "gemini")
@@ -208,33 +208,33 @@ class App:
disc_data = disc_sec.get("discussions", {}).get(self.active_discussion, {}) disc_data = disc_sec.get("discussions", {}).get(self.active_discussion, {})
with self._disc_entries_lock: with self._disc_entries_lock:
self.disc_entries: list[dict[str, Any]] = _parse_history_entries(disc_data.get("history", []), self.disc_roles) self.disc_entries: list[dict[str, Any]] = _parse_history_entries(disc_data.get("history", []), self.disc_roles)
self.ui_output_dir = self.project.get("output", {}).get("output_dir", "./md_gen") self.ui_output_dir: str = self.project.get("output", {}).get("output_dir", "./md_gen")
self.ui_files_base_dir = self.project.get("files", {}).get("base_dir", ".") self.ui_files_base_dir: str = self.project.get("files", {}).get("base_dir", ".")
self.ui_shots_base_dir = self.project.get("screenshots", {}).get("base_dir", ".") self.ui_shots_base_dir: str = self.project.get("screenshots", {}).get("base_dir", ".")
proj_meta = self.project.get("project", {}) proj_meta = self.project.get("project", {})
self.ui_project_git_dir = proj_meta.get("git_dir", "") self.ui_project_git_dir: str = proj_meta.get("git_dir", "")
self.ui_project_main_context = proj_meta.get("main_context", "") self.ui_project_main_context: str = proj_meta.get("main_context", "")
self.ui_project_system_prompt = proj_meta.get("system_prompt", "") self.ui_project_system_prompt: str = proj_meta.get("system_prompt", "")
self.ui_gemini_cli_path = self.project.get("gemini_cli", {}).get("binary_path", "gemini") self.ui_gemini_cli_path: str = self.project.get("gemini_cli", {}).get("binary_path", "gemini")
self.ui_word_wrap = proj_meta.get("word_wrap", True) self.ui_word_wrap: bool = proj_meta.get("word_wrap", True)
self.ui_summary_only = proj_meta.get("summary_only", False) self.ui_summary_only: bool = proj_meta.get("summary_only", False)
self.ui_auto_add_history = disc_sec.get("auto_add", False) self.ui_auto_add_history: bool = disc_sec.get("auto_add", False)
self.ui_global_system_prompt = self.config.get("ai", {}).get("system_prompt", "") self.ui_global_system_prompt: str = self.config.get("ai", {}).get("system_prompt", "")
self.ui_ai_input = "" self.ui_ai_input: str = ""
self.ui_disc_new_name_input = "" self.ui_disc_new_name_input: str = ""
self.ui_disc_new_role_input = "" self.ui_disc_new_role_input: str = ""
self.ui_epic_input = "" self.ui_epic_input: str = ""
self.proposed_tracks: list[dict[str, Any]] = [] self.proposed_tracks: list[dict[str, Any]] = []
self._show_track_proposal_modal = False self._show_track_proposal_modal: bool = False
self.ui_new_track_name = "" self.ui_new_track_name: str = ""
self.ui_new_track_desc = "" self.ui_new_track_desc: str = ""
self.ui_new_track_type = "feature" self.ui_new_track_type: str = "feature"
self.ui_conductor_setup_summary = "" self.ui_conductor_setup_summary: str = ""
self.ui_last_script_text = "" self.ui_last_script_text: str = ""
self.ui_last_script_output = "" self.ui_last_script_output: str = ""
self.ai_status = "idle" self.ai_status: str = "idle"
self.ai_response = "" self.ai_response: str = ""
self.last_md = "" self.last_md: str = ""
self.last_md_path: Path | None = None self.last_md_path: Path | None = None
self.last_file_items: list[Any] = [] self.last_file_items: list[Any] = []
self.send_thread: threading.Thread | None = None self.send_thread: threading.Thread | None = None
@@ -255,82 +255,82 @@ class App:
"Diagnostics": False, "Diagnostics": False,
} }
saved = self.config.get("gui", {}).get("show_windows", {}) saved = self.config.get("gui", {}).get("show_windows", {})
self.show_windows = {k: saved.get(k, v) for k, v in _default_windows.items()} self.show_windows: dict[str, bool] = {k: saved.get(k, v) for k, v in _default_windows.items()}
self.show_script_output = False self.show_script_output: bool = False
self.show_text_viewer = False self.show_text_viewer: bool = False
self.text_viewer_title = "" self.text_viewer_title: str = ""
self.text_viewer_content = "" self.text_viewer_content: str = ""
self._pending_dialog: ConfirmDialog | None = None self._pending_dialog: ConfirmDialog | None = None
self._pending_dialog_open = False self._pending_dialog_open: bool = False
self._pending_actions: dict[str, ConfirmDialog] = {} self._pending_actions: dict[str, ConfirmDialog] = {}
self._pending_ask_dialog = False self._pending_ask_dialog: bool = False
self._ask_dialog_open = False self._ask_dialog_open: bool = False
self._ask_request_id = None self._ask_request_id: str | None = None
self._ask_tool_data = None self._ask_tool_data: dict[str, Any] | None = None
self.mma_step_mode = False self.mma_step_mode: bool = False
self.active_track: Track | None = None self.active_track: Track | None = None
self.active_tickets: list[dict[str, Any]] = [] self.active_tickets: list[dict[str, Any]] = []
self.active_tier: str | None = None self.active_tier: str | None = None
self.ui_focus_agent: str | None = None self.ui_focus_agent: str | None = None
self.mma_status = "idle" self.mma_status: str = "idle"
self._pending_mma_approval: dict[str, Any] | None = None self._pending_mma_approval: dict[str, Any] | None = None
self._mma_approval_open = False self._mma_approval_open: bool = False
self._mma_approval_edit_mode = False self._mma_approval_edit_mode: bool = False
self._mma_approval_payload = "" self._mma_approval_payload: str = ""
self._pending_mma_spawn: dict[str, Any] | None = None self._pending_mma_spawn: dict[str, Any] | None = None
self._mma_spawn_open = False self._mma_spawn_open: bool = False
self._mma_spawn_edit_mode = False self._mma_spawn_edit_mode: bool = False
self._mma_spawn_prompt = '' self._mma_spawn_prompt: str = ''
self._mma_spawn_context = '' self._mma_spawn_context: str = ''
self.mma_tier_usage = { self.mma_tier_usage: dict[str, dict[str, Any]] = {
"Tier 1": {"input": 0, "output": 0, "model": "gemini-3.1-pro-preview"}, "Tier 1": {"input": 0, "output": 0, "model": "gemini-3.1-pro-preview"},
"Tier 2": {"input": 0, "output": 0, "model": "gemini-3-flash-preview"}, "Tier 2": {"input": 0, "output": 0, "model": "gemini-3-flash-preview"},
"Tier 3": {"input": 0, "output": 0, "model": "gemini-2.5-flash-lite"}, "Tier 3": {"input": 0, "output": 0, "model": "gemini-2.5-flash-lite"},
"Tier 4": {"input": 0, "output": 0, "model": "gemini-2.5-flash-lite"}, "Tier 4": {"input": 0, "output": 0, "model": "gemini-2.5-flash-lite"},
} }
self._tool_log: list[dict] = [] self._tool_log: list[dict[str, Any]] = []
self._comms_log: list[dict[str, Any]] = [] self._comms_log: list[dict[str, Any]] = []
self._pending_comms: list[dict[str, Any]] = [] self._pending_comms: list[dict[str, Any]] = []
self._pending_tool_calls: list[dict] = [] self._pending_tool_calls: list[dict[str, Any]] = []
self._pending_history_adds: list[dict[str, Any]] = [] self._pending_history_adds: list[dict[str, Any]] = []
self._trigger_blink = False self._trigger_blink: bool = False
self._is_blinking = False self._is_blinking: bool = False
self._blink_start_time = 0.0 self._blink_start_time: float = 0.0
self._trigger_script_blink = False self._trigger_script_blink: bool = False
self._is_script_blinking = False self._is_script_blinking: bool = False
self._script_blink_start_time = 0.0 self._script_blink_start_time: float = 0.0
self._scroll_disc_to_bottom = False self._scroll_disc_to_bottom: bool = False
self._scroll_comms_to_bottom = False self._scroll_comms_to_bottom: bool = False
self._scroll_tool_calls_to_bottom = False self._scroll_tool_calls_to_bottom: bool = False
self._pending_gui_tasks: list[dict[str, Any]] = [] self._pending_gui_tasks: list[dict[str, Any]] = []
self.session_usage = {"input_tokens": 0, "output_tokens": 0, "cache_read_input_tokens": 0, "cache_creation_input_tokens": 0} self.session_usage: dict[str, Any] = {"input_tokens": 0, "output_tokens": 0, "cache_read_input_tokens": 0, "cache_creation_input_tokens": 0, "last_latency": 0.0}
self._gemini_cache_text = "" self._gemini_cache_text: str = ""
self._last_stable_md: str = '' self._last_stable_md: str = ''
self._token_stats: dict = {} self._token_stats: dict[str, Any] = {}
self._token_stats_dirty: bool = False self._token_stats_dirty: bool = False
self.ui_disc_truncate_pairs: int = 2 self.ui_disc_truncate_pairs: int = 2
self.ui_auto_scroll_comms = True self.ui_auto_scroll_comms: bool = True
self.ui_auto_scroll_tool_calls = True self.ui_auto_scroll_tool_calls: bool = True
agent_tools_cfg = self.project.get("agent", {}).get("tools", {}) agent_tools_cfg = self.project.get("agent", {}).get("tools", {})
self.ui_agent_tools: dict[str, bool] = {t: agent_tools_cfg.get(t, True) for t in AGENT_TOOL_NAMES} self.ui_agent_tools: dict[str, bool] = {t: agent_tools_cfg.get(t, True) for t in AGENT_TOOL_NAMES}
self.tracks: list[dict[str, Any]] = [] self.tracks: list[dict[str, Any]] = []
self._show_add_ticket_form = False self._show_add_ticket_form: bool = False
self.ui_new_ticket_id = "" self.ui_new_ticket_id: str = ""
self.ui_new_ticket_desc = "" self.ui_new_ticket_desc: str = ""
self.ui_new_ticket_target = "" self.ui_new_ticket_target: str = ""
self.ui_new_ticket_deps = "" self.ui_new_ticket_deps: str = ""
self._track_discussion_active = False self._track_discussion_active: bool = False
self.mma_streams: dict[str, str] = {} self.mma_streams: dict[str, str] = {}
self._tier_stream_last_len: dict[str, int] = {} self._tier_stream_last_len: dict[str, int] = {}
self.is_viewing_prior_session = False self.is_viewing_prior_session: bool = False
self.prior_session_entries: list[dict[str, Any]] = [] self.prior_session_entries: list[dict[str, Any]] = []
self.test_hooks_enabled = ("--enable-test-hooks" in sys.argv) or (os.environ.get("SLOP_TEST_HOOKS") == "1") self.test_hooks_enabled: bool = ("--enable-test-hooks" in sys.argv) or (os.environ.get("SLOP_TEST_HOOKS") == "1")
self.ui_manual_approve = False self.ui_manual_approve: bool = False
self.perf_monitor = PerformanceMonitor() self.perf_monitor: PerformanceMonitor = PerformanceMonitor()
self.perf_history = {"frame_time": [0.0]*100, "fps": [0.0]*100, "cpu": [0.0]*100, "input_lag": [0.0]*100} self.perf_history: dict[str, list[float]] = {"frame_time": [0.0]*100, "fps": [0.0]*100, "cpu": [0.0]*100, "input_lag": [0.0]*100}
self._perf_last_update = 0.0 self._perf_last_update: float = 0.0
self._autosave_interval = 60.0 self._autosave_interval: float = 60.0
self._last_autosave = time.time() self._last_autosave: float = time.time()
label = self.project.get("project", {}).get("name", "") label = self.project.get("project", {}).get("name", "")
session_logger.open_session(label=label) session_logger.open_session(label=label)
self._prune_old_logs() self._prune_old_logs()
@@ -856,7 +856,7 @@ class App:
self._switch_discussion(remaining[0]) self._switch_discussion(remaining[0])
# ---------------------------------------------------------------- logic # ---------------------------------------------------------------- logic
def _on_comms_entry(self, entry: dict) -> None: def _on_comms_entry(self, entry: dict[str, Any]) -> None:
# sys.stderr.write(f"[DEBUG] _on_comms_entry: {entry.get('kind')} {entry.get('direction')}\n") # sys.stderr.write(f"[DEBUG] _on_comms_entry: {entry.get('kind')} {entry.get('direction')}\n")
session_logger.log_comms(entry) session_logger.log_comms(entry)
entry["local_ts"] = time.time() entry["local_ts"] = time.time()
@@ -898,7 +898,7 @@ class App:
with self._pending_tool_calls_lock: with self._pending_tool_calls_lock:
self._pending_tool_calls.append({"script": script, "result": result, "ts": time.time(), "source_tier": source_tier}) self._pending_tool_calls.append({"script": script, "result": result, "ts": time.time(), "source_tier": source_tier})
def _on_api_event(self, *args, **kwargs) -> None: def _on_api_event(self, *args: Any, **kwargs: Any) -> None:
payload = kwargs.get("payload", {}) payload = kwargs.get("payload", {})
with self._pending_gui_tasks_lock: with self._pending_gui_tasks_lock:
self._pending_gui_tasks.append({"action": "refresh_api_metrics", "payload": payload}) self._pending_gui_tasks.append({"action": "refresh_api_metrics", "payload": payload})
@@ -992,16 +992,16 @@ class App:
setattr(self, attr_name, value) setattr(self, attr_name, value)
if item == "gcli_path": if item == "gcli_path":
if not ai_client._gemini_cli_adapter: if not ai_client._gemini_cli_adapter:
ai_client._gemini_cli_adapter = ai_client.GeminiCliAdapter(binary_path=value) ai_client._gemini_cli_adapter = ai_client.GeminiCliAdapter(binary_path=str(value))
else: else:
ai_client._gemini_cli_adapter.binary_path = value ai_client._gemini_cli_adapter.binary_path = str(value)
elif action == "click": elif action == "click":
item = task.get("item") item = task.get("item")
user_data = task.get("user_data") user_data = task.get("user_data")
if item == "btn_project_new_automated": if item == "btn_project_new_automated":
self._cb_new_project_automated(user_data) self._cb_new_project_automated(user_data)
elif item == "btn_mma_load_track": elif item == "btn_mma_load_track":
self._cb_load_track(user_data) self._cb_load_track(str(user_data or ""))
elif item in self._clickable_actions: elif item in self._clickable_actions:
# Check if it's a method that accepts user_data # Check if it's a method that accepts user_data
import inspect import inspect
@@ -1018,7 +1018,7 @@ class App:
item = task.get("listbox", task.get("item")) item = task.get("listbox", task.get("item"))
value = task.get("item_value", task.get("value")) value = task.get("item_value", task.get("value"))
if item == "disc_listbox": if item == "disc_listbox":
self._switch_discussion(value) self._switch_discussion(str(value or ""))
elif task.get("type") == "ask": elif task.get("type") == "ask":
self._pending_ask_dialog = True self._pending_ask_dialog = True
self._ask_request_id = task.get("request_id") self._ask_request_id = task.get("request_id")
@@ -1037,18 +1037,18 @@ class App:
elif cb in self._predefined_callbacks: elif cb in self._predefined_callbacks:
self._predefined_callbacks[cb](*args) self._predefined_callbacks[cb](*args)
elif action == "mma_step_approval": elif action == "mma_step_approval":
dlg = MMAApprovalDialog(task.get("ticket_id"), task.get("payload")) dlg = MMAApprovalDialog(str(task.get("ticket_id") or ""), str(task.get("payload") or ""))
self._pending_mma_approval = task self._pending_mma_approval = task
if "dialog_container" in task: if "dialog_container" in task:
task["dialog_container"][0] = dlg task["dialog_container"][0] = dlg
elif action == 'refresh_from_project': elif action == 'refresh_from_project':
self._refresh_from_project() self._refresh_from_project()
elif action == "mma_spawn_approval": elif action == "mma_spawn_approval":
dlg = MMASpawnApprovalDialog( spawn_dlg = MMASpawnApprovalDialog(
task.get("ticket_id"), str(task.get("ticket_id") or ""),
task.get("role"), str(task.get("role") or ""),
task.get("prompt"), str(task.get("prompt") or ""),
task.get("context_md") str(task.get("context_md") or "")
) )
self._pending_mma_spawn = task self._pending_mma_spawn = task
self._mma_spawn_prompt = task.get("prompt", "") self._mma_spawn_prompt = task.get("prompt", "")
@@ -1056,7 +1056,7 @@ class App:
self._mma_spawn_open = True self._mma_spawn_open = True
self._mma_spawn_edit_mode = False self._mma_spawn_edit_mode = False
if "dialog_container" in task: if "dialog_container" in task:
task["dialog_container"][0] = dlg task["dialog_container"][0] = spawn_dlg
except Exception as e: except Exception as e:
print(f"Error executing GUI task: {e}") print(f"Error executing GUI task: {e}")
@@ -1143,7 +1143,7 @@ class App:
else: else:
print("[DEBUG] No pending spawn approval found") print("[DEBUG] No pending spawn approval found")
def _handle_mma_respond(self, approved: bool, payload: str = None, abort: bool = False, prompt: str = None, context_md: str = None) -> None: def _handle_mma_respond(self, approved: bool, payload: str | None = None, abort: bool = False, prompt: str | None = None, context_md: str | None = None) -> None:
if self._pending_mma_approval: if self._pending_mma_approval:
dlg = self._pending_mma_approval.get("dialog_container", [None])[0] dlg = self._pending_mma_approval.get("dialog_container", [None])[0]
if dlg: if dlg:
@@ -1155,17 +1155,17 @@ class App:
dlg._condition.notify_all() dlg._condition.notify_all()
self._pending_mma_approval = None self._pending_mma_approval = None
if self._pending_mma_spawn: if self._pending_mma_spawn:
dlg = self._pending_mma_spawn.get("dialog_container", [None])[0] spawn_dlg = self._pending_mma_spawn.get("dialog_container", [None])[0]
if dlg: if spawn_dlg:
with dlg._condition: with spawn_dlg._condition:
dlg._approved = approved spawn_dlg._approved = approved
dlg._abort = abort spawn_dlg._abort = abort
if prompt is not None: if prompt is not None:
dlg._prompt = prompt spawn_dlg._prompt = prompt
if context_md is not None: if context_md is not None:
dlg._context_md = context_md spawn_dlg._context_md = context_md
dlg._done = True spawn_dlg._done = True
dlg._condition.notify_all() spawn_dlg._condition.notify_all()
self._pending_mma_spawn = None self._pending_mma_spawn = None
def _handle_approve_ask(self) -> None: def _handle_approve_ask(self) -> None:
@@ -1173,7 +1173,7 @@ class App:
if not self._ask_request_id: return if not self._ask_request_id: return
request_id = self._ask_request_id request_id = self._ask_request_id
def do_post(): def do_post() -> None:
try: try:
requests.post( requests.post(
"http://127.0.0.1:8999/api/ask/respond", "http://127.0.0.1:8999/api/ask/respond",
@@ -1191,7 +1191,7 @@ class App:
if not self._ask_request_id: return if not self._ask_request_id: return
request_id = self._ask_request_id request_id = self._ask_request_id
def do_post(): def do_post() -> None:
try: try:
requests.post( requests.post(
"http://127.0.0.1:8999/api/ask/respond", "http://127.0.0.1:8999/api/ask/respond",
@@ -1268,7 +1268,7 @@ class App:
self._loop.create_task(self._process_event_queue()) self._loop.create_task(self._process_event_queue())
# Fallback: process queues even if GUI thread is idling/stuck # Fallback: process queues even if GUI thread is idling/stuck
async def queue_fallback(): async def queue_fallback() -> None:
while True: while True:
try: try:
self._process_pending_gui_tasks() self._process_pending_gui_tasks()
@@ -1393,7 +1393,7 @@ class App:
usage[k] += u.get(k, 0) or 0 usage[k] += u.get(k, 0) or 0
self.session_usage = usage self.session_usage = usage
def _refresh_api_metrics(self, payload: dict, md_content: str | None = None) -> None: def _refresh_api_metrics(self, payload: dict[str, Any], md_content: str | None = None) -> None:
if "latency" in payload: if "latency" in payload:
self.session_usage["last_latency"] = payload["latency"] self.session_usage["last_latency"] = payload["latency"]
self._recalculate_session_usage() self._recalculate_session_usage()
@@ -1567,7 +1567,7 @@ class App:
self.config["gui"] = {"show_windows": self.show_windows} self.config["gui"] = {"show_windows": self.show_windows}
theme.save_to_config(self.config) theme.save_to_config(self.config)
def _do_generate(self) -> tuple[str, Path, list, str, str]: def _do_generate(self) -> tuple[str, Path, list[dict[str, Any]], str, str]:
"""Returns (full_md, output_path, file_items, stable_md, discussion_text).""" """Returns (full_md, output_path, file_items, stable_md, discussion_text)."""
self._flush_to_project() self._flush_to_project()
self._save_active_project() self._save_active_project()
@@ -1589,7 +1589,7 @@ class App:
def _fetch_models(self, provider: str) -> None: def _fetch_models(self, provider: str) -> None:
self.ai_status = "fetching models..." self.ai_status = "fetching models..."
def do_fetch(): def do_fetch() -> None:
try: try:
models = ai_client.list_models(provider) models = ai_client.list_models(provider)
self.available_models = models self.available_models = models
@@ -1698,12 +1698,14 @@ class App:
self._tool_log.append(tc) self._tool_log.append(tc)
self._pending_tool_calls.clear() self._pending_tool_calls.clear()
if self.show_windows.get("Context Hub", False): if self.show_windows.get("Context Hub", False):
exp, self.show_windows["Context Hub"] = imgui.begin("Context Hub", self.show_windows["Context Hub"]) exp, opened = imgui.begin("Context Hub", self.show_windows["Context Hub"])
self.show_windows["Context Hub"] = bool(opened)
if exp: if exp:
self._render_projects_panel() self._render_projects_panel()
imgui.end() imgui.end()
if self.show_windows.get("Files & Media", False): if self.show_windows.get("Files & Media", False):
exp, self.show_windows["Files & Media"] = imgui.begin("Files & Media", self.show_windows["Files & Media"]) exp, opened = imgui.begin("Files & Media", self.show_windows["Files & Media"])
self.show_windows["Files & Media"] = bool(opened)
if exp: if exp:
if imgui.collapsing_header("Files"): if imgui.collapsing_header("Files"):
self._render_files_panel() self._render_files_panel()
@@ -1711,7 +1713,8 @@ class App:
self._render_screenshots_panel() self._render_screenshots_panel()
imgui.end() imgui.end()
if self.show_windows.get("AI Settings", False): if self.show_windows.get("AI Settings", False):
exp, self.show_windows["AI Settings"] = imgui.begin("AI Settings", self.show_windows["AI Settings"]) exp, opened = imgui.begin("AI Settings", self.show_windows["AI Settings"])
self.show_windows["AI Settings"] = bool(opened)
if exp: if exp:
if imgui.collapsing_header("Provider & Model"): if imgui.collapsing_header("Provider & Model"):
self._render_provider_panel() self._render_provider_panel()
@@ -1721,34 +1724,40 @@ class App:
self._render_token_budget_panel() self._render_token_budget_panel()
imgui.end() imgui.end()
if self.show_windows.get("MMA Dashboard", False): if self.show_windows.get("MMA Dashboard", False):
exp, self.show_windows["MMA Dashboard"] = imgui.begin("MMA Dashboard", self.show_windows["MMA Dashboard"]) exp, opened = imgui.begin("MMA Dashboard", self.show_windows["MMA Dashboard"])
self.show_windows["MMA Dashboard"] = bool(opened)
if exp: if exp:
self._render_mma_dashboard() self._render_mma_dashboard()
imgui.end() imgui.end()
if self.show_windows.get("Tier 1: Strategy", False): if self.show_windows.get("Tier 1: Strategy", False):
exp, self.show_windows["Tier 1: Strategy"] = imgui.begin("Tier 1: Strategy", self.show_windows["Tier 1: Strategy"]) exp, opened = imgui.begin("Tier 1: Strategy", self.show_windows["Tier 1: Strategy"])
self.show_windows["Tier 1: Strategy"] = bool(opened)
if exp: if exp:
self._render_tier_stream_panel("Tier 1", "Tier 1") self._render_tier_stream_panel("Tier 1", "Tier 1")
imgui.end() imgui.end()
if self.show_windows.get("Tier 2: Tech Lead", False): if self.show_windows.get("Tier 2: Tech Lead", False):
exp, self.show_windows["Tier 2: Tech Lead"] = imgui.begin("Tier 2: Tech Lead", self.show_windows["Tier 2: Tech Lead"]) exp, opened = imgui.begin("Tier 2: Tech Lead", self.show_windows["Tier 2: Tech Lead"])
self.show_windows["Tier 2: Tech Lead"] = bool(opened)
if exp: if exp:
self._render_tier_stream_panel("Tier 2", "Tier 2 (Tech Lead)") self._render_tier_stream_panel("Tier 2", "Tier 2 (Tech Lead)")
imgui.end() imgui.end()
if self.show_windows.get("Tier 3: Workers", False): if self.show_windows.get("Tier 3: Workers", False):
exp, self.show_windows["Tier 3: Workers"] = imgui.begin("Tier 3: Workers", self.show_windows["Tier 3: Workers"]) exp, opened = imgui.begin("Tier 3: Workers", self.show_windows["Tier 3: Workers"])
self.show_windows["Tier 3: Workers"] = bool(opened)
if exp: if exp:
self._render_tier_stream_panel("Tier 3", None) self._render_tier_stream_panel("Tier 3", None)
imgui.end() imgui.end()
if self.show_windows.get("Tier 4: QA", False): if self.show_windows.get("Tier 4: QA", False):
exp, self.show_windows["Tier 4: QA"] = imgui.begin("Tier 4: QA", self.show_windows["Tier 4: QA"]) exp, opened = imgui.begin("Tier 4: QA", self.show_windows["Tier 4: QA"])
self.show_windows["Tier 4: QA"] = bool(opened)
if exp: if exp:
self._render_tier_stream_panel("Tier 4", "Tier 4 (QA)") self._render_tier_stream_panel("Tier 4", "Tier 4 (QA)")
imgui.end() imgui.end()
if self.show_windows.get("Theme", False): if self.show_windows.get("Theme", False):
self._render_theme_panel() self._render_theme_panel()
if self.show_windows.get("Discussion Hub", False): if self.show_windows.get("Discussion Hub", False):
exp, self.show_windows["Discussion Hub"] = imgui.begin("Discussion Hub", self.show_windows["Discussion Hub"]) exp, opened = imgui.begin("Discussion Hub", self.show_windows["Discussion Hub"])
self.show_windows["Discussion Hub"] = bool(opened)
if exp: if exp:
# Top part for the history # Top part for the history
imgui.begin_child("HistoryChild", size=(0, -200)) imgui.begin_child("HistoryChild", size=(0, -200))
@@ -1765,7 +1774,8 @@ class App:
imgui.end_tab_bar() imgui.end_tab_bar()
imgui.end() imgui.end()
if self.show_windows.get("Operations Hub", False): if self.show_windows.get("Operations Hub", False):
exp, self.show_windows["Operations Hub"] = imgui.begin("Operations Hub", self.show_windows["Operations Hub"]) exp, opened = imgui.begin("Operations Hub", self.show_windows["Operations Hub"])
self.show_windows["Operations Hub"] = bool(opened)
if exp: if exp:
imgui.text("Focus Agent:") imgui.text("Focus Agent:")
imgui.same_line() imgui.same_line()
@@ -1794,7 +1804,8 @@ class App:
if self.show_windows.get("Log Management", False): if self.show_windows.get("Log Management", False):
self._render_log_management() self._render_log_management()
if self.show_windows["Diagnostics"]: if self.show_windows["Diagnostics"]:
exp, self.show_windows["Diagnostics"] = imgui.begin("Diagnostics", self.show_windows["Diagnostics"]) exp, opened = imgui.begin("Diagnostics", self.show_windows["Diagnostics"])
self.show_windows["Diagnostics"] = bool(opened)
if exp: if exp:
now = time.time() now = time.time()
if now - self._perf_last_update >= 0.5: if now - self._perf_last_update >= 0.5:
@@ -1893,7 +1904,7 @@ class App:
else: else:
self._ask_dialog_open = False self._ask_dialog_open = False
if imgui.begin_popup_modal("Approve Tool Execution", None, imgui.WindowFlags_.always_auto_resize)[0]: if imgui.begin_popup_modal("Approve Tool Execution", None, imgui.WindowFlags_.always_auto_resize)[0]:
if not self._pending_ask_dialog: if not self._pending_ask_dialog or self._ask_tool_data is None:
imgui.close_current_popup() imgui.close_current_popup()
else: else:
tool_name = self._ask_tool_data.get("tool", "unknown") tool_name = self._ask_tool_data.get("tool", "unknown")
@@ -2000,7 +2011,7 @@ class App:
self._is_script_blinking = True self._is_script_blinking = True
self._script_blink_start_time = time.time() self._script_blink_start_time = time.time()
try: try:
imgui.set_window_focus("Last Script Output") imgui.set_window_focus("Last Script Output") # type: ignore[call-arg]
except Exception: except Exception:
pass pass
if self._is_script_blinking: if self._is_script_blinking:
@@ -2013,7 +2024,8 @@ class App:
imgui.push_style_color(imgui.Col_.frame_bg, vec4(0, 100, 255, alpha)) imgui.push_style_color(imgui.Col_.frame_bg, vec4(0, 100, 255, alpha))
imgui.push_style_color(imgui.Col_.child_bg, vec4(0, 100, 255, alpha)) imgui.push_style_color(imgui.Col_.child_bg, vec4(0, 100, 255, alpha))
imgui.set_next_window_size(imgui.ImVec2(800, 600), imgui.Cond_.first_use_ever) imgui.set_next_window_size(imgui.ImVec2(800, 600), imgui.Cond_.first_use_ever)
expanded, self.show_script_output = imgui.begin("Last Script Output", self.show_script_output) expanded, opened = imgui.begin("Last Script Output", self.show_script_output)
self.show_script_output = bool(opened)
if expanded: if expanded:
imgui.text("Script:") imgui.text("Script:")
imgui.same_line() imgui.same_line()
@@ -2043,7 +2055,8 @@ class App:
imgui.end() imgui.end()
if self.show_text_viewer: if self.show_text_viewer:
imgui.set_next_window_size(imgui.ImVec2(900, 700), imgui.Cond_.first_use_ever) imgui.set_next_window_size(imgui.ImVec2(900, 700), imgui.Cond_.first_use_ever)
expanded, self.show_text_viewer = imgui.begin(f"Text Viewer - {self.text_viewer_title}", self.show_text_viewer) expanded, opened = imgui.begin(f"Text Viewer - {self.text_viewer_title}", self.show_text_viewer)
self.show_text_viewer = bool(opened)
if expanded: if expanded:
if self.ui_word_wrap: if self.ui_word_wrap:
imgui.begin_child("tv_wrap", imgui.ImVec2(-1, -1), False) imgui.begin_child("tv_wrap", imgui.ImVec2(-1, -1), False)
@@ -2153,7 +2166,7 @@ class App:
self._cb_plan_epic() self._cb_plan_epic()
def _cb_plan_epic(self) -> None: def _cb_plan_epic(self) -> None:
def _bg_task(): def _bg_task() -> None:
try: try:
self.ai_status = "Planning Epic (Tier 1)..." self.ai_status = "Planning Epic (Tier 1)..."
history = orchestrator_pm.get_track_history_summary() history = orchestrator_pm.get_track_history_summary()
@@ -2166,7 +2179,7 @@ class App:
_t1_resp = [e for e in _t1_new if e.get("direction") == "IN" and e.get("kind") == "response"] _t1_resp = [e for e in _t1_new if e.get("direction") == "IN" and e.get("kind") == "response"]
_t1_in = sum(e.get("payload", {}).get("usage", {}).get("input_tokens", 0) for e in _t1_resp) _t1_in = sum(e.get("payload", {}).get("usage", {}).get("input_tokens", 0) for e in _t1_resp)
_t1_out = sum(e.get("payload", {}).get("usage", {}).get("output_tokens", 0) for e in _t1_resp) _t1_out = sum(e.get("payload", {}).get("usage", {}).get("output_tokens", 0) for e in _t1_resp)
def _push_t1_usage(i, o): def _push_t1_usage(i: int, o: int) -> None:
self.mma_tier_usage["Tier 1"]["input"] += i self.mma_tier_usage["Tier 1"]["input"] += i
self.mma_tier_usage["Tier 1"]["output"] += o self.mma_tier_usage["Tier 1"]["output"] += o
with self._pending_gui_tasks_lock: with self._pending_gui_tasks_lock:
@@ -2194,7 +2207,7 @@ class App:
def _cb_accept_tracks(self) -> None: def _cb_accept_tracks(self) -> None:
self._show_track_proposal_modal = False self._show_track_proposal_modal = False
def _bg_task(): def _bg_task() -> None:
# Generate skeletons once # Generate skeletons once
self.ai_status = "Phase 2: Generating skeletons for all tracks..." self.ai_status = "Phase 2: Generating skeletons for all tracks..."
parser = ASTParser(language="python") parser = ASTParser(language="python")
@@ -2374,7 +2387,8 @@ class App:
imgui.end_popup() imgui.end_popup()
def _render_log_management(self) -> None: def _render_log_management(self) -> None:
exp, self.show_windows["Log Management"] = imgui.begin("Log Management", self.show_windows["Log Management"]) exp, opened = imgui.begin("Log Management", self.show_windows["Log Management"])
self.show_windows["Log Management"] = bool(opened)
if not exp: if not exp:
imgui.end() imgui.end()
return return
@@ -2413,19 +2427,19 @@ class App:
if imgui.button(f"Unstar##{session_id}"): if imgui.button(f"Unstar##{session_id}"):
registry.update_session_metadata( registry.update_session_metadata(
session_id, session_id,
message_count=metadata.get("message_count"), message_count=int(metadata.get("message_count") or 0),
errors=metadata.get("errors"), errors=int(metadata.get("errors") or 0),
size_kb=metadata.get("size_kb"), size_kb=int(metadata.get("size_kb") or 0),
whitelisted=False, whitelisted=False,
reason=metadata.get("reason") reason=str(metadata.get("reason") or "")
) )
else: else:
if imgui.button(f"Star##{session_id}"): if imgui.button(f"Star##{session_id}"):
registry.update_session_metadata( registry.update_session_metadata(
session_id, session_id,
message_count=metadata.get("message_count"), message_count=int(metadata.get("message_count") or 0),
errors=metadata.get("errors"), errors=int(metadata.get("errors") or 0),
size_kb=metadata.get("size_kb"), size_kb=int(metadata.get("size_kb") or 0),
whitelisted=True, whitelisted=True,
reason="Manually whitelisted" reason="Manually whitelisted"
) )
@@ -2867,7 +2881,7 @@ class App:
self._is_blinking = True self._is_blinking = True
self._blink_start_time = time.time() self._blink_start_time = time.time()
try: try:
imgui.set_window_focus("Response") imgui.set_window_focus("Response") # type: ignore[call-arg]
except: except:
pass pass
is_blinking = False is_blinking = False
@@ -3131,7 +3145,7 @@ class App:
imgui.pop_style_color() imgui.pop_style_color()
imgui.table_next_column() imgui.table_next_column()
if imgui.button(f"Load##{track.get('id')}"): if imgui.button(f"Load##{track.get('id')}"):
self._cb_load_track(track.get("id")) self._cb_load_track(str(track.get("id") or ""))
imgui.end_table() imgui.end_table()
# 1b. New Track Form # 1b. New Track Form
@@ -3248,14 +3262,14 @@ class App:
# 4. Task DAG Visualizer # 4. Task DAG Visualizer
imgui.text("Task DAG") imgui.text("Task DAG")
if self.active_track: if self.active_track:
tickets_by_id = {t.get('id'): t for t in self.active_tickets} tickets_by_id = {str(t.get('id') or ''): t for t in self.active_tickets}
all_ids = set(tickets_by_id.keys()) all_ids = set(tickets_by_id.keys())
# Build children map # Build children map
children_map = {} children_map: dict[str, list[str]] = {}
for t in self.active_tickets: for t in self.active_tickets:
for dep in t.get('depends_on', []): for dep in t.get('depends_on', []):
if dep not in children_map: children_map[dep] = [] if dep not in children_map: children_map[dep] = []
children_map[dep].append(t.get('id')) children_map[dep].append(str(t.get('id') or ''))
# Roots are those whose depends_on elements are NOT in all_ids # Roots are those whose depends_on elements are NOT in all_ids
roots = [] roots = []
for t in self.active_tickets: for t in self.active_tickets:
@@ -3263,7 +3277,7 @@ class App:
has_local_dep = any(d in all_ids for d in deps) has_local_dep = any(d in all_ids for d in deps)
if not has_local_dep: if not has_local_dep:
roots.append(t) roots.append(t)
rendered = set() rendered: set[str] = set()
for root in roots: for root in roots:
self._render_ticket_dag_node(root, tickets_by_id, children_map, rendered) self._render_ticket_dag_node(root, tickets_by_id, children_map, rendered)
@@ -3341,7 +3355,7 @@ class App:
pass pass
imgui.end_child() imgui.end_child()
def _render_ticket_dag_node(self, ticket: Ticket, tickets_by_id: dict[str, Ticket], children_map: dict[str, list[str]], rendered: set[str]) -> None: def _render_ticket_dag_node(self, ticket: dict[str, Any], tickets_by_id: dict[str, Any], children_map: dict[str, list[str]], rendered: set[str]) -> None:
tid = ticket.get('id', '??') tid = ticket.get('id', '??')
is_duplicate = tid in rendered is_duplicate = tid in rendered
if not is_duplicate: if not is_duplicate:
@@ -3553,7 +3567,9 @@ class App:
ch, self.ui_project_system_prompt = imgui.input_text_multiline("##psp", self.ui_project_system_prompt, imgui.ImVec2(-1, 100)) ch, self.ui_project_system_prompt = imgui.input_text_multiline("##psp", self.ui_project_system_prompt, imgui.ImVec2(-1, 100))
def _render_theme_panel(self) -> None: def _render_theme_panel(self) -> None:
exp, self.show_windows["Theme"] = imgui.begin("Theme", self.show_windows["Theme"]) exp, opened = imgui.begin("Theme", self.show_windows["Theme"])
self.show_windows["Theme"] = bool(opened)
if exp: if exp:
imgui.text("Palette") imgui.text("Palette")
cp = theme.get_current_palette() cp = theme.get_current_palette()

BIN
gui_2_mypy.txt Normal file

Binary file not shown.

BIN
gui_2_only_errors.txt Normal file

Binary file not shown.

View File

@@ -3,6 +3,7 @@ import tomli_w
import tomllib import tomllib
from datetime import datetime from datetime import datetime
import os import os
from typing import Any
class LogRegistry: class LogRegistry:
""" """
@@ -18,7 +19,7 @@ class LogRegistry:
registry_path (str): The file path to the TOML registry. registry_path (str): The file path to the TOML registry.
""" """
self.registry_path = registry_path self.registry_path = registry_path
self.data = {} self.data: dict[str, dict[str, Any]] = {}
self.load_registry() self.load_registry()
def load_registry(self) -> None: def load_registry(self) -> None:
@@ -56,16 +57,16 @@ class LogRegistry:
""" """
try: try:
# Convert datetime objects to ISO format strings for TOML serialization # Convert datetime objects to ISO format strings for TOML serialization
data_to_save = {} data_to_save: dict[str, Any] = {}
for session_id, session_data in self.data.items(): for session_id, session_data in self.data.items():
session_data_copy = {} session_data_copy: dict[str, Any] = {}
for k, v in session_data.items(): for k, v in session_data.items():
if v is None: if v is None:
continue continue
if k == 'start_time' and isinstance(v, datetime): if k == 'start_time' and isinstance(v, datetime):
session_data_copy[k] = v.isoformat() session_data_copy[k] = v.isoformat()
elif k == 'metadata' and isinstance(v, dict): elif k == 'metadata' and isinstance(v, dict):
metadata_copy = {} metadata_copy: dict[str, Any] = {}
for mk, mv in v.items(): for mk, mv in v.items():
if mv is None: if mv is None:
continue continue
@@ -125,11 +126,13 @@ class LogRegistry:
if self.data[session_id].get('metadata') is None: if self.data[session_id].get('metadata') is None:
self.data[session_id]['metadata'] = {} self.data[session_id]['metadata'] = {}
# Update fields # Update fields
self.data[session_id]['metadata']['message_count'] = message_count metadata = self.data[session_id].get('metadata')
self.data[session_id]['metadata']['errors'] = errors if isinstance(metadata, dict):
self.data[session_id]['metadata']['size_kb'] = size_kb metadata['message_count'] = message_count
self.data[session_id]['metadata']['whitelisted'] = whitelisted metadata['errors'] = errors
self.data[session_id]['metadata']['reason'] = reason metadata['size_kb'] = size_kb
metadata['whitelisted'] = whitelisted
metadata['reason'] = reason
# self.data[session_id]['metadata']['timestamp'] = datetime.utcnow() # Optionally add a timestamp # self.data[session_id]['metadata']['timestamp'] = datetime.utcnow() # Optionally add a timestamp
# Also update the top-level whitelisted flag if provided # Also update the top-level whitelisted flag if provided
if whitelisted is not None: if whitelisted is not None:
@@ -150,7 +153,7 @@ class LogRegistry:
if session_data is None: if session_data is None:
return False # Non-existent sessions are not whitelisted return False # Non-existent sessions are not whitelisted
# Check the top-level 'whitelisted' flag. If it's not set or False, it's not whitelisted. # Check the top-level 'whitelisted' flag. If it's not set or False, it's not whitelisted.
return session_data.get('whitelisted', False) return bool(session_data.get('whitelisted', False))
def update_auto_whitelist_status(self, session_id: str) -> None: def update_auto_whitelist_status(self, session_id: str) -> None:
""" """
@@ -165,14 +168,14 @@ class LogRegistry:
return return
session_data = self.data[session_id] session_data = self.data[session_id]
session_path = session_data.get('path') session_path = session_data.get('path')
if not session_path or not os.path.isdir(session_path): if not session_path or not os.path.isdir(str(session_path)):
return return
total_size_bytes = 0 total_size_bytes = 0
message_count = 0 message_count = 0
found_keywords = [] found_keywords = []
keywords_to_check = ['ERROR', 'WARNING', 'EXCEPTION'] keywords_to_check = ['ERROR', 'WARNING', 'EXCEPTION']
try: try:
for entry in os.scandir(session_path): for entry in os.scandir(str(session_path)):
if entry.is_file(): if entry.is_file():
size = entry.stat().st_size size = entry.stat().st_size
total_size_bytes += size total_size_bytes += size
@@ -210,7 +213,7 @@ class LogRegistry:
reason=reason reason=reason
) )
def get_old_non_whitelisted_sessions(self, cutoff_datetime: datetime) -> list[dict]: def get_old_non_whitelisted_sessions(self, cutoff_datetime: datetime) -> list[dict[str, Any]]:
""" """
Retrieves a list of sessions that are older than a specific cutoff time Retrieves a list of sessions that are older than a specific cutoff time
and are not marked as whitelisted. and are not marked as whitelisted.
@@ -240,3 +243,4 @@ class LogRegistry:
'start_time': start_time_raw 'start_time': start_time_raw
}) })
return old_sessions return old_sessions

View File

@@ -31,8 +31,10 @@ so the AI doesn't wander outside the project workspace.
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import Optional, Callable, Any from typing import Optional, Callable, Any, cast
import os import os
import ast
import subprocess
import summarize import summarize
import outline_tool import outline_tool
import urllib.request import urllib.request
@@ -151,7 +153,7 @@ def _resolve_and_check(raw_path: str) -> tuple[Path | None, str]:
def read_file(path: str) -> str: def read_file(path: str) -> str:
"""Return the UTF-8 content of a file, or an error string.""" """Return the UTF-8 content of a file, or an error string."""
p, err = _resolve_and_check(path) p, err = _resolve_and_check(path)
if err: if err or p is None:
return err return err
if not p.exists(): if not p.exists():
return f"ERROR: file not found: {path}" return f"ERROR: file not found: {path}"
@@ -165,7 +167,7 @@ def read_file(path: str) -> str:
def list_directory(path: str) -> str: def list_directory(path: str) -> str:
"""List entries in a directory. Returns a compact text table.""" """List entries in a directory. Returns a compact text table."""
p, err = _resolve_and_check(path) p, err = _resolve_and_check(path)
if err: if err or p is None:
return err return err
if not p.exists(): if not p.exists():
return f"ERROR: path not found: {path}" return f"ERROR: path not found: {path}"
@@ -195,7 +197,7 @@ def search_files(path: str, pattern: str) -> str:
pattern examples: '*.py', '**/*.toml', 'src/**/*.rs' pattern examples: '*.py', '**/*.toml', 'src/**/*.rs'
""" """
p, err = _resolve_and_check(path) p, err = _resolve_and_check(path)
if err: if err or p is None:
return err return err
if not p.is_dir(): if not p.is_dir():
return f"ERROR: not a directory: {path}" return f"ERROR: not a directory: {path}"
@@ -226,7 +228,7 @@ def get_file_summary(path: str) -> str:
For .toml: table keys. For .md: headings. Others: line count + preview. For .toml: table keys. For .md: headings. Others: line count + preview.
""" """
p, err = _resolve_and_check(path) p, err = _resolve_and_check(path)
if err: if err or p is None:
return err return err
if not p.exists(): if not p.exists():
return f"ERROR: file not found: {path}" return f"ERROR: file not found: {path}"
@@ -245,6 +247,7 @@ def py_get_skeleton(path: str) -> str:
p, err = _resolve_and_check(path) p, err = _resolve_and_check(path)
if err: if err:
return err return err
assert p is not None
if not p.exists(): if not p.exists():
return f"ERROR: file not found: {path}" return f"ERROR: file not found: {path}"
if not p.is_file() or p.suffix != ".py": if not p.is_file() or p.suffix != ".py":
@@ -264,6 +267,7 @@ def py_get_code_outline(path: str) -> str:
p, err = _resolve_and_check(path) p, err = _resolve_and_check(path)
if err: if err:
return err return err
assert p is not None
if not p.exists(): if not p.exists():
return f"ERROR: file not found: {path}" return f"ERROR: file not found: {path}"
if not p.is_file(): if not p.is_file():
@@ -279,12 +283,13 @@ def get_file_slice(path: str, start_line: int, end_line: int) -> str:
p, err = _resolve_and_check(path) p, err = _resolve_and_check(path)
if err: if err:
return err return err
assert p is not None
if not p.exists(): if not p.exists():
return f"ERROR: file not found: {path}" return f"ERROR: file not found: {path}"
try: try:
lines = p.read_text(encoding="utf-8").splitlines(keepends=True) lines = p.read_text(encoding="utf-8").splitlines(keepends=True)
start_idx = int(start_line) - 1 start_idx = start_line - 1
end_idx = int(end_line) end_idx = end_line
return "".join(lines[start_idx:end_idx]) return "".join(lines[start_idx:end_idx])
except Exception as e: except Exception as e:
return f"ERROR reading slice from '{path}': {e}" return f"ERROR reading slice from '{path}': {e}"
@@ -294,12 +299,13 @@ def set_file_slice(path: str, start_line: int, end_line: int, new_content: str)
p, err = _resolve_and_check(path) p, err = _resolve_and_check(path)
if err: if err:
return err return err
assert p is not None
if not p.exists(): if not p.exists():
return f"ERROR: file not found: {path}" return f"ERROR: file not found: {path}"
try: try:
lines = p.read_text(encoding="utf-8").splitlines(keepends=True) lines = p.read_text(encoding="utf-8").splitlines(keepends=True)
start_idx = int(start_line) - 1 start_idx = start_line - 1
end_idx = int(end_line) end_idx = end_line
if new_content and not new_content.endswith("\n"): if new_content and not new_content.endswith("\n"):
new_content += "\n" new_content += "\n"
new_lines = new_content.splitlines(keepends=True) if new_content else [] new_lines = new_content.splitlines(keepends=True) if new_content else []
@@ -309,12 +315,11 @@ def set_file_slice(path: str, start_line: int, end_line: int, new_content: str)
except Exception as e: except Exception as e:
return f"ERROR updating slice in '{path}': {e}" return f"ERROR updating slice in '{path}': {e}"
def _get_symbol_node(tree: Any, name: str) -> Any: def _get_symbol_node(tree: ast.AST, name: str) -> Optional[ast.AST]:
"""Helper to find an AST node by name (Class, Function, or Variable). Supports dot notation.""" """Helper to find an AST node by name (Class, Function, or Variable). Supports dot notation."""
import ast
parts = name.split(".") parts = name.split(".")
def find_in_scope(scope_node, target_name): def find_in_scope(scope_node: Any, target_name: str) -> Optional[ast.AST]:
# scope_node could be Module, ClassDef, or FunctionDef # scope_node could be Module, ClassDef, or FunctionDef
body = getattr(scope_node, "body", []) body = getattr(scope_node, "body", [])
for node in body: for node in body:
@@ -345,6 +350,7 @@ def py_get_definition(path: str, name: str) -> str:
p, err = _resolve_and_check(path) p, err = _resolve_and_check(path)
if err: if err:
return err return err
assert p is not None
if not p.exists(): if not p.exists():
return f"ERROR: file not found: {path}" return f"ERROR: file not found: {path}"
if not p.is_file(): if not p.is_file():
@@ -352,14 +358,13 @@ def py_get_definition(path: str, name: str) -> str:
if p.suffix != ".py": if p.suffix != ".py":
return f"ERROR: py_get_definition currently only supports .py files (unsupported: {p.suffix})" return f"ERROR: py_get_definition currently only supports .py files (unsupported: {p.suffix})"
try: try:
import ast
code = p.read_text(encoding="utf-8").lstrip(chr(0xFEFF)) code = p.read_text(encoding="utf-8").lstrip(chr(0xFEFF))
lines = code.splitlines(keepends=True) lines = code.splitlines(keepends=True)
tree = ast.parse(code) tree = ast.parse(code)
node = _get_symbol_node(tree, name) node = _get_symbol_node(tree, name)
if node: if node:
start = getattr(node, "lineno") - 1 start = cast(int, getattr(node, "lineno")) - 1
end = getattr(node, "end_lineno") end = cast(int, getattr(node, "end_lineno"))
return "".join(lines[start:end]) return "".join(lines[start:end])
return f"ERROR: could not find definition '{name}' in {path}" return f"ERROR: could not find definition '{name}' in {path}"
except Exception as e: except Exception as e:
@@ -370,17 +375,17 @@ def py_update_definition(path: str, name: str, new_content: str) -> str:
p, err = _resolve_and_check(path) p, err = _resolve_and_check(path)
if err: if err:
return err return err
assert p is not None
if not p.exists(): if not p.exists():
return f"ERROR: file not found: {path}" return f"ERROR: file not found: {path}"
try: try:
import ast
code = p.read_text(encoding="utf-8").lstrip(chr(0xFEFF)) code = p.read_text(encoding="utf-8").lstrip(chr(0xFEFF))
tree = ast.parse(code) tree = ast.parse(code)
node = _get_symbol_node(tree, name) node = _get_symbol_node(tree, name)
if not node: if not node:
return f"ERROR: could not find definition '{name}' in {path}" return f"ERROR: could not find definition '{name}' in {path}"
start = getattr(node, "lineno") start = cast(int, getattr(node, "lineno"))
end = getattr(node, "end_lineno") end = cast(int, getattr(node, "end_lineno"))
return set_file_slice(path, start, end, new_content) return set_file_slice(path, start, end, new_content)
except Exception as e: except Exception as e:
return f"ERROR updating definition '{name}' in '{path}': {e}" return f"ERROR updating definition '{name}' in '{path}': {e}"
@@ -390,17 +395,17 @@ def py_get_signature(path: str, name: str) -> str:
p, err = _resolve_and_check(path) p, err = _resolve_and_check(path)
if err: if err:
return err return err
assert p is not None
if not p.exists(): if not p.exists():
return f"ERROR: file not found: {path}" return f"ERROR: file not found: {path}"
try: try:
import ast
code = p.read_text(encoding="utf-8").lstrip(chr(0xFEFF)) code = p.read_text(encoding="utf-8").lstrip(chr(0xFEFF))
lines = code.splitlines(keepends=True) lines = code.splitlines(keepends=True)
tree = ast.parse(code) tree = ast.parse(code)
node = _get_symbol_node(tree, name) node = _get_symbol_node(tree, name)
if not node or not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): if not node or not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
return f"ERROR: could not find function/method '{name}' in {path}" return f"ERROR: could not find function/method '{name}' in {path}"
start = getattr(node, "lineno") - 1 start = node.lineno - 1
body_start = node.body[0].lineno - 1 body_start = node.body[0].lineno - 1
sig_lines = lines[start:body_start] sig_lines = lines[start:body_start]
sig = "".join(sig_lines).strip() sig = "".join(sig_lines).strip()
@@ -420,17 +425,17 @@ def py_set_signature(path: str, name: str, new_signature: str) -> str:
p, err = _resolve_and_check(path) p, err = _resolve_and_check(path)
if err: if err:
return err return err
assert p is not None
if not p.exists(): if not p.exists():
return f"ERROR: file not found: {path}" return f"ERROR: file not found: {path}"
try: try:
import ast
code = p.read_text(encoding="utf-8").lstrip(chr(0xFEFF)) code = p.read_text(encoding="utf-8").lstrip(chr(0xFEFF))
code.splitlines(keepends=True) code.splitlines(keepends=True)
tree = ast.parse(code) tree = ast.parse(code)
node = _get_symbol_node(tree, name) node = _get_symbol_node(tree, name)
if not node or not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): if not node or not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
return f"ERROR: could not find function/method '{name}' in {path}" return f"ERROR: could not find function/method '{name}' in {path}"
start = getattr(node, "lineno") start = node.lineno
body_start_line = node.body[0].lineno body_start_line = node.body[0].lineno
# We replace from start until body_start_line - 1 # We replace from start until body_start_line - 1
# But we must be careful about comments/docstrings between sig and body # But we must be careful about comments/docstrings between sig and body
@@ -445,10 +450,10 @@ def py_get_class_summary(path: str, name: str) -> str:
p, err = _resolve_and_check(path) p, err = _resolve_and_check(path)
if err: if err:
return err return err
assert p is not None
if not p.exists(): if not p.exists():
return f"ERROR: file not found: {path}" return f"ERROR: file not found: {path}"
try: try:
import ast
code = p.read_text(encoding="utf-8").lstrip(chr(0xFEFF)) code = p.read_text(encoding="utf-8").lstrip(chr(0xFEFF))
tree = ast.parse(code) tree = ast.parse(code)
node = _get_symbol_node(tree, name) node = _get_symbol_node(tree, name)
@@ -461,7 +466,7 @@ def py_get_class_summary(path: str, name: str) -> str:
summary.append(f" Docstring: {doc}") summary.append(f" Docstring: {doc}")
for body_node in node.body: for body_node in node.body:
if isinstance(body_node, (ast.FunctionDef, ast.AsyncFunctionDef)): if isinstance(body_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
start = getattr(body_node, "lineno") - 1 start = body_node.lineno - 1
body_start = body_node.body[0].lineno - 1 body_start = body_node.body[0].lineno - 1
sig = "".join(lines[start:body_start]).strip() sig = "".join(lines[start:body_start]).strip()
summary.append(f" - {sig}") summary.append(f" - {sig}")
@@ -474,18 +479,18 @@ def py_get_var_declaration(path: str, name: str) -> str:
p, err = _resolve_and_check(path) p, err = _resolve_and_check(path)
if err: if err:
return err return err
assert p is not None
if not p.is_file() or p.suffix != ".py": if not p.is_file() or p.suffix != ".py":
return f"ERROR: not a python file: {path}" return f"ERROR: not a python file: {path}"
try: try:
import ast
code = p.read_text(encoding="utf-8").lstrip(chr(0xFEFF)) code = p.read_text(encoding="utf-8").lstrip(chr(0xFEFF))
lines = code.splitlines(keepends=True) lines = code.splitlines(keepends=True)
tree = ast.parse(code) tree = ast.parse(code)
node = _get_symbol_node(tree, name) node = _get_symbol_node(tree, name)
if not node or not isinstance(node, (ast.Assign, ast.AnnAssign)): if not node or not isinstance(node, (ast.Assign, ast.AnnAssign)):
return f"ERROR: could not find variable '{name}' in {path}" return f"ERROR: could not find variable '{name}' in {path}"
start = getattr(node, "lineno") - 1 start = cast(int, getattr(node, "lineno")) - 1
end = getattr(node, "end_lineno") end = cast(int, getattr(node, "end_lineno"))
return "".join(lines[start:end]) return "".join(lines[start:end])
except Exception as e: except Exception as e:
return f"ERROR retrieving variable '{name}' from '{path}': {e}" return f"ERROR retrieving variable '{name}' from '{path}': {e}"
@@ -495,17 +500,17 @@ def py_set_var_declaration(path: str, name: str, new_declaration: str) -> str:
p, err = _resolve_and_check(path) p, err = _resolve_and_check(path)
if err: if err:
return err return err
assert p is not None
if not p.is_file() or p.suffix != ".py": if not p.is_file() or p.suffix != ".py":
return f"ERROR: not a python file: {path}" return f"ERROR: not a python file: {path}"
try: try:
import ast
code = p.read_text(encoding="utf-8").lstrip(chr(0xFEFF)) code = p.read_text(encoding="utf-8").lstrip(chr(0xFEFF))
tree = ast.parse(code) tree = ast.parse(code)
node = _get_symbol_node(tree, name) node = _get_symbol_node(tree, name)
if not node or not isinstance(node, (ast.Assign, ast.AnnAssign)): if not node or not isinstance(node, (ast.Assign, ast.AnnAssign)):
return f"ERROR: could not find variable '{name}' in {path}" return f"ERROR: could not find variable '{name}' in {path}"
start = getattr(node, "lineno") start = cast(int, getattr(node, "lineno"))
end = getattr(node, "end_lineno") end = cast(int, getattr(node, "end_lineno"))
return set_file_slice(path, start, end, new_declaration) return set_file_slice(path, start, end, new_declaration)
except Exception as e: except Exception as e:
return f"ERROR updating variable '{name}' in '{path}': {e}" return f"ERROR updating variable '{name}' in '{path}': {e}"
@@ -516,10 +521,10 @@ def get_git_diff(path: str, base_rev: str = "HEAD", head_rev: str = "") -> str:
base_rev: The base revision (default: HEAD) base_rev: The base revision (default: HEAD)
head_rev: The head revision (optional) head_rev: The head revision (optional)
""" """
import subprocess
p, err = _resolve_and_check(path) p, err = _resolve_and_check(path)
if err: if err:
return err return err
assert p is not None
cmd = ["git", "diff", base_rev] cmd = ["git", "diff", base_rev]
if head_rev: if head_rev:
cmd.append(head_rev) cmd.append(head_rev)
@@ -536,12 +541,13 @@ def py_find_usages(path: str, name: str) -> str:
"""Finds exact string matches of a symbol in a given file or directory.""" """Finds exact string matches of a symbol in a given file or directory."""
p, err = _resolve_and_check(path) p, err = _resolve_and_check(path)
if err: return err if err: return err
assert p is not None
try: try:
import re import re
pattern = re.compile(r"\b" + re.escape(name) + r"\b") pattern = re.compile(r"\b" + re.escape(name) + r"\b")
results = [] results = []
def _search_file(fp): def _search_file(fp: Path) -> None:
if fp.name == "history.toml" or fp.name.endswith("_history.toml"): return if fp.name == "history.toml" or fp.name.endswith("_history.toml"): return
if not _is_allowed(fp): return if not _is_allowed(fp): return
try: try:
@@ -573,9 +579,9 @@ def py_get_imports(path: str) -> str:
"""Parses a file's AST and returns a strict list of its dependencies.""" """Parses a file's AST and returns a strict list of its dependencies."""
p, err = _resolve_and_check(path) p, err = _resolve_and_check(path)
if err: return err if err: return err
assert p is not None
if not p.is_file() or p.suffix != ".py": return f"ERROR: not a python file: {path}" if not p.is_file() or p.suffix != ".py": return f"ERROR: not a python file: {path}"
try: try:
import ast
code = p.read_text(encoding="utf-8") code = p.read_text(encoding="utf-8")
tree = ast.parse(code) tree = ast.parse(code)
imports = [] imports = []
@@ -596,9 +602,9 @@ def py_check_syntax(path: str) -> str:
"""Runs a quick syntax check on a Python file.""" """Runs a quick syntax check on a Python file."""
p, err = _resolve_and_check(path) p, err = _resolve_and_check(path)
if err: return err if err: return err
assert p is not None
if not p.is_file() or p.suffix != ".py": return f"ERROR: not a python file: {path}" if not p.is_file() or p.suffix != ".py": return f"ERROR: not a python file: {path}"
try: try:
import ast
code = p.read_text(encoding="utf-8") code = p.read_text(encoding="utf-8")
ast.parse(code) ast.parse(code)
return f"Syntax OK: {path}" return f"Syntax OK: {path}"
@@ -611,10 +617,10 @@ def py_get_hierarchy(path: str, class_name: str) -> str:
"""Scans the project to find subclasses of a given class.""" """Scans the project to find subclasses of a given class."""
p, err = _resolve_and_check(path) p, err = _resolve_and_check(path)
if err: return err if err: return err
import ast assert p is not None
subclasses = [] subclasses: list[str] = []
def _search_file(fp): def _search_file(fp: Path) -> None:
if not _is_allowed(fp): return if not _is_allowed(fp): return
try: try:
code = fp.read_text(encoding="utf-8") code = fp.read_text(encoding="utf-8")
@@ -625,6 +631,7 @@ def py_get_hierarchy(path: str, class_name: str) -> str:
if isinstance(base, ast.Name) and base.id == class_name: if isinstance(base, ast.Name) and base.id == class_name:
subclasses.append(f"{fp.name}: class {node.name}({class_name})") subclasses.append(f"{fp.name}: class {node.name}({class_name})")
elif isinstance(base, ast.Attribute) and base.attr == class_name: elif isinstance(base, ast.Attribute) and base.attr == class_name:
if isinstance(base.value, ast.Name):
subclasses.append(f"{fp.name}: class {node.name}({base.value.id}.{class_name})") subclasses.append(f"{fp.name}: class {node.name}({base.value.id}.{class_name})")
except Exception: except Exception:
pass pass
@@ -647,9 +654,9 @@ def py_get_docstring(path: str, name: str) -> str:
"""Extracts the docstring for a specific module, class, or function.""" """Extracts the docstring for a specific module, class, or function."""
p, err = _resolve_and_check(path) p, err = _resolve_and_check(path)
if err: return err if err: return err
assert p is not None
if not p.is_file() or p.suffix != ".py": return f"ERROR: not a python file: {path}" if not p.is_file() or p.suffix != ".py": return f"ERROR: not a python file: {path}"
try: try:
import ast
code = p.read_text(encoding="utf-8") code = p.read_text(encoding="utf-8")
tree = ast.parse(code) tree = ast.parse(code)
if not name or name == "module": if not name or name == "module":
@@ -657,8 +664,10 @@ def py_get_docstring(path: str, name: str) -> str:
return doc if doc else "No module docstring found." return doc if doc else "No module docstring found."
node = _get_symbol_node(tree, name) node = _get_symbol_node(tree, name)
if not node: return f"ERROR: could not find symbol '{name}' in {path}" if not node: return f"ERROR: could not find symbol '{name}' in {path}"
if isinstance(node, (ast.AsyncFunctionDef, ast.FunctionDef, ast.ClassDef, ast.Module)):
doc = ast.get_docstring(node) doc = ast.get_docstring(node)
return doc if doc else f"No docstring found for '{name}'." return doc if doc else f"No docstring found for '{name}'."
return f"No docstring found for '{name}'."
except Exception as e: except Exception as e:
return f"ERROR getting docstring for '{name}': {e}" return f"ERROR getting docstring for '{name}': {e}"
@@ -666,12 +675,13 @@ def get_tree(path: str, max_depth: int = 2) -> str:
"""Returns a directory structure up to a max depth.""" """Returns a directory structure up to a max depth."""
p, err = _resolve_and_check(path) p, err = _resolve_and_check(path)
if err: return err if err: return err
assert p is not None
if not p.is_dir(): return f"ERROR: not a directory: {path}" if not p.is_dir(): return f"ERROR: not a directory: {path}"
try: try:
max_depth = int(max_depth) m_depth = max_depth
def _build_tree(dir_path, current_depth, prefix=""): def _build_tree(dir_path: Path, current_depth: int, prefix: str = "") -> list[str]:
if current_depth > max_depth: return [] if current_depth > m_depth: return []
lines = [] lines = []
try: try:
entries = sorted(dir_path.iterdir(), key=lambda e: (e.is_file(), e.name.lower())) entries = sorted(dir_path.iterdir(), key=lambda e: (e.is_file(), e.name.lower()))
@@ -706,11 +716,11 @@ class _DDGParser(HTMLParser):
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
attrs_dict = dict(attrs) attrs_dict = dict(attrs)
if tag == "a" and "result__url" in attrs_dict.get("class", ""): if tag == "a" and "result__url" in cast(str, attrs_dict.get("class", "")):
self.current_link = attrs_dict.get("href", "") self.current_link = cast(str, attrs_dict.get("href", ""))
if tag == "a" and "result__snippet" in attrs_dict.get("class", ""): if tag == "a" and "result__snippet" in cast(str, attrs_dict.get("class", "")):
self.in_snippet = True self.in_snippet = True
if tag == "h2" and "result__title" in attrs_dict.get("class", ""): if tag == "h2" and "result__title" in cast(str, attrs_dict.get("class", "")):
self.in_title = True self.in_title = True
def handle_endtag(self, tag: str) -> None: def handle_endtag(self, tag: str) -> None:
@@ -777,7 +787,9 @@ def fetch_url(url: str) -> str:
"""Fetch a URL and return its text content (stripped of HTML tags).""" """Fetch a URL and return its text content (stripped of HTML tags)."""
# Correct duckduckgo redirect links if passed # Correct duckduckgo redirect links if passed
if url.startswith("//duckduckgo.com/l/?uddg="): if url.startswith("//duckduckgo.com/l/?uddg="):
url = urllib.parse.unquote(url.split("uddg=")[1].split("&")[0]) split_uddg = url.split("uddg=")
if len(split_uddg) > 1:
url = urllib.parse.unquote(split_uddg[1].split("&")[0])
if not url.startswith("http"): if not url.startswith("http"):
url = "https://" + url url = "https://" + url
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36'}) req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36'})
@@ -819,13 +831,13 @@ def dispatch(tool_name: str, tool_input: dict[str, Any]) -> str:
Dispatch an MCP tool call by name. Returns the result as a string. Dispatch an MCP tool call by name. Returns the result as a string.
""" """
# Handle aliases # Handle aliases
path = tool_input.get("path", tool_input.get("file_path", tool_input.get("dir_path", ""))) path = str(tool_input.get("path", tool_input.get("file_path", tool_input.get("dir_path", ""))))
if tool_name == "read_file": if tool_name == "read_file":
return read_file(path) return read_file(path)
if tool_name == "list_directory": if tool_name == "list_directory":
return list_directory(path) return list_directory(path)
if tool_name == "search_files": if tool_name == "search_files":
return search_files(path, tool_input.get("pattern", "*")) return search_files(path, str(tool_input.get("pattern", "*")))
if tool_name == "get_file_summary": if tool_name == "get_file_summary":
return get_file_summary(path) return get_file_summary(path)
if tool_name == "py_get_skeleton": if tool_name == "py_get_skeleton":
@@ -833,47 +845,47 @@ def dispatch(tool_name: str, tool_input: dict[str, Any]) -> str:
if tool_name == "py_get_code_outline": if tool_name == "py_get_code_outline":
return py_get_code_outline(path) return py_get_code_outline(path)
if tool_name == "py_get_definition": if tool_name == "py_get_definition":
return py_get_definition(path, tool_input.get("name", "")) return py_get_definition(path, str(tool_input.get("name", "")))
if tool_name == "py_update_definition": if tool_name == "py_update_definition":
return py_update_definition(path, tool_input.get("name", ""), tool_input.get("new_content", "")) return py_update_definition(path, str(tool_input.get("name", "")), str(tool_input.get("new_content", "")))
if tool_name == "py_get_signature": if tool_name == "py_get_signature":
return py_get_signature(path, tool_input.get("name", "")) return py_get_signature(path, str(tool_input.get("name", "")))
if tool_name == "py_set_signature": if tool_name == "py_set_signature":
return py_set_signature(path, tool_input.get("name", ""), tool_input.get("new_signature", "")) return py_set_signature(path, str(tool_input.get("name", "")), str(tool_input.get("new_signature", "")))
if tool_name == "py_get_class_summary": if tool_name == "py_get_class_summary":
return py_get_class_summary(path, tool_input.get("name", "")) return py_get_class_summary(path, str(tool_input.get("name", "")))
if tool_name == "py_get_var_declaration": if tool_name == "py_get_var_declaration":
return py_get_var_declaration(path, tool_input.get("name", "")) return py_get_var_declaration(path, str(tool_input.get("name", "")))
if tool_name == "py_set_var_declaration": if tool_name == "py_set_var_declaration":
return py_set_var_declaration(path, tool_input.get("name", ""), tool_input.get("new_declaration", "")) return py_set_var_declaration(path, str(tool_input.get("name", "")), str(tool_input.get("new_declaration", "")))
if tool_name == "get_file_slice": if tool_name == "get_file_slice":
return get_file_slice(path, tool_input.get("start_line", 1), tool_input.get("end_line", 1)) return get_file_slice(path, int(tool_input.get("start_line", 1)), int(tool_input.get("end_line", 1)))
if tool_name == "set_file_slice": if tool_name == "set_file_slice":
return set_file_slice(path, tool_input.get("start_line", 1), tool_input.get("end_line", 1), tool_input.get("new_content", "")) return set_file_slice(path, int(tool_input.get("start_line", 1)), int(tool_input.get("end_line", 1)), str(tool_input.get("new_content", "")))
if tool_name == "get_git_diff": if tool_name == "get_git_diff":
return get_git_diff( return get_git_diff(
path, path,
tool_input.get("base_rev", "HEAD"), str(tool_input.get("base_rev", "HEAD")),
tool_input.get("head_rev", "") str(tool_input.get("head_rev", ""))
) )
if tool_name == "web_search": if tool_name == "web_search":
return web_search(tool_input.get("query", "")) return web_search(str(tool_input.get("query", "")))
if tool_name == "fetch_url": if tool_name == "fetch_url":
return fetch_url(tool_input.get("url", "")) return fetch_url(str(tool_input.get("url", "")))
if tool_name == "get_ui_performance": if tool_name == "get_ui_performance":
return get_ui_performance() return get_ui_performance()
if tool_name == "py_find_usages": if tool_name == "py_find_usages":
return py_find_usages(path, tool_input.get("name", "")) return py_find_usages(path, str(tool_input.get("name", "")))
if tool_name == "py_get_imports": if tool_name == "py_get_imports":
return py_get_imports(path) return py_get_imports(path)
if tool_name == "py_check_syntax": if tool_name == "py_check_syntax":
return py_check_syntax(path) return py_check_syntax(path)
if tool_name == "py_get_hierarchy": if tool_name == "py_get_hierarchy":
return py_get_hierarchy(path, tool_input.get("class_name", "")) return py_get_hierarchy(path, str(tool_input.get("class_name", "")))
if tool_name == "py_get_docstring": if tool_name == "py_get_docstring":
return py_get_docstring(path, tool_input.get("name", "")) return py_get_docstring(path, str(tool_input.get("name", "")))
if tool_name == "get_tree": if tool_name == "get_tree":
return get_tree(path, tool_input.get("max_depth", 2)) return get_tree(path, int(tool_input.get("max_depth", 2)))
return f"ERROR: unknown MCP tool '{tool_name}'" return f"ERROR: unknown MCP tool '{tool_name}'"
# ------------------------------------------------------------------ tool schema helpers # ------------------------------------------------------------------ tool schema helpers
# These are imported by ai_client.py to build provider-specific declarations. # These are imported by ai_client.py to build provider-specific declarations.

View File

@@ -103,9 +103,9 @@ class WorkerContext:
class Metadata: class Metadata:
id: str id: str
name: str name: str
status: str status: Optional[str] = None
created_at: datetime created_at: Optional[datetime] = None
updated_at: datetime updated_at: Optional[datetime] = None
def to_dict(self) -> Dict[str, Any]: def to_dict(self) -> Dict[str, Any]:
return { return {
@@ -121,9 +121,9 @@ class Metadata:
return cls( return cls(
id=data["id"], id=data["id"],
name=data["name"], name=data["name"],
status=data.get("status", "todo"), status=data.get("status"),
created_at=datetime.fromisoformat(data['created_at']) if data.get('created_at') else datetime.now(), created_at=datetime.fromisoformat(data['created_at']) if data.get('created_at') else None,
updated_at=datetime.fromisoformat(data['updated_at']) if data.get('updated_at') else datetime.now(), updated_at=datetime.fromisoformat(data['updated_at']) if data.get('updated_at') else None,
) )
@dataclass @dataclass

View File

@@ -13,13 +13,14 @@ class CodeOutliner:
return f"ERROR parsing code: {e}" return f"ERROR parsing code: {e}"
output = [] output = []
def get_docstring(node): def get_docstring(node: ast.AST) -> str | None:
if isinstance(node, (ast.AsyncFunctionDef, ast.FunctionDef, ast.ClassDef, ast.Module)):
doc = ast.get_docstring(node) doc = ast.get_docstring(node)
if doc: if doc:
return doc.splitlines()[0] return doc.splitlines()[0]
return None return None
def walk(node, indent=0): def walk(node: ast.AST, indent: int = 0) -> None:
if isinstance(node, ast.ClassDef): if isinstance(node, ast.ClassDef):
start_line = node.lineno start_line = node.lineno
end_line = getattr(node, "end_lineno", start_line) end_line = getattr(node, "end_lineno", start_line)

View File

@@ -2,38 +2,38 @@ from __future__ import annotations
import time import time
import psutil import psutil
import threading import threading
from typing import Any from typing import Any, Optional, Callable
class PerformanceMonitor: class PerformanceMonitor:
def __init__(self) -> None: def __init__(self) -> None:
self._start_time = None self._start_time: Optional[float] = None
self._last_frame_time = 0.0 self._last_frame_time: float = 0.0
self._fps = 0.0 self._fps: float = 0.0
self._last_calculated_fps = 0.0 self._last_calculated_fps: float = 0.0
self._frame_count = 0 self._frame_count: int = 0
self._total_frame_count = 0 self._total_frame_count: int = 0
self._fps_last_time = time.time() self._fps_last_time: float = time.time()
self._process = psutil.Process() self._process: psutil.Process = psutil.Process()
self._cpu_usage = 0.0 self._cpu_usage: float = 0.0
self._cpu_lock = threading.Lock() self._cpu_lock: threading.Lock = threading.Lock()
# Input lag tracking # Input lag tracking
self._last_input_time = None self._last_input_time: Optional[float] = None
self._input_lag_ms = 0.0 self._input_lag_ms: float = 0.0
# Alerts # Alerts
self.alert_callback = None self.alert_callback: Optional[Callable[[str], None]] = None
self.thresholds = { self.thresholds: dict[str, float] = {
'frame_time_ms': 33.3, # < 30 FPS 'frame_time_ms': 33.3, # < 30 FPS
'cpu_percent': 80.0, 'cpu_percent': 80.0,
'input_lag_ms': 100.0 'input_lag_ms': 100.0
} }
self._last_alert_time = 0 self._last_alert_time: float = 0.0
self._alert_cooldown = 30 # seconds self._alert_cooldown: int = 30 # seconds
# Detailed profiling # Detailed profiling
self._component_timings = {} self._component_timings: dict[str, float] = {}
self._comp_start = {} self._comp_start: dict[str, float] = {}
# Start CPU usage monitoring thread # Start CPU usage monitoring thread
self._stop_event = threading.Event() self._stop_event: threading.Event = threading.Event()
self._cpu_thread = threading.Thread(target=self._monitor_cpu, daemon=True) self._cpu_thread: threading.Thread = threading.Thread(target=self._monitor_cpu, daemon=True)
self._cpu_thread.start() self._cpu_thread.start()
def _monitor_cpu(self) -> None: def _monitor_cpu(self) -> None:
@@ -107,15 +107,13 @@ class PerformanceMonitor:
def get_metrics(self) -> dict[str, Any]: def get_metrics(self) -> dict[str, Any]:
with self._cpu_lock: with self._cpu_lock:
cpu_usage = self._cpu_usage cpu_usage = self._cpu_usage
metrics = { metrics: dict[str, Any] = {
'last_frame_time_ms': self._last_frame_time, 'last_frame_time_ms': self._last_frame_time,
'fps': self._last_calculated_fps, 'fps': self._last_calculated_fps,
'cpu_percent': cpu_usage, 'cpu_percent': cpu_usage,
'total_frames': self._total_frame_count, 'total_frames': self._total_frame_count,
'input_lag_ms': self._last_input_time if self._last_input_time else 0.0 # Wait, this should be the calculated lag 'input_lag_ms': self._input_lag_ms
} }
# Oops, fixed the input lag logic in previous turn, let's keep it consistent
metrics['input_lag_ms'] = self._input_lag_ms
# Add detailed timings # Add detailed timings
for name, elapsed in self._component_timings.items(): for name, elapsed in self._component_timings.items():
metrics[f'time_{name}_ms'] = elapsed metrics[f'time_{name}_ms'] = elapsed

View File

@@ -8,5 +8,5 @@ active = "main"
[discussions.main] [discussions.main]
git_commit = "" git_commit = ""
last_updated = "2026-03-04T00:55:41" last_updated = "2026-03-04T09:44:10"
history = [] history = []

View File

@@ -37,4 +37,28 @@ disallow_incomplete_defs = true
ignore_missing_imports = true ignore_missing_imports = true
explicit_package_bases = true explicit_package_bases = true
[tool.ruff]
# Target version
target-version = "py311"
exclude = [
"scripts/ai_style_formatter.py",
"scripts/temp_def.py",
]
[tool.ruff.lint]
# Enable standard rules
select = ["E", "F", "W"]
# Ignore style choices that conflict with project's compact style
ignore = [
"E701", # Multiple statements on one line (colon)
"E702", # Multiple statements on one line (semicolon)
"E402", # Module level import not at top of file
"E722", # Do not use bare `except`
"E501", # Line too long
"W291", # Trailing whitespace
"W293", # Blank line contains whitespace
]

View File

@@ -146,6 +146,7 @@ def log_tool_call(script: str, result: str, script_path: Optional[str]) -> Optio
ps1_path: Optional[Path] = _SCRIPTS_DIR / ps1_name ps1_path: Optional[Path] = _SCRIPTS_DIR / ps1_name
try: try:
if ps1_path:
ps1_path.write_text(script, encoding="utf-8") ps1_path.write_text(script, encoding="utf-8")
except Exception as exc: except Exception as exc:
ps1_path = None ps1_path = None

View File

@@ -14,7 +14,7 @@ def main():
if not client.wait_for_server(timeout=5): if not client.wait_for_server(timeout=5):
print("Hook server not found. Start GUI with --enable-test-hooks") print("Hook server not found. Start GUI with --enable-test-hooks")
return return
sim_agent = UserSimAgent(client) UserSimAgent(client)
# 1. Reset session to start clean # 1. Reset session to start clean
print("Resetting session...") print("Resetting session...")
client.click("btn_reset") client.click("btn_reset")

View File

@@ -26,7 +26,7 @@ context block that replaces full file contents in the initial <context> send.
import ast import ast
import re import re
from pathlib import Path from pathlib import Path
from typing import Callable from typing import Callable, Any
# ------------------------------------------------------------------ per-type extractors # ------------------------------------------------------------------ per-type extractors
@@ -160,7 +160,7 @@ def summarise_file(path: Path, content: str) -> str:
except Exception as e: except Exception as e:
return f"_Summariser error: {e}_" return f"_Summariser error: {e}_"
def summarise_items(file_items: list[dict]) -> list[dict]: def summarise_items(file_items: list[dict[str, Any]]) -> list[dict[str, Any]]:
""" """
Given a list of file_item dicts (as returned by aggregate.build_file_items), Given a list of file_item dicts (as returned by aggregate.build_file_items),
return a parallel list of dicts with an added `summary` key. return a parallel list of dicts with an added `summary` key.
@@ -178,7 +178,7 @@ def summarise_items(file_items: list[dict]) -> list[dict]:
result.append({**item, "summary": summary}) result.append({**item, "summary": summary})
return result return result
def build_summary_markdown(file_items: list[dict]) -> str: def build_summary_markdown(file_items: list[dict[str, Any]]) -> str:
""" """
Build a compact markdown string of file summaries, suitable for the Build a compact markdown string of file summaries, suitable for the
initial <context> block instead of full file contents. initial <context> block instead of full file contents.

View File

@@ -10,7 +10,7 @@ import datetime
import shutil import shutil
from pathlib import Path from pathlib import Path
from typing import Generator, Any from typing import Generator, Any
from unittest.mock import patch, MagicMock from unittest.mock import patch
# Ensure project root is in path for imports # Ensure project root is in path for imports
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
@@ -41,7 +41,7 @@ class VerificationLogger:
}) })
def finalize(self, title: str, status: str, result_msg: str) -> None: def finalize(self, title: str, status: str, result_msg: str) -> None:
elapsed = round(time.time() - self.start_time, 2) round(time.time() - self.start_time, 2)
log_file = self.logs_dir / f"{self.script_name}.txt" log_file = self.logs_dir / f"{self.script_name}.txt"
with open(log_file, "w", encoding="utf-8") as f: with open(log_file, "w", encoding="utf-8") as f:
f.write(f"[ Test: {self.test_name} ]\n") f.write(f"[ Test: {self.test_name} ]\n")

View File

@@ -60,7 +60,6 @@ def main() -> None:
return return
# 2. Check for multi-round tool triggers # 2. Check for multi-round tool triggers
is_resume_list = is_resume and 'list_directory' in prompt
is_resume_read = is_resume and 'read_file' in prompt is_resume_read = is_resume and 'read_file' in prompt
is_resume_powershell = is_resume and 'run_powershell' in prompt is_resume_powershell = is_resume and 'run_powershell' in prompt

View File

@@ -1,4 +1,3 @@
import pytest
import sys import sys
import os import os
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock

View File

@@ -1,4 +1,3 @@
import pytest
import sys import sys
import os import os
import ai_client import ai_client

View File

@@ -1,4 +1,3 @@
import pytest
from typing import Any from typing import Any
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import ai_client import ai_client

View File

@@ -1,7 +1,7 @@
import sys import sys
import os import os
from typing import Any from typing import Any
from unittest.mock import MagicMock, patch from unittest.mock import patch
# Ensure project root is in path for imports # Ensure project root is in path for imports
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))

View File

@@ -3,7 +3,6 @@ Tests for architecture_boundary_hardening_20260302 — Phase 2.
Tasks 2.1-2.4: MCP tool config exposure + MUTATING_TOOLS + HITL enforcement. Tasks 2.1-2.4: MCP tool config exposure + MUTATING_TOOLS + HITL enforcement.
""" """
import tomllib import tomllib
import pytest
from project_manager import default_project from project_manager import default_project
MUTATING_TOOLS = {"set_file_slice", "py_update_definition", "py_set_signature", "py_set_var_declaration"} MUTATING_TOOLS = {"set_file_slice", "py_update_definition", "py_set_signature", "py_set_var_declaration"}
@@ -101,9 +100,8 @@ def test_mutating_tools_excludes_read_tools():
def test_mutating_tool_triggers_pre_tool_callback(monkeypatch): def test_mutating_tool_triggers_pre_tool_callback(monkeypatch):
"""When a mutating tool is called and pre_tool_callback is set, it must be invoked.""" """When a mutating tool is called and pre_tool_callback is set, it must be invoked."""
import ai_client
import mcp_client import mcp_client
from unittest.mock import MagicMock, patch from unittest.mock import patch
callback_called = [] callback_called = []
def fake_callback(desc, base_dir, qa_cb): def fake_callback(desc, base_dir, qa_cb):
callback_called.append(desc) callback_called.append(desc)
@@ -113,12 +111,11 @@ def test_mutating_tool_triggers_pre_tool_callback(monkeypatch):
tool_name = "set_file_slice" tool_name = "set_file_slice"
args = {"path": "foo.py", "start_line": 1, "end_line": 2, "new_content": "x"} args = {"path": "foo.py", "start_line": 1, "end_line": 2, "new_content": "x"}
# Simulate the logic from all 4 provider dispatch blocks # Simulate the logic from all 4 provider dispatch blocks
out = ""
_res = fake_callback(f"# MCP MUTATING TOOL: {tool_name}", ".", None) _res = fake_callback(f"# MCP MUTATING TOOL: {tool_name}", ".", None)
if _res is None: if _res is None:
out = "USER REJECTED: tool execution cancelled" pass
else: else:
out = mcp_client.dispatch(tool_name, args) mcp_client.dispatch(tool_name, args)
assert len(callback_called) == 1, "pre_tool_callback must be called for mutating tools" assert len(callback_called) == 1, "pre_tool_callback must be called for mutating tools"
assert mock_dispatch.called assert mock_dispatch.called

View File

@@ -1,4 +1,3 @@
import pytest
from models import Ticket from models import Ticket
from dag_engine import TrackDAG, ExecutionEngine from dag_engine import TrackDAG, ExecutionEngine

View File

@@ -1,4 +1,3 @@
import pytest
from models import Ticket from models import Ticket
from dag_engine import TrackDAG, ExecutionEngine from dag_engine import TrackDAG, ExecutionEngine

View File

@@ -3,7 +3,6 @@ from unittest.mock import patch, MagicMock
import json import json
import sys import sys
import os import os
import subprocess
# Ensure the project root is in sys.path to resolve imports correctly # Ensure the project root is in sys.path to resolve imports correctly
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))

View File

@@ -68,7 +68,7 @@ def test_gemini_cli_parameter_resilience(live_gui: Any) -> None:
bridge_path_str = bridge_path.replace("\\", "/") bridge_path_str = bridge_path.replace("\\", "/")
else: else:
bridge_path_str = bridge_path bridge_path_str = bridge_path
with open(alias_tool_content := "tests/mock_alias_tool.py", "w") as f: with open("tests/mock_alias_tool.py", "w") as f:
f.write(f'''import sys, json, os, subprocess f.write(f'''import sys, json, os, subprocess
prompt = sys.stdin.read() prompt = sys.stdin.read()
if '"role": "tool"' in prompt: if '"role": "tool"' in prompt:

View File

@@ -1,6 +1,3 @@
from typing import Generator
import pytest
from unittest.mock import patch
from gui_2 import App from gui_2 import App
def test_gui2_hubs_exist_in_show_windows(app_instance: App) -> None: def test_gui2_hubs_exist_in_show_windows(app_instance: App) -> None:

View File

@@ -1,9 +1,6 @@
import pytest
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
from typing import Generator
from gui_2 import App from gui_2 import App
import ai_client import ai_client
from events import EventEmitter
def test_mcp_tool_call_is_dispatched(app_instance: App) -> None: def test_mcp_tool_call_is_dispatched(app_instance: App) -> None:
""" """

View File

@@ -1,5 +1,3 @@
import pytest
from unittest.mock import patch, MagicMock
import sys import sys
import os import os
from typing import Any from typing import Any
@@ -7,7 +5,6 @@ from typing import Any
# Ensure project root is in path for imports # Ensure project root is in path for imports
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from gui_2 import App
def test_diagnostics_panel_initialization(app_instance: Any) -> None: def test_diagnostics_panel_initialization(app_instance: Any) -> None:
assert "Diagnostics" in app_instance.show_windows assert "Diagnostics" in app_instance.show_windows

View File

@@ -1,16 +1,12 @@
import pytest
import sys import sys
import os import os
from unittest.mock import patch from unittest.mock import patch
from typing import Generator, Any
from gui_2 import App from gui_2 import App
import ai_client
# Ensure project root is in path # Ensure project root is in path
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
def test_gui_updates_on_event(app_instance: App) -> None: def test_gui_updates_on_event(app_instance: App) -> None:
mock_stats = {"percentage": 50.0, "current": 500, "limit": 1000}
app_instance.last_md = "mock_md" app_instance.last_md = "mock_md"
with patch.object(app_instance, '_refresh_api_metrics') as mock_refresh: with patch.object(app_instance, '_refresh_api_metrics') as mock_refresh:
# Simulate event (bypassing events.emit since _init_ai_and_hooks is mocked) # Simulate event (bypassing events.emit since _init_ai_and_hooks is mocked)

View File

@@ -1,10 +1,8 @@
import os import os
import json import json
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import patch
import pytest
from gui_2 import App
def test_track_proposal_editing(app_instance): def test_track_proposal_editing(app_instance):
# Setup some proposed tracks # Setup some proposed tracks

View File

@@ -1,5 +1,4 @@
import pytest import pytest
from unittest.mock import patch
from gui_2 import App from gui_2 import App
@pytest.mark.asyncio @pytest.mark.asyncio

View File

@@ -1,4 +1,3 @@
import pytest
from unittest.mock import patch from unittest.mock import patch
import sys import sys
import os import os
@@ -41,7 +40,7 @@ def test_performance_history_updates(app_instance: Any) -> None:
def test_gui_updates_on_event(app_instance: App) -> None: def test_gui_updates_on_event(app_instance: App) -> None:
mock_stats = {"percentage": 50.0, "current": 500, "limit": 1000} mock_stats = {"percentage": 50.0, "current": 500, "limit": 1000}
app_instance.last_md = "mock_md" app_instance.last_md = "mock_md"
with patch('ai_client.get_token_stats', return_value=mock_stats) as mock_get_stats: with patch('ai_client.get_token_stats', return_value=mock_stats):
app_instance._on_api_event(payload={"text": "test"}) app_instance._on_api_event(payload={"text": "test"})
app_instance._process_pending_gui_tasks() app_instance._process_pending_gui_tasks()
assert app_instance._token_stats["percentage"] == 50.0 assert app_instance._token_stats["percentage"] == 50.0

View File

@@ -6,7 +6,6 @@ from unittest.mock import patch
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from api_hook_client import ApiHookClient from api_hook_client import ApiHookClient
import gui_2
def test_hooks_enabled_via_cli(mock_app) -> None: def test_hooks_enabled_via_cli(mock_app) -> None:
with patch.object(sys, 'argv', ['gui_2.py', '--enable-test-hooks']): with patch.object(sys, 'argv', ['gui_2.py', '--enable-test-hooks']):

View File

@@ -2,11 +2,8 @@
Tests for mma_agent_focus_ux_20260302 — Phase 1: Tier Tagging at Emission. Tests for mma_agent_focus_ux_20260302 — Phase 1: Tier Tagging at Emission.
These tests are written RED-first: they fail before implementation. These tests are written RED-first: they fail before implementation.
""" """
from typing import Generator
import pytest import pytest
from unittest.mock import patch
import ai_client import ai_client
from gui_2 import App
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)

View File

@@ -1,10 +1,6 @@
""" """
Tests for mma_agent_focus_ux_20260302 — Phase 3: Focus Agent UI + Filter Logic. Tests for mma_agent_focus_ux_20260302 — Phase 3: Focus Agent UI + Filter Logic.
""" """
from typing import Generator
import pytest
from unittest.mock import patch
from gui_2 import App
def test_ui_focus_agent_state_var_exists(app_instance): def test_ui_focus_agent_state_var_exists(app_instance):

View File

@@ -1,4 +1,3 @@
import pytest
import json import json
from unittest.mock import patch from unittest.mock import patch
import time import time

View File

@@ -1,6 +1,4 @@
from typing import Generator from unittest.mock import patch
import pytest
from unittest.mock import patch, MagicMock
from gui_2 import App from gui_2 import App
def test_cb_ticket_retry(app_instance: App) -> None: def test_cb_ticket_retry(app_instance: App) -> None:

View File

@@ -1,4 +1,3 @@
import pytest
import sys import sys
import os import os
import hashlib import hashlib

View File

@@ -1,7 +1,4 @@
"""Tests for context & token visualization (Track: context_token_viz_20260301).""" """Tests for context & token visualization (Track: context_token_viz_20260301)."""
from typing import Generator
from unittest.mock import patch
import pytest
import ai_client import ai_client
from ai_client import _add_bleed_derived, get_history_bleed_stats from ai_client import _add_bleed_derived, get_history_bleed_stats

View File

@@ -1,4 +1,3 @@
import pytest
import time import time
from api_hook_client import ApiHookClient from api_hook_client import ApiHookClient

View File

@@ -1,4 +1,3 @@
import pytest
def test_vlogger_available(vlogger): def test_vlogger_available(vlogger):
vlogger.log_state("Test", "Before", "After") vlogger.log_state("Test", "Before", "After")

View File

@@ -22,13 +22,14 @@ Usage
import dearpygui.dearpygui as dpg import dearpygui.dearpygui as dpg
from pathlib import Path from pathlib import Path
from typing import Any
# ------------------------------------------------------------------ palettes # ------------------------------------------------------------------ palettes
# Colour key names match the DPG mvThemeCol_* constants (string lookup below). # Colour key names match the DPG mvThemeCol_* constants (string lookup below).
# Only keys that differ from DPG defaults need to be listed. # Only keys that differ from DPG defaults need to be listed.
_PALETTES: dict[str, dict] = { _PALETTES: dict[str, dict[str, Any]] = {
"DPG Default": {}, # empty = reset to DPG built-in defaults "DPG Default": {}, # empty = reset to DPG built-in defaults
"10x Dark": { "10x Dark": {
# Window / frame chrome # Window / frame chrome
@@ -285,11 +286,11 @@ def get_current_font_size() -> float:
def get_current_scale() -> float: def get_current_scale() -> float:
return _current_scale return _current_scale
def get_palette_colours(name: str) -> dict: def get_palette_colours(name: str) -> dict[str, Any]:
"""Return a copy of the colour dict for the named palette.""" """Return a copy of the colour dict for the named palette."""
return dict(_PALETTES.get(name, {})) return dict(_PALETTES.get(name, {}))
def apply(palette_name: str, overrides: dict | None = None) -> None: def apply(palette_name: str, overrides: dict[str, Any] | None = None) -> None:
""" """
Build a global DPG theme from the named palette plus optional per-colour Build a global DPG theme from the named palette plus optional per-colour
overrides, and bind it as the default theme. overrides, and bind it as the default theme.
@@ -368,7 +369,7 @@ def set_scale(factor: float) -> None:
_current_scale = factor _current_scale = factor
dpg.set_global_font_scale(factor) dpg.set_global_font_scale(factor)
def save_to_config(config: dict) -> None: def save_to_config(config: dict[str, Any]) -> None:
"""Persist theme settings into the config dict under [theme].""" """Persist theme settings into the config dict under [theme]."""
config.setdefault("theme", {}) config.setdefault("theme", {})
config["theme"]["palette"] = _current_palette config["theme"]["palette"] = _current_palette
@@ -376,7 +377,7 @@ def save_to_config(config: dict) -> None:
config["theme"]["font_size"] = _current_font_size config["theme"]["font_size"] = _current_font_size
config["theme"]["scale"] = _current_scale config["theme"]["scale"] = _current_scale
def load_from_config(config: dict) -> None: def load_from_config(config: dict[str, Any]) -> None:
"""Read [theme] from config and apply everything.""" """Read [theme] from config and apply everything."""
t = config.get("theme", {}) t = config.get("theme", {})
palette = t.get("palette", "DPG Default") palette = t.get("palette", "DPG Default")