Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7924d65438 | |||
| 3999e9c86d | |||
| 48e2ed852a |
@@ -33,7 +33,7 @@ For deep implementation details when planning or implementing tracks, consult `d
|
|||||||
- **Track Browser:** Real-time visualization of all implementation tracks with status indicators and progress bars. Includes a dedicated **Active Track Summary** featuring a color-coded progress bar, precise ticket status breakdown (Completed, In Progress, Blocked, Todo), and dynamic **ETA estimation** based on historical completion times.
|
- **Track Browser:** Real-time visualization of all implementation tracks with status indicators and progress bars. Includes a dedicated **Active Track Summary** featuring a color-coded progress bar, precise ticket status breakdown (Completed, In Progress, Blocked, Todo), and dynamic **ETA estimation** based on historical completion times.
|
||||||
- **Visual Task DAG:** An interactive, node-based visualizer for the active track's task dependencies using `imgui-node-editor`. Features color-coded state tracking (Ready, Running, Blocked, Done), drag-and-drop dependency creation, and right-click deletion.
|
- **Visual Task DAG:** An interactive, node-based visualizer for the active track's task dependencies using `imgui-node-editor`. Features color-coded state tracking (Ready, Running, Blocked, Done), drag-and-drop dependency creation, and right-click deletion.
|
||||||
- **Strategy Visualization:** Dedicated real-time output streams for Tier 1 (Strategic Planning) and Tier 2/3 (Execution) agents, allowing the user to follow the agent's reasoning chains alongside the task DAG.
|
- **Strategy Visualization:** Dedicated real-time output streams for Tier 1 (Strategic Planning) and Tier 2/3 (Execution) agents, allowing the user to follow the agent's reasoning chains alongside the task DAG.
|
||||||
- **Track-Scoped State Management:** Segregates discussion history and task progress into per-track state files (e.g., `conductor/tracks/<track_id>/state.toml`). This prevents global context pollution and ensures the Tech Lead session is isolated to the specific track's objective.
|
- **Track-Scoped State Management:** Segregates discussion history and task progress into per-track state files. Supports **Project-Specific Conductor Directories**, allowing projects to define their own conductor path in `manual_slop.toml` (`[conductor].dir`) for isolated track management. This prevents global context pollution and ensures the Tech Lead session is isolated to the specific track's objective.
|
||||||
**Native DAG Execution Engine:** Employs a Python-based Directed Acyclic Graph (DAG) engine to manage complex task dependencies. Supports automated topological sorting, robust cycle detection, and **transitive blocking propagation** (cascading `blocked` status to downstream dependents to prevent execution stalls).
|
**Native DAG Execution Engine:** Employs a Python-based Directed Acyclic Graph (DAG) engine to manage complex task dependencies. Supports automated topological sorting, robust cycle detection, and **transitive blocking propagation** (cascading `blocked` status to downstream dependents to prevent execution stalls).
|
||||||
|
|
||||||
- **Programmable Execution State machine:** Governing the transition between "Auto-Queue" (autonomous worker spawning) and "Step Mode" (explicit manual approval for each task transition).
|
- **Programmable Execution State machine:** Governing the transition between "Auto-Queue" (autonomous worker spawning) and "Step Mode" (explicit manual approval for each task transition).
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
|
|
||||||
- **ai_style_formatter.py:** Custom Python formatter specifically designed to enforce 1-space indentation and ultra-compact whitespace to minimize token consumption.
|
- **ai_style_formatter.py:** Custom Python formatter specifically designed to enforce 1-space indentation and ultra-compact whitespace to minimize token consumption.
|
||||||
|
|
||||||
- **src/paths.py:** Centralized module for path resolution, allowing directory paths (logs, conductor, scripts) to be configured via `config.toml` or environment variables, eliminating hardcoded filesystem dependencies.
|
- **src/paths.py:** Centralized module for path resolution. Supports project-specific conductor directory overrides via project TOML (`[conductor].dir`), enabling isolated track management per project. All paths are resolved to absolute objects. Path configuration (logs, conductor, scripts) can also be configured via `config.toml` or environment variables, eliminating hardcoded filesystem dependencies.
|
||||||
|
|
||||||
- **src/presets.py:** Implements `PresetManager` for high-performance CRUD operations on system prompt presets stored in TOML format (`presets.toml`, `project_presets.toml`). Supports dynamic path resolution and scope-based inheritance.
|
- **src/presets.py:** Implements `PresetManager` for high-performance CRUD operations on system prompt presets stored in TOML format (`presets.toml`, `project_presets.toml`). Supports dynamic path resolution and scope-based inheritance.
|
||||||
|
|
||||||
|
|||||||
+12
-6
@@ -430,6 +430,12 @@ class AppController:
|
|||||||
if hasattr(self, 'perf_monitor'):
|
if hasattr(self, 'perf_monitor'):
|
||||||
self.perf_monitor.enabled = value
|
self.perf_monitor.enabled = value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def active_project_root(self) -> str:
|
||||||
|
if self.active_project_path:
|
||||||
|
return str(Path(self.active_project_path).parent)
|
||||||
|
return self.ui_files_base_dir
|
||||||
|
|
||||||
def _update_inject_preview(self) -> None:
|
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."""
|
||||||
if not self._inject_file_path:
|
if not self._inject_file_path:
|
||||||
@@ -1859,7 +1865,7 @@ class AppController:
|
|||||||
agent_tools_cfg = proj.get("agent", {}).get("tools", {})
|
agent_tools_cfg = proj.get("agent", {}).get("tools", {})
|
||||||
self.ui_agent_tools = {t: agent_tools_cfg.get(t, True) for t in models.AGENT_TOOL_NAMES}
|
self.ui_agent_tools = {t: agent_tools_cfg.get(t, True) for t in models.AGENT_TOOL_NAMES}
|
||||||
# MMA Tracks
|
# MMA Tracks
|
||||||
self.tracks = project_manager.get_all_tracks(self.ui_files_base_dir)
|
self.tracks = project_manager.get_all_tracks(self.active_project_root)
|
||||||
# Restore MMA state
|
# Restore MMA state
|
||||||
mma_sec = proj.get("mma", {})
|
mma_sec = proj.get("mma", {})
|
||||||
self.ui_epic_input = mma_sec.get("epic", "")
|
self.ui_epic_input = mma_sec.get("epic", "")
|
||||||
@@ -1889,7 +1895,7 @@ class AppController:
|
|||||||
self.active_tickets = []
|
self.active_tickets = []
|
||||||
# Load track-scoped history if track is active
|
# Load track-scoped history if track is active
|
||||||
if self.active_track:
|
if self.active_track:
|
||||||
track_history = project_manager.load_track_history(self.active_track.id, self.ui_files_base_dir)
|
track_history = project_manager.load_track_history(self.active_track.id, self.active_project_root)
|
||||||
if track_history:
|
if track_history:
|
||||||
with self._disc_entries_lock:
|
with self._disc_entries_lock:
|
||||||
self.disc_entries = models.parse_history_entries(track_history, self.disc_roles)
|
self.disc_entries = models.parse_history_entries(track_history, self.disc_roles)
|
||||||
@@ -1962,7 +1968,7 @@ class AppController:
|
|||||||
|
|
||||||
|
|
||||||
def _cb_load_track(self, track_id: str) -> None:
|
def _cb_load_track(self, track_id: str) -> None:
|
||||||
state = project_manager.load_track_state(track_id, self.ui_files_base_dir)
|
state = project_manager.load_track_state(track_id, self.active_project_root)
|
||||||
if state:
|
if state:
|
||||||
try:
|
try:
|
||||||
# Convert list[Ticket] or list[dict] to list[Ticket] for Track object
|
# Convert list[Ticket] or list[dict] to list[Ticket] for Track object
|
||||||
@@ -1980,7 +1986,7 @@ class AppController:
|
|||||||
# Keep dicts for UI table (or convert models.Ticket objects back to dicts if needed)
|
# Keep dicts for UI table (or convert models.Ticket objects back to dicts if needed)
|
||||||
self.active_tickets = [asdict(t) if not isinstance(t, dict) else t for t in tickets]
|
self.active_tickets = [asdict(t) if not isinstance(t, dict) else t for t in tickets]
|
||||||
# Load track-scoped history
|
# Load track-scoped history
|
||||||
history = project_manager.load_track_history(track_id, self.ui_files_base_dir)
|
history = project_manager.load_track_history(track_id, self.active_project_root)
|
||||||
with self._disc_entries_lock:
|
with self._disc_entries_lock:
|
||||||
if history:
|
if history:
|
||||||
self.disc_entries = models.parse_history_entries(history, self.disc_roles)
|
self.disc_entries = models.parse_history_entries(history, self.disc_roles)
|
||||||
@@ -2650,7 +2656,7 @@ class AppController:
|
|||||||
if not name: return
|
if not name: return
|
||||||
date_suffix = datetime.now().strftime("%Y%m%d")
|
date_suffix = datetime.now().strftime("%Y%m%d")
|
||||||
track_id = f"{name.lower().replace(' ', '_')}_{date_suffix}"
|
track_id = f"{name.lower().replace(' ', '_')}_{date_suffix}"
|
||||||
track_dir = paths.get_tracks_dir() / track_id
|
track_dir = paths.get_track_state_dir(track_id, project_path=self.active_project_root)
|
||||||
track_dir.mkdir(parents=True, exist_ok=True)
|
track_dir.mkdir(parents=True, exist_ok=True)
|
||||||
spec_file = track_dir / "spec.md"
|
spec_file = track_dir / "spec.md"
|
||||||
with open(spec_file, "w", encoding="utf-8") as f:
|
with open(spec_file, "w", encoding="utf-8") as f:
|
||||||
@@ -2669,7 +2675,7 @@ class AppController:
|
|||||||
"progress": 0.0
|
"progress": 0.0
|
||||||
}, f, indent=1)
|
}, f, indent=1)
|
||||||
# Refresh tracks from disk
|
# Refresh tracks from disk
|
||||||
self.tracks = project_manager.get_all_tracks(self.ui_files_base_dir)
|
self.tracks = project_manager.get_all_tracks(self.active_project_root)
|
||||||
|
|
||||||
def _push_mma_state_update(self) -> None:
|
def _push_mma_state_update(self) -> None:
|
||||||
if not self.active_track:
|
if not self.active_track:
|
||||||
|
|||||||
+55
-24
@@ -18,17 +18,18 @@ Configuration (config.toml):
|
|||||||
|
|
||||||
Path Functions:
|
Path Functions:
|
||||||
get_config_path() -> Path to config.toml
|
get_config_path() -> Path to config.toml
|
||||||
get_conductor_dir() -> Path to conductor directory
|
get_conductor_dir(project_path=None) -> Path to conductor directory
|
||||||
get_logs_dir() -> Path to logs/sessions
|
get_logs_dir() -> Path to logs/sessions
|
||||||
get_scripts_dir() -> Path to scripts/generated
|
get_scripts_dir() -> Path to scripts/generated
|
||||||
get_tracks_dir() -> Path to conductor/tracks
|
get_tracks_dir(project_path=None) -> Path to conductor/tracks
|
||||||
get_track_state_dir(track_id) -> Path to conductor/tracks/<track_id>
|
get_track_state_dir(track_id, project_path=None) -> Path to conductor/tracks/<track_id>
|
||||||
get_archive_dir() -> Path to conductor/archive
|
get_archive_dir(project_path=None) -> Path to conductor/archive
|
||||||
|
|
||||||
Resolution Order:
|
Resolution Order:
|
||||||
1. Check environment variable
|
1. Check project-specific manual_slop.toml (for conductor paths)
|
||||||
2. Check config.toml [paths] section
|
2. Check environment variable
|
||||||
3. Fall back to default
|
3. Check config.toml [paths] section
|
||||||
|
4. Fall back to default
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
from src.paths import get_logs_dir, get_scripts_dir
|
from src.paths import get_logs_dir, get_scripts_dir
|
||||||
@@ -51,9 +52,11 @@ _RESOLVED: dict[str, Path] = {}
|
|||||||
def get_config_path() -> Path:
|
def get_config_path() -> Path:
|
||||||
root_dir = Path(__file__).resolve().parent.parent
|
root_dir = Path(__file__).resolve().parent.parent
|
||||||
return Path(os.environ.get("SLOP_CONFIG", root_dir / "config.toml"))
|
return Path(os.environ.get("SLOP_CONFIG", root_dir / "config.toml"))
|
||||||
|
|
||||||
def get_global_presets_path() -> Path:
|
def get_global_presets_path() -> Path:
|
||||||
root_dir = Path(__file__).resolve().parent.parent
|
root_dir = Path(__file__).resolve().parent.parent
|
||||||
return Path(os.environ.get("SLOP_GLOBAL_PRESETS", root_dir / "presets.toml"))
|
return Path(os.environ.get("SLOP_GLOBAL_PRESETS", root_dir / "presets.toml"))
|
||||||
|
|
||||||
def get_project_presets_path(project_root: Path) -> Path:
|
def get_project_presets_path(project_root: Path) -> Path:
|
||||||
return project_root / "project_presets.toml"
|
return project_root / "project_presets.toml"
|
||||||
|
|
||||||
@@ -72,18 +75,47 @@ def get_project_personas_path(project_root: Path) -> Path:
|
|||||||
return project_root / "project_personas.toml"
|
return project_root / "project_personas.toml"
|
||||||
|
|
||||||
def _resolve_path(env_var: str, config_key: str, default: str) -> Path:
|
def _resolve_path(env_var: str, config_key: str, default: str) -> Path:
|
||||||
|
root_dir = Path(__file__).resolve().parent.parent
|
||||||
|
p = None
|
||||||
if env_var in os.environ:
|
if env_var in os.environ:
|
||||||
return Path(os.environ[env_var])
|
p = Path(os.environ[env_var])
|
||||||
try:
|
else:
|
||||||
with open(get_config_path(), "rb") as f:
|
try:
|
||||||
cfg = tomllib.load(f)
|
with open(get_config_path(), "rb") as f:
|
||||||
if "paths" in cfg and config_key in cfg["paths"]:
|
cfg = tomllib.load(f)
|
||||||
return Path(cfg["paths"][config_key])
|
if "paths" in cfg and config_key in cfg["paths"]:
|
||||||
except FileNotFoundError:
|
p = Path(cfg["paths"][config_key])
|
||||||
pass
|
except (FileNotFoundError, tomllib.TOMLDecodeError):
|
||||||
return Path(default)
|
pass
|
||||||
|
if p is None:
|
||||||
|
p = Path(default)
|
||||||
|
if not p.is_absolute():
|
||||||
|
return root_dir / p
|
||||||
|
return p
|
||||||
|
|
||||||
def get_conductor_dir() -> Path:
|
def _get_project_conductor_dir_from_toml(project_root: Path) -> Optional[Path]:
|
||||||
|
# Look for manual_slop.toml in project_root
|
||||||
|
toml_path = project_root / 'manual_slop.toml'
|
||||||
|
if not toml_path.exists(): return None
|
||||||
|
try:
|
||||||
|
with open(toml_path, 'rb') as f:
|
||||||
|
data = tomllib.load(f)
|
||||||
|
# Check [conductor] dir = '...'
|
||||||
|
c_dir = data.get('conductor', {}).get('dir')
|
||||||
|
if c_dir:
|
||||||
|
p = Path(c_dir)
|
||||||
|
if not p.is_absolute(): p = project_root / p
|
||||||
|
return p
|
||||||
|
except: pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_conductor_dir(project_path: Optional[str] = None) -> Path:
|
||||||
|
if project_path:
|
||||||
|
project_root = Path(project_path)
|
||||||
|
p = _get_project_conductor_dir_from_toml(project_root)
|
||||||
|
if p: return p
|
||||||
|
return project_root / 'conductor'
|
||||||
|
|
||||||
if "conductor_dir" not in _RESOLVED:
|
if "conductor_dir" not in _RESOLVED:
|
||||||
_RESOLVED["conductor_dir"] = _resolve_path("SLOP_CONDUCTOR_DIR", "conductor_dir", "conductor")
|
_RESOLVED["conductor_dir"] = _resolve_path("SLOP_CONDUCTOR_DIR", "conductor_dir", "conductor")
|
||||||
return _RESOLVED["conductor_dir"]
|
return _RESOLVED["conductor_dir"]
|
||||||
@@ -98,16 +130,15 @@ def get_scripts_dir() -> Path:
|
|||||||
_RESOLVED["scripts_dir"] = _resolve_path("SLOP_SCRIPTS_DIR", "scripts_dir", "scripts/generated")
|
_RESOLVED["scripts_dir"] = _resolve_path("SLOP_SCRIPTS_DIR", "scripts_dir", "scripts/generated")
|
||||||
return _RESOLVED["scripts_dir"]
|
return _RESOLVED["scripts_dir"]
|
||||||
|
|
||||||
def get_tracks_dir() -> Path:
|
def get_tracks_dir(project_path: Optional[str] = None) -> Path:
|
||||||
return get_conductor_dir() / "tracks"
|
return get_conductor_dir(project_path) / "tracks"
|
||||||
|
|
||||||
def get_track_state_dir(track_id: str) -> Path:
|
def get_track_state_dir(track_id: str, project_path: Optional[str] = None) -> Path:
|
||||||
return get_tracks_dir() / track_id
|
return get_tracks_dir(project_path) / track_id
|
||||||
|
|
||||||
def get_archive_dir() -> Path:
|
def get_archive_dir(project_path: Optional[str] = None) -> Path:
|
||||||
return get_conductor_dir() / "archive"
|
return get_conductor_dir(project_path) / "archive"
|
||||||
|
|
||||||
def reset_resolved() -> None:
|
def reset_resolved() -> None:
|
||||||
"""For testing only - clear cached resolutions."""
|
"""For testing only - clear cached resolutions."""
|
||||||
_RESOLVED.clear()
|
_RESOLVED.clear()
|
||||||
|
|
||||||
|
|||||||
@@ -245,7 +245,7 @@ def save_track_state(track_id: str, state: 'TrackState', base_dir: Union[str, Pa
|
|||||||
"""
|
"""
|
||||||
Saves a TrackState object to conductor/tracks/<track_id>/state.toml.
|
Saves a TrackState object to conductor/tracks/<track_id>/state.toml.
|
||||||
"""
|
"""
|
||||||
track_dir = Path(base_dir) / paths.get_track_state_dir(track_id)
|
track_dir = paths.get_track_state_dir(track_id, project_path=str(base_dir))
|
||||||
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())
|
||||||
@@ -257,7 +257,7 @@ def load_track_state(track_id: str, base_dir: Union[str, Path] = ".") -> Optiona
|
|||||||
Loads a TrackState object from conductor/tracks/<track_id>/state.toml.
|
Loads a TrackState object from conductor/tracks/<track_id>/state.toml.
|
||||||
"""
|
"""
|
||||||
from src.models import TrackState
|
from src.models import TrackState
|
||||||
state_file = Path(base_dir) / paths.get_track_state_dir(track_id) / "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:
|
with open(state_file, "rb") as f:
|
||||||
@@ -302,7 +302,7 @@ def get_all_tracks(base_dir: Union[str, Path] = ".") -> list[dict[str, Any]]:
|
|||||||
Handles missing or malformed metadata.json or state.toml by falling back
|
Handles missing or malformed metadata.json or state.toml by falling back
|
||||||
to available info or defaults.
|
to available info or defaults.
|
||||||
"""
|
"""
|
||||||
tracks_dir = Path(base_dir) / paths.get_tracks_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]] = []
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import os
|
||||||
|
import pytest
|
||||||
|
import tomllib
|
||||||
|
import tomli_w
|
||||||
|
from pathlib import Path
|
||||||
|
from src import paths
|
||||||
|
|
||||||
|
def test_get_conductor_dir_default():
|
||||||
|
paths.reset_resolved()
|
||||||
|
# Should return default "conductor" relative to root
|
||||||
|
expected = Path(__file__).resolve().parent.parent / "conductor"
|
||||||
|
assert paths.get_conductor_dir() == expected
|
||||||
|
|
||||||
|
def test_get_conductor_dir_project_specific_no_toml(tmp_path):
|
||||||
|
paths.reset_resolved()
|
||||||
|
project_root = tmp_path / "my_project"
|
||||||
|
project_root.mkdir()
|
||||||
|
|
||||||
|
# Should default to project_root / "conductor" if no manual_slop.toml
|
||||||
|
res = paths.get_conductor_dir(project_path=str(project_root))
|
||||||
|
assert res == project_root / "conductor"
|
||||||
|
|
||||||
|
def test_get_conductor_dir_project_specific_with_toml(tmp_path):
|
||||||
|
paths.reset_resolved()
|
||||||
|
project_root = tmp_path / "my_project"
|
||||||
|
project_root.mkdir()
|
||||||
|
|
||||||
|
# Create manual_slop.toml with custom conductor dir
|
||||||
|
toml_path = project_root / "manual_slop.toml"
|
||||||
|
config = {
|
||||||
|
"conductor": {
|
||||||
|
"dir": "custom_tracks"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
with open(toml_path, "wb") as f:
|
||||||
|
f.write(tomli_w.dumps(config).encode())
|
||||||
|
|
||||||
|
res = paths.get_conductor_dir(project_path=str(project_root))
|
||||||
|
assert res == project_root / "custom_tracks"
|
||||||
|
|
||||||
|
def test_get_tracks_dir_project_specific(tmp_path):
|
||||||
|
paths.reset_resolved()
|
||||||
|
project_root = tmp_path / "my_project"
|
||||||
|
project_root.mkdir()
|
||||||
|
|
||||||
|
res = paths.get_tracks_dir(project_path=str(project_root))
|
||||||
|
assert res == project_root / "conductor" / "tracks"
|
||||||
|
|
||||||
|
def test_get_track_state_dir_project_specific(tmp_path):
|
||||||
|
paths.reset_resolved()
|
||||||
|
project_root = tmp_path / "my_project"
|
||||||
|
project_root.mkdir()
|
||||||
|
|
||||||
|
res = paths.get_track_state_dir("track-123", project_path=str(project_root))
|
||||||
|
assert res == project_root / "conductor" / "tracks" / "track-123"
|
||||||
Reference in New Issue
Block a user