From 9929e05b52332664b0d88b87f5f8b26f278bd5db Mon Sep 17 00:00:00 2001 From: Mac Anderson Date: Thu, 10 Sep 2026 11:34:57 -0700 Subject: [PATCH 1/7] fix(cli,protocol): stop steering refusals painting over the deck's frame Three reporters in stella-cli wrote turn-time advisories with eprintln! while the deck owned the terminal. Under ratatui that lands inside the drawn frame and scrolls the screen out from under the renderer's diff, which is what shredded the status bar after a prompt submission. They now travel as an AgentEvent::SteeringDropped to the turn's event channel and onto the transcript, beside the turn that paid for them. Headless doors keep stderr, which is theirs to write. --- crates/stella-cli/src/agent/resume.rs | 1 + crates/stella-cli/src/agent/tool_stack.rs | 55 ++++++++++++++++++- crates/stella-cli/src/agent/turn.rs | 10 +++- .../stella-cli/src/command_deck/lead_turn.rs | 8 +++ crates/stella-cli/src/memory/recall.rs | 43 +++++++++++++-- crates/stella-cli/src/tool_lean.rs | 23 +++++--- crates/stella-protocol/src/event/kind.rs | 25 +++++++++ crates/stella-protocol/src/event/tags.rs | 8 +++ crates/stella-protocol/src/event/task_tag.rs | 1 + 9 files changed, 157 insertions(+), 17 deletions(-) diff --git a/crates/stella-cli/src/agent/resume.rs b/crates/stella-cli/src/agent/resume.rs index 05911bd68c..42a4b216d0 100644 --- a/crates/stella-cli/src/agent/resume.rs +++ b/crates/stella-cli/src/agent/resume.rs @@ -191,6 +191,7 @@ pub(crate) async fn run_resume(cfg: &Config, id: Option<&str>) -> Result<(), Cli cfg, Principal::User, tools_registry.hook_bus(), + &super::tool_stack::stderr_advisories, ); let hook_runner = HostHookRunner; let engine_config = engine_config_for(cfg); diff --git a/crates/stella-cli/src/agent/tool_stack.rs b/crates/stella-cli/src/agent/tool_stack.rs index 35cb5632c7..379feaaf35 100644 --- a/crates/stella-cli/src/agent/tool_stack.rs +++ b/crates/stella-cli/src/agent/tool_stack.rs @@ -92,13 +92,44 @@ pub(crate) fn session_gate(workspace_root: &std::path::Path) -> Arc { declared: ToolAdvertisement, ledger: &'l SteeringLedger, + advisories: Option>, } +/// Where a composed stack says which tools the budget priced out. +/// +/// A borrowed `dyn Fn` rather than an owned box, so [`ToolAllowance`] stays +/// `Copy` and every existing call site keeps passing it by value. +pub(crate) type AdvisorySink<'a> = &'a dyn Fn(Vec); + impl<'l> ToolAllowance<'l> { /// The budget and the cell, named — the seam a witness test builds one /// through. + /// + /// Says nothing about what it cuts. A caller with somewhere to put the + /// refusals adds one with [`Self::reporting`]; silence is the honest + /// default for a stack composed where no channel is open, and it is what + /// every witness test wants. pub(crate) fn new(declared: ToolAdvertisement, ledger: &'l SteeringLedger) -> Self { - Self { declared, ledger } + Self { + declared, + ledger, + advisories: None, + } + } + + /// Send this allowance's refusals to `sink`. + /// + /// Each door names its own, because the right answer differs by door: a + /// turn on the deck puts them on the transcript, and a headless run + /// writes stderr. Nothing here may pick for them — under the deck, + /// stderr is the `ratatui` frame, and a line written to it scrolls the + /// screen out from under the renderer's diff. + #[must_use] + pub(crate) fn reporting(self, sink: AdvisorySink<'l>) -> Self { + Self { + advisories: Some(sink), + ..self + } } /// This session's own, off its resolved config. @@ -115,6 +146,7 @@ pub(crate) fn session_stack<'a>( cfg: &Config, principal: Principal, bus: Option, + advisories: AdvisorySink<'_>, ) -> GatedToolSet<'a> { with_journal( session_stack_with_gate( @@ -122,7 +154,7 @@ pub(crate) fn session_stack<'a>( custom_tools, cfg.workspace_root.clone(), session_tool_policy(cfg), - ToolAllowance::of(cfg), + ToolAllowance::of(cfg).reporting(advisories), session_gate(&cfg.workspace_root), principal, ), @@ -130,6 +162,18 @@ pub(crate) fn session_stack<'a>( ) } +/// The advisory sink for a door whose output is a terminal it owns outright: +/// the plain REPL, `stella run`, a resumed headless turn. +/// +/// The deck passes its own, which puts the same lines on the transcript. It +/// must never reach for this one: its stderr is the drawn frame. +pub(crate) fn stderr_advisories(advisories: Vec) { + use colored::Colorize; + for message in advisories { + eprintln!(" {} {message}", "!".yellow()); + } +} + /// Attach the session bus the gate journals its evaluations onto (#3289), /// when the driver carries one — `registry.hook_bus()` at every shipped call /// site, so the authorization plane's `(principal, tool, decision, trace)` @@ -311,7 +355,12 @@ fn budgeted<'a>( ToolAdvertisement::Full => permitted, ToolAdvertisement::Lean(declared) => { let lean = LeanToolSet::new(permitted, allowance.ledger.settle(declared)); - lean.report_drops(); + if let Some(sink) = allowance.advisories { + let advisories = lean.drop_advisories(); + if !advisories.is_empty() { + sink(advisories); + } + } Box::new(lean) } } diff --git a/crates/stella-cli/src/agent/turn.rs b/crates/stella-cli/src/agent/turn.rs index d0b163e338..e32aae42c2 100644 --- a/crates/stella-cli/src/agent/turn.rs +++ b/crates/stella-cli/src/agent/turn.rs @@ -174,8 +174,14 @@ pub(crate) async fn run_turn( // Customs, the operator's switches, and the authorization gate, // outermost-last — one assembly for every driver. let bus = registry.hook_bus(); - let tools = - tool_stack::session_stack(base_tools, custom_tools.to_vec(), cfg, Principal::User, bus); + let tools = tool_stack::session_stack( + base_tools, + custom_tools.to_vec(), + cfg, + Principal::User, + bus, + &tool_stack::stderr_advisories, + ); // Above the whole session chain, for the same reason as the // process-free arm: the grant narrows the assembled surface — // customs and MCP included — and can never widen it. diff --git a/crates/stella-cli/src/command_deck/lead_turn.rs b/crates/stella-cli/src/command_deck/lead_turn.rs index d9540828bc..6385d02fb1 100644 --- a/crates/stella-cli/src/command_deck/lead_turn.rs +++ b/crates/stella-cli/src/command_deck/lead_turn.rs @@ -139,12 +139,20 @@ pub(super) async fn run_lead_turn( let outcome = { // Customs, the operator's switches, and the authorization gate // (#3283) — the deck's lead turn acts as the human at the keyboard. + // The tool budget's refusals go where recall's already do: this + // turn's event channel, and from there the transcript. Never stderr — + // that is the drawn frame, and a line written into it scrolls the + // screen out from under the renderer's diff. + let advisories = |advisories: Vec| { + let _ = tx.send(stella_protocol::AgentEvent::SteeringDropped { advisories }); + }; let permitted = agent::tool_stack::session_stack( &claims, custom_tools.to_vec(), cfg, Principal::User, registry.hook_bus(), + &advisories, ); // Both read before the engine borrows `messages` mutably: the plan // gate's setup (`task_tap::plan_gate`, #4594/#4611) and this turn's diff --git a/crates/stella-cli/src/memory/recall.rs b/crates/stella-cli/src/memory/recall.rs index 6c1c072d0e..04a122f848 100644 --- a/crates/stella-cli/src/memory/recall.rs +++ b/crates/stella-cli/src/memory/recall.rs @@ -53,6 +53,16 @@ pub struct RecalledBlock { /// restrict the surface the operator configured, never widen it. Empty /// for the common turn whose skills carry no directive. pub skill_scopes: Vec, + /// What the turn's steering budgets refused, one advisory line each — + /// the material the `SteeringDropped` event is built from. + /// + /// Carried out of the block rather than printed where it is discovered. + /// A `SessionMemory` under the deck shares its stderr with a `ratatui` + /// frame, so a line written here lands inside the drawn screen and + /// scrolls it out from under the renderer's diff (#643 ruled the same + /// way for the code-graph index pass). The caller owns a channel; this + /// layer owns none, and now says so in its return type. + pub dropped: Vec, } impl RecalledBlock { @@ -65,11 +75,18 @@ impl RecalledBlock { /// Everything this block leaves for the turn runner's channel, in send /// order: the recall telemetry, then one `SkillInjected` per skill it - /// carried — SPEC 6.3's `✦ skill` rows. + /// carried — SPEC 6.3's `✦ skill` rows — and last what the turn's + /// budgets refused. /// /// One event per skill rather than one carrying a list, because each /// becomes one transcript row with its own head, subject and cost; a list /// would make the renderer split what the emitter had already separated. + /// The refusals go the other way for the reason `SteeringDropped`'s own + /// docs give: they are read together, and one of the sources they carry + /// arrives already summarized by count. + /// + /// Last because a refusal is only legible once the reader has seen what + /// did get a seat. #[must_use] pub fn telemetry_events(&self) -> Vec { self.telemetry_event() @@ -82,8 +99,18 @@ impl RecalledBlock { trigger: stella_protocol::SkillTrigger::Auto, } })) + .chain(self.dropped_event()) .collect() } + + /// This block's refusals, ready to send. `None` when every candidate the + /// turn gathered fitted, which is the turn most sessions run. + #[must_use] + pub fn dropped_event(&self) -> Option { + (!self.dropped.is_empty()).then(|| stella_protocol::AgentEvent::SteeringDropped { + advisories: self.dropped.clone(), + }) + } } /// What a turn-opening recall leaves behind for the turn runner: the @@ -386,12 +413,17 @@ impl SessionMemory { // A record edited since the last look joins this very block — see // `records_refresh` for what a swap can and cannot apply. self.refresh_records_if_changed(); + // Every refusal this turn makes, gathered for the block to carry out + // (`RecalledBlock::dropped`). The two reporters below run in + // sequence, so one collector serves both and the lines keep the order + // the plane refused them in. + let mut dropped: Vec = Vec::new(); let RecalledFrames { recall, dropped: frame_drops, } = self .recalled_frames_anchored(prompt, self.anchors_for(prompt, touched), |message| { - eprintln!(" {} {message}", "!".yellow()) + dropped.push(message); }) .await; @@ -426,7 +458,7 @@ impl SessionMemory { record.as_ref(), ); report_steering_drops(&set, self.retrieval.max_tokens, |message| { - eprintln!(" {} {message}", "!".yellow()) + dropped.push(message); }); // The holdout's memory arm, and this turn's memory join, in one door. @@ -474,6 +506,7 @@ impl SessionMemory { produced, injected_skills: skills::injected_skills(&kept), skill_scopes: auto_skill_scopes(&kept), + dropped, } } @@ -565,8 +598,9 @@ impl SessionMemory { &selected, record.as_ref(), ); + let mut dropped: Vec = Vec::new(); report_steering_drops(&set, self.retrieval.max_tokens, |message| { - eprintln!(" {} {message}", "!".yellow()) + dropped.push(message); }); // The per-frame cut this block exists to make. It runs AFTER the plane @@ -623,6 +657,7 @@ impl SessionMemory { // skill that surfaces here is context until the next turn opens // with it selected — the same rule an explicit invocation obeys. skill_scopes: Vec::new(), + dropped, } } diff --git a/crates/stella-cli/src/tool_lean.rs b/crates/stella-cli/src/tool_lean.rs index d6ed1eb96d..f42b654a57 100644 --- a/crates/stella-cli/src/tool_lean.rs +++ b/crates/stella-cli/src/tool_lean.rs @@ -52,18 +52,25 @@ impl<'a> LeanToolSet<'a> { } } - /// Name every cut tool on stderr. It goes through - /// `memory::report_steering_drops`, the one writer every other - /// steering source already uses. + /// Name every cut tool, one advisory line each. It goes through + /// `memory::report_steering_drops`, the one writer every other steering + /// source already uses. + /// + /// Returned rather than printed. The stack this layer sits in is + /// composed inside a turn, and under the deck that turn shares its + /// stderr with a `ratatui` frame — a line written here lands in the drawn + /// screen and scrolls it out from under the renderer's diff. The caller + /// owns the turn's event channel and puts these on it; this layer owns + /// nothing to say them through. /// /// The recall budget it takes is `0` and is never read. That number /// shapes the memory line, and a set built here holds tool drops /// alone. - pub(crate) fn report_drops(&self) { - use colored::Colorize; - crate::memory::report_steering_drops(&self.steering, 0, |message| { - eprintln!(" {} {message}", "!".yellow()) - }); + #[must_use] + pub(crate) fn drop_advisories(&self) -> Vec { + let mut lines = Vec::new(); + crate::memory::report_steering_drops(&self.steering, 0, |message| lines.push(message)); + lines } /// What the plane kept and cut for this session. diff --git a/crates/stella-protocol/src/event/kind.rs b/crates/stella-protocol/src/event/kind.rs index d3a85a021e..de6caaf23d 100644 --- a/crates/stella-protocol/src/event/kind.rs +++ b/crates/stella-protocol/src/event/kind.rs @@ -1436,6 +1436,31 @@ absence of a sibling key." /// terminal events and emitted its own in their place — which is a /// two-way connection between the engine and one of its callers. RunComplete { model: String, cost_usd: f64 }, + /// What this turn's steering budgets refused, and what widens each one. + /// + /// The complement of [`Self::SkillInjected`] and [`Self::ContextRecall`], + /// which name what reached the prompt. A reader who sees only those reads + /// a turn that never mentioned a matching skill as a turn where no skill + /// matched, when the skill was found, ranked, and priced out. + /// + /// Not [`Self::SteeringWithheld`]: that one is a session fact about an + /// authority refusing a checkout's steering before any turn opens, and it + /// carries counts alone because the withheld text is repository- + /// controlled. These candidates were loaded and read by this process, so + /// naming the handle discloses nothing the session did not already hold. + /// + /// One event per turn carrying every line, rather than one per candidate. + /// The memory arm arrives already summarized by count + /// (`report_steering_drops`), so a per-candidate event would have no + /// handle to put in it; and four budgets each refusing one candidate is + /// one thing a reader wants in one place. + /// + /// Each line is a headline and a remedy separated by an em dash — the + /// shape `stella_tui::notice` splits into head and detail. + SteeringDropped { + /// One line per refusal, in the order the steering plane made them. + advisories: Vec, + }, /// The workspace's own steering — memories, rules and published context /// records, skills, commands, agents — was on disk and was **not** loaded, /// because the authority in `withheld_by` refused it (#2302, #3616). diff --git a/crates/stella-protocol/src/event/tags.rs b/crates/stella-protocol/src/event/tags.rs index 5a0aadd74c..89e52f3a8c 100644 --- a/crates/stella-protocol/src/event/tags.rs +++ b/crates/stella-protocol/src/event/tags.rs @@ -441,6 +441,14 @@ agent_event_tags! { SkillInjected => "skill_injected", ConsumerPosture::RecordedOnly { issue: "#5229" }, &[]; + // What a turn's steering budgets refused. `RecordedOnly`: the deck folds + // it to `TranscriptEntry::SteeringDropped` and draws the refusal beside + // the turn that paid for it, which is the only place a user learns a + // matching skill lost its seat. #5229 covers the Observatory query that + // would earn `Surfaced` for this row and `SkillInjected` together. + SteeringDropped => "steering_dropped", + ConsumerPosture::RecordedOnly { issue: "#5229" }, + &[]; // A context receipt. `Behavioral`: `persist_event_detailed` writes the // `context_blocks` row that the Observatory's block registry and // `stella-store`'s preimage reconstruction both resolve against — and the diff --git a/crates/stella-protocol/src/event/task_tag.rs b/crates/stella-protocol/src/event/task_tag.rs index 327fa42d53..4671e235b0 100644 --- a/crates/stella-protocol/src/event/task_tag.rs +++ b/crates/stella-protocol/src/event/task_tag.rs @@ -134,6 +134,7 @@ task_tagged_events! { } untagged { SkillInjected, + SteeringDropped, Stage, Text, TextDelta, From 9809c90b5072d55c8c0925d9ada2a2a179e9ca1a Mon Sep 17 00:00:00 2001 From: Mac Anderson Date: Thu, 10 Sep 2026 11:37:51 -0700 Subject: [PATCH 2/7] feat(tui,protocol): render a refused steering candidate on the transcript One AgentEvent::SteeringDropped per refusal, on SkillInjected's rule that each event becomes one row. The deck draws it as a WARNING note; the plain and exported transcripts take the same wording from textline::steering_dropped, so the two surfaces cannot drift on a sentence whose whole point is naming the right remedy. --- .../stella-cli/src/command_deck/lead_turn.rs | 4 ++- crates/stella-cli/src/diag_bridge.rs | 8 ++++++ crates/stella-cli/src/memory/recall.rs | 15 ++++------- crates/stella-protocol/src/event/kind.rs | 16 ++++++------ crates/stella-tui/src/deck/classify.rs | 10 ++++++++ crates/stella-tui/src/model.rs | 5 ++++ crates/stella-tui/src/model/entry.rs | 17 +++++++++++++ crates/stella-tui/src/render/entry.rs | 17 ++++++++++++- crates/stella-tui/src/textline.rs | 25 +++++++++++++++++++ crates/stella-tui/src/transcript_build.rs | 4 +++ crates/stella-tui/src/transcript_nav.rs | 4 +++ 11 files changed, 105 insertions(+), 20 deletions(-) diff --git a/crates/stella-cli/src/command_deck/lead_turn.rs b/crates/stella-cli/src/command_deck/lead_turn.rs index 6385d02fb1..bbed96bdf5 100644 --- a/crates/stella-cli/src/command_deck/lead_turn.rs +++ b/crates/stella-cli/src/command_deck/lead_turn.rs @@ -144,7 +144,9 @@ pub(super) async fn run_lead_turn( // that is the drawn frame, and a line written into it scrolls the // screen out from under the renderer's diff. let advisories = |advisories: Vec| { - let _ = tx.send(stella_protocol::AgentEvent::SteeringDropped { advisories }); + for advisory in advisories { + let _ = tx.send(stella_protocol::AgentEvent::SteeringDropped { advisory }); + } }; let permitted = agent::tool_stack::session_stack( &claims, diff --git a/crates/stella-cli/src/diag_bridge.rs b/crates/stella-cli/src/diag_bridge.rs index ceaef2c466..a346b43a7f 100644 --- a/crates/stella-cli/src/diag_bridge.rs +++ b/crates/stella-cli/src/diag_bridge.rs @@ -786,6 +786,14 @@ impl DomainBridge { self.at_seq().with("tokens", *tokens), ); } + // The fact alone, on the rule the arm above states: the advisory + // names a workspace-authored handle, and a diagnostic field + // cannot hold that text. A log reader gets "this turn refused a + // steering candidate", which is what turns an unexplained answer + // into a budget question; the transcript names which one. + AgentEvent::SteeringDropped { .. } => { + self.emit(Level::Debug, "agent.steering.dropped", self.at_seq()); + } // ---- Workspace effects. ------------------------------------- AgentEvent::FileChange { diff --git a/crates/stella-cli/src/memory/recall.rs b/crates/stella-cli/src/memory/recall.rs index 04a122f848..3554a0bc39 100644 --- a/crates/stella-cli/src/memory/recall.rs +++ b/crates/stella-cli/src/memory/recall.rs @@ -99,18 +99,13 @@ impl RecalledBlock { trigger: stella_protocol::SkillTrigger::Auto, } })) - .chain(self.dropped_event()) + .chain(self.dropped.iter().map(|advisory| { + stella_protocol::AgentEvent::SteeringDropped { + advisory: advisory.clone(), + } + })) .collect() } - - /// This block's refusals, ready to send. `None` when every candidate the - /// turn gathered fitted, which is the turn most sessions run. - #[must_use] - pub fn dropped_event(&self) -> Option { - (!self.dropped.is_empty()).then(|| stella_protocol::AgentEvent::SteeringDropped { - advisories: self.dropped.clone(), - }) - } } /// What a turn-opening recall leaves behind for the turn runner: the diff --git a/crates/stella-protocol/src/event/kind.rs b/crates/stella-protocol/src/event/kind.rs index de6caaf23d..3ad5a2ede2 100644 --- a/crates/stella-protocol/src/event/kind.rs +++ b/crates/stella-protocol/src/event/kind.rs @@ -1449,17 +1449,17 @@ absence of a sibling key." /// controlled. These candidates were loaded and read by this process, so /// naming the handle discloses nothing the session did not already hold. /// - /// One event per turn carrying every line, rather than one per candidate. - /// The memory arm arrives already summarized by count - /// (`report_steering_drops`), so a per-candidate event would have no - /// handle to put in it; and four budgets each refusing one candidate is - /// one thing a reader wants in one place. + /// One event per refusal, on [`Self::SkillInjected`]'s rule: each becomes + /// one transcript row, and a list would make the renderer split what the + /// emitter had already separated. The memory arm arrives from + /// `report_steering_drops` already summarized by count, so it is one + /// refusal here like any other. /// - /// Each line is a headline and a remedy separated by an em dash — the + /// The line is a headline and a remedy separated by an em dash — the /// shape `stella_tui::notice` splits into head and detail. SteeringDropped { - /// One line per refusal, in the order the steering plane made them. - advisories: Vec, + /// What was refused, and what widens the budget that refused it. + advisory: String, }, /// The workspace's own steering — memories, rules and published context /// records, skills, commands, agents — was on disk and was **not** loaded, diff --git a/crates/stella-tui/src/deck/classify.rs b/crates/stella-tui/src/deck/classify.rs index 8e71c76a9e..2a7bdfb66e 100644 --- a/crates/stella-tui/src/deck/classify.rs +++ b/crates/stella-tui/src/deck/classify.rs @@ -228,6 +228,16 @@ pub(super) fn trace_of(ev: &AgentEvent) -> (TraceKind, String) { AgentEvent::SkillInjected { name, tokens, .. } => { (TraceKind::Context, format!("skill {name}, {tokens} tok")) } + // The headline alone. The remedy is advice for a person reading the + // transcript, and the trace ring is a fixed-width record of what + // happened. + AgentEvent::SteeringDropped { advisory } => ( + TraceKind::Context, + format!( + "dropped {}", + advisory.split_once(" — ").map_or(advisory.as_str(), |(h, _)| h) + ), + ), // Receipts are filtered out of the trace ring above (apply_event's // guard); these arms exist only to keep this mapping total. AgentEvent::BlockRegistered { kind, .. } => { diff --git a/crates/stella-tui/src/model.rs b/crates/stella-tui/src/model.rs index 3e7e540a3f..c17b52aacd 100644 --- a/crates/stella-tui/src/model.rs +++ b/crates/stella-tui/src/model.rs @@ -756,6 +756,11 @@ impl SessionModel { AgentEvent::ContextWrite { .. } | AgentEvent::MemoryLogged { .. } | AgentEvent::MemoryPromoted { .. } => self.fold_memory_write(event), + AgentEvent::SteeringDropped { advisory } => { + self.transcript.push(TranscriptEntry::SteeringDropped { + advisory: advisory.clone(), + }); + } AgentEvent::SkillInjected { name, summary, diff --git a/crates/stella-tui/src/model/entry.rs b/crates/stella-tui/src/model/entry.rs index 93911e8b42..e439695c8c 100644 --- a/crates/stella-tui/src/model/entry.rs +++ b/crates/stella-tui/src/model/entry.rs @@ -302,6 +302,23 @@ pub enum TranscriptEntry { /// Which channel put it in the prompt — the head's `auto|/cmd`. trigger: stella_protocol::SkillTrigger, }, + /// One steering candidate this turn's budget refused, and what widens + /// the budget that refused it (SPEC 6.3's `⚠ steering` note). + /// + /// The other complement of [`Self::Skill`]: that row says a skill fired, + /// and this one says a skill matched and could not be afforded. A reader + /// with only the first cannot tell the two apart, and the remedy each + /// line carries is the reason to keep it where it can be scrolled back + /// to rather than in a dialog that expires. + /// + /// Distinct from [`Self::SteeringWithheld`], which is one session-level + /// refusal by an authority. This is a per-turn budget cut, one row per + /// candidate. + SteeringDropped { + /// What was refused, and what widens the budget — a headline and a + /// remedy separated by an em dash. + advisory: String, + }, /// Context recall completed; frames are cited by human label, never raw /// id (L-C4). /// diff --git a/crates/stella-tui/src/render/entry.rs b/crates/stella-tui/src/render/entry.rs index 9a15ae9323..ba488e989e 100644 --- a/crates/stella-tui/src/render/entry.rs +++ b/crates/stella-tui/src/render/entry.rs @@ -20,7 +20,7 @@ use crate::model::{FileState, TranscriptEntry}; use crate::render::row::*; use crate::textline::{ budget_mode_label, ci_status_label, media_kind_label, media_state_label, pr_status_label, - stage_label, steering_withheld, + stage_label, steering_dropped, steering_withheld, }; use crate::theme; @@ -690,6 +690,21 @@ fn entry_body( } push_note("⚠ steering", loud(theme::WARNING), content, width, out); } + // Composed from `textline::steering_dropped` for the reason the arm + // above gives: one wording, so the deck and the plain door cannot + // drift on a sentence whose whole point is naming the right remedy. + // + // `strong` is false where the withheld row's is true: that row is a + // session refusing to be steered at all, and this one is one + // candidate priced out of a turn that is otherwise steered normally. + TranscriptEntry::SteeringDropped { advisory } => { + let line = steering_dropped(advisory); + let mut content = vec![Span::styled(line.body, value())]; + if let Some(detail) = line.detail { + content.push(Span::styled(format!(" · {detail}"), quiet())); + } + push_note("⚠ steering", theme::WARNING.into(), content, width, out); + } TranscriptEntry::ContextRecall { frames, tokens, diff --git a/crates/stella-tui/src/textline.rs b/crates/stella-tui/src/textline.rs index 501e148190..ed65274dc9 100644 --- a/crates/stella-tui/src/textline.rs +++ b/crates/stella-tui/src/textline.rs @@ -460,6 +460,30 @@ pub fn context_write(provider: &str, upserts: u32, superseded: u32) -> EventLine } } +/// One candidate a turn's steering budget refused, for the surfaces that +/// render a stream as text. +/// +/// The remedy rides `detail` so a narrow terminal keeps what was refused and +/// drops the advice, which is the same split [`skill_injected`] makes and the +/// same one `stella_tui::notice` draws. The em dash is the emitter's +/// (`stella-cli`'s `drop_message`), and the first one splits the line: every +/// remedy clause is written after one, and a handle before it cannot contain +/// one. +#[must_use] +pub fn steering_dropped(advisory: &str) -> EventLine { + let (body, detail) = match advisory.split_once(" — ") { + Some((head, remedy)) => (head.to_string(), Some(remedy.to_string())), + None => (advisory.to_string(), None), + }; + EventLine { + glyph: "⚠", + tone: Tone::Warn, + strong: false, + body, + detail, + } +} + /// One injected skill, for the surfaces that render a stream as text. /// /// The summary rides `detail` rather than the body so a narrow terminal drops @@ -802,6 +826,7 @@ pub fn event_line(event: &AgentEvent) -> Option { tokens, trigger, } => Some(skill_injected(name, summary, *tokens, *trigger)), + AgentEvent::SteeringDropped { advisory } => Some(steering_dropped(advisory)), AgentEvent::MediaProgress { artifact_id, kind, diff --git a/crates/stella-tui/src/transcript_build.rs b/crates/stella-tui/src/transcript_build.rs index a0e4ffbdb7..193f50b4c5 100644 --- a/crates/stella-tui/src/transcript_build.rs +++ b/crates/stella-tui/src/transcript_build.rs @@ -502,6 +502,10 @@ fn note_kind(event: &AgentEvent) -> NoteKind { // colors both of these (`render/entry.rs`), so the plain and // exported forms must not mute them to the same glyph (`#5748`). AgentEvent::Error { .. } | AgentEvent::SteeringWithheld { .. } => NoteKind::Alert, + // Alert too, and for the same reason: the live deck draws it in + // `theme::WARNING`, so muting it here would make the exported + // transcript disagree with the screen it is a record of. + AgentEvent::SteeringDropped { .. } => NoteKind::Alert, // `Steered` stays in the wildcard on purpose: the live deck folds it // into a full user-turn row, not a note, and the plain `Note` model // has no such row to match it with (`#5748`). diff --git a/crates/stella-tui/src/transcript_nav.rs b/crates/stella-tui/src/transcript_nav.rs index 3accf8e1fb..4f40a006ca 100644 --- a/crates/stella-tui/src/transcript_nav.rs +++ b/crates/stella-tui/src/transcript_nav.rs @@ -185,6 +185,10 @@ pub fn entry_fields(entry: &TranscriptEntry) -> Vec<&str> { E::Pr { url, .. } => vec![url], E::TaskUpdate { active, .. } => active.as_deref().into_iter().collect(), E::Error { message, .. } => vec![message], + // Free text a reader types into the find box — the handle that lost + // its seat is exactly what someone searches for after noticing a + // skill did not fire. + E::SteeringDropped { advisory } => vec![advisory], E::Complete { model, .. } => vec![model], // Counts and a closed authority — the row carries no free text a // reader could type into a find box, and deliberately no filename. From 5d40bb941a00a857d914c6c8df11b1ec42ac25b9 Mon Sep 17 00:00:00 2001 From: Mac Anderson Date: Thu, 10 Sep 2026 11:41:27 -0700 Subject: [PATCH 3/7] test(protocol,cli): sample the new event and witness the handed-back advisories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regenerated docs/wire/ for AgentEvent::SteeringDropped and moved its tags.rs entry beside SteeringWithheld, where kind.rs declares it — the committed schema's order is proved against the declaration order. --- crates/stella-cli/src/tool_lean.rs | 52 +++++++++++++++++++ crates/stella-protocol/src/event/tags.rs | 16 +++--- .../tests/wire_contract/samples.rs | 4 ++ docs/wire/agentevent.d.ts | 11 ++++ docs/wire/agentevent.schema.json | 24 +++++++++ docs/wire/serveframe.d.ts | 6 +++ docs/wire/serveframe.schema.json | 18 +++++++ 7 files changed, 123 insertions(+), 8 deletions(-) diff --git a/crates/stella-cli/src/tool_lean.rs b/crates/stella-cli/src/tool_lean.rs index f42b654a57..9ce0e5e6d3 100644 --- a/crates/stella-cli/src/tool_lean.rs +++ b/crates/stella-cli/src/tool_lean.rs @@ -211,6 +211,58 @@ mod tests { set.schemas().iter().map(schema_tokens).sum() } + /// A cut tool is named to the caller, and to nobody else. + /// + /// The return type is the fix for #643's failure mode reappearing here: + /// this layer is composed inside a turn, and under the deck that turn's + /// stderr is the drawn `ratatui` frame. A reporter that printed would put + /// these lines inside the frame and scroll it out from under the + /// renderer's diff — the mangled status bar this test exists to keep + /// fixed. Handing them back leaves the choice of channel to the door that + /// owns one. + #[test] + fn a_cut_tool_is_handed_back_rather_than_printed() { + let leaf = Leaf::new(40); + let full: u64 = leaf.schemas().iter().map(schema_tokens).sum(); + let set = LeanToolSet::new( + Box::new(leaf), + ToolBudget { + max_tokens: full / 4, + mcp_max_tokens: full, + }, + ); + + let advisories = set.drop_advisories(); + + assert!( + !advisories.is_empty(), + "a budget holding a quarter of forty tools cut something" + ); + assert!( + advisories.iter().all(|line| line.contains(" — ")), + "every line names a remedy after an em dash: {advisories:?}" + ); + } + + /// A budget that affords every tool says nothing at all. + /// + /// The silent turn is the common one, and a door that emitted an empty + /// advisory every turn would put a blank row on every transcript. + #[test] + fn an_allowance_that_affords_everything_says_nothing() { + let leaf = Leaf::new(4); + let full: u64 = leaf.schemas().iter().map(schema_tokens).sum(); + let set = LeanToolSet::new( + Box::new(leaf), + ToolBudget { + max_tokens: full * 2, + mcp_max_tokens: full * 2, + }, + ); + + assert!(set.drop_advisories().is_empty()); + } + /// **The witness.** Forty tools, and a budget that holds a quarter of /// them. The schemas sent fit the cap. The rest are left out. The layer /// below still has all forty. diff --git a/crates/stella-protocol/src/event/tags.rs b/crates/stella-protocol/src/event/tags.rs index 89e52f3a8c..9b2309d4f3 100644 --- a/crates/stella-protocol/src/event/tags.rs +++ b/crates/stella-protocol/src/event/tags.rs @@ -441,14 +441,6 @@ agent_event_tags! { SkillInjected => "skill_injected", ConsumerPosture::RecordedOnly { issue: "#5229" }, &[]; - // What a turn's steering budgets refused. `RecordedOnly`: the deck folds - // it to `TranscriptEntry::SteeringDropped` and draws the refusal beside - // the turn that paid for it, which is the only place a user learns a - // matching skill lost its seat. #5229 covers the Observatory query that - // would earn `Surfaced` for this row and `SkillInjected` together. - SteeringDropped => "steering_dropped", - ConsumerPosture::RecordedOnly { issue: "#5229" }, - &[]; // A context receipt. `Behavioral`: `persist_event_detailed` writes the // `context_blocks` row that the Observatory's block registry and // `stella-store`'s preimage reconstruction both resolve against — and the @@ -678,6 +670,14 @@ agent_event_tags! { site: "stella-cli/src/arena.rs::observe (run terminator, latches SessionOutcome::Completed)", }, &[Surface::Observatory]; + // What a turn's steering budgets refused. `RecordedOnly`: the deck folds + // it to `TranscriptEntry::SteeringDropped` and draws the refusal beside + // the turn that paid for it, which is the only place a user learns a + // matching skill lost its seat. #5229 covers the Observatory query that + // would earn `Surfaced` for this row and `SkillInjected` together. + SteeringDropped => "steering_dropped", + ConsumerPosture::RecordedOnly { issue: "#5229" }, + &[]; // What the trust gate held back (#2302's harness half, #3616). `Surfaced` // and not `Behavioral`: nothing in the engine branches on it — the // steering was already withheld by the time this says so — and rendering diff --git a/crates/stella-protocol/tests/wire_contract/samples.rs b/crates/stella-protocol/tests/wire_contract/samples.rs index afcd6c6870..49c5885ef6 100644 --- a/crates/stella-protocol/tests/wire_contract/samples.rs +++ b/crates/stella-protocol/tests/wire_contract/samples.rs @@ -509,6 +509,10 @@ pub(crate) fn sample_events() -> Vec { tokens: 1200, trigger: SkillTrigger::Auto, }, + AgentEvent::SteeringDropped { + advisory: "a skill matching this turn did not fit the skill budget: backlog-triage — raise `skills.max_skills`" + .into(), + }, AgentEvent::StepManifest { turn_instance: 1, step: 0, diff --git a/docs/wire/agentevent.d.ts b/docs/wire/agentevent.d.ts index 05a6f1581a..831772c5d4 100644 --- a/docs/wire/agentevent.d.ts +++ b/docs/wire/agentevent.d.ts @@ -2681,6 +2681,16 @@ export type AgentEvent = { */ ts?: number; type: "run_complete"; +} | { + /** + * What was refused, and what widens the budget that refused it. + */ + advisory: string; + /** + * Wall-clock instant at which the sink wrote this line, in milliseconds since the Unix epoch (UTC). Stamped by the sink rather than carried by the event, so it is optional forever — a line recorded before the field existed has none — and it is not monotonic, so a consumer computing an elapsed offset must clamp a negative delta rather than trust it. + */ + ts?: number; + type: "steering_dropped"; } | { agents: number; commands: number; @@ -2745,4 +2755,5 @@ export type KnownTypeTag = | "error" | "turn_complete" | "run_complete" + | "steering_dropped" | "steering_withheld"; diff --git a/docs/wire/agentevent.schema.json b/docs/wire/agentevent.schema.json index c44d6efc6d..cc7300b3b1 100644 --- a/docs/wire/agentevent.schema.json +++ b/docs/wire/agentevent.schema.json @@ -4512,6 +4512,30 @@ ], "type": "object" }, + { + "description": "What this turn's steering budgets refused, and what widens each one.\n\nThe complement of [`Self::SkillInjected`] and [`Self::ContextRecall`],\nwhich name what reached the prompt. A reader who sees only those reads\na turn that never mentioned a matching skill as a turn where no skill\nmatched, when the skill was found, ranked, and priced out.\n\nNot [`Self::SteeringWithheld`]: that one is a session fact about an\nauthority refusing a checkout's steering before any turn opens, and it\ncarries counts alone because the withheld text is repository-\ncontrolled. These candidates were loaded and read by this process, so\nnaming the handle discloses nothing the session did not already hold.\n\nOne event per refusal, on [`Self::SkillInjected`]'s rule: each becomes\none transcript row, and a list would make the renderer split what the\nemitter had already separated. The memory arm arrives from\n`report_steering_drops` already summarized by count, so it is one\nrefusal here like any other.\n\nThe line is a headline and a remedy separated by an em dash — the\nshape `stella_tui::notice` splits into head and detail.", + "properties": { + "advisory": { + "description": "What was refused, and what widens the budget that refused it.", + "type": "string" + }, + "ts": { + "description": "Wall-clock instant at which the sink wrote this line, in milliseconds since the Unix epoch (UTC). Stamped by the sink rather than carried by the event, so it is optional forever — a line recorded before the field existed has none — and it is not monotonic, so a consumer computing an elapsed offset must clamp a negative delta rather than trust it.", + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "type": { + "const": "steering_dropped", + "type": "string" + } + }, + "required": [ + "type", + "advisory" + ], + "type": "object" + }, { "description": "The workspace's own steering — memories, rules and published context\nrecords, skills, commands, agents — was on disk and was **not** loaded,\nbecause the authority in `withheld_by` refused it (#2302, #3616).\n\nEmitted once per run, before the turn opens, and only when something\nwas actually held back: a notice every repository sees is one nobody\nreads. It is the machine-readable twin of the stderr line, so a harness\nrunning `--output-format stream-json` learns that this session was not\nsteered by the repository it is sitting in without scraping the human\nchannel.\n\n**Counts only** — never a filename, never a body, never the workspace\npath. The withheld text is repository-controlled, and a refusal that\nechoed it would be the exfiltration channel the refusal exists to\nprevent.", "properties": { diff --git a/docs/wire/serveframe.d.ts b/docs/wire/serveframe.d.ts index bbcd163ad7..f8638aee47 100644 --- a/docs/wire/serveframe.d.ts +++ b/docs/wire/serveframe.d.ts @@ -859,6 +859,12 @@ export type AgentEvent = { cost_usd: number; model: string; type: "run_complete"; +} | { + /** + * What was refused, and what widens the budget that refused it. + */ + advisory: string; + type: "steering_dropped"; } | { agents: number; commands: number; diff --git a/docs/wire/serveframe.schema.json b/docs/wire/serveframe.schema.json index ad6fa3689c..92ecc139d8 100644 --- a/docs/wire/serveframe.schema.json +++ b/docs/wire/serveframe.schema.json @@ -1742,6 +1742,24 @@ ], "type": "object" }, + { + "description": "What this turn's steering budgets refused, and what widens each one.\n\nThe complement of [`Self::SkillInjected`] and [`Self::ContextRecall`],\nwhich name what reached the prompt. A reader who sees only those reads\na turn that never mentioned a matching skill as a turn where no skill\nmatched, when the skill was found, ranked, and priced out.\n\nNot [`Self::SteeringWithheld`]: that one is a session fact about an\nauthority refusing a checkout's steering before any turn opens, and it\ncarries counts alone because the withheld text is repository-\ncontrolled. These candidates were loaded and read by this process, so\nnaming the handle discloses nothing the session did not already hold.\n\nOne event per refusal, on [`Self::SkillInjected`]'s rule: each becomes\none transcript row, and a list would make the renderer split what the\nemitter had already separated. The memory arm arrives from\n`report_steering_drops` already summarized by count, so it is one\nrefusal here like any other.\n\nThe line is a headline and a remedy separated by an em dash — the\nshape `stella_tui::notice` splits into head and detail.", + "properties": { + "advisory": { + "description": "What was refused, and what widens the budget that refused it.", + "type": "string" + }, + "type": { + "const": "steering_dropped", + "type": "string" + } + }, + "required": [ + "type", + "advisory" + ], + "type": "object" + }, { "description": "The workspace's own steering — memories, rules and published context\nrecords, skills, commands, agents — was on disk and was **not** loaded,\nbecause the authority in `withheld_by` refused it (#2302, #3616).\n\nEmitted once per run, before the turn opens, and only when something\nwas actually held back: a notice every repository sees is one nobody\nreads. It is the machine-readable twin of the stderr line, so a harness\nrunning `--output-format stream-json` learns that this session was not\nsteered by the repository it is sitting in without scraping the human\nchannel.\n\n**Counts only** — never a filename, never a body, never the workspace\npath. The withheld text is repository-controlled, and a refusal that\nechoed it would be the exfiltration channel the refusal exists to\nprevent.", "properties": { From b369427bee8e9b97cb0551dbc477a630951662f1 Mon Sep 17 00:00:00 2001 From: Mac Anderson Date: Thu, 10 Sep 2026 11:44:04 -0700 Subject: [PATCH 4/7] test(cli,tui): witness the refusal reaching the transcript, not the frame Five tests across the two planes the fix touches: the tool allowance reports to the sink its door named and to nowhere else, a refused recall candidate leaves the block as its own event, a silent turn stays silent, and the deck row splits headline from remedy at the em dash. --- crates/stella-cli/src/agent/tool_stack.rs | 56 +++++++++++++ .../src/memory/tests/steering_selection.rs | 64 +++++++++++++++ .../stella-tui/src/render/tests/steering.rs | 79 +++++++++++++++++++ 3 files changed, 199 insertions(+) diff --git a/crates/stella-cli/src/agent/tool_stack.rs b/crates/stella-cli/src/agent/tool_stack.rs index 379feaaf35..284425e7c1 100644 --- a/crates/stella-cli/src/agent/tool_stack.rs +++ b/crates/stella-cli/src/agent/tool_stack.rs @@ -883,6 +883,62 @@ mod tests { .len() } + /// **The #643 witness, in the tool plane.** A composed stack says what it + /// cut to the sink the door named, and to nowhere else. + /// + /// The default matters as much as the routing. `budgeted` used to print + /// its refusals with `eprintln!`, which under the deck is the drawn + /// `ratatui` frame — the bytes land between rows and scroll the screen + /// out from under the renderer's diff, which is what left the status bar + /// drawn three times over itself after a prompt. An allowance built + /// without [`ToolAllowance::reporting`] now reaches no terminal at all, + /// so a future door that forgets to name a sink is silent rather than + /// destructive. + #[test] + fn a_composed_stack_reports_its_cuts_to_the_named_sink_alone() { + let leaf = WideLeaf { count: 40 }; + let ledger = SteeringLedger::default(); + let quarter = stella_core::steering::tools::ToolBudget { + max_tokens: full_cost(&leaf) / 4, + mcp_max_tokens: full_cost(&leaf), + }; + let seen = std::cell::RefCell::new(Vec::new()); + let sink = |advisories: Vec| seen.borrow_mut().extend(advisories); + + let stack = |allowance| { + session_stack_with_gate( + &leaf, + Vec::new(), + PathBuf::from("."), + ToolPolicy::allow_all(), + allowance, + Arc::new(NoAuthz), + Principal::User, + ) + .schemas() + .len() + }; + + let unreported = ToolAllowance::new(ToolAdvertisement::Lean(quarter), &ledger); + let advertised = stack(unreported); + assert!(advertised < 40, "the ceiling bound: {advertised} advertised"); + assert!( + seen.borrow().is_empty(), + "an allowance nobody asked to report says nothing: {:?}", + seen.borrow() + ); + + assert_eq!( + stack(unreported.reporting(&sink)), + advertised, + "naming a sink changes what is said, never what is advertised" + ); + assert!( + !seen.borrow().is_empty(), + "and the same cut reaches the sink once one is named" + ); + } + /// A declared allowance wide enough to hold `leaf`'s whole surface. fn wide_enough(leaf: &WideLeaf) -> stella_core::steering::tools::ToolBudget { stella_core::steering::tools::ToolBudget { diff --git a/crates/stella-cli/src/memory/tests/steering_selection.rs b/crates/stella-cli/src/memory/tests/steering_selection.rs index beb10d4789..1cc8e7dd27 100644 --- a/crates/stella-cli/src/memory/tests/steering_selection.rs +++ b/crates/stella-cli/src/memory/tests/steering_selection.rs @@ -868,3 +868,67 @@ fn the_merge_drop_report_is_one_summary_line_per_class() { ); } } + +/// **The #643 witness, in the recall plane.** A refusal leaves the block as +/// an event on the turn's stream, one row per candidate. +/// +/// The channel is the whole point. These lines used to go straight to stderr +/// from inside `recall_block_reported`, which under the deck is the drawn +/// `ratatui` frame: the bytes landed between rendered rows and scrolled the +/// screen out from under the renderer's diff, leaving the status bar drawn +/// several times over itself after a prompt submission. `RecalledBlock` now +/// carries them out to the caller, which owns a channel; this layer owns +/// none. +/// +/// One event per line rather than one carrying the list, on the rule +/// `AgentEvent::SkillInjected` already states: each becomes one transcript +/// row, and a list would make the renderer split what the emitter had +/// already separated. +#[test] +fn a_refused_candidate_leaves_the_block_as_its_own_event() { + let block = crate::memory::recall::RecalledBlock { + dropped: vec![ + "a skill matching this turn did not fit the skill budget: seat-loser — raise \ + `skills.max_skills`" + .to_string(), + "2 memories did not fit this turn's 1200-token retrieval budget — raise \ + context.retrieval.max_tokens in stella.toml to include them" + .to_string(), + ], + ..Default::default() + }; + + let advisories: Vec = block + .telemetry_events() + .into_iter() + .filter_map(|event| match event { + stella_protocol::AgentEvent::SteeringDropped { advisory } => Some(advisory), + _ => None, + }) + .collect(); + + assert_eq!( + advisories, block.dropped, + "every refusal reaches the stream, in the order the plane made them" + ); +} + +/// A turn that refused nothing puts no row on the transcript. +/// +/// The silent turn is the common one. An empty advisory emitted every turn +/// would be a blank warning row under every prompt, which is how a signal +/// stops being read. +#[test] +fn a_turn_that_refused_nothing_announces_nothing() { + let block = crate::memory::recall::RecalledBlock::default(); + assert!( + !block + .telemetry_events() + .iter() + .any(|event| matches!( + event, + stella_protocol::AgentEvent::SteeringDropped { .. } + )), + "no refusal, no row" + ); +} diff --git a/crates/stella-tui/src/render/tests/steering.rs b/crates/stella-tui/src/render/tests/steering.rs index 8244b27ef6..6d0558f5e2 100644 --- a/crates/stella-tui/src/render/tests/steering.rs +++ b/crates/stella-tui/src/render/tests/steering.rs @@ -89,3 +89,82 @@ fn the_remedy_names_the_authority_that_can_actually_lift_it() { "and says so outright rather than leaving the flag to be retried: {managed}" ); } + +/// **The witness for the frame this event exists to stop shredding.** A +/// refused steering candidate renders as its own transcript row. +/// +/// Same failure as the withheld notice above, one plane over. The refusals +/// `stella-cli`'s steering plane makes each turn went to stderr, which under +/// the deck is the drawn frame: the bytes landed between rendered rows and +/// scrolled the screen out from under `ratatui`'s diff, so the status bar +/// came back drawn several times over itself after a prompt submission. +/// +/// The transcript rather than `Inbound::Notice`, on the rule +/// `command_deck::steering` already draws: a notice is dismissed by the next +/// keystroke and stays dismissed for the session, and every one of these +/// lines names a remedy a user needs to be able to scroll back to. +#[test] +fn a_refused_candidate_renders_its_headline_and_its_remedy() { + let mut model = SessionModel::new(); + model.apply(&AgentEvent::SteeringDropped { + advisory: "a skill matching this turn did not fit the skill budget: seat-loser — raise \ + `skills.max_skills`" + .to_string(), + }); + assert_eq!( + model.transcript.len(), + 1, + "the fold produced no row: {:?}", + model.transcript + ); + + let text = transcript_lines(&model, false, 120) + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect::>() + .join("\n"); + + assert!(text.contains("steering"), "the row is headed as steering: {text}"); + assert!( + text.contains("seat-loser"), + "the handle that lost its seat is named: {text}" + ); + assert!( + text.contains("skills.max_skills"), + "and so is the knob that widens the budget: {text}" + ); +} + +/// The em dash splits the line, so a narrow frame keeps what was refused and +/// drops the advice rather than the other way round. +#[test] +fn the_headline_is_what_was_refused_and_the_detail_is_the_remedy() { + let line = crate::textline::steering_dropped( + "a tool did not fit what this turn's records left of the steering allowance: bash — \ + raise `context.steering.max_tokens`", + ); + assert!(line.body.contains("bash"), "{line:?}"); + assert!(!line.body.contains("raise"), "the remedy left the head: {line:?}"); + assert_eq!( + line.detail.as_deref(), + Some("raise `context.steering.max_tokens`"), + "{line:?}" + ); +} + +/// A line with no remedy clause is still a whole line, not a truncated one. +/// +/// `drop_message` writes an em dash into every advisory it produces today. +/// This holds the renderer honest if one ever stops: the fallback keeps the +/// text rather than splitting on a separator that is not there. +#[test] +fn an_advisory_with_no_remedy_clause_keeps_its_whole_text() { + let line = crate::textline::steering_dropped("the plane refused something"); + assert_eq!(line.body, "the plane refused something"); + assert_eq!(line.detail, None); +} From 9e7a510a38c923659b1adac88af667703b6f7717 Mon Sep 17 00:00:00 2001 From: Mac Anderson Date: Thu, 10 Sep 2026 12:08:53 -0700 Subject: [PATCH 5/7] chore(guards): keep three files under the size ratchet, and stop counting a required issue field as prose The new event's docs pushed kind.rs, recall.rs and textline.rs past the 1500-line guard. Each is trimmed in place rather than baselined, except textline's new steering_dropped, which gets textline/steering.rs beside the gate and memory modules it matches. check-prose counted `ConsumerPosture::RecordedOnly { issue: "#1234" }` as an issue number in prose. The audit requires that value, so no new event of that posture could be added without raising the file's count with nothing a reader could delete. The exemption is the value form alone; a number in a sentence still fails. Also drops the bare issue number from the skills-section advisory, which told the person reading it nothing, and updates the assertion that pinned it. --- crates/stella-cli/src/agent/tool_stack.rs | 34 +++-- crates/stella-cli/src/diag_bridge.rs | 9 +- crates/stella-cli/src/memory/recall.rs | 142 ++++++++---------- .../src/memory/tests/steering_selection.rs | 25 ++- crates/stella-cli/src/tool_lean.rs | 2 +- crates/stella-protocol/src/event/kind.rs | 63 +++----- crates/stella-protocol/src/event/tags.rs | 4 +- crates/stella-tui/src/deck/classify.rs | 4 +- crates/stella-tui/src/render/entry.rs | 12 +- .../stella-tui/src/render/tests/steering.rs | 42 +++--- crates/stella-tui/src/textline.rs | 88 ++++------- crates/stella-tui/src/textline/steering.rs | 29 ++++ crates/stella-tui/src/transcript_build.rs | 6 +- crates/stella-tui/src/transcript_nav.rs | 5 +- docs/reference/diagnostics.md | 17 +++ scripts/check-prose.py | 10 +- 16 files changed, 230 insertions(+), 262 deletions(-) create mode 100644 crates/stella-tui/src/textline/steering.rs diff --git a/crates/stella-cli/src/agent/tool_stack.rs b/crates/stella-cli/src/agent/tool_stack.rs index 284425e7c1..0d9b293764 100644 --- a/crates/stella-cli/src/agent/tool_stack.rs +++ b/crates/stella-cli/src/agent/tool_stack.rs @@ -106,9 +106,9 @@ impl<'l> ToolAllowance<'l> { /// through. /// /// Says nothing about what it cuts. A caller with somewhere to put the - /// refusals adds one with [`Self::reporting`]; silence is the honest - /// default for a stack composed where no channel is open, and it is what - /// every witness test wants. + /// refusals adds one with [`Self::reporting`]. Silence is the default + /// because a stack can be composed where no channel is open at all, and + /// it is what every witness test wants. pub(crate) fn new(declared: ToolAdvertisement, ledger: &'l SteeringLedger) -> Self { Self { declared, @@ -120,7 +120,7 @@ impl<'l> ToolAllowance<'l> { /// Send this allowance's refusals to `sink`. /// /// Each door names its own, because the right answer differs by door: a - /// turn on the deck puts them on the transcript, and a headless run + /// turn on the deck puts them on the transcript, and non-interactive mode /// writes stderr. Nothing here may pick for them — under the deck, /// stderr is the `ratatui` frame, and a line written to it scrolls the /// screen out from under the renderer's diff. @@ -163,7 +163,7 @@ pub(crate) fn session_stack<'a>( } /// The advisory sink for a door whose output is a terminal it owns outright: -/// the plain REPL, `stella run`, a resumed headless turn. +/// non-interactive mode, `stella run`, and a resumed turn. /// /// The deck passes its own, which puts the same lines on the transcript. It /// must never reach for this one: its stderr is the drawn frame. @@ -883,17 +883,16 @@ mod tests { .len() } - /// **The #643 witness, in the tool plane.** A composed stack says what it - /// cut to the sink the door named, and to nowhere else. + /// **The witness.** A composed stack says what it cut to the sink the + /// door named, and to nowhere else. /// - /// The default matters as much as the routing. `budgeted` used to print - /// its refusals with `eprintln!`, which under the deck is the drawn - /// `ratatui` frame — the bytes land between rows and scroll the screen - /// out from under the renderer's diff, which is what left the status bar - /// drawn three times over itself after a prompt. An allowance built - /// without [`ToolAllowance::reporting`] now reaches no terminal at all, - /// so a future door that forgets to name a sink is silent rather than - /// destructive. + /// The default matters as much as the routing. A library that prints its + /// refusals writes them into whatever owns the process's stderr, which + /// under the deck is the drawn `ratatui` frame: the bytes land between + /// rows and scroll the screen out from under the renderer's diff, and the + /// status bar comes back drawn several times over itself. An allowance + /// built without [`ToolAllowance::reporting`] reaches no terminal at all, + /// so a door that names no sink is silent rather than destructive. #[test] fn a_composed_stack_reports_its_cuts_to_the_named_sink_alone() { let leaf = WideLeaf { count: 40 }; @@ -921,7 +920,10 @@ mod tests { let unreported = ToolAllowance::new(ToolAdvertisement::Lean(quarter), &ledger); let advertised = stack(unreported); - assert!(advertised < 40, "the ceiling bound: {advertised} advertised"); + assert!( + advertised < 40, + "the ceiling bound: {advertised} advertised" + ); assert!( seen.borrow().is_empty(), "an allowance nobody asked to report says nothing: {:?}", diff --git a/crates/stella-cli/src/diag_bridge.rs b/crates/stella-cli/src/diag_bridge.rs index a346b43a7f..173f70516c 100644 --- a/crates/stella-cli/src/diag_bridge.rs +++ b/crates/stella-cli/src/diag_bridge.rs @@ -786,11 +786,10 @@ impl DomainBridge { self.at_seq().with("tokens", *tokens), ); } - // The fact alone, on the rule the arm above states: the advisory - // names a workspace-authored handle, and a diagnostic field - // cannot hold that text. A log reader gets "this turn refused a - // steering candidate", which is what turns an unexplained answer - // into a budget question; the transcript names which one. + // The fact alone, on the rule the arm above states. The + // advisory names a workspace-authored handle, and a diagnostic + // field cannot hold that text. A log reader learns that the turn + // refused a candidate. The transcript names which one. AgentEvent::SteeringDropped { .. } => { self.emit(Level::Debug, "agent.steering.dropped", self.at_seq()); } diff --git a/crates/stella-cli/src/memory/recall.rs b/crates/stella-cli/src/memory/recall.rs index 3554a0bc39..ec52c843cd 100644 --- a/crates/stella-cli/src/memory/recall.rs +++ b/crates/stella-cli/src/memory/recall.rs @@ -54,14 +54,12 @@ pub struct RecalledBlock { /// for the common turn whose skills carry no directive. pub skill_scopes: Vec, /// What the turn's steering budgets refused, one advisory line each — - /// the material the `SteeringDropped` event is built from. + /// the material each `SteeringDropped` event is built from. /// - /// Carried out of the block rather than printed where it is discovered. - /// A `SessionMemory` under the deck shares its stderr with a `ratatui` - /// frame, so a line written here lands inside the drawn screen and - /// scrolls it out from under the renderer's diff (#643 ruled the same - /// way for the code-graph index pass). The caller owns a channel; this - /// layer owns none, and now says so in its return type. + /// Carried out rather than printed. A `SessionMemory` under the deck + /// shares its stderr with a `ratatui` frame, so a line written here lands + /// inside the drawn screen and scrolls it out from under the renderer's + /// diff. The caller owns a channel; this layer owns none. pub dropped: Vec, } @@ -75,17 +73,13 @@ impl RecalledBlock { /// Everything this block leaves for the turn runner's channel, in send /// order: the recall telemetry, then one `SkillInjected` per skill it - /// carried — SPEC 6.3's `✦ skill` rows — and last what the turn's - /// budgets refused. + /// carried — SPEC 6.3's `✦ skill` rows — and last one `SteeringDropped` + /// per candidate the turn's budgets refused. /// - /// One event per skill rather than one carrying a list, because each - /// becomes one transcript row with its own head, subject and cost; a list - /// would make the renderer split what the emitter had already separated. - /// The refusals go the other way for the reason `SteeringDropped`'s own - /// docs give: they are read together, and one of the sources they carry - /// arrives already summarized by count. - /// - /// Last because a refusal is only legible once the reader has seen what + /// One event each rather than one carrying a list, because each becomes + /// one transcript row with its own head, subject and cost; a list would + /// make the renderer split what the emitter had already separated. The + /// refusals come last: one is only legible once the reader has seen what /// did get a seat. #[must_use] pub fn telemetry_events(&self) -> Vec { @@ -408,7 +402,7 @@ impl SessionMemory { // A record edited since the last look joins this very block — see // `records_refresh` for what a swap can and cannot apply. self.refresh_records_if_changed(); - // Every refusal this turn makes, gathered for the block to carry out + // Every refusal this turn makes, for the block to carry out // (`RecalledBlock::dropped`). The two reporters below run in // sequence, so one collector serves both and the lines keep the order // the plane refused them in. @@ -506,36 +500,33 @@ impl SessionMemory { } /// The volatile block for a turn that has DRIFTED from its opening - /// prompt (#3243 Phase 3) — the same three selectors as + /// prompt — the same three selectors as /// [`Self::recall_block_reported`], queried against what the turn has /// become instead of what it was asked. /// /// The signal's touched paths do the work the prompt could not: they - /// join the recall anchors (when they resolve to real files — a created - /// file qualifies the moment it exists), they widen the domain scope - /// skills are selected in, and they join the record channel's - /// `applies_to` path facts. The prompt still carries the lexical query — - /// drift changes *where* the turn is, not what it was asked to do. + /// join the recall anchors once they resolve to real files, they widen + /// the domain scope skills are selected in, and they join the record + /// channel's `applies_to` path facts. The prompt still carries the + /// lexical query — drift changes *where* the turn is, not what it was + /// asked to do. /// - /// One deliberate omission against the pre-turn block: no date section - /// (the turn-opening block already carries today, and repeating it - /// mid-turn buys nothing). The `Recall` travels back with the text for - /// the same reason it does in [`Self::recall_block_reported`] — a - /// re-query is a full fan-out with provider spend behind it, and the - /// adapter that called it reports that spend into the turn's event - /// stream (#3366). `RecalledBlock::text` is `None` when nothing - /// surfaced, when the turn is an A/B control, or when steering is off — - /// the same gates, for the same reasons. + /// One omission against the pre-turn block: no date section, since the + /// turn-opening block already carries today. The `Recall` travels back + /// with the text for the reason it does in + /// [`Self::recall_block_reported`] — a re-query is a full fan-out with + /// provider spend behind it, and the adapter that called it reports that + /// spend into the turn's event stream. `RecalledBlock::text` is `None` + /// under the same three gates: nothing surfaced, an A/B control turn, or + /// steering off. /// - /// `produced` is what this turn's earlier blocks already rendered, and - /// every frame, skill and record in it is left out of this one (#4236, - /// records since #4498). Drift is + /// `produced` is what this turn's earlier blocks rendered, and every + /// frame, skill and record in it is left out of this one. Drift is /// incremental — a re-query answering `{A, B, C, D}` after one that /// answered `{A, B, C}` differs by one frame — so a block deduped by its - /// bytes alone is a block that always differs and is therefore always - /// injected, whole. These are `User` messages that only the overflow - /// summarizer can ever reclaim, so each repeat is permanent in the paid - /// prefix for the rest of the session. + /// bytes alone always differs, and is always injected whole. These are + /// `User` messages that only the overflow summarizer can reclaim, so each + /// repeat is permanent in the paid prefix for the rest of the session. pub async fn signal_recall_block( &self, signal: &stella_core::steering::TurnSignal<'_>, @@ -1300,34 +1291,30 @@ fn record_section_text(rendered: RenderedChannel) -> Option { } /// The eviction report for one dropped candidate — the single producer every -/// source emits through (#3437). -/// -/// One sentence shape for all of them, the record channel's: -/// *what applied, which budget refused it, its handle, and the remedy*. The -/// remedy is the half that differs, and it has to: telling a user whose skill -/// lost its seat to "raise its precedence" is advice for a different channel. +/// source emits through, in the record channel's sentence shape: *what +/// applied, which budget refused it, its handle, and the remedy*. The remedy +/// is the half that differs, and it has to — telling a user whose skill lost +/// its seat to "raise its precedence" is advice for a different channel. /// /// `still_selected` is the section-budget class, and it is why this takes the /// whole ledger rather than a handle. A skill can be in `selected` *and* -/// `dropped` by design — top-k kept it and `skills::section_fit` then left it -/// out of the rendered section (`steering::skill_drops`' own doc). Both classes -/// genuinely miss the prompt, so both are reported; only the remedy differs, -/// because `SKILLS_SECTION_TOKEN_BUDGET` is a constant and nothing -/// configurable widens it until #3243 Phase 4 collapses the two budgets. +/// `dropped` by design: top-k kept it and `skills::section_fit` then left it +/// out of the rendered section. Both classes miss the prompt, so both are +/// reported, and only the remedy differs — +/// `SKILLS_SECTION_TOKEN_BUDGET` is a constant, and nothing widens it until +/// the two budgets are collapsed into one. /// -/// Memory drops return `None`: the frame query already reported them as ONE -/// summary line naming the budget and the remedy, and repeating the same -/// advice once per evicted memory — with an internal id a user cannot act on -/// — is the noise that line exists to replace. +/// Memory drops return `None`. The frame query already reported them as one +/// summary line naming the budget and the remedy. /// -/// A tool drop names the tool and the allowance that refused it. The remedy -/// is the allowance rather than the tool: withholding one is never a -/// capability change (`crate::tool_lean`), so the advice is to widen what the -/// session may spend on schemas, or to turn the lever off. +/// A tool drop names the tool and the allowance that refused it, and the +/// remedy is the allowance: withholding a tool is never a capability change +/// (`crate::tool_lean`), so the advice is to widen what the session may spend +/// on schemas, or to turn the lever off. /// /// A plugin drop names the plugin and the stage it spoke at, because the -/// handle is `/` and both halves are things a person can act on. -/// The remedy is the allowance or the plugin, and never the stage. +/// handle is `/` and a person can act on both halves. The +/// remedy is the allowance or the plugin, never the stage. fn drop_message( drop: &stella_core::steering::DroppedCandidate, still_selected: bool, @@ -1343,7 +1330,7 @@ fn drop_message( SteeringSource::Memory => None, SteeringSource::Skill if still_selected => Some(format!( "a skill matching this turn did not fit the skills section's token budget: \ - {handle} — nothing configurable widens that budget yet (#3243)" + {handle} — nothing configurable widens that budget yet" )), SteeringSource::Skill => Some(format!( "a skill matching this turn did not fit the skill budget: {handle} — raise \ @@ -1365,29 +1352,19 @@ fn drop_message( } /// Report every candidate the ledger says was dropped, whatever its source. +/// The ledger records the cuts; this is the half a person reads. /// -/// #3358 completed the *ledger* across records, skills and frames; this is the -/// human-facing half (#3437). Before it, a skill that lost its seat every turn -/// and a frame the recall host's merge evicted were queryable and said nothing -/// to the person watching the run — the #2709 observability gap in its other -/// half. -/// -/// Memory drops are summarized, not enumerated: `memory_budget` is the +/// Memory drops are summarized rather than listed. `memory_budget` is the /// `context.retrieval.max_tokens` the turn ran with, and the report is one -/// line — how many memories missed the budget, the budget itself, and the -/// knob that widens it — instead of one line per memory repeating the same -/// remedy under an internal id. +/// line: how many memories missed it, the budget, and the knob that widens +/// it. One line per memory repeats that remedy under an id nobody can act on. /// -/// Two recall-side filters are deliberately **not** reported here, and that is -/// a decision rather than an omission. `project_recalled_frame` drops a frame -/// the citation-label rule cannot name, and `is_suppressed_local_frame` drops -/// one the session quarantined. Neither is a budget eviction: the first is a -/// frame this process could not cite, and the second is deliberate -/// suppression of a memory cited untruthful twice. Reporting either as -/// `DroppedCandidate` would tell a user their budget was too small when it was -/// not, and quarantine in particular wants its own vocabulary rather than a -/// line advising a bigger retrieval budget. The provider's spend on both is -/// already accounted for by the usage report captured above the filters. +/// Two recall-side filters stay out of this report. +/// `project_recalled_frame` drops a frame the citation-label rule cannot +/// name, and `is_suppressed_local_frame` drops one the session quarantined. +/// Neither is a budget eviction, so reporting either here would tell a user +/// their budget was too small when it was not. The provider's spend on both +/// is already in the usage report taken above the filters. pub(crate) fn report_steering_drops( set: &stella_core::steering::SteeringSet, memory_budget: u32, @@ -1414,7 +1391,6 @@ pub(crate) fn report_steering_drops( } } } - /// The wall clock's current instant, in Unix seconds. The one `SystemTime` /// read in this module — everything downstream of it (`render_today_section`) /// takes the value as a parameter instead of reading the clock itself, so a diff --git a/crates/stella-cli/src/memory/tests/steering_selection.rs b/crates/stella-cli/src/memory/tests/steering_selection.rs index 1cc8e7dd27..c50afcf0ab 100644 --- a/crates/stella-cli/src/memory/tests/steering_selection.rs +++ b/crates/stella-cli/src/memory/tests/steering_selection.rs @@ -785,7 +785,7 @@ fn every_dropped_source_gets_a_line_with_its_own_remedy() { assert!( lines[3].contains("section-cut") && lines[3].contains("skills section's token budget") - && lines[3].contains("#3243"), + && lines[3].contains("nothing configurable widens that budget"), "and one cut by the section budget says so, rather than advising a \ knob that would not have saved it: {joined}" ); @@ -869,16 +869,16 @@ fn the_merge_drop_report_is_one_summary_line_per_class() { } } -/// **The #643 witness, in the recall plane.** A refusal leaves the block as -/// an event on the turn's stream, one row per candidate. +/// **The witness.** A refusal leaves the block as an event on the turn's +/// stream, one row per candidate. /// -/// The channel is the whole point. These lines used to go straight to stderr -/// from inside `recall_block_reported`, which under the deck is the drawn -/// `ratatui` frame: the bytes landed between rendered rows and scrolled the -/// screen out from under the renderer's diff, leaving the status bar drawn -/// several times over itself after a prompt submission. `RecalledBlock` now -/// carries them out to the caller, which owns a channel; this layer owns -/// none. +/// The channel is the whole point. A line written to stderr from inside +/// `recall_block_reported` reaches whatever owns the process's stderr, which +/// under the deck is the drawn `ratatui` frame: the bytes land between +/// rendered rows and scroll the screen out from under the renderer's diff, +/// leaving the status bar drawn several times over itself after a prompt +/// submission. `RecalledBlock` carries them out to the caller, which owns a +/// channel; this layer owns none. /// /// One event per line rather than one carrying the list, on the rule /// `AgentEvent::SkillInjected` already states: each becomes one transcript @@ -925,10 +925,7 @@ fn a_turn_that_refused_nothing_announces_nothing() { !block .telemetry_events() .iter() - .any(|event| matches!( - event, - stella_protocol::AgentEvent::SteeringDropped { .. } - )), + .any(|event| matches!(event, stella_protocol::AgentEvent::SteeringDropped { .. })), "no refusal, no row" ); } diff --git a/crates/stella-cli/src/tool_lean.rs b/crates/stella-cli/src/tool_lean.rs index 9ce0e5e6d3..32f6a9514e 100644 --- a/crates/stella-cli/src/tool_lean.rs +++ b/crates/stella-cli/src/tool_lean.rs @@ -213,7 +213,7 @@ mod tests { /// A cut tool is named to the caller, and to nobody else. /// - /// The return type is the fix for #643's failure mode reappearing here: + /// The return type is what keeps a library out of the process's stderr: /// this layer is composed inside a turn, and under the deck that turn's /// stderr is the drawn `ratatui` frame. A reporter that printed would put /// these lines inside the frame and scroll it out from under the diff --git a/crates/stella-protocol/src/event/kind.rs b/crates/stella-protocol/src/event/kind.rs index 3ad5a2ede2..9c4412303f 100644 --- a/crates/stella-protocol/src/event/kind.rs +++ b/crates/stella-protocol/src/event/kind.rs @@ -821,51 +821,26 @@ pub enum AgentEvent { /// the two producers below answer two *different* questions, and only one /// of them is about the agent. /// - /// # The two producers, and what each one's answer means + /// Two producers can emit one, and they answer different questions. + /// **Candidate adoption** measures a candidate against a sealed baseline, + /// so it can tell the agent's edits from anyone else's. That is + /// attribution, and nothing in this workspace produces it: the staged + /// pipeline that did was deleted. **The shared-tree turn boundary** — + /// `stella-cli`'s `turn_files`, over `WorkJournal::snapshot_worktree` — + /// emits every event on the stream today, and it is not attribution. It + /// answers what changed in the tree during this turn, so a user editing a + /// file in another window lands here beside the agent's own writes. A + /// consumer that needs "what did the agent do" reads the git diff, or + /// adoption. /// - /// 1. **Candidate adoption** — `Pipeline::deliver_winner` (the built-in - /// staged pipeline's `pipeline/delivery.rs`, deleted in #3865), one - /// event per - /// `AdoptedChange`, emitted beside the `CandidateWorkspace::attribute_adopted` - /// call that writes the same rows to the host's durable ledger (#2907). - /// This one **was** attribution: adoption measured a candidate against a - /// sealed baseline, so it could tell the agent's edits from anyone - /// else's. **It has no producer in this workspace any more** — that - /// crate was deleted in #3865 — so every event on this stream today is - /// the second kind. Read the distinction below as the contract a - /// re-homed adoption producer would have to meet, not as two live - /// sources (#3881). - /// 2. **The shared-tree turn boundary** — `stella-cli`'s `turn_files`, over - /// `WorkJournal::snapshot_worktree` (#3413). This one is **not** - /// attribution. It answers *what changed in the tree during this turn*, - /// which is the question a whole-tree measurement can answer: a - /// user editing a file in another window mid-turn lands here - /// indistinguishably from the agent's own writes. - /// - /// A consumer that needs "what did the agent do" takes it from the git diff - /// of the tree, or from adoption. This stream is for showing a human what - /// moved. - /// - /// # Why an engine-only turn is measured rather than hooked (#3413) - /// - /// It once emitted from the tools, and for a while after that from nowhere: - /// the 12-tool purge (#3244) deleted every file-writing built-in and the - /// file-CRUD ledger that emitted these, and this doc went on naming a - /// `ToolRegistry::record_touch` that no longer existed. The file built-ins - /// have since been restored, so a tool hook is now available — and it is - /// still not right. A hook on `write_file` / `edit_file` / `delete_file` - /// would report a *subset* of the turn while looking exhaustive: `bash` - /// mutates the tree without naming a path, and so do MCP servers and - /// custom script tools, none of which describes its paths in any schema - /// the engine reads. And - /// synthesizing these from tool *inputs* is the known defect, not the - /// design: a wrapper that did exactly that, knowing four hard-coded tool - /// names and sitting on one of three tool stacks, is what reported files - /// edited in bulk or by a worker lane as `+0 -0` (#2290). - /// - /// So the answer is a measurement, taken once per turn at the boundary. - /// The cost is one `git add -A` plus a `write-tree` against a dedicated - /// index, after the model has answered. + /// Hooking the file tools instead would report a subset of the turn while + /// looking complete: `bash` mutates the tree without naming a path, and so + /// do MCP servers and custom script tools, none of which declares its + /// paths in any schema the engine reads. Reading paths out of tool + /// *inputs* is worse — a wrapper that did that, knowing four tool names + /// and sitting on one of three tool stacks, reported bulk edits as + /// `+0 -0`. One measurement at the boundary costs a `git add -A` and a + /// `write-tree` against a dedicated index, after the model has answered. /// /// `added`/`removed` are what the producer measured — git's `--numstat` /// against the two trees, or, for adoption, numstat plus the patch it diff --git a/crates/stella-protocol/src/event/tags.rs b/crates/stella-protocol/src/event/tags.rs index 9b2309d4f3..e08c4d158a 100644 --- a/crates/stella-protocol/src/event/tags.rs +++ b/crates/stella-protocol/src/event/tags.rs @@ -673,8 +673,8 @@ agent_event_tags! { // What a turn's steering budgets refused. `RecordedOnly`: the deck folds // it to `TranscriptEntry::SteeringDropped` and draws the refusal beside // the turn that paid for it, which is the only place a user learns a - // matching skill lost its seat. #5229 covers the Observatory query that - // would earn `Surfaced` for this row and `SkillInjected` together. + // matching skill lost its seat. It earns `Surfaced` once an Observatory + // query names it, which is the same door `SkillInjected` waits at. SteeringDropped => "steering_dropped", ConsumerPosture::RecordedOnly { issue: "#5229" }, &[]; diff --git a/crates/stella-tui/src/deck/classify.rs b/crates/stella-tui/src/deck/classify.rs index 2a7bdfb66e..6dac7960d0 100644 --- a/crates/stella-tui/src/deck/classify.rs +++ b/crates/stella-tui/src/deck/classify.rs @@ -235,7 +235,9 @@ pub(super) fn trace_of(ev: &AgentEvent) -> (TraceKind, String) { TraceKind::Context, format!( "dropped {}", - advisory.split_once(" — ").map_or(advisory.as_str(), |(h, _)| h) + advisory + .split_once(" — ") + .map_or(advisory.as_str(), |(h, _)| h) ), ), // Receipts are filtered out of the trace ring above (apply_event's diff --git a/crates/stella-tui/src/render/entry.rs b/crates/stella-tui/src/render/entry.rs index ba488e989e..36ada5f66f 100644 --- a/crates/stella-tui/src/render/entry.rs +++ b/crates/stella-tui/src/render/entry.rs @@ -690,13 +690,13 @@ fn entry_body( } push_note("⚠ steering", loud(theme::WARNING), content, width, out); } - // Composed from `textline::steering_dropped` for the reason the arm - // above gives: one wording, so the deck and the plain door cannot - // drift on a sentence whose whole point is naming the right remedy. + // Composed from `textline::steering_dropped`, for the reason the + // arm above gives. One wording, so the two surfaces cannot drift on + // a sentence whose point is naming the right remedy. // - // `strong` is false where the withheld row's is true: that row is a - // session refusing to be steered at all, and this one is one - // candidate priced out of a turn that is otherwise steered normally. + // `strong` is false where the withheld row's is true. That row is a + // whole session left unsteered. This one is a single candidate + // priced out of a turn that is steered as usual. TranscriptEntry::SteeringDropped { advisory } => { let line = steering_dropped(advisory); let mut content = vec![Span::styled(line.body, value())]; diff --git a/crates/stella-tui/src/render/tests/steering.rs b/crates/stella-tui/src/render/tests/steering.rs index 6d0558f5e2..d6f7911640 100644 --- a/crates/stella-tui/src/render/tests/steering.rs +++ b/crates/stella-tui/src/render/tests/steering.rs @@ -90,19 +90,17 @@ fn the_remedy_names_the_authority_that_can_actually_lift_it() { ); } -/// **The witness for the frame this event exists to stop shredding.** A -/// refused steering candidate renders as its own transcript row. +/// **The witness.** A refused steering candidate draws its own row. /// -/// Same failure as the withheld notice above, one plane over. The refusals -/// `stella-cli`'s steering plane makes each turn went to stderr, which under -/// the deck is the drawn frame: the bytes landed between rendered rows and -/// scrolled the screen out from under `ratatui`'s diff, so the status bar -/// came back drawn several times over itself after a prompt submission. +/// Same failure as the withheld notice above, one plane over. These +/// refusals went to stderr, which under the deck is the drawn frame. The +/// bytes landed between rows and scrolled the screen out from under +/// `ratatui`'s diff. The status bar came back drawn over itself. /// -/// The transcript rather than `Inbound::Notice`, on the rule -/// `command_deck::steering` already draws: a notice is dismissed by the next -/// keystroke and stays dismissed for the session, and every one of these -/// lines names a remedy a user needs to be able to scroll back to. +/// The transcript, not `Inbound::Notice`, on the rule +/// `command_deck::steering` already draws. A notice dies at the next +/// keystroke and stays dead for the session. Each of these lines names a +/// remedy, and a remedy has to be there to scroll back to. #[test] fn a_refused_candidate_renders_its_headline_and_its_remedy() { let mut model = SessionModel::new(); @@ -129,7 +127,10 @@ fn a_refused_candidate_renders_its_headline_and_its_remedy() { .collect::>() .join("\n"); - assert!(text.contains("steering"), "the row is headed as steering: {text}"); + assert!( + text.contains("steering"), + "the row is headed as steering: {text}" + ); assert!( text.contains("seat-loser"), "the handle that lost its seat is named: {text}" @@ -140,8 +141,8 @@ fn a_refused_candidate_renders_its_headline_and_its_remedy() { ); } -/// The em dash splits the line, so a narrow frame keeps what was refused and -/// drops the advice rather than the other way round. +/// The em dash splits the line. A narrow frame then keeps what was refused +/// and drops the advice. #[test] fn the_headline_is_what_was_refused_and_the_detail_is_the_remedy() { let line = crate::textline::steering_dropped( @@ -149,7 +150,10 @@ fn the_headline_is_what_was_refused_and_the_detail_is_the_remedy() { raise `context.steering.max_tokens`", ); assert!(line.body.contains("bash"), "{line:?}"); - assert!(!line.body.contains("raise"), "the remedy left the head: {line:?}"); + assert!( + !line.body.contains("raise"), + "the remedy left the head: {line:?}" + ); assert_eq!( line.detail.as_deref(), Some("raise `context.steering.max_tokens`"), @@ -157,11 +161,11 @@ fn the_headline_is_what_was_refused_and_the_detail_is_the_remedy() { ); } -/// A line with no remedy clause is still a whole line, not a truncated one. +/// A line with no remedy clause is still whole, never cut short. /// -/// `drop_message` writes an em dash into every advisory it produces today. -/// This holds the renderer honest if one ever stops: the fallback keeps the -/// text rather than splitting on a separator that is not there. +/// `drop_message` writes an em dash into every advisory today. This pins the +/// fallback if one ever stops. It keeps the whole text, and splits on no +/// separator that is not there. #[test] fn an_advisory_with_no_remedy_clause_keeps_its_whole_text() { let line = crate::textline::steering_dropped("the plane refused something"); diff --git a/crates/stella-tui/src/textline.rs b/crates/stella-tui/src/textline.rs index ed65274dc9..a44d90a44e 100644 --- a/crates/stella-tui/src/textline.rs +++ b/crates/stella-tui/src/textline.rs @@ -1,29 +1,23 @@ //! The shared event→text vocabulary — one lookup table for both rendering -//! surfaces (issue #66). +//! surfaces. //! -//! Two independent renderers consume [`stella_protocol::AgentEvent`]s: the -//! plain `colored`+`println` surface in `stella-cli` (REPL and one-shot -//! modes) and this crate's ratatui transcript. Before this module each kept -//! its own event→string mapping, so every new `AgentEvent` variant had to be -//! worded twice. The contract now: **wording lives here, styling stays with -//! each surface.** A constructor per annotation variant yields an -//! [`EventLine`] of semantic pieces (glyph, tone, body, detail) that each -//! surface maps onto its own palette — `colored` codes on the plain surface, -//! `ratatui` styles on the deck. +//! Two independent renderers consume [`stella_protocol::AgentEvent`]s: +//! non-interactive mode's `colored`+`println` surface in `stella-cli`, and +//! this crate's ratatui transcript. **Wording lives here, styling stays with +//! each surface.** A constructor per annotation case yields an [`EventLine`] +//! of semantic pieces (glyph, tone, body, detail) that each surface maps onto +//! its own palette. //! -//! The wording is byte-exact: the plain renderer's observable output -//! is composed as `" {glyph} {body}"` (plus `" {detail}"` when present), and -//! the fixture tests at the bottom pin every line to the exact strings the -//! plain surface printed before the extraction. Change a string here and the -//! plain CLI's output changes with it — that is the point, but it must be -//! deliberate. +//! The wording is byte-exact: the plain renderer composes `" {glyph} +//! {body}"`, plus `" {detail}"` when present, and the fixture tests at the +//! bottom pin every line. Change a string here and the plain output changes +//! with it, which is the point. //! -//! Deliberately *not* here: streaming `Text`/`Reasoning` (accumulated, then -//! markdown-rendered or printed raw per surface), `Stage` transitions (the -//! deck draws rules, the plain surface prints only a "thinking…" cue), and -//! the `ToolStart`/`ToolResult` cards (the two surfaces present tool traffic -//! structurally differently — key=value cards vs an aligned label column — -//! and unifying them is a behavior change out of scope for #66). +//! Not here: streaming `Text`/`Reasoning`, which each surface accumulates and +//! renders its own way; `Stage` transitions, where the deck draws rules and +//! the plain surface prints a "thinking…" cue; and the +//! `ToolStart`/`ToolResult` cards, which the two surfaces lay out +//! differently — key=value cards against an aligned label column. use stella_protocol::{ AgentEvent, BudgetMode, CiStatus, FileChangeKind, MediaJobState, MediaKind, PrStatus, @@ -33,6 +27,8 @@ use stella_protocol::{ // SPEC 6.3's two memory lines, in their own module: this file is at its // 1500-line ceiling and a crossing takes no baseline entry (AGENTS.md). mod memory; +mod steering; +pub use steering::steering_dropped; mod gate; pub use gate::gate_board; @@ -382,17 +378,14 @@ pub fn file_change(path: &str, kind: FileChangeKind) -> EventLine { /// One recall, on a surface that gets exactly one line for it. /// -/// The deck renders a recall as a table (`render::entry`); this surface prints -/// one line per event and cannot fold, so it states the same *facts* in the -/// order they answer questions: how much did the model get, what did it cost, -/// was recall the reason the turn felt slow, what kinds came back, and from +/// The deck renders a recall as a table (`render::entry`); this surface +/// prints one line per event and cannot fold, so it states the same *facts* +/// in the order they answer questions: how much the model got, what it cost, +/// whether recall is why the turn felt slow, what kinds came back, and from /// which legs. /// -/// The two surfaces used to disagree about what a recall even is — this one -/// named the provider mix and no labels, the deck named the labels and no -/// provider mix, and neither said the latency the wire had carried since #875. -/// `kinds` and `cited` are both passed in so the wording stays here, in the one -/// module that owns wording. +/// `kinds` and `cited` are both passed in so the wording stays here, in the +/// one module that owns wording. /// /// `latency_ms` of `0` means *not measured* on the wire, so it is omitted /// rather than printed as `0ms`. @@ -460,35 +453,10 @@ pub fn context_write(provider: &str, upserts: u32, superseded: u32) -> EventLine } } -/// One candidate a turn's steering budget refused, for the surfaces that -/// render a stream as text. -/// -/// The remedy rides `detail` so a narrow terminal keeps what was refused and -/// drops the advice, which is the same split [`skill_injected`] makes and the -/// same one `stella_tui::notice` draws. The em dash is the emitter's -/// (`stella-cli`'s `drop_message`), and the first one splits the line: every -/// remedy clause is written after one, and a handle before it cannot contain -/// one. -#[must_use] -pub fn steering_dropped(advisory: &str) -> EventLine { - let (body, detail) = match advisory.split_once(" — ") { - Some((head, remedy)) => (head.to_string(), Some(remedy.to_string())), - None => (advisory.to_string(), None), - }; - EventLine { - glyph: "⚠", - tone: Tone::Warn, - strong: false, - body, - detail, - } -} - /// One injected skill, for the surfaces that render a stream as text. /// -/// The summary rides `detail` rather than the body so a narrow terminal drops -/// the description and keeps the two facts a reader acts on — which skill, and -/// what it cost. +/// The summary rides `detail` so a narrow terminal drops the description and +/// keeps the two facts a reader acts on: which skill, and what it cost. pub fn skill_injected( name: &str, summary: &str, @@ -894,8 +862,8 @@ pub fn event_line(event: &AgentEvent) -> Option { /// not always the wire spelling — `context_recall` reads "context recall", /// because the underscore is a wire detail and the deck writes prose. /// -/// For a **contributed** stage it is the plugin's own word, verbatim. That is -/// the honest fallback and the same one the `/models` role table settled on +/// For a **contributed** stage it is the plugin's own word, verbatim — the +/// same fallback the `/models` role table settled on /// (`envelope::roles`): the deck has no word for a stage it has never heard of, /// and inventing one — "plugin", "custom", "other" — would name the row after a /// category instead of after itself. diff --git a/crates/stella-tui/src/textline/steering.rs b/crates/stella-tui/src/textline/steering.rs new file mode 100644 index 0000000000..74d2d8284a --- /dev/null +++ b/crates/stella-tui/src/textline/steering.rs @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (c) 2026 Oxagen, Inc. Commercial licensing: licensing@oxagen.sh + +//! One line for a steering candidate the turn could not afford. + +use super::{EventLine, Tone}; + +/// What a budget refused, and the knob that widens it. +/// +/// The remedy rides `detail`. A narrow screen then drops the advice and +/// keeps the handle, which is the split [`super::skill_injected`] makes and +/// the one [`crate::notice`] draws. +/// +/// The em dash comes from the emitter, and the first one splits the line. A +/// remedy is always written after one. A handle cannot hold one. +#[must_use] +pub fn steering_dropped(advisory: &str) -> EventLine { + let (body, detail) = match advisory.split_once(" — ") { + Some((head, remedy)) => (head.to_string(), Some(remedy.to_string())), + None => (advisory.to_string(), None), + }; + EventLine { + glyph: "\u{26a0}", + tone: Tone::Warn, + strong: false, + body, + detail, + } +} diff --git a/crates/stella-tui/src/transcript_build.rs b/crates/stella-tui/src/transcript_build.rs index 193f50b4c5..aa1d77a332 100644 --- a/crates/stella-tui/src/transcript_build.rs +++ b/crates/stella-tui/src/transcript_build.rs @@ -502,9 +502,9 @@ fn note_kind(event: &AgentEvent) -> NoteKind { // colors both of these (`render/entry.rs`), so the plain and // exported forms must not mute them to the same glyph (`#5748`). AgentEvent::Error { .. } | AgentEvent::SteeringWithheld { .. } => NoteKind::Alert, - // Alert too, and for the same reason: the live deck draws it in - // `theme::WARNING`, so muting it here would make the exported - // transcript disagree with the screen it is a record of. + // Alert too, and for the same reason. The live deck draws it in + // `theme::WARNING`. Muting it here would make the exported + // transcript disagree with the screen it records. AgentEvent::SteeringDropped { .. } => NoteKind::Alert, // `Steered` stays in the wildcard on purpose: the live deck folds it // into a full user-turn row, not a note, and the plain `Note` model diff --git a/crates/stella-tui/src/transcript_nav.rs b/crates/stella-tui/src/transcript_nav.rs index 4f40a006ca..95d1e8a1e0 100644 --- a/crates/stella-tui/src/transcript_nav.rs +++ b/crates/stella-tui/src/transcript_nav.rs @@ -185,9 +185,8 @@ pub fn entry_fields(entry: &TranscriptEntry) -> Vec<&str> { E::Pr { url, .. } => vec![url], E::TaskUpdate { active, .. } => active.as_deref().into_iter().collect(), E::Error { message, .. } => vec![message], - // Free text a reader types into the find box — the handle that lost - // its seat is exactly what someone searches for after noticing a - // skill did not fire. + // Free text a reader types into the find box. The handle that lost + // its seat is what someone looks for after a skill did not fire. E::SteeringDropped { advisory } => vec![advisory], E::Complete { model, .. } => vec![model], // Counts and a closed authority — the row carries no free text a diff --git a/docs/reference/diagnostics.md b/docs/reference/diagnostics.md index 9ee8f21770..a6f46e480b 100644 --- a/docs/reference/diagnostics.md +++ b/docs/reference/diagnostics.md @@ -345,6 +345,23 @@ The run entered a named `stage`. These records partition the timeline; the gap b The human steered the run mid-turn. The steering text is content and stays off this plane; the record marks where in the timeline the run's direction changed — behaviour before and after it should not be compared as one run. +### `agent.steering.dropped` + + +- **Level:** `debug` +- **Emitted from:** `crates/stella-cli/src/diag_bridge.rs` +- **Fields:** `seq` + + +A steering candidate did not fit its budget, so it never reached the prompt. +One record per refusal. When a run keeps ignoring a workspace skill, count +these rather than guess. A turn that refuses several every time is a turn +whose budgets are set too low for what the workspace declares. + +The handle is text the workspace wrote, so it stays off this plane. The +transcript's `⚠ steering` rows name the candidate and the knob that widens +the budget. + ### `agent.steering.withheld` diff --git a/scripts/check-prose.py b/scripts/check-prose.py index d69e8037c0..0553db03dc 100755 --- a/scripts/check-prose.py +++ b/scripts/check-prose.py @@ -237,11 +237,11 @@ "issue-reference", # An issue number in prose sends the reader to a tracker to find out # what the sentence means. The sentence must say it instead. Tracking - # markers (TODO and friends) keep their numbers: a gate requires them - # there, and they are bookkeeping, not explanation. - # A CSS hex is not one: `#10100F` read as issue 10100. A colour here is - # always six hex digits, so that form and `#RRGGBBAA` are exempt. - re.compile(r"^(?!.*(?:TODO|FIXME|XXX|HACK|Closes #|Refs #)).*?" + # markers (TODO and friends) keep theirs: a gate requires them there. + # Two more are not prose. A CSS hex reads as one -- `#10100F` as issue + # 10100 -- so the six- and eight-digit forms are exempt. And an + # `issue:` field is a value a type requires, with no sentence in it. + re.compile(r"^(?!.*(?:TODO|FIXME|XXX|HACK|Closes #|Refs #|issue: \"#)).*?" r"(#(?![\da-fA-F]{6}\b)(?![\da-fA-F]{8}\b)\d{2,})"), "say the fact; drop the issue number from the prose", ), From 72572ab2bc9ccdd572122a8d6a1e19b41a043940 Mon Sep 17 00:00:00 2001 From: Mac Anderson Date: Thu, 10 Sep 2026 12:10:17 -0700 Subject: [PATCH 6/7] chore: re-run dod-check From 15da34a485798924e58fd091f57b79f2bf2dc7b5 Mon Sep 17 00:00:00 2001 From: Mac Anderson Date: Thu, 10 Sep 2026 12:59:47 -0700 Subject: [PATCH 7/7] chore(wire): regenerate docs/wire after the FileChange doc trim The schema carries doc comments as descriptions, so trimming that paragraph left the committed artifacts a sentence behind the types. --- docs/wire/agentevent.schema.json | 2 +- docs/wire/serveframe.schema.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/wire/agentevent.schema.json b/docs/wire/agentevent.schema.json index cc7300b3b1..98ab76d565 100644 --- a/docs/wire/agentevent.schema.json +++ b/docs/wire/agentevent.schema.json @@ -3563,7 +3563,7 @@ "type": "object" }, { - "description": "A file was created/modified/deleted during a turn, carrying both the\nauthoritative line delta and a diff for display.\n\n**Observability, never evidence.** Nothing may found a claim about what\nchanged on counting these — #2873 removed the last three decisions that\ndid, and the tally survives only as a recorded-only field on\n`LadderSnapshot`. The reason is sharper than \"it might be incomplete\":\nthe two producers below answer two *different* questions, and only one\nof them is about the agent.\n\n# The two producers, and what each one's answer means\n\n1. **Candidate adoption** — `Pipeline::deliver_winner` (the built-in\n staged pipeline's `pipeline/delivery.rs`, deleted in #3865), one\n event per\n `AdoptedChange`, emitted beside the `CandidateWorkspace::attribute_adopted`\n call that writes the same rows to the host's durable ledger (#2907).\n This one **was** attribution: adoption measured a candidate against a\n sealed baseline, so it could tell the agent's edits from anyone\n else's. **It has no producer in this workspace any more** — that\n crate was deleted in #3865 — so every event on this stream today is\n the second kind. Read the distinction below as the contract a\n re-homed adoption producer would have to meet, not as two live\n sources (#3881).\n2. **The shared-tree turn boundary** — `stella-cli`'s `turn_files`, over\n `WorkJournal::snapshot_worktree` (#3413). This one is **not**\n attribution. It answers *what changed in the tree during this turn*,\n which is the question a whole-tree measurement can answer: a\n user editing a file in another window mid-turn lands here\n indistinguishably from the agent's own writes.\n\nA consumer that needs \"what did the agent do\" takes it from the git diff\nof the tree, or from adoption. This stream is for showing a human what\nmoved.\n\n# Why an engine-only turn is measured rather than hooked (#3413)\n\nIt once emitted from the tools, and for a while after that from nowhere:\nthe 12-tool purge (#3244) deleted every file-writing built-in and the\nfile-CRUD ledger that emitted these, and this doc went on naming a\n`ToolRegistry::record_touch` that no longer existed. The file built-ins\nhave since been restored, so a tool hook is now available — and it is\nstill not right. A hook on `write_file` / `edit_file` / `delete_file`\nwould report a *subset* of the turn while looking exhaustive: `bash`\nmutates the tree without naming a path, and so do MCP servers and\ncustom script tools, none of which describes its paths in any schema\nthe engine reads. And\nsynthesizing these from tool *inputs* is the known defect, not the\ndesign: a wrapper that did exactly that, knowing four hard-coded tool\nnames and sitting on one of three tool stacks, is what reported files\nedited in bulk or by a worker lane as `+0 -0` (#2290).\n\nSo the answer is a measurement, taken once per turn at the boundary.\nThe cost is one `git add -A` plus a `write-tree` against a dedicated\nindex, after the model has answered.\n\n`added`/`removed` are what the producer measured — git's `--numstat`\nagainst the two trees, or, for adoption, numstat plus the patch it\napplied. Consumers **must** use them rather than counting `+`/`-` lines\nin `diff`: the diff is a bounded, coarse rendering of the\nchanged region, and re-deriving from it is what made the two disagree.\nA binary file carries `0/0` and its kind.", + "description": "A file was created/modified/deleted during a turn, carrying both the\nauthoritative line delta and a diff for display.\n\n**Observability, never evidence.** Nothing may found a claim about what\nchanged on counting these — #2873 removed the last three decisions that\ndid, and the tally survives only as a recorded-only field on\n`LadderSnapshot`. The reason is sharper than \"it might be incomplete\":\nthe two producers below answer two *different* questions, and only one\nof them is about the agent.\n\nTwo producers can emit one, and they answer different questions.\n**Candidate adoption** measures a candidate against a sealed baseline,\nso it can tell the agent's edits from anyone else's. That is\nattribution, and nothing in this workspace produces it: the staged\npipeline that did was deleted. **The shared-tree turn boundary** —\n`stella-cli`'s `turn_files`, over `WorkJournal::snapshot_worktree` —\nemits every event on the stream today, and it is not attribution. It\nanswers what changed in the tree during this turn, so a user editing a\nfile in another window lands here beside the agent's own writes. A\nconsumer that needs \"what did the agent do\" reads the git diff, or\nadoption.\n\nHooking the file tools instead would report a subset of the turn while\nlooking complete: `bash` mutates the tree without naming a path, and so\ndo MCP servers and custom script tools, none of which declares its\npaths in any schema the engine reads. Reading paths out of tool\n*inputs* is worse — a wrapper that did that, knowing four tool names\nand sitting on one of three tool stacks, reported bulk edits as\n`+0 -0`. One measurement at the boundary costs a `git add -A` and a\n`write-tree` against a dedicated index, after the model has answered.\n\n`added`/`removed` are what the producer measured — git's `--numstat`\nagainst the two trees, or, for adoption, numstat plus the patch it\napplied. Consumers **must** use them rather than counting `+`/`-` lines\nin `diff`: the diff is a bounded, coarse rendering of the\nchanged region, and re-deriving from it is what made the two disagree.\nA binary file carries `0/0` and its kind.", "properties": { "added": { "default": 0, diff --git a/docs/wire/serveframe.schema.json b/docs/wire/serveframe.schema.json index 92ecc139d8..f91bb9ca44 100644 --- a/docs/wire/serveframe.schema.json +++ b/docs/wire/serveframe.schema.json @@ -937,7 +937,7 @@ "type": "object" }, { - "description": "A file was created/modified/deleted during a turn, carrying both the\nauthoritative line delta and a diff for display.\n\n**Observability, never evidence.** Nothing may found a claim about what\nchanged on counting these — #2873 removed the last three decisions that\ndid, and the tally survives only as a recorded-only field on\n`LadderSnapshot`. The reason is sharper than \"it might be incomplete\":\nthe two producers below answer two *different* questions, and only one\nof them is about the agent.\n\n# The two producers, and what each one's answer means\n\n1. **Candidate adoption** — `Pipeline::deliver_winner` (the built-in\n staged pipeline's `pipeline/delivery.rs`, deleted in #3865), one\n event per\n `AdoptedChange`, emitted beside the `CandidateWorkspace::attribute_adopted`\n call that writes the same rows to the host's durable ledger (#2907).\n This one **was** attribution: adoption measured a candidate against a\n sealed baseline, so it could tell the agent's edits from anyone\n else's. **It has no producer in this workspace any more** — that\n crate was deleted in #3865 — so every event on this stream today is\n the second kind. Read the distinction below as the contract a\n re-homed adoption producer would have to meet, not as two live\n sources (#3881).\n2. **The shared-tree turn boundary** — `stella-cli`'s `turn_files`, over\n `WorkJournal::snapshot_worktree` (#3413). This one is **not**\n attribution. It answers *what changed in the tree during this turn*,\n which is the question a whole-tree measurement can answer: a\n user editing a file in another window mid-turn lands here\n indistinguishably from the agent's own writes.\n\nA consumer that needs \"what did the agent do\" takes it from the git diff\nof the tree, or from adoption. This stream is for showing a human what\nmoved.\n\n# Why an engine-only turn is measured rather than hooked (#3413)\n\nIt once emitted from the tools, and for a while after that from nowhere:\nthe 12-tool purge (#3244) deleted every file-writing built-in and the\nfile-CRUD ledger that emitted these, and this doc went on naming a\n`ToolRegistry::record_touch` that no longer existed. The file built-ins\nhave since been restored, so a tool hook is now available — and it is\nstill not right. A hook on `write_file` / `edit_file` / `delete_file`\nwould report a *subset* of the turn while looking exhaustive: `bash`\nmutates the tree without naming a path, and so do MCP servers and\ncustom script tools, none of which describes its paths in any schema\nthe engine reads. And\nsynthesizing these from tool *inputs* is the known defect, not the\ndesign: a wrapper that did exactly that, knowing four hard-coded tool\nnames and sitting on one of three tool stacks, is what reported files\nedited in bulk or by a worker lane as `+0 -0` (#2290).\n\nSo the answer is a measurement, taken once per turn at the boundary.\nThe cost is one `git add -A` plus a `write-tree` against a dedicated\nindex, after the model has answered.\n\n`added`/`removed` are what the producer measured — git's `--numstat`\nagainst the two trees, or, for adoption, numstat plus the patch it\napplied. Consumers **must** use them rather than counting `+`/`-` lines\nin `diff`: the diff is a bounded, coarse rendering of the\nchanged region, and re-deriving from it is what made the two disagree.\nA binary file carries `0/0` and its kind.", + "description": "A file was created/modified/deleted during a turn, carrying both the\nauthoritative line delta and a diff for display.\n\n**Observability, never evidence.** Nothing may found a claim about what\nchanged on counting these — #2873 removed the last three decisions that\ndid, and the tally survives only as a recorded-only field on\n`LadderSnapshot`. The reason is sharper than \"it might be incomplete\":\nthe two producers below answer two *different* questions, and only one\nof them is about the agent.\n\nTwo producers can emit one, and they answer different questions.\n**Candidate adoption** measures a candidate against a sealed baseline,\nso it can tell the agent's edits from anyone else's. That is\nattribution, and nothing in this workspace produces it: the staged\npipeline that did was deleted. **The shared-tree turn boundary** —\n`stella-cli`'s `turn_files`, over `WorkJournal::snapshot_worktree` —\nemits every event on the stream today, and it is not attribution. It\nanswers what changed in the tree during this turn, so a user editing a\nfile in another window lands here beside the agent's own writes. A\nconsumer that needs \"what did the agent do\" reads the git diff, or\nadoption.\n\nHooking the file tools instead would report a subset of the turn while\nlooking complete: `bash` mutates the tree without naming a path, and so\ndo MCP servers and custom script tools, none of which declares its\npaths in any schema the engine reads. Reading paths out of tool\n*inputs* is worse — a wrapper that did that, knowing four tool names\nand sitting on one of three tool stacks, reported bulk edits as\n`+0 -0`. One measurement at the boundary costs a `git add -A` and a\n`write-tree` against a dedicated index, after the model has answered.\n\n`added`/`removed` are what the producer measured — git's `--numstat`\nagainst the two trees, or, for adoption, numstat plus the patch it\napplied. Consumers **must** use them rather than counting `+`/`-` lines\nin `diff`: the diff is a bounded, coarse rendering of the\nchanged region, and re-deriving from it is what made the two disagree.\nA binary file carries `0/0` and its kind.", "properties": { "added": { "default": 0,