refactor(sdm): Refine SDM tags to 'External Only' and update core files. Pruned internal references to conserve tokens.

This commit is contained in:
2026-05-09 14:31:13 -04:00
parent e9ebcb859a
commit 696c08692e
5 changed files with 720 additions and 59 deletions
+1
View File
@@ -15,3 +15,4 @@ dpg_layout.ini
tests/temp_workspace
.mypy_cache
.slop_cache
sdm_report_refined.json
+173
View File
@@ -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()
+206
View File
@@ -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()
+62 -32
View File
@@ -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 <context> block instead of full file contents.
"""
Build a compact summary section using summarize.py — one short block per file.
Used as the initial <context> 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()
+278 -27
View File
@@ -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 = []