From db12f45acb9a469522c028205a7df346736ea50f Mon Sep 17 00:00:00 2001 From: drew Date: Mon, 18 May 2026 18:10:48 -0400 Subject: [PATCH] =?UTF-8?q?feat(controller):=20Phase=201m=20=E2=80=94=20wi?= =?UTF-8?q?re=20response-builder=20MCPs=20into=20OpenCode=20(Option=20A)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The trial-path expedient (legacy_adapter harvesting chat-JSON) is now the SAFETY NET; the architecture-intended path (agents call the controller's response-builder MCPs, which validate against V1 + write canonical JSON) is now wired end-to-end. Four pieces: 1. MCP `finalize` accepts `output_path` argument ``mcp/_builder_base.finalize_and_emit`` adds ``output_path: str | None`` kwarg. Precedence: explicit arg > ``CONTROLLER_CANONICAL_OUTPUT_PATH`` env > stdout. This solves the per-attempt path problem that blocked opencode.json registration (the env var is static; the per-attempt path comes from the prompt, the agent passes it as a tool arg). Each role's finalize updated: - estimator_finalize(output_path) - implementer_finalize(output_path) - reviewer_finalize(output_path) - conflict_finalize(output_path) - summarizer_finalize(output_path) Creates parent directory if missing (so the controller doesn't need to pre-create). Returns ``wrote_to`` in the ok dict so tests can pin the path. 2. opencode.json registers the 5 controller MCPs ``.opencode/opencode.json`` adds: - implementer-response-builder - reviewer-response-builder - estimator-response-builder - conflict-resolver-response-builder - summarizer-response-builder Each spawned via ``python -m tools.controller.mcp.{role}_builder`` with PYTHONPATH=/repo-root so the controller imports resolve. 3. Controller prompt builder injects the EXACT tool sequence ``worker/prompts.py`` rewrites each role's "Output contract" section. The old "PREFERRED MCP / FALLBACK file-write" instruction becomes a single REQUIRED contract: numbered tool calls (``X_start``, ``X_set_*``, ``X_finalize(output_path=...)``) with the explicit per-attempt path baked in. Explicit "DO NOT emit a JSON object in your final chat message" instruction to override the legacy contract baked into the agent system prompts. 4. Agent permission whitelists include the new MCPs - ``.opencode/agents/task-implementor.md`` (source for tier-{0,1,2,min} variants — regenerated via tools/sync_tier_models.py) - ``.opencode/agents/pr-review-worker.md`` - ``.opencode/agents/estimator-implementation.md`` - ``.opencode/agents/conflict-resolver-worker.md`` Each adds the matching ``"{role}*": allow`` pattern. Safety net preserved: The legacy_adapter (commit bcc59d38a) is KEPT as a fallback path. If an agent ignores the new instruction + emits chat-JSON anyway, ``opencode_session.py:inline_output_callback`` harvests it + ``legacy_adapter`` normalizes to V1 + writes to the canonical out path. Both paths produce valid V1 → ``strict_parse`` succeeds. The MCP path is now the WORKING preferred path; the chat-JSON harvest is the safety net. Total: 738 controller tests pass (+0 net — wiring change, no new behavior tests), 0 regressions. Co-Authored-By: Claude Opus 4.7 (1M context) --- .opencode/agents/conflict-resolver-worker.md | 4 + .opencode/agents/estimator-implementation.md | 4 + .opencode/agents/pr-review-worker.md | 5 + .opencode/agents/task-implementor-tier-0.md | 6 + .opencode/agents/task-implementor-tier-1.md | 6 + .opencode/agents/task-implementor-tier-2.md | 6 + .opencode/agents/task-implementor-tier-min.md | 6 + .opencode/agents/task-implementor.md | 6 + .opencode/opencode.json | 60 +++++++++ tools/controller/mcp/_builder_base.py | 41 +++--- .../mcp/conflict_resolver_builder.py | 8 +- tools/controller/mcp/estimator_builder.py | 12 +- tools/controller/mcp/implementer_builder.py | 9 +- tools/controller/mcp/reviewer_builder.py | 12 +- tools/controller/mcp/summarizer_builder.py | 10 +- tools/controller/worker/prompts.py | 119 ++++++++++-------- 16 files changed, 230 insertions(+), 84 deletions(-) diff --git a/.opencode/agents/conflict-resolver-worker.md b/.opencode/agents/conflict-resolver-worker.md index 9f3bfa4a2..24ff92c0b 100644 --- a/.opencode/agents/conflict-resolver-worker.md +++ b/.opencode/agents/conflict-resolver-worker.md @@ -67,6 +67,10 @@ permission: # /tmp-only file perms. No push (the driver does the merge commit; # this agent resolves the conflicting hunks). "git*": allow + # Controller's conflict-resolver-response-builder MCP (registered + # in opencode.json). The state-machine controller reads from the + # ``output_path`` the prompt provides. + "conflict*": allow "sequential-thinking*": allow "context7*": deny diff --git a/.opencode/agents/estimator-implementation.md b/.opencode/agents/estimator-implementation.md index c62aaff4c..04249f012 100644 --- a/.opencode/agents/estimator-implementation.md +++ b/.opencode/agents/estimator-implementation.md @@ -61,6 +61,10 @@ permission: # MCP allow — only the read-only handoff fetcher. "handoff*": allow # for attempt-history digest fallback + # Controller's estimator-response-builder MCP (registered in + # opencode.json). State-machine controller reads from the + # ``output_path`` the prompt provides. + "estimator*": allow "sequential-thinking*": allow # MCP DENY — these were the action surfaces the model abused in diff --git a/.opencode/agents/pr-review-worker.md b/.opencode/agents/pr-review-worker.md index 64087db95..e5254830e 100644 --- a/.opencode/agents/pr-review-worker.md +++ b/.opencode/agents/pr-review-worker.md @@ -95,6 +95,11 @@ permission: # dispatcher's pre-clone is the normal path). Writes are gated by # the MCP's worktree-path allowlist; the reviewer never pushes. "git*": allow + # Controller's reviewer-response-builder MCP (registered in + # opencode.json). The state-machine controller reads from the + # ``output_path`` the prompt provides; chat-JSON contract is + # NOT read by the controller. + "reviewer*": allow "sequential-thinking*": allow "context7*": allow diff --git a/.opencode/agents/task-implementor-tier-0.md b/.opencode/agents/task-implementor-tier-0.md index fa142a67e..e595bfea0 100644 --- a/.opencode/agents/task-implementor-tier-0.md +++ b/.opencode/agents/task-implementor-tier-0.md @@ -152,6 +152,12 @@ permission: # the per-subagent prompt overhead AND drops the typical subagent # tree depth by one. "git*": allow + # Controller's response-builder MCP (registered in opencode.json + # under ``implementer-response-builder``). The new state-machine + # controller's prompt tells the agent which finalize tool to call + # + with what ``output_path``. The controller reads from that path; + # the legacy chat-JSON contract is NOT read by the controller. + "implementer*": allow "sequential-thinking*": allow "context7*": allow diff --git a/.opencode/agents/task-implementor-tier-1.md b/.opencode/agents/task-implementor-tier-1.md index fa142a67e..e595bfea0 100644 --- a/.opencode/agents/task-implementor-tier-1.md +++ b/.opencode/agents/task-implementor-tier-1.md @@ -152,6 +152,12 @@ permission: # the per-subagent prompt overhead AND drops the typical subagent # tree depth by one. "git*": allow + # Controller's response-builder MCP (registered in opencode.json + # under ``implementer-response-builder``). The new state-machine + # controller's prompt tells the agent which finalize tool to call + # + with what ``output_path``. The controller reads from that path; + # the legacy chat-JSON contract is NOT read by the controller. + "implementer*": allow "sequential-thinking*": allow "context7*": allow diff --git a/.opencode/agents/task-implementor-tier-2.md b/.opencode/agents/task-implementor-tier-2.md index fa142a67e..e595bfea0 100644 --- a/.opencode/agents/task-implementor-tier-2.md +++ b/.opencode/agents/task-implementor-tier-2.md @@ -152,6 +152,12 @@ permission: # the per-subagent prompt overhead AND drops the typical subagent # tree depth by one. "git*": allow + # Controller's response-builder MCP (registered in opencode.json + # under ``implementer-response-builder``). The new state-machine + # controller's prompt tells the agent which finalize tool to call + # + with what ``output_path``. The controller reads from that path; + # the legacy chat-JSON contract is NOT read by the controller. + "implementer*": allow "sequential-thinking*": allow "context7*": allow diff --git a/.opencode/agents/task-implementor-tier-min.md b/.opencode/agents/task-implementor-tier-min.md index fa142a67e..e595bfea0 100644 --- a/.opencode/agents/task-implementor-tier-min.md +++ b/.opencode/agents/task-implementor-tier-min.md @@ -152,6 +152,12 @@ permission: # the per-subagent prompt overhead AND drops the typical subagent # tree depth by one. "git*": allow + # Controller's response-builder MCP (registered in opencode.json + # under ``implementer-response-builder``). The new state-machine + # controller's prompt tells the agent which finalize tool to call + # + with what ``output_path``. The controller reads from that path; + # the legacy chat-JSON contract is NOT read by the controller. + "implementer*": allow "sequential-thinking*": allow "context7*": allow diff --git a/.opencode/agents/task-implementor.md b/.opencode/agents/task-implementor.md index f5c7c4393..407af92cc 100644 --- a/.opencode/agents/task-implementor.md +++ b/.opencode/agents/task-implementor.md @@ -143,6 +143,12 @@ permission: # the per-subagent prompt overhead AND drops the typical subagent # tree depth by one. "git*": allow + # Controller's response-builder MCP (registered in opencode.json + # under ``implementer-response-builder``). The new state-machine + # controller's prompt tells the agent which finalize tool to call + # + with what ``output_path``. The controller reads from that path; + # the legacy chat-JSON contract is NOT read by the controller. + "implementer*": allow "sequential-thinking*": allow "context7*": allow diff --git a/.opencode/opencode.json b/.opencode/opencode.json index ee9a9ca9a..4cbf98790 100644 --- a/.opencode/opencode.json +++ b/.opencode/opencode.json @@ -55,6 +55,66 @@ "/home/drew/repos/cleveragents-core/.venv/bin/python", "/home/drew/repos/cleveragents-core/tools/mcp_block_store_server.py" ] + }, + "implementer-response-builder": { + "type": "local", + "enabled": true, + "command": [ + "/home/drew/repos/cleveragents-core/.venv/bin/python", + "-m", + "tools.controller.mcp.implementer_builder" + ], + "environment": { + "PYTHONPATH": "/home/drew/repos/cleveragents-core" + } + }, + "reviewer-response-builder": { + "type": "local", + "enabled": true, + "command": [ + "/home/drew/repos/cleveragents-core/.venv/bin/python", + "-m", + "tools.controller.mcp.reviewer_builder" + ], + "environment": { + "PYTHONPATH": "/home/drew/repos/cleveragents-core" + } + }, + "estimator-response-builder": { + "type": "local", + "enabled": true, + "command": [ + "/home/drew/repos/cleveragents-core/.venv/bin/python", + "-m", + "tools.controller.mcp.estimator_builder" + ], + "environment": { + "PYTHONPATH": "/home/drew/repos/cleveragents-core" + } + }, + "conflict-resolver-response-builder": { + "type": "local", + "enabled": true, + "command": [ + "/home/drew/repos/cleveragents-core/.venv/bin/python", + "-m", + "tools.controller.mcp.conflict_resolver_builder" + ], + "environment": { + "PYTHONPATH": "/home/drew/repos/cleveragents-core" + } + }, + "summarizer-response-builder": { + "type": "local", + "enabled": true, + "command": [ + "/home/drew/repos/cleveragents-core/.venv/bin/python", + "-m", + "tools.controller.mcp.summarizer_builder" + ], + "environment": { + "PYTHONPATH": "/home/drew/repos/cleveragents-core" + } } } } diff --git a/tools/controller/mcp/_builder_base.py b/tools/controller/mcp/_builder_base.py index e8e105150..8a5c6106d 100644 --- a/tools/controller/mcp/_builder_base.py +++ b/tools/controller/mcp/_builder_base.py @@ -130,24 +130,22 @@ def finalize_and_emit( state: BuilderState, model_class: type[T_Output], *, + output_path: str | None = None, extra_required_check: Callable[[BuilderState], None] | None = None, ) -> dict[str, Any]: """Common finalize helper: validate state.fields against - ``model_class``, mark state.finalized, emit canonical JSON to - EITHER ``$CONTROLLER_CANONICAL_OUTPUT_PATH`` (preferred, set by - the production worker agent_runner) OR stdout (fallback for - direct-call tests). + ``model_class``, mark state.finalized, emit canonical JSON. - Why the env-var path: when the MCP runs as a subprocess of the - worker controller (production), its stdout is shared with the - JSON-RPC transport that FastMCP uses. Writing canonical JSON to - stdout would collide with the transport's framing. The env-var - path gives us a clean side channel: subprocess writes the - canonical JSON to a file; agent_runner reads it after the - subprocess exits. - - For direct-call tests (no subprocess), the env var is unset and - we fall back to stdout (captured by capsys). + Path resolution (precedence: explicit > env > stdout): + 1. ``output_path`` arg (preferred — the controller's prompt tells + the agent the per-attempt path; the agent passes it to + ``{role}_finalize(output_path=...)``). This is the + opencode.json-registered-MCP path, where opencode.json's static + env can't inject a per-attempt path. + 2. ``$CONTROLLER_CANONICAL_OUTPUT_PATH`` env var (legacy — used + when the controller's worker spawns the MCP itself as a + subprocess and injects the env var per-attempt). + 3. stdout (fallback for direct-call tests). """ import os state.require_started() @@ -164,10 +162,16 @@ def finalize_and_emit( canonical = model.model_dump_json() # Mark BEFORE emitting so duplicate finalize calls return error. state.finalized = True - out_path = os.environ.get("CONTROLLER_CANONICAL_OUTPUT_PATH") + out_path = output_path or os.environ.get("CONTROLLER_CANONICAL_OUTPUT_PATH") if out_path: - # Production / subprocess: file-based clean channel. + # Production: file-based clean channel. try: + # Ensure the parent dir exists so the controller doesn't + # have to pre-create it; same idempotent guarantee as + # tempfile.mkstemp gave. + parent = os.path.dirname(out_path) + if parent: + os.makedirs(parent, exist_ok=True) with open(out_path, "w", encoding="utf-8") as f: f.write(canonical) f.write("\n") @@ -180,7 +184,10 @@ def finalize_and_emit( sys.stdout.write(canonical) sys.stdout.write("\n") sys.stdout.flush() - return ok(committed=True, output_bytes=len(canonical)) + return ok( + committed=True, output_bytes=len(canonical), + wrote_to=out_path or "stdout", + ) __all__ = [ diff --git a/tools/controller/mcp/conflict_resolver_builder.py b/tools/controller/mcp/conflict_resolver_builder.py index 513adbfaf..67d14caf7 100644 --- a/tools/controller/mcp/conflict_resolver_builder.py +++ b/tools/controller/mcp/conflict_resolver_builder.py @@ -181,7 +181,9 @@ def _check_resolved_invariants(state: BuilderState) -> None: @server.tool() -def conflict_finalize() -> dict[str, Any]: +def conflict_finalize(output_path: str | None = None) -> dict[str, Any]: + """Validate + emit ConflictResolverOutputV1 JSON to ``output_path`` + (per-attempt path from the controller's prompt).""" def body() -> dict[str, Any]: _STATE.require_started() _STATE.require_not_finalized() @@ -201,11 +203,11 @@ def conflict_finalize() -> dict[str, Any]: _STATE.fields.setdefault("files_modified", []) _STATE.fields.setdefault("commit_shas", []) return finalize_and_emit( - _STATE, ConflictResolverOutputV1, + _STATE, ConflictResolverOutputV1, output_path=output_path, extra_required_check=_check_resolved_invariants, ) - _STATE.record("conflict_finalize", {}) + _STATE.record("conflict_finalize", {"output_path": output_path}) try: return body() except BuilderError as e: diff --git a/tools/controller/mcp/estimator_builder.py b/tools/controller/mcp/estimator_builder.py index 28afb7081..0a3c6a686 100644 --- a/tools/controller/mcp/estimator_builder.py +++ b/tools/controller/mcp/estimator_builder.py @@ -112,7 +112,11 @@ def estimator_set_reasoning(text: str) -> dict[str, Any]: @server.tool() -def estimator_finalize() -> dict[str, Any]: +def estimator_finalize(output_path: str | None = None) -> dict[str, Any]: + """Validate state + emit EstimatorOutputV1 JSON to ``output_path`` + (the per-attempt path the controller passes via the prompt). If + omitted, falls back to ``$CONTROLLER_CANONICAL_OUTPUT_PATH`` (legacy + worker-subprocess wiring) or stdout (direct-call tests).""" def body() -> dict[str, Any]: _STATE.require_started() _STATE.require_not_finalized() @@ -130,9 +134,11 @@ def estimator_finalize() -> dict[str, Any]: _STATE.fields["output_version"] = "V1" _STATE.fields.setdefault("is_metadata_only", False) _STATE.fields["wallclock_seconds"] = wallclock - return finalize_and_emit(_STATE, EstimatorOutputV1) + return finalize_and_emit( + _STATE, EstimatorOutputV1, output_path=output_path, + ) - _STATE.record("estimator_finalize", {}) + _STATE.record("estimator_finalize", {"output_path": output_path}) try: return body() except BuilderError as e: diff --git a/tools/controller/mcp/implementer_builder.py b/tools/controller/mcp/implementer_builder.py index b52c7379c..087d916ea 100644 --- a/tools/controller/mcp/implementer_builder.py +++ b/tools/controller/mcp/implementer_builder.py @@ -196,7 +196,10 @@ def _check_outcome_invariants(state: BuilderState) -> None: @server.tool() -def implementer_finalize() -> dict[str, Any]: +def implementer_finalize(output_path: str | None = None) -> dict[str, Any]: + """Validate state + emit ImplementerOutputV1 JSON to ``output_path`` + (per-attempt path from the controller's prompt). Falls back to + env var / stdout when ``output_path`` is None.""" def body() -> dict[str, Any]: _STATE.require_started() _STATE.require_not_finalized() @@ -217,11 +220,11 @@ def implementer_finalize() -> dict[str, Any]: _STATE.fields.setdefault("commit_shas", []) _STATE.fields.setdefault("blockers", []) return finalize_and_emit( - _STATE, ImplementerOutputV1, + _STATE, ImplementerOutputV1, output_path=output_path, extra_required_check=_check_outcome_invariants, ) - _STATE.record("implementer_finalize", {}) + _STATE.record("implementer_finalize", {"output_path": output_path}) try: return body() except BuilderError as e: diff --git a/tools/controller/mcp/reviewer_builder.py b/tools/controller/mcp/reviewer_builder.py index eb18248a0..c7fe377b1 100644 --- a/tools/controller/mcp/reviewer_builder.py +++ b/tools/controller/mcp/reviewer_builder.py @@ -356,9 +356,11 @@ def reviewer_set_suggested_next_action(action: str) -> dict[str, Any]: @server.tool() -def reviewer_finalize() -> dict[str, Any]: +def reviewer_finalize(output_path: str | None = None) -> dict[str, Any]: """Validate the assembled response and emit canonical - ReviewerOutputV1 JSON to stdout.""" + ReviewerOutputV1 JSON to ``output_path`` (per-attempt path from + the controller's prompt). Falls back to env var / stdout when + ``output_path`` is None.""" def body() -> dict[str, Any]: _STATE.require_started() @@ -400,11 +402,13 @@ def reviewer_finalize() -> dict[str, Any]: _STATE.fields["wallclock_seconds"] = wallclock _STATE.fields.setdefault("blocking_issues", []) # Bridge to base helper. - return finalize_and_emit(_STATE, ReviewerOutputV1) + return finalize_and_emit( + _STATE, ReviewerOutputV1, output_path=output_path, + ) # call_with_invariants doesn't apply here — finalize_and_emit # handles its own status return; we still want the audit entry. - _STATE.record("reviewer_finalize", {}) + _STATE.record("reviewer_finalize", {"output_path": output_path}) try: return body() except BuilderError as e: diff --git a/tools/controller/mcp/summarizer_builder.py b/tools/controller/mcp/summarizer_builder.py index 2bfbde84e..c6e9e3261 100644 --- a/tools/controller/mcp/summarizer_builder.py +++ b/tools/controller/mcp/summarizer_builder.py @@ -78,7 +78,9 @@ def summarizer_set_covers_through_attempt(attempt_number: int) -> dict[str, Any] @server.tool() -def summarizer_finalize() -> dict[str, Any]: +def summarizer_finalize(output_path: str | None = None) -> dict[str, Any]: + """Validate + emit SummarizerOutputV1 JSON to ``output_path`` + (per-attempt path from the controller's prompt).""" def body() -> dict[str, Any]: _STATE.require_started() _STATE.require_not_finalized() @@ -95,9 +97,11 @@ def summarizer_finalize() -> dict[str, Any]: wallclock = (datetime.now(timezone.utc) - started).total_seconds() if started else 0.0 _STATE.fields["output_version"] = "V1" _STATE.fields["wallclock_seconds"] = wallclock - return finalize_and_emit(_STATE, SummarizerOutputV1) + return finalize_and_emit( + _STATE, SummarizerOutputV1, output_path=output_path, + ) - _STATE.record("summarizer_finalize", {}) + _STATE.record("summarizer_finalize", {"output_path": output_path}) try: return body() except BuilderError as e: diff --git a/tools/controller/worker/prompts.py b/tools/controller/worker/prompts.py index 9fdd2ad2e..43e8c7922 100644 --- a/tools/controller/worker/prompts.py +++ b/tools/controller/worker/prompts.py @@ -231,24 +231,31 @@ def build_implementer_prompt(input_payload: dict, *, tier: int) -> str: else: sections.append("_No new comments since last attempt._") + workspace_dir_str = _fmt_or_unavailable(pr.get("workspace_dir")) + output_path = f"{workspace_dir_str}/implementer_output.json" sections.extend([ "", "## Prior implementer attempts", _fmt_prior_attempts_block(pr.get("prior_attempts")), "", - "## Output contract", - "PREFERRED: Use the `implementer-response-builder` MCP to " - "construct your ImplementerOutputV1 output, then call " - "`implementer_finalize()`.", + "## Output contract (REQUIRED — read carefully)", + "When your work is complete, call the `implementer-response-builder` " + "MCP to construct + validate your output, then `implementer_finalize` " + "with the per-attempt output path. The MCP tools:", "", - "FALLBACK (if the MCP isn't available in this session): write " - f"the JSON output as a single object to " - f"`{_fmt_or_unavailable(pr.get('workspace_dir'))}/implementer_output.json` " - "before ending the session. Required fields: output_version " - '(must be "V1"), outcome (resolved/rebase-failed/blocked/noop/' - "competence-failure), files_touched, commit_shas, confidence " - "(high/medium/low), blockers (if blocked), used_tier, " - "wallclock_seconds (float).", + "1. `implementer_start(attempt_id=..., workflow_id=..., pr_number=..., " + "tier=..., head_sha=...)` — open the builder.", + "2. Multiple `implementer_set_*` calls to populate fields " + "(outcome, files_touched, commit_shas, confidence, used_tier, " + "blockers if blocked).", + f"3. `implementer_finalize(output_path=\"{output_path}\")` — " + "validates against ImplementerOutputV1 + writes the canonical " + "JSON. The controller polls this exact path.", + "", + "DO NOT emit a JSON object in your final chat message — the new " + "controller reads from the MCP-written file, not the chat " + "transcript. The legacy `{outcome: ...}` chat-JSON contract is " + "RETIRED for controller-managed sessions.", ]) allowed = pr.get("allowed_files") @@ -342,19 +349,25 @@ def build_reviewer_prompt(input_payload: dict) -> str: "## Prior implementer attempts", _fmt_prior_attempts_block(pr.get("prior_implementer_attempts")), "", - "## Output contract", - "PREFERRED: Use the `reviewer-response-builder` MCP to " - "construct your ReviewerOutputV1 output, then call " - "`reviewer_finalize()`.", + "## Output contract (REQUIRED — read carefully)", + "When your review is complete, call the `reviewer-response-builder` " + "MCP and `reviewer_finalize` with the per-attempt path:", "", - "FALLBACK (if the MCP isn't available): write the JSON output " - f"to `{_fmt_or_unavailable(pr.get('workspace_dir'))}/reviewer_output.json`. " - 'Required fields: output_version ("V1"), verdict (approve/' - "request-changes/comment/abstain), blocking_issues (if " - "request-changes), approved_at_sha (if approve), " - "suggested_next_action (merge/wait-for-ci/re-implement/" - "human-attention/abandon), confidence (high/medium/low), " - "wallclock_seconds (float).", + "1. `reviewer_start(attempt_id=..., workflow_id=..., pr_number=..., " + "head_sha=...)` — open the builder.", + "2. Per-gate: `reviewer_record_gate(name=..., status=..., ...)` for " + "each CI gate you considered. Optional: `reviewer_override_gate` " + "with a ≥20-char justification.", + "3. Issue records: `reviewer_add_blocking_issue(...)` for each " + "blocking concern (only when verdict=request-changes).", + "4. `reviewer_set_verdict(verdict=..., approved_at_sha=...)`, " + "`reviewer_set_confidence(...)`, " + "`reviewer_set_suggested_next_action(...)`.", + f"5. `reviewer_finalize(output_path=\"{_fmt_or_unavailable(pr.get('workspace_dir'))}/reviewer_output.json\")` " + "— validates against ReviewerOutputV1 + writes the canonical JSON.", + "", + "DO NOT emit a JSON object in your final chat message — the " + "new controller reads from the MCP-written file.", ]) return "\n".join(sections) @@ -392,16 +405,21 @@ def build_estimator_prompt(input_payload: dict) -> str: "## CI summary", _fmt_ci_summary(e.get("ci_summary")), "", - "## Output contract", - "PREFERRED: Use the `estimator-response-builder` MCP to " - "construct your EstimatorOutputV1 output, then call " - "`estimator_finalize()`.", + "## Output contract (REQUIRED — read carefully)", + "When your estimate is complete, call the `estimator-response-builder` " + "MCP and `estimator_finalize` with the per-attempt path:", "", - "FALLBACK (if the MCP isn't available): write the JSON output " - f"to `{_fmt_or_unavailable(e.get('workspace_dir'))}/estimator_output.json`. " - 'Required fields: output_version ("V1"), recommended_tier (0/1/2), ' - "is_metadata_only, confidence (high/medium/low), reasoning " - "(≤2048 chars), wallclock_seconds (float).", + "1. `estimator_start(attempt_id=..., workflow_id=..., pr_number=..., " + "head_sha=...)` — open the builder.", + "2. `estimator_set_recommended_tier(tier=N)`, " + "`estimator_set_is_metadata_only(value=bool)`, " + "`estimator_set_confidence(confidence=\"high\"|\"medium\"|\"low\")`, " + "`estimator_set_reasoning(text=...)`.", + f"3. `estimator_finalize(output_path=\"{_fmt_or_unavailable(e.get('workspace_dir'))}/estimator_output.json\")` " + "— validates against EstimatorOutputV1 + writes the canonical JSON.", + "", + "DO NOT emit a JSON object in your final chat message — the " + "new controller reads from the MCP-written file.", "", "**Tier guidance:** 0 = small/local, 1 = cross-file or " "context-heavy, 2 = repo-wide reasoning or research required.", @@ -443,17 +461,18 @@ def build_conflict_resolver_prompt(input_payload: dict, *, tier: int) -> str: "## Prior implementer attempts", _fmt_prior_attempts_block(c.get("prior_implementer_outputs")), "", - "## Output contract", - "PREFERRED: Use the `conflict-resolver-response-builder` MCP " - "to construct your ConflictResolverOutputV1 output, then " - "call `conflict_resolver_finalize()`.", + "## Output contract (REQUIRED — read carefully)", + "When conflict resolution is complete, call the " + "`conflict-resolver-response-builder` MCP + `conflict_finalize`:", "", - "FALLBACK (if the MCP isn't available): write the JSON output " - f"to `{_fmt_or_unavailable(c.get('workspace_dir'))}/conflict_resolver_output.json`. " - 'Required fields: output_version ("V1"), outcome (resolved/' - "partial/irreconcilable/competence-failure), files_modified, " - "commit_shas, new_head_sha (if resolved), reasoning (≤2048 " - "chars), confidence (high/medium/low), wallclock_seconds (float).", + "1. `conflict_start(attempt_id=..., workflow_id=..., pr_number=..., " + "head_sha=..., base_branch=..., base_sha=...)`", + "2. `conflict_set_outcome(...)`, `conflict_set_reasoning(...)`, " + "`conflict_set_confidence(...)`, optional `conflict_add_modified_file`, " + "`conflict_add_commit_sha`, `conflict_set_new_head_sha` (if resolved).", + f"3. `conflict_finalize(output_path=\"{_fmt_or_unavailable(c.get('workspace_dir'))}/conflict_resolver_output.json\")`.", + "", + "DO NOT emit a JSON object in your final chat message.", ]) return "\n".join(sections) @@ -489,15 +508,13 @@ def build_summarizer_prompt(input_payload: dict) -> str: sections.extend([ "", - "## Output contract", - "PREFERRED: Use the `summarizer-response-builder` MCP to " - "construct your SummarizerOutputV1 output, then call " - "`summarizer_finalize()`.", + "## Output contract (REQUIRED — read carefully)", + "Call `summarizer-response-builder` MCP + `summarizer_finalize`:", "", - "FALLBACK (if the MCP isn't available): write the JSON output " - f"to `{_fmt_or_unavailable(s.get('workspace_dir'))}/summarizer_output.json`. " - 'Required fields: output_version ("V1"), summary (50–2000 chars), ' - "covers_through_attempt, wallclock_seconds (float).", + "1. `summarizer_start(attempt_id=..., workflow_id=...)`", + "2. `summarizer_set_summary(summary=...)`, " + "`summarizer_set_covers_through_attempt(value=N)`", + f"3. `summarizer_finalize(output_path=\"{_fmt_or_unavailable(s.get('workspace_dir'))}/summarizer_output.json\")`", "", "Be terse: capture what was tried, what failed, what's worth " "carrying forward. Skip redundant headers, file lists already "