diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3b2ed82..9a4903a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,12 +60,12 @@ jobs: os: [ubuntu-latest, macos-latest, windows-latest] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # v1 with: toolchain: stable # io-harness pulls a bundled rusqlite, which is the expensive part of a # cold build here. - - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - run: cargo build --all-targets - run: cargo test --all-targets # `cargo test --all-targets` does not run doctests. This crate has few, and @@ -78,11 +78,11 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # v1 with: toolchain: stable components: rustfmt, clippy - - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - run: cargo fmt --all -- --check - run: cargo clippy --all-targets -- -D warnings @@ -95,8 +95,8 @@ jobs: # here, so the two can never drift apart. - id: msrv run: echo "version=$(cargo metadata --no-deps --format-version 1 | python3 -c 'import json,sys; print(json.load(sys.stdin)["packages"][0]["rust_version"])')" >> "$GITHUB_OUTPUT" - - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # v1 with: toolchain: ${{ steps.msrv.outputs.version }} - - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - run: cargo build --all-targets diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 57272a4..3b0aaca 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -86,11 +86,11 @@ jobs: archive: zip steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # v1 with: toolchain: stable targets: ${{ matrix.target }} - - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: key: ${{ matrix.target }} diff --git a/.ultraship/products/io-cli/execution/archive/0.1.1.yaml b/.ultraship/products/io-cli/execution/archive/0.1.1.yaml new file mode 100644 index 0000000..77a7ba3 --- /dev/null +++ b/.ultraship/products/io-cli/execution/archive/0.1.1.yaml @@ -0,0 +1,30 @@ +product: io-cli +version: 0.1.1 +execution_state: DEVELOPING +checkpoint: null +blockers: [] +release_fit: + assessment: high + reasons: + - "The workspace records `preferences.minimize_cost: true` and `allow_parallel_agents: false`, + so this is a sequential build with no fan-out. Five small tasks against an existing suite fit + that limit comfortably: the release adds one seam, one field and one line format, and every + one of them is asserted in the harnesses 0.1.0 already built." + - 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 step rendering and then the spinner if it does. + - "The workspace constraint that io-cli contains no agent loop, no provider client and no + session store is not touched by this release. Everything here is rendering: a timer, a field + and a line order. No io-harness change is required and none is anticipated." + - The code this release changes is four files totalling under a thousand lines — `src/app.rs`, + `src/status.rs`, `src/events.rs` and the driver's select loop — and each already has a test + file pointed at it. + major_cost_drivers: + - The clock seam. The work is not the timer; it is making the tick assertable without asserting + on time, which is what keeps N1 true. Getting that seam wrong is the way this release grows a + sleeping test. + - Not repainting when idle. The easy implementation ticks always, and F2 exists because that is + the version that would ship by accident. + - "F3, which cannot be automated: a real turn against a live provider, watched by the owner, and + a screenshot recaptured from it." + recommended_scope_change: null diff --git a/.ultraship/products/io-cli/execution/tasks.yaml b/.ultraship/products/io-cli/execution/tasks.yaml index 3113449..47bb48d 100644 --- a/.ultraship/products/io-cli/execution/tasks.yaml +++ b/.ultraship/products/io-cli/execution/tasks.yaml @@ -1,908 +1,219 @@ product: io-cli -version: 0.1.0 +version: 0.1.1 tasks: - - id: US-IO-CLI-0.1.0-T01 - summary: Create the repository skeleton and the one-crate manifest for the `io` binary. - why_required: "Nothing else in the release can be written, built or tested without a crate. The - manifest is also where N1 is decided: the dependency set that this release is allowed to have - is declared here once, and every later task is measured against it." + - 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." status: done depends_on: [] acceptance_criteria: - > - **N1 — io-cli contains no second harness.** `cargo tree --depth 1` is recorded in the - release evidence and reviewed: `io-harness`, `ratatui`, `crossterm`, `tui-textarea`, `clap`, - `tokio`, `serde`/`toml`, and nothing that constitutes a runtime, a client or a store. Any - addition beyond that list is argued in the release record or it does not ship. - files: - - Cargo.toml - - Cargo.lock - - LICENSE - - NOTICE - - README.md - - CHANGELOG.md - - CONTRIBUTING.md - - CODE_OF_CONDUCT.md - - SECURITY.md - - .gitignore - - .github/CODEOWNERS - - .github/dependabot.yml - - .github/PULL_REQUEST_TEMPLATE.md - - .github/ISSUE_TEMPLATE/bug_report.md - - .github/ISSUE_TEMPLATE/feature_request.md - - src/main.rs - - src/cli.rs - - src/lib.rs - evidence: - - cargo build exits 0 against a lockfile of 274 packages, resolved 2026-08-16. - - "The direct dependency set is exactly the eight N1 names: io-harness 0.60.1, ratatui 0.29, - crossterm 0.28, tui-textarea 0.7, clap 4, tokio 1, serde 1, toml 1, plus tempfile as a - dev-dependency. Nothing that is a runtime, a client or a store." - - The manifest carries publish = false, so the crates.io non-goal is mechanical rather than - remembered. - - "rust-version is 1.95, copied from io-harness rather than guessed: libsqlite3-sys calls the - std cfg_select! macro from its build script and neither it nor rusqlite declares the floor." - - id: US-IO-CLI-0.1.0-T02 - summary: "Add `ci.yml`: build, test, clippy -D warnings and fmt --check on macOS, Linux and Windows." - why_required: O1 requires a green run on three platforms on the feature branch and again on the - merge. Without the workflow there is no run to be green, and Windows — where this line of - repositories historically finds what the other two hide — is never exercised. - status: done - depends_on: - - US-IO-CLI-0.1.0-T01 - acceptance_criteria: - - > - **O1 — CI green on macOS, Linux and Windows**, on the feature branch and again on the merge - to `develop`. Windows is not optional: it is where a terminal renderer's assumptions break, - and it is where this project has historically found what the other two hid. - files: - - .github/workflows/ci.yml - evidence: - - ci.yml runs build --all-targets, test --all-targets, test --doc on ubuntu-latest, - macos-latest and windows-latest with fail-fast off, plus a lint job (fmt --check, clippy -D - warnings) and an msrv job that reads rust-version out of Cargo.toml rather than repeating - it. - - "Every uses: is pinned to a full-length commit SHA with the version in a trailing comment; - no job id contains a dot; cancel-in-progress is false so a superseded run keeps its - diagnostics." - - The green run itself is recorded against O1 when the branch is pushed. - - id: US-IO-CLI-0.1.0-T03 - summary: "Build the inline renderer core and the byte-stream capture harness: `Viewport::Inline`, - `insert_before` for finished content, synchronized output per frame, and a panic hook plus - `Drop` guard that restore the terminal." - why_required: This is the release. Every other surface renders through it, and the contract names it - the single largest technical risk with the instruction to build it before anything else. The - capture harness is built with it because F5 and N3 cannot be asserted without one. - status: done - depends_on: - - US-IO-CLI-0.1.0-T01 - acceptance_criteria: - - > - **F4 — finished content reaches scrollback, not the viewport.** After a scripted session, - the captured byte stream contains every finished message's text outside the viewport region, - and the viewport buffer at rest contains only the composer and the status line. Sabotage: - render a finished message into the viewport instead of committing it, under which only F4 - fails. - - > - **F5 — no alternate screen and no mouse capture, ever.** Over the same captured byte stream, - the sequences `ESC[?1049h`, `ESC[?1047h`, `ESC[?1000h`, `ESC[?1002h` and `ESC[?1003h` do not - appear. This is the release's structural criterion and it is written so that no later - release can reintroduce fullscreen without failing it. Sabotage: call - `EnterAlternateScreen`, under which only F5 fails. - - > - **N2 — the terminal is always restored.** Panic, `Ctrl+C` at any point, and a kill signal - each leave the terminal in cooked mode with the cursor visible and the scrollback intact. - Asserted by a test that panics inside a turn and inspects the restore path, and by manual - check for the signal case. - - > - **N3 — no full-screen clear during a session.** The captured byte stream contains no - `ESC[2J` after startup. This is the flicker criterion, and it is asserted structurally - because flicker itself cannot be asserted on a CI runner. - files: - - src/term.rs - - src/lib.rs - - Cargo.toml - - Cargo.lock - - tests/support/mod.rs - - tests/structure.rs - - tests/scrollback.rs - - tests/restore.rs - evidence: - - 8 tests pass across tests/structure.rs, tests/scrollback.rs and tests/restore.rs; cargo fmt - --all -- --check and cargo clippy --all-targets -- -D warnings both exit 0. - - "The harness is a real CrosstermBackend writing into a Recorder, wrapped by a Fixed backend - that answers only size() and get_cursor_position() from test-chosen values. TestBackend was - rejected on purpose: it emits no escape sequences, so F5 would pass against it because - nothing appears rather than because nothing forbidden appears." - - "SABOTAGE A — queue EnterAlternateScreen in draw(): both F5 tests fail, the other six pass." - - "SABOTAGE B — queue Clear(ClearType::All) in draw(): only - n3_never_clears_the_whole_screen_during_a_session fails." - - "SABOTAGE C — commit() renders through draw() into the viewport instead of insert_before: - both F4 tests fail, F5 and N3 still pass." - - "SABOTAGE D — call the previous panic hook before the restore: only - n2_a_panic_runs_the_restore_before_the_previous_hook fails." - - "SABOTAGE E — drop the idempotence guard on restore(): only - dropping_the_screen_restores_it_once fails." - - insert_before is given a MEASURED height from Paragraph::line_count(width), not an estimate. - ratatui's unstable-rendered-line-info feature is enabled for that one method; an estimated - height truncates committed content, which on this renderer loses it from the transcript - permanently. - - id: US-IO-CLI-0.1.0-T04 - summary: Handle resize without duplicating committed scrollback, and define every surface at 80x24. - why_required: F9 makes 80x24 a supported size rather than a degraded one, and resize is where a - hybrid inline renderer duplicates history if it is wrong. Both are properties of the renderer, - so they are fixed while it is still small. - status: done - depends_on: - - US-IO-CLI-0.1.0-T03 - acceptance_criteria: - - > - **F9 — 80×24 is supported.** The renderer harness runs the scripted session at 80×24 and - asserts no panel is truncated mid-word, the composer remains usable, and the status line - degrades to its narrow form rather than wrapping. - files: - - src/term.rs - - tests/support/mod.rs - - tests/resize.rs - - tests/narrow.rs - evidence: - - 13 tests pass across five test binaries; fmt and clippy -D warnings both exit 0. - - "Resize: a committed line is written exactly once and stays written once across two resizes, - so the transcript is never redrawn — the duplication a full-screen renderer produces has no - code path here." - - "80x24: a forty-line commit into a twenty-four-line terminal keeps the viewport; a - two-hundred-character line wraps with all twenty repeats intact; CJK text wraps with every - character intact and no replacement character in the byte stream." - - "SABOTAGE F — compute the commit height from lines.len() instead of - Paragraph::line_count(width): three tests fail (both wrapping tests and the post-resize - commit), the other ten pass." - - "SABOTAGE G — make Screen::resize a no-op: the FIRST version of the resize test failed - nothing, because ratatui's autoresize re-reads the terminal size at the top of every draw - and covered it. That was a real finding about the test, not the code. The test now commits - between the resize and the next frame, which is the only path where the explicit call is - load-bearing, and the same sabotage fails exactly that one test." - - "The harness gained Fixed::set_size for the same reason: a backend whose size never moves - undoes any resize on the very next frame, which is a property of the harness and would have - hidden a genuine renderer bug." - - id: US-IO-CLI-0.1.0-T05 - summary: Add the theme token set with two shipped themes, terminal background detection, `NO_COLOR`, - and the splash committed once into scrollback. - why_required: N6 requires that a session run with colour disabled stays usable and that every state - colour distinguishes also carries a word. That is a property of the token set, so the tokens - have to exist before any surface uses one. The splash is the first thing written through the - renderer and is suppressed by the same switches. - status: done - depends_on: - - US-IO-CLI-0.1.0-T03 - 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. - > - **N6 — `NO_COLOR` produces a usable session.** With `NO_COLOR=1` the scripted session runs - and every state that colour distinguishes — success, warning, error, refusal — also carries - a word. + **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. files: - - src/theme.rs - - src/splash.rs - - src/lib.rs - - tests/no_color.rs - evidence: - - 19 tests pass; fmt and clippy -D warnings exit 0. Six of them are new and cover the theme, - the splash and NO_COLOR. - - "The colour-is-never-alone rule is a property of the constructor, not a convention: - Theme::notice is the only way a toned line is built and it puts the tone's word in front of - the text, so a call site cannot forget. Tone::word returns None for the three presentation - tones and Some for the four that mean something." - - NO_COLOR wins over an explicitly chosen theme, and the PLAIN theme returns a bare Style with - no modifier — a dim or italic attribute would still be a presentation-only carrier of - meaning. - - "Background detection reads COLORFGBG rather than issuing an OSC 11 query: the query needs a - reply read back off stdin and hangs on any terminal that does not answer, in a product whose - first frame should already be on screen. Unparseable or absent means dark, because the light - palette on a dark terminal is the less readable mistake." - - "SABOTAGE H — Theme::notice emits colour without the word: the two N6 word tests fail, - nothing else." - - "SABOTAGE J — the PLAIN theme emits an indexed colour: only the scripted NO_COLOR session - fails, on the escape-sequence assertion." - - "SABOTAGE K — the splash ignores NO_COLOR: only the splash suppression test fails." - - "The splash is also suppressed below forty-one columns, which the contract does not require: - the mark is forty-one wide and is the first thing that looks broken on a small window." - - id: US-IO-CLI-0.1.0-T06 - summary: "Build the composer on `tui-textarea`: multiline on Shift+Enter with a `\\`+Enter fallback, - prompt history on the arrow keys, and bracketed paste." - why_required: There is no way to type a task without it, so F1 and F6 both stop here. Its narrow - form is also half of what F9 asserts. - status: done - depends_on: - - US-IO-CLI-0.1.0-T04 - - US-IO-CLI-0.1.0-T05 - acceptance_criteria: - - > - **F9 — 80×24 is supported.** The renderer harness runs the scripted session at 80×24 and - asserts no panel is truncated mid-word, the composer remains usable, and the status line - degrades to its narrow form rather than wrapping. - files: - - src/composer.rs - - src/lib.rs - - tests/composer.rs - evidence: - - 30 tests pass across seven binaries; fmt and clippy -D warnings exit 0. Eleven of them are - the composer's. - - "A REAL DEFECT was found by reading the captured byte stream rather than the rendered - buffer: every frame carried ESC[?25l, hiding the cursor. ratatui hides it on any frame that - does not set a cursor position, and a hidden cursor removes the only focus indicator a - screen reader has — the specific criticism this product category is unusable for. The fix - puts frame.set_cursor_position inside Composer::render, so the widget that owns the - insertion point owns the cursor and no frame can forget." - - "A second finding, about the harness rather than the code: ratatui writes only the cells - that differ from the previous frame, so viewport text arrives in the byte stream split at - its unchanged spaces with a cursor move between the pieces. Viewport assertions therefore - read viewport_text(); committed content still reads the bytes, because insert_before draws - every cell of a fresh buffer." - - Shift+Enter inserts a newline and a trailing backslash does the same on the terminals that - cannot report Shift+Enter at all — which without the Kitty keyboard protocol is most of - them. - - The arrows walk prompt history only from the first and last line, so a multiline prompt can - still be edited; a repeated prompt is one history entry; walking past the newest entry - restores the draft that was being typed. - - "SABOTAGE L — remove the backslash continuation: only - a_trailing_backslash_continues_the_line fails." - - "SABOTAGE M — let Up always recall history: only - the_arrows_move_the_cursor_inside_a_multiline_prompt fails." - - "SABOTAGE N — do not set the cursor position: only - the_real_cursor_is_never_hidden_while_input_is_possible fails." - - id: US-IO-CLI-0.1.0-T07 - summary: Build the `Picker` overlay component, once, as the widget every selection surface uses. - why_required: The wizard has four selection steps and F2 drives all of them over the TestBackend. - The contract also names this the one piece of the fallback scope that must not drop, because - cutting it means hand-rolling the wizard and rebuilding it in 0.2.0 for approvals. - status: done - depends_on: - - US-IO-CLI-0.1.0-T04 - - US-IO-CLI-0.1.0-T05 - acceptance_criteria: - - > - **F2 — the wizard writes what it showed.** A test drives the wizard over the `TestBackend` - with a scripted key sequence and asserts the resulting file parses under - `io_harness::Config::from_toml`, that `provider_spec()` returns the chosen variant with the - chosen model, and that the file's mode is `0600` on unix. Sabotage: write the file before - the confirmation screen, under which only F2 fails. - files: - - src/picker.rs - - src/lib.rs - - tests/picker.rs - evidence: - - 37 tests pass across eight binaries; fmt and clippy -D warnings exit 0. Seven of them are - the picker's. - - One widget, in the viewport, never in scrollback — a choice that has scrolled away cannot be - answered. The wizard's four selection steps, /model and /theme are this widget with - different rows, and 0.2.0's approval overlay and 0.4.0's /resume and /fork are the same - widget again. - - The selection is marked by a marker character as well as by colour, so it survives NO_COLOR - and a monochrome terminal. - - "At eighty columns a row loses its DETAIL, not its label, and is fitted with a - single-character ellipsis rather than wrapped: a row that wraps stops the list being a list, - and the label is the part that has to survive." - - Movement clamps at both ends rather than wrapping, matching the composer's history, and a - long list scrolls so the selection is always drawn. - - "SABOTAGE P — never scroll to the selection: only - a_long_list_scrolls_so_the_selection_stays_visible fails." - - "SABOTAGE Q — render the detail unfitted: only the eighty-column row test fails." - - "SABOTAGE R — mark the selected row by colour alone: only - the_selected_row_is_marked_by_more_than_colour fails." - - id: US-IO-CLI-0.1.0-T08 - summary: Render the status line with model, elapsed time and a working indicator, laid out for 0.2.0 - fields. - why_required: F4 asserts that the viewport at rest contains the composer and the status line and - nothing else, so the status line has to exist for that assertion to mean anything. F9 asserts - it degrades to a narrow form rather than wrapping. - status: done - depends_on: - - US-IO-CLI-0.1.0-T04 - - US-IO-CLI-0.1.0-T05 - acceptance_criteria: - - > - **F9 — 80×24 is supported.** The renderer harness runs the scripted session at 80×24 and - asserts no panel is truncated mid-word, the composer remains usable, and the status line - degrades to its narrow form rather than wrapping. + - src/app.rs + - src/main.rs + - tests/tick.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. + + - 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. + status: done + depends_on: + - US-IO-CLI-0.1.1-T01 + 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. + - > + **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. files: + - src/app.rs - src/status.rs - - src/lib.rs - tests/status.rs evidence: - - 42 tests pass across nine binaries; fmt and clippy -D warnings exit 0. Five of them are the - status line's. - - "Three fields filled — model, running state, elapsed — and the 0.2.0 fields are named in the - module rather than left to a later redesign: adding the policy layer, context pressure, - spend and containment is filling in a value." - - The running state is a WORD rather than a spinner. A spinner carries its meaning only to a - reader who can see it move, and this line has to work in a screen reader. - - "The narrow form drops whole fields from the right and never wraps: a status line that - becomes two lines has taken a row from the transcript and stopped being a status line. Below - the width of even one field the model is shown truncated, because a blank status line is - worse than a truncated one. Asserted across nine widths from 1 to 200." - - Field width is counted in characters, not bytes — the separator's middle dot is two bytes - and one cell, and byte accounting reserved room that was not needed and dropped a field a - column early. - - "SABOTAGE S — keep every field regardless of width: only the narrow-form test fails." - - "SABOTAGE T — render the running state as a tone with no word: three tests fail, all of them - status tests." - - id: US-IO-CLI-0.1.0-T09 - summary: "Map io-harness run events to rendered cells with an exhaustive match: `started`, `token`, - `step`, `tool_call` and `finished` styled, `refused` and `approval_requested` as a plain - notice, every other kind as a muted line." - why_required: F8 requires the match to be exhaustive with no wildcard that silently discards. This - is also where token coalescing lives, without which a streaming turn strobes. + - "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. + + - 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. status: done - depends_on: - - US-IO-CLI-0.1.0-T03 + depends_on: [] acceptance_criteria: - > - **F8 — every event kind is accounted for.** A test enumerates the event kinds io-harness - emits and asserts the renderer's match is exhaustive with no wildcard that silently - discards. An event this release does not style renders as a muted line; none is dropped. - Sabotage: add a catch-all arm that returns without rendering, under which only F8 fails. + **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. files: - src/events.rs - - src/lib.rs + - src/status.rs - tests/events.rs - - tests/support/mod.rs - evidence: - - 48 tests pass across ten binaries; fmt and clippy -D warnings exit 0. Six of them are the - event renderer's. - - "EventKind is #[non_exhaustive] with fifty variants and no accessor for its own tag, so a - wildcard arm is REQUIRED BY THE TYPE rather than chosen. F8 is therefore asserted as the two - things that actually matter: no kind renders to nothing, and no kind exists that this - repository has never seen." - - The drift half reads io-harness's OWN SOURCE out of the cargo registry — the version taken - from this crate's lockfile, so the source read is the source built — parses the variants of - pub enum EventKind, and requires the set to equal the seven styled names plus the - forty-three that fall through. A fifty-first kind fails the test by name, which is the - moment someone has to decide what it should look like. A list copied into this repository - could not have noticed. - - "An unstyled kind names itself. The label comes from the derived Debug form, snake-cased the - way #[serde(rename_all)] does, because reaching the serde tag would mean carrying a - serializer for a label." - - "Tokens are coalesced: a token commits nothing and accumulates in a live buffer the viewport - draws, and the whole passage commits once when a step, a refusal or the finish closes it. - Committing per token would put a scrollback line in the terminal for every few characters - the model produced." - - A refusal keeps its rule and its layer even in the plain one-line notice this release - renders it as — those are the two facts no other core records, and 0.2.0's surface is a - presentation of them, not their first appearance. - - "SABOTAGE U — wildcard arm returns no lines: exactly the two no-silent-drop tests fail." - - "SABOTAGE V — commit each token as its own line: only the coalescing test fails." - - "SABOTAGE W — remove one kind from the known list: only the drift test fails, which is what - proves the drift test can fail at all." - - id: US-IO-CLI-0.1.0-T10 - summary: "Bridge io-harness to the screen: an `Observer` that hands events to the render loop - without blocking the run, a session driven by `Session::turn_steered`, and `Ctrl+C` calling - `Steer::interrupt`." - why_required: This is the only path from the harness to the interface, and F6 — interrupt a turn, - keep the session, accept the next prompt — is asserted against it. `Observer::event` is - synchronous and runs on the run's own task, so the bridge has to hand off rather than render - in place. - status: done - depends_on: - - US-IO-CLI-0.1.0-T06 - - US-IO-CLI-0.1.0-T08 - - US-IO-CLI-0.1.0-T09 - acceptance_criteria: - - > - **F6 — an interrupt stops the turn and keeps the session.** A scripted `Ctrl+C` during a - streaming turn causes `Steer::interrupt`, the turn ends, the partial output stays committed - in scrollback, and the composer accepts the next prompt. A second `Ctrl+C` at an idle - composer exits. Sabotage: drop the interrupt on the floor, under which only F6 fails. - files: - - src/app.rs - - src/bridge.rs - - src/lib.rs - - tests/interrupt.rs - evidence: - - 57 tests pass across eleven binaries; fmt and clippy -D warnings exit 0. Nine of them are - the bridge's and the session state's. - - "Observer::event is synchronous and runs on the RUN'S OWN TASK, so the bridge does one thing - — hand the event on down an unbounded channel — and the interface draws when it next gets - to. Rendering inside the observer would put terminal I/O on the agent loop's path: a slow - write slows the run, a blocking write stops it." - - "The channel is unbounded deliberately. A bounded one fills in one of two ways and both are - wrong here: blocking stalls the run, dropping loses an event, which is the failure F8 exists - to prevent. A thousand events through the bridge arrive as a thousand events." - - Ctrl+C during a turn returns Interrupt and the test drives Steer::interrupt, then reads - SteerInbox::pending and sees the interrupt with no message beside it. The turn's partial - output is committed by App::finished rather than lost with the turn, and the composer takes - the next prompt afterwards. F1 covers the same path against a real model. - - Ctrl+C with something typed clears the composer and does NOT count towards the exit, so a - stray keystroke cannot end a session by surprise; twice at an idle composer exits, and the - first one says so. - - Ctrl+D exits only on an empty composer; Ctrl+L clears the VIEWPORT and never the scrollback, - which on this renderer is the terminal's own buffer and not something this process could - redraw. - - "SABOTAGE X — Ctrl+C exits instead of interrupting: only the F6 interrupt test fails." - - "SABOTAGE Y — a finished turn drops what streamed: the same test fails, on the - partial-output assertion." - - "SABOTAGE Z — the observer returns Flow::Cancel: exactly the two bridge tests fail." - - id: US-IO-CLI-0.1.0-T11 - summary: Build the first-run wizard through its eight screens and write io-harness's configuration - file at mode 0600, with nothing written before the confirmation screen. - why_required: F2 is the wizard writing what it showed, and N5 is the credential never reaching the - screen, the scrollback, a log line or the trace. The release's stated outcome starts from an - empty configuration, so without the wizard there is no first run at all. - status: done - depends_on: - - US-IO-CLI-0.1.0-T07 - - US-IO-CLI-0.1.0-T10 - acceptance_criteria: - - > - **F2 — the wizard writes what it showed.** A test drives the wizard over the `TestBackend` - with a scripted key sequence and asserts the resulting file parses under - `io_harness::Config::from_toml`, that `provider_spec()` returns the chosen variant with the - chosen model, and that the file's mode is `0600` on unix. Sabotage: write the file before - the confirmation screen, under which only F2 fails. - - > - **N5 — the credential never reaches the screen, the scrollback, a log line or the trace.** - The captured byte stream from the F2 scripted wizard is searched for the test key and must - not contain it. Sabotage: echo the key in the confirmation screen, under which only N5 - fails. - files: - - src/wizard.rs - - src/settings.rs - - src/lib.rs - - tests/wizard.rs evidence: - - 64 tests pass across twelve binaries; fmt and clippy -D warnings exit 0. Seven of them are - the wizard's, driven by a scripted key sequence over a real CrosstermBackend so the byte - stream N5 asserts over is the one a terminal would have received. - - "A REAL FINDING that changes how F2 can be asserted: Config::from_toml parses text as the - PROJECT scope, and io-harness REFUSES a project-scoped file that widens the permission - boundary. The wizard's default posture — sandboxed workspace — sets exec = allow, so the - file it writes is correct and from_toml is right to reject it: widening is the operator's - own decision and belongs to the user scope, which is where the wizard writes. The test - therefore reads it back through Config::discover with the user file OUTSIDE the discovery - root, and separately checks that a narrowing posture parses either way. F2's own scripted - run chooses 'ask before writes', which narrows, so the criterion's literal from_toml - assertion is still made and still passes." - - "A second trap found while fixing the first: a user file placed at /io.toml is ALSO - the project-scope candidate, so it is read under project rules and refused. The user scope - has to be somewhere the workspace is not." - - "F2: the scripted run picks OpenRouter, types a key, takes the second model, the light theme - and the ask-before-writes posture; the written file parses, provider_spec returns OpenRouter - with openai/gpt-5 and the typed key, the policy's write default is Ask and net is Deny, - [app.io-cli] gives back the chosen theme, and the file's mode is 0600 on unix. Nothing - exists on disk at any point before the confirmation screen." - - "N5: the key never appears in the byte stream, nor do its 12, 20 or 28 character prefixes; - the field renders bullets, so the absence is masking rather than the key simply not being - drawn; and the confirmation screen describes the credential as 'written into this file, mode - 0600' without any of its value." - - Where an environment variable already carries the key, Enter on an empty field writes the - file WITHOUT a key at all — the better outcome rather than a fallback, since a key that is - never on disk cannot leak from it. - - A posture is an io_harness::Policy rather than a flag of io-cli's own, which is what will - let the status line name the layer in force. 'Ask before writes' says plainly at the moment - it is chosen that a write is declined until the approval surface lands, rather than implying - a behaviour this release does not have. - - "SABOTAGE AA — produce the write when the posture is chosen, skipping confirmation: only the - F2 test fails." - - "SABOTAGE BB — remove the mask character: only the F2/N5 test fails." - - "SABOTAGE CC — a rejection carries on to the model step: both F3 tests fail and nothing - else." - - id: US-IO-CLI-0.1.0-T12 - summary: Verify the credential against the live endpoint inside the wizard, returning to the - credential step with the provider's own message and writing no file on failure. - why_required: F3 asserts that a bad key does not get past the wizard. It is the step that decides - whether a first run fails inside a screen that can explain it or on the user's first real - prompt. - status: done - depends_on: - - US-IO-CLI-0.1.0-T11 - acceptance_criteria: - - > - **F3 — a bad key does not get past the wizard.** With a provider endpoint that rejects the - credential, the wizard returns to the credential step carrying the provider's own message, - and **no file is written**. Sabotage: treat any non-transport error as success, under which - only F3 fails. - files: - - src/verify.rs - - src/wizard.rs - - src/main.rs - - src/lib.rs - - src/app.rs - - src/term.rs - - src/events.rs - evidence: - - 69 tests pass across thirteen binaries; fmt and clippy -D warnings exit 0. The verification - path itself is exercised live by F1, which is what a credential check can only be proved by. - - The rejection carries THE PROVIDER'S OWN MESSAGE rather than a category. '401 No auth - credentials found' tells a user what to do and 'verification failed' does not, and every - provider words it differently. - - Provider is not dyn-compatible — its methods return impl Future — so each ProviderSpec - variant is constructed and pinged in its own arm. That is the shape of the harness's trait, - not a choice; the same match appears once more in the driver, where it picks the concrete - type the session loop is generic over. - - "A spec whose variant this release has never seen returns an explicit refusal rather than a - pass it never made: ProviderSpec is #[non_exhaustive] and a wildcard that returned Ok would - report a working key for a provider that was never contacted." - - "The catalogue is io-harness's Reference, which reads OpenRouter's models endpoint — so for - OpenRouter it is not a reference at all but the provider speaking for itself, and for - Anthropic and OpenAI it is filtered by prefix. A catalogue that cannot be read is not fatal: - the wizard offers the provider's usual default, or asks the user to type a model id when - there is nothing to suggest." - - "A DESIGN CONSEQUENCE settled here: ratatui fixes an inline viewport's height when the - terminal is constructed and offers no way to change it, and rebuilding the terminal would - re-query the cursor and risk shifting the scrollback this product exists to protect. So the - viewport does not grow — instead a streaming answer commits each line to scrollback as it - FINISHES and only the unfinished tail is ever live. A two-hundred-line answer leaves the - viewport exactly the size it started. The ceiling it buys is that a prompt longer than two - rows scrolls within them; the composer's own release is 0.7.0." - - The clippy fix for a collapsible match would have put App::key — which mutates — inside a - match guard. Bound to a local instead; a guard is not a place for something that changes - state. - - id: US-IO-CLI-0.1.0-T13 - summary: "Add the five slash commands: `/help`, `/quit`, `/setup`, `/theme`, `/model`." - why_required: They are named in the release scope and each is a `Picker` or a print over components - that already exist. `/setup` in particular is the contract's stated way to re-run the wizard - at any time, which is what makes the wizard reachable after the first run. - status: done - depends_on: - - US-IO-CLI-0.1.0-T11 - acceptance_criteria: [] + - "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. + + - 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. + status: done + depends_on: + - US-IO-CLI-0.1.1-T01 + 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. + - > + **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. + files: + - tests/timing.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." + + - 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. + 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 + 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. + - > + **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. files: - - src/commands.rs - - src/lib.rs - - tests/commands.rs - evidence: - - "Five slash commands, five tests. The keybinding table and the command list are DATA, not - prose: /help renders them and the README renders the same constants, so the two cannot drift - from each other or from the code." - - An unknown command prints the list rather than erroring — somebody who typed /models wants - to be told what does exist. - - A test asserts the table covers every key App::key and Composer::key actually bind, and - fails if a key is added to one and not the other. - - /theme and /model change the SESSION only and say so, rather than silently rewriting a - configuration file that holds settings this release does not own. Making a choice permanent - is "io setup". - - id: US-IO-CLI-0.1.0-T14 - summary: "Assert the forbidden subsystems are absent: no HTTP client, TLS stack, SQL or sandboxing - crate in the dependency set, and no provider-response loop in the source." - why_required: F7 is the criterion that keeps the archived fork from growing back, and N1 is the - evidence reviewed at completion. It is written last on purpose, when the dependency set is - final and the assertion is meaningful. - status: done - depends_on: - - US-IO-CLI-0.1.0-T13 - acceptance_criteria: - - > - **F7 — the forbidden subsystems are absent.** A test over `Cargo.toml` asserts the - dependency set contains no HTTP client, no TLS stack, no SQL or database crate, and no - sandboxing crate, and a grep asserts the source contains no `async fn` that loops over - provider responses. The only path to a model is through `io_harness`. Sabotage: add - `reqwest` as a direct dependency, under which only F7 fails. This is the criterion that - keeps the archived fork from growing back. - - > - **N1 — io-cli contains no second harness.** `cargo tree --depth 1` is recorded in the - release evidence and reviewed: `io-harness`, `ratatui`, `crossterm`, `tui-textarea`, `clap`, - `tokio`, `serde`/`toml`, and nothing that constitutes a runtime, a client or a store. Any - addition beyond that list is argued in the release record or it does not ship. - files: - - tests/dependencies.rs - evidence: - - 76 tests pass; fmt and clippy -D warnings exit 0. Seven of them are F7 and N1. - - "The dependency set is asserted BOTH ways: nothing beyond the eight the contract names, and - nothing the contract permits that has stopped being used. A dependency added quietly fails - the build; one that is genuinely needed is argued in the release record and added to the - list, which is a decision somebody makes rather than a line somebody adds." - - Twenty-three named crates are refused by name — HTTP clients, TLS stacks, databases, - sandboxing crates and a provider client — matched against DIRECT dependencies only, because - a transitive one arrives through io-harness and is the harness's business. - - "The agent-loop assertion is coarse on purpose: a file that calls a provider must contain no - top-level loop, while, or for. Being coarse is what makes it hard to creep past. io-cli - calls a provider EXACTLY ONCE in the whole crate — the wizard's verification handshake — and - the test asserts that count, so a second call anywhere fails it." - - "Also asserted: no std::process::Command, no TcpStream, no std::net (running a command is - the harness's tool layer, inside its sandbox — one here would be a tool no policy governs - and no trace records); no toml::from_str anywhere, because configuration is io-harness's and - a second parser would be a second, disagreeing answer to what a config file means; one - binary, named io; and publish = false." - - "SABOTAGE DD — add reqwest as a direct dependency: exactly the two dependency-set tests - fail." - - "SABOTAGE EE — add a second provider call inside a loop: only the agent-loop test fails." - - id: US-IO-CLI-0.1.0-T15 - summary: "Add `release.yml`: a cross-compile matrix producing the four artifacts plus `SHA256SUMS`, - attached to the Release the tag cuts." - why_required: The GitHub Release is the product's only distribution channel, and O5 requires all - four artifacts and the checksum file to exist on it. The musl row is the one most likely to - fail, so it is proved before the release branch depends on it. - status: done - depends_on: - - US-IO-CLI-0.1.0-T02 - acceptance_criteria: - - > - **O5 — every artifact in the matrix exists and every installer works on a clean machine.** - The Release carries all four artifacts plus `SHA256SUMS`. `install.sh` is then run on a - clean macOS machine and in a clean Linux container, and `install.ps1` on a clean Windows - machine, each followed by running `io --version` **in a new shell** — which is what catches - a script that installed correctly and told the user nothing useful about `PATH`. An - installer verified only on the machine that built it is not verified. - - > - **O6 — the Release is cut by pushing the tag, and nothing else.** `gh release create` is - never used: it produces a Release with no assets. The workflow the tag triggers is what - attaches them, and the Release title is the bare form this line of repositories uses. - files: - - .github/workflows/release.yml - evidence: - - release.yml cross-compiles four targets — aarch64-apple-darwin, x86_64-apple-darwin, - x86_64-unknown-linux-musl, x86_64-pc-windows-msvc — writes SHA256SUMS over all four, and - attaches all five to the Release the TAG cuts. gh release create is never used; it produces - a Release with no assets, and the assets are the whole channel here. - - The musl row installs musl-tools and sets CC and the linker, because io-harness carries - rusqlite with a bundled C SQLite. Without it the build fails inside a dependency's build - script, which reads like a toolchain bug and is not one. It is the row most likely to break - and it is why fail-fast is off. - - Every job runs the built binary with --version where the runner can execute it. A binary - that builds and will not start is exactly what a test profile hides from a release profile, - and it costs one line per target. - - "A checks job runs first and alone: the tag must equal Cargo.toml's version and the - CHANGELOG must have that section. Four cross-compiles is an expensive way to discover a - mismatched tag." - - The release job asserts SHA256SUMS has four lines, so a partial upload is a named failure - rather than a checksum file that happens to be short. - - "Every uses: is pinned to a full-length commit SHA with the version in a trailing comment; - no job id contains a dot; concurrency does not cancel in progress." - - id: US-IO-CLI-0.1.0-T16 - summary: "Write `install.sh` and `install.ps1`: resolve the artifact, verify it against `SHA256SUMS` - before unpacking, install into a per-user directory, and print the `PATH` line rather than - editing a profile." - why_required: N7 is the one place in this product where a failure is a security failure, and N8 - requires an install to need no administrator rights. There is no other way to install io-cli, - so the release does not exist without these two scripts. - status: done - depends_on: - - US-IO-CLI-0.1.0-T15 - acceptance_criteria: - - > - **N7 — an installer never runs an unverified binary.** Both scripts compute the artifact's - SHA-256 and compare it against the published `SHA256SUMS` before unpacking, and **abort - non-zero on a mismatch without leaving anything on disk**. Asserted by running each - installer against a deliberately corrupted artifact and checking both the exit status and - that the target directory is untouched. This is the one place in the product where a failure - is a security failure rather than an inconvenience, and `curl | sh` is a trust-the-publisher - model regardless — the checksum defends against a truncated download and a tampered asset, - not against a compromised repository, and the README says so rather than implying more. - - > - **N8 — an install needs no administrator rights.** Neither script writes outside the user's - own directories and neither invokes `sudo`. Asserted by running each as an unprivileged user - with no write access to any system path. - files: - - install.sh - - install.ps1 - - tests/installers.rs - evidence: - - 82 tests pass; fmt and clippy -D warnings exit 0. Six of them are the installers', and they - run install.sh END TO END against a local release served over file:// — a real archive, a - real SHA256SUMS, a real unpack, and the installed stand-in executed afterwards. - - "N7 the good path: checksum ok, the binary installs, running it produces the content that - was inside the archive, and the script PRINTS the PATH line instead of editing a shell - profile behind the user's back." - - "N7 the bad path: the archive is corrupted AFTER its checksum is computed, so the mismatch - is real rather than a wrong number written into the file. The script exits non-zero, says - 'checksum mismatch' and 'Nothing was installed', and THE TARGET DIRECTORY IS NEVER EVEN - CREATED." - - "N7 the missing-line path: a SHA256SUMS that does not mention this artifact is a refusal, - not a skip. That is the one path where an installer would do the thing it exists to - prevent." - - The script also refuses outright when neither sha256sum nor shasum is on PATH, for the same - reason. - - "SABOTAGE FF — verify after unpacking: only the ORDER test failed, because the unpack - happens inside the temp directory and the target is still untouched. That is a finding about - the two tests rather than a gap: they cover different halves and neither is redundant." - - "SABOTAGE HH — install first and verify afterwards, which is the genuinely dangerous - ordering: THREE tests fail, including the corrupt-artifact one on the untouched-directory - assertion." - - "SABOTAGE GG — default to /usr/local/bin: only the no-administrator-rights test fails." - - "N8 is asserted on both scripts: no sudo, doas, runas or elevation verb; the unix default is - under HOME and the Windows one under LOCALAPPDATA; install.ps1 sets the USER PATH and is - asserted never to touch the machine PATH, which would need administrator rights and change - the environment for everybody on the box." - - A cross-file test asserts the artifact naming the installers resolve is the naming - release.yml produces. The install command is the first thing a user depends on and the two - facts live in three files. - - OPEN QUESTION ANSWERED — the script resolves the LATEST release by default, via the - /releases/latest redirect, so re-running it is the update mechanism; IO_VERSION pins a - version for anyone who needs yesterday's install. No API token and no JSON parser. - - id: US-IO-CLI-0.1.0-T17 - summary: Write the README with the keybinding table, the slash-command reference and the install - commands; fill the CHANGELOG entry and set the version to 0.1.0. - why_required: The keybinding table is required to be a table rather than prose, the install command - is part of the product's public contract, and the README states plainly what a checksum does - and does not defend against, which N7 requires. - status: done - depends_on: - - US-IO-CLI-0.1.0-T16 - acceptance_criteria: - - > - **N7 — an installer never runs an unverified binary.** Both scripts compute the artifact's - SHA-256 and compare it against the published `SHA256SUMS` before unpacking, and **abort - non-zero on a mismatch without leaving anything on disk**. Asserted by running each - installer against a deliberately corrupted artifact and checking both the exit status and - that the target directory is untouched. This is the one place in the product where a failure - is a security failure rather than an inconvenience, and `curl | sh` is a trust-the-publisher - model regardless — the checksum defends against a truncated download and a tampered asset, - not against a compromised repository, and the README says so rather than implying more. - files: - - README.md + - Cargo.toml + - Cargo.lock - CHANGELOG.md - - docs/config.example.toml - - tests/docs.rs - evidence: - - 90 tests pass; fmt and clippy -D warnings exit 0. Eight of them check the documentation - against the code rather than against a reviewer's memory. - - "The keybinding table is a shipped artifact and it is DATA: /help renders the constants and - the README's table is checked against the same constants, row for row, description for - description, including the row COUNT — so a key added to one and not the other fails a named - test. A table that has drifted is worse than no table; it is folklore with a typeface." - - "SABOTAGE II — reword a key's description in the code without touching the README: only the - README key-table test fails." - - "docs/config.example.toml is documentation that RUNS: a test writes it as the user scope and - asserts io-harness accepts it, that it configures a provider and a policy, and that - [app.io-cli] gives back the theme. An example the harness has stopped accepting is not an - example, it is a trap." - - The README states what the checksum does NOT defend against — a compromised repository — in - N7's own words, and a test asserts that sentence is still there. A page that leaves it - unsaid is overselling a curl-into-sh install. - - The CHANGELOG's 0.1.0 section is asserted to exist and to have a body, because release.yml - refuses to cut a Release without one and finding that out from a failed workflow is four - cross-compiles too late. - - "Also asserted: every file the repository refers to exists; NOTICE and the README carry - 'Copyright 2026 Aakash Pawar (InitOrigin)' while the Apache-2.0 body is untouched; and the - release evidence directory stays out of git." - - "The README documents the scope trap found in T11: a project file may narrow the permission - boundary and may never widen it, because a repository you cloned must not be able to grant - itself permission — which is why the wizard writes the user scope." - - id: US-IO-CLI-0.1.0-T18 - summary: Build the binary in release profile and run it, then record `cargo tree --depth 1` and the - startup and streaming measurements as evidence. - why_required: O2 is a standing gate because CI builds the test profile only and a release-profile - break is invisible to it — this has broken a binary in this line of repositories before. N4 - requires the measurements to be recorded rather than asserted. - status: done - depends_on: - - US-IO-CLI-0.1.0-T14 - - US-IO-CLI-0.1.0-T17 - acceptance_criteria: - - > - **O2 — the binary builds in release profile.** `cargo build --release` is run and the binary - executed. CI builds the test profile only, so a release-profile break is invisible to it — - this has bitten this line of repositories before and is now a standing gate. - - > - **N4 — no timing assertion anywhere in this release's tests.** Startup cost and streaming - smoothness are **recorded as measurements** in the release evidence, not gated. This line of - repositories has paid for wall-clock assertions on CI runners more than for anything else. - files: - README.md - - docs/config.example.toml - evidence: - - "O2: cargo build --release exits 0 in 1m46s on rustc 1.95.0 aarch64-apple-darwin, and the - binary RUNS. io --version prints 0.1.0, io --help prints the subcommand and both flags, and - io with a non-tty stdout refuses with exit 1 and a sentence rather than half-working. CI - builds the test profile only, so none of that is visible to it." - - "N1: cargo tree --depth 1 is exactly the eight names the contract permits — io-harness - 0.60.1, ratatui 0.29.0, crossterm 0.28.1, tui-textarea 0.7.0, clap 4.6.6, tokio 1.53.1, - serde 1.0.229, toml 1.1.4 — plus tempfile as a dev-dependency. Saved to the release - evidence." - - "N4 measurements, RECORDED and not gated: binary 20,236,832 bytes; startup 4.86 ms per run - measured over fifty runs against a 1.64 ms process-spawn baseline, so io's own share is 3.22 - ms. THE FIRST VERSION OF THIS MEASUREMENT WAS WRONG and was discarded: it timed a python - process start on either side of the binary and reported 26 ms, almost all of which was the - harness. A misleading number is worse than no number." - - "The business case, measured: 3,458 lines of source across 16 files and 3,102 lines of tests - across 17, against the archived product's 1,199,453 lines across 88 crates. That ratio is - the whole reason this release exists." - - "A LIMITATION FOUND AND DOCUMENTED RATHER THAN LEFT TO SURPRISE SOMEBODY: - Session::turn_steered builds its own TaskContract, and io-harness has no entry point that - takes both a caller's contract and a steer inbox. So [sandbox] limits, [run] budgets, - [instructions]/AGENTS.md, [[mcp]], [[lsp]], [[agent]], [web] and [browser] are read by the - harness and NOT applied to an io-cli turn — honouring them today would mean giving up - Ctrl+C, which F6 requires. The sandbox itself IS on: TaskContract::workspace sets - exec_sandbox, so a workspace turn runs commands inside it; what is missing is the configured - ceilings, not the containment. Stated in the README and in docs/config.example.toml. Nothing - in the 0.1.0 contract promised those sections, so this is not a scope change — but it is the - natural first item for a harness release, and the owner should decide whether it is." - - id: US-IO-CLI-0.1.0-T19 - summary: "Run the live first run end to end against a real provider and record it: wizard, verified - key, written file, streamed answer, sandboxed edit, interrupt, exit, then the terminal's own - search and selection over the transcript." - why_required: F1 is the release's only criterion that a fixture cannot prove, and the recording is - both the release evidence and the README screenshot. The owner's approval of the rendered look - is taken from it. - status: in-progress - depends_on: - - US-IO-CLI-0.1.0-T18 - acceptance_criteria: - - > - **F1 — the live first run.** From an empty `$IO_CONFIG_HOME`, in a real repository, against - a real provider: the wizard is completed, the key is verified by a live call, the file is - written, a task is typed, the answer streams, a file is edited inside the sandbox, `Ctrl+C` - interrupts a second turn mid-flight, and `Ctrl+D` exits. Afterwards, in the same terminal, - the transcript is searched with the terminal's own search and a passage is selected with the - mouse and pasted elsewhere. Manual, recorded, and the recording is the release evidence. A - fixture cannot prove this; only a live run can. - files: - - tests/live.rs - - tests/wizard.rs - - src/term.rs - - src/events.rs - - src/wizard.rs - - src/main.rs - - tests/events.rs - - scripts/f1-fixture.sh + - docs/screenshot.png + - tests/docs.rs + - tests/no_color.rs evidence: - - "F1 PASSED, 2026-08-16, recorded. From an empty configuration: the wizard completed, the key - was verified live, the file was written at mode 0600 carrying the model chosen from the live - catalogue, a task streamed and EDITED THE FILE INSIDE THE SANDBOX — src/lib.rs went from 'a - - b' to 'a + b' — a second turn was interrupted, and the session exited. The terminal's own - search and selection worked on the transcript afterwards, because io-cli never took them - away. Recording held by the owner." - - "DEFECT 5, FOUND BY THE PASSING RUN: the theme step drew NO PICKER. render_theme reserved - four rows for its live sample out of a four-row viewport, leaving zero for the picker, so - the operator saw only the sample — which deliberately contains a refusal and a success so - the palette can be judged — and read it as their own session failing. The same fixed - four-row viewport showed only three rows of a four-hundred-model catalogue, because a picker - draws height - 1." - - The fix gives the WIZARD its own viewport, fourteen rows clamped to the terminal, separate - from the session's four. The two phases are divided by a boundary where nothing is - streaming, so re-placing the viewport there disturbs nothing in flight — which is what made - the fixed-height constraint acceptable in the first place. The sample now opens with - 'preview — not your session:', because a label is what stops the two states worth judging - from reading as alarm. Two tests; two sabotages, reserving the whole viewport again and - removing the label, each fail exactly the new test. - - F1 IS THE OWNER'S RUN AND IT HAS ALREADY EARNED ITS PLACE. The first attempt failed on the - credential screen, three times in a row, before a single character was typed — and the - defect it found is the worst kind this release could have shipped. - - "DEFECT 3, FIRST CONTACT, FOUND BY F1: pasting the API key closed the wizard. The driver's - wizard loop destructured the next event as Event::Key and returned Ok(None) on any other - shape — and Cmd+V arrives as Event::Paste when bracketed paste is on, which this product - enables. The pattern failed, the else branch ran, and the wizard exited. SILENTLY: that path - returned before it could even commit its own 'nothing was written' notice, so a first-time - user sees the splash, the welcome, the credential prompt, and then their shell prompt back - with no explanation. A terminal resize mid-wizard did the same thing. And even once it - stopped exiting, the wizard had no paste handling at all, so the key would have landed - nowhere." - - The session loop had always handled non-key events correctly; only the wizard was wrong — - which is why no test caught it. Dispatch lived in the driver, and the driver is the one - thing the suite cannot reach. The fix moves it into Wizard::event, in the library, where - four tests now cover it. - - "A paste is stripped of newlines rather than submitted: a key copied out of a web page - usually carries a trailing one, and treating it as Enter would submit a field the user was - still filling in." - - "SABOTAGE — make a non-key event cancel again: only - no_event_other_than_a_keypress_can_end_the_wizard fails. SABOTAGE — make the paste land - nowhere: exactly the two paste tests fail. Together they pin both halves of the defect." - - "DEFECT 4, FOUND BY THE OWNER'S SECOND F1 RUN: the 'ask before writes' posture dead-ended. - Every write and exec needs an approver, this release passes DenyAll because the approval - overlay is 0.2.0, and the agent was denied three times, then asked 'May I have approval to - edit src/lib.rs' — and the turn ended awaiting_answer, an outcome io-cli had no arm for. It - fell through the unknown-outcome branch and printed a bare 'error' with no explanation and - no next action. The denials were honest and documented; the DEAD END was not. A wizard - option led somewhere the interface could not get the operator out of." - - "The fix keeps every posture honest without adding the 0.2.0 surface: awaiting_answer, - awaiting_approval and awaiting_plan now read as warnings rather than errors — nothing went - wrong, the run simply cannot continue — and each carries a sentence naming the way out, - which for all three is `io setup` and the sandboxed-workspace posture. A refusal says the - same. Two tests, and two sabotages: dropping the tone arm and emptying the help each fail - exactly the two new tests." - - "A process note from the same fix: cargo fmt had already split the match arm across lines, - so an exact-string patch silently did nothing and the code was unchanged while the edit - reported success. The test caught it. Patching formatted source by string match is only safe - when a test is watching." - - "WHAT WAS PROVED LIVE BEFORE THIS, and still holds: a real key accepted by the wizard's - verification call; F3 live, a wrong key rejected carrying OpenRouter's own words (HTTP 401, - 'User not found.'); 413 models in the catalogue; and a real turn through the real bridge and - renderer that streamed 45 events and edited a file inside the sandbox." - - "DEFECT 1, N2: Screen::attach enabled raw mode and could then return Err without restoring, - leaving the shell with no echo and an error that did not say what had happened. Every - failure path after raw mode now restores first." - - "DEFECT 2, USER-FACING: every ordinary successful turn printed the word 'warning', because - io-harness returns 'finished' rather than 'success' for a turn with no verification - criterion — and every io-cli turn is exactly that, so 'success' is unreachable from this - interface. Invisible to a green suite; found by running one live." - - "STILL THE OWNER'S, and unchanged: the recorded run itself, the terminal's own search and - selection over the transcript afterwards, and approval of the rendered look. 96 tests green - and clippy clean against the tree carrying all three fixes." + - "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." diff --git a/.ultraship/products/io-cli/releases/0.1.1.yaml b/.ultraship/products/io-cli/releases/0.1.1.yaml new file mode 100644 index 0000000..fd6a9e0 --- /dev/null +++ b/.ultraship/products/io-cli/releases/0.1.1.yaml @@ -0,0 +1,375 @@ +product: io-cli +version: 0.1.1 +status: released +release_type: patch +outcome: + user_can: > + A developer starts a turn and can tell, at a glance, that io-cli is working + rather than hung. A moving indicator sits beside the word that names the + state, and the elapsed clock advances on its own — not only when an event + happens to arrive, which in 0.1.0 meant it froze between steps and jumped + when one landed. On a slow first token, which is the exact moment a user + doubts the tool, the interface is visibly alive. + + + The steps read as steps. Where 0.1.0 committed a decision string with a token + count appended, a step now says what the agent decided, what it ran, and what + came back, laid out so a transcript can be skimmed rather than parsed. + + + And the README carries a screenshot of the interface as it actually is. The + one 0.1.0 could not supply was recorded before the theme step's picker was + fixed, which that release recorded as a known limitation rather than shipping + a picture of a defect. + business_value: > + 0.1.0's own release record carries four interface limitations, three of them + raised by the owner within minutes of the first real session: the working + indicator does not move, the clock stops between events, and a step is hard to + read. None of them stops a session and all of them make the product feel + unfinished in the first thirty seconds, which for an interface release is the + part that matters. + + + This is remediation of what 0.1.0 shipped, not new capability, which is why it + is a patch. It also buys a rule for everything after it: the status line is + the surface 0.2.0 fills with the policy layer, context pressure, spend and + containment, and it is cheaper to make it tick correctly once, now, than to + add four fields to a line that stutters. + evidence_of_value: > + The criteria are deliberately structural rather than temporal. This line of + repositories has paid more for wall-clock assertions on CI runners than for + anything else, and 0.1.0's N4 forbids them outright — so "the clock advances + on its own" is asserted as *a repaint happens without an event arriving*, + driven by an injected clock the test controls, and never as *a repaint + happened within N milliseconds*. + + + F3 is the one that cannot be automated: the owner watches a real turn and says + whether it now reads as alive. The same human gate 0.1.0 put on its look, for + the same reason — this is an interface release and the thing being shipped is + what it feels like. +scope: + included: + - > + **A moving working indicator.** A small animation beside the status line's + state word, advancing on the same tick that drives the clock. The WORD stays + — it is what survives `NO_COLOR`, a screen reader and a log — and the motion + is added beside it, never instead of it. Suppressed entirely under + `NO_COLOR`, where an animation is noise a reader cannot use. + - > + **A repaint tick.** A timer that redraws the viewport while a turn is + running, so the clock advances and the indicator moves without an event + having to arrive. It runs only while a turn is in flight: an idle session + must not repaint, because an interface that redraws forever is what the + alternate-screen products are criticised for and what this renderer exists + not to be. + - > + **Step rendering.** A step becomes a legible line: the decision, the tool it + called with its target, and the result, in that order, with the token count + and the step number as trailing muted detail rather than as the middle of + the sentence. Content before metadata, which is the same rule the rest of + the interface already follows. + - > + **The README screenshot**, recaptured from a run of the corrected + interface, plus the CHANGELOG entry and the version at `0.1.1`. + - > + **The tests for all of it**, including a clock the test drives, which is the + piece that makes the tick assertable without asserting on time. + excluded: + - > + **Anything that changes what a session can do.** No new keys, no new slash + commands, no new event surfaces, no configuration this release starts + reading. A patch that grows capability is a minor pretending otherwise. + - > + **The approval overlay and the refusal surface with rule and layer.** 0.2.0, + unchanged. + - > + **Answering a question the agent asked.** 0.1.0 dead-ended there and now + says so with a way out; letting a user actually answer is new capability and + belongs with the approval surface in 0.2.0. + - > + **Type-to-filter on the picker.** Recorded against 0.7.0, where the roadmap + already promised fuzzy matching, and widened there to every picker rather + than only the command palette. Decided with the owner at 0.1.0's release. + - > + **A growable session viewport.** The composer still scrolls within its two + rows; ratatui fixes an inline viewport's height at construction and + rebuilding mid-session would risk the scrollback. 0.7.0. + fallback_scope: + - > + **The repaint tick and the clock.** A still clock is the one that reads as a + crash, and it is also the piece every later status-line field depends on. A + release that ships only this is coherent: the session visibly advances while + it works. + - > + **The step rendering drops first.** It is legibility, not liveness, and the + current form is cluttered rather than wrong. + - > + **The spinner drops second**, because the word already carries the state and + the moving clock already answers "is it alive". The animation is the polish + on top of the answer. +acceptance: + functional: + - > + **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. + - > + **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. + - > + **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. + - > + **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. + non_functional: + - > + **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. + - > + **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. + - > + **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. + 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.1.1` off `develop`, + one pull request into `develop`, `develop` to `main` by pull request, the + tag `v0.1.1` pushed alone. The rulesets make this mechanical. + - > + **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. +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. + deployment_method: > + One feature branch `feat/0.1.1` 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.1.1` 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 0.1.0 writes is read unchanged; this release + adds no setting and reads no new section. + observability: + - > + Unchanged from 0.1.0: the run's durable trace in the harness store, and a + `RUST_LOG`-gated stderr log that is silent by default. No telemetry. + rollback: > + Deleting the tag and the Release removes the artifacts and the install path, + both reversible. A user already on 0.1.1 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.0 remains installable by setting `IO_VERSION`, which is the reason that + variable exists. +dependencies: + products: + - > + io-harness 0.60.1 or later, unchanged. Nothing in this release requires a + harness change, and if one is discovered it is a harness release and this + one waits. + services: + - > + One live model provider, for F3's real turn. + - > + GitHub Actions and GitHub Releases, which remain the sole distribution + channel. + human_approvals: + - > + The owner watches a real turn and says whether it now reads as alive. This + is F3 and it cannot be delegated to a test — the defect being fixed is that + the interface *looked* stopped, and only a person can say it no longer does. +resource_profile: + complexity: small + uncertainty: low + likely_cost_drivers: + - > + **Making the tick assertable without asserting on time.** The work is not + the timer; it is the seam that lets a test advance a clock by hand, which is + what keeps N1 true. Getting that seam right is most of the release. + - > + **Not repainting when idle.** The easy implementation ticks always, and F2 + exists because that is the version that would ship by accident. + preferred_tools: + - "`tokio::time::interval` for the tick, behind a clock the tests can drive" + - "the existing byte-stream and viewport harnesses in `tests/support/mod.rs`" + constraints: + - > + **No new dependency.** N2 asserts it. + - > + **No timing assertion, on any test, on any platform.** N1 asserts it. + - > + **The word is never replaced by the animation.** A state that only a moving + character conveys is a state a screen reader cannot read. +risks: + - > + **A repaint loop is the easiest way to reintroduce flicker.** 0.1.0's whole + differentiation is that the viewport repaints only when something changed, and + this release adds a timer that repaints on purpose. Mitigation: the tick is + live only while a turn is running, F2 asserts the idle case, and synchronized + output already wraps every frame. + - > + **The spinner is the part most likely to expand.** Frames, colours, a second + animation for tool calls. The fallback scope names it as the first thing to + drop for that reason. +open_questions: + - > + **What does the tick cost on a session that is streaming quickly?** Token + events already drive repaints; a timer on top of them may be redundant while + text is arriving and only useful while it is not. Worth measuring — and + recording, not gating — before choosing an interval. + - > + **Should the step line collapse a long tool target?** A path of two hundred + characters is the common case and the picker already has a fitting rule. Reuse + it, or leave the step to wrap, is a decision for when the line is first + rendered against real content. +released_at: "2026-08-16T18:47:29Z" +mode: published +delivered: > + 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. +evidence: + tests: + - "cargo test — 110 passing, 0 failing, 4 ignored (tests/live.rs, ignored by design), across 22 test binaries" + - "cargo test --test tick — 4 passing: F1 repaints across two ticks with no event ever delivered; F2 pumps ten idle ticks and asserts the byte recorder gained nothing at all" + - "cargo test --test status — 7 passing, including a full ten-frame cycle asserting NO_COLOR keeps the word and shows no frame" + - "cargo test --test events — 12 passing; the two F5 tests assert the ORDER of the five facts by their positions in the rendered line, not their presence" + - "cargo test --test timing — 2 passing: no test in the repository sleeps or reads a clock, and src/main.rs is the only module that reads one" + - "six sabotage arms run, each failing only its own criterion's tests: tick never repaints (F1 alone); guard deleted (F2's three alone); spinner replaces the word (tests/status.rs alone); token count in front of the decision (the two F5 tests alone); a sleeping test appended (the scan named its file and line); an Instant::now added to src/app.rs (the driver-only test named it)" + 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.1.1" + - "cargo tree --depth 1 — the same eight names 0.1.0's N1 permits, plus tempfile as a dev-dependency; nothing was added for the animation" + reviews: + - "F3 completed by the owner, 2026-08-17: the release binary run against a live OpenRouter provider, a real turn watched, verdict `spinner and timer working fine`" + - "the capture from that run is docs/screenshot.png and is now the README's screenshot, replacing the one 0.1.0 recorded as a picture of a defect" + deployments: + - "CI green on macOS, Linux and Windows plus lint, msrv and five CodeQL analyses, on feat/0.1.1 at b478d02" + health_checks: + - "the release binary starts, prints io 0.1.1, and the live session in the F3 run reached a provider and streamed" +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. + - > + **The tick's cost while text is streaming quickly was not measured.** The + contract raised it as an open question to record rather than to gate, and it + was recorded rather than measured: token events already drive repaints, so a + timer on top of them is redundant while text is arriving and useful only while + it is not. The interval is 100 ms, chosen for how motion reads rather than + from a measurement. If a later release measures it, the number belongs there. + - > + **A long tool target in a step line wraps rather than being fitted.** The + picker has a fitting rule that could be reused; the contract left the decision + to the first rendering against real content, and the real content in the F3 + run had no long target in it. Left to wrap, deliberately, rather than + designing against a case not yet seen. + - > + **The indicator is braille and there is no ASCII fallback.** Every frame is + one cell wide, which is why braille was chosen, but a terminal without the + glyphs shows a replacement character where the animation should be. The word + beside it is unaffected, and `NO_COLOR` removes the animation entirely. A + proper plain mode is 0.6.0. + - > + **The README screenshot shows the liveness fix and not the step fix.** The + turn it captures was in its first step, so no step line had committed yet. The + picture proves the clock and the indicator, which is what the release is named + for; F5 is proved by tests asserting the rendered order rather than by the + image. + - > + **Two of 0.1.0's four interface limitations are untouched and stay where the + roadmap put them.** The model picker still has no type-to-filter (0.7.0, + decided with the owner at 0.1.0's release and widened there to every picker), + and a prompt longer than two rows still scrolls within them rather than + growing the viewport (0.7.0, because ratatui fixes an inline viewport's height + at construction). This release fixed the other two. + - > + **An action that needs approval is still declined, and the configuration's + `[sandbox]`, `[run]` and `[instructions]` sections are still not applied to a + turn.** Both are 0.1.0 limitations carried forward unchanged; the approval + overlay is 0.2.0 and the contract sections wait on a harness entry point that + takes both a caller's contract and a steer inbox. + - > + **This record is sealed before the Release exists, which is the flow rather + than a shortfall.** The sealed record and its lock pin ride the feature branch + and merge with the code, so the seal necessarily precedes the tag that cuts + the Release and the workflow that attaches the four artifacts and + `SHA256SUMS`. `mode: published` describes the channel this product has. If the + tag or the matrix fails after this point, the correction is a new version, not + an edit. + - > + **The `fmt and clippy` job failed once on this branch and was re-run.** The + failure was seven seconds into `dtolnay/rust-toolchain`, before any of this + repository's own commands ran, on a SHA that had just gone green on `develop`. + Recorded rather than smoothed over: a failure inside a toolchain download is a + flake, and the same two commands exit 0 locally and in the re-run. +supersedes: 0.1.0 +next_recommended_version: 0.2.0 +immutable: true diff --git a/.ultraship/products/io-cli/roadmap.yaml b/.ultraship/products/io-cli/roadmap.yaml index 96fafe0..d7ce41d 100644 --- a/.ultraship/products/io-cli/roadmap.yaml +++ b/.ultraship/products/io-cli/roadmap.yaml @@ -15,6 +15,18 @@ versions: facilities, because io-cli never took them away. detail: specified status: released + - version: 0.1.1 + outcome: > + 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. + detail: specified + status: released - version: 0.2.0 outcome: > The operator can see and steer the boundary the agent is working under. One key cycles the @@ -75,9 +87,11 @@ versions: 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. 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. + 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. detail: hypothesis status: planned - version: 0.8.0 diff --git a/.ultraship/releases.lock b/.ultraship/releases.lock index e78ee61..9389b7a 100644 --- a/.ultraship/releases.lock +++ b/.ultraship/releases.lock @@ -1,2 +1,3 @@ releases: io-cli/0.1.0: sha256:e25f995472436a25bc7e949e5c7e132fb5582b176027e23ee87235cfe2ab6611 + io-cli/0.1.1: sha256:4b423d161651c290e318601ae7872796eab66dec975f80a4db276f6345f26488 diff --git a/.ultraship/views/master-roadmap.md b/.ultraship/views/master-roadmap.md index e805665..5956fe8 100644 --- a/.ultraship/views/master-roadmap.md +++ b/.ultraship/views/master-roadmap.md @@ -9,12 +9,13 @@ Run `ultraship views` to regenerate. | Version | Outcome | Detail | Status | | --- | --- | --- | --- | | 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.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. 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.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.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 9f761e3..138565c 100644 --- a/.ultraship/views/release-history.md +++ b/.ultraship/views/release-history.md @@ -8,8 +8,30 @@ Run `ultraship views` to regenerate. | Version | Released | Mode | Delivered | | --- | --- | --- | --- | +| 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.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. + +- **The tick's cost while text is streaming quickly was not measured.** The contract raised it as an open question to record rather than to gate, and it was recorded rather than measured: token events already drive repaints, so a timer on top of them is redundant while text is arriving and useful only while it is not. The interval is 100 ms, chosen for how motion reads rather than from a measurement. If a later release measures it, the number belongs there. + +- **A long tool target in a step line wraps rather than being fitted.** The picker has a fitting rule that could be reused; the contract left the decision to the first rendering against real content, and the real content in the F3 run had no long target in it. Left to wrap, deliberately, rather than designing against a case not yet seen. + +- **The indicator is braille and there is no ASCII fallback.** Every frame is one cell wide, which is why braille was chosen, but a terminal without the glyphs shows a replacement character where the animation should be. The word beside it is unaffected, and `NO_COLOR` removes the animation entirely. A proper plain mode is 0.6.0. + +- **The README screenshot shows the liveness fix and not the step fix.** The turn it captures was in its first step, so no step line had committed yet. The picture proves the clock and the indicator, which is what the release is named for; F5 is proved by tests asserting the rendered order rather than by the image. + +- **Two of 0.1.0's four interface limitations are untouched and stay where the roadmap put them.** The model picker still has no type-to-filter (0.7.0, decided with the owner at 0.1.0's release and widened there to every picker), and a prompt longer than two rows still scrolls within them rather than growing the viewport (0.7.0, because ratatui fixes an inline viewport's height at construction). This release fixed the other two. + +- **An action that needs approval is still declined, and the configuration's `[sandbox]`, `[run]` and `[instructions]` sections are still not applied to a turn.** Both are 0.1.0 limitations carried forward unchanged; the approval overlay is 0.2.0 and the contract sections wait on a harness entry point that takes both a caller's contract and a steer inbox. + +- **This record is sealed before the Release exists, which is the flow rather than a shortfall.** The sealed record and its lock pin ride the feature branch and merge with the code, so the seal necessarily precedes the tag that cuts the Release and the workflow that attaches the four artifacts and `SHA256SUMS`. `mode: published` describes the channel this product has. If the tag or the matrix fails after this point, the correction is a new version, not an edit. + +- **The `fmt and clippy` job failed once on this branch and was re-run.** The failure was seven seconds into `dtolnay/rust-toolchain`, before any of this repository's own commands ran, on a SHA that had just gone green on `develop`. Recorded rather than smoothed over: a failure inside a toolchain download is a flake, and the same two commands exit 0 locally and in the re-run. + + ### 0.1.0 known limitations - **The configuration's `[sandbox]`, `[run]` and `[instructions]` sections are read by io-harness and not applied to an io-cli turn.** `Session::turn_steered` builds its own `TaskContract`, and the harness has no entry point taking both a caller's contract and a steer inbox — so honouring them would mean giving up `Ctrl+C`, which F6 requires. The sandbox itself is on; what is missing is the configured ceilings, not the containment. Nothing in this contract promised those sections. Stated in the README and in `docs/config.example.toml`, and it is the natural first item for a harness release. diff --git a/.ultraship/views/workspace-brief.md b/.ultraship/views/workspace-brief.md index c644425..aa2bd4b 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.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. | +| 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. | _Canonical sources: workspace.yaml, products//product.yaml, products//lifecycle.yaml_ diff --git a/CHANGELOG.md b/CHANGELOG.md index 23c6cbf..5b4860f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,37 @@ All notable changes to this project are documented here. The format follows ## [Unreleased] +## [0.1.1] - 2026-08-17 + +The session stops looking frozen while it works. Remediation of what 0.1.0 +shipped, not new capability: no new key, no new command, no new setting, and +nothing about the permission boundary, the renderer or the wizard changes. + +### Added + +- **A moving indicator beside the state word.** A small animation next to + `working`, advancing on the same tick that drives the clock. The word stays — + it is what survives `NO_COLOR`, a screen reader and a log — and the motion is + beside it, never instead of it. Suppressed entirely under `NO_COLOR`, where an + animation is noise a reader cannot use. +- **A repaint tick.** The viewport redraws while a turn is in flight, so the + clock advances and the indicator moves without an event having to arrive. It + runs only while a turn is running: an idle session does not repaint, because a + terminal interface that redraws forever is what this renderer exists not to be. + Both halves are asserted against a clock the tests advance by hand, so no test + sleeps and no test measures how long anything took. +- **A mechanical check that no test in the repository sleeps or reads a clock**, + and that the driver is the only module that reads one at all. + +### Changed + +- **A step reads as a step.** The line is now the decision, then the tool it + called with its target, then the result, with the token count and the step + number trailing as muted detail. 0.1.0 put the step number and the token count + in the middle of the decision. The result is stated in both directions — + `changed files` or `no change` — so a transcript can be skimmed down one + column instead of parsed. + ## [0.1.0] - 2026-08-16 First release: a terminal interface over @@ -61,5 +92,6 @@ client, tool, sandbox, policy engine or session store of its own. - There is no crates.io publish and `cargo install` is not an install path. - No test in this release asserts on wall-clock time. -[Unreleased]: https://github.com/initorigin/io-cli/compare/v0.1.0...HEAD +[Unreleased]: https://github.com/initorigin/io-cli/compare/v0.1.1...HEAD +[0.1.1]: https://github.com/initorigin/io-cli/releases/tag/v0.1.1 [0.1.0]: https://github.com/initorigin/io-cli/releases/tag/v0.1.0 diff --git a/Cargo.lock b/Cargo.lock index 33cae18..ead864a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -880,7 +880,7 @@ dependencies = [ [[package]] name = "io-cli" -version = "0.1.0" +version = "0.1.1" dependencies = [ "clap", "crossterm", diff --git a/Cargo.toml b/Cargo.toml index 35e1f0b..33e2b23 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "io-cli" -version = "0.1.0" +version = "0.1.1" 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 ae793b9..be794ce 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,11 @@ permission boundary and the session store are all reimplemented here. A test asserts that: `tests/dependencies.rs` fails the build if this crate ever grows an HTTP client, a TLS stack, a database or a sandbox. +![A session in flight: the IO CLI mark and the finished transcript sitting in the +terminal's own scrollback, a prompt, and one status line naming the model, a +moving indicator beside the word `working`, and the elapsed clock — which +advances on its own while the first token is still on its way.](docs/screenshot.png) + ## It never takes your terminal `io` does not enter the alternate screen and does not capture the mouse, in any @@ -141,7 +146,8 @@ refusal, error and warning also carries a word. ## What this release is not -0.1.0 is the renderer, the composer, the wizard and one real session. Approval +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. diff --git a/docs/screenshot.png b/docs/screenshot.png new file mode 100644 index 0000000..ba17a07 Binary files /dev/null and b/docs/screenshot.png differ diff --git a/src/app.rs b/src/app.rs index b58136a..591f25e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -5,6 +5,8 @@ //! everything a test needs to answer "what does `Ctrl+C` do in the middle of a //! streaming turn". +use std::time::Duration; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use io_harness::RunEvent; use ratatui::layout::Rect; @@ -18,6 +20,14 @@ use crate::status::Status; use crate::term::VIEWPORT_HEIGHT; use crate::theme::{Theme, Tone}; +/// How often the driver offers the session a tick while a turn is running. +/// +/// Ten a second: fast enough that the indicator reads as motion rather than as a +/// character changing, slow enough that it is nothing next to the repaints a +/// streaming answer already causes. It is an offer rather than a repaint — see +/// [`App::tick`], which is what decides whether a frame is drawn. +pub const TICK: Duration = Duration::from_millis(100); + /// Whether a turn is in flight. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Mode { @@ -91,6 +101,27 @@ impl App { self.pending.extend(tail); } + /// The clock moved. Returns whether the viewport has to be redrawn. + /// + /// This is the whole of the release's liveness, and it is a *function of an + /// age the caller supplies* rather than of a timer this type reads. That is + /// deliberate and it is what makes the two properties assertable: a test + /// advances `age` by hand and asks whether a frame is owed, with nothing + /// sleeping and nothing measured. + /// + /// An idle session is told no, and is not even given the new time. A terminal + /// interface that redraws forever is the thing this renderer exists not to + /// be, so the tick is live only while a turn is: between turns the clock is + /// answered by the next keystroke, which repaints anyway. + pub fn tick(&mut self, age: Duration) -> bool { + if self.mode != Mode::Running { + return false; + } + self.status.elapsed = age; + self.status.advance(); + true + } + /// Take an event from the harness. pub fn event(&mut self, event: &RunEvent) { let lines = self.events.event(event); diff --git a/src/events.rs b/src/events.rs index eace90f..2c7a2f0 100644 --- a/src/events.rs +++ b/src/events.rs @@ -16,6 +16,7 @@ use io_harness::{EventKind, RunEvent}; use ratatui::text::{Line, Span}; +use crate::status::SEPARATOR; use crate::theme::{Theme, Tone}; /// Accumulates streaming text and turns events into committed lines. @@ -106,18 +107,34 @@ impl Events { // A step's own narration commits after whatever streamed before // it, so the transcript reads in the order it happened. let mut lines = self.flush(); - let mut detail = format!("step {}", event.step); + + // What was decided, what it ran, what came back — then the + // metadata. 0.1.0 put the step number and the token count in the + // middle of that sentence, which made a transcript something to + // parse rather than to skim. Content before metadata is the rule + // the rest of the interface already follows. + let mut spans = vec![Span::styled(decision.clone(), theme.style(Tone::Normal))]; if !tool_call.is_empty() { - detail.push_str(&format!(" · {tool_call}")); - } - detail.push_str(&format!(" · {tokens} tok")); - if *changed { - detail.push_str(" · changed files"); + spans.push(Span::styled(SEPARATOR, theme.style(Tone::Muted))); + spans.push(Span::styled(tool_call.clone(), theme.style(Tone::Accent))); } - lines.push(Line::from(vec![ - Span::styled(decision.clone(), theme.style(Tone::Muted)), - Span::styled(format!(" {detail}"), theme.style(Tone::Muted)), - ])); + // Always said, in both directions. A result that appears only + // sometimes is a column a reader cannot skim down, and `changed` + // is the one thing this event reports about what came back. + spans.push(Span::styled(SEPARATOR, theme.style(Tone::Muted))); + spans.push(Span::styled( + if *changed { + "changed files" + } else { + "no change" + }, + theme.style(if *changed { Tone::Success } else { Tone::Muted }), + )); + spans.push(Span::styled( + format!("{SEPARATOR}{tokens} tok{SEPARATOR}step {}", event.step), + theme.style(Tone::Muted), + )); + lines.push(Line::from(spans)); lines } EventKind::ToolCall { name, target } => { diff --git a/src/main.rs b/src/main.rs index 26fa835..21ed133 100644 --- a/src/main.rs +++ b/src/main.rs @@ -386,9 +386,22 @@ async fn turn( let mut running = 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. + // `MissedTickBehavior::Delay` rather than the default: a turn that blocked the + // loop should resume ticking from now, not fire a burst catching up on the + // frames nobody saw. + let mut ticker = tokio::time::interval(io_cli::app::TICK); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + let outcome = loop { tokio::select! { result = &mut running => break result, + _ = ticker.tick() => { + if app.tick(started.elapsed()) { + paint(screen, app)?; + } + } Some(event) = events.recv() => { app.event(&event); app.status.elapsed = started.elapsed(); diff --git a/src/status.rs b/src/status.rs index 298e5bf..1194cb2 100644 --- a/src/status.rs +++ b/src/status.rs @@ -21,7 +21,21 @@ use crate::theme::{Theme, Tone}; /// What separates two fields. Spaced, because a bare separator between two words /// reads as punctuation inside one of them. -const SEPARATOR: &str = " · "; +/// +/// Public because the step line separates its own parts with it. One separator in +/// the product, so a transcript and the status line under it read as one surface. +pub const SEPARATOR: &str = " · "; + +/// The frames of the working indicator. +/// +/// Braille, because every frame is exactly one cell wide — a spinner built from +/// characters of differing width shifts the whole line right and left as it turns, +/// which is worse than not moving at all. Ten frames and a modulo; a crate for +/// this would be the beginning of the thing this product exists not to become. +/// +/// It never carries a meaning of its own. The state is the word beside it, and +/// this is only the evidence that the word is still true. +pub const SPINNER: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; /// A field of the status line, in priority order: the first is the last to be /// dropped when the terminal is narrow. @@ -49,6 +63,9 @@ pub struct Status { pub working: bool, /// How long the session has been open. pub elapsed: Duration, + /// 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, } impl Status { @@ -57,28 +74,46 @@ impl Status { model: model.into(), working: false, elapsed: Duration::ZERO, + frame: 0, } } + /// Move the indicator on one frame. Called from the tick and from nowhere + /// else, so the animation cannot outlive the thing it is reporting on. + pub fn advance(&mut self) { + self.frame = self.frame.wrapping_add(1); + } + + /// The indicator, if there is anything to indicate and anywhere to show it. + /// + /// `None` under `NO_COLOR`, where an animation is noise a reader cannot use — + /// and `None` when nothing is running, because a session that spins while it + /// waits for a prompt is lying about being busy. + pub fn indicator(&self, theme: &Theme) -> Option { + (self.working && theme.coloured).then(|| SPINNER[self.frame % SPINNER.len()]) + } + /// The fields, most important first. - pub fn fields(&self) -> Vec { + pub fn fields(&self, theme: &Theme) -> Vec { + // The WORD is the state, and the animation is only beside it. A spinner + // carries a meaning solely for a reader who can see it move, and this + // line has to work in a screen reader, under `NO_COLOR` and in a log — so + // the indicator is a prefix on the field, never the field itself. + let state = match (self.working, self.indicator(theme)) { + (true, Some(frame)) => Field::new(format!("{frame} working"), Tone::Normal), + (true, None) => Field::new("working", Tone::Normal), + (false, _) => Field::new("ready", Tone::Muted), + }; vec![ Field::new(self.model.clone(), Tone::Accent), - // A word, not an animation. A spinner carries the same meaning only - // for a reader who can see it move, and this line has to work in a - // screen reader and in a log. - if self.working { - Field::new("working", Tone::Normal) - } else { - Field::new("ready", Tone::Muted) - }, + state, Field::new(format_elapsed(self.elapsed), Tone::Muted), ] } /// The line, fitted to `width` by dropping whole fields from the right. pub fn line(&self, width: u16, theme: &Theme) -> Line<'static> { - let fields = self.fields(); + let fields = self.fields(theme); let width = width as usize; let mut kept: Vec<&Field> = Vec::new(); diff --git a/tests/events.rs b/tests/events.rs index 293df14..4e6fc03 100644 --- a/tests/events.rs +++ b/tests/events.rs @@ -87,6 +87,81 @@ fn rendered(events: &mut Events, kind: EventKind) -> String { .collect() } +#[test] +fn f5_a_step_reads_decision_then_tool_then_result_then_its_metadata() { + let mut events = Events::new(DARK); + let line: String = events + .event(&RunEvent::new( + 1, + 7, + EventKind::Step { + decision: "edited the parser".into(), + tool_call: "apply_patch src/lib.rs".into(), + tokens: 1234, + changed: true, + }, + )) + .iter() + .flat_map(|line| line.spans.iter()) + .map(|span| span.content.as_ref()) + .collect(); + + // The order is the assertion, not the contents. A line carrying all five + // facts in the wrong order is the line 0.1.0 shipped. + let at = |needle: &str| { + line.find(needle) + .unwrap_or_else(|| panic!("{needle:?} is missing from {line:?}")) + }; + let decision = at("edited the parser"); + let tool = at("apply_patch src/lib.rs"); + let result = at("changed files"); + let tokens = at("1234 tok"); + let step = at("step 7"); + + assert!( + decision < tool, + "the tool came before the decision: {line:?}" + ); + assert!(tool < result, "the result came before the tool: {line:?}"); + assert!( + result < tokens, + "the token count came before the result: {line:?}", + ); + assert!( + tokens < step, + "the step number came before the token count: {line:?}", + ); +} + +#[test] +fn f5_a_step_that_changed_nothing_still_says_so() { + let mut events = Events::new(DARK); + let line: String = events + .event(&RunEvent::new( + 1, + 2, + EventKind::Step { + decision: "read the failing test".into(), + tool_call: String::new(), + tokens: 88, + changed: false, + }, + )) + .iter() + .flat_map(|line| line.spans.iter()) + .map(|span| span.content.as_ref()) + .collect(); + + // The result is always present, so a skim down the transcript reads the same + // column of answers whether or not a step touched anything. + assert!(line.contains("no change"), "{line:?}"); + assert!( + line.find("read the failing test") < line.find("no change"), + "{line:?}", + ); + assert!(line.find("no change") < line.find("88 tok"), "{line:?}"); +} + #[test] fn f8_every_styled_kind_renders_its_own_facts() { let mut events = Events::new(DARK); diff --git a/tests/no_color.rs b/tests/no_color.rs index 48fb290..5b1aec8 100644 --- a/tests/no_color.rs +++ b/tests/no_color.rs @@ -111,7 +111,11 @@ fn the_splash_is_suppressed_without_colour_a_tty_or_the_width_for_it() { .flat_map(|line| line.spans.iter()) .map(|span| span.content.as_ref()) .collect(); - assert!(rendered.contains("io 0.1.0"), "got {rendered:?}"); + // Read from the manifest rather than written out, so the assertion is that + // the splash names THIS build; a literal here goes stale at every bump and + // says nothing about the version actually shipping. + let version = format!("io {}", env!("CARGO_PKG_VERSION")); + assert!(rendered.contains(&version), "got {rendered:?}"); assert!(!rendered.contains('█'), "the mark was drawn anyway"); } diff --git a/tests/status.rs b/tests/status.rs index 88e5971..08bbddd 100644 --- a/tests/status.rs +++ b/tests/status.rs @@ -46,6 +46,67 @@ fn the_running_state_is_a_word_and_not_only_a_colour() { assert!(plain.contains("working"), "got {plain:?}"); } +#[test] +fn f4_a_running_turn_carries_a_moving_indicator_beside_the_word() { + let mut status = Status::new("m"); + status.working = true; + + let first = rendered(&status, 80); + assert!(first.contains("working"), "got {first:?}"); + let spinning = first + .chars() + .find(|character| io_cli::status::SPINNER.contains(character)) + .expect("a running turn shows an indicator"); + + // The tick is what moves it, and it moves on the tick alone — nothing here + // waits for anything. + status.advance(); + let second = rendered(&status, 80); + let moved = second + .chars() + .find(|character| io_cli::status::SPINNER.contains(character)) + .expect("the indicator is still there"); + assert_ne!( + spinning, moved, + "the indicator did not move between two ticks: {second:?}", + ); + + // An idle session has nothing to be alive about. + status.working = false; + let idle = rendered(&status, 80); + assert!( + !idle.chars().any(|c| io_cli::status::SPINNER.contains(&c)), + "an idle session was animating: {idle:?}", + ); +} + +#[test] +fn f4_no_color_keeps_the_word_and_drops_the_animation() { + let mut status = Status::new("m"); + status.working = true; + + for tick in 0..SPINNER_LEN { + let plain: String = status + .line(80, &PLAIN) + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect(); + assert!( + plain.contains("working"), + "the word went with the animation at tick {tick}: {plain:?}", + ); + assert!( + !plain.chars().any(|c| io_cli::status::SPINNER.contains(&c)), + "NO_COLOR animated at tick {tick}: {plain:?}", + ); + status.advance(); + } +} + +/// How many frames the indicator cycles through. +const SPINNER_LEN: usize = io_cli::status::SPINNER.len(); + #[test] fn f9_a_narrow_terminal_drops_whole_fields_from_the_right() { let mut status = Status::new("anthropic/claude-sonnet-4"); @@ -54,16 +115,16 @@ fn f9_a_narrow_terminal_drops_whole_fields_from_the_right() { let wide = rendered(&status, 80); assert_eq!( - wide, "anthropic/claude-sonnet-4 · working · 1m12s", + wide, "anthropic/claude-sonnet-4 · ⠋ working · 1m12s", "the full line at eighty columns", ); // Room for the model and the state, but not the clock. - let narrow = rendered(&status, 36); - assert_eq!(narrow, "anthropic/claude-sonnet-4 · working"); + let narrow = rendered(&status, 38); + assert_eq!(narrow, "anthropic/claude-sonnet-4 · ⠋ working"); // Room for the model only. - let narrower = rendered(&status, 28); + let narrower = rendered(&status, 30); assert_eq!(narrower, "anthropic/claude-sonnet-4"); for width in [1u16, 8, 20, 25, 26, 40, 43, 44, 200] { diff --git a/tests/tick.rs b/tests/tick.rs new file mode 100644 index 0000000..3a2db4d --- /dev/null +++ b/tests/tick.rs @@ -0,0 +1,122 @@ +//! F1 and F2 — the session repaints while a turn runs, and never while it is idle. +//! +//! Both are asserted against a clock the test advances by hand. Nothing here +//! sleeps and nothing here measures how long anything took: `App::tick` takes the +//! session's age as an argument precisely so that the two properties can be +//! checked without a timer being involved in the checking. N1 depends on that, +//! and `tests/timing.rs` enforces it over this file too. + +mod support; + +use std::time::Duration; + +use io_cli::app::App; +use io_cli::theme::DARK; + +/// What the driver does with a tick: repaint if, and only if, the tick says to. +/// +/// This is the shape `main.rs` uses, reproduced here because the property being +/// asserted is about the pair — a `tick` that returns the right answer and a +/// driver that ignores it would still redraw forever. +fn pump(app: &mut App, screen: &mut io_cli::term::Screen, age: Duration) -> bool { + if !app.tick(age) { + return false; + } + screen + .draw(|frame| app.render(frame, frame.area())) + .expect("frame"); + true +} + +/// The status line is the last row the viewport drew. +fn status_row(screen: &io_cli::term::Screen) -> String { + screen + .viewport_text() + .lines() + .next_back() + .unwrap_or_default() + .to_string() +} + +#[test] +fn f1_a_running_turn_repaints_with_no_event_arriving() { + let (mut screen, _recorder) = support::screen(80, 24); + let mut app = App::new(DARK, "anthropic/claude-sonnet-4"); + app.started(); + + // No event is ever delivered to `app` in this test. The only thing that + // happens is the clock moving. + assert!( + pump(&mut app, &mut screen, Duration::from_secs(1)), + "a running turn did not repaint on the tick", + ); + let first = status_row(&screen); + + assert!(pump(&mut app, &mut screen, Duration::from_secs(2))); + let second = status_row(&screen); + + assert_ne!( + first, second, + "the status line did not change between two ticks of a running turn", + ); + assert!( + second.contains("2s"), + "the clock did not advance on its own: {second:?}", + ); +} + +#[test] +fn f2_an_idle_session_never_repaints() { + let (mut screen, recorder) = support::screen(80, 24); + let mut app = App::new(DARK, "anthropic/claude-sonnet-4"); + + // One frame so the recorder holds a session that has already drawn; what is + // asserted below is that nothing is added to it, not that nothing was ever + // written. + screen + .draw(|frame| app.render(frame, frame.area())) + .expect("frame"); + let quiet = recorder.bytes().len(); + + for second in 1..=10 { + assert!( + !pump(&mut app, &mut screen, Duration::from_secs(second)), + "an idle session repainted at second {second}", + ); + } + + assert_eq!( + recorder.bytes().len(), + quiet, + "an idle session wrote to the terminal", + ); +} + +#[test] +fn f2_the_tick_stops_again_when_the_turn_ends() { + let (mut screen, _recorder) = support::screen(80, 24); + let mut app = App::new(DARK, "m"); + + // Whether the running tick fires is F1's assertion and is deliberately not + // repeated here, so that sabotaging the repaint fails F1's test and this one + // keeps testing only what it is named for. + app.started(); + pump(&mut app, &mut screen, Duration::from_secs(1)); + + app.finished(); + assert!( + !pump(&mut app, &mut screen, Duration::from_secs(2)), + "the tick outlived the turn that justified it", + ); +} + +#[test] +fn an_idle_tick_does_not_move_the_clock() { + let mut app = App::new(DARK, "m"); + app.tick(Duration::from_secs(30)); + assert_eq!( + app.status.elapsed, + Duration::ZERO, + "an idle session advanced its own clock, which is a repaint waiting to happen", + ); +} diff --git a/tests/timing.rs b/tests/timing.rs new file mode 100644 index 0000000..4c6bab6 --- /dev/null +++ b/tests/timing.rs @@ -0,0 +1,141 @@ +//! N1 — no test in this repository sleeps, and none measures elapsed time. +//! +//! This is the constraint the whole clock seam exists to satisfy: `App::tick` +//! takes the session's age as an argument rather than reading a clock precisely +//! so that liveness can be asserted without a timer being involved in the +//! asserting. A constraint checked by reading is a constraint that decays, and +//! this release adds a repaint timer — which is exactly the kind of work that +//! grows a sleeping test in the next one. +//! +//! What it does NOT forbid: `Duration`, and the production clock read in the +//! driver. Something has to read a real clock; the driver is the only thing that +//! may, and it is the reason nothing else has to. The second test below is what +//! keeps that "only". + +use std::path::Path; + +/// The needles, assembled from pieces so that this file does not match itself. +/// A scanner that has to skip its own path is a scanner with a hole in it. +fn forbidden() -> Vec<(String, &'static str)> { + vec![ + ( + format!("thread::{}", "sleep"), + "a test that sleeps is a test that fails on a loaded runner", + ), + ( + format!("time::{}", "sleep"), + "an async sleep is a wall-clock assertion wearing a different hat", + ), + ( + format!("{}::now", "Instant"), + "reading a clock in a test is how a timing assertion gets written", + ), + ( + format!("{}::now", "SystemTime"), + "the same, with a clock that can also move backwards", + ), + ( + format!(".{}()", "elapsed"), + "measuring how long something took is the assertion N1 forbids", + ), + ] +} + +/// Every `.rs` file under `tests/`, including this one. +fn test_sources() -> Vec<(std::path::PathBuf, String)> { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests"); + let mut found = Vec::new(); + let mut stack = vec![root]; + while let Some(directory) = stack.pop() { + for entry in std::fs::read_dir(&directory) + .unwrap_or_else(|error| panic!("{} is readable: {error}", directory.display())) + .flatten() + { + let path = entry.path(); + if path.is_dir() { + stack.push(path); + } else if path.extension().is_some_and(|extension| extension == "rs") { + let source = std::fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("{} is readable: {error}", path.display())); + found.push((path, source)); + } + } + } + found +} + +#[test] +fn n1_no_test_sleeps_or_measures_elapsed_time() { + let sources = test_sources(); + + // A scan that read nothing would pass for the wrong reason, and a silent + // empty run is the failure mode this line of repositories has paid for + // before. The floor is deliberately loose — it is a tripwire against a + // scanner that found no files, not a count to maintain. + assert!( + sources.len() >= 10, + "the scan found only {} test files, which means it is not scanning", + sources.len(), + ); + assert!( + sources.iter().any(|(path, _)| path.ends_with("timing.rs")), + "the scan did not even find itself", + ); + + let mut violations = Vec::new(); + for (path, source) in &sources { + for (needle, why) in forbidden() { + for (number, line) in source.lines().enumerate() { + if line.contains(&needle) { + violations.push(format!( + "{}:{}: {needle} — {why}", + path.display(), + number + 1, + )); + } + } + } + } + + assert!( + violations.is_empty(), + "N1 is broken:\n{}", + violations.join("\n"), + ); +} + +#[test] +fn n1_the_driver_is_the_only_thing_that_reads_a_clock() { + let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut readers = Vec::new(); + for entry in std::fs::read_dir(&src).expect("src is readable").flatten() { + let path = entry.path(); + if path.extension().is_none_or(|extension| extension != "rs") { + continue; + } + let source = std::fs::read_to_string(&path).expect("a source file is readable"); + if source.contains(&format!("{}::now", "Instant")) { + readers.push( + path.file_name() + .expect("a file has a name") + .to_string_lossy() + .into_owned(), + ); + } + } + + // `read_dir` yields in whatever order the filesystem chose, which differs + // between the three platforms CI runs on. Sorted, so the failure message is + // the same everywhere and the assertion is about the set rather than the + // directory's mood. + readers.sort(); + + // One reader, and it is the driver. Every other module is handed the age it + // needs, which is what makes the session's liveness testable at all — and + // what stops a second, unsynchronised clock appearing in a later release. + assert_eq!( + readers, + vec!["main.rs".to_string()], + "something other than the driver started reading a clock", + ); +}