diff --git a/CHANGELOG.md b/CHANGELOG.md index f6f5d18..3196006 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,81 @@ All notable changes to this project are documented here. The format follows ## [Unreleased] +## [0.38.2] - 2026-09-06 + +The rest of what the 2026-09-05 field test found, and the io-harness pin that turned out to carry +two of its answers. + +`io resume --list` showed thirteen parked runs as thirteen rows differing only by a run id and a +step number, which is a listing nobody can choose from. It carries each run's start time and its +goal now. io-harness published a reader for neither until 0.81.0; both arrived there, which is why +this was named as carried when 0.38.2 was outlined and is delivered here instead. + +`io plugin search` for something that is not there printed nothing at all, which reads exactly like +a broken command. `io mcp disable` warned about io-harness versions the operator never installed +and could not check. `io setup` over an existing 36 KB configuration announced a first run — and +the comment beside that fix named a test holding the driver to it that had never been written, so +the feature was one deleted line from reverting in silence. + +The pin moves 0.79 → 0.81.0. Its two additions to the `Provider` trait default to `None`, so every +wrapper in this crate compiled clean, passed every test, and answered for itself instead of for the +provider it wraps. Nothing reported it and nothing could have; there is a gate now that reads the +method list out of the locked harness rather than out of a list somebody wrote down. + +Delegating them does not move the number yet, and the release says so rather than implying +otherwise. In io-harness 0.81.0 only `Compatible` implements those two methods, and only from a +catalogue that has to be fetched on the same instance first — so on OpenRouter, Anthropic and +OpenAI the run still assembles under the 24,000-token fallback. That is upstream's to answer and it +has been filed. What this release fixes is that the wrapper is no longer the thing losing it. + +### Added + +- `io resume --list` carries each run's start time and goal on the plain row, and `goal`, + `started_at` and `root` on the `--json` object. A run started before io-harness 0.7.0 has no + stamp in the store and renders `unknown` rather than a date. +- `io plugin search ` with no match says so — in the session, and on **stderr** from the argv + door, so a pipeline still reads exactly the hits and nothing else. + +### Changed + +- `ctx N%`, the `/context` page and `/status` all divide by the ceiling the run announces + (`EventKind::ContextCeiling`, new in io-harness 0.81.0) rather than by the budget on the contract + io-cli built. **On the providers this release can construct, that is the same 24,000 it was**: + in io-harness 0.81.0 only `Compatible` answers `context_window`, and only from a catalogue + `models()` has already fetched on that same instance, so the harness reports `source: "fallback"` + and the number does not move. The plumbing is connected end to end and gated; what it waits on is + a provider that answers. Nothing you see changes yet, and that is stated rather than implied. +- **Behaviour change for scripts.** The plain rows of `io resume --list` are wider — a script + slicing them by column position sees new fields. The `--json` shape is the one this product asks + scripts to parse, and its three new keys are nullable, so an existing reader is unaffected. They + are not appended: `serde_json` emits an object in key order, so `goal` now precedes `run_id`. + Anything parsing JSON is fine; anything matching the line by prefix is not. +- **Behaviour change for scripts.** `io config get` on a key that no file sets and that io-cli's + catalogue does not name answers `no such key` where it answered `default`. A misspelt key used to + be indistinguishable from an unset one, which is the whole reason to ask. +- The two "older binary" warnings on `[[mcp]]` and `[[plugin]]` `enabled` keys name io-cli 0.29.0 — + the release that first understood the key — instead of io-harness 0.69.0 and 0.70.0. Same + boundary, stated in the version an operator can actually check, and it no longer goes stale every + time the pin moves. + +### Fixed + +- All four of this crate's `Provider` wrappers delegate `context_window` and `max_output_tokens`. + Both are new in io-harness 0.81.0 with `None` defaults, so without this the whole tree compiled + and tested green while discarding the window the provider catalogue already held. +- `io setup` over an existing configuration names the file it is about to write over, and the + driver's call is now held to it by a test — the one `src/wizard.rs` had been citing by name. +- A guide page said io-harness emits no event of any kind when the agent is handed an image. Since + io-harness 0.81.0 it emits one; the page said otherwise the moment the pin moved. +- A session row's timestamp carries its UTC marker without breaking the assertion that the stored + `T` separator was replaced by a space — `UTC` contains a `T`, and the check was being made over + the whole string. +- Finished sessions' lock files are swept at startup, a fenced block draws no bare language line, a + thought is committed above the prose it produced, a spawned child's outcome is drawn as words + rather than as a Rust `Debug`, the home line is drawn only on a run that created or moved + something, and no `--help` page narrates release history or shares one example across four + subcommands. + ## [0.38.1] - 2026-09-05 Two defects that switched a feature off without saying so, found by driving the released 0.38.0 @@ -3515,6 +3590,7 @@ client, tool, sandbox, policy engine or session store of its own. - No test in this release asserts on wall-clock time. [Unreleased]: https://github.com/initorigin/io-cli/compare/v0.38.0...HEAD +[0.38.2]: https://github.com/initorigin/io-cli/compare/v0.38.1...v0.38.2 [0.38.1]: https://github.com/initorigin/io-cli/compare/v0.38.0...v0.38.1 [0.38.0]: https://github.com/initorigin/io-cli/compare/v0.37.0...v0.38.0 [0.37.0]: https://github.com/initorigin/io-cli/compare/v0.36.0...v0.37.0 diff --git a/Cargo.lock b/Cargo.lock index 5ec879e..ecc06d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1850,7 +1850,7 @@ dependencies = [ [[package]] name = "io-cli" -version = "0.38.1" +version = "0.38.2" dependencies = [ "clap", "crossterm", @@ -1867,9 +1867,9 @@ dependencies = [ [[package]] name = "io-harness" -version = "0.79.0" +version = "0.81.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73599a2de1b1bf3630c72e24836f01354505d60522dc8f6d09e179687ad486f7" +checksum = "86e2c7ea3bc197d56862b6acf2c65ee32c07e7096174cf11233f56a328f48b7a" dependencies = [ "base64 0.23.1", "calamine", diff --git a/Cargo.toml b/Cargo.toml index 49cd8c2..99877d4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "io-cli" -version = "0.38.1" +version = "0.38.2" 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.79", features = ["media", "browser", "documents"] } +io-harness = { version = "0.81", 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 diff --git a/Formula/io.rb b/Formula/io.rb index 1721c39..29fb974 100644 --- a/Formula/io.rb +++ b/Formula/io.rb @@ -32,13 +32,13 @@ class Io < Formula on_macos do if Hardware::CPU.arm? - url "https://github.com/initorigin/io-cli/releases/download/v0.38.0/io-0.38.0-aarch64-apple-darwin.tar.gz" - sha256 "e38a9a4ac49b6149049d19b2d1398d42da6ed33f3b5d3c5f9f4f5933b725dc79" + url "https://github.com/initorigin/io-cli/releases/download/v0.38.1/io-0.38.1-aarch64-apple-darwin.tar.gz" + sha256 "ba2cc2b821c238686c835a886b5eb4eb392365b87873ffcd5960ef6560aa73d0" end if Hardware::CPU.intel? - url "https://github.com/initorigin/io-cli/releases/download/v0.38.0/io-0.38.0-x86_64-apple-darwin.tar.gz" - sha256 "2f7f8923a6c918a4f8bae9fc33f6c31888a91456a2184daf2eb86978a6777b68" + url "https://github.com/initorigin/io-cli/releases/download/v0.38.1/io-0.38.1-x86_64-apple-darwin.tar.gz" + sha256 "2d273e01d430de2d6f4d70f44c393d282646f421724fa78b5f4d023008d5efe7" end end @@ -47,8 +47,8 @@ class Io < Formula # rather than a URL that 404s halfway through an install. on_linux do if Hardware::CPU.intel? - url "https://github.com/initorigin/io-cli/releases/download/v0.38.0/io-0.38.0-x86_64-unknown-linux-musl.tar.gz" - sha256 "26a6e918526800047ada4050b5e6e296ee45c1a4ab5881d46741fc520b015a0c" + url "https://github.com/initorigin/io-cli/releases/download/v0.38.1/io-0.38.1-x86_64-unknown-linux-musl.tar.gz" + sha256 "2e99e143d1c0803d7e07e005146395e82d7ed20a4d883fc4bbb27be634d4040e" end end diff --git a/README.md b/README.md index b0da6d7..23e4ea8 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ posture on the next.](assets/screenshot.png) | **A session you can read** | Every finished line in the terminal's own scrollback, designed rather than defaulted: a tool call as a verb and a path, a thought as a thought, an answer that ends the turn | | **A working view** | Two sticky rows while a turn runs — a word for the turn with its clock and spend, and a line under it saying what is happening *now* | | **The boundary, visible** | The posture on the footer, a refusal that names the act, the target, the rule and the layer, and `Shift+Tab` to change it from the next turn | +| **A tool held back** | `/context withhold ` builds the session's tool mask and keeps that tool refused until `/context allow`; io-harness sends a byte-identical catalogue either way, so the request grows by the one sentence naming what is withheld | | **Approvals in place** | A write stops the run and shows the diff it proposes; `y`, `a` or `n`, answered where it was asked | | **A fan-out you can watch** | Contained turns spawn children under one shared ceiling; `Ctrl+F` shows the tree and what it is costing | | **Your file, in force** | Every section of `io.toml` bounds a session turn as it bounds `io exec`; the budgets in force are on the status line with what is left of them, and `/status` commits the whole state — policy layers, sandbox backend, caps, budgets, connections — into the scrollback | @@ -54,7 +55,9 @@ posture on the next.](assets/screenshot.png) | **Conversations that survive** | `/resume` reopens an earlier session and answers whatever its last run stopped on, `/fork` continues from an earlier turn, `/clear` starts fresh without leaving | | **A paused run, answered** | A question, a plan or an interrupted call is decided where it was left and the run carries on from the step it stopped at — from a session, or from a script with `io resume` | | **Everything it asks, in one place** | Several questions asked at once arrive as one overlay, answered one at a time and sent together; an offer may explain itself and preview what taking it would do; `Space` marks where a question takes more than one answer | -| **Headless** | `io exec` runs one goal to completion with documented exit codes and `--json`, and `io resume` carries a parked one on | +| **Headless** | `io exec` runs one goal to completion with documented exit codes and `--json`, and `io resume` carries a parked one on. Exit `6` is a run that was judged and did not hold up: a verification gate that failed, or io-harness's `RunOutcome::SchemaUnsatisfied` — the schema contract, a run that never produced the shape `[run] output_schema` asked for | +| **From your editor** | `io acp` serves the Agent Client Protocol (ACP) as newline-delimited JSON-RPC 2.0 over stdio, so Zed or a JetBrains IDE runs io against this machine's own configuration; an action in the grey tier arrives as a `session/request_permission` you answer in the editor | +| **Bundles from a marketplace you named** | `/plugin marketplace add /` clones an index into your own home and `/plugin add ` installs a bundle out of it — the same words from a shell, as `io plugin marketplace add` and `io plugin add` — and what a bundle is allowed to do is shown before it may do it | | **Readable without seeing it** | `--plain` animates nothing and commits every state change as text, for a screen reader, a braille display or a log | | **Markdown, rendered** | Headings, bullets, code and emphasis drawn as themselves rather than printed as notation | | **Documents, read and written** | Spreadsheets, Word, slide decks, PDFs and barcodes through io-harness's own tools — twelve of them, six of which write, every one under the same gate as any other read or write | @@ -109,9 +112,11 @@ scoop bucket add io-cli https://github.com/initorigin/io-cli scoop install io ``` -The tap and the bucket live in this repository rather than in two of their own, -which is why `brew tap` and `scoop bucket add` name a URL. They install the same -checksum-verified artifacts the scripts do. +The tap and the bucket live in this repository rather than in two of their own — +[`Formula/io.rb`](Formula/io.rb) and [`bucket/io.json`](bucket/io.json) — which is +why `brew tap` and `scoop bucket add` name a URL: this repository is not named +`homebrew-io-cli`, so neither tool can derive the URL from the name. They install +the same checksum-verified artifacts the scripts do. Both scripts pick the right build for your machine, **verify it against the published `SHA256SUMS` before unpacking it**, and install into a directory you diff --git a/bucket/io.json b/bucket/io.json index a557176..f518825 100644 --- a/bucket/io.json +++ b/bucket/io.json @@ -1,13 +1,13 @@ { - "version": "0.38.0", + "version": "0.38.1", "description": "Terminal agent that shows what it may do, what it spends and what it refused", "homepage": "https://github.com/initorigin/io-cli", "license": "Apache-2.0", "architecture": { "64bit": { - "url": "https://github.com/initorigin/io-cli/releases/download/v0.38.0/io-0.38.0-x86_64-pc-windows-msvc.zip", - "hash": "6ec1b0db26a25188e49c5c7d20084ef3c5538b50be18ab74692f06eaaf4859c3", - "extract_dir": "io-0.38.0-x86_64-pc-windows-msvc" + "url": "https://github.com/initorigin/io-cli/releases/download/v0.38.1/io-0.38.1-x86_64-pc-windows-msvc.zip", + "hash": "f3cb3c0d6b31a8d37d9603e9faf1b38a8056c1558f0299cf2b6ac4eab689cbbf", + "extract_dir": "io-0.38.1-x86_64-pc-windows-msvc" } }, "bin": "io.exe", diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index 58fcb72..ffc1bbb 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -28,11 +28,11 @@ With no subcommand, `io` opens an interactive session. | `io exec ""` | Runs one goal to completion with no terminal interaction | | `io resume` | Carries on a run that stopped for a question, a plan or an interrupted call; `--list` shows what is waiting | | `io mcp …` | Manage MCP servers without opening a session | -| `io plugin …` | Manage capability bundles and marketplaces | +| `io plugin …` | Manage capability bundles and marketplaces — the verbs are `add`, `install`, `list`, `search`, `remove` and `marketplace`. `io plugin marketplace add /` clones an index, `io plugin marketplace list` and `remove` are its other two, and `io plugin add ` installs a bundle out of one | | `io config …` | Read and write configuration keys | | `io skill …` | Add, list and remove skills | -| `io acp` | Serves the Agent Client Protocol on stdin and stdout, for an editor | -| `io upgrade` | Prints the command that updates this binary, for the way it was installed | +| `io acp` | Serves the Agent Client Protocol (ACP) on stdin and stdout, for an editor — an ACP client such as Zed or a JetBrains IDE spawns it; see [From an editor](guide/editors.md) | +| `io upgrade` | Prints the command that updates this binary, for the way it was installed: `brew upgrade io` from the tap this repository is, `scoop update io` from the bucket, and the installer line from a script install. It prints and does not run | `--plain` reaches an interactive session and stops there. `io exec` builds no theme, draws nothing and animates nothing already, so there is no second thing for the flag to switch off. @@ -62,7 +62,20 @@ to the existing `3` would have moved exactly the runs `6` was invented for. | `3` | CEILING | A step, time, cost or budget ceiling was reached, and nothing judged the work | | `4` | PAUSED | The run is waiting on a question, a plan, an approval or an interrupted call — resumable with `io resume`, except an approval | | `5` | UNFINISHED | The run ended in a state this table does not name | -| `6` | UNVERIFIED | The work was judged by a verification gate and did not hold up | +| `6` | UNVERIFIED | The work was judged and did not hold up — a verification gate that failed, or a run that never satisfied its output schema | + +**Two different failures reach `6`, and a caller that has to tell them apart reads stderr rather +than the code.** A verification gate is `[app.io-cli.gates]`, this crate's own key, and a failing +one is io-harness's `VerificationFailed`. **The schema contract is io-harness's `[run] +output_schema`** — io-cli defines none and sets none, and `Config::apply_to` applies the operator's +to a session turn and to both headless doors alike — and a run that never produces the shape it +asks for is `RunOutcome::SchemaUnsatisfied`. Both mean the same thing, which is why they share a +code: the work produced an answer, something checked it, and it did not hold up. The line +`io exec` writes to stderr is what separates them, and the schema one says the run *never produced +the shape its output schema asked for*. Neither is a ceiling — reporting either as `3` would send +an operator to raise `[run] max_steps`, which buys more attempts at the same failure. Over ACP +both are `max_turn_requests`, because such a run exhausted the attempts it was allowed rather than +refusing anything. Exit `4` names the `question_id`, `plan_id` or `attempt_id` that `io resume` needs — for three of the four pauses. **An approval is the fourth and it names no invocation**, because there is none: @@ -210,6 +223,13 @@ withheld is the moment you watch the agent reach for it. It passes the rule abov bending it: no file is written, nothing is reassigned, and the mask reaches the **next** turn because this one's contract was built before the keystroke. +**The mask changes what the agent may call and not what a turn costs.** io-harness sends the same +catalogue either way and io adds one sentence naming what is withheld, so a masked request is the +longer of the two. `/context allow ` takes one name back and `/context allow` on its own +clears the mask; a name that matches nothing the last request carried is kept, because io-harness +keeps an unknown name deliberately so a mask stays portable between builds, and io says so on the +line. + **`/config` is admitted bare and refused the moment it carries a word.** Through 0.32.0 it was refused in every form, because the bare list carried a row that re-read the provider's catalogue, wrote the prices into a scope file and reassigned the configuration the running turn was holding — diff --git a/docs/guide/limits.md b/docs/guide/limits.md index 2b42298..ab7ee6f 100644 --- a/docs/guide/limits.md +++ b/docs/guide/limits.md @@ -106,6 +106,14 @@ no cache entry. Use it to bound what the agent may do, and use `/context` and `/plugin` to see what your extensions cost — those are two different questions and only the second one is about money. +That is a statement about the mask, and io-harness 0.81.0 added a different lever +that *is* about the catalogue's size: tiered tool exposure, which offers the core +tools and names the rest in one line until the model asks for a family. It makes +exactly the trade described above — a smaller catalogue against a rewritten cache +prefix — and the harness measures it as worth 23% of the description tokens. +**io does not turn it on**, and this page will say so until io does: a lever +nothing here reaches is not a feature this product has. + **A mask does not reach a spawned child.** With `[app.io-cli.containment]` configured, a contained turn's children get contracts io-harness builds fresh, and it carries no mask into them deliberately — the mask is a request about your @@ -114,8 +122,12 @@ called inside the fleet. See [The fleet](fleet.md). **An image the agent was *given* rather than asked for is not shown.** A picture returned by an MCP tool, and a browser screenshot, both become images inside -io-harness — but through private plumbing and with no event of any kind, so -nothing reaches this program to draw. +io-harness. Until 0.81.0 that happened through private plumbing with no event of +any kind, so nothing reached this program at all; since 0.81.0 there is an event +carrying the media type, the size, the digest and where the picture came from. +What is missing now is the drawing rather than the fact — io does not render a +picture it was not handed itself, and `src/picture.rs` draws only what you +attached with `/attach`. **A skill is listed by name and never pasted.** A template is expanded by io-cli into prompt text, so nothing but this program is involved; a skill is read by the diff --git a/src/acp_map.rs b/src/acp_map.rs index 4296e79..fd70e07 100644 --- a/src/acp_map.rs +++ b/src/acp_map.rs @@ -81,6 +81,16 @@ pub const MAPPING: &[(&str, Update, &str)] = &[ Update::None, "the client already knows: it sent the prompt that started this", ), + // 0.81.0, declared immediately after `started` and placed there, per this + // table's ordering rule. + ( + "context_ceiling", + Update::None, + "the window this run assembles inside is io-cli's own status line and its \ + `/context` page; ACP has no session-capacity notification, and the \ + nearest thing — a plan or an agent message — would put a number in the \ + conversation that is not part of it", + ), ( "recovery_paused", Update::ToolCallUpdate, @@ -98,6 +108,22 @@ pub const MAPPING: &[(&str, Update, &str)] = &[ "a per-step timing breakdown, which ACP has no vocabulary for; `io exec \ --json` and `Store::step_attributions` carry it, as `triage` records", ), + // Both 0.81.0, in declaration order: `step_usage` then `image_attached`, + // between `step_attributed` and `tool_call`. + ( + "step_usage", + Update::None, + "a per-step token breakdown, which ACP has no vocabulary for either — and \ + for the same reason as its neighbour above rather than a new one", + ), + ( + "image_attached", + Update::None, + "the image was handed to the run inside io-harness — an MCP reply, a \ + browser screenshot, `view_image` — and is already part of whatever \ + `tool_call` produced it. A second notification carrying the same picture \ + would draw it twice in a client that renders both", + ), ( "tool_call", Update::ToolCall, @@ -432,6 +458,18 @@ pub const TOOL_KINDS: &[(&str, &str)] = &[ ("ask_question", "other"), ("ask_questions", "other"), ("send_message", "other"), + // 0.81.0, and `other` is decided rather than defaulted, on the same standard + // as the three above. `expand_tools` asks the harness to put a withheld tool + // family into the next step's catalogue: it touches no file, runs no program + // and fetches nothing, so seven of the nine are simply wrong. `think` is the + // near miss and is the one worth arguing — a client folds that kind by + // default, which is the right *treatment* for bookkeeping — but it would file + // a request about the run's own shape beside `propose_plan` and `todo_write`, + // where a reader opening the fold is looking for the model's reasoning about + // the task and would find a capability request instead. Declared + // unconditionally by `tools/mod.rs`, so the row is needed whether or not a run + // ever turns tiering on; this crate does not set `tool_tiers`. + ("expand_tools", "other"), ]; /// Is `name` a tool this adapter has classified deliberately? diff --git a/src/adapt.rs b/src/adapt.rs index ba9b06e..2ee80b0 100644 --- a/src/adapt.rs +++ b/src/adapt.rs @@ -70,7 +70,7 @@ //! `self.root.join(d)` and `Path::join` of an absolute argument discards the base. //! io-harness 0.74.0 refuses that spelling for `skills` and for `templates` in //! **every** scope, `Scope::User` included -//! (`io-harness-0.79.0/src/plugin.rs:1097`), and the reason is the one this +//! (`io-harness-0.81.0/src/plugin.rs:1097`), and the reason is the one this //! product would give: every `*.md` under such a directory is read into the //! model's system prompt on every turn, so a manifest contributes a directory it //! *ships* rather than one it points at somewhere else on the machine. Copying is @@ -377,7 +377,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.79.0/src/template.rs:279`), so what is translated is the +/// (`io-harness-0.81.0/src/template.rs:279`), so what is translated is the /// directory and never the files in it — the bytes are copied verbatim into /// [`TEMPLATES_DIR`] and nothing in them is rewritten. pub const COMMANDS_DIR: &str = "commands"; @@ -578,7 +578,7 @@ pub fn at(root: &Path, owner: &str, repo: &str, name: &str) -> PathBuf { /// /// **The two directories are copied into `into` and named relative to it.** /// io-harness 0.74.0 refuses an absolute or a `..`-carrying `skills` or -/// `templates` in every scope (`io-harness-0.79.0/src/plugin.rs:1097`), because +/// `templates` in every scope (`io-harness-0.81.0/src/plugin.rs:1097`), because /// every `*.md` under one is read into the model's system prompt — so a bundle /// contributes a directory it ships. An adapter comes to ship one by copying it, /// and [`Adapter::copied`] reports which moved. The copy refuses a symbolic link @@ -714,7 +714,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.79.0/src/plugin.rs:977`), because a +/// manifest in every scope (`io-harness-0.81.0/src/plugin.rs:977`), because a /// bundle is a third party's directory even when the file naming it is the /// operator's own. /// @@ -1252,7 +1252,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.79.0/src/mcp.rs:301`), so a bundle declaring `sse` is declaring +/// (`io-harness-0.81.0/src/mcp.rs:301`), 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/cli.rs b/src/cli.rs index f3f1d02..f0ca571 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -27,9 +27,13 @@ pub struct Cli { /// /// `[profile.]` is io-harness's own — a profile body is the file /// format again, applied over the merged scopes through the same merge they - /// use. It has been in the harness since its 0.27.0 and no io-cli release - /// selected one until 0.16.0. + /// use. /// + // The history this comment used to carry — which harness release added + // profiles, and which io-cli release first selected one — is a changelog + // entry rather than a description of the flag, and being on a `global` arg it + // printed on every subcommand's `--help` page. A `--help` page describes the + // binary in front of the reader; git and `CHANGELOG.md` hold the rest. /// `global` for the reason `-C` and `-m` are: a set of choices you want for /// one run is exactly the thing you type on either side of a subcommand, and /// `io exec --profile ci "…"` is the shape CI reaches for. Nothing is @@ -51,10 +55,14 @@ pub struct Cli { /// which a status line that only ever repaints is a state nobody can read. /// /// `global` for the same reason `-C` and `-m` are: `io --plain exec "…"` and - /// `io exec --plain "…"` are one command, and 0.5.0 shipped a defect where - /// `-m` after the subcommand was rejected. A flag whose acceptance depends on + /// `io exec --plain "…"` are one command. A flag whose acceptance depends on /// which side of a word it is typed is a flag that works on the author's /// machine. + // + // The release that shipped exactly that defect for `-m` is why the sentence + // above is written down at all, and naming it belongs in the changelog: this + // help page is read by an operator holding *this* binary, for whom a defect in + // a version they are not running is noise between them and the flag. /// /// It reaches an interactive session and stops there. `io exec` constructs no /// theme, draws nothing and animates nothing already — see `crate::exec` — @@ -81,11 +89,21 @@ pub enum Command { Resume(Resume), /// Add, list, inspect, change or remove an MCP server, without opening a /// session. + /// + /// For example: `io mcp add semlith -- semlith --store mcp` declares a + /// server io starts itself, and `io mcp probe semlith` goes and checks that it + /// answers. Mcp(Manage), /// Add, list, search for or remove a capability bundle, and manage the /// marketplaces bundles come from, without opening a session. + /// + /// For example: `io plugin search review` prints one line per bundle any + /// marketplace holds, and its first field is what `io plugin add` takes. Plugin(Manage), /// Read or write one configuration key, without opening a session. + /// + /// For example: `io config get app.io-cli.theme` says what a key is set to and + /// which file decided it, and `io config set app.io-cli.theme dark` changes it. Config(Manage), /// Serve the Agent Client Protocol on stdin and stdout, for an editor. /// @@ -102,13 +120,20 @@ pub enum Command { Acp, /// Install, list or remove a skill, without opening a session. /// - /// **Missing until 0.30.1, and `io skill add` did not exist because of it.** - /// `manage::parse` accepted the surface, `manage::plan` answered for it and - /// the session's own arm ran it — but clap knows only the subcommands named - /// in this enum, so the argv door answered `unrecognized subcommand 'skill'` - /// for a verb the README and the CHANGELOG both documented. Nothing under - /// `tests/` links `src/main.rs` and nothing tested clap's routing, so the - /// whole suite was green over a door that did not open. + /// For example: `io skill add ./reviewer.md` copies one skill file into io's + /// home, and `io skill list` names every skill installed there. + // + // **This variant was once missing, and `io skill add` did not exist because of + // it.** `manage::parse` accepted the surface, `manage::plan` answered for it + // and the session's own arm ran it — but clap knows only the subcommands named + // in this enum, so the argv door answered `unrecognized subcommand 'skill'` for + // a verb the README and the CHANGELOG both documented. Nothing under `tests/` + // links `src/main.rs` and nothing tested clap's routing, so the whole suite was + // green over a door that did not open. + // + // Kept as a comment and not as documentation: it is why the next author must + // not delete a variant that "looks unused", and it is not something the + // operator reading `io skill --help` has any use for. Skill(Manage), /// Print the command that updates this binary, for the way it was /// installed. @@ -122,7 +147,7 @@ pub enum Command { Upgrade, } -/// The three management subcommands' arguments, handed through untouched. +/// The management subcommands' arguments, handed through untouched. /// /// **Every token, verbatim, straight to `crate::manage::parse`** — which is what /// makes `io mcp add …` and `/mcp add …` one parse rather than two that agree @@ -136,13 +161,22 @@ pub enum Command { /// own and the line dies before `manage` sees it — F7's whole subject. Nothing in /// `manage.rs` can compensate; the tokens have to arrive intact. /// -/// The flags are not documented here on purpose. `io mcp --help` shows this -/// paragraph and `manage`'s refusals name the accepted shapes at the point of -/// getting one wrong, which is where an operator is actually looking; a flag list -/// repeated in a doc comment is a second place for the grammar to drift. +/// The flags are not documented here on purpose. `manage`'s refusals name the +/// accepted shapes at the point of getting one wrong, which is where an operator +/// is actually looking; a flag list repeated in a doc comment is a second place +/// for the grammar to drift. (Nothing on this struct reaches a help page anyway — +/// clap takes a subcommand's description from the *variant* above, not from the +/// `Args` type behind it. Only the field below is printed.) #[derive(Debug, clap::Args)] pub struct Manage { - /// The verb and its arguments — `add semlith -- semlith --store mcp`. + /// The verb and its arguments. + // + // **No example here, and that is the whole of F9's first clause.** This one + // field is shared by four subcommands, so an example written in it is an + // example of *one* of them printed on the help page of all four — which is + // exactly what `io mcp`, `io plugin`, `io config` and `io skill` did, each + // showing the same `mcp add` line. Each subcommand's own example is in its + // variant's description above, where clap prints it once, on its own page. #[arg(trailing_var_arg = true, allow_hyphen_values = true)] pub words: Vec, } diff --git a/src/commands.rs b/src/commands.rs index d5e142e..e0c343f 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1489,7 +1489,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.79.0/src/config.rs:2997`). +/// (`read_instructions`, `io-harness-0.81.0/src/config.rs:3120`). pub const UNREAD_MARK: &str = "-"; /// What one row of the memory page stands for. @@ -1705,8 +1705,8 @@ 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.79.0/src/config.rs:190`, read at -/// `:3006`) and there is nothing on any +/// (`DEFAULT_INSTRUCTIONS`, `io-harness-0.81.0/src/config.rs:190`, read at +/// `:3129`) and there is nothing on any /// other surface to pull on. pub fn instruction_said( file: &crate::memory::Instruction, diff --git a/src/configure.rs b/src/configure.rs index c9d3847..550a49f 100644 --- a/src/configure.rs +++ b/src/configure.rs @@ -17,6 +17,13 @@ //! default to a file the operator never wrote it in. That is a lie a reader //! cannot detect, so [`Decided::Default`] is its own answer and names no path. //! +//! And there is a *fifth* answer under that one, because "no file named it" is +//! two facts wearing one word: a key of io-harness's that nobody has set, and a +//! key that does not exist. [`Decided::Unknown`] is the second — no file sets it +//! and no [`CATALOGUE`] entry names it — and it exists because reporting +//! `default` for a misspelling told an operator checking a typo that their +//! setting was in force. +//! //! This crate has paid for that distinction once already: 0.15.0's //! `home::origin` reported `IO_CONFIG_HOME` for io-cli's own default because //! `adopt` had set the variable itself, crediting the operator for a choice they @@ -47,6 +54,25 @@ pub enum Decided { /// No file named it, so io-harness's own default is in force. This names no /// path on purpose. Default, + /// No file named it **and** no catalogue entry does either, so there is + /// nothing here to have a default of. + /// + /// **Split off [`Decided::Default`] because answering `default` for a key that + /// does not exist is a wrong answer, not a thin one.** `Config::origin` + /// returns an empty slice for a misspelling exactly as it does for a real key + /// no file sets, so the two were indistinguishable and `io config get + /// nonexistent.key` reported that io-harness's own default was in force — an + /// operator checking a typo was told their setting was fine. + /// + /// The test is [`CATALOGUE`] membership, which is deliberately narrower than + /// "a key io-harness would accept": io-harness publishes no enumeration of its + /// schema, so no surface here can be sure a key is *absent* from it. What this + /// says is what it can prove — nothing names this key and nothing sets it — + /// and the cost is that a real key outside the catalogue and outside every + /// file (`[app.io-cli.keys]`'s rebindings, say) reads the same way. That is + /// the better error of the two: it sends a reader to look, where `default` + /// sent them away satisfied. + Unknown, } impl Decided { @@ -68,6 +94,11 @@ impl Decided { .. } => "local", Decided::Default => "default", + // Three words rather than one, because there is no scope to name and a + // one-word placeholder in this column would read as one. It reaches an + // operator through `io config get`, which prints this word as the + // origin field, so it has to be the answer and not a label for one. + Decided::Unknown => "no such key", } } @@ -75,7 +106,7 @@ impl Decided { pub fn path(&self) -> Option<&std::path::Path> { match self { Decided::File { path, .. } => Some(path), - Decided::Default => None, + Decided::Default | Decided::Unknown => None, } } @@ -89,7 +120,12 @@ impl Decided { pub fn scope(&self) -> Option { match self { Decided::File { scope, .. } => Some(*scope), - Decided::Default => None, + // `Unknown` answers `None` for the same reason `Default` does, and it + // matters that it is not a refusal: writing a key io-cli's catalogue + // has never heard of is a thing an operator is allowed to do — see + // [`source_for`]'s `None` arm — and it goes to the file a defaulted + // key would go to. + Decided::Default | Decided::Unknown => None, } } } @@ -255,7 +291,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.79.0/src/policy.rs:129`) and each spelling is `Effect::as_str` +/// (`io-harness-0.81.0/src/policy.rs:129`) and each spelling is `Effect::as_str` /// (`:145`), 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 +318,7 @@ pub fn effects() -> Vec { /// The `ExecMode` variants, spelled by io-harness itself. /// -/// **The list is `ExecMode::ALL` (`io-harness-0.79.0/src/sandbox.rs:453`) and the +/// **The list is `ExecMode::ALL` (`io-harness-0.81.0/src/sandbox.rs:453`) and the /// spellings are `ExecMode::as_str` (`:460`).** 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:407`), and @@ -656,7 +692,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.79.0/src/contract.rs:704,722,738`), +/// `DEFAULT_MAX_RETRIES` = 2 (`io-harness-0.81.0/src/contract.rs:755,773,789`), /// 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: @@ -807,7 +843,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.79.0/src/pricing.rs:268`) +/// out of the files.** `PriceTable::models` (`io-harness-0.81.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 @@ -833,7 +869,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.79.0/src/config.rs:627`), so a second discovery re-runs +/// it reads (`io-harness-0.81.0/src/config.rs:627`), 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 @@ -866,7 +902,10 @@ pub fn priced_models(config: &Config) -> Vec { pub fn destination(config: &Config, key: &str) -> (Scope, bool) { match setting(config, key).decided { Decided::File { scope, .. } => (scope, true), - Decided::Default => (Scope::User, false), + // A key no file names has nothing to inherit whether or not the catalogue + // knows it, so the two answer alike here. Refusing to write an uncatalogued + // key would be io-cli enforcing a schema it does not own. + Decided::Default | Decided::Unknown => (Scope::User, false), } } @@ -911,7 +950,7 @@ pub fn destination(config: &Config, key: &str) -> (Scope, bool) { #[must_use] pub fn widens_workspace(key: &str, value: &str) -> bool { /// The clause io-harness's widening refusal always carries - /// (`io-harness-0.79.0/src/config.rs:2826`). Matched rather than the whole + /// (`io-harness-0.81.0/src/config.rs:2949`). Matched rather than the whole /// sentence, which interpolates the path, the key and the destination scope. const WIDENS: &str = "widens the boundary"; @@ -1046,8 +1085,8 @@ 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.79.0/src/config.rs:3036`, the `cmd` arm at -/// `:3156`); this comment claimed two until +/// (`substitute`, `io-harness-0.81.0/src/config.rs:3159`, the `cmd` arm at +/// `:3279`); 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 /// `${file:}` reference is a *name*, and the name is the whole of what an @@ -1139,8 +1178,19 @@ pub fn settings(config: &Config) -> Vec { /// /// A key no file names reads `not set` rather than an empty value, because an /// empty string is a value an operator can actually write. +/// +/// A key nothing names at all gets a different sentence entirely, and not the +/// shape above with a third word in the bracket: "`x` is not set (no such key)" +/// asserts that `x` is a setting in the same breath as denying it, which is the +/// sentence [`crate::manage`]'s refusals are written to avoid. #[must_use] pub fn said(setting: &Setting) -> String { + if setting.decided == Decided::Unknown { + return format!( + "there is no such key as {} — no file sets it and it is not a key io names", + setting.path + ); + } let what = setting.value.as_deref().unwrap_or("not set"); format!("{} is {what} ({})", setting.path, setting.decided.word()) } @@ -1155,7 +1205,14 @@ pub fn setting(config: &Config, key: &str) -> Setting { scope: origin.scope, path: origin.path.clone(), }, - None => Decided::Default, + // **A file is asked first, and the catalogue only decides the leftover.** + // The order is the load-bearing part: a key an operator actually wrote is + // a real key of io-harness's whatever this crate's catalogue knows, and + // [`settings`] lists exactly those on purpose. So [`Decided::Unknown`] is + // reachable only for a key that no file set *and* no catalogue entry + // names, which is the pair of facts it claims and nothing more. + None if CATALOGUE.contains(&key) => Decided::Default, + None => Decided::Unknown, }; let value = decided.path().and_then(|path| { diff --git a/src/context.rs b/src/context.rs index 5e6a390..eb0c6d4 100644 --- a/src/context.rs +++ b/src/context.rs @@ -128,10 +128,10 @@ pub const SKILLS_HEAD: &str = "Skills available to you"; /// **The directive is glued to the last catalogue line with no newline between /// them, so a line scan cannot see the boundary.** `compose` builds the prompt as /// `with_skill_catalog(..)` and then `out.push_str(&directive)` -/// (io-harness-0.79.0/src/run/prompts.rs:73-76); `Skills::catalog()` ends with its +/// (io-harness-0.81.0/src/run/prompts.rs:73-76); `Skills::catalog()` ends with its /// last `- name: description` line carrying **no trailing newline** -/// (src/skills.rs:470-476); and `planning_directive` returns a string beginning -/// with a space (src/run/gate.rs:282-286). So with a plan gate registered — which +/// (src/skills.rs:554-565); and `planning_directive` returns a string beginning +/// with a space (src/run/gate.rs:322-326). So with a plan gate registered — which /// is every contained turn — the final line on the wire reads /// `- bundle__skill: description Before you do anything else you must call …`, /// still starts with `- `, and a naive scan swallows the whole directive. @@ -786,14 +786,35 @@ pub fn total(sections: &[Section]) -> u64 { sections.iter().map(|section| section.tokens).sum() } -/// The window this contract declares, in tokens. +/// The window this run assembles inside, in tokens. /// /// `remaining` is what is left of `[run] max_tokens` for this turn, which is what /// makes the ceiling move: with a run budget the assembled section takes a share /// of what is *unspent*, so a run running low reports a smaller window rather /// than the flat maximum it can no longer afford. -pub fn window(contract: &TaskContract, remaining: Option) -> u64 { - contract.context.effective_tokens(remaining) +/// +/// **`announced` is the run's own answer and it wins outright (0.38.2).** Until +/// io-harness 0.81.0 the contract was the only thing that knew: `ContextBudget` +/// declared `24_000` unless the operator wrote `[run.context]`, so the contract +/// and the run agreed by construction and this function could read either. They +/// no longer agree. The harness now derives the ceiling from the model's own +/// window where the provider knows it and emits `EventKind::ContextCeiling` +/// saying which of `contract`, `model` or `fallback` decided — while the +/// `TaskContract` this crate built still carries the budget it was built with. +/// Dividing by the contract after that would report `ctx 100%` on a run a fifth +/// of the way into a 128,000-token window, which is worse than no field: the +/// number an operator folds a conversation over would be five times too small. +/// +/// **The precedence needs no re-deriving here, because the announcement already +/// carries it.** An explicit `ContextBudget` still wins inside io-harness and is +/// announced as `source: "contract"`, so taking the announced value is taking the +/// operator's own answer where they gave one. `None` is a run that has not +/// announced yet — before the first event, and every run against a harness that +/// does not emit this — and falls back to exactly the pre-0.38.2 expression. +pub fn window(contract: &TaskContract, remaining: Option, announced: Option) -> u64 { + announced + .filter(|ceiling| *ceiling > 0) + .unwrap_or_else(|| contract.context.effective_tokens(remaining)) } /// The page, committed into the scrollback. @@ -808,6 +829,12 @@ pub fn committed( seen: Option<&Request>, contract: &TaskContract, remaining: Option, + // The ceiling this run announced, if it has. Threaded to `window` rather than + // read here, because the page and `ctx N%` must divide by the same number: + // they disagreed once already — the page totalled 4,363 of 24,000 while the + // line said `ctx 0%` — and the fix then was to make them one expression. A + // second reader of the announcement would undo that. + announced: Option, // What the next turn may not call. Drawn on this page because this is where // the tools are named, and an operator who withheld one an hour ago should // not have to remember it — but drawn only when there is one, so the row is @@ -853,10 +880,10 @@ pub fn committed( ); } let total = total(§ions); - let window = window(contract, remaining); + let window = window(contract, remaining, announced); push( format!( - "total: {total} tokens of {window} {dash} the window this contract declares" + "total: {total} tokens of {window} {dash} the window this run assembles inside" ), Tone::Normal, ); diff --git a/src/contract.rs b/src/contract.rs index b047516..b447662 100644 --- a/src/contract.rs +++ b/src/contract.rs @@ -500,7 +500,7 @@ pub fn buying(contract: TaskContract, effort: Option) -> Tas /// sits ahead of a cache breakpoint and removing a definition would save its /// tokens once and pay a cache *write* on every later turn (`src/tools/mod.rs:40`). /// A mask in fact **adds** a sentence to the user prompt naming the withheld tools -/// (`src/run/prompts.rs:1133`, `withheld_sentence`), placed after the observations +/// (`io-harness-0.81.0/src/run/prompts.rs:1381`, `withheld_sentence`), placed after the observations /// precisely so it costs no cache entry. A turn that withholds three tools is /// marginally more expensive than the same turn without the mask, not less. #[must_use] @@ -657,7 +657,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.79.0/src/skills.rs`), and `TaskContract::discover_skills` +/// (`io-harness-0.81.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. @@ -682,7 +682,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.79.0/src/config.rs:3036` — there is no tilde +/// (`substitute`, `io-harness-0.81.0/src/config.rs:3159` — 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/events.rs b/src/events.rs index 0e0529c..80de9f9 100644 --- a/src/events.rs +++ b/src/events.rs @@ -741,6 +741,11 @@ impl Events { tokens, changed, } => { + // **A child's outcome arrives as a Rust `Debug` and is not drawn + // as one (0.38.1).** See [`said_plainly`]. Rebound before + // anything reads it, so every use below — the pairing, the step + // line, the emptiness test — sees the same sentence. + let decision = &said_plainly(decision); // This step closing is the next one opening, and a thought // belonging to the next step is measured from here. self.step_at = at; @@ -1514,8 +1519,23 @@ impl Events { // folds it into the next prompt. `/expand` is where it goes. let elapsed = format_millis(at.saturating_sub(self.step_at)); self.thought = Some(text.clone()); - let mut lines = self.flush_text(); - lines.push(Line::from(vec![ + // **The thought line goes ABOVE the prose it produced, not under + // it (0.38.1).** This arm flushed the buffered prose first and + // then pushed the footer, which is right only if the event + // arrives before the tokens it explains. It does not: the + // 2026-09-05 field test found `thought · 2.3s` drawn *under* the + // final reply, and the same ordering in the JSON stream — the + // `reasoning` event follows the `token` it produced. So a reader + // met the answer, then a note saying the model had been thinking, + // which reads as a thought about the answer rather than the + // thinking that reached it. + // + // Reordering here is the whole fix and it is safe in both + // directions: a provider that streams reasoning first leaves + // `live` empty, so `flush_text` returns nothing and the order + // cannot be observed. Nothing is asked of io-harness, which is + // reporting what its provider gave it in the order it arrived. + let mut lines = vec![Line::from(vec![ Span::styled(leader(separator), theme.style(Tone::Muted)), // Italic, which is what a thought is: the model's own voice // rather than the interface's, and set apart from the tool @@ -1528,7 +1548,8 @@ impl Events { format!("{separator}{elapsed}{separator}{tokens} tok"), theme.style(Tone::Muted), ), - ])); + ])]; + lines.extend(self.flush_text()); lines } // **Nothing in this process dialled anything.** The provider ran its @@ -2371,6 +2392,81 @@ pub fn outcome_help(outcome: &str) -> Option<&'static str> { /// So the names are kept and the arguments are dropped. 0.1.1's F5 asks that a /// step read as decision, then what it ran, then what came back, and it still /// does — the tool cell above it is where the arguments live now. +/// A step's decision sentence with any Rust `Debug` in it written out as words. +/// +/// **io-harness formats a spawned child's outcome with `{outcome:?}`** +/// (`run/tree.rs`), so the sentence it hands this crate is literally +/// `spawned child 149: Success { steps: 1 }`. Drawn verbatim — which is what the +/// 2026-09-05 field test saw — the fleet result line shows a reader a struct +/// literal from a language they may not write, in the one row that is supposed +/// to tell them what their children did. +/// +/// **This is a rendering of io-harness's own sentence, not a second opinion about +/// it.** Every fact drawn comes out of the text the harness produced: the variant +/// name it chose and the fields it printed, re-spelled as words. Nothing is +/// looked up, inferred, or corrected — the same discipline `skill_and_file` keeps +/// when it reads `read skill {label}` out of a decision rather than deciding for +/// itself which skill loaded. +/// +/// It is general over the variant rather than a table of the eighteen, for the +/// reason the wildcard in `acp_map` is a documented hazard: a `RunOutcome` the +/// next pin adds would fall through a table and be drawn as a struct again, and +/// the shape of a `Debug` is the one thing that does not change when a variant +/// does. +/// +/// Only segments that begin `spawned child ` are touched. A decision sentence is +/// prose the harness wrote and rewriting all of it would be exactly the second +/// opinion this crate refuses. +fn said_plainly(decision: &str) -> String { + decision + .split("; ") + .map(|part| match part.split_once(": ") { + Some((head, rest)) if head.starts_with("spawned child ") => { + format!("{head} {}", words_of_debug(rest)) + } + _ => part.to_string(), + }) + .collect::>() + .join("; ") +} + +/// `Success { steps: 1 }` as `success, steps 1`; `Interrupted` as `interrupted`. +/// +/// The variant name is split on its own capitals and lowercased, the braces and +/// parentheses go, and `field: value` becomes `field value`. A shape this does +/// not recognise is returned with its punctuation stripped rather than guessed +/// at, because a sentence that has lost a brace is still readable and a sentence +/// this function invented is not. +fn words_of_debug(text: &str) -> String { + let text = text.trim(); + let (name, fields) = match text.find(['{', '(']) { + Some(at) => ( + text[..at].trim(), + text[at + 1..].trim_end_matches(['}', ')']).trim(), + ), + None => (text, ""), + }; + + let mut spelled = String::new(); + for (index, c) in name.char_indices() { + if c.is_ascii_uppercase() && index > 0 { + spelled.push(' '); + } + spelled.push(c.to_ascii_lowercase()); + } + + if fields.is_empty() { + return spelled; + } + let said = fields + .split(',') + .map(|pair| pair.trim().replacen(':', "", 1).trim().to_string()) + .filter(|pair| !pair.is_empty()) + .collect::>() + .join(", "); + format!("{spelled}, {said}") +} + fn tool_names(tool_call: &str) -> String { tool_call .split(" | ") diff --git a/src/exec.rs b/src/exec.rs index 272b5d3..0a72036 100644 --- a/src/exec.rs +++ b/src/exec.rs @@ -1055,7 +1055,80 @@ fn gate_line(standing: &crate::gates::Standing) -> String { /// is how much that one answer has to cover. It is `1` for an ordinary question /// and `null` for the three pauses that are not questions, which is the shape /// `id` already uses for the run whose process went away. -pub fn listed(run_id: i64, pending: &Pending, json: bool) -> Option { +/// What a parked run is, beyond the pause it is parked on (0.38.2). +/// +/// **Three facts that tell two rows apart, and none of them was readable before +/// io-harness 0.81.0.** The 2026-09-05 field test found thirteen parked runs +/// listed as `run 41 question 12 step 3`, `run 42 question 13 step 4`, and so +/// on — differing only by numbers that mean nothing to the person who has to +/// choose one. `runs.goal` had been written since 0.1.0 with no public reader, and +/// there was no reader for a run's own start time either; io-harness#258 closed +/// both as `Store::run_goal` and `Store::run_created_at`. +/// +/// Every field is an `Option` and each `None` means something different from the +/// others, which is why they are not flattened into a formatted string here: +/// `started_at` is `None` for a run older than io-harness 0.7.0, when the column +/// did not exist, and rendering that as a time — any time — would put a false date +/// on the oldest rows in the one listing this exists to make legible. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Parked { + /// What the run was started to do, as `Store::run_goal` reports it. + pub goal: Option, + /// When it started, as the store recorded it — a stored string, never a clock + /// read. + pub started_at: Option, + /// The root it ran against, as `Store::run_file` reports it. + pub root: Option, +} + +impl Parked { + /// The three facts, read from the store and never guessed at. + /// + /// A read that fails is `None` rather than an error that stops the listing: a + /// row missing its goal is still a row an operator can resume, and refusing to + /// print the other twelve because the thirteenth could not be read would be + /// the worse failure. + #[must_use] + pub fn of(store: &Store, run_id: i64) -> Self { + Self { + goal: store.run_goal(run_id).ok().flatten(), + started_at: store.run_created_at(run_id).ok().flatten(), + root: store.run_file(run_id).ok().flatten(), + } + } + + /// The goal on one line of a listing, or `None` where there is none. + /// + /// **Folded to one line and never cut, and the difference matters here.** The + /// first draft kept sixty characters and put an ellipsis after them, which + /// reads well and is wrong on this door: N6 in `tests/exec.rs` holds this file + /// to composing nothing, because a headless stream is not a viewport and + /// clipping it loses data a machine was going to read. The gate caught it. A + /// long goal therefore makes a long row, and a terminal wraps it — which is the + /// terminal's decision to make and not this program's. + /// + /// N6 sweeps this file's whole text and strips no comments, so its four needles + /// are banned from prose here as well as from code. This sentence has been + /// rewritten twice for that reason: naming the gate spells one of them, and + /// saying what the first draft did spells another. + /// + /// The fold stays, because it is a different act from a cut: one row per run is + /// the shape being read, and a goal somebody typed across four lines would + /// silently make one row four. The verbatim goal, newlines and all, is on the + /// `--json` object, which is the stream that promises it. + #[must_use] + pub fn short_goal(&self) -> Option { + let one_line = self + .goal + .as_deref()? + .split_whitespace() + .collect::>() + .join(" "); + (!one_line.is_empty()).then_some(one_line) + } +} + +pub fn listed(run_id: i64, pending: &Pending, parked: &Parked, json: bool) -> Option { let (waiting_on, id, step, asked) = match pending { Pending::Question { question_id, @@ -1080,12 +1153,32 @@ pub fn listed(run_id: i64, pending: &Pending, json: bool) -> Option { Some(if json { // Built through `serde_json` rather than formatted, so an answer holding // a quote is escaped by the same code that escapes the event stream. + // The three new keys are all nullable, so a reader written against the + // earlier shape goes on working: it ignores what it did not ask for, and a + // `null` is the shape `id` and `questions` already use for a row that has + // none. + // + // **Not "appended", which is what this said first.** `serde_json` without + // `preserve_order` backs its map with a `BTreeMap`, so the object is + // emitted in key order and these three interleave — `goal` now comes out + // ahead of `run_id` as the first key of every row. That is safe for + // anything parsing JSON and is not safe for anything matching on a prefix, + // so the honest statement is nullability rather than position. + // + // `started_at` is the store's own string, unformatted. A script sorting + // parked runs wants the sortable stamp io-harness stored, not a stamp cut + // to the minute for a human to read — that is the plain stream's job, and + // the two streams differing here is the same split `io exec --json` + // already makes. serde_json::json!({ "run_id": run_id, "waiting_on": waiting_on, "id": id, "step": step, "questions": asked, + "goal": parked.goal, + "started_at": parked.started_at, + "root": parked.root, }) .to_string() } else { @@ -1096,9 +1189,23 @@ pub fn listed(run_id: i64, pending: &Pending, json: bool) -> Option { Some(n) if n > 1 => format!(" {n} questions"), _ => String::new(), }; + // **`unknown` and never a date (0.38.2).** `Store::run_created_at` answers + // `None` for a run started before io-harness 0.7.0, when the column did + // not exist. Any rendered time here would be invented, and an invented + // date on the oldest rows is precisely the row an operator is trying to + // identify. + let when = parked + .started_at + .as_deref() + .map_or_else(|| "unknown".to_string(), crate::sessions::stamp); + let goal = parked + .short_goal() + .map_or_else(String::new, |goal| format!(" {goal}")); match id { - Some(id) => format!("run {run_id} {waiting_on} {id} step {step}{several}"), - None => format!("run {run_id} {waiting_on} step {step}"), + Some(id) => { + format!("run {run_id} {waiting_on} {id} step {step}{several} {when}{goal}") + } + None => format!("run {run_id} {waiting_on} step {step} {when}{goal}"), } }) } @@ -1412,7 +1519,8 @@ pub async fn resume_main( for run_id in store.runs().map_err(|error| error.to_string())? { let pending = crate::resume::pending_for(&store, run_id).map_err(|error| error.to_string())?; - if let Some(row) = listed(run_id, &pending, args.json) { + let parked = Parked::of(&store, run_id); + if let Some(row) = listed(run_id, &pending, &parked, args.json) { let _ = writeln!(out, "{row}"); } } @@ -1548,8 +1656,8 @@ impl WithProvider for Resuming { // 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.79.0/src/run.rs:1811`, `:2145`, `:3189`). Recovery has - // `resume_with_recovery_observed` (`:2607`) and nothing tree-aware, so it + // (`io-harness-0.81.0/src/run.rs:1816`, `:2150`, `:3204`). Recovery has + // `resume_with_recovery_observed` (`:2617`) 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 // entry points, and resuming through the flat one would drop it. diff --git a/src/export.rs b/src/export.rs index 720d9e5..b6e21df 100644 --- a/src/export.rs +++ b/src/export.rs @@ -152,9 +152,20 @@ pub fn conversation(store: &Store, session: &io_harness::Session) -> Result, + /// Whether this run is what made the home directory. + /// + /// **The field exists so the home line can stop being unconditional + /// (0.38.1).** See [`Report::lines`]: without it, "did anything happen" and + /// "where do the files live" are the same question, and the answer to the + /// second was printed on every invocation of every subcommand because the + /// first had no way to be false. + pub created: bool, } impl Report { - /// One line per file, then one naming the home. + /// One line per file, then one naming the home — when there is something to + /// report. + /// + /// **The home line was last and unconditional, and that made it the whole + /// output of every run that had nothing to say (0.38.1).** The reasoning was + /// that on a run which moved nothing it is the product answering "where does + /// it live" without being asked. What the 2026-09-05 field test found is that + /// almost every run moves nothing — the migration happens once, ever — so + /// `io keeps its files in …` printed above the output of `io config get`, of + /// every `io mcp list`, of every invocation in a shell loop. Answering an + /// unasked question once is a courtesy; answering it every time is noise on a + /// surface a script reads. /// - /// The home line is last and unconditional: on a run that moved nothing it is - /// the whole report, which is the product answering "where does it live" - /// without being asked. Lines rather than a paragraph because the session - /// commits each one into the scrollback and `io exec` writes each to stderr. + /// So it is drawn when this run **did** something: created the home, moved a + /// file into it, or left one behind. `/status` is where the path is answered + /// on demand, and it always was. + /// + /// Lines rather than a paragraph because the session commits each one into + /// the scrollback and `io exec` writes each to stderr. #[must_use] pub fn lines(&self) -> Vec { if let Some(blocked) = &self.blocked { @@ -180,7 +201,9 @@ impl Report { left.display() )); } - out.push(format!("io keeps its files in {}", self.home.display())); + if self.created || !self.moved.is_empty() || !self.kept.is_empty() { + out.push(format!("io keeps its files in {}", self.home.display())); + } out } } @@ -383,6 +406,12 @@ pub fn adopt() -> Option { // and there is nothing left to migrate from. let previous = io_harness::config::user_path(); + // Asked before `create`, for the obvious reason and one less obvious: a home + // that already exists is the ordinary case — the migration happens once, + // ever — and it is what makes the report's home line noise on every run + // afterwards. `create` is `create_dir_all`, which succeeds either way and so + // cannot answer this itself. + let created = !home.exists(); // Created BEFORE the variable is set, and the variable is not set at all if it // cannot be: pointing io-harness at a directory that does not exist and then // returning `None` would move the configuration path with nobody told, which @@ -402,6 +431,7 @@ pub fn adopt() -> Option { moved: Vec::new(), kept: Vec::new(), blocked: None, + created, }; // **The variable is set only once every file that had to move has moved**, and @@ -445,6 +475,10 @@ pub fn adopt() -> Option { moved: Vec::new(), kept: Vec::new(), blocked: Some(source), + // The blocked report has its own two lines and never reaches the + // home line, so this is inert either way — and `false` is the + // honest value: the home in force here is the one it always was. + created: false, }); } report.moved.push((source, target)); diff --git a/src/import.rs b/src/import.rs index a86c8c2..f4f003c 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.79.0/src/policy.rs:63`). So the nearest thing to a faithful +//! (`Act::Exec`, `io-harness-0.81.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/lock.rs b/src/lock.rs index bdbb426..7f4e121 100644 --- a/src/lock.rs +++ b/src/lock.rs @@ -440,6 +440,81 @@ pub fn acquire(home: &Path, session: i64, root: &Path, started: SystemTime) -> i Ok(Taken::Held(Guard { file, owner, wrote })) } +/// Remove the lock files of sessions that are over, and answer how many went. +/// +/// **The leak is real and the obvious fix is the one thing this module must not +/// do.** The 2026-09-05 field test found thirty-one `session-N.lock` files in +/// `~/.io-cli` with nothing removing them. [`Guard::drop`] already takes the +/// owner record away; the lock file is left, and [`LOCK`]'s own documentation +/// says why — unlinking a file another process holds a lock on releases nothing, +/// so the next opener creates a fresh inode, locks that, and two processes then +/// hold two different locks while each believes it is alone. Removing one's own +/// lock on exit reintroduces exactly that, in the window between the unlink and +/// the unlock. +/// +/// So the removal happens from the outside, at startup, and only where **both** +/// pieces of evidence say the session is over: +/// +/// * no owner record beside it — [`Guard::drop`] removes that first and always, +/// so its absence is the holder having gone; and +/// * `try_lock` succeeds — nobody holds it *now*. +/// +/// **The inode split cannot happen here, and the reason is a property of this +/// crate rather than of the filesystem.** That race needs a process *waiting* to +/// acquire, so that it lands on the unlinked inode after the unlink. Nothing in +/// io-cli ever waits: [`acquire`] calls `try_lock`, which returns `WouldBlock` +/// rather than queueing, and the one caller turns that into a refusal. A +/// concurrent `acquire` during the sweep is refused and creates no waiter. +/// +/// The unlink happens **while the lock is held**, which is what makes a +/// concurrent acquirer see `WouldBlock` rather than a free file. +/// +/// Best effort throughout: a file that cannot be locked or cannot be removed is +/// left alone. A sweep is housekeeping, and housekeeping that fails is a file +/// still on disk rather than a session that could not start. +pub fn sweep(home: &Path) -> usize { + let Ok(entries) = std::fs::read_dir(home) else { + return 0; + }; + let mut gone = 0; + for entry in entries.flatten() { + let path = entry.path(); + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + // Read back through [`paths`] rather than by editing the filename, so + // there is one speller for these two names and not two. A file under this + // stem whose middle is not a session id is not this module's and is left + // alone. + let Some(id) = name + .strip_prefix(STEM) + .and_then(|rest| rest.strip_suffix(&format!(".{LOCK}"))) + else { + continue; + }; + let Ok(session) = id.parse::() else { + continue; + }; + let (lock, owner) = paths(home, session); + // The owner record sits beside it. Its presence means a holder wrote it + // and has not dropped its guard. + if owner.exists() { + continue; + } + let Ok(file) = File::open(&lock) else { + continue; + }; + if file.try_lock().is_err() { + continue; + } + if std::fs::remove_file(&lock).is_ok() { + gone += 1; + } + let _ = file.unlock(); + } + gone +} + /// The record as it is on disk, or an empty one. /// /// A record that is missing, unreadable, or not valid UTF-8 is an [`Owner`] with diff --git a/src/main.rs b/src/main.rs index e3bb9ca..88b6c99 100644 --- a/src/main.rs +++ b/src/main.rs @@ -582,6 +582,12 @@ async fn drive( // The only clock read on this path, and it is here because // `src/main.rs` is the one file `tests/timing.rs` permits one in. let now = std::time::SystemTime::now(); + // **The stale ones go before this one is taken (0.38.1).** Thirty-one + // empty `session-N.lock` files had accumulated in `~/.io-cli` with + // nothing removing them; `lock::sweep` removes those whose holder is + // provably gone, and explains why the removal cannot happen at exit. + // Before `acquire`, so a session never sweeps around its own file. + io_cli::lock::sweep(&home); match io_cli::lock::acquire(&home, session.id(), root, now) { Ok(io_cli::lock::Taken::Held(guard)) => Some(guard), // Not reachable for a session created a line ago, and said @@ -4704,7 +4710,7 @@ async fn loop_over Result>( if hits.is_empty() { app.record( Tone::Muted, - format!("no bundle in any marketplace matches `{text}`"), + io_cli::marketplace::nothing_matched(&text), ); } for hit in hits { @@ -6557,6 +6563,7 @@ async fn loop_over Result>( seen.latest().as_ref(), &reading, reading.max_tokens, + app.status.ceiling, &mask, &app.theme, screen.width(), @@ -8089,6 +8096,7 @@ async fn turn( seen.latest().as_ref(), &contract, contract.max_tokens, + app.status.ceiling, mask, &app.theme, screen.width(), @@ -8567,6 +8575,15 @@ fn note_context( seen: &io_cli::context::Seen, contract: &io_harness::TaskContract, ) { + // **The run's own ceiling, taken before anything divides by it.** Inside this + // function rather than beside its three call sites, for the reason the + // function exists at all: `note_context` is what every door already calls per + // event, and a fourth arm at one of the three is how the headless path or the + // resume path silently keeps the contract's number. The event arrives once, + // beside `Started`, so this runs before the first share is computed. + if let io_harness::EventKind::ContextCeiling { max_tokens, .. } = &event.kind { + app.status.note_ceiling(*max_tokens); + } if let Some(request) = seen.latest() { // **What is LEFT of the run budget, not all of it.** io-harness assembles // against the unspent remainder — a run low on budget gets a smaller @@ -9262,7 +9279,14 @@ async fn wizard( inputs: &mut UnboundedReceiver, theme: Theme, ) -> Result, String> { - let mut wizard = Wizard::new(theme); + // **What this wizard is about to write over (0.38.1).** The welcome screen + // said "No configuration found, so this is the first run" whatever was on + // disk, so `io setup` over a 36 KB `io.toml` told the operator nothing of + // theirs was involved — and then wrote that file. The user scope is the one + // the wizard writes, so it is the one worth naming, and it is reported as in + // force only when it is actually there. + let mut wizard = + Wizard::new(theme).over(io_harness::config::user_path().filter(|path| path.is_file())); // **What the catalogue read already returned, kept instead of thrown away.** // The wizard reads the provider's catalogue to offer a model list, and until // 0.22.0 mapped every row down to its id and dropped the price on it. Holding @@ -9954,13 +9978,25 @@ async fn manage_main( // `marketplace::matching`'s, so the two doors cannot describe a hit // differently, and its first field is the qualified spelling `plugin add` // takes — a script piping this has the thing to install, not a name it has - // to go and disambiguate. Nothing at all is printed for no hits: a listing - // verb that wrote prose to stdout would put a sentence in the middle of - // somebody's pipeline. + // to go and disambiguate. + // + // **No hits says so, and says it on stderr.** The older reading — print + // nothing, because a listing verb writing prose to stdout puts a sentence + // in the middle of somebody's pipeline — was right about the stream and + // wrong about the silence: a search that printed nothing is + // indistinguishable from a search that did not run, which the field test + // found and reported as a broken command. Both halves hold at once by + // splitting the streams the way `io exec --json` and `io resume --list` + // already do — hits on stdout, everything else on stderr — so a pipeline + // reads exactly the hits and a person reads the answer. io_cli::manage::Request::Plugin(io_cli::manage::PluginVerb::Search { text }) => { let markets = io_cli::marketplace::installed() .ok_or_else(|| io_cli::marketplace::NOWHERE.to_string())?; - for hit in io_cli::marketplace::matching(&markets, text) { + let hits = io_cli::marketplace::matching(&markets, text); + if hits.is_empty() { + eprintln!("{}", io_cli::marketplace::nothing_matched(text)); + } + for hit in hits { println!("{hit}"); } } diff --git a/src/manage.rs b/src/manage.rs index 0cf8cc6..ac8375c 100644 --- a/src/manage.rs +++ b/src/manage.rs @@ -1408,7 +1408,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.79.0/src/mcp.rs:439-447`: the + // silent no-op on an HTTP server (`io-harness-0.81.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/markdown.rs b/src/markdown.rs index 07a1f20..a1955cb 100644 --- a/src/markdown.rs +++ b/src/markdown.rs @@ -46,17 +46,27 @@ impl Markdown { let indent = &text[..text.len() - trimmed.len()]; // A fence opens and closes on its own line, and the line itself is not - // content. Drawn as the muted rule it is rather than dropped, so a reader - // can see where the code begins and ends. + // content — so it draws a blank row, which is where the code begins and + // ends. + // + // **The language tag used to be drawn on that row and it read as content + // (0.38.1).** An opening ```` ```python ```` committed the bare word + // `python`, muted, on the line above the code. The intent was to show a + // reader where the block starts; what a reader saw was a one-word + // paragraph, indistinguishable from the model having written the word + // `python` on its own line — which is what the 2026-09-05 field test + // reported it as. Muting is not a distinction at that width: a short + // muted line and a short prose line are the same shape. + // + // Dropped rather than decorated, because the boundary was never carried + // by the word. Everything inside the fence is drawn `Tone::Literal` and + // everything outside it is not, so where the code begins and ends is + // already on the screen in the only way that survives a narrow terminal, + // `--plain`, and `NO_COLOR`. The language is notation about the block and + // the reader is looking at the block. if trimmed.starts_with("```") || trimmed.starts_with("~~~") { self.fenced = !self.fenced; - let language = trimmed.trim_start_matches(['`', '~']).trim(); - let rule = if self.fenced && !language.is_empty() { - format!("{indent}{language}") - } else { - String::new() - }; - return Line::from(Span::styled(rule, theme.style(Tone::Muted))); + return Line::from(Span::styled(String::new(), theme.style(Tone::Muted))); } if self.fenced { return Line::from(Span::styled(text.to_string(), theme.style(Tone::Literal))); diff --git a/src/marketplace.rs b/src/marketplace.rs index 5df58e3..1130cf8 100644 --- a/src/marketplace.rs +++ b/src/marketplace.rs @@ -880,6 +880,24 @@ pub fn fetched( pub const NOWHERE: &str = "io has no home directory of its own, so there is nowhere to keep a marketplace"; +/// What io-cli says when [`matching`] found nothing. +/// +/// **A search that prints nothing is indistinguishable from a search that did not +/// run**, and the two are worth different next moves: one is "no bundle is called +/// that", the other is "the command is broken or the marketplace is empty". An +/// operator staring at a returned prompt has to guess which, and the guess that +/// costs them the most — `plugin search` is quietly broken — is the one a silent +/// answer invites. +/// +/// A function beside [`NOWHERE`] for the same reason that is a constant: two +/// surfaces say it, and the sentence they say has to be one sentence. It carries +/// the term back because a shell scrolls, and an answer that does not repeat the +/// question is an answer to whichever search the reader last remembers typing. +#[must_use] +pub fn nothing_matched(text: &str) -> String { + format!("no bundle in any marketplace matches `{text}`") +} + /// How an act ended. /// /// **Three endings and not a `bool`, because the two that did not change the disk diff --git a/src/memory.rs b/src/memory.rs index 7997e35..ec9f195 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.79.0/src/config.rs:2997`), and the default list is exactly +//! (`io-harness-0.81.0/src/config.rs:3120`), and the default list is exactly //! `["AGENTS.md"]` (`DEFAULT_INSTRUCTIONS`, `config.rs:190`). 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, @@ -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.79.0/src/config.rs:2997`). +/// (`read_instructions`, `io-harness-0.81.0/src/config.rs:3120`). /// /// 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/pluginview.rs b/src/pluginview.rs index d2ecebf..5f8baed 100644 --- a/src/pluginview.rs +++ b/src/pluginview.rs @@ -1067,16 +1067,20 @@ pub fn add(dir: &Path) -> crate::edit::Edit { /// What writing an `enabled` key into a `[[plugin]]` costs an older binary. /// -/// **`enabled` on a `[[plugin]]` is io-harness 0.70.0's, and 0.69.0 does not -/// ignore it — it refuses the whole file.** The `[[mcp]]` case is the opposite and -/// that is exactly why this has to be said out loud: an operator who has seen a -/// forward-compatible key before will assume this one is too. A shared `io.toml` -/// or a second machine on the older binary loses every setting in the file, not -/// the bundle. +/// **`enabled` on a `[[plugin]]` is understood from io-cli 0.29.0, and an older +/// binary does not ignore it — it refuses the whole file.** The `[[mcp]]` case is +/// the opposite and that is exactly why this has to be said out loud: an operator +/// who has seen a forward-compatible key before will assume this one is too. A +/// shared `io.toml` or a second machine on the older binary loses every setting in +/// the file, not the bundle. +/// +/// **Named as an io-cli release rather than an io-harness one (0.38.2)** — see +/// [`crate::servers::OLDER_BINARY`] for the reasoning, which is the same on both +/// halves of the pair. pub const OLDER_BINARY: &str = - "this writes an `enabled` key into a `[[plugin]]` entry, which is io-harness 0.70.0's: an \ - io-cli built against 0.69.0 refuses the whole configuration file rather than ignoring the \ - key"; + "this writes an `enabled` key into a `[[plugin]]` entry, which io-cli understands from 0.29.0: \ + an older io-cli sharing this file refuses the whole configuration file rather than ignoring \ + the key"; // **`add_off` and `declared_off` were deleted in 0.30.0.** // diff --git a/src/provider.rs b/src/provider.rs index 01419dc..70a7602 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -173,6 +173,50 @@ impl Provider for Vendor { } } + // **The two window answers, and the reason they are written out rather than + // left to the trait.** io-harness 0.81.0 added `context_window` and + // `max_output_tokens` with `None` defaults, so every wrapper in this file + // compiles without them and answers "this provider is not saying" — which + // sends the run back to `context::FALLBACK_MAX_TOKENS`, the 24,000 the + // harness release exists to stop being universal. + // + // **What this does NOT do today, stated because the first draft of this + // comment claimed otherwise.** In io-harness 0.81.0 exactly one shipped + // provider implements either method — `Compatible`, at + // `io-harness-0.81.0/src/provider/compatible.rs:512` — and it answers only + // from a catalogue `models()` has already fetched *on that instance*. + // `OpenRouter`, `Anthropic` and `OpenAi` declare neither and take the trait's + // `None`, and this crate never awaits `models()` on the provider it hands a + // run. So on every configuration io-cli builds today the chain answers `None`, + // the harness emits `source: "fallback"`, and the ceiling is the same 24,000 + // it was before. Delegating is still right and still necessary — it is the + // wrapper's whole job, and a wrapper that answers for itself is wrong whatever + // the inner provider says — but the operator-visible change waits on + // io-harness, and `US-IO-CLI-0.38.2-I01` records that. + // + // There is no compiler error and no test failure for a defaulted method: the + // only gate is `f2_every_provider_method_is_delegated_by_every_wrapper`, which + // reads the method list out of the locked harness rather than a literal — and + // it is a source-text gate, so it proves the method is written and never that + // anything answers. + fn context_window(&self) -> Option { + match self { + Self::OpenRouter(p) => p.context_window(), + Self::Anthropic(p) => p.context_window(), + Self::OpenAi(p) => p.context_window(), + Self::Compatible(p) => p.context_window(), + } + } + + fn max_output_tokens(&self) -> Option { + match self { + Self::OpenRouter(p) => p.max_output_tokens(), + Self::Anthropic(p) => p.max_output_tokens(), + Self::OpenAi(p) => p.max_output_tokens(), + Self::Compatible(p) => p.max_output_tokens(), + } + } + fn name(&self) -> &str { match self { Self::OpenRouter(p) => p.name(), @@ -370,6 +414,44 @@ impl Provider for Chain

