Max Review Cycles counts PAID cycles on the commit gate and skill review; identical bytes are never re-reviewed for pay

Owner decisions Q12/Q16-A/Q17-A/Q22-A/Q23-A (update-flow redesign sprint,
lane L-cycles, deltas 5-6). The one shared knob (OUROBOROS_REVIEW_MAX_CYCLES)
now means the same thing on every review gate: paid cycles per task.

Commit gate (commit_gate.py, git.py, review_state.py):
- Typed block-row classification at record time: verdict-blocks (triad/scope
  reviewer FINDINGS) vs infra-blocks (fit fixed_overflow, scope sub_floor,
  revalidation, transport/no-quorum). New durable CommitAttemptRecord fields
  block_class, rebuttal_sha256, paid, review_contract_fingerprint,
  root_task_id (merge- and roundtrip-safe; legacy rows classify by reason).
- A byte-identical resubmission of a verdict-blocked staged diff is refused
  FREE from the FIRST block (identical_diff_refused), before the advisory
  freshness gate and before any paid dispatch, quoting the recorded verdict;
  the refusal is cross-task by fingerprint (anti-laundering). Infra-blocks,
  infra/expired failures and preflight facts neither build nor reset the
  streak; a post-review failure or a success ends it.
- Rebuttals are identified by CONTENT (sha256): a hash new to the streak buys
  exactly one paid re-review; a repeated hash is refused free. A rebuttal is
  "spent" only when it bought a dispatched, verdict-answered wave — one
  refused undispatched (e.g. by the ceiling) stays fresh.
- The knob bounds PAID triad+scope cycles per ROOT task (the task tree shares
  one ceiling; a manual session is its own task; a follow-up task is a fresh
  root — origin_root_task_id is deliberately not honored, the cross-task
  identical-fingerprint refusal stays the anti-laundering backstop). Paid is
  recorded at dispatch on the attempt ledger (plan-review precedent; P7 - no
  counter file) and the ceiling counts MONEY: every dispatched wave counts
  whatever its terminal; only undispatched attempts stay outside the count.
  Exhaustion is a free typed refusal plus
  emit_review_cycles_exhausted(surface="commit_gate"); unlimited disables it.
- Free refusal/replay honors a review-contract fingerprint (triad roster and
  routes, scope rows, enforcement, shipped prompt contract); a changed
  contract lapses the streak and the refusal never quotes across the change.
- Under ADVISORY enforcement neither state hard-blocks: the commit proceeds
  with a loud typed disclosure (commit_review_free_replay event + result
  warning; the wording distinguishes a replayed verdict from a
  ceiling-exhausted commit that has no verdict to reuse) and no further paid
  dispatch. Managed resolvers keep MERGE_HEAD repaired on the free refusal
  path.
- _run_reviewed_stage_cycle order: fingerprint (hoisted only because the free
  gate needs it) -> free cycle gate -> advisory/tests gate -> binding
  precondition (kept at its original position) -> paid dispatch; the
  advisory+tests section moved whole to _advisory_and_tests_gate at the
  function-size gate.

Skill review (skill_review.py, new skill_review_cycles.py,
skill_review_history.py, skill_review_runner.py):
- review_max_cycles() wired in for the first time: paid panel dispatches are
  counted per ceiling key - the root task for task-driven review groups
  (across every skill that task reviews; follow-ups start fresh), and, for
  the manual lane, the CURRENT content snapshot (revised content restarts
  the manual count; marketplace-install rows share the same per-snapshot
  scoping). One chunked wave = ONE cycle; counts derive from the append-only
  review history; exhaustion is a typed refusal plus
  emit_review_cycles_exhausted(surface="skill_review").
- Q17-A free replay: an identical (group, content_hash, panel-contract
  fingerprint) snapshot with a recorded SUBSTANTIVE verdict (clean/warnings/
  blockers) replays at USD 0, quoting it - but only while the persisted
  review state still covers that exact snapshot+verdict; a diverged state
  falls through to a PAID rerun that re-persists (never a live-lock).
  pending/interrupted/timeout/failed/cancelled are infra facts that never
  replay; a rebuttal recorded on such a row stays fresh and buys the paid
  rerun it is owed; a content-new rebuttal buys exactly one paid rerun.
- The panel-contract fingerprint pins the roster, the required items, the
  shipped prompt/aggregation contract text and the skill's RESOLVED review
  profile (computed once and threaded through aggregation).
- Terminal history rows carry the paid fact, the panel-contract fingerprint,
  the rebuttal hash and replay provenance.
- Module splits at the size gates (update_candidate.py precedent): the
  accepted-rebuttal ledger and the review-wave budget admission moved whole
  to skill_review_cycles.py with same-name re-exports; review_skill's persist
  tail extracted to _persist_reviewed_outcome and the cycles gate to
  _skill_cycles_gate.

Disclosed residual: the ceiling checks are read-at-gate-time with no
reservation (TOCTOU) - concurrent dispatches sharing one ceiling key can
overshoot the cap by the concurrency width.

Pinned wording synced in this same commit (CHECKLISTS row 10):
review_cycles.py per-gate docstring (now four gates), commit_gate.py header
and module docstring, the advisory _identical_diff_cap_note, commit_reviewed
rebuttal schema texts, prompts/SYSTEM.md commit-gate wording,
docs/DEVELOPMENT.md cycles section plus the new anti-pattern instruction
(never pay for byte-identical review material; the limit counts PAID cycles;
exhaustion is a typed event), docs/ARCHITECTURE.md (review_cycles and
commit_gate module rows, skill review module map rows, evolution cleanup
paragraph, settings table), ouroboros/config.py settings comment, web
settings_ui.js copy, and review_evidence.py block-reason guidance - all
stating the per-ROOT-task scope. Pinned tests rewritten as contract tests
(test_commit_gate.py, test_review_cycles.py, plus the structural pins in
test_scope_review.py and test_advisory_delegated_route.py) and a new
tests/test_review_cycles_gates.py suite added, including behavior tests that
drive the real recorder to the ledger, the real MERGE_HEAD repair against a
temp git dir, and the real review_skill pipeline through replay, refusal and
paid dispatch.

Retired API: check_blocked_attempt_cap / blocked_attempt_fingerprint_cap
(pay-for-identical-re-review-up-to-a-cap semantics). Legacy
attempt_cap_reached ledger rows remain recognized as refusal records.

Fix round (5-seat review panel convergence; accepted findings F1-F8, F10,
fable P3-2/P3-3):
- F1 (CRITICAL) review_state.py: the 50-row attempt-history trim is now
  authority-preserving - it evicts only non-authoritative noise (free
  refusals, preflight facts, unpaid infra rows), oldest first, and never
  paid rows (the money ledger the per-root-task ceiling derives from),
  review-verdict anchors (the evidence an identical-diff refusal quotes) or
  in-flight rows. The real bound (delta-review follow-up): the ACCOUNTING
  FACTS of preserved rows are immortal, their heavy forensic payloads are
  not - a preserved row that falls outside the newest-50 window is compacted
  (raw_stripped=True): triad_raw_results/scope_raw_result dropped
  (block_class materialized first, so a legacy scope verdict keeps its
  refusal-anchor authority after the classifying evidence is gone),
  block_details bounded to the 600 chars the refusal quote renders,
  commit_message bounded to 300; paid/root/task ids, fingerprints, rebuttal
  hash, timestamps and the quoted critical_findings all survive. The
  serialized ledger therefore grows ~O(preserved rows x small record) - per
  root task bounded by the money ceiling - instead of by full reviewer raw
  output per reviewed commit forever. Regressions: 60+ free refusals after
  the cap change neither the paid count nor the quoted verdict, and a
  120-row heavy flood keeps full raw payloads only inside the newest-50
  window (every over-window row serializes under 4KB).
- F1-adjacent recorder bug (exposed by the eviction test): a NEW attempt
  recorded with no in-flight marker inherited the PREVIOUS terminal
  attempt's fields - paid=True, block_class, rebuttal/contract/fingerprints
  - so once any paid row existed, every free refusal was silently counted as
  a paid cycle. The terminal-latest branch of _record_commit_attempt now
  nulls the inherited row for a fresh attempt number, mirroring the
  contract the reviewing branch already enforced.
- F2 (MAJOR) the paid stamp moved from gate passage to a WRITE-AHEAD record
  at the first PHYSICAL reviewer dispatch: new module
  ouroboros/review_dispatch.py (which also absorbs the slot-identity mint
  moved whole from review_substrate.py at the module-size gate, same-name
  re-exports kept) provides the idempotent thread-safe ReviewPaidStamp;
  the commit gate installs it on ctx (_install_paid_dispatch_stamp) and the
  shared transport entry run_review_request invokes it immediately before
  the first reviewer call on EITHER side - triad and scope dispatch in
  parallel, so any side dispatching makes the cycle paid. Assembly-only
  exits (triad fit ladder, scope pack signals) stay $0 and outside the
  ceiling; a crash after dispatch keeps the durable paid fact. The three
  docstring enumerations ("assembly failures stay outside the count") are
  now literally true. Design note: this seam is where the L-review lane's
  two-phase admission slots in at synthesis.
- F3 (MAJOR) skill review shares ONE durable write-ahead dispatch marker
  (state/skills/<name>/review_dispatch.json) between the lifecycle runner
  and direct review_skill callers, written at physical panel dispatch with
  paid=True + contract fingerprint + rebuttal hash + wave id. Terminal
  history rows merge it idempotently (a lifecycle timeout with no result
  still lands the paid facts) and clear it; an orphaned predecessor is
  flushed into the history as an infra terminal; the derived count includes
  an unmerged marker, so a crashed or swallowed wave never un-counts spent
  money. The quorum-failure append now receives the paid facts (the direct
  marketplace-install path no longer lands unpaid), and history append
  failures surface as loud typed skill_review_history_append_failed events
  instead of a silent pass. Four-outcome behavior tests (substantive
  verdict / quorum-failed / transport-failed / crash-after-dispatch) each
  pin exactly one paid unit on a real jsonl.
- F4 (wording + scoping, no semantics flip): spent-rebuttal memory on the
  skill side is scoped to the CURRENT panel-contract fingerprint (a
  contract lapse clears it along with replay); the contradictory wordings
  (skill_review_cycles.py header, docs/DEVELOPMENT.md) now state the exact
  rule - refusal-streak eligibility (substantive verdicts only) is distinct
  from money accounting (every dispatched wave counts); a rebuttal is spent
  by the substantive verdict it bought; a dispatched infra terminal
  consumes money but not the rebuttal.
- F5 advisory-honesty sync: prompts/SYSTEM.md, web settings copy, the
  advisory-review schema note (claude_advisory_review.py) each carry the
  one enforcement caveat - under blocking the commit is refused for free;
  under advisory it proceeds with a loud durable disclosure and no new
  review spend.
- F6 the blocked-classification comment in git.py no longer claims a
  dispatched infra block costs nothing - money is stamped at dispatch.
- F7 the skill_review tool schema documents the replay key (group,
  content-hash, contract fingerprint), the paid-cycle ceiling, exhaustion
  leaving the skill pending (never executable), and the rebuttal as content
  whose sha256 buys exactly one paid wave when new to the streak.
- F8 auditability: review_evidence attempt projections expose block_class,
  paid, rebuttal_sha256, review_contract_fingerprint and root_task_id; the
  skill review-history UI detail includes the accounting facts inside the
  existing free-form markdown detail string - the
  SkillReviewHistoryDetailResponse contract is typed to exactly four
  fields, so no api_types.js change and no version bump.
- F10 dead _POST_REVIEW_FAILURE_PHASES deleted (the streak walker's generic
  terminal break already covers post-review failure phases).
- fable P3-2: the advisory identical-replay branch is marked as deliberate
  defense-in-depth (verdict rows are only minted under blocking and the
  contract fingerprint includes enforcement, so today it degrades to an
  ordinary paid review).
- fable P3-3: end-to-end tests drive _run_reviewed_stage_cycle through both
  advisory replay reasons, pinning the distinct progress notes and the
  disclosure landing in the commit result formatting.

Residual disclosures:
- Bench settings_base templates set blocking enforcement and do not pin
  OUROBOROS_REVIEW_MAX_CYCLES; the trace audit and the bench-cap option are
  escalated to the owner before landing (per the sprint plan's I1
  verification duty). Only the SWE-Pro evolution lane executes the commit
  gate, and the E1v2 METHODOLOGY already holds advisory.
- Successful reviewed commits also consume paid cycles, so a root task's
  third reviewed commit refuses under blocking at the default cap of 2
  (settled Q16-A consequence).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Anton Razzhigaev 2026-08-20 17:51:31 +00:00
parent 4ec638b170
commit 386e9417b1
25 changed files with 3808 additions and 588 deletions

View file

