Skip to content

io-cli 0.16.0 - #44

Merged
aakashpawar1999 merged 13 commits into
mainfrom
develop
Aug 25, 2026
Merged

io-cli 0.16.0#44
aakashpawar1999 merged 13 commits into
mainfrom
develop

Conversation

@aakashpawar1999

Copy link
Copy Markdown
Contributor

The release PR for 0.16.0 — the configuration file as a surface in the session.

/config, /mcp, /provider and /profile; a writer that replaces one value's
bytes and copies every other byte through; the three TaskContract ceilings
io-harness gives no configuration key; the removal of [app.io-cli] max_steps
that 0.14.0 promised for this release; and the command surface grouped, marked
and committed.

Everything in PR #43, which merged green on three platforms with no flake. The
sealed record, its lock pin and the evidence are on disk under .ultraship/,
which this repository gitignores whole.

  • cargo fmt --check, cargo clippy --all-targets -- -D warnings — exit 0
  • cargo test --no-fail-fast — 61 binaries, 792 passed, 0 failed, 24 ignored
  • cargo build --release — exit 0, binary reports io 0.16.0
  • The built artifact driven end to end: a live run against the real provider, a
    live session showing the removal notice and the grouped palette, and a live
    write through /config that changed exactly one line of a hand-written file
  • io-harness pinned to 0.67, with the whole source audit re-verified against it

io-cli's only writer serialises a whole private File struct and puts it to
disk, which is right for the wizard creating a file and destructive for
0.16.0, where every surface writes into a file an operator wrote by hand.
A re-serialisation drops their comments, their key order and every section
this crate has no type for, and the result still parses and still carries
the value that was asked for — which is what makes the mistake invisible to
any test that re-parses. So the tests assert a byte property.

The mechanism is toml's re-exported Spanned. Three things about it were
measured against toml 1.1.4 first, and each decided the design: a flat
key/value region yields exact value spans for every scalar kind; a table's
own span is its header rather than its body, so the document is cut into
regions before anything is parsed; and a recursive untagged node type
silently loses every nested span instead of erroring. A dotted key reports
the key's span rather than the value's, so those are refused with a
sentence rather than mis-spliced.

No dependency is added — toml_edit is not in the tree and is not wanted.

tests/dependencies.rs forbids toml::from_str anywhere in src/, and caught
this module before review did. Amended the way the spawn ban was amended
for src/shell.rs: permitted by path, plus an assertion that edit.rs names
no io-harness configuration type, so the exception cannot widen into a
second reader of what a setting means.
io-harness has recorded the origin of every key since its 0.30.0 and no
io-cli release has ever asked. This is that question asked.

Three scopes, not four — user, project, local — and a fourth answer that is
not a file: a key no file named has an EMPTY origin, which is the crate's
own default speaking. Attributing that to the lowest-precedence source file
would credit an operator for a value they never wrote, which is a lie a
reader cannot detect. Decided::Default names no path, and the sabotage arm
that makes it name one kills exactly one test.

MemorySection is private in io-harness 0.66 and there is no Config::memory,
so a surface built only on typed accessors would have a hole exactly where
an operator had written something. Those keys are quoted from the file the
origin names, through edit::value_at — the writer's own span machinery,
reading bytes and interpreting nothing.

The catalogue is io-cli's own list because the harness exposes no
enumeration of its schema, so a gate holds it to docs/config.example.toml.
It caught four invented keys on its first run: policy.defaults separates
read from write, and [memory] is max_entries, max_chars and max_entry_chars.
The example documented those three sections in prose and named none of
their keys; it now carries every one the catalogue offers.

A key outside the catalogue is still shown. A surface that listed only what
it already knew would hide exactly the keys a reader went looking for.
…he refusal

The write is verified by io-harness reading it back, and rolled back when
it refuses. That is what keeps io-cli from holding a second copy of the
harness's rules: refuse_widening fires on the scope of the FILE, and only
Config::discover knows that — Config::from_toml has no path and therefore
no scope. So the only honest way to ask "may this file say this" is to
write it and re-discover, and when the answer is no the original bytes go
back and the harness's own sentence comes out, re-worded by nobody.

All seven refusal cases are covered: the five PROJECT_WIDENING pairs and
the two sections refused wholesale. The same five values are accepted in
io.local.toml, which is what proves the rule is about the scope rather than
the value — a test asserting only the refusals would pass against a build
that refused them everywhere.

reload returns the Config AND io-cli's own CliSettings, because main derives
the second from the first once at startup; refreshing only the Config would
leave the theme, the glyph set and every capability as they were at session
start while the surface claimed otherwise.

edit gains append and remove: set reaches a key inside an entry that
already exists, and an array of tables grows by gaining a whole block. A
new entry goes last, because for [[provider]] the order is the fallback
chain and inserting into the middle would rearrange which provider runs.
/config alone opens a picker over every setting with the value in force and
the file that decided it. A chosen row fills the composer with the key
rather than writing anything — the palette's own idiom, and the right one
here: a picker that wrote on a keystroke would change a file on the way
past. /config <key> answers what is in force. /config <key> <value> asks
which of the three scopes it goes in, and only that choice writes.

