more organization

This commit is contained in:
ed
2026-06-06 11:08:07 -04:00
parent 7d555361f9
commit 339b062913
9 changed files with 471 additions and 544 deletions
+6 -12
View File
@@ -154,10 +154,8 @@ def _is_allowed(path: Path) -> bool:
rp = path.resolve() rp = path.resolve()
# Blacklist check by resolved path # Blacklist check by resolved path
if rp == get_config_path().resolve(): if rp == get_config_path().resolve(): return False
return False if rp == get_credentials_path().resolve(): return False
if rp == get_credentials_path().resolve():
return False
name = path.name.lower() name = path.name.lower()
if name == "history.toml" or name.endswith("_history.toml"): if name == "history.toml" or name.endswith("_history.toml"):
@@ -209,10 +207,8 @@ def read_file(path: str) -> str:
p, err = _resolve_and_check(path) p, err = _resolve_and_check(path)
if err or p is None: if err or p is None:
return err return err
if not p.exists(): if not p.exists(): return f"ERROR: file not found: {path}"
return f"ERROR: file not found: {path}" if not p.is_file(): return f"ERROR: not a file: {path}"
if not p.is_file():
return f"ERROR: not a file: {path}"
try: try:
return p.read_text(encoding="utf-8") return p.read_text(encoding="utf-8")
except Exception as e: except Exception as e:
@@ -223,10 +219,8 @@ def list_directory(path: str) -> str:
p, err = _resolve_and_check(path) p, err = _resolve_and_check(path)
if err or p is None: if err or p is None:
return err return err
if not p.exists(): if not p.exists(): return f"ERROR: path not found: {path}"
return f"ERROR: path not found: {path}" if not p.is_dir(): return f"ERROR: not a directory: {path}"
if not p.is_dir():
return f"ERROR: not a directory: {path}"
try: try:
entries = sorted(p.iterdir(), key=lambda e: (e.is_file(), e.name.lower())) entries = sorted(p.iterdir(), key=lambda e: (e.is_file(), e.name.lower()))
lines = [f"Directory: {p}", ""] lines = [f"Directory: {p}", ""]
+8 -13
View File
@@ -21,7 +21,7 @@ Status Machine (Ticket):
Serialization: Serialization:
All dataclasses provide to_dict() and from_dict() class methods for TOML/JSON All dataclasses provide to_dict() and from_dict() class methods for TOML/JSON
persistence via project_manager.py. persistence via project_manager.py.
tomli_w
Thread Safety: Thread Safety:
These dataclasses are NOT thread-safe. Callers must synchronize mutations These dataclasses are NOT thread-safe. Callers must synchronize mutations
if sharing instances across threads (e.g., during ConductorEngine execution). if sharing instances across threads (e.g., during ConductorEngine execution).
@@ -43,6 +43,7 @@ import json
import os import os
import sys import sys
import tomllib import tomllib
import tomli_w
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
@@ -695,10 +696,8 @@ class ExternalEditorConfig:
""" """
editors = {} editors = {}
for name, ed_data in data.get("editors", {}).items(): for name, ed_data in data.get("editors", {}).items():
if isinstance(ed_data, dict): if isinstance(ed_data, dict): editors[name] = TextEditorConfig.from_dict(ed_data)
editors[name] = TextEditorConfig.from_dict(ed_data) elif isinstance(ed_data, str): editors[name] = TextEditorConfig(name=name, path=ed_data)
elif isinstance(ed_data, str):
editors[name] = TextEditorConfig(name=name, path=ed_data)
return cls(editors=editors, default_editor=data.get("default_editor")) return cls(editors=editors, default_editor=data.get("default_editor"))
#region: Persona #region: Persona
@@ -751,14 +750,10 @@ class Persona:
else: else:
processed.append(m) processed.append(m)
res["preferred_models"] = processed res["preferred_models"] = processed
if self.tool_preset is not None: if self.tool_preset is not None: res["tool_preset"] = self.tool_preset
res["tool_preset"] = self.tool_preset if self.bias_profile is not None: res["bias_profile"] = self.bias_profile
if self.bias_profile is not None: if self.context_preset is not None: res["context_preset"] = self.context_preset
res["bias_profile"] = self.bias_profile if self.aggregation_strategy is not None: res["aggregation_strategy"] = self.aggregation_strategy
if self.context_preset is not None:
res["context_preset"] = self.context_preset
if self.aggregation_strategy is not None:
res["aggregation_strategy"] = self.aggregation_strategy
return res return res
@classmethod @classmethod
-9
View File
@@ -62,8 +62,6 @@ class WorkerPool:
def spawn(self, ticket_id: str, target: Callable, args: tuple) -> Optional[threading.Thread]: def spawn(self, ticket_id: str, target: Callable, args: tuple) -> Optional[threading.Thread]:
""" """
Spawns a new worker thread if the pool is not full. Spawns a new worker thread if the pool is not full.
Returns the thread object or None if full. Returns the thread object or None if full.
[C: tests/test_parallel_execution.py:test_worker_pool_completion_cleanup, tests/test_parallel_execution.py:test_worker_pool_limit, tests/test_parallel_execution.py:test_worker_pool_tracking] [C: tests/test_parallel_execution.py:test_worker_pool_completion_cleanup, tests/test_parallel_execution.py:test_worker_pool_limit, tests/test_parallel_execution.py:test_worker_pool_tracking]
@@ -112,8 +110,6 @@ class WorkerPool:
class ConductorEngine: class ConductorEngine:
""" """
Orchestrates the execution of tickets within a track. Orchestrates the execution of tickets within a track.
""" """
@@ -154,7 +150,6 @@ class ConductorEngine:
def pause(self) -> None: def pause(self) -> None:
""" """
Pauses the pipeline execution. Pauses the pipeline execution.
[C: tests/test_pipeline_pause.py:test_pause_method, tests/test_pipeline_pause.py:test_resume_method] [C: tests/test_pipeline_pause.py:test_pause_method, tests/test_pipeline_pause.py:test_resume_method]
""" """
@@ -162,7 +157,6 @@ class ConductorEngine:
def resume(self) -> None: def resume(self) -> None:
""" """
Resumes the pipeline execution. Resumes the pipeline execution.
[C: tests/test_pipeline_pause.py:test_resume_method] [C: tests/test_pipeline_pause.py:test_resume_method]
""" """
@@ -170,7 +164,6 @@ class ConductorEngine:
def approve_task(self, task_id: str) -> None: def approve_task(self, task_id: str) -> None:
""" """
Manually transition todo to in_progress and mark engine dirty. Manually transition todo to in_progress and mark engine dirty.
[C: tests/test_execution_engine.py:test_execution_engine_approve_task, tests/test_execution_engine.py:test_execution_engine_step_mode] [C: tests/test_execution_engine.py:test_execution_engine_approve_task, tests/test_execution_engine.py:test_execution_engine_step_mode]
""" """
@@ -179,7 +172,6 @@ class ConductorEngine:
def update_task_status(self, task_id: str, status: str) -> None: def update_task_status(self, task_id: str, status: str) -> None:
""" """
Force-update ticket status and mark engine dirty. Force-update ticket status and mark engine dirty.
[C: tests/test_arch_boundary_phase3.py:TestArchBoundaryPhase3.test_manual_unblock_restores_todo, tests/test_execution_engine.py:test_execution_engine_auto_queue, tests/test_execution_engine.py:test_execution_engine_basic_flow, tests/test_execution_engine.py:test_execution_engine_status_persistence, tests/test_execution_engine.py:test_execution_engine_update_nonexistent_task] [C: tests/test_arch_boundary_phase3.py:TestArchBoundaryPhase3.test_manual_unblock_restores_todo, tests/test_execution_engine.py:test_execution_engine_auto_queue, tests/test_execution_engine.py:test_execution_engine_basic_flow, tests/test_execution_engine.py:test_execution_engine_status_persistence, tests/test_execution_engine.py:test_execution_engine_update_nonexistent_task]
""" """
@@ -188,7 +180,6 @@ class ConductorEngine:
def kill_worker(self, ticket_id: str) -> None: def kill_worker(self, ticket_id: str) -> None:
""" """
Sets the abort event for a worker and attempts to join its thread. Sets the abort event for a worker and attempts to join its thread.
[C: tests/test_conductor_engine_abort.py:test_kill_worker_sets_abort_and_joins_thread] [C: tests/test_conductor_engine_abort.py:test_kill_worker_sets_abort_and_joins_thread]
""" """
+3 -10
View File
@@ -12,8 +12,6 @@ from src import summarize
def get_track_history_summary() -> str: def get_track_history_summary() -> str:
""" """
Scans conductor/archive/ and conductor/tracks/ to build a summary of past work. Scans conductor/archive/ and conductor/tracks/ to build a summary of past work.
[C: tests/test_orchestrator_pm_history.py:TestOrchestratorPMHistory.test_get_track_history_summary, tests/test_orchestrator_pm_history.py:TestOrchestratorPMHistory.test_get_track_history_summary_missing_files] [C: tests/test_orchestrator_pm_history.py:TestOrchestratorPMHistory.test_get_track_history_summary, tests/test_orchestrator_pm_history.py:TestOrchestratorPMHistory.test_get_track_history_summary_missing_files]
""" """
@@ -21,13 +19,10 @@ def get_track_history_summary() -> str:
archive_path = paths.get_archive_dir() archive_path = paths.get_archive_dir()
tracks_path = paths.get_tracks_dir() tracks_path = paths.get_tracks_dir()
paths_to_scan = [] paths_to_scan = []
if archive_path.exists(): if archive_path.exists(): paths_to_scan.extend(list(archive_path.iterdir()))
paths_to_scan.extend(list(archive_path.iterdir())) if tracks_path.exists(): paths_to_scan.extend(list(tracks_path.iterdir()))
if tracks_path.exists():
paths_to_scan.extend(list(tracks_path.iterdir()))
for track_dir in paths_to_scan: for track_dir in paths_to_scan:
if not track_dir.is_dir(): if not track_dir.is_dir(): continue
continue
metadata_file = track_dir / "metadata.json" metadata_file = track_dir / "metadata.json"
spec_file = track_dir / "spec.md" spec_file = track_dir / "spec.md"
title = track_dir.name title = track_dir.name
@@ -60,8 +55,6 @@ def get_track_history_summary() -> str:
def generate_tracks(user_request: str, project_config: dict[str, Any], file_items: list[dict[str, Any]], history_summary: Optional[str] = None) -> list[dict[str, Any]]: def generate_tracks(user_request: str, project_config: dict[str, Any], file_items: list[dict[str, Any]], history_summary: Optional[str] = None) -> list[dict[str, Any]]:
""" """
Tier 1 (Strategic PM) call. Tier 1 (Strategic PM) call.
Analyzes the project state and user request to generate a list of Tracks. Analyzes the project state and user request to generate a list of Tracks.
[C: tests/test_orchestration_logic.py:test_generate_tracks, tests/test_orchestrator_pm.py:TestOrchestratorPM.test_generate_tracks_malformed_json, tests/test_orchestrator_pm.py:TestOrchestratorPM.test_generate_tracks_markdown_wrapped, tests/test_orchestrator_pm.py:TestOrchestratorPM.test_generate_tracks_success, tests/test_orchestrator_pm_history.py:TestOrchestratorPMHistory.test_generate_tracks_with_history] [C: tests/test_orchestration_logic.py:test_generate_tracks, tests/test_orchestrator_pm.py:TestOrchestratorPM.test_generate_tracks_malformed_json, tests/test_orchestrator_pm.py:TestOrchestratorPM.test_generate_tracks_markdown_wrapped, tests/test_orchestrator_pm.py:TestOrchestratorPM.test_generate_tracks_success, tests/test_orchestrator_pm_history.py:TestOrchestratorPMHistory.test_generate_tracks_with_history]
-2
View File
@@ -174,7 +174,6 @@ def get_conductor_dir(project_path: Optional[str] = None) -> Path:
project_root = Path(project_path).resolve() project_root = Path(project_path).resolve()
p = _get_project_conductor_dir_from_toml(project_root) p = _get_project_conductor_dir_from_toml(project_root)
if p: return p if p: return p
return (project_root / "conductor").resolve() return (project_root / "conductor").resolve()
def get_logs_dir() -> Path: def get_logs_dir() -> Path:
@@ -235,7 +234,6 @@ def get_full_path_info() -> dict[str, dict[str, Any]]:
def reset_resolved() -> None: def reset_resolved() -> None:
""" """
For testing only - clear cached resolutions. For testing only - clear cached resolutions.
[C: tests/conftest.py:reset_paths, tests/test_app_controller_offloading.py:tmp_session_dir, tests/test_gui_phase3.py:test_conductor_setup_scan, tests/test_paths.py:reset_paths, tests/test_project_paths.py:test_get_all_tracks_project_specific, tests/test_project_paths.py:test_get_conductor_dir_default, tests/test_project_paths.py:test_get_conductor_dir_project_specific_with_toml] [C: tests/conftest.py:reset_paths, tests/test_app_controller_offloading.py:tmp_session_dir, tests/test_gui_phase3.py:test_conductor_setup_scan, tests/test_paths.py:reset_paths, tests/test_project_paths.py:test_get_all_tracks_project_specific, tests/test_project_paths.py:test_get_conductor_dir_default, tests/test_project_paths.py:test_get_conductor_dir_project_specific_with_toml]
""" """
+3 -11
View File
@@ -89,8 +89,6 @@ def get_monitor() -> PerformanceMonitor:
class PerformanceMonitor: class PerformanceMonitor:
""" """
Tracks application performance metrics like FPS, frame time, and CPU usage. Tracks application performance metrics like FPS, frame time, and CPU usage.
Supports thread-safe tracking for individual components with efficient moving averages. Supports thread-safe tracking for individual components with efficient moving averages.
""" """
@@ -158,8 +156,7 @@ class PerformanceMonitor:
"""Thread-safe O(1) average retrieval.""" """Thread-safe O(1) average retrieval."""
with self._lock: with self._lock:
h = self._history.get(key) h = self._history.get(key)
if not h or len(h) == 0: if not h or len(h) == 0: return 0.0
return 0.0
return self._history_sums[key] / len(h) return self._history_sums[key] / len(h)
def start_frame(self) -> None: def start_frame(self) -> None:
@@ -229,15 +226,12 @@ class PerformanceMonitor:
with self._lock: with self._lock:
self._component_timings[name] = elapsed self._component_timings[name] = elapsed
self._component_counts[name] = self._component_counts.get(name, 0) + 1 self._component_counts[name] = self._component_counts.get(name, 0) + 1
if name not in self._component_max or elapsed > self._component_max[name]: if name not in self._component_max or elapsed > self._component_max[name]: self._component_max[name] = elapsed
self._component_max[name] = elapsed if name not in self._component_min or elapsed < self._component_min[name]: self._component_min[name] = elapsed
if name not in self._component_min or elapsed < self._component_min[name]:
self._component_min[name] = elapsed
self._add_to_history(f'comp_{name}', elapsed) self._add_to_history(f'comp_{name}', elapsed)
def get_metrics(self) -> dict[str, float]: def get_metrics(self) -> dict[str, float]:
""" """
Returns current metrics and their moving averages. Thread-safe. Returns current metrics and their moving averages. Thread-safe.
[C: tests/test_perf_aggregate.py:test_build_tier3_context_scaling, tests/test_perf_dag.py:test_dag_performance, tests/test_performance_monitor.py:test_perf_monitor_basic_timing, tests/test_performance_monitor.py:test_perf_monitor_component_timing, tests/test_performance_monitor.py:test_perf_monitor_extended_metrics, tests/test_performance_monitor.py:test_perf_monitor_scope_context_manager] [C: tests/test_perf_aggregate.py:test_build_tier3_context_scaling, tests/test_perf_dag.py:test_dag_performance, tests/test_performance_monitor.py:test_perf_monitor_basic_timing, tests/test_performance_monitor.py:test_perf_monitor_component_timing, tests/test_performance_monitor.py:test_perf_monitor_extended_metrics, tests/test_performance_monitor.py:test_perf_monitor_scope_context_manager]
""" """
@@ -274,7 +268,6 @@ class PerformanceMonitor:
def get_history(self, key: str) -> List[float]: def get_history(self, key: str) -> List[float]:
""" """
Returns a snapshot of the full history buffer for a specific metric key. Returns a snapshot of the full history buffer for a specific metric key.
[C: tests/test_history.py:test_initial_state, tests/test_history.py:test_push_state, tests/test_history_manager.py:TestHistoryManager.test_get_history_returns_descriptions] [C: tests/test_history.py:test_initial_state, tests/test_history.py:test_push_state, tests/test_history_manager.py:TestHistoryManager.test_get_history_returns_descriptions]
""" """
@@ -287,7 +280,6 @@ class PerformanceMonitor:
def scope(self, name: str) -> PerformanceScope: def scope(self, name: str) -> PerformanceScope:
""" """
Returns a context manager for timing a component. Returns a context manager for timing a component.
[C: tests/test_perf_aggregate.py:test_build_tier3_context_scaling, tests/test_performance_monitor.py:test_perf_monitor_scope_context_manager] [C: tests/test_perf_aggregate.py:test_build_tier3_context_scaling, tests/test_performance_monitor.py:test_perf_monitor_scope_context_manager]
""" """
-1
View File
@@ -28,7 +28,6 @@ class PersonaManager:
def load_all(self) -> Dict[str, Persona]: def load_all(self) -> Dict[str, Persona]:
""" """
Merges global and project personas into a single dictionary. Merges global and project personas into a single dictionary.
[C: tests/test_persona_manager.py:test_delete_persona, tests/test_persona_manager.py:test_load_all_merged, tests/test_persona_manager.py:test_save_persona, tests/test_preset_manager.py:test_delete_preset, tests/test_preset_manager.py:test_load_all_merged, tests/test_preset_manager.py:test_save_preset_global, tests/test_preset_manager.py:test_save_preset_project, tests/test_presets.py:TestPresetManager.test_delete_preset, tests/test_presets.py:TestPresetManager.test_project_overwrites_global, tests/test_presets.py:TestPresetManager.test_save_and_load_global, tests/test_presets.py:TestPresetManager.test_save_and_load_project] [C: tests/test_persona_manager.py:test_delete_persona, tests/test_persona_manager.py:test_load_all_merged, tests/test_persona_manager.py:test_save_persona, tests/test_preset_manager.py:test_delete_preset, tests/test_preset_manager.py:test_load_all_merged, tests/test_preset_manager.py:test_save_preset_global, tests/test_preset_manager.py:test_save_preset_project, tests/test_presets.py:TestPresetManager.test_delete_preset, tests/test_presets.py:TestPresetManager.test_project_overwrites_global, tests/test_presets.py:TestPresetManager.test_save_and_load_global, tests/test_presets.py:TestPresetManager.test_save_and_load_project]
""" """
-2
View File
@@ -22,7 +22,6 @@ class PresetManager:
def load_all(self) -> Dict[str, Preset]: def load_all(self) -> Dict[str, Preset]:
""" """
Merges global and project presets into a single dictionary. Merges global and project presets into a single dictionary.
[C: tests/test_persona_manager.py:test_delete_persona, tests/test_persona_manager.py:test_load_all_merged, tests/test_persona_manager.py:test_save_persona, tests/test_preset_manager.py:test_delete_preset, tests/test_preset_manager.py:test_load_all_merged, tests/test_preset_manager.py:test_save_preset_global, tests/test_preset_manager.py:test_save_preset_project, tests/test_presets.py:TestPresetManager.test_delete_preset, tests/test_presets.py:TestPresetManager.test_project_overwrites_global, tests/test_presets.py:TestPresetManager.test_save_and_load_global, tests/test_presets.py:TestPresetManager.test_save_and_load_project] [C: tests/test_persona_manager.py:test_delete_persona, tests/test_persona_manager.py:test_load_all_merged, tests/test_persona_manager.py:test_save_persona, tests/test_preset_manager.py:test_delete_preset, tests/test_preset_manager.py:test_load_all_merged, tests/test_preset_manager.py:test_save_preset_global, tests/test_preset_manager.py:test_save_preset_project, tests/test_presets.py:TestPresetManager.test_delete_preset, tests/test_presets.py:TestPresetManager.test_project_overwrites_global, tests/test_presets.py:TestPresetManager.test_save_and_load_global, tests/test_presets.py:TestPresetManager.test_save_and_load_project]
""" """
@@ -49,7 +48,6 @@ class PresetManager:
def save_preset(self, preset: Preset, scope: str = "project") -> None: def save_preset(self, preset: Preset, scope: str = "project") -> None:
""" """
Saves a preset to either the global or project-specific TOML file. Saves a preset to either the global or project-specific TOML file.
[C: tests/test_preset_manager.py:test_save_preset_global, tests/test_preset_manager.py:test_save_preset_project, tests/test_preset_manager.py:test_save_preset_project_no_root, tests/test_presets.py:TestPresetManager.test_delete_preset, tests/test_presets.py:TestPresetManager.test_project_overwrites_global, tests/test_presets.py:TestPresetManager.test_save_and_load_global, tests/test_presets.py:TestPresetManager.test_save_and_load_project] [C: tests/test_preset_manager.py:test_save_preset_global, tests/test_preset_manager.py:test_save_preset_project, tests/test_preset_manager.py:test_save_preset_project_no_root, tests/test_presets.py:TestPresetManager.test_delete_preset, tests/test_presets.py:TestPresetManager.test_project_overwrites_global, tests/test_presets.py:TestPresetManager.test_save_and_load_global, tests/test_presets.py:TestPresetManager.test_save_and_load_project]
""" """
+22 -55
View File
@@ -163,7 +163,6 @@ def default_project(name: str = "unnamed") -> dict[str, Any]:
def get_history_path(project_path: Union[str, Path]) -> Path: def get_history_path(project_path: Union[str, Path]) -> Path:
""" """
Return the Path to the sibling history TOML file for a given project. Return the Path to the sibling history TOML file for a given project.
[C: tests/test_history_management.py:test_save_separation] [C: tests/test_history_management.py:test_save_separation]
""" """
@@ -172,14 +171,11 @@ def get_history_path(project_path: Union[str, Path]) -> Path:
def load_project(path: Union[str, Path]) -> dict[str, Any]: def load_project(path: Union[str, Path]) -> dict[str, Any]:
""" """
Load a project TOML file. Load a project TOML file.
Automatically migrates legacy 'discussion' keys to a sibling history file. Automatically migrates legacy 'discussion' keys to a sibling history file.
[C: tests/test_history_management.py:test_history_persistence_across_turns, tests/test_history_management.py:test_migration_on_load, tests/test_project_manager_modes.py:test_load_save_execution_mode, tests/test_project_serialization.py:TestProjectSerialization.test_backward_compatibility_strings, tests/test_project_serialization.py:TestProjectSerialization.test_fileitem_roundtrip] [C: tests/test_history_management.py:test_history_persistence_across_turns, tests/test_history_management.py:test_migration_on_load, tests/test_project_manager_modes.py:test_load_save_execution_mode, tests/test_project_serialization.py:TestProjectSerialization.test_backward_compatibility_strings, tests/test_project_serialization.py:TestProjectSerialization.test_fileitem_roundtrip]
""" """
with open(path, "rb") as f: with open(path, "rb") as f: proj = tomllib.load(f)
proj = tomllib.load(f)
# Deserialise FileItems in files.paths # Deserialise FileItems in files.paths
if "files" in proj and "paths" in proj["files"]: if "files" in proj and "paths" in proj["files"]:
from src import models from src import models
@@ -198,7 +194,6 @@ def load_project(path: Union[str, Path]) -> dict[str, Any]:
def load_history(project_path: Union[str, Path]) -> dict[str, Any]: def load_history(project_path: Union[str, Path]) -> dict[str, Any]:
""" """
Load the segregated discussion history from its dedicated TOML file. Load the segregated discussion history from its dedicated TOML file.
[C: tests/test_thinking_persistence.py:test_save_and_load_history_with_thinking_segments] [C: tests/test_thinking_persistence.py:test_save_and_load_history_with_thinking_segments]
""" """
@@ -210,20 +205,15 @@ def load_history(project_path: Union[str, Path]) -> dict[str, Any]:
def clean_nones(data: Any) -> Any: def clean_nones(data: Any) -> Any:
""" """
Recursively remove None values from a dictionary/list. Recursively remove None values from a dictionary/list.
[C: tests/test_thinking_persistence.py:test_clean_nones_removes_thinking] [C: tests/test_thinking_persistence.py:test_clean_nones_removes_thinking]
""" """
if isinstance(data, dict): if isinstance(data, dict): return {k: clean_nones(v) for k, v in data.items() if v is not None}
return {k: clean_nones(v) for k, v in data.items() if v is not None} elif isinstance(data, list): return [clean_nones(v) for v in data if v is not None]
elif isinstance(data, list):
return [clean_nones(v) for v in data if v is not None]
return data return data
def save_project(proj: dict[str, Any], path: Union[str, Path], disc_data: Optional[dict[str, Any]] = None) -> None: def save_project(proj: dict[str, Any], path: Union[str, Path], disc_data: Optional[dict[str, Any]] = None) -> None:
""" """
Save the project TOML. Save the project TOML.
If 'discussion' is present in proj, it is moved to the sibling history file. If 'discussion' is present in proj, it is moved to the sibling history file.
[C: tests/test_history_management.py:test_history_persistence_across_turns, tests/test_history_management.py:test_save_separation, tests/test_project_manager_modes.py:test_load_save_execution_mode, tests/test_project_serialization.py:TestProjectSerialization.test_fileitem_roundtrip, tests/test_thinking_persistence.py:test_save_and_load_history_with_thinking_segments] [C: tests/test_history_management.py:test_history_persistence_across_turns, tests/test_history_management.py:test_save_separation, tests/test_project_manager_modes.py:test_load_save_execution_mode, tests/test_project_serialization.py:TestProjectSerialization.test_fileitem_roundtrip, tests/test_thinking_persistence.py:test_save_and_load_history_with_thinking_segments]
@@ -233,8 +223,7 @@ def save_project(proj: dict[str, Any], path: Union[str, Path], disc_data: Option
if "files" in proj and "paths" in proj["files"]: if "files" in proj and "paths" in proj["files"]:
proj["files"]["paths"] = [p.to_dict() if hasattr(p, "to_dict") else p for p in proj["files"]["paths"]] proj["files"]["paths"] = [p.to_dict() if hasattr(p, "to_dict") else p for p in proj["files"]["paths"]]
if "discussion" in proj: if "discussion" in proj:
if disc_data is None: if disc_data is None: disc_data = proj["discussion"]
disc_data = proj["discussion"]
proj = dict(proj) proj = dict(proj)
del proj["discussion"] del proj["discussion"]
proj = clean_nones(proj) proj = clean_nones(proj)
@@ -252,8 +241,7 @@ def migrate_from_legacy_config(cfg: dict[str, Any]) -> dict[str, Any]:
name = cfg.get("output", {}).get("namespace", "project") name = cfg.get("output", {}).get("namespace", "project")
proj = default_project(name) proj = default_project(name)
for key in ("output", "files", "screenshots"): for key in ("output", "files", "screenshots"):
if key in cfg: if key in cfg: proj[key] = dict(cfg[key])
proj[key] = dict(cfg[key])
disc = cfg.get("discussion", {}) disc = cfg.get("discussion", {})
proj["discussion"]["roles"] = disc.get("roles", ["User", "AI", "Vendor API", "System", "Context"]) proj["discussion"]["roles"] = disc.get("roles", ["User", "AI", "Vendor API", "System", "Context"])
main_disc = proj["discussion"]["discussions"]["main"] main_disc = proj["discussion"]["discussions"]["main"]
@@ -286,8 +274,6 @@ def flat_config(proj: dict[str, Any], disc_name: Optional[str] = None, track_id:
def save_track_state(track_id: str, state: 'TrackState', base_dir: Union[str, Path] = ".") -> None: def save_track_state(track_id: str, state: 'TrackState', base_dir: Union[str, Path] = ".") -> None:
""" """
Saves a TrackState object to conductor/tracks/<track_id>/state.toml. Saves a TrackState object to conductor/tracks/<track_id>/state.toml.
[C: tests/test_project_manager_tracks.py:test_get_all_tracks_with_state, tests/test_track_state_persistence.py:test_track_state_persistence] [C: tests/test_project_manager_tracks.py:test_get_all_tracks_with_state, tests/test_track_state_persistence.py:test_track_state_persistence]
""" """
@@ -295,47 +281,36 @@ def save_track_state(track_id: str, state: 'TrackState', base_dir: Union[str, Pa
track_dir.mkdir(parents=True, exist_ok=True) track_dir.mkdir(parents=True, exist_ok=True)
state_file = track_dir / "state.toml" state_file = track_dir / "state.toml"
data = clean_nones(state.to_dict()) data = clean_nones(state.to_dict())
with open(state_file, "wb") as f: with open(state_file, "wb") as f: tomli_w.dump(data, f)
tomli_w.dump(data, f)
def load_track_state(track_id: str, base_dir: Union[str, Path] = ".") -> Optional['TrackState']: def load_track_state(track_id: str, base_dir: Union[str, Path] = ".") -> Optional['TrackState']:
""" """
Loads a TrackState object from conductor/tracks/<track_id>/state.toml. Loads a TrackState object from conductor/tracks/<track_id>/state.toml.
[C: tests/test_track_state_persistence.py:test_track_state_persistence] [C: tests/test_track_state_persistence.py:test_track_state_persistence]
""" """
from src.models import TrackState from src.models import TrackState
state_file = paths.get_track_state_dir(track_id, project_path=str(base_dir)) / 'state.toml' state_file = paths.get_track_state_dir(track_id, project_path=str(base_dir)) / 'state.toml'
if not state_file.exists(): if not state_file.exists(): return None
return None with open(state_file, "rb") as f: data = tomllib.load(f)
with open(state_file, "rb") as f:
data = tomllib.load(f)
return TrackState.from_dict(data) return TrackState.from_dict(data)
def load_track_history(track_id: str, base_dir: Union[str, Path] = ".") -> list[str]: def load_track_history(track_id: str, base_dir: Union[str, Path] = ".") -> list[str]:
""" """
Loads the discussion history for a specific track from its state.toml. Loads the discussion history for a specific track from its state.toml.
Returns a list of entry strings formatted with @timestamp. Returns a list of entry strings formatted with @timestamp.
""" """
state = load_track_state(track_id, base_dir) state = load_track_state(track_id, base_dir)
if not state: if not state: return []
return []
history: list[str] = [] history: list[str] = []
for entry in state.discussion: for entry in state.discussion:
e = dict(entry) e = dict(entry)
ts = e.get("ts") ts = e.get("ts")
if isinstance(ts, datetime.datetime): if isinstance(ts, datetime.datetime): e["ts"] = ts.strftime(TS_FMT)
e["ts"] = ts.strftime(TS_FMT)
history.append(entry_to_str(e)) history.append(entry_to_str(e))
return history return history
def save_track_history(track_id: str, history: list[str], base_dir: Union[str, Path] = ".") -> None: def save_track_history(track_id: str, history: list[str], base_dir: Union[str, Path] = ".") -> None:
""" """
Saves the discussion history for a specific track to its state.toml. Saves the discussion history for a specific track to its state.toml.
'history' is expected to be a list of formatted strings. 'history' is expected to be a list of formatted strings.
""" """
@@ -349,8 +324,6 @@ def save_track_history(track_id: str, history: list[str], base_dir: Union[str, P
def get_all_tracks(base_dir: Union[str, Path] = ".") -> list[dict[str, Any]]: def get_all_tracks(base_dir: Union[str, Path] = ".") -> list[dict[str, Any]]:
""" """
Scans the conductor/tracks/ directory and returns a list of dictionaries Scans the conductor/tracks/ directory and returns a list of dictionaries
containing track metadata: 'id', 'title', 'status', 'complete', 'total', containing track metadata: 'id', 'title', 'status', 'complete', 'total',
and 'progress' (0.0 to 1.0). and 'progress' (0.0 to 1.0).
@@ -359,12 +332,12 @@ def get_all_tracks(base_dir: Union[str, Path] = ".") -> list[dict[str, Any]]:
[C: tests/test_project_manager_tracks.py:test_get_all_tracks_empty, tests/test_project_manager_tracks.py:test_get_all_tracks_malformed, tests/test_project_manager_tracks.py:test_get_all_tracks_with_metadata_json, tests/test_project_manager_tracks.py:test_get_all_tracks_with_state, tests/test_project_paths.py:test_get_all_tracks_project_specific] [C: tests/test_project_manager_tracks.py:test_get_all_tracks_empty, tests/test_project_manager_tracks.py:test_get_all_tracks_malformed, tests/test_project_manager_tracks.py:test_get_all_tracks_with_metadata_json, tests/test_project_manager_tracks.py:test_get_all_tracks_with_state, tests/test_project_paths.py:test_get_all_tracks_project_specific]
""" """
tracks_dir = paths.get_tracks_dir(project_path=str(base_dir)) tracks_dir = paths.get_tracks_dir(project_path=str(base_dir))
if not tracks_dir.exists(): if not tracks_dir.exists(): return []
return []
results: list[dict[str, Any]] = [] results: list[dict[str, Any]] = []
for entry in tracks_dir.iterdir(): for entry in tracks_dir.iterdir():
if not entry.is_dir(): if not entry.is_dir(): continue
continue
track_id = entry.name track_id = entry.name
track_info: dict[str, Any] = { track_info: dict[str, Any] = {
"id": track_id, "id": track_id,
@@ -375,6 +348,7 @@ def get_all_tracks(base_dir: Union[str, Path] = ".") -> list[dict[str, Any]]:
"progress": 0.0 "progress": 0.0
} }
state_found = False state_found = False
try: try:
state = load_track_state(track_id, base_dir) state = load_track_state(track_id, base_dir)
if state: if state:
@@ -388,6 +362,7 @@ def get_all_tracks(base_dir: Union[str, Path] = ".") -> list[dict[str, Any]]:
state_found = True state_found = True
except Exception: except Exception:
pass pass
if not state_found: if not state_found:
metadata_file = entry / "metadata.json" metadata_file = entry / "metadata.json"
if metadata_file.exists(): if metadata_file.exists():
@@ -399,6 +374,7 @@ def get_all_tracks(base_dir: Union[str, Path] = ".") -> list[dict[str, Any]]:
track_info["status"] = data.get("status", "unknown") track_info["status"] = data.get("status", "unknown")
except Exception: except Exception:
pass pass
if track_info["total"] == 0: if track_info["total"] == 0:
plan_file = entry / "plan.md" plan_file = entry / "plan.md"
if plan_file.exists(): if plan_file.exists():
@@ -413,13 +389,12 @@ def get_all_tracks(base_dir: Union[str, Path] = ".") -> list[dict[str, Any]]:
track_info["progress"] = float(track_info["complete"]) / track_info["total"] track_info["progress"] = float(track_info["complete"]) / track_info["total"]
except Exception: except Exception:
pass pass
results.append(track_info) results.append(track_info)
return results return results
def calculate_track_progress(tickets: list) -> dict: def calculate_track_progress(tickets: list) -> dict:
""" """
Calculates track progress based on ticket statuses. Calculates track progress based on ticket statuses.
percentage (float), completed (int), total (int), in_progress (int), blocked (int), todo (int) percentage (float), completed (int), total (int), in_progress (int), blocked (int), todo (int)
[C: tests/test_progress_viz.py:test_calculate_track_progress_all_completed, tests/test_progress_viz.py:test_calculate_track_progress_all_todo, tests/test_progress_viz.py:test_calculate_track_progress_empty, tests/test_progress_viz.py:test_calculate_track_progress_mixed] [C: tests/test_progress_viz.py:test_calculate_track_progress_all_completed, tests/test_progress_viz.py:test_calculate_track_progress_all_todo, tests/test_progress_viz.py:test_calculate_track_progress_empty, tests/test_progress_viz.py:test_calculate_track_progress_mixed]
@@ -439,7 +414,6 @@ def calculate_track_progress(tickets: list) -> dict:
in_progress = sum(1 for t in tickets if t.status == "in_progress") in_progress = sum(1 for t in tickets if t.status == "in_progress")
blocked = sum(1 for t in tickets if t.status == "blocked") blocked = sum(1 for t in tickets if t.status == "blocked")
todo = sum(1 for t in tickets if t.status == "todo") todo = sum(1 for t in tickets if t.status == "todo")
percentage = (completed / total) * 100.0 percentage = (completed / total) * 100.0
return { return {
@@ -454,16 +428,12 @@ def calculate_track_progress(tickets: list) -> dict:
def branch_discussion(project_dict: dict, source_id: str, new_id: str, message_index: int) -> None: def branch_discussion(project_dict: dict, source_id: str, new_id: str, message_index: int) -> None:
""" """
Creates a new discussion in project_dict['discussion']['discussions'] by copying Creates a new discussion in project_dict['discussion']['discussions'] by copying
the history from source_id up to (and including) message_index, and sets active to new_id. the history from source_id up to (and including) message_index, and sets active to new_id.
[C: tests/test_discussion_takes.py:TestDiscussionTakes.test_branch_discussion_creates_new_take] [C: tests/test_discussion_takes.py:TestDiscussionTakes.test_branch_discussion_creates_new_take]
""" """
if "discussion" not in project_dict or "discussions" not in project_dict["discussion"]: if "discussion" not in project_dict or "discussions" not in project_dict["discussion"]: return
return if source_id not in project_dict["discussion"]["discussions"]: return
if source_id not in project_dict["discussion"]["discussions"]:
return
source_disc = project_dict["discussion"]["discussions"][source_id] source_disc = project_dict["discussion"]["discussions"][source_id]
new_disc = default_discussion() new_disc = default_discussion()
@@ -476,14 +446,11 @@ def branch_discussion(project_dict: dict, source_id: str, new_id: str, message_i
def promote_take(project_dict: dict, take_id: str, new_id: str) -> None: def promote_take(project_dict: dict, take_id: str, new_id: str) -> None:
""" """
Renames a take_id to new_id in the discussions dict. Renames a take_id to new_id in the discussions dict.
[C: tests/test_discussion_takes.py:TestDiscussionTakes.test_promote_take_renames_discussion] [C: tests/test_discussion_takes.py:TestDiscussionTakes.test_promote_take_renames_discussion]
""" """
if "discussion" not in project_dict or "discussions" not in project_dict["discussion"]: if "discussion" not in project_dict or "discussions" not in project_dict["discussion"]: return
return if take_id not in project_dict["discussion"]["discussions"]: return
if take_id not in project_dict["discussion"]["discussions"]:
return
disc = project_dict["discussion"]["discussions"].pop(take_id) disc = project_dict["discussion"]["discussions"].pop(take_id)
project_dict["discussion"]["discussions"][new_id] = disc project_dict["discussion"]["discussions"][new_id] = disc