@ -155,7 +155,8 @@ server.py (Starlette+uvicorn) ← HTTP + WebSocket on configurable host:port (de
├── review_slot_cancel.py ← Hosted-review slot poller's cancel-honesty helpers (extracted from `review_execution.py` at the module-size gate; `review_execution` imports them and the poll loop stays there): early-termination decision for a parked question (`_interaction_outlives_slot`), the verified slot cancel reporting only what it PROVED (`_slot_cancel_outcome` — outcome + state + the verify read's own `terminal_detail` when carried), honest attribution wording (`_cancel_honesty_clause` — "host-cancelled" only on a `confirmed` receipt whose state is the cancel's own; a confirmed `failed`/`interrupted` is attributed to the run's OWN terminal, BR2-2), and completion-wins consumption of a discovered natural success (`_natural_success_terminal` — the carried detail is used as-is, a re-read gets one bounded retry, and a still-unreadable detail raises the typed `ReviewSessionSucceededResultUnavailable` naming the settled custody row and the capture surfaces instead of "may still be live", BR2-1).
├── reviewer_slot_config.py ← Structured reviewer-slot SSOT: stable slot ids, route targets, per-slot effort, legacy projections, save/runtime validation, and disclosure-only last-effective execution records. Malformed configuration loudly refuses commit, scope, advisory, plan, and skill review; task acceptance deliberately retains the projected legacy/default API panel.
├── review_state.py ← Durable advisory pre-review state (advisory_review.json)
├── review_cycles.py ← Shared paid-review-cycle cap SSOT (`OUROBOROS_REVIEW_MAX_CYCLES`, string: positive int or `unlimited`; `review_max_cycles()` → Optional[int]): one number, three documented per-gate meanings — plan review (panel cycles per task), task acceptance (`improvement passes = cycles − 1`; the retired `OUROBOROS_ACCEPTANCE_MAX_IMPROVEMENT_PASSES` migrates into this key at settings load) and the commit gate's identical-diff attempt cap — plus `emit_review_cycles_exhausted`, the typed D27 escalation on the existing events rail
├── review_cycles.py ← Shared paid-review-cycle cap SSOT (`OUROBOROS_REVIEW_MAX_CYCLES`, string: positive int or `unlimited`; `review_max_cycles()` → Optional[int]): one number, four documented per-gate meanings — plan review (panel cycles per task), task acceptance (`improvement passes = cycles − 1`; the retired `OUROBOROS_ACCEPTANCE_MAX_IMPROVEMENT_PASSES` migrates into this key at settings load), the commit gate (paid triad+scope cycles per root task; independently, a byte-identical staged diff is refused free from the first verdict-block) and skill review (paid panel dispatches per root task / manual group lane, free replay of identical snapshots) — plus `emit_review_cycles_exhausted`, the typed D27 escalation on the existing events rail
├── review_dispatch.py ← Reviewer dispatch primitives (moved whole from `review_substrate.py` at the module-size gate): the row-identity mint (`slot_id_for_row` + surface prefixes; `review_substrate` re-exports the historical names) and the write-ahead PAID stamp seam — a gate that meters paid review cycles installs an idempotent `ReviewPaidStamp` on `ctx._review_paid_stamp` for the duration of its wave, and `run_review_request` invokes it immediately before the first physical reviewer transport call, so assembly-only refusals stay $0 while a crash after dispatch keeps the durable paid fact
├── reviewer_window.py ← Reviewer context-window SSOT for every review surface (triad, scope, plan, deep self-review): ONE typed `ReviewerWindow` per ROUTE (window/status/stale/observed_at + computed `blocking_authority_allowed`, v6.87.44) with a metadata-only probe serialised by a per-route lock and rate-limited by `probe`'s own evidence TTL, never by a process-lifetime memo that would outlive the record (v6.87.45), fail-closed sub-floor when no evidence exists, and output/tokenizer reserves scaled to a sub-1M window so a small-window slot gets a fit-sized pack instead of a zero limit (v6.87.22; replaces the hardcoded 1M assumption each surface carried)
├── triad_review.py ← Shared multi-model review primitives: JSON-array extraction is reused by repo + skill review; per-actor records, quorum/degraded accounting, and model-error events power the skill-review path. It also owns the review OUTPUT CONTRACT text rendered by the repo-triad, repo-advisory and scope prompts (skill review states its own contract in `skill_review.py`): findings-only (`REVIEW_JSON_ARRAY_CONTRACT`) and required-matrix (`REVIEW_JSON_MATRIX_CONTRACT`, no all-clear — advisory selects it whenever `expected_items` is supplied). A clean findings-only verdict is recognised only when the WHOLE response — modulo one optional code fence — is `[]`, optionally followed by the `NO_FINDINGS` sentinel and nothing else. Any surrounding prose, and the sentinel without the array, are parse failures: a refusal cannot be distinguished from a benign preamble by structure, so neither is accepted. (A valid non-empty array is of course the normal findings path; it is simply never a *clean* verdict, even with the sentinel appended.) Keeping the text beside `empty_array_is_verified_clean` — the parser that enforces it — is what stops the two from drifting apart
├── onboarding_wizard.py ← Shared desktop/web onboarding bootstrap + validation
@ -186,7 +187,8 @@ server.py (Starlette+uvicorn) ← HTTP + WebSocket on configurable host:port (de
├── skill_review_status.py ← Skill-review verdict aggregation SSOT (FAILs → clean/warnings/blockers/pending; hard trust-boundary items block on FAIL, bug_hunting + selected conditional safety items follow severity; enforcement maps verdicts to executable_review)
├── skill_review_passes.py ← (v6.41.0) Skill-review pass runner: one multi-model review pass, or a chunked per-pack pass (with per-chunk parseable quorum) when an over-budget skill is split — merged into one verdict (P5 token budget)
├── skill_review.py ← Skill review pipeline: deterministic preflight + optional fail-open Claude Code advisory over the skill payload only (repo diff excluded, Skill Review Checklist coverage contract, scope-review effort, raw/session metadata plus parsed_items/contract_warning persisted as advisory_result) followed by the tri-model executable trust gate against the Skill Review Checklist section of docs/CHECKLISTS.md plus minimal host skill/widget context (CREATING_SKILLS.md, PluginAPI contract, extension UI validator); supports rebuttal/history/convergence evidence
├── skill_review_history.py ← Append-only Skill Review history helpers: group-wide rounds, per-snapshot attempts, legacy read-time ordinals, and job-idempotent terminal rows
├── skill_review_history.py ← Append-only Skill Review history helpers: group-wide rounds, per-snapshot attempts, legacy read-time ordinals, and job-idempotent terminal rows (terminal rows also carry the Max-Review-Cycles facts: paid dispatch + wave id, panel contract fingerprint, rebuttal content hash, replay provenance), plus the ONE write-ahead dispatch marker (`review_dispatch.json`, shared by lifecycle and direct callers): written immediately before a wave's first reviewer transport call, merged into that wave's terminal row (a timeout terminal with no result still gets the paid facts) and cleared on merge; an orphaned predecessor is flushed into the history as an infra terminal, and append failures surface as loud typed `skill_review_history_append_failed` events
├── skill_review_cycles.py ← Max-Review-Cycles semantics for skill review (Q17/Q23): paid-cycle counting derived from the review history plus the unmerged write-ahead dispatch marker (root task for task-driven groups, manual lane otherwise; one chunked wave = one cycle), $0 replay of a recorded substantive verdict for an identical (group, content-hash, panel-contract) snapshot (spent-rebuttal memory is scoped to the same contract fingerprint), typed `review_cycles_exhausted` refusal, and the accepted-rebuttal ledger (moved whole from `skill_review.py` at the module-size gate)
├── extension_loader.py ← Phase 4 loader for type: extension skills; imports no-dependency pure-Python extensions in-process with PluginAPIImpl, but catalogs isolated-dep/native-marker extensions through child-process proxies so plugin import cannot abort server.py; tracks registrations per-skill for atomic unload
├── extension_process_runner.py ← Short-lived child-process runner for isolated-dep/native-marker extension catalog/tool/route/WS dispatch; uses scrubbed env, per-skill deps, process-group tracking, timeout/output caps, and returns graceful host errors on child crash
├── extension_ui_validation.py ← One host-owned recursive declarative-schema-v1 validator shared by extension loader and skill preflight; exact tree paths, stable identity, depth/node budgets, passive-subscription enforcement
@ -263,7 +265,7 @@ server.py (Starlette+uvicorn) ← HTTP + WebSocket on configurable host:port (de
│ ├── ci.py ← CI trigger and monitoring (GitHub Actions API)
│ ├── claude_advisory_review.py ← Advisory pre-review tool (read-only Claude Agent SDK)
│ ├── recent_tasks.py ← Read-only context recovery tool exposing recent task_results summaries/traces for LLM-first continuation recovery
│ ├── commit_gate.py ← Advisory freshness gate and commit-attempt recording (extracted from git.py); `_record_commit_attempt` runs LLM-based claim synthesis (via `review_synthesis.py`) on blocked attempts before durable obligations are created
│ ├── commit_gate.py ← Advisory freshness gate, commit-attempt recording (extracted from git.py; `_record_commit_attempt` runs LLM-based claim synthesis via `review_synthesis.py` on blocked attempts before durable obligations are created) AND the commit-side Max-Review-Cycles machinery: typed block classification (`classify_review_block`/`attempt_block_class`), the free identical-diff refusal (`check_identical_verdict_refusal`), the per-root-task paid-cycle ceiling (`count_paid_review_cycles`/`check_review_cycles_ceiling`) and the review-contract fingerprint (`commit_review_contract_fingerprint`)
│ ├── git_rollback.py ← vcs_rollback tool (wraps git_ops.rollback_to_version)
│ ├── git_pr.py ← PR integration tools: fetch_pr_ref, create_integration_branch, cherry_pick_pr_commits, stage_adaptations, stage_pr_merge (non-core, require enable_tools)
│ ├── github.py ← GitHub integration: issues (list/get/comment/close) + PR tools: list_github_prs, get_github_pr, comment_on_pr (non-core; github.py is in _FROZEN_TOOL_MODULES so PR inspection/comment tools work in packaged builds)
@ -1011,7 +1013,7 @@ Background Consciousness is the high-horizon awareness loop. It can groom memory
An active campaign owns an explicit objective, campaign id, transaction, and task claim. `evolution_mode_enabled` is only its scheduling projection. Dispatch, review, commit, publication, and restart revalidate that exact authority; a restored row without a live uncommitted claim is cancelled. A reviewed commit binds to the claim by exact SHA before publication. If authority changes after commit, the commit is moved to a private inspection ref and any attempt-created tag is removed from the normal namespace; concurrent index/worktree edits are not reset and no restart occurs. Restart verification and boot reconciliation decide whether the cycle is absorbed, abandoned, or still pending, and exact terminal replay resumes only incomplete effects without double-counting a cycle.
Campaign cleanup is deterministic and custody-aware. A no-op or abandoned cycle may restore the transaction base while preserving dirty/ahead work in recorded stash or local refs, but cleanup is skipped when another task, a live test, or the operator kill-switch makes reset unsafe. Repeated review-verdict blocks on a byte-identical diff are capped without counting preflight failures; changing the diff or supplying a review rebuttal creates a new reviewable case. Checkpoint and outcome rows preserve git/memory identity, cost, rounds, and explicit omissions so the promotion loop learns from failed as well as absorbed cycles. An agent-requested restart first drains heartbeat-fresh running tasks up to its configured bound and then fails closed rather than cutting another task silently.
Campaign cleanup is deterministic and custody-aware. A no-op or abandoned cycle may restore the transaction base while preserving dirty/ahead work in recorded stash or local refs, but cleanup is skipped when another task, a live test, or the operator kill-switch makes reset unsafe. A byte-identical diff whose last terminal is a review-verdict block is refused for free from the first block (preflight failures and infra-blocks neither build nor reset the streak); changing the diff, or supplying a rebuttal whose content hash is new to the streak, creates a new paid reviewable case, and the shared cycle cap bounds paid triad+scope cycles per root task. Checkpoint and outcome rows preserve git/memory identity, cost, rounds, and explicit omissions so the promotion loop learns from failed as well as absorbed cycles. An agent-requested restart first drains heartbeat-fresh running tasks up to its configured bound and then fails closed rather than cutting another task silently.
Post-task evolution is owner-gated and default-off. The worker may recommend one backlog item by writing a request, but only the supervisor may convert that request into one normal campaign cycle. An owner stop closes the campaign, clears queued requests, persists a sentinel, and cannot be autonomously reversed; only an owner-authorized start clears it. Evolution is hard-blocked in `light` runtime mode at every entry point and uses the normal task/review path in `advanced` or `pro`.
@ -2357,7 +2359,7 @@ Runtime floors:
| OUROBOROS_LLM_TRANSPORT_READ_TIMEOUT_SEC | 2700 | (v6.54.3) Default httpx read/write timeout for no_proxy LLM clients (was a hardcoded 3600). Deliberately generous — long silent reasoning (scope review, deep self-review) can take 20-40 min before the first byte; this is the dead-socket bound, and explicit per-call timeouts always win. |
| OUROBOROS_PLAN_TASK_DEADLINE_MIN_SEC | 300 | plan_task deadline floor: below this remaining window `plan_task` returns a typed `PLAN_TASK_SKIPPED_DEADLINE` + `plan_task_deadline_skip` telemetry instead of eating the budget tail. Without a deadline behavior is unchanged. |
| OUROBOROS_ACCEPTANCE_REVIEW_EST_SEC | 200 | Initial acceptance-review estimate and floor. The first review reserves at least 200s; later passes use `max(floor, 1.5×EWMA)` with `alpha=0.5`, reconstructed from existing timing events. |
| OUROBOROS_REVIEW_MAX_CYCLES | "2" | Shared paid-review-cycle cap (SSOT `ouroboros/review_cycles.py`; Settings → Behavior "Max Review Cycles" 1/2/3/5/∞). STRING: a positive integer or `unlimited` (aliases `inf`, `∞`); malformed values fail closed to the default and are logged once. Per-gate meaning: plan review — paid reviewer-panel cycles per task; task acceptance — paid panel runs per task, `improvement passes = cycles − 1` (binds under EVERY policy incl. Required+Blocking; an explicit `task_contract.budget_profile.max_improvement_passes` still wins; `unlimited` = no local count cap, deadline/budget/lifecycle rails still bind); commit gate — consecutive review-blocks of a BYTE-IDENTICAL staged diff before the identical-diff attempt cap refuses (was a hardcoded 3). |
| OUROBOROS_REVIEW_MAX_CYCLES | "2" | Shared paid-review-cycle cap (SSOT `ouroboros/review_cycles.py`; Settings → Behavior "Max Review Cycles" 1/2/3/5/∞). STRING: a positive integer or `unlimited` (aliases `inf`, `∞`); malformed values fail closed to the default and are logged once. Per-gate meaning: plan review — paid reviewer-panel cycles per task; task acceptance — paid panel runs per task, `improvement passes = cycles − 1` (binds under EVERY policy incl. Required+Blocking; an explicit `task_contract.budget_profile.max_improvement_passes` still wins; `unlimited` = no local count cap, deadline/budget/lifecycle rails still bind); commit gate — PAID triad+scope cycles per root task (a follow-up task is a fresh root; paid recorded at dispatch on the attempt ledger and every dispatched wave counts whatever its terminal — only undispatched attempts stay outside the count; a byte-identical staged diff is independently refused FREE from the first verdict-block, and a content-new rebuttal buys exactly one paid re-review; under advisory enforcement exhaustion/identical-refusal disclose loudly and the commit proceeds without buying another review); skill review — paid panel dispatches per root task (task-driven groups) or per manual-lane content snapshot, one chunked wave = one cycle, identical snapshots replay free. |
| OUROBOROS_ACCEPTANCE_MAX_IMPROVEMENT_PASSES | (retired) | Rename alias of the shared cap: a customized value is MIGRATED into `OUROBOROS_REVIEW_MAX_CYCLES` (cycles = passes + 1) when settings load and the key is then dropped, so it never binds at runtime. Residual: an env-only legacy value is not migrated. |
| OUROBOROS_ACCEPTANCE_RESERVE_PCT | 5 | (v6.54.4) Default finalization-reserve percentage; reserve = max(OUROBOROS_FINALIZATION_GRACE_SEC, pct×total budget). |
| OUROBOROS_OBSERVABILITY_RETENTION_DAYS | unset | Deprecated audit knob for private observability manifests/blobs; forensic replay blobs are kept compressed indefinitely |

View file

@ -802,12 +802,38 @@ Review Cycles" 1 / 2 / 3 / 5 / ∞). Its per-gate meaning is documented in that
module and is literally: plan review — paid reviewer-panel cycles per task;
task acceptance — paid panel runs per task, `improvement passes = cycles − 1`
(the retired `OUROBOROS_ACCEPTANCE_MAX_IMPROVEMENT_PASSES` is migrated into the shared key at
settings load — cycles = passes + 1 — and never binds at runtime); commit gate — consecutive review-blocks of a
BYTE-IDENTICAL staged diff before the identical-diff attempt cap refuses another
triad+scope run (changing the diff starts a fresh streak; a rebuttal lifts the
cap for that attempt only; the default moved from a hardcoded 3 to the shared 2). `unlimited` removes the
local count everywhere; deadline, budget and lifecycle rails still bind. A
malformed value fails closed to the bounded default and is logged once.
settings load — cycles = passes + 1 — and never binds at runtime); commit gate
— paid triad+scope cycles per ROOT task (the whole task tree shares one
ceiling; a manual session is its own task; a follow-up task is a fresh root;
the paid fact is recorded on the attempt row at dispatch and the count is
derived from the attempt ledger); skill review — paid reviewer-panel
dispatches per ceiling key (the root task for task-driven groups; the manual
lane is scoped per content snapshot, so revised content restarts its count;
one chunked wave = ONE cycle). `unlimited` removes the local count
everywhere; deadline, budget and lifecycle rails still bind. A malformed
value fails closed to the bounded default and is logged once.
Anti-pattern: paying for byte-identical review material. Never dispatch a paid
reviewer wave for material a gate has already reviewed under the same review
contract — the commit gate refuses a byte-identical staged diff for free from
the FIRST verdict-block (`identical_diff_refused`, quoting the recorded
verdict), and skill review replays a recorded substantive verdict for an
identical snapshot at $0 (only while the persisted review state still covers
it). A rebuttal is identified by CONTENT (sha256): a hash new to the streak
buys exactly ONE paid re-review; a repeated hash is refused free. The exact
rule keeps two axes distinct. Refusal-streak eligibility is about VERDICTS:
only substantive reviewer verdicts build (or end) the identical-bytes refusal
streak, and a rebuttal is "spent" only by the substantive verdict it bought —
never when it was refused undispatched or when its wave died on infra. Money
accounting is about DISPATCH: the limit counts PAID cycles, and every
physically dispatched wave counts whatever its terminal — so a dispatched
infra terminal (quorum failure, transport death, timeout) consumes money but
not the rebuttal, while infra facts refused at assembly (fit overflow,
sub-floor window) never dispatched and stay outside the count; the paid fact
is recorded write-ahead at first dispatch. Byte-identical resubmissions are
refused before any spend. Exhaustion is always the typed
`review_cycles_exhausted` event with honest exits, never a silent grind or
another paid dispatch.
`docs/CHECKLISTS.md` is the only reviewer-question, severity, and output SSOT.
Architecture owns the dataflow; this section owns operator sequence. Finish all

View file

@ -279,7 +279,7 @@ SETTINGS_DEFAULTS = {**UPDATE_SETTINGS_DEFAULTS,
# reserves at least 200s; later passes use max(this floor, 1.5×timing EWMA).
"OUROBOROS_ACCEPTANCE_REVIEW_EST_SEC": 200,
# Shared paid-review-cycle cap (SSOT + per-gate meaning: ouroboros/review_cycles.py):
# STRING "N"|"unlimited": plan review, acceptance (passes = cycles - 1), commit-gate cap.
# STRING "N"|"unlimited": plan review, acceptance (passes = cycles - 1), commit gate and skill review (paid cycles per root task / manual snapshot); identical material is never re-reviewed for pay on any gate.
"OUROBOROS_REVIEW_MAX_CYCLES": "2",
"OUROBOROS_ACCEPTANCE_RESERVE_PCT": 5,
# Prompt-cache TTL, one honest GLOBAL override (owner decision 2026-08-08, batch #2 Q2=A): applied to

View file

@ -1051,8 +1051,28 @@ def _skill_review_history_detail_sync(
"raw_actor_records": degraded_actors,
"error": error_note,
}
markdown = render_skill_review_block(outcome, attempt_idx=attempt)
# Max-Review-Cycles accounting facts (Q16/Q17 auditability) ride the
# free-form markdown detail: the response contract
# (SkillReviewHistoryDetailResponse) is typed to exactly four fields, so
# adding response keys would need an api_types version bump — the rendered
# detail string is the additive channel. Legacy rows without the facts
# render nothing.
accounting = []
if record.get("paid"):
accounting.append("paid panel dispatch (counts toward Max Review Cycles)")
if record.get("replayed_from_ts"):
accounting.append(f"free replay of the {record.get('replayed_from_ts')} verdict")
if record.get("review_contract_fingerprint"):
accounting.append(
f"panel contract {str(record.get('review_contract_fingerprint'))[:12]}…"
)
if record.get("rebuttal_sha256"):
accounting.append(f"rebuttal sha256 {str(record.get('rebuttal_sha256'))[:12]}…")
if accounting:
markdown += "\n\n_Review accounting: " + "; ".join(accounting) + "._"
return {
"markdown": render_skill_review_block(outcome, attempt_idx=attempt),
"markdown": markdown,
"status": status,
"content_hash": outcome["content_hash"],
"job_status": str(record.get("job_status") or ""),

View file

@ -9,17 +9,37 @@ shipped default with one loud log line. It is a string on purpose:
default would silently swallow ``"unlimited"``. ``review_max_cycles()`` returns
``Optional[int]`` — ``None`` means unlimited.
Per-gate meaning of the ONE number:
Per-gate meaning of the ONE number — on every gate it counts PAID cycles, and
identical material is never re-reviewed for pay:
* plan review — paid reviewer-panel cycles per task (the engine consumes the
getter; this module only exposes it);
* task acceptance — paid panel runs per task, ``passes = cycles - 1``
(``acceptance_max_improvement_passes_from_cycles``), so the default 2 equals
the historical default of 1 improvement pass; unlimited → None;
* commit gate — consecutive review-blocks on a BYTE-IDENTICAL staged diff before
the identical-diff attempt cap refuses another triad+scope run. Semantics
unchanged (a changed diff starts a fresh streak); only the number's source
changed, and the default moved 3 → 2 (owner-approved, disclosed).
* commit gate — paid triad+scope cycles per ROOT task (the whole task tree
shares one ceiling; a manual session is its own task; a follow-up task is a
fresh root). The paid fact is recorded on the attempt row AT DISPATCH and
the count is derived from the attempt ledger; the ceiling counts MONEY —
every dispatched wave counts whatever its terminal, and only UNDISPATCHED
attempts (free refusals/replays, preflight and assembly failures) stay
outside the count, which is also the only sense in which "infra retries
freely" applies to the ceiling. Independently of the number, a
byte-identical staged diff whose last terminal is a review-VERDICT block is
refused for FREE from the FIRST block (``identical_diff_refused``, quoting
the recorded verdict); a rebuttal is content-hashed and a hash new to the
streak buys exactly ONE paid re-review (a rebuttal is "spent" only when it
bought a dispatched, verdict-answered wave). Exhaustion under blocking is a
free typed refusal; under advisory the commit proceeds with a loud typed
disclosure and no further paid dispatch;
* skill review — paid reviewer-panel dispatches per ceiling key (the root task
for task-driven review groups — shared across every skill that task reviews,
follow-ups start fresh — or, for the manual lane, the CURRENT content
snapshot: revised content restarts the manual count; a chunked oversized
wave is ONE cycle, not one per pack). A byte-identical skill snapshot with a
recorded substantive verdict under the same panel contract replays free
(``skill_review_cycles.py``) when the persisted review state still covers
it; exhaustion is the same typed event with ``surface="skill_review"``.
Values are read from ``os.environ`` (``config.apply_settings_to_env`` projects
saved settings there) falling back to ``SETTINGS_DEFAULTS`` — no second

View file

@ -0,0 +1,90 @@
"""Reviewer dispatch primitives: the row-identity mint (moved whole from
``review_substrate.py`` at the module-size gate, same split shape as
``skill_review_cycles.py``) and the write-ahead PAID stamp seam (owner
Q16/Q17; Max-Review-Cycles fix round).
The ``paid`` fact of Max-Review-Cycles accounting is recorded at PHYSICAL
dispatch: a gate that must durably record "this wave spent reviewer money"
installs a :class:`ReviewPaidStamp` on ``ctx._review_paid_stamp`` for the
duration of its wave, and the shared reviewer transport entry
(``review_substrate.run_review_request``) invokes it immediately before the
first transport call. Assembly-only refusals (triad fit ladder, scope pack
signals, skill prompt building) exit before the seam, so a $0 attempt stays
outside every ceiling; a crash after dispatch keeps the durable paid fact
(write-ahead). This seam is also where the L-review lane's two-phase
admission slots in at synthesis.
"""
from __future__ import annotations
import logging
import threading
from typing import Any, Callable
log = logging.getLogger(__name__)
# Identity prefixes for the configured reviewer surfaces. A surface that fans
# rows out registers its prefix here rather than spelling one inline, so
# ``slot_id_for_row`` stays the only place a row id is built.
SLOT_ID_PREFIX = "slot"
SCOPE_SLOT_ID_PREFIX = "scope_slot"
PLAN_SLOT_ID_PREFIX = "plan_slot"
def slot_id_for_row(index: int, *, prefix: str = SLOT_ID_PREFIX) -> str:
"""Identity of the ``index``-th (1-based) configured reviewer row.
The single mint for reviewer-slot identity, and the reason the substrate
contract says slot identity is separate from model identity. Naming a row
after its own model instead collides two rows that share a model (a supported
configuration — ``get_scope_review_models`` preserves duplicates on purpose),
collides two model spellings that sanitize alike (``openai::gpt-5`` and
``openai/gpt/5``), and moves a row's identity the moment the owner edits its
model, so the row's receipts stop lining up with its own history. The model,
the route and the effort are PROPERTIES of a row, never its name.
"""
return f"{prefix}_{int(index)}"
class ReviewPaidStamp:
"""Idempotent, thread-safe once-only wrapper around one durable write.
Parallel dispatch means two sides can race to be "the first transport
call" (the commit gate dispatches triad and scope concurrently): the first
caller performs the durable write-ahead, later callers block on the lock
until it lands and then no-op — so EVERY side is guaranteed the paid fact
is durable before its own transport begins. A failing write is not
retried and still marks the stamp fired: the terminal record is the
primary ledger, and this writer is fail-open cost accounting, never a
safety gate.
"""
def __init__(self, write: Callable[[], None]) -> None:
self._write = write
self._lock = threading.Lock()
self.fired = False
def __call__(self) -> None:
with self._lock:
if self.fired:
return
try:
self._write()
finally:
self.fired = True
def stamp_review_paid_on_dispatch(ctx: Any) -> None:
"""Invoke the caller-installed write-ahead paid stamp; no-op without one.
Called by the shared reviewer transport entry immediately before the first
physical reviewer call. Surfaces that do not meter paid cycles simply
never install ``ctx._review_paid_stamp``. Fail-open by design.
"""
stamp = getattr(ctx, "_review_paid_stamp", None) if ctx is not None else None
if not callable(stamp):
return
try:
stamp()
except Exception:
log.debug("review paid dispatch stamp failed (fail-open)", exc_info=True)

View file

@ -1396,6 +1396,14 @@ def _review_status_attempt_payload(ca: Any) -> Dict[str, Any] | None:
"pre_review_fingerprint": ca.pre_review_fingerprint[:12] or None,
"post_review_fingerprint": ca.post_review_fingerprint[:12] or None,
"degraded_reasons": list(ca.degraded_reasons or []),
# Max-Review-Cycles accounting facts (Q16 auditability): the typed
# block class, the dispatch-paid fact, and the identities the free
# refusal/replay decisions key on.
"block_class": str(getattr(ca, "block_class", "") or "") or None,
"paid": bool(getattr(ca, "paid", False)),
"rebuttal_sha256": str(getattr(ca, "rebuttal_sha256", "") or "")[:12] or None,
"review_contract_fingerprint": str(getattr(ca, "review_contract_fingerprint", "") or "")[:12] or None,
"root_task_id": str(getattr(ca, "root_task_id", "") or "") or None,
**_review_status_actor_summary(ca),
})
return data
@ -1464,6 +1472,8 @@ def _review_status_message(projection: Dict[str, Any]) -> str:
"fingerprint_unavailable": "The staged diff could not be fingerprinted. Fix git diff and retry.",
"overlap_guard": "Another reviewed attempt is still active. Wait or expire it before retrying.",
"attempt_cap_reached": "The same staged diff was review-blocked repeatedly. Change the diff or rebut via review_rebuttal.",
"identical_diff_refused": "This exact staged diff was already review-blocked. Change the diff or supply a NEW review_rebuttal (identical bytes are never re-reviewed for pay).",
"review_cycles_exhausted": "This task tree spent its paid review cycles (OUROBOROS_REVIEW_MAX_CYCLES). Finalize honestly or ask the owner to raise the cap.",
}
label = "BLOCKED" if ca.status == "blocked" else "FAILED"
current = (
@ -1479,9 +1489,16 @@ def _review_status_message(projection: Dict[str, Any]) -> str:
def _attempt_to_dict(item: Any) -> Dict[str, Any]:
data = {
key: str(getattr(item, key, "") or "")
for key in ("ts", "tool_name", "status", "phase", "block_reason", "scope_model")
for key in (
"ts", "tool_name", "status", "phase", "block_reason", "scope_model",
# Max-Review-Cycles accounting facts (Q16 auditability).
"block_class", "rebuttal_sha256", "review_contract_fingerprint",
"root_task_id",
)
}
data.update({
"paid": bool(getattr(item, "paid", False)),
"raw_stripped": bool(getattr(item, "raw_stripped", False)),
"attempt": int(getattr(item, "attempt", 0) or 0),
"late_result_pending": bool(getattr(item, "late_result_pending", False)),
"duration_sec": float(getattr(item, "duration_sec", 0.0) or 0.0),

View file

@ -106,8 +106,8 @@ def _commit_readiness_debts_view(state: Any) -> List["CommitReadinessDebtItem"]:
_OBLIGATION_STR_DEFAULTS = {"obligation_id": "", "item": "", "severity": "critical", "reason": "", "source_attempt_ts": "", "source_attempt_msg": "", "status": "still_open", "resolved_by": "", "repo_key": _LEGACY_CURRENT_REPO_KEY}
_DEBT_STR_DEFAULTS = {"debt_id": "", "category": "", "summary": "", "severity": "warning", "status": "detected", "repo_key": _LEGACY_CURRENT_REPO_KEY, "fingerprint": "", "title": "Commit readiness debt", "source": "review_state", "first_seen_at": "", "last_seen_at": "", "updated_at": "", "verified_at": ""}
_RUN_STR_DEFAULTS = {"snapshot_hash": "", "commit_message": "", "status": "stale", "snapshot_summary": "", "raw_result": "", "bypass_reason": "", "bypassed_by_task": "", "repo_key": _LEGACY_CURRENT_REPO_KEY, "tool_name": _DEFAULT_ADVISORY_TOOL_NAME, "phase": "advisory", "model_used": "", "session_id": ""}
_ATTEMPT_STR_DEFAULTS = {"commit_message": "", "snapshot_hash": "", "block_reason": "", "block_details": "", "task_id": "", "repo_key": _LEGACY_CURRENT_REPO_KEY, "tool_name": _DEFAULT_TOOL_NAME, "pre_review_fingerprint": "", "post_review_fingerprint": "", "fingerprint_status": "", "scope_model": ""}
_ATTEMPT_MERGE_INCOMING_FIRST = ("ts", "commit_message", "status", "snapshot_hash", "block_reason", "block_details", "duration_sec", "task_id", "repo_key", "tool_name", "phase", "pre_review_fingerprint", "post_review_fingerprint", "fingerprint_status", "scope_model")
_ATTEMPT_STR_DEFAULTS = {"commit_message": "", "snapshot_hash": "", "block_reason": "", "block_details": "", "task_id": "", "repo_key": _LEGACY_CURRENT_REPO_KEY, "tool_name": _DEFAULT_TOOL_NAME, "pre_review_fingerprint": "", "post_review_fingerprint": "", "fingerprint_status": "", "scope_model": "", "block_class": "", "rebuttal_sha256": "", "review_contract_fingerprint": "", "root_task_id": ""}
_ATTEMPT_MERGE_INCOMING_FIRST = ("ts", "commit_message", "status", "snapshot_hash", "block_reason", "block_details", "duration_sec", "task_id", "repo_key", "tool_name", "phase", "pre_review_fingerprint", "post_review_fingerprint", "fingerprint_status", "scope_model", "block_class", "rebuttal_sha256", "review_contract_fingerprint", "root_task_id")
_ATTEMPT_MERGE_INCOMING_LISTS = ("critical_findings", "advisory_findings", "obligation_ids", "readiness_warnings")
_RUN_STATUS_ICONS = {"fresh": "✅", "stale": "⚠️", "bypassed": "⏭️", "skipped": "⏭️", "parse_failure": "🔴"}
@ -256,6 +256,24 @@ class CommitAttemptRecord:
scope_model: str = ""
triad_raw_results: List[Dict[str, Any]] = field(default_factory=list)
scope_raw_result: Dict[str, Any] = field(default_factory=dict)
# Max-Review-Cycles semantics (owner Q12/Q16/Q22/Q23): the typed class of a
# blocked row ("verdict" = reviewer findings, "infra" = fit/quorum/transport/
# revalidation facts, "" = preflight/legacy), the content hash of the rebuttal
# supplied with the attempt (absent on old rows = no rebuttal), whether a paid
# triad/scope dispatch physically happened for this attempt (recorded at
# dispatch time, plan-review precedent), the review-contract fingerprint the
# attempt ran under (roster+routes+enforcement+prompt contract; a changed
# fingerprint lapses free-replay/refusal authority), and the root task the
# attempt belongs to (the whole task tree shares one paid-cycle ceiling).
block_class: str = ""
rebuttal_sha256: str = ""
paid: bool = False
review_contract_fingerprint: str = ""
root_task_id: str = ""
# True once the row's heavy forensic payloads (raw reviewer results, full
# free text) were compacted because the preserved accounting row fell
# outside the newest-50 ledger window (see _strip_attempt_heavy_payload).
raw_stripped: bool = False
@dataclass
@ -482,10 +500,38 @@ class AdvisoryReviewState:
self.attempts[idx] = merged
return merged
self.attempts.append(attempt)
if len(self.attempts) > _MAX_ATTEMPT_HISTORY:
self.attempts = self.attempts[-_MAX_ATTEMPT_HISTORY:]
self._trim_attempt_history()
return attempt
def _trim_attempt_history(self) -> None:
"""Authority-preserving eviction (Q16 fix round, F1): trimming to the
historical cap may only evict NON-authoritative rows, oldest first.
Paid rows are the money ledger ``count_paid_review_cycles`` derives the
per-root-task ceiling from, and verdict-block rows anchor the
identical-diff refusal streak (the quoted verdict) — evicting either
would let a capped root task loop free refusals until the ceiling and
the refusal both forget themselves. Both preserved classes are bounded
by the money ceiling itself, so only the noise portion (free refusals,
preflight facts, unpaid infra rows) is capped at
``_MAX_ATTEMPT_HISTORY``; the list may exceed the cap only by the
authority-bearing rows. Preserved rows that fall OUTSIDE the newest-50
window are COMPACTED (``_strip_attempt_heavy_payload``): accounting
facts are immortal, heavy forensic payloads are not — keeping the
serialized ledger ~O(preserved rows x small record) instead of growing
by full reviewer raw output per reviewed commit forever."""
overflow = len(self.attempts) - _MAX_ATTEMPT_HISTORY
if overflow <= 0:
return
kept: List[CommitAttemptRecord] = []
for item in self.attempts:
if overflow > 0 and _attempt_history_evictable(item):
overflow -= 1
continue
kept.append(item)
self.attempts = kept
for item in self.attempts[:-_MAX_ATTEMPT_HISTORY]:
_strip_attempt_heavy_payload(item)
def _allocate_obligation_id(self) -> str:
candidate, next_seq = _allocate_prefixed_id(
self.open_obligations,
@ -1070,6 +1116,8 @@ def _commit_attempt_from_dict(d: Dict[str, Any]) -> CommitAttemptRecord:
triad_models=[str(x) for x in (d.get("triad_models") or [])],
triad_raw_results=list(d.get("triad_raw_results") or []),
scope_raw_result=dict(d.get("scope_raw_result") or {}),
paid=bool(d.get("paid", False)),
raw_stripped=bool(d.get("raw_stripped", False)),
)
@ -1593,6 +1641,75 @@ def _normalize_findings(items: List[Any]) -> List[Dict[str, Any]]:
return normalized
def _attempt_history_evictable(item: "CommitAttemptRecord") -> bool:
"""True only for rows the Max-Review-Cycles machinery derives NO authority
from. Never evictable: paid rows (the per-root-task money count), rows of
an attempt still in flight (their upcoming terminal/paid merge must find
them), and review-VERDICT blocks (the anchors an identical-diff refusal
quotes; legacy rows classify by recorded reason via the commit gate's
``attempt_block_class``). Unclassifiable rows are kept — fail toward
remembering."""
if bool(getattr(item, "paid", False)):
return False
if str(getattr(item, "status", "") or "") == "reviewing" or bool(
getattr(item, "late_result_pending", False)
):
return False
try:
from ouroboros.tools.commit_gate import BLOCK_CLASS_VERDICT, attempt_block_class
return attempt_block_class(item) != BLOCK_CLASS_VERDICT
except Exception:
return False
# What the refusal quote renders at most from an over-window row's
# block_details (mirrors _quote_verdict_attempt's own limit=600), and a small
# bound for the free-text commit message on compacted rows.
_STRIPPED_DETAILS_LIMIT = 600
_STRIPPED_MESSAGE_LIMIT = 300
def _strip_attempt_heavy_payload(item: CommitAttemptRecord) -> None:
"""Compact one preserved accounting row that fell outside the newest-50
ledger window (F1 follow-up: paid-row immortality must not make the hot
load-modify-save state file grow by full reviewer raw output forever).
Dropped: the heavy forensic payloads NO gate reads from over-window rows —
``triad_raw_results`` and ``scope_raw_result`` (full reviewer raw_text) —
plus the free-text fields bounded to what consumers render. Kept, exactly
the facts the Max-Review-Cycles gates consume: ``paid``/``root_task_id``
(the ceiling count), status/phase/``block_reason``/``block_class``/
``pre_review_fingerprint``/``review_contract_fingerprint``/
``rebuttal_sha256`` (the streak walker), and ``critical_findings`` plus a
600-char ``block_details`` excerpt (everything ``_quote_verdict_attempt``
renders in an identical-diff refusal). A legacy scope verdict classifies
THROUGH ``scope_raw_result``, so ``block_class`` is materialized before
that evidence is dropped — the row keeps its refusal-anchor authority.
``raw_stripped=True`` marks the compaction for audits; full payloads live
only in the newest-50 window."""
if bool(getattr(item, "raw_stripped", False)):
return
if not item.block_class:
try:
from ouroboros.tools.commit_gate import attempt_block_class
klass = attempt_block_class(item)
if klass:
item.block_class = klass
except Exception:
pass
item.triad_raw_results = []
item.scope_raw_result = {}
item.block_details = _truncate_review_artifact(
str(item.block_details or ""), limit=_STRIPPED_DETAILS_LIMIT
)
item.commit_message = _truncate_review_artifact(
str(item.commit_message or ""), limit=_STRIPPED_MESSAGE_LIMIT
)
item.raw_stripped = True
def _merge_attempt(existing: CommitAttemptRecord, incoming: CommitAttemptRecord) -> CommitAttemptRecord:
data = {
name: getattr(incoming, name) or getattr(existing, name)
@ -1610,6 +1727,14 @@ def _merge_attempt(existing: CommitAttemptRecord, incoming: CommitAttemptRecord)
triad_models=list(incoming.triad_models or existing.triad_models),
triad_raw_results=list(getattr(incoming, "triad_raw_results", None) or getattr(existing, "triad_raw_results", None) or []),
scope_raw_result=dict(getattr(incoming, "scope_raw_result", None) or getattr(existing, "scope_raw_result", None) or {}),
# Once an attempt physically dispatched a paid triad/scope wave the fact is
# durable: a later terminal update on the same row must never launder it.
paid=bool(getattr(incoming, "paid", False) or getattr(existing, "paid", False)),
# The compaction mark is sticky too — an unlikely late merge onto an
# over-window row must not present a stripped row as a full one.
raw_stripped=bool(
getattr(incoming, "raw_stripped", False) or getattr(existing, "raw_stripped", False)
),
)
return CommitAttemptRecord(**data)

View file

@ -893,27 +893,16 @@ def build_improvement_capsule(
return "\n".join(lines)
# Identity prefixes for the configured reviewer surfaces. A surface that fans
# rows out registers its prefix here rather than spelling one inline, so
# ``slot_id_for_row`` stays the only place a row id is built.
SLOT_ID_PREFIX = "slot"
SCOPE_SLOT_ID_PREFIX = "scope_slot"
PLAN_SLOT_ID_PREFIX = "plan_slot"
def slot_id_for_row(index: int, *, prefix: str = SLOT_ID_PREFIX) -> str:
"""Identity of the ``index``-th (1-based) configured reviewer row.
The single mint for reviewer-slot identity, and the reason this module's
contract says slot identity is separate from model identity. Naming a row
after its own model instead collides two rows that share a model (a supported
configuration — ``get_scope_review_models`` preserves duplicates on purpose),
collides two model spellings that sanitize alike (``openai::gpt-5`` and
``openai/gpt/5``), and moves a row's identity the moment the owner edits its
model, so the row's receipts stop lining up with its own history. The model,
the route and the effort are PROPERTIES of a row, never its name.
"""
return f"{prefix}_{int(index)}"
# The row-identity mint (slot_id_for_row + surface prefixes) moved whole to
# ouroboros/review_dispatch.py at the module-size gate; the historical names
# stay importable from here for every existing consumer.
from ouroboros.review_dispatch import ( # noqa: E402,F401 — re-exports
PLAN_SLOT_ID_PREFIX,
SCOPE_SLOT_ID_PREFIX,
SLOT_ID_PREFIX,
slot_id_for_row,
stamp_review_paid_on_dispatch,
)
def reviewer_slots(
@ -1586,6 +1575,11 @@ def run_review_request(
llm: LLMClient | None = None,
usage_ctx: Any = None,
) -> ReviewRunResult:
# Write-ahead paid stamp (Q16 dispatch seam): a gate that meters paid
# cycles installed a callback on ctx; it durably lands the paid fact
# BEFORE the first reviewer transport call. Assembly-only refusals never
# reach this line, so undispatched attempts stay outside every ceiling.
stamp_review_paid_on_dispatch(usage_ctx)
coordinator = ReviewCoordinator(llm=llm, drive_root=drive_root, usage_ctx=usage_ctx)
result = coordinator.run(request, reviewer_slots(role_hint=request.surface) if slots is None else slots)
if request.surface == "task_acceptance":

View file

@ -56,7 +56,6 @@ from ouroboros.tools.review_helpers import (
from ouroboros.triad_review import emit_review_model_error_events, extract_json_array, parse_model_review_results
from ouroboros.utils import (
append_jsonl,
atomic_write_json,
estimate_tokens,
sanitize_tool_result_for_log,
utc_now_iso,
@ -183,6 +182,18 @@ class SkillReviewOutcome:
requested_permissions: List[str] = field(default_factory=list)
auto_granted_permissions: List[str] = field(default_factory=list)
review_profile: str = ""
# Max-Review-Cycles facts (Q17/Q23): ``paid`` = a reviewer panel was
# physically dispatched for this outcome (recorded WRITE-AHEAD at dispatch
# via the durable dispatch marker; one chunked wave = ONE cycle) with
# ``wave_id`` naming that dispatch; the contract fingerprint the panel ran
# under; the content hash of the rebuttal supplied; and, for a $0 free
# replay, the ts of the recorded verdict it quotes. All land on the
# terminal history row.
paid: bool = False
wave_id: str = ""
review_contract_fingerprint: str = ""
rebuttal_sha256: str = ""
replayed_from_ts: str = ""
def _apply_auto_grant_outcome(outcome: SkillReviewOutcome, skill: Any, auto_grant: Any) -> None:
@ -284,122 +295,18 @@ def _review_history_path(drive_root: pathlib.Path, skill_name: str) -> pathlib.P
return review_history_path(drive_root, skill_name)
def _accepted_rebuttals_path(drive_root: pathlib.Path, skill_name: str) -> pathlib.Path:
"""Path to persisted accepted rebuttals for one skill."""
return drive_root / "state" / "skills" / skill_name / "accepted_rebuttals.json"
def _load_accepted_rebuttals(drive_root: pathlib.Path, skill_name: str) -> List[Dict[str, Any]]:
"""Return persisted accepted rebuttals (empty list when none / unreadable)."""
path = _accepted_rebuttals_path(drive_root, skill_name)
try:
raw = path.read_text(encoding="utf-8")
except OSError:
return []
try:
data = json.loads(raw)
except json.JSONDecodeError:
return []
if not isinstance(data, dict):
return []
items = data.get("items")
if not isinstance(items, list):
return []
out: List[Dict[str, Any]] = []
for entry in items:
if isinstance(entry, dict):
out.append(entry)
return out
def _persist_rebuttal_flips(
drive_root: pathlib.Path,
skill_name: str,
*,
history: List[Dict[str, Any]],
findings: List[Dict[str, Any]],
review_rebuttal: str,
content_hash: str,
responded_models: List[str],
) -> None:
"""Record rebuttals for items that flipped FAIL -> PASS on this attempt."""
if not review_rebuttal or not history:
return
last_fail_items = _fail_items_from_history_entry(history[-1])
current_fail_items = {
str(f.get("item") or "")
for f in findings
if isinstance(f, dict)
and str(f.get("verdict") or "").upper() == "FAIL"
and str(f.get("item") or "")
}
for item in sorted(last_fail_items - current_fail_items):
_record_accepted_rebuttal(
drive_root,
skill_name,
item=item,
rebuttal_text=review_rebuttal,
content_hash=content_hash,
passed_models=list(responded_models),
)
def _fail_items_from_history_entry(entry: Dict[str, Any]) -> set[str]:
"""Return FAIL item names from both v5.18 and legacy history entries."""
out = {
str(f.get("item") or "")
for f in (entry.get("fail_findings") or [])
if isinstance(f, dict) and str(f.get("item") or "")
}
if out:
return out
for signature in entry.get("failure_signature") or []:
parts = str(signature or "").split(":")
if len(parts) >= 2 and parts[1].upper() == "FAIL" and parts[0]:
out.add(parts[0])
return out
def _record_accepted_rebuttal(
drive_root: pathlib.Path,
skill_name: str,
*,
item: str,
rebuttal_text: str,
content_hash: str,
passed_models: Optional[List[str]] = None,
) -> None:
"""Persist (or refresh) an accepted rebuttal for ``item``."""
path = _accepted_rebuttals_path(drive_root, skill_name)
existing = _load_accepted_rebuttals(drive_root, skill_name)
target: Optional[Dict[str, Any]] = None
for entry in existing:
if str(entry.get("item") or "") == item:
target = entry
break
if target is None:
target = {
"item": item,
"rebuttal_text": rebuttal_text,
"accepted_at": utc_now_iso(),
"content_hash_seen": [content_hash] if content_hash else [],
"models_that_passed_after": list(passed_models or []),
}
existing.append(target)
else:
target["rebuttal_text"] = rebuttal_text
target["accepted_at"] = utc_now_iso()
seen = list(target.get("content_hash_seen") or [])
if content_hash and content_hash not in seen:
seen.append(content_hash)
target["content_hash_seen"] = seen
if passed_models:
target["models_that_passed_after"] = list(passed_models)
try:
path.parent.mkdir(parents=True, exist_ok=True)
atomic_write_json(path, {"items": existing}, trailing_newline=True)
except OSError:
log.debug("accepted rebuttal write failed", exc_info=True)
# The accepted-rebuttal ledger and the Max-Review-Cycles machinery moved whole
# to ouroboros/skill_review_cycles.py (module-size gate; same split shape as
# phase 0's update_candidate.py). Historical names stay importable from here.
from ouroboros.skill_review_cycles import ( # noqa: E402
accepted_rebuttals_path as _accepted_rebuttals_path, # noqa: F401 — re-export
load_accepted_rebuttals as _load_accepted_rebuttals,
fail_items_from_history_entry as _fail_items_from_history_entry, # noqa: F401 — re-export
install_skill_dispatch_stamp as _install_skill_dispatch_stamp,
persist_rebuttal_flips as _persist_rebuttal_flips,
record_accepted_rebuttal as _record_accepted_rebuttal, # noqa: F401 — re-export
review_wave_budget_block as _review_wave_budget_block,
)
def _build_skill_review_history_section(
@ -1036,53 +943,6 @@ def _run_skill_advisory_pre_review(ctx: Any, *, skill_name: str, file_pack: str)
return {"status": "empty", "prompt_section": ""}
def _review_wave_budget_block(
ctx: Any,
skill_name: str,
file_packs: List[str],
models: List[str],
) -> Optional[str]:
"""Return a human-readable refusal when the review wave cannot fit the
remaining root budget, else None. Read-only; emits one typed event."""
from ouroboros.tools.review_helpers import review_wave_budget_gate
# Estimate the WHOLE wave: a chunked oversized skill runs one full
# reviewer pass PER pack (run_skill_review_passes), and every pass re-sends
# the stable governance/checklist/host-contract files the prompt builder
# inlines — so both the payload chars and the governance chars multiply by
# the pack count. A single-pack estimate would under-admit exactly the
# multi-chunk waves most likely to die mid-review.
governance_chars = 0
for rel in (
"docs/ARCHITECTURE.md", "docs/DEVELOPMENT.md", "BIBLE.md",
"docs/CHECKLISTS.md", "docs/CREATING_SKILLS.md",
):
try:
governance_chars += int((_REPO_ROOT / rel).stat().st_size)
except OSError:
pass
packs = max(1, len(file_packs))
total_chars = sum(len(pack) + governance_chars for pack in file_packs)
# One admission slot per PHYSICAL reviewer call (models x packs), each
# sized at the average per-pack prompt: the input estimate sums to the
# exact wave total while the per-call output reservation also multiplies
# by the pack count (a models-only wave under-reserved chunked output).
admission = review_wave_budget_gate(
ctx, surface="skill_review", models=list(models) * packs,
prompt_chars=total_chars // packs,
extra={"skill_name": skill_name, "packs": packs},
)
if admission is None:
return None
return (
"review wave declined before dispatch: estimated reviewer-wave cost "
f"~${admission.get('estimated_wave_usd')} exceeds the remaining root budget "
f"${admission.get('remaining_usd')} (limit ${admission.get('limit_usd')}). "
"No reviewer was called; the skill stays pending. Raise the per-task "
"budget or re-run the review in a fresh task."
)
def _build_review_prompt_for_attempt(
ctx: Any,
drive_root: pathlib.Path,
@ -1247,6 +1107,68 @@ def is_official_hub_payload_verified(skill: Any) -> bool:
# Public entry point
def _skill_cycles_gate(
ctx: Any,
skill: Any,
drive_root: pathlib.Path,
models: List[str],
review_rebuttal: str,
content_hash: str,
) -> tuple[Optional["SkillReviewOutcome"], str, str, str]:
"""Max Review Cycles on the skill gate (Q17/Q23), run BEFORE any paid
panel: a byte-identical snapshot with a recorded substantive verdict under
the same panel contract replays for FREE, and the shared knob bounds PAID
panel cycles per ceiling key (root task for task-driven groups; the manual
lane per content_hash). Returns ``(early_outcome_or_None,
contract_fingerprint, rebuttal_sha256, review_profile)`` — the resolved
profile is part of the panel-contract identity and is computed ONCE here
for the whole review."""
from ouroboros.skill_review_cycles import (
free_replay_outcome,
skill_review_contract_fingerprint,
skill_review_cycles_refusal,
)
from ouroboros.tools.commit_gate import compute_rebuttal_sha256
review_profile = _official_hub_review_profile(skill)
contract_fp = skill_review_contract_fingerprint(
models, required_items=_SKILL_REVIEW_ITEMS, review_profile=review_profile,
)
rebuttal_sha = compute_rebuttal_sha256(review_rebuttal)
group_id = str(getattr(ctx, "_skill_review_group_id", "") or "") or f"manual:{skill.name}"
replayed = free_replay_outcome(
skill, drive_root=drive_root, group_id=group_id, content_hash=content_hash,
contract_fingerprint=contract_fp, rebuttal_sha256=rebuttal_sha,
)
if replayed is not None:
return replayed, contract_fp, rebuttal_sha, review_profile
refusal = skill_review_cycles_refusal(
ctx, skill.name, drive_root=drive_root, group_id=group_id, models=models,
content_hash=content_hash, contract_fingerprint=contract_fp,
)
return refusal, contract_fp, rebuttal_sha, review_profile
def _stamp_paid_facts(
outcome: "SkillReviewOutcome",
contract_fp: str,
rebuttal_sha: str,
stamp: Any = None,
) -> "SkillReviewOutcome":
"""Max-Review-Cycles facts for a post-panel outcome: the contract and
rebuttal identities always ride it; ``paid`` (and the wave id) only when
the wave PHYSICALLY dispatched — ``stamp.fired`` mirrors the durable
write-ahead dispatch marker recorded before the first transport call
(plan-review precedent), so a crash cannot launder the spend and an
assembly-refused $0 wave never counts."""
outcome.paid = bool(stamp is not None and getattr(stamp, "fired", False))
outcome.review_contract_fingerprint = contract_fp
outcome.rebuttal_sha256 = rebuttal_sha
if outcome.paid:
outcome.wave_id = str(getattr(stamp, "wave_id", "") or "")
return outcome
def _skill_quorum_failure_outcome(
skill: Any,
*,
@ -1261,10 +1183,18 @@ def _skill_quorum_failure_outcome(
drive_root: pathlib.Path,
persist: bool,
lifecycle_owns_history: bool = False,
paid: bool = False,
review_contract_fingerprint: str = "",
rebuttal_sha256: str = "",
wave_id: str = "",
) -> SkillReviewOutcome:
"""PENDING outcome for a skill review that missed the adaptive reviewer quorum,
preserving the single-reviewer degraded marker on the outcome AND the durable
history (extracted to keep ``review_skill`` under the function-size gate)."""
history (extracted to keep ``review_skill`` under the function-size gate).
The caller passes the Max-Review-Cycles paid facts (F3): the internal
history append must carry them — a dispatched quorum failure spent
reviewer money, and an unpaid row here would let the direct
``review_skill(persist=True)`` path (marketplace install) launder it."""
outcome = SkillReviewOutcome(
skill_name=skill.name,
status=STATUS_PENDING,
@ -1289,6 +1219,10 @@ def _skill_quorum_failure_outcome(
findings=findings,
raw_actor_records=[record.to_dict() for record in parsed_review.actor_records],
single_reviewer_no_diversity=single_reviewer_no_diversity,
paid=paid,
review_contract_fingerprint=review_contract_fingerprint,
rebuttal_sha256=rebuttal_sha256,
wave_id=wave_id,
)
return outcome
@ -1417,6 +1351,12 @@ def review_skill(
skill_name=skill.name, status=STATUS_PENDING, content_hash=content_hash,
error=f"invalid reviewer-slot configuration blocks skill review: {slot_err}")
models = list(get_review_models())
early_outcome, contract_fp, rebuttal_sha, review_profile = _skill_cycles_gate(
ctx, skill, drive_root, models, review_rebuttal, content_hash,
)
if early_outcome is not None:
return early_outcome
if len(file_packs) > 1:
log.warning(
"Skill %s exceeds the reviewer token budget; reviewing in %d chunked passes.",
@ -1433,52 +1373,68 @@ def review_skill(
error=budget_block)
from ouroboros.skill_review_passes import run_skill_review_passes
prompt, advisory_evidence, result_json_text, infra_error = run_skill_review_passes(
ctx,
drive_root,
skill,
evidence={
"manifest_dump": manifest_dump,
"content_hash": content_hash,
"history": history,
"review_rebuttal": review_rebuttal,
"required_items": _SKILL_REVIEW_ITEMS,
},
file_packs=file_packs,
models=models,
build_prompt=_build_review_prompt_for_attempt,
run_review=_handle_multi_model_review,
# F3 write-ahead seam: the durable dispatch marker lands immediately
# before the panel's first transport call (assembly failures inside the
# pass runner stay $0); every post-panel outcome below derives its paid
# fact from whether the stamp actually fired.
stamp, _prior_stamp = _install_skill_dispatch_stamp(
ctx, drive_root, skill.name,
group_id=str(getattr(ctx, "_skill_review_group_id", "") or "") or f"manual:{skill.name}",
content_hash=content_hash, contract_fp=contract_fp, rebuttal_sha=rebuttal_sha,
)
def _paid_facts(outcome: SkillReviewOutcome) -> SkillReviewOutcome:
return _stamp_paid_facts(outcome, contract_fp, rebuttal_sha, stamp)
try:
prompt, advisory_evidence, result_json_text, infra_error = run_skill_review_passes(
ctx,
drive_root,
skill,
evidence={
"manifest_dump": manifest_dump,
"content_hash": content_hash,
"history": history,
"review_rebuttal": review_rebuttal,
"required_items": _SKILL_REVIEW_ITEMS,
},
file_packs=file_packs,
models=models,
build_prompt=_build_review_prompt_for_attempt,
run_review=_handle_multi_model_review,
)
finally:
ctx._review_paid_stamp = _prior_stamp
if infra_error:
log.warning("Skill review infrastructure failure for %s: %s", skill.name, infra_error)
return SkillReviewOutcome(
return _paid_facts(SkillReviewOutcome(
skill_name=skill.name,
status=STATUS_PENDING,
reviewer_models=models,
content_hash=content_hash,
error=f"infrastructure failure: {sanitize_tool_result_for_log(infra_error)}",
)
))
try:
result_json = json.loads(result_json_text)
except json.JSONDecodeError:
return SkillReviewOutcome(
return _paid_facts(SkillReviewOutcome(
skill_name=skill.name,
status=STATUS_PENDING,
reviewer_models=models,
content_hash=content_hash,
error="review returned non-JSON top-level response",
raw_result=_truncate_raw_result(result_json_text),
)
))
if "error" in result_json:
return SkillReviewOutcome(
return _paid_facts(SkillReviewOutcome(
skill_name=skill.name,
status=STATUS_PENDING,
reviewer_models=models,
content_hash=content_hash,
error=f"review service error: {result_json['error']}",
)
))
parsed_review = parse_model_review_results(result_json, required_items=_SKILL_REVIEW_ITEMS)
emit_review_model_error_events(ctx, parsed_review, source="skill_review", skill_name=skill.name)
@ -1491,7 +1447,7 @@ def review_skill(
# P3) — in the outcome + review history below, not just this log line.
log.warning("Skill review (trust gate) ran with a single reviewer (single_reviewer_no_diversity).")
if len(responded_models) < required_quorum:
return _skill_quorum_failure_outcome(
return _paid_facts(_skill_quorum_failure_outcome(
skill,
findings=findings,
models=models,
@ -1504,16 +1460,23 @@ def review_skill(
drive_root=drive_root,
persist=persist,
lifecycle_owns_history=bool(getattr(ctx, "_skill_review_lifecycle_guard", False)),
)
# F3(a): the internal history append must carry the paid facts —
# a dispatched quorum failure spent reviewer money.
paid=bool(getattr(stamp, "fired", False)),
review_contract_fingerprint=contract_fp,
rebuttal_sha256=rebuttal_sha,
wave_id=str(getattr(stamp, "wave_id", "") or "") if getattr(stamp, "fired", False) else "",
))
review_profile = _official_hub_review_profile(skill)
# review_profile was resolved ONCE in _skill_cycles_gate (it is part of
# the panel-contract fingerprint) and rides down to aggregation here.
status = _aggregate_status(
findings,
skill_type=skill.manifest.type,
is_module_widget=_is_module_widget_skill(skill),
review_profile=review_profile,
)
outcome = SkillReviewOutcome(
outcome = _paid_facts(SkillReviewOutcome(
skill_name=skill.name,
status=status,
findings=findings,
@ -1526,69 +1489,77 @@ def review_skill(
advisory_result=advisory_evidence,
convergence_hint=_convergence_hint(history, findings, current_status=status),
review_profile=review_profile,
)
))
if persist:
if getattr(ctx, "_skill_review_lifecycle_guard", False):
from ouroboros.skill_review_runner import _can_persist_review_outcome
return _persist_reviewed_outcome(
ctx, skill, drive_root, outcome,
history=history, review_rebuttal=review_rebuttal,
contract_fp=contract_fp, rebuttal_sha=rebuttal_sha,
)
return outcome
if not _can_persist_review_outcome(
drive_root,
skill.name,
content_hash,
expected_job_id=str(getattr(ctx, "_skill_review_lifecycle_job_id", "") or ""),
):
outcome.status = STATUS_PENDING
outcome.error = (
"review outcome was not persisted because the lifecycle job "
"is already terminal or no longer matches this content hash"
)
return outcome
save_review_state(
def _persist_reviewed_outcome(
ctx: Any,
skill: Any,
drive_root: pathlib.Path,
outcome: SkillReviewOutcome,
*,
history: List[Dict[str, Any]],
review_rebuttal: str,
contract_fp: str,
rebuttal_sha: str,
) -> SkillReviewOutcome:
"""Persist one reviewed verdict (state, history, rebuttal flips, auto-grant)
— extracted whole from ``review_skill`` at the function-size gate."""
content_hash = outcome.content_hash
if getattr(ctx, "_skill_review_lifecycle_guard", False):
from ouroboros.skill_review_runner import _can_persist_review_outcome
if not _can_persist_review_outcome(
drive_root,
skill.name,
SkillReviewState(
status=outcome.status,
content_hash=content_hash,
findings=findings,
reviewer_models=responded_models,
timestamp=utc_now_iso(),
prompt_chars=outcome.prompt_chars,
cost_usd=outcome.cost_usd,
raw_result=outcome.raw_result,
raw_actor_records=[record.to_dict() for record in parsed_review.actor_records],
advisory_result=dict(advisory_evidence or {}),
review_profile=review_profile,
),
)
if not getattr(ctx, "_skill_review_lifecycle_guard", False):
_append_skill_review_history(
drive_root, skill.name,
status=outcome.status, content_hash=content_hash, findings=findings,
single_reviewer_no_diversity=single_reviewer_no_diversity,
content_hash,
expected_job_id=str(getattr(ctx, "_skill_review_lifecycle_job_id", "") or ""),
):
outcome.status = STATUS_PENDING
outcome.error = (
"review outcome was not persisted because the lifecycle job "
"is already terminal or no longer matches this content hash"
)
_persist_rebuttal_flips(
return outcome
persisted_state = SkillReviewState(
status=outcome.status,
content_hash=content_hash,
findings=outcome.findings,
reviewer_models=outcome.reviewer_models,
timestamp=utc_now_iso(),
prompt_chars=outcome.prompt_chars,
cost_usd=outcome.cost_usd,
raw_result=outcome.raw_result,
raw_actor_records=list(outcome.raw_actor_records or []),
advisory_result=dict(outcome.advisory_result or {}),
review_profile=outcome.review_profile,
)
save_review_state(drive_root, skill.name, persisted_state)
if not getattr(ctx, "_skill_review_lifecycle_guard", False):
_append_skill_review_history(
drive_root, skill.name,
history=history, findings=findings,
review_rebuttal=review_rebuttal, content_hash=content_hash,
responded_models=list(responded_models),
status=outcome.status, content_hash=content_hash, findings=outcome.findings,
single_reviewer_no_diversity=outcome.single_reviewer_no_diversity,
paid=outcome.paid, review_contract_fingerprint=contract_fp,
rebuttal_sha256=rebuttal_sha, wave_id=outcome.wave_id,
)
skill.review = SkillReviewState(
status=outcome.status,
content_hash=content_hash,
findings=findings,
reviewer_models=responded_models,
timestamp=utc_now_iso(),
prompt_chars=outcome.prompt_chars,
cost_usd=outcome.cost_usd,
raw_result=outcome.raw_result,
raw_actor_records=[record.to_dict() for record in parsed_review.actor_records],
advisory_result=dict(advisory_evidence or {}),
review_profile=review_profile,
)
auto_grant = auto_grant_if_enabled(drive_root, skill)
_apply_auto_grant_outcome(outcome, skill, auto_grant)
_persist_rebuttal_flips(
drive_root, skill.name,
history=history, findings=outcome.findings,
review_rebuttal=review_rebuttal, content_hash=content_hash,
responded_models=list(outcome.reviewer_models),
)
skill.review = persisted_state
auto_grant = auto_grant_if_enabled(drive_root, skill)
_apply_auto_grant_outcome(outcome, skill, auto_grant)
return outcome

View file

@ -0,0 +1,649 @@
"""Max-Review-Cycles semantics for skill review, plus the rebuttal ledger.
Owner decisions Q12/Q16/Q17/Q23 aligned every review gate on ONE meaning of
``OUROBOROS_REVIEW_MAX_CYCLES``: it bounds PAID review cycles per task, and
identical material is never re-reviewed for pay. For skill review that means:
* **Paid cycle** = one physical reviewer-panel dispatch, counted as MONEY:
every dispatched wave counts whatever its terminal (a quorum-failed panel
still spent reviewer money); only undispatched attempts stay outside the
count. A chunked oversized skill (multiple packs) is ONE cycle per wave,
not per pack. The dispatch fact is recorded WRITE-AHEAD: a durable dispatch
marker (``skill_review_history``) lands immediately before the first
transport call, shared by the lifecycle runner and direct callers; terminal
history rows merge it, and an unmerged marker still counts (a crashed or
swallowed wave spent the money). Counts are DERIVED from the existing
append-only review history plus that marker (P7 — no counter file).
* **Ceiling key** (Q23): a task-driven review group (``task:<root>:<skill>``)
shares the root task's ceiling across every skill that task reviews (a
follow-up task is a fresh root); a manual group (``manual:<skill>``) has no
session boundary, so its ceiling is scoped to the CURRENT content_hash —
revised skill content always starts a fresh manual count.
* **Free replay** (Q17-A): an identical ``(group_id, content_hash,
roster/contract fingerprint)`` with a recorded SUBSTANTIVE terminal verdict
(clean/warnings/blockers — never pending/interrupted/timeout/failed/
cancelled, which are infra facts) and no NEW rebuttal replays the recorded
verdict at $0, quoting it. A rebuttal is identified by CONTENT: a hash new
to the snapshot's rows buys exactly one paid rerun.
* **Exhaustion** = free typed refusal + ``emit_review_cycles_exhausted``
(surface ``skill_review``) with honest LLM-first exits, never a silent grind.
This module also owns the accepted-rebuttal ledger (moved whole from
``skill_review.py`` at the module-size gate; ``skill_review`` re-exports the
historical names) — rebuttals are the currency the cycle semantics meter.
"""
from __future__ import annotations
import hashlib
import json
import logging
import pathlib
from typing import Any, Dict, List, Optional
from ouroboros.review_cycles import emit_review_cycles_exhausted, review_max_cycles
from ouroboros.skill_review_history import load_history
from ouroboros.skill_review_status import (
STATUS_BLOCKERS,
STATUS_CLEAN,
STATUS_PENDING,
STATUS_WARNINGS,
normalize_skill_review_status,
)
from ouroboros.utils import atomic_write_json, iter_jsonl_objects, utc_now_iso
log = logging.getLogger(__name__)
# Resolve repo root from this file for source and packaged builds.
_REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
# The ONLY statuses a free replay may quote: real reviewer verdicts. Everything
# else on a terminal row (pending/interrupted/timeout/failed/cancelled) is an
# infrastructure fact. The exact rule (F4): refusal/replay ELIGIBILITY
# (substantive verdicts only — infra terminals never replay, never lapse a
# streak, and never "spend" the rebuttal they carried) is distinct from MONEY
# accounting (every dispatched wave counts toward the ceiling via its ``paid``
# fact, whatever its terminal status). A dispatched infra terminal therefore
# consumes money but leaves the rebuttal fresh for the paid rerun it is owed.
SUBSTANTIVE_SKILL_REVIEW_STATUSES = frozenset(
{STATUS_CLEAN, STATUS_WARNINGS, STATUS_BLOCKERS}
)
def _skill_prompt_contract_hash() -> str:
"""Identity of the SHIPPED skill-review prompt/aggregation contract: the
prompt builder's source text (the template literals ARE the contract) plus
the aggregation vocabulary skill_review_status pins. Any shipped change to
either lapses free-replay authority — conservative in the paid direction.
Fail-open "": an unreadable contract never matches, so nothing replays."""
try:
import inspect
from ouroboros import skill_review as _skill_review
from ouroboros import skill_review_status as _status
parts = [
inspect.getsource(_skill_review._build_review_prompt),
json.dumps(sorted(_status.HARD_CRITICAL_ITEMS)),
json.dumps(sorted(_status.SEVERITY_DRIVEN_ITEMS)),
str(int(getattr(_status, "WARNINGS_CONVERGENCE_ROUNDS", 0) or 0)),
]
return hashlib.sha256("\n".join(parts).encode("utf-8")).hexdigest()
except Exception:
log.debug("skill prompt-contract hash unavailable (fail-open)", exc_info=True)
return ""
def skill_review_contract_fingerprint(
models: List[str], *, required_items: Any = (), review_profile: str = ""
) -> str:
"""Identity of the skill-review panel contract (Q17/Q22 pattern:
plan_review's ``reviewer_config_fingerprint``): the configured roster, the
required-item vocabulary, the shipped prompt/aggregation contract text
(skill-4 parity with the commit gate) and the skill's RESOLVED review
profile (official_hub aggregates blockers differently — a profile change
must lapse replay). A changed fingerprint lapses free-replay authority —
a fresh paid panel is due."""
prompt_contract = _skill_prompt_contract_hash()
if not prompt_contract:
return "" # unknown contract never matches (fail-open toward paying)
payload = json.dumps(
{
"models": [str(model) for model in (models or [])],
"required_items": [str(item) for item in (required_items or ())],
"prompt_contract": prompt_contract,
"review_profile": str(review_profile or ""),
},
sort_keys=True,
ensure_ascii=False,
)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
def _group_root_task_id(group_id: str) -> str:
parts = str(group_id or "").split(":", 2)
return parts[1] if len(parts) == 3 and parts[0] == "task" and parts[1] else ""
def _paid_row(row: Any) -> bool:
# The ceiling counts MONEY (machine-5): every row whose panel physically
# dispatched counts, whatever its terminal (a quorum-failed wave still
# spent reviewer money). Rows without the paid fact — free replays,
# pre-dispatch refusals, pre-upgrade history, interrupted jobs whose
# dispatch is unknown — never count (fail-open).
return isinstance(row, dict) and bool(row.get("paid"))
def _unmerged_marker_paid(
drive_root: pathlib.Path,
skill_name: str,
rows: List[Dict[str, Any]],
*,
group_id: str = "",
content_hash: str = "",
root: str = "",
) -> int:
"""1 when this skill's write-ahead dispatch marker records a paid wave on
the given ceiling key whose terminal row has NOT landed yet (crashed wave,
or a direct-call infra outcome without a history row) — the money was
spent before the first transport call and must count. 0 otherwise: a
landed terminal row already carries the merged facts (the marker is
cleared on merge; the wave-key check below also guards a failed clear)."""
from ouroboros.skill_review_history import load_dispatch_marker
marker = load_dispatch_marker(pathlib.Path(drive_root), skill_name)
wave = str(marker.get("wave_id") or "")
if not wave or not marker.get("paid"):
return 0
if root:
if str(marker.get("root_task_id") or "") != root:
return 0
elif (
str(marker.get("group_id") or "") != str(group_id or "")
or str(marker.get("content_hash") or "") != str(content_hash or "")
):
return 0
for row in rows:
if str(row.get("wave_id") or row.get("job_id") or "") == wave:
return 0
return 1
def count_paid_skill_review_cycles(
drive_root: pathlib.Path, skill_name: str, group_id: str, *, content_hash: str = ""
) -> int:
"""Paid panel cycles already spent on the ceiling key, derived from the
append-only review history (P7) plus this skill's unmerged write-ahead
dispatch marker (a dispatched wave whose terminal row never landed still
spent the money). Task-driven groups count across EVERY skill the root
task reviewed (terminal rows carry ``root_task_id``) — the root task is a
naturally bounded lifetime. The manual lane has no session boundary, so
its ceiling is scoped to the CURRENT content_hash lineage
(machine-1/skill-1): paid rows for the SAME snapshot only — a revised
skill (new content_hash) always starts a fresh count, mirroring the
commit gate's changed-diff reset and Q23-A's "manual = its own task".
Marketplace-install rows normalize into the same manual lane and are
scoped identically."""
root = _group_root_task_id(group_id)
if not root:
rows = load_history(drive_root, skill_name, limit=0)
paid = sum(
1
for row in rows
if str(row.get("group_id") or "") == str(group_id or "")
and _paid_row(row)
and str(row.get("content_hash") or "") == str(content_hash or "")
)
return paid + _unmerged_marker_paid(
drive_root, skill_name, rows,
group_id=group_id, content_hash=content_hash,
)
count = 0
skills_root = pathlib.Path(drive_root) / "state" / "skills"
try:
skill_dirs = sorted(path for path in skills_root.iterdir() if path.is_dir())
except OSError:
return 0
for skill_dir in skill_dirs:
rows: List[Dict[str, Any]] = []
try:
rows = [
row
for row in iter_jsonl_objects(skill_dir / "review_history.jsonl")
if isinstance(row, dict)
]
except OSError:
rows = []
count += sum(
1
for row in rows
if str(row.get("root_task_id") or "") == root and _paid_row(row)
)
count += _unmerged_marker_paid(drive_root, skill_dir.name, rows, root=root)
return count
def find_free_replay_row(
drive_root: pathlib.Path,
skill_name: str,
*,
group_id: str,
content_hash: str,
contract_fingerprint: str,
rebuttal_sha256: str = "",
) -> Optional[Dict[str, Any]]:
"""The recorded verdict a free replay may quote, or ``None`` for a paid run.
Replays require the identical triple ``(group_id, content_hash,
roster/contract fingerprint)`` and a SUBSTANTIVE terminal verdict; rows
without a recorded fingerprint (pre-upgrade) never replay, and a rebuttal
hash new to this snapshot's rows buys exactly one paid rerun (a repeated
hash replays free, quoting the previous outcome)."""
if not content_hash or not contract_fingerprint:
return None
rows = [
row
for row in load_history(drive_root, skill_name, limit=0, group_id=group_id)
if str(row.get("content_hash") or "") == content_hash
]
if not rows:
return None
if rebuttal_sha256:
# A rebuttal is "spent" only when a SUBSTANTIVE verdict answered it
# (machine-6/skill-3): one recorded on an infra terminal (quorum
# failure, timeout, interrupted) bought a wave that never delivered a
# verdict, so it stays fresh and buys the paid rerun it is owed.
# Spent-hash memory is scoped to the CURRENT contract fingerprint
# (F4/Q22 parity with replay): a contract lapse clears the streak, so
# a rebuttal answered under a retired roster/prompt contract is fresh
# again for the contract that has never adjudicated it.
seen_rebuttals = {
str(row.get("rebuttal_sha256") or "")
for row in rows
if row.get("rebuttal_sha256")
and str(row.get("review_contract_fingerprint") or "") == contract_fingerprint
and normalize_skill_review_status(str(row.get("status") or ""))
in SUBSTANTIVE_SKILL_REVIEW_STATUSES
}
if rebuttal_sha256 not in seen_rebuttals:
return None # a NEW rebuttal = one paid rerun
for row in reversed(rows):
status = normalize_skill_review_status(str(row.get("status") or ""))
if status not in SUBSTANTIVE_SKILL_REVIEW_STATUSES:
continue # infra facts (pending/interrupted/timeout/…) neither replay nor lapse
if str(row.get("review_contract_fingerprint") or "") != contract_fingerprint:
return None # roster/contract changed (or legacy row): a paid review is due
return row
return None
def _quote_replay_row(row: Dict[str, Any]) -> str:
fails = [f for f in (row.get("fail_findings") or []) if isinstance(f, dict)]
if not fails:
return ""
lines = ["", "Recorded FAIL findings:"]
for finding in fails[:5]:
lines.append(
f" - [{str(finding.get('severity') or '?').upper()}] "
f"{finding.get('item') or '?'}: {finding.get('reason_excerpt') or ''}"
)
if len(fails) > 5:
lines.append(f" … and {len(fails) - 5} more in the skill's review history.")
return "\n".join(lines)
def free_replay_outcome(
skill: Any,
*,
drive_root: pathlib.Path,
group_id: str,
content_hash: str,
contract_fingerprint: str,
rebuttal_sha256: str = "",
) -> Optional[Any]:
"""A $0 ``SkillReviewOutcome`` replaying the recorded verdict (Q17-A), or
``None`` when a paid panel is due.
A replay is allowed ONLY when the mutable persisted review state
(review.json → ``skill.review``) already covers this exact
(content_hash, status) — the $0 path must be effect-equivalent to the
paid path, and the paid persist pipeline (state write, grants) already
ran when that state landed. When the persisted state has diverged (e.g.
content was edited away and reverted), the replay falls through to a
PAID rerun that re-persists properly — never a live-lock on a stale
verdict (skill-2)."""
row = find_free_replay_row(
drive_root,
str(getattr(skill, "name", "") or ""),
group_id=group_id,
content_hash=content_hash,
contract_fingerprint=contract_fingerprint,
rebuttal_sha256=rebuttal_sha256,
)
if row is None:
return None
from ouroboros.skill_review import SkillReviewOutcome
status = normalize_skill_review_status(str(row.get("status") or ""))
review_state = getattr(skill, "review", None)
if (
review_state is None
or str(getattr(review_state, "content_hash", "") or "") != content_hash
or normalize_skill_review_status(str(getattr(review_state, "status", "") or ""))
!= status
):
return None # persisted state diverged from the history row: pay and re-persist
findings: List[Dict[str, Any]] = list(getattr(review_state, "findings", []) or [])
reviewer_models: List[str] = list(getattr(review_state, "reviewer_models", []) or [])
return SkillReviewOutcome(
skill_name=str(getattr(skill, "name", "") or ""),
status=status,
findings=findings,
reviewer_models=reviewer_models,
content_hash=content_hash,
review_contract_fingerprint=contract_fingerprint,
rebuttal_sha256=rebuttal_sha256,
replayed_from_ts=str(row.get("ts") or ""),
single_reviewer_no_diversity=bool(row.get("single_reviewer_no_diversity")),
convergence_hint=(
"FREE REPLAY (Max Review Cycles): this exact skill snapshot "
f"(content_hash {content_hash[:12]}) already has a recorded "
f"'{status}' verdict from {row.get('ts') or 'an earlier run'} under the "
"same reviewer roster — no paid panel was dispatched and no cycle was "
"spent. Change the skill content, or supply a NEW review_rebuttal to buy "
"exactly one paid rerun." + _quote_replay_row(row)
),
)
def skill_review_cycles_refusal(
ctx: Any,
skill_name: str,
*,
drive_root: pathlib.Path,
group_id: str,
models: List[str],
content_hash: str,
contract_fingerprint: str,
) -> Optional[Any]:
"""Free typed refusal when the ceiling key already spent
``review_max_cycles()`` paid panel cycles; ``None`` allows the dispatch.
Fail-open on ledger errors — a cost guard, not a safety gate. DISCLOSED
RESIDUAL (skill-5): read-at-gate-time with no reservation — the paid fact
lands write-ahead at first physical dispatch (the dispatch marker), which
narrows but does not close the window: concurrent dispatches sharing one
ceiling key can still each read ``paid < cap`` and overshoot by the
concurrency width."""
cap = review_max_cycles()
if cap is None:
return None
try:
paid = count_paid_skill_review_cycles(
drive_root, skill_name, group_id, content_hash=content_hash,
)
except Exception:
log.debug("paid skill-review cycle count failed (fail-open)", exc_info=True)
return None
if paid < cap:
return None
root = _group_root_task_id(group_id)
lane = (
f"root task {root}"
if root
else f"manual lane {group_id} for snapshot {str(content_hash or '')[:12]}"
)
fresh_exit = (
"leave the review to a fresh task with its own ceiling"
if root
else "revise the skill content — a new snapshot starts a fresh manual count"
)
message = (
f"⚠️ REVIEW_CYCLES_EXHAUSTED: {lane} already spent {paid} of {cap} paid "
"skill-review panel cycle(s) (OUROBOROS_REVIEW_MAX_CYCLES). Refusing to buy "
"another panel; the skill stays honestly pending. Decide the honest exit "
"yourself: finalize and disclose the unreviewed skill, ask the owner to raise "
f"Max Review Cycles (3/5/unlimited are one settings change away), or {fresh_exit}. "
"A rebuttal cannot buy past the ceiling — rebuttal cycles count toward it."
)
try:
from ouroboros.config import get_review_enforcement
enforcement = str(get_review_enforcement() or "")
except Exception:
enforcement = ""
emit_review_cycles_exhausted(
getattr(ctx, "event_queue", None),
drive_root,
surface="skill_review",
task_id=str(getattr(ctx, "task_id", "") or ""),
cycles_paid=int(paid),
cap=int(cap),
enforcement=enforcement,
group_id=str(group_id or ""),
skill=str(skill_name or ""),
root_task_id=root,
)
from ouroboros.skill_review import SkillReviewOutcome
return SkillReviewOutcome(
skill_name=str(skill_name or ""),
status=STATUS_PENDING,
reviewer_models=list(models or []),
content_hash=str(content_hash or ""),
review_contract_fingerprint=str(contract_fingerprint or ""),
error=message,
)
def install_skill_dispatch_stamp(
ctx: Any,
drive_root: pathlib.Path,
skill_name: str,
*,
group_id: str,
content_hash: str,
contract_fp: str,
rebuttal_sha: str,
) -> tuple[Any, Any]:
"""Install the write-ahead paid stamp for ONE skill-review wave (F3, the
same seam as the commit gate's F2 stamp): the shared reviewer transport
entry invokes it via ``ctx._review_paid_stamp`` immediately before the
first physical panel call, durably writing the ONE dispatch marker shared
by lifecycle and direct callers (the lifecycle job id names the wave when
present, so a timeout terminal can merge the marker back). Returns
``(stamp, previous_attr_value)`` — the caller restores the previous value
after the wave."""
import uuid
from ouroboros.review_dispatch import ReviewPaidStamp
from ouroboros.skill_review_history import write_dispatch_marker
wave_id = (
str(getattr(ctx, "_skill_review_lifecycle_job_id", "") or "") or uuid.uuid4().hex
)
def _write() -> None:
write_dispatch_marker(
drive_root,
skill_name,
wave_id=wave_id,
group_id=group_id,
content_hash=content_hash,
root_task_id=_group_root_task_id(group_id),
review_contract_fingerprint=contract_fp,
rebuttal_sha256=rebuttal_sha,
)
stamp = ReviewPaidStamp(_write)
stamp.wave_id = wave_id
previous = getattr(ctx, "_review_paid_stamp", None)
ctx._review_paid_stamp = stamp
return stamp, previous
def review_wave_budget_block(
ctx: Any,
skill_name: str,
file_packs: List[str],
models: List[str],
) -> Optional[str]:
"""Return a human-readable refusal when the review wave cannot fit the
remaining root budget, else None. Read-only; emits one typed event."""
from ouroboros.tools.review_helpers import review_wave_budget_gate
# Estimate the WHOLE wave: a chunked oversized skill runs one full
# reviewer pass PER pack (run_skill_review_passes), and every pass re-sends
# the stable governance/checklist/host-contract files the prompt builder
# inlines — so both the payload chars and the governance chars multiply by
# the pack count. A single-pack estimate would under-admit exactly the
# multi-chunk waves most likely to die mid-review.
governance_chars = 0
for rel in (
"docs/ARCHITECTURE.md", "docs/DEVELOPMENT.md", "BIBLE.md",
"docs/CHECKLISTS.md", "docs/CREATING_SKILLS.md",
):
try:
governance_chars += int((_REPO_ROOT / rel).stat().st_size)
except OSError:
pass
packs = max(1, len(file_packs))
total_chars = sum(len(pack) + governance_chars for pack in file_packs)
# One admission slot per PHYSICAL reviewer call (models x packs), each
# sized at the average per-pack prompt: the input estimate sums to the
# exact wave total while the per-call output reservation also multiplies
# by the pack count (a models-only wave under-reserved chunked output).
admission = review_wave_budget_gate(
ctx, surface="skill_review", models=list(models) * packs,
prompt_chars=total_chars // packs,
extra={"skill_name": skill_name, "packs": packs},
)
if admission is None:
return None
return (
"review wave declined before dispatch: estimated reviewer-wave cost "
f"~${admission.get('estimated_wave_usd')} exceeds the remaining root budget "
f"${admission.get('remaining_usd')} (limit ${admission.get('limit_usd')}). "
"No reviewer was called; the skill stays pending. Raise the per-task "
"budget or re-run the review in a fresh task."
)
# --- Accepted-rebuttal ledger (moved whole from skill_review.py) -------------
def accepted_rebuttals_path(drive_root: pathlib.Path, skill_name: str) -> pathlib.Path:
"""Path to persisted accepted rebuttals for one skill."""
return drive_root / "state" / "skills" / skill_name / "accepted_rebuttals.json"
def load_accepted_rebuttals(drive_root: pathlib.Path, skill_name: str) -> List[Dict[str, Any]]:
"""Return persisted accepted rebuttals (empty list when none / unreadable)."""
path = accepted_rebuttals_path(drive_root, skill_name)
try:
raw = path.read_text(encoding="utf-8")
except OSError:
return []
try:
data = json.loads(raw)
except json.JSONDecodeError:
return []
if not isinstance(data, dict):
return []
items = data.get("items")
if not isinstance(items, list):
return []
out: List[Dict[str, Any]] = []
for entry in items:
if isinstance(entry, dict):
out.append(entry)
return out
def persist_rebuttal_flips(
drive_root: pathlib.Path,
skill_name: str,
*,
history: List[Dict[str, Any]],
findings: List[Dict[str, Any]],
review_rebuttal: str,
content_hash: str,
responded_models: List[str],
) -> None:
"""Record rebuttals for items that flipped FAIL -> PASS on this attempt."""
if not review_rebuttal or not history:
return
last_fail_items = fail_items_from_history_entry(history[-1])
current_fail_items = {
str(f.get("item") or "")
for f in findings
if isinstance(f, dict)
and str(f.get("verdict") or "").upper() == "FAIL"
and str(f.get("item") or "")
}
for item in sorted(last_fail_items - current_fail_items):
record_accepted_rebuttal(
drive_root,
skill_name,
item=item,
rebuttal_text=review_rebuttal,
content_hash=content_hash,
passed_models=list(responded_models),
)
def fail_items_from_history_entry(entry: Dict[str, Any]) -> set[str]:
"""Return FAIL item names from both v5.18 and legacy history entries."""
out = {
str(f.get("item") or "")
for f in (entry.get("fail_findings") or [])
if isinstance(f, dict) and str(f.get("item") or "")
}
if out:
return out
for signature in entry.get("failure_signature") or []:
parts = str(signature or "").split(":")
if len(parts) >= 2 and parts[1].upper() == "FAIL" and parts[0]:
out.add(parts[0])
return out
def record_accepted_rebuttal(
drive_root: pathlib.Path,
skill_name: str,
*,
item: str,
rebuttal_text: str,
content_hash: str,
passed_models: Optional[List[str]] = None,
) -> None:
"""Persist (or refresh) an accepted rebuttal for ``item``."""
path = accepted_rebuttals_path(drive_root, skill_name)
existing = load_accepted_rebuttals(drive_root, skill_name)
target: Optional[Dict[str, Any]] = None
for entry in existing:
if str(entry.get("item") or "") == item:
target = entry
break
if target is None:
target = {
"item": item,
"rebuttal_text": rebuttal_text,
"accepted_at": utc_now_iso(),
"content_hash_seen": [content_hash] if content_hash else [],
"models_that_passed_after": list(passed_models or []),
}
existing.append(target)
else:
target["rebuttal_text"] = rebuttal_text
target["accepted_at"] = utc_now_iso()
seen = list(target.get("content_hash_seen") or [])
if content_hash and content_hash not in seen:
seen.append(content_hash)
target["content_hash_seen"] = seen
if passed_models:
target["models_that_passed_after"] = list(passed_models)
try:
path.parent.mkdir(parents=True, exist_ok=True)
atomic_write_json(path, {"items": existing}, trailing_newline=True)
except OSError:
log.debug("accepted rebuttal write failed", exc_info=True)

View file

@ -26,6 +26,142 @@ def review_history_path(drive_root: pathlib.Path, skill_name: str) -> pathlib.Pa
return drive_root / "state" / "skills" / skill_name / "review_history.jsonl"
def dispatch_marker_path(drive_root: pathlib.Path, skill_name: str) -> pathlib.Path:
return drive_root / "state" / "skills" / skill_name / "review_dispatch.json"
def _emit_history_event(drive_root: pathlib.Path, event: Dict[str, Any]) -> None:
"""Loud typed event on the existing events rail (never raises)."""
try:
append_jsonl(
pathlib.Path(drive_root) / "logs" / "events.jsonl",
{"ts": utc_now_iso(), **event},
)
except Exception:
log.debug("skill review history event emission failed", exc_info=True)
def load_dispatch_marker(drive_root: pathlib.Path, skill_name: str) -> Dict[str, Any]:
"""The current write-ahead dispatch marker, ``{}`` when none/unreadable."""
try:
data = json.loads(dispatch_marker_path(drive_root, skill_name).read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
return data if isinstance(data, dict) else {}
def clear_dispatch_marker(drive_root: pathlib.Path, skill_name: str, *, wave_id: str) -> None:
"""Remove the marker once its wave's terminal row landed (merge complete)."""
if not wave_id:
return
marker = load_dispatch_marker(drive_root, skill_name)
if str(marker.get("wave_id") or "") != str(wave_id):
return
try:
dispatch_marker_path(drive_root, skill_name).unlink()
except OSError:
log.debug("skill review dispatch marker unlink failed", exc_info=True)
def write_dispatch_marker(
drive_root: pathlib.Path,
skill_name: str,
*,
wave_id: str,
group_id: str,
content_hash: str,
root_task_id: str = "",
review_contract_fingerprint: str = "",
rebuttal_sha256: str = "",
) -> None:
"""Durable WRITE-AHEAD dispatch marker (Q17; same principle as the commit
gate's paid stamp): written immediately before the first physical reviewer
transport call of ONE skill-review wave, shared by the lifecycle runner and
direct ``review_skill`` callers. Terminal history rows merge it (facts ride
the row, marker cleared); a wave that never lands a terminal row keeps the
marker, so the derived paid-cycle count never forgets spent money. An
unmerged predecessor from a crashed wave is first flushed into the history
as an infra terminal carrying its paid facts. A failing write surfaces as a
loud typed event — this is fail-open cost accounting, not a safety gate."""
from ouroboros.utils import atomic_write_json
predecessor = load_dispatch_marker(drive_root, skill_name)
if predecessor.get("wave_id") and str(predecessor["wave_id"]) != str(wave_id):
_flush_orphan_dispatch_marker(drive_root, skill_name, predecessor)
payload = {
"ts": utc_now_iso(),
"wave_id": str(wave_id),
"group_id": str(group_id or ""),
"content_hash": str(content_hash or ""),
"root_task_id": str(root_task_id or ""),
"paid": True,
"review_contract_fingerprint": str(review_contract_fingerprint or ""),
"rebuttal_sha256": str(rebuttal_sha256 or ""),
}
try:
path = dispatch_marker_path(drive_root, skill_name)
path.parent.mkdir(parents=True, exist_ok=True)
# Same redaction invariant as the history rows: marker fields are all
# hashes/ids today, so this is a no-op class guard, not a data change.
atomic_write_json(path, _redact_history_payload(payload), trailing_newline=True)
except Exception:
log.warning("skill review dispatch marker write failed for %s", skill_name, exc_info=True)
_emit_history_event(drive_root, {
"type": "skill_review_history_append_failed",
"skill": skill_name, "wave_id": str(wave_id),
"reason": "dispatch marker write failed",
})
def _flush_orphan_dispatch_marker(
drive_root: pathlib.Path, skill_name: str, marker: Dict[str, Any]
) -> None:
"""A previous wave dispatched but never finalized (crash, or a direct-call
infra outcome that returns without a history row): append its paid facts
as an infra terminal row — idempotently keyed by the wave id — so the
ledger catches up instead of forgetting the spend."""
append_history_once(drive_root, skill_name, {
"ts": utc_now_iso(),
"status": "interrupted",
"terminal_reason": "dispatched_wave_never_finalized",
"content_hash": str(marker.get("content_hash") or ""),
"group_id": str(marker.get("group_id") or ""),
"root_task_id": str(marker.get("root_task_id") or ""),
"paid": True,
"review_contract_fingerprint": str(marker.get("review_contract_fingerprint") or ""),
"rebuttal_sha256": str(marker.get("rebuttal_sha256") or ""),
"job_id": str(marker.get("wave_id") or ""),
"wave_id": str(marker.get("wave_id") or ""),
"failure_signature": [],
"fail_findings": [],
})
def _merge_dispatch_marker_facts(
drive_root: pathlib.Path, skill_name: str, payload: Dict[str, Any]
) -> Dict[str, Any]:
"""Merge the write-ahead marker's paid facts into ITS wave's terminal row.
The producer can lose the facts legitimately — a lifecycle timeout
finalizes with no result object — but the marker recorded the dispatch
before the first transport call, so the terminal row still carries
``paid``/contract/rebuttal. Rows of other waves pass through untouched."""
marker = load_dispatch_marker(drive_root, skill_name)
wave = str(marker.get("wave_id") or "")
row_wave = str(payload.get("wave_id") or payload.get("job_id") or "")
if not wave or wave != row_wave:
return payload
merged = dict(payload)
if marker.get("paid") and not merged.get("paid"):
merged["paid"] = True
for key in ("review_contract_fingerprint", "rebuttal_sha256",
"group_id", "content_hash", "root_task_id"):
if marker.get(key) and not merged.get(key):
merged[key] = marker[key]
merged.setdefault("wave_id", wave)
return merged
def finding_signature(findings: List[Dict[str, Any]]) -> List[str]:
return sorted({
f"{finding.get('item')}:{finding.get('verdict')}:{finding.get('severity')}"
@ -152,6 +288,11 @@ def append_history(
findings: List[Dict[str, Any]],
raw_actor_records: Optional[List[Dict[str, Any]]] = None,
single_reviewer_no_diversity: bool = False,
paid: bool = False,
review_contract_fingerprint: str = "",
rebuttal_sha256: str = "",
replayed_from_ts: str = "",
wave_id: str = "",
) -> None:
try:
payload: Dict[str, Any] = {
@ -165,12 +306,39 @@ def append_history(
payload["single_reviewer_no_diversity"] = True
if raw_actor_records:
payload["raw_actor_records"] = list(raw_actor_records)
append_jsonl(
# Max-Review-Cycles facts (Q17/Q23): the paid-dispatch fact and the
# panel contract identity ride the history row — counts and free-replay
# decisions are DERIVED from this ledger (P7, no counter file).
if paid:
payload["paid"] = True
if review_contract_fingerprint:
payload["review_contract_fingerprint"] = str(review_contract_fingerprint)
if rebuttal_sha256:
payload["rebuttal_sha256"] = str(rebuttal_sha256)
if replayed_from_ts:
payload["replayed_from_ts"] = str(replayed_from_ts)
if wave_id:
payload["wave_id"] = str(wave_id)
payload = _merge_dispatch_marker_facts(drive_root, skill_name, payload)
if not append_jsonl(
review_history_path(drive_root, skill_name),
_redact_history_payload(payload),
)
):
raise OSError("append_jsonl reported failure")
if wave_id:
clear_dispatch_marker(drive_root, skill_name, wave_id=wave_id)
except Exception:
log.debug("skill review history append failed", exc_info=True)
# LOUD failure (F3): a lost terminal row silently un-counts spent
# money and hides a verdict — log at warning and emit the typed event.
log.warning("skill review history append failed for %s", skill_name, exc_info=True)
_emit_history_event(drive_root, {
"type": "skill_review_history_append_failed",
"skill": skill_name,
"status": str(status or ""),
"content_hash": str(content_hash or ""),
"wave_id": str(wave_id or ""),
"reason": "direct history append failed",
})
def append_history_once(
@ -191,7 +359,11 @@ def append_history_once(
try:
try:
if any(str(row.get("job_id") or "") == job_id for row in iter_jsonl_objects(path)):
# Already landed (idempotent retry): finish the merge by
# clearing this wave's dispatch marker if it is still present.
clear_dispatch_marker(drive_root, skill_name, wave_id=job_id)
return True
payload = _merge_dispatch_marker_facts(drive_root, skill_name, payload)
safe_payload = _redact_history_payload(payload)
data = (json.dumps(safe_payload, ensure_ascii=False) + "\n").encode("utf-8")
fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644)
@ -202,6 +374,10 @@ def append_history_once(
os.fsync(fd)
finally:
os.close(fd)
clear_dispatch_marker(
drive_root, skill_name,
wave_id=str(payload.get("wave_id") or job_id),
)
return True
except OSError:
log.warning("skill review terminal history append failed for %s", skill_name, exc_info=True)

View file

@ -150,7 +150,17 @@ def _review_provenance(ctx: Any, source: str, skill_name: str) -> Dict[str, Any]
group_id = f"manual:{skill_name}"
root_task_id = ""
else:
root_task_id = origin_root_task_id or origin_task_id
# Ceiling/group root = the CURRENT task tree's root. The follow-up
# chain marker origin_root_task_id is deliberately NOT honored here
# (adversarial wave, machine-3): a scheduled follow-up is a fresh
# root with a fresh review-cycle ceiling — the "fresh task" exit the
# refusal advertises must be real. origin_* stay recorded below as
# provenance facts.
root_task_id = str(
metadata.get("root_task_id")
or getattr(ctx, "root_task_id", "")
or task_id
)
group_id = f"task:{root_task_id}:{skill_name}"
try:
chat_id = int(getattr(ctx, "current_chat_id", 0) or 0)
@ -208,6 +218,18 @@ def _terminal_history_payload(
payload["raw_actor_records"] = raw_actor_records
if bool(getattr(result, "single_reviewer_no_diversity", False)):
payload["single_reviewer_no_diversity"] = True
# Max-Review-Cycles facts (Q17/Q23) ride the terminal row: paid panel
# dispatch (one chunked wave = ONE cycle), its wave id, the panel contract
# identity, the rebuttal content hash, and — for a $0 replay — the quoted
# verdict ts. A terminal with NO result (lifecycle timeout) still gets the
# facts: append_history_once merges the write-ahead dispatch marker by
# wave/job id (F3), so the money spent before the timeout stays counted.
if bool(getattr(result, "paid", False)):
payload["paid"] = True
for fact_key in ("wave_id", "review_contract_fingerprint", "rebuttal_sha256", "replayed_from_ts"):
value = str(getattr(result, fact_key, "") or "")
if value:
payload[fact_key] = value
return payload
@ -221,7 +243,7 @@ def _append_terminal_history(
result: Any = None,
ts: str,
) -> bool:
return skill_review_history.append_history_once(
appended = skill_review_history.append_history_once(
drive_root,
skill_name,
_terminal_history_payload(
@ -232,6 +254,24 @@ def _append_terminal_history(
ts=ts,
),
)
if not appended:
# LOUD failure (F3): a silently lost terminal row un-counts spent
# review money and hides the verdict from the derived ledger. The
# unmerged dispatch marker still preserves the paid fact.
log.warning("skill review terminal history row did not land for %s", skill_name)
append_jsonl(
_events_path(drive_root),
{
"ts": ts,
"type": "skill_review_history_append_failed",
"skill": skill_name,
"job_id": str(job_data.get("job_id") or ""),
"status": status,
"terminal_reason": terminal_reason,
"reason": "terminal history append failed",
},
)
return appended
def _append_review_chat_summary(

View file

@ -1224,20 +1224,39 @@ def _audit_bypass(ctx: ToolContext, snapshot_hash: str, commit_message: str,
def _identical_diff_cap_note() -> str:
"""Schema-build-time NOTE about the identical-diff attempt cap, derived from the
shared OUROBOROS_REVIEW_MAX_CYCLES (never a hardcoded number)."""
"""Schema-build-time NOTE about Max-Review-Cycles semantics on the commit
gate, derived from the shared OUROBOROS_REVIEW_MAX_CYCLES (never a
hardcoded number). Identical bytes are never re-reviewed for pay: from the
FIRST review-verdict block, resubmitting the byte-identical staged diff
without a NEW rebuttal never buys a new review (identical_diff_refused);
the knob itself counts PAID triad+scope cycles per task. Whether either
state blocks the commit follows enforcement (the honest caveat below)."""
from ouroboros.review_cycles import review_max_cycles
cap = review_max_cycles()
base = (
"NOTE: identical bytes are never re-reviewed for pay — after ANY review-verdict "
"block, a byte-identical resubmission to commit_reviewed buys no new review "
"(identical_diff_refused, quoting the recorded verdict) until the diff changes "
"or a NEW review_rebuttal is supplied (a rebuttal new to the streak buys exactly "
"one paid re-review; a repeated one buys none)."
)
caveat = (
" In both states, under blocking enforcement the commit is refused for free; "
"under advisory the commit proceeds with a loud durable disclosure and no new "
"review spend."
)
if cap is None:
return (
"NOTE: no identical-diff cap is configured (OUROBOROS_REVIEW_MAX_CYCLES=unlimited): "
"commit_reviewed never refuses a resubmission on cap grounds."
f"{base} OUROBOROS_REVIEW_MAX_CYCLES=unlimited: no per-root-task ceiling on "
f"paid triad+scope cycles is configured.{caveat}"
)
return (
f"NOTE: after {cap} genuine review-verdict block(s) of a byte-identical staged diff "
"(the shared OUROBOROS_REVIEW_MAX_CYCLES cap), commit_reviewed refuses further "
"attempts (attempt_cap_reached) until the diff changes or a review_rebuttal is provided."
f"{base} The shared OUROBOROS_REVIEW_MAX_CYCLES cap bounds PAID triad+scope "
f"cycles per ROOT task (shared across the whole task tree; a follow-up task "
f"starts its own): after {cap} paid cycle(s) commit_reviewed buys no further "
"review (typed review_cycles_exhausted event; every dispatched wave counts, "
f"only undispatched attempts stay outside the count).{caveat}"
)

View file

@ -1,12 +1,17 @@
"""Advisory freshness gate and durable commit-attempt recording."""
"""Advisory freshness gate, durable commit-attempt recording, and the
commit-side Max-Review-Cycles machinery (block classification, the free
identical-diff refusal, the per-root-task paid-cycle ceiling, and the
review-contract fingerprint)."""
from __future__ import annotations
import hashlib
import json
import logging
import pathlib
from typing import Any, Dict, List, Optional
from ouroboros.review_cycles import review_max_cycles
from ouroboros.review_cycles import REASON_REVIEW_CYCLES_EXHAUSTED, review_max_cycles
from ouroboros.review_state import infer_review_phase
from ouroboros.tools.registry import ToolContext
from ouroboros.utils import (
@ -52,38 +57,298 @@ def _attempt_accepts_reviewing_update(existing: Any) -> bool:
return bool(existing.status == "reviewing" or existing.late_result_pending)
# Identical-diff blocked-review cap: after this many genuine review blocks of
# the SAME staged diff (matching pre_review_fingerprint), further attempts are
# refused BEFORE spending another triad+scope run. Changing the diff (fixing
# findings) starts a fresh streak — legitimate fix-and-retry loops are never
# capped, only verbatim resubmissions hoping for a different verdict. The number
# is the shared review-cycle cap (OUROBOROS_REVIEW_MAX_CYCLES, default 2, was a
# hardcoded 3), read at call time; ``unlimited`` disables the cap.
_ATTEMPT_CAP_BLOCK_REASON = "attempt_cap_reached"
# Max Review Cycles semantics on the commit gate (owner Q12/Q16/Q22/Q23):
# identical bytes are never re-reviewed for pay. From the FIRST genuine
# review-verdict block of a staged diff, resubmitting the same
# pre_review_fingerprint without a NEW rebuttal is refused for FREE — before
# the advisory-freshness gate and before any paid triad+scope dispatch —
# quoting the recorded verdict. A rebuttal is identified by CONTENT
# (sha256): a hash new to the current identical-fingerprint streak buys
# exactly ONE paid re-review; a repeated hash is refused free, quoting the
# previous outcome. Infra-blocks (fit overflow, sub-floor window,
# revalidation, transport/no-quorum) are not verdicts: they never build the
# refusal streak and retry freely. The shared OUROBOROS_REVIEW_MAX_CYCLES
# knob (``review_max_cycles()``; ``None`` = unlimited) bounds PAID
# triad+scope cycles per ROOT task (the whole task tree shares one ceiling;
# a manual session is its own task; a follow-up task starts a fresh one).
# The ceiling counts MONEY: every attempt that physically dispatched a wave
# (``paid`` recorded at dispatch) counts regardless of how it terminated —
# only UNDISPATCHED attempts (preflight refusals, assembly failures, free
# replays) are outside the count. Exhaustion is a
# free typed refusal plus ``emit_review_cycles_exhausted``. Both refusals
# honor the recorded review-contract fingerprint (roster+routes+enforcement+
# prompt contract): a changed contract lapses the streak. Under ADVISORY
# enforcement neither refusal hard-blocks a commit — the prior verdict is
# reused, loudly disclosed, and the commit proceeds without buying another
# review.
IDENTICAL_DIFF_BLOCK_REASON = "identical_diff_refused"
_LEGACY_CAP_BLOCK_REASON = "attempt_cap_reached" # pre-Q16 refusal rows
_REFUSAL_BLOCK_REASONS = frozenset({
IDENTICAL_DIFF_BLOCK_REASON,
REASON_REVIEW_CYCLES_EXHAUSTED,
_LEGACY_CAP_BLOCK_REASON,
})
BLOCK_CLASS_VERDICT = "verdict"
BLOCK_CLASS_INFRA = "infra"
# Triad block reasons that ARE reviewer verdicts; everything else recorded at
# phase=blocking_review is an infrastructure fact about the gate. Post-review
# failure phases (post_commit_tests, commit_binding, tag_binding) need no set
# of their own: the streak walker's generic terminal break already ends an
# identical-diff streak on them, and their paid dispatch stays counted.
_TRIAD_VERDICT_BLOCK_REASONS = frozenset({"critical_findings"})
# Failed-status phases that are pre/around-review infrastructure facts.
_INFRA_FAILURE_PHASES = frozenset({"infra", "expired"})
def blocked_attempt_fingerprint_cap() -> Optional[int]:
"""Live identical-diff cap from the shared setting; ``None`` = never cap."""
return review_max_cycles()
def _scope_actor_rows(scope_raw_result: Optional[Dict[str, Any]]) -> List[Dict[str, Any]]:
raw = scope_raw_result if isinstance(scope_raw_result, dict) else {}
rows = [row for row in (raw.get("raw_results") or []) if isinstance(row, dict)]
return rows or ([raw] if raw else [])
def check_blocked_attempt_cap(ctx: ToolContext, fingerprint: str, *, has_rebuttal: bool = False) -> str:
"""Refusal message when the same staged diff was already review-blocked
``blocked_attempt_fingerprint_cap()`` times in a row; "" allows the attempt.
def _scope_verdict_blocked(scope_blocked: bool, scope_raw_result: Optional[Dict[str, Any]]) -> bool:
"""A scope-side VERDICT block = an authoritative (``responded``) actor row
carrying critical findings while the scope aggregate blocked. Sub-floor,
fit-overflow, transport, parse and quorum blocks all arrive under
non-``responded`` statuses — they are infra facts, not verdicts."""
if not scope_blocked:
return False
for row in _scope_actor_rows(scope_raw_result):
if str(row.get("status") or "") == "responded" and (row.get("critical_findings") or []):
return True
return False
Counts trailing blocked attempts for this (repo, tool) whose
``pre_review_fingerprint`` matches the current staged diff — deliberately
NOT task-scoped: the byte-identical diff is the identity, so opening a new
task with the same unchanged diff cannot reset the streak. Cap-refusal
records themselves are skipped (they must not reset the streak); any other
non-matching terminal attempt (different diff, success, failure) breaks it.
A call carrying a review_rebuttal is exempt — the rebuttal IS new review
input even when the diff bytes are unchanged.
Fail-open on ledger errors — the cap is a cost guard, not a safety gate.
"""
def classify_review_block(
*,
triad_blocked: bool,
triad_block_reason: str,
scope_blocked: bool,
scope_raw_result: Optional[Dict[str, Any]] = None,
) -> str:
"""Type one blocked review outcome at record time: ``verdict`` when ANY
side delivered genuine reviewer findings, ``infra`` otherwise."""
if triad_blocked and str(triad_block_reason or "") in _TRIAD_VERDICT_BLOCK_REASONS:
return BLOCK_CLASS_VERDICT
if _scope_verdict_blocked(scope_blocked, scope_raw_result):
return BLOCK_CLASS_VERDICT
return BLOCK_CLASS_INFRA
def attempt_block_class(item: Any) -> str:
"""The typed class of one ledger row: the recorded ``block_class`` when
present, else a conservative legacy inference from the recorded reason.
Non-review rows (preflight facts, refusal records) stay ``""``."""
recorded = str(getattr(item, "block_class", "") or "")
if recorded:
return recorded
if str(getattr(item, "status", "") or "") != "blocked":
return ""
if str(getattr(item, "block_reason", "") or "") in _REFUSAL_BLOCK_REASONS:
return ""
if str(getattr(item, "phase", "") or "") != "blocking_review":
return "" # preflight/advisory-gate rows are neither verdict nor infra
reason = str(getattr(item, "block_reason", "") or "")
if reason in _TRIAD_VERDICT_BLOCK_REASONS:
return BLOCK_CLASS_VERDICT
if reason == "scope_blocked" and _scope_verdict_blocked(
True, getattr(item, "scope_raw_result", None)
):
return BLOCK_CLASS_VERDICT
return BLOCK_CLASS_INFRA
def compute_rebuttal_sha256(review_rebuttal: Any) -> str:
"""Content identity of a rebuttal; "" when no rebuttal was supplied."""
text = str(review_rebuttal or "").strip()
if not text:
return ""
return hashlib.sha256(text.encode("utf-8", errors="replace")).hexdigest()
def resolve_root_task_id(ctx: ToolContext) -> str:
"""The root of the current task tree (Q23: one paid-cycle ceiling per
tree); a task with no recorded root is its own root. "" = unknown.
DELIBERATE TRADEOFF (adversarial wave, machine-3): ``origin_root_task_id``
— the follow-up chain marker — is NOT honored here, so a scheduled
follow-up task is a FRESH root with its own ceiling. This makes the
refusal's "leave the remaining work to a follow-up task with its own
budget" exit real; the cost is that a follow-up can buy new paid cycles
for the same goal. The cross-task identical-fingerprint refusal remains
the anti-laundering backstop: byte-identical bytes stay refused whichever
task resubmits them."""
metadata = getattr(ctx, "task_metadata", None)
metadata = metadata if isinstance(metadata, dict) else {}
return str(
metadata.get("root_task_id")
or getattr(ctx, "root_task_id", "")
or getattr(ctx, "task_id", "")
or ""
)
def commit_review_contract_fingerprint() -> str:
"""Identity of the commit gate's live review contract (Q22): triad roster+
routes, scope rows, enforcement, and the shipped prompt-contract text. A
changed fingerprint lapses free-refusal/replay authority (a new paid
review is allowed and refusals never quote across the change). Fail-open
"" — an unknown contract never matches, so nothing is refused on it."""
try:
from ouroboros.config import get_review_enforcement
from ouroboros.review_substrate import scope_reviewer_slots
from ouroboros.reviewer_slot_config import commit_triad_delivery
from ouroboros.tools.review_helpers import CRITICAL_FINDING_CALIBRATION, REVIEW_PREAMBLE
from ouroboros.triad_review import REVIEW_JSON_ARRAY_CONTRACT
row_plan = commit_triad_delivery()
triad_rows = [
[
str(model),
str(getattr(route, "value", route) or ""),
str(effort or ""),
str(target or ""),
str(profile or ""),
str(slot_id or ""),
]
for model, route, effort, target, profile, slot_id in zip(
row_plan["models"], row_plan["routes"], row_plan["efforts"],
row_plan["session_targets"], row_plan["session_profiles"],
row_plan["slot_ids"],
)
]
scope_rows = [
[
str(getattr(slot, "slot_id", "") or ""),
str(getattr(slot, "model", "") or ""),
str(getattr(getattr(slot, "route", None), "value", "") or ""),
str(getattr(slot, "session_target", "") or ""),
str(getattr(slot, "session_profile", "") or ""),
str(getattr(slot, "effort", "") or ""),
]
for slot in scope_reviewer_slots()
]
prompt_contract = hashlib.sha256(
"\n".join([REVIEW_PREAMBLE, CRITICAL_FINDING_CALIBRATION, REVIEW_JSON_ARRAY_CONTRACT]).encode("utf-8")
).hexdigest()
payload = json.dumps(
{
"triad": triad_rows,
"scope": scope_rows,
"enforcement": str(get_review_enforcement() or ""),
"prompt_contract": prompt_contract,
},
sort_keys=True,
ensure_ascii=False,
)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
except Exception:
log.debug("commit review contract fingerprint unavailable (fail-open)", exc_info=True)
return ""
def _quote_verdict_attempt(item: Any) -> str:
"""Render the recorded verdict an identical resubmission is refused with."""
lines = [
f"Recorded verdict: attempt #{int(getattr(item, 'attempt', 0) or 0)} "
f"({getattr(item, 'ts', '') or 'unknown ts'}, block_reason="
f"{getattr(item, 'block_reason', '') or 'unknown'}"
+ (f", rebuttal_sha256={str(getattr(item, 'rebuttal_sha256', '') or '')[:12]}…" if getattr(item, "rebuttal_sha256", "") else "")
+ ")"
]
findings = [f for f in (getattr(item, "critical_findings", None) or []) if isinstance(f, dict)]
for finding in findings[:5]:
label = str(finding.get("item") or finding.get("reason") or "?")
reason = _truncate_review_reason(str(finding.get("reason", "") or ""), limit=200)
lines.append(f" - [CRITICAL] {label}: {reason}")
if len(findings) > 5:
lines.append(f" … and {len(findings) - 5} more critical finding(s) in review_status.")
details = str(getattr(item, "block_details", "") or "").strip()
if details and not findings:
lines.append(_truncate_review_reason(details, limit=600))
return "\n".join(lines)
def _walk_identical_verdict_streak(
attempts: List[Any], fp: str, contract_fingerprint: str
) -> tuple[Optional[Any], set]:
"""Trailing verdict-block streak for ``fp``: ``(last_verdict_row,
rebuttal_hashes_seen)``. ``(None, …)`` = no live streak. Skips in-flight
rows, refusal records, preflight facts and infra-blocks (none of them is a
verdict or evidence the diff changed); breaks on any other terminal. A
verdict row recorded under a DIFFERENT (or unknown) review contract lapses
the streak (Q22)."""
last_verdict: Optional[Any] = None
seen_rebuttals: set = set()
for item in reversed(attempts):
status = str(getattr(item, "status", "") or "")
if status == "reviewing":
continue # in-flight marker, not a verdict
if str(getattr(item, "block_reason", "") or "") in _REFUSAL_BLOCK_REASONS:
# Free refusals never reset the streak. Their recorded rebuttal
# hashes are deliberately NOT harvested: a "spent" rebuttal is one
# that BOUGHT a dispatch — one refused without dispatching stays
# fresh (e.g. after the owner raises the ceiling).
continue
klass = attempt_block_class(item)
if status == "blocked" and not klass:
# Preflight facts (stale advisory, tests, protection) inherit the
# prior fingerprint through the ledger merge; they are neither a
# review verdict nor evidence the diff changed.
continue
if klass == BLOCK_CLASS_INFRA:
continue # infra facts never build NOR break the streak
if status == "failed" and str(getattr(item, "phase", "") or "") in _INFRA_FAILURE_PHASES:
# Infra failures (lock/stage errors, expired reviewing rows) are
# transients, not verdicts and not evidence the diff changed —
# mirroring the ceiling's dispatch accounting, they neither build
# nor reset the streak (adversarial wave, machine-2).
continue
if (
status == "blocked"
and klass == BLOCK_CLASS_VERDICT
and str(getattr(item, "pre_review_fingerprint", "") or "") == fp
):
row_contract = str(getattr(item, "review_contract_fingerprint", "") or "")
if not contract_fingerprint or row_contract != contract_fingerprint:
if last_verdict is None:
# The streak's HEAD was recorded under another (or unknown)
# contract: replay authority lapses, a paid review is due.
return None, seen_rebuttals
# An OLDER row from a previous contract merely ends the streak;
# the newer same-contract verdict keeps its refusal authority.
break
if last_verdict is None:
last_verdict = item
# A rebuttal is "spent" only when it BOUGHT this dispatched,
# verdict-answered wave (machine-4/wording-2): harvest hashes from
# paid verdict rows only — never from refusal rows or infra facts.
rebuttal = str(getattr(item, "rebuttal_sha256", "") or "")
if rebuttal and bool(getattr(item, "paid", False)):
seen_rebuttals.add(rebuttal)
continue
break # success / pass / different diff / post-review terminal
return last_verdict, seen_rebuttals
def check_identical_verdict_refusal(
ctx: ToolContext,
fingerprint: str,
*,
rebuttal_sha256: str = "",
contract_fingerprint: str = "",
) -> str:
"""Free typed refusal for a byte-identical resubmission whose streak's last
terminal is a review VERDICT block and no NEW rebuttal is supplied; ""
allows the attempt. Fires from the FIRST verdict-block — identical bytes
are never re-reviewed for pay. Deliberately NOT task-scoped: the
byte-identical diff is the identity, so a new task with the same unchanged
diff cannot launder a fresh paid review (anti-laundering). Fail-open on
ledger errors — this is a cost guard, not a safety gate."""
fp = str(fingerprint or "").strip()
cap = blocked_attempt_fingerprint_cap()
if not fp or has_rebuttal or cap is None:
if not fp:
return ""
try:
from ouroboros.review_state import load_state, make_repo_key
@ -93,42 +358,95 @@ def check_blocked_attempt_cap(ctx: ToolContext, fingerprint: str, *, has_rebutta
repo_key=make_repo_key(pathlib.Path(ctx.repo_dir)),
tool_name=_current_review_tool_name(ctx),
)
streak = 0
for item in reversed(attempts):
status = str(getattr(item, "status", "") or "")
if status == "reviewing":
continue # in-flight marker, not a verdict
if str(getattr(item, "block_reason", "") or "") == _ATTEMPT_CAP_BLOCK_REASON:
continue # earlier refusals must not reset the streak
phase = str(getattr(item, "phase", "") or "")
if status == "blocked" and phase != "blocking_review":
# Preflight blocks (stale advisory, tests, protection) inherit
# the prior fingerprint through the ledger merge; they are
# neither a review verdict (must not inflate the streak — the
# cap message claims N verdicts) nor evidence the diff changed
# (must not reset it).
continue
if (
status == "blocked"
and str(getattr(item, "pre_review_fingerprint", "") or "") == fp
):
streak += 1
continue
break
if streak < cap:
last_verdict, seen_rebuttals = _walk_identical_verdict_streak(
attempts, fp, str(contract_fingerprint or "")
)
if last_verdict is None:
return ""
if rebuttal_sha256 and rebuttal_sha256 not in seen_rebuttals:
return "" # a NEW rebuttal buys exactly ONE paid re-review
repeated_note = (
"\nThe supplied review_rebuttal is byte-identical to one already spent on this "
"streak — a repeated rebuttal does not buy another review."
if rebuttal_sha256 else ""
)
return (
f"⚠️ REVIEW_ATTEMPT_CAP: this exact staged diff was already blocked by review "
f"{streak} times in a row. Refusing to spend another triad+scope run on an "
"unchanged diff. Either FIX the blocking findings (any change to the staged "
"diff starts a fresh review), genuinely rebut them via review_rebuttal with "
"new evidence, or stop and escalate the disagreement to the owner."
"⚠️ IDENTICAL_DIFF_REFUSED: this exact staged diff was already reviewed and "
"BLOCKED — you appear to have forgotten to change anything. Identical bytes are "
f"never re-reviewed for pay.{repeated_note}\n"
f"{_quote_verdict_attempt(last_verdict)}\n"
"Honest exits: change the code (any change to the staged diff starts a fresh "
"paid review); supply a NEW review_rebuttal with genuinely new evidence (buys "
"exactly one paid re-review); escalate the disagreement to the owner; or "
"finalize honestly without this commit."
)
except Exception:
log.debug("blocked-attempt cap check failed (fail-open)", exc_info=True)
log.debug("identical-verdict refusal check failed (fail-open)", exc_info=True)
return ""
def count_paid_review_cycles(ctx: ToolContext, *, root_task_id: str) -> int:
"""Paid triad/scope cycles already spent by this root task on this
(repo, tool) gate, derived from the existing attempt ledger (P7 — no new
counter file). The ceiling counts MONEY (machine-5): every attempt that
physically dispatched a wave (``paid`` recorded at dispatch) counts,
whatever its terminal — a dispatched-then-crashed or quorum-failed wave
still spent reviewer money. Only UNDISPATCHED attempts (free refusals,
replays, preflight/assembly failures — all ``paid=False``) stay outside
the count; that is the whole "infra retries freely" carve-out."""
root = str(root_task_id or "")
if not root:
return 0
from ouroboros.review_state import load_state, make_repo_key
state = load_state(pathlib.Path(ctx.drive_root))
attempts = state.filter_attempts(
repo_key=make_repo_key(pathlib.Path(ctx.repo_dir)),
tool_name=_current_review_tool_name(ctx),
)
return sum(
1
for item in attempts
if bool(getattr(item, "paid", False))
and str(getattr(item, "root_task_id", "") or "") == root
)
def check_review_cycles_ceiling(
ctx: ToolContext, *, root_task_id: str
) -> Optional[Dict[str, Any]]:
"""``None`` allows a paid dispatch; otherwise typed exhaustion facts
(message/cycles_paid/cap) for the per-root-task paid-cycle ceiling
(``review_max_cycles()``; ``None``/unknown root = unlimited). Fail-open on
ledger errors — a cost guard, not a safety gate. DISCLOSED RESIDUAL
(skill-5): the check is read-at-gate-time with no reservation, so
concurrent dispatches sharing one root can each read ``paid < cap`` and
overshoot by the concurrency width; the write-ahead paid stamp at first
physical dispatch narrows but does not close that window."""
cap = review_max_cycles()
root = str(root_task_id or "")
if cap is None or not root:
return None
try:
paid = count_paid_review_cycles(ctx, root_task_id=root)
except Exception:
log.debug("paid review-cycle count failed (fail-open)", exc_info=True)
return None
if paid < cap:
return None
message = (
f"⚠️ REVIEW_CYCLES_EXHAUSTED: this task tree (root {root}) already spent "
f"{paid} of {cap} paid triad+scope review cycle(s) "
"(OUROBOROS_REVIEW_MAX_CYCLES). Refusing to buy another review.\n"
"Honest exits: finalize honestly with what is already reviewed and committed; "
"escalate to the owner (the ceiling is the owner's Max Review Cycles setting — "
"3/5/unlimited are one settings change away); or leave the remaining work to a "
"follow-up task with its own budget. A rebuttal cannot buy past the ceiling — "
"rebuttal cycles count toward it."
)
return {"message": message, "cycles_paid": paid, "cap": cap}
def _record_commit_attempt(
ctx: ToolContext,
commit_message: Any = None,
@ -174,6 +492,11 @@ def _record_commit_attempt(
scope_model = _req("scope_model")
triad_raw_results = _req("triad_raw_results", None)
scope_raw_result = _req("scope_raw_result", None)
block_class = _req("block_class")
rebuttal_sha256 = _req("rebuttal_sha256")
paid = _req("paid", False)
review_contract_fingerprint = _req("review_contract_fingerprint")
root_task_id = resolve_root_task_id(ctx)
dr = pathlib.Path(ctx.drive_root)
repo_key = make_repo_key(pathlib.Path(ctx.repo_dir))
tool_name = _current_review_tool_name(ctx)
@ -241,6 +564,13 @@ def _record_commit_attempt(
attempt_no = int(existing.attempt or 0)
else:
attempt_no = state.next_attempt_number(repo_key, tool_name, task_id)
# A NEW attempt inherits NOTHING from the previous terminal
# row (mirrors the reviewing branch above). Leaking the
# prior attempt's fields here let every fresh preflight
# record inherit paid=True/block_class/fingerprints from
# the last real review — inflating the paid-cycle count
# on every free refusal (found by the F1 eviction test).
existing = None
ctx._current_review_attempt_number = attempt_no
else:
existing = state.latest_attempt_for(
@ -301,6 +631,14 @@ def _record_commit_attempt(
scope_model=scope_model or str(getattr(existing, "scope_model", "") or ""),
triad_raw_results=list(triad_raw_results or []),
scope_raw_result=dict(scope_raw_result or {}),
block_class=block_class or str(getattr(existing, "block_class", "") or ""),
rebuttal_sha256=rebuttal_sha256 or str(getattr(existing, "rebuttal_sha256", "") or ""),
paid=bool(paid or getattr(existing, "paid", False)),
review_contract_fingerprint=(
review_contract_fingerprint
or str(getattr(existing, "review_contract_fingerprint", "") or "")
),
root_task_id=root_task_id or str(getattr(existing, "root_task_id", "") or ""),
)
state.record_attempt(attempt, semantic_redirects=_obligation_redirects)

View file

@ -39,11 +39,21 @@ from ouroboros.tools.claude_advisory_review import (
advisory_gate_unavailable,
)
from ouroboros.tools.commit_gate import (
IDENTICAL_DIFF_BLOCK_REASON,
_check_advisory_freshness,
_check_overlapping_review_attempt,
_invalidate_advisory,
_record_commit_attempt,
check_blocked_attempt_cap,
check_identical_verdict_refusal,
check_review_cycles_ceiling,
classify_review_block,
commit_review_contract_fingerprint,
compute_rebuttal_sha256,
resolve_root_task_id,
)
from ouroboros.review_cycles import (
REASON_REVIEW_CYCLES_EXHAUSTED,
emit_review_cycles_exhausted,
)
from ouroboros.tools.review_revalidation import handle_revalidation_failure
from ouroboros.utils import utc_now_iso, write_text, safe_relpath, run_cmd
@ -268,6 +278,7 @@ def _finalize_blocked_review(
combined_findings: List[Dict[str, Any]],
pre_fingerprint: Dict[str, Any],
post_fingerprint: Dict[str, Any],
block_class: str = "",
) -> str:
"""Persist a genuine blocked review result, then unstage the reviewed diff."""
_record_commit_attempt(
@ -279,6 +290,7 @@ def _finalize_blocked_review(
duration_sec=time.time() - commit_start,
critical_findings=combined_findings,
phase="blocking_review",
block_class=block_class,
pre_review_fingerprint=pre_fingerprint.get("fingerprint", ""),
post_review_fingerprint=post_fingerprint.get("fingerprint", ""),
fingerprint_status="matched",
@ -348,7 +360,23 @@ def _mark_failed_bypass_advisory_stale(
log.debug("Failed to stale bypass advisory after preflight block", exc_info=True)
def _refuse_capped_attempt(
def _repair_managed_merge_head(ctx: ToolContext) -> None:
"""Re-establish MERGE_HEAD for an authorized managed resolver after a
refusal's index reset, so the resolution is not stranded (same repair the
blocked-review path performs in ``_repo_commit_push``)."""
try:
from supervisor.update_merge import managed_assisted_tx_for, reestablish_merge_head
tx, _block = managed_assisted_tx_for(
getattr(ctx, "task_id", ""), getattr(ctx, "task_metadata", None)
)
if tx:
reestablish_merge_head(str(tx.get("target_sha") or ""))
except Exception:
log.debug("reestablish_merge_head after free refusal failed", exc_info=True)
def _free_cycle_gate(
ctx: ToolContext,
commit_message: str,
commit_start: float,
@ -356,35 +384,124 @@ def _refuse_capped_attempt(
pre_fingerprint: Dict[str, Any],
review_rebuttal: str,
) -> Optional[Dict[str, Any]]:
"""Identical-diff blocked-attempt cap preflight; None allows the attempt."""
cap_msg = check_blocked_attempt_cap(
ctx,
pre_fingerprint.get("fingerprint", ""),
has_rebuttal=bool(str(review_rebuttal or "").strip()),
"""Max-Review-Cycles gate, run BEFORE advisory freshness and any paid
dispatch. ``None`` allows a paid attempt. Two free typed outcomes:
a byte-identical resubmission of a verdict-blocked diff without a NEW
rebuttal, and an exhausted per-root-task paid-cycle ceiling. Under
blocking enforcement each is a free refusal dict; under advisory each is
an ``{"advisory_replay": …}`` marker — the commit proceeds with a loud
disclosure and WITHOUT buying another review."""
from ouroboros.config import get_review_enforcement
fp = pre_fingerprint.get("fingerprint", "")
rebuttal_sha = compute_rebuttal_sha256(review_rebuttal)
contract_fp = commit_review_contract_fingerprint()
ctx._current_review_rebuttal_sha256 = rebuttal_sha
ctx._current_review_contract_fingerprint = contract_fp
root_task_id = resolve_root_task_id(ctx)
identical_msg = check_identical_verdict_refusal(
ctx, fp, rebuttal_sha256=rebuttal_sha, contract_fingerprint=contract_fp
)
if not cap_msg:
ceiling = None if identical_msg else check_review_cycles_ceiling(
ctx, root_task_id=root_task_id
)
if not identical_msg and ceiling is None:
return None
enforcement = str(get_review_enforcement() or "")
reason = IDENTICAL_DIFF_BLOCK_REASON if identical_msg else REASON_REVIEW_CYCLES_EXHAUSTED
message = identical_msg or str(ceiling["message"])
if ceiling is not None:
emit_review_cycles_exhausted(
getattr(ctx, "event_queue", None), ctx.drive_root,
surface="commit_gate", task_id=str(getattr(ctx, "task_id", "") or ""),
cycles_paid=int(ceiling["cycles_paid"]), cap=int(ceiling["cap"]),
enforcement=enforcement, root_task_id=root_task_id, fingerprint=str(fp),
)
if enforcement != "blocking":
# ADVISORY: neither state hard-blocks a commit — disclose loudly (typed
# event + result message) and reuse the recorded outcome for free.
# The identical-replay half of this branch is structurally near-dead
# TODAY (fable P3-2, deliberate): verdict rows are only minted under
# blocking enforcement, and the review-contract fingerprint includes
# enforcement, so blocking-era verdicts never match an advisory-era
# contract — the streak lapses and the commit degrades to an ordinary
# PAID review. Kept as defense-in-depth for any future path that
# records advisory-era verdict rows.
try:
from ouroboros.utils import append_jsonl
append_jsonl(ctx.drive_logs() / "events.jsonl", {
"ts": utc_now_iso(), "type": "commit_review_free_replay",
"reason": reason, "enforcement": enforcement,
"task_id": str(getattr(ctx, "task_id", "") or ""),
"root_task_id": root_task_id,
"pre_review_fingerprint": str(fp),
})
except Exception:
log.debug("commit_review_free_replay event emission failed", exc_info=True)
return {"advisory_replay": message, "replay_reason": reason}
try:
run_cmd(["git", "reset", "HEAD"], cwd=ctx.repo_dir)
except Exception:
pass
if _authorized_managed_update_resolver(ctx):
_repair_managed_merge_head(ctx)
_record_commit_attempt(
ctx,
commit_message,
"blocked",
block_reason="attempt_cap_reached",
block_details=cap_msg,
block_reason=reason,
block_details=message,
duration_sec=time.time() - commit_start,
phase="preflight",
pre_review_fingerprint=pre_fingerprint.get("fingerprint", ""),
pre_review_fingerprint=str(fp),
rebuttal_sha256=rebuttal_sha,
review_contract_fingerprint=contract_fp,
)
return {
"status": "blocked",
"message": cap_msg,
"block_reason": "attempt_cap_reached",
"message": message,
"block_reason": reason,
}
def _install_paid_dispatch_stamp(
ctx: ToolContext,
commit_message: str,
commit_start: float,
pre_fingerprint: Dict[str, Any],
) -> None:
"""WRITE-AHEAD paid stamp (Q16 fix round, F2): durably merge ``paid=True``
onto the current attempt row immediately BEFORE the first physical
reviewer transport call, on EITHER side — triad and scope dispatch in
parallel, so scope spend can coexist with a triad assembly overflow, and
any side dispatching makes the cycle paid. Assembly-only exits (triad fit
ladder, scope pack signals) never invoke the stamp, so a $0 attempt stays
outside the ceiling; a crash after dispatch keeps the paid fact
(write-ahead). The shared transport entry (``run_review_request``)
invokes the stamp via ``ctx._review_paid_stamp`` — the seam where the
L-review lane's two-phase admission slots in at synthesis."""
from ouroboros.review_dispatch import ReviewPaidStamp
def _write() -> None:
_record_commit_attempt(
ctx,
commit_message,
"reviewing",
duration_sec=time.time() - commit_start,
phase="review",
pre_review_fingerprint=pre_fingerprint.get("fingerprint", ""),
fingerprint_status="pending",
paid=True,
rebuttal_sha256=str(getattr(ctx, "_current_review_rebuttal_sha256", "") or ""),
review_contract_fingerprint=str(
getattr(ctx, "_current_review_contract_fingerprint", "") or ""
),
)
ctx._review_paid_stamp = ReviewPaidStamp(_write)
def _review_cycle_infra_failure(
ctx: ToolContext,
commit_message: str,
@ -550,62 +667,19 @@ def _managed_candidate_needs_proof(ctx: ToolContext) -> bool:
return True
def _run_reviewed_stage_cycle(
def _advisory_and_tests_gate(
ctx: ToolContext,
commit_message: str,
commit_start: float,
*,
paths: Optional[List[str]] = None,
skip_advisory_review: bool = False,
skip_advisory_pre_review: bool = False,
skip_tests: bool = False,
goal: str = "",
scope: str = "",
review_rebuttal: str = "",
came_from_detached_checkout: bool = False,
require_release_tag: bool = True,
) -> Dict[str, Any]:
skip_advisory_pre_review = bool(skip_advisory_review or skip_advisory_pre_review)
classification_paths, advisory_paths, stage_error = _stage_candidate_for_review(
ctx,
commit_message,
commit_start,
paths=paths,
came_from_detached_checkout=came_from_detached_checkout,
)
if stage_error is not None:
return stage_error
protected_staged_paths = protected_paths_in(classification_paths)
runtime_mode = _current_runtime_mode()
if (
protected_staged_paths
and not mode_allows_protected_write(runtime_mode)
and not _authorized_managed_update_resolver(ctx)
):
msg = _protected_paths_block_message(
protected_staged_paths,
runtime_mode=runtime_mode,
action="commit",
)
try:
run_cmd(["git", "reset", "HEAD"], cwd=ctx.repo_dir)
except Exception:
pass
_record_commit_attempt(
ctx,
commit_message,
"blocked",
block_reason="core_protection_blocked",
block_details=msg,
duration_sec=time.time() - commit_start,
critical_findings=[],
phase="preflight",
)
return {
"status": "blocked",
"message": msg,
"block_reason": "core_protection_blocked",
}
classification_paths: List[str],
advisory_paths: Optional[List[str]],
skip_advisory_pre_review: bool,
skip_tests: bool,
) -> Optional[Dict[str, Any]]:
"""Advisory-freshness gate plus the compensating tests preflight (moved
whole out of ``_run_reviewed_stage_cycle`` at the function-size gate).
``None`` = proceed to review."""
advisory_err = _check_advisory_freshness(
ctx,
commit_message,
@ -682,7 +756,7 @@ def _run_reviewed_stage_cycle(
block_details=msg,
duration_sec=time.time() - commit_start,
# Preflight, not a review verdict: must neither inflate nor
# reset the identical-diff blocked-attempt cap streak.
# reset the identical-diff refusal streak.
phase="preflight",
)
_mark_failed_bypass_advisory_stale(ctx, commit_message, advisory_paths)
@ -711,7 +785,65 @@ def _run_reviewed_stage_cycle(
)
except Exception:
pass
return None
def _run_reviewed_stage_cycle(
ctx: ToolContext,
commit_message: str,
commit_start: float,
*,
paths: Optional[List[str]] = None,
skip_advisory_review: bool = False,
skip_advisory_pre_review: bool = False,
skip_tests: bool = False,
goal: str = "",
scope: str = "",
review_rebuttal: str = "",
came_from_detached_checkout: bool = False,
require_release_tag: bool = True,
) -> Dict[str, Any]:
skip_advisory_pre_review = bool(skip_advisory_review or skip_advisory_pre_review)
classification_paths, advisory_paths, stage_error = _stage_candidate_for_review(
ctx,
commit_message,
commit_start,
paths=paths,
came_from_detached_checkout=came_from_detached_checkout,
)
if stage_error is not None:
return stage_error
protected_staged_paths = protected_paths_in(classification_paths)
runtime_mode = _current_runtime_mode()
if (
protected_staged_paths
and not mode_allows_protected_write(runtime_mode)
and not _authorized_managed_update_resolver(ctx)
):
msg = _protected_paths_block_message(
protected_staged_paths,
runtime_mode=runtime_mode,
action="commit",
)
try:
run_cmd(["git", "reset", "HEAD"], cwd=ctx.repo_dir)
except Exception:
pass
_record_commit_attempt(
ctx,
commit_message,
"blocked",
block_reason="core_protection_blocked",
block_details=msg,
duration_sec=time.time() - commit_start,
critical_findings=[],
phase="preflight",
)
return {
"status": "blocked",
"message": msg,
"block_reason": "core_protection_blocked",
}
pre_fingerprint = _fingerprint_staged_diff(pathlib.Path(ctx.repo_dir))
if not pre_fingerprint.get("ok"):
return {
@ -727,6 +859,31 @@ def _run_reviewed_stage_cycle(
"pre_fingerprint": pre_fingerprint,
"post_fingerprint": {},
}
# Max-Review-Cycles free gate: BEFORE the advisory-freshness gate (an
# identical resubmission must not be told to buy a fresh SDK advisory) and
# before any paid dispatch. A refusal is free; under advisory it becomes a
# free replay marker instead of a block. The fingerprint is computed early
# because this gate needs it; the binding-precondition check keeps its
# original place AFTER the advisory/tests gate (wording-4, I1).
gate_outcome = _free_cycle_gate(
ctx, commit_message, commit_start,
pre_fingerprint=pre_fingerprint, review_rebuttal=review_rebuttal,
)
advisory_replay: Optional[Dict[str, Any]] = None
if gate_outcome is not None:
if "advisory_replay" in gate_outcome:
advisory_replay = gate_outcome
else:
return gate_outcome
advisory_gate_outcome = _advisory_and_tests_gate(
ctx, commit_message, commit_start,
classification_paths=classification_paths,
advisory_paths=advisory_paths,
skip_advisory_pre_review=skip_advisory_pre_review,
skip_tests=skip_tests,
)
if advisory_gate_outcome is not None:
return advisory_gate_outcome
binding_error = _review_binding_precondition_error(
pre_fingerprint, require_release_tag=require_release_tag
)
@ -749,12 +906,6 @@ def _run_reviewed_stage_cycle(
"pre_fingerprint": pre_fingerprint,
"post_fingerprint": {},
}
cap_refusal = _refuse_capped_attempt(
ctx, commit_message, commit_start,
pre_fingerprint=pre_fingerprint, review_rebuttal=review_rebuttal,
)
if cap_refusal is not None:
return cap_refusal
_record_commit_attempt(
ctx,
commit_message,
@ -763,15 +914,61 @@ def _run_reviewed_stage_cycle(
phase="review",
pre_review_fingerprint=pre_fingerprint.get("fingerprint", ""),
fingerprint_status="pending",
# The PAID fact is deliberately NOT recorded here: it lands write-ahead
# at the first PHYSICAL reviewer dispatch (_install_paid_dispatch_stamp
# below, plan-review precedent), so an attempt where BOTH packs refuse
# at assembly — and a free advisory replay, which dispatches nothing —
# stays unpaid. The per-root-task cycle count is derived from these
# rows, never from a separate counter file (P7).
rebuttal_sha256=str(getattr(ctx, "_current_review_rebuttal_sha256", "") or ""),
review_contract_fingerprint=str(
getattr(ctx, "_current_review_contract_fingerprint", "") or ""
),
)
review_err, scope_result, triad_block_reason, triad_advisory = _run_parallel_review(
ctx,
commit_message,
goal=goal,
scope=scope,
review_rebuttal=review_rebuttal,
)
if advisory_replay is not None:
# ADVISORY free outcome: disclose loudly and let the commit proceed
# without buying another triad+scope run. Honest wording per cause
# (wording-3): an identical-diff replay REUSES a recorded verdict; a
# ceiling exhaustion on NEW bytes has no verdict to reuse — the diff
# ships without a fresh review and the disclosure must say so.
review_err, scope_result, triad_block_reason, triad_advisory = None, None, "", []
replay_reason = str(advisory_replay.get("replay_reason") or "")
if replay_reason == IDENTICAL_DIFF_BLOCK_REASON:
progress_note = (
"Max Review Cycles: identical staged diff — reusing the recorded "
"review verdict, no paid triad+scope dispatch."
)
else:
progress_note = (
"Max Review Cycles: paid-cycle ceiling exhausted — no review outcome "
"exists for this diff; the commit proceeds without a fresh triad+scope "
"review under advisory enforcement."
)
disclosure = (
"Review enforcement=Advisory: no new triad+scope review was bought for "
f"this commit ({replay_reason}). "
+ str(advisory_replay.get("advisory_replay") or "")
)
advisory_list = getattr(ctx, "_review_advisory", None)
if isinstance(advisory_list, list):
advisory_list.append(disclosure)
try:
ctx.emit_progress_fn(progress_note)
except Exception:
pass
else:
_install_paid_dispatch_stamp(ctx, commit_message, commit_start, pre_fingerprint)
try:
review_err, scope_result, triad_block_reason, triad_advisory = _run_parallel_review(
ctx,
commit_message,
goal=goal,
scope=scope,
review_rebuttal=review_rebuttal,
)
finally:
ctx._review_paid_stamp = None
blocked, combined_msg, block_reason, combined_findings, scope_advisory = _aggregate_review_verdict(
review_err,
scope_result,
@ -818,6 +1015,18 @@ def _run_reviewed_stage_cycle(
"post_fingerprint": post_fingerprint,
}
if blocked:
# Typed block-row classification (Q16/Δ5): a reviewer VERDICT builds
# the identical-diff refusal streak; an INFRA fact (fit/quorum/
# transport/sub-floor) never does and retries freely. Money is a
# separate axis: paid was stamped at PHYSICAL dispatch, so a
# dispatched-then-infra-blocked wave still counts toward the ceiling,
# while an assembly-refused (undispatched) infra block stays free.
block_class = classify_review_block(
triad_blocked=bool(review_err),
triad_block_reason=str(triad_block_reason or ""),
scope_blocked=bool(scope_result is not None and getattr(scope_result, "blocked", False)),
scope_raw_result=getattr(ctx, "_last_scope_raw_result", {}) or {},
)
return {
"status": "blocked",
"message": _finalize_blocked_review(
@ -829,6 +1038,7 @@ def _run_reviewed_stage_cycle(
combined_findings=combined_findings,
pre_fingerprint=pre_fingerprint,
post_fingerprint=post_fingerprint,
block_class=block_class,
),
"block_reason": block_reason,
"pre_fingerprint": pre_fingerprint,
@ -2874,7 +3084,7 @@ def get_tools() -> List[ToolEntry]:
"paths": {"type": "array", "items": {"type": "string"}, "description": "Optional subset of task-attributed clean-at-baseline paths. Omitted computes the full attributed candidate set; an empty set never stages the whole tree."},
"skip_tests": {"type": "boolean", "default": False, "description": "Skip pre-commit tests."},
"review_rebuttal": {"type": "string", "default": "",
"description": "If previous commit was blocked by reviewers and you disagree, include counter-argument."},
"description": "If the previous commit was blocked by reviewers and you disagree, include a counter-argument. The rebuttal is identified by CONTENT: a rebuttal new to the current identical-diff streak buys exactly ONE paid re-review of the unchanged diff; resubmitting the same rebuttal (or none) is refused for free, quoting the recorded verdict."},
"skip_advisory_review": {"type": "boolean", "default": False,
"description": skip_advisory_description},
"goal": {"type": "string", "default": "",
@ -2890,7 +3100,8 @@ def get_tools() -> List[ToolEntry]:
"commit_message": {"type": "string"},
"paths": {"type": "array", "items": {"type": "string"}, "description": "Optional subset of task-attributed clean-at-baseline paths. Omitted computes candidates; empty never means git add -A."},
"skip_tests": {"type": "boolean", "default": False, "description": "Skip pre-commit tests."},
"review_rebuttal": {"type": "string", "default": ""},
"review_rebuttal": {"type": "string", "default": "",
"description": "Content-hashed counter-argument to a prior review block: a NEW rebuttal buys exactly one paid re-review of an unchanged diff; a repeated one is refused free."},
"skip_advisory_review": {"type": "boolean", "default": False,
"description": skip_advisory_description},
"goal": {"type": "string", "default": ""},

View file

@ -1057,7 +1057,17 @@ _REVIEW_SCHEMA = {
"shared reviewer-slot configuration and scored against the "
"Skill Review Checklist section in docs/CHECKLISTS.md. Persists the "
"verdict to data/state/skills/<name>/review.json with a content "
"hash so a later edit invalidates the review automatically."
"hash so a later edit invalidates the review automatically. "
"Max Review Cycles semantics: an identical snapshot never buys a new "
"panel — a recorded substantive verdict REPLAYS free, keyed by (review "
"group, content hash, panel-contract fingerprint), so only changed "
"content, a changed panel contract, or a NEW rebuttal dispatches "
"reviewers again. The shared OUROBOROS_REVIEW_MAX_CYCLES ceiling "
"bounds PAID panel dispatches per ceiling key (root task for "
"task-driven reviews; the current content snapshot for the manual "
"lane); on exhaustion the review is refused free with a typed "
"review_cycles_exhausted event and the skill stays honestly PENDING — "
"never executable without a real verdict."
),
"parameters": {
"type": "object",
@ -1071,7 +1081,12 @@ _REVIEW_SCHEMA = {
"description": (
"Optional rebuttal to prior review findings. Use only when "
"you have code-grounded evidence that a previous finding was "
"a false positive or already addressed."
"a false positive or already addressed. The rebuttal is "
"identified by CONTENT: its sha256 buys exactly ONE paid "
"panel wave when new to the current snapshot's streak; "
"repeating an already-adjudicated rebuttal replays the "
"recorded verdict free, and no rebuttal buys past the "
"paid-cycle ceiling."
),
},
},

View file

@ -549,10 +549,19 @@ depth layers.
Every commit is a release. Before commit, update all version carriers together:
`VERSION`, `pyproject.toml` (PEP 440 canonical form), README badge/changelog, and
`docs/ARCHITECTURE.md` header. Then use `commit_reviewed`; the commit path creates
the annotated `v{VERSION}` tag automatically after the commit. After the configured number of genuine
review-verdict blocks of a byte-identical staged diff, `commit_reviewed` refuses
further attempts (`attempt_cap_reached`) — change the diff, provide a
`review_rebuttal`, or escalate to the owner.
the annotated `v{VERSION}` tag automatically after the commit. Identical bytes are
never re-reviewed for pay: after ANY review-verdict block, resubmitting the
byte-identical staged diff is refused for free (`identical_diff_refused`, quoting
the recorded verdict) — change the diff, provide a NEW `review_rebuttal` (a
rebuttal new to the streak buys exactly one paid re-review; a repeated one is
refused free), or escalate to the owner. The Max Review Cycles setting bounds
PAID triad+scope cycles per ROOT task — the whole task tree shares one ceiling
(a follow-up task starts its own); on exhaustion no further review is bought
and the typed `review_cycles_exhausted` event fires (every dispatched wave
counts; undispatched refusals and replays do not) — finalize honestly or ask
the owner to raise the cap. In both states, under blocking enforcement the
commit is refused for free; under advisory the commit proceeds with a loud
durable disclosure and no new review spend.
## Git Attribution

View file

@ -203,9 +203,14 @@ def test_commit_gate_bypass_detection_rides_the_route_slot_aware_predicate():
from ouroboros.tools import git as git_mod
source = inspect.getsource(git_mod._run_reviewed_stage_cycle)
assert "advisory_gate_unavailable" in source
assert 'os.environ.get("ANTHROPIC_API_KEY"' not in source
# The bypass decision lives in the advisory+tests gate helper the stage
# cycle calls (extracted whole at the function-size gate).
stage_source = inspect.getsource(git_mod._run_reviewed_stage_cycle)
assert "_advisory_and_tests_gate" in stage_source
assert 'os.environ.get("ANTHROPIC_API_KEY"' not in stage_source
gate_source = inspect.getsource(git_mod._advisory_and_tests_gate)
assert "advisory_gate_unavailable" in gate_source
assert 'os.environ.get("ANTHROPIC_API_KEY"' not in gate_source
def _clear_session_route_envs(monkeypatch):

View file

@ -50,11 +50,18 @@ def test_tool_registered(tool_name):
assert tool_name in names
def test_blocked_attempt_cap_refuses_identical_diff_resubmission(tmp_path, monkeypatch):
"""B4: after blocked_attempt_fingerprint_cap() review-blocks of the SAME
staged-diff fingerprint (the shared OUROBOROS_REVIEW_MAX_CYCLES, default 2),
the next attempt is refused BEFORE triad+scope; a changed diff or a
review_rebuttal lifts the cap; refusal records do not reset the streak."""
CONTRACT_FP = "contract-fp-1"
def _identical_ctx(tmp_path, task_id="t-cap"):
return types.SimpleNamespace(repo_dir=tmp_path, drive_root=tmp_path, task_id=task_id)
def _add_attempt(tmp_path, status, fingerprint, *, block_reason="critical_findings",
attempt=1, phase="blocking_review", task_id="t-cap",
block_class="", rebuttal_sha256="",
review_contract_fingerprint=CONTRACT_FP,
critical_findings=None, paid=True):
import pathlib
from ouroboros.review_state import (
@ -63,71 +70,155 @@ def test_blocked_attempt_cap_refuses_identical_diff_resubmission(tmp_path, monke
update_state,
_utc_now,
)
repo_key = make_repo_key(pathlib.Path(tmp_path))
def _mutate(state):
state.attempts.append(CommitAttemptRecord(
ts=_utc_now(), commit_message="msg", status=status,
block_reason=block_reason if status == "blocked" else "",
repo_key=repo_key, tool_name="commit_reviewed", task_id=task_id,
attempt=attempt, phase=phase,
pre_review_fingerprint=fingerprint,
block_class=block_class,
rebuttal_sha256=rebuttal_sha256,
review_contract_fingerprint=review_contract_fingerprint,
critical_findings=list(critical_findings or []),
paid=paid,
))
update_state(pathlib.Path(tmp_path), _mutate)
def test_identical_diff_refused_free_from_first_verdict_block(tmp_path, monkeypatch):
"""Q12/Q16 contract: identical bytes are never re-reviewed for pay. ONE
review-verdict block of a staged-diff fingerprint refuses a byte-identical
resubmission (quoting the recorded verdict), regardless of the cycles knob;
a changed diff starts fresh; a cross-task identical resubmit stays refused
(anti-laundering); a success ends the streak."""
from ouroboros.tools.commit_gate import check_identical_verdict_refusal
monkeypatch.setenv("OUROBOROS_REVIEW_MAX_CYCLES", "unlimited") # refusal is knob-independent
ctx = _identical_ctx(tmp_path)
# FIRST verdict-block already refuses — no paid streak of N is required.
_add_attempt(tmp_path, "blocked", "fp-same", block_class="verdict",
critical_findings=[{"item": "bug_x", "reason": "boom", "severity": "critical"}])
msg = check_identical_verdict_refusal(ctx, "fp-same", contract_fingerprint=CONTRACT_FP)
assert "IDENTICAL_DIFF_REFUSED" in msg
assert "bug_x" in msg # quotes the recorded verdict
# Cross-task: the byte-identical diff is the identity.
other = _identical_ctx(tmp_path, task_id="t-other")
assert "IDENTICAL_DIFF_REFUSED" in check_identical_verdict_refusal(
other, "fp-same", contract_fingerprint=CONTRACT_FP)
# A different staged diff is a fresh paid case.
assert check_identical_verdict_refusal(ctx, "fp-other", contract_fingerprint=CONTRACT_FP) == ""
# A refusal record must not reset the streak.
_add_attempt(tmp_path, "blocked", "fp-same", block_reason="identical_diff_refused",
phase="preflight", attempt=2)
assert "IDENTICAL_DIFF_REFUSED" in check_identical_verdict_refusal(
ctx, "fp-same", contract_fingerprint=CONTRACT_FP)
# A successful commit ends the streak.
_add_attempt(tmp_path, "succeeded", "fp-same", attempt=3, phase="commit")
assert check_identical_verdict_refusal(ctx, "fp-same", contract_fingerprint=CONTRACT_FP) == ""
def test_identical_refusal_rebuttal_by_content_and_contract_lapse(tmp_path, monkeypatch):
"""Q16/Q22 contract: a rebuttal hash NEW to the streak buys exactly one
paid re-review; the SAME hash is refused free; a changed (or unknown)
review-contract fingerprint lapses the streak entirely."""
from ouroboros.tools.commit_gate import (
blocked_attempt_fingerprint_cap,
check_blocked_attempt_cap,
check_identical_verdict_refusal,
compute_rebuttal_sha256,
)
monkeypatch.delenv("OUROBOROS_REVIEW_MAX_CYCLES", raising=False)
BLOCKED_ATTEMPT_FINGERPRINT_CAP = blocked_attempt_fingerprint_cap()
assert BLOCKED_ATTEMPT_FINGERPRINT_CAP == 2 # shared default (was a hardcoded 3)
ctx = _identical_ctx(tmp_path)
_add_attempt(tmp_path, "blocked", "fp-r", block_class="verdict")
ctx = types.SimpleNamespace(repo_dir=tmp_path, drive_root=tmp_path, task_id="t-cap")
repo_key = make_repo_key(pathlib.Path(tmp_path))
new_sha = compute_rebuttal_sha256("the finding is a false positive because ...")
assert new_sha and compute_rebuttal_sha256("") == ""
# NEW rebuttal content: exempt (buys one paid re-review).
assert check_identical_verdict_refusal(
ctx, "fp-r", rebuttal_sha256=new_sha, contract_fingerprint=CONTRACT_FP) == ""
# That rebuttal is spent on the streak (recorded on the next verdict-block):
_add_attempt(tmp_path, "blocked", "fp-r", attempt=2, block_class="verdict",
rebuttal_sha256=new_sha)
repeated = check_identical_verdict_refusal(
ctx, "fp-r", rebuttal_sha256=new_sha, contract_fingerprint=CONTRACT_FP)
assert "IDENTICAL_DIFF_REFUSED" in repeated
assert "repeated rebuttal" in repeated
# A genuinely different rebuttal buys again.
assert check_identical_verdict_refusal(
ctx, "fp-r", rebuttal_sha256=compute_rebuttal_sha256("different evidence"),
contract_fingerprint=CONTRACT_FP) == ""
# A rebuttal is "spent" only when it BOUGHT a dispatch (machine-4/wording-2):
# one recorded on an UNDISPATCHED refusal row (e.g. a ceiling refusal) stays
# fresh — after the owner raises the cap it still buys its paid re-review.
undispatched = compute_rebuttal_sha256("never dispatched")
_add_attempt(tmp_path, "blocked", "fp-r", attempt=3,
block_reason="review_cycles_exhausted", phase="preflight",
rebuttal_sha256=undispatched, paid=False)
assert check_identical_verdict_refusal(
ctx, "fp-r", rebuttal_sha256=undispatched, contract_fingerprint=CONTRACT_FP) == ""
# Q22: a changed contract fingerprint invalidates the streak — a paid
# review is allowed and the refusal never quotes across the change.
assert check_identical_verdict_refusal(
ctx, "fp-r", contract_fingerprint="another-contract") == ""
# An unknown current contract (fail-open "") never refuses.
assert check_identical_verdict_refusal(ctx, "fp-r", contract_fingerprint="") == ""
# The lapse applies to the streak HEAD only: an OLDER row from a previous
# contract ends the streak but a NEWER verdict under the current contract
# keeps its refusal authority.
_add_attempt(tmp_path, "blocked", "fp-mixed", attempt=1, block_class="verdict",
review_contract_fingerprint="old-contract")
_add_attempt(tmp_path, "blocked", "fp-mixed", attempt=2, block_class="verdict",
review_contract_fingerprint=CONTRACT_FP)
assert "IDENTICAL_DIFF_REFUSED" in check_identical_verdict_refusal(
ctx, "fp-mixed", contract_fingerprint=CONTRACT_FP)
def _add_attempt(status, fingerprint, block_reason="critical_findings", attempt=1,
phase="blocking_review", task_id="t-cap"):
def _mutate(state):
state.attempts.append(CommitAttemptRecord(
ts=_utc_now(), commit_message="msg", status=status,
block_reason=block_reason if status == "blocked" else "",
repo_key=repo_key, tool_name="commit_reviewed", task_id=task_id,
attempt=attempt, phase=phase,
pre_review_fingerprint=fingerprint,
))
update_state(pathlib.Path(tmp_path), _mutate)
# Below the cap: allowed. The second block comes from a DIFFERENT task —
# the cap is diff-scoped, so a new task with the same unchanged diff
# continues the streak instead of resetting it.
_add_attempt("blocked", "fp-same", attempt=1)
for i in range(BLOCKED_ATTEMPT_FINGERPRINT_CAP - 2):
_add_attempt("blocked", "fp-same", attempt=i + 1, task_id="t-other")
assert check_blocked_attempt_cap(ctx, "fp-same") == ""
def test_identical_refusal_skips_infra_and_preflight_rows(tmp_path, monkeypatch):
"""Δ5 contract: infra-blocks (fit/quorum/transport/revalidation) and
preflight facts neither build the refusal streak nor reset it — the
recorded verdict stays authoritative through infra noise, and infra-only
history never refuses anything."""
from ouroboros.tools.commit_gate import check_identical_verdict_refusal
# A preflight block (e.g. stale advisory) inheriting the same fingerprint
# is NOT a review verdict: it must neither inflate nor reset the streak.
_add_attempt("blocked", "fp-same", block_reason="no_advisory",
attempt=BLOCKED_ATTEMPT_FINGERPRINT_CAP, phase="preflight")
assert check_blocked_attempt_cap(ctx, "fp-same") == ""
monkeypatch.delenv("OUROBOROS_REVIEW_MAX_CYCLES", raising=False)
ctx = _identical_ctx(tmp_path)
# At the cap: refused.
_add_attempt("blocked", "fp-same", attempt=BLOCKED_ATTEMPT_FINGERPRINT_CAP + 1)
msg = check_blocked_attempt_cap(ctx, "fp-same")
assert "REVIEW_ATTEMPT_CAP" in msg
# Infra-only history: retry freely, never a refusal.
_add_attempt(tmp_path, "blocked", "fp-i", block_reason="review_quorum",
block_class="infra")
_add_attempt(tmp_path, "blocked", "fp-i", block_reason="fixed_overflow",
block_class="infra", attempt=2)
assert check_identical_verdict_refusal(ctx, "fp-i", contract_fingerprint=CONTRACT_FP) == ""
# A rebuttal-bearing call is exempt (rebuttal IS new review input).
assert check_blocked_attempt_cap(ctx, "fp-same", has_rebuttal=True) == ""
# A different staged diff starts fresh.
assert check_blocked_attempt_cap(ctx, "fp-other") == ""
# Cap-refusal records themselves must not reset the streak.
_add_attempt("blocked", "fp-same", block_reason="attempt_cap_reached",
attempt=BLOCKED_ATTEMPT_FINGERPRINT_CAP + 2)
assert "REVIEW_ATTEMPT_CAP" in check_blocked_attempt_cap(ctx, "fp-same")
# A successful commit breaks the streak.
_add_attempt("committed", "fp-same", attempt=BLOCKED_ATTEMPT_FINGERPRINT_CAP + 3)
assert check_blocked_attempt_cap(ctx, "fp-same") == ""
# A verdict-block, then infra + preflight noise: still refused.
_add_attempt(tmp_path, "blocked", "fp-i", attempt=3, block_class="verdict")
_add_attempt(tmp_path, "blocked", "fp-i", block_reason="review_quorum",
block_class="infra", attempt=4)
_add_attempt(tmp_path, "blocked", "fp-i", block_reason="tests_preflight_blocked",
phase="preflight", attempt=5)
_add_attempt(tmp_path, "blocked", "", block_reason="tests_preflight_blocked",
phase="preflight", task_id="t-new", attempt=1)
# machine-2: a FAILED infra/expired row (lock timeout, path error, expired
# reviewing attempt) is a transient too — it must not reset the streak.
_add_attempt(tmp_path, "failed", "fp-i", phase="infra", attempt=6, paid=False)
_add_attempt(tmp_path, "failed", "fp-i", phase="expired", attempt=7)
assert "IDENTICAL_DIFF_REFUSED" in check_identical_verdict_refusal(
ctx, "fp-i", contract_fingerprint=CONTRACT_FP)
# A POST-REVIEW failure (the paid review completed, usually with a PASS)
# supersedes the old verdict and ends the streak.
_add_attempt(tmp_path, "failed", "fp-i", phase="post_commit_tests", attempt=8)
assert check_identical_verdict_refusal(ctx, "fp-i", contract_fingerprint=CONTRACT_FP) == ""
def test_tests_preflight_block_recorded_with_preflight_phase():
"""The tests-preflight `_record_commit_attempt` call site must stamp
phase="preflight": without it `infer_review_phase` defaults a blocked
record to "blocking_review" and the identical-diff cap would count a flaky
test failure as a review verdict (inflating the streak same-task) or break
the streak from a new task (empty inherited fingerprint)."""
record to "blocking_review" and legacy-row classification could read a
flaky test failure as a review verdict for the identical-diff refusal."""
git_mod = _get_git_module()
source = inspect.getsource(git_mod)
idx = source.find('block_reason="tests_preflight_blocked"')
@ -137,59 +228,46 @@ def test_tests_preflight_block_recorded_with_preflight_phase():
assert 'phase="preflight"' in window
def test_blocked_attempt_cap_ignores_tests_preflight_blocks(tmp_path, monkeypatch):
"""A tests-preflight block recorded the way ouroboros/tools/git.py records
it (block_reason=tests_preflight_blocked, phase=preflight) neither inflates
nor resets the identical-diff streak — in BOTH directions: same-task with
an inherited fingerprint, and new-task with an empty fingerprint."""
import pathlib
def test_legacy_rows_classify_by_block_reason(tmp_path, monkeypatch):
"""Pre-upgrade ledger rows carry no block_class: critical_findings rows
keep building the refusal streak (verdict), while quorum/fit/transport
rows classify infra and never refuse; preflight/refusal rows stay
unclassified."""
import types as _types
from ouroboros.review_state import (
CommitAttemptRecord,
make_repo_key,
update_state,
_utc_now,
)
from ouroboros.tools.commit_gate import (
blocked_attempt_fingerprint_cap,
check_blocked_attempt_cap,
BLOCK_CLASS_INFRA,
BLOCK_CLASS_VERDICT,
attempt_block_class,
check_identical_verdict_refusal,
)
monkeypatch.delenv("OUROBOROS_REVIEW_MAX_CYCLES", raising=False)
BLOCKED_ATTEMPT_FINGERPRINT_CAP = blocked_attempt_fingerprint_cap()
assert BLOCKED_ATTEMPT_FINGERPRINT_CAP == 2 # shared default (was a hardcoded 3)
ctx = types.SimpleNamespace(repo_dir=tmp_path, drive_root=tmp_path, task_id="t-cap")
repo_key = make_repo_key(pathlib.Path(tmp_path))
def _legacy(status, block_reason, phase="blocking_review", scope_raw=None):
return _types.SimpleNamespace(
status=status, block_reason=block_reason, phase=phase,
block_class="", scope_raw_result=scope_raw or {},
)
def _add(status, fingerprint, block_reason="critical_findings",
phase="blocking_review", task_id="t-cap"):
def _mutate(state):
state.attempts.append(CommitAttemptRecord(
ts=_utc_now(), commit_message="msg", status=status,
block_reason=block_reason if status == "blocked" else "",
repo_key=repo_key, tool_name="commit_reviewed", task_id=task_id,
attempt=1, phase=phase,
pre_review_fingerprint=fingerprint,
))
update_state(pathlib.Path(tmp_path), _mutate)
assert attempt_block_class(_legacy("blocked", "critical_findings")) == BLOCK_CLASS_VERDICT
assert attempt_block_class(_legacy("blocked", "review_quorum")) == BLOCK_CLASS_INFRA
assert attempt_block_class(_legacy("blocked", "fixed_overflow")) == BLOCK_CLASS_INFRA
assert attempt_block_class(_legacy("blocked", "no_advisory", phase="advisory_gate")) == ""
assert attempt_block_class(_legacy("blocked", "attempt_cap_reached", phase="preflight")) == ""
# Legacy scope_blocked rows: verdict only when a RESPONDED actor row
# carried critical findings; sub-floor/overflow scope blocks are infra.
responded = {"raw_results": [{"status": "responded", "critical_findings": [{"item": "x"}]}]}
sub_floor = {"raw_results": [{"status": "sub_floor", "critical_findings": []}]}
assert attempt_block_class(_legacy("blocked", "scope_blocked", scope_raw=responded)) == BLOCK_CLASS_VERDICT
assert attempt_block_class(_legacy("blocked", "scope_blocked", scope_raw=sub_floor)) == BLOCK_CLASS_INFRA
# CAP-1 genuine verdicts, then a tests-preflight block with the SAME
# inherited fingerprint: must NOT count as the capping verdict.
for _ in range(BLOCKED_ATTEMPT_FINGERPRINT_CAP - 1):
_add("blocked", "fp-x")
_add("blocked", "fp-x", block_reason="tests_preflight_blocked", phase="preflight")
assert check_blocked_attempt_cap(ctx, "fp-x") == ""
# One more genuine verdict reaches the cap.
_add("blocked", "fp-x")
assert "REVIEW_ATTEMPT_CAP" in check_blocked_attempt_cap(ctx, "fp-x")
# A NEW-task tests-preflight block with an EMPTY fingerprint (no inherited
# stage in that task yet) must not break the capped streak either.
_add("blocked", "", block_reason="tests_preflight_blocked",
phase="preflight", task_id="t-new")
assert "REVIEW_ATTEMPT_CAP" in check_blocked_attempt_cap(ctx, "fp-x")
# End-to-end on the ledger: a legacy critical_findings row (no block_class)
# still refuses the identical resubmission.
ctx = _identical_ctx(tmp_path)
_add_attempt(tmp_path, "blocked", "fp-legacy", block_class="")
assert "IDENTICAL_DIFF_REFUSED" in check_identical_verdict_refusal(
ctx, "fp-legacy", contract_fingerprint=CONTRACT_FP)
def test_non_committing_review_cycle_exists_and_reuses_shared_stage_cycle():
@ -510,16 +588,18 @@ def test_review_status_registered():
def test_advisory_gate_in_repo_commit_push():
"""The shared reviewed stage must gate review on advisory freshness."""
"""The shared reviewed stage must gate review on advisory freshness (the
check lives in the extracted _advisory_and_tests_gate helper, called before
any paid dispatch and after the free Max-Review-Cycles gate)."""
git_mod = _get_git_module()
source = inspect.getsource(git_mod._run_reviewed_stage_cycle)
assert "_check_advisory_freshness" in source
# Advisory gate must come before parallel review (which contains unified review)
advisory_pos = source.find("_check_advisory_freshness")
gate_pos = source.find("_advisory_and_tests_gate")
review_pos = source.find("_run_parallel_review")
assert advisory_pos != -1, "_check_advisory_freshness not found in _run_reviewed_stage_cycle"
assert gate_pos != -1, "_advisory_and_tests_gate not found in _run_reviewed_stage_cycle"
assert review_pos != -1, "_run_parallel_review not found in _run_reviewed_stage_cycle"
assert advisory_pos < review_pos, "Advisory gate must precede parallel review"
assert gate_pos < review_pos, "Advisory gate must precede parallel review"
gate_source = inspect.getsource(git_mod._advisory_and_tests_gate)
assert "_check_advisory_freshness" in gate_source
# Verify _run_parallel_review contains _run_unified_review
parallel_source = inspect.getsource(git_mod._run_parallel_review)
assert "_run_unified_review" in parallel_source

View file

@ -95,9 +95,14 @@ def test_getter_env_or_default_and_fail_closed_logged_once(monkeypatch, caplog):
def test_settings_file_roundtrip_projects_unlimited_into_env(monkeypatch, tmp_path):
# apply_settings_to_env writes os.environ directly; register the key with monkeypatch
# first so the projected value is restored after the test (no cross-test leak).
monkeypatch.delenv(KEY, raising=False)
# apply_settings_to_env writes os.environ directly. SETENV-then-DELENV is
# what registers the key's original (absent) state with monkeypatch:
# pytest's delenv on an ABSENT key records NOTHING, so the previous
# delenv-only registration silently LEAKED the projected "3" into sibling
# tests on the same xdist worker (observed: improvement-pass caps reading
# cycles=3 in test_review_verification_v6544).
monkeypatch.setenv(KEY, "registered-for-restore")
monkeypatch.delenv(KEY)
settings_path = tmp_path / "settings.json"
settings_path.write_text(json.dumps({KEY: "unlimited"}), encoding="utf-8")
monkeypatch.setattr(cfg, "SETTINGS_PATH", settings_path)
@ -183,10 +188,13 @@ def test_improvement_pass_gate_and_rails_follow_shared_cap(monkeypatch):
# ---------------------------------------------------------------------------
# Commit gate reads the live value
# Commit gate reads the live value: the shared knob bounds PAID triad+scope
# cycles per ROOT task (Q16/Q23); the identical-diff refusal is knob-independent
# and covered in tests/test_commit_gate.py + tests/test_review_cycles_gates.py.
def _seed_blocks(tmp_path, count, fingerprint="fp-same"):
def _seed_paid_attempts(tmp_path, count, *, start=1, root="root-1", status="succeeded",
block_reason="", block_class="", phase="commit", paid=True):
from ouroboros.review_state import CommitAttemptRecord, make_repo_key, update_state, _utc_now
repo_key = make_repo_key(pathlib.Path(tmp_path))
@ -194,55 +202,107 @@ def _seed_blocks(tmp_path, count, fingerprint="fp-same"):
def _mutate(state):
for i in range(count):
state.attempts.append(CommitAttemptRecord(
ts=_utc_now(), commit_message="msg", status="blocked",
block_reason="critical_findings", repo_key=repo_key,
tool_name="commit_reviewed", task_id="t-cap", attempt=i + 1,
phase="blocking_review", pre_review_fingerprint=fingerprint,
ts=_utc_now(), commit_message="msg", status=status,
block_reason=block_reason, block_class=block_class,
repo_key=repo_key, tool_name="commit_reviewed", task_id=root,
attempt=start + i, phase=phase, paid=paid,
pre_review_fingerprint=f"fp-{start + i}",
root_task_id=root,
))
update_state(pathlib.Path(tmp_path), _mutate)
def test_commit_gate_cap_reads_live_setting(monkeypatch, tmp_path):
from ouroboros.tools.commit_gate import blocked_attempt_fingerprint_cap, check_blocked_attempt_cap
def test_commit_gate_ceiling_reads_live_setting(monkeypatch, tmp_path):
from ouroboros.tools.commit_gate import (
check_review_cycles_ceiling,
count_paid_review_cycles,
)
ctx = types.SimpleNamespace(repo_dir=tmp_path, drive_root=tmp_path, task_id="t-cap")
assert blocked_attempt_fingerprint_cap() == 2 # default moved 3 -> 2 (D20, disclosed)
_seed_blocks(tmp_path, 1)
assert check_blocked_attempt_cap(ctx, "fp-same") == ""
_seed_blocks(tmp_path, 1) # streak 2 == cap: refused
msg = check_blocked_attempt_cap(ctx, "fp-same")
assert "REVIEW_ATTEMPT_CAP" in msg and "2 times in a row" in msg
ctx = types.SimpleNamespace(repo_dir=tmp_path, drive_root=tmp_path, task_id="root-1")
assert rc.review_max_cycles() == 2 # default moved 3 -> 2 (D20, disclosed)
_seed_paid_attempts(tmp_path, 1)
assert count_paid_review_cycles(ctx, root_task_id="root-1") == 1
assert check_review_cycles_ceiling(ctx, root_task_id="root-1") is None
_seed_paid_attempts(tmp_path, 1, start=2) # 2 paid == cap: refused
exhausted = check_review_cycles_ceiling(ctx, root_task_id="root-1")
assert exhausted is not None and "REVIEW_CYCLES_EXHAUSTED" in exhausted["message"]
assert exhausted["cycles_paid"] == 2 and exhausted["cap"] == 2
# Another root task has its own ceiling.
assert check_review_cycles_ceiling(ctx, root_task_id="root-2") is None
# An unknown root never gates (fail-open, disclosed).
assert check_review_cycles_ceiling(ctx, root_task_id="") is None
# Raise the shared cap: the SAME ledger is below it again.
monkeypatch.setenv(KEY, "4")
assert blocked_attempt_fingerprint_cap() == 4
assert check_blocked_attempt_cap(ctx, "fp-same") == ""
_seed_blocks(tmp_path, 2) # streak 4
assert "REVIEW_ATTEMPT_CAP" in check_blocked_attempt_cap(ctx, "fp-same")
# Unlimited: never cap, however long the identical-diff streak.
assert check_review_cycles_ceiling(ctx, root_task_id="root-1") is None
_seed_paid_attempts(tmp_path, 2, start=3)
assert check_review_cycles_ceiling(ctx, root_task_id="root-1") is not None
# Unlimited: never a ceiling, however many paid cycles.
monkeypatch.setenv(KEY, "unlimited")
assert blocked_attempt_fingerprint_cap() is None
_seed_blocks(tmp_path, 10)
assert check_blocked_attempt_cap(ctx, "fp-same") == ""
_seed_paid_attempts(tmp_path, 10, start=5)
assert check_review_cycles_ceiling(ctx, root_task_id="root-1") is None
# Garbage fails closed to the bounded default (2), never to "no cap".
monkeypatch.setenv(KEY, "lots")
assert blocked_attempt_fingerprint_cap() == 2
assert "REVIEW_ATTEMPT_CAP" in check_blocked_attempt_cap(ctx, "fp-same")
assert check_review_cycles_ceiling(ctx, root_task_id="root-1") is not None
def test_paid_cycle_count_counts_dispatched_money_only(tmp_path, monkeypatch):
"""machine-5 contract: the ceiling counts MONEY. Every row that physically
dispatched a wave (paid=True) counts whatever its terminal — verdict-block,
pass, success, quorum-failed infra-block, crashed/expired failure alike.
Only undispatched rows (paid=False: legacy history, free replays,
preflight refusals) stay outside the count."""
from ouroboros.tools.commit_gate import count_paid_review_cycles
monkeypatch.delenv(KEY, raising=False)
ctx = types.SimpleNamespace(repo_dir=tmp_path, drive_root=tmp_path, task_id="root-1")
_seed_paid_attempts(tmp_path, 1, status="blocked", block_reason="critical_findings",
block_class="verdict", phase="blocking_review")
_seed_paid_attempts(tmp_path, 1, start=2, status="reviewed", phase="review_only")
_seed_paid_attempts(tmp_path, 1, start=3) # succeeded
assert count_paid_review_cycles(ctx, root_task_id="root-1") == 3
# Dispatched-then-degraded waves spent reviewer money: they count.
_seed_paid_attempts(tmp_path, 1, start=4, status="blocked", block_reason="review_quorum",
block_class="infra", phase="blocking_review")
_seed_paid_attempts(tmp_path, 1, start=5, status="failed", block_reason="infra_failure",
phase="expired")
_seed_paid_attempts(tmp_path, 1, start=6, status="failed",
block_reason="post_commit_tests_failed", phase="post_commit_tests")
assert count_paid_review_cycles(ctx, root_task_id="root-1") == 6
# Undispatched rows (legacy / free replay / refusals) never count.
_seed_paid_attempts(tmp_path, 1, start=7, paid=False)
_seed_paid_attempts(tmp_path, 1, start=8, status="blocked", paid=False,
block_reason="identical_diff_refused", phase="preflight")
assert count_paid_review_cycles(ctx, root_task_id="root-1") == 6
def test_commit_gate_has_no_import_time_cap_constant():
import ouroboros.tools.commit_gate as commit_gate
assert not hasattr(commit_gate, "BLOCKED_ATTEMPT_FINGERPRINT_CAP")
# The pre-Q16 paid-identical-re-review API is retired: identical bytes are
# refused free from the first verdict-block, not re-reviewed up to a cap.
assert not hasattr(commit_gate, "check_blocked_attempt_cap")
assert not hasattr(commit_gate, "blocked_attempt_fingerprint_cap")
def test_advisory_review_schema_note_derives_from_shared_cap(monkeypatch):
def test_advisory_review_schema_note_states_paid_cycle_semantics(monkeypatch):
from ouroboros.tools.claude_advisory_review import _identical_diff_cap_note
assert "after 2 genuine review-verdict block(s)" in _identical_diff_cap_note()
note = _identical_diff_cap_note()
assert "identical bytes are never re-reviewed for pay" in note
assert "identical_diff_refused" in note
assert "after 2 paid cycle(s)" in note
assert "per ROOT task" in note # wording-5: the tree shares one ceiling
# F5 honesty caveat: "refused" is enforcement-dependent — advisory commits
# proceed with a loud disclosure and no new spend.
assert "under blocking enforcement the commit is refused for free" in note
assert "under advisory the commit proceeds with a loud durable disclosure" in note
monkeypatch.setenv(KEY, "5")
assert "after 5 genuine" in _identical_diff_cap_note()
assert "after 5 paid cycle(s)" in _identical_diff_cap_note()
monkeypatch.setenv(KEY, "unlimited")
assert "no identical-diff cap is configured" in _identical_diff_cap_note()
note = _identical_diff_cap_note()
assert "no per-root-task ceiling" in note
assert "identical bytes are never re-reviewed for pay" in note # knob-independent
source = (REPO / "ouroboros" / "tools" / "claude_advisory_review.py").read_text(encoding="utf-8")
assert "after 3 genuine" not in source

View file

@ -0,0 +1,607 @@
"""Max-Review-Cycles fix round: dispatch-time paid accounting and the
authority-preserving attempt-ledger eviction.
Contract under test (accepted panel fixes F1-F3, F4a, fable P3-2/P3-3):
* F1 — trimming the commit-attempt ledger never evicts paid rows or the
verdict anchors of the identical-diff refusal streak: a capped root cannot
loop free refusals until the ceiling and the quoted verdict forget
themselves;
* F2 — the commit gate's paid fact lands WRITE-AHEAD at the first PHYSICAL
reviewer dispatch (either side): an attempt where both packs refuse at
assembly spends $0 and consumes no ceiling cycle, while one dispatched side
makes the cycle paid;
* F3 — skill review shares ONE durable dispatch marker between lifecycle and
direct callers: every dispatched wave (substantive verdict, quorum failure,
transport failure, crash-after-dispatch) yields exactly one paid unit in
the derived count, on a REAL history jsonl;
* F4a — a rebuttal's "spent" memory is scoped to the CURRENT panel-contract
fingerprint;
* P3-3 — under advisory enforcement each free-replay reason drives the full
stage cycle to a passing commit with its own honest progress note and a
disclosure that lands in the commit result formatting.
"""
from __future__ import annotations
import json
import pathlib
import subprocess
import types
import pytest
from tests._shared import ensure_claude_agent_sdk_mock
ensure_claude_agent_sdk_mock()
KEY = "OUROBOROS_REVIEW_MAX_CYCLES"
@pytest.fixture(autouse=True)
def _clean_env(monkeypatch):
monkeypatch.delenv(KEY, raising=False)
monkeypatch.delenv("OUROBOROS_REVIEW_ENFORCEMENT", raising=False)
yield
# ---------------------------------------------------------------------------
# F1 — authority-preserving attempt-ledger eviction
def test_free_refusal_flood_cannot_evict_paid_rows_or_the_verdict_anchor(
tmp_path, monkeypatch,
):
"""60+ free refusals after the cap is reached change NOTHING: the paid
count stays, and the identical-diff refusal still quotes the original
verdict — only the refusal noise is trimmed."""
import ouroboros.tools.git as git_mod
from ouroboros.review_state import (
CommitAttemptRecord,
load_state,
make_repo_key,
update_state,
_utc_now,
)
from ouroboros.tools.commit_gate import count_paid_review_cycles
monkeypatch.setenv("OUROBOROS_REVIEW_ENFORCEMENT", "blocking")
monkeypatch.setenv(KEY, "2")
monkeypatch.setattr(git_mod, "commit_review_contract_fingerprint", lambda: "cf-1")
monkeypatch.setattr(git_mod, "run_cmd", lambda *a, **k: "")
monkeypatch.setattr(git_mod, "_authorized_managed_update_resolver", lambda ctx: False)
(pathlib.Path(tmp_path) / "logs").mkdir(parents=True, exist_ok=True)
ctx = types.SimpleNamespace(
repo_dir=tmp_path, drive_root=pathlib.Path(tmp_path), task_id="root-1",
task_metadata={}, event_queue=None,
drive_logs=lambda: pathlib.Path(tmp_path) / "logs",
_current_review_tool_name="commit_reviewed",
)
repo_key = make_repo_key(pathlib.Path(tmp_path))
def _seed(state):
for attempt, (status, phase, paid) in enumerate(
[("succeeded", "commit", True), ("blocked", "blocking_review", True)], start=1,
):
state.attempts.append(CommitAttemptRecord(
ts=_utc_now(), commit_message="m", status=status, phase=phase,
block_reason="critical_findings" if status == "blocked" else "",
block_class="verdict" if status == "blocked" else "",
critical_findings=(
[{"item": "bug_original", "reason": "the anchor", "severity": "critical"}]
if status == "blocked" else []
),
repo_key=repo_key, tool_name="commit_reviewed", task_id="root-1",
attempt=attempt, paid=paid, root_task_id="root-1",
pre_review_fingerprint="fp-1",
review_contract_fingerprint="cf-1",
))
update_state(pathlib.Path(tmp_path), _seed)
assert count_paid_review_cycles(ctx, root_task_id="root-1") == 2
for _ in range(60):
outcome = git_mod._free_cycle_gate(
ctx, "msg", 0.0, pre_fingerprint={"fingerprint": "fp-1"}, review_rebuttal="",
)
assert outcome is not None and outcome["status"] == "blocked"
assert outcome["block_reason"] == "identical_diff_refused"
assert "bug_original" in outcome["message"] # the anchor is still quoted
assert count_paid_review_cycles(ctx, root_task_id="root-1") == 2
state = load_state(pathlib.Path(tmp_path))
rows = state.filter_attempts(repo_key=repo_key)
# The authority rows survived the flood; the noise portion stayed capped.
assert any(r.paid and r.status == "succeeded" for r in rows)
assert any(r.block_class == "verdict" and r.pre_review_fingerprint == "fp-1" for r in rows)
assert len([r for r in rows if not r.paid and r.block_class != "verdict"]) <= 50
def test_ledger_growth_stays_bounded_while_accounting_authority_survives(tmp_path):
"""F1 follow-up (strip-not-evict): flooding far past the 50-row window with
HEAVY paid rows plus refusal noise must keep the serialized ledger bounded
— over-window preserved rows lose their raw payloads (raw_stripped=True) —
while every accounting fact and the refusal-quote behavior survive."""
from ouroboros.review_state import (
CommitAttemptRecord,
load_state,
make_repo_key,
update_state,
_utc_now,
)
from ouroboros.tools.commit_gate import (
check_identical_verdict_refusal,
count_paid_review_cycles,
)
repo_key = make_repo_key(pathlib.Path(tmp_path))
heavy_raw = "HEAVYRAWX" * 600 # ~5.4KB per payload field
def _flood(state):
for i in range(1, 61):
# A paid verdict-blocked wave with FULL forensic payloads …
state.record_attempt(CommitAttemptRecord(
ts=_utc_now(), commit_message="m" * 400, status="blocked",
block_reason="critical_findings", block_class="verdict",
block_details="details " + heavy_raw,
repo_key=repo_key, tool_name="commit_reviewed", task_id="root-1",
attempt=2 * i - 1, phase="blocking_review", paid=True,
root_task_id="root-1", pre_review_fingerprint="fp-1",
review_contract_fingerprint="cf-1",
critical_findings=[{"item": "bug_anchor", "reason": "still broken",
"severity": "critical"}],
triad_raw_results=[{"model_id": "m1", "raw_text": heavy_raw}],
scope_raw_result={"status": "responded", "raw_text": heavy_raw,
"raw_results": [{"status": "responded",
"critical_findings": [{"item": "x"}]}]},
))
# … followed by a free-refusal noise row.
state.record_attempt(CommitAttemptRecord(
ts=_utc_now(), commit_message="m", status="blocked",
block_reason="identical_diff_refused", block_details="refused",
repo_key=repo_key, tool_name="commit_reviewed", task_id="root-1",
attempt=2 * i, phase="preflight", pre_review_fingerprint="fp-1",
review_contract_fingerprint="cf-1",
))
update_state(pathlib.Path(tmp_path), _flood)
ctx = types.SimpleNamespace(
repo_dir=tmp_path, drive_root=pathlib.Path(tmp_path), task_id="root-1",
task_metadata={}, _current_review_tool_name="commit_reviewed",
)
# Every paid dispatch is still counted; the refusal still quotes the verdict.
assert count_paid_review_cycles(ctx, root_task_id="root-1") == 60
refusal = check_identical_verdict_refusal(ctx, "fp-1", contract_fingerprint="cf-1")
assert "IDENTICAL_DIFF_REFUSED" in refusal and "bug_anchor" in refusal
state = load_state(pathlib.Path(tmp_path))
rows = state.filter_attempts(repo_key=repo_key)
over_window, in_window = rows[:-50], rows[-50:]
assert len(rows) > 50 and over_window # preservation forced past the cap
for row in over_window:
assert row.raw_stripped is True
assert row.triad_raw_results == [] and row.scope_raw_result == {}
assert len(row.block_details) <= 700 and len(row.commit_message) <= 400
# The accounting facts are intact on every compacted row.
assert row.paid is True and row.block_class == "verdict"
assert row.root_task_id == "root-1"
assert row.review_contract_fingerprint == "cf-1"
assert row.critical_findings and row.critical_findings[0]["item"] == "bug_anchor"
# The serialized ledger carries full raw payloads ONLY inside the window:
# every heavy-sentinel occurrence is accounted for by in-window rows, and
# each compacted over-window row serializes to a small bounded record —
# the immortal portion grows ~O(preserved rows x small record), never by
# full reviewer raw output per reviewed commit.
import dataclasses
raw = (pathlib.Path(tmp_path) / "state" / "advisory_review.json").read_text(encoding="utf-8")
heavy_in_window = sum(
1 for row in in_window if row.triad_raw_results or row.scope_raw_result
)
assert heavy_in_window <= 50
in_window_sentinels = sum(
json.dumps(dataclasses.asdict(row)).count("HEAVYRAWX" * 600) for row in in_window
)
assert raw.count("HEAVYRAWX" * 600) == in_window_sentinels > 0
for row in over_window:
assert len(json.dumps(dataclasses.asdict(row))) < 4_000
# ---------------------------------------------------------------------------
# F2 / P3-3 — the write-ahead paid stamp on the real stage cycle
def _stage_cycle_harness(tmp_path, monkeypatch, *, fingerprint):
"""A REAL git repo with a stageable change plus the heavy collaborators
(advisory gate, binding, fingerprint) pinned, so _run_reviewed_stage_cycle
runs its true order: free gate -> advisory gate -> dispatch."""
import ouroboros.tools.git as git_mod
repo = pathlib.Path(tmp_path) / "repo"
repo.mkdir()
for cmd in (
["git", "init"],
["git", "config", "user.email", "t@t"],
["git", "config", "user.name", "t"],
):
subprocess.run(cmd, cwd=repo, check=True, capture_output=True)
(repo / "f.txt").write_text("base\n", encoding="utf-8")
subprocess.run(["git", "add", "-A"], cwd=repo, check=True, capture_output=True)
subprocess.run(["git", "commit", "-m", "base"], cwd=repo, check=True, capture_output=True)
(repo / "f.txt").write_text("changed\n", encoding="utf-8")
drive = pathlib.Path(tmp_path) / "drive"
(drive / "logs").mkdir(parents=True, exist_ok=True)
progress: list = []
ctx = types.SimpleNamespace(
repo_dir=repo, drive_root=drive, task_id="root-1", task_metadata={},
event_queue=None, branch_dev="dev",
drive_logs=lambda: drive / "logs",
emit_progress_fn=progress.append,
_current_review_tool_name="commit_reviewed",
_review_advisory=[],
_last_triad_models=[], _last_scope_model="",
_last_triad_raw_results=[], _last_scope_raw_result={},
_review_degraded_reasons=[],
)
monkeypatch.setattr(git_mod, "commit_review_contract_fingerprint", lambda: "cf-1")
monkeypatch.setattr(
git_mod, "_fingerprint_staged_diff",
lambda repo_dir: {"ok": True, "fingerprint": fingerprint},
)
monkeypatch.setattr(git_mod, "_advisory_and_tests_gate", lambda *a, **k: None)
monkeypatch.setattr(git_mod, "_review_binding_precondition_error", lambda *a, **k: "")
return git_mod, ctx, progress
def _overflow_wave(dispatch):
"""A wave whose BOTH sides land infra-blocked; ``dispatch`` controls
whether the (real) transport seam was reached before the refusal."""
from ouroboros.review_dispatch import stamp_review_paid_on_dispatch
from ouroboros.tools.scope_review import ScopeReviewResult
def _wave(ctx, commit_message, **kwargs):
if dispatch:
stamp_review_paid_on_dispatch(ctx) # exactly what run_review_request does
ctx._last_review_block_reason = "fixed_overflow"
ctx._last_review_critical_findings = []
scope = ScopeReviewResult(
blocked=True,
block_message="⚠️ SCOPE_REVIEW_BLOCKED: pack did not assemble.",
status="fixed_overflow",
)
return "⚠️ REVIEW_BLOCKED: prompt cannot fit.", scope, "fixed_overflow", []
return _wave
def test_all_assembly_refused_attempt_stays_unpaid(tmp_path, monkeypatch):
"""F2(a): BOTH packs refusing at assembly ($0 spent) must not consume a
ceiling cycle — with the default cap this used to exhaust a root for free."""
from ouroboros.tools.commit_gate import count_paid_review_cycles
git_mod, ctx, _progress = _stage_cycle_harness(tmp_path, monkeypatch, fingerprint="fp-a")
monkeypatch.setattr(git_mod, "_run_parallel_review", _overflow_wave(dispatch=False))
outcome = git_mod._run_reviewed_stage_cycle(ctx, "msg", 0.0)
assert outcome["status"] == "blocked" and outcome["block_reason"] == "fixed_overflow"
assert count_paid_review_cycles(ctx, root_task_id="root-1") == 0
from ouroboros.review_state import load_state, make_repo_key
rows = load_state(ctx.drive_root).filter_attempts(
repo_key=make_repo_key(pathlib.Path(ctx.repo_dir)))
assert rows and rows[-1].paid is False and rows[-1].block_class == "infra"
def test_one_side_dispatched_attempt_counts_as_paid(tmp_path, monkeypatch):
"""F2(b): parallel dispatch means one side can spend while the other
overflows at assembly — any side dispatching makes the cycle paid."""
from ouroboros.tools.commit_gate import count_paid_review_cycles
git_mod, ctx, _progress = _stage_cycle_harness(tmp_path, monkeypatch, fingerprint="fp-b")
monkeypatch.setattr(git_mod, "_run_parallel_review", _overflow_wave(dispatch=True))
outcome = git_mod._run_reviewed_stage_cycle(ctx, "msg", 0.0)
assert outcome["status"] == "blocked"
assert count_paid_review_cycles(ctx, root_task_id="root-1") == 1
assert ctx._review_paid_stamp is None # the seam never leaks past the wave
@pytest.mark.parametrize(
"seed,expected_reason,expected_note_part",
[
("verdict", "identical_diff_refused", "reusing the recorded"),
("ceiling", "review_cycles_exhausted", "no review outcome"),
],
)
def test_advisory_replay_reasons_drive_the_stage_cycle_to_a_disclosed_pass(
tmp_path, monkeypatch, seed, expected_reason, expected_note_part,
):
"""fable P3-3 (end to end): under advisory each free-replay reason lets the
REAL stage cycle pass without any dispatch, with its own honest progress
note, and the disclosure lands in the commit result formatting."""
from ouroboros.review_state import CommitAttemptRecord, make_repo_key, update_state, _utc_now
monkeypatch.setenv("OUROBOROS_REVIEW_ENFORCEMENT", "advisory")
git_mod, ctx, progress = _stage_cycle_harness(tmp_path, monkeypatch, fingerprint="fp-r")
monkeypatch.setattr(
git_mod, "_run_parallel_review",
lambda *a, **k: (_ for _ in ()).throw(AssertionError("free replay must not dispatch")),
)
repo_key = make_repo_key(pathlib.Path(ctx.repo_dir))
if seed == "verdict":
update_state(ctx.drive_root, lambda s: s.attempts.append(CommitAttemptRecord(
ts=_utc_now(), commit_message="m", status="blocked",
block_reason="critical_findings", block_class="verdict",
repo_key=repo_key, tool_name="commit_reviewed", task_id="root-1",
attempt=1, phase="blocking_review", pre_review_fingerprint="fp-r",
review_contract_fingerprint="cf-1",
critical_findings=[{"item": "bug_q", "reason": "r", "severity": "critical"}],
)))
else:
monkeypatch.setenv(KEY, "1")
update_state(ctx.drive_root, lambda s: s.attempts.append(CommitAttemptRecord(
ts=_utc_now(), commit_message="m", status="succeeded", repo_key=repo_key,
tool_name="commit_reviewed", task_id="root-1", attempt=1, phase="commit",
paid=True, root_task_id="root-1", pre_review_fingerprint="fp-old",
)))
outcome = git_mod._run_reviewed_stage_cycle(ctx, "msg", 0.0)
assert outcome["status"] == "passed"
notes = [n for n in progress if "Max Review Cycles" in n]
assert notes and expected_note_part in notes[0]
# The loud disclosure reached the advisory channel AND the commit result.
assert any(expected_reason in w for w in ctx._review_advisory)
result = git_mod._format_commit_result(ctx, "msg", "", "")
assert "no new triad+scope review was bought" in result
assert expected_reason in result
events = [json.loads(line) for line in
(ctx.drive_root / "logs" / "events.jsonl").read_text(encoding="utf-8").splitlines()]
replays = [e for e in events if e["type"] == "commit_review_free_replay"]
assert replays and replays[-1]["reason"] == expected_reason
# ---------------------------------------------------------------------------
# F3 — the skill dispatch marker: four wave outcomes, one paid unit each
def _fake_manifest():
return types.SimpleNamespace(
name="demo", description="d", version="1", type="tool", runtime="python",
timeout_sec=30, permissions=[], conflicts=[], env_from_settings=[],
requires=[], scripts=[], scheduled_tasks=[], entry="main.py",
is_extension=lambda: False,
)
def _wire_skill_wave(monkeypatch, tmp_path, *, content_hash, passes):
import ouroboros.skill_review_passes as passes_mod
from ouroboros import config as cfg
from ouroboros import skill_review
drive = pathlib.Path(tmp_path)
skill = types.SimpleNamespace(
name="demo", manifest=_fake_manifest(), skill_dir=drive / "skill",
load_error="", review=None, source="",
)
binding = types.SimpleNamespace(state_drive_root=drive)
monkeypatch.setattr(skill_review, "build_resolved_resource_binding", lambda *a, **k: binding)
monkeypatch.setattr(skill_review, "load_bound_skill", lambda b: skill)
monkeypatch.setattr(skill_review, "compute_content_hash", lambda *a, **k: content_hash)
monkeypatch.setattr(skill_review, "_run_deterministic_preflight", lambda *a, **k: None)
monkeypatch.setattr(skill_review, "reviewer_slot_config_error", lambda: "")
monkeypatch.setattr(skill_review, "_build_skill_file_packs", lambda *a, **k: ["pack"])
monkeypatch.setattr(skill_review, "_official_hub_review_profile", lambda s: "")
monkeypatch.setattr(skill_review, "_review_wave_budget_block", lambda *a, **k: None)
monkeypatch.setattr(skill_review, "emit_review_model_error_events", lambda *a, **k: None)
monkeypatch.setattr(skill_review, "save_review_state", lambda *a, **k: None)
monkeypatch.setattr(
skill_review, "auto_grant_if_enabled",
lambda *a, **k: types.SimpleNamespace(
requested_keys=[], granted_keys=[],
requested_permissions=[], granted_permissions=[]),
)
monkeypatch.setattr(cfg, "get_review_models", lambda: ["m1", "m2"])
monkeypatch.setattr(passes_mod, "run_skill_review_passes", passes)
return skill
def _panel_result(models, *, verdict="PASS"):
"""A minimal parseable two-model panel body for the REAL parser: each actor
returns a JSON array answering every required checklist item."""
from ouroboros.skill_review import _SKILL_REVIEW_ITEMS
items = [
{"item": item, "verdict": verdict, "severity": "advisory", "reason": "checked"}
for item in _SKILL_REVIEW_ITEMS
]
return json.dumps({
"model_count": len(models),
"results": [
{"model": model, "text": json.dumps(items), "slot_id": f"slot_{idx + 1}"}
for idx, model in enumerate(models)
],
})
def _dispatching(passes_body):
from ouroboros.review_dispatch import stamp_review_paid_on_dispatch
def _wave(ctx, *a, **k):
stamp_review_paid_on_dispatch(ctx)
return passes_body(ctx)
return _wave
def _paid_units(tmp_path, content_hash):
from ouroboros.skill_review_cycles import count_paid_skill_review_cycles
return count_paid_skill_review_cycles(
pathlib.Path(tmp_path), "demo", "manual:demo", content_hash=content_hash,
)
def _history_rows(tmp_path):
from ouroboros.skill_review_history import review_history_path
path = review_history_path(pathlib.Path(tmp_path), "demo")
if not path.is_file():
return []
return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()]
def test_skill_wave_outcomes_yield_exactly_one_paid_unit_each(tmp_path, monkeypatch):
"""F3 on a REAL jsonl (persist=True): substantive verdict, quorum failure,
transport failure, and exception-after-dispatch each leave exactly ONE
paid unit in the derived count — via the terminal row when one lands, via
the unmerged write-ahead dispatch marker when none does."""
from ouroboros import skill_review
from ouroboros.skill_review_history import load_dispatch_marker
ctx = types.SimpleNamespace(task_id="", task_metadata={}, event_queue=None)
# (1) substantive verdict: terminal row carries the paid facts, marker merged.
_wire_skill_wave(
monkeypatch, tmp_path, content_hash="h-verdict",
passes=_dispatching(lambda c: ("prompt", {}, _panel_result(["m1", "m2"]), "")),
)
outcome = skill_review.review_skill(ctx, "demo", persist=True)
assert outcome.status in ("clean", "warnings", "blockers")
assert outcome.paid is True and outcome.wave_id
assert _paid_units(tmp_path, "h-verdict") == 1
assert load_dispatch_marker(pathlib.Path(tmp_path), "demo") == {} # merged+cleared
row = _history_rows(tmp_path)[-1]
assert row["paid"] is True and row["wave_id"] == outcome.wave_id
assert row["review_contract_fingerprint"] == outcome.review_contract_fingerprint
# (2) quorum failure: the internal history append carries the paid facts.
_wire_skill_wave(
monkeypatch, tmp_path, content_hash="h-quorum",
passes=_dispatching(lambda c: ("prompt", {}, _panel_result(["m1"]), "")),
)
outcome = skill_review.review_skill(ctx, "demo", persist=True)
assert outcome.status == "pending" and outcome.paid is True
assert _paid_units(tmp_path, "h-quorum") == 1
assert load_dispatch_marker(pathlib.Path(tmp_path), "demo") == {}
row = _history_rows(tmp_path)[-1]
assert row["paid"] is True and row["content_hash"] == "h-quorum"
# (3) transport failure: no history row lands, the unmerged marker counts.
_wire_skill_wave(
monkeypatch, tmp_path, content_hash="h-transport",
passes=_dispatching(lambda c: ("prompt", {}, "", "provider exploded")),
)
outcome = skill_review.review_skill(ctx, "demo", persist=True)
assert outcome.status == "pending" and outcome.paid is True
assert _paid_units(tmp_path, "h-transport") == 1
marker = load_dispatch_marker(pathlib.Path(tmp_path), "demo")
assert marker.get("content_hash") == "h-transport" and marker.get("paid") is True
# (4) exception after dispatch: the write-ahead marker survives the crash.
def _boom(ctx_arg):
raise RuntimeError("wave crashed after dispatch")
_wire_skill_wave(
monkeypatch, tmp_path, content_hash="h-crash", passes=_dispatching(_boom),
)
with pytest.raises(RuntimeError):
skill_review.review_skill(ctx, "demo", persist=True)
assert _paid_units(tmp_path, "h-crash") == 1
# Writing marker (4) flushed the orphaned marker (3) into the history as an
# infra terminal — the spend of wave (3) is still exactly one unit.
assert _paid_units(tmp_path, "h-transport") == 1
flushed = [r for r in _history_rows(tmp_path)
if r.get("terminal_reason") == "dispatched_wave_never_finalized"]
assert flushed and flushed[-1]["content_hash"] == "h-transport"
# The seam is restored after every wave.
assert getattr(ctx, "_review_paid_stamp", None) is None
def test_lifecycle_timeout_terminal_merges_the_dispatch_marker(tmp_path, monkeypatch):
"""F3(b): a lifecycle timeout finalizes with NO result object — the
terminal history row still carries the paid facts, merged from the
write-ahead dispatch marker by job id."""
from ouroboros.skill_review_history import load_dispatch_marker, write_dispatch_marker
from ouroboros.skill_review_runner import _mark_review_job_timeout, review_job_state_path
from ouroboros.utils import atomic_write_json
drive = pathlib.Path(tmp_path)
(drive / "logs").mkdir(parents=True, exist_ok=True)
job_path = review_job_state_path(drive, "demo")
job_path.parent.mkdir(parents=True, exist_ok=True)
atomic_write_json(job_path, {
"job_id": "job-77", "status": "running", "skill": "demo",
"content_hash": "h-t", "group_id": "task:root-5:demo", "root_task_id": "root-5",
}, trailing_newline=True)
write_dispatch_marker(
drive, "demo", wave_id="job-77", group_id="task:root-5:demo",
content_hash="h-t", root_task_id="root-5",
review_contract_fingerprint="cf-9", rebuttal_sha256="reb-9",
)
_mark_review_job_timeout(drive, "demo", "h-t", reason="lifecycle_timeout")
rows = _history_rows(tmp_path)
assert rows and rows[-1]["status"] == "timeout"
assert rows[-1]["paid"] is True # merged from the marker, result was None
assert rows[-1]["review_contract_fingerprint"] == "cf-9"
assert rows[-1]["rebuttal_sha256"] == "reb-9"
assert load_dispatch_marker(drive, "demo") == {} # merge cleared it
from ouroboros.skill_review_cycles import count_paid_skill_review_cycles
assert count_paid_skill_review_cycles(drive, "demo", "task:root-5:demo") == 1
def test_terminal_history_append_failure_is_a_loud_typed_event(tmp_path, monkeypatch):
"""F3(c): a swallowed terminal-history append surfaces as a typed event."""
import ouroboros.skill_review_runner as runner
from ouroboros import skill_review_history
drive = pathlib.Path(tmp_path)
(drive / "logs").mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(skill_review_history, "append_history_once", lambda *a, **k: False)
ok = runner._append_terminal_history(
drive, "demo", {"job_id": "j-1"}, status="failed",
terminal_reason="boom", result=None, ts="t1",
)
assert ok is False
events = [json.loads(line) for line in
(drive / "logs" / "events.jsonl").read_text(encoding="utf-8").splitlines()]
failed = [e for e in events if e["type"] == "skill_review_history_append_failed"]
assert failed and failed[0]["skill"] == "demo" and failed[0]["job_id"] == "j-1"
# ---------------------------------------------------------------------------
# F4a — spent-rebuttal memory is scoped to the CURRENT panel contract
def test_spent_rebuttal_memory_lapses_with_the_panel_contract(tmp_path):
from ouroboros.skill_review_history import review_history_path
from ouroboros.skill_review_cycles import find_free_replay_row
path = review_history_path(pathlib.Path(tmp_path), "demo")
path.parent.mkdir(parents=True, exist_ok=True)
rows = [
# reb-1 was answered by a substantive verdict — under the RETIRED contract.
{"ts": "t1", "status": "warnings", "content_hash": "h1", "paid": True,
"group_id": "manual:demo", "review_contract_fingerprint": "cf-old",
"rebuttal_sha256": "reb-1", "job_id": "j1"},
# The current contract has its own substantive verdict for the snapshot.
{"ts": "t2", "status": "warnings", "content_hash": "h1", "paid": True,
"group_id": "manual:demo", "review_contract_fingerprint": "cf-new",
"job_id": "j2"},
]
with open(path, "a", encoding="utf-8") as fh:
for row in rows:
fh.write(json.dumps(row) + "\n")
# Under the current contract reb-1 has never been adjudicated: it buys the
# paid rerun (no replay) instead of being refused as already spent.
assert find_free_replay_row(
pathlib.Path(tmp_path), "demo", group_id="manual:demo", content_hash="h1",
contract_fingerprint="cf-new", rebuttal_sha256="reb-1",
) is None
# Once the CURRENT contract has answered it, the same hash replays free.
with open(path, "a", encoding="utf-8") as fh:
fh.write(json.dumps(
{"ts": "t3", "status": "warnings", "content_hash": "h1", "paid": True,
"group_id": "manual:demo", "review_contract_fingerprint": "cf-new",
"rebuttal_sha256": "reb-1", "job_id": "j3"}) + "\n")
assert find_free_replay_row(
pathlib.Path(tmp_path), "demo", group_id="manual:demo", content_hash="h1",
contract_fingerprint="cf-new", rebuttal_sha256="reb-1",
) is not None

View file

@ -0,0 +1,743 @@
"""Max-Review-Cycles semantics on the commit gate and skill review (Δ5/Δ6;
owner decisions Q12/Q16/Q17/Q22/Q23).
Contract under test:
* blocked commit attempts are TYPED (verdict vs infra) at record time;
* the paid fact is recorded at dispatch on the attempt ledger and survives the
terminal merge; per-root-task counts are derived from it (P7);
* the free-cycle gate runs BEFORE advisory freshness: blocking → free typed
refusal (managed resolvers get MERGE_HEAD repaired), advisory → free replay
marker (typed event, commit proceeds, no paid dispatch);
* skill review: panel-contract fingerprint, per-key paid-cycle counting,
$0 replay of an identical substantive verdict, typed exhaustion refusal,
and the Max-Review-Cycles facts on terminal history rows.
"""
from __future__ import annotations
import json
import pathlib
import types
import pytest
from tests._shared import ensure_claude_agent_sdk_mock
ensure_claude_agent_sdk_mock()
REPO = pathlib.Path(__file__).resolve().parents[1]
KEY = "OUROBOROS_REVIEW_MAX_CYCLES"
@pytest.fixture(autouse=True)
def _clean_env(monkeypatch):
monkeypatch.delenv(KEY, raising=False)
monkeypatch.delenv("OUROBOROS_REVIEW_ENFORCEMENT", raising=False)
yield
# ---------------------------------------------------------------------------
# Block-row classification at record time (Δ5.1)
def test_classify_review_block_verdict_vs_infra():
from ouroboros.tools.commit_gate import classify_review_block
# Triad critical findings = verdict.
assert classify_review_block(
triad_blocked=True, triad_block_reason="critical_findings",
scope_blocked=False,
) == "verdict"
# Triad fit/quorum/transport = infra.
for reason in ("fixed_overflow", "review_quorum", "infra_failure", "parse_failure"):
assert classify_review_block(
triad_blocked=True, triad_block_reason=reason, scope_blocked=False,
) == "infra"
# Scope: a RESPONDED actor row that blocked on critical findings = verdict …
responded = {"raw_results": [
{"status": "responded", "critical_findings": [{"item": "x"}]},
]}
assert classify_review_block(
triad_blocked=False, triad_block_reason="", scope_blocked=True,
scope_raw_result=responded,
) == "verdict"
# … while sub-floor / overflow / error scope blocks are infra.
for status in ("sub_floor", "fixed_overflow", "error", "empty_response", "parse_failure"):
infra_rows = {"raw_results": [{"status": status, "critical_findings": []}]}
assert classify_review_block(
triad_blocked=False, triad_block_reason="", scope_blocked=True,
scope_raw_result=infra_rows,
) == "infra"
# A verdict on EITHER side wins even when the other side infra-blocked.
assert classify_review_block(
triad_blocked=True, triad_block_reason="review_quorum",
scope_blocked=True, scope_raw_result=responded,
) == "verdict"
def test_finalize_blocked_review_records_block_class(tmp_path, monkeypatch):
"""git.py's blocked finalizer stamps the typed class on the durable row."""
import ouroboros.tools.git as git_mod
from ouroboros.review_state import load_state, make_repo_key
monkeypatch.setattr(git_mod, "run_cmd", lambda *a, **k: "")
ctx = types.SimpleNamespace(
repo_dir=tmp_path, drive_root=tmp_path, task_id="t1", task_metadata={},
_current_review_tool_name="commit_reviewed",
_last_triad_models=[], _last_scope_model="",
_last_triad_raw_results=[], _last_scope_raw_result={},
_review_degraded_reasons=[],
)
git_mod._finalize_blocked_review(
ctx, "msg", 0.0,
combined_msg="blocked", block_reason="critical_findings",
combined_findings=[{"item": "x", "reason": "r", "severity": "critical"}],
pre_fingerprint={"fingerprint": "fp"}, post_fingerprint={"fingerprint": "fp"},
block_class="verdict",
)
state = load_state(pathlib.Path(tmp_path))
rows = state.filter_attempts(repo_key=make_repo_key(pathlib.Path(tmp_path)))
assert rows and rows[-1].block_class == "verdict"
assert rows[-1].root_task_id == "t1" # ctx.task_id is its own root
# ---------------------------------------------------------------------------
# Paid-at-dispatch on the ledger (Δ5 paid + merge semantics, Q23 root scoping)
def test_paid_fact_survives_terminal_merge_and_roundtrip(tmp_path):
from ouroboros.review_state import (
CommitAttemptRecord,
load_state,
make_repo_key,
update_state,
_utc_now,
)
repo_key = make_repo_key(pathlib.Path(tmp_path))
def _record(state, **kw):
base = dict(
ts=_utc_now(), commit_message="m", repo_key=repo_key,
tool_name="commit_reviewed", task_id="t1", attempt=1,
)
base.update(kw)
state.record_attempt(CommitAttemptRecord(**base))
# Dispatch-time row: paid recorded on the in-flight marker.
update_state(pathlib.Path(tmp_path), lambda s: _record(
s, status="reviewing", phase="review", paid=True,
rebuttal_sha256="r" * 64, review_contract_fingerprint="c" * 64,
root_task_id="root-1", pre_review_fingerprint="fp-1",
))
# Terminal update on the SAME attempt does not pass paid — the merge must
# keep it (a terminal row must never launder the spend).
update_state(pathlib.Path(tmp_path), lambda s: _record(
s, status="blocked", phase="blocking_review",
block_reason="critical_findings", block_class="verdict",
pre_review_fingerprint="fp-1",
))
state = load_state(pathlib.Path(tmp_path))
row = state.filter_attempts(repo_key=repo_key)[-1]
assert row.status == "blocked" and row.paid is True
assert row.block_class == "verdict"
assert row.rebuttal_sha256 == "r" * 64
assert row.review_contract_fingerprint == "c" * 64
assert row.root_task_id == "root-1"
# And all of it survives the JSON roundtrip (save happened in update_state).
reloaded = load_state(pathlib.Path(tmp_path)).filter_attempts(repo_key=repo_key)[-1]
assert (reloaded.paid, reloaded.block_class, reloaded.root_task_id) == (True, "verdict", "root-1")
def test_record_commit_attempt_reviewing_lands_paid_on_the_ledger(tmp_path):
"""Behavior test for the dispatch seam (wording-1): driving the REAL
recorder with the reviewing-row kwargs must land paid/rebuttal/contract/
root facts on the durable ledger, and the ceiling counter must see them."""
from ouroboros.review_state import load_state, make_repo_key
from ouroboros.tools.commit_gate import (
_record_commit_attempt,
count_paid_review_cycles,
)
ctx = types.SimpleNamespace(
repo_dir=tmp_path, drive_root=tmp_path, task_id="t-1",
task_metadata={"root_task_id": "root-9"},
_current_review_tool_name="commit_reviewed",
)
_record_commit_attempt(
ctx, "msg", "reviewing",
duration_sec=0.0, phase="review",
pre_review_fingerprint="fp-1", fingerprint_status="pending",
paid=True, rebuttal_sha256="r" * 64, review_contract_fingerprint="c" * 64,
)
row = load_state(pathlib.Path(tmp_path)).filter_attempts(
repo_key=make_repo_key(pathlib.Path(tmp_path)))[-1]
assert row.status == "reviewing" and row.paid is True
assert row.rebuttal_sha256 == "r" * 64
assert row.review_contract_fingerprint == "c" * 64
assert row.root_task_id == "root-9"
assert count_paid_review_cycles(ctx, root_task_id="root-9") == 1
def test_stage_cycle_free_gate_runs_before_advisory_gate():
"""Order pin: the free Max-Review-Cycles gate precedes the advisory/tests
gate, which precedes the paid dispatch."""
import inspect
import ouroboros.tools.git as git_mod
source = inspect.getsource(git_mod._run_reviewed_stage_cycle)
assert source.index("_free_cycle_gate(") < source.index("_advisory_and_tests_gate(")
assert source.index("_advisory_and_tests_gate(") < source.index("_run_parallel_review(")
def test_resolve_root_task_id_ignores_the_followup_chain():
"""machine-3 decision: a follow-up task is a FRESH root for the ceiling —
origin_root_task_id (the follow-up chain marker) is deliberately not
honored; the identical-fingerprint refusal stays the anti-laundering
backstop."""
from ouroboros.tools.commit_gate import resolve_root_task_id
followup = types.SimpleNamespace(
task_metadata={"origin_root_task_id": "old-root", "origin_task_id": "old-task"},
task_id="f-1",
)
assert resolve_root_task_id(followup) == "f-1"
subtask = types.SimpleNamespace(
task_metadata={"root_task_id": "tree-root", "origin_root_task_id": "old-root"},
task_id="child-1",
)
assert resolve_root_task_id(subtask) == "tree-root"
def test_repair_managed_merge_head_writes_merge_head(tmp_path, monkeypatch):
"""Behavior test (wording-1): the refusal path's repair helper re-writes
the REAL .git/MERGE_HEAD via reestablish_merge_head with the tx target."""
import subprocess
import ouroboros.tools.git as git_mod
import supervisor.git_ops as git_ops
import supervisor.update_merge as update_merge
repo = tmp_path / "repo"
repo.mkdir()
subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True)
monkeypatch.setattr(git_ops, "REPO_DIR", repo)
target = "a" * 40
monkeypatch.setattr(
update_merge, "managed_assisted_tx_for",
lambda task_id, metadata: ({"target_sha": target}, ""),
)
ctx = types.SimpleNamespace(task_id="t-1", task_metadata={})
git_mod._repair_managed_merge_head(ctx)
assert (repo / ".git" / "MERGE_HEAD").read_text(encoding="utf-8") == target + "\n"
# ---------------------------------------------------------------------------
# The free-cycle gate (Δ5.2/Δ5.4): blocking refusals, advisory replay
def _gate_ctx(tmp_path, task_id="root-1"):
(pathlib.Path(tmp_path) / "logs").mkdir(parents=True, exist_ok=True)
return types.SimpleNamespace(
repo_dir=tmp_path, drive_root=pathlib.Path(tmp_path), task_id=task_id,
task_metadata={}, event_queue=None,
drive_logs=lambda: pathlib.Path(tmp_path) / "logs",
_current_review_tool_name="commit_reviewed",
)
def _seed_verdict_block(tmp_path, fingerprint, contract_fp, **kw):
from ouroboros.review_state import CommitAttemptRecord, make_repo_key, update_state, _utc_now
repo_key = make_repo_key(pathlib.Path(tmp_path))
row = dict(
ts=_utc_now(), commit_message="m", status="blocked",
block_reason="critical_findings", block_class="verdict",
repo_key=repo_key, tool_name="commit_reviewed", task_id="root-1",
attempt=1, phase="blocking_review", pre_review_fingerprint=fingerprint,
review_contract_fingerprint=contract_fp,
critical_findings=[{"item": "bug_y", "reason": "broken", "severity": "critical"}],
)
row.update(kw)
update_state(pathlib.Path(tmp_path), lambda s: s.attempts.append(CommitAttemptRecord(**row)))
def test_free_cycle_gate_blocking_identical_refusal_repairs_managed_merge_head(
tmp_path, monkeypatch,
):
import ouroboros.tools.git as git_mod
monkeypatch.setenv("OUROBOROS_REVIEW_ENFORCEMENT", "blocking")
monkeypatch.setattr(git_mod, "commit_review_contract_fingerprint", lambda: "cf-1")
monkeypatch.setattr(git_mod, "run_cmd", lambda *a, **k: "")
repaired = []
monkeypatch.setattr(git_mod, "_authorized_managed_update_resolver", lambda ctx: True)
monkeypatch.setattr(git_mod, "_repair_managed_merge_head", lambda ctx: repaired.append(True))
ctx = _gate_ctx(tmp_path)
_seed_verdict_block(tmp_path, "fp-1", "cf-1")
outcome = git_mod._free_cycle_gate(
ctx, "msg", 0.0, pre_fingerprint={"fingerprint": "fp-1"}, review_rebuttal="",
)
assert outcome is not None and outcome["status"] == "blocked"
assert outcome["block_reason"] == "identical_diff_refused"
assert "IDENTICAL_DIFF_REFUSED" in outcome["message"] and "bug_y" in outcome["message"]
assert repaired == [True] # MERGE_HEAD kept repaired on the free refusal path
# The refusal itself is durably recorded and never resets the streak.
from ouroboros.review_state import load_state, make_repo_key
rows = load_state(pathlib.Path(tmp_path)).filter_attempts(
repo_key=make_repo_key(pathlib.Path(tmp_path)))
assert rows[-1].block_reason == "identical_diff_refused"
again = git_mod._free_cycle_gate(
ctx, "msg", 0.0, pre_fingerprint={"fingerprint": "fp-1"}, review_rebuttal="",
)
assert again is not None and again["block_reason"] == "identical_diff_refused"
# A fresh diff passes the gate.
assert git_mod._free_cycle_gate(
ctx, "msg", 0.0, pre_fingerprint={"fingerprint": "fp-2"}, review_rebuttal="",
) is None
def test_free_cycle_gate_blocking_ceiling_emits_typed_event(tmp_path, monkeypatch):
import ouroboros.tools.git as git_mod
from ouroboros.review_state import CommitAttemptRecord, make_repo_key, update_state, _utc_now
monkeypatch.setenv("OUROBOROS_REVIEW_ENFORCEMENT", "blocking")
monkeypatch.setenv(KEY, "1")
monkeypatch.setattr(git_mod, "commit_review_contract_fingerprint", lambda: "cf-1")
monkeypatch.setattr(git_mod, "run_cmd", lambda *a, **k: "")
monkeypatch.setattr(git_mod, "_authorized_managed_update_resolver", lambda ctx: False)
ctx = _gate_ctx(tmp_path)
repo_key = make_repo_key(pathlib.Path(tmp_path))
update_state(pathlib.Path(tmp_path), lambda s: s.attempts.append(CommitAttemptRecord(
ts=_utc_now(), commit_message="m", status="succeeded", repo_key=repo_key,
tool_name="commit_reviewed", task_id="root-1", attempt=1, phase="commit",
paid=True, root_task_id="root-1", pre_review_fingerprint="fp-old",
)))
outcome = git_mod._free_cycle_gate(
ctx, "msg", 0.0, pre_fingerprint={"fingerprint": "fp-new"}, review_rebuttal="",
)
assert outcome is not None and outcome["block_reason"] == "review_cycles_exhausted"
assert "REVIEW_CYCLES_EXHAUSTED" in outcome["message"]
events = [json.loads(line) for line in
(pathlib.Path(tmp_path) / "logs" / "events.jsonl").read_text(encoding="utf-8").splitlines()]
exhausted = [e for e in events if e["type"] == "review_cycles_exhausted"]
assert exhausted and exhausted[0]["surface"] == "commit_gate"
assert exhausted[0]["cycles_paid"] == 1 and exhausted[0]["cap"] == 1
assert exhausted[0]["enforcement"] == "blocking"
def test_free_cycle_gate_advisory_returns_replay_marker_not_a_block(tmp_path, monkeypatch):
"""A.4/A.2 advisory contract: exhaustion/identical-refusal do NOT hard-block
— the gate returns a replay marker (typed free-replay event), never a
blocked outcome, and dispatches nothing."""
import ouroboros.tools.git as git_mod
monkeypatch.setenv("OUROBOROS_REVIEW_ENFORCEMENT", "advisory")
monkeypatch.setattr(git_mod, "commit_review_contract_fingerprint", lambda: "cf-1")
monkeypatch.setattr(
git_mod, "run_cmd",
lambda *a, **k: (_ for _ in ()).throw(AssertionError("no git calls on the advisory replay path")),
)
ctx = _gate_ctx(tmp_path)
_seed_verdict_block(tmp_path, "fp-1", "cf-1")
outcome = git_mod._free_cycle_gate(
ctx, "msg", 0.0, pre_fingerprint={"fingerprint": "fp-1"}, review_rebuttal="",
)
assert outcome is not None and "status" not in outcome
assert outcome["replay_reason"] == "identical_diff_refused"
assert "IDENTICAL_DIFF_REFUSED" in outcome["advisory_replay"]
events = [json.loads(line) for line in
(pathlib.Path(tmp_path) / "logs" / "events.jsonl").read_text(encoding="utf-8").splitlines()]
replays = [e for e in events if e["type"] == "commit_review_free_replay"]
assert replays and replays[0]["reason"] == "identical_diff_refused"
assert replays[0]["enforcement"] == "advisory"
def test_free_cycle_gate_infra_streak_never_gates_advisory_or_blocking(tmp_path, monkeypatch):
"""Infra-blocks retry freely: they neither refuse under blocking nor
trigger the advisory replay."""
import ouroboros.tools.git as git_mod
monkeypatch.setattr(git_mod, "commit_review_contract_fingerprint", lambda: "cf-1")
monkeypatch.setattr(git_mod, "run_cmd", lambda *a, **k: "")
ctx = _gate_ctx(tmp_path)
_seed_verdict_block(
tmp_path, "fp-1", "cf-1",
block_reason="review_quorum", block_class="infra", critical_findings=[],
)
for enforcement in ("blocking", "advisory"):
monkeypatch.setenv("OUROBOROS_REVIEW_ENFORCEMENT", enforcement)
assert git_mod._free_cycle_gate(
ctx, "msg", 0.0, pre_fingerprint={"fingerprint": "fp-1"}, review_rebuttal="",
) is None
# ---------------------------------------------------------------------------
# Skill review (Δ6, Q17/Q23)
def test_skill_review_contract_fingerprint_tracks_roster_items_and_profile():
from ouroboros.skill_review_cycles import skill_review_contract_fingerprint
base = skill_review_contract_fingerprint(["m1", "m2"], required_items=("a", "b"))
assert base and len(base) == 64
assert base == skill_review_contract_fingerprint(["m1", "m2"], required_items=("a", "b"))
assert base != skill_review_contract_fingerprint(["m1"], required_items=("a", "b"))
assert base != skill_review_contract_fingerprint(["m1", "m2"], required_items=("a",))
# skill-4: the resolved review profile is contract identity — official_hub
# aggregates blockers differently, so a profile change lapses replay.
assert base != skill_review_contract_fingerprint(
["m1", "m2"], required_items=("a", "b"), review_profile="official_hub")
def _write_history(drive_root, skill, rows):
from ouroboros.skill_review_history import review_history_path
path = review_history_path(pathlib.Path(drive_root), skill)
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "a", encoding="utf-8") as fh:
for row in rows:
fh.write(json.dumps(row) + "\n")
def test_count_paid_skill_review_cycles_task_and_manual_keys(tmp_path):
"""Q23 + machine-5 + machine-1: a task-driven group counts every PAID
(dispatched) cycle across EVERY skill the root task reviewed — including
dispatched-then-degraded waves; the manual lane is scoped per
content_hash (a revised snapshot restarts its count); unpaid/legacy rows
never count."""
from ouroboros.skill_review_cycles import count_paid_skill_review_cycles
_write_history(tmp_path, "skill_a", [
{"ts": "t1", "status": "blockers", "content_hash": "h1", "paid": True,
"group_id": "task:root-9:skill_a", "root_task_id": "root-9", "job_id": "j1"},
{"ts": "t2", "status": "interrupted", "content_hash": "h1", "paid": True,
"group_id": "task:root-9:skill_a", "root_task_id": "root-9", "job_id": "j2"},
{"ts": "t3", "status": "clean", "content_hash": "h2", # legacy: no paid fact
"group_id": "task:root-9:skill_a", "root_task_id": "root-9", "job_id": "j3"},
])
_write_history(tmp_path, "skill_b", [
{"ts": "t4", "status": "warnings", "content_hash": "h3", "paid": True,
"group_id": "task:root-9:skill_b", "root_task_id": "root-9", "job_id": "j4"},
{"ts": "t5", "status": "clean", "content_hash": "h4", "paid": True,
"group_id": "manual:skill_b", "job_id": "j5"},
{"ts": "t6", "status": "pending", "content_hash": "h4", "paid": True, # quorum-failed wave: money
"group_id": "manual:skill_b", "job_id": "j6"},
])
# Task key = money across the tree: blockers(a) + interrupted-dispatched(a)
# + warnings(b) = 3; legacy (no paid) and the manual lane excluded.
assert count_paid_skill_review_cycles(pathlib.Path(tmp_path), "skill_a", "task:root-9:skill_a") == 3
assert count_paid_skill_review_cycles(pathlib.Path(tmp_path), "skill_b", "task:root-9:skill_b") == 3
# Manual key: per content snapshot — h4 spent 2 (clean + quorum-failed wave),
# a revised snapshot h5 starts fresh at 0.
assert count_paid_skill_review_cycles(
pathlib.Path(tmp_path), "skill_b", "manual:skill_b", content_hash="h4") == 2
assert count_paid_skill_review_cycles(
pathlib.Path(tmp_path), "skill_b", "manual:skill_b", content_hash="h5") == 0
def test_skill_free_replay_row_rules(tmp_path):
"""Q17-A: replay needs the identical (group, content-hash, contract) triple
with a SUBSTANTIVE verdict; infra terminals are skipped; a legacy row
without a fingerprint never replays; a NEW rebuttal buys a paid rerun and
a repeated one replays free."""
from ouroboros.skill_review_cycles import find_free_replay_row
group = "manual:demo"
_write_history(tmp_path, "demo", [
{"ts": "t1", "status": "warnings", "content_hash": "h1", "paid": True,
"group_id": group, "review_contract_fingerprint": "cf-1",
"rebuttal_sha256": "reb-1", "job_id": "j1",
"fail_findings": [{"item": "bug_z", "severity": "warning", "reason_excerpt": "meh"}]},
{"ts": "t2", "status": "timeout", "content_hash": "h1", "paid": True,
"group_id": group, "review_contract_fingerprint": "cf-1", "job_id": "j2"},
])
row = find_free_replay_row(
pathlib.Path(tmp_path), "demo", group_id=group, content_hash="h1",
contract_fingerprint="cf-1",
)
assert row is not None and row["status"] == "warnings" # timeout skipped, verdict quoted
# Different content hash / contract / group: paid run due.
assert find_free_replay_row(pathlib.Path(tmp_path), "demo", group_id=group,
content_hash="h2", contract_fingerprint="cf-1") is None
assert find_free_replay_row(pathlib.Path(tmp_path), "demo", group_id=group,
content_hash="h1", contract_fingerprint="cf-2") is None
assert find_free_replay_row(pathlib.Path(tmp_path), "demo", group_id="manual:other",
content_hash="h1", contract_fingerprint="cf-1") is None
# Rebuttal by content: new hash = paid rerun; the spent hash replays free.
assert find_free_replay_row(pathlib.Path(tmp_path), "demo", group_id=group,
content_hash="h1", contract_fingerprint="cf-1",
rebuttal_sha256="reb-NEW") is None
assert find_free_replay_row(pathlib.Path(tmp_path), "demo", group_id=group,
content_hash="h1", contract_fingerprint="cf-1",
rebuttal_sha256="reb-1") is not None
# machine-6/skill-3: a rebuttal recorded only on an INFRA terminal (its
# paid rerun quorum-failed/timed out) was never answered by a substantive
# verdict — it stays fresh and buys the paid rerun it is owed.
_write_history(tmp_path, "demo", [
{"ts": "t3", "status": "timeout", "content_hash": "h1", "paid": True,
"group_id": group, "review_contract_fingerprint": "cf-1",
"rebuttal_sha256": "reb-infra", "job_id": "j3"},
])
assert find_free_replay_row(pathlib.Path(tmp_path), "demo", group_id=group,
content_hash="h1", contract_fingerprint="cf-1",
rebuttal_sha256="reb-infra") is None
# A legacy substantive row WITHOUT a fingerprint never replays.
_write_history(tmp_path, "legacy", [
{"ts": "t1", "status": "clean", "content_hash": "h1", "paid": True,
"group_id": "manual:legacy", "job_id": "j1"},
])
assert find_free_replay_row(pathlib.Path(tmp_path), "legacy", group_id="manual:legacy",
content_hash="h1", contract_fingerprint="cf-1") is None
def test_skill_free_replay_outcome_quotes_verdict_and_is_unpaid(tmp_path):
from ouroboros.skill_review_cycles import free_replay_outcome
group = "manual:demo"
_write_history(tmp_path, "demo", [
{"ts": "2026-08-20T00:00:00Z", "status": "blockers", "content_hash": "h1",
"paid": True, "group_id": group, "review_contract_fingerprint": "cf-1",
"job_id": "j1",
"fail_findings": [{"item": "no_repo_mutation", "severity": "critical",
"reason_excerpt": "writes into repo/"}]},
])
review_state = types.SimpleNamespace(
content_hash="h1", status="blockers",
findings=[{"item": "no_repo_mutation", "verdict": "FAIL", "severity": "critical",
"reason": "writes into repo/"}],
reviewer_models=["m1", "m2"],
)
skill = types.SimpleNamespace(name="demo", review=review_state)
outcome = free_replay_outcome(
skill, drive_root=pathlib.Path(tmp_path), group_id=group, content_hash="h1",
contract_fingerprint="cf-1",
)
assert outcome is not None
assert outcome.status == "blockers" and outcome.paid is False
assert outcome.replayed_from_ts == "2026-08-20T00:00:00Z"
assert outcome.findings == review_state.findings # full findings reused from state
assert "FREE REPLAY" in outcome.convergence_hint
assert "no_repo_mutation" in outcome.convergence_hint
# No recorded verdict → no replay.
assert free_replay_outcome(
skill, drive_root=pathlib.Path(tmp_path), group_id=group, content_hash="h2",
contract_fingerprint="cf-1",
) is None
# skill-2: when the mutable persisted state (review.json) has DIVERGED from
# the history row, the replay falls through to a PAID rerun that
# re-persists — never a $0 replay whose effects the grant/enable gates
# cannot see.
diverged = types.SimpleNamespace(
name="demo",
review=types.SimpleNamespace(
content_hash="OTHER", status="clean", findings=[], reviewer_models=[]),
)
assert free_replay_outcome(
diverged, drive_root=pathlib.Path(tmp_path), group_id=group, content_hash="h1",
contract_fingerprint="cf-1",
) is None
stateless = types.SimpleNamespace(name="demo", review=None)
assert free_replay_outcome(
stateless, drive_root=pathlib.Path(tmp_path), group_id=group, content_hash="h1",
contract_fingerprint="cf-1",
) is None
def test_skill_review_cycles_refusal_emits_typed_event(tmp_path, monkeypatch):
from ouroboros.skill_review_cycles import skill_review_cycles_refusal
monkeypatch.setenv(KEY, "1")
group = "task:root-7:demo"
_write_history(tmp_path, "demo", [
{"ts": "t1", "status": "clean", "content_hash": "h1", "paid": True,
"group_id": group, "root_task_id": "root-7", "job_id": "j1"},
])
ctx = types.SimpleNamespace(task_id="t-77", event_queue=None)
outcome = skill_review_cycles_refusal(
ctx, "demo", drive_root=pathlib.Path(tmp_path), group_id=group,
models=["m1"], content_hash="h2", contract_fingerprint="cf-1",
)
assert outcome is not None and outcome.status == "pending"
assert "REVIEW_CYCLES_EXHAUSTED" in outcome.error
assert outcome.paid is False
events = [json.loads(line) for line in
(pathlib.Path(tmp_path) / "logs" / "events.jsonl").read_text(encoding="utf-8").splitlines()]
exhausted = [e for e in events if e["type"] == "review_cycles_exhausted"]
assert exhausted and exhausted[0]["surface"] == "skill_review"
assert exhausted[0]["root_task_id"] == "root-7" and exhausted[0]["cap"] == 1
# Under the cap (another key) nothing is refused.
assert skill_review_cycles_refusal(
ctx, "demo", drive_root=pathlib.Path(tmp_path), group_id="manual:demo",
models=["m1"], content_hash="h2", contract_fingerprint="cf-1",
) is None
# Unlimited: never a ceiling.
monkeypatch.setenv(KEY, "unlimited")
assert skill_review_cycles_refusal(
ctx, "demo", drive_root=pathlib.Path(tmp_path), group_id=group,
models=["m1"], content_hash="h2", contract_fingerprint="cf-1",
) is None
def test_terminal_history_payload_carries_cycle_facts():
from ouroboros.skill_review_runner import _terminal_history_payload
result = types.SimpleNamespace(
findings=[], content_hash="h1", raw_actor_records=[],
single_reviewer_no_diversity=False,
paid=True, review_contract_fingerprint="cf-1",
rebuttal_sha256="reb-1", replayed_from_ts="",
)
payload = _terminal_history_payload(
{"job_id": "j1", "group_id": "manual:demo"},
status="clean", terminal_reason="clean", result=result, ts="t1",
)
assert payload["paid"] is True
assert payload["review_contract_fingerprint"] == "cf-1"
assert payload["rebuttal_sha256"] == "reb-1"
assert "replayed_from_ts" not in payload # empty facts are not written
# A replayed outcome discloses its provenance and stays unpaid.
replayed = types.SimpleNamespace(
findings=[], content_hash="h1", raw_actor_records=[],
single_reviewer_no_diversity=False,
paid=False, review_contract_fingerprint="cf-1",
rebuttal_sha256="", replayed_from_ts="t0",
)
payload = _terminal_history_payload(
{"job_id": "j2", "group_id": "manual:demo"},
status="clean", terminal_reason="clean", result=replayed, ts="t2",
)
assert "paid" not in payload and payload["replayed_from_ts"] == "t0"
def _fake_manifest():
return types.SimpleNamespace(
name="demo", description="d", version="1", type="tool", runtime="python",
timeout_sec=30, permissions=[], conflicts=[], env_from_settings=[],
requires=[], scripts=[], scheduled_tasks=[], entry="main.py",
)
def _wire_review_skill(monkeypatch, tmp_path, *, content_hash, review_state, passes):
"""Wire review_skill's heavy collaborators to a temp drive so the REAL
pipeline (cycles gate → budget → panel seam) executes end to end."""
import ouroboros.skill_review_passes as passes_mod
from ouroboros import config as cfg
from ouroboros import skill_review
drive = pathlib.Path(tmp_path)
skill = types.SimpleNamespace(
name="demo", manifest=_fake_manifest(), skill_dir=drive / "skill",
load_error="", review=review_state, source="",
)
binding = types.SimpleNamespace(state_drive_root=drive)
monkeypatch.setattr(skill_review, "build_resolved_resource_binding",
lambda *a, **k: binding)
monkeypatch.setattr(skill_review, "load_bound_skill", lambda b: skill)
monkeypatch.setattr(skill_review, "compute_content_hash", lambda *a, **k: content_hash)
monkeypatch.setattr(skill_review, "_run_deterministic_preflight",
lambda *a, **k: None)
monkeypatch.setattr(skill_review, "reviewer_slot_config_error", lambda: "")
monkeypatch.setattr(skill_review, "_build_skill_file_packs", lambda *a, **k: ["pack"])
monkeypatch.setattr(skill_review, "_official_hub_review_profile", lambda s: "")
monkeypatch.setattr(skill_review, "_review_wave_budget_block",
lambda *a, **k: None)
monkeypatch.setattr(cfg, "get_review_models", lambda: ["m1", "m2"])
monkeypatch.setattr(passes_mod, "run_skill_review_passes", passes)
return skill
def _live_skill_contract_fp():
from ouroboros import skill_review
from ouroboros.skill_review_cycles import skill_review_contract_fingerprint
return skill_review_contract_fingerprint(
["m1", "m2"], required_items=skill_review._SKILL_REVIEW_ITEMS, review_profile="",
)
def test_review_skill_replays_refuses_and_pays_functionally(tmp_path, monkeypatch):
"""Behavior test (wording-1): the REAL review_skill (a) free-replays an
identical snapshot without touching the panel seam, (b) refuses at the
exhausted ceiling without dispatching, (c) stamps the paid facts on a
dispatched outcome."""
from ouroboros import skill_review
def _must_not_dispatch(*a, **k):
raise AssertionError("run_skill_review_passes must not be called")
contract_fp = _live_skill_contract_fp()
state = types.SimpleNamespace(
content_hash="h1", status="warnings",
findings=[{"item": "bug_hunting", "verdict": "FAIL", "severity": "advisory",
"reason": "meh"}],
reviewer_models=["m1", "m2"],
)
# (a) $0 replay: seeded substantive row under the LIVE contract + matching state.
_write_history(tmp_path, "demo", [
{"ts": "t1", "status": "warnings", "content_hash": "h1", "paid": True,
"group_id": "manual:demo", "review_contract_fingerprint": contract_fp,
"job_id": "j1"},
])
_wire_review_skill(monkeypatch, tmp_path, content_hash="h1",
review_state=state, passes=_must_not_dispatch)
ctx = types.SimpleNamespace(task_id="", task_metadata={}, event_queue=None)
outcome = skill_review.review_skill(ctx, "demo", persist=False)
assert outcome.status == "warnings" and outcome.paid is False
assert outcome.replayed_from_ts == "t1"
# (b) ceiling: cap=1 with one paid row already spent on this snapshot,
# and the persisted state diverged so no $0 replay can rescue it —
# the typed refusal fires before any dispatch.
monkeypatch.setenv(KEY, "1")
_wire_review_skill(monkeypatch, tmp_path, content_hash="h1",
review_state=types.SimpleNamespace(
content_hash="OTHER", status="clean", findings=[],
reviewer_models=[]),
passes=_must_not_dispatch)
refused = skill_review.review_skill(ctx, "demo", persist=False)
assert refused.status == "pending"
assert "REVIEW_CYCLES_EXHAUSTED" in refused.error and refused.paid is False
# (c) paid dispatch: fresh snapshot, the wave DISPATCHES (the fake invokes
# the write-ahead stamp exactly like the shared transport entry does) and
# then fails — the outcome still carries the paid facts (money was spent).
from ouroboros.review_dispatch import stamp_review_paid_on_dispatch
def _dispatch_then_fail(ctx_arg, *a, **k):
stamp_review_paid_on_dispatch(ctx_arg)
return ("prompt", {}, "", "provider exploded")
monkeypatch.setenv(KEY, "5")
_wire_review_skill(monkeypatch, tmp_path, content_hash="h2",
review_state=None, passes=_dispatch_then_fail)
paid_outcome = skill_review.review_skill(ctx, "demo", persist=False)
assert paid_outcome.status == "pending" and paid_outcome.paid is True
assert paid_outcome.review_contract_fingerprint == contract_fp
assert paid_outcome.wave_id # the write-ahead dispatch marker names the wave
assert "infrastructure failure" in paid_outcome.error
# (c2) an ASSEMBLY-refused wave (the seam never invoked) stays unpaid.
_wire_review_skill(monkeypatch, tmp_path, content_hash="h3",
review_state=None,
passes=lambda *a, **k: ("prompt", {}, "", "packs never assembled"))
unpaid_outcome = skill_review.review_skill(ctx, "demo", persist=False)
assert unpaid_outcome.status == "pending" and unpaid_outcome.paid is False
assert unpaid_outcome.wave_id == ""
def test_review_skill_pipeline_order_pin():
"""Order pin (kept thin beside the behavior test): the cycles gate runs
before budget admission, before the panel seam; post-dispatch outcomes go
through the paid-facts stamp."""
import inspect
from ouroboros import skill_review
gate_source = inspect.getsource(skill_review._skill_cycles_gate)
assert gate_source.index("free_replay_outcome(") < gate_source.index("skill_review_cycles_refusal(")
source = inspect.getsource(skill_review.review_skill)
assert source.index("_skill_cycles_gate(") < source.index("_review_wave_budget_block(")
assert source.index("_review_wave_budget_block(") < source.index("run_skill_review_passes(")
assert source.count("_paid_facts(") >= 5 # dispatch marks every post-panel outcome paid

View file

@ -1486,7 +1486,10 @@ class TestGitWiring:
source = inspect.getsource(git._repo_commit_push)
assert "_run_reviewed_stage_cycle" in source
shared_source = inspect.getsource(git._run_reviewed_stage_cycle)
assert "_check_advisory_freshness" in shared_source
# The advisory-freshness check lives in the extracted gate helper the
# stage cycle calls before any paid dispatch.
assert "_advisory_and_tests_gate" in shared_source
assert "_check_advisory_freshness" in inspect.getsource(git._advisory_and_tests_gate)
assert "_run_parallel_review" in shared_source
parallel_source = inspect.getsource(git._run_parallel_review)
assert "run_scope_review" in parallel_source

View file

@ -462,7 +462,7 @@ export function renderSettingsPage() {
<div class="form-section">
<h3>Max Review Cycles</h3>
<div class="settings-section-copy">One shared cap on paid review cycles per task for plan review, task acceptance (improvement passes = cycles &minus; 1) and the commit gate (review blocks of the same unchanged diff before it refuses another run). <code>&infin;</code> removes the cap; the task's own deadline, budget and lifecycle rails still bind.</div>
<div class="settings-section-copy">One shared cap on paid review cycles per task for plan review, task acceptance (improvement passes = cycles &minus; 1), the commit gate (paid triad+scope cycles per root task, shared across the whole task tree; an unchanged diff never buys a new review after a verdict block regardless of this number &mdash; under blocking enforcement the commit is refused for free, under advisory it proceeds with a loud durable disclosure and no new review spend) and skill review (paid panel runs per root task or per manual snapshot; identical snapshots replay free). <code>&infin;</code> removes the cap; the task's own deadline, budget and lifecycle rails still bind.</div>
<div class="settings-effort-card">
<label>Max Review Cycles</label>
<input id="s-review-max-cycles" type="hidden" value="2">