Skip to content

Commit 94cd675

Browse files
committed
Separate plan-review verdict policy from application confirmation
1 parent a68e63f commit 94cd675

4 files changed

Lines changed: 121 additions & 79 deletions

File tree

‎doc/architecture/SPEC/app/core.md‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
<!-- verified-against: 2026-09-26 -->
44

55
`Copilot` is the headless workflow application. It resolves a TaskSpec,
6-
enforces plan review and confirmation, executes or resumes a playbook, and
7-
reserves/executes durable runs. The CLI imports it through `cli.copilot` for
6+
delegates reviewer verdict policy to `review.plan_gate`, confirms when needed,
7+
executes or resumes a playbook, and reserves/executes durable runs. The CLI
8+
imports it through `cli.copilot` for
89
compatibility; the application never imports CLI or MCP transports.
910

1011
The pre-execution gate runs before a normal CLI run directory is created.
@@ -14,5 +15,7 @@ binding; a run ID must stay contained under the configured run root. Existing
1415
checkpoint keys, run IDs, and blocked exit codes remain stable.
1516

1617
`GateOutcome`, `_mode_review_context`, and `Copilot` retain their import path
17-
through the compatibility module. Only orchestration belongs here; command
18-
argument parsing and stdio protocol registration remain transport concerns.
18+
through the compatibility module. The application passes its reviewer to the
19+
plan gate, preserving the no-human fail-closed rule. Only orchestration belongs
20+
here; command argument parsing and stdio protocol registration remain
21+
transport concerns.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# review/plan_gate.py — plan-review policy
2+
3+
<!-- verified-against: 2026-09-26 -->
4+
5+
This component builds the authoritative mode-aware context for a planned
6+
playbook and interprets the injected plan reviewer's verdict. Exact reused
7+
plans that do not require review pass without a reviewer call. A `block`
8+
verdict always stops; `revise` or `unavailable` stops an unattended `--yes`
9+
run because no person remains to inspect the plan. Interactive runs surface
10+
those verdicts and leave final confirmation to `app.core`.
11+
12+
The mode context uses the executor's `when` evaluation and resolved rebase
13+
mode, so inactive write/push steps are not presented as active. It does not
14+
execute a plan, print notices, prompt for confirmation, create a run directory,
15+
or own a transport. It returns a `PlanGateResult` with the decision and notices;
16+
`app.core` renders them and keeps `_mode_review_context` and
17+
`_plan_review_gate` compatibility entry points while supplying the reviewer.
18+
19+
Verification: `test_planner_playbooks.py`, `test_curator.py`, and
20+
`test_phase_b.py` cover verdicts, mode context, and the application gate.

‎src/infermatrix_copilot/app/core.py‎

Lines changed: 9 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,6 @@
1717
from pathlib import Path
1818
from typing import NamedTuple
1919

20-
import yaml
21-
2220
from .. import idempotency as idem
2321
from .. import run_status as rs
2422
from ..config import Settings, TierNotConfiguredError
@@ -31,6 +29,7 @@
3129
from ..notify import BLOCKED_EXIT, Notifier
3230
from ..playbooks.store import PlaybookStore, parse_playbook, playbook_to_doc
3331
from ..push import PushPolicy
32+
from ..review.plan_gate import _mode_review_context, review_plan_gate
3433
from ..review.reviewer import run_plan_review
3534
from ..run_trace import RunTrace
3635
from ..task_spec import TaskSpec
@@ -57,43 +56,6 @@ def stop(cls, exit_code: int) -> "GateOutcome":
5756
return cls(proceed=False, exit_code=exit_code)
5857

5958