{ self.head().model_hint() } + /// **The smallest window any link declares, for `accepts_images`'s reason.** + /// + /// The head's window is the wrong answer for the same reason the head's image + /// support is: a run assembles its context once, and the call that matters is + /// the fall-through. Sizing to a 200,000-token head and falling through to a + /// 32,000-token link overflows the request precisely when the first link has + /// already failed, which is the worst moment to discover it. + /// + /// A link that says nothing is not a link that says zero — it is skipped, and + /// `None` here means no link in the chain declared a window at all, which + /// sends the run to io-harness's own fallback exactly as before. + fn context_window(&self) -> Option { + self.links.iter().filter_map(Provider::context_window).min() + } + + /// **The largest reserved answer any link declares — the opposite of the + /// window above, because this number is subtracted rather than divided by.** + /// + /// The instinct is that the two are the same question and take the same + /// conservative direction. They are not. `ContextBudget::for_window` computes + /// `max_tokens = window - (max_output + floor)`, so a *smaller* reserve leaves + /// a *larger* assembled section: taking the minimum reserves room for the link + /// that answers least and then overflows on the link that answers most. + /// + /// Concretely, two links on 128,000-token windows, the head allowing 4,096 + /// output tokens and the fall-through allowing 32,768. The minimum reserves + /// 4,096, leaving 115,712 for the prompt; the fall-through may then answer + /// 32,768 into a 128,000 window that already holds 115,712. That is the same + /// overflow the window's minimum exists to prevent, arriving at the same + /// moment — after the head has failed — through the arithmetic running the + /// other way. + fn max_output_tokens(&self) -> Option { + self.links + .iter() + .filter_map(Provider::max_output_tokens) + .max() + } + fn name(&self) -> &str { self.head().name() } @@ -623,6 +705,14 @@ impl Provider for Watched

