Private
Public Access
0
0

docs(spec): correct Phase 2 ProjectContext field shape for cruft_elimination_20260627

Tier 2 marked Phase 2 (VC8) as 'spec mismatch' because the spec says
'add ProjectContext with all fields observed in flat_config' but
doesn't enumerate which fields. Tier 2 needs the spec to be specific
before it can resume.

This correction specifies the exact schema based on the actual code:

flat_config returns a NESTED dict with 6 top-level fields:
- project     (Meta: name, summary_only, execution_mode)
- output      (Output: namespace, output_dir)
- files       (Files: base_dir, paths)
- screenshots (Screenshots: base_dir, paths)
- context_presets (opaque dict pass-through)
- discussion  (Discussion: roles, history)

The 11 sub-fields are derived from aggregate.run's access patterns
(src/aggregate.py:484-525). output_dir and files.base_dir are REQUIRED
(direct subscript); all others use .get() with defaults.

Recommended design: 6 sub-dataclasses (ProjectMeta, ProjectOutput,
ProjectFiles, ProjectScreenshots, ProjectDiscussion, ProjectContext),
each matching the nested dict shape. ProjectContext has dict-compat
methods (__getitem__ + get) so consumers don't need migration.

Two migration options:
- Option A (incremental): ProjectContext has dict-compat; consumers
  unchanged. Flat fix.
- Option B (full): Migrate all 8 consumer sites + 2 test mocks to
  use sub-dataclass access. ~40 lines across 10 files.

Acceptance: 5 corrected VC8 criteria. Tier 2 can resume Phase 2 directly.

