ouroboros/scripts/run_plan_review.py
Ouroboros 82de26a2fd Resolve only declared file targets as the plan's constitutional fact
`plan_spec.resolve_constitutional` read every non-URL string in the spec's
`affected_resources` list as a file path. On the live install that turned the
prose item «Отдельный проект «TSMC — 10-летний инвестиционный анализ»» into "a
path under the Ouroboros repository": the plan was marked constitutional and
every reviewer of a deck-shaped plan received BIBLE.md plus ARCHITECTURE.md in
full, ~470k tokens per cycle. Across the recorded history not one of the 14
constitutional verdicts came from a real file path in that list, and the model
never once spelled a target as `file://` (0 of 186 locators).

The spec now says the two things separately (owner decisions 8=A, 9=A, 16=A):

* `affected_paths` — REQUIRED — is the files the work will CHANGE, and the only
  list the host resolves. `[]` is a real claim: "this work changes no files". A
  path under the system repository decides whether or not the file exists yet,
  because creating `ouroboros/new_module.py` IS self-modification.
* `affected_resources` is prose: systems, services, projects, people. Nothing in
  it reaches the filesystem.
* An `evidence` locator is something to LOOK AT. Reading a repository file is not
  changing it, so it no longer buys the pack on its own (16=A); when the work
  will also change that file, the file is in `affected_paths` and decides there.
  System-repo evidence reads are named in the disclosure note, so an author who
  expected the constitution can see why it did not come. Existence stopped
  deciding anything, so `_default_exists`/`evidence_exists` are gone with it.

A NEW spec submitted in the old mixed form is refused before any dispatch with
`PLAN_RESOURCE_FORM_REQUIRED`, quoting the field with a two-line example. The
refusal deliberately does NOT carry the `PLAN_SPEC_INVALID` token: that family
records a superseding attempt on the way out and would orphan an open wave, so
this one writes nothing and, when a wave is open, names its fingerprint and the
free `review_disposition` exit. Waves recorded under the old shape keep their
identity: `affected_paths` is never defaulted onto stored input, so their
`spec_hash` and `plan_fingerprint` are byte-identical (pinned against the three
live examples) and they still close at $0. `spec_delta` reports the appearance of
the key itself, because "stored before the field existed" and "changes no files"
are different claims.

No grace mechanism for the 31 open waves — already disclosed to the owner.

Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
2026-09-15 17:08:58 +03:00

200 lines
8.9 KiB
Python

