Skip to content

io-cli 0.25.0 - #64

Merged
aakashpawar1999 merged 14 commits into
mainfrom
develop
Aug 28, 2026
Merged

aakashpawar1999 merged 14 commits into
mainfrom
develop

Conversation

@aakashpawar1999

Copy link
Copy Markdown
Contributor

Release PR for 0.25.0 — the git surface.

A turn's work ends as something somebody can review: the branch on the status
line, a commit in the scrollback with the message the agent wrote, /commit
handing the work to the agent to describe and stage under
[run.commit_identity], and worktree = true rooting a child agent in a
checkout of its own.

And the repair that made any of it reachable. io-harness's git spawn accepts
only Effect::Allow and returns Error::Refused for anything else, where every
other gated act routes Ask to an approver — so under ask-writes, the posture
this product recommends, all seven git built-ins were refused before they ran and
nobody was ever asked. Filed as initorigin/io-harness#214, with #215 for a
worktree child's root being written to runs.file and never selected.

Verification

  • Suite 89 binaries / 1,315 passed / 0 failed / 31 ignored (0.24.0: 86/1,255/28)
  • Live suite 30/30 against a real provider, including the worktree rooting
    and a real exec/git refusal observed from an asking posture
  • clippy, fmt --check, and the nightly rustdoc gate clean
  • Every acceptance criterion sabotaged; each killed only its own arm
  • Full CI matrix green on all three platforms, msrv and CodeQL included

The adversarial review

Two HIGH defects behind a fully green suite — the eighth consecutive release,
and two of three reviewers found both independently: a scrollback that would have
claimed commits that never happened, and /commit allow defeating the very
posture the pre-turn check exists to protect. A third reviewer found one of this
release's own new gates was vacuous.

This release also repaired the 0.19.0 gate for advertised-but-inert commands,
which had been matching only /help since the unknown arm stopped returning
the help listing.

develop is byte-identical to the tree the gates ran on. The UltraShip record is
sealed and the lock pinned; .ultraship/ is gitignored whole, so both live on
disk and never in this history.

Adds the two module seams 0.25.0 is built on and the crate-doc paragraph
that names them.

src/repo.rs reads the branch the working tree is on from .git/HEAD with the
standard library: a symbolic ref, a detached head reported as a short object
id, a linked worktree whose .git is a file, and a directory that is not a
repository at all. No subprocess is involved; src/shell.rs remains the only
file in the crate permitted std::process::Command.

src/commit.rs reads a commit the agent made off the typed ToolCall the model
issued, via Store::step_turns, and states in its rustdoc why the display
string StepRecord::tool_call is deliberately not used: it joins name and JSON
with a colon, and splitting on that fails on the first commit message
containing one.

The module is repo and not git because the git engine is pub(crate) in
io-harness, so a crate-root module of that name would send a reader looking
here for something that is not here.
io-harness's git spawn checks the exec policy itself and accepts only
Effect::Allow (tools/git.rs:549-561). Every other gated act routes Ask to an
approver; this one returns Error::Refused. So under an asking exec posture all
seven git built-ins are refused before they run and the operator is never
asked -- and Posture::AskWrites, which io-cli's own wizard recommends, is
exactly that posture.

Adds three items to src/approval.rs, built on the existing effective_policy
remembered layer rather than on a second mechanism:

  GIT             the binary name, which is the whole vocabulary an Act::Exec
                  pattern has -- it matches a program, never a command line
  refuses_git     whether the policy in force falls short of Allow for git,
                  which is upstream's own comparison rather than a test for Ask
  git_allowance   Rule { act: Exec, effect: Allow, pattern: GIT }

Four arms in tests/policy.rs, each asserted on a Policy::check verdict and
none on a rendered word. The arm that pins the pattern is the one asserting
the rule changes nothing for another program: with pattern "*" the other three
still pass, because a wildcard satisfies them by granting every binary on the
machine.

Filed upstream as io-harness#214.
The status line, /commit's report of where work will land, and the commit
block's branch line all read from this one function.

Four shapes, only one of which a developer's own checkout usually has: a
symbolic ref, a detached head reported as a short object id, a linked worktree
whose .git is a file holding a gitdir: line, and a directory that is not a
repository at all, which answers None so io-cli stays silent where it always
was. A ref that does not live under refs/heads/ answers None deliberately, and
a HEAD that is not hex answers None rather than putting seven characters of
arbitrary file contents on the status line dressed as a commit.

