feat(major2): --proposal-review answers the review at the terminal; four refusals by name, EOF stops the run [skip-docs]

Ordre 20260904T173146Z-8102814273-from-portfolio-optimiser, steg 6 av 10.

(a) argparse --proposal-review. (b) rader i BEGGE partisjonene (report_forbidden og
single_only) - report-modus og portefoelje returnerer over dispatchen, saa en utelatelse
er et STILLE DROPP, ikke en nekt (F4-gapet). (c) TRE navngitte nekter i EN topp-nivaa-blokk
if args.proposal_review: - plasseringen er MAALT, ikke plassert paa oeyemaal: naboen
--scripted-replies/--live-dry-run er nostet under if args.scripted_replies, og
--explore/--live-dry-run under if args.explore, saa under noen av dem ville et bart
--live-dry-run --proposal-review falt rett gjennom til dry-run-dispatchen og droppet flagget.
(d) reviewer bygget paa KALLSTEDET + except ProposalReviewInputError -> "run stopped:" rc 1,
en DISTINKT kanal fra "run refused:". (e) proposal_review_notice printes fra kjoeringens EGEN
post. (f) _load_scripted_replies' aerlighetsgrense navngir review-stien.

--resume KOMPONERER (A3 verifisert av en arm, ikke utsatt): resume-blokka gir mandatet og
faller gjennom til SAMME full-run-dispatch.

RODT foer impl: 9 armer. T13 og T16 kjoerer i et BARN (P4). Nekt-armene kjoerer in-process
med _default_factory som REISER - ved exit-koden ser en nekt etter forbruket identisk ut med
en foer (oekt 57).

TO ARMER BLE FALSIFISERT AV MAALINGEN FOER de kunne gate noe:
(1) T18s rc-0-kontroll avslorte at F4-testens ledger-fixtur ({"entries": []}) faar rc 1 av
SavingsLedger.load ("must be a JSON array"), ikke av partisjonsraden - armen ville vaert
groenn mot en fjernet rad. Fixturen er naa en JSON-array, og kontrollen beviser at argv-en
ellers ville blitt AKSEPTERT.
(2) notice-null-armen ga BudgetExceeded i stedet for en avvist kjoering: et to-stegs
proposer-manus mot max_attempts=3 faller til default-svaret, som aldri parser, og rundeboka
fyrer - noeyaktig aerlighetsgrensen _load_scripted_replies uttaler, reprodusert ved uhell.
Manuset har naa like mange steg som forsoek.

Planens T19 er foldet inn i T13 og uttalt: "et bart, uskriptet flaggparse" ville kalt en
levende modell, saa argparse-vitnet er barnets egen unrecognized-arguments-assert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-05 07:06:22 +02:00
commit fedf9897c4
2 changed files with 437 additions and 3 deletions

View file

