From 7761d520ff607cae607a3f17960096233aaaf1b5 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Mon, 31 Aug 2026 23:19:27 +0530 Subject: [PATCH 01/11] chore(deps): pin io-harness 0.73.0 and bump to 0.34.0 The pin forces no compile fix: io-harness 0.73.0 makes Skill non_exhaustive and gives it a root field, and this crate constructs none, only reads fields. Its real cost is a silent one, handled in the commits that follow. --- Cargo.lock | 6 +++--- Cargo.toml | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 61fe74c..dfd795d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1850,7 +1850,7 @@ dependencies = [ [[package]] name = "io-cli" -version = "0.33.0" +version = "0.34.0" dependencies = [ "clap", "crossterm", @@ -1867,9 +1867,9 @@ dependencies = [ [[package]] name = "io-harness" -version = "0.72.0" +version = "0.73.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d116567f264ba451d220c30b42eaa7be505b0ff2b89289257751e542beb82de" +checksum = "1de74280eb7a5984f677008c4288922d5dbe17233e2c67688c84b29ede389559" dependencies = [ "base64 0.23.1", "calamine", diff --git a/Cargo.toml b/Cargo.toml index a490f43..8d22a6e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "io-cli" -version = "0.33.0" +version = "0.34.0" edition = "2021" # io-harness declares 1.95, set by `libsqlite3-sys` calling the std `cfg_select!` # macro from its build script. Nothing here can be lower than what the harness @@ -63,7 +63,7 @@ path = "src/lib.rs" # still correct about what this *renderer* can be handed; it is simply no longer # load-bearing on the build. Turning `barcode` back off is what would restore # it, and `documents` would then have to be spelled out as its other four. -io-harness = { version = "0.72", features = ["media", "browser", "documents"] } +io-harness = { version = "0.73", features = ["media", "browser", "documents"] } # **`tui-textarea` is gone as of 0.18.0, and it is why this pair was frozen.** # The comment that stood here said ratatui stayed at 0.29 and crossterm at 0.28 # because the composer was built out of that widget and the 0.30 line carried From 0c20b08b7483879fd0abd5a870ab89c4046bb5d0 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Mon, 31 Aug 2026 23:21:39 +0530 Subject: [PATCH 02/11] feat(events): close the three surfaces that still printed the separator A read_skill call is drawn as a loaded-skill row - the skill's displayed name and what became of it - from io-harness's own decision sentence rather than beside it, so the string that carried the separator is no longer drawn for that tool at all. A failed read still says the read failed. trim_result is repaired at the cause: a pending call keeps io-harness's wire target beside the displayed one, and both the said set and the trailing-target strip consult it. Translating the target in place is what stopped the repetition being recognised, and plain() keeps _ while dropping :, so the two sides differed by the separator itself. An MCP tool cell reads Call github:create_issue. The prefix is stripped before translating because the prefix itself ends with the separator, and the cell that carries a measured duration now matches on the wire name - the only event in the enum reporting how long a tool ran, lost silently otherwise. A slash-invoked skill echoes what was typed while the prompt stays the catalogue name. The echo is a field of the harness's own Started event, so it is carried from the driver rather than translated on the way past. And read_skill's target is no longer assumed to be a name: io-harness 0.73.0 announces a companion path in preference to it, and translating one draws a file that does not exist. --- src/events.rs | 467 +++++++++++++++++++++++++++++++++++++++++++++--- src/main.rs | 25 ++- tests/events.rs | 309 ++++++++++++++++++++++++++++++++ 3 files changed, 776 insertions(+), 25 deletions(-) diff --git a/src/events.rs b/src/events.rs index e73e342..4746263 100644 --- a/src/events.rs +++ b/src/events.rs @@ -84,7 +84,10 @@ pub const VERBS: &[(&str, &str)] = &[ ("lsp_references", "References"), ("lsp_rename", "Rename"), ("lsp_symbols", "Symbols"), - ("read_skill", "Read skill"), + // `Skill` and not `Read skill`: from 0.34.0 this cell is a loaded-skill row + // rather than a read of a file that happens to be a skill, and the verb is + // the column that says which of the two a reader is looking at. + ("read_skill", "Skill"), ("todo_write", "Todo"), ("remember", "Remember"), ("forget", "Forget"), @@ -114,8 +117,25 @@ pub const VERBS: &[(&str, &str)] = &[ ("barcode_decode", "Decode"), ]; +/// The verb an MCP tool is drawn under. +/// +/// A word rather than nothing, so the first column stays the column a reader +/// skims down. Every other cell in this transcript opens with a verb in accent +/// and bold and puts its target after it muted; an MCP cell that opened with the +/// server and the tool would be the one row whose first column is a target. +pub const MCP_VERB: &str = "Call"; + /// The verb for a tool name, or the name exactly as it arrived. +/// +/// **An MCP tool is answered before the table is consulted.** Its name is built +/// by io-harness from a prefix, a server id and the tool's own name, so it can +/// never appear in a static table — and up to 0.33.0 it fell through this +/// function unchanged and reached the transcript whole, separators and all. The +/// server and the tool are drawn as the cell's target; see `announce`. pub fn verb(name: &str) -> &str { + if name.starts_with(MCP_TOOL_PREFIX) { + return MCP_VERB; + } VERBS .iter() .find(|(tool, _)| *tool == name) @@ -135,6 +155,21 @@ struct Pending { /// the verb and the sentence said the same thing twice in two vocabularies. raw: String, target: String, + /// io-harness's own target, before the display translation was applied to it. + /// + /// **The translation broke the deduplication, and this is the repair at the + /// cause.** [`trim_result`] drops the step's decision sentence when the + /// sentence only repeats what the cell already says, and it decides that by + /// comparing words. Once 0.32.0 translated the target *in place*, the + /// harness's `bundle__skill` stopped matching the cell's `bundle:skill` — so + /// the sentence was no longer recognised as a repetition and the one string + /// the translation existed to hide was printed in full, beside the + /// translation of it. + /// + /// Kept *beside* the displayed form rather than instead of it, because a + /// sentence may legitimately repeat either spelling and both have to count + /// as things the cell has already said. + target_raw: String, /// The session age at which the call was announced. An age handed in by the /// driver, never a clock read here: this module has no timer, and N1 is what /// keeps it that way. @@ -256,14 +291,75 @@ pub struct Events { /// nothing: a path is printed whole rather than trimmed against a guess at /// where the session is. `App::set_root` is the one caller. root: std::path::PathBuf, + /// What the operator actually typed, when it is not what was submitted. + /// + /// **The prompt echo is io-harness's own event field, not a string io-cli + /// holds** — the row is drawn from `EventKind::Started`'s `goal`, which is + /// the text that was sent. For a slash-invoked skill those two deliberately + /// differ: the submitted prompt is the catalogue name `ultraship__brainstorm` + /// because that is the only string `read_skill` resolves, and the operator + /// typed `/ultraship:brainstorm`. Up to 0.33.0 the machine's spelling was + /// what came back. + /// + /// Carried rather than derived, and that is the point. Translating the goal + /// in the `Started` arm would run the display translation over arbitrary + /// operator prose — which is how `src/__init__.py` became `src/:init__.py` in + /// 0.32.0's first draft, on the largest text surface in the product. Only the + /// driver knows a submission was an invocation, so only the driver says so. + /// + /// Taken by the arm that draws it, so it cannot outlive the turn it belongs + /// to and be shown over the next prompt. + echo: Option, } /// io-harness's name for the tool that opens a skill. /// -/// Named here because it is the one tool whose `target` is a *name* rather than a -/// path, which is what decides whether the display translation applies to it. +/// Named here because it is the one tool whose `target` can be a *name* rather +/// than a path, which is what decides whether the display translation applies to +/// it. pub const READ_SKILL: &str = "read_skill"; +/// What a `read_skill` cell says when the read returned and said nothing more. +pub const LOADED: &str = "loaded"; + +/// What a `read_skill` cell says when the call asked for the bundle itself. +/// +/// io-harness writes an empty `path` into its own sentence as `.`, and says why +/// in its own comment: the empty path resolves to the root and lists it, and the +/// dot keeps that listing distinguishable from a body read in the durable trace. +/// A dot alone is that distinction spelled for a trace rather than for a person, +/// so the cell says the word. +pub const LISTED: &str = "listed"; + +/// Whether a `read_skill` target is a skill's *name* rather than a file inside +/// one. +/// +/// **This stopped being a property of the tool in io-harness 0.73.0.** That +/// version gave `read_skill` an optional `path`, so a skill can read its own +/// bundle's `shared/` and `references/` files — and the announcement picks the +/// first argument present out of an ordered list in which `path` is checked +/// *before* `name`. So the same tool now announces a relative file path where +/// every earlier version announced a skill's name. +/// +/// The display translation must not reach that path. It rewrites the first +/// separator to a colon, and a companion file called `__init__.py` would be +/// drawn as `:init__.py` — a path that does not exist, on the one surface an +/// operator checks to see what the agent touched. That is verbatim the failure +/// 0.32.0 found and gated against; 0.73.0 moved the ground under the gate. +/// +/// A path separator or a dot is what tells them apart. A skill's name is a +/// directory or file stem io-harness joined to a bundle id, and the two things +/// this refuses — a separator and an extension — are what a path has and a name +/// does not. The authority is the step's own decision sentence, which carries +/// the skill and the file as two words; this is only what the *live* row can +/// know, before any sentence has arrived. +fn names_a_skill(target: &str) -> bool { + !target.is_empty() + && !target.contains('/') + && !target.contains('\\') + && !target.contains('.') +} + impl Events { pub fn new(theme: Theme) -> Self { Self { @@ -283,9 +379,20 @@ impl Events { last_prose: true, thinking: false, root: std::path::PathBuf::new(), + echo: None, } } + /// Say what the operator typed, for the turn about to start. + /// + /// Called by the driver immediately before submitting a prompt that is not + /// the text that was typed — today, a slash-invoked skill, whose submitted + /// form is the catalogue name. Every other prompt submits what was typed and + /// needs no call at all. + pub fn set_echo(&mut self, typed: impl Into) { + self.echo = Some(typed.into()); + } + /// Forget everything this module holds about a conversation. /// /// What is dropped is what a new conversation must not inherit: the tail @@ -305,6 +412,7 @@ impl Events { self.last_prose = true; self.thinking = false; self.step_at = Duration::ZERO; + self.echo = None; } /// Say which workspace this session is held over, so a target inside it can @@ -432,7 +540,7 @@ impl Events { let mut lines = self.flush_text(); let theme = self.theme; for call in std::mem::take(&mut self.open) { - lines.push(cell_line(theme, &call, "unfinished", None)); + lines.push(cell_line(theme, &call, "unfinished", None, false)); } self.refused_this_step = false; // The turn is over, so nothing is thinking and nobody is being waited @@ -584,9 +692,16 @@ impl Events { // words. The rest of the prompt is indented under the first // character rather than under the mark, so the block reads as one // thing said once. + // + // **What the operator typed, when the driver said it differed + // from what was sent.** Taken rather than read, so a turn cannot + // show the previous turn's typing; a prompt submitted as itself + // sets nothing and falls back to the goal, which is the same + // string. let marker = theme.glyphs.marker; let indent = " ".repeat(marker.chars().count()); - let mut lines: Vec> = goal + let typed = self.echo.take().unwrap_or_else(|| goal.clone()); + let mut lines: Vec> = typed .split('\n') .enumerate() .map(|(row, line)| { @@ -669,7 +784,7 @@ impl Events { } else { verdict }; - lines.push(cell_line(theme, call, result, Some(at))); + lines.push(cell_line(theme, call, result, Some(at), paired)); self.after_cell = true; } @@ -759,13 +874,27 @@ impl Events { // `read src/:init__.py`, a path that does not exist, in the one // place an operator checks what the agent touched. Every Python // and Jest repository would have met it on the first turn. - target: if target == name { + // + // **An MCP tool's identity IS its target, and it is answered + // first.** io-harness builds the name as a prefix, the server + // and the tool, so the cell's useful fact is which server ran + // which tool; the call's arguments go to the durable trace, + // where a whole argument list belongs. The prefix is stripped + // *before* translating, and that order is load-bearing: the + // prefix itself ends with the separator, so translating the + // whole name splits at the prefix's own join and yields + // `mcp:github__create_issue` — a string that is both wrong and + // still carrying the thing the translation exists to remove. + target: if let Some(tool) = name.strip_prefix(MCP_TOOL_PREFIX) { + crate::naming::display(tool) + } else if target == name { shown.to_string() - } else if name == crate::events::READ_SKILL { + } else if name == crate::events::READ_SKILL && names_a_skill(target) { crate::naming::display(target) } else { relative(target, &self.root) }, + target_raw: target.clone(), opened_at: at, measured: None, }); @@ -1479,9 +1608,16 @@ impl Events { // // A connect, a discover or a disconnect carries no tool and no // duration, and matches nothing. + // **Matched on the wire name and never on the drawn one.** From + // 0.34.0 an MCP cell is drawn as a verb and a translated target, + // so the field this used to compare against no longer holds the + // string being rebuilt here. `raw` is io-harness's own name for + // the tool and is the only field that still does — and getting + // this wrong loses the measurement silently, on the one event in + // the whole enum that reports how long a tool actually ran. if let (Some(tool), Some(millis)) = (tool, millis) { let name = format!("{MCP_TOOL_PREFIX}{server}{NAMESPACE}{tool}"); - if let Some(call) = self.open.iter_mut().rev().find(|c| c.name == name) { + if let Some(call) = self.open.iter_mut().rev().find(|c| c.raw == name) { call.measured = Some(Duration::from_millis(*millis)); } } @@ -1824,11 +1960,20 @@ fn trim_result(result: &str, call: &Pending) -> String { .to_lowercase() } + // **Both spellings of the target, because the cell shows one and the sentence + // is written in the other.** io-harness writes its decision in its own + // vocabulary, so a translated target leaves the sentence saying + // `bundle__skill` while the cell says `bundle:skill`. Comparing against the + // displayed form alone stopped recognising the repetition the moment 0.32.0 + // introduced a translation — and `plain` keeps `_` while dropping `:`, so the + // two sides do not merely differ by a character, they differ by the separator + // itself, which is the whole string being hidden. let said = format!( - "{}{}{}", + "{}{}{}{}", plain(&call.raw), plain(&call.name), - plain(&call.target) + plain(&call.target), + plain(&call.target_raw) ); let mut rest = result.trim_start(); while let Some((head, tail)) = rest.split_once(char::is_whitespace) { @@ -1852,8 +1997,15 @@ fn trim_result(result: &str, call: &Pending) -> String { // The WHOLE target, never its last word. Stripping one token turned // `Run cargo test · ran cargo test` into `ran cargo`, which is a sentence // this interface made up out of one the harness wrote. - if !call.target.is_empty() { - if let Some(head) = rest.strip_suffix(call.target.as_str()) { + // + // Tried in both spellings for the same reason `said` holds both: the sentence + // ends in the words io-harness chose, which for a translated target are not + // the words the cell is showing. + for tail in [call.target.as_str(), call.target_raw.as_str()] { + if tail.is_empty() { + continue; + } + if let Some(head) = rest.strip_suffix(tail) { let head = head.trim_end(); if !plain(head).is_empty() { return head.to_string(); @@ -1874,8 +2026,108 @@ fn ends_blank(lines: &[Line<'static>]) -> bool { .is_none_or(|line| line.spans.iter().all(|span| span.content.trim().is_empty())) } -fn cell_line(theme: Theme, call: &Pending, result: &str, at: Option) -> Line<'static> { +/// A `read_skill` decision split into the skill that loaded and what it read. +/// +/// io-harness writes this decision as the skill's own name and, when the call +/// carried one, the companion file's relative path — two words, in that order. +/// The first is a *name* by construction, which makes it the one place the +/// display translation is certainly right rather than probably right: the live +/// row has to guess from the target's shape ([`names_a_skill`]) because no +/// sentence has arrived yet, and this does not have to guess at all. +/// +/// `None` when the decision is not in that shape, which is every case where the +/// harness did not write a sentence for this call — a refusal, a step verdict +/// standing in for one, or a cell closed unfinished. Those keep the ordinary +/// cell, because a row asserting a skill loaded is exactly what must not be +/// drawn when the read did not happen. +fn skill_and_file(decision: &str) -> Option<(String, String)> { + /// io-harness's own sentence for a skill that was read. + const READ: &str = "read skill "; + /// And for one that could not be. Both are matched rather than guessed at, + /// because the difference between them is the difference between a row that + /// says a skill loaded and a row that says so wrongly. + const FAILED_HEAD: &str = "skill "; + const FAILED_TAIL: &str = " read error"; + + let decision = decision.trim(); + let (label, failed) = if let Some(label) = decision.strip_prefix(READ) { + (label, false) + } else if let Some(rest) = decision.strip_prefix(FAILED_HEAD) { + (rest.strip_suffix(FAILED_TAIL)?, true) + } else { + return None; + }; + // The label is the skill's name and, when the call carried one, the companion + // file's relative path — io-harness builds it that way and writes it into the + // decision, the observation header and the supersede target alike. + let (skill, file) = match label.split_once(char::is_whitespace) { + Some((skill, rest)) => (skill, rest.trim()), + None => (label, ""), + }; + if skill.is_empty() { + return None; + } + let file = match file { + "." => LISTED, + other => other, + }; + let detail = match (failed, file) { + (false, "") => LOADED.to_string(), + (false, file) => file.to_string(), + (true, "") => FAILED_TAIL.trim().to_string(), + (true, file) => format!("{file}{FAILED_TAIL}"), + }; + Some((crate::naming::display(skill), detail)) +} + +fn cell_line( + theme: Theme, + call: &Pending, + result: &str, + at: Option, + paired: bool, +) -> Line<'static> { let separator = theme.glyphs.separator; + // **A loaded skill is drawn as a loaded skill, in io-cli's own words.** + // + // This is what removes the separator at its source rather than filtering it + // downstream: io-harness's decision sentence for this tool is not drawn at + // all, so the string that used to carry the separator into the transcript is + // no longer a thing the cell prints. The two facts a reader wants — which + // skill, and what came of it — are taken from that sentence and said once + // each. + // + // Gated on `paired`, so the sentence being read really is this call's. A + // refusal, a step verdict or an unfinished flush all fall through to the + // ordinary cell, because `loaded` is an assertion and the one thing worse + // than an unreadable row is a confident wrong one. + if paired && call.raw == READ_SKILL { + if let Some((skill, file)) = skill_and_file(result) { + let detail = if file.is_empty() { + LOADED.to_string() + } else { + file + }; + return with_duration( + theme, + vec![ + Span::styled( + format!(" {} ", theme.glyphs.bullet), + theme.style(Tone::Muted), + ), + Span::styled( + call.name.clone(), + theme.style(Tone::Accent).add_modifier(Modifier::BOLD), + ), + Span::styled(format!(" {skill}"), theme.style(Tone::Muted)), + Span::styled(separator, theme.style(Tone::Muted)), + Span::styled(detail, theme.style(Tone::Normal)), + ], + call, + at, + ); + } + } let mut spans = vec![ Span::styled( format!(" {} ", theme.glyphs.bullet), @@ -1910,15 +2162,32 @@ fn cell_line(theme: Theme, call: &Pending, result: &str, at: Option) - spans.push(Span::styled(result, theme.style(Tone::Normal))); } - // Two different kinds of number, told apart on the line itself. A measured - // duration is io-harness's own and is printed plainly; anything else is the - // interval io-cli observed between two events — which includes the model's - // own turnaround and the queue in front of the tool — and wears a `~` to say - // that it is an observation rather than how long the tool ran. - // - // `at` is `None` when the cell is closed with nothing having reported on it. - // io-cli does not know a duration there and says none, rather than printing - // the age of the announcement as though it were one. + with_duration(theme, spans, call, at) +} + +/// A cell's spans with its duration appended, whichever shape the cell took. +/// +/// Shared by the ordinary cell and the loaded-skill row so that the two kinds of +/// number below are told apart in one place. A second copy of this reasoning is +/// how one of the two rows ends up printing an observation as though it were a +/// measurement. +/// +/// Two different kinds of number, told apart on the line itself. A measured +/// duration is io-harness's own and is printed plainly; anything else is the +/// interval io-cli observed between two events — which includes the model's own +/// turnaround and the queue in front of the tool — and wears a `~` to say that it +/// is an observation rather than how long the tool ran. +/// +/// `at` is `None` when the cell is closed with nothing having reported on it. +/// io-cli does not know a duration there and says none, rather than printing the +/// age of the announcement as though it were one. +fn with_duration( + theme: Theme, + mut spans: Vec>, + call: &Pending, + at: Option, +) -> Line<'static> { + let separator = theme.glyphs.separator; let observed = at.map(|at| format!("~{}", format_millis(at.saturating_sub(call.opened_at)))); if let Some(duration) = call.measured.map(format_millis).or(observed) { spans.push(Span::styled( @@ -1929,6 +2198,158 @@ fn cell_line(theme: Theme, call: &Pending, result: &str, at: Option) - Line::from(spans) } +#[cfg(test)] +mod tests { + use super::*; + + /// A call as `announce` would have built it, for the two functions below. + /// + /// In-crate because both are private, and they have to be: `trim_result` is + /// the comparison this release repairs and `skill_and_file` is the parse that + /// decides whether a row may say a skill loaded. A criterion about either is + /// gated by nothing if the only way to reach it is through a rendered line + /// that also exercises six other decisions. + fn call(raw: &str, name: &str, target: &str, target_raw: &str) -> Pending { + Pending { + name: name.to_string(), + raw: raw.to_string(), + target: target.to_string(), + target_raw: target_raw.to_string(), + opened_at: Duration::ZERO, + measured: None, + } + } + + /// **F3 — the exact pair from the live session that opened this release.** + /// + /// 0.32.0 translated the cell's target and left io-harness's sentence beside + /// it in the untranslated vocabulary, so the comparison that exists to drop a + /// repetition stopped recognising one — and printed in full the single string + /// the translation had been added to hide. + #[test] + fn f3_a_decision_repeating_a_translated_target_is_dropped() { + let call = call( + READ_SKILL, + "Skill", + "ultraship:using-ultraship", + "ultraship__using-ultraship", + ); + assert_eq!( + trim_result("read skill ultraship__using-ultraship", &call), + "", + "the sentence only repeats the cell and must leave the result column \ + empty, in either vocabulary", + ); + } + + /// And the displayed spelling is dropped too, so the repair does not depend + /// on which of the two io-harness happens to have written. + #[test] + fn f3_a_decision_repeating_the_displayed_target_is_also_dropped() { + let call = call( + READ_SKILL, + "Skill", + "ultraship:using-ultraship", + "ultraship__using-ultraship", + ); + assert_eq!(trim_result("read skill ultraship:using-ultraship", &call), ""); + } + + /// **F4 — and a genuine result is still not stripped.** + /// + /// The whole target at the end is a repetition; one token of it is a sentence + /// this interface made up out of one the harness wrote. `Run cargo test · ran + /// cargo` was that mistake. + #[test] + fn f4_a_genuine_result_survives_and_a_whole_repeated_target_does_not() { + // **`ran`, not the empty string.** The cell has already said `Run` and + // `cargo test`, so what the sentence *adds* is the one word `ran`, and + // that is what it carries — the rule is "what the result adds, and not + // what it repeats", never "drop a sentence that mentions the target". + // 0.34.0's contract said this column was empty; the behaviour it + // describes has been asserted since 0.11.0 and is the one that is right. + let run = call("shell", "Run", "cargo test", "cargo test"); + assert_eq!(trim_result("ran cargo test", &run), "ran"); + let list = call("list_dir", "List", "src", "src"); + assert_eq!(trim_result("list_dir (4 entries)", &list), "(4 entries)"); + } + + /// **The success sentence and the failure sentence are told apart.** + /// + /// A row saying a skill loaded is an assertion, so it is made only from the + /// sentence io-harness writes when one did. + #[test] + fn a_loaded_skill_is_parsed_and_a_failed_read_is_not_mistaken_for_one() { + assert_eq!( + skill_and_file("read skill ultraship__plan"), + Some(("ultraship:plan".to_string(), LOADED.to_string())), + ); + assert_eq!( + skill_and_file("read skill ultraship__plan shared/principles.md"), + Some(( + "ultraship:plan".to_string(), + "shared/principles.md".to_string() + )), + ); + // An empty `path` lists the bundle, which io-harness spells `.`. + assert_eq!( + skill_and_file("read skill ultraship__plan ."), + Some(("ultraship:plan".to_string(), LISTED.to_string())), + ); + // The failure sentence says the read failed and never says `loaded`. + assert_eq!( + skill_and_file("skill ultraship__plan read error"), + Some(("ultraship:plan".to_string(), "read error".to_string())), + ); + assert_eq!( + skill_and_file("skill ultraship__plan missing.md read error"), + Some(( + "ultraship:plan".to_string(), + "missing.md read error".to_string() + )), + ); + // Anything else is not this tool's sentence and takes the ordinary cell: + // a refusal, a step verdict standing in for a sentence, an unfinished + // flush. None of the three may become a row claiming a skill loaded. + for other in ["refused", "no change", "changed files", "unfinished", ""] { + assert_eq!( + skill_and_file(other), + None, + "{other:?} is not io-harness's sentence for a skill that was read", + ); + } + } + + /// **F11 — a companion path is not a name, and the pin made that a live + /// question rather than a theoretical one.** + /// + /// io-harness 0.73.0 announces the path in preference to the skill's name, so + /// the target this tool arrives with is now sometimes a file. Translating one + /// draws a path that does not exist. + #[test] + fn f11_a_companion_path_is_never_taken_for_a_skills_name() { + for name in ["ultraship__plan", "brainstorm", "a__b"] { + assert!( + names_a_skill(name), + "{name} is a skill name and must still be translated", + ); + } + for path in [ + "references/__init__.py", + "shared/principles.md", + "__init__.py", + "NOTES.md", + "a\\b__c.md", + "", + ] { + assert!( + !names_a_skill(path), + "{path} is a file and translating it draws a path that does not exist", + ); + } + } +} + /// `420ms`, `1.4s`, `92.0s`. A tool cell's own duration. /// /// Separate from [`format_elapsed`](crate::status::format_elapsed), which floors diff --git a/src/main.rs b/src/main.rs index dcc84b0..5c74e2c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -422,6 +422,20 @@ async fn drive( // that will drive the loop; `src/resolved.rs` is the only module permitted // to make the call at all, asserted by `tests/dependencies.rs`. let holdings = tokio::task::block_in_place(|| io_cli::resolved::Resolved::load(&config)); + // **A bundle's own program goes on this process's `PATH`, appended.** Done + // here because it is the same answer for both entry points and because + // io-harness's executable resolver reads the environment: a command it spawns + // inherits what is set before the run, and there is no contract field for it. + // Appended and never prepended, and nothing is written to disk — see + // `src/bundle_path.rs` for both reasons. + for (declared, actual) in io_cli::bundle_path::mismatched(holdings.loaded()) { + notices.push(format!( + "a bundle declares the program {declared} but ships it as {actual} — io puts the \ + directory on the path and writes nothing, so it answers to {actual} and not to \ + {declared}", + )); + } + io_cli::bundle_path::install(holdings.loaded()); let bundles = bundle_skills(holdings.loaded()); let (skills, complaint) = commands::skills(&home, skills_dir.as_deref(), &bundles); if let Some(complaint) = complaint { @@ -2536,7 +2550,7 @@ async fn loop_over Result>( // `Chosen::Held`, and `Held` is what makes the // entry `enabled = false` and earns the operator // the disclosure before a stranger's bundle - // contributes to six subsystems. Prefilling the + // contributes to seven subsystems. Prefilling the // directory would take the path reading, which // exists for a directory the operator wrote // themselves, and switch this one straight on. @@ -4330,6 +4344,12 @@ async fn loop_over Result>( .split_once(char::is_whitespace) .map(|(_, rest)| rest.trim().to_string()) .unwrap_or_default(); + // The prompt is the catalogue name; the echo is what was typed. + // Said here because this is the only place that knows the two + // differ — the echo row is drawn from io-harness's own `Started` + // event, and translating that field on the way past would run the + // display translation over every prompt an operator ever writes. + app.events.set_echo(text.to_string()); command = Command::Submit(if args.is_empty() { wire } else { @@ -4349,6 +4369,7 @@ async fn loop_over Result>( // there is no public call that runs a skill on the model's // behalf, so what io-cli controls is that the prompt is the // catalogue name and nothing else. + app.events.set_echo(text.to_string()); Command::Submit(if args.is_empty() { wire } else { @@ -9783,7 +9804,7 @@ async fn manage_main( // `io_harness::Plugins::inspect` — so a bundle io-harness refuses returned `Err` // from `plan` above and nothing reached this line. // - // What an operator gets here is therefore the same six contribution kinds the + // What an operator gets here is therefore the same seven contribution kinds the // session shows, on stderr, before anything is written; and then the install // happens, because they named this bundle in the command they typed. A `--yes` // was considered and refused for the reason it was refused in 0.29.0: it is a diff --git a/tests/events.rs b/tests/events.rs index 3ec1c93..82f0ae8 100644 --- a/tests/events.rs +++ b/tests/events.rs @@ -2759,3 +2759,312 @@ fn a_skills_target_is_still_drawn_the_way_the_operator_reads_it() { else: {live:?}", ); } + +/// A `read_skill` call announced, then closed by the step that ran it. +/// +/// The pair is the unit under test throughout this section: the announcement +/// decides what the live row says and the step's own sentence decides what the +/// committed cell says, and the defects this release fixes are all in the +/// relationship between the two. +fn skill_cell(target: &str, decision: &str) -> String { + let mut events = Events::new(DARK); + events.event( + &event(EventKind::ToolCall { + name: io_cli::events::READ_SKILL.to_string(), + target: target.to_string(), + }), + Duration::ZERO, + ); + rendered( + &mut events, + EventKind::Step { + decision: decision.to_string(), + tool_call: String::new(), + tokens: 12, + changed: false, + }, + ) +} + +/// **F1 — a loaded skill says which skill loaded, and says nothing in the +/// machine's spelling.** +/// +/// This is the line that opened the release. `Read skill ultraship:using-ultraship +/// · ultraship__using-ultraship` was drawn: the target translated, io-harness's +/// sentence beside it untouched, and the one string the translation existed to +/// hide printed in full next to the translation of it. +#[test] +fn f1_a_loaded_skill_is_drawn_as_a_loaded_skill_row() { + let cell = skill_cell( + "ultraship__using-ultraship", + "read skill ultraship__using-ultraship", + ); + assert!( + cell.contains("ultraship:using-ultraship"), + "the row must name the skill the operator reads: {cell:?}", + ); + assert!( + cell.contains("loaded"), + "the row must say what became of it: {cell:?}", + ); + assert!( + !cell.contains("__"), + "io-harness's separator reached the transcript: {cell:?}", + ); + assert!( + !cell.contains("read skill"), + "the harness's own sentence is not drawn for this tool, which is what \ + removes the separator at its source: {cell:?}", + ); +} + +/// **F2 — and a skill no bundle contributed gets the same row.** +/// +/// An unqualified skill carries no separator, so nothing here is about +/// translation. The row must not be a bundle-only surface: a skill an operator +/// wrote themselves loads exactly as visibly. +#[test] +fn f2_an_unqualified_skill_gets_the_same_row_with_its_bare_name() { + let cell = skill_cell("brainstorm", "read skill brainstorm"); + assert!(cell.contains("brainstorm"), "{cell:?}"); + assert!(cell.contains("loaded"), "{cell:?}"); + assert!(!cell.contains(':'), "there is no bundle to name: {cell:?}"); +} + +/// **The row is an assertion, so it is not made when the read did not happen.** +/// +/// Drawing `loaded` from io-cli's own words rather than from the harness's +/// sentence is what removes the separator, and it is also what makes this row +/// able to be wrong in a way the old one could not. A failed read still says the +/// read failed. +#[test] +fn a_failed_skill_read_says_so_and_never_says_loaded() { + let cell = skill_cell("ultraship__plan", "skill ultraship__plan read error"); + assert!( + cell.contains("read error"), + "a failed read must say it failed: {cell:?}", + ); + assert!( + !cell.contains("loaded"), + "the row claimed a skill loaded when the read failed: {cell:?}", + ); + assert!(!cell.contains("__"), "{cell:?}"); +} + +/// **F11 — a companion path is drawn as itself.** +/// +/// io-harness 0.73.0 gave `read_skill` an optional `path` and announces it in +/// preference to the skill's name, so this tool's target is now sometimes a file. +/// The translation rewrites the first separator to a colon, so a companion file +/// called `__init__.py` would be drawn as `:init__.py` — a path that does not +/// exist, in the one place an operator checks what the agent touched. That is +/// verbatim the failure 0.32.0 found; the pin moved the ground under the gate. +#[test] +fn f11_a_companion_path_is_drawn_intact_and_the_skill_is_still_named() { + for path in ["references/__init__.py", "shared/principles.md"] { + let mut events = Events::new(DARK); + events.event( + &event(EventKind::ToolCall { + name: io_cli::events::READ_SKILL.to_string(), + target: path.to_string(), + }), + Duration::ZERO, + ); + let live = events.live().to_string(); + assert!( + live.contains(path), + "the companion path lost its own characters on the live row: \ + {live:?} for {path}", + ); + let cell = skill_cell(path, &format!("read skill ultraship__plan {path}")); + assert!( + cell.contains(path), + "the committed cell must show the file that was read: {cell:?}", + ); + assert!( + cell.contains("ultraship:plan"), + "and must still say which skill it belonged to: {cell:?}", + ); + // The mangled spelling, not "no separator anywhere" — a companion file + // called `__init__.py` carries one legitimately, and a gate that forbade + // it would force the very translation this asserts against. + assert!( + !cell.contains(":init__"), + "the path was translated and now names a file that does not exist: \ + {cell:?}", + ); + } +} + +/// And an empty `path`, which asks for the bundle itself, still names the skill. +/// +/// The announced target is empty for this call, so a cell drawn from the target +/// alone would be blank. It is drawn from the harness's sentence, which is not. +#[test] +fn f11_an_empty_path_names_the_skill_rather_than_drawing_a_blank() { + let cell = skill_cell("", "read skill ultraship__plan ."); + assert!(cell.contains("ultraship:plan"), "{cell:?}"); + assert!( + cell.contains("listed"), + "an empty path lists the bundle, and `.` is a spelling for a trace \ + rather than for a person: {cell:?}", + ); +} + +/// **F6 — an MCP tool says its server and its tool.** +/// +/// `verb` fell an unknown name through unchanged, so `mcp__github__create_issue` +/// reached the transcript whole. The prefix is stripped before translating: it +/// ends with the separator itself, so translating the whole name splits at the +/// prefix's own join and yields `mcp:github__create_issue`, which is both wrong +/// and still carrying the thing being removed. +#[test] +fn f6_an_mcp_tool_cell_names_the_server_and_the_tool() { + let mut events = Events::new(DARK); + events.event( + &event(EventKind::ToolCall { + name: "mcp__github__create_issue".to_string(), + target: "mcp__github__create_issue".to_string(), + }), + Duration::ZERO, + ); + let live = events.live().to_string(); + assert!( + live.contains("Call") && live.contains("github:create_issue"), + "the cell must keep a verb and name both halves: {live:?}", + ); + assert!(!live.contains("__"), "{live:?}"); + assert!( + !live.contains("mcp:"), + "the prefix was translated instead of stripped: {live:?}", + ); +} + +/// A server id carrying the separator splits on the prefix and the first join +/// only, and the tool's own name is left exactly as it was found. +#[test] +fn f6_a_server_id_containing_the_separator_still_splits_at_the_first_join() { + let mut events = Events::new(DARK); + events.event( + &event(EventKind::ToolCall { + name: "mcp__deep__nested__tool".to_string(), + target: "mcp__deep__nested__tool".to_string(), + }), + Duration::ZERO, + ); + let live = events.live().to_string(); + assert!( + live.contains("deep:nested__tool"), + "only the first join after the prefix is the server boundary: {live:?}", + ); +} + +/// **The measured duration still reaches an MCP cell.** +/// +/// `EventKind::Mcp` is the only event in the whole enum carrying a per-tool +/// duration, and it is matched to its open cell by rebuilding io-harness's own +/// namespaced name. Translating the field that match compares against loses the +/// measurement silently — nothing fails, the cell simply falls back to io-cli's +/// own observation and wears a `~` that says it is one. +#[test] +fn an_mcp_calls_measured_duration_survives_the_translation() { + let mut events = Events::new(DARK); + events.event( + &event(EventKind::ToolCall { + name: "mcp__github__create_issue".to_string(), + target: "mcp__github__create_issue".to_string(), + }), + Duration::ZERO, + ); + events.event( + &event(EventKind::Mcp { + server: "github".to_string(), + tool: Some("create_issue".to_string()), + ok: Some(true), + millis: Some(1234), + tools: None, + }), + Duration::ZERO, + ); + let cell = rendered( + &mut events, + EventKind::Step { + decision: "called create_issue".to_string(), + tool_call: String::new(), + tokens: 12, + changed: false, + }, + ); + assert!( + cell.contains("1.2s"), + "io-harness's own measurement must reach the cell: {cell:?}", + ); + assert!( + !cell.contains("~1.2s"), + "a measurement was drawn as an observation: {cell:?}", + ); +} + +/// **F5 — the operator's own command comes back, and the model's does not.** +/// +/// The echo row is drawn from io-harness's own `Started` event, whose `goal` is +/// the text that was *sent* — and for a slash-invoked skill that is deliberately +/// the catalogue name, because it is the only string `read_skill` resolves. Up to +/// 0.33.0 the machine's spelling was what the operator read back. +#[test] +fn f5_a_slash_invoked_skill_echoes_what_was_typed_not_what_was_sent() { + let mut events = Events::new(DARK); + events.set_echo("/ultraship:brainstorm make me a portfolio"); + let echoed = rendered( + &mut events, + EventKind::Started { + goal: "ultraship__brainstorm\n\nmake me a portfolio".to_string(), + provider: "openrouter".to_string(), + }, + ); + assert!( + echoed.contains("/ultraship:brainstorm"), + "the operator's own command must come back: {echoed:?}", + ); + assert!( + echoed.contains("make me a portfolio"), + "and so must the argument: {echoed:?}", + ); + assert!( + !echoed.contains("__"), + "the machine's spelling reached the row the reader wrote: {echoed:?}", + ); +} + +/// And it is taken, not held — the next turn shows the next prompt. +/// +/// A carried string that outlives its turn is the second-use defect this +/// product's adversarial review has found in fifteen consecutive releases: right +/// on a fresh process, wrong from the second turn onward. +#[test] +fn f5_an_echo_does_not_outlive_the_turn_it_belongs_to() { + let mut events = Events::new(DARK); + events.set_echo("/ultraship:brainstorm"); + let _ = rendered( + &mut events, + EventKind::Started { + goal: "ultraship__brainstorm".to_string(), + provider: "openrouter".to_string(), + }, + ); + let second = rendered( + &mut events, + EventKind::Started { + goal: "now write the tests".to_string(), + provider: "openrouter".to_string(), + }, + ); + assert!( + second.contains("now write the tests"), + "the second turn drew the first turn's typing: {second:?}", + ); + assert!( + !second.contains("brainstorm"), + "the echo outlived its turn: {second:?}", + ); +} From 5ed7dc471e35f482ad8265fa18cc780417e85a12 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Mon, 31 Aug 2026 23:21:45 +0530 Subject: [PATCH 03/11] feat(plugins): put a bundle's declared program where a run finds it io-harness 0.73.0 parses a [[bin]], refuses one from a scope that may not contribute an executing thing, and offers it on an accessor it never calls itself; its contract says outright that placement is the host's decision. Appended and never prepended, so nothing a bundle ships can shadow a system command for the tool calls io-harness makes on the model's behalf, whose permission gate matches a binary name. No file is written on a stranger's behalf, so resolution is by the file's own name and a declaration that renames its program is reported at startup rather than staged. The plugin pane names the program and its path. Consenting to the bare word bin was free until this release put the program on PATH. --- src/bundle_path.rs | 139 +++++++++++++++++++++++++++ src/lib.rs | 1 + src/pluginview.rs | 58 ++++++++--- tests/bundle_path.rs | 224 +++++++++++++++++++++++++++++++++++++++++++ tests/glyphs.rs | 1 + tests/plugins.rs | 46 ++++++--- 6 files changed, 443 insertions(+), 26 deletions(-) create mode 100644 src/bundle_path.rs create mode 100644 tests/bundle_path.rs diff --git a/src/bundle_path.rs b/src/bundle_path.rs new file mode 100644 index 0000000..3933efc --- /dev/null +++ b/src/bundle_path.rs @@ -0,0 +1,139 @@ +//! Where a bundle's declared program goes, so that a run can find it. +//! +//! **io-harness places nothing, and says so in its own contract.** It parses a +//! `[[bin]]`, refuses one declared from a scope that may not contribute an +//! executing thing, validates the path lexically, and offers the result on +//! [`Plugin::bin`](io_harness::Plugin::bin) — an accessor it never calls itself. +//! Where a contributed binary goes is stated there as the host's decision, so the +//! whole of the mechanism is here. +//! +//! **Appended, never prepended.** A bundle is a stranger's code and `PATH` is +//! resolved by first match, so a prepended entry would let anything an operator +//! installed answer to `git`, `cargo` or `ls` for every tool call in the process +//! — including the calls io-harness makes on the model's behalf, whose permission +//! gate matches a *binary name* and would be satisfied by the wrong program under +//! the right name. Appended, a collision resolves to the system command and the +//! bundle's own program is unreachable under that name, which is the failure that +//! can be read rather than the one that cannot. +//! +//! **Nothing is created on disk.** The entry put on `PATH` is the directory the +//! declaring file already sits in, so resolution is by that file's own name. A +//! declaration whose `name` differs from its file is *reported* — see +//! [`mismatched`] — rather than made true by writing a link under the name a +//! stranger chose. Creating one is io-cli installing a program, which this +//! product's contract excludes. +//! +//! The three functions that decide anything are pure and take what they read, so +//! the order, the deduplication and the appending are all assertable without a +//! process environment. + +use std::collections::BTreeSet; +use std::ffi::{OsStr, OsString}; +use std::path::PathBuf; + +use io_harness::Plugins; + +/// The environment variable a program is looked up in. +pub const PATH_VAR: &str = "PATH"; + +/// The directories to append, in the order they will be appended. +/// +/// One entry per directory rather than one per declaration: two programs in one +/// `bin/` directory are one `PATH` entry, and a duplicate entry is a lookup +/// performed twice for the same answer. Sorted, so the value written is the same +/// on two runs over the same configuration — a `PATH` that reorders itself +/// between sessions makes a collision intermittent, which is the worst way for +/// one to present. +/// +/// Only loaded bundles contribute. A bundle declared `enabled = false` comes back +/// on [`Plugins::disabled`] as a fully parsed plugin contributing nothing, and +/// putting its programs on `PATH` would be the one contribution a disabled bundle +/// still made. +pub fn entries(plugins: &Plugins) -> Vec { + let mut dirs = BTreeSet::new(); + for plugin in plugins.iter() { + for (_, path) in plugin.bin() { + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() { + dirs.insert(parent.to_path_buf()); + } + } + } + } + dirs.into_iter().collect() +} + +/// The declarations whose `name` is not the name their file answers to. +/// +/// Returned as the declared name and the file's own name, for a surface to say +/// plainly. Nothing is placed under the declared name — appending a directory +/// puts a file on `PATH` under the name it already has — so a declaration that +/// renames its program does not resolve, and an operator reading `plugin list` +/// should be told that rather than left to discover it when the model reports +/// that a command does not exist. +pub fn mismatched(plugins: &Plugins) -> Vec<(String, String)> { + let mut out = Vec::new(); + for plugin in plugins.iter() { + for (declared, path) in plugin.bin() { + let actual = path + .file_name() + .map(OsStr::to_string_lossy) + .unwrap_or_default() + .to_string(); + if actual != declared { + out.push((declared.to_string(), actual)); + } + } + } + out +} + +/// `current` with `dirs` appended, skipping any already present. +/// +/// Returns `None` when there is nothing to add, so a caller can tell "no bundle +/// declares a program" from "the variable was rewritten with the same value" and +/// leave the environment alone in the first case. +/// +/// Idempotent by construction: an entry already on `PATH` is not appended a second +/// time, so re-resolving a session's bundles cannot grow the variable without +/// bound. That matters because the inventory is re-read at every turn boundary. +pub fn appended(current: Option<&OsStr>, dirs: &[PathBuf]) -> Option { + let existing: Vec = current + .map(|value| std::env::split_paths(value).collect()) + .unwrap_or_default(); + let mut all = existing; + let before = all.len(); + for dir in dirs { + if !all.contains(dir) { + all.push(dir.clone()); + } + } + if all.len() == before { + return None; + } + std::env::join_paths(all).ok() +} + +/// Put every loaded bundle's program directory on this process's `PATH`. +/// +/// The process's own variable and not a field on a contract, because that is +/// where io-harness looks: its executable resolver reads `PATH` out of the +/// environment and the commands it spawns inherit it. `src/home.rs` sets io-cli's +/// configuration home the same way and for the same reason. +/// +/// Returns the directories appended, for the caller to report, and an empty +/// vector when nothing was. +pub fn install(plugins: &Plugins) -> Vec { + let dirs = entries(plugins); + if dirs.is_empty() { + return Vec::new(); + } + let current = std::env::var_os(PATH_VAR); + match appended(current.as_deref(), &dirs) { + Some(value) => { + std::env::set_var(PATH_VAR, value); + dirs + } + None => Vec::new(), + } +} diff --git a/src/lib.rs b/src/lib.rs index 0236c6e..d8c6061 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -62,6 +62,7 @@ pub mod app; pub mod approval; pub mod attach; pub mod bridge; +pub mod bundle_path; pub mod cli; pub mod clipboard; pub mod commands; diff --git a/src/pluginview.rs b/src/pluginview.rs index 9983d9e..f3fdc12 100644 --- a/src/pluginview.rs +++ b/src/pluginview.rs @@ -2,15 +2,15 @@ //! one brought, and which ones io-harness refused to load. //! //! A plugin is a directory with a `plugin.toml` contributing skills, templates, -//! agents, MCP servers, hooks and deny-only policy layers, and io-cli has never -//! had a surface for one. It needs one for a reason the other management panels -//! do not: **every other capability an operator sees is one they put there.** A -//! skill file is theirs or io-cli's, an `[[mcp]]` entry is a line they wrote, a -//! policy layer came from a posture they chose. A bundle is none of those — it is -//! a directory somebody else wrote, named once by path, that then adds names to -//! four subsystems at once. The question this surface exists to answer is *what -//! did that directory put in my session*, and until now the only way to answer it -//! was to open the manifest. +//! agents, MCP servers, hooks, executables and deny-only policy layers, and +//! io-cli has never had a surface for one. It needs one for a reason the other +//! management panels do not: **every other capability an operator sees is one +//! they put there.** A skill file is theirs or io-cli's, an `[[mcp]]` entry is a +//! line they wrote, a policy layer came from a posture they chose. A bundle is +//! none of those — it is a directory somebody else wrote, named once by path, +//! that then adds names to four subsystems at once. The question this surface +//! exists to answer is *what did that directory put in my session*, and until now +//! the only way to answer it was to open the manifest. //! //! # A dropped bundle is the state this surface is really for //! @@ -38,9 +38,10 @@ //! rather than two: `Plugins::iter` is what loaded, `Plugins::dropped` is what was //! refused, and [`io_harness::Plugins::disabled`] is what was written //! `enabled = false` — read, parsed, held to the whole trust rule, and -//! contributing nothing to any of the six kinds. That is a third *state*, never a -//! second kind of failure: everything on `dropped` is something an operator has to -//! fix, and a switched-off bundle is doing exactly what the file asked of it. +//! contributing nothing to any of the seven kinds. That is a third *state*, +//! never a second kind of failure: everything on `dropped` is something an +//! operator has to fix, and a switched-off bundle is doing exactly what the file +//! asked of it. //! //! **It is listed, under its own mark, and it counts against [`View::is_empty`].** //! Before 0.29.0 [`view`] read `iter()` and `dropped()` and nothing else, so a @@ -206,7 +207,7 @@ pub struct Listed { /// The directory the manifest was read from. pub root: PathBuf, /// Which kinds of contribution it declared, in io-harness's own fixed order: - /// skills, templates, agents, mcp, hooks, policy. + /// skills, templates, agents, mcp, hooks, bin, policy. pub contributions: Vec<&'static str>, /// The skills directory it contributes, absolute, if it declared one. pub skills: Option, @@ -224,6 +225,18 @@ pub struct Listed { /// namespaces: a `[[hook]]` contributes no name, it names events, a path and /// an argv, and all three belong to the operator's tree. pub hooks: Vec<(String, String)>, + /// The executables it contributes, one `(declared name, path)` pair each. + /// + /// **The same argument as `hooks`, one contribution kind over.** `bin` is a + /// word that tells an operator nothing about what they are consenting to, and + /// from 0.34.0 consenting to it is consenting to a program that will be on the + /// run's `PATH` — so the pane names the program and where it lives, the way it + /// names a hook's event and its command. + /// + /// Not namespaced, and nothing io-harness namespaces: the declared name is + /// what a model or an operator would type, and `rust-review__review` is not a + /// name anyone types. See [`crate::bundle_path`] for what is done with it. + pub bin: Vec<(String, String)>, /// The policy layer names it contributes, already namespaced. Deny rules /// only — io-harness drops a bundle whose layer carries anything else. pub layers: Vec, @@ -373,6 +386,11 @@ pub fn copy_out(plugin: &io_harness::Plugin, enabled: bool) -> Listed { .map(|server| server.id.clone()) .collect(), hooks: plugin.hooks().iter().map(hook_line).collect(), + bin: plugin + .bin() + .into_iter() + .map(|(name, path)| (name.to_string(), path.display().to_string())) + .collect(), layers: plugin .policy_layers() .iter() @@ -712,6 +730,18 @@ pub fn detail(plugin: &Listed, width: u16, glyphs: &Glyphs) -> Vec { ) })); } + if !plugin.bin.is_empty() { + // **One row per executable, naming the program and the file.** The other + // contribution that runs a program, and until 0.34.0 nothing was done with + // it — so the bare word cost nothing. It costs something now. + out.push(Row::heading("executables")); + out.extend(plugin.bin.iter().map(|(name, path)| { + Row::with_detail( + name.clone(), + fit(path, room.saturating_sub(name.chars().count() + 2), glyphs), + ) + })); + } if !plugin.layers.is_empty() { out.push(Row::heading("policy layers, deny only")); out.extend( @@ -1181,6 +1211,7 @@ mod tests { agents: vec!["rust-review__reviewer".to_string()], servers: Vec::new(), hooks: vec![("tool_call".to_string(), "[cargo fmt]".to_string())], + bin: vec![("review".to_string(), "/x/bin/review.mjs".to_string())], layers: vec!["rust-review__no-secrets".to_string()], } } @@ -1538,6 +1569,7 @@ mod tests { agents: Vec::new(), servers: Vec::new(), hooks: Vec::new(), + bin: Vec::new(), layers: Vec::new(), }; let rows = detail(&plugin, 80, &ASCII); diff --git a/tests/bundle_path.rs b/tests/bundle_path.rs new file mode 100644 index 0000000..6d31bea --- /dev/null +++ b/tests/bundle_path.rs @@ -0,0 +1,224 @@ +//! F9 and F10 — where a bundle's declared program goes, and what happens to a +//! bundle that declares none. +//! +//! **io-harness places nothing.** It parses a `[[bin]]`, refuses one declared from +//! a scope that may not contribute an executing thing, validates the path +//! lexically, and offers the result on an accessor it never calls itself — its own +//! contract says outright that where a contributed binary goes is the host's +//! decision. So every assertion here is about io-cli's answer to a question +//! io-harness deliberately does not answer. +//! +//! The three functions that decide anything are pure and take what they read, so +//! the order, the deduplication and the appending are assertable without touching +//! a process environment. `install` is the one line that is not, and it is one +//! line for that reason. + +use std::ffi::OsString; +use std::path::{Path, PathBuf}; + +use io_cli::bundle_path; +use io_harness::config::{Config, LOCAL_FILE}; +use io_harness::PLUGIN_FILE; + +/// The entries this fixture's own bundles contributed. +/// +/// **`Config::discover` layers the machine's real user-scope file**, which on a +/// developer's machine declares bundles of its own — so an assertion of equality +/// against the whole list is green on a clean runner and red here, or the other +/// way round. Filtered to the temporary root, which is the only thing this test +/// put anything in. +fn ours(plugins: &io_harness::Plugins, root: &Path) -> Vec { + bundle_path::entries(plugins) + .into_iter() + .filter(|dir| dir.starts_with(root)) + .collect() +} + +/// A bundle declaring one executable whose declared name is the file's own name. +const SHIPS_A_PROGRAM: &str = r#" +name = "ultraship" +description = "Ships things." + +[[bin]] +name = "ultraship" +path = "bin/ultraship" +"#; + +/// And one whose declared name is not what the file answers to. +/// +/// The case the chosen mechanism cannot honour: appending a directory puts a file +/// on `PATH` under the name it already has, so a declaration that renames its +/// program does not resolve. Reported rather than made true by writing a link, +/// which would be io-cli installing a program. +const RENAMES_ITS_PROGRAM: &str = r#" +name = "caveman" +description = "Renames things." + +[[bin]] +name = "caveman" +path = "bin/cm.mjs" +"#; + +/// A bundle contributing something, and nothing that runs a program. +const NO_PROGRAM: &str = r#" +name = "quiet" +description = "Contributes no program." +skills = "skills" +"#; + +fn bundle(root: &Path, at: &str, manifest: &str) -> PathBuf { + let dir = root.join(at); + std::fs::create_dir_all(dir.join("skills")).expect("the skills directory"); + std::fs::create_dir_all(dir.join("bin")).expect("the bin directory"); + std::fs::write(dir.join(PLUGIN_FILE), manifest).expect("the manifest"); + dir +} + +/// The plugins a configuration declaring each of `bundles` loads. +/// +/// **Declared in the local-scope file, and that is not a detail.** io-harness +/// refuses a `[[bin]]` from a project-scoped `io.toml` — the file arrives with a +/// `git clone`, and an executable named in one is a program the machine would run +/// on somebody else's say-so — and the refusal drops the whole bundle. A fixture +/// written in project scope asserts over an empty vector while passing, which is +/// what the drop check below exists to catch. +fn loaded(root: &Path, bundles: &[(&str, &str)]) -> io_harness::Plugins { + let text: String = bundles + .iter() + .map(|(at, manifest)| { + bundle(root, at, manifest); + format!("[[plugin]]\npath = \"{at}\"\n\n") + }) + .collect(); + std::fs::write(root.join(LOCAL_FILE), text).expect("the configuration"); + let plugins = Config::discover(root) + .expect("the configuration loads") + .plugins(); + // **A dropped bundle contributes nothing and looks exactly like a bundle that + // declared nothing**, so a fixture that silently drops asserts nothing while + // passing. io-harness drops a bundle *whole* for a manifest it refuses — an + // executable declared from a scope that may not contribute one, or a path it + // will not accept — and this is where that becomes a readable failure rather + // than an empty vector. + let ours: Vec<(PathBuf, String)> = plugins + .dropped() + .iter() + .filter(|d| d.path.starts_with(root)) + .map(|d| (d.path.clone(), d.error.to_string())) + .collect(); + assert!( + ours.is_empty(), + "a fixture bundle was dropped, so this test would assert over nothing: {ours:?}", + ); + plugins +} + +/// **F9 — a declared program's own directory is what goes on the path.** +#[test] +fn f9_a_declared_program_puts_its_own_directory_on_the_path() { + let dir = tempfile::tempdir().expect("a temporary directory"); + let root = dir.path(); + let plugins = loaded(root, &[("ultraship", SHIPS_A_PROGRAM)]); + assert_eq!( + ours(&plugins, root), + vec![root.join("ultraship").join("bin")], + "the entry is the directory the declaring file sits in, so the program \ + is found under the name it already has", + ); + assert!( + !bundle_path::mismatched(&plugins) + .iter() + .any(|(declared, _)| declared == "ultraship"), + "the declared name is the file's own name, so there is nothing to report", + ); +} + +/// **F10 — and a bundle declaring no program contributes no entry.** +#[test] +fn f10_a_bundle_declaring_no_program_changes_nothing() { + let dir = tempfile::tempdir().expect("a temporary directory"); + let root = dir.path(); + let plugins = loaded(root, &[("quiet", NO_PROGRAM)]); + assert!( + ours(&plugins, root).is_empty(), + "a bundle that declares no executable must not touch the path", + ); +} + +/// **A declaration that renames its program is reported, not staged.** +/// +/// The honest failure. Appending a directory cannot honour a declared name that +/// differs from its file, and writing a link under the name a stranger chose is +/// the thing this product's contract excludes. So the operator is told, at +/// startup, rather than discovering it when the model reports that a command does +/// not exist. +#[test] +fn a_declared_name_that_is_not_the_files_name_is_reported() { + let dir = tempfile::tempdir().expect("a temporary directory"); + let root = dir.path(); + let plugins = loaded(root, &[("caveman", RENAMES_ITS_PROGRAM)]); + assert!( + bundle_path::mismatched(&plugins) + .contains(&("caveman".to_string(), "cm.mjs".to_string())), + "the declared name and the name the file actually answers to must both \ + be reported: {:?}", + bundle_path::mismatched(&plugins), + ); + // The directory still goes on the path: the program is reachable, under the + // name it has. Reporting the difference is not a reason to withhold it. + assert_eq!(ours(&plugins, root), vec![root.join("caveman").join("bin")]); +} + +/// **Appended, never prepended — the whole security argument in one assertion.** +/// +/// `PATH` resolves by first match, so a prepended entry would let anything an +/// operator installed answer to `git`, `cargo` or `ls` for every tool call in the +/// process, including the ones io-harness makes on the model's behalf — whose +/// permission gate matches a *binary name* and would be satisfied by the wrong +/// program under the right name. +#[test] +fn f10_entries_are_appended_so_a_bundle_cannot_shadow_a_system_command() { + let existing = OsString::from("/usr/bin:/bin"); + let added = PathBuf::from("/home/someone/.io-cli/plugins/x/bin"); + let joined = bundle_path::appended(Some(&existing), std::slice::from_ref(&added)) + .expect("an entry was added"); + let order: Vec = std::env::split_paths(&joined).collect(); + assert_eq!( + order, + vec![PathBuf::from("/usr/bin"), PathBuf::from("/bin"), added], + "a bundle's directory goes last, so a collision resolves to the system \ + command and never to the stranger's program", + ); +} + +/// **And appending is idempotent, because the inventory is re-read every turn.** +/// +/// A `PATH` that grows once per turn boundary is a variable that eventually stops +/// fitting in an environment block, and the growth would be invisible until it +/// did. +#[test] +fn appending_the_same_entry_twice_adds_it_once() { + let added = PathBuf::from("/opt/x/bin"); + let first = bundle_path::appended(Some(&OsString::from("/usr/bin")), std::slice::from_ref(&added)) + .expect("the first append adds it"); + assert!( + bundle_path::appended(Some(&first), std::slice::from_ref(&added)).is_none(), + "an entry already on the path is not appended again, and `None` says so \ + rather than rewriting the variable with the same value", + ); +} + +/// Two programs in one directory are one entry, and the order does not wander. +/// +/// A path that reorders itself between sessions makes a collision intermittent, +/// which is the worst way for one to present. +#[test] +fn two_programs_in_one_directory_are_one_entry() { + let dirs = vec![PathBuf::from("/a/bin"), PathBuf::from("/a/bin")]; + let joined = + bundle_path::appended(None, &dirs).expect("something is added to an empty path"); + assert_eq!( + std::env::split_paths(&joined).collect::>(), + vec![PathBuf::from("/a/bin")], + ); +} diff --git a/tests/glyphs.rs b/tests/glyphs.rs index 52ae749..ed36a6e 100644 --- a/tests/glyphs.rs +++ b/tests/glyphs.rs @@ -930,6 +930,7 @@ fn plugin_marks() -> Vec<&'static str> { agents: Vec::new(), servers: Vec::new(), hooks: Vec::new(), + bin: Vec::new(), layers: Vec::new(), }; let mut off = bundle( diff --git a/tests/plugins.rs b/tests/plugins.rs index 078cbfe..2a8a5fa 100644 --- a/tests/plugins.rs +++ b/tests/plugins.rs @@ -24,7 +24,7 @@ use io_harness::config::{Config, LOCAL_FILE, PROJECT_FILE}; use io_harness::PLUGIN_FILE; /// The manifest io-harness's own module docs open with: a bundle contributing -/// something of five of the six kinds, and nothing that runs a program. +/// something of four of the seven kinds, and nothing that runs a program. /// /// Written out here rather than assembled from parts, because it is the shape a /// bundle author copies out of the documentation and it has to keep loading. @@ -296,13 +296,19 @@ fn executing(kind: &str) -> String { let block = match kind { "hook" => "[[hook]]\non = [\"finished\"]\nrun = [\"touch\", \"ran\"]\n", "mcp" => "[[mcp]]\nid = \"docs\"\ntransport = \"stdio\"\ncommand = \"mcp-docs\"\n", + // io-harness 0.73.0's third one. The path is relative and stays inside + // the bundle, so what this fixture varies is the trust rule and never + // `check_bins` — a `[[bin]]` refused for its path would be dropped by + // both scopes and the pair of tests below would agree for the wrong + // reason. + "bin" => "[[bin]]\nname = \"review\"\npath = \"bin/review.mjs\"\n", other => panic!("no such contribution: {other}"), }; format!("name = \"runner\"\nskills = \"skills\"\n\n[[agent]]\nname = \"reviewer\"\n\n{block}") } /// **F3.** A bundle declared in the committed `io.toml` that contributes a -/// `[[hook]]` or an `[[mcp]]` is dropped **whole**. +/// `[[hook]]`, an `[[mcp]]` or a `[[bin]]` is dropped **whole**. /// /// The assertion is deliberately not "the hook was skipped". A half-applied /// stranger's manifest is the failure the rule exists to prevent: the bundle's @@ -310,14 +316,14 @@ fn executing(kind: &str) -> String { /// and a loader that kept them would be trusting the author it just refused. /// /// Sabotage: change `refuse_executing_contributions` from an error into a filter -/// that clears `manifest.hook` and `manifest.mcp` and lets the rest load — which is -/// the reading a reviewer would call the friendlier fix. Under it only this test -/// fails, and what ships is a `git clone` that installs an agent definition and a -/// skills directory of the cloned repository's choosing into every session run in -/// it. +/// that clears `manifest.hook`, `manifest.mcp` and `manifest.bin` and lets the +/// rest load — which is the reading a reviewer would call the friendlier fix. +/// Under it only this test fails, and what ships is a `git clone` that installs +/// an agent definition and a skills directory of the cloned repository's choosing +/// into every session run in it. #[test] fn f3_a_bundle_that_names_a_program_is_dropped_whole_from_the_committed_file() { - for kind in ["hook", "mcp"] { + for kind in ["hook", "mcp", "bin"] { let (_dir, root) = root(); bundle(&root, "runner", &executing(kind)); declaring(&root, PROJECT_FILE, &["runner"]); @@ -365,12 +371,12 @@ fn f3_a_bundle_that_names_a_program_is_dropped_whole_from_the_committed_file() { } /// **F3, the other half.** The same manifest declared in `io.local.toml` loads, -/// with all six kinds. +/// with all seven kinds. /// /// Without this the rule above is indistinguishable from "a bundle may never -/// contribute a hook or a server", which would make the feature useless rather -/// than safe — and a fix that refused everywhere would pass every assertion in the -/// test above. +/// contribute a hook, a server or an executable", which would make the feature +/// useless rather than safe — and a fix that refused everywhere would pass every +/// assertion in the test above. /// /// Sabotage: apply `refuse_executing_contributions` in every scope rather than /// under `if scope == Scope::Project`. Under it only this test fails, and the @@ -378,7 +384,7 @@ fn f3_a_bundle_that_names_a_program_is_dropped_whole_from_the_committed_file() { /// declared by anyone. #[test] fn f3_the_same_manifest_declared_locally_contributes_all_of_it() { - for kind in ["hook", "mcp"] { + for kind in ["hook", "mcp", "bin"] { let (_dir, root) = root(); bundle(&root, "runner", &executing(kind)); // The local file, which does not travel with a clone — and no `io.toml` at @@ -414,6 +420,18 @@ fn f3_the_same_manifest_declared_locally_contributes_all_of_it() { "the namespaced server is not on the contract: {:?}", contract.mcp.iter().map(|s| &s.id).collect::>(), ), + // **Not on the contract, and that is io-harness's decision.** A + // `[[bin]]` is handed back resolved and placed nowhere: io-harness + // puts nothing on a `PATH`, so what the local scope has to prove + // here is that the entry survived the load with its own name and a + // path inside the bundle, which is the pair a run needs to make it + // resolvable at all. + "bin" => assert_eq!( + plugin.bin(), + vec![("review", root.join("runner/bin/review.mjs"))], + "the bundle's executable did not come back resolved against its \ + own root", + ), _ => assert!( io_cli::contract::hooks(&config, &config.plugins(), &root).is_some(), "the bundle's hook is declared and nothing will run it", @@ -428,6 +446,8 @@ fn kind_name(kind: &str) -> &'static str { match kind { "hook" => "hooks", "mcp" => "mcp", + // The one kind whose array and whose reported name are the same word. + "bin" => "bin", other => panic!("no such contribution: {other}"), } } From 21b1f3304348c75529c019354e7f5e231bd0843f Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Mon, 31 Aug 2026 23:21:51 +0530 Subject: [PATCH 04/11] docs: the seventh contribution kind, the closed exceptions, and the new gate A bundle contributes seven kinds now, not six - six sentences said otherwise across the guides, the capabilities index, the example configuration and two source files. The plugins guide gains a section on the executable a bundle ships: the append-not-prepend reason, that no file is written, and the downgrade hazard - a manifest carrying a [[bin]] is refused whole by an older io-cli reading the same home, which drops every skill, agent, hook and layer in that bundle rather than the one entry. The skills guide gains read_skill's optional path, and the naming rule now states the loaded-skill row and that the echoed line and the sent prompt deliberately differ. --- AGENTS.md | 15 ++++++- CHANGELOG.md | 95 ++++++++++++++++++++++++++++++++++++++- README.md | 2 +- docs/CAPABILITIES.md | 2 +- docs/CONTRACT.md | 14 +++++- docs/config.example.toml | 11 +++-- docs/guide/plugins.md | 91 +++++++++++++++++++++++++++++++------ docs/guide/skills.md | 33 +++++++++++--- docs/guide/the-session.md | 11 +++-- src/marketplace.rs | 2 +- tests/marketplace.rs | 2 +- 11 files changed, 245 insertions(+), 33 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 23f95b6..760a5f6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,7 +70,7 @@ the only dev-dependency. Adding one is a deliberate, argued act — read the com - **No agent loop.** The sweep strips comments first, so a doc comment containing "while" is not a finding; `src/provider.rs` is exempted by path and held to four properties of its own. -**Module map (`src/`, 75 files).** +**Module map (`src/`, 79 files).** - Session surface — `app.rs` (state), `term.rs` (an inline viewport, never the alternate screen), `events.rs` + `triage.rs` (what each io-harness event does; a kind with no @@ -89,7 +89,8 @@ the only dev-dependency. Adding one is a deliberate, argued act — read the com - Store — `store.rs`, `export.rs`, `undo.rs`, `rewind.rs`, `sessions.rs`. - Bundles — `skills.rs`, `skillview.rs`, `plugin`-facing `pluginview`, `marketplace.rs`, `fetch.rs`, `import.rs`, `adapt.rs` (the three manifest formats io did not invent, read; and the - `plugin.toml` io generates for one, written and never parsed). + `plugin.toml` io generates for one, written and never parsed), `bundle_path.rs` (where a + declared `[[bin]]` goes, appended and creating no file — io-harness places nothing itself). - Media and git — `picture.rs`, `attach.rs`, `repo.rs`, `commit.rs`. **Nothing under `tests/` links `src/main.rs`.** A guard written in the driver cannot be tested @@ -130,6 +131,16 @@ tense, no diary (history goes in `CHANGELOG.md`; a version number in a sentence not a story). Name the reason for a non-obvious decision where the decision is, once. No first person, no "powerful"/"robust"/"simply". +**A bundle's name is translated where it is drawn, never renamed.** +`io_harness::NAMESPACE` is `__` and it is load-bearing on the wire — the system prompt, the tool +dispatch and every event `target` carry it — so `naming::display` is applied at the moment a name +reaches a person and `naming::wire` to a name they typed. `tests/namespacing.rs` walks the drawn +output of six operator-facing surfaces (the transcript, the status line, the pickers, and the +plugin, skill and marketplace panes) and fails if the separator appears in any of it, so a new +surface that draws a contributed name inherits the rule instead of rediscovering it. Translate the +qualifier only: a path is not a name, and `read src/__init__.py` drawn through `display` is a file +that does not exist. + **Comments carry the argument.** This codebase keeps long "why" comments — `Cargo.toml`'s dependency notes, `tests/dependencies.rs`'s exemption rationale. Match that density; do not strip them. A gate that reads prose can forbid a file from explaining itself: `tests/dependencies.rs` diff --git a/CHANGELOG.md b/CHANGELOG.md index cf9570e..852a054 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,98 @@ All notable changes to this project are documented here. The format follows ## [Unreleased] +## [0.34.0] - 2026-08-31 + +0.32.0 made io-harness's `__` a wire detail: a bundle's contribution is drawn with +a colon and translated back at the keyboard. It left three places where the +separator still reached a person — the sentence io-harness writes about a skill +read, the repetition guard that stopped recognising that sentence, and every MCP +tool call, drawn as the raw `mcp__github__create_issue`. This release closes all +three and puts a gate under the rule, so the next surface to draw a contributed +name cannot reintroduce the underscore quietly. + +The pin moves to io-harness 0.73.0, which adds a seventh kind of contribution. A +bundle can ship an executable, and until now it contributed it to nothing: a model +could not tell *not installed* from *installed somewhere I may not look*. io-cli +makes it resolvable by appending the file's own directory to the run's `PATH` — +appended, so nothing a bundle ships can shadow a system command — and creates no +file to do it. + +### Added + +**A bundle's `[[bin]]` is invocable inside a run.** io-harness 0.73.0 accepts the +key, checks that the path stays inside the bundle, and places nothing itself; io +appends the directory holding the declared file to its own `PATH`, which every +command a run spawns inherits. Appended and never prepended: a prepended directory +lets anything a bundle ships answer to `git`, `cargo` or `ls` for every tool call +in the session, and the permission gate that would stop it matches a binary +*name*, which the wrong program under the right name satisfies. One entry per +directory, sorted, and none from a bundle switched off. No file is written and +nothing is linked, so the program resolves under the name it already has on disk — +a `[[bin]]` whose `name` is not that name is named at startup and does not +resolve, rather than being papered over with a wrapper io would then own inside +somebody else's directory. A bundle declared in the committed `io.toml` that +carries one is refused whole, exactly as one carrying a `[[hook]]` or an `[[mcp]]` +is. + +**A skill can read the files beside it without a shell.** `read_skill` takes an +optional `path` on io-harness 0.73.0, so a bundle's `shared/*.md` and +`references/*` reach the model through the same tool and the same policy as the +skill body. Such a call is drawn with its path intact and untranslated: the +qualifier takes the colon and the path is left exactly as it was asked for. + +**`tests/namespacing.rs` walks six operator-facing surfaces** — the transcript, +the status line, the pickers, and the plugin, skill and marketplace panes — and +fails if `io_harness::NAMESPACE` appears in the output any of them draws. The rule +had three exceptions and no gate, which is how it had three exceptions. + +### Changed + +**A skill read is drawn as a skill loaded**, `Skill ultraship:plan · loaded`, and +io-harness's own decision sentence is not drawn for that tool. That sentence names +the skill on the wire, and the row above it already says what the call did — so +this removes the separator at its source rather than translating a sentence +io-cli did not write. A read that fails or is refused still says so, in +io-harness's words. + +**An MCP tool call reads `Call github:create_issue`.** Its wire name is assembled +from a prefix, the server id and io-harness's separator, so +`mcp__github__create_issue` was a string nobody wrote and nobody reads. + +**Invoking a bundle's skill echoes what you typed.** `/ultraship:brainstorm …` +goes into the scrollback as you wrote it, colon and all, while the prompt behind +it carries `ultraship__brainstorm` — the string the model's catalogue holds and +the only one `read_skill` resolves. The two differ deliberately: a transcript that +rewrote the line you typed would be the only place in the session that does. + +**A bundle contributes seven kinds of thing, not six.** `Plugin::contributions()` +reports `bin` after `hooks` and before `policy`, and the pane, the guide and the +README say seven. + +### Fixed + +**The repetition guard stopped recognising io-harness's decision sentence.** A +pending call kept only the displayed target, so `trim_result` compared a sentence +naming the wire name against a call naming the colon form and printed both. The +pending call now keeps io-harness's own target beside the drawn one, which repairs +it at the cause rather than by matching two spellings at the comparison. + +### Dependencies + +- io-harness 0.72 → **0.73.0**. `Plugin::contributions()` gains `bin`, a manifest + gains `[[bin]]`, and `read_skill` gains an optional `path`. The direct dependency + set is unchanged at ten names. + +### Upgrading + +- **A `plugin.toml` carrying a `[[bin]]` cannot be read by an io-cli built against + io-harness 0.72.0 or earlier.** `Manifest` is `deny_unknown_fields`, so that + binary refuses the manifest **whole** and drops the bundle entirely — every + skill, agent, hook and layer in it, not just the `[[bin]]`. This matters where + two versions of io read one home: once io has written a `[[bin]]` into a manifest + it generates under `~/.io-cli`, the older binary sees the bundle as gone rather + than as shortened. Remove the `[[bin]]` tables before downgrading. + ## [0.33.0] - 2026-08-31 io-harness 0.72.0 lets an agent ask several things at once and lets a choice carry @@ -2977,7 +3069,8 @@ client, tool, sandbox, policy engine or session store of its own. - There is no crates.io publish and `cargo install` is not an install path. - No test in this release asserts on wall-clock time. -[Unreleased]: https://github.com/initorigin/io-cli/compare/v0.33.0...HEAD +[Unreleased]: https://github.com/initorigin/io-cli/compare/v0.34.0...HEAD +[0.34.0]: https://github.com/initorigin/io-cli/compare/v0.33.0...v0.34.0 [0.33.0]: https://github.com/initorigin/io-cli/compare/v0.32.0...v0.33.0 [0.32.0]: https://github.com/initorigin/io-cli/compare/v0.31.0...v0.32.0 [0.31.0]: https://github.com/initorigin/io-cli/compare/v0.30.2...v0.31.0 diff --git a/README.md b/README.md index ae3e8ed..e979e66 100644 --- a/README.md +++ b/README.md @@ -146,7 +146,7 @@ the limits that capability actually has. | [When a run stops for you](docs/guide/resume.md) | A question, a plan or an interrupted call answered where it was left — and one `io` at a time on one conversation | | [What it costs](docs/guide/accounting.md) | The money and the token split, where a price comes from, and why an unpriced model makes a total a floor | | [Skills](docs/guide/skills.md) | The five shipped, yours, and what a duplicate name costs | -| [Capability bundles](docs/guide/plugins.md) | A directory contributing to six subsystems, marketplaces, installing a Claude Code or Codex plugin, and what a bundle is allowed to do being shown before it may do it | +| [Capability bundles](docs/guide/plugins.md) | A directory contributing to seven subsystems, the executable one of them ships and where a run finds it, marketplaces, installing a Claude Code or Codex plugin, and what a bundle is allowed to do being shown before it may do it | | [Hooks](docs/guide/hooks.md) | Reacting to a run from `io.toml`, and the failure that is quiet | | [The fleet](docs/guide/fleet.md) | Contained turns, the tree `Ctrl+F` shows, and the plan a contained turn proposes | | [Verification gates](docs/guide/verification.md) | The criterion, what it costs per step, and exit `6` | diff --git a/docs/CAPABILITIES.md b/docs/CAPABILITIES.md index cc1afb0..c51f255 100644 --- a/docs/CAPABILITIES.md +++ b/docs/CAPABILITIES.md @@ -26,7 +26,7 @@ the limits that capability actually has. | [When a run stops for you](guide/resume.md) | A question, a plan or an interrupted call decided where it was left; the turn an interrupt makes unresumable; and one `io` at a time on one conversation | | [What it costs](guide/accounting.md) | Money and the token split by run, session, model and day; where a price comes from; and why an unpriced model makes a total a floor | | [Skills](guide/skills.md) | The five this crate ships, adding and removing your own, the claimed name being the frontmatter name, and what a duplicate costs | -| [Capability bundles](guide/plugins.md) | A directory contributing to six subsystems at once, marketplaces, installing a Claude Code or Codex plugin, and the disclosure that happens with your configuration file untouched | +| [Capability bundles](guide/plugins.md) | A directory contributing to seven subsystems at once, marketplaces, installing a Claude Code or Codex plugin, the executable a bundle ships, and the disclosure that happens with your configuration file untouched | | [Hooks](guide/hooks.md) | An audit log, a notification, a formatter or a check that stops the run, declared in `io.toml` — and the failure that is quiet | | [The fleet](guide/fleet.md) | Contained turns spawning children under one shared ceiling, the live tree, and the plan a contained turn proposes before it acts | | [Verification gates](guide/verification.md) | The criterion, the fact that it is evaluated after every step rather than once per turn, per-gate retry, and exit `6` | diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index c0fc9ad..026c666 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -174,7 +174,19 @@ together. **A bundle's contributions are displayed with a colon and addressed with io-harness's separator.** `ultraship:brainstorm` is what you read; `ultraship__brainstorm` is what the model was shown and what `read_skill` resolves. `io exec`, `io plugin` and `io skill` report the wire name, because a -script addresses the wire. +script addresses the wire. From 0.34.0 the separator reaches no operator-facing surface at all — +including an MCP tool call, drawn `Call github:create_issue` — and a gate walks six of those +surfaces and fails on `io_harness::NAMESPACE` appearing in any of them. Invoking a bundle's skill +as a command is the one place the two forms are side by side on purpose: the scrollback echoes the +line you typed, `/ultraship:brainstorm …`, and the prompt behind it carries `ultraship__brainstorm`. + +**A bundle's `[[bin]]` is resolvable inside a run and nowhere else.** io-harness 0.73.0 accepts the +key, resolves the path against the bundle root and places nothing; io-cli **appends** the directory +holding that file to its own process `PATH`, which every command a run spawns inherits. Appended +and never prepended, so nothing a bundle ships can shadow a system command. No file is written and +nothing is linked, so the program resolves under the name it already has on disk — a `[[bin]]` +whose `name` is not that name is reported at startup and does not resolve. Your shell's `PATH` is +untouched, and a bundle switched off contributes no entry. ## What may change diff --git a/docs/config.example.toml b/docs/config.example.toml index 682073e..7680960 100644 --- a/docs/config.example.toml +++ b/docs/config.example.toml @@ -387,9 +387,14 @@ net = "deny" # exactly the `[[plugin]]` entry below. The clone is a `git` invocation; there # is no HTTP client here and no network path beside io-harness's. # -# One directory can contribute six kinds of thing at once — skills, prompt -# templates, [[agent]] definitions, [[mcp]] servers, [[hook]] tables and policy -# layers — which is why there is a surface for it. `/plugin` lists THREE sets: +# One directory can contribute seven kinds of thing at once — skills, prompt +# templates, [[agent]] definitions, [[mcp]] servers, [[hook]] tables, [[bin]] +# executables and policy layers — which is why there is a surface for it. A +# [[bin]] is io-harness 0.73.0's, and io places it by appending the declaring +# file's own directory to the run's PATH: appended so nothing a bundle ships can +# shadow a system command, and no file is written, so the program answers to the +# name it already has rather than to the name the manifest gave it. +# `/plugin` lists THREE sets: # what loaded and what each bundle contributed; what was declared `enabled = # false`, under its own mark, with what switching it back on would bring; and # every bundle that was declared and DROPPED with io-harness's own reason beside diff --git a/docs/guide/plugins.md b/docs/guide/plugins.md index f9b7f51..239cf27 100644 --- a/docs/guide/plugins.md +++ b/docs/guide/plugins.md @@ -15,14 +15,15 @@ There is still no registry either; from 0.29.0 there are [marketplaces](#marketplaces), which are repositories you name and clone, and installing out of one writes exactly the entry above. -One directory can hand over six kinds of thing at once: skills, prompt templates, -`[[agent]]` definitions for a fan-out to draw children from, `[[mcp]]` servers, -`[[hook]]` tables, and policy layers. That breadth is why `/plugin` exists. -Every other capability in a session is one you put there — a skill file is yours -or io-cli's, an `[[mcp]]` entry is a line you wrote, a policy layer came from a -posture you chose. A bundle is a directory somebody else wrote that adds names to -six subsystems on one line, and *what did that directory put in my session* is a -question whose only previous answer was to open the manifest. +One directory can hand over seven kinds of thing at once: skills, prompt +templates, `[[agent]]` definitions for a fan-out to draw children from, +`[[mcp]]` servers, `[[hook]]` tables, `[[bin]]` executables, and policy layers. +That breadth is why `/plugin` exists. Every other capability in a session is one +you put there — a skill file is yours or io-cli's, an `[[mcp]]` entry is a line +you wrote, a policy layer came from a posture you chose. A bundle is a directory +somebody else wrote that adds names to seven subsystems on one line, and *what +did that directory put in my session* is a question whose only previous answer +was to open the manifest. **`/plugin` answers it, and it answers the dropped ones too.** One row per bundle with its id, its root and what it contributed; choosing one opens what it brought, @@ -78,11 +79,12 @@ something you type more comfortably into your own file than into a picker. **Which file declared a bundle decides what it may contribute.** A bundle named in the project-scoped `io.toml` — the file a `git clone` delivers — may contribute skills, templates, agents and policy, and may **not** contribute -hooks or MCP servers, because both of those run a program on this machine. A -project-scoped bundle that tries is refused **whole**: it contributes nothing at -all, not the half that would have been safe. Move the `[[plugin]]` line into -`io.local.toml` or into your user file and the same directory loads completely. -The rule is about which file names it, exactly as it is for `[browser]`. +hooks, MCP servers or executables, because each of those three names a program +this machine would run. A project-scoped bundle that tries is refused **whole**: +it contributes nothing at all, not the half that would have been safe. Move the +`[[plugin]]` line into `io.local.toml` or into your user file and the same +directory loads completely. The rule is about which file names it, exactly as it +is for `[browser]`. **A bundle's policy may only narrow.** Its layers may deny and may never allow: a `[policy] defaults` table in a manifest is refused by name, and a single rule @@ -101,6 +103,15 @@ io-harness keeps its own separator on the wire. That is a translation at the edg and not a third spelling: `io plugin list` and `io exec` report the underscore form, because a script addresses the wire. +**From 0.34.0 the translation has no exceptions left.** Three places still put +`__` in front of a person: the cell for a skill read, the sentence io-harness +writes about that read, and an MCP tool call, which was drawn as the raw +`mcp__github__create_issue` and now reads `Call github:create_issue`. A gate +walks the drawn output of the transcript, the status line, the pickers and the +plugin, skill and marketplace panes and fails if the separator appears in any of +it, so the next surface to draw a bundle's name cannot reintroduce the underscore +quietly. + **Hooks are named, everywhere, from 0.30.0.** Each one gets a row: the event it fires on, or the tool call it sits in front of, and the command it runs. This is the contribution kind that runs programs, so it is the one you most need itemised @@ -116,6 +127,58 @@ parsed rather than what io-cli made of the same bytes — and it sees two shapes the hand-written reader could not: an inline `hook = [{…}]` array, and a `[[hook]]` header with a comment after it. +## An executable a bundle ships + +**A bundle can carry a program, and from 0.34.0 a run can find it.** io-harness +0.73.0 adds a `[[bin]]` table naming one: + +```toml +[[bin]] +name = "review" +path = "bin/review" +``` + +`path` is relative to the bundle and may be neither absolute nor a climb out of +it with `..`; a manifest that tries is refused whole. A bundle contributes an +executable it ships, not one it points at somewhere else on your machine. + +**io-harness places nothing and says so in its own contract.** What it hands back +is the declared name and the path resolved against the bundle root. Making that +program invocable is io-cli's, and it is one edit: the directory the declared +file sits in is **appended** to io's own `PATH`, which every command a run spawns +inherits. Appended, never prepended — a prepended directory lets anything a +bundle ships answer to `git`, `cargo` or `ls` for every tool call in the session, +and the permission gate that would stop it matches a binary *name*, which the +wrong program under the right name satisfies. Appended, a collision resolves to +the system command and the bundle's program is simply unreachable under that +name: the failure you can read rather than the one you cannot. Your shell's own +`PATH` is untouched. + +One entry per directory, sorted, and only from bundles that are switched on — a +bundle written `enabled = false` is parsed in full and contributes nothing, and +its programs would otherwise be the one thing it still contributed. + +**io creates no file.** The program resolves under the name it already has on +disk, so a `[[bin]]` whose `name` is not that name is reported at startup and +does not resolve: `name = "review"` against `path = "bin/review.mjs"` puts the +directory on the path and leaves a program that answers to `review.mjs`. Nothing +is written to close that gap — a wrapper or a link io wrote inside somebody +else's bundle is a file io would then own in a directory it did not install, and +io-cli installing a program is outside what this product does. Renaming it is the +bundle author's edit to make. + +**A `[[bin]]` names a program, so the project-scope rule covers it.** A bundle +declared in the committed `io.toml` that carries one is refused whole, exactly as +one carrying a `[[hook]]` or an `[[mcp]]` is. + +**And writing one costs an older binary the whole bundle.** An io-cli built +against io-harness 0.72.0 or earlier does not know the key, and a manifest is +`deny_unknown_fields` — so a `plugin.toml` carrying a `[[bin]]` is refused +entirely by that binary, taking every skill, agent, hook and layer in the bundle +with it. That matters for the manifests io generates for a Claude Code or Codex +bundle under `~/.io-cli/adapters/`, because two versions of io reading one home +is the ordinary case during a downgrade. + ## Marketplaces **A marketplace is a git repository you name.** It is cloned into your own home @@ -218,7 +281,7 @@ it deletes anything. ### What a bundle is allowed to do is shown before it is allowed to do it -A bundle contributes to six subsystems at once, and until 0.29.0 every one you +A bundle contributes to seven subsystems at once, and until 0.29.0 every one you declared came from a directory you had read. A marketplace removes that reading, so the install puts it back. diff --git a/docs/guide/skills.md b/docs/guide/skills.md index 5d56248..e8dcfa5 100644 --- a/docs/guide/skills.md +++ b/docs/guide/skills.md @@ -78,6 +78,15 @@ io-harness's own `read_skill` tool, under this session's policy, like any other read. So a skill costs the prompt one line until it is relevant, and it is subject to the same boundary as everything else in the session. +**A skill can also read the files beside it.** On io-harness 0.73.0 `read_skill` +takes an optional `path`, so a skill whose bundle ships `shared/style.md` or a +`references/` directory reaches them through the same tool and the same policy — +where before the only route was a shell command, which is a program run for what +is a read. Such a call is drawn with its path intact: the qualifier is +translated to the colon form and the path is not touched at all, because a path +is the bundle author's spelling and a translation applied to one is how +`src/__init__.py` becomes a file that does not exist. + **An upgrade refreshes what nobody touched and leaves the rest alone.** io-cli records the bytes it last wrote for each shipped skill; a file that still matches gets the new text, and a file you have edited is kept exactly as it is @@ -162,15 +171,29 @@ load-bearing: it is what goes into the model's own catalogue, and `read_skill` resolves it by exact match, so the model can only ask for the string it was shown. **Since 0.32.0 you read `ultraship:brainstorm` instead.** The `/skills` list, the -`/` palette, the `Read skill` line in your scrollback, `/plugin`'s inventory and -`/status`'s policy rows all draw the colon form, and typing `/ultraship:brainstorm` -runs it. Nothing about the wire changed: io translates at the two edges — once on -the way to your screen, once on the way back from your keyboard — and io-harness -never sees a colon. +`/` palette, the line in your scrollback, `/plugin`'s inventory and `/status`'s +policy rows all draw the colon form, and typing `/ultraship:brainstorm` runs it. +Nothing about the wire changed: io translates at the two edges — once on the way +to your screen, once on the way back from your keyboard — and io-harness never +sees a colon. `io exec`, `io plugin` and `io skill` report the underscore form, because a script reads their output and a script addresses the wire. +**A skill being read is drawn as a skill being loaded**, `Skill ultraship:plan · +loaded`, and io-harness's own sentence about that call is not drawn beside it. +That sentence names the skill on the wire, so it was the one place the separator +reached you without passing through the translation — and the row above already +says what the call did. A read that fails or is refused still says so, in +io-harness's words: what is dropped is the decision, never the outcome. + +**What you typed and what the model was sent deliberately differ.** Running +`/ultraship:brainstorm draft the release note` echoes your own line into the +scrollback, colon and all, because a transcript that rewrote what you typed would +be the only place in the session that does. The prompt behind it carries +`ultraship__brainstorm`, which is the string the model's catalogue holds and the +only string `read_skill` will resolve. + **Choosing a skill from the palette now writes a command rather than a sentence.** It used to put `use the skill: ` into the composer, which submits as an ordinary prompt — so whether the skill actually ran depended on how the model read diff --git a/docs/guide/the-session.md b/docs/guide/the-session.md index b6b413a..e257984 100644 --- a/docs/guide/the-session.md +++ b/docs/guide/the-session.md @@ -57,9 +57,14 @@ turn spent, on both rows. What lands in the scrollback is designed rather than defaulted. A tool call reads as a verb and a workspace-relative path — `Read src/lib.rs`, not `read_file` and an absolute path — and a tool this release has never seen keeps the name -io-harness sent, because a verb invented for it would mean nothing. A turn ends -on its answer: the run's step and token counts are on the status line beside the -provider, which is where every other number in this interface lives. +io-harness sent, because a verb invented for it would mean nothing. **An MCP tool +is the exception from 0.34.0**: its wire name is assembled out of a prefix, the +server and io-harness's own separator, so `mcp__github__create_issue` is a string +nobody wrote and nobody reads. It is drawn `Call github:create_issue` — the +server and the tool, under the same colon every other contributed name is drawn +with. A turn ends on its answer: the run's step and token counts are on the +status line beside the provider, which is where every other number in this +interface lives. **Three more things reach the transcript in 0.14.0**, all of them facts io-harness has been emitting into every ordinary session and this interface has diff --git a/src/marketplace.rs b/src/marketplace.rs index 64a5692..9e47b52 100644 --- a/src/marketplace.rs +++ b/src/marketplace.rs @@ -80,7 +80,7 @@ //! # A marketplace install discloses before it writes, and io-harness does the reading //! //! A bundle out of a marketplace is a directory **nobody on this machine has -//! read**, and it contributes to six subsystems at once. Every other bundle io-cli +//! read**, and it contributes to seven subsystems at once. Every other bundle io-cli //! declares came from a directory the operator typed the path of; this one came //! from a stranger's repository, and [`Chosen`] is where the two part. //! diff --git a/tests/marketplace.rs b/tests/marketplace.rs index 0dc00fa..abcf73d 100644 --- a/tests/marketplace.rs +++ b/tests/marketplace.rs @@ -1249,7 +1249,7 @@ fn nothing_out_of_a_stranger_s_manifest_reaches_a_surface_unfiltered() { // --- F6, F16, F17 and F20: disclosure before anything is written --------------- // -// A marketplace bundle is code from a stranger that contributes to six subsystems +// A marketplace bundle is code from a stranger that contributes to seven subsystems // at once. Through 0.29.0 io-harness published no loader that took a directory — // `load_one` was private and `Plugins::load` was `pub(crate)` — so the only way to // have a bundle read, parsed, validated and trust-checked was to *declare* it: the From b61135997d8dcd00cac2dcaf4accfd7016d8a603 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Mon, 31 Aug 2026 23:25:09 +0530 Subject: [PATCH 05/11] docs: re-verify all 24 io-harness source citations against 0.73.0 Re-read, not version-bumped. The two trees were diffed first: of the eleven harness files cited, three changed, and four citations in adapt.rs shift by the lines inserted above them - each confirmed by landing on the target rather than by arithmetic. plugin.rs:311 still joins the plugin root, so the absolute-path argument holds; plugin.rs:841 still refuses substitutions in every scope and still takes the whole bundle. Every claim re-checked against the code rather than assumed: the three step and retry defaults, the five widening pairs, the three substitution forms, the two MCP transports, and that Act::Exec still matches a binary name alone. exec.rs also claimed 'Still true at 0.72.0'. That is a currency claim rather than a citation, so no gate would have caught it and a bump would have left it asserting currency for a version this crate no longer pins. --- src/adapt.rs | 12 ++++++------ src/commands.rs | 4 ++-- src/configure.rs | 14 +++++++------- src/contract.rs | 4 ++-- src/home.rs | 2 +- src/import.rs | 2 +- src/manage.rs | 2 +- src/memory.rs | 4 ++-- src/reload.rs | 2 +- 9 files changed, 23 insertions(+), 23 deletions(-) diff --git a/src/adapt.rs b/src/adapt.rs index 1a0e6ae..b87fca9 100644 --- a/src/adapt.rs +++ b/src/adapt.rs @@ -64,7 +64,7 @@ //! instead, and every path inside it is **absolute** and points back into the //! clone. That is not a convenience: //! [`Plugin::skills_dir`](io_harness::Plugin::skills_dir) is `self.root.join(d)` -//! (`io-harness-0.72.0/src/plugin.rs:268`), and `Path::join` of an absolute +//! (`io-harness-0.73.0/src/plugin.rs:311`), and `Path::join` of an absolute //! argument discards the base — so an absolute `skills` is the one spelling that //! reaches a directory outside the manifest's own root. //! @@ -367,7 +367,7 @@ pub const SKILLS_DIR: &str = "skills"; /// io-harness calls the same thing a **template**, and the two formats are the /// same file: markdown, optional frontmatter, `$ARGUMENTS` for what the operator /// typed. `Templates::discover` takes every `*.md` in a directory -/// (`io-harness-0.72.0/src/template.rs:279`), so what is translated is the +/// (`io-harness-0.73.0/src/template.rs:279`), so what is translated is the /// directory and never the files in it — nothing is copied and nothing is /// rewritten. pub const COMMANDS_DIR: &str = "commands"; @@ -437,7 +437,7 @@ pub fn at(root: &Path, owner: &str, repo: &str, name: &str) -> PathBuf { /// `agents/*.md` becomes an `[[agent]]`, and each server in `.mcp.json` becomes an /// `[[mcp]]`. Every path written is **absolute** and points into the clone: /// `Plugin::skills_dir` is `self.root.join(d)` -/// (`io-harness-0.72.0/src/plugin.rs:268`) and `Path::join` of an absolute +/// (`io-harness-0.73.0/src/plugin.rs:311`) and `Path::join` of an absolute /// argument discards the base, so an absolute path is what lets a manifest in /// io's home name a directory in somebody else's checkout. /// @@ -543,7 +543,7 @@ pub fn generate(bundle: &Path, name: &str, into: &Path) -> Result Option { /// adapted, so expanding it reads nothing of the host — it writes down a path io /// already knew. `${env:…}`, `${file:…}`, `${cmd:…}` and every shell variable a /// bundle writes are the opposite: io-harness refuses each of them inside a -/// manifest in every scope (`io-harness-0.72.0/src/plugin.rs:774`), because a +/// manifest in every scope (`io-harness-0.73.0/src/plugin.rs:841`), because a /// bundle is a third party's directory even when the file naming it is the /// operator's own. /// @@ -921,7 +921,7 @@ fn unquoted(said: &str) -> &str { /// /// **A transport io-harness does not speak is a refusal naming the server**, not /// a server quietly left out. io-harness speaks stdio and streamable HTTP -/// (`io-harness-0.72.0/src/mcp.rs:289`), so a bundle declaring `sse` is declaring +/// (`io-harness-0.73.0/src/mcp.rs:289`), so a bundle declaring `sse` is declaring /// a capability no adapter can deliver — and a manifest carrying one fewer server /// than the bundle does is exactly the silent absence this module's docs exist to /// end. The whole generation stops, because a bundle is installed for what it diff --git a/src/commands.rs b/src/commands.rs index fc51db6..9bcb64c 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1431,7 +1431,7 @@ pub const READ_MARK: &str = "+"; /// The case this page exists for. A file that is there and is not read looks, /// from everywhere else in this product, exactly like one that is read: nothing /// warns, and a missing or skipped instruction file is passed over in silence -/// (`read_instructions`, `io-harness-0.72.0/src/config.rs:2111`). +/// (`read_instructions`, `io-harness-0.73.0/src/config.rs:2111`). pub const UNREAD_MARK: &str = "-"; /// What one row of the memory page stands for. @@ -1647,7 +1647,7 @@ pub fn memory_notes(view: &crate::recall::View, glyphs: &crate::glyphs::Glyphs) /// writing: a file that exists and is not read. It names what would make it /// read rather than only reporting the fact, because `[instructions] files` /// **replaces** the default list rather than adding to it -/// (`DEFAULT_INSTRUCTIONS`, `io-harness-0.72.0/src/config.rs:159`, read at +/// (`DEFAULT_INSTRUCTIONS`, `io-harness-0.73.0/src/config.rs:159`, read at /// `:2120`) and there is nothing on any /// other surface to pull on. pub fn instruction_said( diff --git a/src/configure.rs b/src/configure.rs index ce39584..45d9beb 100644 --- a/src/configure.rs +++ b/src/configure.rs @@ -255,7 +255,7 @@ pub enum Kind { /// /// **Both halves are the dependency's since io-harness 0.71.0, and neither is /// written here any more**: the list is `Effect::ALL` -/// (`io-harness-0.72.0/src/policy.rs:127`) and each spelling is `Effect::as_str` +/// (`io-harness-0.73.0/src/policy.rs:127`) and each spelling is `Effect::as_str` /// (`:143`), which is the word io-harness's own deserializer reads. /// /// Until this release io-cli held a copy of both — an array naming three variants @@ -282,7 +282,7 @@ pub fn effects() -> Vec { /// The `ExecMode` variants, spelled by io-harness itself. /// -/// **The list is `ExecMode::ALL` (`io-harness-0.72.0/src/sandbox.rs:424`) and the +/// **The list is `ExecMode::ALL` (`io-harness-0.73.0/src/sandbox.rs:424`) and the /// spellings are `ExecMode::as_str` (`:431`).** io-cli wrote the variant list out /// by hand until this release for a reason that was the dependency's and not a /// choice made here: `ExecMode` is `#[non_exhaustive]` (`sandbox.rs:378-381`), and @@ -386,7 +386,7 @@ pub fn kind_of(key: &str) -> Option { /// preference — but half of the old reason is now false and the correction is /// worth writing down.** io-harness 0.71.0 names its own defaults: /// `DEFAULT_MAX_STEPS` = 8, `DEFAULT_WORKSPACE_MAX_STEPS` = 12 and -/// `DEFAULT_MAX_RETRIES` = 2 (`io-harness-0.72.0/src/contract.rs:652,670,686`), +/// `DEFAULT_MAX_RETRIES` = 2 (`io-harness-0.73.0/src/contract.rs:652,670,686`), /// re-exported at the crate root. "There is nothing to read" was true when this /// was written and is not true now. What is still true is that none of it anchors /// *this* ladder: @@ -537,7 +537,7 @@ pub fn shape_of(key: &str, config: &Config) -> Option { /// The models `[prices.models]` names, across every scope, sorted and deduplicated. /// /// **Read from the dependency's own table since io-harness 0.71.0, not scraped -/// out of the files.** `PriceTable::models` (`io-harness-0.72.0/src/pricing.rs:268`) +/// out of the files.** `PriceTable::models` (`io-harness-0.73.0/src/pricing.rs:268`) /// lists every model the table can actually price, and [`Config::prices`] has /// always built that table out of the three scopes — so the merged question this /// used to hand-roll is precisely the one the accessor answers, and the gap filed @@ -563,7 +563,7 @@ pub fn shape_of(key: &str, config: &Config) -> Option { /// /// **This takes the `Config` the caller already holds, and must never re-discover /// one.** `Config::discover` resolves every `${env:}`, `${file:}` and `${cmd:}` as -/// it reads (`io-harness-0.72.0/src/config.rs:517`), so a second discovery re-runs +/// it reads (`io-harness-0.73.0/src/config.rs:517`), so a second discovery re-runs /// an operator's credential commands — which for a `${cmd:}` fetching a key out of /// a keychain means a Touch-ID prompt raised in order to draw a menu, every time /// the picker opens. Taking a `&Config` is not an optimisation; it is the @@ -603,7 +603,7 @@ pub fn destination(config: &Config, key: &str) -> (Scope, bool) { /// Whether writing `value` to `key` would be refused in a **project-scoped** file. /// /// io-harness refuses five (key, value) pairs in a committed `io.toml` -/// (`PROJECT_WIDENING`, `io-harness-0.72.0/src/config.rs:1998-2008`): the two acts +/// (`PROJECT_WIDENING`, `io-harness-0.73.0/src/config.rs:1998-2008`): the two acts /// defaulted to `allow`, egress re-opened inside the sandbox, the portable floor /// switched off, and the widest exec mode. The narrowing value of each stays legal, /// which is what the scope is for. @@ -753,7 +753,7 @@ fn is_credential(path: &str) -> bool { /// /// **There are three substitution forms and not two.** io-harness resolves /// `${env:...}`, `${file:...}` **and** `${cmd:...}` -/// (`substitute`, `io-harness-0.72.0/src/config.rs:2150`, the `cmd` arm at +/// (`substitute`, `io-harness-0.73.0/src/config.rs:2150`, the `cmd` arm at /// `:2241`); this comment claimed two until /// 0.21.0, and the sentence it claimed it in was the argument for which forms /// pass through here. The third is deliberately not one of them: a `${env:}` or diff --git a/src/contract.rs b/src/contract.rs index 4cd1f92..43538da 100644 --- a/src/contract.rs +++ b/src/contract.rs @@ -535,7 +535,7 @@ pub fn skills_dir(config: &Config, capabilities: &Capabilities, root: PathBuf) - /// **The existence test is not caution, it is the whole of what makes this /// default safe.** `Skills::discover` does not return early on a directory that /// is not there — it returns `Error::Config("skills directory … does not exist")` -/// (`io-harness-0.72.0/src/skills.rs`), and `TaskContract::discover_skills` +/// (`io-harness-0.73.0/src/skills.rs`), and `TaskContract::discover_skills` /// propagates it from `run.rs` at run start, before the first completion. A /// contract that named this directory unconditionally would therefore fail every /// turn of every operator who has never made one, which is almost all of them. @@ -560,7 +560,7 @@ fn default_skills() -> Option { /// /// **One expansion for two keys, applied after both have had their say.** /// io-harness substitutes `${env:…}`, `${file:…}` and `${cmd:…}` and nothing else -/// (`substitute`, `io-harness-0.72.0/src/config.rs:2150` — there is no tilde +/// (`substitute`, `io-harness-0.73.0/src/config.rs:2150` — there is no tilde /// branch anywhere in it, and 0.71.0 narrowed the forms rather than widening /// them: a plugin manifest now refuses all three), so a `~` an operator wrote in /// `[run] skills` or `[app.io-cli] skills` diff --git a/src/home.rs b/src/home.rs index 3f9db21..2efdb20 100644 --- a/src/home.rs +++ b/src/home.rs @@ -9,7 +9,7 @@ //! This module names one. It is deliberately **not** a second configuration //! system: io-harness resolves `$IO_CONFIG`, then `$IO_CONFIG_HOME`, then the //! platform's own place, reading the environment at call time -//! (`io-harness-0.72.0/src/config.rs:1891`, the Windows branch at `:1910`), and +//! (`io-harness-0.73.0/src/config.rs:1891`, the Windows branch at `:1910`), and //! there is no caller-supplied home //! anywhere in `Config`'s public surface — no `discover_in`, no builder. So the //! one lever is `$IO_CONFIG_HOME`, set once, before the first diff --git a/src/import.rs b/src/import.rs index c2a1dd6..c28e5bf 100644 --- a/src/import.rs +++ b/src/import.rs @@ -30,7 +30,7 @@ //! decision="allow")` and Claude's `permissions.ask` says `Bash(cargo yank *)` — //! both of which match a *command line*. io-harness's `Act::Exec` matches a //! **binary name and nothing else**; it has no argument matching at all -//! (`Act::Exec`, `io-harness-0.72.0/src/policy.rs:63`). So the nearest thing to a faithful +//! (`Act::Exec`, `io-harness-0.73.0/src/policy.rs:63`). So the nearest thing to a faithful //! import of `bun install` is a blanket allow on `bun`, which is a wider //! permission than the operator ever granted, written by a tool they were //! trusting to be careful. A boundary half-imported is worse than one left alone, diff --git a/src/manage.rs b/src/manage.rs index f58608b..486e988 100644 --- a/src/manage.rs +++ b/src/manage.rs @@ -1372,7 +1372,7 @@ fn mcp_add(args: &Args) -> Result { // Built by hand rather than through `McpServer::stdio(…).with_args(…)`, // because `env` and `headers` have no builder at all and `with_args` is a - // silent no-op on an HTTP server (`io-harness-0.72.0/src/mcp.rs:439-447`: the + // silent no-op on an HTTP server (`io-harness-0.73.0/src/mcp.rs:439-447`: the // body writes only into the `Stdio` arm) — a constructor chain here would drop // the arguments of half the servers it was handed and say nothing. // Asked of the harness rather than written as literals, the way `servers::add` diff --git a/src/memory.rs b/src/memory.rs index 39bce26..a9b837e 100644 --- a/src/memory.rs +++ b/src/memory.rs @@ -27,7 +27,7 @@ //! //! Worth knowing before a surface promises otherwise. `read_instructions` joins //! each name in `[instructions] files` to the **discovery root** -//! (`io-harness-0.72.0/src/config.rs:2111`), and the default list is exactly +//! (`io-harness-0.73.0/src/config.rs:2111`), and the default list is exactly //! `["AGENTS.md"]` (`DEFAULT_INSTRUCTIONS`, `config.rs:159`). So `AGENTS.md` is read by every project //! with no configuration at all; `AGENTS.local.md` is read only where a file //! names it; and `IO.md` is not reachable by a bare name at all, because a @@ -464,7 +464,7 @@ fn bullet(line: &str) -> Option { /// The order the three files are named in, and it is the order they reach the /// model in: `read_instructions` pushes one constraint per name in list order -/// (`read_instructions`, `io-harness-0.72.0/src/config.rs:2111`). +/// (`read_instructions`, `io-harness-0.73.0/src/config.rs:2111`). /// /// Project, then local, then user — widest audience first, so the operator's own /// standing note is the last thing said. It also matches the order the three diff --git a/src/reload.rs b/src/reload.rs index 0441e27..da01a2e 100644 --- a/src/reload.rs +++ b/src/reload.rs @@ -2,7 +2,7 @@ //! //! **io-harness takes the instruction files as a snapshot and never takes //! another.** `read_instructions` runs inside `Config::discover` -//! (`io-harness-0.72.0/src/config.rs:2111`), composes each named file into a +//! (`io-harness-0.73.0/src/config.rs:2111`), composes each named file into a //! constraint, and stores the result in a private `Config.instructions` field. //! There is no `Config::reload`, and `Config::with_profile` clones the field //! rather than going back to disk — so a `Config` is exactly as old as the call From 5792d739caf23235ba09912536bacbc1c634f2f9 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Mon, 31 Aug 2026 23:25:54 +0530 Subject: [PATCH 06/11] fix(plugins): place a bundle's programs on the headless doors too The placement went on the interactive session's startup, and io exec and io resume return from main hundreds of lines before that - so neither headless door placed anything. The two-entry-point asymmetry, again. Found by the live gate and only by it: the model reported that io-cli-probe was not on PATH, then found it by walking the workspace and ran it anyway. A check grepping for the program's own output would have said PASS over a placement that never happened. Four steps before the fix, two after. install_for lives in exec.rs rather than the driver because tests link the library and not main.rs, and f9_every_door_that_reaches_a_run_places_a_bundles _programs counts the sites - a contains is satisfied forever by whichever door was written first, which is exactly how this shipped. Also gates F5: set_echo is called at the two sites that submit a skill under its catalogue name, each beside the submission it belongs to. --- src/bundle_path.rs | 31 +++++++++++++ src/exec.rs | 16 ++++++- src/main.rs | 6 +-- tests/dependencies.rs | 104 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 150 insertions(+), 7 deletions(-) diff --git a/src/bundle_path.rs b/src/bundle_path.rs index 3933efc..f182aaa 100644 --- a/src/bundle_path.rs +++ b/src/bundle_path.rs @@ -114,6 +114,37 @@ pub fn appended(current: Option<&OsStr>, dirs: &[PathBuf]) -> Option { std::env::join_paths(all).ok() } +/// Resolve `config`'s bundles and place their programs, reporting any mismatch. +/// +/// **The headless doors' way in, and they need their own because they leave +/// before the session's does.** `io exec` and `io resume` return from `main` long +/// before the interactive path resolves anything, so a placement written once on +/// the session's startup reaches neither — which is this product's two-entry-point +/// asymmetry, and it was caught here by a live run rather than by the suite: the +/// model found the program by walking the workspace and reported honestly that it +/// was *not* on `PATH`, so a check that grepped for the program's own output +/// would have passed over a placement that never happened. +pub fn install_for(config: &io_harness::config::Config) -> Vec { + let holdings = crate::resolved::Resolved::load(config); + let notices = mismatched(holdings.loaded()) + .into_iter() + .map(|(declared, actual)| mismatch_notice(&declared, &actual)) + .collect(); + install(holdings.loaded()); + notices +} + +/// What an operator is told when a declaration renames its own program. +/// +/// One sentence, written once, so the session and the two headless doors say the +/// same thing about the same manifest. +pub fn mismatch_notice(declared: &str, actual: &str) -> String { + format!( + "a bundle declares the program {declared} but ships it as {actual} — io puts the \ + directory on the path and writes nothing, so it answers to {actual} and not to {declared}", + ) +} + /// Put every loaded bundle's program directory on this process's `PATH`. /// /// The process's own variable and not a field on a contract, because that is diff --git a/src/exec.rs b/src/exec.rs index e04c668..517d29f 100644 --- a/src/exec.rs +++ b/src/exec.rs @@ -608,6 +608,13 @@ pub async fn main( // a refused posture costs nothing and leaves no run behind. let posture = args.policy.map(posture_for).transpose()?; + // **A bundle's own program, placed here as well as on the session's startup.** + // This door returns from `main` before the interactive path resolves + // anything, so the placement written there reaches neither headless door. + for notice in crate::bundle_path::install_for(&config) { + eprintln!("{notice}"); + } + let spec = spec_for(args.provider, &config, model_override.as_deref())?; let store = settings::store_path().ok_or("no place to keep the run store")?; let store = Store::open(&store).map_err(|error| error.to_string())?; @@ -1146,6 +1153,11 @@ pub async fn resume_main( // no store, no session and no provider. let posture = args.policy.map(posture_for).transpose()?; + // The second headless door, and it resumes runs that execute things too. + for notice in crate::bundle_path::install_for(&config) { + eprintln!("{notice}"); + } + let path = settings::store_path().ok_or("no place to keep the run store")?; let store = Store::open(&path).map_err(|error| error.to_string())?; @@ -1281,11 +1293,11 @@ impl WithProvider for Resuming { // refuses a contained run outright because io-harness publishes no // tree-aware recovery entry point to keep those limits with. // - // **Still true at 0.72.0, and the shape of the gap is worth naming.** Every + // **Still true at 0.73.0, and the shape of the gap is worth naming.** Every // other pause kind has both forms — `resume_tree_with_answer` beside // `resume_with_answer`, `resume_tree_with_plan_decision` beside its flat // one, `resume_tree_with_decision` beside `resume_with_decision` - // (`io-harness-0.72.0/src/run.rs:1770`, `:2089`, `:3003`). Recovery has + // (`io-harness-0.73.0/src/run.rs:1770`, `:2089`, `:3003`). Recovery has // `resume_with_recovery_observed` (`:2551`) and nothing tree-aware, so it // is the one pause a contained run cannot be resumed from. Not an oversight // this crate can route around: a fleet's shared ceiling lives in the tree diff --git a/src/main.rs b/src/main.rs index 5c74e2c..d38651a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -429,11 +429,7 @@ async fn drive( // Appended and never prepended, and nothing is written to disk — see // `src/bundle_path.rs` for both reasons. for (declared, actual) in io_cli::bundle_path::mismatched(holdings.loaded()) { - notices.push(format!( - "a bundle declares the program {declared} but ships it as {actual} — io puts the \ - directory on the path and writes nothing, so it answers to {actual} and not to \ - {declared}", - )); + notices.push(io_cli::bundle_path::mismatch_notice(&declared, &actual)); } io_cli::bundle_path::install(holdings.loaded()); let bundles = bundle_skills(holdings.loaded()); diff --git a/tests/dependencies.rs b/tests/dependencies.rs index 167a43a..24265b1 100644 --- a/tests/dependencies.rs +++ b/tests/dependencies.rs @@ -1892,3 +1892,107 @@ fn n6_no_shipped_page_states_a_harness_bound_as_io_clis_own() { ); } } + +/// **F9 — every door that can run a program places a bundle's programs.** +/// +/// **Written because the release shipped this defect and a live run found it.** +/// The placement went on the interactive session's startup, and `io exec` and +/// `io resume` return from `main` hundreds of lines before that — so neither +/// headless door placed anything. The live arm caught it only because the model +/// said so in words: it found the program by walking the workspace and reported +/// that it was *not* on `PATH`. A check grepping for the program's own output +/// would have passed over a placement that never happened. +/// +/// Counted, never `contains`. Three doors reach a run, and a `contains` is +/// satisfied forever by whichever one was written first — which is precisely how +/// this was shipped. +#[test] +fn f9_every_door_that_reaches_a_run_places_a_bundles_programs() { + let mut sites: Vec<(PathBuf, usize)> = Vec::new(); + for (path, text) in sources() { + let code = code_of(&text); + let calls = code.matches("bundle_path::install").count(); + if calls > 0 { + sites.push((path, calls)); + } + } + sites.sort(); + + let src = spawning_modules() + .first() + .map(|p| p.parent().expect("src/").to_path_buf()) + .expect("a module to take the directory from"); + assert_eq!( + sites, + vec![ + // `install_for` twice: `exec::main` and `exec::resume_main`. Both are + // in the library, so unlike the driver's they can be reached from a + // test at all. + (src.join("exec.rs"), 2), + // And the interactive session's own, which already holds a resolved + // set and so calls `install` rather than resolving a second time. + (src.join("main.rs"), 1), + ], + "every entry point that can reach a run places a bundle's programs, and \ + a door that stops doing it fails here rather than in a session where a \ + command silently is not found", + ); +} + +/// **F5 — the echo is set where, and only where, the submitted prompt is not the +/// typed text.** +/// +/// The driver is the only place that knows a submission was a skill invocation, +/// and `src/main.rs` is linked by no test binary — so this criterion has no +/// runnable site at all and a source-text gate is what it gets instead. Said +/// plainly rather than dressed up as a behavioural test. +/// +/// **Counted, never `contains`.** Two doors submit a skill by its catalogue name — +/// the qualified word typed at the prompt, and the palette's own `Action::Skill` — +/// and a `contains` is satisfied forever by either one of them. Losing the echo on +/// the second door is exactly the shape this product has shipped before: correct +/// through the surface the test used, wrong through the one it did not. +#[test] +fn f5_every_submission_that_rewrites_the_prompt_also_carries_the_typed_text() { + let driver = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/main.rs"); + let mut callers = Vec::new(); + for (path, text) in sources() { + let code = code_of(&text); + // `.set_echo(` and not `set_echo(`: the second also matches the method's + // own definition, which would make the module that declares it look like + // a caller of it. + let calls = code.matches(".set_echo(").count(); + if calls > 0 { + callers.push((path, calls, code)); + } + } + callers.sort_by(|a, b| a.0.cmp(&b.0)); + + let sites: Vec<(PathBuf, usize)> = callers + .iter() + .map(|(path, calls, _)| (path.clone(), *calls)) + .collect(); + assert_eq!( + sites, + vec![(driver.clone(), 2)], + "the echo is carried by the driver and by nothing else, at exactly the two \ + sites that submit a skill under its catalogue name. A third site is a \ + third place the two strings can drift apart; a second is what the palette \ + door needs and what a `contains` would never have asked for.", + ); + + // And each call sits with the submission it belongs to: a `Command::Submit` + // carrying `wire`, which is the catalogue name and the only string + // `read_skill` resolves. An echo set anywhere else would show the operator + // text that was never sent. + let code = &callers[0].2; + for (index, _) in code.match_indices("set_echo(") { + let window = &code[index..code.len().min(index + 320)]; + assert!( + window.contains("Command::Submit(") && window.contains("wire"), + "a `set_echo` at byte {index} is not beside a submission of the \ + catalogue name, so the echoed text and the sent prompt are not the \ + pair this criterion is about", + ); + } +} From c4e9b7a206c81aa6d57e81805f6bdbefc520f245 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Mon, 31 Aug 2026 23:28:13 +0530 Subject: [PATCH 07/11] test(naming): gate the rule over drawn output, on all six surfaces Seven tests over what the transcript, the status line, the pickers and the plugin, skill and marketplace panes actually draw. The gate asserts an untranslated namespaced name - the literal for an id the fixture declared - and not the absence of the separator. A plugin id cannot contain __ (io-harness's check_id), so the first occurrence is always the join and everything after it is the contribution's own name: bundle__deep__nested is correctly drawn bundle:deep__nested and keeps one. Under the blanket spelling, writing display with replace instead of split_once reads as a fix, which is how 0.32.0 shipped its path defect. Every surface gets both halves - a contributed name translated, and a path or a doubly-separated name surviving intact - and the wire spelling is built from io_harness::NAMESPACE rather than from crate::naming, so the gate does not measure the translation against itself. AGENTS.md described the blanket rule and then contradicted it a sentence later; it now states what the gate actually asserts. --- AGENTS.md | 11 +- tests/namespacing.rs | 761 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 768 insertions(+), 4 deletions(-) create mode 100644 tests/namespacing.rs diff --git a/AGENTS.md b/AGENTS.md index 760a5f6..c634b56 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -136,10 +136,13 @@ person, no "powerful"/"robust"/"simply". dispatch and every event `target` carry it — so `naming::display` is applied at the moment a name reaches a person and `naming::wire` to a name they typed. `tests/namespacing.rs` walks the drawn output of six operator-facing surfaces (the transcript, the status line, the pickers, and the -plugin, skill and marketplace panes) and fails if the separator appears in any of it, so a new -surface that draws a contributed name inherits the rule instead of rediscovering it. Translate the -qualifier only: a path is not a name, and `read src/__init__.py` drawn through `display` is a file -that does not exist. +plugin, skill and marketplace panes) and fails when one of them draws a name still in the wire +spelling, so a new surface that draws a contributed name inherits the rule instead of rediscovering +it. **Not "the separator appears nowhere"** — that is a different and wrong property, and the +release that set this rule shipped the defect proving it. Translate the qualifier only: a path is +not a name, and `read src/__init__.py` drawn through `display` is a file that does not exist; and +only the FIRST separator is the join, so `bundle__deep__nested` is drawn `bundle:deep__nested` and +keeps one. A gate spelled as the blanket rule reads `display` written with `replace` as a fix. **Comments carry the argument.** This codebase keeps long "why" comments — `Cargo.toml`'s dependency notes, `tests/dependencies.rs`'s exemption rationale. Match that density; do not strip diff --git a/tests/namespacing.rs b/tests/namespacing.rs new file mode 100644 index 0000000..541867e --- /dev/null +++ b/tests/namespacing.rs @@ -0,0 +1,761 @@ +//! F7 — the rendering gate. No operator-facing surface draws a name still +//! carrying io-harness's own namespace separator. +//! +//! `io_harness::NAMESPACE` is `__`. It is the join io-harness builds a bundle's +//! contributions with — `ultraship__brainstorm` — and it is load-bearing on the +//! wire: the system prompt, the tool dispatch and every event `target` carry it. +//! `crate::naming::display` translates it to `:` at the moment a name is drawn, +//! and `crate::naming::wire` translates back for a name an operator typed. This +//! file walks the **drawn output** of the six operator-facing surfaces and fails +//! when one of them draws the wire spelling. +//! +//! # What is asserted, and why it is not "no `__` anywhere" +//! +//! **Definition. An untranslated namespaced name is the literal string +//! ``, for a bundle id the fixture +//! declared.** That, and only that, is the leak. A blanket "the output contains +//! no `__`" would be a *different and wrong* property, and 0.32.0 shipped the +//! defect that proves it — twice over: +//! +//! - **A path is not a name.** `src/__init__.py`, `__pycache__`, `__tests__`, +//! `__mocks__`, `__snapshots__` are ordinary files and directories, and they +//! reach the transcript, the skill pane, the plugin pane and the status page as +//! themselves. 0.32.0 translated every `read_skill` target and drew +//! `read src/:init__.py` — a path that does not exist, on the one surface an +//! operator checks to see what the agent touched. +//! - **Only the FIRST separator is the join.** A plugin id is +//! `[a-z0-9][a-z0-9-]{0,31}` (io-harness's `check_id`), so it can never contain +//! `__` and the first occurrence is always the join. Everything after it is the +//! contribution's own name and is kept byte for byte: +//! `bundle__deep__nested` is drawn `bundle:deep__nested`, which contains `__` +//! and is correct. +//! +//! So each surface is fed both halves and both are asserted: +//! +//! 1. a contributed name is drawn in its translated spelling and **never** in its +//! wire spelling — this is the half that fails when a surface stops +//! translating; +//! 2. a legitimate `__` — a file path, and a contribution whose own name carries +//! the separator — survives **intact** — this is the half that fails when +//! somebody repairs (1) by translating everything, which is the actual bug +//! 0.32.0 shipped and the reason a `!contains("__")` gate must not be written +//! here. +//! +//! The `bundle__deep__nested` fixture carries both halves in one assertion: the +//! drawn form is compared for equality against `bundle:deep__nested`, which is +//! false for the untranslated `bundle__deep__nested` and equally false for the +//! over-translated `bundle:deep:nested`. +//! +//! **The wire spelling is built out of `io_harness::NAMESPACE` and never out of +//! `crate::naming`**, so the gate does not measure the translation against +//! itself — the vacuity `AGENTS.md` records this repository having shipped three +//! times. +//! +//! Every assertion is over a value a surface *returned* — a `Line`'s spans +//! concatenated, a `Row`'s label and detail, a `String` a live row composed. +//! Nothing here reads source text: a source-text gate is satisfiable by a +//! comment. +//! +//! **The sabotage arm for every surface is written out at the foot of this +//! file**, one concrete source edit each — file, function and the exact change. +//! It is a `//` block rather than `//!` because an inner doc comment may only +//! precede the first item, and the arms belong beside the assertions they kill. +//! +//! `tests/marketplace.rs`'s F6 and `src/pluginview.rs`'s +//! `the_detail_view_groups_what_a_bundle_contributed` already assert this +//! property on two surfaces. This file extends their shape to the other four and +//! adds the over-translation half none of them had. + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use io_cli::events::Events; +use io_cli::glyphs::{ASCII, UNICODE}; +use io_cli::picker::Picker; +use io_cli::status::Status; +use io_cli::theme::DARK; +use io_cli::{commands, marketplace, pluginview, skillview, status}; +use io_harness::config::Scope; +use io_harness::{EventKind, RunEvent, Templates, MCP_TOOL_PREFIX, NAMESPACE}; + +// --- what a name is, in both spellings ---------------------------------------- + +/// One name a bundle contributed, in the two spellings that matter. +/// +/// The wire form is assembled from `io_harness::NAMESPACE` here rather than +/// asked of `crate::naming::wire`, and the drawn form is written out rather than +/// asked of `crate::naming::display`: a gate that built both operands out of the +/// code under test would pass for any translation at all, including none. +struct Name { + /// The bundle's id. Never contains `__` — io-harness's `check_id` forbids it, + /// which is what makes "the first occurrence is the join" true. + bundle: &'static str, + /// The contribution's own name, which **may** contain `__` and is kept. + own: &'static str, +} + +impl Name { + /// `__` — what io-harness wrote, and what must never be drawn. + fn wire(&self) -> String { + format!("{}{NAMESPACE}{}", self.bundle, self.own) + } + + /// `:` — what the operator must read, with `own` untouched. + fn drawn(&self) -> String { + format!("{}:{}", self.bundle, self.own) + } +} + +/// A bundle skill with an ordinary name. +const BRAINSTORM: Name = Name { + bundle: "ultraship", + own: "brainstorm", +}; + +/// The one from the live session that opened this release. +const USING: Name = Name { + bundle: "ultraship", + own: "using-ultraship", +}; + +/// **The over-translation fixture.** Its own name carries the separator, so only +/// the first occurrence may be rewritten. `bundle__deep__nested` drawn as +/// `bundle:deep:nested` is a name nothing resolves. +const DEEP: Name = Name { + bundle: "bundle", + own: "deep__nested", +}; + +/// An MCP tool, after `MCP_TOOL_PREFIX` has been stripped. The prefix itself ends +/// with the separator, so it is removed *before* translating — translating the +/// whole name splits at the prefix's own join and yields `mcp:github__create_issue`. +const MCP_TOOL: Name = Name { + bundle: "github", + own: "create_issue", +}; + +/// A path with a legitimate double underscore. The Python case 0.32.0 paid for. +const PY_PATH: &str = "references/__init__.py"; + +/// A directory with one, used as an ancestor of a bundle root so that a real +/// io-harness read produces a drawn path carrying `__`. +const JS_DIR: &str = "__snapshots__"; + +// --- the two halves, as assertions -------------------------------------------- + +/// The translated spelling is drawn and the wire spelling is not. +fn translated(drawn: &str, name: &Name, surface: &str) { + assert!( + drawn.contains(&name.drawn()), + "{surface}: {} is not drawn as {}, which is the name the operator reads \ + everywhere else: {drawn:?}", + name.wire(), + name.drawn(), + ); + assert!( + !drawn.contains(&name.wire()), + "{surface}: io-harness's own separator reached a person in {}: {drawn:?}", + name.wire(), + ); +} + +/// A double underscore that is not a join survives byte for byte. +fn survives(drawn: &str, literal: &str, surface: &str) { + assert!( + drawn.contains(literal), + "{surface}: {literal:?} is a path, not a namespaced name, and translating \ + it draws something that does not exist: {drawn:?}", + ); +} + +// --- transcript ---------------------------------------------------------------- + +fn flatten(lines: Vec>) -> String { + lines + .iter() + .flat_map(|line| line.spans.iter()) + .map(|span| span.content.as_ref()) + .collect() +} + +fn call(name: &str, target: &str) -> RunEvent { + RunEvent::new( + 1, + 1, + EventKind::ToolCall { + name: name.into(), + target: target.into(), + }, + ) +} + +fn step(decision: &str, tool_call: &str) -> RunEvent { + RunEvent::new( + 1, + 1, + EventKind::Step { + decision: decision.into(), + tool_call: tool_call.into(), + tokens: 12, + changed: false, + }, + ) +} + +/// The cell one announced-then-finished tool call commits. +fn cell(tool: &str, target: &str, decision: &str) -> String { + let mut events = Events::new(DARK); + events.event(&call(tool, target), Duration::ZERO); + flatten(events.event(&step(decision, tool), Duration::from_millis(400))) +} + +/// **The transcript.** `Events::event` is the seam; the drawn value is the +/// `Vec` it returns, and the live row `Events::live` composes from the same +/// pending call. +/// +/// Four sites in one surface: the committed `read_skill` cell (`skill_and_file`), +/// the live row that has to guess from the target's shape (`names_a_skill`), the +/// MCP cell whose prefix is stripped before the translation, and the `Started` +/// row that draws what the operator typed rather than the wire prompt. +#[test] +fn f7_the_transcript_translates_a_contributed_name_and_leaves_a_path_alone() { + // The committed cell. io-harness's decision sentence is `read skill