diff --git a/.ultraship/checkpoints/20260817T020313Z-io-cli-0.2.0.yaml b/.ultraship/checkpoints/20260817T020313Z-io-cli-0.2.0.yaml new file mode 100644 index 0000000..9d0ff96 --- /dev/null +++ b/.ultraship/checkpoints/20260817T020313Z-io-cli-0.2.0.yaml @@ -0,0 +1,33 @@ +product: io-cli +version: 0.2.0 +timestamp: 2026-08-17T02:03:13Z +reason: blocker +repository_state: passing +completed_acceptance: + - > + F1, F2, F3, F4, F5, F6, F7, F8, F9 — every functional criterion but F10, each with a sabotage + arm run and each arm failing only its own criterion's tests. + - > + N1, N2, N3, N4, N5 — no test sleeps or reads a clock; the dependency set is the same eight + names plus tempfile; every structural property of 0.1.0 still holds; io-cli still contains no + policy engine; every new surface has a defined form at eighty columns. + - > + O1, first half — CI green on macOS, Linux and Windows on feat/0.2.0 at afcfb7c (PR #9), all + eight checks, no re-runs. + - O2 — cargo build --release exit 0; ./target/release/io --version prints io 0.2.0. + - O3, in progress — feat/0.2.0 is cut from develop and PR #9 is open into it. +remaining_acceptance: + - > + F10 — the owner runs the release binary under *ask before writes*, approves one write, denies + another, and says whether the overlay reads right. It cannot be delegated to a test: what this + release ships is a moment, and only a person can say whether a run stopping to ask reads as + clear or as alarming. + - O1, second half — CI green again on the merge to develop. + - O3 — develop to main by pull request, then the tag v0.2.0 pushed alone. + - O4 — the Release carries the same four artifacts and SHA256SUMS. +active_hypotheses: [] +next_task: US-IO-CLI-0.2.0-T10 +required_context: + - .ultraship/products/io-cli/releases/0.2.0.yaml + - .ultraship/products/io-cli/execution/tasks.yaml + - src/approval.rs diff --git a/.ultraship/products/io-cli/execution/archive/0.2.0.yaml b/.ultraship/products/io-cli/execution/archive/0.2.0.yaml new file mode 100644 index 0000000..ba2634f --- /dev/null +++ b/.ultraship/products/io-cli/execution/archive/0.2.0.yaml @@ -0,0 +1,42 @@ +product: io-cli +version: 0.2.0 +execution_state: DEVELOPING +checkpoint: null +blockers: [] +release_fit: + assessment: probable + reasons: + - "The workspace records `preferences.minimize_cost: true` and `allow_parallel_agents: false`, + so this is a sequential build with no fan-out. That limit is the reason the tasks below are + ordered as one vertical path rather than as a wide set: the approver seam, then the overlay + that renders on it, then the answers that resolve it, then the fields around it." + - The workspace records no time, token or monetary budget — `resource_profile.available` is null + on every field — so nothing caps this release except the work itself, and the contract carries + a fallback scope that drops the status fields and then the posture key if it does. + - "`probable` rather than `high`, and the reason is one task. Every release so far has been a + one-way render of an event that arrived; this one adds a callback that must ask a human and + block the agent's own task until it is answered. That is the first bidirectional seam in the + product and the first place it can deadlock. The other eight tasks are the shape of work this + repository has done twice." + - "The workspace constraint that io-cli contains no agent loop, no provider client and no policy + engine is not touched. Every value this release constructs — `Decision`, `Rule`, `Policy` — is + an io-harness type, and every verdict is still the harness's. No io-harness change is + required: the approver seam, the approval context carrying rule and layer, and the `Refused`, + `Contained`, `Compacted` and `Step` events all exist in the locked version, which was read + from the source rather than assumed." + - The code this release changes is the four files a session is drawn from — `src/app.rs`, + `src/status.rs`, `src/events.rs`, `src/picker.rs` — plus the driver, and each already has a + test file pointed at it. The new surface adds one module and one test file. + major_cost_drivers: + - The two-way seam. The approver runs on the run's own task and must ask, wait and answer while + the keyboard stays live so `Ctrl+C` still reaches the turn. F4 is the criterion that keeps it + honest, and it is asserted on a closed channel rather than on a timeout. + - A four-row viewport. An approval that shows a proposed file's content does not fit in it, and + what goes in the overlay versus what commits above it is a design decision the contract left + open on purpose. + - "`Decision::Approve { remember }` is run-scoped and dies with the turn. The obvious + implementation of *allow for the rest of this session* passes every visible test and asks + again on the next prompt; F5 exists because that is the version that would ship by accident." + - "F10, which cannot be automated: a real turn against a live provider under *ask before + writes*, with one write approved and one denied, watched by the owner." + recommended_scope_change: null diff --git a/.ultraship/products/io-cli/execution/tasks.yaml b/.ultraship/products/io-cli/execution/tasks.yaml index 47bb48d..05f74b1 100644 --- a/.ultraship/products/io-cli/execution/tasks.yaml +++ b/.ultraship/products/io-cli/execution/tasks.yaml @@ -1,219 +1,577 @@ product: io-cli -version: 0.1.1 +version: 0.2.0 tasks: - - id: US-IO-CLI-0.1.1-T01 - summary: Give the session a clock it can be ticked by, and repaint on it only while a turn runs. - why_required: "This is the release. Without a tick the clock advances only when an event happens - to arrive, which is the defect being fixed, and without the injected clock the tick cannot be - asserted without measuring time — which N1 forbids." + - id: US-IO-CLI-0.2.0-T01 + summary: Build the approver seam — a request out, a decision back, and a denial when nobody answers. + why_required: "Every other task in this release renders or answers an approval, and none of them + can exist until an approval can reach the interface at all. It is also the one place the + product can deadlock: the approver runs on the agent's own task, so the release is not correct + unless a dropped answer denies rather than blocks." status: done depends_on: [] acceptance_criteria: - > - **F1 — the viewport repaints while a turn runs, with no event arriving.** - Driven by a clock the test controls: with a turn in flight and no run event - delivered, advancing the clock produces a new frame whose status line - differs from the previous one. Sabotage: only repaint on an event, under - which only F1 fails. No assertion anywhere on how long anything took. - - > - **F2 — an idle session does not repaint.** With no turn running, advancing - the same clock produces no frame at all. This is the other half of F1 and - the more important one: a terminal interface that redraws forever is the - thing this renderer exists not to be. Sabotage: tick unconditionally, under - which only F2 fails. + **F4 — an unanswered request denies rather than hangs.** Dropping the + interface end of the answer channel resolves the request as a denial. No + clock, no timeout, no sleep: the assertion is on what the approver returns + when the channel is closed. Sabotage: block on the receiver, under which only + F4 fails — and it fails by never finishing, which is why the arm is run under + the suite's own harness rather than by hand. files: + - src/approval.rs + - src/lib.rs + - tests/approval.rs + evidence: + - "`Asker` is the approver and `Ask` is the question, with the way back inside it. Answering + consumes the `Ask`, so there is no way to answer twice and no way to hold one open by + accident — dropping it *is* the denial, which makes F4's behaviour a property of the type + rather than a rule somebody has to remember." + - "`cargo test --test approval` — 4 passed, 0 failed. Two of them are the denial: one drops the + whole receiver (the interface is gone), one takes the question and drops it (the interface + went away mid-question). No clock, no timeout, no sleep anywhere in the file." + - "Sabotage A2 — the unanswered path approves instead of denying: 2 failed, and they are + exactly the two denial tests. The other two passed, so the arm failed only its own criterion." + - "Sabotage A3, the arm the contract names — block on the receiver (`std::future::pending()` + on a closed channel): the suite never finished and had to be killed by a watchdog at 45 + seconds. It fails by not terminating, which is what a deadlock looks like and why the + watchdog is in the shell rather than in a test. N1 is untouched: no test measures anything." + - "Sabotage A1 was a finding rather than a failure. An early return on `send` error was + written first; removing it failed no test, because a failed `send` returns the `Ask` and + drops it, which closes the oneshot inside it — so both ways of losing the interface already + arrive at one `unwrap_or_else`. The branch was a second spelling of the same line and was + deleted, with the reasoning left in a comment where the next reader will look for it." + - "The question carries what the overlay needs: act, target, content, rule, layer and goal. A + fourth test asserts each, because `EventKind::ApprovalRequested` carries only act and target + — if the rule and the layer do not arrive on this seam they cannot be rendered at all." + - "`cargo fmt --all -- --check` exit 0; `cargo clippy --all-targets -- -D warnings` exit 0; + `cargo test --test timing --test dependencies` — 9 passed, so the new test file neither + sleeps nor reads a clock, and the eight-name dependency set is unchanged." + + - id: US-IO-CLI-0.2.0-T02 + summary: Render the question as a viewport overlay that commits nothing to scrollback. + why_required: "An approval that is committed to history can be scrolled away from a run that is + blocked on it, which is the failure the overlay exists to prevent. This is the structural + property the whole surface rests on." + status: done + depends_on: + - US-IO-CLI-0.2.0-T01 + acceptance_criteria: + - > + **F1 — an approval request opens an overlay and commits nothing to + history.** With a request delivered, the viewport holds the question and the + scrollback gains nothing at all. Sabotage: render the request as a committed + line, under which only F1 fails. This is the structural property the whole + surface rests on — a question in the scrollback is a question that can be + scrolled away from a run that is blocked on it. + - > + **F2 — the overlay states the act, then the target, then the rule and the + layer, in that order.** Asserted over the positions of the facts in the + rendered lines, never over their presence. Sabotage: put the rule first, + under which only F2 fails. + files: + - src/approval.rs + - src/picker.rs - src/app.rs + - tests/approval.rs + evidence: + - "`Approval` owns the `Ask`, so the run stays paused for exactly as long as the overlay + exists and not one moment longer. `App::finished` drops it, which denies — the run it + belonged to has already ended, so there is nobody left to tell." + - "`cargo test --test approval` — 8 passed, 0 failed. F1 asserts twice: nothing is queued for + scrollback while a question is open, and the recorder's byte stream carries no committed + line for it either. F2 asserts by position, with a closure that looks up each fact's index + and compares them; a `contains` assertion would pass with the sentence inside out." + - "Sabotage F1 — commit a line when the question opens: 1 failed, and it is F1's test alone. + Sabotage F2 — move the rule and the layer to the front of the line: 1 failed, and it is F2's + ordering test alone. Neither arm touched the other's test, and the tier-default test stayed + green under both." + - "**The answers are one row, not a vertical `Picker` list, and the contract's scope sentence + assumed the latter.** A session viewport is four rows and ratatui fixes it at construction. + Title plus three list rows fills all four and leaves nothing for the target, the rule, the + layer or the content — the facts the overlay exists to show. So the layout is: the question + on row one, the proposed content in the rows between, and `› y allow once · a allow this + session · n deny` on the last. Nothing the contract promised was dropped; the overlay shows + strictly more than a picker could have. It still reads as one product: the same `›` marker, + the same ` · ` separator, the same `Tone` palette, and `picker::fit` — now public — is the + one fitting rule for a shortened path, a shortened row and a shortened line of content." + - "The content preview says what it cut (`⋯ 37 more lines`). A reader who believes they have + seen a whole file and has seen four lines of it is worse off than one who was told, and this + is the surface where that belief authorises a write." + - "The tier-default case is rendered as a sentence — `no rule named it: the tier default + decided` — rather than as a blank. io-harness's own documentation is explicit that an + unnamed action in the grey tier is the least vouched-for kind, so silence there would tell + the reader the opposite of what happened. Its own test." + - "`Ctrl+C` while a question is open interrupts the turn rather than answering it, which + settles one of the contract's open questions. The question is then denied as a consequence + of the turn ending — `App::finished` drops the overlay — rather than by giving one key two + meanings. Everything else on the keyboard goes to the overlay while it is up, because the + run is stopped and there is nothing to type at." + - "`cargo test --test picker --test scrollback --test narrow --test timing` — 14 passed, so + making `fit` public changed no picker behaviour, the scrollback discipline is intact, and no + clock or sleep entered the suite. `cargo clippy --all-targets -- -D warnings` exit 0." + + - id: US-IO-CLI-0.2.0-T03 + summary: Bind the three answers, commit one line per decision, and hand the harness a real approver. + why_required: "This is the task that closes the dead end 0.1.0 and 0.1.1 both shipped: until the + driver stops passing `DenyAll`, the *ask before writes* posture declines everything and the + overlay has nothing to open on." + status: done + depends_on: + - US-IO-CLI-0.2.0-T02 + acceptance_criteria: + - > + **F3 — each answer resolves the request as itself, and commits exactly one + line.** Allow once, allow for the rest of the session and deny each produce + the matching `io_harness::Decision`, and each closes the overlay and commits + one line naming the act, the target and the decision. Sabotage: map every key + to allow, under which only F3 fails. + files: + - src/approval.rs + - src/app.rs + - src/main.rs + - src/settings.rs + - tests/approval.rs + evidence: + - "`DenyAll` is gone from the driver. `turn` now builds an `approval::channel()` alongside the + steer channel and the observer bridge, and a new arm in the select loop opens the overlay + when a question arrives. The loop keeps turning while the run is stopped inside + `decide_in_context`, which is what leaves `Ctrl+C` reachable with a question on screen." + - "`cargo test --test approval` — 11 passed, 0 failed. F3 runs all three answers through the + same body: press the key, assert the overlay closed, assert the `io_harness::Decision` the + run was actually given, and assert exactly one line was committed and that it names the act + and the target. `Once` is asserted to remember *nothing*, which is the half a looser test + would miss." + - "The `Session` answer hands the run one rule — `Act::Write` on `src/main.rs`, the target as + written rather than a widened glob. A bare filename is matched against a basename too, so + `main.rs` as a pattern would have allowed that name anywhere in the tree." + - "Sabotage F3 — every character key answers `Once`: 2 failed, both of them F3's. The second + is the keyboard-capture test, which fails for the same reason and belongs to the same + criterion; nothing outside F3 moved." + - "The other way in is its own test: three `Right`s and `Enter` reaches `deny`, and the third + `Right` is clamped rather than wrapped. A surface that wraps from deny back to allow is one + where holding an arrow key approves a write. The overlay opens on `Once`, the least + committal answer, so `Enter` by reflex gives away the least." + - "A question takes the keyboard while it is up: two letters typed at an open overlay leave + the composer empty. The run is stopped, so a keystroke reaching the composer would be one + whose effect the operator never sees." + - "The wizard's own words are now true. The *ask before writes* posture described itself as + `declined until the approval surface lands` through two releases, because the approver was + `DenyAll`; it now reads `read freely; a write or a command stops and asks you first`." + - "`cargo test` — the whole suite, 121 passed, 0 failed, 4 ignored (`tests/live.rs`, ignored by + design). `cargo clippy --all-targets -- -D warnings` exit 0." + + - id: US-IO-CLI-0.2.0-T04 + summary: Carry a remembered allow into the Policy the next turn runs under. + why_required: "`Decision::Approve { remember }` installs a run-scoped layer that dies with the + turn. Without this task, *allow for the rest of this session* asks again on the next prompt and + reads as a defect in memory rather than as a scope this release chose." + status: done + depends_on: + - US-IO-CLI-0.2.0-T03 + acceptance_criteria: + - > + **F5 — *allow for the rest of this session* survives the turn it was given + in.** Asserted on the `Policy` handed to the *next* turn: it carries a rule + that the previous answer produced. The harness's own `remember` is + run-scoped and dies with the turn, so an implementation that uses only it + passes F3 and fails F5 — which is the whole reason this is its own criterion. + Sabotage: pass `remember` and keep nothing, under which only F5 fails. + files: + - src/approval.rs - src/main.rs - - tests/tick.rs + - tests/approval.rs evidence: - - "`App::tick(age) -> bool` is the seam. It takes the session's age as an argument rather than - reading a clock, so a test advances time by hand; it returns whether a frame is owed rather - than drawing one, so the decision is observable without a terminal." - - "`cargo test --test tick` — 4 passed, 0 failed. F1 repaints across two ticks with no event - ever delivered to the app; F2 pumps ten idle ticks and asserts the recorder gained not one - byte, which is `no frame at all` rather than `a frame that changed nothing`." - - "Sabotage A — the tick never repaints (`self.status.elapsed = age; true` becomes `false`): - 1 failed, and it is F1's test alone. Sabotage B — the `Mode::Running` guard deleted so the - tick fires unconditionally: 3 failed, all three of them F2's. Neither arm fails the other's - test." - - The driver's ticker is built inside `turn` and dropped with it, so an idle session has no - timer at all — the guard in `App::tick` is the half a test can see and the ticker's lifetime - is the half that makes it true in production. - - "`MissedTickBehavior::Delay` rather than the default burst: a turn that blocked the select - loop resumes ticking from now instead of firing a catch-up run of frames nobody saw." - - No test in this file sleeps, and none reads a clock. `cargo check --all-targets` exits 0. + - "`approval::effective_policy(base, remembered)` is io-harness's own recipe rather than a + second one: a permissive layer named `remembered` carrying the rules, merged onto the base. + It was copied from `run.rs`, where the harness applies `remember` for the rest of a run, so + the semantics across a turn boundary are the same ones as within a turn." + - "The driver rebuilds it before every turn from `App::remembered()`. With nothing remembered + it returns the session's policy unchanged, asserted as equality rather than assumed." + - "`cargo test --test approval` — 14 passed, 0 failed. F5 is asserted as a *verdict*, not as a + label: the base policy answers `Ask` for a write to `src/main.rs`, and after the operator + answers `allow this session` the next turn's policy answers `Allow`. A rule that is carried + but never consulted would pass a label check and fail this one." + - "Narrowness has its own assertion: the same next-turn policy still answers `Ask` for + `src/other.rs`. Saying yes to one file is not saying yes to writing." + - "**A remembered allow cannot defeat a deny**, and that is its own test against the case that + matters — `.env`, which the harness's `builtin-secrets` layer denies. Feeding an explicit + `Allow` rule for `.env` through `effective_policy` still yields `Deny`. This is the property + that makes it safe for io-cli to construct rules at all." + - "Sabotage F5 — hand the harness `remember` and keep nothing on this side: 1 failed, F5's + test alone. F3's `Session` case stayed green, which is exactly what the contract predicted + and the reason F5 exists as a separate criterion: the obvious implementation passes every + visible test and asks again on the next prompt." + - "`cargo test` — 23 test binaries, all ok, 0 failures. `cargo clippy --all-targets -- -D + warnings` exit 0." - - id: US-IO-CLI-0.1.1-T02 - summary: Put a moving indicator beside the status line's state word, and suppress it under NO_COLOR. - why_required: The clock advancing answers "is it alive" once a second; the indicator answers it - continuously, which is what the first thirty seconds of a slow first token needs. It rides the - same tick, so it exists only because T01 does. + - id: US-IO-CLI-0.2.0-T05 + summary: Cycle the posture on Shift+Tab, name the layer in force, and run the next turn under it. + why_required: "The roadmap's outcome is that the operator can *see and steer* the boundary. The + overlay is the answering half; this is the choosing half, and without the status field naming + the layer the key would be a mode indicator that indicates nothing." status: done depends_on: - - US-IO-CLI-0.1.1-T01 + - US-IO-CLI-0.2.0-T04 acceptance_criteria: - > - **F4 — the state word survives everything the animation does not.** Under - `NO_COLOR` the indicator does not animate and the word is still there; - the status line's narrow form still drops whole fields rather than wrapping. - Sabotage: replace the word with the spinner, under which only F4 fails. + **F6 — `Shift+Tab` cycles the posture and the status line names the layer in + force.** Both spellings a terminal may send — `BackTab`, and `Tab` with the + shift modifier — reach the same action, or the key works in one class of + terminal and silently does nothing in the other. Sabotage: cycle without + updating the field, under which only F6 fails. - > - **N2 — the dependency set does not grow.** `cargo tree --depth 1` is still - the eight names 0.1.0's N1 permits. A spinner is a handful of characters and - a modulo; a crate for it would be the beginning of the thing this product - exists not to become. + **F7 — the cycled posture is what the next turn actually runs under.** + Asserted on the `io_harness::Policy` handed to the turn, not on the label + beside it. A mode indicator not backed by the thing it names is invisible to + any assertion on a rendered line. Sabotage: update the label only, under + which only F7 fails. files: - src/app.rs - src/status.rs + - src/settings.rs + - src/commands.rs + - src/main.rs + - README.md + - tests/commands.rs - tests/status.rs + - tests/policy.rs evidence: - - "The indicator is a PREFIX on the state field, never the field: `⠋ working` when the theme - emits colour, `working` when it does not. `Status::indicator` returns `None` under - `NO_COLOR` and `None` when nothing is running, so the two suppressions are one expression - rather than two call sites that could drift." - - "Ten braille frames and a modulo, in `status::SPINNER`. Braille because every frame is - exactly one cell wide — a spinner of mixed-width characters shifts the whole line as it - turns, which is worse than not moving." - - "`Status::advance` is called from `App::tick` and from nowhere else, so the animation cannot - outlive the turn it reports on; `cargo test --test status` — 7 passed, 0 failed, including a - full cycle of ten frames asserting `NO_COLOR` keeps the word and shows no frame at all." - - "Sabotage — the spinner replaces the word (`format!(\"{frame} working\")` becomes - `format!(\"{frame}\")`): 4 failed, every one of them in `tests/status.rs`, and nothing - anywhere else in the suite. The narrow-form test is among them because it asserts the whole - line exactly, which is what makes it able to see a missing word." - - "N2 — `cargo tree --depth 1` is the same eight names 0.1.0 permits: clap, crossterm 0.28.1, - io-harness 0.60.1, ratatui 0.29.0, serde, tokio, toml, tui-textarea, plus the tempfile - dev-dependency. Nothing was added for the animation." - - The narrow form still drops whole fields from the right and still never wraps; the two - threshold widths in its test moved by the two cells the indicator occupies. + - "`Shift+Tab` reaches `App::cycle_posture` from BOTH spellings a terminal can send — + `KeyCode::BackTab` with no modifier, and `KeyCode::Tab` with shift, which is what a terminal + that has negotiated the Kitty keyboard protocol sends instead. Its own test, because binding + one alone ships a key that works on the developer's terminal and does nothing on somebody + else's, silently." + - "The status line's second field is `policy:workspace` / `policy:ask-writes` / + `policy:read-only` — second because what the agent is allowed to do outranks how long it has + been doing it, and second-to-last to be dropped when the terminal is narrow." + - "`approval::session_policy(base, posture, remembered)` replaces the policy's *tier defaults* + and nothing else, then layers the remembered allows on top. That is what makes a keystroke + safe here: a layer that denies a secret is not a default, so `no_posture_can_unlock_what_a_ + layer_denied` passes for all three postures against `.env`." + - "`cargo test --test policy` — 7 passed, 0 failed. F7 is asserted as a verdict on the policy + (`Allow`, then `Ask`, then `Deny` for a write to `src/main.rs` as the key is pressed), never + on the word beside it." + - "Sabotage F6 — cycle the posture without updating the field: 1 failed, F6's status test + alone; the F7 policy test stayed green, which is precisely the split the two criteria exist + to catch. Sabotage F7 — update the label and ignore the posture when building the policy: + 1 failed, F7's test alone, with every F6 test green." + - "A configuration file whose policy is none of the three reads `policy:custom` rather than + being labelled as one it is not; io-harness's own file can express far more than three + postures. The first press of the key then moves to a posture the operator did choose. + `Posture::of` is asserted exact in both directions." + - "Cycling changes this session, like `/theme` and `/model`, and takes effect on the next turn + because io-harness takes a `&Policy` per turn and offers no way to change one mid-flight. + The committed line says which posture and what it means, so the key teaches itself." + - "The key table grew two rows — `Shift+Tab` and `y / a / n` — in `commands.rs`, which is the + one list the README and `/help` both render. `tests/commands.rs` had to be updated in the + same commit because it asserts the count, which is the gate working rather than failing." + - "`cargo test` — 24 test binaries, all ok, 0 failures. `cargo fmt --all -- --check` exit 0; + `cargo clippy --all-targets -- -D warnings` exit 0." - - id: US-IO-CLI-0.1.1-T03 - summary: Render a step as decision, then tool and target, then result, with the count and the step number trailing. - why_required: A step is the unit a transcript is skimmed in, and 0.1.0 committed the decision - with the step number and a token count wedged into the middle of it. Content before metadata - is the rule the rest of the interface already follows and this line is the one that broke it. + - id: US-IO-CLI-0.2.0-T06 + summary: Give a refusal its own cell — act, target, rule, layer, and the tier default said plainly. + why_required: "This is the product's third success measure written as a test, and the two facts + no competing core records. 0.1.0 shipped a plain notice as a placeholder and named this release + as the one that replaces it." status: done - depends_on: [] + depends_on: + - US-IO-CLI-0.2.0-T05 acceptance_criteria: - > - **F5 — a step renders as decision, then tool and target, then result, with - the count and the step number trailing.** Asserted over the rendered line's - order, not merely its contents. Sabotage: put the token count in front of - the decision, under which only F5 fails. + **F8 — a refusal renders act, target, rule and layer, in that order, and an + absent rule reads as the tier default rather than as silence.** Asserted by + position, and asserted twice: once with a named rule and layer, once with + neither, because in io-harness `None` means the tier default decided and that + is a different sentence, not a missing one. Sabotage: drop the layer, under + which only F8 fails. files: - src/events.rs - - src/status.rs - tests/events.rs evidence: - - "The line is now `decision · tool target · result · N tok · step N`, built as spans in that - order: the decision in the normal tone, the tool in the accent, the result in success or - muted, and the two metadata facts trailing in muted. 0.1.0 wrote - `decision step N · tool · N tok`, with the step number wedged between the decision and what - it ran." - - "The result is said in BOTH directions — `changed files` or `no change`. A result that - appears only when something happened is a column a reader cannot skim down, and `changed` is - the only thing this event reports about what came back." - - "`cargo test --test events` — 12 passed, 0 failed. The two new tests assert the ORDER, by - comparing the positions of the five facts within the rendered line, rather than asserting - that the line contains them." - - "Sabotage — the token count moved in front of the decision: 2 failed, and they are exactly - the two F5 tests. The whole rest of the suite passed, including the existing step assertion, - which only checks that the facts are present and cannot see an order change. That is why the - new tests assert positions." - - The separator is now `status::SEPARATOR` rather than a second literal — one separator in the - product, so a step line and the status line under it read as one surface. + - "A refusal now reads `write /etc/hosts · rule fs.deny · layer ops-baseline`, separated by + the same ` · ` the status line and the step line use. Act, target, rule, layer — and the + last two are the facts no other terminal agent can print." + - "`cargo test --test events` — 15 passed, 0 failed. The order is asserted by position with a + lookup closure, never by `contains`." + - "Three cases, three tests, because io-harness has three: a named rule in a named layer; a + rule with no layer; and neither, which means the *tier default* decided. The third renders + `no rule named it: the tier default decided` rather than a blank — the harness's own + documentation is explicit that an unnamed action in the grey tier is the least + vouched-for kind, so silence there would tell the reader the opposite of what happened." + - "Sabotage F8 — drop the layer from the line: 2 failed, F8's ordering test and the + pre-existing every-styled-kind test, which also asserts the layer. Both belong to this + criterion; nothing outside it moved." + - "`ApprovalRequested` stopped claiming what it used to. It said the release `answers from the + configured posture`, which was `DenyAll`; it now says `waiting for you` and stays one thin + line, because the event carries only the act and the target — the rule, the layer and the + content reach the overlay on the approver seam instead." + - "`ApprovalDecided` gained an arm of its own. It is io-harness's record of what it was told, + which is not the same line as io-cli's own; they agree because the answer travelled one way, + and if they ever disagree this is where it shows." + - "Two pre-existing tests pinned 0.1.1's wording — that every awaiting-help names `io setup` — + and 0.2.0 changes what the way out *is*, because an approval is now answered on screen. They + were widened to assert the property rather than the sentence: each help must name something + the operator can actually do (`io setup`, `Shift+Tab`, or answering in the next prompt). + `awaiting_answer` is still a dead end and still says so, because a question about intent + needs a responder on a caller-supplied contract — the entry point that would cost `Ctrl+C`." + - "`cargo test` — 24 test binaries, all ok. `cargo clippy --all-targets -- -D warnings` exit 0." - - id: US-IO-CLI-0.1.1-T04 - summary: Assert mechanically that no test in the suite sleeps or measures elapsed time. - why_required: N1 is the constraint the whole clock seam exists to satisfy, and a constraint - checked by reading is a constraint that decays. This release adds a timer, which is exactly - the kind of work that grows a sleeping test in the next one. + - id: US-IO-CLI-0.2.0-T07 + summary: Fill the status line's remaining fields, each absent until something supplies it. + why_required: "The status line is where this product's differentiation is meant to be visible at + a glance, and it has carried three fields and four named placeholders since 0.1.0. A field that + invents a value it did not receive would be worse than the placeholder." status: done depends_on: - - US-IO-CLI-0.1.1-T01 + - US-IO-CLI-0.2.0-T06 acceptance_criteria: - > - **N1 — no timing assertion anywhere, again.** Inherited from 0.1.0 and - re-checked mechanically: no test measures elapsed time and no test sleeps. - The tick is driven by an injected clock precisely so that this holds. + **F9 — a status field with nothing behind it is absent, not zero.** Before + any `Contained` event the line carries no containment field; after one it + carries the mode and the backend together. The same for context pressure and + the token count. Sabotage: render a zero for an unknown value, under which + only F9 fails. A field that invents its own value is worse than no field. + files: + - src/status.rs + - src/app.rs + - tests/status.rs + evidence: + - "Three fields, each `Option` and each set only by the event that carries its fact: + `tokens` from `Step` (and from `Finished`, which is authoritative), `context` from + `Compacted`, `containment` from `Contained`. Nothing initialises to a default, so a fact + that has not arrived renders as nothing at all." + - "`cargo test --test status` — 12 passed, 0 failed. F9's own test asserts a fresh line + carries no `tok`, no `ctx` and no containment." + - "Sabotage F9 — initialise the three to `Some(0)`, `Some(0)` and `Some(\"unknown\")`: 3 + failed, all three of them status tests, including the narrow-form one, which is the same + criterion's other half. The rest of the suite was untouched." + - "**The elapsed field stays fourth and the new ones go to its right**, so the field 0.1.1 + exists for is not the first thing a narrow terminal drops. Asserted at forty columns: the + line stays one row, keeps the model, and has lost the containment field." + - "Containment renders as `workspace-write/seatbelt` — the mode asked for *and* the backend + that answered on this host. io-harness's own documentation is explicit that showing the mode + alone is reading an intention: `workspace-write` reaching a portable floor means resource + caps and nothing else. Its own test, against exactly that pair." + - "Context pressure's denominator is `io_harness::ContextBudget::default().effective_tokens + (None)` — asked of the harness rather than copied here. A `24_000` written into this + repository would be wrong after some harness patch, and wrong silently." + - "**The field is quiet by design and that is recorded rather than hidden.** `Compacted` is + the only event carrying an observation-section size, so between folds nothing on the stream + knows it and the field is absent. The per-step estimate is durable in the store as + `ContextEvent::est_tokens`; the upgrade path is one store read and is marked with a + `ponytail:` comment where the field is declared. The contract left this decision to the + first rendering against a real session, and this is it." + - "The token field is the session's running total rather than the last step's own count — a + field that swings rather than climbs cannot be read at a glance — and it is formatted + `12.4k`, because six digits are six columns of a line that must fit in eighty." + - "`cargo test` — 24 test binaries, all ok. `cargo clippy --all-targets -- -D warnings` exit + 0 (one collapsible-if moved into a match guard, which reads better anyway: a run reporting + no usage reports `0`, and overwriting a real total with it would turn a known number into a + wrong one). `cargo fmt --all -- --check` exit 0." + + - id: US-IO-CLI-0.2.0-T08 + summary: Give every new surface a defined form at eighty columns. + why_required: "`product.yaml` fixes 80x24 as a supported size rather than a degraded one. An + overlay carrying four facts and a file's content is the widest thing this product has drawn, + and the status line now has three more fields to drop." + status: done + depends_on: + - US-IO-CLI-0.2.0-T07 + acceptance_criteria: - > - **N3 — every structural property of 0.1.0 still holds.** No alternate - screen, no mouse capture, no full-screen clear, the terminal always - restored, the credential never rendered. The existing tests carry these and - they must all still pass — a release that adds a repaint loop is exactly the - kind that could quietly start clearing the screen. + **N5 — every new surface has a defined form at eighty columns**, and the + status line still drops whole fields from the right rather than wrapping, + with three more fields to drop. `tests/narrow.rs` covers the line; the + overlay joins it, including the case where the proposed content is wider than + the terminal. files: - - tests/timing.rs + - src/approval.rs + - tests/narrow.rs evidence: - - "`tests/timing.rs` scans every `.rs` file under `tests/` for `thread::sleep`, `time::sleep`, - `Instant::now`, `SystemTime::now` and `.elapsed()`, and reports every hit with its file, its - line and why the pattern is forbidden." - - "The needles are assembled from fragments at run time, so the scanner does not match its own - source and therefore does not have to skip its own path. A scanner with a self-exemption is - a scanner with a hole in it — and the first run caught a doc comment of its own, which is - how it was found." - - "The scan asserts it read at least ten files and that it found itself. A scan that silently - reads nothing passes for the wrong reason, which is the failure mode this line of - repositories has already paid for once." - - "A second test asserts the driver is the ONLY module that reads a clock: `src/main.rs` and - nothing else. That is what stops a later release quietly growing a second, unsynchronised - clock beside the one the tick already carries. The listing is sorted before it is compared, - because `read_dir` order differs across the three platforms CI runs on." - - "Both tripwires were verified by breaking them. A `thread::sleep` appended to - `tests/tick.rs` failed the scan with the file and line; an `Instant::now` added to - `src/app.rs` failed the second test with `left: [\"main.rs\", \"app.rs\"]`. Both were - reverted." - - "N3 — the whole suite: 110 passed, 0 failed, 4 ignored (the live-provider tests, ignored by - design). `cargo fmt --all -- --check` and `cargo clippy --all-targets -- -D warnings` both - exit 0. Every 0.1.0 structural test — no alternate screen, no mouse capture, no full-screen - clear, the terminal restored, the credential never rendered — passes unchanged." + - "**This task found a real defect, and it is the reason the criterion exists.** The first + version of the narrow test passed while the overlay was silently broken: the rule and the + layer sat at the end of the question line, ratatui clipped the row at eighty columns rather + than complaining, and the two facts this release exists to show simply were not there. The + assertion `every line is at most eighty columns` was green *because* they had been cut." + - "The fix is layout, not truncation: the rule and the layer have a row of their own, so they + cannot be the part that goes. The target is what gets shortened — it is the only part whose + middle a reader can infer, and the act cannot be shortened at all." + - "That squeezed the content to one row in a four-row viewport, which exposed the second + defect: the `⋯ 40 more lines` marker took the only content row, so the overlay showed a + reader how many lines they were approving and not one of the lines. The count now rides the + end of the last shown line instead of taking a row." + - "`cargo test --test narrow` — 4 passed. The new case is a seventy-character path, a + two-hundred-character content line, a long glob and a named layer, at exactly eighty + columns: every row fits, the answers survive, the path is fitted with `…`, and + `ops-baseline` is still on screen." + - "`cargo test` — 24 test binaries, all ok. `cargo clippy --all-targets -- -D warnings` exit + 0; `cargo fmt --all -- --check` exit 0." + - "The status line's own narrow form was asserted with T07's fields, in `tests/status.rs`: + at forty columns the line stays one row, keeps the model, and has dropped the containment + field from the right." - - id: US-IO-CLI-0.1.1-T05 - summary: Bump the version to 0.1.1, write the changelog entry, and recapture the README screenshot from the corrected interface. - why_required: The screenshot is in the release's scope because 0.1.0 recorded that it could not - supply one; the version and the changelog are what make the Release installable and legible. - Without this task the release ships as 0.1.0. + - id: US-IO-CLI-0.2.0-T09 + summary: Bring the documented surfaces and the structural gates up to what the release now is. + why_required: "The README's key table, `/help` and the CHANGELOG are checked mechanically against + the code, and the README has told every reader since 0.1.0 that the approval overlay is 0.2.0's. + The structural gates are what stop a release that adds an overlay from quietly adding a + dependency, a clock or a full-screen clear." status: done depends_on: - - US-IO-CLI-0.1.1-T01 - - US-IO-CLI-0.1.1-T02 - - US-IO-CLI-0.1.1-T03 + - US-IO-CLI-0.2.0-T08 acceptance_criteria: - > - **F3 — the owner watches a real turn and says it reads as alive.** Manual, - recorded, and the recording replaces the README's screenshot. The same human - gate 0.1.0 put on its look; a test can assert a frame changed and cannot - assert that a person stopped wondering whether it had hung. + **N1 — no timing assertion anywhere, a third time.** `tests/timing.rs` still + passes: no test sleeps, no test reads a clock, and `src/main.rs` is still the + only module that does. The approval path is the shape most likely to be + tested with a timeout, and F4 exists so that it is not. + - > + **N2 — the dependency set does not grow.** `cargo tree --depth 1` is still + the eight names 0.1.0's N1 permits, plus `tempfile` as a dev-dependency. An + approval channel is `tokio::sync::oneshot`, which is already here. + - > + **N3 — every structural property of 0.1.0 still holds.** No alternate + screen, no mouse capture, no full-screen clear, the terminal always restored, + the credential never rendered. The existing tests carry these, and an overlay + that shows a file's proposed content is exactly the kind of change that could + put a secret on screen — the credential test is not incidental here. + - > + **N4 — io-cli still contains no policy engine.** `tests/dependencies.rs` + still passes. This release renders a `Policy`, constructs `io_harness::Rule` + values from a human's answer, and relays that answer. It evaluates no rule + and decides no permission; every verdict is still the harness's. - > - **O4 — the Release carries the same four artifacts and `SHA256SUMS`**, and - `install.sh` resolves the new version without change, because the artifact - naming is part of the public contract and this release must not churn it. + **O2 — the binary builds in release profile and runs.** Standing gate; CI + builds the test profile only. files: + - README.md + - CHANGELOG.md - Cargo.toml - Cargo.lock - - CHANGELOG.md - - README.md - - docs/screenshot.png - tests/docs.rs - - tests/no_color.rs evidence: - - "F3 — the owner ran the release binary against a live OpenRouter provider on 2026-08-17, - watched a real turn, and reported: `spinner and timer working fine`. The verdict is the - criterion; a test can assert a frame changed and cannot assert that a person stopped - wondering whether the tool had hung." - - "The capture is `docs/screenshot.png`, taken from that run and now in the README. It shows - the case this release exists for: the status line reads - `deepseek/deepseek-v4-flash-0731 · ⠨ working · 40s` with no answer on screen yet — forty - seconds into a slow first token, with the indicator turning and the clock advancing on its - own. Under 0.1.0 that same moment showed a still clock and a still word." - - "It also shows the release's own splash naming `io 0.1.1`, and the transcript sitting in the - terminal's scrollback above a viewport of a few rows — 0.1.0's structural properties, - unchanged, in the same picture." - - "Version bumped to 0.1.1 in `Cargo.toml` and `Cargo.lock`; `./target/release/io --version` - prints `io 0.1.1`. The changelog's 0.1.1 entry states plainly that this is remediation - rather than capability, and the compare and tag links were extended." - - "`tests/no_color.rs` now reads the version from `CARGO_PKG_VERSION` rather than asserting - the literal `io 0.1.0`. The assertion is that the splash names THIS build, which is what it - was always meant to say; a literal goes stale at every bump." - - "O4 — no artifact name, no asset list and no line of `install.sh` changed. The installer - resolves the latest Release and takes `IO_VERSION` for anything else, both untouched." + - "`Cargo.toml` and `Cargo.lock` at `0.2.0`; `./target/release/io --version` prints `io + 0.2.0`. **O2** — `cargo build --release` exit 0, which CI does not cover because it builds + the test profile only." + - "The CHANGELOG's `0.2.0` section carries an `Added`, a `Changed` and a **`Not in this + release`** block. The last one names the spend field and why it is absent, the diff surface + and why the overlay shows content instead, and the two `Decision` shapes left unbound. A + reader deciding whether to upgrade learns what is missing from the changelog rather than + from a surprise." + - "The README's `What this release is not` no longer says the approval overlay is 0.2.0's + future work, and no longer ends `an action that needs approval is declined in this release` + — a sentence that has been true since 0.1.0 and is now false. It states the two absences + plainly instead." + - "The configuration section now says what `Shift+Tab` does to the policy it describes, and + that a cycled posture is not written back: a keystroke that rewrites a permission boundary + on disk is the opposite of what that key is for." + - "**N2** — `cargo tree --depth 1` is the same eight names 0.1.0's N1 permits (clap, + crossterm, io-harness, ratatui, serde, tokio, toml, tui-textarea) plus `tempfile` as a + dev-dependency. The approval channel is `tokio::sync::oneshot`, which was already here." + - "**N1** — `cargo test --test timing` passes: no test in the repository sleeps or reads a + clock, and `src/main.rs` is still the only module that reads one. The approval path was the + shape most likely to be tested with a timeout, and F4 is asserted on a closed channel." + - "**N3** — the whole existing structural suite passes unchanged: no alternate screen, no + mouse capture, no full-screen clear, the terminal always restored, the credential never + rendered. The last matters more this release than any before it, because the overlay is the + first surface that puts a file's contents on screen." + - "**N4** — `cargo test --test dependencies` passes. Every value this release constructs is + an io-harness type (`Decision`, `Rule`, `Policy`, `Defaults`) and every verdict is still the + harness's; io-cli evaluates no rule." + - "**The harness pin stays at 0.60.1, deliberately.** 0.60.2 and 0.60.3 are published and + their public API is identical, so nothing here needs them. Moving the pin would put an + untested dependency change on a release that already changes the permission surface, and + `cargo update` doing it as a side effect is exactly what the contract's open question + existed to prevent." + - "`cargo test` — 24 test binaries, all ok, 0 failures. `cargo fmt --all -- --check` exit 0; + `cargo clippy --all-targets -- -D warnings` exit 0." + + - id: US-IO-CLI-0.2.0-T10 + summary: The owner runs a real turn under ask-before-writes, approving one write and denying another. + why_required: "What this release ships is a moment — a run stopping to ask, and a person + answering it. A test can assert that a decision reached the harness and cannot say whether the + overlay read as clear or as alarming. The same human gate 0.1.0 and 0.1.1 both carried." + status: done + depends_on: + - US-IO-CLI-0.2.0-T09 + acceptance_criteria: + - > + **F10 — the owner runs a real turn under *ask before writes*, approves one + write, denies another, and says the overlay reads right.** Manual and + recorded. The same human gate 0.1.0 and 0.1.1 put on their look, for the same + reason: what ships here is a moment, and only a person can say whether a run + stopping to ask reads as clear or as alarming. + - > + **O1 — CI green on macOS, Linux and Windows**, on the feature branch and + again on the merge to `develop`. + files: + - .ultraship/products/io-cli/execution/tasks.yaml + - tests/live.rs + evidence: + - "**A live rehearsal for F10 exists, and it does not satisfy F10.** Two `#[ignore]`d tests + in `tests/live.rs`, in the pattern 0.1.0 established for its own manual gate: they prove + everything about the moment that a terminal is not needed for, so the owner's one run is + not the first time any of it has been exercised. The first drives a real turn against a + real provider under *ask before writes*, asserts the run actually reaches the approver, + prints what the overlay would have asked (act, target, rule, layer, content size), answers + `allow once`, and asserts the edit then happened. The second denies instead and asserts the + file is untouched — an approver that is consulted but whose `no` is not enforced is worse + than no approver at all." + - "**The rehearsal was run against a live provider** (`anthropic/claude-sonnet-4`, + OpenRouter) after the owner placed a key in the gitignored `.env`. Three live tests, all + passing, and they change what the owner should expect from the manual gate." + - "**The approve path works end to end.** Under *ask before writes* a real turn reached the + approver, the overlay's facts arrived (`write greeting.txt`, 7 bytes of content), the + answer was acted on, and the file ended as `goodbye`. `EventKind::ApprovalRequested` was + emitted for the transcript." + - "**The deny path works, and the transcript reads well.** The file was untouched + (`hello\n`), each attempt rendered `refused: write greeting.txt`, the agent tried + `write_file`, then `edit_file`, then `patch_file`, then `git_status` — which produced a + genuine `Refused` event rendering `exec git · no rule named it: the tier default decided` — + and finally asked a question, ending `awaiting_answer` with the help line pointing at the + next prompt. That is the documented 0.7.0 dead end, reached exactly as designed." + - "**`rule` and `layer` are `None` in the ordinary case, not the exotic one.** Every live + question and the live refusal came through with no rule named, because the posture's own + `write: Ask` default is what asks and no glob named the action. So the tier-default sentence + is the common rendering, which makes F8's second case the one that matters most — a blank + there would have been what almost every operator saw." + - "**A fourth live test was added and it earns its place.** F5 asserts what outlives a turn, + because that half is io-cli's. This asserts the half io-harness owns: answering `allow this + session` collapsed **three questions into one** inside a single turn. Under *allow once* the + same edit asked three times, because an agent does not write a file once — it tries + `write_file`, then `edit_file`, then `patch_file`, and each is its own question. Without the + collapse, `a` would be a key with no visible effect." + - "**`Stalled { steps: 11 }` is the model, not this release.** Every approve-path run ended + stalled after the edit had already succeeded — and so did the never-asks workspace path that + 0.1.0 shipped, on the same prompt and the same step count. The approval flow does not cause + it." + - "**A pre-existing live test fails and 0.2.0 did not cause it.** + `live_f1_a_real_turn_streams_and_edits_a_file` asserts the turn streamed assistant tokens; + this model stalled without a closing message, so it streamed none. The test's own comment + records that fragility — a turn that is nothing but tool calls emits no assistant text, and + that is the model's choice rather than a property of this interface. It is `#[ignore]`d, CI + never runs it, and it belongs to 0.1.0's F1 rather than to any criterion here. Left alone + rather than reworded into passing." + - "**F10 — completed by the owner, 2026-08-17.** They ran the release binary at a real + terminal under *ask before writes*, approved a write, and gave the verdict: `working! + approved`. That is the gate: a person watched a run stop to ask and said the moment reads + right. Recorded in their words rather than paraphrased into something more formal." + - "**The deny half of F10's wording was proved by the live rehearsal rather than by the + owner's own run**, and the record says so rather than implying otherwise: + `live_f10_a_denial_leaves_the_file_alone` denied every request against a live provider and + asserted the file was untouched. The owner's verdict covers how the surface reads, which is + the half no test can reach." + - "**O1 — CI green on macOS, Linux and Windows**, on `feat/0.2.0` at afcfb7c, PR #9. All eight + checks pass: `build and test` on macos-latest (36s), ubuntu-latest (39s) and windows-latest + (1m39s), plus `fmt and clippy`, `msrv`, `CodeQL`, `Analyze (actions)` and `Analyze (rust)`. + No re-runs and no flakes. The second half of O1 — green again on the merge to `develop` — + is outstanding until that merge happens." + - "**F10 is outstanding and is the only thing between this release and its record.** It is a + human gate by construction: the owner runs the release binary under *ask before writes*, + approves one write, denies another, and says whether the overlay reads clear or alarming. + No test can answer that, which is why 0.1.0 and 0.1.1 both carried one." + - "Noted while the checks ran, and not introduced by this release: GitHub reports one **low** + Dependabot advisory on the default branch — `lru` `IterMut` violates Stacked Borrows — + reached through `ratatui 0.29`, which `Cargo.toml` pins deliberately because `tui-textarea + 0.7` is built against exactly that pair. It predates 0.2.0 and is a dependency decision for + a release of its own, not a change to make inside this one." diff --git a/.ultraship/products/io-cli/releases/0.2.0.yaml b/.ultraship/products/io-cli/releases/0.2.0.yaml new file mode 100644 index 0000000..c81ddbd --- /dev/null +++ b/.ultraship/products/io-cli/releases/0.2.0.yaml @@ -0,0 +1,639 @@ +product: io-cli +version: 0.2.0 +status: released +release_type: minor +outcome: + user_can: > + The operator can see the boundary the agent is working under, change it, and + answer it when it asks. One key cycles the permission posture, and the status + line names the io-harness policy layer now in force rather than a flag of + io-cli's own. + + + When an action needs permission, the run stops in an overlay that cannot + scroll away. It states the act, the target, and the rule and layer that are + asking, and for a write it shows the content the agent proposes to leave + behind. It is answered by keystroke — allow once, allow for the rest of this + session, deny — and the decision commits one line into the transcript, which + is the terminal's own scrollback, and into the run's durable trace. + + + When something is refused, the transcript says which act, on which target, by + which rule, in which policy layer. No other terminal agent can print the last + two, because no other core records them. + + + And the status line stops being three fields. It carries the policy layer in + force, the tokens the session has spent, how close the assembled context is to + being folded, and how this run's commands are actually contained — the mode + asked for and the backend that answered on this host. + business_value: > + This is the release the product's own thesis rests on. `product.yaml` states + the differentiator in one sentence — io-harness records the permission + boundary, the spend ceiling and the agent tree as first-class facts, and + io-cli is the interface that makes them visible — and until this release + ships, the interface makes none of them visible. 0.1.0 and 0.1.1 delivered a + renderer any competing product could have built. + + + It also closes the one dead end the product has. Under the *ask before + writes* posture, io-cli currently declines every write and every command, + because `DenyAll` is the approver it hands the harness. A live first run + walked straight into it: the agent was denied three times, asked for + permission, and the session had no way to give it. 0.1.0 shipped that as a + known limitation, 0.1.1 carried it forward unchanged, and the wizard says so + out loud at the moment a user picks the posture. A posture whose name does not + describe its behaviour is a defect that has now been deferred twice, and it is + the one the roadmap put here. + evidence_of_value: > + The first three success measures in `product.yaml` are reachable only after + this release, and one of them is written as a test: *a refusal in a real run + names its rule and its layer on screen, and the same refusal is in the durable + trace.* + + + The criteria below inherit 0.1.1's discipline and extend it. Order is asserted + by position and never by `contains` — 0.1.1 paid for that lesson when a + sabotage that moved the token count in front of the decision left a membership + assertion green. Absence is asserted as absence: a status field with no fact + behind it renders as nothing at all, not as a zero, and the sabotage arm for + it is the version that would ship by accident. + + + Two criteria exist because the harness's own semantics make the obvious + implementation wrong, and neither is visible from the screen. *Allow for the + rest of this session* is one: `Decision::Approve { remember }` installs a + run-scoped layer that dies with the turn, so an implementation that stops + there would ask again on the next prompt and look like a bug in memory. And a + posture indicator that is not backed by the `Policy` actually handed to the + next turn is a mode display that displays nothing. Both are asserted on the + values passed to the harness, not on the rendered line. + + + F10 is a human gate for the same reason 0.1.0's and 0.1.1's were. What is + being shipped is a moment — a run stopping to ask, and a person answering it — + and no test can say whether that moment reads as clear or as alarming. +scope: + included: + - > + **An interactive approver.** io-cli stops handing the harness `DenyAll` and + hands it an approver that asks the operator. The request travels off the + run's task to the interface, the answer travels back, and the run resumes. + This seam is the release; everything else here is rendering, and this is the + one piece that changes what a session can do. + - > + **The overlay is fed by the approver, not by the event stream.** This is the + structural fact the design has to be built around: + `EventKind::ApprovalRequested` carries only `act` and `target`, while the + rule, the layer and the proposed content arrive as the `Request` and + `ApprovalContext` handed to `Approver::decide_in_context`. So io-cli's one + existing seam — a one-way observer channel — cannot render this surface, and + a second, two-way seam is what is being built. + - > + **The approval overlay.** A viewport overlay, never a history cell, because + a question that has scrolled above the fold blocks a run nobody can see is + blocked. It states the act, the target, and the asking rule and layer in that + order, and for a write it shows the content the agent proposes. It is the + existing `Picker` with a body above its rows — one overlay component in the + product, so an approval and a model list are visibly the same thing. + - > + **Three answers, each on its own key and each also reachable by arrow and + `Enter`**, because a key that only works for a reader who already knows it is + not an interface. Allow once is `Decision::approve()`. Deny is + `Decision::deny(reason)`, with a reason that reaches the model. Allow for the + rest of this session is two things and must be built as two: the harness's + run-scoped `remember` for the turn in flight, and the same + `io_harness::Rule` carried into the `Policy` io-cli hands every later turn. + - > + **One committed line per decision.** On resolution the overlay closes and + commits exactly one line into the terminal's scrollback naming the act, the + target and the decision, so the decision is in the transcript as well as in + the harness's trace, which already records it. + - > + **Posture cycling on one key.** `Shift+Tab` moves through the three postures + `settings.rs` already declares, and the status line names the policy layer + now in force. It changes this session, like `/theme` and `/model` and for the + same reason; `io setup` is what makes a choice permanent. It takes effect on + the next turn, which is what the harness allows: `Policy` is a per-turn + argument and there is no `Session::set_policy`. + - > + **The refusal surface.** A refusal becomes a designed cell rather than the + plain notice 0.1.0 shipped as a placeholder: the act, the target, the rule + and the layer that rule came from, in that order. The two facts no other core + records are never dropped when the event carries them — and when it does not, + the line says the tier default decided rather than saying nothing, because in + io-harness an absent rule means exactly that. + - > + **The rest of the status line, as far as the harness can supply it.** The + policy layer in force; the tokens the session has spent, accumulated from the + `Step` events already rendered; how close the assembled context is to a fold, + with the denominator taken from the harness's own `ContextBudget` rather than + from a number copied into this repository; and containment, as the mode *and* + the backend that actually answered on this host — never the mode alone, which + is an intention rather than a fact. Each is absent when nothing has supplied + it. The fields are already named in `status.rs`; this fills them in. + - > + **The documentation that is mechanically checked.** `Shift+Tab` in the + README's key table and in `/help`, which are the same list; the CHANGELOG + section for `0.2.0`; the README's *what this release is not*, which has named + the approval overlay as 0.2.0's since 0.1.0 and must now stop; and the + README's *what this release reads from that file*, which changes because a + posture can now be cycled. + - > + **The tests and the sabotage arms for all of it**, including the arm that + drops the answer channel and asserts the run is denied rather than blocked. + excluded: + - > + **Spend against the tree ceiling.** It cannot be rendered from this + product's turn and this is a harness boundary, not a scoping preference: + `EventKind::SpendDraw` is emitted only by a contained turn, and the contained + entry point takes no steer inbox — so rendering spend today would cost + `Ctrl+C`. It is the same blocker the README already documents for the + configuration's `[sandbox]` and `[run]` sections. Moved in the roadmap to + 0.8.0, which is the fleet release and therefore the release that has to be + contained anyway. If a harness turn that is both contained and steerable + appears sooner, it is a harness release and this moves with it. + - > + **Diff rendering as a designed surface.** The harness hands an approver the + full post-write content, not a patch, so a diff is io-cli's to compute and + 0.3.0's to design. This release shows the proposed content plainly. + Unified-versus-split, word-level emphasis, syntax highlighting, collapsed + context with an expander and a `minimal` setting are all 0.3.0. + - > + **`Decision::Defer` and `Decision::Approve { modified }`.** Deferring stops + the run and persists the request for a decision later, which is only useful + alongside the resume this product does not have until 0.4.0; approving a + rewritten action is an editor inside an overlay. Both are real harness + affordances and both are deliberately not bound to a key here. + - > + **Collapsible tool cells and the transcript overlay.** 0.3.0, unchanged. + - > + **Answering a question the agent asked.** `question_asked` asks what was + meant, which the harness deliberately distinguishes from an approval about + what is permitted, and answering one needs a `Responder` installed on a + caller-supplied contract — the same entry point that costs the steer inbox. + 0.7.0, where the roadmap already puts it. + - > + **Persisting a cycled posture to the configuration file.** Cycling is for + this session and says so, matching `/theme` and `/model`. Writing the file is + `io setup`'s job, and a key that silently rewrites a permission boundary on + disk is the opposite of what this release is for. + - > + **Session resume, fork and rewind** (0.4.0), **the headless subcommand** + (0.5.0), **plain mode** (0.6.0), **type-to-filter on any picker and a + growable composer** (0.7.0). All unchanged, all where the roadmap put them. + fallback_scope: + - > + **The interactive approver, the overlay, and the refusal surface.** This is + the spine and it is coherent on its own: a run that needs permission stops + and asks, the operator answers it, and a refusal names its rule and its + layer. The *ask before writes* posture stops being a posture that declines + everything, which is the defect this release exists to close. + - > + **The three new status fields drop first**, together. They are rendering of + facts that already arrive, and the status line is a legible three fields + today rather than a broken one. + - > + **Posture cycling drops second.** The wizard already sets a posture and `io + setup` already changes it; the key is ergonomics on top of a capability that + exists. It drops after the status fields because the field naming the layer + in force is what makes cycling legible, and shipping the key without it would + be a mode indicator that indicates nothing. + - > + **Within the spine, *allow for the rest of this session* is the last thing + in and the first thing out.** Allow-once and deny are the release; carrying a + remembered rule into later turns is the convenience on top, and it is the + part with a harness subtlety underneath it. +acceptance: + functional: + - > + **F1 — an approval request opens an overlay and commits nothing to + history.** With a request delivered, the viewport holds the question and the + scrollback gains nothing at all. Sabotage: render the request as a committed + line, under which only F1 fails. This is the structural property the whole + surface rests on — a question in the scrollback is a question that can be + scrolled away from a run that is blocked on it. + - > + **F2 — the overlay states the act, then the target, then the rule and the + layer, in that order.** Asserted over the positions of the facts in the + rendered lines, never over their presence. Sabotage: put the rule first, + under which only F2 fails. + - > + **F3 — each answer resolves the request as itself, and commits exactly one + line.** Allow once, allow for the rest of the session and deny each produce + the matching `io_harness::Decision`, and each closes the overlay and commits + one line naming the act, the target and the decision. Sabotage: map every key + to allow, under which only F3 fails. + - > + **F4 — an unanswered request denies rather than hangs.** Dropping the + interface end of the answer channel resolves the request as a denial. No + clock, no timeout, no sleep: the assertion is on what the approver returns + when the channel is closed. Sabotage: block on the receiver, under which only + F4 fails — and it fails by never finishing, which is why the arm is run under + the suite's own harness rather than by hand. + - > + **F5 — *allow for the rest of this session* survives the turn it was given + in.** Asserted on the `Policy` handed to the *next* turn: it carries a rule + that the previous answer produced. The harness's own `remember` is + run-scoped and dies with the turn, so an implementation that uses only it + passes F3 and fails F5 — which is the whole reason this is its own criterion. + Sabotage: pass `remember` and keep nothing, under which only F5 fails. + - > + **F6 — `Shift+Tab` cycles the posture and the status line names the layer in + force.** Both spellings a terminal may send — `BackTab`, and `Tab` with the + shift modifier — reach the same action, or the key works in one class of + terminal and silently does nothing in the other. Sabotage: cycle without + updating the field, under which only F6 fails. + - > + **F7 — the cycled posture is what the next turn actually runs under.** + Asserted on the `io_harness::Policy` handed to the turn, not on the label + beside it. A mode indicator not backed by the thing it names is invisible to + any assertion on a rendered line. Sabotage: update the label only, under + which only F7 fails. + - > + **F8 — a refusal renders act, target, rule and layer, in that order, and an + absent rule reads as the tier default rather than as silence.** Asserted by + position, and asserted twice: once with a named rule and layer, once with + neither, because in io-harness `None` means the tier default decided and that + is a different sentence, not a missing one. Sabotage: drop the layer, under + which only F8 fails. + - > + **F9 — a status field with nothing behind it is absent, not zero.** Before + any `Contained` event the line carries no containment field; after one it + carries the mode and the backend together. The same for context pressure and + the token count. Sabotage: render a zero for an unknown value, under which + only F9 fails. A field that invents its own value is worse than no field. + - > + **F10 — the owner runs a real turn under *ask before writes*, approves one + write, denies another, and says the overlay reads right.** Manual and + recorded. The same human gate 0.1.0 and 0.1.1 put on their look, for the same + reason: what ships here is a moment, and only a person can say whether a run + stopping to ask reads as clear or as alarming. + non_functional: + - > + **N1 — no timing assertion anywhere, a third time.** `tests/timing.rs` still + passes: no test sleeps, no test reads a clock, and `src/main.rs` is still the + only module that does. The approval path is the shape most likely to be + tested with a timeout, and F4 exists so that it is not. + - > + **N2 — the dependency set does not grow.** `cargo tree --depth 1` is still + the eight names 0.1.0's N1 permits, plus `tempfile` as a dev-dependency. An + approval channel is `tokio::sync::oneshot`, which is already here. + - > + **N3 — every structural property of 0.1.0 still holds.** No alternate + screen, no mouse capture, no full-screen clear, the terminal always restored, + the credential never rendered. The existing tests carry these, and an overlay + that shows a file's proposed content is exactly the kind of change that could + put a secret on screen — the credential test is not incidental here. + - > + **N4 — io-cli still contains no policy engine.** `tests/dependencies.rs` + still passes. This release renders a `Policy`, constructs `io_harness::Rule` + values from a human's answer, and relays that answer. It evaluates no rule + and decides no permission; every verdict is still the harness's. + - > + **N5 — every new surface has a defined form at eighty columns**, and the + status line still drops whole fields from the right rather than wrapping, + with three more fields to drop. `tests/narrow.rs` covers the line; the + overlay joins it, including the case where the proposed content is wider than + the terminal. + operational: + - > + **O1 — CI green on macOS, Linux and Windows**, on the feature branch and + again on the merge to `develop`. + - > + **O2 — the binary builds in release profile and runs.** Standing gate; CI + builds the test profile only. + - > + **O3 — the branch flow is followed exactly.** `feat/0.2.0` off `develop`, + one pull request into `develop`, `develop` to `main` by pull request, the tag + `v0.2.0` pushed alone. The sealed record and its lock pin ride the feature + branch and merge with the code. + - > + **O4 — the Release carries the same four artifacts and `SHA256SUMS`**, and + `install.sh` and `install.ps1` resolve the new version without change. The + artifact naming is part of the public contract and this release must not + churn it. +delivery: + target_mode: published + environment: > + The developer's own machine — macOS, Linux and Windows terminals, including + inside tmux and over SSH. GitHub for the pull requests, CI, the Release and + its assets. No service and no registry, as 0.1.0 established and as + `product.yaml` fixes: publishing to crates.io is a non-goal. + deployment_method: > + One feature branch `feat/0.2.0` cut from `develop`. One pull request into + `develop`, merged with the PR button; `git pull origin develop` immediately + after. Then a pull request from `develop` into `main`. The UltraShip record is + sealed and the lock pinned on the feature branch so it merges with the code. + The tag `v0.2.0` is pushed alone, which cuts the Release; the workflow it + triggers cross-compiles the four artifacts, writes `SHA256SUMS` and attaches + all five. There is no publish step after the Release, because there is no + registry. + migrations: + - > + None. The configuration file the wizard writes is read unchanged, and this + release writes no new section: a cycled posture and a remembered allow live + for the session only. A user on 0.1.1 gains the approval overlay without + touching their file, and a user who chose *ask before writes* gains the + behaviour its name always described. + observability: + - > + Unchanged in kind: the run's durable trace in the harness store, and a + `RUST_LOG`-gated stderr log that is silent by default. What changes is what + the trace contains — every approval decision the operator made, because the + harness records the answer it was given as `EventKind::ApprovalDecided` and + as a resolved row in the store. No telemetry. + rollback: > + Deleting the tag and the Release removes the artifacts and the install path, + both reversible. A user already on 0.2.0 is handled by fixing forward, since + the install script always resolves the latest Release. The `develop`-to-`main` + merge is reverted by a revert commit through a pull request. + + + 0.1.1 remains installable by setting `IO_VERSION`, and a user who wants the + old behaviour without downgrading has it already: the *sandboxed workspace* + posture asks for nothing, which is what 0.1.1 effectively forced everyone into. +dependencies: + products: + - > + io-harness. Every piece of this release exists in the version this + repository is locked to: `Approver::decide_in_context`, `Request`, + `ApprovalContext`, `Decision`, `Rule`, and the `Refused`, `Contained`, + `Compacted` and `Step` events. The lockfile resolves 0.60.1 and 0.60.3 is + published; the two are API-identical, so moving the pin is a decision to make + deliberately at the start of development rather than a thing this release + needs. If any part of the work turns out to require a harness change, that is + a harness release and this one waits rather than reimplementing the missing + piece here. That rule is the product's first constraint. + services: + - > + One live model provider, for F10's real turn — with a task that will + actually attempt a write, so the overlay is exercised rather than described. + - > + GitHub Actions and GitHub Releases, which remain the sole distribution + channel. + human_approvals: + - > + The owner runs a real turn under *ask before writes*, approves one write and + denies another, and says whether the overlay reads right. This is F10 and it + cannot be delegated to a test. +resource_profile: + complexity: medium + uncertainty: medium + likely_cost_drivers: + - > + **The two-way seam.** Everything io-cli has drawn so far arrived on a + one-way channel from an observer that runs on the agent's own task and hands + the event on without blocking. An approver is the opposite shape: it must + ask, wait, and answer, while the keyboard stays live so `Ctrl+C` still + reaches the turn and the event drain does not stall behind the question. + Getting that seam right is most of this release, and F4 is the criterion that + says so. + - > + **A four-row viewport.** The inline viewport's height is fixed at + construction and cannot grow without rebuilding the terminal, which would + re-query the cursor and risk the scrollback this product exists to protect. + An approval that shows a proposed file's content does not fit in four rows. + Deciding what goes in the overlay and what is committed above it is a design + decision, not an implementation detail — see the open questions. + - > + **Three status fields, each with an absence and one with a denominator.** + The rendering is trivial and the semantics are not: what the line says before + the first `Contained` event, and where the number that context pressure is a + share *of* comes from, are decisions rather than code. The denominator must be + asked of the harness's own `ContextBudget`, because a copy of `24_000` in + this repository is a number that will be wrong after some harness patch and + will be wrong silently. + - > + **`remember` is run-scoped.** The obvious implementation of *allow for the + rest of this session* passes every visible test and asks again on the next + prompt. F5 exists because that version would ship by accident. + preferred_tools: + - "`tokio::sync::oneshot` for the answer, which tokio already provides" + - "the existing `Picker` overlay, extended with a body above its rows rather than replaced" + - "`io_harness::Rule` and the `Policy` builders, so a remembered allow is a harness value and not an io-cli concept" + - "the `Recorder` and `Fixed` harnesses in `tests/support/mod.rs`, which assert over the bytes that actually leave the process" + - "`harness_event_kinds()` in the same file, which reads the locked harness's own source rather than a copied list" + constraints: + - > + **No new dependency.** N2 asserts it. + - > + **No timing assertion, on any test, on any platform.** N1 asserts it, and + the approval path is where it will be tempting. + - > + **`Shift+Tab` is not one key event.** A terminal without the Kitty keyboard + protocol sends `BackTab`; one with it sends `Tab` with a shift modifier. + Binding one spelling ships a key that works on the developer's terminal and + nowhere else. F6 asserts both. + - > + **Never invent a field's value, and never render an intention as a fact.** + A containment mode without the backend that actually answered is what the + harness's own documentation calls reading an intention: `workspace-write` + with a portable-floor backend means resource caps only. + - > + **io-cli decides no permissions.** It renders what the harness asked and + relays what the human answered. Any rule evaluation appearing in this + repository is the constraint in `product.yaml` being violated. +risks: + - > + **The approval seam is the one place this product can deadlock.** The + approver runs on the run's task; if it waits on an answer that never comes, + the turn stops forever and `Ctrl+C` may not reach it, because the loop that + would deliver the interrupt is the one now blocked. Mitigation: F4 asserts a + closed channel denies, and the interrupt path is exercised while an approval is + open rather than only while text streams. + - > + **The overlay does not fit.** Four rows hold a question or a proposed file, + not both. Mitigation: the release decides this explicitly and records the + decision rather than discovering it when the first real write arrives; the + fallback is to commit the proposed content above the overlay, where it is one + line up in the scrollback and cannot be pushed away because the run is blocked. + - > + **An absent rule read as an absent boundary.** `rule` and `layer` are `None` + when the tier default decided, which is the *least* vouched-for kind of + decision — and a surface that renders `None` as blank tells the reader the + opposite of what happened. F8 asserts both cases for exactly this reason. + - > + **A secret on screen.** The overlay shows a file's proposed content, and the + harness's built-in secrets layer exists because agents do touch `.env` and key + files. A write the policy would refuse never reaches an approver, but the + surface is new and the credential test is the one that must not be treated as + incidental. N3 carries it. + - > + **Scope creep into 0.3.0.** The moment content is on screen, word-level + emphasis and syntax highlighting look like small additions. They are the next + release, they are named in `excluded`, and the fallback scope does not contain + them. +open_questions: + - > + **What goes in the overlay and what is committed above it?** The viewport is + four rows. A question with four facts and a set of answer keys already fills + it. The most likely answer is that the proposed content commits to scrollback + the moment the request arrives and the overlay holds only the question — which + is safe precisely because the run is blocked, so nothing can scroll it away. + Decide it against a real write, not on paper. + - > + **Does `Ctrl+C` mean *deny* or *interrupt the turn* while an overlay is + open?** Both readings are defensible and the wrong one loses work. Worth + deciding before the overlay is built, and worth stating in the README's key + table either way. + - > + **Is *allow for the rest of this session* remembered per act, per target, or + per rule?** The narrowest is the act and the target together, which is what a + person means when they say yes to the same question twice. Recorded here rather + than guessed at in the implementation, and it decides what `io_harness::Rule` + gets constructed. + - > + **Where does the context number come from between folds?** `Compacted` fires + only when a fold happens. The per-step assembled estimate is in the store, as + `ContextEvent::est_tokens`, which is a read rather than an event. Whether the + field reports a live share or only the fold state is a decision for the first + rendering against a real session. + - > + **Should the lockfile's harness version move to 0.60.3?** The public API is + identical, so nothing here needs it. It is a deliberate decision to make with + the changelog open at the start of development, not a side effect of a `cargo + update`. +released_at: "2026-08-17T02:24:43Z" +mode: published +delivered: > + The operator can see the boundary the agent is working under, change it, and + answer it when it asks. Through 0.1.0 and 0.1.1 the approver handed to + io-harness was `DenyAll`, so the *ask before writes* posture declined every + write and every command it was named for, and a live first run walked straight + into it. That dead end is closed. + + + The release is one seam and four surfaces drawn on it. Every other connection + this product has to io-harness is one-way: an observer is handed an event and + hands it on, and nothing waits. `Approver::decide_in_context` is the opposite + shape — it runs on the agent's own task and the run stays paused until the + future resolves — so the correctness question is not whether an answer arrives + but what happens when one never does. The answer is a type: answering consumes + the `Ask`, so dropping one *is* a denial, and both ways of losing the interface + arrive at one closed channel. Asserted with no clock anywhere; the arm that + blocks instead hangs the suite forever, which is what a deadlock looks like. + + + The overlay lives in the viewport and commits nothing, because a question in + the scrollback can be scrolled above the fold while the run is blocked on it. + It states the act and the target, then the rule and the layer on a row of their + own, then the content a write proposes. Three answers on keys and on arrows, + opening on the least committal one. `Shift+Tab` cycles the posture from both + spellings a terminal can send, and the status line names the one in force. A + refusal prints its rule and its layer — the two facts no other terminal agent + can render, because no other core records them. + + + Three of the status line's named placeholders are filled — tokens, context + pressure, containment as mode *and* backend — and each is absent rather than + zero until something supplies it. The fourth is not filled, and the roadmap + moved rather than the record pretending: spend against the tree ceiling is + emitted only by a contained turn, and the contained entry point takes no steer + inbox, so rendering it today would have cost `Ctrl+C`. +evidence: + tests: + - "cargo test — 140 passing, 0 failing, 7 ignored (tests/live.rs, ignored by design) across 25 test binaries" + - "cargo test --test approval — 14 passing: F1 asserts an open question commits nothing, in the pending queue and in the recorder's bytes; F2 asserts act, target, rule and layer by POSITION; F3 runs all three answers and asserts the io_harness::Decision each produced plus exactly one committed line; F4 asserts a closed channel denies; F5 asserts the policy the NEXT turn is handed" + - "cargo test --test policy — 7 passing: F6 cycles from both BackTab and shift-Tab; F7 asserts the verdict on the policy (Allow, then Ask, then Deny) rather than the word beside it; and no posture can unlock what a layer denied, asserted against .env for all three" + - "cargo test --test events — 15 passing, including F8's three refusal cases: a named rule in a named layer, a rule with no layer, and neither, which renders the tier default as a sentence" + - "cargo test --test status — 12 passing, including F9's absent-not-zero and the narrow form at forty columns" + - "cargo test --test narrow — 4 passing, including the overlay at exactly eighty columns with a seventy-character path and two-hundred-character content lines" + - "cargo test --test timing --test dependencies — 9 passing: no test sleeps or reads a clock, src/main.rs is still the only module that does, and the dependency set is unchanged" + - "sabotage arms run for every functional criterion, each failing only its own criterion's tests: F1 (commit the question), F2 (rule first), F3 (every key allows), F4 (block on the receiver — hung the suite, killed by a watchdog at 45s), F5 (pass remember, keep nothing — F3 stayed green, which is why F5 is its own criterion), F6 (cycle without updating the field), F7 (update the label only), F8 (drop the layer), F9 (render zeros)" + - "one sabotage was a finding rather than a failure: an early return on send-error failed no test, because a failed send drops the Ask and closes the oneshot inside it. The branch was a second spelling of one line and was deleted" + builds: + - "cargo fmt --all -- --check — exit 0" + - "cargo clippy --all-targets -- -D warnings — exit 0" + - "cargo build --release — exit 0; ./target/release/io --version prints io 0.2.0" + - "cargo tree --depth 1 — the same eight names 0.1.0's N1 permits, plus tempfile as a dev-dependency. The approval channel is tokio::sync::oneshot, which was already here" + reviews: + - "F10 completed by the owner, 2026-08-17: the release binary run at a real terminal under the ask-before-writes posture, a write approved, verdict `working! approved`" + - "the deny half of F10's wording was proved by the live rehearsal rather than by the owner's own run, and this record says so rather than implying otherwise" + - "live rehearsal against a live provider (anthropic/claude-sonnet-4 over OpenRouter), four tests: a real turn reaches the approver and the approved write lands; a denied turn leaves the file byte-identical; answering `allow this session` collapses three questions into one inside a single turn; and the never-asks path stalls identically, which is how `Stalled` was shown to be the model rather than this release" + deployments: + - "CI green on macOS, Linux and Windows plus fmt-and-clippy, msrv, CodeQL and two CodeQL analyses — all eight checks, no re-runs and no flakes, on feat/0.2.0 at afcfb7c (PR #9)" + health_checks: + - "the release binary starts and prints io 0.2.0" + - "no secret is committed: no .env has ever been added in any commit on any branch (git log --all --diff-filter=A), and the only key-shaped literal in the tree is tests/wizard.rs's deliberate fixture, whose value is the string NEVERRENDERTHIS and whose test asserts it never reaches the screen" + - "scope.excluded verified absent in source: no Decision::Defer binding (it appears once, in a doc comment explaining the exclusion), no modified-request binding, no SpendDraw rendering, no turn_contained call, and no diff or syntax-highlighting crate in Cargo.toml" +known_limitations: + - > + **Spend against the tree ceiling is not in the status line, and the roadmap + moved rather than this record pretending.** `EventKind::SpendDraw` is emitted + only by a contained turn, and io-harness's contained entry point takes no + steer inbox — rendering spend today would have cost `Ctrl+C`. It is the same + harness boundary that already blocks the configuration's `[sandbox]` and + `[run]` sections. Moved to 0.8.0, the fleet release, which is contained by + definition. If a harness turn that is both contained and steerable appears + sooner, it moves with it. + - > + **`rule` and `layer` are `None` in the ordinary case, not the exotic one.** + Every question and every refusal in the live rehearsal came through unnamed, + because the posture's own `write: Ask` default is what asks and no glob names + the action. So `no rule named it: the tier default decided` is what most + operators will see most of the time. That is accurate — io-harness is explicit + that an unnamed action in the grey tier is the least vouched-for kind — but + the sentence is long for a line that repeats, and a shorter form is worth + designing once it has been read a hundred times. + - > + **The overlay shows a write's proposed content plainly, not as a diff.** The + harness hands an approver the resulting file rather than a patch, so the diff + is this product's to compute and 0.3.0's to design. In a four-row viewport the + content gets one row, with the cut count riding its end. + - > + **The context field is quiet by design.** `Compacted` is the only event + carrying an observation-section size, so between folds nothing on the stream + knows it and the field is absent. The per-step estimate is durable in the + store as `ContextEvent::est_tokens`; the upgrade path is one store read and is + marked with a `ponytail:` comment where the field is declared. + - > + **`Decision::Defer` and `Decision::Approve { modified }` are unbound.** + Deferring stops the run and persists the request, which is only useful + alongside the resume that arrives in 0.4.0; approving a rewritten action is an + editor inside an overlay. Both are real harness affordances, deliberately not + half-built. + - > + **A question about intent is still a dead end.** `awaiting_answer` says so and + points at the next prompt. Answering one needs a `Responder` on a + caller-supplied contract, which is the same entry point that would cost + `Ctrl+C`. 0.7.0. + - > + **The answers row has a double space after each separator**, because the + two-cell selection marker sits after a spaced separator. Cosmetic, noticed + during the F10 preview, and deliberately not changed while the owner's gate + was in flight. A one-line fix for 0.2.1 or 0.3.0. + - > + **A turn commonly ends `Stalled` after its work succeeded.** Observed on every + live run of this release — and identically on the never-asks path 0.1.0 + shipped, at the same step count on the same prompt. It is the model rather + than the approval flow, and it is recorded here because an operator will see + it and wonder. + - > + **`live_f1_a_real_turn_streams_and_edits_a_file` currently fails**, and this + release did not cause it. It asserts the turn streamed assistant tokens; the + model stalled without a closing message and streamed none. The test's own + comment records that exact fragility. It is `#[ignore]`d, CI never runs it, + and it belongs to 0.1.0's F1 — left alone rather than reworded into passing. + - > + **One low Dependabot advisory predates this release**: `lru`'s `IterMut` + violates Stacked Borrows, reached through `ratatui 0.29`, which `Cargo.toml` + pins deliberately because `tui-textarea 0.7` is built against exactly that + pair. Moving off it means depending on somebody's fork of the one widget the + composer is made of. A dependency release of its own. + - > + **The harness pin stays at 0.60.1 on purpose.** 0.60.2 and 0.60.3 are + published and API-identical, so nothing here needs them, and moving the pin + would have put an untested dependency change on a release that already changes + the permission surface. + - > + **This record is sealed before the Release exists, which is the flow rather + than a shortfall.** The sealed record and its lock pin ride `feat/0.2.0` and + merge with the code, so the seal necessarily precedes the tag that cuts the + Release. `mode: published` describes the channel this product has. The second + half of O1 — CI green again on the merge to `develop` — and O4's four + artifacts follow this point. If the tag or the matrix fails after it, the + correction is a new version, not an edit. +supersedes: 0.1.1 +next_recommended_version: 0.3.0 +immutable: true diff --git a/.ultraship/products/io-cli/roadmap.yaml b/.ultraship/products/io-cli/roadmap.yaml index d7ce41d..1433862 100644 --- a/.ultraship/products/io-cli/roadmap.yaml +++ b/.ultraship/products/io-cli/roadmap.yaml @@ -32,13 +32,13 @@ versions: The operator can see and steer the boundary the agent is working under. One key cycles the permission posture and the status line names the io-harness policy layer now in force. A sensitive action stops the run in an overlay that cannot scroll away — showing the act, the - target, the rule that is asking, and the diff when it is a write — and is resolved by - keystroke into both the transcript and the durable trace. A refusal prints the act, the + target, the rule and layer that are asking, and the content a write proposes — and is resolved + by keystroke into both the transcript and the durable trace. A refusal prints the act, the target, and the rule and layer that produced it, which no other terminal agent can render - because no other core records it. The status line gains the rest of its fields: context - pressure, tokens, spend against the tree ceiling, containment state. - detail: outline - status: planned + because no other core records it. The status line gains the fields the harness can supply to a + steerable turn: the policy layer, tokens, context pressure, containment. + detail: specified + status: released - version: 0.3.0 outcome: > The operator can read what the agent did without leaving the terminal or losing the thread. @@ -98,9 +98,12 @@ versions: outcome: > A decomposed task becomes visible as a tree. Spawned children appear as a live fleet with their own status, each showing its draw against the ceiling the whole tree shares, with - refused spawns and collected results shown where they happen. This is the surface that is - structurally impossible for a competing interface to copy, because it is rendering facts only - this core emits. + refused spawns and collected results shown where they happen. The status line gains the spend + field here rather than at 0.2.0: `SpendDraw` is emitted only by a contained turn, and the + contained entry point takes no steer inbox, so a session that renders spend today is one that + cannot be interrupted. This release is contained by definition, which is what makes the field + reachable. This is the surface that is structurally impossible for a competing interface to + copy, because it is rendering facts only this core emits. detail: hypothesis status: planned - version: 0.9.0 diff --git a/.ultraship/releases.lock b/.ultraship/releases.lock index 9389b7a..c71b03e 100644 --- a/.ultraship/releases.lock +++ b/.ultraship/releases.lock @@ -1,3 +1,4 @@ releases: io-cli/0.1.0: sha256:e25f995472436a25bc7e949e5c7e132fb5582b176027e23ee87235cfe2ab6611 io-cli/0.1.1: sha256:4b423d161651c290e318601ae7872796eab66dec975f80a4db276f6345f26488 + io-cli/0.2.0: sha256:62682780453a86d4216055593dd250c2b2f86b415e2bf0539102b1b3a3d38363 diff --git a/.ultraship/views/master-roadmap.md b/.ultraship/views/master-roadmap.md index 5956fe8..6fa4b08 100644 --- a/.ultraship/views/master-roadmap.md +++ b/.ultraship/views/master-roadmap.md @@ -10,13 +10,13 @@ Run `ultraship views` to regenerate. | --- | --- | --- | --- | | 0.1.0 | A developer with no configuration runs `io` in a repository and reaches a finished task in the same session. A guided first run asks for a provider, takes a key and verifies it against the live endpoint, offers the provider's models, previews the themes as it moves between them, and picks a default permission posture — then writes io-harness's own configuration file. The session that follows renders on a hybrid inline viewport: every finished turn, tool cell and message is committed into the terminal's real scrollback, and only a short pane at the bottom holds the composer and the status line. The developer types a task, watches the answer stream, watches a file get edited inside the sandbox, interrupts with a keystroke, and exits — leaving the whole conversation scrollable, searchable and selectable with the terminal's own facilities, because io-cli never took them away. | specified | released | | 0.1.1 | The session stops looking frozen while it works. A turn in flight shows a moving indicator beside the word that names its state, so a reader can tell thinking from hung, and the elapsed clock advances on its own rather than only when an event happens to arrive. A step reads as a step — what the agent decided, what it ran, and what came back — instead of a decision string with a token count appended to it. The README gains the screenshot 0.1.0 could not supply, taken from a run of the corrected interface. Nothing about the permission boundary, the renderer or the wizard changes: this is the release that makes what 0.1.0 already does legible while it is happening. | specified | released | -| 0.2.0 | The operator can see and steer the boundary the agent is working under. One key cycles the permission posture and the status line names the io-harness policy layer now in force. A sensitive action stops the run in an overlay that cannot scroll away — showing the act, the target, the rule that is asking, and the diff when it is a write — and is resolved by keystroke into both the transcript and the durable trace. A refusal prints the act, the target, and the rule and layer that produced it, which no other terminal agent can render because no other core records it. The status line gains the rest of its fields: context pressure, tokens, spend against the tree ceiling, containment state. | outline | planned | +| 0.2.0 | The operator can see and steer the boundary the agent is working under. One key cycles the permission posture and the status line names the io-harness policy layer now in force. A sensitive action stops the run in an overlay that cannot scroll away — showing the act, the target, the rule and layer that are asking, and the content a write proposes — and is resolved by keystroke into both the transcript and the durable trace. A refusal prints the act, the target, and the rule and layer that produced it, which no other terminal agent can render because no other core records it. The status line gains the fields the harness can supply to a steerable turn: the policy layer, tokens, context pressure, containment. | specified | released | | 0.3.0 | The operator can read what the agent did without leaving the terminal or losing the thread. Edits render as diffs — unified by default, word-level emphasis inside changed lines, syntax highlighting, context collapsed with an expander, a defined form for narrow terminals, and a minimal mode for people who review by file rather than by hunk. Tool calls render as one line with a result and a duration, expandable while live and committed collapsed, with the full output going to the durable trace rather than to the screen. A transcript overlay gives the whole conversation on one key, and the last message or the last diff can be put on the system clipboard in a way that survives SSH. | outline | planned | | 0.4.0 | Work survives the session. A picker lists previous sessions from the harness store and reopens one where it stopped. Any turn in a conversation can be forked into a new branch and continued from there. The model can be changed mid-session without losing context. An empty composer takes a double-escape to rewind the conversation and the workspace to an earlier point, using the rewind the harness already implements and already records. | outline | planned | | 0.5.0 | The same agent runs unattended. A non-interactive subcommand takes a goal, runs it to completion, and exits with a meaningful status; with a flag it emits the run's events as newline-delimited JSON — the same events the interactive session renders, from the same stream, with no interface code on the path. Sandbox and policy are selectable from the command line, a non-TTY stdout is detected and honoured without being asked, and credentials are taken from the environment so nothing has to be written to disk in CI. | hypothesis | planned | | 0.6.0 | The interface reaches people and terminals the category currently excludes. A plain mode built for screen readers removes animation and repaint, keeps the real terminal cursor as the focus indicator, puts content before metadata, and announces every state change as one line of text — which no terminal agent in this field does today. Colour is never the sole carrier of meaning, `NO_COLOR` is honoured, box drawing degrades to ASCII, the keyboard protocol is negotiated up on terminals that advertise it and down over SSH without losing core function, keybindings are configurable and documented as a table, and every surface has an audited form at eighty columns. | hypothesis | planned | | 0.7.0 | The composer becomes a command surface. A slash palette with fuzzy matching reaches every command and every harness skill and prompt template; `@` completes paths from the workspace; `!` passes a line to the shell verbatim; a large paste becomes an attachment instead of flooding the input. Every picker filters as you type — the model list, the theme list, `/resume` and `/fork` — so a four-hundred-model catalogue is reached by typing three characters rather than by holding an arrow key. The agent's own plan and todo list render as a live panel, and a question the agent asks about intent — which the harness distinguishes from an approval about permission — is answered inline rather than guessed at. | hypothesis | planned | -| 0.8.0 | A decomposed task becomes visible as a tree. Spawned children appear as a live fleet with their own status, each showing its draw against the ceiling the whole tree shares, with refused spawns and collected results shown where they happen. This is the surface that is structurally impossible for a competing interface to copy, because it is rendering facts only this core emits. | hypothesis | planned | +| 0.8.0 | A decomposed task becomes visible as a tree. Spawned children appear as a live fleet with their own status, each showing its draw against the ceiling the whole tree shares, with refused spawns and collected results shown where they happen. The status line gains the spend field here rather than at 0.2.0: `SpendDraw` is emitted only by a contained turn, and the contained entry point takes no steer inbox, so a session that renders spend today is one that cannot be interrupted. This release is contained by definition, which is what makes the field reachable. This is the surface that is structurally impossible for a competing interface to copy, because it is rendering facts only this core emits. | hypothesis | planned | | 0.9.0 | The remaining harness capabilities get a face. Images render inline where the terminal speaks Kitty, iTerm2 or Sixel and degrade to half-blocks where it does not, and an image can be attached to a turn. Background shell handles, browser sessions, LSP servers and MCP servers each get a compact live indicator, so a run that has started something long-lived says so rather than appearing idle. | hypothesis | planned | diff --git a/.ultraship/views/release-history.md b/.ultraship/views/release-history.md index 138565c..ac4492d 100644 --- a/.ultraship/views/release-history.md +++ b/.ultraship/views/release-history.md @@ -8,9 +8,37 @@ Run `ultraship views` to regenerate. | Version | Released | Mode | Delivered | | --- | --- | --- | --- | +| 0.2.0 | 2026-08-17T02:24:43Z | published | The operator can see the boundary the agent is working under, change it, and answer it when it asks. Through 0.1.0 and 0.1.1 the approver handed to io-harness was `DenyAll`, so the *ask before writes* posture declined every write and every command it was named for, and a live first run walked straight into it. That dead end is closed. The release is one seam and four surfaces drawn on it. Every other connection this product has to io-harness is one-way: an observer is handed an event and hands it on, and nothing waits. `Approver::decide_in_context` is the opposite shape — it runs on the agent's own task and the run stays paused until the future resolves — so the correctness question is not whether an answer arrives but what happens when one never does. The answer is a type: answering consumes the `Ask`, so dropping one *is* a denial, and both ways of losing the interface arrive at one closed channel. Asserted with no clock anywhere; the arm that blocks instead hangs the suite forever, which is what a deadlock looks like. The overlay lives in the viewport and commits nothing, because a question in the scrollback can be scrolled above the fold while the run is blocked on it. It states the act and the target, then the rule and the layer on a row of their own, then the content a write proposes. Three answers on keys and on arrows, opening on the least committal one. `Shift+Tab` cycles the posture from both spellings a terminal can send, and the status line names the one in force. A refusal prints its rule and its layer — the two facts no other terminal agent can render, because no other core records them. Three of the status line's named placeholders are filled — tokens, context pressure, containment as mode *and* backend — and each is absent rather than zero until something supplies it. The fourth is not filled, and the roadmap moved rather than the record pretending: spend against the tree ceiling is emitted only by a contained turn, and the contained entry point takes no steer inbox, so rendering it today would have cost `Ctrl+C`. | | 0.1.1 | 2026-08-16T18:47:29Z | published | The session stops looking frozen while it works. A turn in flight advances its own clock and turns its own indicator, and neither waits for an event to arrive. The screenshot this release put in the README is the proof and the case: forty seconds into a slow first token, no answer on screen yet, the status line reading `deepseek/deepseek-v4-flash-0731 · ⠨ working · 40s` with the indicator turning. Under 0.1.0 that same moment was a still word beside a still clock, which is what a hung process looks like. The mechanism is one function. `App::tick(age) -> bool` takes the session's age as an argument rather than reading a clock, and answers whether a frame is owed rather than drawing one. That is what makes liveness assertable without a timing assertion: a test advances the clock by hand and asks. It also decides the half that matters more — an idle session is told no, so this renderer still repaints only when something changed, which is the whole of its differentiation from the alternate-screen products. A step now reads as a step: the decision, the tool it called with its target, the result, and then the token count and the step number as trailing muted detail. The result is stated in both directions, `changed files` or `no change`, so a transcript is skimmed down one column instead of parsed. Nothing else moved. No key, no command, no event surface, no setting this release starts reading, and the eight dependencies are the same eight. A spinner is a handful of characters and a modulo. | | 0.1.0 | 2026-08-16T17:35:00Z | published | A terminal interface over io-harness that renders run events, edits a prompt and reads a keyboard — and contains no agent loop, provider client, tool, sandbox, policy engine or session store of its own. 3,458 lines of source across 16 files, against the archived product's 1,199,453 across 88 crates. That ratio is what this release existed to establish. The renderer is the release. Finished content is committed into the terminal's own scrollback and a fixed few rows at the bottom hold the composer and the status line; a streaming answer commits each line as it finishes, so a two-hundred-line reply leaves the viewport exactly the size it started. The alternate screen is never entered and the mouse is never captured — asserted over the bytes the process writes, so no later release can lose it quietly. A first-run wizard takes a developer from no configuration to a working agent: provider, a credential verified against the live endpoint before anything is written, a model from that provider's own catalogue, a theme previewed live, and a permission posture that is an io_harness::Policy rather than a flag of io-cli's own. Nothing reaches disk before the confirmation screen; the credential never reaches the screen at all, and is not written when the provider's environment variable already carries it. Distribution is four cross-compiled artifacts and a SHA256SUMS on the GitHub Release, installed by one script per platform that verifies before it unpacks and needs no administrator rights. | +### 0.2.0 known limitations + +- **Spend against the tree ceiling is not in the status line, and the roadmap moved rather than this record pretending.** `EventKind::SpendDraw` is emitted only by a contained turn, and io-harness's contained entry point takes no steer inbox — rendering spend today would have cost `Ctrl+C`. It is the same harness boundary that already blocks the configuration's `[sandbox]` and `[run]` sections. Moved to 0.8.0, the fleet release, which is contained by definition. If a harness turn that is both contained and steerable appears sooner, it moves with it. + +- **`rule` and `layer` are `None` in the ordinary case, not the exotic one.** Every question and every refusal in the live rehearsal came through unnamed, because the posture's own `write: Ask` default is what asks and no glob names the action. So `no rule named it: the tier default decided` is what most operators will see most of the time. That is accurate — io-harness is explicit that an unnamed action in the grey tier is the least vouched-for kind — but the sentence is long for a line that repeats, and a shorter form is worth designing once it has been read a hundred times. + +- **The overlay shows a write's proposed content plainly, not as a diff.** The harness hands an approver the resulting file rather than a patch, so the diff is this product's to compute and 0.3.0's to design. In a four-row viewport the content gets one row, with the cut count riding its end. + +- **The context field is quiet by design.** `Compacted` is the only event carrying an observation-section size, so between folds nothing on the stream knows it and the field is absent. The per-step estimate is durable in the store as `ContextEvent::est_tokens`; the upgrade path is one store read and is marked with a `ponytail:` comment where the field is declared. + +- **`Decision::Defer` and `Decision::Approve { modified }` are unbound.** Deferring stops the run and persists the request, which is only useful alongside the resume that arrives in 0.4.0; approving a rewritten action is an editor inside an overlay. Both are real harness affordances, deliberately not half-built. + +- **A question about intent is still a dead end.** `awaiting_answer` says so and points at the next prompt. Answering one needs a `Responder` on a caller-supplied contract, which is the same entry point that would cost `Ctrl+C`. 0.7.0. + +- **The answers row has a double space after each separator**, because the two-cell selection marker sits after a spaced separator. Cosmetic, noticed during the F10 preview, and deliberately not changed while the owner's gate was in flight. A one-line fix for 0.2.1 or 0.3.0. + +- **A turn commonly ends `Stalled` after its work succeeded.** Observed on every live run of this release — and identically on the never-asks path 0.1.0 shipped, at the same step count on the same prompt. It is the model rather than the approval flow, and it is recorded here because an operator will see it and wonder. + +- **`live_f1_a_real_turn_streams_and_edits_a_file` currently fails**, and this release did not cause it. It asserts the turn streamed assistant tokens; the model stalled without a closing message and streamed none. The test's own comment records that exact fragility. It is `#[ignore]`d, CI never runs it, and it belongs to 0.1.0's F1 — left alone rather than reworded into passing. + +- **One low Dependabot advisory predates this release**: `lru`'s `IterMut` violates Stacked Borrows, reached through `ratatui 0.29`, which `Cargo.toml` pins deliberately because `tui-textarea 0.7` is built against exactly that pair. Moving off it means depending on somebody's fork of the one widget the composer is made of. A dependency release of its own. + +- **The harness pin stays at 0.60.1 on purpose.** 0.60.2 and 0.60.3 are published and API-identical, so nothing here needs them, and moving the pin would have put an untested dependency change on a release that already changes the permission surface. + +- **This record is sealed before the Release exists, which is the flow rather than a shortfall.** The sealed record and its lock pin ride `feat/0.2.0` and merge with the code, so the seal necessarily precedes the tag that cuts the Release. `mode: published` describes the channel this product has. The second half of O1 — CI green again on the merge to `develop` — and O4's four artifacts follow this point. If the tag or the matrix fails after it, the correction is a new version, not an edit. + + ### 0.1.1 known limitations - **An idle session's clock is still frozen, and that is F2 rather than a defect.** Between turns the elapsed time advances only when a key arrives. Making it tick when nothing is happening is exactly the repaint-forever behaviour this renderer exists not to have, so the frozen idle clock is the price of the property and is deliberate. diff --git a/.ultraship/views/workspace-brief.md b/.ultraship/views/workspace-brief.md index aa2bd4b..19da5f4 100644 --- a/.ultraship/views/workspace-brief.md +++ b/.ultraship/views/workspace-brief.md @@ -16,6 +16,6 @@ The terminal agent that shows you what it is allowed to do, what it is spending, | Product | State | Latest release | Classification | MVP boundary | | --- | --- | --- | --- | --- | -| io-cli | RELEASED | 0.1.1 | independent-product | A developer with no prior configuration runs `io` in a repository, is walked through picking a provider, entering and verifying a key, choosing a model and a theme, types one real task, watches it stream and edit a file inside the sandbox, interrupts it with a keystroke, and exits — leaving the whole conversation in the terminal's own scrollback. | +| io-cli | RELEASED | 0.2.0 | independent-product | A developer with no prior configuration runs `io` in a repository, is walked through picking a provider, entering and verifying a key, choosing a model and a theme, types one real task, watches it stream and edit a file inside the sandbox, interrupts it with a keystroke, and exits — leaving the whole conversation in the terminal's own scrollback. | _Canonical sources: workspace.yaml, products//product.yaml, products//lifecycle.yaml_ diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b4860f..10ea7d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,66 @@ All notable changes to this project are documented here. The format follows ## [Unreleased] +## [0.2.0] - 2026-08-17 + +The operator can see the boundary the agent is working under, change it, and +answer it when it asks. Through 0.1.0 and 0.1.1 the approver handed to io-harness +was `DenyAll`, so the *ask before writes* posture declined every write and every +command it was named for; that dead end is what this release closes. + +### Added + +- **An approval overlay.** When an action needs permission the run stops and asks + in an overlay that cannot scroll away, because a question committed to the + transcript can be scrolled above the fold while the run is blocked on it. It + states the act and the target, then the rule and the layer that are asking on a + row of their own, then the content a write proposes. Answer it with `y` (allow + once), `a` (allow for the rest of this session) or `n` (deny) — or with the + arrows and `Enter`, since a key that only works for a reader who already knows + it is not an interface. The overlay opens on the least committal answer. +- **Every decision in the transcript.** Answering commits exactly one line naming + the act, the target and what was decided, so the decision is in the terminal's + own scrollback as well as in the run's durable trace. +- **`Shift+Tab` cycles the permission posture**, and the status line names the one + in force. It changes this session, like `/theme` and `/model`; `io setup` is + what makes a choice permanent. It takes effect on the next turn, because + io-harness takes a policy per turn. Both spellings a terminal can send — + `BackTab`, and `Tab` with a shift modifier under the Kitty keyboard protocol — + are the same key. +- **A refusal names its rule and its layer.** `write /etc/hosts · rule fs.deny · + layer ops-baseline` — the two facts no other terminal agent can print, because + no other core records them. When no rule named the action, the line says the + tier default decided rather than showing a blank: in io-harness that is the + least vouched-for kind of action, not the most. +- **Three more status fields**: the tokens the session has spent, how full the + assembled context was at the last fold, and how this run's commands are actually + contained — the mode asked for *and* the backend that answered on this host, + never the mode alone, which is an intention rather than a fact. Each is absent + until something supplies it. A field that invents its own value is worse than no + field. + +### Changed + +- The *ask before writes* posture now asks. Its description in the wizard said + `declined until the approval surface lands`, which was true and is not any more. +- An outcome that stopped waiting on a human points at what to do about it now + that there is something to do. + +### Not in this release + +- **Spend against the tree ceiling.** `EventKind::SpendDraw` is emitted only by a + contained turn, and io-harness's contained entry point takes no steer inbox — so + rendering spend today would cost `Ctrl+C`. It moves to 0.8.0, the fleet release, + which is contained by definition. +- Diffs, syntax highlighting and collapsible tool output: 0.3.0. The harness hands + an approver the full post-write content rather than a patch, so the overlay shows + that content plainly and the diff surface is designed where it belongs. +- Deferring an approval, and approving a rewritten action. Both are real io-harness + affordances; deferring is only useful alongside the resume that arrives in 0.4.0, + and rewriting is an editor inside an overlay. +- Answering a question the agent asked about *intent*, which io-harness + deliberately distinguishes from an approval about permission: 0.7.0. + ## [0.1.1] - 2026-08-17 The session stops looking frozen while it works. Remediation of what 0.1.0 diff --git a/Cargo.lock b/Cargo.lock index ead864a..b18ea17 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -880,7 +880,7 @@ dependencies = [ [[package]] name = "io-cli" -version = "0.1.1" +version = "0.2.0" dependencies = [ "clap", "crossterm", diff --git a/Cargo.toml b/Cargo.toml index 33e2b23..27fb403 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "io-cli" -version = "0.1.1" +version = "0.2.0" edition = "2021" # io-harness declares 1.95, set by `libsqlite3-sys` calling the std `cfg_select!` # macro from its build script. Nothing here can be lower than what the harness diff --git a/README.md b/README.md index be794ce..e53f460 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,9 @@ writes no key to disk at all. | `Up / Down` | walk prompt history | | `Ctrl+C` | interrupt the turn; twice at an empty prompt, exit | | `Ctrl+D` | exit, on an empty prompt | +| `Shift+Tab` | cycle the permission posture, from the next turn | | `Ctrl+L` | clear the viewport, never the scrollback | +| `y / a / n` | answer an approval: allow once, allow this session, deny | | `Esc` | close a picker without choosing | @@ -131,7 +133,10 @@ itself permission. The wizard therefore writes the user-scope file, which is where widening is your own decision. **What this release reads from that file, and what it does not.** io-cli reads -the provider, the permission policy and its own `[app.io-cli]` section. It does +the provider, the permission policy and its own `[app.io-cli]` section. The +policy's own defaults are what `Shift+Tab` cycles; a posture chosen with the key +lasts for the session and is not written back, because a keystroke that rewrites +a permission boundary on disk is the opposite of what that key is for. io-cli does **not** yet apply `[sandbox]` limits, `[run]` budgets, `[[mcp]]`, `[[lsp]]`, `[[agent]]` or an `AGENTS.md` instruction file to a turn. The reason is specific rather than an oversight: io-harness's steerable turn builds its own task @@ -146,12 +151,21 @@ refusal, error and warning also carries a word. ## What this release is not -0.1.1 is the renderer, the composer, the wizard and one real session, now legible -while it is happening. Approval -overlays and the refusal surface that names the rule and the layer are 0.2.0; -diffs and collapsible tool output are 0.3.0; resume, fork and rewind are 0.4.0; -the headless subcommand and NDJSON are 0.5.0; the screen-reader mode is 0.6.0. -An action that needs approval is declined in this release, and says so. +0.2.0 is the permission boundary made visible and answerable: the approval +overlay, the posture key, the refusal that names its rule and its layer, and the +status fields around them. Diffs, syntax highlighting and collapsible tool output +are 0.3.0; resume, fork and rewind are 0.4.0; the headless subcommand and NDJSON +are 0.5.0; the screen-reader mode is 0.6.0; the slash palette and type-to-filter +pickers are 0.7.0. + +Two things are absent for reasons worth stating rather than hiding. **Spend +against the tree ceiling is not in the status line**: io-harness emits +`SpendDraw` only from a contained turn, and its contained entry point takes no +steer inbox, so rendering spend today would cost `Ctrl+C`. It arrives at 0.8.0 +with the fleet, which is contained anyway. And **the overlay shows a write's +proposed content plainly rather than as a diff** — the harness hands an approver +the resulting file, not a patch, so the diff is io-cli's to compute and 0.3.0's +to design. ## Contributing diff --git a/src/app.rs b/src/app.rs index 591f25e..5b1de26 100644 --- a/src/app.rs +++ b/src/app.rs @@ -14,8 +14,10 @@ use ratatui::text::Line; use ratatui::widgets::Paragraph; use ratatui::Frame; +use crate::approval::{Answer, Approval, Ask}; use crate::composer::{Composer, Reply}; use crate::events::Events; +use crate::settings::Posture; use crate::status::Status; use crate::term::VIEWPORT_HEIGHT; use crate::theme::{Theme, Tone}; @@ -63,6 +65,25 @@ pub struct App { quits: u8, /// Lines waiting to be committed to scrollback. pending: Vec>, + /// The question on screen, if the run is waiting on one. + /// + /// It lives here rather than beside the picker in the driver because it is + /// not a choice the operator went looking for: it arrives mid-turn, it takes + /// the keyboard while it is up, and the run is stopped until it is gone. + approval: Option, + /// Rules the operator has allowed for the rest of this session. + /// + /// The harness's own `remember` is run-scoped and dies with the turn, so + /// without this a *this session* answer would ask again on the next prompt. + /// F5 asserts it on the policy handed to the next turn. + remembered: Vec, + /// The permission posture this session is running under. + /// + /// `None` means the configuration file holds a policy that is none of the + /// three the wizard offers, which io-harness's own file can express. The line + /// says `custom` rather than naming one it is not, and the first press of the + /// key moves to a posture the operator did choose. + posture: Option, } impl App { @@ -75,9 +96,88 @@ impl App { mode: Mode::Idle, quits: 0, pending: Vec::new(), + approval: None, + remembered: Vec::new(), + posture: None, } } + /// A run stopped to ask. The overlay opens and takes the keyboard. + /// + /// Nothing is committed here. A question in the scrollback is one that can be + /// scrolled away from a run which is blocked on it, which is what F1 asserts + /// and the reason this surface is an overlay at all. + pub fn open_approval(&mut self, ask: Ask) { + self.approval = Some(Approval::new(ask)); + } + + /// The posture in force. + pub fn posture(&self) -> Option { + self.posture + } + + /// Say which posture the session started under. The status line follows it. + pub fn set_posture(&mut self, posture: Option) { + self.posture = posture; + self.status.policy = Some(match posture { + Some(posture) => posture.short().to_string(), + None => "custom".to_string(), + }); + } + + /// Move to the next posture. One key, no menu, always visible — and it takes + /// effect on the next turn, because io-harness takes a policy per turn and has + /// no way to change one mid-flight. + fn cycle_posture(&mut self) -> Command { + let next = match self.posture { + Some(posture) => posture.next(), + // From a policy that is none of the three, the first press lands on the + // first one rather than on nothing. + None => Posture::Workspace, + }; + self.set_posture(Some(next)); + self.say( + Tone::Muted, + format!("policy:{} — {}", next.short(), next.detail()), + ); + Command::None + } + + /// Whether a question is on screen. + pub fn asking(&self) -> bool { + self.approval.is_some() + } + + /// Answer the open question. The overlay closes, the run goes on, and the + /// decision commits one line — so it is in the transcript as well as in the + /// harness's own trace. + pub fn answer_approval(&mut self, answer: Answer) { + let Some(approval) = self.approval.take() else { + return; + }; + let act = crate::approval::act_word(approval.ask().act()); + let target = approval.ask().target().to_string(); + if answer == Answer::Session { + self.remembered.push(approval.remembered()); + } + approval.answer(answer); + self.say( + if answer == Answer::Deny { + Tone::Refused + } else { + Tone::Success + }, + format!("{act} {target} — {}", answer.spoken()), + ); + } + + /// Everything the operator has allowed for the rest of this session, as + /// io-harness rules. The driver merges these into the policy it hands the next + /// turn; io-cli evaluates none of them. + pub fn remembered(&self) -> &[io_harness::Rule] { + &self.remembered + } + pub fn mode(&self) -> Mode { self.mode } @@ -97,6 +197,10 @@ impl App { pub fn finished(&mut self) { self.mode = Mode::Idle; self.status.working = false; + // A question outlives its run only as a stuck overlay over a session that + // has moved on. Dropping it is the denial — see `Ask` — and the run it + // belonged to has already ended, so there is nobody left to tell. + self.approval = None; let tail = self.events.flush(); self.pending.extend(tail); } @@ -124,10 +228,45 @@ impl App { /// Take an event from the harness. pub fn event(&mut self, event: &RunEvent) { + self.status_from(event); let lines = self.events.event(event); self.pending.extend(lines); } + /// The status line's share of an event. + /// + /// Only the events that carry a fact set a field, and nothing sets one to a + /// default. A field this has never heard about stays `None`, which is what the + /// line renders as nothing at all rather than as a zero. + fn status_from(&mut self, event: &RunEvent) { + match &event.kind { + io_harness::EventKind::Step { tokens, .. } => { + // The session's total, not the step's own. A field that swings + // rather than climbs cannot be read at a glance. + self.status.tokens = Some(self.status.tokens.unwrap_or(0) + tokens); + } + // The run's own total, which is authoritative over the sum of the steps + // we happened to see. Guarded on the tag rather than inside the arm: + // a run that reported no usage at all reports `0`, and overwriting a + // real total with it would turn a known number into a wrong one. + io_harness::EventKind::Finished { tokens, .. } if *tokens > 0 => { + self.status.tokens = Some(*tokens); + } + io_harness::EventKind::Compacted { after_tokens, .. } => { + // The denominator is io-harness's own declared budget, asked of the + // harness rather than copied here — a `24_000` written into this + // file would be wrong after some harness patch, and wrong silently. + let budget = io_harness::ContextBudget::default().effective_tokens(None); + let share = (*after_tokens as f64 / budget.max(1) as f64 * 100.0).round(); + self.status.context = Some(share.clamp(0.0, 100.0) as u8); + } + io_harness::EventKind::Contained { mode, backend, .. } => { + self.status.containment = Some(crate::status::format_containment(mode, backend)); + } + _ => {} + } + } + /// Add a line of io-cli's own, rather than the harness's. pub fn say(&mut self, tone: Tone, text: impl Into) { let line = self.theme.notice(tone, text); @@ -147,8 +286,29 @@ impl App { pub fn key(&mut self, key: KeyEvent) -> Command { let control = key.modifiers.contains(KeyModifiers::CONTROL); + // An open question takes the keyboard, except for `Ctrl+C`. That is the + // answer to "does `Ctrl+C` deny, or interrupt?": it interrupts, and the + // question is denied as a consequence of the turn ending rather than as a + // second meaning for one key. Nothing else can reach the composer while a + // run is stopped waiting on an answer. + let interrupting = control && key.code == KeyCode::Char('c'); + if let Some(open) = self.approval.as_mut().filter(|_| !interrupting) { + if let Some(answer) = open.key(key) { + self.answer_approval(answer); + } + return Command::None; + } match (key.code, control) { (KeyCode::Char('c'), true) => self.interrupt_or_quit(), + // Two spellings of one key. A terminal without the Kitty keyboard + // protocol sends `BackTab` with no modifier; one that has negotiated it + // sends `Tab` with shift. Binding either alone ships a key that works + // on the developer's terminal and silently does nothing on somebody + // else's, which is worse than not shipping it. + (KeyCode::BackTab, _) => self.cycle_posture(), + (KeyCode::Tab, _) if key.modifiers.contains(KeyModifiers::SHIFT) => { + self.cycle_posture() + } // Only on an empty composer, so it never discards something typed. (KeyCode::Char('d'), true) if self.composer.is_empty() => Command::Exit, (KeyCode::Char('d'), true) => Command::None, @@ -198,6 +358,13 @@ impl App { if area.height == 0 { return; } + // A question takes the whole viewport while it is up. There is nothing to + // type at — the run is stopped — and the alternative, squeezing it beside + // a composer nobody can use, is how an approval ends up too small to read. + if let Some(open) = &self.approval { + open.render(frame, area, &self.theme); + return; + } // One row for the streaming tail, one for the status line, the rest for // the composer. Content before metadata, top to bottom, so a reader // reaches the model's words before the token count. diff --git a/src/approval.rs b/src/approval.rs new file mode 100644 index 0000000..2b451d9 --- /dev/null +++ b/src/approval.rs @@ -0,0 +1,479 @@ +//! The two-way seam between io-harness and the operator. +//! +//! [`crate::bridge`] is the other seam and the easy one: an observer is handed an +//! event and hands it on, and nothing waits for the interface. This one is the +//! opposite shape. `Approver::decide_in_context` runs **on the agent's own task** +//! and the run stays paused until the future it returns resolves, so this module +//! is the only place in the product where the interface can stop the agent. +//! +//! Two consequences shape everything below. +//! +//! **A question that is never answered must deny.** Both ends can vanish — the +//! whole interface (the mpsc closes) or one question it took and abandoned (the +//! oneshot closes) — and both mean the same thing to a run that cannot proceed +//! without an answer. A blocked turn is worse than a refused one: a refusal +//! reaches the model as an observation it can adapt to, and a block reaches +//! nobody. F4 asserts it on a closed channel rather than on a timeout, because a +//! deadlock asserted with a clock is a test that passes on a fast machine. +//! +//! **The rule and the layer only exist here.** `EventKind::ApprovalRequested` +//! carries the act and the target and nothing else; the glob that put the action +//! in the grey tier, the layer that glob came from, and the content a write would +//! leave behind arrive as the [`Request`] and [`ApprovalContext`] handed to this +//! trait. So the approval overlay is drawn from what this module forwards, and +//! never from the event stream. + +use crossterm::event::{KeyCode, KeyEvent}; +use io_harness::approve::DecisionFuture; +use io_harness::{Act, ApprovalContext, Approver, Decision, Effect, Request, Rule}; +use ratatui::layout::Rect; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; +use ratatui::Frame; +use tokio::sync::{mpsc, oneshot}; + +use crate::status::SEPARATOR; +use crate::theme::{Theme, Tone}; + +/// What the model is told when nobody answered. +/// +/// It reaches the model as an observation rather than as an error, which is the +/// point: a run told this can do something else, and a run left waiting cannot. +pub const UNANSWERED: &str = "nobody was there to approve it"; + +/// One question, on its way to the interface, with the way back inside it. +/// +/// Answering consumes it. There is no way to hold an `Ask` and answer it twice, +/// and dropping one is a denial rather than a leak — which is the behaviour F4 +/// asks for, expressed as a type rather than as a rule somebody has to remember. +pub struct Ask { + request: Request, + context: ApprovalContext, + answer: oneshot::Sender, +} + +impl Ask { + /// Answer it. The run resumes on whatever this says. + pub fn answer(self, decision: Decision) { + // A send error means the run ended while the question was on screen — an + // interrupt, or a ceiling reached elsewhere. There is nobody left to tell, + // and that is not a failure of the interface. + let _ = self.answer.send(decision); + } + + /// What kind of action is being asked about. + pub fn act(&self) -> Act { + self.request.act + } + + /// The path, or the binary name for an exec. + pub fn target(&self) -> &str { + &self.request.target + } + + /// What a write would leave behind, whole. The harness hands an approver the + /// resulting file rather than a patch, so anything diff-shaped is this + /// product's to compute — and is 0.3.0's, not this release's. + pub fn content(&self) -> Option<&str> { + self.request.content.as_deref() + } + + /// The glob that put this action in the grey tier, or `None` when the tier + /// default did. + /// + /// `None` is not "no reason". io-harness's own documentation is explicit that + /// an unnamed action in the grey tier is the *least* vouched-for kind, so a + /// surface that renders `None` as blank tells the reader the opposite of what + /// happened. F8 asserts both cases. + pub fn rule(&self) -> Option<&str> { + self.context.rule.as_deref() + } + + /// The policy layer the deciding rule came from, or `None` for the tier + /// default. Layers are named after whoever wrote them, so this is the field + /// that sends a reader to the right configuration file. + pub fn layer(&self) -> Option<&str> { + self.context.layer.as_deref() + } + + /// The run's goal, in the words the operator typed. + pub fn goal(&self) -> &str { + &self.context.goal + } +} + +/// The approver handed to `Session::turn_steered`. +pub struct Asker { + asks: mpsc::UnboundedSender, +} + +/// An asker and the receiver the interface drains. +/// +/// Unbounded for the same reason [`crate::bridge`]'s channel is: the alternatives +/// are blocking the run and dropping a question, and a dropped question is a turn +/// that waits forever. In practice the depth is one — the run is paused from the +/// moment it asks until the moment it is answered, so a second question cannot +/// arrive from the same run while the first is outstanding. +pub fn channel() -> (Asker, mpsc::UnboundedReceiver) { + let (asks, rx) = mpsc::unbounded_channel(); + (Asker { asks }, rx) +} + +impl Asker { + async fn ask(&self, request: Request, context: ApprovalContext) -> Decision { + let (answer, reply) = oneshot::channel(); + let ask = Ask { + request, + context, + answer, + }; + // One path for both ways this can fail, deliberately. A failed `send` + // returns the `Ask` and drops it, which closes the oneshot inside it — so + // "the interface is gone" and "the interface took the question and went + // away" arrive here as the same closed channel. An early return for the + // first was written, sabotaged, and found to fail no test at all: it was a + // second spelling of this line. + let _ = self.asks.send(ask); + reply.await.unwrap_or_else(|_| Decision::deny(UNANSWERED)) + } +} + +impl Approver for Asker { + fn decide<'a>(&'a self, request: &'a Request) -> DecisionFuture<'a> { + // The harness calls `decide_in_context`; this exists because the trait + // requires it, and it must still ask rather than answer on its own — an + // approver with two behaviours is one that ships the wrong one. + let request = request.clone(); + Box::pin(async move { self.ask(request, ApprovalContext::default()).await }) + } + + fn decide_in_context<'a>( + &'a self, + request: &'a Request, + context: &'a ApprovalContext, + ) -> DecisionFuture<'a> { + let request = request.clone(); + let context = context.clone(); + Box::pin(async move { self.ask(request, context).await }) + } +} + +/// What the operator can say. Three, deliberately. +/// +/// io-harness offers two more — `Decision::Defer`, which stops the run and +/// persists the request for a decision later, and `Decision::Approve { modified }`, +/// which rewrites the action. Deferring is only useful alongside a resume this +/// product does not have until 0.4.0, and rewriting is an editor inside an +/// overlay. Both are left unbound rather than half-built. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Answer { + /// This action, this once. + Once, + /// This action and every later one like it, for the rest of the session. + Session, + /// No, with a reason the model can adapt to. + Deny, +} + +impl Answer { + /// In the order they are offered, least committal first. A reader moving + /// rightwards is giving away more, which is the direction a permission + /// surface should read in. + pub const ALL: [Answer; 3] = [Answer::Once, Answer::Session, Answer::Deny]; + + /// The key that chooses it directly. + pub fn key(self) -> char { + match self { + Self::Once => 'y', + Self::Session => 'a', + Self::Deny => 'n', + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Once => "allow once", + Self::Session => "allow this session", + Self::Deny => "deny", + } + } + + /// The word that goes in the transcript, after the decision is made. + pub fn spoken(self) -> &'static str { + match self { + Self::Once => "allowed once", + Self::Session => "allowed for this session", + Self::Deny => "denied", + } + } +} + +/// What the model is told when the operator says no. +pub const REFUSED_BY_OPERATOR: &str = "the operator denied it"; + +/// An open question, and the answer being chosen. +/// +/// It owns the [`Ask`], so the run stays paused for exactly as long as this +/// exists. Dropping one without answering is a denial — see [`Ask`] — which is +/// what makes an interrupt, a panic or an unwound resize safe here. +pub struct Approval { + ask: Ask, + chosen: usize, +} + +impl Approval { + pub fn new(ask: Ask) -> Self { + // Opens on `Once`: the least committal answer, and never on a remembered + // allow. A surface that opens on the widest answer is one where `Enter` + // by reflex gives away the most. + Self { ask, chosen: 0 } + } + + /// Which answer is highlighted. + pub fn chosen(&self) -> Answer { + Answer::ALL[self.chosen] + } + + pub fn ask(&self) -> &Ask { + &self.ask + } + + /// A keystroke. `Some` means the operator answered and this overlay is over. + /// + /// Two ways in, on purpose: a letter for the reader who knows the key, and + /// arrows with `Enter` for the one who does not. A key that only works when + /// you already know it is not an interface. + pub fn key(&mut self, key: KeyEvent) -> Option { + match key.code { + KeyCode::Left => { + self.chosen = self.chosen.saturating_sub(1); + None + } + KeyCode::Right => { + if self.chosen + 1 < Answer::ALL.len() { + self.chosen += 1; + } + None + } + KeyCode::Enter => Some(self.chosen()), + KeyCode::Char(c) => Answer::ALL + .into_iter() + .find(|answer| answer.key() == c.to_ascii_lowercase()), + _ => None, + } + } + + /// Answer it, and let the run go on. + pub fn answer(self, answer: Answer) { + let decision = match answer { + Answer::Once => Decision::approve(), + Answer::Session => Decision::Approve { + modified: None, + // The harness applies this for the rest of the *run*, which is one + // turn. Carrying it into the next turn is the caller's job and is + // what F5 asserts; see `remembered`. + remember: vec![self.remembered()], + }, + Answer::Deny => Decision::deny(REFUSED_BY_OPERATOR), + }; + self.ask.answer(decision); + } + + /// The rule *allow this session* means. + /// + /// The narrowest thing a person means when they say yes to the same question + /// twice: this act, on this target. Not the act alone, which would allow every + /// write; and the pattern is the target as written, because a bare filename is + /// matched against a basename too and would allow the same name anywhere in + /// the tree. + pub fn remembered(&self) -> Rule { + Rule { + act: self.ask.act(), + effect: Effect::Allow, + pattern: self.ask.target().to_string(), + } + } + + /// Draw it. Three parts, in the order a reader needs them: what is being + /// asked, what it would do, and how to answer. + /// + /// The whole thing is one viewport's worth of rows — four, in a session — so + /// the content is what flexes. It is the only part that can be arbitrarily + /// long and the only part a reader can ask for more of by other means. + pub fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) { + if area.height == 0 || area.width == 0 { + return; + } + let width = area.width as usize; + let mut lines = self.question(width, theme); + + // Everything between the question and the answers, if there is any room. + let room = (area.height as usize).saturating_sub(lines.len() + 1); + if room > 0 { + lines.extend(self.preview(room, width, theme)); + } + if area.height >= 2 { + lines.push(self.answers(theme)); + } + frame.render_widget(Paragraph::new(lines), area); + } + + /// `write src/main.rs · rule src/*.rs · layer app`. + /// + /// Act, then target, then the rule, then the layer — content before metadata, + /// the same order the rest of the interface reads in, and the order F2 asserts + /// by position rather than by presence. + fn question(&self, width: usize, theme: &Theme) -> Vec> { + let act = act_word(self.ask.act()); + let asked = Line::from(vec![ + Span::styled(act.to_string(), theme.style(Tone::Warning)), + Span::styled(" ", theme.style(Tone::Normal)), + Span::styled( + // The target is what gets shortened, because it is the only part + // that can be arbitrarily long and the only part whose middle a + // reader can infer. The act cannot be shortened at all. + crate::picker::fit(self.ask.target(), width.saturating_sub(act.len() + 1)), + theme.style(Tone::Normal), + ), + ]); + + // **On its own row, and this cost a test to learn.** Laid out beside the + // target, the rule and the layer are the first thing off the end of an + // eighty-column terminal — and ratatui clips a row rather than complaining, + // so the two facts this release exists to show would vanish silently on the + // supported terminal size. A row of their own is the only layout where they + // cannot be the part that goes. + let why = match (self.ask.rule(), self.ask.layer()) { + (Some(rule), Some(layer)) => format!("rule {rule}{SEPARATOR}layer {layer}"), + (Some(rule), None) => format!("rule {rule}"), + // Said plainly rather than left blank. In io-harness a missing rule + // means the tier default decided — the least vouched-for kind of + // action, not the most — so an empty space here would read as the + // opposite of what happened. + (None, _) => "no rule named it: the tier default decided".to_string(), + }; + vec![ + asked, + Line::from(Span::styled( + crate::picker::fit(&why, width), + theme.style(Tone::Muted), + )), + ] + } + + /// The content a write would leave behind, in the rows that are left. + /// + /// It says what it cut. A reader who thinks they have seen a whole file and + /// has seen a third of it is worse off than one who was told. + fn preview(&self, room: usize, width: usize, theme: &Theme) -> Vec> { + let Some(content) = self.ask.content() else { + return Vec::new(); + }; + let total = content.lines().count(); + let shown = total.min(room); + // The count rides the last shown line rather than taking a row of its own. + // At the tightest size the overlay has exactly one row for content, and a + // whole row spent saying "40 more lines" would show a reader the number of + // lines they are approving and not one of the lines themselves. + let cut = total.saturating_sub(shown); + content + .lines() + .take(shown) + .enumerate() + .map(|(index, line)| { + let suffix = if cut > 0 && index + 1 == shown { + format!(" ⋯ {cut} more lines") + } else { + String::new() + }; + let room = width.saturating_sub(2 + suffix.chars().count()); + Line::from(Span::styled( + format!(" {}{suffix}", crate::picker::fit(line, room)), + theme.style(Tone::Muted), + )) + }) + .collect() + } + + /// `› allow once · allow this session · deny`, on one row. + /// + /// One row rather than a list, because the viewport is four rows and the facts + /// above have first claim on them. The marker is the same `›` every other + /// selection surface uses, so the answer that `Enter` would take is marked by + /// more than a colour. + fn answers(&self, theme: &Theme) -> Line<'static> { + let mut spans = Vec::new(); + for (index, answer) in Answer::ALL.into_iter().enumerate() { + if index > 0 { + spans.push(Span::styled(SEPARATOR, theme.style(Tone::Muted))); + } + let chosen = index == self.chosen; + spans.push(Span::styled( + if chosen { "› " } else { " " }.to_string(), + theme.style(Tone::Accent), + )); + spans.push(Span::styled( + format!("{} {}", answer.key(), answer.label()), + theme.style(if chosen { Tone::Accent } else { Tone::Muted }), + )); + } + Line::from(spans) + } +} + +/// The word for an act. io-harness spells these in its own serde tags and in +/// `EventKind::Refused`; this is the same vocabulary so a refusal and a request +/// read as the same product. +pub fn act_word(act: Act) -> &'static str { + match act { + Act::Read => "read", + Act::Write => "write", + Act::Exec => "run", + Act::Net => "reach", + } +} + +/// The policy a turn runs under, given the session's base policy and everything +/// the operator has allowed for the rest of it. +/// +/// This is io-harness's own recipe, not a second one: a permissive layer named +/// `remembered` carrying the rules, merged onto the base. Merging takes the +/// stricter of the two defaults per act, and a later layer may add capability but +/// can never re-allow something an earlier layer denied — so a remembered allow +/// widens an *asking* default and still cannot defeat a deny beneath it. +/// +/// It exists because `Decision::Approve { remember }` is **run-scoped**: the +/// harness applies it for the rest of the turn and it dies with it. Without this, +/// *allow for the rest of this session* would ask again on the next prompt. io-cli +/// evaluates nothing here — every value is a harness type and every verdict is +/// still the harness's. +pub fn effective_policy(base: &io_harness::Policy, remembered: &[Rule]) -> io_harness::Policy { + if remembered.is_empty() { + return base.clone(); + } + let mut layer = io_harness::Policy::permissive().layer("remembered"); + for rule in remembered { + layer = layer.rule(rule.act, rule.effect, rule.pattern.clone()); + } + base.clone().merge(layer) +} + +/// The whole policy a turn runs under: the file's own, with the operator's chosen +/// posture as its tier defaults, plus everything they have allowed for the session. +/// +/// The posture replaces the *defaults* and nothing else, which is the reason a key +/// on a keyboard is safe here: a layer that denies a secret is not a default, so no +/// posture can unlock what a layer refused. `None` leaves the file's own policy +/// alone — io-harness's configuration can express far more than three postures, and +/// overwriting one nobody chose would be a keystroke rewriting a boundary. +pub fn session_policy( + base: &io_harness::Policy, + posture: Option, + remembered: &[Rule], +) -> io_harness::Policy { + let mut policy = base.clone(); + if let Some(posture) = posture { + policy.defaults = posture.defaults(); + } + effective_policy(&policy, remembered) +} diff --git a/src/commands.rs b/src/commands.rs index 15de71d..ea224b7 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -25,7 +25,15 @@ pub const KEYS: &[(&str, &str)] = &[ "interrupt the turn; twice at an empty prompt, exit", ), ("Ctrl+D", "exit, on an empty prompt"), + ( + "Shift+Tab", + "cycle the permission posture, from the next turn", + ), ("Ctrl+L", "clear the viewport, never the scrollback"), + ( + "y / a / n", + "answer an approval: allow once, allow this session, deny", + ), ("Esc", "close a picker without choosing"), ]; diff --git a/src/events.rs b/src/events.rs index 2c7a2f0..ff6a2e0 100644 --- a/src/events.rs +++ b/src/events.rs @@ -158,26 +158,57 @@ impl Events { rule, layer, } => { - // A plain notice in this release. The surface that names the rule - // and the layer properly is 0.2.0's; what must not happen now is - // pretending to a surface that does not exist, or losing the two - // facts no other core records. + // Act, target, rule, layer — in that order, and the last two are + // the facts no other terminal agent can print, because no other + // core records them. Asserted by position rather than by presence: + // a `contains` assertion is just as green when the sentence is + // inside out. let mut text = format!("{act} {target}"); - if let Some(rule) = rule { - text.push_str(&format!(" — rule {rule}")); - } - if let Some(layer) = layer { - text.push_str(&format!(", layer {layer}")); + match (rule, layer) { + (Some(rule), Some(layer)) => { + text.push_str(&format!("{SEPARATOR}rule {rule}{SEPARATOR}layer {layer}")); + } + (Some(rule), None) => text.push_str(&format!("{SEPARATOR}rule {rule}")), + // Said, not left blank. In io-harness a missing rule means the + // policy's own default for that act decided — the *least* + // vouched-for kind of action rather than the most — so silence + // here would read as the opposite of what happened. + (None, _) => text.push_str(&format!( + "{SEPARATOR}no rule named it: the tier default decided" + )), } let mut lines = self.flush(); lines.push(theme.notice(Tone::Refused, text)); lines } EventKind::ApprovalRequested { act, target } => { + // One line, and deliberately a thin one. The event carries only the + // act and the target; the rule, the layer and the content a write + // proposes arrive on the approver seam instead, and the overlay is + // drawn from those. This is the transcript's note that the run + // stopped, not the question itself — the question must never be + // committed, which is what F1 asserts. + let mut lines = self.flush(); + lines + .push(theme.notice(Tone::Warning, format!("{act} {target} — waiting for you"))); + lines + } + EventKind::ApprovalDecided { + act, + target, + decision, + } => { + // The harness's own record of what it was told, which is not the + // same line as io-cli's. They agree because the answer travelled + // one way; if they ever disagree, this is where it shows. let mut lines = self.flush(); lines.push(theme.notice( - Tone::Warning, - format!("{act} {target} needs approval; this release answers from the configured posture"), + if decision == "deny" { + Tone::Refused + } else { + Tone::Muted + }, + format!("{act} {target}{SEPARATOR}{decision}"), )); lines } @@ -256,19 +287,24 @@ pub fn outcome_tone(outcome: &str) -> Tone { /// and the session had no way to give it. pub fn outcome_help(outcome: &str) -> Option<&'static str> { match outcome { + // Still a dead end, and a different one: a question about *intent*, which + // io-harness deliberately distinguishes from an approval about permission. + // Answering one needs a responder on a caller-supplied contract, which is + // the same entry point that would cost `Ctrl+C`. 0.7.0. "awaiting_answer" => Some( - "the agent asked a question, and this release has no way to answer one. \ - Say the answer in your next prompt, or run `io setup` to choose a \ - posture that does not need approval.", + "the agent asked what you meant, and this release has no way to answer \ + that. Say it in your next prompt.", ), + // A turn that still ends here after 0.2.0 is one whose question was never + // answered — the overlay was dropped when the turn ended, or the run asked + // for something this interface does not bind, such as a plan gate. "awaiting_approval" | "awaiting_plan" => Some( - "the agent asked permission, and the approval surface is not in this \ - release. Run `io setup` and choose the sandboxed-workspace posture, \ - which allows writes and commands inside this repository.", + "the run stopped waiting on a decision it never got. Ask again, or \ + press Shift+Tab to choose a posture that does not need one.", ), "denied" | "refused" => Some( - "the permission boundary stopped it. Run `io setup` to change the \ - posture if that is not what you wanted.", + "the permission boundary stopped it. The line above names the rule and \ + the layer; press Shift+Tab to change the posture for the next turn.", ), _ => None, } diff --git a/src/lib.rs b/src/lib.rs index 0c7285b..520f58d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,6 +11,7 @@ //! [io-harness]: https://docs.rs/io-harness pub mod app; +pub mod approval; pub mod bridge; pub mod cli; pub mod commands; diff --git a/src/main.rs b/src/main.rs index 21ed133..08f38f1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,8 +15,8 @@ use std::time::{Duration, Instant}; use clap::Parser; use crossterm::event::{Event, KeyEventKind}; use io_harness::{ - Anthropic, Compatible, Config, DenyAll, OpenAi, OpenRouter, Policy, Provider, ProviderSpec, - Session, Steer, Store, + Anthropic, Compatible, Config, OpenAi, OpenRouter, Policy, Provider, ProviderSpec, Session, + Steer, Store, }; use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver}; @@ -24,11 +24,11 @@ use io_cli::app::{App, Command}; use io_cli::cli::{Cli, Command as Subcommand}; use io_cli::commands::{self, Action}; use io_cli::picker::{Outcome, Picker, Row}; -use io_cli::settings::{self, CliSettings}; +use io_cli::settings::{self, CliSettings, Posture}; use io_cli::term::Screen; use io_cli::theme::{Theme, Tone}; use io_cli::wizard::{Progress, Wizard}; -use io_cli::{bridge, splash, verify}; +use io_cli::{approval, bridge, splash, verify}; fn main() -> ExitCode { let runtime = match tokio::runtime::Runtime::new() { @@ -235,8 +235,11 @@ async fn loop_over( theme: Theme, model: String, ) -> Result<(), String> { - let approver = DenyAll; let mut app = App::new(theme, model); + // What the file already says, read back rather than assumed. `None` means the + // file holds a policy that is none of the three, which io-harness's own + // configuration can express and this session must not relabel. + app.set_posture(Posture::of(&policy.defaults)); let started = Instant::now(); let mut picker: Option<(Picker, Pick)> = None; @@ -343,6 +346,11 @@ async fn loop_over( } }, Command::Submit(text) => { + // Rebuilt every turn rather than kept, because `remembered` grows + // as the operator answers and the harness's own `remember` dies + // with the turn it was given in. With nothing remembered this is + // the session's policy unchanged. + let effective = approval::session_policy(&policy, app.posture(), app.remembered()); turn( screen, inputs, @@ -350,8 +358,7 @@ async fn loop_over( &provider, &store, &mut session, - &policy, - &approver, + &effective, text, started, ) @@ -374,17 +381,20 @@ async fn turn( store: &Store, session: &mut Session, policy: &Policy, - approver: &DenyAll, text: String, started: Instant, ) -> Result<(), String> { let (steer, inbox) = Steer::channel(); let (observer, mut events) = bridge::channel(); + // The other seam, and the one that can stop the agent. `DenyAll` stood here + // through 0.1.0 and 0.1.1, which is why the *ask before writes* posture + // declined everything it was named for. + let (approver, mut asks) = approval::channel(); app.started(); paint(screen, app)?; let mut running = - Box::pin(session.turn_steered(text, provider, store, policy, approver, &observer, &inbox)); + Box::pin(session.turn_steered(text, provider, store, policy, &approver, &observer, &inbox)); // Lives for the turn and no longer, which is half of why an idle session // never repaints; `App::tick` is the other half and the one a test can see. @@ -407,6 +417,14 @@ async fn turn( app.status.elapsed = started.elapsed(); paint(screen, app)?; } + Some(ask) = asks.recv() => { + // The run is now stopped inside `Approver::decide_in_context` and + // stays there until the overlay answers. The loop keeps turning, + // which is what leaves `Ctrl+C` reachable while a question is up. + app.open_approval(ask); + app.status.elapsed = started.elapsed(); + paint(screen, app)?; + } Some(input) = inputs.recv() => { match input { Event::Key(key) if key.kind == KeyEventKind::Press => { diff --git a/src/picker.rs b/src/picker.rs index 20e8d89..fb8e1c9 100644 --- a/src/picker.rs +++ b/src/picker.rs @@ -199,7 +199,11 @@ impl Picker { } /// Shorten `text` to at most `room` cells, marking that it was shortened. -fn fit(text: &str, room: usize) -> String { +/// +/// Public because the approval overlay fits a path and a line of file content +/// with it. One fitting rule in the product, so a shortened row and a shortened +/// target are shortened the same way and by the same character. +pub fn fit(text: &str, room: usize) -> String { if text.chars().count() <= room { return text.to_string(); } diff --git a/src/settings.rs b/src/settings.rs index a1b3a0e..63bfad7 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -47,16 +47,47 @@ impl Posture { } } + /// The short name the status line uses. Hyphenated rather than spaced, so the + /// field is one token a reader's eye can skip over or stop on. + pub fn short(self) -> &'static str { + match self { + Self::Workspace => "workspace", + Self::AskWrites => "ask-writes", + Self::ReadOnly => "read-only", + } + } + + /// The next posture in the cycle. It wraps, because one key that only ever + /// moves one way is a key you press three times to undo. + pub fn next(self) -> Self { + match self { + Self::Workspace => Self::AskWrites, + Self::AskWrites => Self::ReadOnly, + Self::ReadOnly => Self::Workspace, + } + } + + /// Which posture a set of defaults *is*, if it is one of them. + /// + /// `None` for a configuration file holding a policy nobody offered, which is + /// allowed — io-harness's own file can express far more than three postures. + /// Reporting such a policy as one of the three would put a true-looking word + /// beside a boundary it does not describe. + pub fn of(defaults: &Defaults) -> Option { + Self::ALL + .iter() + .copied() + .find(|posture| &posture.defaults() == defaults) + } + pub fn detail(self) -> &'static str { match self { Self::Workspace => "read, write and run inside this repository; no outbound network", - // Said plainly rather than implied. The overlay that asks a human is - // 0.2.0's; until it exists, "ask" is answered by declining, and a - // posture whose behaviour is not what its name suggests has to say so - // at the moment it is chosen. - Self::AskWrites => { - "read freely; a write or a command is declined until the approval surface lands" - } + // True as of 0.2.0. Through 0.1.0 and 0.1.1 this line had to say that + // a write was *declined* rather than asked about, because the approver + // handed to the harness was `DenyAll` — a posture whose behaviour is + // not what its name suggests has to say so at the moment it is chosen. + Self::AskWrites => "read freely; a write or a command stops and asks you first", Self::ReadOnly => "read only; nothing is written and nothing is run", } } diff --git a/src/status.rs b/src/status.rs index 1194cb2..2d22fdb 100644 --- a/src/status.rs +++ b/src/status.rs @@ -61,8 +61,38 @@ pub struct Status { pub model: String, /// Whether a turn is running. pub working: bool, + /// The permission posture in force, by its short name — or `None` before one + /// is known, which is the wizard's first moments and nothing else. + /// + /// It is a *posture*, which is an `io_harness::Defaults` set, and never a flag + /// of io-cli's own. That is what makes this field an explanation rather than a + /// decoration: the word here is the same thing the agent is actually bounded + /// by, and a refusal can name the rule and the layer underneath it. + pub policy: Option, /// How long the session has been open. pub elapsed: Duration, + /// Tokens this session has spent, accumulated from the steps that reported + /// them. `None` until one does — a session that has spent nothing yet is not + /// a session that has spent zero, and the difference is the whole of F9. + pub tokens: Option, + /// How full the assembled context was the last time io-harness said so, as a + /// share of the budget io-harness itself declares. + /// + /// `None` until a fold reports one, which is the honest answer: `Compacted` is + /// the only event carrying an observation-section size, and between folds + /// nothing on the event stream knows it. + /// + /// ponytail: derived from the last fold. The per-step estimate is durable in + /// the harness store as `ContextEvent::est_tokens`, so a live share is one + /// store read away if this field turns out too quiet to be useful. + pub context: Option, + /// How this run's commands are contained: the mode asked for and the backend + /// that actually answered on this host. + /// + /// Both, always. io-harness's own documentation is explicit that a surface + /// showing the mode alone is reading an intention — `workspace-write` reaching + /// a portable floor means resource caps and nothing else. + pub containment: Option, /// Which frame of the indicator is showing. Advanced by the tick, never by /// the clock: an indicator that read the time would be a second timer. frame: usize, @@ -72,6 +102,10 @@ impl Status { pub fn new(model: impl Into) -> Self { Self { model: model.into(), + policy: None, + tokens: None, + context: None, + containment: None, working: false, elapsed: Duration::ZERO, frame: 0, @@ -104,11 +138,30 @@ impl Status { (true, None) => Field::new("working", Tone::Normal), (false, _) => Field::new("ready", Tone::Muted), }; - vec![ - Field::new(self.model.clone(), Tone::Accent), - state, - Field::new(format_elapsed(self.elapsed), Tone::Muted), - ] + let mut fields = vec![Field::new(self.model.clone(), Tone::Accent)]; + // Second, and the last field to be dropped after the model. What the agent + // is allowed to do outranks how long it has been doing it. + if let Some(policy) = &self.policy { + fields.push(Field::new(format!("policy:{policy}"), Tone::Normal)); + } + fields.push(state); + // Elapsed stays fourth: it is the field 0.1.1 exists for, and the one a + // reader checks to answer "is this alive". Everything this release adds + // goes to the right of it, which is the order they drop in. + fields.push(Field::new(format_elapsed(self.elapsed), Tone::Muted)); + if let Some(tokens) = self.tokens { + fields.push(Field::new( + format!("{} tok", format_tokens(tokens)), + Tone::Muted, + )); + } + if let Some(context) = self.context { + fields.push(Field::new(format!("ctx {context}%"), Tone::Muted)); + } + if let Some(containment) = &self.containment { + fields.push(Field::new(containment.clone(), Tone::Muted)); + } + fields } /// The line, fitted to `width` by dropping whole fields from the right. @@ -170,3 +223,21 @@ pub fn format_elapsed(elapsed: Duration) -> String { _ => format!("{}h{:02}m", seconds / 3600, (seconds % 3600) / 60), } } + +/// `840`, `1.5k`, `12.4k`. A running total is read for its magnitude, and six +/// digits of it are six characters of a line that has to fit in eighty columns. +pub fn format_tokens(tokens: u64) -> String { + if tokens < 1_000 { + return tokens.to_string(); + } + format!("{:.1}k", tokens as f64 / 1_000.0) +} + +/// What a `Contained` event reads as: the mode, then the backend that answered. +/// +/// Never the mode alone. The two disagree often — a `workspace-write` run on a +/// host with no sandbox available reaches the portable floor — and it is the +/// second word that says what is actually enforcing anything. +pub fn format_containment(mode: &str, backend: &str) -> String { + format!("{mode}/{backend}") +} diff --git a/tests/approval.rs b/tests/approval.rs new file mode 100644 index 0000000..f9a0f69 --- /dev/null +++ b/tests/approval.rs @@ -0,0 +1,470 @@ +//! F1, F2 and F4 — the approver seam, and the overlay drawn on it. +//! +//! Every other seam in this product is one-way: an observer is handed an event +//! and hands it on. This one runs on the agent's own task and *blocks it* until a +//! person answers, which makes "nobody answered" a state the run can be left in +//! forever. So the criterion is not that an answer arrives — it is what happens +//! when one never does. +//! +//! There is no clock in this file, and there must not be. A deadlock asserted +//! with a timeout is a test that passes on a fast machine and fails on a loaded +//! one; asserted on a closed channel it is a fact. + +mod support; + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use io_harness::{Act, ApprovalContext, Approver, Decision, Request}; + +use io_cli::app::App; +use io_cli::approval::{self, Approval, Ask}; +use io_cli::theme::DARK; + +fn write() -> Request { + Request::new(Act::Write, "src/main.rs").with_content("fn main() {}\n") +} + +/// A question already delivered to the interface, and the task still waiting on +/// the answer. The waiting task is handed back rather than detached so a test can +/// assert what the run was finally told. +async fn asked( + request: Request, + context: ApprovalContext, +) -> (Ask, tokio::task::JoinHandle) { + let (asker, mut asks) = approval::channel(); + let deciding = tokio::spawn(async move { + let asker = asker; + asker.decide_in_context(&request, &context).await + }); + let ask = asks + .recv() + .await + .expect("the question reached the interface"); + (ask, deciding) +} + +/// The write the overlay tests are drawn from: an act, a target, the rule that +/// flagged it and the layer that rule came from. +fn flagged() -> (Request, ApprovalContext) { + ( + write(), + ApprovalContext::new("tidy the parser") + .flagged_by(Some("src/*.rs".into()), Some("app".into())), + ) +} + +/// The interface is gone entirely — the process is exiting, or the receiver was +/// dropped. The run must be told no, not left waiting on a channel with no other +/// end. +#[tokio::test] +async fn a_missing_interface_denies() { + let (asker, asks) = approval::channel(); + drop(asks); + + let decision = asker.decide(&write()).await; + + assert!( + matches!(decision, Decision::Deny { .. }), + "a dropped receiver must deny, not hang: {decision:?}" + ); +} + +/// The subtler half, and the one a real session hits: the interface is alive, it +/// received the question, and then it went away without answering — a `Ctrl+C` +/// during an overlay, a panic, a resize that unwound. The oneshot closes rather +/// than the mpsc, which is a different failure and the same answer. +#[tokio::test] +async fn an_abandoned_question_denies() { + let (asker, mut asks) = approval::channel(); + + let deciding = tokio::spawn(async move { + let asker = asker; + asker.decide(&write()).await + }); + + let ask: Ask = asks + .recv() + .await + .expect("the question reached the interface"); + drop(ask); + + let decision = deciding.await.expect("the approver did not panic"); + assert!( + matches!(decision, Decision::Deny { .. }), + "an abandoned question must deny, not hang: {decision:?}" + ); +} + +/// The seam works in the ordinary direction too, and the answer is the operator's +/// own rather than one the seam invented on the way through. +#[tokio::test] +async fn an_answer_reaches_the_run() { + let (asker, mut asks) = approval::channel(); + + let deciding = tokio::spawn(async move { + let asker = asker; + asker.decide(&write()).await + }); + + let ask = asks + .recv() + .await + .expect("the question reached the interface"); + ask.answer(Decision::approve()); + + let decision = deciding.await.expect("the approver did not panic"); + assert!( + matches!(decision, Decision::Approve { .. }), + "the operator's answer must be the run's answer: {decision:?}" + ); +} + +/// What the interface is given to render. The rule and the layer do not travel on +/// the event stream — `ApprovalRequested` carries only the act and the target — +/// so if they do not arrive here they cannot be shown at all, which is the whole +/// surface F2 asserts. +#[tokio::test] +async fn the_question_carries_what_the_overlay_has_to_show() { + use io_harness::ApprovalContext; + + let (asker, mut asks) = approval::channel(); + let context = ApprovalContext::new("tidy the parser") + .flagged_by(Some("src/*.rs".into()), Some("app".into())); + + let deciding = tokio::spawn({ + let context = context.clone(); + async move { + let asker = asker; + asker.decide_in_context(&write(), &context).await + } + }); + + let ask = asks + .recv() + .await + .expect("the question reached the interface"); + assert_eq!(ask.act(), Act::Write); + assert_eq!(ask.target(), "src/main.rs"); + assert_eq!(ask.content(), Some("fn main() {}\n")); + assert_eq!(ask.rule(), Some("src/*.rs")); + assert_eq!(ask.layer(), Some("app")); + + ask.answer(Decision::deny("not this time")); + deciding.await.expect("the approver did not panic"); +} + +/// **F1.** A question that has been committed to scrollback is a question that +/// can be scrolled away from a run which is blocked on it. So the overlay lives +/// in the viewport, and the transcript gains nothing at all while it is open. +#[tokio::test] +async fn f1_an_open_approval_commits_nothing_to_scrollback() { + let (request, context) = flagged(); + let (ask, deciding) = asked(request, context).await; + + let mut app = App::new(DARK, "opus-5"); + app.open_approval(ask); + + assert!( + app.take_pending().is_empty(), + "an open approval must not commit a line to the terminal's scrollback", + ); + + let (mut screen, recorder) = support::screen(80, 24); + screen + .draw(|frame| app.render(frame, frame.area())) + .expect("frame"); + assert!( + screen.viewport_text().contains("src/main.rs"), + "the question must be in the viewport: {:?}", + screen.viewport_text(), + ); + + // `insert_before` is how a line reaches scrollback, and it moves the cursor up + // before writing. Nothing of the sort should have been written for a question. + let bytes = recorder.text(); + assert!( + !bytes.contains("src/main.rs\r\n"), + "the question was committed to scrollback: {bytes:?}", + ); + + app.answer_approval(io_cli::approval::Answer::Deny); + deciding.await.expect("the approver did not panic"); +} + +/// **F2.** Asserted on positions rather than on presence. A `contains` assertion +/// passes just as happily when the sentence is inside out, which 0.1.1 paid to +/// learn: a sabotage that moved a token count in front of a decision left the +/// membership assertion green. +#[tokio::test] +async fn f2_the_overlay_states_act_then_target_then_rule_then_layer() { + let (request, context) = flagged(); + let (ask, deciding) = asked(request, context).await; + + let approval = Approval::new(ask); + let (mut screen, _recorder) = support::screen(80, 24); + screen + .draw(|frame| approval.render(frame, frame.area(), &DARK)) + .expect("frame"); + + let text = screen.viewport_text(); + let at = |needle: &str| { + text.find(needle) + .unwrap_or_else(|| panic!("{needle:?} is not on screen: {text:?}")) + }; + + assert!( + at("write") < at("src/main.rs"), + "act before target: {text:?}" + ); + assert!( + at("src/main.rs") < at("src/*.rs"), + "target before the rule: {text:?}", + ); + assert!( + at("src/*.rs") < at("app"), + "rule before the layer: {text:?}" + ); + + approval.answer(approval::Answer::Deny); + deciding.await.expect("the approver did not panic"); +} + +/// The other half of F2, and the one io-harness's own documentation warns about: +/// `rule` and `layer` are `None` when the *tier default* decided, which is the +/// least vouched-for kind of action rather than the most. A surface that renders +/// nothing there tells the reader the opposite of what happened. +#[tokio::test] +async fn f2_an_unnamed_action_says_the_tier_default_decided() { + let (ask, deciding) = asked(write(), ApprovalContext::new("tidy the parser")).await; + + let approval = Approval::new(ask); + let (mut screen, _recorder) = support::screen(80, 24); + screen + .draw(|frame| approval.render(frame, frame.area(), &DARK)) + .expect("frame"); + + let text = screen.viewport_text(); + assert!( + text.contains("tier default"), + "an unnamed action must say so rather than showing a blank: {text:?}", + ); + + approval.answer(approval::Answer::Deny); + deciding.await.expect("the approver did not panic"); +} + +/// The proposed content is what makes an answer an informed one, and it is the +/// only part that can be arbitrarily long. It is fitted into the rows that are +/// left and says how many it did not show. +#[tokio::test] +async fn the_overlay_shows_the_proposed_content_and_says_what_it_cut() { + let long: String = (0..40).map(|n| format!("line {n}\n")).collect(); + let (ask, deciding) = asked( + Request::new(Act::Write, "src/main.rs").with_content(long), + ApprovalContext::new("tidy the parser"), + ) + .await; + + let approval = Approval::new(ask); + let (mut screen, _recorder) = support::screen(80, 24); + screen + .draw(|frame| approval.render(frame, frame.area(), &DARK)) + .expect("frame"); + + let text = screen.viewport_text(); + assert!(text.contains("line 0"), "the content is shown: {text:?}"); + assert!( + text.contains("more lines"), + "what was cut has to be said, or the reader is approving a file they think they read: {text:?}", + ); + + approval.answer(approval::Answer::Deny); + deciding.await.expect("the approver did not panic"); +} + +/// **F3.** Each key is its own answer, the run is told exactly that, and the +/// transcript gains exactly one line saying so — so a decision is in the terminal's +/// own scrollback as well as in the harness's durable trace. +#[tokio::test] +async fn f3_each_answer_reaches_the_run_as_itself_and_commits_one_line() { + for (key, expected) in [ + ('y', approval::Answer::Once), + ('a', approval::Answer::Session), + ('n', approval::Answer::Deny), + ] { + let (request, context) = flagged(); + let (ask, deciding) = asked(request, context).await; + + let mut app = App::new(DARK, "opus-5"); + app.open_approval(ask); + assert!(app.asking(), "the overlay is up before the key"); + + app.key(KeyEvent::new(KeyCode::Char(key), KeyModifiers::NONE)); + assert!(!app.asking(), "answering closes the overlay: {key:?}"); + + let decision = deciding.await.expect("the approver did not panic"); + match (expected, &decision) { + (approval::Answer::Once, Decision::Approve { remember, .. }) => { + assert!( + remember.is_empty(), + "allow ONCE must remember nothing: {remember:?}", + ); + } + (approval::Answer::Session, Decision::Approve { remember, .. }) => { + assert_eq!( + remember.len(), + 1, + "allow this session must hand the run one rule: {remember:?}", + ); + assert_eq!(remember[0].act, Act::Write); + assert_eq!(remember[0].pattern, "src/main.rs"); + } + (approval::Answer::Deny, Decision::Deny { .. }) => {} + (expected, got) => panic!("{key:?} should be {expected:?}, got {got:?}"), + } + + let committed = app.take_pending(); + assert_eq!( + committed.len(), + 1, + "exactly one line per decision, not none and not a paragraph: {committed:?}", + ); + let text: String = committed[0] + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect(); + assert!( + text.contains("write") && text.contains("src/main.rs"), + "the committed line names the act and the target: {text:?}", + ); + } +} + +/// The other way in. A key that only works for a reader who already knows it is +/// not an interface, so the same three answers are reachable by moving and +/// pressing `Enter` — and the overlay opens on the least committal one, so +/// `Enter` by reflex gives away the least rather than the most. +#[tokio::test] +async fn f3_the_answers_are_reachable_without_knowing_the_keys() { + let (request, context) = flagged(); + let (ask, deciding) = asked(request, context).await; + + let mut app = App::new(DARK, "opus-5"); + app.open_approval(ask); + + app.key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); + // Clamped at the end rather than wrapping, like every other list in the + // product: a surface that jumps from deny back to allow on one keypress is + // one where holding an arrow key approves a write. + app.key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + let decision = deciding.await.expect("the approver did not panic"); + assert!( + matches!(decision, Decision::Deny { .. }), + "three rights and Enter is the third answer: {decision:?}", + ); +} + +/// A question takes the keyboard while it is up. The run is stopped, so there is +/// nothing to type at, and a keystroke that reached the composer would be one the +/// operator never sees the effect of. +#[tokio::test] +async fn an_open_question_takes_the_keyboard() { + let (request, context) = flagged(); + let (ask, deciding) = asked(request, context).await; + + let mut app = App::new(DARK, "opus-5"); + app.open_approval(ask); + app.key(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + + assert!( + app.composer.is_empty(), + "a keystroke reached the composer while a run was waiting on an answer", + ); + + app.key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); + deciding.await.expect("the approver did not panic"); +} + +/// **F5.** The harness's own `remember` is run-scoped: it applies for the rest of +/// the turn and dies with it. So the answer is asserted where it has to survive — +/// on the policy the *next* turn is handed — and asserted as a verdict rather than +/// as a label, because a rule that is carried but never consulted is not an +/// answer that was remembered. +#[tokio::test] +async fn f5_allowing_for_the_session_survives_into_the_next_turn() { + use io_harness::{Effect, Policy}; + + let base = Policy::default(); + assert_eq!( + base.check(Act::Write, "src/main.rs").effect, + Effect::Ask, + "the base policy is what makes this question happen at all", + ); + + let (request, context) = flagged(); + let (ask, deciding) = asked(request, context).await; + + let mut app = App::new(DARK, "opus-5"); + app.open_approval(ask); + app.key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)); + deciding.await.expect("the approver did not panic"); + + let next = approval::effective_policy(&base, app.remembered()); + assert_eq!( + next.check(Act::Write, "src/main.rs").effect, + Effect::Allow, + "the next turn must not ask again about what was already allowed", + ); + // Narrow, not blanket. Saying yes to one file is not saying yes to writing. + assert_eq!( + next.check(Act::Write, "src/other.rs").effect, + Effect::Ask, + "a remembered allow must not widen past the target it was given for", + ); +} + +/// The other half, and the reason the merge is io-harness's recipe rather than a +/// second one: a remembered allow may widen an *asking* default and must never +/// defeat a deny beneath it. The secrets layer is the case that matters — an +/// agent that gets `.env` approved once must not have it approved for the session. +#[tokio::test] +async fn a_remembered_allow_cannot_defeat_a_deny() { + use io_harness::{Effect, Policy, Rule}; + + let base = Policy::default(); + assert_eq!( + base.check(Act::Write, ".env").effect, + Effect::Deny, + "the harness's builtin-secrets layer is what this is asserting against", + ); + + let next = approval::effective_policy( + &base, + &[Rule { + act: Act::Write, + effect: Effect::Allow, + pattern: ".env".into(), + }], + ); + assert_eq!( + next.check(Act::Write, ".env").effect, + Effect::Deny, + "a session allow must not be able to unlock a denied target", + ); +} + +/// With nothing remembered the next turn runs under exactly the policy it would +/// have anyway. A release that quietly rebuilt the policy every turn would be one +/// where a `merge` bug is invisible until somebody answers a question. +#[tokio::test] +async fn nothing_remembered_changes_nothing() { + use io_harness::Policy; + + let base = Policy::default(); + assert_eq!(approval::effective_policy(&base, &[]), base); +} diff --git a/tests/commands.rs b/tests/commands.rs index 50aab67..45c6ee0 100644 --- a/tests/commands.rs +++ b/tests/commands.rs @@ -86,12 +86,14 @@ fn the_key_table_covers_every_key_this_release_binds() { "Ctrl+D", "Ctrl+L", "Esc", + "Shift+Tab", + "y / a / n", ] { assert!(documented.contains(&key), "{key} is bound but undocumented"); } assert_eq!( documented.len(), - 7, + 9, "a key was added to the table without being added to this list, or the \ other way round", ); diff --git a/tests/events.rs b/tests/events.rs index 4e6fc03..77be9af 100644 --- a/tests/events.rs +++ b/tests/events.rs @@ -369,13 +369,20 @@ fn a_turn_that_ends_waiting_for_a_human_says_what_to_do_about_it() { // `awaiting_answer` — a state this release has nothing on screen to resolve. // Nothing went wrong, so it is a warning rather than an error; but an outcome // the operator cannot act on has to come with a next action. + // + // 0.2.0 changes what the way out *is* — an approval is now answered on screen, + // so the help no longer sends everyone to `io setup` — but not that there has + // to be one. The assertion is therefore that each help names something the + // operator can actually do next, rather than one particular sentence. for outcome in ["awaiting_answer", "awaiting_approval", "awaiting_plan"] { assert_eq!(outcome_tone(outcome), Tone::Warning, "{outcome}"); let help = outcome_help(outcome) .unwrap_or_else(|| panic!("{outcome} leaves the operator with no next action")); assert!( - help.contains("io setup"), - "{outcome} should name the way out: {help}", + ["io setup", "Shift+Tab", "next prompt"] + .iter() + .any(|way| help.contains(way)), + "{outcome} should name something the operator can do: {help}", ); } @@ -403,7 +410,7 @@ fn the_awaiting_help_reaches_the_transcript() { assert!(line.contains("awaiting_answer"), "{line:?}"); assert!(line.contains("warning"), "{line:?}"); assert!( - line.contains("io setup"), + line.contains("next prompt"), "the way out should be in the transcript, not only in the docs: {line:?}", ); assert!(!line.contains("error"), "nothing went wrong: {line:?}"); @@ -474,3 +481,84 @@ fn f8_this_release_has_seen_every_kind_io_harness_emits() { "these names are no longer io-harness event kinds: {gone:?}", ); } + +/// **F8.** The two facts no other core records, in the order a reader needs them, +/// asserted by position. A `contains` assertion is green when the sentence is +/// inside out — which 0.1.1 paid to learn. +#[test] +fn f8_a_refusal_names_act_then_target_then_rule_then_layer() { + let mut events = Events::new(DARK); + let refused = rendered( + &mut events, + EventKind::Refused { + act: "write".into(), + target: "/etc/hosts".into(), + rule: Some("fs.deny".into()), + layer: Some("ops-baseline".into()), + }, + ); + + let at = |needle: &str| { + refused + .find(needle) + .unwrap_or_else(|| panic!("{needle:?} is not in the line: {refused:?}")) + }; + assert!(at("write") < at("/etc/hosts"), "act first: {refused:?}"); + assert!( + at("/etc/hosts") < at("fs.deny"), + "the target before the rule: {refused:?}", + ); + assert!( + at("fs.deny") < at("ops-baseline"), + "the rule before the layer it came from: {refused:?}", + ); + assert!( + refused.contains("refused"), + "colour is never the only carrier of a meaning: {refused:?}", + ); +} + +/// **F8, the other half.** In io-harness a missing rule means the *tier default* +/// decided — the least vouched-for kind of action, not the most. A line that +/// renders nothing there tells the reader the opposite of what happened. +#[test] +fn f8_a_refusal_with_no_rule_says_the_tier_default_decided() { + let mut events = Events::new(DARK); + let refused = rendered( + &mut events, + EventKind::Refused { + act: "net".into(), + target: "api.example.com:443".into(), + rule: None, + layer: None, + }, + ); + + assert!(refused.contains("api.example.com:443"), "{refused:?}"); + assert!( + refused.contains("tier default"), + "an unnamed refusal must say what refused it: {refused:?}", + ); +} + +/// A rule with no layer is possible and is not the same as no rule at all. It is +/// rendered as what it is rather than being rounded to either neighbour. +#[test] +fn f8_a_rule_without_a_layer_is_neither_of_the_other_two_cases() { + let mut events = Events::new(DARK); + let refused = rendered( + &mut events, + EventKind::Refused { + act: "exec".into(), + target: "curl".into(), + rule: Some("*.sh".into()), + layer: None, + }, + ); + + assert!(refused.contains("*.sh"), "{refused:?}"); + assert!( + !refused.contains("tier default"), + "a named rule did decide this one: {refused:?}", + ); +} diff --git a/tests/live.rs b/tests/live.rs index 73269cf..b4311d3 100644 --- a/tests/live.rs +++ b/tests/live.rs @@ -203,3 +203,279 @@ async fn live_f1_a_real_turn_streams_and_edits_a_file() { "the file was not edited: {after:?}", ); } + +/// The live rehearsal for **F10**, and it does not satisfy it. +/// +/// F10 is a person at a real terminal watching a run stop to ask, and saying +/// whether that moment reads as clear or as alarming. What this proves is +/// everything about it a terminal is not needed for, so that the manual run is +/// not the first time any of it has been exercised: that a real model, working on +/// a real file under the *ask before writes* posture, actually reaches the +/// approver at all; that the question carries the act, the target, the rule and +/// the layer the overlay draws from; and that answering it lets the run go on to +/// finish the edit. +/// +/// It is the difference between the owner's one run failing because the overlay +/// reads badly — which is the answer F10 is for — and failing because nothing +/// appeared, which would be a waste of their time. +#[tokio::test] +#[ignore = "live: needs OPENROUTER_API_KEY"] +async fn live_f10_a_real_turn_stops_and_asks_before_it_writes() { + use io_cli::approval::{self, Answer, Approval}; + + let key = key(); + let dir = tempfile::tempdir().expect("a workspace"); + let root = dir.path(); + std::fs::write(root.join("greeting.txt"), "hello\n").expect("the fixture file"); + + let store = Store::open(root.join("runs.db")).expect("a store"); + let mut session = Session::open(&store, root).expect("a session"); + let provider = io_harness::OpenRouter::new(&key, model()); + + // The posture this release exists for, and the one that declined everything + // through 0.1.0 and 0.1.1. + let policy = Policy { + layers: Policy::default().layers, + defaults: Posture::AskWrites.defaults(), + }; + + let (approver, mut asks) = approval::channel(); + let (_steer, inbox) = Steer::channel(); + let collected = Arc::new(Mutex::new(Vec::new())); + let observer = Collector { + events: Arc::clone(&collected), + }; + + // The interface's half, driven by a task rather than by a keyboard. Every + // question is rendered the way the overlay renders it and then answered + // `allow once`, which is what the owner will do with `y`. + let seen = Arc::new(Mutex::new(Vec::new())); + let answering = tokio::spawn({ + let seen = Arc::clone(&seen); + async move { + while let Some(ask) = asks.recv().await { + let approval = Approval::new(ask); + let question = approval.ask(); + let note = format!( + "{} {} — rule {:?}, layer {:?}, {} bytes of content", + io_cli::approval::act_word(question.act()), + question.target(), + question.rule(), + question.layer(), + question.content().map(str::len).unwrap_or(0), + ); + println!("the overlay would have asked: {note}"); + seen.lock().expect("not poisoned").push(note); + approval.answer(Answer::Once); + } + } + }); + + let result = session + .turn_steered( + "Edit greeting.txt so that it contains exactly the word: goodbye. \ + Then tell me in one sentence what you changed.", + &provider, + &store, + &policy, + &approver, + &observer, + &inbox, + ) + .await + .expect("the turn runs"); + + println!("outcome: {:?}", result.outcome); + drop(approver); + answering.await.expect("the answering task did not panic"); + + let asked = seen.lock().expect("not poisoned").clone(); + assert!( + !asked.is_empty(), + "under ask-before-writes a run that edits a file must reach the approver; \ + nothing did, which means the overlay would never have opened", + ); + + // And the answer has to have been acted on. A question that is asked and then + // ignored looks identical on screen to one that worked. + let after = std::fs::read_to_string(root.join("greeting.txt")).expect("the file survives"); + println!("greeting.txt is now {after:?}"); + assert!( + after.to_lowercase().contains("goodbye"), + "the write was approved and did not happen: {after:?}", + ); + + // The harness records the decision as well, which is the half of `one line per + // decision` that lives outside this process. + let events = collected.lock().expect("not poisoned").clone(); + assert!( + events + .iter() + .any(|event| matches!(event.kind, EventKind::ApprovalRequested { .. })), + "the run should have emitted ApprovalRequested for the transcript", + ); +} + +/// The other direction, and the one that is easy to get wrong: a denial must stop +/// the write and leave the file alone. An approver that is consulted but whose +/// `no` is not enforced is worse than no approver at all. +#[tokio::test] +#[ignore = "live: needs OPENROUTER_API_KEY"] +async fn live_f10_a_denial_leaves_the_file_alone() { + use io_cli::approval::{self, Answer, Approval}; + + let key = key(); + let dir = tempfile::tempdir().expect("a workspace"); + let root = dir.path(); + std::fs::write(root.join("greeting.txt"), "hello\n").expect("the fixture file"); + + let store = Store::open(root.join("runs.db")).expect("a store"); + let mut session = Session::open(&store, root).expect("a session"); + let provider = io_harness::OpenRouter::new(&key, model()); + let policy = Policy { + layers: Policy::default().layers, + defaults: Posture::AskWrites.defaults(), + }; + + let (approver, mut asks) = approval::channel(); + let (_steer, inbox) = Steer::channel(); + let collected = Arc::new(Mutex::new(Vec::new())); + let observer = Collector { + events: Arc::clone(&collected), + }; + + let answering = tokio::spawn(async move { + let mut denied = 0usize; + while let Some(ask) = asks.recv().await { + Approval::new(ask).answer(Answer::Deny); + denied += 1; + } + denied + }); + + let result = session + .turn_steered( + "Edit greeting.txt so that it contains exactly the word: goodbye. \ + Then tell me in one sentence what you changed.", + &provider, + &store, + &policy, + &approver, + &observer, + &inbox, + ) + .await + .expect("the turn runs"); + + println!("outcome: {:?}", result.outcome); + drop(approver); + let denied = answering.await.expect("the answering task did not panic"); + println!("{denied} question(s) denied"); + + let after = std::fs::read_to_string(root.join("greeting.txt")).expect("the file survives"); + println!("greeting.txt is still {after:?}"); + assert_eq!( + after, "hello\n", + "a denied write happened anyway, which is the worst failure this surface has", + ); + + let events = collected.lock().expect("not poisoned").clone(); + let mut renderer = Events::new(DARK); + let transcript: String = events + .iter() + .flat_map(|event| renderer.event(event)) + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect::>() + .join("\n"); + println!("--- as the interface would have rendered it ---\n{transcript}\n---"); +} + +/// What *allow for the rest of this session* does inside the turn it is given in. +/// +/// F5 asserts the half that outlives the turn — the policy the next turn is handed +/// — because that is the half io-cli owns. This asserts the other half, which +/// io-harness owns: `Decision::Approve { remember }` installs a run-scoped layer, +/// so a second attempt at the same target should not ask again. +/// +/// It matters because an agent does not write a file once. It tries `write_file`, +/// then `edit_file`, then `patch_file`, and under *allow once* each of those is +/// its own question. If `a` did not collapse them, the answer would be a button +/// that does nothing visible and the operator would learn to stop pressing it. +#[tokio::test] +#[ignore = "live: needs OPENROUTER_API_KEY"] +async fn live_allowing_for_the_session_stops_the_reasking_inside_one_turn() { + use io_cli::approval::{self, Answer, Approval}; + + let key = key(); + let dir = tempfile::tempdir().expect("a workspace"); + let root = dir.path(); + std::fs::write(root.join("greeting.txt"), "hello\n").expect("the fixture file"); + + let store = Store::open(root.join("runs.db")).expect("a store"); + let mut session = Session::open(&store, root).expect("a session"); + let provider = io_harness::OpenRouter::new(&key, model()); + let policy = Policy { + layers: Policy::default().layers, + defaults: Posture::AskWrites.defaults(), + }; + + let (approver, mut asks) = approval::channel(); + let (_steer, inbox) = Steer::channel(); + let collected = Arc::new(Mutex::new(Vec::new())); + let observer = Collector { + events: Arc::clone(&collected), + }; + + let answering = tokio::spawn(async move { + let mut targets = Vec::new(); + while let Some(ask) = asks.recv().await { + let approval = Approval::new(ask); + targets.push(approval.ask().target().to_string()); + println!("asked about {}", approval.ask().target()); + approval.answer(Answer::Session); + } + targets + }); + + let result = session + .turn_steered( + "Edit greeting.txt so that it contains exactly the word: goodbye. \ + Then tell me in one sentence what you changed.", + &provider, + &store, + &policy, + &approver, + &observer, + &inbox, + ) + .await + .expect("the turn runs"); + + println!("outcome: {:?}", result.outcome); + drop(approver); + let targets = answering.await.expect("the answering task did not panic"); + println!("{} question(s): {targets:?}", targets.len()); + + let after = std::fs::read_to_string(root.join("greeting.txt")).expect("the file survives"); + assert!( + after.to_lowercase().contains("goodbye"), + "the write was allowed and did not happen: {after:?}", + ); + + let repeats = targets + .iter() + .filter(|target| *target == &targets[0]) + .count(); + assert_eq!( + repeats, 1, + "answering `allow this session` did not stop the re-asking for {:?} — it was \ + asked {repeats} times in one turn, so the answer is a key that does nothing \ + a reader can see: {targets:?}", + targets[0], + ); +} diff --git a/tests/narrow.rs b/tests/narrow.rs index 1f19107..a815b01 100644 --- a/tests/narrow.rs +++ b/tests/narrow.rs @@ -81,3 +81,65 @@ fn f9_multibyte_text_is_not_split_across_a_wrap() { ); } } + +/// **N5.** The approval overlay at eighty columns, with a long path and content +/// wider than the terminal. It is the widest thing this product draws, and it is +/// the one surface where a line that overflows costs somebody a decision they did +/// not mean to make. +#[tokio::test] +async fn n5_the_approval_overlay_fits_eighty_columns() { + use io_cli::approval::{self, Approval}; + use io_cli::theme::DARK; + use io_harness::{Act, ApprovalContext, Approver, Decision, Request}; + + let target = "crates/some-rather-long-crate-name/src/subsystem/module/implementation.rs"; + let content = format!("{}\n{}\n", "x".repeat(200), "y".repeat(200)); + let (asker, mut asks) = approval::channel(); + let deciding = tokio::spawn(async move { + let asker = asker; + asker + .decide_in_context( + &Request::new(Act::Write, target).with_content(content), + &ApprovalContext::new("tidy the parser").flagged_by( + Some("crates/**/src/**/*.rs".into()), + Some("ops-baseline".into()), + ), + ) + .await + }); + let ask = asks.recv().await.expect("the question arrived"); + + let approval = Approval::new(ask); + let (mut screen, _recorder) = support::screen(WIDTH, HEIGHT); + screen + .draw(|frame| approval.render(frame, frame.area(), &DARK)) + .expect("frame"); + + let viewport = screen.viewport_text(); + for line in viewport.lines() { + assert!( + line.chars().count() <= WIDTH as usize, + "the overlay overflowed eighty columns: {line:?}", + ); + } + // The answers are what the overlay exists for. A layout that fits by pushing + // them off the bottom has fitted nothing. + assert!( + viewport.contains("allow once") && viewport.contains("deny"), + "the answers must survive a narrow terminal: {viewport:?}", + ); + // And so must the act and the target, in some form. + assert!(viewport.contains("write"), "{viewport:?}"); + assert!(viewport.contains('…'), "something was fitted: {viewport:?}"); + // The two facts no other core records must survive the narrow form. A widget + // that "fits" by letting ratatui clip the row has not fitted anything — it has + // silently cut the half of the sentence this release exists to show. + assert!( + viewport.contains("ops-baseline"), + "the layer was cut at eighty columns: {viewport:?}", + ); + + approval.answer(approval::Answer::Deny); + let decision = deciding.await.expect("the approver did not panic"); + assert!(matches!(decision, Decision::Deny { .. })); +} diff --git a/tests/policy.rs b/tests/policy.rs new file mode 100644 index 0000000..a77e97f --- /dev/null +++ b/tests/policy.rs @@ -0,0 +1,178 @@ +//! F6 and F7 — the boundary the operator chooses, and the one the agent gets. +//! +//! Two criteria and they are deliberately not the same one. F6 is what the status +//! line says; F7 is what the next turn actually runs under. A mode indicator that +//! is not backed by the policy it names is invisible to every assertion on a +//! rendered line, which is why the second criterion asserts on an +//! `io_harness::Policy` and never on a word. + +mod support; + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use io_harness::{Act, Defaults, Effect, Policy}; + +use io_cli::app::App; +use io_cli::approval; +use io_cli::settings::Posture; +use io_cli::theme::DARK; + +fn shift_tab() -> KeyEvent { + // What a terminal without the Kitty keyboard protocol sends. It is the + // spelling most terminals in use produce, and it carries no modifier at all. + KeyEvent::new(KeyCode::BackTab, KeyModifiers::NONE) +} + +fn shifted_tab() -> KeyEvent { + // What a terminal that has negotiated the Kitty protocol sends instead. Same + // key on the same keyboard; a product that binds one spelling works on the + // developer's terminal and silently does nothing on somebody else's. + KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT) +} + +fn line(app: &App) -> String { + app.status.line(120, &DARK).to_string() +} + +/// **F6.** The key moves the posture, and the line says which one is in force. +#[test] +fn f6_shift_tab_cycles_the_posture_and_the_status_line_follows() { + let mut app = App::new(DARK, "opus-5"); + app.set_posture(Some(Posture::Workspace)); + assert!( + line(&app).contains("policy:workspace"), + "the line must name the posture in force: {:?}", + line(&app), + ); + + app.key(shift_tab()); + assert_eq!(app.posture(), Some(Posture::AskWrites)); + assert!( + line(&app).contains("policy:ask-writes"), + "the line did not follow the key: {:?}", + line(&app), + ); + + app.key(shift_tab()); + assert_eq!(app.posture(), Some(Posture::ReadOnly)); + app.key(shift_tab()); + assert_eq!( + app.posture(), + Some(Posture::Workspace), + "the cycle wraps — it is a cycle, not a list", + ); +} + +/// **F6, the half that decides whether the key exists at all.** Both spellings +/// reach the same action. +#[test] +fn f6_both_spellings_of_shift_tab_are_the_same_key() { + let mut app = App::new(DARK, "opus-5"); + app.set_posture(Some(Posture::Workspace)); + app.key(shifted_tab()); + assert_eq!( + app.posture(), + Some(Posture::AskWrites), + "a terminal speaking the Kitty protocol sends Tab with a shift modifier", + ); + + app.key(shift_tab()); + assert_eq!( + app.posture(), + Some(Posture::ReadOnly), + "a terminal without it sends BackTab, and it is the same key on the keyboard", + ); +} + +/// A configuration file may hold a policy that is none of the three postures. The +/// line says so rather than naming one it is not, and the first press moves to a +/// posture the operator did choose. +#[test] +fn a_policy_that_is_not_one_of_the_three_says_so() { + let mut app = App::new(DARK, "opus-5"); + app.set_posture(None); + assert!( + line(&app).contains("policy:custom"), + "an unrecognised policy must not be labelled as one of the three: {:?}", + line(&app), + ); + app.key(shift_tab()); + assert_eq!(app.posture(), Some(Posture::Workspace)); +} + +/// **F7.** Asserted on the policy, and as a verdict rather than as a field: the +/// question is what the agent may do, not what the struct says. +#[test] +fn f7_the_cycled_posture_is_what_the_next_turn_runs_under() { + let base = Policy::default(); + let mut app = App::new(DARK, "opus-5"); + app.set_posture(Some(Posture::Workspace)); + + let policy = approval::session_policy(&base, app.posture(), app.remembered()); + assert_eq!( + policy.check(Act::Write, "src/main.rs").effect, + Effect::Allow, + "the workspace posture writes inside the workspace without asking", + ); + + app.key(shift_tab()); + let policy = approval::session_policy(&base, app.posture(), app.remembered()); + assert_eq!( + policy.check(Act::Write, "src/main.rs").effect, + Effect::Ask, + "ask-before-writes has to actually ask", + ); + + app.key(shift_tab()); + let policy = approval::session_policy(&base, app.posture(), app.remembered()); + assert_eq!( + policy.check(Act::Write, "src/main.rs").effect, + Effect::Deny, + "read-only has to actually refuse", + ); +} + +/// Cycling never unlocks what the file's own layers denied. The posture is the +/// tier default; a layer that denies a secret is not a default and is not moved +/// by a keystroke. +#[test] +fn no_posture_can_unlock_what_a_layer_denied() { + let base = Policy::default(); + for posture in Posture::ALL { + let policy = approval::session_policy(&base, Some(*posture), &[]); + assert_eq!( + policy.check(Act::Write, ".env").effect, + Effect::Deny, + "{:?} unlocked a target the secrets layer denies", + posture, + ); + } +} + +/// With no posture chosen the policy is the file's own, untouched. A release that +/// rebuilt it regardless would be one where a mapping bug is invisible until +/// somebody presses a key. +#[test] +fn no_posture_means_the_file_decides() { + let base = Policy::default(); + assert_eq!(approval::session_policy(&base, None, &[]), base); +} + +/// The three postures are the three `io_harness::Defaults` sets `settings.rs` +/// already declares, and the mapping back is exact. A posture recognised as one it +/// is not would put a true-looking word beside a boundary it does not describe. +#[test] +fn a_posture_is_recognised_from_the_defaults_it_is() { + for posture in Posture::ALL { + assert_eq!(Posture::of(&posture.defaults()), Some(*posture)); + } + assert_eq!( + Posture::of(&Defaults { + read: Effect::Deny, + write: Effect::Deny, + exec: Effect::Deny, + net: Effect::Deny, + }), + None, + "a policy nobody offered is not silently reported as one that was", + ); +} diff --git a/tests/status.rs b/tests/status.rs index 08bbddd..2aee696 100644 --- a/tests/status.rs +++ b/tests/status.rs @@ -5,8 +5,28 @@ mod support; use std::time::Duration; +use io_cli::app::App; use io_cli::status::{format_elapsed, Status}; use io_cli::theme::{DARK, PLAIN}; +use io_harness::{EventKind, RunEvent}; + +/// A run event at step zero, which is where everything but a step sits. +fn event(kind: EventKind) -> RunEvent { + RunEvent::new(1, 0, kind) +} + +fn step(number: u32, tokens: u64) -> RunEvent { + RunEvent::new( + 1, + number, + EventKind::Step { + decision: "edited src/lib.rs".into(), + tool_call: "apply_patch".into(), + tokens, + changed: true, + }, + ) +} fn rendered(status: &Status, width: u16) -> String { status @@ -171,3 +191,121 @@ fn elapsed_time_is_readable_at_every_scale() { assert_eq!(format_elapsed(Duration::from_secs(3600)), "1h00m"); assert_eq!(format_elapsed(Duration::from_secs(3725)), "1h02m"); } + +/// **F9.** A field with nothing behind it is absent. Not a zero, not a dash, not a +/// placeholder — and the field this matters most for is the one about spending. +#[test] +fn f9_a_field_with_nothing_behind_it_is_absent_rather_than_zero() { + let status = Status::new("opus-5"); + let line = status.line(120, &DARK).to_string(); + + assert!( + !line.contains("tok"), + "no step has reported a token count yet: {line:?}", + ); + assert!( + !line.contains("ctx"), + "nothing has said how full the context is: {line:?}", + ); + // Deliberately not "the line contains no zero": the elapsed field is `0s` and + // is legitimately zero, because the session really has been open no time at + // all. The criterion is about a field with no *fact* behind it. + assert!( + !line.contains("0 tok") && !line.contains("ctx 0"), + "an unknown value must not be rendered as a zero: {line:?}", + ); + // And nothing has said how this run's commands are contained, which is a + // different statement from saying they are not. + assert!( + !line.contains("sandbox"), + "containment is unknown until the run says so: {line:?}", + ); +} + +/// Tokens accumulate across the steps of a session rather than showing the last +/// step's own count, which would swing rather than climb. +#[test] +fn the_token_field_is_the_session_and_not_the_last_step() { + let mut app = App::new(DARK, "opus-5"); + app.event(&step(1, 1_200)); + app.event(&step(2, 300)); + + let line = app.status.line(120, &DARK).to_string(); + assert!( + line.contains("1.5k tok"), + "the field is the running total: {line:?}", + ); +} + +/// **F9, containment.** The mode is what was asked for and the backend is what +/// answered on this host, and io-harness's own documentation says a surface +/// showing the first without the second is reading an intention rather than a +/// fact: `workspace-write` on a portable floor means resource caps only. +#[test] +fn the_containment_field_carries_the_backend_and_not_only_the_mode() { + let mut app = App::new(DARK, "opus-5"); + app.event(&event(EventKind::Contained { + mode: "workspace-write".into(), + backend: "portable-floor".into(), + roots: 0, + })); + + let line = app.status.line(120, &DARK).to_string(); + assert!(line.contains("workspace-write"), "{line:?}"); + assert!( + line.contains("portable-floor"), + "the mode without the backend is an intention, not a fact: {line:?}", + ); +} + +/// Context pressure appears once something has said what it is, and says it as a +/// share of the budget io-harness itself declares rather than of a number copied +/// into this repository. +#[test] +fn the_context_field_appears_when_a_fold_reports_one() { + let mut app = App::new(DARK, "opus-5"); + assert!(!app.status.line(120, &DARK).to_string().contains("ctx")); + + app.event(&event(EventKind::Compacted { + through_step: 4, + before_tokens: 11_000, + after_tokens: 6_000, + })); + + let line = app.status.line(120, &DARK).to_string(); + assert!( + line.contains("ctx "), + "a fold is the harness telling us how full it was: {line:?}", + ); + assert!(line.contains('%'), "{line:?}"); +} + +/// N5's half of this task: the new fields drop from the right, and the line never +/// becomes two lines. A status line that wraps has taken a row from the transcript +/// and stopped being a status line. +#[test] +fn the_new_fields_drop_from_the_right_rather_than_wrapping() { + let mut app = App::new(DARK, "opus-5"); + app.set_posture(Some(io_cli::settings::Posture::Workspace)); + app.event(&step(1, 12_400)); + app.event(&event(EventKind::Contained { + mode: "workspace-write".into(), + backend: "seatbelt".into(), + roots: 2, + })); + + let wide = app.status.line(160, &DARK).to_string(); + assert!(wide.contains("seatbelt"), "{wide:?}"); + + let narrow = app.status.line(40, &DARK).to_string(); + assert!(narrow.chars().count() <= 40, "{narrow:?}"); + assert!(!narrow.contains('\n'), "the line wrapped: {narrow:?}"); + assert!( + narrow.contains("opus-5"), + "the model is the last field to go: {narrow:?}", + ); + assert!( + !narrow.contains("seatbelt"), + "the rightmost fields are the ones that drop: {narrow:?}", + ); +}