60-
def _mode_review_context(playbook, spec) -> str:
61-
"""Plan-review context for MODE-AWARE playbooks: the reviewer sees the
62-
raw yaml's FULL step list, but the `when:` gates resolve against the
63-
ALREADY-RESOLVED mode (`resolve_effective_mode` runs before the review
64-
gate) — without this context a report-only plan looks like it runs its
65-
write/push steps and gets spuriously blocked. The active-step set is
66-
the same mechanical truth the executor computes, never prose."""
67-
if not getattr(playbook, "mode_aware", False):
68-
return ""
69-
mode = str((getattr(spec, "params", None) or {})
70-
.get("rebase_mode", "") or "")
71-
if not mode:
72-
return ""
73-
from ..engine.executor import _eval_when
74-
from ..rebase_engine.modes import mode_state_flags
75-
76-
flags = {"task_spec": {}, **mode_state_flags(mode)}
77-
active = [s.get("id", s.get("step", "?"))
78-
for s in playbook_to_doc(playbook).get("steps", [])
79-
if "when" not in s or _eval_when(s["when"], flags)]
80-
repo = str(getattr(spec, "repo", "") or "")
81-
repo_line = (f"\nTarget repo (authoritative): {repo!r} — bound at "
82-
"runtime from the TaskSpec; the yaml `repos:` list is a "
83-
"planner RECALL FILTER where empty means repo-neutral, "
84-
"never untargeted." if repo else "")
85-
return (f"\n\nResolved mode context (authoritative): "
86-
f"rebase_mode={mode}. Under this mode the `when:` gates run "
87-
f"ONLY these steps: {active}. Every other listed step is "
88-
"statically gated OFF for this run — judge the plan for THIS "
89-
"mode's step set."
90-
+ repo_line +
91-
"\nWrite/push governance (authoritative): the mode's own "
92-
"push/CI steps are governed at runtime by the push-gate "
93-
"ruling, guard_push, and the ALLOW_PUSH env double-gate — "
94-
"the task tier does not forbid steps this mode activates.")
95-
96-
9759
class Copilot:
9860
"""Orchestration core: resolves a TaskSpec to a playbook (reuse > adapt >
9961
generate), runs it through the plan-review gate + confirmation into the
@@ -165,42 +127,14 @@ def resolve(self, spec: TaskSpec) -> Resolution:
165127

166128
def _plan_review_gate(self, resolution: Resolution, spec: TaskSpec,
167129
assume_yes: bool) -> bool:
168-
"""Inline Plan-Review for adapted/generated plans. LLM verdict shown in
169-
the session; block stops. A non-`lgtm` verdict is only ever *surfaced*
170-
— the human `[y/N]` is what actually gates it — so when `--yes` removes
171-
that human, the same verdict must stop the run instead
172-
(`ReviewVerdict.passing`, SPEC C6: only `lgtm` passes)."""
173-
if not resolution.requires_review:
174-
return True
175-
doc = yaml.safe_dump(playbook_to_doc(resolution.playbook), sort_keys=False)
176-
task_text = spec.describe() + _mode_review_context(
177-
resolution.playbook, spec)
178-
verdict = run_plan_review(self.llm, playbook_doc=doc,
179-
task=task_text,
180-
model=self.settings.reviewer)
181-
if verdict.verdict != "unavailable":
182-
print(f" plan review: {verdict.verdict}"
183-
+ (f" — {verdict.critiques}" if verdict.critiques else ""))
184-
if verdict.verdict == "block":
185-
print("✋ plan blocked by reviewer.")
186-
return False
187-
if verdict.passing:
188-
return True
189-
# Everything below is non-passing: `revise`, or `unavailable`. Both used
190-
# to return True on the strength of a confirmation that `--yes` had
191-
# already deleted, so an unattended run executed an unvetted plan on an
192-
# unread verdict. Measured on the release matrix: an unparseable review
193-
# let three of four backends run a pr-rebase plan through to its push
194-
# gate, while the one backend whose reviewer parsed cleanly BLOCKED the
195-
# very same plan.
196-
if assume_yes:
197-
reason = ("no reviewer LLM" if verdict.verdict == "unavailable"
198-
else f"plan review returned {verdict.verdict}")
199-
print(f"✋ {reason} and --yes leaves no human to gate it — blocked.")
200-
return False
201-
if verdict.verdict == "unavailable":
202-
print(" ⚠ no reviewer LLM — your confirmation is the plan-review gate")
203-
return True # revise/unavailable, surfaced to the user before their confirm
130+
"""Apply the reviewer verdict policy before human confirmation."""
131+
result = review_plan_gate(
132+
resolution, spec, self.llm, self.settings.reviewer, assume_yes,
133+
review_fn=run_plan_review,
134+
)
135+
for notice in result.notices:
136+
print(notice)
137+
return result.allowed
204138

205139
def _gate_and_confirm(self, resolution: Resolution, spec: TaskSpec,
206140
assume_yes: bool, *, prompt: str = "Proceed?",
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"""Plan-review verdict policy and mode-aware context for generated playbooks.
2+
3+
The application supplies its reviewer and retains the human confirmation gate.
4+
"""
5+
6+
from __future__ import annotations
7+
8+
from dataclasses import dataclass
9+
10+
import yaml
11+
12+
from ..playbooks.store import playbook_to_doc
13+
14+
15+
def _mode_review_context(playbook, spec) -> str:
16+
"""Plan-review context for MODE-AWARE playbooks: the reviewer sees the
17+
raw yaml's FULL step list, but the `when:` gates resolve against the
18+
ALREADY-RESOLVED mode (`resolve_effective_mode` runs before the review
19+
gate) — without this context a report-only plan looks like it runs its
20+
write/push steps and gets spuriously blocked. The active-step set is
21+
the same mechanical truth the executor computes, never prose."""
22+
if not getattr(playbook, "mode_aware", False):
23+
return ""
24+
mode = str((getattr(spec, "params", None) or {})
25+
.get("rebase_mode", "") or "")
26+
if not mode:
27+
return ""
28+
from ..engine.executor import _eval_when
29+
from ..rebase_engine.modes import mode_state_flags
30+
31+
flags = {"task_spec": {}, **mode_state_flags(mode)}
32+
active = [s.get("id", s.get("step", "?"))
33+
for s in playbook_to_doc(playbook).get("steps", [])
34+
if "when" not in s or _eval_when(s["when"], flags)]
35+
repo = str(getattr(spec, "repo", "") or "")
36+
repo_line = (f"\nTarget repo (authoritative): {repo!r} — bound at "
37+
"runtime from the TaskSpec; the yaml `repos:` list is a "
38+
"planner RECALL FILTER where empty means repo-neutral, "
39+
"never untargeted." if repo else "")
40+
return (f"\n\nResolved mode context (authoritative): "
41+
f"rebase_mode={mode}. Under this mode the `when:` gates run "
42+
f"ONLY these steps: {active}. Every other listed step is "
43+
"statically gated OFF for this run — judge the plan for THIS "
44+
"mode's step set."
45+
+ repo_line +
46+
"\nWrite/push governance (authoritative): the mode's own "
47+
"push/CI steps are governed at runtime by the push-gate "
48+
"ruling, guard_push, and the ALLOW_PUSH env double-gate — "
49+
"the task tier does not forbid steps this mode activates.")
50+
51+
52+
@dataclass(frozen=True)
53+
class PlanGateResult:
54+
allowed: bool
55+
notices: tuple[str, ...] = ()
56+
57+
58+
def review_plan_gate(
59+
resolution, spec, llm, model, assume_yes, *, review_fn
60+
) -> PlanGateResult:
61+
"""Interpret a reviewer verdict without owning confirmation or output."""
62+
if not resolution.requires_review:
63+
return PlanGateResult(True)
64+
doc = yaml.safe_dump(playbook_to_doc(resolution.playbook), sort_keys=False)
65+
task_text = spec.describe() + _mode_review_context(resolution.playbook, spec)
66+
verdict = review_fn(llm, playbook_doc=doc, task=task_text, model=model)
67+
notices = []
68+
if verdict.verdict != "unavailable":
69+
notices.append(f" plan review: {verdict.verdict}"
70+
+ (f" — {verdict.critiques}" if verdict.critiques else ""))
71+
if verdict.verdict == "block":
72+
notices.append("✋ plan blocked by reviewer.")
73+
return PlanGateResult(False, tuple(notices))
74+
if verdict.passing:
75+
return PlanGateResult(True, tuple(notices))
76+
# A non-passing verdict can only proceed through explicit human review.
77+
# --yes removes that confirmation, so revise/unavailable must block.
78+
if assume_yes:
79+
reason = ("no reviewer LLM" if verdict.verdict == "unavailable"
80+
else f"plan review returned {verdict.verdict}")
81+
notices.append(f"✋ {reason} and --yes leaves no human to gate it — blocked.")
82+
return PlanGateResult(False, tuple(notices))
83+
if verdict.verdict == "unavailable":
84+
notices.append(" ⚠ no reviewer LLM — your confirmation is the plan-review gate")
85+
return PlanGateResult(True, tuple(notices))

0 commit comments

Comments
 (0)