#!/usr/bin/env python3
"""Standalone Ouroboros multi-model plan-review dry-run.
Runs the SAME plan-review engine ``plan_task`` uses (``ouroboros/tools/plan_review.py``)
over an operator-supplied envelope — goal, plan prose, spec JSON, optional evidence
locators — against the configured reviewer slots, in an ISOLATED drive root, and prints
the recorded wave (every slot's outcome, the validated findings, the host aggregate) plus
the coordinated tool output, untruncated. Nothing here is a second engine.
Usage (from anywhere):
python scripts/run_plan_review.py --goal "..." --plan /path/to/plan.md \\
--spec-json /path/to/spec.json [--evidence path/or/task:<id> ...] \\
[--subject-root /path/to/workspace] [--drive-root /tmp/x] [--output out.txt]
``spec.json`` follows the plan_task spec schema: in_scope, non_goals, acceptance_claims,
invariants, decisions[{choice, rejected, why}], deferred[{what, why_safe_to_defer}],
affected_paths (REQUIRED — the files the work will CHANGE, ``[]`` when it changes none; this
is the only list resolved as paths, and a path under the system repo makes the plan
constitutional), affected_resources (the same question in words: systems, services, projects,
people), evidence (``--evidence`` values are appended to it).
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import pathlib
import sys
REPO = pathlib.Path(__file__).resolve().parents[1]
if str(REPO) not in sys.path:
sys.path.insert(0, str(REPO))
def _load_settings_into_env() -> None:
"""Load runtime settings through the shared config path; never print secrets."""
try:
from ouroboros.config import apply_settings_to_env, load_settings
from ouroboros.server_runtime import apply_runtime_provider_defaults
settings, _changed, _changed_keys = apply_runtime_provider_defaults(load_settings())
apply_settings_to_env(settings)
except Exception as exc: # pragma: no cover - operator script
print(f"WARN: could not load/apply Ouroboros settings: {exc}", file=sys.stderr)
def _read_text_file(path_text: str, *, label: str) -> str:
path = pathlib.Path(path_text).expanduser().resolve(strict=False)
try:
return path.read_text(encoding="utf-8")
except Exception as exc:
raise SystemExit(f"ERROR: could not read {label} at {path}: {exc}") from exc
def _read_spec(path_text: str) -> dict:
if not path_text:
return {}
try:
payload = json.loads(_read_text_file(path_text, label="spec JSON"))
except json.JSONDecodeError as exc:
raise SystemExit(f"ERROR: spec JSON is not valid JSON: {exc}") from exc
if not isinstance(payload, dict):
raise SystemExit("ERROR: spec JSON must be an object")
return payload
async def _run(args: argparse.Namespace) -> str:
import tempfile
from ouroboros.task_results import load_plan_review_state
from ouroboros.tools.plan_review import _PlanRequest, _run_plan_review_async
from ouroboros.tools.plan_review_runtime import plan_review_slots
from ouroboros.tools.registry import ToolContext
plan = _read_text_file(args.plan, label="plan")
spec = _read_spec(getattr(args, "spec_json", "") or "")
evidence = [str(v).strip() for v in (args.evidence or []) if str(v or "").strip()]
if evidence:
spec["evidence"] = list(spec.get("evidence") or []) + evidence
goal = str(args.goal or "").strip() or str(spec.get("goal") or "")
drive_root = (
pathlib.Path(args.drive_root).expanduser().resolve(strict=False)
if args.drive_root
else pathlib.Path(tempfile.mkdtemp(prefix="ouroboros-plan-review-"))
)
drive_root.mkdir(parents=True, exist_ok=True)
(drive_root / "logs").mkdir(parents=True, exist_ok=True)
subject_root = (
pathlib.Path(getattr(args, "subject_root", "")).expanduser().resolve(strict=False)
if getattr(args, "subject_root", "") else REPO
)
task_id = str(getattr(args, "task_id", "") or "plan-review-cli")
ctx = ToolContext(
repo_dir=REPO,
system_repo_dir=REPO,
workspace_root=subject_root if subject_root != REPO else None,
workspace_mode="external" if subject_root != REPO else "",
drive_root=drive_root,
task_id=task_id,
task_metadata={"root_task_id": task_id},
)
slots = [
{"slot_id": s.slot_id, "model": s.model, "route": getattr(s.route, "value", str(s.route)),
"effort": s.effort}
for s in plan_review_slots()
]
if getattr(args, "dry_run", False):
# Assemble exactly what a paid cycle would send, then stop. The operator entry point
# runs OUTSIDE a task, where `review_wave_budget_gate` has no usage scope and therefore
# no cost ceiling — so the cheap way to see the bill first is to build and measure.
from ouroboros.tools.plan_review import build_plan_review_packet_for_dry_run
packet = build_plan_review_packet_for_dry_run(ctx, _PlanRequest(goal=goal, plan=plan, spec=spec))
sep = "=" * 80
return "\n".join([
sep, "PLAN REVIEW DRY RUN (packet assembled; NOTHING dispatched, no cycle consumed)", sep,
json.dumps({
"slots": slots, "drive_root": str(drive_root),
"constitutional": packet["constitutional"],
"constitutional_note": packet["constitutional_note"],
"system_prompt_chars": len(packet["system_prompt"]),
"user_content_chars": len(packet["user_content"]),
"packet_chars_per_slot": len(packet["system_prompt"]) + len(packet["user_content"]),
"approx_tokens_per_slot": (len(packet["system_prompt"]) + len(packet["user_content"])) // 4,
"evidence_attached": [
{k: v for k, v in item.items() if k != "text"}
for item in packet["manifest"].get("attached") or []
],
"evidence_omissions": packet["manifest"].get("omissions") or [],
"fingerprint": packet["fingerprint"],
}, ensure_ascii=False, indent=2, default=str),
])
coordinated = await _run_plan_review_async(ctx, _PlanRequest(goal=goal, plan=plan, spec=spec))
try:
state = load_plan_review_state(drive_root, task_id)
except ValueError:
state = {}
waves = state.get("waves") or []
wave = waves[-1] if waves else {}
sep = "=" * 80
return "\n".join([
sep, "RESOLVED PLAN REVIEW CONFIG", sep,
json.dumps({
"slots": slots,
"drive_root": str(drive_root),
"system_repo_root": str(REPO),
"subject_root": str(subject_root),
"plan": str(pathlib.Path(args.plan).expanduser().resolve(strict=False)),
"spec_json": str(getattr(args, "spec_json", "") or ""),
"evidence": evidence,
"cycles_paid": state.get("cycles_paid"),
}, ensure_ascii=False, indent=2, default=str),
sep, "PLAN REVIEW WAVE (recorded state; every slot, validated findings, host aggregate)", sep,
json.dumps(wave, ensure_ascii=False, indent=2, default=str),
sep, "PLAN REVIEW COORDINATED OUTPUT", sep,
coordinated,
])
def main() -> int:
parser = argparse.ArgumentParser(
description="Run the configured Ouroboros plan-review panel over a goal + plan + spec envelope."
)
parser.add_argument("--goal", default="", help="Goal under review (falls back to spec.goal).")
parser.add_argument("--plan", required=True, help="Path to the plan prose file.")
parser.add_argument("--spec-json", default="", help="Path to the spec JSON object (plan_task spec schema).")
parser.add_argument(
"--evidence", action="append", default=[],
help="Evidence locator (path relative to the subject root, absolute path, task:<id>, or URL); repeatable.",
)
parser.add_argument(
"--subject-root", default="",
help="Active repository/workspace under review; governance remains the Ouroboros repository.",
)
parser.add_argument(
"--drive-root", default=os.environ.get("OUROBOROS_REVIEW_DRIVE_ROOT", ""),
help="Drive root for review state/observability writes. Prefer a temp dir.",
)
parser.add_argument("--task-id", default="", help=(
"State/task id for this run (default plan-review-cli). Distinct ids keep separate\n"
"cycle counters, so two operator runs do not share one cap."))
parser.add_argument("--dry-run", action="store_true", help=(
"Assemble the packet and print its size per slot, then stop BEFORE any paid dispatch."))
parser.add_argument("--output", default="", help="Optional path to also write the full output.")
args = parser.parse_args()
_load_settings_into_env()
output = asyncio.run(_run(args))
print(output)
if args.output:
pathlib.Path(args.output).expanduser().write_text(output + "\n", encoding="utf-8")
return 0
if __name__ == "__main__":
raise SystemExit(main())