release: prepare Codewhale v0.9.11 - #5542
Conversation
…mand contract - CommandPresentationContext: stable-key translation with named replacements (D3) - CommandMediaContext + MediaAttachmentReceipt: atomic composer/media attach (D4) - CommandWorkspaceContext::operation_digest: session-aware canonical digest (D5) - Envelope: presentation + media optional slots with duplicate-slot asserts (D7) - Contract tests: object safety, transport, translation failures, media atomicity, digest, duplicate-slot rejection (9 total) Generated with Claude Code
…(D3) Review fix: unknown translation key now fails with a generic safe error in the contract test double and the assertion verifies the raw key is not echoed. Generated with Claude Code
- PresentationAdapter: stable-key translation with named replacement validation and English fallback (D3) - MediaAdapter: atomic media validation + composer insertion, portable receipt (D4) - WorkspaceAdapter::operation_digest: session-aware canonical digest with no-active/failure semantics (D5) - Bundle carries presentation + media adapters; construction performs no eager work (D7) - 16 contract adapter tests green; boundary + migration gates PASS Generated with Claude Code
Lib build flagged unused Locale import; moved to the cfg(test) module where it is used. Generated with Claude Code
Expose bounded, workspace-relative LSP diagnostics for multiple existing files through the model-visible lsp tool. Reuse the shared transport pool, fail clearly when LSP is unavailable, and preserve the frozen tool catalog budget for #4070.
…ct registration - /automation, /mcp: Contextual handlers consuming presentation facet for localized text (D3/D6) - /attach: Contextual handler resolving paths via workspace facet + atomic media facet (D4/D6) - /task: Contextual handler with workspace operation_digest for digest (D5/D6) - /jobs, /network, /update: Pure argument-only handlers (D6) - /network uses codewhale_config leaf APIs + reqwest::Url host parse; no TUI persistence/network_policy helpers - mod.rs bridges all seven via ContextualCommand::from_contract; FunctionCommand removed from utility - FEAT-015 transitional test updated: utility excluded from legacy-only assertion - 35 utility tests + full TUI lib 10857/0 green Generated with Claude Code
…ity dispatch - utility removed from scripts/command-migration-topology.json frontier (topology scope immutable) - utility removed from PENDING_GROUPS TUI projection - Live migration gate PASS: frontier [config,core,debug,memory,plugins,project,session,skills] exact source correspondence - Public dispatch tests: 7-entry portable inventory, pure /jobs+/update, contextual /automation+/task+/mcp+/attach+/network through public seam (Task 6.2) - FEAT-015 transitional dead-code allowances removed (ContextualCommand legacy/command_handler/is_legacy); PENDING_GROUPS/parts get targeted test/gate allows - Migration fixture updated to shrunk 8-group frontier; 54/54 fixtures + CI wiring 11/11 green Generated with Claude Code
…work normalization - MediaAdapter: video-path attach test (extension-gated, composer reference preserved) - Network: wildcard/trailing-dot/case host normalization, URL-path rejection, hostless URL rejection, exact conflict removal tests Generated with Claude Code
…d duration assertion) run.metrics.duration.as_millis() > 0 flaked under saturated serial test runs: the offline simulated loop performs real fs/tempdir work that can complete sub-millisecond, so the millisecond threshold intermittently measured 0ms. Aligned granularity with the per-tool assertion (as_nanos() > 0), which is deterministic. Pre-existing on origin/main; repaired under the Boy Scout Rule. Generated with Claude Code
Every nightly since 2026-08-16 failed the windows-arm64 leg while compiling codewhale-tui, with `thread 'optimize module codewhale_tui.551c4fd52ebe22df -cgu.13' has overflowed its stack` — the same codegen unit on all three build attempts, so this was never flaky. The stack belongs to the LLVM worker threads rustc spawns to run per-codegen- unit optimization, not to rustc's main thread. `lto=off` is why that work runs while the *library* is compiled instead of being deferred to the ThinLTO stage, but it is not the root cause: removing the override would only relocate the optimization and hide the failure. Evidence (local, aarch64-apple-darwin, crate and every flag held fixed, only RUST_MIN_STACK varied, CARGO_PROFILE_RELEASE_LTO=off): default (2 MiB) -> builds, 2m53s 1 MiB -> rustc dies, SIGBUS 2 MiB -> builds 4 MiB -> builds So the requirement sits between 1 and 2 MiB. Unix std defaults to 2 MiB and passes; the Windows ARM64 runner sat under it. That requirement follows from the size of the crate — crates/tui is 788k lines and the regression window added 24.7k of them — not from the operating system, so RUST_MIN_STACK is set for the whole matrix rather than special-cased. The value is reserved address space, not committed memory. Splitting the crate's largest modules is the durable fix and is tracked separately. Also drops CARGO_PROFILE_RELEASE_CODEGEN_UNITS: [profile.release] already sets codegen-units = 16, so the override restated the existing value and never provoked anything. Believing it did is what pointed the first diagnosis at the wrong flag. Shipped binaries were never affected: release-artifacts.yml builds --profile dist with fat LTO and codegen-units = 1. Verified locally: actionlint 1.7.12 clean, YAML parses, Build step env asserted. The windows-arm64 nightly turning green is CI-only and is not proven here. Diagnosis and patch produced with agent assistance. Signed-off-by: Hunter Bown <hmbown@gmail.com>
…kspace Ordinary workspace-write turns built their sandbox with `network_access: true`, so agreeing to let a session edit this repository also handed every shell command unrestricted outbound egress. #273 introduced that grant, and justified it: the seatbelt default denies DNS, which broke curl, yt-dlp, and package managers, and the comment argued the application-level NetworkPolicy would remain "the only outbound boundary". The second half was not true. NetworkPolicy governs fetch_url, web_search, and MCP HTTP; it never saw a shell subprocess. So the layer meant to compensate for the wide OS policy did not cover the thing the OS policy had opened, and workspace-write sessions ran with no outbound boundary at any layer. This is not tightening a working boundary — it is installing one that was missing. Workspace-write is now created network-restricted. Egress comes from exactly three explicit places: - `sandbox_network_access` in config (env: CODEWHALE_SANDBOX_NETWORK_ACCESS), - a `danger-full-access` posture, which applies no sandbox at all, - the existing post-denial elevation prompt, which grants network for one call after the user sees what was blocked. Yolo and --yolo/Bypass are unchanged: they resolve to DangerFullAccess, so their deliberate "no guardrails" contract still reports network. Plan stays ReadOnly. Writable roots, tmpdir handling, and the git-worktree metadata roots are untouched — only the network bit moved. The decision is carried by a typed `SandboxNetworkAccess` rather than another bool, so the default is stated once at the type instead of at each of the eight resolver call sites, and "the user asked for network" cannot be transposed with "some caller passed true". Two surfaces were lying and now read the flag: `external-sandbox` hardcoded `network_access: true` even when nothing granted it, and /status printed "sandbox workspace-write, network on" for every workspace-write policy — true only by accident of the old default. Tests. The #273 regression test is retargeted rather than deleted: it now pins that Agent mode still elevates *writes* while withholding network, which is the property #273 actually needed. Added coverage for the full posture matrix (Agent/Ask/Auto-Review/Never x configured overrides, plus Yolo and Plan), the config key and its camelCase alias, and — the gap the audit surfaced — that the generated seatbelt profile emits network rules if and only if the policy grants them, so the OS layer and the application policy are verified to agree instead of one being assumed to compensate for the other. Verified: cargo test -p codewhale-tui --lib -> 10850 passed, 0 failed, 13 ignored (RUST_MIN_STACK=16777216, as scripts/dev-test.sh exports). cargo fmt --all clean. Platforms with no sandbox backend (default Linux without bubblewrap, and Windows) still enforce nothing either way; /status and doctor continue to say so, and that honesty gap is unchanged by this commit. Implemented with agent assistance. Signed-off-by: Hunter Bown <hmbown@gmail.com>
…nts' law Two problems, one subsystem. **Another tool's instruction file was standing authority here.** The canonical list ranked `.claude/instructions.md` second and `CLAUDE.md` third — above Codewhale's own `.codewhale/instructions.md` at fourth — `.claude/rules/` was an auto-discovered rules directory, and `.cursorrules`, `.cursor/rules`, `.clinerules`, `.windsurf/rules`, `.gemini`, `.github/copilot-instructions.md` and `.github/muse-instructions.md` were imported into the system prompt with no opt-in at all. Dropping a `CLAUDE.md` written as law for a different agent into a repository silently made it law for this one, and outranked the file this project actually owns. That is an injection surface, not a compatibility feature. Codewhale now reads `AGENTS.md`, the cross-agent `.agents/AGENTS.md`, and its own instruction files by default. Every other agent's format is opt-in by name through `project_instruction_imports` (env `CODEWHALE_PROJECT_INSTRUCTION_IMPORTS`), imported files rank *below* Codewhale's own rather than above them, and a workspace containing an un-imported format produces a warning naming the exact setting — so this is discoverable rather than a silent behavior loss. Unknown names in the key are reported instead of dropped, because a typo otherwise means "import nothing" and nobody would notice. **A symlinked candidate directory could read outside the workspace.** `collect_candidate_files` checked every *file* it found for symlinks but reached them through `path.is_dir()`, which follows links. A symlinked `.cursor/rules` pointing anywhere on disk was therefore traversed, and the real files behind it passed every per-entry check. `project_context.rs` has refused symlinked rules directories since it gained them, with a comment explaining this precise escape; `fragments.rs` never got the same guard. Now both loaders apply it. The regression test fails without the fix and passes with it — I checked, rather than assuming. **One budget instead of four.** The chain had 200 KiB, the rules block 500 KiB, imported fragments 40 KiB, and the global fallback layer was merged in *after* the chain budget had already closed, so it counted against nothing. No single number described how much standing instruction text could precede the conversation. All of it now shares one 48 KiB aggregate ceiling, applied once after assembly. Instructions claim it before rules, and are trimmed from the front — dropping the broadest scope first — so the nearest-scope file is the last thing dropped instead of the first thing stranded, which is what the old root-first per-segment accounting did. Truncation still leaves an explicit marker. The opt-in set is threaded as a parameter rather than read from a global inside the loader, so callers and tests state which formats are in play instead of racing on process-wide state. Preserved: nearest-scope traversal, repository-root stopping, the $HOME clamp, `O_NOFOLLOW` on unix, truncation markers, `<project_instructions source=…>` provenance, and the existing duplicate-suppression between the two loaders. Verified: cargo test -p codewhale-tui --lib -> 10854 passed, 0 failed, 13 ignored. cargo test -p codewhale-core -> 80 passed, 0 failed. cargo fmt --all clean. Note for the release notes: this is a behavior change. A repository whose only instructions live in CLAUDE.md will stop contributing them until `project_instruction_imports = ["claude"]` is set. The warning names the key. Implemented with agent assistance. Signed-off-by: Hunter Bown <hmbown@gmail.com>
Regenerate the committed website facts after exposing read_lints through the existing lsp tool.
At 80 columns the Japanese provider screen rendered モデルの実行先を選びます。ホステッドプロバイダーにはキーが必要ですが、ローカ and stopped. The sentence continues "ルランタイムはキーなしで続行できます。" — local runtimes continue without a key — which is the reassurance that screen exists to deliver, on the one screen where the user is deciding whether they need an API key. It was not wrapped to a second line; it was written past the right edge and clipped by the terminal. `wrap_words` iterates `text.split_whitespace()`. Japanese, Chinese, and Thai do not delimit words, so the whole sentence is a single token. The wrap check is `!current.is_empty() && needed > width`, which cannot fire for the first token on a line, so the token was appended whole however wide it was. The function's doc comment asserted the opposite: "no paragraph re-wrap can clip a locale with longer sentences." A token wider than the lane is now broken by display width on grapheme clusters, which is the conventional wrap for those scripts. Light kinsoku comes with it: a line may not begin with closing punctuation or a sentence-final mark (`。 、 」 』 ) ! ? ー` and the Latin equivalents), so the break pulls one cluster back rather than orphaning it. Grapheme clustering means Devanagari and emoji sequences are never split mid-cluster either. Languages that do use spaces keep their existing behaviour — the new branch only runs for a token that cannot fit on any line. Found by driving first-run onboarding through a PTY in all fifteen shipped locales at 120x32 and 80x24 and reading the reconstructed frames. Korean, Hindi, Russian, German, French, Spanish, Portuguese, Catalan, Indonesian, Vietnamese and both Chinese packs already wrapped correctly; Japanese was the locale whose translation was long enough to cross the lane at 80 columns. Verified: cargo test -p codewhale-tui --lib -> 10858 passed, 0 failed, 13 ignored. The wrap regression test fails with the new branch disabled and passes with it. Re-driving the real binary at 80x24 shows both lines. Implemented with agent assistance. Signed-off-by: Hunter Bown <hmbown@gmail.com>
…remains
`crates/core/src/engine/` was 526 lines describing a runtime that does not
exist. `Engine::run` accepted `Op::SendMessage`, appended the content to a
journal, and emitted `TurnComplete { status: "completed" }` — no provider
request, no tools, no streaming. `TurnExecutor`, which `docs/ARCHITECTURE.md`
pointed at as the owner of turn orchestration, was a field-copy constructor and
`step < self.max_steps`; its `exec_policy` field was stored and never read.
Nothing consumed it. The only occurrence of `codewhale_core::engine` in the
entire workspace was a doc comment inside the tree describing how the next
migration slice would use it, and `crates/core/src/lib.rs` declared the module
without re-exporting `executor`. `cargo check --workspace` passes unchanged
after removal.
Leaving it in place was not neutral. Its comments were the source of the claim
that `crates/core` owns the agent loop, which is false: the live loop is
`Engine::run_turn` in `crates/tui/src/core/engine/turn_loop.rs`, and
`crates/tui/src/core/` is a module inside the TUI crate, not this crate. That
confusion is exactly the kind a stalled migration leaves behind, and it made an
"is the runtime already migrated?" question un-answerable by reading.
Deleting it is also the part of the core-ownership migration that is honestly
achievable now, and it delivers the property that mattered: there is exactly one
turn loop in the workspace, enforced by a guard rather than asserted in prose.
`crates/core/tests/single_turn_loop.rs` scans the workspace, requires exactly
one `run_turn`, requires it to be the one in `turn_loop.rs`, and fails if
`crates/core/src/engine/` reappears. It is a source scan on purpose: a second
implementation would not be reachable from the first, so no type-level check
could see it.
The full crate hoist stays out of this release. Two hard blockers make it
unsafe to attempt now, and both should be recorded rather than rediscovered:
`crates/command-contract`'s only dependency is `codewhale-core`, so moving
TUI modules that need it into core is a cargo cycle; and `Event::AgentList`
declares `roster: Vec<crate::tui::agent_roster::AgentRosterRow>`, so core's
public event enum would name a TUI type the moment `events.rs` moved.
`docs/ARCHITECTURE.md` now describes what `crates/core` actually is, notes that
`app-server --http`/`--mobile` delegate to the TUI binary, and `AGENTS.md`
records the one-loop rule next to the other current contracts.
Verified: cargo check --workspace clean; cargo test -p codewhale-core ->
76 + 2 + 2 passed, 0 failed. cargo fmt --all clean.
Implemented with agent assistance.
Signed-off-by: Hunter Bown <hmbown@gmail.com>
…equest
`/prompt`, `prompt/request` and `prompt/run` returned HTTP 200 with a
`PromptResponse` for work that never happened. `Runtime::handle_prompt`
contacted no model: it resolved config, ran a local `ModelRegistry`
lookup, emitted three canned hook events (the `ResponseDelta` payload was
the literal string `model-selected`), and set `output` to a stringified
JSON echo of the caller's own routing metadata — provider, model,
telemetry flags, and the prompt itself. A client could not tell that
apart from a real answer, which is the whole problem: a benchmark
harness, an SDK, or a person reading the response all get a success and
a plausible-looking body for a model call that did not occur.
The durable damage was worse. With a `thread_id`, `handle_prompt` called
`touch_message` (appending a genuine user row and flipping the thread to
`Running`), then wrote that same echo into history as an **assistant**
message and saved a `prompt_response` checkpoint pointing at it. Nothing
marked the row synthetic, and nothing ever moved the thread out of
`Running`. Every prompt permanently poisoned the transcript it touched.
The real path already existed one file over: `RuntimeBridge::message_thread`
POSTs to `/v1/threads/{id}/turns` and streams the turn's SSE events, and
stdio `thread/message` has used it all along. So there was no engine to
build — only a fake to delete and a route to point at the engine. All
four prompt surfaces now go through one `run_bridged_turn`, and
`Runtime::handle_prompt` is gone along with `persist_latest_checkpoint`,
which existed only to record its fabrication.
HTTP `POST /thread` with a `Message` body had the same shape of lie in a
milder form: `status: "accepted"` and a `ResponseDelta("queued")` with no
worker started and no bridge call. The stdio path for the identical
request did real work, so the two transports disagreed about what
`accepted` meant. HTTP now runs the turn and replies `completed` with the
streamed frames. `Runtime::handle_thread` no longer accepts
`ThreadRequest::Message` at all — it owns thread bookkeeping, not the turn
engine — and says so, naming `POST /v1/threads/{id}/turns`.
Failure is now typed rather than success-shaped. `POST /prompt` returns
`{"error":{...}}` with 400/404/503/500 instead of HTTP 500 carrying a
`PromptResponse` whose `output` held the error text, and stdio gained
`-32005 runtime_unavailable` for "the turn engine could not be reached,
so nothing ran" — retryable, and distinct from `-32603`. There is no
configuration in which a prompt quietly echoes: if no runtime can be
reached, the caller is told.
`prompt_handler` also no longer holds the `Runtime` write lock across the
request. It held it for the entire body while doing no model work.
Verified: `cargo test -p codewhale-app-server --lib` (83 passed),
`cargo test -p codewhale-core --lib` (76 passed),
`RUSTFLAGS=-Dwarnings cargo clippy` clean on both crates, and
`cargo check --workspace --all-targets` clean. The four new app-server
tests and the rewritten core test were each confirmed to fail against a
temporarily restored pre-fix implementation.
Contract changes are declared in CHANGELOG.md.
Implemented with agent assistance.
Signed-off-by: Hunter Bown <hmbown@gmail.com>
…swers
`AppRequest::SubmitUserInput` returned `ok: true` with
`resolved: true` and stored the caller's answers in
`state.pending_user_input`. That map had no read site anywhere in the
crate — declared, initialised, written, never read. Every clarification
answer a client submitted was acknowledged as resolved and then dropped.
The doc comment called an in-flight resume "a follow-up", which read as
a partial implementation rather than what it was: a reply path that
cannot deliver anything.
It cannot be completed on this transport, and that is a structural fact,
not a missing feature. `handle_line_during_turn` executes exactly one
method while a turn is streaming — `thread/interrupt`. Everything else,
`app/request` included, hits the catch-all and queues until the turn
ends. So an answer sent over this transport waits for the turn, and a
turn blocked on that answer waits for the answer. Building the resume
awaiter would have produced a deadlock, not a feature.
The runtime API already has the surface that works:
`POST /v1/user-input/{thread_id}/{request_id}`, which owns the pending
request and can resume the turn that raised it. So this now returns
`ok: false` with `error: "user_input_reply_unsupported"` and a message
naming that endpoint and the reason. `pending_user_input` and its
type alias are deleted with it.
The minting side is untouched and was never fake: `Runtime::invoke_tool`
still emits a genuine `EventFrame::UserInputRequest` with the model's
real questions and returns `user_input_required`. Only its documented
reply path was a lie, and the comment there now points at the runtime
API instead of at this crate.
Verified: `cargo test -p codewhale-app-server --lib` (84 passed),
`cargo test -p codewhale-core --lib` (76 passed),
`RUSTFLAGS=-Dwarnings cargo clippy` clean on both crates,
`cargo check --workspace --all-targets` clean. The new test was confirmed
to fail against a temporarily restored `resolved: true` implementation.
Implemented with agent assistance.
Signed-off-by: Hunter Bown <hmbown@gmail.com>
`check-versions.sh` compares `crates/tui/CHANGELOG.md` against its slice of the root `CHANGELOG.md` and fails on any divergence. The placeholder-engine removal was recorded in the root file only, because the change lives in `crates/core` rather than the tui crate — but the gate compares files, not subject matter. Generated with ./scripts/sync-changelog.sh. Verified: ./scripts/release/check-versions.sh exits 0 on this branch. Signed-off-by: Hunter Bown <hmbown@gmail.com>
Mirrors the fix landed on main (75dca2c): the release-workflow contract test asserts nightly.yml never matches /codewhale-tui/, and the stack-size comment named the crate. Reworded to crates/tui. Verified: full ci.yml versions job (check-versions.sh + 13 release-helper contract tests) passes locally on this branch. Signed-off-by: Hunter Bown <hmbown@gmail.com>
AGENTS.md says the model-facing sub-agent surface is `agent` only. It was
not. `agents/list`, `agents/message`, `agents/followup`, `agents/interrupt`,
`agents/coordinate`, and `agents/wait` never overrode `ToolSpec::
model_visible`, which defaults to true, so all six cleared the filter in
`ToolRegistry::build_api_tools` and entered the model catalog. Being deferred
did not hide them — deferral makes a tool discoverable via `tool_search`, and
both matchers read the same catalog. The `agent` description then named five
of them outright ("the narrow agents/list, agents/message, … tools expose the
same semantics directly"), so the surface actively taught the second
transport it claimed not to have.
The fix follows the precedent already in this tree: `rlm` and `exec_shell`
return false from `model_visible`, staying registered and executable by name
so a persisted transcript replays against the same implementation while never
being advertised. All six now do the same.
Five were pure duplicates of an `agent` action. `agents/coordinate` was not.
Its `claim` action is the only path to `SubAgentManager::expand_write_claim`,
and write-scope enforcement fails closed — so hiding it without a replacement
would have left the refusal message ("expand it first with agents/coordinate
action=claim") pointing at a tool the model can no longer call, with no way to
proceed. `agent` therefore gains exactly one action, `claim`, and no more:
propose/accept/supersede/reconcile/inspect stay off the model surface because
nothing fails closed without them.
`claim` reuses the write-scope vocabulary `action=start` already speaks —
`write_roots` advertised, `exact_files` and `coordination_contracts`
parse-accepted — so one set of names describes a child's scope whether it is
declared at launch or widened later. The translation to the coordinate wire is
the load-bearing part and has its own documented function, because both ways
of getting it wrong fail silently: the wire key is `roots`, and forwarding
`write_roots` hands `expand_write_claim` three empty lists, which returns the
unchanged claim with `Ok` — a success receipt for an expansion that never
happened. The same no-op-success is why a scopeless claim is refused outright
rather than passed through. Approval stays `Auto`, inheriting
`agents/coordinate`'s rationale: gating a coordination record deadlocks
autonomous fan-in, and `claim` can only widen the caller's own scope.
Per-role gating had to be solved explicitly, and this is the part worth
reviewing. Every capability gate here keys off a tool *name*, which is exactly
what breaks when six tools become one, and `agent` is deliberately exempt from
both: `posture_permits_tool` short-circuits it so delegation depth rather than
write posture governs spawning, and `execution_envelope::is_delegation_tool`
classifies it `Bounded` so a read-only member can still fan out read-only
work. A capability folded into `agent` therefore inherits no gate at all.
`agent_action_permitted` supplies one per action, reproducing the check the
retired tool actually had — `agents/coordinate` declared `WritesFiles` and was
kept off a read-only role's catalog by `envelope.write` — rather than
inventing a new policy. It is applied when shaping the catalog and again at
dispatch, because catalog shaping has never been the authority boundary here.
The other seven actions keep exactly today's visibility; narrowing
message/followup/interrupt for read-only roles would be an unrelated behavior
change smuggled in behind a catalog cleanup. Making `agent` a
`CANONICAL_ACTION_ALIASES` family would have reused the existing action-policy
seam, but `canonical_action_alias` feeds `execution_envelope`, where the
`agent` name is what earns the `Bounded` reclassification — so that route
would have made `claim` demand write *and* shell authority.
Contract changes, declared: the six tools leave the model catalog (wire
shapes, schemas, and dispatch-by-name are unchanged); `agent` gains the
`claim` action and its enum grows by one; the out-of-scope write refusal and
the child's write-scope briefing now name `agent action=claim`; the frozen
per-role surfaces drop the `agents/*` entries.
Tests: the catalog test that asserted `agent` appears once now also asserts
none of the six appear and that each stays registered and model-invisible;
`tool_search` is exercised on both the regex and bm25 paths with queries
aimed at the retired names and their descriptions; the `agent` description and
schema are asserted to name none of them; the claim path is proven end to end
by writing a file that was refused before the claim and admitted after it,
which is what makes the wrong wire key fail here instead of in production.
Every new test was confirmed to fail with its fix reverted.
Implemented with agent assistance.
Signed-off-by: Hunter Bown <hmbown@gmail.com>
`Message.role` was a `String`, so "assistant" and "assistnat" were both valid transcripts and only a provider could tell them apart. Worse, the four wire adapters each answered "what is an unfamiliar role?" differently by accident: Chat Completions and Responses dropped it silently, Anthropic forwarded it verbatim for the provider to 400 on, and cloud-code bailed. There was no single place to state the answer because there was no type to hang it on. `crates/core/src/role.rs` adds that type. It is closed over the roles this build actually mints — user, assistant, system, and the `assistant_interrupted` sentinel — plus `Unrecognized(String)` for everything else. There is deliberately no `Tool` variant: nothing on the neutral side constructs one, and inventing a variant for a producer that does not exist would just move the guessing into the type. `Unrecognized` is not a shrug, it is the compatibility contract. `Role` serializes through `as_str()` and deserializes from any string, so a persisted transcript's bytes are identical before and after this change. That matters concretely: `Message` derives Serialize/Deserialize and every saved session holds these strings, while `session_manager` refuses a session whose `schema_version` exceeds CURRENT and has no migration ladder back down. A forward-incompatible role encoding would have stranded anyone who downgraded. Two tests pin it — a per-role byte comparison against the raw string encoding, and a whole-transcript load/re-save that must not change a single byte, including an unknown "developer" role. `assistant_interrupted` stays its own variant rather than a flag on Assistant, so it keeps round-tripping as a distinct session item as `models.rs` already asserted. `PartialEq<&str>`/`PartialEq<String>` in both directions keep the ~40 read-only string comparisons across the tree compiling unchanged; only construction sites moved. Fleet roles (`crates/tui/src/fleet/`, `crates/workflow/`, the runtime worker-job APIs) are a different concept and are untouched. No behaviour change: this commit is types and construction sites only. The adapters still each decide placement for themselves; that is the next commit. Implemented with agent assistance. Signed-off-by: Hunter Bown <hmbown@gmail.com>
Avoid counting the private diagnostics helper as a second model-visible tool and refresh generated web facts back to the real 75-tool catalog.
`tools/subagent/coord.rs` was 3.8k lines holding two things that have almost
nothing to do with each other: the narrow `agents/*` tool wrappers, and the
durable coordination records — decisions, write-scope claims, contention and
reconciliation receipts, the projection types — that those wrappers happen to
read and write. The split is not arbitrary: the records have consumers outside
the tool layer entirely (`tui::coordination_detail`, `tui::work_surface`,
`tui::ui::tests`, `core::engine::tests` all name them), while the wrappers are
model-surface code with no consumer but the registry. Anyone reading either
half started by scrolling past the other.
`ledger.rs` now owns the records and their unit tests; `coord.rs` keeps the
tools. `coord` re-exports the ledger, so every existing
`crate::tools::subagent::coord::{…}` path still resolves and not one consumer
file was edited — which is the property that makes this reviewable as a move
rather than a refactor.
The re-export is a glob rather than an explicit list, deliberately. Several of
these types are named only from `cfg(test)` code in other modules, so an
explicit `pub use` of them reads as an unused import in a release build under
`-D warnings`; the glob also preserves each item's own visibility, keeping
`MAX_RECONCILIATION_RETRIES` reachable inside `coord` without promoting it to
the module's public surface.
Verified as a content-pure move: every chunk of the pre-split file is present
verbatim in exactly one of the two files, modulo three adjustments the move
itself forces — the `use` blocks split between the files, `coord`'s test module
relocating to the end of the file (an item may not follow a test module), and
one constant going from private to `pub(super)` because its only caller,
`AgentsCoordinateTool`, stayed behind. The lib suite reports the same 10864
passing tests before and after, so nothing was dropped in transit.
Implemented with agent assistance.
Signed-off-by: Hunter Bown <hmbown@gmail.com>
…e seam
Four adapters each answered "where does this role go, and what if I do not
recognise it?" and the four answers had drifted apart:
* Chat Completions matched user/assistant/system in an if/else-if chain
with no final else, so anything else fell off the end silently.
* OpenAI Responses matched user/assistant/tool and swallowed system in a
catch-all `_ => {}`. Its "tool" arm was dead: nothing on the neutral
side has ever constructed that role, and the user arm already renders
ToolResult blocks as function_call_output.
* Anthropic Messages forwarded message.role verbatim, remapping only the
interrupted sentinel. A system message — a compaction or branch summary,
which session_tree mints routinely — therefore went out as
{"role":"system"} for the provider to reject with a 400 naming neither
the role nor the message.
* Google cloud-code was the only one that failed closed.
`client/role_placement.rs` is now the single table. Given a Role and a
WireDialect it returns which channel carries the message, that the message
is omitted, or that the pair is rejected. Adapters still own the structural
rendering for their own dialect — Chat's tool_calls array, Responses'
function_call_output items, Anthropic's content blocks, cloud-code's parts —
but none of them chooses a channel any more.
`reject_unsupported_roles` runs at the top of prepare_outbound_request, the
one outbound seam, before any dialect builds a body. That is deliberate: a
pair that cannot be represented should die in this process with the message
index, the role, and the wire named, not as a provider error that arrives
with none of that.
Two behaviour changes, stated plainly rather than smuggled in:
1. On Anthropic Messages, an in-transcript system role and any unrecognised
role are now rejected locally. Both were already broken — verbatim
pass-through meant a guaranteed provider 400 — so nothing that used to
succeed now fails; the failure just becomes legible. The alternative,
dropping them like the OpenAI-shaped dialects do, was rejected: a
dropped system message is a dropped compaction summary, i.e. silently
sending a truncated history. Fail closed.
2. The dead "tool" arm in the Responses adapter is deleted, and cloud-code
no longer special-cases a literal "model" role. Nothing constructs
either.
Everything else is preserved on purpose. Responses keeps dropping
in-transcript system messages (live sessions are on that path today and
rejecting would break them), Chat and Responses keep dropping unknown
roles, and cloud-code keeps bailing on everything it cannot represent.
Placement grants no authority: it says which channel carries the bytes,
never how much the model should trust them.
Verified: the three tests that pin the changed behaviour
(anthropic_never_emits_a_role_outside_user_and_assistant,
seam_refuses_an_in_transcript_system_message_on_anthropic,
seam_refuses_the_interrupted_sentinel_on_cloud_code) were each confirmed to
fail with the Anthropic pass-through and the seam call temporarily
restored, and to pass with them in place. The remaining new tests are
characterization of preserved behaviour and pass either way, as intended.
Full `cargo test -p codewhale-tui --lib`: 10875 passed, 0 failed.
Implemented with agent assistance.
Signed-off-by: Hunter Bown <hmbown@gmail.com>
…ing them The cloud-code arm caught every non-user, non-assistant placement with `_`, which quietly re-opened the problem the closed enum exists to solve: adding a placement variant would have made it a hard error on this wire without anyone deciding that it should be. Naming the four rejected variants makes the compiler ask. No behaviour change — the same set of placements bails with the same message. Implemented with agent assistance. Signed-off-by: Hunter Bown <hmbown@gmail.com>
# Conflicts: # CHANGELOG.md # crates/tui/CHANGELOG.md
# Conflicts: # CHANGELOG.md # crates/tui/CHANGELOG.md
The idle screen is a centred composition: whale, wordmark, then "What do you
want to accomplish?". Between the wordmark and the prompt sat the workspace
caption, and at 80 columns it rendered like this:
Codewhale
/private/tmp/claude-501/-Volumes-VIXinSSD-CW-codewhale/34267917-11f4-4d15-911a-…
What do you want to accomplish?
Full-bleed, flush-left, straight through the middle of the composition, ending
in an ellipsis mid-directory.
The centring was never wrong. The caption was built at full length, handed to
`truncate_to_width(&context, width)`, and only then measured:
let context = truncate_to_width(&context, width);
let inset = " ".repeat(width.saturating_sub(context.width()) / 2);
After truncation `context.width() == width`, so the inset is always zero. The
centring silently degraded into left-alignment exactly when the string was long
— which is always, for a real absolute path. Two lines apart, one defeats the
other.
Clipping also destroyed the only thing the line exists to say. A path cut at
`34267917-11f4-4d15-911a-` names no directory; the reader learns nothing while
the line consumes the full width and the strongest position on the screen.
So the caption now sheds detail rather than getting cut. In order: the MCP
count goes first, then the branch, then leading path components. The folder you
are standing in is the last thing to go, because it is the only part anyone
reads here. Elisions land on a separator, so `…/surface/ws2` reads as "somewhere
above here" instead of as a severed token. `shorten_workspace` declines to
elide when it would not actually save width — `~/code/app` stays as it is
rather than becoming `…/code/app`, which is the same length and throws away the
`~`.
The same line at 80 columns now:
Codewhale
…/surface/ws2 · no git
What do you want to accomplish?
Found by driving the real binary in a PTY and reading the reconstructed frame,
not by reading the code.
Verified: cargo test -p codewhale-tui --lib -> 10863 passed, 0 failed,
13 ignored. Five new tests cover the centring invariant (the inset must be
non-zero at 60/80/100/120 columns), the shed order, that the current folder
survives every width, and that elision lands on a separator. cargo fmt clean;
the ci.yml versions job passes locally.
Implemented with agent assistance.
Signed-off-by: Hunter Bown <hmbown@gmail.com>
5c1aabb to
add169e
Compare
Signed-off-by: CodeWhale Bot <bot@codewhale.net>
Signed-off-by: CodeWhale Bot <bot@codewhale.net>
Signed-off-by: CodeWhale Bot <bot@codewhale.net>
Maintainer release receipt — v0.9.11 candidate
|
Promotes the fully gated non-benchmark candidate while preserving the benchmark tree exactly from the prior release-PR head. Signed-off-by: CodeWhale Bot <bot@codewhale.net>
|
Claude finished @Hmbown's task in 7m 39s —— View job Code Review — v0.9.11 release integration (PR #5542)Todo list
This is a large release-integration PR (~39K/17K diff across ~412 files vs 🔴 High: SSRF via unvalidated redirect hops in
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 959dbe4382
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
I'll analyze this and get back to you. |
Signed-off-by: CodeWhale Bot <bot@codewhale.net>
1e496ac to
accfa93
Compare
|
Claude finished @Hmbown's task in 1m 24s —— View job Claude finished @Hmbown's task —— View job Code Review — new commit since last review (PR #5542)Todo list
The prior full review posted earlier on this PR already covered the bulk of this release-integration diff (SSRF finding in
|
|
Fresh CI found one real release-gate regression on the previous candidate: the model-facing runtime-contract schema exceeded its established budget after the Fleet and bounded I reproduced it locally and pushed the focused signed fix as Post-fix local evidence:
Fresh required checks are now running on the exact signed tip. No provider call, publication, deployment, or benchmark-lane mutation occurred. |
|
Maintainer triage of the full Claude review:
The PR will not merge until the confirmed blocker is fixed and the replacement candidate has fresh local and GitHub evidence. Contributor history and |
Signed-off-by: CodeWhale Bot <bot@codewhale.net>
|
Claude finished @Hmbown's task in 2m 53s —— View job Code Review — new commit
|
Portable config bundles: - import before a config file exists, removing the created document on rollback instead of requiring a backup copy - `config import|export --project` loads the workspace document under the current (or legacy) app dir instead of the global store - export serializes from the typed config model, preserving TOML value kinds and typed sections; import deep-merges without erasing omitted local authority (credentials, endpoints, hooks, LSP commands) - recursive key/value secret policy on export and import, including camelCase and dotted credential keys, machine-local network proxy routes, cookies, and redaction placeholders - compaction previews keep redacting camelCase/dotted credential keys Terminal, launch, localization: - fragment-backed foreign instruction formats participate in the opt-in warning only when the bounded loader would actually load them - terminal input shutdown no longer joins a wedged TTY read on Unix - launch records persist through a uniquely named temp file with replace-existing semantics on every platform - the rewritten /status report is fully localized and preserves custom-provider identities that contain brace-like text Signed-off-by: CodeWhale Bot <bot@codewhale.net>
|
Claude finished @Hmbown's task in 5m 18s —— View job Code Review — new commit
|
|
Replacement candidate pushed: PR head is now This commit closes every open Codex review thread (replies are posted per thread with the exact functions and regression tests) plus the earlier
Local gates at Fresh required checks are running on |

Summary
Prepare the non-benchmark Codewhale v0.9.11 release candidate on top of current
main.This branch intentionally excludes
benchmarks/pi-agent-parity/**and its release-lane ancestry. PR headb38ecbfa332de285390a245d0de905091e861b86is byte-for-byte identical to the fully gated local integration commit98e3814e02f9f01d959c30580c3d07413e0cf4faoutside that path. The branch advanced959dbe43…→accfa93e5…(runtime-contract budget fix) →491a7121…(SSRF redirect revalidation, duplicate-key refusal, idle MCP ping handling, app-server text) →b38ecbfa…(all Codex P1/P2 review threads).Included
deepseek-v4-flash-vision-expcatalog/aliases, official root/v1/betaendpoint behavior, custom-proxy separation, Runtime/Web capability truth, and Chat text +image_urlserializationread_lintsand portable command-contract facetsFresh CI fix
The first GitHub run on
959dbe438...caught the runtime-contract budget gate: model-facing LSP/Fleet schema prose exceeded the established one-way ceiling. The follow-up commit compacts those descriptions without changing behavior and keeps their exact contract tests. Current measurements pass all 55 ceilings:The fix commit is signed off and changes only:
crates/tui/src/tools/lsp.rscrates/tui/src/tools/workflow.rsCommunity credit
This candidate preserves the integration ancestry and authorship for:
@bistack), tool-call extraction@wuisabel-gif), bounded multi-fileread_lints, plus review hardening credited to@Lstarsky0@aboimpinto), portable command-contract facets, including the repository author-map alias@RepentStar, credited for the reproduced issue and maintainer fix#5530 is recorded only as independently reviewed/superseded overlap; this PR does not claim it was merged.
Review follow-up
Fresh automated review (Claude full review + Codex review) on the first online candidates found one confirmed security blocker (bundle redirect hops were not revalidated) and nine portable-config/terminal/launch/localization defects. All are fixed on the branch with regression tests, each review thread carries a reply naming the function and tests, and the fixes are summarized in the PR comments.
Exact source candidate
b38ecbfa332de285390a245d0de905091e861b8698e3814e02f9f01d959c30580c3d07413e0cf4fa456b0e3408c3e2a7260a9e5a47c18263f85161a0main:75dca2cfbe1b5e0e9fd58c9eed27dbc1b4705c48The earlier
23fca25b...,6824d78d...,959dbe438...,accfa93e5..., and491a7121...artifacts/screenshots/receipts are historical only. Final artifact identity, installation, and fresh terminal/Web captures will be recorded from the merged 40-charactermainSHA; no intermediate artifact is presented as final.Local validation at the final source tree
PASS:
cargo fmt --all -- --checkgit diff --checkcargo check --workspace --all-targets --lockedcargo clippy --workspace --all-targets --all-features --locked -- -D warningscargo test --workspace --all-features --locked(13,355 passed, 0 failed at the final tree)scripts/release/publish-crates.sh dry-run(dry-run only; nothing uploaded)codewhaleandcodewregistered; nocodewhale-tuiOne loaded full-suite run transiently missed a persistent-service PID fixture. The failing scenario then passed alone, the complete three-scenario module passed three consecutive times, and a fresh exact full-workspace rerun passed. This receipt is retained rather than hidden.
No paid provider turn was sent. The GLM-5.3 continuation remains intentionally unsent because no explicit all-in USD cap and provider-side hard quota were supplied.
CI and merge gate
Fresh checks are running on
b38ecbfa.... The protected-main ruleset requires Lint, Ubuntu, macOS, Windows, Version drift, and npm wrapper smoke. GitGuardian is informational/non-required here and is reporting the same historical synthetic redaction-test fixtures across the branch history, not a new credential or occurrence in the current two-file change; no GitGuardian incident state has been mutated.The PR will not be merged until the fresh required checks and review are complete and every non-exempt failure has a precise disposition.
No tag, GitHub Release, crates/npm/container publication, deployment, DNS change, provider canary, or production mutation occurred.
No-Issue: maintainer-owned v0.9.11 release integration and packaging.