diff --git a/.gitignore b/.gitignore index 8dd4e44..be63506 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ dpg_layout.ini tests/temp_workspace .mypy_cache .slop_cache +sdm_report_refined.json diff --git a/scripts/sdm_injector.py b/scripts/sdm_injector.py new file mode 100644 index 0000000..d4bf8d0 --- /dev/null +++ b/scripts/sdm_injector.py @@ -0,0 +1,173 @@ +import ast +import json +import os +import pathlib +import sys +import re +from typing import List, Dict, Any, Optional, Tuple + +def find_closing_quotes_pos(line: str) -> Tuple[int, str]: + pos_double = line.rfind('"""') + pos_single = line.rfind("'''") + if pos_double != -1 and pos_single != -1: + if pos_double > pos_single: return pos_double, '"""' + else: return pos_single, "'''" + elif pos_double != -1: + return pos_double, '"""' + elif pos_single != -1: + return pos_single, "'''" + return -1, "" + +class SdmDocstringInjectorVisitor(ast.NodeVisitor): + def __init__(self, file_path: str, sdm_tags_map: Dict[str, Any], lines: List[str]): + self.file_path = file_path + self.sdm_tags_map = sdm_tags_map + self.lines = lines + self.targets_to_modify = [] + self.current_class_name = None + self.project_root = pathlib.Path.cwd().resolve() + + def get_rel_path(self, path): + p = pathlib.Path(path).resolve() + try: + return str(p.relative_to(self.project_root)).replace("\\", "/") + except (ValueError, RuntimeError): + return str(p).replace("\\", "/") + + def _get_sdm_tags(self, name: str, node_type: str, parent_class_name: Optional[str] = None) -> List[str]: + relative_file_path = self.get_rel_path(self.file_path) + file_data = self.sdm_tags_map.get(relative_file_path) + if not file_data: return [] + tags = [] + if node_type == 'ClassDef': + class_data = file_data.get('classes', {}).get(name, {}) + class_tag = class_data.get('class_tag') + if class_tag: tags.append(class_tag) + elif node_type in ('FunctionDef', 'AsyncFunctionDef'): + if parent_class_name: + class_data = file_data.get('classes', {}).get(parent_class_name, {}) + tag = class_data.get('methods', {}).get(name) + if tag: tags.append(tag) + else: + tag = file_data.get('functions', {}).get(name) + if tag: tags.append(tag) + return tags + + def _process_node(self, node, node_type: str): + if not node.body: return + sdm_tags = self._get_sdm_tags(node.name, node_type, self.current_class_name) + first_body_node = node.body[0] + if (node.lineno == first_body_node.lineno): return + + docstring_node = None + if isinstance(node.body[0], ast.Expr) and \ + isinstance(node.body[0].value, ast.Constant) and isinstance(node.body[0].value.value, str): + docstring_node = node.body[0].value + + # Use col_offset of the first body node for exact matching + body_indent_count = first_body_node.col_offset + + if docstring_node: + self.targets_to_modify.append({ + 'type': 'append', 'node': node, 'name': node.name, 'sdm_tags': sdm_tags, + 'start_lineno': docstring_node.lineno, 'end_lineno': docstring_node.end_lineno, + 'indent_count': body_indent_count, 'existing_doc': docstring_node.value + }) + elif sdm_tags: + self.targets_to_modify.append({ + 'type': 'new', 'node': node, 'name': node.name, 'sdm_tags': sdm_tags, + 'insert_lineno': first_body_node.lineno, 'indent_count': body_indent_count + }) + + def visit_ClassDef(self, node): + self._process_node(node, 'ClassDef') + old_class = self.current_class_name + self.current_class_name = node.name + self.generic_visit(node) + self.current_class_name = old_class + + def visit_FunctionDef(self, node): + self._process_node(node, 'FunctionDef') + self.generic_visit(node) + + def visit_AsyncFunctionDef(self, node): + self._process_node(node, 'AsyncFunctionDef') + self.generic_visit(node) + +def strip_tags(docstring: str) -> str: + lines = docstring.splitlines() + new_lines = [] + for line in lines: + if re.search(r'\[C:.*\]|\[M:.*\]|\[U:.*\]|\[VARS:.*\]', line): continue + new_lines.append(line) + while new_lines and not new_lines[-1].strip(): new_lines.pop() + return "\n".join(new_lines) + +def process_file(py_file_path: pathlib.Path, sdm_tags_map): + try: + with open(py_file_path, 'r', encoding='utf-8') as f: content = f.read() + lines = content.splitlines() + if not lines: return + try: tree = ast.parse(content) + except SyntaxError: return + visitor = SdmDocstringInjectorVisitor(str(py_file_path.resolve()), sdm_tags_map, lines) + visitor.visit(tree) + if not visitor.targets_to_modify: return + visitor.targets_to_modify.sort(key=lambda t: t['node'].lineno, reverse=True) + modified_lines = lines[:] + file_modified = False + for target in visitor.targets_to_modify: + sdm_tags = target['sdm_tags'] + indent = " " * target['indent_count'] + if target['type'] == 'append': + clean_doc = strip_tags(target['existing_doc']) + if sdm_tags: + prepared_tags = [f"{indent}{line}" for t in sdm_tags for line in t.splitlines()] + new_content = (clean_doc + "\n" + "\n".join(prepared_tags)) if clean_doc.strip() else "\n".join(prepared_tags) + else: + new_content = clean_doc + start_idx = target['start_lineno'] - 1 + end_idx = target['end_lineno'] - 1 + first_line, last_line = modified_lines[start_idx], modified_lines[end_idx] + q_start_pos = first_line.find('"""') + if q_start_pos == -1: q_start_pos = first_line.find("'''") + q_end_pos, q_type = find_closing_quotes_pos(last_line) + if q_start_pos != -1 and q_end_pos != -1: + q_prefix, q_suffix = first_line[:q_start_pos + 3], last_line[q_end_pos:] + if "\n" in new_content or (start_idx != end_idx): + replacement = [q_prefix] + [f"{indent}{l}" for l in new_content.splitlines()] + [f"{indent}{q_suffix}"] + else: + replacement = [f"{q_prefix}{new_content}{q_suffix}"] + modified_lines[start_idx:end_idx+1] = replacement + file_modified = True + elif sdm_tags: + prepared_tags = [f"{indent}{line}" for t in sdm_tags for line in t.splitlines()] + new_doc = [f'{indent}"""', "\n".join(prepared_tags), f'{indent}"""'] + insert_idx = target['insert_lineno'] - 1 + while insert_idx > 0 and not modified_lines[insert_idx-1].strip(): insert_idx -= 1 + modified_lines[insert_idx:insert_idx] = new_doc + file_modified = True + if file_modified: + with open(py_file_path, 'w', encoding='utf-8') as f: f.write("\n".join(modified_lines)) + except Exception as e: print(f"Error processing {py_file_path}: {e}", file=sys.stderr) + +def main(): + sdm_report_path = "sdm_report_refined.json" + if not pathlib.Path(sdm_report_path).exists(): + print(f"Error: {sdm_report_path} not found.", file=sys.stderr); sys.exit(1) + with open(sdm_report_path, 'r', encoding='utf-8') as f: sdm_tags_map = json.load(f) + targets = sys.argv[1:] + if not targets: + for d in ["src", "simulation", "tests"]: + sd = pathlib.Path(d) + if sd.exists(): + for f in sd.rglob("*.py"): process_file(f, sdm_tags_map) + else: + for t in targets: + tp = pathlib.Path(t) + if tp.is_file(): process_file(tp, sdm_tags_map) + elif tp.is_dir(): + for f in tp.rglob("*.py"): process_file(f, sdm_tags_map) + +if __name__ == "__main__": + main() diff --git a/scripts/sdm_mapper.py b/scripts/sdm_mapper.py new file mode 100644 index 0000000..2e39844 --- /dev/null +++ b/scripts/sdm_mapper.py @@ -0,0 +1,206 @@ +import ast +import os +import json +import sys +import pathlib + +class SDMMapper: + def __init__(self): + self.files = {} # path -> {"functions": {}, "classes": {}} + self.functions_global = {} # name -> {"file": str, "class": str, "callers": set()} + self.current_file = "" + self.current_class = None + self.current_function = None + self.project_root = pathlib.Path.cwd().resolve() + + def get_rel_path(self, path): + p = pathlib.Path(path).resolve() + try: + return str(p.relative_to(self.project_root)).replace("\\", "/") + except (ValueError, RuntimeError): + return str(p).replace("\\", "/") + + def collect_symbols(self, dirs): + for d in dirs: + if not os.path.exists(d): continue + for root, _, files in os.walk(d): + for f in files: + if f.endswith(".py"): + path = os.path.join(root, f) + rel_path = self.get_rel_path(path) + try: + with open(path, "r", encoding="utf-8-sig") as file: + tree = ast.parse(file.read(), filename=path) + if rel_path not in self.files: + self.files[rel_path] = {"functions": {}, "classes": {}} + + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + full_name = node.name + # In first pass, we just note definitions. + # Class-member identification happens in visit_ClassDef. + pass + elif isinstance(node, ast.ClassDef): + if node.name not in self.files[rel_path]["classes"]: + self.files[rel_path]["classes"][node.name] = {"methods": {}, "variables": {}} + except Exception as e: + print(f"Error collecting symbols from {path}: {e}", file=sys.stderr) + + def analyze_files(self, dirs): + for d in dirs: + if not os.path.exists(d): continue + for root, _, files in os.walk(d): + for f in files: + if f.endswith(".py"): + self.analyze_file(os.path.join(root, f)) + + def analyze_file(self, path): + self.current_file = self.get_rel_path(path) + if self.current_file not in self.files: + self.files[self.current_file] = {"functions": {}, "classes": {}} + + try: + with open(path, "r", encoding="utf-8-sig") as file: + tree = ast.parse(file.read(), filename=path) + visitor = SDMVisitor(self) + visitor.visit(tree) + except Exception as e: + print(f"Error analyzing {path}: {e}", file=sys.stderr) + +class SDMVisitor(ast.NodeVisitor): + def __init__(self, mapper): + self.mapper = mapper + self.current_class = None + self.current_function = None + + def visit_ClassDef(self, node): + old_class = self.current_class + self.current_class = node.name + if self.current_class not in self.mapper.files[self.mapper.current_file]["classes"]: + self.mapper.files[self.mapper.current_file]["classes"][self.current_class] = {"methods": {}, "variables": {}} + self.generic_visit(node) + self.current_class = old_class + + def visit_FunctionDef(self, node): + old_func = self.current_function + self.current_function = node.name + + full_name = f"{self.current_class}.{node.name}" if self.current_class else node.name + if full_name not in self.mapper.functions_global: + self.mapper.functions_global[full_name] = { + "file": self.mapper.current_file, + "class": self.current_class, + "callers": set() + } + + self.generic_visit(node) + self.current_function = old_func + + def visit_AsyncFunctionDef(self, node): + self.visit_FunctionDef(node) + + def visit_Call(self, node): + name = None + if isinstance(node.func, ast.Name): + name = node.func.id + elif isinstance(node.func, ast.Attribute): + name = node.func.attr + + if name: + # Try to find if it's a known function/method + potential_matches = [n for n in self.mapper.functions_global if n == name or n.endswith("." + name)] + for match in potential_matches: + match_file = self.mapper.functions_global[match]["file"] + # EXTERNAL FILTER: Only add caller if it's from a different file + if match_file != self.mapper.current_file: + caller_name = f"{self.current_class}.{self.current_function}" if self.current_class else (self.current_function or "module") + # Include file name for external clarity + self.mapper.functions_global[match]["callers"].add(f"{self.mapper.current_file}:{caller_name}") + + self.generic_visit(node) + + def visit_Attribute(self, node): + if isinstance(node.value, ast.Name) and node.value.id == "self" and self.current_class: + attr_name = node.attr + class_data = self.mapper.files[self.mapper.current_file]["classes"][self.current_class] + if attr_name not in class_data["variables"]: + class_data["variables"][attr_name] = {"mutations": [], "usages": set()} + + if isinstance(node.ctx, ast.Store): + class_data["variables"][attr_name]["mutations"].append({ + "file": self.mapper.current_file, + "line": node.lineno, + "method": self.current_function + }) + elif isinstance(node.ctx, ast.Load): + class_data["variables"][attr_name]["usages"].add(self.mapper.current_file) + self.generic_visit(node) + +def main(): + target = "." + if len(sys.argv) > 1: + target = sys.argv[1] + + mapper = SDMMapper() + dirs = ["src", "simulation", "tests"] + + if os.path.isfile(target): + mapper.collect_symbols(dirs) + mapper.analyze_file(target) + else: + search_dirs = [target] if target in dirs else dirs + mapper.collect_symbols(search_dirs) + mapper.analyze_files(search_dirs) + + # Build the final grouped report + report = {} + + # 1. Add functions/methods + for full_name, data in mapper.functions_global.items(): + f_path = data["file"] + if f_path not in report: report[f_path] = {"functions": {}, "classes": {}} + + # External callers only + callers = sorted(list(data["callers"])) + if not callers: + continue + + tag = f"[C: {', '.join(callers)}]" + if data["class"]: + c_name = data["class"] + if c_name not in report[f_path]["classes"]: + report[f_path]["classes"][c_name] = {"methods": {}, "variables": {}} + m_name = full_name.split(".")[-1] + report[f_path]["classes"][c_name]["methods"][m_name] = tag + else: + report[f_path]["functions"][full_name] = tag + + # 2. Add class variables + for f_path, f_data in mapper.files.items(): + if f_path not in report: continue + for c_name, c_data in f_data["classes"].items(): + if c_name not in report[f_path]["classes"]: + report[f_path]["classes"][c_name] = {"methods": {}, "variables": {}} + + class_vars_summary = [] + for v_name, v_data in c_data["variables"].items(): + # EXTERNAL FILTER: Only include mutations/usages from different files + ext_muts = [f"{m['file']}:{m['line']}, {m['method']}" for m in v_data["mutations"] if m['file'] != f_path] + ext_usages = [u for u in v_data["usages"] if u != f_path] + + if not ext_muts and not ext_usages: + continue + + m_tag = f"[M: {'; '.join(ext_muts or ['None'])}]" + u_tag = f"[U: {', '.join(sorted(list(ext_usages or ['None'])))}]" + tag = f"{m_tag} {u_tag}" + report[f_path]["classes"][c_name]["variables"][v_name] = tag + class_vars_summary.append(f"{v_name}: {tag}") + + if class_vars_summary: + report[f_path]["classes"][c_name]["class_tag"] = "\n".join(class_vars_summary) + + print(json.dumps(report, indent=1)) + +if __name__ == "__main__": + main() diff --git a/src/aggregate.py b/src/aggregate.py index 4107fbb..b2fe481 100644 --- a/src/aggregate.py +++ b/src/aggregate.py @@ -61,9 +61,10 @@ def resolve_paths(base_dir: Path, entry: str) -> list[Path]: def build_discussion_section(history: list[Any]) -> str: """ - Builds a markdown section for discussion history. - Handles both legacy list[str] and new list[dict]. - """ + + Builds a markdown section for discussion history. + Handles both legacy list[str] and new list[dict]. + """ sections = [] for i, entry in enumerate(history, start=1): if isinstance(entry, dict): @@ -76,6 +77,9 @@ def build_discussion_section(history: list[Any]) -> str: return "\n\n---\n\n".join(sections) def build_files_section(base_dir: Path, files: list[str | dict[str, Any]]) -> str: + """ + [C: tests/test_tiered_context.py:test_build_files_section_with_dicts] + """ sections = [] for entry_raw in files: if isinstance(entry_raw, dict): @@ -120,19 +124,21 @@ def build_screenshots_section(base_dir: Path, screenshots: list[str]) -> str: def build_file_items(base_dir: Path, files: list[str | dict[str, Any]]) -> list[dict[str, Any]]: """ - Return a list of dicts describing each file, for use by ai_client when it - wants to upload individual files rather than inline everything as markdown. - - Each dict has: - path : Path (resolved absolute path) - entry : str (original config entry string) - content : str (file text, or error string) - error : bool - mtime : float (last modification time, for skip-if-unchanged optimization) - tier : int | None (optional tier for context management) - auto_aggregate : bool - force_full : bool - """ + + Return a list of dicts describing each file, for use by ai_client when it + wants to upload individual files rather than inline everything as markdown. + + Each dict has: + path : Path (resolved absolute path) + entry : str (original config entry string) + content : str (file text, or error string) + error : bool + mtime : float (last modification time, for skip-if-unchanged optimization) + tier : int | None (optional tier for context management) + auto_aggregate : bool + force_full : bool + [C: src/app_controller.py:AppController._bg_task, src/orchestrator_pm.py:module, tests/test_aggregate_flags.py:test_auto_aggregate_skip, tests/test_aggregate_flags.py:test_force_full, tests/test_tiered_context.py:test_build_file_items_with_tiers] + """ with get_monitor().scope("build_file_items"): items: list[dict[str, Any]] = [] for entry_raw in files: @@ -175,15 +181,19 @@ def build_file_items(base_dir: Path, files: list[str | dict[str, Any]]) -> list[ def build_summary_section(base_dir: Path, files: list[str | dict[str, Any]]) -> str: """ - Build a compact summary section using summarize.py — one short block per file. - Used as the initial block instead of full file contents. - """ + + Build a compact summary section using summarize.py — one short block per file. + Used as the initial block instead of full file contents. + """ with get_monitor().scope("build_summary_section"): items = build_file_items(base_dir, files) return summarize.build_summary_markdown(items) def _build_files_section_from_items(file_items: list[dict[str, Any]]) -> str: - """Build the files markdown section from pre-read file items (avoids double I/O).""" + """ + Build the files markdown section from pre-read file items (avoids double I/O). + [C: tests/test_aggregate_flags.py:test_auto_aggregate_skip, tests/test_ui_summary_only_removal.py:test_aggregate_from_items_respects_auto_aggregate] + """ sections = [] for item in file_items: if not item.get("auto_aggregate", True): @@ -201,6 +211,9 @@ def _build_files_section_from_items(file_items: list[dict[str, Any]]) -> str: return "\n\n---\n\n".join(sections) def build_beads_section(base_dir: Path) -> str: + """ + [C: tests/test_aggregate_beads.py:test_build_beads_compaction] + """ client = beads_client.BeadsClient(base_dir) if not client.is_initialized(): return "" @@ -247,20 +260,28 @@ def build_markdown_from_items(file_items: list[dict[str, Any]], screenshot_base_ return "\n\n---\n\n".join(parts) def build_markdown_no_history(file_items: list[dict[str, Any]], screenshot_base_dir: Path, screenshots: list[str], summary_only: bool = False, aggregation_strategy: str = "auto") -> str: - """Build markdown with only files + screenshots (no history). Used for stable caching.""" + """ + Build markdown with only files + screenshots (no history). Used for stable caching. + [C: src/app_controller.py:AppController._do_generate, tests/test_history_management.py:test_aggregate_blacklist] + """ return build_markdown_from_items(file_items, screenshot_base_dir, screenshots, history=[], summary_only=summary_only, aggregation_strategy=aggregation_strategy) def build_discussion_text(history: list[str]) -> str: - """Build just the discussion history section text. Returns empty string if no history.""" + """ + Build just the discussion history section text. Returns empty string if no history. + [C: src/app_controller.py:AppController._do_generate, tests/test_history_management.py:test_aggregate_includes_segregated_history] + """ if not history: return "" return "## Discussion History\n\n" + build_discussion_section(history) def build_tier1_context(file_items: list[dict[str, Any]], screenshot_base_dir: Path, screenshots: list[str], history: list[str]) -> str: """ - Tier 1 Context: Strategic/Orchestration. - Full content for core conductor files and files with tier=1, summaries for others. - """ + + Tier 1 Context: Strategic/Orchestration. + Full content for core conductor files and files with tier=1, summaries for others. + [C: tests/test_aggregate_flags.py:test_auto_aggregate_skip, tests/test_aggregate_flags.py:test_force_full, tests/test_tiered_context.py:test_build_tier1_context_exists, tests/test_tiered_context.py:test_tiered_context_by_tier_field] + """ core_files = {"product.md", "tech-stack.md", "workflow.md", "tracks.md"} sections = [] for item in file_items: @@ -289,16 +310,20 @@ def build_tier1_context(file_items: list[dict[str, Any]], screenshot_base_dir: P def build_tier2_context(file_items: list[dict[str, Any]], screenshot_base_dir: Path, screenshots: list[str], history: list[str]) -> str: """ - Tier 2 Context: Architectural/Tech Lead. - Full content for all files (standard behavior). - """ + + Tier 2 Context: Architectural/Tech Lead. + Full content for all files (standard behavior). + [C: tests/test_tiered_context.py:test_build_tier2_context_exists] + """ return build_markdown_from_items(file_items, screenshot_base_dir, screenshots, history, summary_only=False) def build_tier3_context(file_items: list[dict[str, Any]], screenshot_base_dir: Path, screenshots: list[str], history: list[str], focus_files: list[str]) -> str: """ - Tier 3 Context: Execution/Worker. - Full content for focus_files and files with tier=3, summaries/skeletons for others. - """ + + Tier 3 Context: Execution/Worker. + Full content for focus_files and files with tier=3, summaries/skeletons for others. + [C: tests/test_aggregate_flags.py:test_auto_aggregate_skip, tests/test_aggregate_flags.py:test_force_full, tests/test_perf_aggregate.py:test_build_tier3_context_scaling, tests/test_tiered_context.py:test_build_tier3_context_ast_skeleton, tests/test_tiered_context.py:test_build_tier3_context_exists, tests/test_tiered_context.py:test_tiered_context_by_tier_field] + """ with get_monitor().scope("build_tier3_context"): focus_set = set(focus_files) parser = ASTParser("python") @@ -362,6 +387,9 @@ def build_markdown(base_dir: Path, files: list[str | dict[str, Any]], screenshot return "\n\n---\n\n".join(parts) def run(config: dict[str, Any], aggregation_strategy: str = "auto") -> tuple[str, Path, list[dict[str, Any]]]: + """ + [C: simulation/sim_base.py:run_sim, src/ai_client.py:_send_anthropic, src/ai_client.py:_send_deepseek, src/ai_client.py:_send_gemini, src/ai_client.py:_send_gemini_cli, src/ai_client.py:_send_minimax, src/app_controller.py:AppController._cb_start_track, src/app_controller.py:AppController._do_generate, src/app_controller.py:AppController._process_event_queue, src/app_controller.py:AppController._start_track_logic, src/external_editor.py:_find_vscode_in_registry, src/gui_2.py:App._render_snapshot_tab, src/gui_2.py:App.run, src/gui_2.py:main, src/mcp_client.py:get_git_diff, src/project_manager.py:get_git_commit, src/project_manager.py:get_git_log, src/rag_engine.py:RAGEngine._search_mcp, src/shell_runner.py:run_powershell, tests/conftest.py:kill_process_tree, tests/conftest.py:live_gui, tests/test_conductor_abort_event.py:test_conductor_abort_event_populated, tests/test_conductor_engine_v2.py:test_conductor_engine_dynamic_parsing_and_execution, tests/test_conductor_engine_v2.py:test_conductor_engine_run_executes_tickets_in_order, tests/test_extended_sims.py:test_ai_settings_sim_live, tests/test_extended_sims.py:test_context_sim_live, tests/test_extended_sims.py:test_execution_sim_live, tests/test_extended_sims.py:test_tools_sim_live, tests/test_external_editor_gui.py:get_vscode_processes, tests/test_external_editor_gui.py:test_vscode_launches_with_diff_view, tests/test_gui_custom_window.py:test_app_window_is_borderless, tests/test_headless_simulation.py:module, tests/test_headless_verification.py:test_headless_verification_error_and_qa_interceptor, tests/test_headless_verification.py:test_headless_verification_full_run, tests/test_mock_gemini_cli.py:run_mock, tests/test_orchestration_logic.py:test_conductor_engine_run, tests/test_parallel_execution.py:test_conductor_engine_pool_integration, tests/test_sim_ai_settings.py:test_ai_settings_simulation_run, tests/test_sim_context.py:test_context_simulation_run, tests/test_sim_execution.py:test_execution_simulation_run, tests/test_sim_tools.py:test_tools_simulation_run] + """ namespace = config.get("project", {}).get("name") if not namespace: namespace = config.get("output", {}).get("namespace", "project") @@ -385,6 +413,9 @@ def run(config: dict[str, Any], aggregation_strategy: str = "auto") -> tuple[str def main() -> None: # Load global config to find active project + """ + [C: simulation/live_walkthrough.py:module, simulation/ping_pong.py:module, src/api_hooks.py:WebSocketServer._run_loop, src/gui_2.py:module, tests/mock_concurrent_mma.py:module, tests/mock_gemini_cli.py:module, tests/test_cli_tool_bridge.py:TestCliToolBridge.test_allow_decision, tests/test_cli_tool_bridge.py:TestCliToolBridge.test_deny_decision, tests/test_cli_tool_bridge.py:TestCliToolBridge.test_unreachable_hook_server, tests/test_cli_tool_bridge.py:module, tests/test_cli_tool_bridge_mapping.py:TestCliToolBridgeMapping.test_mapping_from_api_format, tests/test_cli_tool_bridge_mapping.py:module, tests/test_discussion_takes.py:module, tests/test_external_editor_gui.py:module, tests/test_headless_service.py:TestHeadlessStartup.test_headless_flag_triggers_run, tests/test_headless_service.py:TestHeadlessStartup.test_normal_startup_calls_app_run, tests/test_mma_skeleton.py:module, tests/test_orchestrator_pm.py:module, tests/test_orchestrator_pm_history.py:module, tests/test_post_process.py:module, tests/test_presets.py:module, tests/test_project_serialization.py:module, tests/test_run_worker_lifecycle_abort.py:module, tests/test_symbol_lookup.py:module, tests/test_system_prompt_exposure.py:module, tests/test_theme_nerv_fx.py:module] + """ from src.paths import get_config_path config_path = get_config_path() if not config_path.exists(): @@ -406,4 +437,3 @@ def main() -> None: if __name__ == "__main__": main() - diff --git a/src/app_controller.py b/src/app_controller.py index a1124a8..b8b0f36 100644 --- a/src/app_controller.py +++ b/src/app_controller.py @@ -42,6 +42,9 @@ from src import rag_engine from src import theme_2 as theme def hide_tk_root() -> Tk: + """ + [C: src/gui_2.py:App._render_files_panel, src/gui_2.py:App._render_projects_panel, src/gui_2.py:App._render_provider_panel, src/gui_2.py:App._render_screenshots_panel, src/gui_2.py:App._render_theme_panel, src/gui_2.py:App.render_path_field] + """ root = Tk() root.withdraw() root.wm_attributes("-topmost", True) @@ -49,12 +52,17 @@ def hide_tk_root() -> Tk: def parse_symbols(text: str) -> list[str]: """ - Finds all occurrences of '@SymbolName' in text and returns SymbolName. - SymbolName can be a function, class, or method (e.g. @MyClass, @my_func, @MyClass.my_method). + + Finds all occurrences of '@SymbolName' in text and returns SymbolName. + SymbolName can be a function, class, or method (e.g. @MyClass, @my_func, @MyClass.my_method). + [C: tests/test_symbol_lookup.py:TestSymbolLookup.test_parse_symbols_basic, tests/test_symbol_lookup.py:TestSymbolLookup.test_parse_symbols_edge_cases, tests/test_symbol_lookup.py:TestSymbolLookup.test_parse_symbols_methods, tests/test_symbol_lookup.py:TestSymbolLookup.test_parse_symbols_mixed, tests/test_symbol_lookup.py:TestSymbolLookup.test_parse_symbols_no_symbols] """ return re.findall(r"@([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*)", text) def get_symbol_definition(symbol: str, files: list[str]) -> tuple[str, str, int] | None: + """ + [C: tests/test_symbol_lookup.py:TestSymbolLookup.test_get_symbol_definition_found, tests/test_symbol_lookup.py:TestSymbolLookup.test_get_symbol_definition_not_found] + """ for file_path in files: result = mcp_client.py_get_symbol_info(file_path, symbol) if isinstance(result, tuple): @@ -74,6 +82,9 @@ class ConfirmRequest(BaseModel): class ConfirmDialog: def __init__(self, script: str, base_dir: str) -> None: + """ + [C: src/mcp_client.py:_DDGParser.__init__, src/mcp_client.py:_TextExtractor.__init__] + """ self._uid = str(uuid.uuid4()) self._script = str(script) if script is not None else "" self._base_dir = str(base_dir) if base_dir is not None else "" @@ -82,6 +93,9 @@ class ConfirmDialog: self._approved = False def wait(self) -> tuple[bool, str]: + """ + [C: src/mcp_client.py:StdioMCPServer.stop, src/multi_agent_conductor.py:confirm_execution, src/multi_agent_conductor.py:confirm_spawn, tests/conftest.py:live_gui, tests/test_ai_client_concurrency.py:run_t1, tests/test_ai_client_concurrency.py:run_t2, tests/test_conductor_engine_abort.py:worker, tests/test_parallel_execution.py:test_worker_pool_limit] + """ start_time = time.time() with self._condition: while not self._done: @@ -92,12 +106,18 @@ class ConfirmDialog: class MMAApprovalDialog: def __init__(self, ticket_id: str, payload: str) -> None: + """ + [C: src/mcp_client.py:_DDGParser.__init__, src/mcp_client.py:_TextExtractor.__init__] + """ self._payload = payload self._condition = threading.Condition() self._done = False self._approved = False def wait(self) -> tuple[bool, str]: + """ + [C: src/mcp_client.py:StdioMCPServer.stop, src/multi_agent_conductor.py:confirm_execution, src/multi_agent_conductor.py:confirm_spawn, tests/conftest.py:live_gui, tests/test_ai_client_concurrency.py:run_t1, tests/test_ai_client_concurrency.py:run_t2, tests/test_conductor_engine_abort.py:worker, tests/test_parallel_execution.py:test_worker_pool_limit] + """ start_time = time.time() with self._condition: while not self._done: @@ -108,6 +128,9 @@ class MMAApprovalDialog: class MMASpawnApprovalDialog: def __init__(self, ticket_id: str, role: str, prompt: str, context_md: str) -> None: + """ + [C: src/mcp_client.py:_DDGParser.__init__, src/mcp_client.py:_TextExtractor.__init__] + """ self._prompt = prompt self._context_md = context_md self._condition = threading.Condition() @@ -116,6 +139,9 @@ class MMASpawnApprovalDialog: self._abort = False def wait(self) -> dict[str, Any]: + """ + [C: src/mcp_client.py:StdioMCPServer.stop, src/multi_agent_conductor.py:confirm_execution, src/multi_agent_conductor.py:confirm_spawn, tests/conftest.py:live_gui, tests/test_ai_client_concurrency.py:run_t1, tests/test_ai_client_concurrency.py:run_t2, tests/test_conductor_engine_abort.py:worker, tests/test_parallel_execution.py:test_worker_pool_limit] + """ start_time = time.time() with self._condition: while not self._done: @@ -131,12 +157,16 @@ class MMASpawnApprovalDialog: class AppController: """ - The headless controller for the Manual Slop application. - Owns the application state and manages background services. + + The headless controller for the Manual Slop application. + Owns the application state and manages background services. """ def __init__(self): # Initialize locks first to avoid initialization order issues + """ + [C: src/mcp_client.py:_DDGParser.__init__, src/mcp_client.py:_TextExtractor.__init__] + """ self._send_thread_lock: threading.Lock = threading.Lock() self._disc_entries_lock: threading.Lock = threading.Lock() self._pending_comms_lock: threading.Lock = threading.Lock() @@ -486,7 +516,10 @@ class AppController: return self.ui_files_base_dir def _update_inject_preview(self) -> None: - """Updates the preview content based on the selected file and injection mode.""" + """ + Updates the preview content based on the selected file and injection mode. + [C: src/gui_2.py:App._gui_func, tests/test_skeleton_injection.py:test_update_inject_preview_full, tests/test_skeleton_injection.py:test_update_inject_preview_skeleton, tests/test_skeleton_injection.py:test_update_inject_preview_truncation] + """ if not self._inject_file_path: self._inject_preview = "" return @@ -720,6 +753,9 @@ class AppController: def _process_pending_gui_tasks(self) -> None: # Periodic telemetry broadcast + """ + [C: src/gui_2.py:App._gui_func, tests/test_api_hook_extensions.py:test_app_processes_new_actions, tests/test_gui_updates.py:test_gui_updates_on_event, tests/test_live_gui_integration_v2.py:test_user_request_error_handling, tests/test_live_gui_integration_v2.py:test_user_request_integration_flow, tests/test_mma_orchestration_gui.py:test_handle_ai_response_fallback, tests/test_mma_orchestration_gui.py:test_handle_ai_response_with_stream_id, tests/test_mma_orchestration_gui.py:test_process_pending_gui_tasks_mma_spawn_approval, tests/test_mma_orchestration_gui.py:test_process_pending_gui_tasks_show_track_proposal, tests/test_process_pending_gui_tasks.py:test_gcli_path_updates_adapter, tests/test_process_pending_gui_tasks.py:test_redundant_calls_in_process_pending_gui_tasks] + """ now = time.time() if hasattr(self, 'event_queue') and hasattr(self.event_queue, 'websocket_server') and self.event_queue.websocket_server: if now - self._last_telemetry_time >= 1.0: @@ -918,6 +954,9 @@ class AppController: class AutoSpawnDialog: def __init__(self, t): self.t = t def wait(self): + """ + [C: src/mcp_client.py:StdioMCPServer.stop, src/multi_agent_conductor.py:confirm_execution, src/multi_agent_conductor.py:confirm_spawn, tests/conftest.py:live_gui, tests/test_ai_client_concurrency.py:run_t1, tests/test_ai_client_concurrency.py:run_t2, tests/test_conductor_engine_abort.py:worker, tests/test_parallel_execution.py:test_worker_pool_limit] + """ return {'approved': True, 'abort': False, 'prompt': self.t.get("prompt"), 'context_md': self.t.get("context_md")} task["dialog_container"][0] = AutoSpawnDialog(task) continue @@ -987,7 +1026,10 @@ class AppController: print(f"Error executing GUI task: {e}") def _process_pending_history_adds(self) -> None: - """Synchronizes pending history entries to the active discussion and project state.""" + """ + Synchronizes pending history entries to the active discussion and project state. + [C: src/gui_2.py:App._gui_func] + """ with self._pending_history_adds_lock: items = self._pending_history_adds[:] self._pending_history_adds.clear() @@ -1012,7 +1054,10 @@ class AppController: self.disc_entries.append(item) def _process_pending_tool_calls(self) -> bool: - """Drains pending tool calls into the tool log. Returns True if any were processed.""" + """ + Drains pending tool calls into the tool log. Returns True if any were processed. + [C: src/gui_2.py:App._gui_func] + """ with self._pending_tool_calls_lock: items = self._pending_tool_calls[:] self._pending_tool_calls.clear() @@ -1056,7 +1101,10 @@ class AppController: self._pending_dialog = None def init_state(self): - """Initializes the application state from configurations.""" + """ + Initializes the application state from configurations. + [C: src/gui_2.py:App.__init__, src/gui_2.py:App._render_paths_panel, src/gui_2.py:App._save_paths, tests/test_app_controller_mcp.py:test_app_controller_mcp_loading, tests/test_app_controller_mcp.py:test_app_controller_mcp_project_override, tests/test_external_mcp_e2e.py:test_external_mcp_e2e_refresh_and_call, tests/test_system_prompt_exposure.py:TestSystemPromptExposure.test_app_controller_init_state_loads_prompts] + """ self.active_tickets = [] self.ui_separate_task_dag = False self.ui_separate_usage_analytics = False @@ -1198,6 +1246,9 @@ class AppController: self.event_queue.put('refresh_external_mcps', None) async def refresh_external_mcps(self): + """ + [C: tests/test_external_mcp_e2e.py:test_external_mcp_e2e_refresh_and_call] + """ await mcp_client.get_external_mcp_manager().stop_all() # Start servers with auto_start=True for name, cfg in self.mcp_config.mcpServers.items(): @@ -1205,6 +1256,9 @@ class AppController: await mcp_client.get_external_mcp_manager().add_server(cfg) def cb_load_prior_log(self, path: Optional[str] = None) -> None: + """ + [C: src/gui_2.py:App._render_log_management] + """ root = hide_tk_root() if path is None: path = filedialog.askdirectory( @@ -1393,6 +1447,9 @@ class AppController: def cb_exit_prior_session(self): + """ + [C: src/gui_2.py:App._render_comms_history_panel, src/gui_2.py:App._render_discussion_panel] + """ self.is_viewing_prior_session = False if self._current_session_usage: self.session_usage = self._current_session_usage @@ -1475,6 +1532,9 @@ class AppController: thread.start() def _fetch_models(self, provider: str) -> None: + """ + [C: src/gui_2.py:App.run] + """ self.ai_status = "fetching models..." def do_fetch() -> None: @@ -1498,14 +1558,20 @@ class AppController: self.models_thread.start() def start_services(self, app: Any = None): - """Starts background threads.""" + """ + Starts background threads. + [C: src/gui_2.py:App.__init__] + """ self._prune_old_logs() self._init_ai_and_hooks(app) self._loop_thread = threading.Thread(target=self._run_event_loop, daemon=True) self._loop_thread.start() def shutdown(self) -> None: - """Stops background threads and cleans up resources.""" + """ + Stops background threads and cleans up resources. + [C: src/gui_2.py:App.run, src/gui_2.py:App.shutdown, tests/conftest.py:app_instance, tests/conftest.py:mock_app] + """ from src import ai_client ai_client.cleanup() if hasattr(self, 'hook_server') and self.hook_server: @@ -1604,7 +1670,10 @@ class AppController: asyncio.run(self.refresh_external_mcps()) def _handle_request_event(self, event: events.UserRequestEvent) -> None: - """Processes a UserRequestEvent by calling the AI client.""" + """ + Processes a UserRequestEvent by calling the AI client. + [C: tests/test_live_gui_integration_v2.py:test_user_request_error_handling, tests/test_live_gui_integration_v2.py:test_user_request_integration_flow, tests/test_rag_integration.py:test_rag_integration] + """ self.ai_status = 'sending...' ai_client.set_current_tier(None) # Ensure main discussion is untagged # Clear response area for new turn @@ -1662,6 +1731,9 @@ class AppController: self.event_queue.put("response", {"text": text, "status": "streaming...", "role": "AI"}) def _on_comms_entry(self, entry: Dict[str, Any]) -> None: + """ + [C: tests/test_app_controller_offloading.py:test_on_comms_entry_tool_result_offloading] + """ optimized_entry = self._offload_entry_payload(entry) session_logger.log_comms(optimized_entry) entry["local_ts"] = time.time() @@ -1756,6 +1828,9 @@ class AppController: self._pending_comms.append(entry) def _on_tool_log(self, script: str, result: str) -> None: + """ + [C: tests/test_app_controller_offloading.py:test_on_tool_log_offloading] + """ session_logger.log_tool_call(script, result, None) session_logger.log_tool_output(result) source_tier = ai_client.get_current_tier() @@ -1763,6 +1838,9 @@ class AppController: self._pending_tool_calls.append({"script": script, "result": result, "ts": time.time(), "source_tier": source_tier}) def _on_api_event(self, event_name: str = "generic_event", **kwargs: Any) -> None: + """ + [C: tests/test_gui_updates.py:test_gui_updates_on_event] + """ payload = kwargs.get("payload", {}) # Push to background event queue, NOT GUI queue self.event_queue.put("refresh_api_metrics", payload) @@ -1778,6 +1856,9 @@ class AppController: }) def _confirm_and_run(self, script: str, base_dir: str, qa_callback: Optional[Callable[[str], str]] = None, patch_callback: Optional[Callable[[str, str], Optional[str]]] = None) -> Optional[str]: + """ + [C: tests/test_arch_boundary_phase2.py:TestArchBoundaryPhase2.test_mutating_tool_triggers_callback, tests/test_arch_boundary_phase2.py:TestArchBoundaryPhase2.test_rejection_prevents_dispatch] + """ if self.test_hooks_enabled and not getattr(self, "ui_manual_approve", False): self.ai_status = "running powershell..." output = shell_runner.run_powershell(script, base_dir, qa_callback=qa_callback, patch_callback=patch_callback) @@ -1815,6 +1896,9 @@ class AppController: return output def _append_tool_log(self, script: str, result: str, source_tier: str | None = None, elapsed_ms: float = 0.0) -> None: + """ + [C: tests/test_mma_agent_focus_phase1.py:test_append_tool_log_dict_has_source_tier, tests/test_mma_agent_focus_phase1.py:test_append_tool_log_dict_keys, tests/test_mma_agent_focus_phase1.py:test_append_tool_log_stores_dict] + """ self._tool_log.append({"script": script, "result": result, "ts": time.time(), "source_tier": source_tier}) tool_name = self._extract_tool_name(script) is_failure = "REJECTED" in result or "Error" in result or "error" in result.lower() @@ -1910,7 +1994,10 @@ class AppController: self._token_stats_dirty = True def create_api(self) -> FastAPI: - """Creates and configures the FastAPI application for headless mode.""" + """ + Creates and configures the FastAPI application for headless mode. + [C: src/gui_2.py:App.run, tests/test_headless_service.py:TestHeadlessAPI.setUp] + """ api = FastAPI(title="Manual Slop Headless API") API_KEY_NAME = "X-API-KEY" api_key_header = APIKeyHeader(name=API_KEY_NAME, auto_error=False) @@ -1934,7 +2021,10 @@ class AppController: @api.get("/api/gui/state", dependencies=[Depends(get_api_key)]) def get_gui_state() -> dict[str, Any]: - """Returns the current GUI state for specific fields.""" + """ + Returns the current GUI state for specific fields. + [C: tests/test_ai_settings_layout.py:test_change_provider_via_hook, tests/test_ai_settings_layout.py:test_set_params_via_custom_callback, tests/test_conductor_api_hook_integration.py:simulate_conductor_phase_completion, tests/test_external_editor_gui.py:test_button_click_is_received, tests/test_external_editor_gui.py:test_patch_modal_shows_with_configured_editor, tests/test_external_editor_gui.py:test_vscode_launches_with_diff_view, tests/test_gui_text_viewer.py:test_text_viewer_state_update, tests/test_hooks.py:test_live_hook_server_responses, tests/test_live_gui_integration_v2.py:test_api_gui_state_live, tests/test_live_workflow.py:test_full_live_workflow, tests/test_live_workflow.py:wait_for_value, tests/test_patch_modal_gui.py:test_patch_apply_modal_workflow, tests/test_patch_modal_gui.py:test_patch_modal_appears_on_trigger, tests/test_rag_phase4_final_verify.py:test_phase4_final_verify, tests/test_rag_phase4_stress.py:test_rag_large_codebase_verification_sim, tests/test_saved_presets_sim.py:test_preset_manager_modal, tests/test_saved_presets_sim.py:test_preset_switching, tests/test_task_dag_popout_sim.py:test_task_dag_popout, tests/test_tool_management_layout.py:test_tool_management_gettable_fields, tests/test_tool_management_layout.py:test_tool_management_state_updates, tests/test_tool_presets_sim.py:test_tool_preset_switching, tests/test_usage_analytics_popout_sim.py:test_usage_analytics_popout] + """ gettable = getattr(self, "_gettable_fields", {}) state = {} import dataclasses @@ -1948,7 +2038,10 @@ class AppController: @api.get("/api/gui/mma_status", dependencies=[Depends(get_api_key)]) def get_mma_status() -> dict[str, Any]: - """Dedicated endpoint for MMA-related status.""" + """ + Dedicated endpoint for MMA-related status. + [C: tests/test_headless_simulation.py:test_mma_track_lifecycle_simulation, tests/test_live_workflow.py:test_full_live_workflow, tests/test_mma_concurrent_tracks_sim.py:_poll_mma_status, tests/test_mma_concurrent_tracks_sim.py:test_mma_concurrent_tracks_execution, tests/test_mma_concurrent_tracks_stress_sim.py:test_mma_concurrent_tracks_stress, tests/test_mma_step_mode_sim.py:_poll_mma_status, tests/test_mma_step_mode_sim.py:test_mma_step_mode_approval_flow, tests/test_visual_orchestration.py:test_mma_epic_lifecycle, tests/test_visual_sim_gui_ux.py:test_gui_ux_event_routing, tests/test_visual_sim_mma_v2.py:_poll] + """ return { "mma_status": self.mma_status, "ai_status": self.ai_status, @@ -1963,7 +2056,10 @@ class AppController: @api.post("/api/gui", dependencies=[Depends(get_api_key)]) def post_gui(req: dict) -> dict[str, str]: - """Pushes a GUI task to the event queue.""" + """ + Pushes a GUI task to the event queue. + [C: tests/test_ai_settings_layout.py:test_set_params_via_custom_callback, tests/test_api_hook_client.py:test_post_gui_success, tests/test_gui2_parity.py:test_gui2_custom_callback_hook_works, tests/test_gui2_parity.py:test_gui2_set_value_hook_works, tests/test_visual_mma.py:test_visual_mma_components] + """ self.event_queue.put("gui_task", req) return {"status": "queued"} @@ -1988,7 +2084,10 @@ class AppController: @api.get("/api/performance", dependencies=[Depends(get_api_key)]) def get_performance() -> dict[str, Any]: - """Returns performance monitor metrics.""" + """ + Returns performance monitor metrics. + [C: tests/test_gui2_performance.py:test_performance_benchmarking, tests/test_gui_performance_requirements.py:test_idle_performance_requirements, tests/test_gui_stress_performance.py:test_comms_volume_stress_performance, tests/test_selectable_ui.py:test_selectable_label_stability, tests/test_visual_sim_gui_ux.py:test_gui_ux_event_routing] + """ return {"performance": self.perf_monitor.get_metrics()} @api.get("/api/gui/diagnostics", dependencies=[Depends(get_api_key)]) @@ -2107,7 +2206,10 @@ class AppController: @api.get("/api/v1/sessions/{session_id}", dependencies=[Depends(get_api_key)]) def get_session(session_id: str) -> dict[str, Any]: - """Returns the content of the comms.log for a specific session.""" + """ + Returns the content of the comms.log for a specific session. + [C: simulation/ping_pong.py:main, simulation/sim_context.py:ContextSimulation.run, simulation/sim_execution.py:ExecutionSimulation.run, simulation/sim_tools.py:ToolsSimulation.run, simulation/workflow_sim.py:WorkflowSimulator.run_discussion_turn_async, simulation/workflow_sim.py:WorkflowSimulator.wait_for_ai_response, tests/test_api_hook_client.py:test_get_session_success, tests/test_gui_stress_performance.py:test_comms_volume_stress_performance, tests/test_live_workflow.py:test_full_live_workflow, tests/test_rag_phase4_final_verify.py:test_phase4_final_verify, tests/test_rag_phase4_stress.py:test_rag_large_codebase_verification_sim] + """ log_path = paths.get_logs_dir() / session_id / "comms.log" if not log_path.exists(): raise HTTPException(status_code=404, detail="Session log not found") @@ -2162,15 +2264,24 @@ class AppController: self.ai_status = "config saved" def _cb_reset_base_prompt(self, user_data=None) -> None: + """ + [C: src/gui_2.py:App._render_system_prompts_panel] + """ self.ui_base_system_prompt = ai_client._SYSTEM_PROMPT self.ui_use_default_base_prompt = False def _cb_clear_summary_cache(self, user_data=None) -> None: + """ + [C: src/gui_2.py:App._render_files_panel] + """ from src import summarize summarize._summary_cache.clear() self._push_mma_state_update() def _cb_show_base_prompt_diff(self, user_data=None) -> None: + """ + [C: src/gui_2.py:App._render_system_prompts_panel] + """ self._show_base_prompt_diff_modal = True def _cb_disc_create(self) -> None: @@ -2180,6 +2291,9 @@ class AppController: self.ui_disc_new_name_input = "" def _switch_project(self, path: str) -> None: + """ + [C: src/gui_2.py:App._render_projects_panel] + """ if not Path(path).exists(): self.ai_status = f"project file not found: {path}" return @@ -2200,6 +2314,9 @@ class AppController: def _refresh_from_project(self) -> None: # Deserialize FileItems in files.paths + """ + [C: tests/test_mma_dashboard_refresh.py:test_mma_dashboard_initialization_refresh, tests/test_mma_dashboard_refresh.py:test_mma_dashboard_refresh] + """ raw_paths = self.project.get("files", {}).get("paths", []) self.files = [] for p in raw_paths: @@ -2279,6 +2396,9 @@ class AppController: self._rebuild_rag_index() def _cb_save_workspace_profile(self, name: str, scope: str = 'project') -> None: + """ + [C: src/gui_2.py:App._render_save_workspace_profile_modal] + """ if not hasattr(self, '_app') or not self._app: return profile = self._app._capture_workspace_profile(name) @@ -2287,18 +2407,27 @@ class AppController: self._app.workspace_profiles = self.workspace_profiles def _cb_delete_workspace_profile(self, name: str, scope: str = 'project') -> None: + """ + [C: src/gui_2.py:App._show_menus] + """ self.workspace_manager.delete_profile(name, scope=scope) self.workspace_profiles = self.workspace_manager.load_all_profiles() if hasattr(self, '_app') and self._app: self._app.workspace_profiles = self.workspace_profiles def _cb_load_workspace_profile(self, name: str) -> None: + """ + [C: src/gui_2.py:App._show_menus] + """ if name in self.workspace_profiles: profile = self.workspace_profiles[name] if hasattr(self, '_app') and self._app: self._app._apply_workspace_profile(profile) def _apply_preset(self, name: str, scope: str) -> None: + """ + [C: src/gui_2.py:App._render_system_prompts_panel] + """ print(f"[DEBUG] _apply_preset: name={name}, scope={scope}") if name == "None": if scope == "global": @@ -2318,6 +2447,9 @@ class AppController: self.ui_project_preset_name = name def _cb_save_preset(self, name, content, scope): + """ + [C: src/gui_2.py:App._render_preset_manager_content] + """ print(f"[DEBUG] _cb_save_preset: name={name}, scope={scope}") if not name or not name.strip(): raise ValueError("Preset name cannot be empty or whitespace.") @@ -2330,19 +2462,31 @@ class AppController: print(f"[DEBUG] _cb_save_preset: saved {name}, total presets now {len(self.presets)}") def _cb_delete_preset(self, name, scope): + """ + [C: src/gui_2.py:App._render_preset_manager_content] + """ self.preset_manager.delete_preset(name, scope) self.presets = self.preset_manager.load_all() def _cb_save_tool_preset(self, name, categories, scope): + """ + [C: src/gui_2.py:App._render_tool_preset_manager_content] + """ preset = models.ToolPreset(name=name, categories=categories) self.tool_preset_manager.save_preset(preset, scope) self.tool_presets = self.tool_preset_manager.load_all_presets() def _cb_delete_tool_preset(self, name, scope): + """ + [C: src/gui_2.py:App._render_tool_preset_manager_content] + """ self.tool_preset_manager.delete_preset(name, scope) self.tool_presets = self.tool_preset_manager.load_all_presets() def _cb_save_bias_profile(self, profile: models.BiasProfile, scope: str = "project"): + """ + [C: src/gui_2.py:App._render_tool_preset_manager_content] + """ self.tool_preset_manager.save_bias_profile(profile, scope) self.bias_profiles = self.tool_preset_manager.load_all_bias_profiles() @@ -2351,15 +2495,24 @@ class AppController: self.bias_profiles = self.tool_preset_manager.load_all_bias_profiles() def _cb_save_persona(self, persona: models.Persona, scope: str = "project") -> None: + """ + [C: src/gui_2.py:App._render_persona_editor_window] + """ self.persona_manager.save_persona(persona, scope) self.personas = self.persona_manager.load_all() def _cb_delete_persona(self, name: str, scope: str = "project") -> None: + """ + [C: src/gui_2.py:App._render_persona_editor_window] + """ self.persona_manager.delete_persona(name, scope) self.personas = self.persona_manager.load_all() def _cb_load_track(self, track_id: str) -> None: + """ + [C: src/gui_2.py:App._render_mma_dashboard] + """ state = project_manager.load_track_state(track_id, self.active_project_root) if state: try: @@ -2391,6 +2544,9 @@ class AppController: print(f"Error loading track {track_id}: {e}") def _save_active_project(self) -> None: + """ + [C: src/gui_2.py:App.delete_context_preset, src/gui_2.py:App.save_context_preset] + """ if self.active_project_path: try: cleaned = project_manager.clean_nones(self.project) @@ -2399,11 +2555,17 @@ class AppController: self.ai_status = f"save error: {e}" def _get_discussion_names(self) -> list[str]: + """ + [C: src/gui_2.py:App._render_discussion_panel] + """ disc_sec = self.project.get("discussion", {}) discussions = disc_sec.get("discussions", {}) return sorted(discussions.keys()) def _switch_discussion(self, name: str) -> None: + """ + [C: src/gui_2.py:App._render_discussion_panel, src/gui_2.py:App._render_takes_panel] + """ self._flush_disc_entries_to_project() disc_sec = self.project.get("discussion", {}) discussions = disc_sec.get("discussions", {}) @@ -2419,6 +2581,9 @@ class AppController: self.ai_status = f"discussion: {name}" def _flush_disc_entries_to_project(self) -> None: + """ + [C: src/gui_2.py:App._render_discussion_panel] + """ history_strings = [project_manager.entry_to_str(e) for e in self.disc_entries] if self.active_track and self._track_discussion_active: project_manager.save_track_history(self.active_track.id, history_strings, self.active_project_root) @@ -2430,6 +2595,9 @@ class AppController: disc_data["last_updated"] = project_manager.now_ts() def _create_discussion(self, name: str) -> None: + """ + [C: src/gui_2.py:App._render_discussion_panel, src/gui_2.py:App._render_synthesis_panel, src/gui_2.py:App._render_takes_panel] + """ disc_sec = self.project.setdefault("discussion", {}) discussions = disc_sec.setdefault("discussions", {}) if name in discussions: @@ -2439,6 +2607,9 @@ class AppController: self._switch_discussion(name) def _branch_discussion(self, index: int) -> None: + """ + [C: src/gui_2.py:App._render_discussion_panel] + """ self._flush_disc_entries_to_project() # Generate a unique branch name base_name = self.active_discussion.split("_take_")[0] @@ -2453,6 +2624,9 @@ class AppController: project_manager.branch_discussion(self.project, self.active_discussion, new_name, index) self._switch_discussion(new_name) def _rename_discussion(self, old_name: str, new_name: str) -> None: + """ + [C: src/gui_2.py:App._render_discussion_panel] + """ disc_sec = self.project.get("discussion", {}) discussions = disc_sec.get("discussions", {}) if old_name not in discussions: @@ -2466,6 +2640,9 @@ class AppController: disc_sec["active"] = new_name def _delete_discussion(self, name: str) -> None: + """ + [C: src/gui_2.py:App._render_discussion_panel] + """ disc_sec = self.project.get("discussion", {}) discussions = disc_sec.get("discussions", {}) if len(discussions) <= 1: @@ -2479,6 +2656,9 @@ class AppController: self._switch_discussion(remaining[0]) def _handle_mma_respond(self, approved: bool, payload: str | None = None, abort: bool = False, prompt: str | None = None, context_md: str | None = None) -> None: + """ + [C: src/gui_2.py:App._gui_func, src/gui_2.py:App._handle_approve_mma_step, src/gui_2.py:App._handle_approve_spawn] + """ if self._pending_mma_approvals: task = self._pending_mma_approvals.pop(0) dlg = task.get("dialog_container", [None])[0] @@ -2504,7 +2684,10 @@ class AppController: spawn_dlg._condition.notify_all() def _handle_approve_ask(self) -> None: - """Responds with approval for a pending /api/ask request.""" + """ + Responds with approval for a pending /api/ask request. + [C: src/gui_2.py:App.__init__, src/gui_2.py:App._gui_func] + """ if not self._ask_request_id: return request_id = self._ask_request_id @@ -2522,7 +2705,10 @@ class AppController: self._ask_tool_data = None def _handle_reject_ask(self) -> None: - """Responds with rejection for a pending /api/ask request.""" + """ + Responds with rejection for a pending /api/ask request. + [C: src/gui_2.py:App._gui_func] + """ if not self._ask_request_id: return request_id = self._ask_request_id @@ -2540,7 +2726,10 @@ class AppController: self._ask_tool_data = None def _handle_reset_session(self) -> None: - """Logic for resetting the AI session and GUI state.""" + """ + Logic for resetting the AI session and GUI state. + [C: src/gui_2.py:App._render_message_panel] + """ ai_client.reset_session() ai_client.clear_comms_log() self._tool_log.clear() @@ -2568,9 +2757,15 @@ class AppController: self._pending_gui_tasks.clear() def _handle_md_only(self) -> None: - """Logic for the 'MD Only' action.""" + """ + Logic for the 'MD Only' action. + [C: src/gui_2.py:App._render_message_panel] + """ def worker(): + """ + [C: tests/test_symbol_parsing.py:test_handle_generate_send_appends_definitions, tests/test_symbol_parsing.py:test_handle_generate_send_no_symbols] + """ try: md, path, *_ = self._do_generate() self.last_md = md @@ -2583,9 +2778,15 @@ class AppController: threading.Thread(target=worker, daemon=True).start() def _handle_generate_send(self) -> None: - """Logic for the 'Gen + Send' action.""" + """ + Logic for the 'Gen + Send' action. + [C: src/gui_2.py:App._render_message_panel, src/gui_2.py:App._render_synthesis_panel, src/gui_2.py:App._render_takes_panel, tests/test_gui_events_v2.py:test_handle_generate_send_pushes_event, tests/test_symbol_parsing.py:test_handle_generate_send_appends_definitions, tests/test_symbol_parsing.py:test_handle_generate_send_no_symbols] + """ def worker(): + """ + [C: tests/test_symbol_parsing.py:test_handle_generate_send_appends_definitions, tests/test_symbol_parsing.py:test_handle_generate_send_no_symbols] + """ sys.stderr.write("[DEBUG] _handle_generate_send worker started\n") sys.stderr.flush() try: @@ -2650,6 +2851,9 @@ class AppController: self._cached_files = stats.get("cached_files", []) def _refresh_api_metrics(self, payload: dict[str, Any], md_content: str | None = None) -> None: + """ + [C: tests/test_gui_updates.py:test_telemetry_data_updates_correctly] + """ if "latency" in payload: self.session_usage["last_latency"] = payload["latency"] if "usage" in payload and "percentage" in payload["usage"]: @@ -2674,11 +2878,17 @@ class AppController: self._cached_tool_stats = dict(self._tool_stats) def clear_cache(self) -> None: + """ + [C: src/gui_2.py:App._render_cache_panel] + """ from src import ai_client ai_client.cleanup() self._update_cached_stats() def get_session_insights(self) -> Dict[str, Any]: + """ + [C: src/gui_2.py:App._render_session_insights_panel] + """ from src import cost_tracker total_input = sum(e["input"] for e in self._token_history) total_output = sum(e["output"] for e in self._token_history) @@ -2701,6 +2911,9 @@ class AppController: } def _flush_to_project(self) -> None: + """ + [C: src/gui_2.py:App._gui_func, src/gui_2.py:App._render_discussion_panel, src/gui_2.py:App._render_projects_panel, src/gui_2.py:App._show_menus] + """ proj = self.project proj.setdefault("output", {})["output_dir"] = self.ui_output_dir proj.setdefault("files", {})["base_dir"] = self.ui_files_base_dir @@ -2738,6 +2951,9 @@ class AppController: project_manager.save_project(cleaned_proj, self.active_project_path) def _flush_to_config(self) -> None: + """ + [C: src/gui_2.py:App._gui_func, src/gui_2.py:App._render_discussion_panel, src/gui_2.py:App._render_projects_panel, src/gui_2.py:App._render_theme_panel, src/gui_2.py:App._show_menus, tests/test_system_prompt_exposure.py:TestSystemPromptExposure.test_app_controller_flush_saves_prompts] + """ self.config["ai"] = { "provider": self.current_provider, "model": self.current_model, @@ -2778,7 +2994,10 @@ class AppController: theme.save_to_config(self.config) 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). + [C: src/gui_2.py:App._show_menus, tests/test_tiered_aggregation.py:test_app_controller_do_generate_uses_persona_strategy] + """ self._flush_to_project() self._flush_to_config() models.save_config(self.config) @@ -2809,6 +3028,9 @@ class AppController: return full_md, path, file_items, stable_md, discussion_text def _cb_plan_epic(self) -> None: + """ + [C: src/gui_2.py:App._render_mma_dashboard, tests/test_mma_orchestration_gui.py:test_cb_plan_epic_launches_thread] + """ def _bg_task() -> None: sys.stderr.write("[DEBUG] _cb_plan_epic _bg_task started\n") sys.stderr.flush() @@ -2855,6 +3077,9 @@ class AppController: threading.Thread(target=_bg_task, daemon=True).start() def _cb_accept_tracks(self) -> None: + """ + [C: src/gui_2.py:App._render_track_proposal_modal] + """ self._show_track_proposal_modal = False def _bg_task() -> None: @@ -2896,6 +3121,9 @@ class AppController: threading.Thread(target=_bg_task, daemon=True).start() def _cb_start_track(self, user_data: Any = None) -> None: + """ + [C: src/gui_2.py:App._render_track_proposal_modal] + """ if isinstance(user_data, str): # If track_id is provided directly track_id = user_data @@ -3005,6 +3233,9 @@ class AppController: print(f"ERROR in _start_track_logic: {e}") def _cb_ticket_retry(self, ticket_id: str) -> None: + """ + [C: tests/test_mma_ticket_actions.py:test_cb_ticket_retry] + """ for t in self.active_tickets: if t.get('id') == ticket_id: t['status'] = 'todo' @@ -3012,6 +3243,9 @@ class AppController: self.event_queue.put("mma_retry", {"ticket_id": ticket_id}) def _cb_ticket_skip(self, ticket_id: str) -> None: + """ + [C: tests/test_mma_ticket_actions.py:test_cb_ticket_skip] + """ for t in self.active_tickets: if t.get('id') == ticket_id: t['status'] = 'skipped' @@ -3031,7 +3265,10 @@ class AppController: self.event_queue.put("mma_retry", {"ticket_id": ticket_id}) def kill_worker(self, worker_id: str) -> None: - """Aborts a running worker.""" + """ + Aborts a running worker. + [C: src/gui_2.py:App._cb_kill_ticket, tests/test_conductor_engine_abort.py:test_kill_worker_sets_abort_and_joins_thread] + """ engine = self.engines.get(self.active_track.id if self.active_track else None) if engine: engine.kill_worker(worker_id) @@ -3051,7 +3288,10 @@ class AppController: engine.resume() def inject_context(self, data: dict) -> None: - """Programmatic context injection.""" + """ + Programmatic context injection. + [C: tests/test_headless_simulation.py:test_mma_track_lifecycle_simulation] + """ file_path = data.get("file_path") if file_path: if not os.path.isabs(file_path): @@ -3097,6 +3337,9 @@ class AppController: self._push_mma_state_update() def _cb_run_conductor_setup(self) -> None: + """ + [C: src/gui_2.py:App._render_mma_dashboard, tests/test_gui_phase3.py:test_conductor_setup_scan] + """ base = paths.get_conductor_dir(project_path=self.active_project_root) if not base.exists(): self.ui_conductor_setup_summary = f"Error: {base}/ directory not found." @@ -3124,6 +3367,9 @@ class AppController: self.ui_conductor_setup_summary = "\n".join(summary) def _cb_create_track(self, name: str, desc: str, track_type: str) -> None: + """ + [C: src/gui_2.py:App._render_mma_dashboard, tests/test_gui_phase3.py:test_create_track] + """ if not name: return date_suffix = datetime.now().strftime("%Y%m%d") track_id = f"{name.lower().replace(' ', '_')}_{date_suffix}" @@ -3149,6 +3395,9 @@ class AppController: self.tracks = project_manager.get_all_tracks(self.active_project_root) def _push_mma_state_update(self) -> None: + """ + [C: src/gui_2.py:App._cb_block_ticket, src/gui_2.py:App._cb_unblock_ticket, src/gui_2.py:App._render_mma_dashboard, src/gui_2.py:App._render_task_dag_panel, src/gui_2.py:App._render_ticket_queue, src/gui_2.py:App._reorder_ticket, src/gui_2.py:App.bulk_block, src/gui_2.py:App.bulk_execute, src/gui_2.py:App.bulk_skip, tests/test_gui_phase4.py:test_push_mma_state_update] + """ if not self.active_track: return # Sync active_tickets (list of dicts) back to active_track.tickets (list of models.Ticket objects) @@ -3170,7 +3419,10 @@ class AppController: project_manager.save_track_state(self.active_track.id, state, self.active_project_root) def _load_active_tickets(self) -> None: - """Populates self.active_tickets based on the current execution mode.""" + """ + Populates self.active_tickets based on the current execution mode. + [C: tests/test_gui_dag_beads.py:test_load_active_tickets_from_beads] + """ if getattr(self, "ui_project_execution_mode", "native") == "beads": from src import beads_client bclient = beads_client.BeadsClient(Path(self.active_project_root)) @@ -3191,4 +3443,3 @@ class AppController: self.active_tickets = [asdict(t) if not isinstance(t, dict) else t for t in self.active_track.tickets] else: self.active_tickets = [] -