portfolio-optimiser/.claude/projects/2026-06-24-fase2-mvp-vertical-slice/plan.md
Kjell Tore Guttormsen 9973d9fe58 docs(fase2): /trekplan — adversarial-reviewed implementation plan [skip-docs]
14-step plan composing the four Fase 1 spikes into a src/ vertical slice
(debate -> blocking validator -> two-layer HITL + provenance -> ExpeL learning),
deterministic-core-first, on real chat clients in both profiles. Grounded in the
3 research briefs + installed-1.9.0-source introspection (7 exploration agents).

Adversarial review: plan-critic REVISE (3 blockers/7 major/4 minor) -> all
addressed; scope-guardian ALIGNED (0 creep, 9/9 criteria mapped, 6/6 Non-Goals
honored). Key revisions: in-process retriever-as-tool MVP path (mcp dep
conditional/GA-only); two-layer HITL capture + stable Verdict.id minting;
extend_instructions retired by a REAL SessionContext test; TextSpan ownership +
wave re-ordering; budget None-as-hard-fail with synthetic-usage test double;
self_repair token-bound in the generate loop (validator.py frozen); global
stop-on-failure rule. gemini-bridge Pass 2 unavailable (MCP SDK broke).
plan-validator strict 0 errors. brief research_status pending to complete.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fif1r1En5W542HbZV88yMH
2026-06-24 12:58:33 +02:00

53 KiB
Raw Blame History

Fase 2 — MVP Vertical Slice: one synthetic project end-to-end on MAF 1.9.0

Plan quality: A (88/100) — APPROVE_WITH_NOTES (revised after adversarial review)

Generated by trekplan v2.0 on 2026-06-24 — plan_version: 1.7

Context

Fase 1 proved the four riskiest assumptions in isolation with throwaway spikes (maker-checker convergence, fan-out state-bleed, the hybrid blocking validator, ExpeL retrieval) and produced a verified MAF 1.9.0 capability map. Fase 2 builds the first thing that is actually a system: ONE synthetic "anleggskostnad" project flowing through the whole method — candidate measures debated (Group Chat maker-checker), a deterministic validator deciding the value, a domain expert judging via HITL, and the judgment fed back so the next run is smarter — on real (not faked) chat clients, in both profiles. The danger being bought down is integration risk: each spike worked alone; the open question is whether they compose into one fail-fast, budget-bounded, provenance-stamped, learning loop. This slice is the MVP backbone every later phase builds on (Fase 3 fan-out, Fase 4 open-source, the D7 Claude-SDK sibling), so getting the seams right matters more than breadth (brief §Intent).

Three research briefs (installed-1.9.0-source-verified) correct the brief's earlier leanings and are binding on this plan:

  • HITL (research 01): capture the durable learned verdict out-of-band in the VerdictStore; use the GA in-run gate (GroupChatBuilder.with_request_info) only for optional synchronous Layer-1 review; defer checkpointing off the MVP path.
  • Data access (research 02): the official @modelcontextprotocol/server-filesystem cannot carry citations → the data source is a framework-agnostic in-process retriever returning {file, locator, snippet, score} (the D7-portable seam), exposed to the agents as a GA tool/ContextProvider; provenance is first-class Pydantic data, not MAF's Annotation (open streaming-drop bug #4316).
  • Local client (research 03): use OpenAIChatCompletionClient(base_url=…) non-streaming (NOT OpenAIChatClient/Responses); UsageDetails is populated None-safely non-streaming; the blocking validator becomes the retry/repair reliability mechanism for weak local models.

Architecture Diagram