Thirteen arms drive repo::branch against real .git trees built in a TempDir --
HEAD with its trailing newline, config, objects/, the loose ref, and for the
worktree case the .git file plus worktrees/<name>/{HEAD,commondir,gitdir}. The
worktree arm asserts the child reads feat/0.25.0 while the parent reads main,
so reporting the parent's branch cannot pass.

Three defects fixed while writing them: git_dir consumed the whole .git file
rather than its first line, so a stray second line turned a readable worktree
into None; a comment described taking the ref's last component where the code
strips the refs/heads/ prefix, which is what keeps feat/0.25.0 from rendering
as 0.25.0; and the detached arm built a String before checking it was hex.

The module doc no longer spells the forbidden spawn name in order to say it is
not used -- tests/dependencies.rs matches that literal in raw text with
comments included, so the sentence reddened the gate it was describing.
No EventKind carries a commit message, a branch or an object id, and no Store
method returns one. The message survives in exactly one place -- the call the
model made -- so made_in reads Store::step_turns and takes it off the typed
ToolCall.arguments.

StepRecord::tool_call carries it too, as git_commit:{...} joined by " | ", and
is deliberately not used: that joining is an internal display convention rather
than a documented format, and splitting on the colon fails on the first commit
message containing one, which is most of them. Two arms pin that property.

A call is a decision, not an outcome. AssistantTurn is staged from the
provider's response before any call is dispatched, and no event carries a tool
result, so a git_commit that was refused or failed reads identically to one
that landed. made_in answers what the model asked for; deciding whether it
landed belongs to the caller, which watched the refusal go past. Documented
rather than guessed at -- the wrong half of that boundary is a scrollback
claiming a commit that does not exist.

authored_as needs no fallback: TaskContract::commit_identity is an Identity
rather than an Option, already holding the harness default when the operator
configured none, so an unconfigured repository is told which default will be
used instead of being given one this crate invented.

Three defects fixed while writing the tests: an empty subject for a message
opening with a newline, two trailing spaces where a blank body line belonged,
and "committed on " with nothing after it for a blank branch.
A child working in its own checkout is invisible otherwise, and an operator
watching two children edit what looks like one tree has no way to tell whether
they are colliding.

The row says whether, not which, and that boundary is the point. io-harness
writes a contained child's real worktree path into its runs row and no query
ever selects it back out; the functions that derive one are private. A path
drawn here could only be reconstructed, and a reconstruction is an address that
disagrees with the truth the moment either side changes. The DERIVED_MARK note
already permits copying a constant only where being wrong is harmless -- being
wrong about a directory an operator would cd into is not.

role_of becomes def_of, returning the AgentDef rather than the role string, so
the label and the containment bit come off one roster lookup that cannot
disagree with itself. An address the roster cannot name is not marked rather
than guessed at, and a reload that drops the switch unmarks the child instead
of keeping a stale mark.

The mark is a word, so it is identical in both glyph sets and needs no Glyphs
class. It costs 11 columns and only on a marked row, drawn immediately before
the goal so the goal is still the column that gives way.

Filed upstream as io-harness#215.
…rded it

/commit sits in Group::Turn, which it takes to ten of ten -- the bound. It
belongs there for the reason 0.22.0 filed /image, /copy and /copy diff there:
all of them act on the work the turn just finished. /copy takes the answer,
/copy diff takes the patch, and this one makes the patch permanent.

The wording says "ask the agent" because io-cli runs no git. The command sends
commit::prompt and the agent does the reviewing, staging and message-writing.

The gate 0.19.0 built for advertised-but-inert commands was vacuous, and the
sabotage is what exposed it. Removing the parse arm while leaving the COMMANDS
row killed nothing: 27/27 green. The check only ever asked whether
parse(name) == parse("help"), which held while the unknown arm returned
help(...) -- and it has not for some time. That arm now builds a warning notice
plus commands(theme) with no key table, so an armless command's Action::Print
can never equal /help's. The gate had been matching exactly one thing, /help
itself, and was blind to the whole class of defect it was written for.

It now catches both inert shapes: equality with the help listing, and a Print
carrying "there is no /<name>". Against the repaired gate the same sabotage
fails with left: ["/help", "/commit"], right: ["/help"].