{ self.inner.model_hint() } + fn context_window(&self) -> Option { + self.inner.context_window() + } + + fn max_output_tokens(&self) -> Option { + self.inner.max_output_tokens() + } + fn name(&self) -> &str { self.inner.name() } @@ -729,6 +819,14 @@ impl Provider for Printable

{ self.inner.model_hint() } + fn context_window(&self) -> Option { + self.inner.context_window() + } + + fn max_output_tokens(&self) -> Option { + self.inner.max_output_tokens() + } + fn name(&self) -> &str { self.inner.name() } diff --git a/src/reload.rs b/src/reload.rs index 70695ad..17618b6 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.79.0/src/config.rs:2997`), composes each named file into a +//! (`io-harness-0.81.0/src/config.rs:3120`), 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 diff --git a/src/servers.rs b/src/servers.rs index d2dc7fd..0a55306 100644 --- a/src/servers.rs +++ b/src/servers.rs @@ -84,8 +84,8 @@ //! //! **What writing it costs an older binary is not what the plugin key costs, and //! the two sentences are deliberately different.** See [`OLDER_BINARY`] beside -//! [`crate::pluginview::OLDER_BINARY`]: a 0.69.0 binary *refuses the whole file* -//! over `enabled` in a `[[plugin]]`, and *silently ignores it* in an `[[mcp]]` — +//! [`crate::pluginview::OLDER_BINARY`]: an io-cli older than 0.29.0 *refuses the +//! whole file* over `enabled` in a `[[plugin]]`, and *silently ignores it* in an `[[mcp]]` — //! running a server the operator switched off. One is loud and total, the other is //! quiet and exactly the failure mode this module exists to close. //! @@ -609,19 +609,28 @@ pub const KEYS: &[&str] = &[ /// What writing an `enabled` key into an `[[mcp]]` entry costs an older binary. /// /// **The mirror image of [`crate::pluginview::OLDER_BINARY`], and the dangerous -/// half of the pair.** `enabled` on a `[[plugin]]` makes an io-cli built against -/// io-harness 0.69.0 refuse the whole configuration file: loud, total, and -/// impossible to miss. `[[mcp]]` is one of the two sections that binary exempts -/// from `deny_unknown_fields`, so the same key there is **read, accepted and -/// ignored** — and the server the operator switched off starts and runs, on that -/// machine, with nothing on any surface saying so. +/// half of the pair.** `enabled` on a `[[plugin]]` makes an older io-cli refuse +/// the whole configuration file: loud, total, and impossible to miss. `[[mcp]]` +/// is one of the two sections that binary exempts from `deny_unknown_fields`, so +/// the same key there is **read, accepted and ignored** — and the server the +/// operator switched off starts and runs, on that machine, with nothing on any +/// surface saying so. /// /// So the two sentences are two constants. One string used for both would be /// telling an operator on the quiet path that they will notice. +/// +/// **The sentence names an io-cli release and never an io-harness one (0.38.2).** +/// The key is io-harness 0.70.0's and io-cli first pinned that in its own 0.29.0, +/// so both facts describe the same boundary — but an operator knows which `io` +/// they installed and has no reason to know which harness it was built against. +/// The field test read the old spelling as a stale note about a dependency and +/// not as a warning about their own second machine, which is the whole failure. +/// It also stops the sentence rotting at every pin, which is what +/// `f4_no_compatibility_note_names_an_io_harness_version` now holds it to. pub const OLDER_BINARY: &str = - "this writes an `enabled` key into an `[[mcp]]` entry, which is io-harness 0.70.0's: an io-cli \ - built against 0.69.0 does not refuse it — it ignores the key and starts the server anyway, \ - with nothing said"; + "this writes an `enabled` key into an `[[mcp]]` entry, which io-cli understands from 0.29.0: \ + an older io-cli sharing this file does not refuse the key — it ignores it and starts the \ + server anyway, with nothing said"; /// The edit that switches the server at `at` on or off. /// diff --git a/src/sessions.rs b/src/sessions.rs index 2aafee5..b88cd62 100644 --- a/src/sessions.rs +++ b/src/sessions.rs @@ -222,15 +222,34 @@ pub fn resume(store: &Store, id: i64) -> Result String { +/// +/// **The marker, and why it is a marker rather than local time (0.38.1).** The +/// store writes UTC and this drew it bare, so the 2026-09-05 field test read +/// `03:21` at twenty to nine in the morning and had no way to know the two were +/// the same instant — an unmarked timestamp is worse than no timestamp, because a +/// reader believes it. +/// +/// Local time was the friendlier answer and it is not available here. Converting +/// needs the current offset, which needs a clock this module may not read and a +/// timezone database this crate has no dependency for — and adding one would take +/// the direct dependency set past the ten names `tests/dependencies.rs` holds it +/// to, for a rendering. So the fact is stated instead of being converted, which +/// is the same trade this file already makes by refusing to say *two minutes +/// ago*. +pub const ZONE: &str = " UTC"; + +/// `pub` from 0.38.2, because `io resume --list` draws the same kind of stamp and +/// two spellings of a timestamp on two listings of the same store is exactly the +/// disagreement `ZONE` exists to end. +pub fn stamp(created_at: &str) -> String { let cut: String = created_at.chars().take(16).collect(); - cut.replace('T', " ") + format!("{}{ZONE}", cut.replace('T', " ")) } /// The mark a session's row carries, or `None` for one that finished. diff --git a/src/status.rs b/src/status.rs index 3342ae1..76bf484 100644 --- a/src/status.rs +++ b/src/status.rs @@ -331,6 +331,27 @@ pub struct Status { /// number never measured. The window beside it is not cleared, because the /// file did not change. pub context: Option, + /// The ceiling this run announced it would assemble under, in tokens (0.38.2). + /// + /// **The denominator, taken from the run rather than guessed from the + /// contract.** io-harness 0.81.0 emits `EventKind::ContextCeiling` once per + /// run, beside `Started`, carrying the number and one word for what decided + /// it. Before that release the contract was the only thing that knew and it + /// was always right; now the harness derives the ceiling from the model's own + /// window, and a `TaskContract` this crate built with io-harness's default + /// budget says `24_000` about a run assembling inside 128,000. + /// + /// It is a **run** fact and is cleared by [`Status::forget_run`] with the + /// others: a resumed or cleared conversation may run on a different model + /// entirely, and carrying the old ceiling forward would divide the new run's + /// pressure by the previous model's window — a wrong percentage that looks + /// exactly like a right one. + /// + /// `None` is "this run has not said", which is every run against a harness + /// older than 0.81.0 and every moment before the first event of one that is + /// not. [`crate::context::window`] falls back to the contract there, which is + /// the pre-0.38.2 expression byte for byte. + pub ceiling: Option, /// How this run's commands are contained: the mode asked for and the backend /// that actually answered on this host. /// @@ -635,6 +656,7 @@ impl Status { run_tokens: None, cost: None, context: None, + ceiling: None, containment: None, boundary: None, branch: None, @@ -715,6 +737,9 @@ impl Status { self.provider = None; self.steps = None; self.context = None; + // Beside the share it is the denominator of. A ceiling belongs to the run + // that announced it, and the next run may be a different model. + self.ceiling = None; self.containment = None; // Beside the containment word it qualifies, and for the same reason: a // measurement belongs to the run that took it, and carrying it onto the @@ -977,8 +1002,38 @@ impl Status { /// is what enforces it, so a section briefly over its window is ordinary. /// `ctx 137%` would read as a bug in this line rather than as the pressure it /// actually is. + /// The ceiling this run announced, from `EventKind::ContextCeiling` (0.38.2). + /// + /// **One setter, called once per run, and it overwrites rather than fills.** + /// A resume announces again; so does the next turn of a session whose model + /// changed under `/model`. Taking the first answer and keeping it would be the + /// same class of defect as the field this replaces — a denominator that was + /// true when it was written and is not now. + /// + /// A zero is refused rather than stored. `crate::context::window` filters it + /// too, and both are deliberate: a window that divides is a number somebody + /// eventually divides by, and this crate has already spent two releases on + /// exactly that. + pub fn note_ceiling(&mut self, max_tokens: u64) { + if max_tokens > 0 { + self.ceiling = Some(max_tokens); + } + } + pub fn note_context(&mut self, est_tokens: u64) { - let Some(window) = self.budgets.window.filter(|window| *window > 0) else { + // **The announced ceiling first, and the contract only where nothing was + // announced (0.38.2).** This divided by `budgets.window` alone, which is + // the contract's own budget — so a fold arriving through + // `EventKind::Compacted` computed its share against 24,000 while the + // `/context` page one keystroke away used the run's real ceiling. Two + // denominators for one word on one screen is the failure this field has + // already been fixed for once; a third reader of the same question is how + // it came back. + let Some(window) = self + .ceiling + .or(self.budgets.window) + .filter(|window| *window > 0) + else { return; }; let share = (est_tokens as f64 / window as f64 * 100.0).round(); @@ -1040,7 +1095,7 @@ impl Status { contract: &io_harness::TaskContract, remaining: Option, ) { - let window = crate::context::window(contract, remaining); + let window = crate::context::window(contract, remaining, self.ceiling); if window == 0 { return; } @@ -2335,7 +2390,13 @@ pub fn committed( // every contract declares one. The arm below is that type's `None` spelled out // rather than an `unwrap` — a committed page is not worth a panic — and it is // unreachable from here. - let window = match Budgets::in_force(contract).window { + // The same denominator the share was taken against, and not the contract's + // (0.38.2). `status.context` is a percentage of the announced ceiling; drawing + // it beside the contract's number would print a fill and a window that do not + // multiply out — `39% of a 24,000 window` on a run assembling inside 103,424. + // The page and the line were made one expression for exactly this reason and + // this is the third surface asking the same question. + let window = match status.ceiling.or(Budgets::in_force(contract).window) { Some(tokens) => format_tokens(tokens), None => "unknown".to_string(), }; diff --git a/src/triage.rs b/src/triage.rs index 4cb158d..dfeb069 100644 --- a/src/triage.rs +++ b/src/triage.rs @@ -327,6 +327,57 @@ pub const TRIAGE: &[(&str, Disposition, &str)] = &[ "the dial line, carrying the host as the command asked for it, the port, and whether the \ policy permitted it", ), + // **0.38.2 — the three kinds io-harness 0.80.0 and 0.81.0 added, arriving + // together on one pin bump.** None of them fails to compile: `EventKind` is + // `#[non_exhaustive]`, so a kind with no row falls through the wildcard and is + // drawn by nothing, silently. This table is the only thing that notices, and + // it noticed all three the moment the pin moved. + // + // **`context_ceiling` is a status field and not a line**, because the number + // it carries is a denominator rather than an event: `ctx N%` is the assembled + // request over exactly this ceiling, and until io-harness 0.81.0 the contract + // was the only thing that knew it. `Status::note_ceiling` takes it and + // `context::window` divides by it, so the share and the `/context` page move + // together — they disagreed once, on one screen, and the fix then was to make + // them one expression. A line would be a row per run restating a number + // already on the line, and the one case worth acting on — `source: + // "fallback"`, the harness guessing — is worth a release that renders it + // rather than a row that mentions it. + ( + "context_ceiling", + Disposition::Status, + "the `ctx` field's denominator, and the `/context` page's total, which are one expression \ + so that they cannot disagree", + ), + // **`step_usage` is silent for exactly `step_attributed`'s reason**, and it is + // the same shape of fact: a breakdown emitted beside every committed step, + // whose numbers no surface in this release renders. The cached-versus-fresh + // split it carries is the answer to a real complaint — the running `tok` + // figure reads as though every re-sent catalogue were paid in full — and + // answering it is a footer this release does not build. Recorded as a close + // call, like its sibling, rather than as an obvious silence. + ( + "step_usage", + Disposition::Silent, + "`io exec --json`, which forwards it verbatim, and the durable trace; the fresh-versus-\ + cached split it carries is what the footer's `tok` figure does not yet separate, and \ + separating it is a release rather than a row", + ), + // **`image_attached` is silent because the picture it announces is one this + // crate did not hand over.** `src/picture.rs` already draws every image the + // operator attached, at the moment they attach it; this event fires for one + // that reached the run through an MCP reply, a browser screenshot or + // `view_image`, which is plumbing inside io-harness that emitted nothing at + // all before 0.81.0. Drawing it is a renderer, and a renderer is the roadmap's + // work rather than a patch's. A line naming a picture without showing it would + // be the class of sentence this release exists to delete. + ( + "image_attached", + Disposition::Silent, + "`io exec --json`, which forwards it verbatim, and the durable trace; the image reached \ + the run inside io-harness rather than through the operator's own attachment command, \ + and no surface in this release draws one this crate did not hand over", + ), ( "finished", Disposition::Line, diff --git a/src/wizard.rs b/src/wizard.rs index eb29d54..97130b5 100644 --- a/src/wizard.rs +++ b/src/wizard.rs @@ -19,6 +19,8 @@ //! catalogue — are handed back to the driver rather than performed here, so this //! whole flow is drivable from a test with a scripted key sequence. +use std::path::PathBuf; + use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use io_harness::ProviderSpec; use ratatui::layout::Rect; @@ -197,9 +199,36 @@ pub struct Wizard { /// variable also keeps the theme step drivable from a test that never has to /// set a process-wide variable. no_color: bool, + /// The configuration file already in force, when there is one. + /// + /// **The welcome screen claimed a first run unconditionally (0.38.1).** It + /// said "No configuration found, so this is the first run" to an operator + /// running `io setup` over a 36 KB `io.toml`, which the 2026-09-05 field test + /// found and which is worse than merely wrong: the sentence is what tells + /// somebody nothing of theirs is at stake, and the wizard goes on to write + /// that exact file. + /// + /// Set through [`Wizard::over`] rather than through `new`, because `new` has + /// fifteen call sites in the suite and one in the driver, and a parameter + /// added for the driver's benefit would be `None` at every other one anyway. + /// The default is the first-run wording, so the honest reading is something + /// the one real caller supplies — and + /// `f8_the_wizard_is_told_what_configuration_is_already_in_force` holds it to + /// that, because nothing under `tests/` links the driver. + existing: Option, } impl Wizard { + /// What configuration this wizard is about to write over, if any. + /// + /// See the field: this is the driver's job to supply, and the gate over + /// `src/main.rs` is what makes it not optional in practice. + #[must_use] + pub fn over(mut self, existing: Option) -> Self { + self.existing = existing; + self + } + pub fn new(theme: Theme) -> Self { Self { step: Step::Welcome, @@ -224,6 +253,7 @@ impl Wizard { rejection: None, env_key_present: false, no_color: !theme.coloured, + existing: None, } } @@ -397,11 +427,19 @@ impl Wizard { .map(|kind| Row::with_detail(kind.label(), kind.detail())) .collect(), )); + // **What is at stake, said correctly (0.38.1).** The first line claimed a + // first run whatever was on disk, so an operator running `io setup` over + // a configuration they had spent months on was told nothing of theirs was + // involved — and then the wizard wrote that same file. Naming the file is + // also more useful than the old sentence ever was on a genuine first run, + // where "no configuration found" and "this is the first run" say the same + // thing twice. + let opening = match &self.existing { + Some(path) => format!("Configuration in force: {}.", path.display()), + None => "No configuration found, so this is the first run.".to_string(), + }; Progress::Commit(vec![ - Line::from(Span::styled( - "No configuration found, so this is the first run.", - self.theme.style(Tone::Normal), - )), + Line::from(Span::styled(opening, self.theme.style(Tone::Normal))), Line::from(Span::styled( "Nothing is written until the last screen says so.", self.theme.style(Tone::Muted), diff --git a/tests/adapt.rs b/tests/adapt.rs index 9f6dd9a..36bf1a8 100644 --- a/tests/adapt.rs +++ b/tests/adapt.rs @@ -510,7 +510,7 @@ fn loaded(into: &Path) -> Plugins { /// that io wrote until 0.35.0. The generator still runs and the fixture still /// builds — and `dropped()` carries the whole bundle, because io-harness 0.74.0 /// refuses an absolute `skills` or `templates` in every scope, `Scope::User` -/// included (`io-harness-0.79.0/src/plugin.rs:1097`): a manifest contributes a +/// included (`io-harness-0.81.0/src/plugin.rs:1097`): a manifest contributes a /// directory it ships rather than one it points at somewhere else on the machine, /// since every `*.md` under it is read into the model's system prompt. #[test] diff --git a/tests/context.rs b/tests/context.rs index d1ec2af..beeacc2 100644 --- a/tests/context.rs +++ b/tests/context.rs @@ -338,20 +338,86 @@ fn f7_the_sections_sum_to_the_total_and_the_total_is_stated_against_the_window() // The denominator is the contract's own budget, not `ContextBudget::default`: // an operator who tightened `[context]` must see the ceiling they set. - assert_eq!(context::window(&contract, None), 24_000); + assert_eq!(context::window(&contract, None, None), 24_000); let tightened = TaskContract::workspace("g", "/repo").with_context_budget(io_harness::ContextBudget { max_tokens: 8_000, share: 0.25, }); - assert_eq!(context::window(&tightened, None), 8_000); + assert_eq!(context::window(&tightened, None, None), 8_000); assert_eq!( - context::window(&tightened, Some(20_000)), + context::window(&tightened, Some(20_000), None), 5_000, "with a run budget the window is a share of what is left", ); } +/// **F1 — the ceiling the run announced is the denominator, and it wins.** +/// +/// Before io-harness 0.81.0 the contract was the only thing that knew the window, +/// so the two could not disagree and this assertion had nothing to say. They +/// disagree now: the harness derives the ceiling from the model's own window and +/// announces it, while the `TaskContract` this crate built still carries the +/// budget it was built with. Dividing by the contract after that reports `ctx +/// 100%` on a run a fifth of the way into a large window — a number five times +/// too small at exactly the moment an operator uses it to decide whether to fold. +/// +/// The three arms are the three things that can be wrong: the announcement +/// ignored, the announcement taken when the operator set a budget of their own +/// (it is not — io-harness announces an explicit budget as `source: "contract"`, +/// so taking the announcement *is* taking their answer), and a zero stored as a +/// denominator. +/// +/// Sabotage: drop the `announced` parameter from `context::window` and read the +/// contract unconditionally, which is the pre-0.38.2 body. +#[test] +fn f1_an_announced_ceiling_is_the_window_the_share_is_taken_against() { + let contract = contract(); + + assert_eq!( + context::window(&contract, None, Some(128_000)), + 128_000, + "the run said it assembles inside 128,000 and the contract's 24,000 is \ + the guess it replaces", + ); + + // A run that has not announced — every run against a harness older than + // 0.81.0, and every moment before the first event of one that is not — is the + // pre-0.38.2 expression byte for byte. + assert_eq!( + context::window(&contract, None, None), + 24_000, + "with nothing announced the contract is still the answer, unchanged", + ); + + // Zero is refused rather than divided by. A window that divides is a number + // somebody eventually divides by. + assert_eq!( + context::window(&contract, None, Some(0)), + 24_000, + "a zero ceiling falls back rather than becoming a denominator", + ); + + // And the same value reaches `ctx N%`, through `Status`, rather than being a + // second reader of the event. + let mut status = io_cli::status::Status::new("a-model"); + status.note_ceiling(128_000); + assert_eq!(status.ceiling, Some(128_000)); + status.note_ceiling(0); + assert_eq!( + status.ceiling, + Some(128_000), + "a zero announcement does not erase the ceiling already in force", + ); + status.forget_run(); + assert_eq!( + status.ceiling, None, + "a ceiling belongs to the run that announced it: the next run may be a \ + different model, and dividing its pressure by this window would be a \ + wrong percentage that looks exactly like a right one", + ); +} + // --------------------------------------------------------------------------- // The catalogue is the wire's, not io-cli's // --------------------------------------------------------------------------- @@ -375,6 +441,7 @@ fn f7_the_catalogue_names_a_tool_only_the_request_knew_about() { Some(&Request::of(&request())), &contract(), None, + None, &ToolMask::none(), &ascii(), 80, @@ -432,6 +499,7 @@ fn f7_the_system_block_is_tokens_of_its_own_text_and_not_a_byte_count() { Some(&seen), &contract, None, + None, &ToolMask::none(), &ascii(), 80, @@ -466,6 +534,7 @@ fn f7_the_page_draws_in_ascii_and_says_so_before_a_turn_has_run() { None, &contract(), None, + None, &ToolMask::none(), &ascii(), 80, @@ -482,6 +551,7 @@ fn f7_the_page_draws_in_ascii_and_says_so_before_a_turn_has_run() { Some(&Request::of(&request())), &contract(), None, + None, &ToolMask::none(), &ascii(), 80, @@ -525,7 +595,7 @@ fn f10_the_status_share_is_the_page_total_over_the_page_window() { let sections = context::sections(&seen, &contract); let total = context::total(§ions); - let window = context::window(&contract, contract.max_tokens); + let window = context::window(&contract, contract.max_tokens, None); assert!( total > 0 && window > 0, "the fixture has to put something in a window for this to mean anything", @@ -549,6 +619,51 @@ fn f10_the_status_share_is_the_page_total_over_the_page_window() { expected > 0, "a fixture where both read zero would pass this test and prove nothing", ); + + // **The same agreement once a ceiling has been announced (0.38.2), which is + // the arm this test was missing.** Everything above runs with nothing + // announced, so it agreed for the old reason and would have gone on agreeing + // while three other surfaces divided by the contract. An adversarial review + // found `/status`'s label and `Status::note_context` both still reading + // `budgets.window`; this is the arm that would have caught them. + const ANNOUNCED: u64 = 103_424; + let mut status = io_cli::status::Status::new("a-model"); + status.budgets = io_cli::status::Budgets::in_force(&contract); + status.note_ceiling(ANNOUNCED); + status.note_context_request(&seen, &contract, contract.max_tokens); + + let announced_window = context::window(&contract, contract.max_tokens, Some(ANNOUNCED)); + assert_eq!( + announced_window, ANNOUNCED, + "the announced ceiling is the page's denominator", + ); + let expected = (total as f64 / ANNOUNCED as f64 * 100.0).round() as u8; + assert_eq!( + status.context, + Some(expected), + "the share follows the announced ceiling, not the contract's budget", + ); + + // `note_context` is the OTHER writer of the same field — a fold arrives on + // `EventKind::Compacted` and goes straight through it — and it divided by + // `budgets.window` alone, so one screen carried two denominators for one word. + let mut folded = io_cli::status::Status::new("a-model"); + folded.budgets = io_cli::status::Budgets::in_force(&contract); + folded.note_ceiling(ANNOUNCED); + folded.note_context(30_000); + assert_eq!( + folded.context, + Some((30_000f64 / ANNOUNCED as f64 * 100.0).round() as u8), + "a fold's share is taken against the run's ceiling too — against the \ + contract's 24,000 this reads 100%, which is the pressure it is reporting \ + wrongly", + ); + assert_ne!( + folded.context, + Some(100), + "and 100% is what the contract's budget would have produced, so this is \ + the arm that tells the two apart", + ); } /// The window is forgotten with the conversation it described. @@ -844,6 +959,7 @@ fn f9_the_withheld_row_is_absent_until_there_is_one_and_then_states_its_cost() { Some(&Request::of(&request())), &contract(), None, + None, &mask, &ascii(), 80, @@ -890,7 +1006,7 @@ fn f9_allow_reports_a_no_op_and_a_clear_names_what_returned() { /// **The defect this replaces was the safety lever being silently inert.** /// io-harness keeps an unknown mask name rather than rejecting it, deliberately, /// so a mask stays portable across builds with different cargo features -/// (`io-harness-0.79.0/src/tools/mod.rs:55-58`). That means `mask_gate` matches on +/// (`io-harness-0.81.0/src/tools/mod.rs:55-58`). That means `mask_gate` matches on /// the exact string and a misspelling withholds nothing — while the operator was /// told "calling it will be refused before anything starts" and `/context` drew it /// on the withheld row. `/context withhold Docx_Write` and the file gets written. diff --git a/tests/contract.rs b/tests/contract.rs index d198565..5c85bf5 100644 --- a/tests/contract.rs +++ b/tests/contract.rs @@ -1829,7 +1829,7 @@ fn f5_servers_in_both_scopes_are_merged_and_a_collision_is_named() { /// **F7 — a tilde is a home directory, never a directory named `~`.** /// /// io-harness substitutes `${env:…}` and `${file:…}` and nothing else — there is -/// no tilde branch anywhere in `io-harness-0.79.0/src/config.rs` — so a `~` an +/// no tilde branch anywhere in `io-harness-0.81.0/src/config.rs` — so a `~` an /// operator writes in `[run] skills` reaches `Skills::discover` verbatim and the /// harness looks inside a directory whose name is one character long. The /// operator's skills sit exactly where they said they would, and the session diff --git a/tests/docs.rs b/tests/docs.rs index d67caae..e7fed03 100644 --- a/tests/docs.rs +++ b/tests/docs.rs @@ -2517,9 +2517,9 @@ fn f6_the_headless_guide_carves_out_the_provider_endpoint() { /// the risk is a sentence rather than a branch.** io-harness offers a masked turn a /// byte-identical catalogue on purpose: the tool array sits ahead of the provider's /// cache breakpoint, so dropping a definition would save its tokens once and pay a -/// cache *write* on every later turn (`io-harness-0.79.0/src/tools/mod.rs:33-42`). +/// cache *write* on every later turn (`io-harness-0.81.0/src/tools/mod.rs:33-42`). /// Withholding in fact makes the request marginally **larger**, by one sentence -/// naming what is withheld (`src/run/prompts.rs:1133`). +/// naming what is withheld (`io-harness-0.81.0/src/run/prompts.rs:1381`). /// /// The roadmap entry 0.37.0 was planned from assumed the opposite and said so in /// its headline. That framing is what a writer reaches for, because "withhold" means @@ -2582,3 +2582,662 @@ fn n3_no_shipped_page_claims_a_mask_reduces_what_a_turn_costs() { bad.join("\n"), ); } + +// --------------------------------------------------------------------------- +// 0.38.1 F10 — the front page and the contract page reach the five releases of +// work that happened after them. +// +// The README was 240 lines that predated 0.34.0. ACP — two releases of work and +// this crate's whole answer to an editor — was named once, in a row of the guides +// table. The tool mask, the schema contract and `RunOutcome::SchemaUnsatisfied` +// were named zero times, and the last of those is an exit a scripted caller +// branches on. Nothing failed, because nothing asked. +// +// These four gates are the asking. Each one is bound to something the page cannot +// edit: a constant, a parse, an outcome's own mapping, or a file on disk. A page +// that stops being true has to break one of them. +// --------------------------------------------------------------------------- + +/// **F10 — both front pages name the editor door, and the door is still there.** +/// +/// `io acp` is 0.36.0's adapter and 0.38.0's permission round trip, and until this +/// release an operator arriving at the repository could learn of it only from a +/// six-word cell in the guides table. Zed and a JetBrains IDE are the two clients +/// the protocol work was written against, so naming the protocol without naming +/// them leaves a reader unable to tell whether their editor is one of them. +/// +/// The transport claim is the one most able to go quietly wrong: ACP is +/// **newline-delimited** JSON-RPC 2.0 and not LSP's `Content-Length` framing, and a +/// writer who has met LSP first will write the other one. Both pages say the +/// newline form and neither offers the header form. +/// +/// Bound to the code twice. [`io_cli::acp::PROTOCOL_VERSION`] is the version the +/// server answers with, so a v2 that moved past these pages fails here; and +/// `src/acp.rs` really does raise `session/request_permission`, which is the +/// sentence 0.38.0 earned and the one a page could otherwise claim on its own. +/// +/// Sabotage: delete any needle from either page; bump `PROTOCOL_VERSION`; or take +/// the permission request out of `src/acp.rs`. Each fails on its own. +#[test] +fn f10_the_front_page_and_the_contract_name_the_editor_door() { + // The permission round trip exists in the code the prose is describing — + // `f2_the_install_discloses_before_it_writes_and_says_so`'s shape, for its + // reason: the prose half alone would pass if the feature were ripped out. + let acp = std::fs::read_to_string(repo().join("src/acp.rs")).expect("src/acp.rs exists"); + assert!( + acp.contains("\"session/request_permission\""), + "both pages say an editor session is asked for permission, and nothing in \ + src/acp.rs sends the request that asks", + ); + assert_eq!( + io_cli::acp::PROTOCOL_VERSION, + 1, + "the pages describe the v1 protocol; the server now answers a different \ + version and both are describing a wire this build does not speak", + ); + + let readme = flat(&read("README.md")); + for needle in [ + // The protocol, its framing, and the two clients — in one cell, because a + // reader deciding whether this product reaches their editor is asking one + // question. + "`io acp` serves the Agent Client Protocol (ACP) as newline-delimited JSON-RPC 2.0 over stdio", + "Zed or a JetBrains IDE", + // 0.38.0's half. Without it the row describes 0.36.0. + "arrives as a `session/request_permission` you answer in the editor", + ] { + assert!( + readme.contains(needle), + "README.md is missing {needle:?}. ACP is this crate's answer to an \ + editor and two releases of work; a front page that names it once in a \ + table row has not reached it", + ); + } + + let contract = flat(&read("docs/CONTRACT.md")); + for needle in [ + "Serves the Agent Client Protocol (ACP) on stdin and stdout", + "an ACP client such as Zed or a JetBrains IDE spawns it", + // Already on the page and asserted here so it cannot leave with the rest: + // the framing is the claim a rewrite is most likely to get wrong. + "speaks newline-delimited JSON-RPC 2.0 at its stdin", + "session/request_permission", + ] { + assert!( + contract.contains(needle), + "docs/CONTRACT.md is missing {needle:?}; `io acp` is a subcommand a \ + script and an editor both address and this is the page that enumerates \ + what may be depended on", + ); + } + + // And the framing nobody may claim: ACP is newline-delimited, and a page that + // said `Content-Length` would be describing LSP. + for (name, text) in shipped_prose() { + assert!( + !text.contains("Content-Length"), + "{name} describes the ACP transport with LSP's header framing; ACP \ + delimits frames with newlines and forbids an interior one", + ); + } +} + +/// **F10 — both front pages name the tool mask, in the direction that is true.** +/// +/// 0.37.0 built `/context withhold` and neither front page mentioned it. The gate +/// that already exists — `n3_no_shipped_page_claims_a_mask_reduces_what_a_turn_costs` +/// — forbids the wrong sentence everywhere, and a page that says nothing satisfies +/// it perfectly. This is the positive half: the pages have to carry the claim, and +/// carrying it is what puts them under that sweep at all. +/// +/// Bound to [`io_cli::context::withheld_line`], which is the sentence the product +/// itself prints. A release that changed the mask's cost story would change that +/// function, and the pages would then be describing something else. +/// +/// Sabotage: delete either page's mask sentence, or teach `withheld_line` to +/// promise a saving. Each fails on its own. +#[test] +fn f10_both_pages_name_the_tool_mask_and_say_what_it_does_not_do() { + // What the product says when a tool is withheld. The needle is the *direction* + // — the catalogue is unchanged — rather than the whole sentence, because the + // sentence is prose and gets reworded. + let mask = io_harness::ToolMask::withholding(["docx_write"]); + let line = io_cli::context::withheld_line(&mask, "—") + .expect("a non-empty mask earns the line that explains it"); + assert!( + line.contains("The catalogue above is unchanged"), + "`withheld_line` stopped saying the catalogue is unchanged, so the pages \ + below are describing a cost story the product no longer tells: {line}", + ); + + let readme = flat(&read("README.md")); + for needle in [ + "`/context withhold ` builds the session's tool mask", + "keeps that tool refused until `/context allow`", + // The honest half, which is the whole reason this row is hard to write. + "io-harness sends a byte-identical catalogue either way, so the request \ + grows by the one sentence naming what is withheld", + ] { + assert!( + readme.contains(needle), + "README.md is missing {needle:?}. The mask is 0.37.0's capability and \ + the front page named it zero times; a row that named it without the \ + second half would be the exact sentence N3 exists to forbid", + ); + } + + let contract = flat(&read("docs/CONTRACT.md")); + for needle in [ + "**The mask changes what the agent may call and not what a turn costs.**", + "io-harness sends the same catalogue either way and io adds one sentence \ + naming what is withheld, so a masked request is the longer of the two", + // The portability rule, which is why a misspelling is kept rather than + // refused — a script author reading this page is the person most likely to + // write a name against the wrong build. + "because io-harness keeps an unknown name deliberately so a mask stays \ + portable between builds", + ] { + assert!( + contract.contains(needle), + "docs/CONTRACT.md is missing {needle:?}; the mask is the one mid-turn \ + command that changes something, and this page already says so without \ + saying what it changes", + ); + } +} + +/// **F10 — both front pages name the schema contract and the outcome it produces.** +/// +/// `RunOutcome::SchemaUnsatisfied` is io-harness 0.77.0's, undeclared in its own +/// changelog, and `RunOutcome` is `#[non_exhaustive]` — so it arrived here silently +/// and `src/exec.rs` maps it to `6`. Every documented account of `6` said "a +/// verification gate", which is one of the two failures that reach it. A CI job +/// branching on `6` and reading the gate story goes looking for a gate it never +/// configured. +/// +/// The three claims are asked of the code, not of a second copy of the list: the +/// mapping from [`io_cli::exec::code`], the stderr wording from +/// [`io_cli::exec::describe`], and the fact that a gate failure lands on the same +/// number — which is what makes "read stderr to tell them apart" the honest advice +/// rather than a hedge. +/// +/// Sabotage: remap `SchemaUnsatisfied`, reword `describe`, or drop either page's +/// paragraph. Each fails on its own. +#[test] +fn f10_both_pages_name_the_schema_contract_and_the_outcome_that_reaches_six() { + let schema = io_harness::RunOutcome::SchemaUnsatisfied { steps: 3 }; + let gate = io_harness::RunOutcome::VerificationFailed { steps: 3 }; + + assert_eq!( + io_cli::exec::code(&schema), + io_cli::exec::UNVERIFIED, + "an unsatisfied output schema no longer exits `6`, so both pages now \ + document a code a caller will not see for it", + ); + assert_eq!( + io_cli::exec::code(&gate), + io_cli::exec::UNVERIFIED, + "a failed verification gate no longer exits `6`, so the pages' \"two \ + different failures reach it\" is one failure", + ); + + // The line that separates them, which is what both pages tell a caller to + // read. Quoted on the contract page verbatim, so the advice is followable. + let said = io_cli::exec::describe(&schema); + const STDERR: &str = "never produced the shape its output schema asked for"; + assert!( + said.contains(STDERR), + "`describe` stopped naming the schema failure in the words the contract \ + page quotes, so a caller grepping stderr for it finds nothing: {said}", + ); + + let readme = flat(&read("README.md")); + for needle in [ + "io-harness's `RunOutcome::SchemaUnsatisfied`", + "a run that never produced the shape `[run] output_schema` asked for", + "a verification gate that failed", + ] { + assert!( + readme.contains(needle), + "README.md is missing {needle:?}. Exit `6` is a number a scripted \ + caller branches on and the front page described only one of the two \ + ways to reach it", + ); + } + + let contract = flat(&read("docs/CONTRACT.md")); + for needle in [ + "**Two different failures reach `6`, and a caller that has to tell them \ + apart reads stderr rather than the code.**", + "**The schema contract is io-harness's `[run] output_schema`**", + "a run that never produces the shape it asks for is `RunOutcome::SchemaUnsatisfied`", + // The table cell itself, so a reader who only skims the table is not told + // that `6` means a gate and nothing else. + "| `6` | UNVERIFIED | The work was judged and did not hold up", + ] { + assert!( + contract.contains(needle), + "docs/CONTRACT.md is missing {needle:?}; this is the page a script \ + author reads and `6` had one of its two meanings written down", + ); + } + assert!( + contract.contains(STDERR), + "docs/CONTRACT.md tells a caller to read stderr to tell the two `6`s \ + apart, so it has to quote the line they will be reading", + ); +} + +/// **F10 — the tap and the marketplace appear with the command that uses them.** +/// +/// Both were mentions rather than paths. The tap and the bucket live in *this* +/// repository, which is why `brew tap` and `scoop bucket add` have to name a URL — +/// the repository is not `homebrew-io-cli`, so neither tool can derive one — and a +/// reader who copies a bare `brew install io` gets somebody else's formula. The +/// marketplace had its name on four pages and its verb on none of the two a reader +/// arrives at. +/// +/// Bound to disk and to the parse. The two files are asserted to exist, because a +/// tap whose formula is gone installs nothing; the upgrade commands are taken from +/// [`io_cli::upgrade`]'s own constants, so a renamed formula fails here rather than +/// leaving the README printing a command that updates nothing; and the marketplace +/// verb is fed through [`io_cli::manage::parse`], which is the same parse both the +/// slash surface and the shell reach. +/// +/// Sabotage: delete `Formula/io.rb`; change either upgrade constant; drop the +/// explicit-URL form from the install section; or remove the marketplace verb from +/// either page. Each fails on its own. +#[test] +fn f10_the_tap_and_the_marketplace_appear_with_the_command_that_uses_them() { + for path in ["Formula/io.rb", "bucket/io.json"] { + assert!( + repo().join(path).is_file(), + "{path} is what makes this repository its own tap, and the README \ + links it", + ); + } + + let readme = flat(&read("README.md")); + for needle in [ + // The explicit-URL form, both halves. `brew install io` alone resolves to + // core, which is not this product. + "brew tap initorigin/io-cli https://github.com/initorigin/io-cli", + "brew install initorigin/io-cli/io", + "scoop bucket add io-cli https://github.com/initorigin/io-cli", + "scoop install io", + // Why the URL is there, which is the part a reader otherwise reads as + // ceremony and drops. + "this repository is not named `homebrew-io-cli`", + // And the two files, linked rather than described. + "[`Formula/io.rb`](Formula/io.rb)", + "[`bucket/io.json`](bucket/io.json)", + ] { + assert!( + readme.contains(needle), + "README.md is missing {needle:?}; the tap is a path with a command, \ + and a reader who cannot copy it has been told about it rather than \ + given it", + ); + } + + // The upgrade lines, from the constants the binary prints. A formula renamed + // without these moving would leave both pages naming a command that updates + // nothing. + let contract = flat(&read("docs/CONTRACT.md")); + for command in [io_cli::upgrade::HOMEBREW, io_cli::upgrade::SCOOP] { + assert!( + readme.contains(&format!("`{command}`")), + "README.md should quote `{command}`, which is what `io upgrade` prints \ + for that install", + ); + assert!( + contract.contains(&format!("`{command}`")), + "docs/CONTRACT.md should quote `{command}`; `io upgrade` prints and \ + does not run, so what it prints is the whole of its contract", + ); + } + + // The marketplace verb reaches the parse both doors share, so the command the + // pages print is a command that is actually routed. + for line in [ + "plugin marketplace add initorigin/io-cli", + "plugin marketplace list", + "plugin marketplace remove initorigin/io-cli", + "plugin add ultraship", + ] { + assert!( + io_cli::manage::parse(&io_cli::manage::tokens(line)).is_ok(), + "both pages print `io {line}` and the parse both doors share refuses it", + ); + } + + for needle in [ + "`/plugin marketplace add /` clones an index into your own home", + "`/plugin add ` installs a bundle out of it", + "as `io plugin marketplace add` and `io plugin add`", + ] { + assert!( + readme.contains(needle), + "README.md is missing {needle:?}. A marketplace was named on the front \ + page without the verb that adds one, which leaves a reader knowing the \ + word and not the command", + ); + } + for needle in [ + "`io plugin marketplace add /` clones an index", + "`io plugin add ` installs a bundle out of one", + ] { + assert!( + contract.contains(needle), + "docs/CONTRACT.md is missing {needle:?}; the argv surface is what this \ + page exists to enumerate and `io plugin …` was an ellipsis", + ); + } +} + +// --------------------------------------------------------------------------- +// 0.38.1 F9 — a help page describes the binary in front of the reader, and an +// answer that is not an answer is not one. +// --------------------------------------------------------------------------- + +/// Every `--help` page the binary can print, by the command that prints it. +/// +/// Rendered rather than assembled out of `get_about` and `get_help`, because the +/// claim under test is about what an operator *sees*: a global flag's help is +/// reprinted on every subcommand's page, and a sweep that read each string once +/// would check a paragraph in one place while it shipped in nine. +fn help_pages() -> Vec<(String, String)> { + use clap::CommandFactory; + + let mut cli = io_cli::cli::Cli::command(); + let names: Vec = cli + .get_subcommands() + .map(|sub| sub.get_name().to_string()) + .collect(); + + let mut pages = vec![("io".to_string(), cli.render_long_help().to_string())]; + for name in names { + let page = cli + .find_subcommand_mut(&name) + .expect("a subcommand clap has just named") + .render_long_help() + .to_string(); + pages.push((format!("io {name}"), page)); + } + pages +} + +/// Every `..` literal in `text`. +/// +/// Three components and not two, which is the difference between a version and a +/// protocol: `io acp --help` says "JSON-RPC 2.0" and must go on saying it. +fn versions_named(text: &str) -> Vec { + text.split(|c: char| !(c.is_ascii_digit() || c == '.')) + .map(|token| token.trim_matches('.')) + .filter(|token| { + let parts: Vec<&str> = token.split('.').collect(); + parts.len() == 3 && parts.iter().all(|part| !part.is_empty()) + }) + .map(str::to_string) + .collect() +} + +/// **No `--help` page dates itself against a version this binary is not, and none +/// of them tells the reader a story about a release.** +/// +/// A `--help` page describes the binary in front of the reader. Every version +/// number on one is therefore either the pin — which the reader can act on — or a +/// claim about a build they are not running, which at best costs them the time to +/// work out that it does not apply. The field test of 0.38.0 found three: the +/// harness release that added `--profile`, the io-cli release that first selected +/// one, and the release whose `-m` defect is why `--plain` is a global flag. +/// +/// The pin is read from `Cargo.lock` through [`pinned_harness`] and never typed +/// here. A gate carrying the number goes stale on the next pin, which is the exact +/// defect class this one exists to remove. +/// +/// Sabotage: put back either sentence `src/cli.rs` used to carry on `--profile` or +/// on `--plain`. The failure names the page and the number. +#[test] +fn f9_no_shipped_help_names_an_unpinned_harness_or_narrates_a_release() { + // Narration that carries no number at all. The version sweep below is the wide + // net; this is for the sentence that says a release happened without saying + // which, which the sweep cannot see. + const NARRATION: &[&str] = &[ + "shipped a defect", + "did not exist because", + "was missing until", + "was green over", + "no io-cli release", + "in an earlier release", + "until this release", + ]; + + let pinned = pinned_harness(); + let pages = help_pages(); + assert!( + pages.len() > 5, + "only {} help pages were rendered, so this sweep is reading almost \ + nothing and is not checking what it claims", + pages.len(), + ); + + for (command, page) in &pages { + for version in versions_named(page) { + assert_eq!( + version, pinned, + "`{command} --help` names {version}, which is neither the pinned \ + io-harness ({pinned}) nor anything the operator holding this \ + binary can act on; a version on a help page is a claim about \ + what is in front of the reader", + ); + } + // Whitespace flattened first, because clap wraps a help page to the + // terminal's width: a phrase that fell across a line break would be + // missed, and the gate would be quietly reading a different string than + // the one an operator sees. + let said = page + .split_whitespace() + .collect::>() + .join(" ") + .to_lowercase(); + for phrase in NARRATION { + assert!( + !said.contains(phrase), + "`{command} --help` says {phrase:?}, which is a changelog entry \ + rather than a description of this binary; `CHANGELOG.md` and git \ + hold the history", + ); + } + } +} + +/// **No subcommand's help example names a subcommand other than its own.** +/// +/// One `Args` type is shared by `io mcp`, `io plugin`, `io config` and `io skill`, +/// so an example written into its field is an example of one of them printed on +/// the help page of all four — which is what the 0.38.0 field test found, with an +/// `mcp add` line under `io config --help`. The example belongs on the variant, +/// where clap prints it once, on its own page. +/// +/// The distinctness assertion is the one that would have caught the original: four +/// identical paragraphs is precisely the shape of a copy-paste, and every other +/// check here passes on it as long as the one string names its own subcommand. +/// +/// Sabotage: move any one example back onto the `words` field of `Manage`, or copy +/// `io mcp`'s paragraph onto `io config`. The first empties the list and fails on +/// the four names below; the second fails on distinctness and on the name. +#[test] +fn f9_no_subcommands_help_example_names_another_subcommand() { + use clap::CommandFactory; + + // The marker the examples are written behind. A convention rather than a + // grammar, and it is what makes this gate about *examples* — `io acp --help` + // mentions `io exec` in a sentence contrasting the two, which is a + // cross-reference an operator wants and not an example of the wrong command. + const MARKER: &str = "For example:"; + + let cli = io_cli::cli::Cli::command(); + let names: Vec = cli + .get_subcommands() + .map(|sub| sub.get_name().to_string()) + .collect(); + + let mut examples: Vec<(String, String)> = Vec::new(); + for sub in cli.get_subcommands() { + let name = sub.get_name().to_string(); + let about = sub + .get_long_about() + .or_else(|| sub.get_about()) + .map(|about| about.to_string()) + .unwrap_or_default(); + // Flattened per paragraph, for the reason the sweep above flattens a page: + // where the source broke its lines is not a fact about the help text, and a + // needle that fell across a break would be missed. + let paragraphs: Vec = about + .split("\n\n") + .map(|part| part.split_whitespace().collect::>().join(" ")) + .collect(); + for paragraph in paragraphs.iter().filter(|part| part.contains(MARKER)) { + for other in &names { + if *other == name { + continue; + } + assert!( + !paragraph.contains(&format!("io {other} ")), + "`io {name} --help` gives an example of `io {other}`, so three \ + readers out of four are being shown a command they did not \ + ask about:\n{paragraph}", + ); + } + assert!( + paragraph.contains(&format!("io {name} ")), + "`io {name} --help` carries an example that never types \ + `io {name}`:\n{paragraph}", + ); + examples.push((name.clone(), paragraph.clone())); + } + } + + // The four that share one `Args` type, by name, because a gate made of + // negatives passes on a binary that shows no examples at all. + for wanted in ["mcp", "plugin", "config", "skill"] { + assert!( + examples.iter().any(|(name, _)| name == wanted), + "`io {wanted} --help` shows no example, and it is one of the four \ + subcommands that share a single argument type — the place an example \ + cannot be written once for all of them", + ); + } + + for (i, (name, paragraph)) in examples.iter().enumerate() { + for (other, another) in &examples[i + 1..] { + assert_ne!( + paragraph, another, + "`io {name}` and `io {other}` show the same example word for word, \ + which is one subcommand's example on two pages", + ); + } + } +} + +/// **`io config get` on a key nothing names says there is no such key.** +/// +/// `Config::origin` returns an empty slice both for a real key no file sets and +/// for a misspelling, so through 0.38.0 both answered `default` — and an operator +/// checking a typo was told io-harness's own default was in force, which is a +/// wrong answer rather than a thin one. +/// +/// The two assertions that must keep holding are the point of the other half: a +/// catalogue key no file sets is still a default, and no row of the listing reads +/// as unknown. A key a *file* sets which the catalogue does not name is a real key +/// the operator wrote, `configure::settings` lists it deliberately, and it is +/// unreachable here by construction — `Decided::Unknown` is only chosen when +/// `origin` came back empty. +/// +/// Sabotage: drop the `CATALOGUE` test from `configure::setting` so every unset key +/// is `Unknown`. The listing assertion fails on the first catalogue row. +#[test] +fn f9_config_get_on_a_key_nothing_names_says_there_is_no_such_key() { + // No files at all, which is the fixture this needs: every key is one no file + // sets, so the catalogue is the only thing left deciding the answer. + let config = io_harness::config::Config::default(); + + let missing = io_cli::configure::setting(&config, "nonexistent.key"); + assert_eq!( + missing.decided.word(), + "no such key", + "`io config get nonexistent.key` prints this word as its origin field, and \ + `default` there claims a setting exists", + ); + assert_eq!( + missing.value, None, + "there is nothing to quote for a key no file names", + ); + assert!( + io_cli::configure::said(&missing).contains("no such key"), + "the session's own `/config ` says: {}", + io_cli::configure::said(&missing), + ); + + let unset = io_cli::configure::setting(&config, "run.max_retries"); + assert_eq!( + unset.decided.word(), + "default", + "a catalogue key no file sets is a real key running on io-harness's own \ + default, and calling it unknown would be a second wrong answer", + ); + + for row in io_cli::configure::settings(&config) { + assert_ne!( + row.decided.word(), + "no such key", + "the listing offers {} and then denies it exists", + row.path, + ); + } +} + +/// **`io plugin search` with no match says nothing matched.** +/// +/// A search that prints nothing is indistinguishable from a search that did not +/// run, and the two want different next moves — "no bundle is called that" against +/// "this command is broken". The argv door printed nothing at all through 0.38.0; +/// the session door said so in a `format!` of its own, which is the second +/// implementation this crate keeps finding disagreeing later. +/// +/// Both call sites are asserted, and that is the half that makes this a gate rather +/// than a sentence: `marketplace::nothing_matched` existing proves nothing about +/// what either door prints. +/// +/// Sabotage: delete either call in `src/main.rs`, or re-spell one of them as a +/// literal. +#[test] +fn f9_plugin_search_with_no_match_says_nothing_matched() { + let said = io_cli::marketplace::nothing_matched("nothing-is-called-this"); + assert!( + said.contains("nothing-is-called-this"), + "the answer does not repeat the term, so a scrolled terminal cannot say \ + which search it answers: {said}", + ); + assert!( + said.contains("marketplace"), + "the answer should say where it looked: {said}", + ); + + let main = read("src/main.rs"); + let calls = main.matches("marketplace::nothing_matched").count(); + assert!( + calls >= 2, + "only {calls} of the two doors say it. `io plugin search ` and the \ + session's `/plugin search ` both answer an empty search, and both \ + call `marketplace::nothing_matched` — a door that prints nothing leaves \ + the operator unable to tell an empty result from a broken command", + ); + assert!( + !main.contains("no bundle in any marketplace matches"), + "src/main.rs spells the sentence itself instead of calling \ + `marketplace::nothing_matched`, which is two answers to one question \ + agreeing only until one of them is edited", + ); +} diff --git a/tests/events.rs b/tests/events.rs index 012c7c8..a75db12 100644 --- a/tests/events.rs +++ b/tests/events.rs @@ -3230,3 +3230,108 @@ fn f7_a_withheld_call_is_one_refused_cell_that_names_the_mask() { describes a call that ran and changed nothing: {whole:?}" ); } + +/// **F8 — a thought is drawn above the answer it produced, not under it.** +/// +/// The arm flushed the buffered prose first and pushed its footer after, which is +/// right only if the event arrives before the tokens it explains. It does not: +/// the 2026-09-05 field test found `thought · 2.3s` under the final reply, and +/// the same order in the JSON stream — the `reasoning` event follows the `token` +/// it produced. A reader met the answer and then a note saying the model had been +/// thinking, which reads as a thought *about* the answer rather than the thinking +/// that reached it. +/// +/// The fixture is the broken order on purpose: tokens first, then the reasoning. +/// Asserting the fixed order against the *already correct* order would pass +/// before the change as well as after, which is the shape that proves nothing. +/// +/// Sabotage: put `flush_text` back at the top of the arm and this fails. +#[test] +fn f8_a_thought_is_committed_above_the_prose_it_produced() { + let mut events = Events::new(DARK); + started_at(&mut events, Duration::from_secs(1)); + + // The provider streamed the answer, and only then said it had reasoned. + let _ = events.event( + &event(EventKind::Token { + text: "the parser is the only caller".into(), + }), + Duration::from_millis(3_000), + ); + let committed = rows(events.event( + &event(thought("checking the callers", 120)), + Duration::from_millis(3_500), + )); + + let thought_at = committed + .iter() + .position(|row| row.contains("thought")) + .unwrap_or_else(|| panic!("the thought row is committed: {committed:?}")); + let answer_at = committed + .iter() + .position(|row| row.contains("only caller")) + .unwrap_or_else(|| panic!("the buffered answer is committed with it: {committed:?}")); + assert!( + thought_at < answer_at, + "the thought must be drawn above the answer it produced: {committed:?}", + ); +} + +/// **F8 — a spawned child's outcome is words, not a struct literal.** +/// +/// io-harness formats it with `{outcome:?}`, so the sentence handed to this crate +/// is literally `spawned child 149: Success { steps: 1 }` and the fleet result +/// line drew it verbatim — a Rust struct in the one row meant to tell an operator +/// what their children did. +/// +/// Asserted over the punctuation rather than over one expected string, and over +/// several `RunOutcome` shapes rather than one, because the point is that no +/// `Debug` survives — including a variant a later pin adds, which is precisely +/// what a table of the eighteen would miss. +/// +/// Sabotage: drop the `said_plainly` rebinding in the `Step` arm and every row +/// with a brace in it fails. +#[test] +fn f8_a_spawned_childs_outcome_is_drawn_as_words() { + for debug in [ + "Success { steps: 1 }", + "VerificationFailed { steps: 9 }", + "SchemaUnsatisfied { steps: 3 }", + "Interrupted", + ] { + let mut events = Events::new(DARK); + started_at(&mut events, Duration::from_secs(1)); + let whole = rendered( + &mut events, + EventKind::Step { + decision: format!("spawned child 149: {debug}"), + tool_call: "spawn_agent:{}".into(), + tokens: 8_800, + changed: true, + }, + ); + + assert!( + !whole.contains('{') && !whole.contains('}'), + "a struct body reached the transcript for `{debug}`: {whole}", + ); + // And the facts survived the rewriting — this is a rendering of + // io-harness's own sentence, so losing the child or its step count would + // be worse than the punctuation it removes. + assert!( + whole.contains("spawned child 149"), + "the child is still named for `{debug}`: {whole}", + ); + if debug.contains("steps") { + let steps = debug + .split("steps: ") + .nth(1) + .and_then(|rest| rest.split(' ').next()) + .expect("the fixture states a step count"); + assert!( + whole.contains(&format!("steps {steps}")), + "the step count is still reported for `{debug}`: {whole}", + ); + } + } +} diff --git a/tests/exec.rs b/tests/exec.rs index 5e7cdf6..ab40cec 100644 --- a/tests/exec.rs +++ b/tests/exec.rs @@ -41,6 +41,17 @@ use io_harness::{ Observer, Policy, Provider, RunEvent, RunOutcome, Session, Store, }; +/// A parked run the store can say nothing about (0.38.2). +/// +/// The shape every row in this module had before `exec::Parked` existed, and +/// still the honest one for a run older than io-harness 0.7.0 — no goal, no +/// stamp, no root. The rows built from it are what an operator sees when the +/// three readers answer nothing, which is the case worth holding still: it must +/// render `unknown` and never a date. +fn unknown() -> exec::Parked { + exec::Parked::default() +} + /// A workspace and a store, with no configuration file anywhere near the /// developer's own. /// @@ -1647,10 +1658,10 @@ fn the_listing_names_the_handle_each_parked_run_is_answered_through() { questions: vec![], step: 12, }; - let plain = exec::listed(41, &question, false).expect("a question is a row"); + let plain = exec::listed(41, &question, &unknown(), false).expect("a question is a row"); assert!(plain.contains("41") && plain.contains("219"), "{plain}"); - let row = exec::listed(41, &question, true).expect("a question is a row"); + let row = exec::listed(41, &question, &unknown(), true).expect("a question is a row"); let value: serde_json::Value = serde_json::from_str(&row).expect("one JSON object"); assert_eq!(value["run_id"], 41); assert_eq!(value["waiting_on"], "question"); @@ -1664,6 +1675,7 @@ fn the_listing_names_the_handle_each_parked_run_is_answered_through() { steps: vec![], step: 5, }, + &unknown(), true, ) .expect("a plan is a row"); @@ -1678,6 +1690,7 @@ fn the_listing_names_the_handle_each_parked_run_is_answered_through() { tool: "charge".into(), step: 4, }, + &unknown(), true, ) .expect("an interrupted call is a row"); @@ -1687,8 +1700,8 @@ fn the_listing_names_the_handle_each_parked_run_is_answered_through() { // A run whose process went away has no second id, and says so with a null // rather than with a number it made up. - let died = - exec::listed(41, &Pending::Died { last_step: 6 }, true).expect("a died run is a row"); + let died = exec::listed(41, &Pending::Died { last_step: 6 }, &unknown(), true) + .expect("a died run is a row"); let value: serde_json::Value = serde_json::from_str(&died).expect("one JSON object"); assert_eq!(value["waiting_on"], "died"); assert!(value["id"].is_null(), "{died}"); @@ -1697,11 +1710,121 @@ fn the_listing_names_the_handle_each_parked_run_is_answered_through() { // Under `--json` every row is one object and nothing else, the same split // `io exec --json` makes. for pending in [&question, &Pending::Died { last_step: 6 }] { - let row = exec::listed(41, pending, true).expect("a row"); + let row = exec::listed(41, pending, &unknown(), true).expect("a row"); assert!(row.starts_with('{') && row.ends_with('}'), "{row}"); } } +/// **F3 — a parked row says when it started and what it was for.** +/// +/// The 2026-09-05 field test met thirteen rows reading `run 41 question 219 +/// step 12` and differing only by numbers, and could not tell which of them was +/// the work it wanted. `runs.goal` had been written since io-harness 0.1.0 with no +/// public reader; there was no reader for a run's own start time either. Both +/// arrived in io-harness 0.81.0 as `Store::run_goal` and `Store::run_created_at`, +/// closing io-harness#258, which is what makes this criterion possible at all — +/// it was named as carried rather than promised when 0.38.2 was outlined. +/// +/// **The `unknown` arm is the one that matters and it is not the empty case.** +/// `Store::run_created_at` answers `None` for a run started before io-harness +/// 0.7.0, when the column did not exist — so `None` here means *either* no such +/// run *or* a run too old to have been stamped, and the oldest rows in a long- +/// lived store are exactly the ones somebody is trying to identify. Any rendered +/// time would be invented. +/// +/// Sabotage: render `started_at` with a fallback of the epoch, or `unwrap_or_ +/// default()` the goal, and the first two arms hold while the third fails. +#[test] +fn f3_a_parked_row_carries_the_runs_stamp_and_its_goal() { + use io_cli::resume::Pending; + + let question = Pending::Question { + question_id: 219, + question: "which environment?".into(), + context: None, + choices: vec![], + questions: vec![], + step: 12, + }; + let known = exec::Parked { + goal: Some("fix the tokenizer".into()), + started_at: Some("2026-09-06T07:13:44".into()), + root: Some("/work/parser".into()), + }; + + let plain = exec::listed(41, &question, &known, false).expect("a question is a row"); + assert!( + plain.contains("2026-09-06 07:13 UTC"), + "the stamp is the store's own string, cut to the minute and marked — the \ + same spelling `/resume` uses, because two listings of one store must not \ + spell a timestamp two ways: {plain}", + ); + assert!( + plain.contains("fix the tokenizer"), + "the goal is what tells two parked runs apart: {plain}", + ); + + // The JSON keys are appended and nullable, so a reader written against the + // 0.37.0 shape goes on working — and `started_at` is the store's raw sortable + // string there rather than the cut one, which is the same stdout/stderr split + // of audiences `io exec --json` already makes. + let row = exec::listed(41, &question, &known, true).expect("a question is a row"); + let value: serde_json::Value = serde_json::from_str(&row).expect("one JSON object"); + assert_eq!(value["goal"], "fix the tokenizer"); + assert_eq!(value["started_at"], "2026-09-06T07:13:44"); + assert_eq!(value["root"], "/work/parser"); + assert_eq!(value["run_id"], 41, "and the old keys are untouched"); + assert_eq!(value["waiting_on"], "question"); + + // A run the store cannot stamp says so in a word, and the word is not a time. + let old = exec::Parked { + goal: Some("something from before the column existed".into()), + started_at: None, + root: None, + }; + let plain = exec::listed(41, &question, &old, false).expect("a question is a row"); + assert!( + plain.contains("unknown"), + "a run older than io-harness 0.7.0 has no stamp: {plain}", + ); + assert!( + !plain.contains("1970"), + "and the epoch is a date, which is the one thing this must never be: \ + {plain}", + ); + let row = exec::listed(41, &question, &old, true).expect("a question is a row"); + let value: serde_json::Value = serde_json::from_str(&row).expect("one JSON object"); + assert!( + value["started_at"].is_null(), + "null, the shape `id` already uses for a row that has none: {row}", + ); + + // A multi-line goal is folded to one line, because one row per run is the + // shape being read — and it is NOT cut. `n6_the_plain_output_is_never_ + // composed_to_a_width` holds this file to composing nothing: a headless stream + // is not a viewport, and clipping it loses data a machine was going to read. + // The first draft of this truncated to sixty characters and that gate caught + // it, which is the arm below. + let long = exec::Parked { + goal: Some(format!("{}\nand a second line", "w".repeat(200))), + ..exec::Parked::default() + }; + let one_line = long.short_goal().expect("a goal that is there is rendered"); + assert!(!one_line.contains('\n'), "one row per run: {one_line:?}"); + assert!( + one_line.contains(&"w".repeat(200)), + "the goal is folded and never clipped: a row this door writes is read by \ + machines as well as people, and the `--json` object carries it verbatim \ + because that is the stream which promises it", + ); + + assert_eq!( + unknown().short_goal(), + None, + "no goal renders nothing rather than an empty pair of spaces", + ); +} + /// 0.23.0 F8 — the two runs nobody is waiting on are not offered as work. #[test] fn a_run_that_cannot_be_carried_on_is_not_a_row_in_the_listing() { @@ -1712,8 +1835,16 @@ fn a_run_that_cannot_be_carried_on_is_not_a_row_in_the_listing() { io_cli::resume::Pending::Interrupted, io_cli::resume::Pending::Finished, ] { - assert_eq!(exec::listed(41, &pending, false), None, "{pending:?}"); - assert_eq!(exec::listed(41, &pending, true), None, "{pending:?}"); + assert_eq!( + exec::listed(41, &pending, &unknown(), false), + None, + "{pending:?}" + ); + assert_eq!( + exec::listed(41, &pending, &unknown(), true), + None, + "{pending:?}" + ); } } @@ -1977,7 +2108,7 @@ fn a_parked_batch_is_listed_and_refused_as_the_several_questions_it_is() { // The listing keeps naming what the run is waiting on. Renaming `question` // would describe a difference the resume door does not have and break every // script written against this stream. - let row = exec::listed(41, &batch, true).expect("a batch is a row"); + let row = exec::listed(41, &batch, &unknown(), true).expect("a batch is a row"); let value: serde_json::Value = serde_json::from_str(&row).expect("one JSON object"); assert_eq!(value["waiting_on"], "question"); assert_eq!( @@ -2000,7 +2131,7 @@ fn a_parked_batch_is_listed_and_refused_as_the_several_questions_it_is() { questions: vec![], step: 12, }; - let row = exec::listed(41, &single, true).expect("a question is a row"); + let row = exec::listed(41, &single, &unknown(), true).expect("a question is a row"); let value: serde_json::Value = serde_json::from_str(&row).expect("one JSON object"); assert_eq!(value["questions"], 1); // And the three pauses that are not questions say null rather than a count of @@ -2013,20 +2144,22 @@ fn a_parked_batch_is_listed_and_refused_as_the_several_questions_it_is() { }, Pending::Died { last_step: 6 }, ] { - let row = exec::listed(41, &pending, true).expect("a row"); + let row = exec::listed(41, &pending, &unknown(), true).expect("a row"); let value: serde_json::Value = serde_json::from_str(&row).expect("one JSON object"); assert!(value["questions"].is_null(), "{row}"); } // The plain stream says it too, and only when there is more than one: a // `1 question` on every row is the mark nobody reads. - let plain = exec::listed(41, &batch, false).expect("a batch is a row"); + let plain = exec::listed(41, &batch, &unknown(), false).expect("a batch is a row"); assert!(plain.contains("3 questions"), "{plain}"); assert_eq!( - exec::listed(41, &single, false).expect("a question is a row"), - "run 41 question 219 step 12", - "a single question's plain row is byte for byte what it was, so nothing new \ - appears on the rows an operator already reads", + exec::listed(41, &single, &unknown(), false).expect("a question is a row"), + "run 41 question 219 step 12 unknown", + "a single question's plain row carries the four fields it always did, plus \ + the stamp 0.38.2 added — `unknown` here because this fixture's store \ + answers nothing, which is also what a run started before io-harness \ + 0.7.0 gets. The one thing it must never be is a date", ); // The refusal an operator gets when they type no `--answer`. diff --git a/tests/home.rs b/tests/home.rs index 783671c..37607f7 100644 --- a/tests/home.rs +++ b/tests/home.rs @@ -434,12 +434,18 @@ fn f9_running_twice_moves_nothing_the_second_time() { second.kept.is_empty(), "and nothing to keep, because the source is gone" ); - assert_eq!( - second.lines().len(), - 1, - "a run that moved nothing reports the home and nothing else" + // **And says nothing at all (0.38.2).** This asserted the opposite — one line, + // naming the home — which is the line the field test met on every single + // invocation of every subcommand, for a directory that had existed since the + // operator's first run. A report is owed for work that happened; a second + // adoption does no work. The line is now drawn only where something was + // created or moved, which the F8 arm in this file holds directly and this arm + // holds on the idempotent path. + assert!( + second.lines().is_empty(), + "a run that moved nothing has nothing to report: {:?}", + second.lines(), ); - assert!(second.lines()[0].contains(".io-cli")); } /// **N3.** The home belongs to the operator alone. A credential sits in the file @@ -503,3 +509,72 @@ fn the_origin_word_is_the_variable_the_harness_reads() { io_harness::config::CONFIG_HOME_VAR ); } + +/// **F8 — the home line is drawn when this run did something, and not otherwise.** +/// +/// It was last and unconditional, on the reasoning that a run which moved nothing +/// is the product answering "where does it live" without being asked. What the +/// 2026-09-05 field test found is that almost every run moves nothing — the +/// migration happens once, ever — so `io keeps its files in …` printed above the +/// output of every `io config get`, every `io mcp list`, every invocation in a +/// shell loop. Answering an unasked question once is a courtesy; answering it +/// every time is noise on a surface a script reads. +/// +/// The four rows are the whole rule. The last is the one that matters: an adopted +/// home with nothing to report says **nothing at all**, not a shorter something. +/// +/// Sabotage: drop the condition in `Report::lines` and the last row fails; drop +/// `created` from it and the first row fails. +#[test] +fn f8_the_home_line_is_drawn_only_when_this_run_created_or_moved_something() { + let home = PathBuf::from("/tmp/io-cli-home"); + let report = |created: bool, moved: bool| home::Report { + home: home.clone(), + moved: if moved { + vec![(PathBuf::from("/old/io.toml"), home.join("io.toml"))] + } else { + Vec::new() + }, + kept: Vec::new(), + blocked: None, + created, + }; + let says_home = |r: &home::Report| { + r.lines() + .iter() + .any(|line| line.contains("keeps its files in")) + }; + + assert!( + says_home(&report(true, false)), + "the run that made the home is the one invocation that should say where it is", + ); + assert!( + says_home(&report(false, true)), + "a file moved is a migration, and a migration names the destination", + ); + assert!( + says_home(&report(true, true)), + "created and moved is still one report", + ); + assert!( + !says_home(&report(false, false)), + "an ordinary run moves nothing and made nothing, and printing the home \ + there is the noise on every invocation this criterion exists to remove", + ); + assert!( + report(false, false).lines().is_empty(), + "and it says nothing at all rather than something shorter", + ); + + // The kept case is a migration too — a file left behind is something the + // operator has to know about, and the home is where the one in force lives. + let kept = home::Report { + home: home.clone(), + moved: Vec::new(), + kept: vec![(PathBuf::from("/old/io.toml"), home.join("io.toml"))], + blocked: None, + created: false, + }; + assert!(says_home(&kept)); +} diff --git a/tests/hooks.rs b/tests/hooks.rs index 7d88157..8efe008 100644 --- a/tests/hooks.rs +++ b/tests/hooks.rs @@ -410,7 +410,7 @@ fn a_hook_naming_an_event_that_does_not_exist_is_refused_rather_than_installed() // --------------------------------------------------------------------------- // // `io_harness::Hooks::new` creates every `append` path it is given, empty, as the -// value is constructed (`io-harness-0.79.0/src/hooks.rs`) — deliberately, so that +// value is constructed (`io-harness-0.81.0/src/hooks.rs`) — deliberately, so that // "the filter matched nothing" stays distinguishable from "the hook was never // installed". That makes *constructing* a `Hooks` a write to the filesystem, and // it resolves a relative `append` against the directory it was handed. Handed diff --git a/tests/instructions.rs b/tests/instructions.rs index 363c3ab..8c1ea32 100644 --- a/tests/instructions.rs +++ b/tests/instructions.rs @@ -5,7 +5,7 @@ //! other half and `tests/memory.rs` covers it. This file covers the half that //! decides whether any of it reaches a model: `[instructions] files`, which is //! the only mechanism io-harness has for finding two of the three -//! (`io-harness-0.79.0/src/config.rs:190` — the automatic set is exactly +//! (`io-harness-0.81.0/src/config.rs:190` — the automatic set is exactly //! `["AGENTS.md"]`). //! //! Every test sets `IO_CONFIG_HOME`, because that is the only way to move the diff --git a/tests/live.rs b/tests/live.rs index 143489f..9dffe13 100644 --- a/tests/live.rs +++ b/tests/live.rs @@ -4059,7 +4059,7 @@ async fn live_f6_a_withheld_tool_is_refused_by_the_mask_and_says_so() { // working mask, which is the finding worth keeping: io-harness *announces* the // mask in the user prompt — "Unavailable this turn — these tools are listed // above but calling one is refused and starts nothing: write_file" - // (`run/prompts.rs:1133`) — so a compliant model never attempts the call and + // (`io-harness-0.81.0/src/run/prompts.rs:1381`) — so a compliant model never attempts the call and // never produces the refusal. The run above said so in its own reasoning: "the // previous turns show write_file was refused, so I used a shell redirect // instead". That is the mask working at its best, not evidence of absence. diff --git a/tests/lock.rs b/tests/lock.rs index 96130d2..6958283 100644 --- a/tests/lock.rs +++ b/tests/lock.rs @@ -536,3 +536,126 @@ fn n3_the_lock_and_its_record_are_readable_by_their_owner_alone() { drop(held); } + +/// **F7 — the sweep removes a finished session's lock and never a live one.** +/// +/// Asserted in both directions, because a sweep that removes a live lock is +/// worse than the leak it replaces: the leak is thirty-one empty files, and the +/// bug would be two `io` processes believing they are alone in one session. +/// +/// The live case is the one a naive implementation gets wrong. "No owner record" +/// is not on its own evidence that a session is over — it is also true for the +/// window before `acquire` writes the record, and true forever on a disk too full +/// to write one, which `acquire` deliberately treats as non-fatal. So the sweep +/// requires `try_lock` to succeed as well, and that is what this asserts by +/// holding the guard across the call. +/// +/// Sabotage: drop the `owner.exists()` check and the third row fails; drop the +/// `try_lock` check and the second fails. +#[test] +fn f7_the_sweep_takes_finished_locks_and_leaves_held_ones() { + let home = tempfile::tempdir().expect("a temporary home"); + let root = home.path().join("workspace"); + // A fixed instant, never a real clock read: N1 bans one in a test, and this + // one has no need of a real instant — the sweep decides on the owner record + // and `try_lock`, never on how old a stamp is. (N1's sweep does not strip + // comments, so naming the banned call even to say it is not used fails the + // gate. That is the right trade for a gate this cheap.) + let now = started(); + + // 1. A session that finished: its guard is dropped, so the owner record is + // gone and nothing holds the lock. + let (finished_lock, finished_owner) = lock::paths(home.path(), 11); + match lock::acquire(home.path(), 11, &root, now).expect("the lock is takeable") { + Taken::Held(guard) => drop(guard), + Taken::Refused(_) => panic!("a fresh id cannot be refused"), + } + assert!( + finished_lock.exists(), + "the lock file outlives the guard, which is the leak this sweeps", + ); + assert!( + !finished_owner.exists(), + "the owner record is what `Guard::drop` already removes", + ); + + // 2. A session that is running: guard held across the sweep. + let (live_lock, live_owner) = lock::paths(home.path(), 22); + let held = match lock::acquire(home.path(), 22, &root, now).expect("the lock is takeable") { + Taken::Held(guard) => guard, + Taken::Refused(_) => panic!("a fresh id cannot be refused"), + }; + + // 3. A file under the stem that is not a session lock at all. + let stranger = home.path().join("session-notanumber.lock"); + std::fs::write(&stranger, b"").expect("the stranger is written"); + + let gone = lock::sweep(home.path()); + + assert_eq!(gone, 1, "exactly the finished session's lock was removed"); + assert!( + !finished_lock.exists(), + "the finished session's lock is what the sweep exists to take", + ); + assert!( + live_lock.exists() && live_owner.exists(), + "a lock a live process holds must survive the sweep — removing it splits \ + the inode and gives two processes two locks on one session", + ); + assert!( + stranger.exists(), + "a file this module did not name is not this module's to delete", + ); + + drop(held); +} + +/// **F7's other half — the driver actually calls the sweep.** +/// +/// `lock::sweep` being correct is not evidence that anything runs it, and nothing +/// under `tests/` links `src/main.rs`, so the call has no gate but this one. The +/// arm above passes in full with the call deleted, and the symptom of deleting it +/// is a directory quietly filling with empty lock files again — the exact +/// 2026-09-05 field-test finding, reverted, under a green suite. +/// +/// A source-text gate, and it says so. The same instrument the wizard's and the +/// plugin search's driver calls are held by, for the same reason. +/// +/// Sabotage: delete the `lock::sweep` call in `src/main.rs`, or move it below +/// `acquire`, where this process's own lock is already held. +#[test] +fn f7_the_driver_sweeps_before_it_takes_a_lock() { + // **Comments stripped, and parens required. Both were found by sabotage, one + // arm at a time.** + // + // The first draft searched for `lock::sweep`, and the arm that renames the + // call to `lock::sweep_DELETED` *contains* that string, so it survived. Adding + // the paren killed that arm and not the next one: commenting the call out + // leaves `lock::sweep(` in the file verbatim, and a gate reading raw text + // cannot tell a call from a mention of one. + // + // So the sweep runs over code with the comment lines removed, which is what + // `tests/dependencies.rs` already does for its own needles and for the same + // reason. Three separate gates in this release have now been fooled by their + // own prose; a source-text gate that does not strip comments is checking the + // documentation rather than the program. + let main: String = std::fs::read_to_string("src/main.rs") + .expect("the driver is readable") + .lines() + .filter(|line| !line.trim_start().starts_with("//")) + .collect::>() + .join("\n"); + + let sweep = main + .find("lock::sweep(") + .expect("the driver never calls `lock::sweep(`, so finished locks leak forever"); + let acquire = main + .find("lock::acquire(") + .expect("the driver takes a session lock somewhere"); + assert!( + sweep < acquire, + "the sweep must run before this session takes its own lock — after it, the \ + file this process is about to hold is one of the candidates, and the \ + ordering is the whole of why the sweep is safe", + ); +} diff --git a/tests/markdown.rs b/tests/markdown.rs index 0992646..4c37492 100644 --- a/tests/markdown.rs +++ b/tests/markdown.rs @@ -139,8 +139,13 @@ fn a_bullet_becomes_the_themes_bullet_at_its_own_depth() { #[test] fn a_fenced_block_is_left_exactly_as_the_model_wrote_it() { let lines = render("```rust\nlet x = *p; // **not bold**\n```\nafter **bold**"); - // The opening fence draws the language and the closing one draws nothing. - assert_eq!(text(&lines[0]), "rust"); + // **Neither fence draws a word (0.38.2).** This asserted `"rust"` on the + // opening line, which is the defect the field test reported rather than the + // behaviour: a bare `rust` in the scrollback reads as something the model + // said. See `f8_a_fence_draws_no_bare_language_line` for the property; this + // arm holds the line still being *there*, because a fence that drew nothing at + // all would close the block up against the prose above it. + assert_eq!(text(&lines[0]), ""); assert_eq!(text(&lines[1]), "let x = *p; // **not bold**"); assert!(carrying(&lines[1], Modifier::BOLD).is_empty()); assert_eq!(text(&lines[2]), ""); @@ -168,3 +173,48 @@ fn a_rule_is_drawn_in_the_glyph_sets_own_character() { assert!(text(line).starts_with("──"), "{:?}", text(line)); assert!(!text(line).contains('-'), "{:?}", text(line)); } + +/// **F8 — a fence's language tag is not drawn as a line of its own.** +/// +/// An opening ```` ```python ```` committed the bare word `python`, muted, on the +/// row above the code. Muting is not a distinction at that width — a short muted +/// line and a short prose line are the same shape — so what the 2026-09-05 field +/// test saw was the model apparently writing the word `python` on its own line +/// above a code block. +/// +/// The boundary the tag was supposed to mark is carried by `Tone::Literal`, which +/// is on every row inside the fence and no row outside it, and which survives a +/// narrow terminal, `--plain` and `NO_COLOR` in a way a word does not. Both halves +/// are asserted here, because dropping the tag is only right if the styling is +/// genuinely doing the work. +/// +/// Sabotage: restore the `format!("{indent}{language}")` arm and the first +/// assertion fails. +#[test] +fn f8_a_fence_draws_no_bare_language_line() { + let mut md = Markdown::default(); + let opened = md.line("```python", &DARK); + assert!( + text(&opened).trim().is_empty(), + "the language tag was drawn as a line of its own: {:?}", + text(&opened), + ); + + // The block itself is literal, which is the boundary that replaced the word. + let inside = md.line("print('hi')", &DARK); + assert_eq!(text(&inside), "print('hi')"); + assert!( + inside + .spans + .iter() + .any(|span| span.style != DARK.style(io_cli::theme::Tone::Normal)), + "code inside a fence must not be styled as ordinary prose, or nothing \ + marks where the block is", + ); + + // Closing returns to prose, so the fence state still tracks. + let closed = md.line("```", &DARK); + assert!(text(&closed).trim().is_empty()); + let after = md.line("back to prose", &DARK); + assert_eq!(text(&after), "back to prose"); +} diff --git a/tests/marketplace.rs b/tests/marketplace.rs index 4abbdc2..3216799 100644 --- a/tests/marketplace.rs +++ b/tests/marketplace.rs @@ -1974,7 +1974,15 @@ fn the_surface_where_consent_happens_never_shortens_a_hook_s_command() { #[test] fn f12_writing_enabled_says_what_it_costs_an_older_binary() { let said = io_cli::pluginview::OLDER_BINARY; - for named in ["enabled", "[[plugin]]", "0.70.0", "0.69.0", "whole"] { + // **The version named is io-cli's, not io-harness's (0.38.2).** This required + // `0.70.0` and `0.69.0` — the harness release the key arrived in and the one + // before it — which was true and was the wrong frame: an operator knows which + // `io` they installed and cannot check which harness it was built against. It + // also rotted, because the pin moved eleven times and the sentence could not + // follow. io-cli 0.29.0 is the release that first understood the key and it + // does not move; `f4_no_compatibility_note_names_an_io_harness_version` holds + // the other half, that no harness version comes back. + for named in ["enabled", "[[plugin]]", "io-cli", "0.29.0", "whole"] { assert!( said.contains(named), "the disclosure does not name `{named}`: {said}", diff --git a/tests/provider.rs b/tests/provider.rs index e4c14cc..5be210b 100644 --- a/tests/provider.rs +++ b/tests/provider.rs @@ -16,6 +16,8 @@ use std::path::{Path, PathBuf}; +mod support; + /// The four constructors that turn a credential into a provider. const CONSTRUCTORS: &[&str] = &[ "OpenRouter::new", @@ -571,3 +573,221 @@ fn f5_an_image_is_refused_unless_every_link_accepts_one() { assertion above is satisfied by a function that always answers false", ); } + +/// A link that declares a window and an answer limit, and nothing else. +/// +/// Separate from [`Fake`] rather than a field on it: every one of `Fake`'s callers +/// would have to be given two more arguments to test a property none of them are +/// about, and the trait's `None` default is exactly what this fixture exists to +/// override. +struct Declaring { + window: Option, + output: Option, +} + +impl io_harness::Provider for Declaring { + async fn complete( + &self, + _request: io_harness::CompletionRequest, + ) -> io_harness::Result { + Ok(io_harness::CompletionResponse::default()) + } + + fn context_window(&self) -> Option { + self.window + } + + fn max_output_tokens(&self) -> Option { + self.output + } +} + +/// **F2's behavioural half — a chain's two window answers go opposite ways.** +/// +/// The text gate below proves the methods are written. It cannot prove either one +/// returns the right thing, and an adversarial review found `max_output_tokens` +/// returning the wrong one: it took the minimum "for the same reason" as the +/// window, and the reason inverts. +/// +/// `ContextBudget::for_window` computes `max_tokens = window - (max_output + +/// floor)`. The window is *divided into*, so the smallest is safe. The reserve is +/// *subtracted*, so the smallest leaves the most room and overflows on the link +/// that answers most — the same failure the window's minimum prevents, reached by +/// the arithmetic running the other way, and at the same moment: after the head +/// has already failed. +/// +/// The numbers are deliberately not symmetric, so an implementation that took the +/// same direction for both would fail one arm rather than passing both. +/// +/// Sabotage: swap either `min` for `max`. +#[test] +fn f2_a_chain_takes_the_smallest_window_and_the_largest_reserve() { + let chain = Chain::of(vec![ + Declaring { + window: Some(200_000), + output: Some(4_096), + }, + Declaring { + window: Some(32_000), + output: Some(32_768), + }, + ]) + .expect("a chain"); + + assert_eq!( + chain.context_window(), + Some(32_000), + "the smallest window: a run assembles its context once, and sizing to the \ + head then falling through to a smaller link overflows on the call that \ + matters", + ); + assert_eq!( + chain.max_output_tokens(), + Some(32_768), + "the largest reserve, which is the opposite direction and is why this arm \ + exists: the reserve is subtracted from the window, so the smallest one \ + leaves the most room and the fall-through's longer answer runs off the end", + ); + + // A link that says nothing is skipped rather than counted as zero, and a chain + // where nobody says anything is `None` — which is the pre-0.38.2 fallback, not + // a window of zero. + let quiet = Chain::of(vec![ + Declaring { + window: None, + output: None, + }, + Declaring { + window: Some(64_000), + output: None, + }, + ]) + .expect("a chain"); + assert_eq!(quiet.context_window(), Some(64_000), "silence is not zero"); + assert_eq!(quiet.max_output_tokens(), None); + + let silent = Chain::of(vec![Declaring { + window: None, + output: None, + }]) + .expect("a chain"); + assert_eq!( + silent.context_window(), + None, + "a chain nobody sized answers `None`, which sends the run to io-harness's \ + own fallback exactly as before", + ); +} + +/// The text between a declaration's opening brace and the first `}` in column +/// zero, which is where every item in the harness's source ends. +fn body_after<'a>(source: &'a str, marker: &str) -> &'a str { + let start = source + .find(marker) + .unwrap_or_else(|| panic!("the locked source declares `{marker}`")); + let rest = &source[start + marker.len()..]; + let end = rest + .find("\n}\n") + .unwrap_or_else(|| panic!("`{marker}` is closed")); + &rest[..end] +} + +/// **F2 — every method io-harness's `Provider` declares is delegated by every +/// wrapper in this crate, and both lists are derived rather than written.** +/// +/// The defect this exists to catch neither fails to compile nor fails any other +/// test. `Provider`'s methods carry default bodies, deliberately, so that an +/// implementation written before a method existed keeps building — and a +/// *wrapper* that inherits one of those defaults answers for itself instead of +/// for the provider it wraps, which is the one place the default is never right. +/// +/// io-harness 0.81.0 added `context_window` and `max_output_tokens`, both +/// defaulting to `None`. With the pin moved to it and nothing else changed, this +/// crate's four wrappers compiled with no error and no warning, every existing +/// test passed, and every run went on assembling under +/// `io_harness::context::FALLBACK_MAX_TOKENS` on a model whose window the +/// provider catalogue already held — which is precisely the behaviour that +/// harness release exists to end. +/// +/// **The method list is read out of the locked harness, never written here.** A +/// literal list is a gate that goes stale at the next pin and is then repaired by +/// editing the literal, which is the failure this repository has already paid +/// for. Parsed from the trait declaration, a method the harness adds is in force +/// the moment the pin moves. +/// +/// **The wrapper list is derived from `src/provider.rs` itself**, so a fifth +/// wrapper is covered by existing rather than by being remembered. +#[test] +fn f2_every_provider_method_is_delegated_by_every_wrapper() { + let harness = support::harness_source_at(&["provider", "mod.rs"]); + let declared: Vec<&str> = body_after(&harness, "pub trait Provider {") + .lines() + .filter_map(|line| line.strip_prefix(" fn ")) + .filter_map(|rest| rest.split(['(', '<']).next()) + .map(str::trim) + .filter(|name| !name.is_empty()) + .collect(); + + // Not a count — an anchor. A parse that silently returned nothing would make + // every assertion below vacuously true, which is the one way this gate can + // fail open. + assert!( + declared.contains(&"complete") && declared.contains(&"context_window"), + "the trait parse found {declared:?}, which does not look like \ + `Provider` — the declaration moved and this gate is reading the wrong \ + thing rather than passing", + ); + + // **Newlines normalised, because CI checks this repository out with CRLF on + // Windows.** The needle below is `"\n}\n"` and a Windows checkout has + // `"\r\n}\r\n"`, so the impl sweep found no closing brace at all and the gate + // panicked with "the impl is closed" — green on macOS and Linux, red on the + // one platform nobody can reproduce locally. `support::harness_source` carries + // this exact reasoning for the registry source and says it is the third + // instance of the class in this repository; this is the fourth, and the first + // to hit a file in the crate's own tree rather than a dependency's. + let ours = std::fs::read_to_string("src/provider.rs") + .expect("this crate's provider module") + .replace("\r\n", "\n"); + let mut wrappers: Vec<(&str, &str)> = Vec::new(); + for (at, _) in ours.match_indices("Provider for ") { + let line = ours[..at].rfind('\n').map_or(0, |n| n + 1); + if !ours[line..].starts_with("impl") { + continue; + } + let named = ours[at + "Provider for ".len()..] + .split(['<', ' ', '{']) + .next() + .unwrap_or_default() + .trim(); + let rest = &ours[line..]; + let end = rest.find("\n}\n").expect("the impl is closed"); + wrappers.push((named, &rest[..end])); + } + + // Derived against derived: an independent count of the impl headers, so a + // parse that dropped one narrows the gate loudly instead of quietly. + let headers = ours + .lines() + .filter(|l| l.starts_with("impl") && l.contains("Provider for ")) + .count(); + assert_eq!( + wrappers.len(), + headers, + "the impl sweep found {} of {headers} `impl … Provider for …` headers, so \ + some wrapper is not being checked at all", + wrappers.len(), + ); + + for (name, body) in &wrappers { + for method in &declared { + assert!( + body.contains(&format!("fn {method}(")), + "`{name}` does not delegate `Provider::{method}`, so it answers the \ + trait's own default on behalf of the provider it wraps. The method \ + list comes from the locked io-harness, so this is the pin having \ + moved rather than a test to update", + ); + } + } +} diff --git a/tests/reload.rs b/tests/reload.rs index 87c5f19..ddf2e7c 100644 --- a/tests/reload.rs +++ b/tests/reload.rs @@ -79,7 +79,7 @@ fn workspace(io_toml: &str, agents: &str) -> (tempfile::TempDir, Configuration) /// Whether any composed instruction carries `line`. /// /// io-harness words each instruction file as `Project instructions from -/// `AGENTS.md`:\n` (`io-harness-0.79.0/src/config.rs:3020-3023`), so the +/// `AGENTS.md`:\n` (`io-harness-0.81.0/src/config.rs:3143-3146`), so the /// assertion is a containment and not an equality — this file is not the place /// that pins the harness's wording. fn instructs(held: &Configuration, line: &str) -> bool { diff --git a/tests/servers_enabled.rs b/tests/servers_enabled.rs index 02a2646..f588790 100644 --- a/tests/servers_enabled.rs +++ b/tests/servers_enabled.rs @@ -305,8 +305,8 @@ fn f10_widening_keys_by_one_name_did_not_widen_the_check() { /// **F12 — the two `enabled` writes cost an older binary opposite things.** /// -/// A `[[plugin]]` entry carrying `enabled` makes an io-cli built against io-harness -/// 0.69.0 refuse the **whole configuration file**: every setting in it, loudly, at +/// A `[[plugin]]` entry carrying `enabled` makes an io-cli older than 0.29.0 +/// refuse the **whole configuration file**: every setting in it, loudly, at /// startup. The same key in an `[[mcp]]` entry is **ignored** by that binary, and /// the server the operator switched off starts and runs with nothing said. /// @@ -594,3 +594,39 @@ async fn f14_a_probe_and_a_state_nobody_reached_are_different_words() { "a probe put its own result under a heading that says a turn produced it", ); } + +/// **F4 — a compatibility note names an io-cli release, never an io-harness one.** +/// +/// Both `OLDER_BINARY` sentences described the boundary as "io-harness 0.70.0's, +/// and a binary built against 0.69.0 …". Every word of that was true and it was +/// the wrong frame twice over. An operator knows which `io` they installed and +/// has no reason to know which harness it was compiled against, so the sentence +/// read as a note about somebody else's dependency rather than as a warning about +/// their own second machine — which is how the 2026-09-05 field test reported it, +/// as stale noise on a routine `io mcp disable`. +/// +/// And it rotted. The pin has moved eleven times since that sentence was written +/// and the sentence could not follow, because the fact it names is not the pin — +/// it is the io-cli release that first understood the key, which is 0.29.0 and +/// does not change. Naming the durable fact is what makes this gate possible at +/// all: there is no version here for a later release to have to update. +/// +/// Sabotage: put any `io-harness 0.NN.0` back into either constant. +#[test] +fn f4_no_compatibility_note_names_an_io_harness_version() { + for (which, sentence) in [ + ("pluginview", io_cli::pluginview::OLDER_BINARY), + ("servers", servers::OLDER_BINARY), + ] { + assert!( + !sentence.contains("io-harness"), + "`{which}::OLDER_BINARY` names io-harness, which an operator did not \ + install and cannot check: {sentence}", + ); + assert!( + sentence.contains("io-cli") && sentence.contains("0.29.0"), + "`{which}::OLDER_BINARY` must name the io-cli release that first \ + understood the key, which is the fact that does not move: {sentence}", + ); + } +} diff --git a/tests/sessions.rs b/tests/sessions.rs index 5ae51ca..e42a4d1 100644 --- a/tests/sessions.rs +++ b/tests/sessions.rs @@ -283,20 +283,38 @@ fn f1_a_row_carries_the_root_the_turn_count_and_the_first_prompt() { nothing on its own", ); - // The stamp is the store's string, sliced to the minute — not a relative - // age. "two minutes ago" would need the current time, and this module is not - // allowed to ask for it. + // The stamp is the store's string, sliced to the minute and marked — not a + // relative age. "two minutes ago" would need the current time, and this module + // is not allowed to ask for it. + // + // **The marker is the assertion, not the length (0.38.2).** io-harness stores + // UTC and hands the string over unformatted; io-cli drew it beside a local + // wall clock with nothing saying which, so a session started at 07:13 UTC read + // as 07:13 to somebody at 12:43. `sessions::ZONE` is what fixes it, and a bare + // length check is what let it ship unnoticed — the count moved from 16 to 20 + // and said nothing about why. + assert!( + row.at.ends_with(io_cli::sessions::ZONE), + "a stamp with no zone is read as the reader's own clock: {row:?}", + ); assert_eq!( row.at.chars().count(), - 16, - "a stamp cut to the minute: {row:?}" + "2026-09-06 07:13".chars().count() + io_cli::sessions::ZONE.chars().count(), + "a stamp cut to the minute, plus the marker: {row:?}", ); assert!( !row.at.contains("ago"), "a relative age would mean a clock read: {row:?}", ); + // **Over the stamp and not over the whole string (0.38.2).** This read + // `!row.at.contains('T')`, which the marker `ZONE` falsifies all by itself — + // `UTC` has a `T` in it. The claim being made is about the ISO separator at + // index 10, so it is made about the stamp, and the marker is asserted + // separately above. The length arm failed first, which is the only reason this + // did not ship as a green test asserting something that could not be true. + let stamp: String = row.at.chars().take(16).collect(); assert!( - row.at.chars().nth(10) == Some(' ') && !row.at.contains('T'), + stamp.chars().nth(10) == Some(' ') && !stamp.contains('T'), "the stored `T` separator is replaced by a space for reading: {row:?}", ); } diff --git a/tests/timing.rs b/tests/timing.rs index f63cadf..a6257fc 100644 --- a/tests/timing.rs +++ b/tests/timing.rs @@ -300,3 +300,125 @@ fn n1_the_driver_is_the_only_thing_that_reads_a_clock() { watching a string that is no longer written anywhere.", ); } + +/// Every test name this repository's own prose cites is a test that exists. +/// +/// **N6, and it is a criterion because 0.38.2 found one that was not.** The doc +/// comment on `Wizard::existing` said +/// "`f8_the_wizard…` holds it to that, because nothing under `tests/` links the +/// driver" — naming a gate that had never been written. Every *other* thing about +/// that feature was covered, so the suite was green, and the one line in the +/// driver that made the feature work could have been deleted with nothing going +/// red and a comment beside it asserting otherwise. +/// +/// That is worse than an uncovered mechanism. An uncovered mechanism is a gap +/// somebody may still find; a comment claiming coverage is the reason nobody +/// looks. It is the same class as a stale citation, and this release exists to +/// delete that class — so shipping the class in a new spelling would be its own +/// failure. +/// +/// **The convention is what makes this checkable.** Tests here are named +/// `f_…`, `n_…` or `o_…` after the acceptance criterion they hold, so a +/// token in that shape is a test name and nothing else — no variable, no field and +/// no function in `src/` is spelled that way. Anything matching it must resolve to +/// a `fn` under `tests/`. +/// +/// Sabotage: put a criterion-shaped name that no test defines into any comment — +/// spelled out here would fail this gate from inside its own documentation, which +/// is the shape working. +#[test] +fn n6_no_comment_cites_a_test_that_does_not_exist() { + let tests = test_sources(); + assert!( + tests.len() > 50, + "the walk found only {} test files, which means it is not walking", + tests.len(), + ); + + // Every test this repository actually defines. + // + // **Not `strip_prefix("fn ")`.** That was the first draft and it reported + // nineteen live tests as missing: `#[tokio::test] async fn` and `pub fn` both + // put a word in front, so every asynchronous test in the suite looked + // undefined. A gate whose first run produces nineteen findings is far more + // likely to be wrong about its own instrument than to have found nineteen + // defects, and it was. + // + // **Both trees, not just `tests/`.** The second draft still reported fourteen, + // and every one was a `#[cfg(test)] mod tests` unit test living in `src/` — + // `src/app.rs` alone holds nine. A test in the crate is a test that exists, and + // a citation of one is not a stale claim. + // + // Comment lines are skipped so that `// fn f99_…` cannot define a test by + // mentioning one, which would quietly make this gate permissive. + let sources: Vec<(PathBuf, String)> = crate_sources().into_iter().chain(tests).collect(); + let mut defined: Vec = Vec::new(); + for (_, source) in &sources { + for line in source.lines() { + let line = line.trim_start(); + if line.starts_with("//") { + continue; + } + let Some(at) = line.find("fn ") else { + continue; + }; + if let Some(name) = line[at + 3..].split(['(', '<']).next() { + defined.push(name.trim().to_string()); + } + } + } + + // A token is a criterion-named test if it is `f`/`n`/`o`, then digits, then an + // underscore, then more. `f2_x` is too short to be one of this repository's + // names and is excluded rather than guessed at. + // The length test comes first and is not decoration: splitting on delimiters + // yields empty strings between consecutive ones, and `word[1..]` on an empty + // string panics rather than answering false. + let cited_shape = |word: &str| { + if word.len() <= 12 || !matches!(word.as_bytes()[0], b'f' | b'n' | b'o') { + return false; + } + let digits = word[1..].chars().take_while(char::is_ascii_digit).count(); + digits > 0 + && word[1 + digits..].starts_with('_') + // A citation broken across two comment lines leaves a token ending in + // `_`, which is half a name rather than a missing test. + && word.ends_with(|c: char| c.is_ascii_alphanumeric()) + }; + + // **A prefix counts, and a deleted test is allowed to be named.** Both fell out + // of running this for the first time, and neither is a loophole: + // + // * prose elides a long name with an ellipsis — + // ``f11_the_permitted_spawn_set_is_exact_paths…`` in `tests/dependencies.rs` + // — and the token before the ellipsis is a prefix of the real test. The claim + // is true; only the spelling is short. + // * `tests/events.rs` records that a test **was deleted** and why. Naming it is + // the opposite of claiming it holds something, and losing that sentence would + // cost the reason the deletion was right. + // + // Anything else is a comment asserting a guarantee nothing enforces. + let mut missing: Vec = Vec::new(); + for (path, source) in &sources { + for line in source.lines() { + if line.contains("deleted") { + continue; + } + for word in line.split(|c: char| !(c.is_ascii_alphanumeric() || c == '_')) { + if cited_shape(word) && !defined.iter().any(|name| name.starts_with(word)) { + let where_ = format!("{}: {word}", path.display()); + if !missing.contains(&where_) { + missing.push(where_); + } + } + } + } + } + + assert!( + missing.is_empty(), + "these name a test that does not exist. A comment claiming a guarantee \ + nothing enforces is the reason nobody goes looking for the gap:\n{}", + missing.join("\n"), + ); +} diff --git a/tests/wizard.rs b/tests/wizard.rs index 431e984..790d1d5 100644 --- a/tests/wizard.rs +++ b/tests/wizard.rs @@ -940,3 +940,95 @@ fn lines(wizard: &Wizard, newline: Newline) -> Vec { }) .collect() } + +/// **F8 — the welcome screen says what is at stake, and not always the same thing.** +/// +/// It said "No configuration found, so this is the first run" whatever was on +/// disk. The 2026-09-05 field test ran `io setup` on a machine with a 36 KB +/// `io.toml` and was told exactly that — and the wizard goes on to write that +/// same file. The sentence is the one that tells somebody nothing of theirs is at +/// stake, which makes being wrong about it worse than being silent. +/// +/// Both directions are asserted, because a fix that only ever printed the new +/// wording would be the same defect pointing the other way: an operator with no +/// configuration would be told a file is in force. +/// +/// Sabotage: drop the `existing` arm from `welcome` and the first half fails; +/// make `over` a no-op and the first half fails the same way. +#[test] +fn f8_the_welcome_screen_names_the_configuration_already_in_force() { + let commit = |wizard: &mut Wizard| match wizard.key(key(KeyCode::Enter)) { + Progress::Commit(lines) => lines + .iter() + .flat_map(|line| line.spans.iter().map(|span| span.content.to_string())) + .collect::(), + other => panic!("the welcome screen commits its lines: {other:?}"), + }; + + let mut told = + Wizard::new(DARK).over(Some(std::path::PathBuf::from("/home/me/.io-cli/io.toml"))); + let said = commit(&mut told); + assert!( + said.contains("/home/me/.io-cli/io.toml"), + "the screen must name the file it is about to write over: {said}", + ); + assert!( + !said.contains("first run"), + "and must not call it a first run when a configuration is in force: {said}", + ); + + let mut fresh = Wizard::new(DARK).over(None); + let said = commit(&mut fresh); + assert!( + said.contains("first run"), + "an operator with no configuration is genuinely on their first run: {said}", + ); +} + +/// **F8 — the driver tells the wizard what it is about to write over.** +/// +/// This gate is written because `src/wizard.rs` already claimed it existed. Its +/// doc comment on `existing` said +/// "`f8_the_wizard_is_told_what_configuration_is_already_in_force` holds it to +/// that, because nothing under `tests/` links the driver" — and no such test was +/// ever written. Everything else about the feature was covered: `Wizard::over` +/// has arms, the wording has arms, and all of them pass with the one call in the +/// driver deleted, because `existing` defaults to `None` and `None` is the +/// first-run wording. So the whole feature was one line away from reverting to the +/// exact defect it fixes, in silence, with a comment asserting otherwise. +/// +/// A sentence claiming a guarantee that nothing enforces is worse than no +/// sentence: it is the reason nobody goes looking. That is the class of claim +/// 0.38.2 exists to delete, and the honest deletion here is to make the claim +/// true rather than to remove it. +/// +/// A source-text gate, and it says so. Nothing under `tests/` links +/// `src/main.rs`, so this reads the driver as text — the same instrument +/// `f9_plugin_search_with_no_match_says_nothing_matched` uses, for the same +/// reason. +/// +/// Sabotage: delete the `.over(...)` call in `src/main.rs`, or narrow it to +/// `user_path()` without the `is_file` filter. +#[test] +fn f8_the_wizard_is_told_what_configuration_is_already_in_force() { + let main = std::fs::read_to_string("src/main.rs").expect("the driver is readable"); + + assert!( + main.contains("Wizard::new(theme).over("), + "the driver builds a wizard without telling it what is already in force, \ + so `io setup` claims a first run over an operator's existing file — which \ + is the 2026-09-05 field-test finding, reverted", + ); + assert!( + main.contains("io_harness::config::user_path()"), + "the file named must be the one the wizard actually writes, which is the \ + user scope's — naming a discovered project file would point at something \ + this run is not about to touch", + ); + assert!( + main.contains(".filter(|path| path.is_file())"), + "a path that does not exist is not configuration in force. Without the \ + filter every operator is told their user file is at stake, which is the \ + same lie in the other direction", + ); +}