config and capabilities are both mutable now and both re-derived after a
write. That is F3's sabotage made structural: main derives io-cli's own
settings from the Config once at startup, so refreshing only the Config
would leave the theme, the glyph set and every capability as they were at
session start while the surface claimed the value had changed.

Adding one command row failed four gates in three files, which is the blast
radius this release's risks predicted: the written-out COMMANDS list, the
README table, and the palette's narrowing counts, which are stated rather
than derived so a count cannot recompute itself into passing while the
narrowing it describes stops happening. All three updated.

The fourth was this branch's own gate catching its own prose — edit.rs may
not reach for a configuration type, and its doc comment explains why, which
means naming several. The gate now strips comments: it is a property of
what the code does, and one that read prose would forbid the file from
explaining itself.
…t ceilings

[app.io-cli] max_steps was deprecated in 0.14.0 with a notice naming this
release, in the terminal, the README and the CHANGELOG. It is gone.

The notice is not, and the pairing is the point. CliSettings carries no
deny_unknown_fields, so a file still holding the key parses fine and the key
is simply ignored: no error, no warning, and a step cap that quietly changed.
So the notice reads the raw section now, through Config::app with a shape
naming only the dead key. Its sabotage is deleting the notice along with the
field, under which the contract is still right and an operator's turns
quietly start ending at a different number with nothing to say why.

Two existing tests asserted the behaviour being removed. Both rewritten to
assert what is true now: [run] max_steps is the only spelling left.

In its place, the three TaskContract ceilings io-harness gives no key at
all — max_parallel_reads, spawn_background_after_secs, detached_spawns.
RunSection carries thirteen fields and none of them is these, so io-cli
names them in its own section.

They are applied in contract::configured rather than beside the other
[app.io-cli] keys, and the placement is the criterion: io exec calls
configured and nothing else, so a ceiling applied in session would bound a
terminal and leave CI running on the defaults. That is the asymmetry 0.14.0
deleted, and the sabotage that reintroduces it fails only the headless arm.

The README key-table gate failed by design and caught all three of a missing
row, a stale row, and the prose count above the table.
The registry moved under this release. 0.66.0 was the newest published
version when the contract was written; 0.67.0 is now, and the standing rule
is that every release pins the newest published io-harness.

The whole source audit was re-verified against 0.67.0 after the bump and
every load-bearing fact holds unchanged: Scope still has three variants,
PRESETS is still 21 and still pub(crate), Config still derives only Debug,
Clone and Default, MemorySection is still private, the eight Config
accessors this release uses are still public, and EventKind::Mcp still
carries exactly server, tool, ok and millis. The suite is unmoved at 752.

0.67.0 also closes the wall io-cli 0.17.0 was declared to be blocked on:
turn_bounded_steered and turn_contained_bounded_steered take the caller's
contract, an observer and a steer inbox on one call. Nothing in this release
uses them; the roadmap entry for 0.17.0 is corrected to say the dependency
is satisfied rather than pending.
Configured and reached are different sets, and the second is the one an
operator is asking about. The status line's aggregate counts what the run
reached and carries no server names at all, so a server that is in the file
and never answered looks exactly like one that is not in the file.

Three states, and "not reached this session" is its own. It is the state
every server is in before the first turn runs, and drawing it as a failure
would tell an operator their configuration is broken at the moment it is
most likely to be fine. The sabotage that does exactly that kills two tests.

The count is distinct tools asked for, not calls, and the panel says it is a
lower bound. EventKind::Mcp carries server, tool, ok and millis and no tool
count, and io_harness::mcp exposes no catalogue accessor, so how many tools
a server OFFERED is not on any channel this product can reach. It becomes
reachable in 0.17.0, behind io-harness 0.68.0.