A test can go vacuous without going red -- this time inside the gate built to
prevent exactly that.
The field describes the checkout, not a run and not a conversation, so neither
forget_run nor start_run clears it -- it sits beside policy and budgets rather
than beside containment. forget_run's doc names it in the leave-alone list, so
the omission reads as deliberate rather than forgotten.

note_branch takes the whole RunEvent and filters internally, matching against
tools::GIT_BRANCH_TOOL rather than a literal, so the driver can hand it every
event it already routes. For the turn-boundary re-read the field is pub, so
repo::branch needs no second API.

branch_field is the one renderer both fields() and footer call, which is what
stops the two disagreeing -- the same shape cost_field and gate_field use, and
the answer to how 0.12.0 shipped a planning mode that appeared nowhere on
screen. Both arms assert through the both_renderers helper.

Spelled git:<name> to match policy: and provider:. A bare branch name sitting
between a posture and a containment word has no owner.

The sabotage -- clearing it in forget_run beside containment -- kills exactly
one arm and leaves fifty-nine green.
Adds a ## Git section between ## Verification gates and ## Pictures. The
surrounding sections are the arc of what a turn does -- the fleet, planning,
the gate -- and this is the next beat: a gate says the work holds up, this says
what becomes of it.

It covers the seven built-ins and the fixed argv that can reach no other
subcommand, the branch on the status line, the commit block and why the diff is
not redrawn, /commit as a prompt the agent answers rather than a git
invocation, [run.commit_identity] as something read and never chosen, and
worktree = true.

The part that matters most is a defect repair rather than a feature, so it is
written as one: io-harness's git spawn accepts only Allow and returns Refused
for anything else, so an asking exec posture -- the one this README recommends
-- had every git tool refused before it ran, with nobody asked. The section
names the single exec/git rule that lifts it, why one binary name is the
narrowest grant available, and why no rule is offered under a posture that
denies rather than asks.

It ends on What none of this does: nothing removes a worktree, nothing deletes
a branch, no pull request, no remote.

docs/config.example.toml gains the [[agent]] block it never had, stating what
worktree = true costs and that nothing removes one, so .worktrees/ accumulates
until cleared by hand.

tests/docs.rs is unchanged on purpose -- its assertion is rows ==
COMMANDS.len(), so it went green on the README edit with nothing to widen.
…fts it

The reactive half of the refusal work. note_git runs at the end of App::event,
so the paragraph lands under the per-call refusal line events already commits,
and it consults nothing about /commit -- any of the seven tools fires it. An
agent that reached for git_branch on its own initiative is explained too, which
is the whole point: the silent case is the one this release exists to repair.

allow_git is the only door to the rule. Git::run refuses anything short of
Allow before an approver is consulted, so no ApprovalRequested ever reaches
answer_approval and the operator can never arrive at the remember path that
already exists for every other act. Idempotent by value rather than by a count.

The guard matches act == "exec", io-harness's own word on the wire, and not
approval::act_word(Act::Exec), which reads "run" for the operator. Matching the
operator's vocabulary there would match nothing, forever, in silence.

Deduplicated per turn rather than per session: five refusals in a step are five
facts but one reason, and the operator may have changed posture between turns.

set_branch and branch() are an accessor pair over Status::branch rather than a
field of their own. A second copy would be a second answer to one question, and
the two would part company the first time either was set alone -- the shape of
the defect 0.17.0 found between /context and ctx N%, where neither number was
wrong and the pair was.

The sabotage -- explaining only a /commit-initiated refusal -- kills four of
the nine arms.
The driver half, held in the main thread: nothing under tests/ links main.rs,
so every decision here is a call into the library and the arm holds wiring.

/commit is rewritten into a Command::Submit before the match rather than
answered inside it. Every other command in that arm reports something or writes
a file; this one hands the turn's work to the agent, so it has to become the
same command a typed prompt becomes -- otherwise the prompt would sit in the
queue until the operator started a turn of their own. What remains in the match
is a debug_assert rather than a wildcard, so the next command added without an
arm still fails a named test.

