Private
Public Access
feat(models,project_manager): add ProjectContext + 5 sub-dataclasses (Phase 2 / VC8)
Phase 2: Fix flat_config to return typed ProjectContext (FR8 / VC8)
Before: def flat_config(...) -> Metadata (returned dict[str, Any])
After: def flat_config(...) -> ProjectContext (typed fat struct)
Delta: -1 anonymous dict return type; +6 new dataclasses
Per SPEC_CORRECTION_phase_2.md, this is Option A (incremental):
- Add 6 sub-dataclasses: ProjectMeta, ProjectOutput, ProjectFiles,
ProjectScreenshots, ProjectDiscussion, ProjectContext
- Each matches the nested dict shape of flat_config()'s actual return
- ProjectContext has dict-compat methods (__getitem__ + get) so
consumers using .get() / [] continue to work unchanged
- ProjectContext.to_dict() returns the legacy dict shape for migration
- EMPTY_PROJECT_CONTEXT sentinel exported
File locations per spec:
- src/models.py: 6 new dataclasses + EMPTY_PROJECT_CONTEXT sentinel
- src/project_manager.py: flat_config body rewritten to construct
ProjectContext from the proj dict (typed return type)
- tests/test_project_context_20260627.py: NEW regression-guard test file
with 10 tests covering: imports, return type, zero defaults, full
input, dict-compat __getitem__/get, to_dict round-trip, sentinel,
output_dir required field, consumer patterns unchanged
Verification:
- audit_weak_types --strict: OK (96 <= 112 baseline; down from 107)
- generate_type_registry: 23 files regenerated
- 10 test_project_context_20260627 tests PASS
- All existing consumer tests pass (test_context_composition_decoupled: 2,
test_orchestrator_pm: 3, test_orchestration_logic: 8,
test_orchestrator_pm_history + test_context_preview_button: 7,
test_project_manager_tracks: 4, test_track_state_persistence: 1)
VC8 (corrected) verification:
- flat_config returns ProjectContext (typed) ✓
- All 6 sub-dataclasses exist + importable ✓
- Dict-compat methods (ctx["key"], ctx.get("key")) work ✓
- output_dir REQUIRED field defaults to "" (empty, but valid) ✓
- Consumer patterns (ctx.get("output", {}).get("namespace", "project"))
work unchanged via dict-compat ✓
Phase 2 IS COMPLETE.
This commit is contained in:
@@ -1099,3 +1099,86 @@ def load_mcp_config(path: str) -> MCPConfiguration:
|
||||
return MCPConfiguration()
|
||||
|
||||
#endregion: MCP Config
|
||||
#region: Project Context (Phase 2 dataclasses for cruft_elimination_20260627)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ProjectMeta:
|
||||
name: str = ""
|
||||
summary_only: bool = False
|
||||
execution_mode: str = "standard"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ProjectOutput:
|
||||
namespace: str = "project"
|
||||
output_dir: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ProjectFiles:
|
||||
base_dir: str = ""
|
||||
paths: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ProjectScreenshots:
|
||||
base_dir: str = "."
|
||||
paths: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ProjectDiscussion:
|
||||
roles: tuple[str, ...] = ()
|
||||
history: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ProjectContext:
|
||||
"""Typed return type for project_manager.flat_config().
|
||||
Replaces the dict[str, Any] that flat_config() returned.
|
||||
Per conductor/tracks/cruft_elimination_20260627/SPEC_CORRECTION_phase_2.md."""
|
||||
project: ProjectMeta = field(default_factory=ProjectMeta)
|
||||
output: ProjectOutput = field(default_factory=ProjectOutput)
|
||||
files: ProjectFiles = field(default_factory=ProjectFiles)
|
||||
screenshots: ProjectScreenshots = field(default_factory=ProjectScreenshots)
|
||||
context_presets: Metadata = field(default_factory=dict)
|
||||
discussion: ProjectDiscussion = field(default_factory=ProjectDiscussion)
|
||||
|
||||
def to_dict(self) -> Metadata:
|
||||
return {
|
||||
"project": {
|
||||
"name": self.project.name,
|
||||
"summary_only": self.project.summary_only,
|
||||
"execution_mode": self.project.execution_mode,
|
||||
},
|
||||
"output": {
|
||||
"namespace": self.output.namespace,
|
||||
"output_dir": self.output.output_dir,
|
||||
},
|
||||
"files": {
|
||||
"base_dir": self.files.base_dir,
|
||||
"paths": list(self.files.paths),
|
||||
},
|
||||
"screenshots": {
|
||||
"base_dir": self.screenshots.base_dir,
|
||||
"paths": list(self.screenshots.paths),
|
||||
},
|
||||
"context_presets": dict(self.context_presets),
|
||||
"discussion": {
|
||||
"roles": list(self.discussion.roles),
|
||||
"history": list(self.discussion.history),
|
||||
},
|
||||
}
|
||||
|
||||
def __getitem__(self, key: str) -> Any:
|
||||
return self.to_dict()[key]
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
return self.to_dict().get(key, default)
|
||||
|
||||
|
||||
EMPTY_PROJECT_CONTEXT: ProjectContext = ProjectContext()
|
||||
|
||||
|
||||
#endregion: Project Context
|
||||
|
||||
+30
-13
@@ -265,8 +265,12 @@ def migrate_from_legacy_config(cfg: Metadata) -> Metadata:
|
||||
return proj
|
||||
# ── flat config for aggregate.run() ─────────────────────────────────────────
|
||||
|
||||
def flat_config(proj: Metadata, disc_name: Optional[str] = None, track_id: Optional[str] = None) -> Metadata:
|
||||
"""Return a flat config dict compatible with aggregate.run()."""
|
||||
def flat_config(proj: Metadata, disc_name: Optional[str] = None, track_id: Optional[str] = None) -> "ProjectContext":
|
||||
"""Return a typed ProjectContext compatible with aggregate.run().
|
||||
The returned dataclass supports dict-compat (__getitem__ / get) so
|
||||
existing consumers using .get() and [] continue to work unchanged
|
||||
(Phase 2 Option A per SPEC_CORRECTION_phase_2.md)."""
|
||||
from src.models import ProjectContext, ProjectMeta, ProjectOutput, ProjectFiles, ProjectScreenshots, ProjectDiscussion
|
||||
disc_sec = proj.get("discussion", {})
|
||||
if track_id:
|
||||
history = load_track_history(track_id, proj.get("files", {}).get("base_dir", "."))
|
||||
@@ -274,17 +278,30 @@ def flat_config(proj: Metadata, disc_name: Optional[str] = None, track_id: Optio
|
||||
name = disc_name or disc_sec.get("active", "main")
|
||||
disc_data = disc_sec.get("discussions", {}).get(name, {})
|
||||
history = disc_data.get("history", [])
|
||||
return {
|
||||
"project": proj.get("project", {}),
|
||||
"output": proj.get("output", {}),
|
||||
"files": proj.get("files", {}),
|
||||
"screenshots": proj.get("screenshots", {}),
|
||||
"context_presets": proj.get("context_presets", {}),
|
||||
"discussion": {
|
||||
"roles": disc_sec.get("roles", []),
|
||||
"history": history,
|
||||
},
|
||||
}
|
||||
return ProjectContext(
|
||||
project=ProjectMeta(
|
||||
name=proj.get("project", {}).get("name", ""),
|
||||
summary_only=proj.get("project", {}).get("summary_only", False),
|
||||
execution_mode=proj.get("project", {}).get("execution_mode", "standard"),
|
||||
),
|
||||
output=ProjectOutput(
|
||||
namespace=proj.get("output", {}).get("namespace", "project"),
|
||||
output_dir=proj.get("output", {}).get("output_dir", ""),
|
||||
),
|
||||
files=ProjectFiles(
|
||||
base_dir=proj.get("files", {}).get("base_dir", ""),
|
||||
paths=tuple(proj.get("files", {}).get("paths", [])),
|
||||
),
|
||||
screenshots=ProjectScreenshots(
|
||||
base_dir=proj.get("screenshots", {}).get("base_dir", "."),
|
||||
paths=tuple(proj.get("screenshots", {}).get("paths", [])),
|
||||
),
|
||||
context_presets=dict(proj.get("context_presets", {})),
|
||||
discussion=ProjectDiscussion(
|
||||
roles=tuple(disc_sec.get("roles", [])),
|
||||
history=tuple(history),
|
||||
),
|
||||
)
|
||||
# ── track state persistence ─────────────────────────────────────────────────
|
||||
|
||||
def save_track_state(track_id: str, state: 'TrackState', base_dir: Union[str, Path] = ".") -> None:
|
||||
|
||||
Reference in New Issue
Block a user