@ -104,7 +104,10 @@ from portfolio_optimiser.mcp_tools import (
from portfolio_optimiser.proposal_review import (
ProposalReview,
ProposalReviewer,
ProposalReviewInputError,
proposal_review_notice,
proposal_reviews_payload,
terminal_proposal_reviewer,
)
from portfolio_optimiser.provenance import ProvenanceStamp
from portfolio_optimiser.reference_domain import Project, load_reference_projects
@ -2099,7 +2102,12 @@ def _load_scripted_replies(
start, by the fresh client ``generate_via_llm`` builds. A script whose first step is a tool
call therefore answers the generation call with a ``function_call`` too, which will not parse.
That is the operator's to write correctly: guessing which steps were "meant for" which call
site would be repair, and this loader validates."""
site would be repair, and this loader validates.
The same limit binds ``--proposal-review`` (MAJOR-2), where it is easiest to trip: each
``revise`` the piped answers request buys ONE more generation attempt, so a proposer script
one entry short of them degrades to the selector's default reply, which never parses — and the
round ledger fires on the parse-retry. Write the script long enough."""
try:
raw = json.loads(Path(path).read_text(encoding="utf-8"))
except FileNotFoundError as exc:
@ -2190,6 +2198,20 @@ def main(argv: list[str] | None = None) -> int:
"round trip is recorded in {run_id}-exploration.json, feedback verbatim. Input that ends "
"without an answer is an error, NEVER a sign-off",
)
parser.add_argument(
"--proposal-review",
action="store_true",
help="MAJOR-2 synchronous HITL door: after the deterministic validator ACCEPTS a "
'candidate, show it AT THIS TERMINAL and read "approve" or "revise <what to change>". A '
"revision buys ONE more attempt under the existing attempt/round budget — no new loop and "
"no new cap — and the words go into the next hypothesis prompt verbatim. Every answer is "
"recorded in {run_id}-proposal-reviews.json. Input that ends without an answer STOPS the "
"run, NEVER a sign-off. An approve is not an expert verdict (that still arrives via "
"--decision/--rationale or the verdict inbox). Refused with --portfolio, --report, "
"--live-dry-run, --proposals-from-mandate and --checkpoint-dir (pass it at --resume "
"instead); it composes with --explore --plan-review, which reads two doors from one stdin "
"in sequence",
)
parser.add_argument(
"--checkpoint-dir",
default=None,
@ -2429,6 +2451,9 @@ def main(argv: list[str] | None = None) -> int:
# this list is silently dropped rather than refused — which is the whole reason the
# list enumerates every distinguishable flag instead of the ones that would misbehave.
"--plan-review": args.plan_review,
# MAJOR-2, and for the identical reason: report mode returns above the run dispatch, so
# an omission here is a SILENT DROP — the door would be accepted and never asked.
"--proposal-review": args.proposal_review,
# The three U12 flags, listed for exactly that reason: report mode returns before the
# resume dispatch, so an omission here is a silent drop, not a refusal.
"--checkpoint-dir": args.checkpoint_dir is not None,
@ -2507,6 +2532,11 @@ def main(argv: list[str] | None = None) -> int:
# --explore" would tell an operator who wrote --portfolio --plan-review to add the one
# flag this mode also refuses.
"--plan-review": args.plan_review,
# MAJOR-2: one terminal, and a portfolio pass interleaves its waves as coroutines —
# several projects' candidates would arrive at the same prompt with nothing to tell
# them apart. BY NAME, like its neighbours: falling through to a --bundle-dir
# requirement would tell the operator to add a flag this mode also refuses.
"--proposal-review": args.proposal_review,
# And the asynchronous half of the same door, on the same side of the partition and by
# NAME for the same reason.
"--checkpoint-dir": args.checkpoint_dir,
@ -2674,6 +2704,38 @@ def main(argv: list[str] | None = None) -> int:
file=sys.stderr,
)
return 1
# --- MAJOR-2. THREE modes return ABOVE generation, so each would accept the door and then
# never ask it — the silent-drop class ``report_forbidden`` exists for. The block is at
# FUNCTION scope on purpose, measured rather than placed by eye: the neighbouring
# ``--scripted-replies``/``--live-dry-run`` refusal is nested under ``if args.scripted_replies
# is not None`` and the ``--explore``/``--live-dry-run`` one under ``if args.explore is not
# None``, so under either guard a bare ``--live-dry-run --proposal-review`` would fall straight
# through to the dry-run dispatch and drop the flag. Every message carries prose the others do
# not share, and each fires before the first model call.
if args.proposal_review:
if args.live_dry_run:
print(
"run refused: --proposal-review and --live-dry-run contradict (the dry run stops "
"before the first model call, so no candidate ever reaches a reviewer) — drop one",
file=sys.stderr,
)
return 1
if args.proposals_from_mandate:
print(
"run refused: --proposal-review has nothing to answer under "
"--proposals-from-mandate (that mode settles the commission deterministically and "
"never generates a candidate) — drop one",
file=sys.stderr,
)
return 1
if args.checkpoint_dir is not None:
print(
"run refused: --proposal-review and --checkpoint-dir: a parked exploration "
"returns before any candidate exists, so the review would never be asked — pass "
"--proposal-review at --resume instead",
file=sys.stderr,
)
return 1
if args.resume is not None:
if args.explore is not None:
print(
@ -3323,9 +3385,23 @@ def main(argv: list[str] | None = None) -> int:
client_factory=scripted_client_factory,
mandate=mandate,
mcp_servers=mcp_servers,
# Built at the CALL SITE, never inside the library (the ``--plan-review``
# precedent): the terminal is the operator's, and ``run_project`` must stay
# answerable by a persona, a test double or nobody at all.
proposal_reviewer=(
terminal_proposal_reviewer() if args.proposal_review else None
),
)
),
)
except ProposalReviewInputError as exc:
# A DISTINCT channel from ``run refused:`` below, and the class is what routes it here.
# The argv was fine and the run had already spent tokens, so "refused" would mislabel it;
# and a ``ValueError``-shaped error would sit one frame from ``_fetch_parsed``'s
# ``except (ValidationError, ValueError, TypeError)`` and be captured as a parse failure
# instead. ``run_project``'s ``finally`` has already written the review record.
print(f"run stopped: {exc}", file=sys.stderr)
return 1
except (ValueError, FileNotFoundError, ValidationError, BudgetExceeded) as exc:
# Structured refusal (rc 1, no traceback) for the full-run path: run_project's fail-fast
# loaders (contracts, load_dimension, outbox run_id guard) surface here as one clean line.
@ -3361,6 +3437,12 @@ def main(argv: list[str] | None = None) -> int:
fold_notice = unkeyed_verdicts_notice(result.unkeyed_verdicts)
if fold_notice is not None:
print(fold_notice)
# MAJOR-2: what the human answered, read off the run's OWN record rather than off argv — so
# stdout and ``{run_id}-proposal-reviews.json`` cannot disagree. ``offered`` IS argv, because
# "a reviewer was offered and never consulted" is a fact only the flag can supply.
review_notice = proposal_review_notice(result.expert_revisions, offered=args.proposal_review)
if review_notice is not None:
print(review_notice)
# The settlement against the commission (Trekk A4). Empty without a mandate, so an
# un-commissioned run prints exactly what it printed before.
settlement = settle(result.coverage)

