Files
Codewhale/scripts/check-runtime-contract-budget.py
24a7b99a73 refactor(split): codewhale-runtime crate and boundary ratchet (RS-0..RS-7) (#6586)
* ci(split): RS-0 runtime -> UI boundary ratchet and a hermetic step that cannot pass empty

First rail of the runtime/TUI crate split.

- scripts/split/module_graph.py: module graph of crates/tui/src and
  crates/runtime/src with a comment/string-masking lexer and grouped
  `use crate::{...}` expansion. Computes the runtime closure from the eight
  seed modules (plus everything already in crates/runtime, plus test-only
  support modules its tests use) and counts, per module pair:
  prod / test references into UI code (tui, commands, remote_control,
  context_report, composer_*, private lib.rs items), "late" references into
  modules that move after the core (exec_agent, route_preferences), UI-library
  uses (ratatui, crossterm, codewhale_tui, codewhale_palette), and intra-doc
  links into UI code.
- scripts/runtime-boundary-baseline.json: prod 84, test 95, late 2, uilib 13,
  doc 1 across 101 closure modules. Any rise or new pair fails with file:line;
  a drop the baseline did not record also fails (`--update` lowers it and
  refuses to raise).
- scripts/check-command-crate-boundaries.py is now table-driven
  (BoundaryRule: package, metadata|tree dependency mode, forbidden packages,
  source scan) and runs the ratchet. `tree` mode reads `cargo tree -p`, which
  resolves features per package; `cargo metadata` unifies them and would see
  palette's ratatui feature the TUI enables. The runtime entry lands with the
  crate (RS-2).
- ci.yml hermetic safety step: `cargo test ... -- auto_review authority
  sandbox` becomes `cargo nextest run --no-tests=fail -E 'test(auto_review) |
  test(authority) | test(sandbox)'`, and the job installs nextest. After the
  modules move, the old step would have matched 0 tests and passed.

Tests: python3 scripts/test_check_command_crate_boundaries.py -> Ran 19
tests, OK (11 existing + 8 new: tree mode, lexer masking, grouped imports,
late/uilib edges, runtime-crate closure, rise/drop, checked-in baseline).
python3 scripts/check-command-crate-boundaries.py -> PASS.
No Rust changed; no cargo build run for this commit.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01R9rJEoMuUSRWznU6QjkE7h

* docs(split): RS-1 record the runtime split sequence and its three departures

docs/design/TUI_DECONSTRUCTION.md gains the runtime split sequence (rails,
crate with live code, cut upward edges one blocker per slice, rename-only
core move, then the client contract) and records where it departs from the
older order in the same file, with why each is safe:

1. config moves inside the runtime's strongly connected component instead
   of leaving first (#6034/#6143 become internal runtime work);
2. loop convergence is not a precondition for moving the engine, because
   run_subagent and Engine::run_turn move into the same crate;
3. the moves use one root path alias in the TUI lib.rs, deleted at the end,
   instead of rewriting every caller in the moving change.

It also states that the engine, turn loop and tools go into
codewhale-runtime (no separate codewhale-engine), why the runtime cannot
live in crates/core (command-contract depends on core), and that the TUI
stays the only terminal-output owner behind the host_terminal port.

docs/ARCHITECTURE.md points at that sequence in its boundary note and
corrects crates/secrets's ownership (it also owns the shared sanitizer and
redaction).

Docs only; no build or test run.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01R9rJEoMuUSRWznU6QjkE7h

* refactor(split): RS-2 create codewhale-runtime with 19 live leaf modules

`git mv` of the 19 leaf modules that reference nothing outside the batch in
production or test code and touch no UI library (7,149 lines):
session_tree native_memory goal_loop context_budget continual_harness
skill_state tool_history_repair retry_status llm_response_cache sleep_guard
media_originals regex_cache elapsed workspace_discovery hashing fast_hash
prompt_zones safe_label model_context.

- crates/runtime (codewhale-runtime): publishable (version.workspace, no
  `publish = false`), `[lints] workspace = true`, the TUI's crate-level
  `#![allow(clippy::uninlined_format_args)]` copied, no build.rs (nothing in
  the batch needs one). Listed in scripts/release/crates.sh after
  config/core/memory/models and before tui. The first crates.io
  publication of the new name happens at the next release run; that is a
  release-owner decision before this merges.
- TUI: 19 `mod` lines replaced by one private alias block
  `use codewhale_runtime::{...};`, so no TUI file's content changes.
  examples/zz_perf_probe.rs uses `codewhale_runtime::session_tree` instead of
  a `#[path]` include.
- Visibility widened only where the compiler asked (scripts/split/widen.py,
  lints capped to warnings during the loop, final pass with lints on):
  25 items `pub(crate)` -> `pub` (fast_hash aliases, llm_response_cache
  API, regex_cache::compile_user_regex, safe_label API and
  safe_error_text, tool_history_repair receipt, workspace_discovery
  helpers, hashing), and 12 `expect(dead_code)` / `cfg_attr(not(test),
  expect(dead_code))` attributes removed because exported items are no
  longer dead. No security-module items in this batch.
- `test-support` feature: retry_status's per-thread test state and readers,
  SafeLabel::is_redacted and the WorldState diff renders move from
  `cfg(test)` to `cfg(any(test, feature = "test-support"))`, all `pub`; the
  TUI enables it from [dev-dependencies] only.
- Exported-only fixes: five rustdoc links from public `safe_error_text` to
  private items became code spans; `WorldState::len` gets a local
  `allow(clippy::len_without_is_empty)` instead of new API.
- Path sweep (scripts/split/move-modules.py): docs, AGENTS.md,
  check-blocking-calls-budget.json key, check-runtime-contract-budget.py
  fragment path, dev-test.sh examples; dev-test.sh gains a `runtime` area.
- Boundary checker: codewhale-runtime rule in `tree` mode (cargo tree -p,
  per-package features) forbidding codewhale-tui/cli, ratatui*, crossterm,
  ansi-to-tui and kit crates, plus a source scan of crates/runtime/src.
  Ratchet unchanged: prod 84, test 95, late 2, uilib 13, doc 1.

Verification (local):
- widen.py final pass: cargo check -p codewhale-runtime -p codewhale-tui
  --lib --tests --examples with lints on: 0 errors.
- cargo check -p codewhale-runtime --all-targets (standalone features): ok.
- cargo test -p codewhale-runtime --lib: 127 passed; 0 failed.
- cargo clippy -p codewhale-runtime --all-targets --all-features -D warnings: clean.
- RUSTDOCFLAGS=-Dwarnings cargo doc -p codewhale-runtime --no-deps: clean.
- cargo tree -p codewhale-runtime -e normal,build: 0 ratatui/crossterm/tui.
- python3 scripts/test_check_command_crate_boundaries.py: 21 tests OK;
  check-command-crate-boundaries.py: PASS.
- validate-crate-publish-order.test.sh: passed.
- check-blocking-calls-budget.py: 717 sites, within budget.
Not run locally: TUI test execution (the TUI test binary build hit ENOSPC
on the shared volume; TUI tests are type-checked only), TUI clippy,
check-runtime-contract-budget.py measurement (it builds the TUI tests).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01R9rJEoMuUSRWznU6QjkE7h

* refactor(palette): RS-3 make ratatui an optional default feature

The headless runtime needs palette's theme-setting and hex normalizers
(settings.rs) and RGB tokens (logging.rs), but palette pulled in ratatui
unconditionally. `ratatui` is now a default feature; without it the crate
is the renderer-free core:

- ids.rs (new): ThemeId with from_name/name/display_name/tagline,
  SELECTABLE_THEMES, normalize_theme_name, USER_THEME_PREFIX,
  normalize_user_theme_selector, normalize_theme_setting, and a
  `(u8, u8, u8)` hex parser `parse_hex_rgb` behind normalize_hex_rgb_color
  (same `#rrggbb` output as before).
- rgb.rs (new): the 161 `*_RGB` tuple tokens, split out of tokens.rs with
  their comments; tokens.rs keeps the 103 `Color` roles.
- Behind `#[cfg(feature = "ratatui")]`: adapt, contrast, detect, grammar,
  osc11, themes (UiTheme, ThemeId::ui_theme, parse_hex_rgb_color,
  hex_rgb_string), tokens, user_theme, and the crate tests.
- The public API with default features is unchanged (lib.rs re-exports the
  same names), so codewhale-tui needs no edit. The runtime will depend on
  `codewhale-palette = { default-features = false }` when settings/logging
  move (RS-14).
- Ratchet: codewhale_palette is no longer a UI library for the runtime
  closure; uilib 13 -> 10 (settings.rs's 3 palette uses drop out).

Verification (local):
- cargo check -p codewhale-palette --no-default-features --all-targets: ok.
- cargo test -p codewhale-palette: 81 passed; 0 failed.
- cargo clippy -p codewhale-palette --all-targets -D warnings (default and
  --no-default-features): clean.
- RUSTDOCFLAGS=-Dwarnings cargo doc -p codewhale-palette --no-deps
  --no-default-features: clean.
- cargo check -p codewhale-tui --lib --tests: ok.
- python3 scripts/test_check_command_crate_boundaries.py: 21 tests OK.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01R9rJEoMuUSRWznU6QjkE7h

* refactor(split): RS-4 the host_terminal port, with raw mode as its first use

Runtime code toggled crossterm raw mode directly around interactive
children in three places (tools/shell.rs x2, shell_dispatcher.rs), each with
its own copy of a restore guard. That is the only crossterm use left in the
runtime closure's production code.

- codewhale_runtime::host_terminal: the one port for terminal side effects.
  `HostTerminal` trait (suspend_raw_mode / resume_raw_mode), a first-wins
  `OnceLock` install, a no-op host when nothing is installed, and
  `suspend_raw_mode()` returning a `RawModeSuspension` guard that resumes
  only if raw mode was on (issue #1690 semantics). RS-7 adds the
  notification-delivery methods to the same trait.
- The three call sites use `crate::host_terminal::suspend_raw_mode()`; the
  three hand-written guard structs are deleted.
- tui/ui/terminal.rs: `TuiHostTerminal` implements the port with crossterm;
  `install_host_terminal()` is called at the top of `run_with_args`, so
  every mode the binary runs (interactive, exec, serve) keeps today's
  behavior. Stdio hosts can later be launched without it.
- integration harness: `use codewhale_runtime::host_terminal;` at the
  harness root, like `tool_parser`, for its `#[path]` shell_dispatcher.
- Ratchet: uilib 10 -> 1 (tools|crossterm 7 -> 1, the remaining one is a
  roster_routes test; shell_dispatcher|crossterm 3 -> 0).

Verification (local):
- cargo test -p codewhale-runtime --lib: 128 passed; 0 failed (adds
  host_terminal::tests::no_host_suspension_is_a_no_op).
- cargo clippy -p codewhale-runtime --all-targets --all-features -D warnings: clean.
- RUSTDOCFLAGS=-Dwarnings cargo doc -p codewhale-runtime --no-deps: clean.
- cargo check -p codewhale-tui --lib --tests --examples (incl. the
  integration harness): ok.
- python3 scripts/split/module_graph.py: PASS after --update.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01R9rJEoMuUSRWznU6QjkE7h

* refactor(split): RS-5 split the voice capture core out of commands::voice

runtime_api/voice.rs (the `/v1/voice` routes) imported
`crate::commands::voice`, a UI module, for recorder detection, ASR choice
and the headless dictation cycle.

- New runtime-side module `crate::voice` (774 lines) holds everything that
  is not a slash command: recorder detection and `is_available`, WAV
  encoding, `record_audio`, the send-suffix contract (`SEND_PHRASES`,
  `split_send_suffix`), chat-completions transcription (provider, Groq,
  local whisper), voice-control, ASR selection, and the headless
  `dictate_once` / `DictateMode` / `DictateError` / `DictationOutcome`,
  with their tests (vendor pin, WAV header, send suffix, recorder probe).
  It uses `config` and `client`, so it moves into codewhale-runtime with
  the core (M1).
- `commands::voice` keeps the three slash commands, the recording status
  line, `VoiceCaptureOutcome` and `capture_and_transcribe` (it drives the
  composer while the user speaks), importing the core from `crate::voice`.
  Seven core functions become `pub(crate)` for that loop; nothing else
  changes.
- runtime_api/voice.rs imports `crate::voice`.
- Ratchet: prod runtime_api|commands 10 -> 9.

Verification (local): cargo check -p codewhale-tui --lib --tests: ok.
The moved tests live in the TUI test binary, which was not linked locally
(shared volume at ~8 GB free); they are type-checked only.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01R9rJEoMuUSRWznU6QjkE7h

* refactor(split): RS-6 move the context-window formatter down into utils

fleet/capability_badges.rs (runtime) imported
`tui::model_picker::format_picker_context_window`, a pure formatter.

- It moves to `crate::utils::format_context_window` (same body: `1M`,
  `1.05M`, `262K`, `500`); the picker's 10 call sites and the badges' 6
  (including its `1M/1.05M/262K/500` assertions) call it there.
- Not merged with `agent_roster::format_tokens` (`1.2k`): that labels usage,
  not window size, and merging would change output.
- Ratchet: prod fleet|tui 1 -> 0.

Verification (local): cargo check -p codewhale-tui --lib --tests: ok.
The badge and picker tests are in the TUI test binary, which was not
linked locally (shared volume at ~8 GB free); type-checked only.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01R9rJEoMuUSRWznU6QjkE7h

* refactor(split): RS-7 notification policy down, delivery behind host_terminal

Runtime code imported `tui::notifications` and friends: the `notify` tool
(tools/notify.rs), native notification preparation
(runtime_api/notification_delivery.rs) and headless exec (exec_agent.rs).
Per the plan's second review, delivery (OSC 9/99/777 escapes, taskbar and
title sequences, stdout writes) stays in the TUI, the one terminal-output
owner; only policy moves down.

- New runtime-side module `crate::notify`: `git mv` of
  tui/notification_payload.rs -> notify/payload.rs,
  tui/notification_audio.rs -> notify/audio.rs,
  tui/sound_policy.rs -> notify/sound_policy.rs, plus the pure policy that
  was in tui/notifications.rs: `Method`, `DeliveryOutcome` (+ receipts),
  `NotificationGate`, `AttentionCondition` and its grace rule,
  `native_attention_allowed`, and `settings_projection` (now takes
  `&NotificationsConfig`). It uses `config`, so it moves into
  codewhale-runtime with the core.
- `sanitize_stream_chunk` moves to `codewhale_secrets::sanitize` beside
  `strip_ansi_into`; the payload and notifications call it there and
  tui/ui/frame.rs re-exports it for the event loop, which does not change.
- host_terminal port gains `notify_model(title, body) -> receipt`,
  `set_terminal_focused`, and `apply_notification_settings(&NotificationsConfig)`
  (no-op / "no terminal host" receipt when nothing is installed). The TUI
  host delegates to `tui::notifications::{notify_model,
  set_terminal_focused, apply_settings}`; `settings(&Config)` is now a thin
  wrapper over `apply_settings`, so its ~40 UI call sites are unchanged.
- tools/notify.rs calls the port; its emission-chain test (method=off
  silences the sink) moves to tui::notifications tests, where the chain
  lives. exec_agent.rs calls the port.
- notification_delivery.rs uses `crate::notify`; it still calls
  `tui::notifications::notify_with_sinks` with null sinks, because that
  function mixes the policy with transport selection. Known limitation,
  written in notify/mod.rs: splitting transport out of notify_with_sinks is
  the remaining step (1 prod runtime_api|tui reference left).
- Ratchet: prod 82 -> 75 (runtime_api|tui 5 -> 1, tools|tui 11 -> 8),
  test 95 -> 89 (tools|tui 19 -> 14, config|tui 10 -> 9).

Plan note: RS-7 was scoped to wait for R3/R7 (exec_agent.rs) and #6569
(tui/ui/frame.rs); this branch touches those files and will need a rebase
after they land.

Verification (local):
- cargo check -p codewhale-tui --lib --tests --examples: ok.
- cargo test -p codewhale-runtime --lib: 128 passed; 0 failed.
- cargo test -p codewhale-secrets --lib: 76 passed; 0 failed; 1 ignored.
- cargo clippy -p codewhale-runtime -p codewhale-secrets --all-targets
  --all-features -D warnings: clean.
- RUSTDOCFLAGS=-Dwarnings cargo doc -p codewhale-runtime --no-deps: clean.
The notification tests are in the TUI test binary, which was not linked
locally (shared volume at ~8 GB free); type-checked only.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01R9rJEoMuUSRWznU6QjkE7h

* wip: unfinished review fix-up (interrupted by the session usage limit)

Uncommitted edits from the fix-up stage, saved so nothing is lost. Not
compiled or tested after these edits; the next pass reviews and finishes them.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01R9rJEoMuUSRWznU6QjkE7h

* ci(split): run the ratchet's baseline-ref step under bash; wip fix-up reviewed

The boundary step uses `[[ =~ ]]` to skip an all-zero push `before` SHA, so
it names `shell: bash` instead of relying on the runner default.

This also closes the review of 7c8319eb1 (the interrupted fix-up): its edits
were compiled and tested here unchanged, and are kept as they are:
- the ratchet compares the committed baseline with the PR base
  (`--baseline-ref`), so a hand-raised JSON fails CI;
- `super::` chains that reach the crate root count as references;
- `cargo tree --target all` so cfg(windows) deps are checked too;
- the thread-scoped retry state moves from `test-support` to its own
  `test-thread-scoped-state` feature, debug-assertions only;
- RawModeSuspension resumes on the host that suspended it;
- palette `ids` tests run without the ratatui gate.

Ratchet proven to fail (then reverted):
- new `use crate::tui::...` in tools/notify.rs: FAIL, prod tools|tui 8 -> 9
- same edge via `super::super::tui::...`: FAIL, 8 -> 9
- baseline hand-raised to 9 with `--baseline-ref HEAD`: FAIL
  ("the baseline was raised relative to HEAD")
- `use ratatui::...` in crates/runtime/src: FAIL (source-scan)
Ratchet on a snapshot of this branch merged with origin/main: PASS.

Evidence (local):
- cargo check -p codewhale-runtime --locked: ok
- cargo check -p codewhale-palette --no-default-features --locked: ok
- cargo check -p codewhale-tui --locked: ok, 0 warnings
- cargo test -p codewhale-runtime -p codewhale-palette --lib: 130/0 and 84/0
- cargo test -p codewhale-tui --lib -- host_terminal notif retry_status
  tools:🐚 296 passed, 0 failed, 2 ignored
- scripts/test_check_command_crate_boundaries.py: 23/0
- scripts/check-command-crate-boundaries.py --baseline-ref origin/main: PASS
- validate-crate-publish-order (crates.sh, includes codewhale-runtime
  before codewhale-tui): ok; its test script: passed

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01R9rJEoMuUSRWznU6QjkE7h

* test(split): pin the notify tool's no-host receipt; document retry-state feature reach

Review follow-up on the runtime split (both reviews: ship, no blocker or
major findings).

- tools::notify: the_model_sees_success_whatever_the_host_did now asserts
  the exact receipt "notification not sent: no terminal host: done". The
  old assertion only checked for "done", so a tool that ignored host()
  and always said "sent" would still have passed. Also fixes the stale
  capabilities comment (delivery goes through the terminal host now).
- runtime::retry_status: documents that Cargo feature unification lets
  test-thread-scoped-state reach the debug `codewhale` binary under
  `cargo test --workspace`; release and optimized builds never get it.

Behavior note for the series (RS-7): hosts that never install the
terminal host (e.g. in-process `cli dispatch`) now get "notification not
sent: no terminal host" from the notify tool instead of OSC bytes on
stdout.

Evidence:
- cargo test -p codewhale-tui --lib -- tools::notify: 7 passed; 0 failed.
- cargo fmt --all; git diff --check: clean.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01R9rJEoMuUSRWznU6QjkE7h

* ci(split): move voice blocking-call budget with the code; list runtime crate in web facts

Lint (blocking-calls ratchet): RS-5 moved the synchronous voice
transcription core from commands/groups/core/voice.rs to src/voice.rs.
The 6 std::fs sites (temp WAV write / sidecar read / cleanup) moved
unchanged, so the budget key follows them: voice.rs gets std_fs 6 and
the now-clean commands/groups/core/voice.rs entry is dropped. Total
site count stays 718 across 202 files; no new debt. --update also
tightened fleet/exact.rs (clean on main, std_fs 2 -> 0) and re-sorted
the runtime/media_originals.rs entry.

Lint & Type Check (web): check:facts failed because the committed
facts.generated.ts predates the new `runtime` crate. Regenerated with
derive-facts.mjs.

Local checks: check-blocking-calls-budget.py within budget (718 sites,
202 files); test_check_blocking_calls_budget.py 11/11 OK;
check-facts OK; check-cloud-facts OK; check:latest-release OK;
check:tokens OK; derive-changelog current (6 releases);
check-versions.sh --range-audit-advisory OK; cargo fmt --check clean;
git diff --check clean. Not run locally: web lint/tsc/build/check:docs
(no web node_modules in this checkout; PR touches no other web files).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01R9rJEoMuUSRWznU6QjkE7h

* fix(web): read palette RGB tokens from rgb.rs after RS-3

RS-3 moved the *_RGB consts from crates/palette/src/tokens.rs to rgb.rs, so
check:tokens failed with "no palette RGB consts found". The exporter and the
three docs that name the token source now point at rgb.rs. tokens.css
changes only its generated-from comment; all 142 token values are identical.

Checks: export-design-tokens.py --check: up to date (1 file, 142 tokens).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01R9rJEoMuUSRWznU6QjkE7h

* test(core): keep engine tests off exec_agent/runtime_chat_relay for the runtime ratchet

Merging origin/main brought two core engine test edges into modules outside
the runtime closure: #6517 asserted the isolated-chat prompt via
crate::runtime_chat_relay::dedicated_chat_system_prompt(None), and the
Auto-Review question fix built the headless catalog via
crate::exec_agent::exec_disallowed_tools(None). The RS-0..RS-7 ratchet counts
test edges as `late` (they block the crate move like prod edges), so
test_checked_in_baseline_holds failed: late core|exec_agent 1 -> 2 and
late core|runtime_chat_relay 0 -> 1 (new pair).

Both intents are kept without the edges: the engine test asserts the shared
ISOLATED_CHAT_SYSTEM_PROMPT (the relay test already pins
dedicated_chat_system_prompt(None) to that constant), and the headless half
passes the explicit deny list (exec_agent tests already pin
exec_disallowed_tools(None) to it). Baseline unchanged.

Local: scripts/test_check_command_crate_boundaries.py 23/23 OK;
check-command-crate-boundaries.py --baseline-ref origin/main PASS
(prod 75, test 89, late 2, uilib 1, doc 1); focused cargo tests 4/4 passed
(question_tool_survives..., isolated_runtime_chat_provider_request...,
isolated_chat_prompt_drops..., headless_exec_withholds...); other Lint-job
python gates green; cargo fmt --all --check and git diff --check clean.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01R9rJEoMuUSRWznU6QjkE7h

---------

Co-authored-by: CodeWhale Bot <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 19:44:58 -07:00

757 lines
28 KiB
Python
Executable File

#!/usr/bin/env python3
"""Enforce one-way ceilings on Codewhale's provider-free runtime contract.
The default invocation runs ``measure-runtime-contract.py`` with Cargo forced
offline. Pass ``--receipt`` to check an existing JSON receipt without compiling,
which also keeps this checker's unit tests hermetic.
Usage:
python3 scripts/check-runtime-contract-budget.py
python3 scripts/check-runtime-contract-budget.py --receipt receipt.json
python3 scripts/check-runtime-contract-budget.py --update
python3 scripts/check-runtime-contract-budget.py --update --allow-increase
``--update`` alone only locks in decreases and refuses growth. A PR whose
change intentionally grows the contract, or changes a structural identity,
runs ``--update --allow-increase`` to rewrite the budget from its own
measurement so the fix lands in the same PR instead of turning main red after
merge. Existing ``_``-prefixed history notes are preserved either way.
"""
from __future__ import annotations
import argparse
import copy
import hashlib
import json
import os
import re
import shlex
import stat
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Any, Sequence
REPO_ROOT = Path(__file__).resolve().parent.parent
BUDGET_PATH = REPO_ROOT / "scripts" / "runtime-contract-budget.json"
MEASURE_SCRIPT = REPO_ROOT / "scripts" / "measure-runtime-contract.py"
RECEIPT_KIND = "codewhale.runtime_contract_receipt"
BUDGET_KIND = "codewhale.runtime_contract_budget"
SCHEMA_VERSION = 1
REPRESENTATIVE_FIXTURE_ID = "representative-v1"
TOOL_SURFACE_PROFILE = "production-default-builtins-no-mcp-no-host-interpreters-bash-v2"
MetricPath = tuple[str, ...]
MetricResult = tuple[str, str, int, int]
VISIBLE_MODES = (("plan", "Plan"), ("act", "Act"), ("operate", "Operate"))
REPRESENTATIVE_STAGES = (
("base", "base"),
("project", "project-authority"),
("instructions", "configured-instructions"),
("skill", "skill"),
("memory", "memory"),
("goal", "goal"),
("handoff", "handoff"),
)
TOOL_SURFACES = (("full", "full"), ("active", "active"))
METRICS: tuple[tuple[MetricPath, str], ...] = (
*(
(("system_prompt", "modes", mode, field), f"{label} {description}")
for mode, label in VISIBLE_MODES
for field, description in (
("system_prompt_bytes", "system-prompt bytes"),
("system_prompt_tokens_est", "system-prompt estimated tokens"),
("system_prompt_blocks", "system-prompt blocks"),
("mode_instructions_bytes", "mode-instruction bytes"),
("mode_instructions_tokens_est", "mode-instruction estimated tokens"),
)
),
*(
(
("representative_context", "stages", stage, "bytes"),
f"representative {label} stage bytes",
)
for stage, label in REPRESENTATIVE_STAGES
),
*(
(
("representative_context", "stages", stage, "delta_bytes"),
f"representative {label} stage delta bytes",
)
for stage, label in REPRESENTATIVE_STAGES[1:]
),
(
("representative_context", "total_bytes"),
"representative total bytes",
),
(
("representative_context", "total_tokens_est"),
"representative estimated tokens",
),
(
("representative_context", "system_prompt_blocks"),
"representative system-prompt blocks",
),
*(
(
("tool_catalog", "modes", mode, surface, field),
f"{label} {surface_label} {description}",
)
for mode, label in VISIBLE_MODES
for surface, surface_label in TOOL_SURFACES
for field, description in (
("tools", "tool count"),
("bytes", "tool-schema bytes"),
("tokens_est", "tool-schema estimated tokens"),
)
),
(
("skill_discovery", "first_delta", "root_discovery_calls"),
"first unchanged-turn root discovery calls",
),
(
("skill_discovery", "first_delta", "directories_visited"),
"first unchanged-turn directories visited",
),
(
("skill_discovery", "first_delta", "skill_md_read_attempts"),
"first unchanged-turn SKILL.md read attempts",
),
(
("skill_discovery", "second_delta", "root_discovery_calls"),
"second unchanged-turn root discovery calls",
),
(
("skill_discovery", "second_delta", "directories_visited"),
"second unchanged-turn directories visited",
),
(
("skill_discovery", "second_delta", "skill_md_read_attempts"),
"second unchanged-turn SKILL.md read attempts",
),
)
IDENTITIES: tuple[tuple[MetricPath, str], ...] = (
(("tool_catalog", "surface_profile"), "tool surface profile"),
(("tool_catalog", "execution_shell"), "tool fixture shell"),
*(
(
("tool_catalog", "modes", mode, surface, field),
f"{label} {surface_label} tool {description}",
)
for mode, label in VISIBLE_MODES
for surface, surface_label in TOOL_SURFACES
for field, description in (
("tool_names", "names"),
("identity_sha256", "identity digest"),
)
),
*(
(
("representative_context", "stages", stage, "identity_sha256"),
f"representative {label} stage identity digest",
)
for stage, label in REPRESENTATIVE_STAGES
),
)
class RuntimeContractError(ValueError):
"""A receipt or budget is missing a required, well-typed metric."""
def load_json(path: Path, kind: str) -> dict[str, Any]:
try:
document = json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError as error:
raise RuntimeContractError(f"missing {kind}: {path}") from error
except (OSError, json.JSONDecodeError) as error:
raise RuntimeContractError(f"invalid {kind} {path}: {error}") from error
if not isinstance(document, dict):
raise RuntimeContractError(f"invalid {kind} {path}: top level must be an object")
return document
def validate_document(
document: dict[str, Any], expected_kind: str, source: str
) -> None:
actual_kind = document.get("document_kind")
if actual_kind != expected_kind:
raise RuntimeContractError(
f"{source} document_kind must be `{expected_kind}`, got {actual_kind!r}"
)
version = document.get("schema_version")
if (
isinstance(version, bool)
or not isinstance(version, int)
or version != SCHEMA_VERSION
):
raise RuntimeContractError(
f"{source} schema_version must be {SCHEMA_VERSION}, got {version!r}"
)
def required_value(document: dict[str, Any], path: MetricPath, kind: str) -> Any:
value: Any = document
dotted = ".".join(path)
for part in path:
if not isinstance(value, dict) or part not in value:
raise RuntimeContractError(f"{kind} is missing required field `{dotted}`")
value = value[part]
return value
def tool_identity_digest(names: list[str]) -> str:
return hashlib.sha256("\0".join(names).encode("utf-8")).hexdigest()
def validate_identity_structure(document: dict[str, Any], kind: str) -> None:
profile = required_value(document, ("tool_catalog", "surface_profile"), kind)
if profile != TOOL_SURFACE_PROFILE:
raise RuntimeContractError(
f"{kind} tool surface_profile must be `{TOOL_SURFACE_PROFILE}`, "
f"got {profile!r}"
)
shell = required_value(document, ("tool_catalog", "execution_shell"), kind)
if shell != "bash":
raise RuntimeContractError(
f"{kind} tool execution_shell must be `bash`, got {shell!r}"
)
for mode, _label in VISIBLE_MODES:
for surface, _surface_label in TOOL_SURFACES:
base = ("tool_catalog", "modes", mode, surface)
names = required_value(document, (*base, "tool_names"), kind)
dotted_names = ".".join((*base, "tool_names"))
if (
not isinstance(names, list)
or any(not isinstance(name, str) or not name for name in names)
or names != sorted(set(names))
):
raise RuntimeContractError(
f"{kind} field `{dotted_names}` must be sorted unique non-empty strings"
)
count = metric_value(document, (*base, "tools"), kind)
if count != len(names):
raise RuntimeContractError(
f"{kind} metric `{'.'.join((*base, 'tools'))}` must equal the "
f"owned tool_names length ({len(names)})"
)
digest = required_value(document, (*base, "identity_sha256"), kind)
expected = tool_identity_digest(names)
if digest != expected:
raise RuntimeContractError(
f"{kind} field `{'.'.join((*base, 'identity_sha256'))}` must "
"match the owned sorted tool_names"
)
for stage, _label in REPRESENTATIVE_STAGES:
path = ("representative_context", "stages", stage, "identity_sha256")
digest = required_value(document, path, kind)
if not isinstance(digest, str) or re.fullmatch(r"[0-9a-f]{64}", digest) is None:
raise RuntimeContractError(
f"{kind} field `{'.'.join(path)}` must be a lowercase SHA-256 digest"
)
def validate_receipt(receipt: dict[str, Any]) -> None:
validate_document(receipt, RECEIPT_KIND, "receipt")
skill_discovery = receipt.get("skill_discovery")
identical = (
skill_discovery.get("prompts_byte_identical")
if isinstance(skill_discovery, dict)
else None
)
if identical is not True:
raise RuntimeContractError(
"receipt metric `skill_discovery.prompts_byte_identical` must be true"
)
representative = receipt.get("representative_context")
fixture_id = (
representative.get("fixture_id")
if isinstance(representative, dict)
else None
)
if fixture_id != REPRESENTATIVE_FIXTURE_ID:
raise RuntimeContractError(
"receipt metric `representative_context.fixture_id` must be "
f"`{REPRESENTATIVE_FIXTURE_ID}`, got {fixture_id!r}"
)
representative_identical = representative.get("prompts_byte_identical")
if representative_identical is not True:
raise RuntimeContractError(
"receipt metric `representative_context.prompts_byte_identical` must be true"
)
validate_identity_structure(receipt, "receipt")
def validate_budget(budget: dict[str, Any]) -> None:
validate_document(budget, BUDGET_KIND, "budget")
representative = budget.get("representative_context")
fixture_id = (
representative.get("fixture_id")
if isinstance(representative, dict)
else None
)
if fixture_id != REPRESENTATIVE_FIXTURE_ID:
raise RuntimeContractError(
"budget metric `representative_context.fixture_id` must be "
f"`{REPRESENTATIVE_FIXTURE_ID}`, got {fixture_id!r}"
)
validate_identity_structure(budget, "budget")
def metric_value(document: dict[str, Any], path: MetricPath, kind: str) -> int:
value = required_value(document, path, kind)
dotted = ".".join(path)
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
raise RuntimeContractError(
f"{kind} metric `{dotted}` must be a non-negative integer"
)
return value
def compare(
receipt: dict[str, Any], budget: dict[str, Any]
) -> tuple[list[MetricResult], list[MetricResult]]:
"""Return (increases, decreases) as path/label/current/ceiling tuples."""
validate_receipt(receipt)
validate_budget(budget)
for path, label in IDENTITIES:
receipt_value = required_value(receipt, path, "receipt")
budget_value = required_value(budget, path, "budget")
if receipt_value != budget_value:
detail = ""
if isinstance(receipt_value, list) and isinstance(budget_value, list):
added = [str(item) for item in receipt_value if item not in budget_value]
removed = [
str(item) for item in budget_value if item not in receipt_value
]
detail = f" (added={added} removed={removed})"
raise RuntimeContractError(
f"identity changed for {label} [`{'.'.join(path)}`]{detail}"
)
increases: list[MetricResult] = []
decreases: list[MetricResult] = []
for path, label in METRICS:
current = metric_value(receipt, path, "receipt")
ceiling = metric_value(budget, path, "budget")
result = (".".join(path), label, current, ceiling)
if current > ceiling:
increases.append(result)
elif current < ceiling:
decreases.append(result)
return increases, decreases
def set_path_value(document: dict[str, Any], path: MetricPath, value: Any) -> None:
target = document
for part in path[:-1]:
target = target.setdefault(part, {})
target[path[-1]] = value
def budget_from_receipt(receipt: dict[str, Any]) -> dict[str, Any]:
validate_receipt(receipt)
budget: dict[str, Any] = {
"_comment": (
"One-way numeric ceilings and exact structural identities for the "
"provider-free runtime contract. Decreases pass; increases or identity "
"changes fail. Lock in decreases with: python3 "
"scripts/check-runtime-contract-budget.py --update"
),
"document_kind": BUDGET_KIND,
"schema_version": SCHEMA_VERSION,
"representative_context": {
"fixture_id": REPRESENTATIVE_FIXTURE_ID,
},
}
for path, _label in METRICS:
set_path_value(budget, path, metric_value(receipt, path, "receipt"))
for path, _label in IDENTITIES:
set_path_value(
budget,
path,
copy.deepcopy(required_value(receipt, path, "receipt")),
)
return budget
def write_budget_atomic(path: Path, budget: dict[str, Any]) -> None:
"""Replace an existing budget atomically without changing its mode bits."""
original_mode = stat.S_IMODE(path.stat().st_mode)
payload = json.dumps(budget, indent=2, sort_keys=True) + "\n"
file_descriptor, temporary_name = tempfile.mkstemp(
prefix=f".{path.name}.", suffix=".tmp", dir=path.parent
)
temporary_path = Path(temporary_name)
try:
with os.fdopen(file_descriptor, "w", encoding="utf-8") as handle:
handle.write(payload)
handle.flush()
os.fsync(handle.fileno())
os.chmod(temporary_path, original_mode)
os.replace(temporary_path, path)
except BaseException:
temporary_path.unlink(missing_ok=True)
raise
def run_measurement() -> dict[str, Any]:
env = os.environ.copy()
env["CARGO_NET_OFFLINE"] = "true"
proc = subprocess.run(
[sys.executable, str(MEASURE_SCRIPT)],
cwd=REPO_ROOT,
env=env,
capture_output=True,
text=True,
check=False,
)
sys.stderr.write(proc.stderr)
if proc.returncode != 0:
sys.stdout.write(proc.stdout)
raise RuntimeContractError(
f"runtime-contract measurement failed with exit code {proc.returncode}"
)
try:
receipt = json.loads(proc.stdout)
except json.JSONDecodeError as error:
raise RuntimeContractError(f"measurement emitted invalid JSON: {error}") from error
if not isinstance(receipt, dict):
raise RuntimeContractError("measurement top level must be an object")
validate_receipt(receipt)
return receipt
def update_command(
receipt_path: Path | None, budget_path: Path, *, allow_increase: bool = False
) -> str:
parts = ["python3", "scripts/check-runtime-contract-budget.py"]
if receipt_path is not None:
parts.extend(["--receipt", str(receipt_path)])
if budget_path != BUDGET_PATH:
parts.extend(["--budget", str(budget_path)])
parts.append("--update")
if allow_increase:
parts.append("--allow-increase")
return shlex.join(parts)
def rebased_budget(receipt: dict[str, Any], previous: dict[str, Any]) -> dict[str, Any]:
"""Budget from ``receipt`` that keeps ``previous``'s ``_`` history notes."""
budget = budget_from_receipt(receipt)
for key, value in previous.items():
if key.startswith("_"):
budget[key] = copy.deepcopy(value)
return budget
FRAGMENT_MODULE = REPO_ROOT / "crates" / "core" / "src" / "fragments.rs"
FRAGMENT_MAX_TOKENS_CEILING = 10_000
FRAGMENT_MAX_BYTES_CEILING = FRAGMENT_MAX_TOKENS_CEILING * 4
FRAGMENT_DEFAULT_MAX_BYTES_CEILING = 4 * 1024
FRAGMENT_MAX_COUNT_CEILING = 16
def check_fragment_caps() -> None:
"""Gate the bounded fragment hard caps (issue #5264).
Static check — no cargo needed. Fails closed if the fragment module is
missing, if any cap has been raised without review, or if the
project-instruction import is absent.
"""
try:
text = FRAGMENT_MODULE.read_text(encoding="utf-8")
except FileNotFoundError as error:
raise RuntimeContractError(
f"missing bounded fragment module: {FRAGMENT_MODULE} ({error})"
) from error
def const_value(pattern: str) -> int:
match = re.search(pattern, text)
if not match:
raise RuntimeContractError(f"fragment cap missing: {pattern}")
try:
return int(match.group(1).replace("_", ""))
except ValueError as error:
raise RuntimeContractError(f"fragment cap not an int: {pattern}") from error
max_tokens = const_value(r"pub const MAX_FRAGMENT_TOKENS:\s*usize\s*=\s*([0-9_]+)")
if max_tokens != FRAGMENT_MAX_TOKENS_CEILING:
raise RuntimeContractError(
f"MAX_FRAGMENT_TOKENS must be {FRAGMENT_MAX_TOKENS_CEILING}, got {max_tokens}"
)
# MAX_FRAGMENT_BYTES must be defined as MAX_FRAGMENT_TOKENS * 4 (canonical)
# or as a literal 40000. Either way the derived ceiling is 40_000.
has_multiplication = re.search(
r"pub const MAX_FRAGMENT_BYTES:\s*usize\s*=\s*MAX_FRAGMENT_TOKENS\s*\*\s*4", text
)
bytes_literal = re.search(
r"pub const MAX_FRAGMENT_BYTES:\s*usize\s*=\s*([0-9_]+)", text
)
if bytes_literal:
literal = int(bytes_literal.group(1).replace("_", ""))
if literal != FRAGMENT_MAX_BYTES_CEILING:
raise RuntimeContractError(
f"MAX_FRAGMENT_BYTES must be {FRAGMENT_MAX_BYTES_CEILING}, got {literal}"
)
elif not has_multiplication:
raise RuntimeContractError(
"MAX_FRAGMENT_BYTES must be defined as MAX_FRAGMENT_TOKENS * 4 or as 40000"
)
# DEFAULT is defined as 4 * 1024 (canonical) or 4096 literal
has_default_multiplication = re.search(
r"pub const DEFAULT_FRAGMENT_MAX_BYTES:\s*usize\s*=\s*4\s*\*\s*1024", text
)
default_literal = re.search(
r"pub const DEFAULT_FRAGMENT_MAX_BYTES:\s*usize\s*=\s*([0-9_]+)", text
)
if has_default_multiplication:
# canonical 4*1024 == 4096, which equals ceiling
pass
elif default_literal:
default_bytes = int(default_literal.group(1).replace("_", ""))
if default_bytes != FRAGMENT_DEFAULT_MAX_BYTES_CEILING:
raise RuntimeContractError(
f"DEFAULT_FRAGMENT_MAX_BYTES must be {FRAGMENT_DEFAULT_MAX_BYTES_CEILING}, got {default_bytes}"
)
if default_bytes > FRAGMENT_MAX_BYTES_CEILING:
raise RuntimeContractError(
f"DEFAULT_FRAGMENT_MAX_BYTES ({default_bytes}) must not exceed MAX_FRAGMENT_BYTES ({FRAGMENT_MAX_BYTES_CEILING})"
)
else:
raise RuntimeContractError("DEFAULT_FRAGMENT_MAX_BYTES definition not found")
max_count = const_value(
r"pub const MAX_FRAGMENTS_PER_CONTEXT:\s*usize\s*=\s*([0-9_]+)"
)
if max_count != FRAGMENT_MAX_COUNT_CEILING:
raise RuntimeContractError(
f"MAX_FRAGMENTS_PER_CONTEXT must be {FRAGMENT_MAX_COUNT_CEILING}, got {max_count}"
)
if max_count > FRAGMENT_MAX_COUNT_CEILING:
raise RuntimeContractError(
f"MAX_FRAGMENTS_PER_CONTEXT ({max_count}) must not exceed {FRAGMENT_MAX_COUNT_CEILING}"
)
# Ensure every injection type is in FragmentId::all() and the
# project-instruction import is present as a typed fragment.
required_fragments = [
"Workspace",
"Permissions",
"Route",
"AgentTopology",
"SkillsTools",
"TokenBudget",
"ProjectInstructions",
"Constitution",
]
for name in required_fragments:
if f"Self::{name}" not in text and f"{name} =>" not in text and f'"{name.lower()}"' not in text.lower():
# Fallback: search for enum variant declaration
if not re.search(rf"\b{name}\b", text):
raise RuntimeContractError(
f"FragmentId missing required variant {name}"
)
# Marker stability — these strings are pinned by tests / prefix cache
required_markers = [
"<!-- cw:ctx:workspace -->",
"<!-- cw:ctx:project_instructions -->",
"<!-- cw:ctx:constitution -->",
]
for marker in required_markers:
if marker not in text:
raise RuntimeContractError(
f"bounded fragment module missing required marker {marker!r}"
)
# Project-instruction import must be a typed fragment, not ad-hoc
if "load_project_instruction_fragment" not in text:
raise RuntimeContractError(
"bounded fragment module must expose load_project_instruction_fragment (project-instruction import as typed fragment)"
)
if "PROJECT_INSTRUCTION_CANDIDATES" not in text:
raise RuntimeContractError(
"bounded fragment module must define PROJECT_INSTRUCTION_CANDIDATES"
)
# Required candidate files from #3978
required_candidates = [
".cursorrules",
".clinerules",
".windsurf/rules",
".gemini",
".github/copilot-instructions.md",
]
for candidate in required_candidates:
if candidate not in text:
raise RuntimeContractError(
f"PROJECT_INSTRUCTION_CANDIDATES missing required entry {candidate!r}"
)
# matches_text recognizer must exist on the fragment trait
if "fn matches_text" not in text:
raise RuntimeContractError(
"bounded fragment module must define a matches_text recognizer on the fragment trait"
)
if "trait ContextFragment" not in text:
raise RuntimeContractError(
"bounded fragment module must define trait ContextFragment with matches_text"
)
# No unbounded fragment — enforce that creation clamps to MAX_FRAGMENT_BYTES
if "MAX_FRAGMENT_BYTES" not in text or "enforce_byte_cap" not in text:
raise RuntimeContractError(
"bounded fragment module must enforce byte caps via enforce_byte_cap and MAX_FRAGMENT_BYTES"
)
# TUI must be unified with the core boundary (shared crates/core module)
tui_fragment = REPO_ROOT / "crates" / "runtime" / "src" / "model_context" / "fragment.rs"
try:
tui_text = tui_fragment.read_text(encoding="utf-8")
except FileNotFoundError as error:
raise RuntimeContractError(
f"missing TUI fragment module: {tui_fragment} ({error})"
) from error
if "codewhale_core::fragments" not in tui_text:
raise RuntimeContractError(
"TUI model_context/fragment.rs must re-export caps from codewhale_core::fragments (shared crates/core boundary)"
)
if "ProjectInstructions" not in tui_text:
raise RuntimeContractError(
"TUI fragment module must include ProjectInstructions variant (unified with core)"
)
if "MAX_FRAGMENT_BYTES" not in tui_text:
raise RuntimeContractError(
"TUI fragment module must enforce MAX_FRAGMENT_BYTES (10K-token ceiling)"
)
if "matches_text" not in tui_text:
raise RuntimeContractError(
"TUI fragment module must expose a matches_text recognizer"
)
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--receipt",
type=Path,
help="check an existing measurement JSON instead of compiling",
)
parser.add_argument(
"--budget",
type=Path,
default=BUDGET_PATH,
help=argparse.SUPPRESS,
)
parser.add_argument(
"--update",
action="store_true",
help="tighten all ceilings to the current receipt; refuses increases",
)
parser.add_argument(
"--allow-increase",
action="store_true",
help=(
"with --update, also accept increases and identity changes: rewrite "
"the budget from the receipt so the change lands in the same PR"
),
)
args = parser.parse_args(argv)
if args.allow_increase and not args.update:
parser.error("--allow-increase requires --update")
grow_command = update_command(args.receipt, args.budget, allow_increase=True)
try:
check_fragment_caps()
if args.receipt is not None and args.receipt.resolve() == args.budget.resolve():
raise RuntimeContractError(
"receipt and budget must resolve to distinct filesystem paths"
)
budget = load_json(args.budget, "budget")
receipt = (
load_json(args.receipt, "receipt")
if args.receipt is not None
else run_measurement()
)
if args.allow_increase:
validate_receipt(receipt)
validate_budget(budget)
write_budget_atomic(args.budget, rebased_budget(receipt, budget))
print(
f"[runtime-contract-budget] wrote {args.budget} from the current "
f"measurement ({len(METRICS)} metrics, {len(IDENTITIES)} identities). "
"Record why it grew in the budget's _comment or the PR description."
)
return 0
increases, decreases = compare(receipt, budget)
except RuntimeContractError as error:
print(f"[runtime-contract-budget] ERROR: {error}", file=sys.stderr)
if str(error).startswith("identity changed"):
print(
"If the identity change is intended, land the new budget in this PR:\n"
f" {grow_command}",
file=sys.stderr,
)
return 2
except OSError as error:
print(
f"[runtime-contract-budget] ERROR: failed to update budget: {error}",
file=sys.stderr,
)
return 2
if increases:
print("[runtime-contract-budget] FAIL: runtime contract grew:", file=sys.stderr)
for path, label, current, ceiling in increases:
print(
f" {label}: {current} > {ceiling} (+{current - ceiling}) [{path}]",
file=sys.stderr,
)
print(
"\nReduce the model-facing surface, or if the growth is intended land "
f"the higher ceiling in this PR:\n {grow_command}",
file=sys.stderr,
)
return 1
if args.update:
try:
write_budget_atomic(args.budget, rebased_budget(receipt, budget))
except OSError as error:
print(
f"[runtime-contract-budget] ERROR: failed to update budget: {error}",
file=sys.stderr,
)
return 2
print(
f"[runtime-contract-budget] wrote {args.budget}: "
f"{len(decreases)} decreased ceilings locked in "
f"({len(METRICS)} total)"
)
return 0
if decreases:
print(
f"[runtime-contract-budget] PASS: {len(METRICS)} ceilings respected; "
f"{len(decreases)} can be tightened."
)
for path, label, current, ceiling in decreases:
print(f" {label}: {current} < {ceiling} (-{ceiling - current}) [{path}]")
print(f"Tighten with:\n {update_command(args.receipt, args.budget)}")
return 0
print(
f"[runtime-contract-budget] PASS: all {len(METRICS)} metrics are exactly at budget."
)
return 0
if __name__ == "__main__":
raise SystemExit(main())