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)