/commit allow exists because the refusal was otherwise an offer nobody could
accept. allow_git had no caller from any keystroke: io-cli named the rule that
lifts the refusal and gave the operator no way to take it. The word is an
argument on the existing row rather than a second command, following
/contain on|off, and the allowance is taken before the policy is built or it
would not be in the policy the same keystroke is judged against.

commit_commits is a sibling of commit_edits, wired at all three of its sites
and anchored on Step for the same reason. It gates on changed: true, which is
what separates a commit that landed from one the policy refused -- made_in
reads the model's requested calls and cannot tell those apart, so the caller
has to be the one that knows.

The branch is re-read from .git/HEAD at the turn boundary, so a git switch run
in another terminal is stale for at most one turn.

Status::note_branch had no production caller after the branch holders were
collapsed. Routing note_git through it turned a test red: it recorded a branch
called git_branch when the call named nothing, because io-harness falls a
call's subject back to the tool's own name. The two implementations disagreed
and the stricter one was right.
Two defects the rendering gates found, one this release introduced and one it
inherited.

The branch field went into the footer's right-hand group on the argument that
it describes the circumstances the agent works in rather than counting
anything. That reads well and is wrong: row fits that group all or nothing, so
at eighty columns with planning on, a set branch took the posture, the
containment mode and the planning phase off together and left a bare key hint.

  [None]          "/ for commands   read-only · workspace-write/… · planning"
  [Some("main")]  "/ for commands"

The break-even is a branch name one character long, so no real branch escaped
it. It is verbatim the failure f4_a_full_counts_row_drops_a_counter_and_not_
the_planning_phase exists to prevent, and 0.22.0 shipped its ancestor.

The branch now lives in counts, pushed last, so counts.pop() takes it before it
takes any number. A branch is the one fact on that row an operator can afford
to lose -- it is a keystroke away on the /status page, while a posture that
silently vanished is not.

Second, pre-existing: the idle footer spelled its bullet as a literal U+2022
instead of theme.glyphs.bullet, so a terminal told it cannot render Unicode was
handed one anyway. It survived every release because every sweep in
tests/glyphs.rs ran over Status::line, which only a terminal under seven rows
draws, while Status::render takes the footer on every real one -- a gate aimed
at the surface nobody sees.

Putting the branch back in the non-yielding group kills exactly the new arm.
…mine

Three reviewers, split by area, against a fully green suite. Two of them found
the same two HIGH defects independently.

A scrollback claiming a commit that never happened. commit_commits gated on
Step { changed: true }, and its own rustdoc asserted that io-harness sets that
only when a git_add or git_commit succeeded. It does not: changed is a
step-level OR over every dispatch, so an ordinary write_file in the same step
sets it while a git_commit beside that write is refused or rejected. Under the
wizard's own posture the step printed the refusal paragraph and "committed on
main" together. The honest signal is the step's decision, which io-harness
writes as "git_commit ok" per call that actually landed; counting those bounds
what may be drawn, and a refused commit now draws nothing.

/commit allow defeating the posture it was written to protect. The allowance
was applied before the question was asked, so pushing the rule made every
posture -- read only included -- one that permits git, and the check then
bought a turn the .git write gate was always going to refuse. The rule also
outlived the keystroke, leaving git runnable for the rest of a session whose
status line still read policy:read-only. asked() now returns Ready, Offer or
Refuse off the whole Verdict rather than off the tier default, and the driver
applies the allowance only on Offer. That also fixes advice that could never be
taken: under a deny rule a later allow cannot win, so the offer is withheld and
the rule that decided is named instead.

Also from the review: no depth guard, so a worktree child's git_branch rewrote
the parent's status line and its commits were attributed to the parent's
branch; the resume path called started() without re-reading the branch; the
branch was never read at session start, so /status asserted a repository had no
readable head until the first turn; and git_dir did not walk up, so io started
in a subdirectory reported no branch at all.

The dead touched parameter is gone. It had one production caller passing None
forever, because no EventKind carries a tool's result -- only tests supplied
it, and the README described a line no operator could see.

The worktree branch is not named after the roster entry. It is
<entry>-<run>-<step>-<digest>, which is what makes two children of one entry
land in two checkouts; README and config.example.toml both said otherwise, and
both are what an operator reads to go and find the work afterwards.
io-cli 0.25.0 — the work ends as something reviewable
@aakashpawar1999
aakashpawar1999 merged commit 1b91de1 into main Aug 28, 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