Which is also why the status line's own field is renamed here. `mcp N/M
tools` counted calls from 0.10.0 to 0.16.0, for exactly this reason: the
number it wanted was never on the wire, so it counted the one that was.
Renamed in both renderers, because Status has two and a field added to one
is a field missing from the other.

Add, edit and remove write through the release's own writer. The byte
property is proved on the one table where the loss would be silent: an
[[mcp]] entry carrying env and args, neither of which io-cli models, and
which serde does not reject because McpServer is flatten-based.

Disable and reconnect are not offered. There is no key for disable, and an
invented one would be accepted by the file and ignored by the harness, so
the server would start anyway. There is nothing to reconnect: servers attach
per turn, so the panel says a change takes effect on the next turn.
… been

io-harness has read [[provider]] as a fallback chain since its 0.27.0 — the
first entry is what a run uses and each later one is the next link — and no
io-cli release has offered a way to arrange it. This interface has drawn an
event for a fallback happening without ever being able to cause one.

Reordering needed a new writer primitive. Order is meaning here, so a move
carries the entry's own bytes: its comments, and every key io-cli does not
model. The test asserts the moved entry arrives whole, that a later section
does not shift, and that moving it back is the identity.

The twenty-one presets are offered, and io-cli's list is proved against
io-harness's rather than trusted. PRESETS, preset_names and preset_list are
all pub(crate), so the real list is read out of Compatible::preset's own
refusal, which its documentation says names the presets that do exist. The
gate checks the refusal still carries them before comparing, because two
empty lists compare equal and that is a control that cannot fail. Where a
preset points, and whether it is local, come from Compatible::base rather
than a second table here.

The credential column reads the file's own bytes, because io-harness
substitutes ${env:…} while parsing and refuses an unset variable outright —
so by the time a Config exists the indirection is already gone, and showing
an operator which variable they named means quoting what they wrote.

Verification is the wizard's, reused. The panel adds none of its own, which
an existing gate now enforces: it may name Compatible::preset to ask a
preset a question, and may not keep the answer.
io-harness has carried [profile.<name>] since its 0.27.0 and no io-cli
release has ever selected one, so a set of choices has never had a name in
this product.

It has no accessor for them either. with_profile applies one by name and
says so when the name is wrong, but nothing lists them: the merged table is
private and profile keys do not appear in Config::origins, which a probe
confirmed prints nothing for a file declaring two. So the names come from
the file, through a new edit::sections that exposes the scan the writer
already cuts a document with — header paths only, nothing about what is
inside them.

Two doors. --profile is global like -C and -m, because a set of choices you
want for one run is exactly what you type on either side of a subcommand,
and it is applied above both arms so a session and an io exec run get the
same overlay from one decision. /profile lists and switches in a keystroke,
re-deriving io-cli's own settings alongside the Config, because a profile
body is the file format again and can carry [app.io-cli] keys too.

Neither writes anything, and a wrong name gets the harness's own sentence.
…w is

Twenty commands in one flat list is a list nobody reads, and this release is
what made it twenty. Four groups by what the operator is doing — the
session, this turn, inspect, configure — none longer than ten.

The palette shows those groups while it is browsed and drops them the moment
a character is typed, because a ranked list with headings interleaved puts a
heading above a row that ranked there for unrelated reasons. A heading can
never be chosen; every movement steps past one, and Enter declines anyway.

That found a real bug: Picker::new sets its match set directly and never
calls refilter, so a grouped list opened with the marker on a heading and
Enter answered nothing.

The kind mark is a third field on Row, and both exclusions are the point.
Not in the label, because the matcher ranks the label and a shared first
character makes exact and prefix matching unreachable — the defect the
stripped slash exists to avoid, coming back as the price of marking rows.
Not in the detail, because the detail is dropped first on a narrow terminal,
which is exactly where the 0.15.0 marking failed. The roadmap said the kinds
were drawn identically; they were not, and the real defect was next to it.

palette and palette_pick now read one construction. They walked the
inventories separately and agreed because they were written next to each
other; headings make that agreement impossible to keep by inspection.

/help renders the same grouping, and its order is asserted to be the
palette's rather than a second arrangement. /usage answers what /status
answers and appears in no inventory, no group and no palette row.
The README's [app.io-cli] key table is asserted against a fully-populated
CliSettings, so removing max_steps and adding three ceilings failed it three
ways: a missing row, a stale row, and the prose count above the table, which
the gate reads because a count written in prose is the first half of a table
to go stale.

The command table is grouped now, matching the palette and /help, so three
surfaces cannot describe three different arrangements. The palette's marks
and the unlisted /usage alias are documented beside it.

The CHANGELOG states the removal as a removal, under its own heading, with
why the notice outlives the field: [app.io-cli] is not schema-checked, so
without it the key would simply stop working. A Changed heading records the
mcp label, which counted calls under the word "tools" since 0.10.0, and a
Known limitations heading records the tool count and the absent disable verb
with the reason for each and where they go.

config.example.toml gained every [run], [sandbox] and [memory] key the
/config catalogue offers; it documented those sections in prose and named
none of their keys, which is what the catalogue gate caught.
The version bump, and the two findings clippy raised on the release gates:
an unnecessary_sort_by in the writer's splice ordering, and a single_match
in the scope-picker arm. The sort keeps its stability, which a move depends
on — it pushes a removal and an insertion that can share a start.

Gates: fmt clean, clippy clean, 61 binaries / 792 passed / 0 failed / 24
ignored, release build green and the binary reports io 0.16.0. The doc gates
and the release binary both came after the bump, which is the rule 0.57.0,
0.60.2, 0.65.0 and 0.14.0 each paid for once.
io-cli 0.16.0 — the configuration file as a surface
@aakashpawar1999
aakashpawar1999 merged commit 04fad6a into main Aug 25, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant