Repository navigation
io-cli 0.20.0 - #54
Merged
Merged
Conversation
Turn on io-harness's `documents` feature — the umbrella it already declares as `xlsx`, `docx`, `pptx`, `pdf` and `barcode`, which is exactly the five formats this release names. One word rather than five, because the harness had already grouped them and a list written out here would be a second copy to keep true. The agent gains twelve tools, and six of them write: `xlsx_write`, `xlsx_set_cell`, `docx_write`, `pdf_write`, `pdf_watermark` and `pdf_fill_form`. io-cli cannot take a tool out of io-harness's workspace set, so this is stated in the README and the CHANGELOG rather than left to be discovered — the same rule the `media` comment has followed since 0.9.0. `barcode` is `rxing`, and every published `rxing 0.9.x` pins `image` at exactly `=0.25.8`. This crate asks for `^0.25` and the lockfile stood at 0.25.10, which cargo cannot reconcile, so the lock moves down a patch to the version both requirements accept. It costs 159 transitive crates, and it means the `default-features = false` argument on `image` no longer wins: `rxing` and `docx-rs` both take that crate with its defaults, and features unify across a graph. Both consequences are written at the pin. No direct dependency is added. The ten-crate direct set is unchanged.
`[[hook]]` tables have been parsed and silently inert since the harness gained them. They run now, and io-cli spawns nothing to do it: io-harness's `Hooks` is itself an `Observer` and already owns the subprocess, its timeout, its kill and its drained stdout. Building it is `Config::hooks()`; running it is installing it. The one-spawning-module rule holds untouched. Two installs, because the file has two halves. The `at = "before_tool"` tables reach a run through `TaskContract::with_tool_hooks`, attached in `contract::configured` — the one function both the session and `io exec` are built from, so a hook cannot reach a terminal and miss CI. The `on = [...]` tables reach it as an observer, which is the driver's job. `Fanout` exists because io-harness has no combinator to do it with. `Broadcast` is not a tee — it is a store-writing decorator over exactly one inner observer — and the only three `impl Observer` in that crate are `Hooks`, `Ignore` and `Broadcast`. So io-cli folds `Flow` itself, calling every observer even after one has cancelled, and folding so that `Cancel` wins over `Continue` whatever the order. A hook that asked to stop the turn is not overruled by whichever observer was registered first. The `is_empty` guard on the hooks is not an optimisation. io-harness disables read speculation on any run carrying a `Hooks` at all, even an empty one, so attaching it unconditionally would quietly make every session slower for the overwhelming majority of operators who have never written a hook — with nothing failing and nothing on screen to connect the loss to this release. `configure::refusal` gives a failed `Config::discover` a sentence an operator can act on. The commonest way a well-formed repository stops io from starting is a `[[hook]]` in the project file, which io-harness refuses because that file arrives with a `git clone`. That refusal is correct and is not softened; it is passed through whole, with the directory named, instead of arriving as a bare error string from a program that has already exited.
`/plugin` is the surface for a bundle an operator dropped in. A bundle is a directory with a `plugin.toml`, named by a `[[plugin]]` entry — a declaration, never a scan — and it can hand over skills, templates, agents, MCP servers, hooks and deny-only policy at once. io-cli had never called `Config::plugins()`, so none of it arrived and the two events that report it described a route that could not fire. `Plugins::apply_to` now runs in `contract::configured`, which is why one call covers both arms. `/plugin` lists what loaded and what it contributed, descends into a bundle for its agents, servers and policy layers, and lists a dropped bundle with io-harness's own sentence — which matters most for the one an operator will actually hit, where a project-declared bundle contributing hooks or servers is refused whole and the sentence is what names the two files it could move to instead. What cannot honestly be shown is not implied. A bundle's hooks are not listable: there is no `Plugin::hooks()` and `Hook` is private to io-harness, so the word `hooks` from `contributions()` is the entire signal, and the surface says so. Both plugin events stay silent on the stream and the route column stops lying about why. They fire at step 0 of every turn, so a line each would be a line about a directory that has not moved since the session started; `/plugin` is where standing facts belong, and it exists now. The fleet gains the two facts the event stream does not carry. `EventKind::Spawned` has neither address nor role, so both come from the store: a child draws as the address it was spawned under, with its roster role beside it, and a message one agent sent a named sibling is drawn with its body. Read on `Step` and never on `Spawned` — io-harness commits the row at the step boundary, and a read at the announcing event is a race that passes on a quiet laptop and fails on a loaded CI machine. Naming is unconditional and costs one query per fleet; the mailbox is read only while the pane that shows it is open, because `messages_for` returns a whole mailbox every call and polling it per step is the same quadratic sweep `status.rs` already records for the context read. `Enter` on a detached child attaches to it. The selection has driven only the highlight since 0.8.0; this is what it was for. It is inert on every other row on purpose — a working child's events are already in the scrollback, and replaying them from the store would be two copies of one run. Watching is a mode rather than a background poll, because the idle loop blocks on input and has no tick to hang one on.
The README gains sections on capability bundles, hooks and documents, and its command table gains `/plugin`. The documents section names all twelve tools with the six that write, and states what they cannot do — Word is generate-and-read with no in-place edit, PowerPoint is read-only, PDF reading order is best effort and a scanned page comes back empty rather than erroring, `xlsx_set_cell` preserves a workbook in practice rather than by guarantee, and there is no OCR and no barcode generation. An operator who finds that out by losing work has been failed by this file. `docs/config.example.toml` documents `[[plugin]]` and `[[hook]]` for the first time, with the scope-trust rules as comments. The CHANGELOG names the `image` pin, because an operator running `cargo update` will meet it.
Fourteen tests over the two halves of the release that had none. The plugin tests build real bundle directories with `tempfile` and assert through io-harness's own calls rather than io-cli's account of them: a declared bundle reaches the contract with its agents namespaced; each way a bundle can be broken produces one dropped row and leaves the others loading; a project-declared bundle contributing hooks or servers is refused whole, and the same bundle in local scope loads fully. The `Edit` builders are tested through `edit::apply` on a file carrying sections io-cli has no type for, because surviving those is the property that matters about splicing bytes rather than re-serialising. The hook tests defend the guard that nothing else would notice. `contract::hooks` returning `Some` on a file with no hook is a change no test fails on and no operator can see — it just quietly costs everyone read speculation — so there is a test whose only job is that it returns `None`, and another asserting the contract carries no tool hooks to match. The rest assert io-cli adds no check of its own: a malformed hook is refused by io-harness, in io-harness's words, and the project-scope refusal keeps the harness's sentence intact while naming the directory it was read from.
`f5_both_arms_are_handed_a_steer_inbox` reads the driver's source and asserted the literal `&observer` in both turn calls. That argument is `watcher` from 0.20.0 — the local holding `Broadcast` over `Fanout` over the bridge and the operator's hooks — so the needle is re-pointed at its new shape rather than widened until it matches again, which would convert the gate into a rubber stamp. What the test asserts is unchanged: both arms take the caller's contract and a steer inbox, in that positional order.
clippy `-D warnings` had four: two `.err().expect()` in the new hook tests,
which `expect_err` says in one call, and two hand-rolled ASCII sweeps in the
fleet's tests that `str::is_ascii` already answers. `cargo fmt` reflowed
five files.
The fourth gate is the one io-cli's CI does not run and io-harness's does:
RUSTDOCFLAGS="--cfg docsrs -D warnings" cargo +nightly doc --all-features --no-deps
It caught two doc links in this release's own new modules, both invisible to
stable `cargo doc` and to clippy: `Fleet::rows` linked `Fleet::selected`, a
private field, and `pluginview::rows` linked `ROOT_FLOOR`, a private const.
A public item's documentation may only link public items — the fix is to
name the private collaborator in prose, which is what both now do.
Four more of the same class are older than this release and are left alone:
`context.rs` has an unresolved `ContextBudget::effective_tokens` and a
redundant explicit target on `MCP_TOOL_PREFIX`, `home.rs` links `chosen` and
`picker.rs` links `Picker::refilter`, both private. They are recorded here
rather than fixed because they belong to modules this release does not
touch, and because the gate that finds them is not in `ci.yml` — which is
the more useful thing to know.
The suite was green for all of them. Two were severe. **`Enter` was swallowed whenever the fleet pane was open.** The new attach arm returned on every `Enter` rather than only at an empty prompt, so a line typed to be queued behind a running turn was silently dropped, and `Shift+Enter` never reached the composer — a multi-line prompt could not be written. The pane is drawn over the prompt, not in front of the keyboard, and the queue surface four lines below had the guard right all along. Worse, the pane is open exactly when a turn is running, which is when queueing a line is most likely. `tests/queue_surface.rs`'s existing fleet test went on passing, vacuously, because `Enter` no longer reached anything. **The fleet was never cleared between turns.** `Fleet::forget` was reachable only from `/resume`, `/fork`, a rewind and `/clear`, so a turn that fanned out left its children in the model for the rest of the conversation. 0.19.0 could afford that. 0.20.0 could not: the model now also holds mail, so turn two drew turn one's agents and their messages as though they were current — and because `note_fleet`'s cheap early return is `Fleet::is_empty`, one fan-out anywhere in a session put a `run_root` and a `tree_addresses` query on every step of every ordinary turn afterwards, which is the exact cost that guard exists to avoid. Cleared at the start of a turn rather than the end of one, so a fleet stays readable after the turn that produced it. The rest, all in the attach path: - Attaching to a child that had already finished polled forever and printed nothing. `from_now()` does not look at the run, and a detached child's `Finished` reaches nobody once its parent's turn has ended — so the row still reads `detached` and is precisely the row an operator selects. The run's status is now asked first. - A watched child's events went through `App::event`, which folds them into the status line and the fleet. Its tokens inflated this session's total permanently, its provider and model replaced this session's on the footer, and its tree was grafted into the pane opened to look at it. `App::watched` draws the lines and counts nothing. - `Resize` was dropped while watching, leaving the screen believing a width the terminal no longer had. - `Ctrl+C` did nothing while watching — the one key a configuration file may not rebind, inert on the one surface that had no other way out. - The poll re-armed its timeout after every key, so an auto-repeating key or a dragged window edge turned ten store reads a second into tens. And one outside it: `contract::configured` built a `Hooks` unconditionally, including for `server_notices`, which calls it with an empty root at startup. `Hooks::new` creates every `append` path it is given — so a relative `append = "audit.jsonl"` left a stray empty file in whatever directory `io` was launched from. `/plugin` also gains the removal the contract promised and this release had not built. The entry is found by matching the directory across all three scope files, never by picker row: `rows()` lists loaded bundles then refused ones, an order with no relation to any file's, so a row number handed to `pluginview::remove` deletes a bundle nobody named. Where no file names the directory, io says so and removes nothing, and the directory itself is never touched. Two findings are documented rather than fixed, in the README's hooks section, because both belong to io-harness: a `run` hook executes on the turn's own critical path with `timeout_ms` as its only bound, and a hook that fails reports through a log this binary installs no subscriber for.
io-cli 0.20.0
Ten tests over the fixes, and one more fix they exposed. `pluginview::declared_at` gets five: the entry's own index rather than a position in some other list, a relative and an absolute declaration resolving alike, four near-miss paths that must return `None` rather than guess, local scope winning when two files declare the same bundle, and a file with no `[[plugin]]` at all. The near-miss test is the one that matters — the caller must refuse rather than remove something, because deleting the wrong entry is silent and the operator finds out when a bundle's skills stop being offered. The hooks tests lead with a positive control: with a real root `contract::hooks` returns `Some` **and** the `append` file now exists, which is what makes "no `Hooks` built" mean "no file written". Then the rootless arm, and `server_notices` driven directly — the caller that passes an empty root at startup — asserting the working directory gained no `audit.jsonl`. The fleet gets three, and one of them found something: guarding the pane's `Enter` on an empty composer was not enough, because the arm matched regardless of modifiers, so `Shift+Enter` at an empty prompt was still swallowed. A newline is a smaller loss than a dropped queued line but it is the same defect, and `modifiers.is_empty()` is the rest of the guard. Two things are recorded rather than covered, and neither is papered over. Isolating the process working directory would have made every other test in that binary order-dependent, so the CWD half is the control plus the `server_notices` observation rather than a `set_current_dir`. And `tests/queue_surface.rs`'s fleet test is passing vacuously — it cannot reach the `!self.fleet_open` guard it was written for, because the fleet's own `Enter` arm matches first, and it was vacuous before this release too. Left alone rather than widened into a rubber stamp.
io-cli 0.20.0 — the review's tests, and one more fix
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Release 0.20.0. Four themes, and they turn out to be one thing wearing four faces: io-cli passed exactly one bare
Observerand never calledConfig::plugins().What ships
Capability bundles, and
/plugin. A bundle is a directory with aplugin.toml, named by a[[plugin]]entry — a declaration, never a scan — contributing skills, templates, agents, MCP servers, hooks and deny-only policy at once.Plugins::apply_toruns incontract::configured, the one function both arms are built from./pluginlists what loaded and what it contributed, descends into a bundle, lists dropped ones with io-harness's own sentence, and removes a declaration — finding the entry by matching the directory across all three scope files, never by picker row.[[hook]]tables run, in the session and underio exec. io-cli spawns nothing: io-harness'sHooksis itself anObserverand owns the subprocess, its timeout and its kill, so the one-spawning-module rule holds. Both halves install, or the file is half accepted and never run. A project-scoped[[hook]]is still refused, now in words an operator can act on.The observer composition, which is the spine.
Broadcastis not a fan-out — it decorates exactly one inner observer — so io-cli foldsFlowitself, calling every observer even after one cancels and folding soCancelwins whatever the order.Broadcaststill wraps it, because it is the only writer ofrun_events, which is whatAttachreads.The fleet gains names. A child draws as the address it was spawned under with its roster role; a sibling message draws with its body;
Enteron a detached child attaches and watches it. Store reads happen onStep, never onSpawned, because io-harness commits the row at the step boundary.Documents.
features = [..., "documents"]— the umbrella io-harness already declares as the five formats named. Twelve tools, six of which write, disclosed in the README and CHANGELOG. 159 transitive crates;imagepinned to=0.25.8becausebarcodeisrxing. No direct dependency added.The adversarial review
Budgeted per release, and it paid for itself a third consecutive time: 12 findings, 8 real defects, all behind a fully green 1,067-test suite. Two were severe —
Enterwas swallowed whenever the fleet pane was open (a queued line silently dropped,Shift+Enterunusable), and the fleet was never cleared between turns (turn one's agents and mail drawn as turn two's, permanently defeating the guard meant to keep store queries off ordinary turns). Also fixed: attaching to a finished child hung forever; a watched child corrupted this session's token total and provider;ResizeandCtrl+Cwere dead while attached; a poll busy-spun on keystrokes; andserver_noticescreated stray hookappendfiles in the launch directory.Two findings are documented rather than fixed, because io-harness owns them: a
runhook executes on the turn's critical path, and a failed hook reports through a log this binary installs no subscriber for.Gates
cargo test --all-targets— 79 binaries, 1077 passed, 0 failed, 24 ignored (0.19.0 finished at 76/1023)-D warnings,cargo fmt --check,cargo test --doc— cleanRUSTDOCFLAGS="--cfg docsrs -D warnings" cargo +nightly doc— clean in every file this release touches; it caught two public-docs-link-private-item errors in the new modules, and it is not inci.ymlio-harness stays pinned at
0.69— the newest published, satisfied by not moving, the sixth time.