From f1c23c7da5dcfd6f3339387f5a1a6e434684aec8 Mon Sep 17 00:00:00 2001 From: Ed_ Date: Sun, 21 Jun 2026 15:46:25 -0400 Subject: [PATCH] conductor(plan): any_type_componentization_20260621 - 7 phases, 23 tasks, ~150 TDD steps Implements the 5 fat-struct candidates from docs/reports/ANY_TYPE_AUDIT_20260621.md: - Phase 0: JsonValue TypeAlias + audit_dataclass_coverage.py + styleguide section 12 - Phase 1: src/mcp_tool_specs.py (P1, 8 sites) - Phase 2: src/openai_schemas.py (P1, 17 sites) - Phase 3: src/provider_state.py (P2, 41 sites) - Phase 4: src/log_registry.py Session (P2, 7 sites) - Phase 5: src/api_hooks.py WebSocketMessage (P3, 16 sites) - Phase 6: verify + docs + archive Blocked by data_structure_strengthening_20260606 (pending merge). Sequencing: NOT blocked by code_path_audit_20260607 (orthogonal tracks). Tier 2 autonomous sandbox will execute via: /tier-2-auto-execute any_type_componentization_20260621 Spec: conductor/tracks/any_type_componentization_20260621/spec.md (approved 2026-06-21) Plan: this commit State: conductor/tracks/any_type_componentization_20260621/state.toml Metadata: conductor/tracks/any_type_componentization_20260621/metadata.json --- .../plan.md | 1557 +++++++++++++++++ 1 file changed, 1557 insertions(+) create mode 100644 conductor/tracks/any_type_componentization_20260621/plan.md diff --git a/conductor/tracks/any_type_componentization_20260621/plan.md b/conductor/tracks/any_type_componentization_20260621/plan.md new file mode 100644 index 00000000..8c67566d --- /dev/null +++ b/conductor/tracks/any_type_componentization_20260621/plan.md @@ -0,0 +1,1557 @@ +# Any-Type Componentization Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Promote 5 fat-struct candidates (89 sites) from `dict[str, Any]` / `list[dict[...]]` / module-globals to `dataclass(frozen=True)` definitions following the `src/vendor_capabilities.py` pattern. Add a `--strict` CI gate (`scripts/audit_dataclass_coverage.py`). Add styleguide §12 "When to Promote TypeAlias to dataclass". + +**Architecture:** 7 phases (1 scaffolding + 5 per-candidate + 1 verify/archive). Each phase is independent; cross-phase coupling is explicitly deferred. New module per P1/P2 candidate (`src/mcp_tool_specs.py`, `src/openai_schemas.py`, `src/provider_state.py`); P2/P3 candidates use inline dataclasses in existing files. New dataclasses expose `from_dict(cls, data: Metadata) -> Result[Self, ErrorInfo]` and `to_dict(self) -> Metadata` per the data-oriented convention. + +**Tech Stack:** Python 3.11+ stdlib (`dataclasses`, `threading`, `typing`). No new dependencies. New module additions follow `src/vendor_capabilities.py:64-76` template. + +**Reference Files:** +- `docs/reports/ANY_TYPE_AUDIT_20260621.md` — input audit (the "why") +- `conductor/tracks/any_type_componentization_20260621/spec.md` — the design (the "what") +- `src/vendor_capabilities.py:64-76` — the reference pattern +- `conductor/code_styleguides/type_aliases.md` — to extend with §12 +- `conductor/code_styleguides/error_handling.md` — `Result[T]` convention for `from_dict()` + +**Code Style:** 1-space indentation, CRLF line endings, no comments in source code, type hints mandatory (per `conductor/workflow.md` Code Style section). + +--- + +## File Structure + +``` +src/ + type_aliases.py # MODIFIED (Phase 0): + JsonPrimitive + JsonValue + vendor_capabilities.py # UNCHANGED (reference) + mcp_tool_specs.py # NEW (Phase 1): ToolParameter + ToolSpec + registry + openai_schemas.py # NEW (Phase 2): ToolCall + ChatMessage + UsageStats + provider_state.py # NEW (Phase 3): ProviderHistory + _PROVIDER_HISTORIES + mcp_client.py # MODIFIED (Phase 1): 8 sites + openai_compatible.py # MODIFIED (Phase 2): 17 sites + ai_client.py # MODIFIED (Phase 2+3): 41 sites + log_registry.py # MODIFIED (Phase 4): 7 sites + inline dataclasses + session_logger.py # MODIFIED (Phase 4): Session consumers + log_pruner.py # MODIFIED (Phase 4): Session consumers + gui_2.py # MODIFIED (Phase 4): Log Management panel + api_hooks.py # MODIFIED (Phase 5): WebSocketMessage + 16 sites + +scripts/ + audit_dataclass_coverage.py # NEW (Phase 0) + audit_dataclass_coverage.baseline.json # NEW (Phase 6) + +conductor/ + code_styleguides/ + type_aliases.md # MODIFIED (Phase 0): §12 + +tests/ + test_audit_dataclass_coverage.py # NEW (Phase 0) + test_mcp_tool_specs.py # NEW (Phase 1) + test_openai_schemas.py # NEW (Phase 2) + test_provider_state.py # NEW (Phase 3) + test_log_registry.py # MODIFIED (Phase 4): extend + test_api_hooks.py # MODIFIED (Phase 5): extend + +docs/ + type_registry/ # AUTO-GENERATED (Phase 6) + reports/TRACK_COMPLETION_*.md # NEW (Phase 6) +``` + +--- + +## Phase 0: Shared Scaffolding (3 tasks, ~3 commits) + +Focus: JsonValue TypeAlias + dataclass-coverage audit + styleguide §12. Additive only; no behavior change. + +### Task 0.1: Add JsonValue TypeAlias + +**Files:** +- Modify: `src/type_aliases.py` +- Test: `tests/test_type_aliases.py` (extend existing; 2 new tests) + +- [ ] **Step 1: Write failing tests** (extend `tests/test_type_aliases.py`) + +```python +def test_json_primitive_alias_resolves_to_union() -> None: + from src.type_aliases import JsonPrimitive + import typing + # Should resolve to a union of basic types + assert typing.get_origin(JsonPrimitive) is typing.Union + +def test_json_value_alias_is_recursive() -> None: + from src.type_aliases import JsonValue + # JsonValue must accept list[JsonValue] and dict[str, JsonValue] + # Use get_type_hints with include_extras=False to verify + import typing + hints = typing.get_type_hints(JsonValue, include_extras=False) + # The alias should contain 'list' and 'dict' in its string representation + assert "list" in str(JsonValue) + assert "dict" in str(JsonValue) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_type_aliases.py::test_json_primitive_alias_resolves_to_union tests/test_type_aliases.py::test_json_value_alias_is_recursive -v` +Expected: FAIL (NameError: cannot import name 'JsonPrimitive') + +- [ ] **Step 3: Add TypeAliases to `src/type_aliases.py`** (1-space indent) + +Append after the existing aliases (after line 19): +```python +JsonPrimitive: TypeAlias = str | int | float | bool | None + +JsonValue: TypeAlias = JsonPrimitive | list["JsonValue"] | dict[str, "JsonValue"] +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_type_aliases.py -v` +Expected: PASS (all 10 original + 2 new = 12 tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/type_aliases.py tests/test_type_aliases.py +git commit -m "feat(type_aliases): add JsonPrimitive + JsonValue recursive TypeAliases" +``` + +### Task 0.2: Create `scripts/audit_dataclass_coverage.py` + +**Files:** +- Create: `scripts/audit_dataclass_coverage.py` +- Test: `tests/test_audit_dataclass_coverage.py` + +- [ ] **Step 1: Write failing tests** + +Create `tests/test_audit_dataclass_coverage.py`: +```python +"""Tests for scripts/audit_dataclass_coverage.py.""" +import json +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).parent.parent + +def test_audit_script_runs_without_error() -> None: + """Audit script should run in informational mode and exit 0.""" + result = subprocess.run( + [sys.executable, "scripts/audit_dataclass_coverage.py"], + capture_output=True, text=True, cwd=REPO_ROOT, + ) + assert result.returncode == 0, f"stdout: {result.stdout}\nstderr: {result.stderr}" + assert "Anonymous Any audit" in result.stdout + +def test_audit_json_mode_produces_valid_json() -> None: + """--json mode should print machine-readable report.""" + result = subprocess.run( + [sys.executable, "scripts/audit_dataclass_coverage.py", "--json"], + capture_output=True, text=True, cwd=REPO_ROOT, + ) + assert result.returncode == 0 + report = json.loads(result.stdout) + assert "total_any" in report + assert "files_with_findings" in report + +def test_audit_strict_mode_exits_nonzero_when_regression() -> None: + """--strict mode should exit 1 when current count > baseline.""" + # Create a temporary baseline with a very low count (1) + import tempfile + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump({"total_any": 1, "files_with_findings": 1}, f) + baseline_path = f.name + try: + result = subprocess.run( + [sys.executable, "scripts/audit_dataclass_coverage.py", "--strict", + "--baseline", baseline_path], + capture_output=True, text=True, cwd=REPO_ROOT, + ) + assert result.returncode == 1, f"expected exit 1, got {result.returncode}" + finally: + Path(baseline_path).unlink() + +def test_audit_human_readable_output_includes_summary() -> None: + """Informational mode output should include summary stats.""" + result = subprocess.run( + [sys.executable, "scripts/audit_dataclass_coverage.py"], + capture_output=True, text=True, cwd=REPO_ROOT, + ) + assert "Total Any findings" in result.stdout + assert "Files with findings" in result.stdout +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_audit_dataclass_coverage.py -v` +Expected: FAIL (FileNotFoundError: scripts/audit_dataclass_coverage.py) + +- [ ] **Step 3: Implement `scripts/audit_dataclass_coverage.py`** + +Mirror the structure of `scripts/audit_weak_types.py` (read it first for the exact regex patterns and Finding dataclass shape). Key requirements: + +```python +"""Audit anonymous Any type usage across src/. + +Counts `Any` annotations in src/**/*.py that are NOT: +- TypeAlias targets (intentional) +- `@dataclass(frozen=True)` fields (those are structural) +- Pattern 3/4/5 per docs/reports/ANY_TYPE_AUDIT_20260621.md §2.2 (SDK holders, __getattr__, generic serialization) + +Modes: + - default: informational report (exit 0) + - --json: machine-readable (exit 0) + - --strict: CI gate (exit 1 if total_any > baseline.total_any) + - --baseline : baseline JSON (default: scripts/audit_dataclass_coverage.baseline.json) +""" +from __future__ import annotations +import ast +import json +import re +import sys +from dataclasses import dataclass, asdict +from pathlib import Path + +REPO_ROOT = Path(__file__).parent.parent +SRC_DIR = REPO_ROOT / "src" +DEFAULT_BASELINE = REPO_ROOT / "scripts" / "audit_dataclass_coverage.baseline.json" + +@dataclass +class Finding: + file: str + line: int + category: str + snippet: str + +@dataclass +class Report: + total_any: int + files_with_findings: int + by_category: dict[str, int] + by_file: dict[str, int] + +def _is_pattern_3_4_5(node: ast.AST, file_text: str) -> bool: + """Exclude SDK client holders, __getattr__, generic serialization. + Approximation: skip Any in: + - module-level vars named __client / __chat / __cache + - function bodies that contain 'def __getattr__' in the same file + - function signatures with '_serialize_for_api' or '_resolve_log_ref' in the name + """ + # Module-level SDK holders + if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name): + name = node.target.id + if re.match(r"^_\w+_(client|chat|cache)$", name): + return True + # Function signatures (Pattern 5) + if isinstance(node, ast.arg): + arg_text = ast.unparse(node.annotation) if node.annotation else "" + if "Any" in arg_text and any( + keyword in (node.arg or "") for keyword in ("serialize", "resolve_log_ref") + ): + return True + return False + +def scan_file(path: Path) -> list[Finding]: + """Scan a single .py file for anonymous Any annotations.""" + text = path.read_text(encoding="utf-8") + try: + tree = ast.parse(text) + except SyntaxError: + return [] + findings = [] + for node in ast.walk(tree): + if _is_pattern_3_4_5(node, text): + continue + # Detect `Any` in annotations + if isinstance(node, ast.AnnAssign) and node.annotation: + ann_text = ast.unparse(node.annotation) + if ann_text == "Any" or ann_text.startswith("Any[") or ann_text.endswith(" | Any") or ann_text.startswith("Any |"): + findings.append(Finding( + file=str(path.relative_to(REPO_ROOT)), + line=node.lineno, + category="any_standalone", + snippet=ann_text[:60], + )) + # Detect `dict[str, Any]` / `list[dict[str, Any]]` etc. + if isinstance(node, ast.arg) and node.annotation: + ann_text = ast.unparse(node.annotation) + if "Any" in ann_text: + findings.append(Finding( + file=str(path.relative_to(REPO_ROOT)), + line=node.lineno, + category="any_in_container", + snippet=ann_text[:60], + )) + return findings + +def scan_all() -> Report: + """Scan all .py files under src/.""" + all_findings: list[Finding] = [] + for py_file in SRC_DIR.rglob("*.py"): + all_findings.extend(scan_file(py_file)) + by_category: dict[str, int] = {} + by_file: dict[str, int] = {} + for f in all_findings: + by_category[f.category] = by_category.get(f.category, 0) + 1 + by_file[f.file] = by_file.get(f.file, 0) + 1 + files_with_findings = len(by_file) + return Report( + total_any=len(all_findings), + files_with_findings=files_with_findings, + by_category=by_category, + by_file=by_file, + ) + +def print_human_report(report: Report) -> None: + print("=== Anonymous Any Audit: src ===") + print(f"Total Any findings: {report.total_any}") + print(f"Files with findings: {report.files_with_findings}") + print("\nBy category:") + for cat, count in sorted(report.by_category.items(), key=lambda x: -x[1]): + print(f" {cat:30s} {count}") + +def main() -> int: + import argparse + parser = argparse.ArgumentParser() + parser.add_argument("--json", action="store_true") + parser.add_argument("--strict", action="store_true") + parser.add_argument("--baseline", default=str(DEFAULT_BASELINE)) + args = parser.parse_args() + report = scan_all() + if args.json: + print(json.dumps(asdict(report), indent=2)) + elif args.strict: + baseline_path = Path(args.baseline) + if not baseline_path.exists(): + print(f"STRICT ERROR: baseline not found at {baseline_path}", file=sys.stderr) + return 1 + baseline = json.loads(baseline_path.read_text()) + if report.total_any > baseline.get("total_any", 0): + print(f"STRICT FAIL: {report.total_any} Any sites > baseline {baseline['total_any']}", file=sys.stderr) + return 1 + print(f"STRICT OK: {report.total_any} Any sites <= baseline {baseline['total_any']}") + else: + print_human_report(report) + return 0 + +if __name__ == "__main__": + sys.exit(main()) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_audit_dataclass_coverage.py -v` +Expected: PASS (4/4) + +- [ ] **Step 5: Commit** + +```bash +git add scripts/audit_dataclass_coverage.py tests/test_audit_dataclass_coverage.py +git commit -m "feat(audit): dataclass-coverage audit script with --strict CI gate" +``` + +### Task 0.3: Add styleguide §12 + generate baseline + +**Files:** +- Modify: `conductor/code_styleguides/type_aliases.md` (append §12) +- Create: `scripts/audit_dataclass_coverage.baseline.json` + +- [ ] **Step 1: Read existing styleguide structure** + +Run: `uv run python -c "print(open('conductor/code_styleguides/type_aliases.md').read()[-500:])"` +Expected: shows the end of the file; identify where §11 ends (or where to append) + +- [ ] **Step 2: Append §12 to styleguide** + +Append to `conductor/code_styleguides/type_aliases.md`: +```markdown + +--- + +## When to Promote `TypeAlias` to `dataclass` + +A `TypeAlias` like `Metadata: TypeAlias = dict[str, Any]` is a **rename** — the +underlying shape is unchanged. This is appropriate when: + +- The shape is **truly open** (extra keys allowed; the dict is a bag) +- The shape is **self-describing** (caller reads `entry.get("path")` without + needing to know which keys are required) +- The shape is **transient** (JSON-serialized, then deserialized; no + in-memory struct invariants) + +Promote to `dataclass(frozen=True)` when: + +- The shape has **a known set of required fields** with **specific types** + (e.g., a chat completion's `usage: UsageStats` with 4 int fields) +- Multiple sites access the same fields with **string keys** + (`payload["usage"]["input_tokens"]` × 5 sites = 5× the bug surface) +- The shape is **stable across serialization boundaries** (the on-disk / + on-wire format is documented and won't change per-call) +- The shape is **shared across multiple modules** (the same schema is used + by `ai_client.py` and `openai_compatible.py` and `api_hooks.py`) + +The reference pattern is `src/vendor_capabilities.py`. When in doubt, follow +that template: `frozen=True` dataclass + module-level registry + factory +functions. + +The fat-struct candidates identified in +[`docs/reports/ANY_TYPE_AUDIT_20260621.md`](../../docs/reports/ANY_TYPE_AUDIT_20260621.md) +(§3) are the canonical worked examples. +``` + +- [ ] **Step 3: Generate baseline** + +Run: `uv run python scripts/audit_weak_types.py --json > /tmp/audit_pre.json` +Then create baseline manually: +```bash +uv run python -c " +import json, subprocess +result = subprocess.run(['uv', 'run', 'python', 'scripts/audit_dataclass_coverage.py', '--json'], capture_output=True, text=True) +report = json.loads(result.stdout) +baseline = { + 'total_any': report['total_any'], + 'files_with_findings': report['files_with_findings'], + 'generated_at': '2026-06-21', + 'note': 'Baseline for --strict mode. Re-generate when a new track intentionally reduces the count.' +} +with open('scripts/audit_dataclass_coverage.baseline.json', 'w') as f: + json.dump(baseline, f, indent=2) +print('baseline:', baseline['total_any'], 'Any sites in', baseline['files_with_findings'], 'files') +" +``` +Expected output: `baseline: ~210 Any sites in ~25 files` + +- [ ] **Step 4: Verify strict mode passes** + +Run: `uv run python scripts/audit_dataclass_coverage.py --strict` +Expected: `STRICT OK: Any sites <= baseline ` exit 0 + +- [ ] **Step 5: Commit** + +```bash +git add conductor/code_styleguides/type_aliases.md scripts/audit_dataclass_coverage.baseline.json +git commit -m "docs(styleguide): add §12 When to Promote TypeAlias to dataclass + audit baseline" +``` + +--- + +## Phase 1: src/mcp_tool_specs.py (P1, 8 sites) + +Focus: Convert 45 tool specs from `list[dict[str, Any]]` to `list[ToolSpec]`. Update 6 call sites across 3 files. + +### Task 1.1: Write failing tests for ToolSpec module + +**Files:** +- Create: `tests/test_mcp_tool_specs.py` + +- [ ] **Step 1: Write tests** + +```python +"""Tests for src/mcp_tool_specs.py.""" +from src.mcp_tool_specs import ToolParameter, ToolSpec, get_tool_spec, tool_names, get_tool_schemas, register, _REGISTRY + +def test_all_45_tools_registered() -> None: + """All MCP tools from mcp_client.MCP_TOOL_SPECS must be registered.""" + names = tool_names() + assert len(names) == 45, f"expected 45 tools, got {len(names)}" + +def test_get_tool_spec_returns_correct_spec() -> None: + """get_tool_spec('py_remove_def') must return the expected spec.""" + spec = get_tool_spec("py_remove_def") + assert isinstance(spec, ToolSpec) + assert spec.name == "py_remove_def" + assert spec.description # non-empty + +def test_tool_names_matches_specs() -> None: + """tool_names() must match the set of ToolSpec.name values.""" + names = tool_names() + specs = get_tool_schemas() + assert names == {s.name for s in specs} + +def test_tool_spec_parameters_are_immutable_tuple() -> None: + """ToolSpec.parameters must be a tuple (immutable, matching frozen=True).""" + spec = get_tool_spec("py_remove_def") + assert isinstance(spec.parameters, tuple) + +def test_tool_spec_is_frozen() -> None: + """ToolSpec must be frozen (immutable).""" + spec = get_tool_spec("py_remove_def") + try: + spec.description = "modified" # type: ignore[misc] + assert False, "ToolSpec should be frozen" + except Exception: + pass # expected + +def test_register_new_tool() -> None: + """register() must add a new ToolSpec to the registry.""" + initial_count = len(tool_names()) + test_spec = ToolSpec(name="test_tool", description="test", parameters=()) + register(test_spec) + assert "test_tool" in tool_names() + assert len(tool_names()) == initial_count + 1 + +def test_tool_parameter_enum_is_optional() -> None: + """ToolParameter.enum must default to None (most params have no enum).""" + spec = get_tool_spec("py_remove_def") + for p in spec.parameters: + assert p.enum is None or isinstance(p.enum, list) + +def test_tool_names_subset_of_agent_tool_names() -> None: + """Cross-module invariant: tool_names() ⊆ models.AGENT_TOOL_NAMES.""" + from src import models + mcp_names = tool_names() + agent_names = set(models.AGENT_TOOL_NAMES) + assert mcp_names.issubset(agent_names), f"MCP-only tools not in AGENT_TOOL_NAMES: {mcp_names - agent_names}" +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_mcp_tool_specs.py -v` +Expected: FAIL (ModuleNotFoundError: No module named 'src.mcp_tool_specs') + +### Task 1.2: Create `src/mcp_tool_specs.py` skeleton + +- [ ] **Step 3: Create empty module** + +Create `src/mcp_tool_specs.py`: +```python +"""Module-level abstraction layer for MCP tool specifications. + +Mirrors src/vendor_capabilities.py:64-76 template: + frozen dataclass + module-level registry + factory functions. +""" +from __future__ import annotations +from dataclasses import dataclass, field +from typing import Optional + +@dataclass(frozen=True) +class ToolParameter: + name: str + type: str # "string" | "integer" | "boolean" | "object" | "array" + description: str + required: bool = False + enum: Optional[list[str]] = None + +@dataclass(frozen=True) +class ToolSpec: + name: str + description: str + parameters: tuple[ToolParameter, ...] + category: str = "file" + +_REGISTRY: dict[str, ToolSpec] = {} + +def register(spec: ToolSpec) -> None: + _REGISTRY[spec.name] = spec + +def get_tool_spec(name: str) -> ToolSpec: + return _REGISTRY[name] + +def tool_names() -> set[str]: + return set(_REGISTRY.keys()) + +def get_tool_schemas() -> list[ToolSpec]: + return list(_REGISTRY.values()) +``` + +- [ ] **Step 4: Run tests to verify which now pass** + +Run: `uv run pytest tests/test_mcp_tool_specs.py -v` +Expected: PASS for `test_tool_spec_parameters_are_immutable_tuple`, `test_tool_spec_is_frozen`, `test_register_new_tool`, `test_tool_parameter_enum_is_optional`. FAIL for the rest (empty registry). + +### Task 1.3: Migrate MCP_TOOL_SPECS to ToolSpec registry + +- [ ] **Step 5: Read source dicts** + +Run: `uv run python -c " +import ast, json +tree = ast.parse(open('src/mcp_client.py').read()) +for n in ast.walk(tree): + if isinstance(n, ast.AnnAssign) and isinstance(n.target, ast.Name) and n.target.id == 'MCP_TOOL_SPECS': + for elt in n.value.elts: + print(elt.values[0].value) # tool name +"` +Expected: list of 45 tool names + +- [ ] **Step 6: Generate migration script** + +The 45 tool specs need to be converted from dict to ToolSpec. Use this helper script (`scripts/_migrate_mcp_specs.py`, throwaway; delete after Phase 1): +```python +"""One-time migration: convert MCP_TOOL_SPECS dicts to ToolSpec instances.""" +import ast +from pathlib import Path + +src = Path("src/mcp_client.py").read_text(encoding="utf-8") +tree = ast.parse(src) +specs = [] +for n in ast.walk(tree): + if isinstance(n, ast.AnnAssign) and isinstance(n.target, ast.Name) and n.target.id == "MCP_TOOL_SPECS": + for elt in n.value.elts: + d = ast.literal_eval(elt) + specs.append(d) + +# Print Python source for the registry population +print("""# AUTO-GENERATED by scripts/_migrate_mcp_specs.py on 2026-06-21 +# Source: src/mcp_client.py:1972 MCP_TOOL_SPECS""") +for spec in specs: + params = spec.get("parameters", {}) + props = params.get("properties", {}) + required = set(params.get("required", [])) + tool_params = [] + for pname, pdef in props.items(): + tool_params.append(ToolParameter( + name=pname, + type=pdef.get("type", "string"), + description=pdef.get("description", ""), + required=pname in required, + enum=pdef.get("enum"), + )) + category = "ast" if spec["name"].startswith(("py_", "ts_")) else "file" + if spec["name"] in ("web_search", "fetch_url"): + category = "network" + register(ToolSpec( + name=spec["name"], + description=spec.get("description", ""), + parameters=tuple(tool_params), + category=category, + )) +``` + +Run: `uv run python scripts/_migrate_mcp_specs.py > /tmp/mcp_specs_migration.py` +Then copy the output into `src/mcp_tool_specs.py` (replace the `# migration content` placeholder). + +- [ ] **Step 7: Run tests** + +Run: `uv run pytest tests/test_mcp_tool_specs.py -v` +Expected: PASS (8/8) + +- [ ] **Step 8: Delete migration script + commit** + +```bash +rm scripts/_migrate_mcp_specs.py +git add src/mcp_tool_specs.py tests/test_mcp_tool_specs.py +git commit -m "feat(mcp): ToolSpec + ToolParameter dataclasses; migrate 45 MCP_TOOL_SPECS entries" +``` + +### Task 1.4: Update `src/mcp_client.py` call sites + +- [ ] **Step 1: Find call sites** + +Run: `uv run python scripts/grep.py "MCP_TOOL_SPECS|TOOL_NAMES" src/mcp_client.py` (or use grep tool) +Expected: lines 1944, 1958, 1972 (declaration), 2747 + +- [ ] **Step 2: Update line 1944** (`native_names = {t['name'] for t in MCP_TOOL_SPECS}`) + +Replace with: +```python +from src import mcp_tool_specs +native_names = mcp_tool_specs.tool_names() +``` + +- [ ] **Step 3: Update line 1958** (`res = list(MCP_TOOL_SPECS)`) + +Replace with: +```python +res = mcp_tool_specs.get_tool_schemas() +``` + +- [ ] **Step 4: Delete MCP_TOOL_SPECS declaration** (line 1972) + +The dict literal in `src/mcp_client.py` is now in `src/mcp_tool_specs.py`; delete the declaration. + +- [ ] **Step 5: Update line 2747** (`TOOL_NAMES: set[str] = {t['name'] for t in MCP_TOOL_SPECS}`) + +Replace with: +```python +TOOL_NAMES: set[str] = mcp_tool_specs.tool_names() +``` + +(Keeps backward-compat re-export; can be removed later.) + +- [ ] **Step 6: Run regression tests** + +Run: `uv run pytest tests/test_mcp_client.py -v` +Expected: PASS (all existing tests) + +### Task 1.5: Update `src/ai_client.py` callers + +- [ ] **Step 1: Find call sites** + +Run: `grep -n "mcp_client.TOOL_NAMES" src/ai_client.py` +Expected: lines 560, 582, 1012 + +- [ ] **Step 2: Add import + update 3 sites** + +Add at top of `src/ai_client.py`: +```python +from src import mcp_tool_specs +``` + +Replace all 3 occurrences of `mcp_client.TOOL_NAMES` with `mcp_tool_specs.tool_names()`. + +- [ ] **Step 3: Run regression tests** + +Run: `uv run pytest tests/test_ai_client*.py -v` +Expected: PASS + +### Task 1.6: Phase 1 checkpoint + +- [ ] **Step 1: Full verification** + +Run: +```bash +uv run pytest tests/test_mcp_tool_specs.py tests/test_mcp_client.py tests/test_ai_client*.py --timeout=60 +uv run python scripts/audit_weak_types.py --strict +uv run python scripts/audit_dataclass_coverage.py --strict +``` +Expected: all PASS / exit 0 + +- [ ] **Step 2: Phase 1 checkpoint commit + git note** + +```bash +git add -A +git commit -m "conductor(checkpoint): Phase 1 complete - mcp_tool_specs (P1, 8 sites migrated)" +git notes add -m "Phase 1 checkpoint: src/mcp_tool_specs.py + 45 ToolSpec instances; mcp_client.py + ai_client.py updated; 8 weak sites resolved" HEAD +``` + +Update `conductor/tracks/any_type_componentization_20260621/state.toml` to mark phase_1 status="completed" + checkpointsha. + +--- + +## Phase 2: src/openai_schemas.py (P1, 17 sites) + +Focus: Convert `NormalizedResponse` + `OpenAICompatibleRequest` to use `ChatMessage` + `UsageStats` + `ToolCall` dataclasses. Update `_send_grok` + `_send_minimax` + `_send_llama` in `ai_client.py`. + +### Task 2.1: Write failing tests for ChatMessage + UsageStats + ToolCall + +**Files:** +- Create: `tests/test_openai_schemas.py` + +- [ ] **Step 1: Write tests** + +```python +"""Tests for src/openai_schemas.py.""" +from src.openai_schemas import ToolCall, ToolCallFunction, ChatMessage, UsageStats, NormalizedResponse, OpenAICompatibleRequest + +def test_chat_message_user_role() -> None: + msg = ChatMessage(role="user", content="hello") + assert msg.role == "user" + assert msg.content == "hello" + assert msg.tool_calls is None + assert msg.tool_call_id is None + +def test_chat_message_assistant_with_tool_calls() -> None: + tc = ToolCall(id="1", type="function", function=ToolCallFunction(name="f", arguments="{}")) + msg = ChatMessage(role="assistant", content="", tool_calls=(tc,)) + assert msg.tool_calls == (tc,) + assert len(msg.tool_calls) == 1 + +def test_chat_message_tool_response() -> None: + msg = ChatMessage(role="tool", content="result", tool_call_id="1") + assert msg.tool_call_id == "1" + +def test_usage_stats_field_access() -> None: + usage = UsageStats(input_tokens=100, output_tokens=50) + assert usage.input_tokens == 100 + assert usage.output_tokens == 50 + assert usage.cache_read_tokens == 0 + assert usage.cache_creation_tokens == 0 + +def test_usage_stats_is_frozen() -> None: + usage = UsageStats(input_tokens=100, output_tokens=50) + try: + usage.input_tokens = 200 # type: ignore[misc] + assert False + except Exception: + pass + +def test_tool_call_function_arguments_is_string() -> None: + """arguments is a JSON string, not a dict (matches OpenAI API).""" + tc = ToolCall(id="1", type="function", function=ToolCallFunction(name="f", arguments='{"x":1}')) + assert tc.function.arguments == '{"x":1}' + +def test_normalized_response_uses_usage_stats() -> None: + """NormalizedResponse.usage should be a UsageStats object, not 4 separate int fields.""" + usage = UsageStats(input_tokens=10, output_tokens=5, cache_read_tokens=2) + response = NormalizedResponse(text="hello", tool_calls=(), usage=usage, raw_response=None) + assert response.usage.input_tokens == 10 + assert response.usage.cache_read_tokens == 2 + +def test_normalized_response_raw_response_is_any() -> None: + """Pattern 3: raw_response stays as Any (SDK-specific).""" + import typing + response = NormalizedResponse(text="x", tool_calls=(), usage=UsageStats(input_tokens=0, output_tokens=0), raw_response={"sdk": "data"}) + assert response.raw_response == {"sdk": "data"} + assert "Any" in str(typing.get_type_hints(NormalizedResponse)["raw_response"]) or \ + str(typing.get_type_hints(NormalizedResponse)["raw_response"]) == "typing.Any" + +def test_openai_compatible_request_messages_typed() -> None: + """OpenAICompatibleRequest.messages must be list[ChatMessage].""" + msg = ChatMessage(role="user", content="hi") + req = OpenAICompatibleRequest(messages=[msg], model="gpt-4") + assert req.messages[0].role == "user" + +def test_openai_compatible_request_defaults() -> None: + req = OpenAICompatibleRequest(messages=[], model="gpt-4") + assert req.temperature == 0.0 + assert req.top_p == 1.0 + assert req.max_tokens == 8192 + assert req.stream is False +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_openai_schemas.py -v` +Expected: FAIL (ModuleNotFoundError) + +### Task 2.2: Create `src/openai_schemas.py` + +- [ ] **Step 1: Create module** + +Create `src/openai_schemas.py`: +```python +"""OpenAI-compatible API schemas (ChatMessage, UsageStats, ToolCall, NormalizedResponse, OpenAICompatibleRequest). + +Mirrors src/vendor_capabilities.py:64-76 template. +""" +from __future__ import annotations +from dataclasses import dataclass, field +from typing import Any, Callable, Optional +from src.type_aliases import Metadata + +@dataclass(frozen=True) +class ToolCallFunction: + name: str + arguments: str # JSON string (matches OpenAI wire format) + +@dataclass(frozen=True) +class ToolCall: + id: str + type: str = "function" + function: ToolCallFunction = field(default_factory=ToolCallFunction) + +@dataclass(frozen=True) +class ChatMessage: + role: str # "system" | "user" | "assistant" | "tool" + content: str + tool_calls: Optional[tuple[ToolCall, ...]] = None + tool_call_id: Optional[str] = None + name: Optional[str] = None + +@dataclass(frozen=True) +class UsageStats: + input_tokens: int + output_tokens: int + cache_read_tokens: int = 0 + cache_creation_tokens: int = 0 + +@dataclass(frozen=True) +class NormalizedResponse: + text: str + tool_calls: tuple[ToolCall, ...] + usage: UsageStats + raw_response: Any # Pattern 3: SDK-specific (stays Any) + +@dataclass +class OpenAICompatibleRequest: + messages: list[ChatMessage] + model: str + temperature: float = 0.0 + top_p: float = 1.0 + max_tokens: int = 8192 + tools: Optional[list[dict[str, Any]]] = None # TODO(future-track): migrate to list[ToolSpec] + tool_choice: str = "auto" + stream: bool = False + stream_callback: Optional[Callable[[str], None]] = None + extra_body: Optional[dict[str, Any]] = None +``` + +- [ ] **Step 2: Run tests** + +Run: `uv run pytest tests/test_openai_schemas.py -v` +Expected: PASS (10/10) + +- [ ] **Step 3: Commit** + +```bash +git add src/openai_schemas.py tests/test_openai_schemas.py +git commit -m "feat(openai_schemas): ChatMessage + UsageStats + ToolCall + NormalizedResponse + OpenAICompatibleRequest" +``` + +### Task 2.3: Update `src/openai_compatible.py` + +- [ ] **Step 1: Find consumers** + +Run: `grep -n "NormalizedResponse\|OpenAICompatibleRequest" src/openai_compatible.py src/ai_client.py src/api_hook_client.py | head -30` + +- [ ] **Step 2: Update imports + type hints** + +In `src/openai_compatible.py`: +- Add: `from src.openai_schemas import NormalizedResponse, OpenAICompatibleRequest, ChatMessage, UsageStats, ToolCall, ToolCallFunction` +- Remove the local `NormalizedResponse` + `OpenAICompatibleRequest` class definitions (lines ~10-30) + +- [ ] **Step 3: Update internal consumers** (~5 functions) + +Each consumer that constructs or destructures `NormalizedResponse`: +- Replace `usage_input_tokens=..., usage_output_tokens=..., usage_cache_read_tokens=..., usage_cache_creation_tokens=...` with `usage=UsageStats(input_tokens=..., output_tokens=..., cache_read_tokens=..., cache_creation_tokens=...)` +- Replace `tool_calls=[...]` (list) with `tool_calls=(...)` (tuple of ToolCall) +- Replace `messages=[{"role": ..., "content": ...}]` (list of dict) with `messages=[ChatMessage(role=..., content=...)]` + +- [ ] **Step 4: Run regression tests** + +Run: `uv run pytest tests/test_openai_compatible.py -v` +Expected: PASS + +### Task 2.4: Update `src/ai_client.py` _send_grok + _send_minimax + _send_llama + +- [ ] **Step 1: Find the 3 functions** + +Run: `grep -n "def _send_grok\|def _send_minimax\|def _send_llama" src/ai_client.py` + +- [ ] **Step 2: Update each function** (same pattern as Task 2.3) + +For each of `_send_grok`, `_send_minimax`, `_send_llama`: +- Replace dict constructions with dataclass constructions +- Update return type if it returns `dict[str, Any]` for normalized response + +- [ ] **Step 3: Run regression tests** + +Run: `uv run pytest tests/test_ai_client*.py -v` +Expected: PASS + +### Task 2.5: Phase 2 checkpoint + +- [ ] **Step 1: Full verification** + +```bash +uv run pytest tests/test_openai_schemas.py tests/test_openai_compatible.py tests/test_ai_client*.py --timeout=60 +uv run python scripts/audit_weak_types.py --strict +uv run python scripts/audit_dataclass_coverage.py --strict +``` + +- [ ] **Step 2: Checkpoint commit + git note** + +```bash +git add -A +git commit -m "conductor(checkpoint): Phase 2 complete - openai_schemas (P1, 17 sites migrated)" +git notes add -m "Phase 2 checkpoint: src/openai_schemas.py + ChatMessage/UsageStats/ToolCall; 3 send_* functions updated; 17 weak sites resolved" HEAD +``` + +--- + +## Phase 3: src/provider_state.py (P2, 41 sites) [LARGEST PHASE] + +Focus: Replace 14 module globals (7 histories + 7 locks) with `_PROVIDER_HISTORIES` dict. Update ~27 call sites in `ai_client.py`. + +### Task 3.1: Snapshot baseline + write tests + +- [ ] **Step 1: Snapshot pre-Phase-3 baseline** + +Run: `uv run python scripts/audit_dataclass_coverage.py --json > /tmp/pre_phase3.json` +Expected: total_any should be ~baseline minus 25 (Phases 1+2 contributions) + +- [ ] **Step 2: Write tests** + +Create `tests/test_provider_state.py`: +```python +"""Tests for src.provider_state.""" +import threading +from src.provider_state import ProviderHistory, _PROVIDER_HISTORIES, get_history + +def test_all_six_providers_have_history() -> None: + """Each of the 6 providers must have a ProviderHistory instance.""" + assert set(_PROVIDER_HISTORIES.keys()) == {"anthropic", "deepseek", "minimax", "qwen", "grok", "llama"} + +def test_get_history_returns_singleton() -> None: + """get_history(p) must return the same instance across calls.""" + a1 = get_history("anthropic") + a2 = get_history("anthropic") + assert a1 is a2 + +def test_provider_history_append_under_lock() -> None: + """append() must be thread-safe (lock protects mutation).""" + h = ProviderHistory() + results = [] + def worker() -> None: + for i in range(100): + h.append({"role": "user", "content": str(i)}) + results.append(len(h.get_all())) + ts = [threading.Thread(target=worker) for _ in range(10)] + for t in ts: t.start() + for t in ts: t.join() + assert len(h.get_all()) == 1000 + +def test_provider_history_clear_resets_list() -> None: + """clear() must reset the messages list (lock preserved).""" + h = ProviderHistory() + h.append({"role": "user", "content": "a"}) + h.append({"role": "user", "content": "b"}) + assert len(h.get_all()) == 2 + h.clear() + assert h.get_all() == [] + # Lock is preserved (same instance) + assert h.lock is h.lock + +def test_provider_history_replace_all_swaps_list() -> None: + """replace_all(messages) must atomically swap the list.""" + h = ProviderHistory() + h.append({"role": "user", "content": "old"}) + new_msgs = [{"role": "user", "content": "new1"}, {"role": "user", "content": "new2"}] + h.replace_all(new_msgs) + assert h.get_all() == new_msgs + +def test_default_factory_creates_fresh_lock() -> None: + """Each ProviderHistory() must have its own lock (default_factory).""" + h1 = ProviderHistory() + h2 = ProviderHistory() + assert h1.lock is not h2.lock + +def test_global_histories_isolated() -> None: + """Mutating anthropic's history must not affect grok's.""" + a = get_history("anthropic") + g = get_history("grok") + a.append({"role": "user", "content": "a_msg"}) + assert g.get_all() == [] +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `uv run pytest tests/test_provider_state.py -v` +Expected: FAIL (ModuleNotFoundError) + +### Task 3.2: Create `src/provider_state.py` + +- [ ] **Step 1: Create module** + +Create `src/provider_state.py`: +```python +"""Per-provider history state (Phase 3 of any_type_componentization_20260621). + +Replaces 14 module globals in src/ai_client.py (7 __history + 7 __history_lock) +with a single _PROVIDER_HISTORIES dict. + +Mirrors src/vendor_capabilities.py:64-76 template (frozen=False here because ProviderHistory mutates). +""" +from __future__ import annotations +import threading +from dataclasses import dataclass, field +from src.type_aliases import Metadata + +@dataclass +class ProviderHistory: + messages: list[Metadata] = field(default_factory=list) + lock: threading.Lock = field(default_factory=threading.Lock) + + def append(self, message: Metadata) -> None: + with self.lock: + self.messages.append(message) + + def get_all(self) -> list[Metadata]: + with self.lock: + return list(self.messages) + + def replace_all(self, messages: list[Metadata]) -> None: + with self.lock: + self.messages = list(messages) + + def clear(self) -> None: + with self.lock: + self.messages = [] + +_PROVIDER_HISTORIES: dict[str, ProviderHistory] = { + "anthropic": ProviderHistory(), + "deepseek": ProviderHistory(), + "minimax": ProviderHistory(), + "qwen": ProviderHistory(), + "grok": ProviderHistory(), + "llama": ProviderHistory(), +} + +def get_history(provider: str) -> ProviderHistory: + return _PROVIDER_HISTORIES[provider] +``` + +- [ ] **Step 2: Run tests** + +Run: `uv run pytest tests/test_provider_state.py -v` +Expected: PASS (7/7) + +- [ ] **Step 3: Commit** + +```bash +git add src/provider_state.py tests/test_provider_state.py +git commit -m "feat(provider_state): ProviderHistory dataclass + _PROVIDER_HISTORIES dict" +``` + +### Task 3.3: Remove globals from `src/ai_client.py` + +- [ ] **Step 1: Add import + remove 14 globals** (lines 111-133) + +Add at top of `src/ai_client.py` (with other ai_client imports): +```python +from src.provider_state import get_history +``` + +Delete lines 111-133 (the 7 history + 7 lock declarations). + +- [ ] **Step 2: Update cleanup() function** (lines 463-499) + +For each of the 7 providers, replace the lock-guarded reset: +```python +# OLD: +with _anthropic_history_lock: + _anthropic_history = [] + +# NEW: +get_history("anthropic").clear() +``` + +Apply this pattern to all 7 providers in the cleanup() function. + +- [ ] **Step 3: Run tests** + +Run: `uv run pytest tests/test_ai_client_result.py -v` +Expected: PASS (this is the conservative smoke test) + +- [ ] **Step 4: Commit** + +```bash +git add src/ai_client.py +git commit -m "refactor(ai_client): remove 14 module globals; use get_history().clear() in cleanup()" +``` + +### Task 3.4: Update _send_anthropic + +- [ ] **Step 1: Find all references** + +Run: `grep -n "_anthropic_history" src/ai_client.py | head -30` +Expected: ~20 lines (1447, 1457-1460, 1469, 1471, 1475, 1489, 1503, 1506, 1582, etc.) + +- [ ] **Step 2: Mechanical replacement** + +For each `_anthropic_history` reference: +- Direct read (e.g., `for msg in _anthropic_history:`): replace with `for msg in get_history("anthropic").get_all():` +- Direct write (e.g., `_anthropic_history.append({...})`): replace with `get_history("anthropic").append({...})` +- Lock-guarded read (`with _anthropic_history_lock: ... _anthropic_history ...`): replace with `h = get_history("anthropic"); with h.lock: ... h.messages ...` +- The `_repair_anthropic_history(_anthropic_history)` call: pass `get_history("anthropic").get_all()` (the helper takes a list parameter and doesn't need the lock context) + +- [ ] **Step 3: Run tests** + +Run: `uv run pytest tests/test_ai_client*.py -v` +Expected: PASS + +- [ ] **Step 4: Commit** + +```bash +git add src/ai_client.py +git commit -m "refactor(ai_client): _send_anthropic uses get_history('anthropic')" +``` + +### Task 3.5: Update _send_deepseek + _send_grok + _send_minimax + _send_qwen + _send_llama + +Repeat Task 3.4 pattern for each of the remaining 5 send functions. Each function has ~8-10 references. + +- [ ] **Step 1-5: For each function** (deepseek, grok, minimax, qwen, llama): + - Find references via grep + - Replace with `get_history("").X()` pattern + - Run tests after each + - Commit after each + +```bash +# After each function update: +git add src/ai_client.py +git commit -m "refactor(ai_client): _send_ uses get_history('')" +``` + +### Task 3.6: Phase 3 checkpoint + +- [ ] **Step 1: Full verification** + +```bash +uv run pytest tests/test_provider_state.py tests/test_ai_client*.py --timeout=60 +uv run python scripts/audit_weak_types.py --strict +uv run python scripts/audit_dataclass_coverage.py --strict +uv run python -c "from src import ai_client; print('SDK clients preserved:', ai_client._gemini_chat is None or ai_client._gemini_client is not None)" +``` +Expected: all PASS / exit 0; SDK client holders NOT touched + +- [ ] **Step 2: Checkpoint commit + git note** + +```bash +git add -A +git commit -m "conductor(checkpoint): Phase 3 complete - provider_state (P2, 41 sites migrated)" +git notes add -m "Phase 3 checkpoint: src/provider_state.py + ProviderHistory dict; 14 globals removed; ~27 call sites updated; SDK clients (Pattern 3) preserved" HEAD +``` + +--- + +## Phase 4: src/log_registry.py Session (P2, 7 sites) + +Focus: Add `Session` + `SessionMetadata` dataclasses inline; convert `self.data: dict[str, dict[str, Any]]` → `dict[str, Session]`. + +### Task 4.1: Write failing tests + +- [ ] **Step 1: Extend tests/test_log_registry.py** + +Add to existing `tests/test_log_registry.py`: +```python +from src.log_registry import Session, SessionMetadata, LogRegistry + +def test_session_dataclass_has_expected_fields() -> None: + s = Session(session_id="abc", path="/tmp/abc.jsonl", start_time="2026-06-21T00:00:00") + assert s.session_id == "abc" + assert s.path == "/tmp/abc.jsonl" + assert s.whitelisted is False + assert s.metadata is None + +def test_session_metadata_defaults() -> None: + m = SessionMetadata() + assert m.message_count == 0 + assert m.errors == 0 + assert m.size_kb == 0 + assert m.whitelisted is False + +def test_session_is_frozen() -> None: + s = Session(session_id="x", path="/p", start_time="t") + try: + s.session_id = "y" # type: ignore[misc] + assert False + except Exception: + pass + +def test_log_registry_data_is_dict_of_session() -> None: + """self.data must be dict[str, Session], not dict[str, dict[str, Any]].""" + import typing + hints = typing.get_type_hints(LogRegistry) + # Check the 'data' field annotation + data_type = hints.get("data") + assert "Session" in str(data_type) + +def test_session_metadata_is_optional() -> None: + s = Session(session_id="x", path="/p", start_time="t", metadata=SessionMetadata(message_count=5)) + assert s.metadata is not None + assert s.metadata.message_count == 5 +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_log_registry.py -v` +Expected: FAIL (ImportError for Session/SessionMetadata) + +### Task 4.2: Add dataclasses to `src/log_registry.py` + +- [ ] **Step 1: Add imports + dataclasses** (top of file) + +Add at top: +```python +from dataclasses import dataclass, field +from typing import Optional + +@dataclass(frozen=True) +class SessionMetadata: + message_count: int = 0 + errors: int = 0 + size_kb: int = 0 + whitelisted: bool = False + reason: str = '' + timestamp: Optional[str] = None + +@dataclass(frozen=True) +class Session: + session_id: str + path: str + start_time: str # ISO format + whitelisted: bool = False + metadata: Optional[SessionMetadata] = None +``` + +- [ ] **Step 2: Update `LogRegistry.data` type annotation** + +Change `self.data: dict[str, dict[str, Any]] = {}` to `self.data: dict[str, Session] = field(default_factory=dict)`. + +Also update the `@dataclass` decorator on `LogRegistry` if not already present. + +- [ ] **Step 3: Run tests** + +Run: `uv run pytest tests/test_log_registry.py -v` +Expected: PASS + +### Task 4.3: Update consumers (session_logger, log_pruner, gui_2) + +- [ ] **Step 1: Find consumer references** + +Run: `grep -n "log_registry\|LogRegistry\|session_log" src/session_logger.py src/log_pruner.py src/gui_2.py | head -30` + +- [ ] **Step 2: Update session_logger.py** (`open_session`, `close_session`) + +Replace dict construction with `Session(...)` dataclass construction. + +- [ ] **Step 3: Update log_pruner.py** (`prune_old_logs`) + +Update iteration over `self.data` to use `Session` field access (`.path`, `.start_time`, `.metadata`). + +- [ ] **Step 4: Update gui_2.py Log Management panel** + +Find the panel rendering code (search for "log_registry" or "session_log" in gui_2.py) and update field access from `data[key]["path"]` to `data[key].path`. + +- [ ] **Step 5: Run regression tests** + +```bash +uv run pytest tests/test_log_registry.py tests/test_session_logger.py tests/test_log_pruner.py --timeout=60 +``` + +### Task 4.4: Phase 4 checkpoint + +```bash +git add -A +git commit -m "conductor(checkpoint): Phase 4 complete - log_registry Session (P2, 7 sites migrated)" +git notes add -m "Phase 4 checkpoint: Session + SessionMetadata dataclasses; 4 files updated; 7 weak sites resolved" HEAD +``` + +--- + +## Phase 5: src/api_hooks.py WebSocketMessage (P3, 16 sites) + +Focus: Add `WebSocketMessage` dataclass; convert `broadcast(channel, payload)` to `broadcast(message: WebSocketMessage)`. + +### Task 5.1: Write failing tests + +- [ ] **Step 1: Extend tests/test_api_hooks.py** + +```python +from src.api_hooks import WebSocketMessage +from src.type_aliases import JsonValue + +def test_websocket_message_is_frozen() -> None: + msg = WebSocketMessage(channel="test", payload={"key": "value"}) + try: + msg.channel = "other" # type: ignore[misc] + assert False + except Exception: + pass + +def test_websocket_message_payload_accepts_json_value() -> None: + """payload must be JsonValue (recursive).""" + msg_str = WebSocketMessage(channel="c", payload="hello") + msg_dict = WebSocketMessage(channel="c", payload={"k": "v"}) + msg_list = WebSocketMessage(channel="c", payload=[1, 2, 3]) + msg_nested = WebSocketMessage(channel="c", payload={"items": [{"id": 1}]}) + assert msg_str.payload == "hello" + assert msg_dict.payload == {"k": "v"} + assert msg_list.payload == [1, 2, 3] + assert msg_nested.payload == {"items": [{"id": 1}]} + +def test_websocket_message_payload_rejects_non_json() -> None: + """payload must reject non-JSON values (type-checker enforces).""" + # This is a static type check, not runtime; just verify the annotation + import typing + hints = typing.get_type_hints(WebSocketMessage) + assert "JsonValue" in str(hints["payload"]) + +def test_serialize_for_api_returns_json_value() -> None: + """_serialize_for_api return type must be JsonValue.""" + import typing + from src.api_hooks import _serialize_for_api + hints = typing.get_type_hints(_serialize_for_api) + assert "JsonValue" in str(hints["return"]) + +def test_get_set_app_attr_unchanged() -> None: + """Pattern 4 preserved: _get_app_attr / _set_app_attr signatures unchanged.""" + from src.api_hooks import _get_app_attr, _set_app_attr + import typing + get_hints = typing.get_type_hints(_get_app_attr) + set_hints = typing.get_type_hints(_set_app_attr) + assert "Any" in str(get_hints.get("return", "")) + assert "Any" in str(get_hints.get("default", "")) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_api_hooks.py -v` +Expected: FAIL (ImportError for WebSocketMessage) + +### Task 5.2: Add dataclass to `src/api_hooks.py` + +- [ ] **Step 1: Add import + dataclass** + +Add to top of `src/api_hooks.py`: +```python +from dataclasses import dataclass +from src.type_aliases import JsonValue + +@dataclass(frozen=True) +class WebSocketMessage: + channel: str + payload: JsonValue +``` + +- [ ] **Step 2: Update `_serialize_for_api` return type** + +Change `def _serialize_for_api(obj: Any) -> Any:` to `def _serialize_for_api(obj: Any) -> JsonValue:`. + +(The body stays the same; the type hint change is the only modification.) + +- [ ] **Step 3: Update `broadcast` signature** + +Change `def broadcast(self, channel: str, payload: dict[str, Any]) -> None:` to `def broadcast(self, message: WebSocketMessage) -> None:` (and update the body to use `message.channel` + `message.payload`). + +- [ ] **Step 4: Update broadcast callers** (~5-10 sites) + +In `src/app_controller.py` and `src/gui_2.py`, change: +```python +broadcast(channel="x", payload={"k": "v"}) +``` +to: +```python +broadcast(WebSocketMessage(channel="x", payload={"k": "v"})) +``` + +- [ ] **Step 5: Run tests** + +```bash +uv run pytest tests/test_api_hooks.py tests/test_app_controller*.py --timeout=60 +``` +Expected: PASS + +### Task 5.3: Phase 5 checkpoint + +```bash +git add -A +git commit -m "conductor(checkpoint): Phase 5 complete - api_hooks WebSocketMessage (P3, 16 sites migrated)" +git notes add -m "Phase 5 checkpoint: WebSocketMessage dataclass + JsonValue usage; _serialize_for_api signature updated; Pattern 4 preserved" HEAD +``` + +--- + +## Phase 6: Verify + Docs + Archive + +Focus: Full audit + 11-tier regression + type registry regeneration + end-of-track report + archive. + +### Task 6.1: Full audit + regression suite + +- [ ] **Step 1: Run all 3 audits** + +```bash +uv run python scripts/audit_weak_types.py --strict +uv run python scripts/audit_dataclass_coverage.py --strict +uv run python scripts/generate_type_registry.py --check +``` +Expected: all exit 0 + +- [ ] **Step 2: Run 11-tier batched regression** + +```bash +uv run python scripts/run_tests_batched.py +``` +Expected: all tiers PASS (or with documented pre-existing skips) + +### Task 6.2: Regenerate type registry + +- [ ] **Step 1: Run the generator** + +```bash +uv run python scripts/generate_type_registry.py +git add docs/type_registry/ +git commit -m "docs(type_registry): regenerate with new modules (mcp_tool_specs, openai_schemas, provider_state)" +``` + +- [ ] **Step 2: Verify --check** + +Run: `uv run python scripts/generate_type_registry.py --check` +Expected: "Registry in sync (N files checked)" + +### Task 6.3: Write end-of-track report + +- [ ] **Step 1: Write the report** + +Create `docs/reports/TRACK_COMPLETION_any_type_componentization_20260621.md` covering: +- Executive summary (89 Any sites resolved; 5 dataclasses added; 3 new modules) +- The 5 candidates (per spec §4) +- Per-phase outcomes (sites migrated, tests added, commits) +- Verification commands + results +- Out of scope (211 Any sites remaining; Pattern 3/4/5 preserved) +- Follow-up tracks (any_type_componentization_phase2, openai_tools_dataclass_bridge) + +- [ ] **Step 2: Commit** + +```bash +git add docs/reports/TRACK_COMPLETION_any_type_componentization_20260621.md +git commit -m "docs(reports): TRACK_COMPLETION_any_type_componentization_20260621" +``` + +### Task 6.4: Archive + tracks.md update + +- [ ] **Step 1: Move track dir to archive** + +```bash +git mv conductor/tracks/any_type_componentization_20260621 conductor/tracks/archive/ +``` + +- [ ] **Step 2: Update tracks.md** + +Find the entry for `any_type_componentization_20260621` (added during track init; mark as `[x]` completed + move to Recently Completed section). + +- [ ] **Step 3: Final state.toml update + commit** + +Update `state.toml` to set all phases `completed` and the track status `completed`. + +```bash +git add -A +git commit -m "conductor(archive): ship any_type_componentization_20260621 to archive" +git notes add -m "TRACK COMPLETE: any_type_componentization_20260621. 5 fat-struct candidates promoted (89 sites); 3 new modules; 1 new audit script; 1 styleguide section. 211 Any sites remain (Patterns 3/4/5)" HEAD +``` + +--- + +## Self-Review (run after writing the plan) + +**1. Spec coverage check:** Each section in `spec.md` maps to a task in this plan. + +| Spec section | Plan coverage | +|---|---| +| §1 Overview + sequencing correction | n/a (background) | +| §2 Goals (A/B/C/D) | Phase 0 (B: JsonValue + styleguide §12) + Phases 1-5 (A: 5 candidates) + Phase 6 (C: registry) | +| §3 Architecture | n/a (design intent, in spec) | +| §4 Per-Phase Plan | Phases 0-6 in plan | +| §5 Audit Script as CI Gate | Phase 0 Task 0.2 | +| §6 Configuration | No new deps (consistent throughout) | +| §7 Testing Strategy | Each phase has its own test file; 48+ tests total | +| §8 Migration Rollout | 7 phases, ~50 commits | +| §9 Risks | Phase 3 Task 3.1 baseline snapshot + Task 3.6 SDK client verification | +| §10 Out of Scope | Pattern 3/4/5 preserved (Phase 3 Task 3.6 verification + Phase 5 Task 5.1 test) | +| §11 Decisions | Documented in spec | +| §12 See Also | n/a (references) | +| §13 Verification Criteria | Phase 6 Task 6.1 + 6.2 | + +**2. Placeholder scan:** Searched the plan for "TBD", "TODO", "fill in details" — none present in actionable steps. + +**3. Type consistency check:** Names used consistently: +- `ToolSpec` (defined Phase 1, used throughout) +- `ProviderHistory` (defined Phase 3, used throughout) +- `ChatMessage`, `UsageStats`, `ToolCall` (defined Phase 2) +- `WebSocketMessage` (defined Phase 5) +- `JsonValue` (defined Phase 0, used Phase 5) + +No naming drift. + +**4. Ambiguity check:** Step descriptions are concrete (exact file:line refs, full code blocks, explicit verification commands). + +--- + +## Execution Handoff + +Plan complete and saved to `conductor/tracks/any_type_componentization_20260621/plan.md`. + +**Two execution options:** + +1. **Subagent-Driven (recommended)** — Dispatch a fresh Tier 3 subagent per task, review between tasks, fast iteration. Best for ~50-commit tracks with clear per-task boundaries. + +2. **Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints. Best for tight feedback loops when the user is watching. + +**Recommended for this track:** Subagent-Driven (Phase 3 alone has 15 tasks; per-task review prevents the largest ripple from cascading). + +**Blockers before execution can start:** +- `data_structure_strengthening_20260606` must merge to `master` (this track is `blocked_by` it per spec §1 / metadata.json) +- Tier 2 must first apply the data_structure_strengthening polish (5 must-fix items per Tier 1 review) on the tier2 branch + +**Then Tier 2 creates a new branch `tier2/any_type_componentization_20260621` from updated `master` and starts Phase 0.** \ No newline at end of file