graph TD
    subgraph "src/portfolio_optimiser/ — Fase 2 vertical slice"
        RUN[run.py orchestrator<br/>single-command entry] --> CONTRACTS[contracts.py<br/>fail-fast loaders]
        RUN --> WF[workflow.py<br/>fresh_workflow + GroupChat maker-checker]
        RUN --> CAP[verdicts.py<br/>capture_verdict Layer-2 + VerdictStore + ExpeL]
        WF --> BK[backends.py<br/>LocalBackend OpenAIChatCompletionClient / FoundryChatClient + model-map]
        WF --> BUD[budget.py<br/>TokenMeter + ChatMiddleware off UsageDetails]
        WF --> TOOL[retrieval.py exposed as GA tool/ContextProvider]
        TOOL --> RET[retrieval.py<br/>in-process retriever core + path-security - D7 seam]
        WF --> GEN[generate.py<br/>LLM->IR + validator-as-retry]
        GEN --> VAL[validator.py + ir.py<br/>blocking: IR->CBC->Monte-Carlo]
        VAL --> PROV[provenance.py<br/>first-class Pydantic stamp]
        PROV --> CAP
        CAP -.next run.-> WF
    end
    REF[reference_domain.py + data/*.json<br/>Fase 0, settled] --> RUN

Codebase Analysis

  • Tech stack: Python ≥3.10, uv, hatchling, ruff (line-length 100), mypy, pytest+pytest-asyncio (asyncio_mode="auto"). Installed (ground-truth-verified): agent-framework-core 1.9.0, -openai 1.8.2, -foundry 1.8.2, -orchestrations 1.0.0; underlying openai SDK 2.43.0; pulp 3.3.2; pydantic 2.13.4. CBC binary present (.venv/.../pulp/solverdir/cbc/osx/i64/cbc). mcp SDK is NOT installed (governs Step 7's build-vs-defer decision).
  • Key patterns: backend profiles = Strategy+Factory (backends.py:26-84, ChatBackend @runtime_checkable Protocol + Profile enum + get_backend(), fail-fast ValueError); validator = staged pipeline returning a type-discriminated ValidatedProposal | Rejection (frozen dataclasses); debate = GroupChatBuilder round-robin with deterministic termination; learning = retrieval + ContextProvider injection. Tests: tests/ (production) + tests/spikes/ (mirror); gated live arms via @pytest.mark.skipif(_NO_ENDPOINT); determinism asserted over fixed inputs.
  • Relevant files (verified): src/portfolio_optimiser/backends.py (D2 seam, both create_chat_client raise NotImplementedError :50-61,64-76), reference_domain.py (Project/CostItem frozen + load_reference_projects()), data/reference_projects.json (3 projects, project[0]=FV42-GSV-E1). Real injection seam for tests: SessionContext (exported, _sessions.py:154; extend_instructions(source_id, instructions) two-arg :253).
  • Reusable code (promote from spikes — REUSE near-verbatim unless noted): spikes/c_validator.py:58-205 (Pydantic IR + validate_proposal + CBC + Monte-Carlo seed _MC_SEED=20260624 + self_repair attempts-bounded + proposal_for; generate_via_llm :207 is the stub); spikes/d_verdictstore.py:29-227 (ProposalFeatures/Verdict/similarity/VerdictStore/ExpeLContextProvider/seed_store — bug :115 one-arg extend_instructions, masked by single-arg fake test_d_verdictstore.py:99); spikes/b_footguns.py:100-106 (fresh_workflow()); spikes/a_groupchat.py:48-63,130-163 (termination + maker-checker builder); spikes/_harness.py:41-94 (Budget/TokenMeter — drop _word_tokens :97-99).
  • External tech (researched): see Research Sources.
  • Recent git activity: src/ written in Fase 0 (b57aa83), cold/settled. Spike cluster most-recently churned by the round-2 fan-out rebuild (a2dff21). Conventions: Conventional Commits, feat(fase2):, [skip-docs] on doc commits.

Research Sources

Technology Source Key Findings Confidence
MAF native HITL research/01 ctx.request_info/@response_handler/run(responses=); GroupChatBuilder.with_request_info(agents=[checker]); durable checkpoint-resume fragile → out-of-band verdict, defer checkpointing high
Local-folder MCP + citations research/02 official filesystem server can't cite → in-process retriever (D7 seam) + provenance as own Pydantic (route around #4316); SessionContext.extend_instructions(source_id,…) confirmed high
Local chat client + UsageDetails research/03 OpenAIChatCompletionClient(base_url) non-streaming; UsageDetails None-safe (_chat_completion_client.py:705,757); add_usage_details(); qwen3:4b; Intel-CPU = plumbing only high
Solver Spike C + installed pulp 3.3.2, bundled CBC works Intel mac; PULP_CBC_CMD deprecation; PuLP 4.0 → pulp[cbc]/COIN_CMD high

Implementation Plan

Deterministic-core-first ordering (MAF-free modules before MAF-coupled wiring). TDD throughout. Dependency note: TextSpan is owned by retrieval.py (Step 5) and imported by provenance.py (Step 6) — so Step 6 follows Step 5. Headless halt rule (applies to every step): on any failure, perform the step's On-failure action then HALT the session — never run a dependent step on a failed predecessor.

Step 1: Promote orchestrations + pulp to core; resolve the mcp dependency decision

  • Files: pyproject.toml
  • Changes: Move agent-framework-orchestrations>=1.0.0 and pulp>=2.8 from dev into [project.dependencies] (now MVP-runtime). Update the pulp comment to note installed 3.3.2 + the PuLP-4.0 pulp[cbc]/COIN_CMD migration. mcp decision: attempt uv add mcp — if it resolves a GA (non-pre) release, add it to core deps (enables the optional thin MCP-server wrapper in Step 7); if it resolves only as a pre-release, DO NOT add it (GA-pin discipline) and record that the MVP data source is the in-process retriever-as-tool (research 02 fallback), with the MCP-server wrapper deferred as a documented fast-follow. Re-lock. (Per brief Constraints.)
  • Reuses: existing dependency block pyproject.toml:7-26.
  • Test first:
    • File: tests/test_imports_core.py (new)
    • Verifies: agent_framework_orchestrations + pulp import; PULP_CBC_CMD().available() is True.
    • Pattern: tests/spikes/test_imports.py
  • Verify: uv lock && uv sync && uv run pytest tests/test_imports_core.py → expected: 1 passed
  • On failure: retry once pinning exact installed versions; if still failing, escalate — then HALT.
  • Checkpoint: git commit -m "build(fase2): promote orchestrations + pulp to core deps"
  • Manifest:
    manifest:
      expected_paths:
        - pyproject.toml
        - tests/test_imports_core.py
      min_file_count: 2
      commit_message_pattern: "^build\\(fase2\\): promote orchestrations \\+ pulp to core deps$"
      bash_syntax_check: []
      forbidden_paths:
        - src/portfolio_optimiser/backends.py
      must_contain:
        - path: pyproject.toml
          pattern: "agent-framework-orchestrations"
    

Step 2: Promote the Pydantic IR + blocking validator to src/

  • Files: src/portfolio_optimiser/ir.py (new), src/portfolio_optimiser/validator.py (new)
  • Changes: Move AffectedItem/SavingsProposal (+ validators) into ir.py. Move validate_proposal, ValidatedProposal, Rejection, _solve_max_feasible, _monte_carlo (keep _MC_SEED=20260624), self_repair (attempts-bounded — promoted verbatim; token-budget bounding is added by the generate loop in Step 10, NOT here), proposal_for, CbcUnavailable, _quiet_pulp into validator.py. Pure modules — NO agent_framework import (D7-portable core). Import Project from reference_domain. (new files)
  • Reuses: spikes/c_validator.py:44-205; reference_domain.Project.
  • Test first:
    • File: tests/test_validator.py (new)
    • Verifies: valid IR → ValidatedProposal (p10<=p50<=p90); out-of-range → Rejection (reason, no percentiles); same fixed input twice → identical (p10,p50,p90); Pydantic blocks negative qty / claim>affected-total.
    • Pattern: tests/spikes/test_c_validator.py:24-78 (determinism over FIXED inputs, never an LLM)
  • Verify: uv run pytest tests/test_validator.py → expected: 5 passed
  • On failure: revert git checkout -- src/portfolio_optimiser/ir.py src/portfolio_optimiser/validator.py tests/test_validator.py; HALT.
  • Checkpoint: git commit -m "feat(fase2): promote blocking validator + IR to src"
  • Manifest:
    manifest:
      expected_paths:
        - src/portfolio_optimiser/ir.py
        - src/portfolio_optimiser/validator.py
        - tests/test_validator.py
      min_file_count: 3
      commit_message_pattern: "^feat\\(fase2\\): promote blocking validator \\+ IR to src$"
      bash_syntax_check: []
      forbidden_paths: []
      must_contain:
        - path: src/portfolio_optimiser/validator.py
          pattern: "class Rejection"
        - path: src/portfolio_optimiser/validator.py
          pattern: "_MC_SEED"
    

Step 3: Promote VerdictStore + ExpeL + verdict capture (two-arg fix; real-SessionContext test)

  • Files: src/portfolio_optimiser/verdicts.py (new)
  • Changes: Move ProposalFeatures, Verdict, similarity, VerdictStore (in-memory, deterministic top-k retrieve), ExpeLContextProvider(ContextProvider), seed_store(). Fix the Fase 1 bug: before_run must call context.extend_instructions(self.source_id, [self.format_fewshot()]) — TWO args (_sessions.py:253). Add Verdict.id minting = stable content hash sha256(canonical(ProposalFeatures))[:16] (so a structurally identical proposal maps to a stable id) and capture_verdict(features, decision, rationale) -> Verdict (the Layer-2 out-of-band constructor). VerdictStore is in-memory only for the MVP — no JSON/file persistence (durable persistence deferred to Fase 3; the brief's learning-loop criterion is satisfied within a session via seed_store() + in-memory state). (new file)
  • Reuses: spikes/d_verdictstore.py:29-227 (with the :115 two-arg correction); agent_framework.ContextProvider, agent_framework.SessionContext.
  • Test first:
    • File: tests/test_verdicts.py (new)
    • Verifies: structural match beats text decoys (hits[0].id=="TRUE"); retrieval deterministic; k<=0 raises; capture_verdict mints a stable id (same features → same id); before_run populates a REAL SessionContext via two-arg extend_instructions(source_id, instructions) — construct from agent_framework import SessionContext, call await provider.before_run(...) with it, assert the retrieved verdict text landed in ctx.instructions (NOT a single-arg fake — this exercises the genuine GA signature and retires the Critical risk).
    • Pattern: tests/spikes/test_d_verdictstore.py:67-89 (retrieval) + real SessionContext
  • Verify: uv run pytest tests/test_verdicts.py → expected: 5 passed
  • On failure: revert git checkout -- src/portfolio_optimiser/verdicts.py tests/test_verdicts.py; HALT.
  • Checkpoint: git commit -m "feat(fase2): VerdictStore + ExpeL + capture_verdict (two-arg, real SessionContext test)"
  • Manifest:
    manifest:
      expected_paths:
        - src/portfolio_optimiser/verdicts.py
        - tests/test_verdicts.py
      min_file_count: 2
      commit_message_pattern: "^feat\\(fase2\\): VerdictStore \\+ ExpeL \\+ capture_verdict \\(two-arg, real SessionContext test\\)$"
      bash_syntax_check: []
      forbidden_paths: []
      must_contain:
        - path: src/portfolio_optimiser/verdicts.py
          pattern: "extend_instructions\\(self.source_id"
        - path: src/portfolio_optimiser/verdicts.py
          pattern: "def capture_verdict"
    

Step 4: Token meter + budget ChatMiddleware off real UsageDetails

  • Files: src/portfolio_optimiser/budget.py (new)
  • Changes: Promote Budget/BudgetExceeded/TokenMeter (fail-fast positive caps, structured BudgetExceeded.kind/limit/observed) fed from real UsageDetails: a ChatMiddleware reads response.usage_details["total_token_count"] (None-safe via add_usage_details) and short-circuits when the cap is crossed. usage_details is None handling: a strict_usage: bool flag (default True in dev/prod) makes a None usage a HARD FAIL (a usage regression must never silently disable the cap, research 03 Rec 3); test doubles that legitimately supply synthetic usage do not trip it. NO len(...split()) proxy. (new file)
  • Reuses: spikes/_harness.py:41-94 (drop _word_tokens); agent_framework.UsageDetails/add_usage_details (_types.py:417), agent_framework.ChatMiddleware.
  • Test first:
    • File: tests/test_budget.py (new)
    • Verifies (hand-built UsageDetails, no LLM): meter reads total_token_count + accumulates; cap crossed → BudgetExceeded; non-positive cap rejected; with strict_usage=True, usage_details=None raises. Meta-guard: grep -rE "\.split\(\)" src/ finds zero token proxies.
    • Pattern: tests/spikes/test_harness.py:27-60
  • Verify: uv run pytest tests/test_budget.py → expected: 5 passed
  • On failure: revert git checkout -- src/portfolio_optimiser/budget.py tests/test_budget.py; HALT.
  • Checkpoint: git commit -m "feat(fase2): real-UsageDetails token meter + budget middleware"
  • Manifest:
    manifest:
      expected_paths:
        - src/portfolio_optimiser/budget.py
        - tests/test_budget.py
      min_file_count: 2
      commit_message_pattern: "^feat\\(fase2\\): real-UsageDetails token meter \\+ budget middleware$"
      bash_syntax_check: []
      forbidden_paths: []
      must_contain:
        - path: src/portfolio_optimiser/budget.py
          pattern: "usage_details"
    

Step 5: Framework-agnostic local-folder retriever core (owns TextSpan + path-security)

  • Files: src/portfolio_optimiser/retrieval.py (new)
  • Changes: Define TextSpan (start_index,end_index — the single owner; imported by provenance in Step 6), RetrievedChunk (file, locator: TextSpan, snippet, score), and retrieve(query, docs_dir, top_k) -> list[RetrievedChunk]. Chunk-at-ingest with EXACT char-span locators (citations exact by construction). Keyword/substring scoring (no heavy vector deps — D5/D6). Path-security (this module reads the docs folder): canonicalise + os.path.realpath + boundary-check every accessed path against docs_dir, fail closed on prefix-collision and symlink-escape (the EscapeRoute CVE class, research 02 Dim 5); native file APIs only. NO agent_framework/mcp imports — D7-portable seam. (new file)
  • Reuses: stdlib only; TextSpan aligns with provenance.Citation.locator (Step 6).
  • Test first:
    • File: tests/test_retrieval.py (new)
    • Verifies: retrieve returns chunks whose locator char-span exactly slices snippet; deterministic for fixed query+dir; top_k respected; a ../ traversal and a symlink pointing outside docs_dir are both rejected (fail-closed); a sibling dir sharing a name prefix is rejected.
    • Pattern: tests/test_reference_domain.py:8-38 + tests/test_backends.py:30-32 (raises)
  • Verify: uv run pytest tests/test_retrieval.py → expected: 7 passed
  • On failure: revert git checkout -- src/portfolio_optimiser/retrieval.py tests/test_retrieval.py; HALT.
  • Checkpoint: git commit -m "feat(fase2): local-folder retriever core with path-security"
  • Manifest:
    manifest:
      expected_paths:
        - src/portfolio_optimiser/retrieval.py
        - tests/test_retrieval.py
      min_file_count: 2
      commit_message_pattern: "^feat\\(fase2\\): local-folder retriever core with path-security$"
      bash_syntax_check: []
      forbidden_paths: []
      must_contain:
        - path: src/portfolio_optimiser/retrieval.py
          pattern: "class RetrievedChunk"
        - path: src/portfolio_optimiser/retrieval.py
          pattern: "realpath"
    

Step 6: First-class Pydantic provenance stamp

  • Files: src/portfolio_optimiser/provenance.py (new)
  • Changes: Define Citation (file, locator: TextSpan imported from retrieval, snippet) and ProvenanceStamp (citations: list[Citation] min_length=1, model, role, validator_decision: Literal["validated","rejected"], token_usage: int). Authoritative provenance — independent of MAF Annotation (#4316). Provide to_annotations() mapping to MAF Annotation(type="citation", …) for display only. (new file; imports TextSpan from Step 5)
  • Reuses: retrieval.TextSpan (Step 5); agent_framework._types.Annotation/TextSpanRegion (display adapter); pydantic.BaseModel.
  • Test first:
    • File: tests/test_provenance.py (new)
    • Verifies: 0 citations → ValidationError; valid stamp exposes ≥1 citation + model/role + decision + token usage; to_annotations() yields type=="citation" dicts.
    • Pattern: tests/spikes/test_c_validator.py:66-78
  • Verify: uv run pytest tests/test_provenance.py → expected: 3 passed
  • On failure: revert git checkout -- src/portfolio_optimiser/provenance.py tests/test_provenance.py; HALT.
  • Checkpoint: git commit -m "feat(fase2): first-class Pydantic provenance stamp"
  • Manifest:
    manifest:
      expected_paths:
        - src/portfolio_optimiser/provenance.py
        - tests/test_provenance.py
      min_file_count: 2
      commit_message_pattern: "^feat\\(fase2\\): first-class Pydantic provenance stamp$"
      bash_syntax_check: []
      forbidden_paths: []
      must_contain:
        - path: src/portfolio_optimiser/provenance.py
          pattern: "class ProvenanceStamp"
    

Step 7: Expose the retriever to the agents as a citation-bearing data source

  • Files: src/portfolio_optimiser/datasource.py (new)
  • Changes: Wire retrieval.retrieve() into the debate as a citation-bearing data source. MVP (GA-safe) path: a GA @tool/FunctionTool (or a ContextProvider) that calls retrieve() and returns chunks whose {file, locator, snippet} the orchestrator maps into provenance.Citation — no new dependency, D7-portable. Conditional MCP wrapper: IF Step 1 added mcp as a GA dependency, ALSO expose a thin custom stdio MCP server (FastMCP) returning the same chunks as structuredContent, consumed via MCPStdioTool(parse_tool_results=…), to honor the CLAUDE.md "data access via MCP" convention; if mcp was not GA-addable, the MCP wrapper is a documented fast-follow and the MVP uses the in-process tool. Either way the path-security lives in retrieval.py (Step 5). (new file)
  • Reuses: retrieval.retrieve (Step 5); provenance.Citation (Step 6); agent_framework @tool/FunctionTool; (conditional) MCPStdioTool + mcp.
  • Test first:
    • File: tests/test_datasource.py (new)
    • Verifies: the tool/ContextProvider returns citation-ready chunks for a query over the synthetic docs dir; chunks carry exact locators usable to build a Citation; (if mcp present) the MCP wrapper returns the same structuredContent shape.
    • Pattern: tests/test_retrieval.py (Step 5) + tests/test_backends.py
  • Verify: uv run pytest tests/test_datasource.py → expected: all passed
  • On failure: revert git checkout -- src/portfolio_optimiser/datasource.py tests/test_datasource.py; HALT.
  • Checkpoint: git commit -m "feat(fase2): expose retriever as citation-bearing data source"
  • Manifest:
    manifest:
      expected_paths:
        - src/portfolio_optimiser/datasource.py
        - tests/test_datasource.py
      min_file_count: 2
      commit_message_pattern: "^feat\\(fase2\\): expose retriever as citation-bearing data source$"
      bash_syntax_check: []
      forbidden_paths: []
      must_contain:
        - path: src/portfolio_optimiser/datasource.py
          pattern: "retrieve"
    

Step 8: Wire the two backend profiles + role→model map + env template

  • Files: src/portfolio_optimiser/backends.py, src/portfolio_optimiser/data/model_map.json (new), .env.template (new), tests/test_backends.py
  • Changes: Implement LocalBackend.create_chat_clientOpenAIChatCompletionClient(base_url=<PORTFOLIO_LOCAL_BASE_URL>, api_key=<PORTFOLIO_LOCAL_API_KEY default "ollama">, model_id=<resolved>) configured non-streaming (research 03 — NOT OpenAIChatClient). Implement AzureFoundryBackend.create_chat_clientFoundryChatClient (deployment names from env). Add a data/model_map.json (the role→model/deployment map artifact, the SAME file Step 11's contract validates) loaded into a small resolver feeding create_chat_client(model=…). Add .env.template documenting PORTFOLIO_LOCAL_BASE_URL, PORTFOLIO_LOCAL_API_KEY, Foundry project_endpoint/credential + the research-03 no-egress notes (Ollama bind 127.0.0.1, pin ≥0.17.1, OLLAMA_DEBUG unset, model-pull = explicit egress). Keep get_backend() fail-fast. Invert the skeleton test.
  • Reuses: backends.py:26-84; spikes/_harness.py:191-215 (env pattern, switch client class); agent_framework_openai.OpenAIChatCompletionClient, agent_framework_foundry.FoundryChatClient.
  • Test first:
    • File: tests/test_backends.py (existing — modify)
    • Verifies: both backends satisfy ChatBackend; unknown profile raises ValueError; the model_map resolves a role→model id; create_chat_client returns a client object (no network) — replace test_create_chat_client_is_skeleton (:36).
    • Pattern: tests/test_backends.py:14-39
  • Verify: uv run pytest tests/test_backends.py → expected: all passed
  • On failure: revert git checkout -- src/portfolio_optimiser/backends.py src/portfolio_optimiser/data/model_map.json .env.template tests/test_backends.py; HALT.
  • Checkpoint: git commit -m "feat(fase2): wire local + Foundry profiles + model-map + env template"
  • Manifest:
    manifest:
      expected_paths:
        - src/portfolio_optimiser/backends.py
        - src/portfolio_optimiser/data/model_map.json
        - .env.template
        - tests/test_backends.py
      min_file_count: 4
      commit_message_pattern: "^feat\\(fase2\\): wire local \\+ Foundry profiles \\+ model-map \\+ env template$"
      bash_syntax_check: []
      forbidden_paths: []
      must_contain:
        - path: src/portfolio_optimiser/backends.py
          pattern: "OpenAIChatCompletionClient"
    

Step 9: fresh_workflow isolation factory + maker-checker GroupChat (Layer-1 HITL gate)

  • Files: src/portfolio_optimiser/workflow.py (new)
  • Changes: Promote fresh_workflow() as the core factory — FRESH GroupChatBuilder + FRESH chat clients per project run (zero cross-run bleed, B7). Maker-checker debate: proposer + checker, deterministic termination (make_termination), with_max_rounds(n) AND the external budget-middleware guard (B4: max_round_count=None does NOT self-terminate). Layer-1 HITL = optionally enable with_request_info(agents=[checker]) (in-process synchronous review, no checkpoint). Non-streaming. (new file)
  • Reuses: spikes/b_footguns.py:100-106; spikes/a_groupchat.py:48-63,130-163; budget.ChatMiddleware (Step 4); backends (Step 8); agent_framework_orchestrations.GroupChatBuilder.
  • Test first:
    • File: tests/test_workflow.py (new)
    • Verifies (FakeChatClient): fresh workflow per run → each run a clean thread; a participant sees ONLY its own project (assert on received-message CONTENT, not a call counter — the Fase 1 round-2 fix); a never-converging debate with with_max_rounds(3) halts at exactly 3 rounds (pin the observed count, retiring the 1-turn=1-round off-by-one assumption — not just <=).
    • Pattern: tests/spikes/test_b_footguns.py:40-45 + tests/spikes/test_harness.py:103-126
  • Verify: uv run pytest tests/test_workflow.py → expected: all passed
  • On failure: revert git checkout -- src/portfolio_optimiser/workflow.py tests/test_workflow.py; HALT.
  • Checkpoint: git commit -m "feat(fase2): fresh_workflow factory + maker-checker GroupChat"
  • Manifest:
    manifest:
      expected_paths:
        - src/portfolio_optimiser/workflow.py
        - tests/test_workflow.py
      min_file_count: 2
      commit_message_pattern: "^feat\\(fase2\\): fresh_workflow factory \\+ maker-checker GroupChat$"
      bash_syntax_check: []
      forbidden_paths: []
      must_contain:
        - path: src/portfolio_optimiser/workflow.py
          pattern: "with_max_rounds"
    

Step 10: LLM→IR generation wired to validator-as-retry (token budget in the generate loop)

  • Files: src/portfolio_optimiser/generate.py (new)
  • Changes: Implement generate_via_llm(chat_client, project, context, meter) -> SavingsProposal (replaces the stub c_validator.py:207): a NON-STREAMING chat call requesting structured output, parsed into the IR. On ValidationError (small models emit text-leaked/wrong-typed tool calls — research 03 Dim 3), invoke validator.self_repair — bounded by self_repair's own max_attempts AND by the budget meter checked here in the generate loop between attempts (NO edit to validator.py — the validator stays the verbatim-promoted module from Step 2; the token bound lives in this loop). (new file)
  • Reuses: validator.self_repair/ir.SavingsProposal (Step 2); budget meter (Step 4); a chat client from backends (Step 8).
  • Test first:
    • File: tests/test_generate.py (new)
    • Verifies (FakeChatClient scripted): well-formed reply → SavingsProposal; malformed/text-leaked reply → bounded self_repair (retried, not silently accepted); exhausting attempts OR crossing the meter cap → typed failure (never a malformed proposal); validator.py is unmodified (no diff).
    • Pattern: tests/spikes/test_harness.py:66-76 + test_c_validator.py
  • Verify: uv run pytest tests/test_generate.py → expected: 3 passed
  • On failure: revert git checkout -- src/portfolio_optimiser/generate.py tests/test_generate.py; HALT.
  • Checkpoint: git commit -m "feat(fase2): LLM->IR generation with validator-as-retry"
  • Manifest:
    manifest:
      expected_paths:
        - src/portfolio_optimiser/generate.py
        - tests/test_generate.py
      min_file_count: 2
      commit_message_pattern: "^feat\\(fase2\\): LLM->IR generation with validator-as-retry$"
      bash_syntax_check: []
      forbidden_paths:
        - src/portfolio_optimiser/validator.py
      must_contain:
        - path: src/portfolio_optimiser/generate.py
          pattern: "self_repair"
    

Step 11: Fail-fast contract loaders

  • Files: src/portfolio_optimiser/contracts.py (new)
  • Changes: Pydantic models + load_contracts(...) for the four contracts: data-source config, model-map (validates data/model_map.json from Step 8), termination contract, feedback schema. Validate ALL at startup and raise before any chat-client is constructed or called (brief NFR fail-fast). Pydantic gives JSON-Schema-grade validation (CLAUDE.md convention). (new file)
  • Reuses: pydantic.BaseModel; data/model_map.json (Step 8); backends.Profile.
  • Test first:
    • File: tests/test_contracts.py (new)
    • Verifies: malformed data-source / model-map / termination / feedback → ValidationError at load; AND no chat-client call was made (inject a FakeChatClient spy, assert call_count==0).
    • Pattern: tests/test_backends.py:30-32 + FakeChatClient call_count (spikes/_harness.py:138)
  • Verify: uv run pytest tests/test_contracts.py → expected: all passed
  • On failure: revert git checkout -- src/portfolio_optimiser/contracts.py tests/test_contracts.py; HALT.
  • Checkpoint: git commit -m "feat(fase2): fail-fast contract loaders"
  • Manifest:
    manifest:
      expected_paths:
        - src/portfolio_optimiser/contracts.py
        - tests/test_contracts.py
      min_file_count: 2
      commit_message_pattern: "^feat\\(fase2\\): fail-fast contract loaders$"
      bash_syntax_check: []
      forbidden_paths: []
      must_contain:
        - path: src/portfolio_optimiser/contracts.py
          pattern: "def load_contracts"
    

Step 12: Orchestrator + single-command entry (two-layer HITL wiring)

  • Files: src/portfolio_optimiser/run.py (new), src/portfolio_optimiser/__init__.py
  • Changes: run_project(project_id, profile, verdict_input=None) composes the slice: load_contracts (fail-fast) → load project + retrieve cited chunks via the Step-7 data source → fresh_workflow maker-checker debate (budget middleware + round cap; Layer-1 = the optional in-run with_request_info gate) → generate_via_llm candidate → blocking validate_proposalValidatedProposal | Rejection → attach ProvenanceStampLayer-2 (out-of-band): capture_verdict(features, decision, rationale) where decision comes from verdict_input (function arg / CLI prompt / fixture in tests; the B11 notification is a stub in Fase 2) → VerdictStore persist → (next run) ExpeLContextProvider retrieval. Document the two layers explicitly in a module docstring. Console entry for the single-command run. Export public API from __init__.py. (new file + modify __init__.py)
  • Reuses: every Step 211 module.
  • Test first:
    • File: tests/test_run_smoke.py (new)
    • Verifies: run_project importable; runs end-to-end on an injected FakeChatClient (with a fixture verdict_input) and returns a ValidatedProposal with a ProvenanceStamp.
    • Pattern: tests/test_smoke.py
  • Verify: uv run python -c "from portfolio_optimiser.run import run_project" → no error; uv run pytest tests/test_run_smoke.py1 passed
  • On failure: revert git checkout -- src/portfolio_optimiser/run.py src/portfolio_optimiser/__init__.py tests/test_run_smoke.py; HALT.
  • Checkpoint: git commit -m "feat(fase2): vertical-slice orchestrator + two-layer HITL wiring"
  • Manifest:
    manifest:
      expected_paths:
        - src/portfolio_optimiser/run.py
        - tests/test_run_smoke.py
      min_file_count: 2
      commit_message_pattern: "^feat\\(fase2\\): vertical-slice orchestrator \\+ two-layer HITL wiring$"
      bash_syntax_check: []
      forbidden_paths: []
      must_contain:
        - path: src/portfolio_optimiser/run.py
          pattern: "def run_project"
    

Step 13: End-to-end integration test (success criteria, on FakeChatClient with synthetic usage)

  • Files: tests/test_vertical_slice_e2e.py (new), tests/conftest.py (new)
  • Changes: A deterministic CI integration test (scripted FakeChatClient — no real LLM). The test double emits a synthetic UsageDetails (e.g. {"total_token_count": N}) so the budget middleware (Step 4, strict_usage=True) does NOT hard-fail and the provenance stamp's token_usage is a positive number sourced from UsageDetails (this is the in-CI accounting check; the REAL-provider populated-usage assertion lives in Step 14). Add a shared tests/conftest.py providing the scripted FakeChatClient + synthetic-usage factory + seed_store fixtures. Criteria covered: (a) one valid proposal e2e → exactly one ValidatedProposal with populated provenance (≥1 citation, model/role, validator decision, token usage>0 from synthetic UsageDetails); (b) out-of-range → Rejection with reason; (c) Layer-2 verdict captured via capture_verdict → written to VerdictStore (record exists); (d) second run on a structurally similar proposal retrieves the prior verdict via ExpeLContextProvider (assert retrieved.id == persisted.id, exercising two-arg extend_instructions); (e) tiny budget/round cap → halts via middleware/round-cap (assert cap not exceeded); (f) malformed contract → raises before any chat call. (new files)
  • Reuses: run.run_project; tests/conftest.py FakeChatClient + synthetic-usage; seed_store().
  • Test first: this IS the test step.
    • File: tests/test_vertical_slice_e2e.py (new)
    • Verifies: all six assertions above.
    • Pattern: tests/spikes/test_harness.py:103-126 + test_c_validator.py + test_d_verdictstore.py
  • Verify: uv run pytest && uv run ruff check . && uv run mypy src → expected: all pass, ruff clean, mypy clean
  • On failure: escalate — an e2e failure means a composition seam is wrong; diagnose against the failing criterion; HALT.
  • Checkpoint: git commit -m "test(fase2): end-to-end vertical-slice integration suite"
  • Manifest:
    manifest:
      expected_paths:
        - tests/test_vertical_slice_e2e.py
        - tests/conftest.py
      min_file_count: 2
      commit_message_pattern: "^test\\(fase2\\): end-to-end vertical-slice integration suite$"
      bash_syntax_check: []
      forbidden_paths: []
      must_contain:
        - path: tests/test_vertical_slice_e2e.py
          pattern: "ValidatedProposal"
    

Step 14: Gated live-profile checks (real UsageDetails + no-egress)

  • Files: tests/test_local_profile_live.py (new), tests/test_foundry_profile_live.py (new)
  • Changes: Separately-gated tests (@pytest.mark.skipif, NOT default CI). Local: a REAL OpenAIChatCompletionClient(base_url) non-streaming call returns a response AND the meter is populated from real UsageDetails — positive, load-bearing total_token_count > 0 (this is the criterion FakeChatClient cannot prove). Assert the call goes only to the configured loopback base_url (no-egress: no outbound to api.openai.com). Foundry: a trivial agent responds on FoundryChatClient (cheapest model, hard cap — D6). (new files)
  • Reuses: backends (Step 8); live_local_client_or_skip skip pattern (spikes/_harness.py:191-215).
  • Test first: these ARE gated tests.
    • File: tests/test_local_profile_live.py, tests/test_foundry_profile_live.py (new)
    • Verifies: skip cleanly without env; when env present, a successful response + total_token_count > 0 from each path; local call targets only the loopback endpoint.
    • Pattern: tests/spikes/test_a_groupchat.py:14-16,65
  • Verify: uv run pytest tests/test_local_profile_live.py tests/test_foundry_profile_live.py → expected: 2 skipped (no endpoint in CI) — or passed with env set
  • On failure: skip — no endpoint is the expected CI outcome; a non-skip failure with env set escalates; HALT.
  • Checkpoint: git commit -m "test(fase2): gated live local + Foundry profile checks"
  • Manifest:
    manifest:
      expected_paths:
        - tests/test_local_profile_live.py
        - tests/test_foundry_profile_live.py
      min_file_count: 2
      commit_message_pattern: "^test\\(fase2\\): gated live local \\+ Foundry profile checks$"
      bash_syntax_check: []
      forbidden_paths: []
      must_contain:
        - path: tests/test_local_profile_live.py
          pattern: "skipif"
    

Alternatives Considered

Approach Pros Cons Why rejected
Reuse @modelcontextprotocol/server-filesystem No custom code; official Cannot produce citations; npx/Node dep; CVE/supply-chain surface (research 02) Citation NFR is load-bearing; in-process retriever produces exact locators
Standalone thin MCP server on the MVP critical path Honors "data access via MCP" convention fully Requires mcp (not installed; possibly --pre → violates GA-pin); extra process In-process retriever-as-tool for MVP (GA-safe); MCP wrapper conditional on mcp GA (Step 7)
Native OllamaChatClient for local MS-recommended; sidesteps /v1 edges --pre beta + new ollama dep — violates GA-pin OpenAIChatCompletionClient non-streaming; native is spike-gated fallback
MAF checkpoint-resume for durable verdict Native durable pause Fragile (open bugs into 1.9.0; pickle vs Pydantic IR) + D7 lock-in Out-of-band VerdictStore; defer checkpointing
MAF Annotation propagation for provenance Native Python streaming-drop bug #4316 → silent empty citations First-class own Pydantic provenance
JSON-persistent VerdictStore in MVP Cross-process durability Not required by any Fase 2 criterion (in-session learning suffices) In-memory for MVP; durable persistence deferred to Fase 3

Test Strategy

  • Framework: pytest + pytest-asyncio (asyncio_mode="auto"). pythonpath=["src","."], testpaths=["tests"]. New shared tests/conftest.py (Step 13) provides the scripted FakeChatClient + synthetic-UsageDetails factory + seed_store fixtures.
  • Existing patterns: deterministic validator/Monte-Carlo over FIXED inputs (test_c_validator.py:59-63, seed 20260624); FakeChatClient driving real GA builders with round caps (test_harness.py:103-126); gated live arm via skipif (test_a_groupchat.py:14-16); real-SessionContext exercise for the two-arg extend_instructions (Step 3).
  • Determinism discipline (research 03 Dim 5): determinism asserted ONLY over the validator + Monte-Carlo with fixed inputs; NO test asserts LLM output. The propose (non-deterministic) → validate (deterministic) boundary is explicit.
  • Usage-accounting split: in-CI accounting uses the FakeChatClient's synthetic UsageDetails (Step 13); the REAL-provider total_token_count > 0 assertion is gated/live (Step 14) — this is the only place "real UsageDetails" is provable.

Tests to write

Type File Verifies Model test
Unit tests/test_validator.py validate/reject/determinism/Pydantic-block tests/spikes/test_c_validator.py
Unit tests/test_verdicts.py retrieval + REAL-SessionContext two-arg extend_instructions + id minting tests/spikes/test_d_verdictstore.py
Unit tests/test_budget.py UsageDetails meter, strict None hard-fail, cap, no-proxy guard tests/spikes/test_harness.py
Unit tests/test_retrieval.py exact-locator chunks + EscapeRoute path rejection tests/test_reference_domain.py
Unit tests/test_provenance.py ≥1 citation, fields, annotation adapter tests/spikes/test_c_validator.py
Unit tests/test_datasource.py citation-ready chunks via tool/(opt) MCP tests/test_retrieval.py
Unit tests/test_backends.py profiles wired, model-map, no-NotImplementedError tests/test_backends.py (existing)
Integration tests/test_workflow.py fresh-instance isolation (content), exact round cap tests/spikes/test_b_footguns.py
Unit tests/test_generate.py LLM→IR + bounded self_repair (attempts+budget); validator.py unmodified tests/spikes/test_harness.py
Unit tests/test_contracts.py fail-fast before any chat call tests/test_backends.py
Integration tests/test_vertical_slice_e2e.py all 6 e2e criteria (synthetic usage) tests/spikes/test_harness.py
Live (gated) tests/test_local_profile_live.py real client + real UsageDetails>0 + loopback-only tests/spikes/test_a_groupchat.py
Live (gated) tests/test_foundry_profile_live.py trivial Foundry response tests/spikes/test_a_groupchat.py

Risks and Mitigations

Priority Risk Location Impact Mitigation
Critical extend_instructions single-arg bug masked by a tautological fake spikes/d_verdictstore.py:115, test_d_verdictstore.py:99 Learning-loop close crashes against a real SessionContext Step 3: two-arg call + a test using a REAL SessionContext (not a fake), retiring the risk in CI
Critical Budget enforcement word-count theatre / usage=None silently disables cap spikes/_harness.py:97-99 Hard-cap NFR fake; unbounded run Step 4: middleware off UsageDetails; strict_usage None=hard fail; non-streaming; src grep-guard
Critical Round cap not self-terminating Fase 1 B4 Unbounded debate on a real client Step 9: explicit with_max_rounds + external budget guard; test pins EXACT round count
High usage=None on FakeChatClient breaks e2e under strict middleware Steps 4/13 e2e cannot run Step 13: test double emits synthetic UsageDetails; real-usage assertion moved to gated Step 14
High mcp not installed / possibly --pre Steps 1/7 Step 7 import fails; GA-pin violated Step 1 conditional add; Step 7 MVP uses in-process tool (no dep); MCP wrapper only if mcp GA
High Wrong OpenAI client (Responses vs Chat Completions) spikes/_harness.py:210, backends.py:65 Local profile fails / UsageDetails not populated Step 8: OpenAIChatCompletionClient non-streaming
High Small-model tool-calling unreliability research 03 Dim 3 Malformed candidates crash/pass Step 10: validator-as-retry (attempts+budget); qwen3:4b; num_ctx≥32k
High Provenance via MAF annotation (#4316) research 02 Dim 2 Citations silently drop on streaming Step 6: own Pydantic provenance; non-streaming
High Retriever owns path/symlink security (EscapeRoute) Step 5 Out-of-folder read → silent exfil Step 5: canonicalise+realpath+boundary fail-closed; TDD bypass patterns
Medium self_repair token-budget vs frozen validator Steps 2/10 Scope-fence violation Step 10: budget checked in generate loop; validator.py in forbidden_paths
Medium HITL verdict on checkpoint-resume research 01 Silent loss / pickle rejection Out-of-band capture_verdict; checkpointing deferred
Medium Determinism asserted through the LLM research 03 Dim 5 Flaky tests Pin validator+MC inputs only
Medium Intel-CPU latency makes a real debate minutes-long research 03 Dim 4 Live test hangs Tiny runs (1 maker+1 checker, ≤3 rounds, hard caps); gate live; representative runs on Foundry
Medium fresh_workflow reuse → B7 bleed returns spikes/b_footguns.py:100 Silent cross-project contamination Step 9: fresh workflow + clients per run; B7 content-level test
Medium Headless cascade on dependency-chained failure all steps Downstream steps run on a failed predecessor Global halt rule + per-step "HALT" in On-failure
Low #1772 double system prompt (OpenAIChatClient+Ollama+middleware) research 03 Dim 3 Wasted tokens Verify vs 1.9.0 in client spike-gate; native client avoids it
Low no-op uv.lock diff on re-lock Step 1 Manifest min_file_count misfire Step 1 manifest gates only pyproject.toml + test (min_file_count 2)

Assumptions

# Assumption Why unverifiable Impact if wrong
1 Research is COMPLETE (3 valid briefs on disk) — (verified) Would re-run research
2 Follow research over brief prose (HITL split, in-process retriever, OpenAIChatCompletionClient non-streaming) research post-dates brief prose Planning against superseded premises
3 Operator supplies a working local OpenAI-compatible endpoint + model (Ollama qwen3:4b on 127.0.0.1:11434/v1/) machine-specific (brief [OPEN]) Local full-run + "both profiles" criterion can't execute; local-only run still satisfies the other 8
4 Operator supplies Foundry deployment names tenant-specific (brief [OPEN]) Foundry minimal check skips
5 mcp GA availability decides Step 7's MCP wrapper (else in-process tool only) not installed; resolved in Step 1 If --pre-only, the "data access via MCP" convention is met by a fast-follow, not the MVP
6 Synthetic Fase 0 domain reused as the single project brief [ASSUMPTION]
7 Solver stays PuLP/CBC (installed 3.3.2, CBC present); PuLP 4.0 → pulp[cbc]/COIN_CMD — (verified) Future PuLP bump breaks bundled CBC

Verification

End-to-end integration checks (per-step manifests verify each step):

  • uv run pytest → all pass (live arms skip without env)
  • uv run ruff check . → clean
  • uv run mypy src → clean
  • uv run python -c "from portfolio_optimiser.run import run_project" then a FakeChatClient e2e → exactly one ValidatedProposal with a populated ProvenanceStamp (≥1 citation, model/role, validator decision, token usage>0)
  • Out-of-range candidate → Rejection (type-asserted, reason)
  • Second run on a structurally similar proposal → retrieved.id == persisted.id
  • Malformed contract → raises at startup; no chat-client call made
  • Tiny budget/round cap → halts; loop did not exceed cap (exact round count pinned)
  • grep -rE "\.split\(\)" src/ → no word-count token proxy
  • No silent egress: gated live local call targets only the loopback base_url (Step 14); telemetry env vars unset
  • Gated live (when env set): real UsageDetails.total_token_count > 0 on the local profile; trivial Foundry response

Estimated Scope

  • Files to modify: 2 existing (pyproject.toml, backends.py, __init__.py, tests/test_backends.py — 4 touch points)
  • Files to create: ~25 (11 src/ modules incl. datasource.py + data/model_map.json + .env.template + ~12 test files incl. conftest.py)
  • Complexity: high (integration of four subsystems on real clients, two profiles)

Execution Strategy

14 steps > 5 → grouped into sessions/waves. Dependencies are explicit; sessions in the same wave touch disjoint files AND have no cross-session import edge. Global headless rule: on any step failure, perform the On-failure action then HALT — never run a dependent step on a failed predecessor.

Session 1: Dependencies

  • Steps: 1
  • Wave: 1
  • Depends on: none
  • Scope fence: Touch pyproject.toml, uv.lock, tests/test_imports_core.py; Never touch src/**

Session 2: Deterministic core (no MAF), dependency-ordered

  • Steps: 2, 5, 6, 3, 4 (note: 5 retrieval before 6 provenance — TextSpan ownership; all internally independent except 6→5)
  • Wave: 2
  • Depends on: Session 1
  • Scope fence: Touch src/portfolio_optimiser/{ir,validator,retrieval,provenance,verdicts,budget}.py + tests; Never touch backends.py, workflow.py, datasource.py

Session 3: Data source + backend wiring

  • Steps: 7, 8
  • Wave: 3 (Step 7 depends on Steps 5+6; Step 8 independent of Session 2 internals)
  • Depends on: Session 2 (Step 7 → retrieval/provenance)
  • Scope fence: Touch src/portfolio_optimiser/{datasource,backends}.py, data/model_map.json, .env.template + tests; Never touch the core modules' internals (import only)

Session 4: Composition

  • Steps: 9, 10, 11, 12
  • Wave: 4
  • Depends on: Sessions 2, 3
  • Scope fence: Touch src/portfolio_optimiser/{workflow,generate,contracts,run,__init__}.py + tests; Never touch core/datasource/backends internals (incl. validator.py — Step 10 forbidden_path)

Session 5: Integration + live

  • Steps: 13, 14
  • Wave: 5
  • Depends on: Session 4
  • Scope fence: Touch tests/test_vertical_slice_e2e.py, tests/conftest.py, tests/test_local_profile_live.py, tests/test_foundry_profile_live.py; Never touch src/**

Execution Order

  • Wave 1: Session 1
  • Wave 2: Session 2 (internal order 2 → 5 → 6 → 3 → 4)
  • Wave 3: Session 3 (after Wave 2)
  • Wave 4: Session 4 (after Wave 3)
  • Wave 5: Session 5 (after Wave 4)

Grouping rules applied

  • Steps sharing files / import edges → same session, sequenced; 35 steps/session; sessions ordered by dependency. No two sessions in the same wave share an import edge (the Step 7→6 edge moved Step 7 to Wave 3).

Plan Quality Score

Dimension Weight Score Notes
Structural integrity 0.15 90 dependency-ordered; TextSpan ownership fixed; Step 7 moved to its own wave; halt rule added
Step quality 0.20 88 each step 12 files, TDD, concrete reuse; verdict capture + id minting now explicit
Coverage completeness 0.20 92 all 9 criteria → steps + verification; two-layer HITL mapped; no-egress verified
Specification quality 0.15 86 model-map artifact + env template defined; some new-module internals left to impl
Risk & pre-mortem 0.15 92 17 risks incl. the 3 premise corrections; extend_instructions retired by a real-session test
Headless readiness 0.10 88 On-failure + HALT + Checkpoint + Manifest per step
Manifest quality 0.05 86 checkable manifests; strengthened retrieval/datasource predicates; lock-step gating fixed
Weighted total 1.00 89 Grade: A

Adversarial review:

  • Plan critic: REVISE on the first draft — 3 blockers (FakeChatClient usage contradiction, missing mcp dep, unspecified two-layer HITL capture), 7 major, 4 minor. All addressed in Revisions below.
  • Scope guardian: ALIGNED — 0 creep, all 9 criteria mapped, all 6 Non-Goals honored; 1 minor (two-layer HITL naming) addressed.

Revisions

# Finding Severity Resolution
1 FakeChatClient e2e can't satisfy usage-populated vs None-hard-fail blocker Step 13 test double emits synthetic UsageDetails; strict_usage flag (Step 4); real-usage assertion moved to gated Step 14
2 mcp/FastMCP dependency never added blocker Step 1 conditional mcp add (GA only); Step 7 MVP uses in-process retriever-as-tool (no dep); MCP wrapper conditional/fast-follow
3 Two-layer HITL verdict-capture unspecified; verdict-id minting undefined blocker Step 3 adds capture_verdict + stable Verdict.id hash; Step 12 wires Layer-1 (in-run gate) + Layer-2 (out-of-band capture) + B11 notification stub, mapped explicitly
4 extend_instructions not retired by a real-session test major Step 3 test uses a REAL agent_framework.SessionContext (verified importable), not a fake
5 Session 2∥3 not independent (Step 7→6) + unowned TextSpan major TextSpan owned by retrieval.py (Step 5), imported by provenance (Step 6); Step 7 moved to Wave 3; Execution Strategy reworked
6 env vars / .env.template / no-silent-egress unverified major Step 8 adds .env.template + no-egress notes; Verification adds loopback-only + telemetry-unset checks (Step 14)
7 No circuit-breaker/halt language major Global halt rule + per-step "HALT" appended to every On-failure
8 Step 3 JSON persistence = scope creep major Removed; VerdictStore in-memory for MVP; durable persistence deferred to Fase 3
9 self_repair token-budget edits frozen validator.py major Step 10 enforces the token bound in the generate loop; validator.py in Step 10 forbidden_paths
10 Step 1 manifest min_file_count:3 may misfire on no-op lock major Manifest gates only pyproject.toml + test (min_file_count 2)
11 Estimated Scope counts inconsistent minor Corrected
12 model-map config artifact undefined minor data/model_map.json defined in Step 8, validated by Step 11
13 Round-cap assertion doesn't catch off-by-one minor Step 9 pins the EXACT observed round count
14 Weak realpath manifest predicate minor Path-security moved into retrieval.py (Step 5) with realpath + behavioural EscapeRoute tests

Adversarial Pass 2 (gemini-bridge, v5.1.1 high-effort)

Status: UNAVAILABLE. The high-effort plan signal mandates an independent gemini-bridge review of the post-revision plan. The gemini-mcp server has returned an identical 400 BadRequestError on every call this project (its client SDK predates Google's May-2026 Interactions API change). A fifth invocation would fail deterministically, so it was not attempted (cost discipline D6). Independent second-opinion triangulation on the plan is therefore absent — the plan rests on plan-critic + scope-guardian (Phase 9) + installed-source-grounded exploration. To restore this pass, upgrade the gemini-mcp client SDK to ≥ 2.0.0 and re-run /trekplan (or a standalone gemini review) on plan.md.