View file

@ -26,6 +26,7 @@ from __future__ import annotations
import io
import json
import subprocess
import sys
from collections.abc import Sequence
from pathlib import Path
@ -41,6 +42,7 @@ from portfolio_optimiser.generate import ParseFailure, _build_messages, generate
from portfolio_optimiser.ir import AffectedItem, SavingsProposal
from portfolio_optimiser.mandate import OWN_PROPOSAL_ID, Approach, Mandate
from portfolio_optimiser.reference_domain import load_reference_projects
from portfolio_optimiser import run
from portfolio_optimiser.run import RunResult, run_project
from portfolio_optimiser.simulation import ScriptedChatClient
from portfolio_optimiser.validator import Rejection, ValidatedProposal, proposal_for
@ -810,8 +812,8 @@ async def test_t5run_a_revise_on_one_approach_leaves_the_next_untouched(tmp_path
sink=control_sink,
)
assert isinstance(treated, RunResult) and isinstance(control, RunResult)
for run in (treated, control):
assert [row.status for row in run.coverage].count("not_evaluated") == 0
for finished in (treated, control):
assert [row.status for row in finished.coverage].count("not_evaluated") == 0
def _gen_prompts(sink: list[str], label: str) -> list[str]:
return [b for b in sink if _GENERATION_MARK in b and label in b]
@ -1043,3 +1045,353 @@ def test_t20_the_streams_are_resolved_at_call_time(monkeypatch: pytest.MonkeyPat
monkeypatch.setattr(sys, "stdout", out)
assert door(_request()) == pr.ProposalReviewDecision.revise("later")
assert "PROPOSAL REVIEW" in out.getvalue()
# ---------------------------------------------------------------------------------------------
# Group C (Step 6) — the CLI door: the flag, the four refusals, the ``run stopped:`` channel and
# the notice. The two door arms run in a CHILD (P4: stdin/stdout/rc are answered in a subprocess,
# never with ``capsys``); the refusal arms run in-process with the model factory RAISING, because
# at the exit code a refusal after the spend looks exactly like a refusal before it.
# ---------------------------------------------------------------------------------------------
_CLI_FEEDBACK = f"Bruk 40000, ikke 30000. {_FEEDBACK_SENTINEL}"
_CLI_MEASURE = "Behovsstyrt belysning i fellesarealer"
def _cli_replies_file(
tmp_path: Path, *, claims: tuple[int, ...] = (30_000, 40_000), name: str = "replies.json"
) -> str:
"""A TWO-ENTRY proposer step list. A single constant string would make attempt 2 byte-identical
to attempt 1 — the door would go green while proving nothing about the answer being used."""
path = tmp_path / name
path.write_text(
json.dumps(
{
"proposer": [_run_reply(_CLI_MEASURE, claim) for claim in claims],
"checker": "VERDICT: APPROVE",
}
),
encoding="utf-8",
)
return str(path)
def _cli_argv(tmp_path: Path, *, outbox: str, replies: str | None = None) -> list[str]:
return [
_RUN_PID,
"--docs-dir",
str(_BUNDLE_DIR),
"--bundle-dir",
str(_BUNDLE_DIR),
"--scripted-replies",
replies or _cli_replies_file(tmp_path),
"--proposal-review",
"--outbox-dir",
str(tmp_path / outbox),
"--run-id",
_RUN_ID,
]
def _child(argv: list[str], *, stdin: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[sys.executable, "-m", "portfolio_optimiser.run", *argv],
input=stdin,
capture_output=True,
text=True,
cwd=str(_REPO),
)
def _refuse_model(monkeypatch: pytest.MonkeyPatch) -> None:
"""The CLI's ONE injection point (``test_run_cli_loadbearing``'s seam). A refusal that fires
after the spend is indistinguishable from one that fires before it at the exit code — økt 57's
rule — so every refusal arm asserts on ZERO model clients, not only on rc 1."""
def _raise(_profile: Any) -> Any:
raise AssertionError("a model client was built on a path that must make no model calls")
monkeypatch.setattr(run, "_default_factory", _raise)
def test_t13_the_flag_answers_the_review_from_a_real_argv_and_the_answer_is_used(
tmp_path: Path,
) -> None:
"""T13 — the CLI door, end to end in a CHILD. Detach points: the argparse flag (M20), the
reviewer wiring at the dispatch, the sink, the artefact.
The child settles three things no in-process arm can: that the flag exists on the parsed
command line, that a real pipe reaches the reviewer, and that stdout renders the candidate as
TEXT. The proposer's second step carries a DIFFERENT amount, so "the answer was used" is the
outcome, not the presence of a record.
(The plan's separate T19 — "a bare, unscripted flag parse" — is folded in here: without a
script that argv would call a live model, so the argparse witness is this child's own
``unrecognized arguments`` assertion.)"""
proc = _child(_cli_argv(tmp_path, outbox="outbox"), stdin=f"revise {_CLI_FEEDBACK}\napprove\n")
assert "unrecognized arguments" not in proc.stderr, proc.stderr
assert "Traceback" not in proc.stderr, proc.stderr
assert proc.returncode == 0, proc.stderr
payload = _reviews_on_disk(tmp_path / "outbox")
assert [(r["decision"], r["honoured"]) for r in payload["reviews"]] == [
("revise", True),
("approve", True),
]
assert payload["reviews"][0]["feedback"] == _CLI_FEEDBACK # VERBATIM
assert [r["attempt"] for r in payload["reviews"]] == [0, 1] # exactly two generation attempts
outcome = json.loads(
(tmp_path / "outbox" / f"{_RUN_ID}-proposal.json").read_text(encoding="utf-8")
)
assert outcome["proposal"]["claimed_saving_nok"] == 40_000 # entry 2 — the answer was USED
assert _CLI_MEASURE in proc.stdout # the candidate was rendered as TEXT
assert _REPR_LEAK not in proc.stdout
assert "proposal review: 2 answer(s)" in proc.stdout
def test_t16_input_that_ends_without_an_answer_stops_the_run_on_its_own_channel(
tmp_path: Path,
) -> None:
"""T16 — EOF at the CLI. Detach points: reading EOF as approve (M15), not catching
``ProposalReviewInputError`` by name (M16).
``run stopped:`` is a DISTINCT channel from ``run refused:``: the argv was fine and the run
had already spent tokens, so "refused" would mislabel it — and a ``ValueError``-shaped error
would sit one frame from ``_fetch_parsed``'s catch-all and be captured as a *parse failure*
instead. Both are asserted: no traceback, and no ``{run_id}-parse-failures.json``.
T13's run (same fixture, same flags, a SEPARATE outbox) is the positive control that
``-outcome.json`` CAN appear when the door is answered."""
control = _child(
_cli_argv(tmp_path, outbox="answered"), stdin=f"revise {_CLI_FEEDBACK}\napprove\n"
)
assert control.returncode == 0, control.stderr
assert (tmp_path / "answered" / f"{_RUN_ID}-outcome.json").exists()
proc = _child(_cli_argv(tmp_path, outbox="silent"), stdin="")
assert proc.returncode == 1
assert "Traceback" not in proc.stderr, proc.stderr
assert "run refused:" not in proc.stderr
assert "run stopped:" in proc.stderr
assert "--proposal-review" in proc.stderr
assert "end of input" in proc.stderr
silent = tmp_path / "silent"
assert not (silent / f"{_RUN_ID}-outcome.json").exists()
assert not (silent / f"{_RUN_ID}-parse-failures.json").exists()
# The ``finally`` still wrote the record, and it is empty of decisions.
assert _reviews_on_disk(silent) == {"reviews": [], "run_id": _RUN_ID}
def test_t17_the_door_belongs_to_single_project_mode(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""T17 — the documented partition. Detach point: dropping the ``single_only`` row (M18).
Concurrent portfolio waves would share ONE terminal and interleave prompts from several
projects. The assert names ``--portfolio``, never the shared ``--proposal-review``: a dropped
row falls through to a neighbouring refusal that names the flag too, and asserting on the
shared token would stay green against no partition entry at all (økt 57's own mutation)."""
_refuse_model(monkeypatch)
assert run.main(["--portfolio", "--proposal-review"]) == 1
assert "--portfolio" in capsys.readouterr().err
def test_t18_the_door_is_refused_in_report_mode(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""T18 — report mode returns ABOVE every dispatch, so a flag missing from
``report_forbidden`` is a silent DROP, not a refusal (the F4 gap). Detach point: dropping the
row (M19).
Run against an argv report mode would otherwise ACCEPT, with the rc-0 control proving it —
otherwise rc 1 could come from the missing ``--ledger`` rather than from this row."""
_refuse_model(monkeypatch)
ledger = tmp_path / "ledger.json"
# A JSON ARRAY: ``SavingsLedger.load`` refuses a dict, and an rc-1 from THAT would make the
# arm green for the wrong reason — which is precisely what the rc-0 control below catches.
ledger.write_text(json.dumps([]), encoding="utf-8")
assert run.main(["--report", "--ledger", str(ledger)]) == 0
capsys.readouterr()
assert run.main(["--report", "--ledger", str(ledger), "--proposal-review"]) == 1
assert "mode-exclusive" in capsys.readouterr().err
def test_the_door_and_a_dry_run_contradict(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""Detach point: dropping the ``--live-dry-run`` refusal (M30).
The dry-run cut returns ABOVE generation, so no candidate ever reaches a reviewer — the flag
would be accepted and then silently inert. Asserts on the PARTNER token."""
_refuse_model(monkeypatch)
rc = run.main(
[
_RUN_PID,
"--docs-dir",
str(_BUNDLE_DIR),
"--bundle-dir",
str(_BUNDLE_DIR),
"--live-dry-run",
"--proposal-review",
]
)
assert rc == 1
assert "--live-dry-run" in capsys.readouterr().err
def test_the_door_has_nothing_to_answer_under_proposals_from_mandate(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""Detach point: dropping the ``--proposals-from-mandate`` refusal (M31).
That mode is SYNC by construction — it settles the commission against the derived baseline and
returns at the terminal dispatch, above any generation — so the flag would exit 0 having asked
nobody anything. The rc-0 control (same argv without the flag) proves the refusal is this row
and not some other precondition."""
_refuse_model(monkeypatch)
mandate_path = tmp_path / "mandate.json"
mandate_path.write_text(
Mandate(
objective="Finn kostnadsbesparelser i K2",
approaches=(
Approach(
id="a1",
label="Redusert sprengningsvolum i sone A",
affected_codes=("21.1",),
claimed_saving_nok=200_000.0,
),
),
allow_own_proposals=False,
).model_dump_json(),
encoding="utf-8",
)
priced = str(_REPO / "tests" / "fixtures" / "k2-prisskjema-SYNTETISK")
argv = [
"K2",
"--docs-dir",
priced,
"--bundle-dir",
priced,
"--derive-cost-baseline",
"--proposals-from-mandate",
"--mandate",
str(mandate_path),
]
assert run.main(argv) == 0
capsys.readouterr()
assert run.main([*argv, "--proposal-review"]) == 1
assert "--proposals-from-mandate" in capsys.readouterr().err
def test_the_door_and_a_parked_exploration_contradict(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""The FOURTH return above generation. Detach point: dropping the ``--checkpoint-dir``
refusal (M39).
A parked exploration returns on the park leg before any candidate exists, so the flag would be
accepted and never used. The refusal names ``--resume``, which is where the door DOES compose
— a refusal that only forbids leaves the operator without the door that works."""
_refuse_model(monkeypatch)
rc = run.main(
[
_RUN_PID,
"--docs-dir",
str(_BUNDLE_DIR),
"--bundle-dir",
str(_BUNDLE_DIR),
"--explore",
"Find the cheapest saving.",
"--checkpoint-dir",
str(tmp_path / "checkpoints"),
"--outbox-dir",
str(tmp_path / "outbox"),
"--run-id",
_RUN_ID,
"--proposal-review",
]
)
assert rc == 1
err = capsys.readouterr().err
assert "--checkpoint-dir" in err
assert "--resume" in err
def test_the_door_composes_with_resume(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""A3, VERIFIED rather than deferred: the resume block yields the parked exploration's mandate
and falls through to the SAME full-run dispatch, so the reviewer built at the CLI is reached.
Detach point: refusing ``--resume`` together with the door, or forgetting to pass the reviewer
on that path.
``run_project`` is replaced by a recorder, so the arm measures the WIRING rather than a whole
resumed exploration."""
calls: list[dict[str, Any]] = []
async def _recorder(*args: Any, **kwargs: Any) -> Any:
calls.append(kwargs)
raise ValueError("recorded")
monkeypatch.setattr(run, "run_project", _recorder)
_refuse_model(monkeypatch)
run.main(
[
_RUN_PID,
"--docs-dir",
str(_BUNDLE_DIR),
"--bundle-dir",
str(_BUNDLE_DIR),
"--scripted-replies",
_cli_replies_file(tmp_path),
"--outbox-dir",
str(tmp_path / "outbox"),
"--run-id",
_RUN_ID,
"--proposal-review",
]
)
assert len(calls) == 1
assert calls[0]["proposal_reviewer"] is not None
def test_a_reviewer_nobody_could_consult_says_so_on_stdout(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""The notice's zero case, at the CLI. Detach points: the renderer returning ``None`` on zero
with a reviewer present (M33), and the print itself (M37).
An operator who passed ``--proposal-review`` and sees nothing cannot tell "no candidate was
ever validated, so nobody was asked" from "the door hung"."""
monkeypatch.setattr(sys, "stdin", io.StringIO(""))
rc = run.main(
_cli_argv(
tmp_path,
outbox="outbox",
# BOTH attempts claim above the P90 cap (90 000), so the validator rejects each one
# and the reviewer is never reached — offered, never consulted.
# As many entries as ``max_attempts`` (3), so every attempt PARSES and is rejected
# by the deterministic gate. A shorter list would fall through to the selector's
# default reply, which never parses, and the round ledger would fire instead — the
# honesty limit ``_load_scripted_replies`` states, reproduced here by accident once.
replies=_cli_replies_file(
tmp_path, claims=(200_000, 200_000, 200_000), name="rejected.json"
),
)
)
assert rc == 0
assert "proposal review offered, never consulted" in capsys.readouterr().out
def test_a_run_with_no_reviewer_prints_no_review_line(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""CONTROL for the notice (M32): the renderer must return ``None`` when no reviewer was
offered, so a run that answered nobody says nothing."""
argv = [a for a in _cli_argv(tmp_path, outbox="outbox") if a != "--proposal-review"]
assert run.main(argv) == 0
assert "proposal review" not in capsys.readouterr().out