TIER-1 READ conductor/tracks/cruft_elimination_20260627/spec.md + src/project_manager.py:268 + src/aggregate.py:484-525 + src/type_aliases.py + src/models.py before this commit.
This commit is contained in:
2026-06-26 05:36:36 -04:00
parent 0e6c067fd0
commit 7d59d3cf97
@@ -0,0 +1,281 @@
# SPEC CORRECTION: Phase 2 — ProjectContext Field Shape
**Track:** `cruft_elimination_20260627`
**Phase:** 2 (Fix `flat_config` to return typed `ProjectContext`)
**Date:** 2026-06-27
**Author:** Tier 1 (post-mortem of VC8 mismatch)
**Status:** Awaiting Tier 2 resumption
---
## TL;DR
The spec for Phase 2 says: "Add `ProjectContext` to `src/models.py` with all fields observed in `src/project_manager.py:flat_config`." This is underspecified. The actual `flat_config` returns a NESTED dict structure with 6 top-level fields, each with sub-fields. The spec doesn't enumerate which fields belong to `ProjectContext` (a flat dict) vs which are sub-objects.
This correction specifies the exact schema. Tier 2 can resume Phase 2 directly.
---
## Actual `flat_config` return shape (measured from `src/project_manager.py:268`)
```python
def flat_config(proj: Metadata, disc_name: Optional[str] = None, track_id: Optional[str] = None) -> Metadata:
...
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,
},
}
```
**Top-level keys** (the `Metadata` dict): `project`, `output`, `files`, `screenshots`, `context_presets`, `discussion`
**Sub-keys observed in `aggregate.run()`** (`src/aggregate.py:484-525`):
| Top-level key | Sub-key | Access pattern |
|---|---|---|
| `project` | `name` | `config.get("project", {}).get("name")` |
| `project` | `summary_only` | `config.get("project", {}).get("summary_only", False)` |
| `project` | `execution_mode` | `config.get("project", {}).get("execution_mode", "standard")` |
| `output` | `namespace` | `config.get("output", {}).get("namespace", "project")` |
| `output` | `output_dir` | `config["output"]["output_dir"]` (REQUIRED — direct subscript, not `.get`) |
| `files` | `base_dir` | `config["files"]["base_dir"]` (REQUIRED) |
| `files` | `paths` | `config["files"].get("paths", [])` |
| `screenshots` | `base_dir` | `config.get("screenshots", {}).get("base_dir", ".")` |
| `screenshots` | `paths` | `config.get("screenshots", {}).get("paths", [])` |
| `discussion` | `roles` | (passed through; not consumed by aggregate.run directly) |
| `discussion` | `history` | `config.get("discussion", {}).get("history", [])` |
| `context_presets` | (opaque dict) | (passed through to other consumers; not consumed by aggregate.run) |
`output_dir` and `files.base_dir` are accessed via **direct subscript** (`config["output"]["output_dir"]`, `config["files"]["base_dir"]`). All other fields use `.get()` with defaults. **Both patterns must be supported** by the dataclass design.
---
## Tier 2's design choice (recommended)
Use **6 top-level sub-dataclasses**, one per top-level key. Each sub-dataclass has its own fields. This matches the actual nested structure of `flat_config`.
```python
# src/models.py — add after existing dataclasses
@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 = "" # REQUIRED by aggregate.run
@dataclass(frozen=True, slots=True)
class ProjectFiles:
base_dir: str = "" # REQUIRED by aggregate.run
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() currently returns.
"""
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) # opaque pass-through
discussion: ProjectDiscussion = field(default_factory=ProjectDiscussion)
def to_dict(self) -> Metadata:
"""Convert back to the dict shape for backward compat with consumers
that use .get() / [] (aggregate.run et al)."""
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),
},
}
```
Then `flat_config()` becomes:
```python
def flat_config(proj: Metadata, disc_name: Optional[str] = None, track_id: Optional[str] = None) -> ProjectContext:
disc_sec = proj.get("discussion", {})
if track_id:
history = load_track_history(track_id, proj.get("files", {}).get("base_dir", "."))
else:
name = disc_name or disc_sec.get("active", "main")
disc_data = disc_sec.get("discussions", {}).get(name, {})
history = disc_data.get("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),
),
)
```
---
## Migration strategy (consumer side)
There are 8 consumer call sites of `flat_config()`:
- `src/aggregate.py:536`
- `src/api_hooks.py:173`
- `src/app_controller.py:4023, 4583, 4691, 4704, 4805`
- `src/gui_2.py:4456`
- `src/orchestrator_pm.py:133`
Plus 2 test mocks:
- `tests/test_context_composition_decoupled.py:34`
- `tests/test_context_preview_button.py:65`
**Two migration options** (Tier 2's choice):
### Option A (incremental, recommended): Add `to_dict()` to ProjectContext, leave consumers unchanged
The consumers use `.get()` and `[]` patterns on the dict. The dataclass's `to_dict()` produces the same shape. So:
```python
# Before:
flat = project_manager.flat_config(proj)
namespace = flat.get("project", {}).get("name") or flat.get("output", {}).get("namespace", "project")
# After (incremental):
flat = project_manager.flat_config(proj)
flat_dict = flat.to_dict() # unchanged consumer code uses flat_dict
namespace = flat_dict.get("project", {}).get("name") or flat_dict.get("output", {}).get("namespace", "project")
```
Then per-consumer migration: `flat = flat.to_dict()``flat = flat` (consumer directly uses the dataclass's `__getitem__`/`get` dict-compat methods — which already exist on the Metadata fat struct!)
Wait — `ProjectContext` is NOT a Metadata. The dataclass does NOT have `__getitem__`/`get`. So consumers that do `flat.get(...)` would FAIL on the bare dataclass.
**Fix:** give `ProjectContext` dict-compat methods too (or make it inherit from Metadata's pattern). But Metadata's `__getitem__` raises KeyError, and consumers use `.get()` with defaults. So `ProjectContext` needs `get()` and `__getitem__()`.
```python
@dataclass(frozen=True, slots=True)
class ProjectContext:
# ... fields ...
def __getitem__(self, key: str) -> Any:
return self.to_dict()[key] # always returns the dict
def get(self, key: str, default: Any = None) -> Any:
return self.to_dict().get(key, default)
def to_dict(self) -> Metadata:
# ... (as above)
```
This makes `flat.get(...)` work directly without `to_dict()` calls. Consumers migrate minimally: just remove the `.get(...)``flat_dict.get(...)` indirection.
### Option B (full migration): Migrate all 10 consumer sites to use `flat.project.name`, `flat.output.output_dir`, etc.
This is more thorough but touches 10 sites. Each consumer needs:
- Replace `flat.get("project", {}).get("name")` with `flat.project.name`
- Replace `flat["output"]["output_dir"]` with `flat.output.output_dir`
- Etc.
Each migration is mechanical. Total work: ~40 lines across 10 files. Plus regression-guard tests.
---
## Recommendation
**Option A** (incremental, dict-compat) is faster and lower-risk. Phase 2 just adds the dataclasses + dict-compat methods + changes `flat_config` return type. Consumer migration is deferred to a follow-up.
**Option B** is the "proper" fix (per the spec's spirit) but takes longer. Consumer migration touches the same files that the spec's other VCs touch (`aggregate.py`, `app_controller.py`, etc.).
**Tier 2 should pick one and document the choice in the next track commit.**
---
## Acceptance criteria (corrected Phase 2)
After this correction is applied:
| VC | Description | Verification |
|---|---|---|
| VC8 (corrected) | `flat_config` returns typed `ProjectContext` | `from src.models import ProjectContext; from src.project_manager import flat_config; from src.models import Metadata; proj = Metadata(); ctx = flat_config(proj); assert isinstance(ctx, ProjectContext)` |
| VC8 (corrected) | All 6 sub-dataclasses exist | `from src.models import ProjectMeta, ProjectOutput, ProjectFiles, ProjectScreenshots, ProjectDiscussion, ProjectContext; assert all 6 importable` |
| VC8 (corrected) | Consumers unchanged (Option A) | `tests/test_project_manager_*.py` all pass without modification |
| VC8 (corrected) | Dict-compat works | `ctx = flat_config(Metadata()); assert ctx.get("project") == {} # default empty; or matches proj.get("project"))` |
| VC8 (corrected) | `output_dir` REQUIRED field works | `flat_config(Metadata())` returns `ProjectContext` with `output.output_dir = ""` (the empty default); aggregate.run would fail with clear error when output_dir is empty (existing behavior, not a regression) |
---
## File locations
- `src/models.py` — add 6 new dataclasses (after existing dataclasses in the file)
- `src/project_manager.py` — change `flat_config` return type from `Metadata` to `ProjectContext`
- `src/aggregate.py` — NO CHANGE (Option A) or migrate to use sub-dataclass access (Option B)
- `tests/test_project_context_20260627.py` — NEW regression-guard test file with 8+ tests covering the dataclass + dict-compat methods
---
## See also
- `conductor/tracks/cruft_elimination_20260627/spec.md` — the original spec (Phase 2 section, lines ~95-120)
- `src/project_manager.py:268``flat_config()` actual definition
- `src/aggregate.py:484-525``aggregate.run()` consumer (the key reference for which fields are REQUIRED)
- `src/type_aliases.py` — the wire-format `Metadata` dataclass (similar pattern for dict-compat)
- `conductor/code_styleguides/data_oriented_design.md` — the "Prefer Fewer Types" principle