diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dbdc4ec..c9e5842 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,19 +5,36 @@ on: branches: [main] pull_request: branches: [main] + workflow_dispatch: + +# Cancel superseded runs on the same ref so a fresh push doesn't queue +# behind its own stale jobs. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +# Least privilege: CI only reads the repo. Jobs that need more request it. +permissions: + contents: read env: CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 RUSTFLAGS: "-D warnings" +# Third-party actions are pinned to commit SHAs (version in the trailing +# comment). Bump the SHA and the comment together; never use a mutable tag. jobs: fmt: name: rustfmt runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + # `toolchain` must match rust-toolchain.toml / Cargo.toml rust-version (Q10); + # this pinned action requires the input explicitly. Keep the three in sync. + - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # master with: + toolchain: "1.95.0" components: rustfmt - run: cargo fmt --all --check @@ -29,11 +46,12 @@ jobs: matrix: os: [macos-latest, windows-latest, ubuntu-latest] steps: - - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # master with: + toolchain: "1.95.0" # keep in lockstep with rust-toolchain.toml (Q10) components: clippy - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2.9.1 - run: cargo clippy --workspace --all-targets -- -D warnings test: @@ -44,25 +62,60 @@ jobs: matrix: os: [macos-latest, windows-latest, ubuntu-latest] steps: - - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - - uses: taiki-e/install-action@nextest + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # master + with: + toolchain: "1.95.0" # keep in lockstep with rust-toolchain.toml (Q10) + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2.9.1 + - uses: taiki-e/install-action@d9be7d8cda89035c9c843f78bd44d4f72d8403d4 # v2.79.7 + with: + tool: nextest - run: cargo nextest run --workspace deny: name: cargo-deny runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: EmbarkStudios/cargo-deny-action@v2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: taiki-e/install-action@d9be7d8cda89035c9c843f78bd44d4f72d8403d4 # v2.79.7 + with: + tool: cargo-deny + - run: cargo deny check + + actionlint: + name: actionlint + runs-on: ubuntu-latest + # release.yml is generated by cargo-dist and trips shellcheck info/style + # notes (SC2086/SC2129) we don't own. Drop those severities so actionlint + # still catches real warnings/errors in our own workflows. + env: + SHELLCHECK_OPTS: --severity=warning + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Install actionlint + # taiki-e/install-action does not ship actionlint and the crate has + # no prebuilt binaries, so we use the upstream downloader — pinned to + # actionlint's v1.7.7 commit and verified by sha256 before running, so + # this is never an unverified `curl | bash`. + id: install + env: + ACTIONLINT_SHA: 03d0035246f3e81f36aed592ffb4bebf33a03106 # v1.7.7 + DOWNLOADER_SHA256: 221d1d16c03e4e4fcd867de34104e8d479bdce20ccdfa553b9a5c0dc29bf6af2 + run: | + curl -fsSL -o download-actionlint.bash \ + "https://raw.githubusercontent.com/rhysd/actionlint/${ACTIONLINT_SHA}/scripts/download-actionlint.bash" + echo "${DOWNLOADER_SHA256} download-actionlint.bash" | sha256sum --check --status + bash download-actionlint.bash 1.7.7 + echo "path=$(pwd)/actionlint" >> "$GITHUB_OUTPUT" + - name: Run actionlint + run: ${{ steps.install.outputs.path }} -color markdown: name: markdownlint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: DavidAnson/markdownlint-cli2-action@v23 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: DavidAnson/markdownlint-cli2-action@ded1f9488f68a970bc66ea5619e13e9b52e601cd # v23 with: globs: '**/*.md' config: '.markdownlint-cli2.jsonc' diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..c839113 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,53 @@ +name: CodeQL + +# Whole-program static analysis for the Rust workspace. CodeQL builds the +# project, extracts a database, and runs the `security-and-quality` suite; +# findings land in the Security tab. Complements cargo-deny (CVE-driven, +# dependency-side) and Clippy (in-tree style + simple soundness) with taint +# tracking and cross-function patterns those cannot see. + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + # Weekly: catches drift in the query packs themselves (new queries may + # flag code already on main). + - cron: '0 7 * * 1' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + analyze: + name: Analyze (Rust) + # No path gate: SAST covers every commit on the default branch. + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write # upload SARIF to code-scanning + actions: read # required for private repos; harmless on public + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + # toolchain pinned to match rust-toolchain.toml / Cargo.toml (Q10). + - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # master + with: + toolchain: "1.95.0" + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2.9.1 + - name: Initialize CodeQL + uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 + with: + languages: rust + queries: security-and-quality + - name: Autobuild + uses: github/codeql-action/autobuild@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 + - name: Perform analysis + uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 + with: + category: "/language:rust" diff --git a/.gitignore b/.gitignore index 0bfdcf4..ccf102e 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,9 @@ # personal notes (not part of the project) /.personal/ +# generated review decks — keep the Typst source, not the rendered PDF +/docs/reviews/*.pdf + # Claude Code local (per-machine) settings — never shared. The shared # .claude/settings.json may be tracked; the *.local.json overlay is not. .claude/settings.local.json diff --git a/AGENTS.md b/AGENTS.md index 49b3155..1803945 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,6 +98,9 @@ exists). Do not relax them locally. ## Conventions +- Coding standards (Tidy First, CUPID & YAGNI, TDD + Reflect, Clean Code) live + in [`CODING_STANDARDS.md`](CODING_STANDARDS.md). This file (AGENTS.md) takes + precedence where they collide. - Markdown prose: 80-col wrap (tables / code blocks exempt, see `.markdownlint-cli2.jsonc`). - Commit messages: no "Claude" signature (per global user instruction). diff --git a/CODING_STANDARDS.md b/CODING_STANDARDS.md new file mode 100644 index 0000000..4ffa595 --- /dev/null +++ b/CODING_STANDARDS.md @@ -0,0 +1,427 @@ +# Coding Standards + +This file is the working agreement for code in this repo. It is meant to be +re-read on a slow day, not skimmed once. Four pillars, in the order you +usually apply them: + +1. **Tidy First** — separate behaviour changes from clean-ups. +2. **CUPID & YAGNI** — properties to aim for in design and refactoring. +3. **TDD (Red → Green → Refactor → Reflect)** — the loop that keeps the above + honest. +4. **Clean Code** — local taste rules that survive automation. + +Repo-specific rules take precedence when they collide. The authoritative +sources, in order, are [`AGENTS.md`](AGENTS.md), [`docs/PRD.md`](docs/PRD.md), +and [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md). This file expands on the +*how*; those define the *what* and *why*. + +--- + +## 1. Tidy First (Kent Beck) + +> *Make the change easy, then make the easy change.* + +Behaviour changes and structural changes are **two different commits**. + +- **Tidying** — renames, extractions, dead-code removal, reformatting, + splitting a long function, adding a missing test that pins existing + behaviour. Never alters observable output. +- **Behaviour change** — the actual feature, fix, or contract change. + +Rules of thumb: + +- If the diff to add a feature feels too big, stop. Tidy the surrounding code + first (in its own commit), then come back. The feature commit shrinks. +- Tests that pin existing behaviour are **Must-have**, not Could-have. Land + them *before* the behaviour change. The point of pinning is to make the + behaviour change reviewable as a small, intentional diff. +- If a tidy ends up changing observable behaviour, it wasn't a tidy. Revert + and split. + +Acceptable commit shapes: + +```text +✅ refactor(core): extract allocate_session helper (tidy) + feat(m2): bound the PTY output channel (behaviour) + +❌ feat(m2): bound the channel + tidy session registry +``` + +--- + +## 2. CUPID & YAGNI + +Five properties to optimise for, in roughly this order: + +| Property | One-liner | Smell when violated | +| ------------------- | ------------------------------------------------------- | ------------------------------------------------- | +| **Composable** | Plays well with others; small surface, no surprises. | "I have to mock half the world to test this." | +| **Unix philosophy** | Does one thing well. | Module/trait with `and` in its purpose statement. | +| **Predictable** | Behaves as expected; no hidden state, no spooky action. | "Works on my machine" / order-dependent tests. | +| **Idiomatic** | Reads like the language and the codebase. | Reviewer says "this is clever" with a sigh. | +| **Domain-based** | Names match the product's vocabulary. | Generic `Manager`/`Helper`/`Util` names. | + +### C — Composable + +Pieces plug together without special knowledge of each other. + +- `core` is decoupled from `app`/iced; the headless `App` can be driven from a + test harness with no GUI. +- Adapters plug in through port traits (`core::ports::{ProjectScanner, + PtyHost}`); add, remove, or swap an adapter without touching `core`. +- `Event`/`Effect` are the contract between the shell and the core: `apply` + consumes an `Event` and returns `Effect`s. The shell never reaches into core + internals. + +**Watch for**: shell code that re-implements domain decisions `core` should +own (focus routing, selection, status folding). If several call sites want the +same derived view, hoist it into `core` once instead of recomputing per site. + +### U — Unix philosophy + +Do one thing well. + +- One tokio runtime, **actor-per-session**: each session task owns its PTY + handle and terminal grid; everyone else talks to it over channels. +- One crate, one job: `claude` parses, `scan` walks the filesystem, `pty` + drives terminals, `core` decides, `app` renders. + +**Watch for**: an adapter that "while we're here" makes a domain decision. The +decision belongs in `core`; the adapter only performs the `Effect`. + +### P — Predictable + +Same input, same output, on any machine. + +- `core::App::apply` is **pure**: no I/O, no clock, no panic. The same `Event` + sequence yields the same `Effect`s anywhere. +- Deterministic iteration: if order matters, sort explicitly — don't rely on + `HashMap`/`HashSet` iteration order. +- Broken invariants surface as `None`/`Err` (see the `Workspace` mutators), + never via `unwrap` or a panic. + +Non-deterministic work (filesystem, PTY, time, network) lives in adapters +behind ports, never in `core` or `claude`. + +### I — Idiomatic + +Feels like modern Rust to a Rust reader. + +- Newtypes and enums to make impossible states impossible: `SessionId` over a + bare `u64`, `Event`/`Effect`/`SessionStatus` as enums, not strings or bools. +- Iterator chains (`map`, `filter`, `fold`) over accumulating in a mut `Vec` — + when it reads cleaner. Use a `for` loop when it doesn't. +- `Result` and `Option` over sentinel values. Pattern matching over + nested `if let`. +- `thiserror` for library errors in `core`/`claude`; `anyhow` is acceptable at + the `app` binary boundary for terminal errors. No `unwrap`/`expect`/`panic` + in `core`/`claude` (clippy-denied) unless provably unreachable and commented. + +For example, prefer a newtype over a bare integer so illegal states can't be +constructed: + +```rust +// Bad: any u64 is a session id, including 0 or a stale one. +fn write(&self, session: u64, bytes: &[u8]) -> Result<(), PtyError>; + +// Good: a session id is non-zero and minted only by the registry. +pub struct SessionId(pub NonZeroU64); +fn write(&self, session: SessionId, bytes: &[u8]) -> Result<(), PtyError>; +``` + +### D — Domain-based + +The code speaks the product's vocabulary: sessions, panes, terminals. + +- Types map to the domain: `Session`, `Workspace`, `Pane`, `Tab`, `Screen`, + `Project`, `Event`, `Effect`, `SessionStatus`. +- File and module names name the concept, not the data structure: + `workspace.rs`, `browser.rs`, `digest.rs`, `osc.rs`. +- Avoid `Manager`/`Helper`/`Util`. The Electron god-object is the cautionary + tale this rewrite exists to escape (PRD §4). + +**Watch for**: implementation-named helpers leaking into a port's public API. +Rename toward the domain when promoting something to the trait surface. + +### YAGNI (anti-speculation rule) + +CUPID describes what good code *is*; YAGNI protects against building what you +don't need yet. + +- No abstraction for a second adapter that doesn't exist. A concrete adapter is + fine until the next one actually lands (`mcp` is Unsure — don't pre-abstract + for it). +- No trait with a single implementation — **except a port** that deliberately + decouples `core` from an adapter. That is the hexagonal contract, not + speculation; the distinction is whether the trait exists to invert a + dependency or merely to look extensible. +- No field that duplicates information derivable from another (don't store what + you can compute from the pane tree; derive it with a helper). +- No macro to remove scaffolding when the scaffolding *is* the contract. + +When a refactor toward CUPID would require speculative work, stop and wait for +the second use case. + +--- + +## 3. TDD with a fourth step — Reflect + +The standard Red → Green → Refactor loop, with a deliberate **Reflect** beat at +the end of each cycle. The reflect step is what keeps the loop from grinding +out lots of small green tests that don't add up to a coherent design. + +```text + ┌──────────┐ + │ RED │ Write the smallest failing test that names the + │ │ behaviour you want. Run it. Confirm it fails for + │ │ the right reason (not a typo, not an import). + └────┬─────┘ + │ + ▼ + ┌──────────┐ + │ GREEN │ Write the least code that makes the test pass. + │ │ Ugly is fine here. Don't generalise yet. + └────┬─────┘ + │ + ▼ + ┌──────────┐ + │ REFACTOR │ With the test green, clean up — names, duplication, + │ │ shape. Tests stay green between every keystroke. + │ │ This is a TIDY (see §1); commit it separately. + └────┬─────┘ + │ + ▼ + ┌──────────┐ + │ REFLECT │ Pause. Ask: + │ │ • What did this cycle teach me? + │ │ • What surprised me (red took longer? green was + │ │ trivial? refactor revealed a missing concept)? + │ │ • Is the *next* test on my list still the right + │ │ one, or did this cycle change the plan? + │ │ • Is there a test I should retire because it now + │ │ overlaps with a stronger one? + │ │ • Did I learn a domain rule worth pinning in + │ │ another test, separate from the one I just + │ │ wrote? + │ │ Update the test list. Then loop. + └────┬─────┘ + │ + ▼ + (next test) +``` + +Reflect rules: + +- **Reflect is short.** A minute, sometimes thirty seconds. If it becomes a + meeting, do it asynchronously between cycles. +- **Reflect updates the plan, not the code.** If reflection reveals code that + should change, that's the *next* RED test, not an edit you smuggle into the + current cycle. +- **Reflect after Green-but-no-Refactor cycles too.** "There was nothing to + clean" is itself a signal — either the design is good or you're not looking + hard enough. +- **Always surface findings to the user with a recommendation.** Every + reflection that produces a finding (a new test worth pinning, a surprise that + suggests a missing concept, a smell you noticed) gets a one-line decision + prompt: *"apply now / add to today / add to the roadmap / forget it"*. Do not + silently carry findings forward and do not silently apply them. + + Recommend the best move per the principles, and say *why* in one short + clause. Heuristics: + - **Apply now** — the finding closes a still-open hole from the cycle just + finished, the fix is small, and skipping it would leave the work + half-done. (e.g. forward contract test landed → reverse test is ~30 LOC → + apply now closes the lesson.) + - **Add to today** — same-session work, but it would derail the current + task; better as the next discrete cycle. + - **Add to the roadmap** — useful but not on the critical path; capture it in + [`ROADMAP.md`](ROADMAP.md) so it's not lost. + - **Forget it** — speculative, low-leverage, or you're not sure it's real. + Recording every passing thought is its own debt. + + Default leans toward *apply now* when the finding is small and directly tied + to the cycle that surfaced it (Tidy First: keep the diff coherent). Lean + toward *roadmap* when the finding is larger than the cycle it interrupted + (CUPID-Composable: don't bundle unrelated work). + +### Testing in `termherd` + +- **Unit tests** live in the same file, under `#[cfg(test)] mod tests`. `core` + and `claude` are pure, so they are exhaustively unit-tested — there is no I/O + excuse. A `Workspace` mutator (`open`/`split`/`focus`) gets at least a + positive, a negative, and an edge case, and an invariant violation **must** + return `None`/`Err` with a test that pins it. +- **Property tests** use `proptest` (already a dev-dependency in `claude`) for + codec round-trips and invariants — e.g. every derived `cwd` re-encodes to its + folder name. +- **Integration tests** for adapters live in `tests/`. The codec is validated + against a real `~/.claude/projects` tree (every session digests, every cwd + re-encodes). +- **Tests may use `unwrap`/`expect`/`panic`** — `clippy.toml` allows it in + tests so the discipline doesn't fight the harness. Production paths may not. +- CI runs the suite via `cargo nextest run --workspace`; locally `cargo test + --workspace` is fine. + +--- + +## 4. Clean Code + +Local taste rules. None of these are absolute; they exist to be broken *on +purpose*, not by accident. + +### Names + +- A name should let a reader skip the implementation. If they have to read the + body to understand the name, rename it. +- Domain words beat generic ones. +- Boolean names read as predicates: `is_live`, `has_focus`, `should_carry`. Not + `flag`, not `status` (unless it's an enum like `SessionStatus`). +- Types: `PascalCase`; functions/vars: `snake_case`; constants: + `SCREAMING_SNAKE_CASE`; enum variants (`Event::LaunchSession`): `PascalCase`; + files: `snake_case.rs`. + +### Functions + +- One purpose per function. If you'd need "and" to describe it, split. +- Short by default — long when the alternative is a tangle of helpers no one + will read in order. +- Arguments: 0–3 is fine; 4+ wants a struct or builder (see `SpawnSpec` for the + PTY spawn call instead of five positional args). +- No flag arguments that change *what* the function does. `do(x, dry_run: + bool)` is fine (toggles a side-effect); `do(x, mode: Mode)` is usually two + functions. + +### Comments & Documentation + +- Default to **no inline comments**. Code says *what*; commit messages and PR + descriptions say *why*. +- Write an inline comment only when the *why* is non-obvious from the code: a + hidden constraint, a surprising invariant, a workaround for a specific bug + (e.g. the `ESC[6n` responder that ConPTY needs before it starts the child). +- Every public item has a doc comment: a one-line summary, then details and + examples, using intra-doc links. + +```rust +/// Drives one terminal session: spawn, write, resize, kill. +/// +/// Implemented by the [`pty`] adapter as an actor-per-session; `core` performs +/// terminal [`Effect`]s against this port and never touches a PTY directly. +pub trait PtyHost { + // ... +} +``` + +### Errors + +- Validate at boundaries (CLI args, file I/O, PTY spawn). Trust internal + callers. +- **Fail loudly and early.** A silent catch is not a style preference here — it + is one of the four quality gaps this rewrite exists to fix (PRD §4). A + swallowed error is a future bug report; surface it as `Err` or `tracing`, + never an ignored `Result`. +- `core` and `claude` return typed `thiserror` errors. The `app` binary handles + terminal errors at the boundary (`anyhow` acceptable there). +- **One logging stack: `tracing`.** No `println!` outside tests. + +--- + +## Commit style + +Commits follow [Conventional Commits](https://www.conventionalcommits.org/): + +```text +(): + + + + +``` + +Types: + +- `feat`: new feature or capability +- `fix`: bug fix +- `docs`: documentation only +- `refactor`: code change that neither fixes a bug nor adds a feature +- `test`: adding or fixing tests +- `chore`: build tooling, dependencies +- `perf`: performance improvement + +Scopes in this repo are either a **milestone** (`m0`, `m1`, `m2`) or a +**crate/area** (`core`, `claude`, `pty`, `scan`, `app`, `ci`, `dist`, `docs`). + +Breaking changes include `BREAKING CHANGE:` in the footer or `!` after the +type. Commit messages carry **no "Claude" signature** (per the global user +instruction). + +--- + +## Toolchain & Layout + +### Workspace layout + +Hexagonal workspace — adapters depend on `core`, never the reverse: + +```text +crates/ +├── core/ # domain: App state machine, Workspace, ports, keymap (no I/O) +├── claude/ # pure CLI-format codec: path · derive · digest · osc · jsonl +├── scan/ # filesystem discovery adapter (implements ProjectScanner) +├── pty/ # terminal adapter, actor-per-session (implements PtyHost) +└── app/ # iced GUI shell; constructs adapters in main() and injects them +``` + +When adding code, ask *which crate does this belong in?* If the answer is +"`core` should call this adapter directly," the answer is wrong — add a port +trait in `core::ports` and have the adapter implement it. + +### Toolchain policy + +- `rust-toolchain.toml` pins **exactly `1.95.0`, edition 2024** (Q10). Unlike + projects that track `stable`, termherd pins an exact minor version — do **not** + bump it without updating the pin and re-checking CI in the same commit. +- `Cargo.toml [workspace.package]` declares `rust-version = "1.95"`. Keep it in + lockstep with the toolchain pin; bumping one without the other is a bug. + +### Clippy & lints + +Lints are declared once at the workspace root and tightened per crate: + +- `[workspace.lints.rust]`: `unsafe_code = "deny"` everywhere. +- `[workspace.lints.clippy]`: `unwrap_used`, `expect_used`, `panic`, `todo`, + `unimplemented` = `warn` by default. +- `core` and `claude` tighten `unwrap_used`/`expect_used`/`panic` to **`deny`** + in their own `Cargo.toml` (Q5): production code in those crates must return + typed errors. +- `clippy.toml` opts tests out (`allow-unwrap-in-tests` and friends). +- CI runs `cargo clippy --workspace --all-targets -- -D warnings`: **every + warning is blocking.** Mirror it locally before pushing. + +### CI gates (all blocking) + +```bash +cargo fmt --all --check +cargo clippy --workspace --all-targets -- -D warnings +cargo nextest run --workspace # `cargo test --workspace` locally +cargo deny check # if cargo-deny installed +markdownlint-cli2 # 80-col prose; see .markdownlint-cli2.jsonc +``` + +Markdown prose wraps at **80 columns** (tables and code blocks are exempt). This +file obeys that rule; keep it that way when you edit. + +--- + +## Review mindset + +When reviewing a change, ask: + +1. Does it meet the design principles above? +2. Does it respect the dependency rule, and does domain logic live in `core` + (not the shell)? +3. Are there impossible states the types now allow? +4. Is any error silently swallowed? (The cardinal sin — PRD §4.) +5. Is the logic tested, and is the documentation up to date? +6. Would a future maintainer understand *why*, not just *what*? + +Kindness over pedantry. The goal is a better codebase, not a perfect one. diff --git a/deny.toml b/deny.toml index 01e13ad..07a3b62 100644 --- a/deny.toml +++ b/deny.toml @@ -9,11 +9,9 @@ allow = [ "BSD-2-Clause", "BSD-3-Clause", "ISC", - "Unicode-DFS-2016", "Unicode-3.0", "CC0-1.0", "Zlib", - "MPL-2.0", # Boost Software License — OSI-approved permissive; pulled in by iced's # Windows clipboard support (clipboard-win, error-code). "BSL-1.0", diff --git a/docs/reviews/code-review-m2.typ b/docs/reviews/code-review-m2.typ new file mode 100644 index 0000000..2481f60 --- /dev/null +++ b/docs/reviews/code-review-m2.typ @@ -0,0 +1,480 @@ +// TermHerd — M0→M2 code review deck +// Palette is TermHerd's own terminal default: bg #111318, fg #d0d0d0. +// CeTZ is used for three explanation diagrams (cached offline). + +#import "@preview/cetz:0.4.2" + +#let bg = rgb("#111318") +#let panel = rgb("#181b22") +#let fg = rgb("#d0d0d0") +#let dim = rgb("#8a93a3") +#let accent = rgb("#5ec8a0") // terminal green +#let cyan = rgb("#56b6c2") +#let amber = rgb("#e5c07b") +#let red = rgb("#e06c75") +#let mono = "JetBrainsMono NF" + +#set document(title: "TermHerd — M0→M2 Code Review", author: "Code review") +#set page( + width: 25.4cm, height: 14.29cm, // 16:9 + margin: (x: 1.6cm, y: 1.3cm), + fill: bg, +) +#set text(font: "Inter", size: 15pt, fill: fg) +#show raw: set text(font: mono, size: 0.92em) +#set par(leading: 0.62em) + +// ── helpers ───────────────────────────────────────────────────────────── +#let kicker(s) = text(font: mono, size: 9pt, fill: accent, tracking: 2pt, upper(s)) + +#let chip(label, col) = box( + fill: col.lighten(0%).transparentize(80%), + stroke: 0.6pt + col, + inset: (x: 6pt, y: 2pt), radius: 3pt, +)[#text(font: mono, size: 8.5pt, fill: col, weight: "bold")[#label]] + +// Standard content slide with a running header. +#let slide(title: none, kick: none, body) = { + set page(footer: context [ + #set text(font: mono, size: 7.5pt, fill: dim) + #grid(columns: (1fr, auto), + [termherd · m0→m2 review], + [#counter(page).display()], + ) + ]) + if title != none [ + #if kick != none { kicker(kick); v(-4pt) } + #text(size: 22pt, fill: fg, weight: "bold")[#title] + #v(-2pt) + #line(length: 100%, stroke: 0.8pt + accent.transparentize(40%)) + #v(2pt) + ] + body +} + +// A finding card. +#let finding(n, total, sev, sevcol, title, file, problem, scenario, fix) = { + set page(footer: context [ + #set text(font: mono, size: 7.5pt, fill: dim) + #grid(columns: (1fr, auto), [termherd · m0→m2 review], [#counter(page).display()]) + ]) + kicker("finding " + str(n) + " / " + str(total)); v(-4pt) + text(size: 19pt, fill: fg, weight: "bold")[#title] + v(-3pt) + line(length: 100%, stroke: 0.8pt + sevcol.transparentize(40%)) + v(3pt) + grid(columns: (auto, 1fr), gutter: 8pt, + chip(sev, sevcol), + align(right + horizon)[#text(font: mono, size: 9pt, fill: cyan)[#file]], + ) + v(5pt) + block(fill: panel, radius: 5pt, inset: 9pt, width: 100%)[ + #set text(size: 12.5pt) + #text(fill: amber, weight: "bold")[Problem.] #problem + ] + v(6pt) + grid(columns: (1fr, 1fr), gutter: 10pt, align: top + left, + block(fill: panel, radius: 5pt, inset: 9pt, width: 100%)[ + #text(fill: red, weight: "bold", size: 10.5pt)[⚠ FAILURE SCENARIO] \ + #v(1pt) #set text(size: 11.5pt); #scenario + ], + block(fill: accent.transparentize(88%), radius: 5pt, inset: 9pt, width: 100%, + stroke: 0.6pt + accent.transparentize(40%))[ + #text(fill: accent, weight: "bold", size: 10.5pt)[✓ FIX] \ + #v(1pt) #set text(size: 11.5pt); #fix + ], + ) +} + +// A diagram slide: title + one-line caption + a centered CeTZ canvas. +#let dia(kick, title, caption, canvas) = slide(kick: kick, title: title)[ + #text(size: 13pt, fill: dim)[#caption] + #v(10pt) + #align(center, canvas) +] + +// ════════════════════════════════════════════════════════════════════════ +// TITLE +// ════════════════════════════════════════════════════════════════════════ +#page(footer: none)[ + #v(1fr) + #kicker("code review · medium effort") + #v(6pt) + #text(size: 40pt, weight: "bold", fill: fg)[TermHerd] + #v(-10pt) + #text(size: 20pt, fill: dim)[M0 → M2 build-out — what changed & what to fix] + #v(14pt) + #grid(columns: (auto, auto, auto, auto), gutter: 8pt, + chip("19 commits", cyan), + chip("~9,000 LOC", cyan), + chip("8 findings", amber), + chip("0 crashers", accent), + ) + #v(1fr) + #text(font: mono, size: 9pt, fill: dim)[range 0fa45ff..HEAD · pty → gui hot path · 2026-06-13] +] + +// ════════════════════════════════════════════════════════════════════════ +// WHAT CHANGED +// ════════════════════════════════════════════════════════════════════════ +#slide(kick: "context", title: "What landed since 0fa45ff")[ + #set text(size: 14pt) + Three milestones took the repo from an empty scaffold to a working embedded + terminal — built on the hexagonal rule: #text(font: mono, size: 11pt, fill: cyan)[adapters → core ← claude]. + + #v(6pt) + #grid(columns: (1fr, 1fr, 1fr), gutter: 10pt, align: top + left, + block(fill: panel, radius: 5pt, inset: 9pt, width: 100%)[ + #chip("M0", dim) #h(4pt) *Foundations* \ #v(2pt) + #set text(size: 11pt, fill: dim) + Workspace skeleton, CI gates, iced window shell, cargo-dist release + pipeline (mac/linux/win). + ], + block(fill: panel, radius: 5pt, inset: 9pt, width: 100%)[ + #chip("M1", cyan) #h(4pt) *Browser + search* \ #v(2pt) + #set text(size: 11pt, fill: dim) + `scan` adapter, pure `core::browser` grouping, debounced fs-watch (FR2), + in-memory search (FR3). + ], + block(fill: panel, radius: 5pt, inset: 9pt, width: 100%)[ + #chip("M2", accent) #h(4pt) *Embedded terminal* \ #v(2pt) + #set text(size: 11pt, fill: dim) + `pty` actor-per-session, colour grid, raw keys, resize, OSC badges (FR8), + scrollback + select (FR4). + ], + ) + #v(7pt) + #block(fill: amber.transparentize(90%), radius: 5pt, inset: 9pt, width: 100%, + stroke: 0.6pt + amber.transparentize(55%))[ + #set text(size: 12.5pt) + #text(fill: amber, weight: "bold")[The pure `claude` codec] underpins it all: + `path` · `derive` · `digest` · `osc` · `jsonl` — no I/O, fully unit + property tested. + ] +] + +// ════════════════════════════════════════════════════════════════════════ +// THE VERDICT / MAP +// ════════════════════════════════════════════════════════════════════════ +#slide(kick: "verdict", title: "Eight findings across two passes")[ + Pass 1 (rows 1–6) covered `0fa45ff..` — no crashers, the + #text(fill: amber, weight: "bold")[PTY → GUI hot path]. Pass 2 (7–8) covers + `ce13614`, the #text(fill: amber, weight: "bold")[Attention status]. + + #v(4pt) + #set text(size: 11.5pt) + #table( + columns: (auto, 1fr, auto, auto), + stroke: none, + fill: (_, row) => if row == 0 { accent.transparentize(85%) } + else if calc.odd(row) { panel } else { none }, + inset: (x: 8pt, y: 4pt), + align: (center, left, left, center), + text(font: mono, size: 9pt, fill: accent)[\#], + text(font: mono, size: 9pt, fill: accent)[FINDING], + text(font: mono, size: 9pt, fill: accent)[AREA], + text(font: mono, size: 9pt, fill: accent)[SEV], + + [1], [Full grid snapshot per byte-chunk → unbounded channel], [pty], chip("HIGH", red), + [2], [Per-cell `String` + `Advanced` shaping every frame], [gui], chip("MED", amber), + [3], [`spawn()` swallows poisoned lock, returns `Ok`], [pty], chip("MED", amber), + [4], [Hardcoded palette constant drifts from `pty`], [gui], chip("LOW", cyan), + [5], [Blocking `scan()` inside an async task], [gui], chip("LOW", cyan), + [6], [Focus + selection logic stranded in the shell], [altitude], chip("NOTE", dim), + [7], [`Attention` is sticky with no non-`Busy` escape (payload discarded)], [pty], chip("MED?", amber), + [8], [Status state machine split across `pty` + `core`], [altitude], chip("NOTE", dim), + ) + #v(3pt) + #text(size: 10pt, fill: dim)[Dropped on verify: empty-line selection panic (grid is + padded to `cols`); `u64` SessionId overflow (~1.8 × 10¹⁹ launches).] +] + +// ════════════════════════════════════════════════════════════════════════ +// FINDINGS +// ════════════════════════════════════════════════════════════════════════ +#finding(1, 8, "HIGH · memory + cpu", red, + "Snapshot-per-chunk over an unbounded channel", + "crates/pty/src/lib.rs:525 · main.rs:57", + [`spawn_term` clones the *entire* visible grid (`snapshot(&term)`) and sends a + `PtyEvent::Output` after #text(weight: "bold")[every] `TermCmd::Bytes` chunk — across an + #text(font: mono)[unbounded] channel.], + [`cat large_file` or a streaming `claude` reply delivers hundreds of small + chunks/sec. If the GUI lags (resize, shader compile) the queue grows without + bound → memory blowup. Even keeping up, every snapshot but the last per frame + is thrown away — O(rows·cols) wasted per chunk.], + [Coalesce: emit at most one snapshot per frame (timer / dirty flag). Switch to + a #text(font: mono)[bounded] channel that drops stale frames under backpressure. + #text(fill: accent, weight: "bold")[Highest leverage — fixes both axes at the root.]], +) + +#dia("finding 1 · diagram", "Why the queue grows without bound", + [Every read chunk clones the whole grid into an *unbounded* channel. The fix: + one snapshot per frame, into a bounded channel that drops stale frames.], + cetz.canvas(length: 0.82cm, { + import cetz.draw: * + let box(x, y, w, body, sc, tc) = { + rect((x, y), (x + w, y + 1.2), fill: panel, stroke: sc + 0.9pt, radius: 0.12) + content((x + w/2, y + 0.6), text(font: mono, size: 8.5pt, fill: tc)[#body]) + } + let arr(x1, y, x2, c) = line((x1, y), (x2, y), mark: (end: ">", fill: c), stroke: c + 1pt) + + content((-0.2, 5.7), anchor: "west", text(font: mono, size: 9pt, fill: red, weight: "bold")[BEFORE]) + box(0, 4.2, 2.6, [PTY child], dim, fg) + arr(2.6, 4.8, 3.5, dim) + box(3.5, 4.2, 3.0, [reader thd], dim, fg) + arr(6.5, 4.8, 7.4, dim) + box(7.4, 4.2, 3.8, [term \ snapshot()], amber, amber) + arr(11.2, 4.8, 12.1, red) + for i in range(6) { + rect((12.2 + i*0.3, 4.0 + i*0.16), (13.9 + i*0.3, 5.4 + i*0.16), + fill: red.transparentize(72%), stroke: red + 0.6pt, radius: 0.05) + } + content((13.9, 6.5), text(font: mono, size: 7.5pt, fill: red)[unbounded ↑]) + arr(15.7, 4.8, 16.6, dim) + box(16.6, 4.2, 3.0, [GUI (lags)], dim, fg) + content((9.3, 3.6), text(size: 8pt, fill: amber)[full grid clone / chunk]) + + content((-0.2, 1.6), anchor: "west", text(font: mono, size: 9pt, fill: accent, weight: "bold")[AFTER]) + box(7.4, 0.2, 3.8, [term \ coalesce 1/frame], accent, accent) + arr(11.2, 0.8, 12.1, accent) + for i in range(3) { + rect((12.2 + i*0.55, 0.3), (12.7 + i*0.55, 1.3), + fill: accent.transparentize(72%), stroke: accent + 0.7pt, radius: 0.05) + } + content((13.0, 2.0), text(font: mono, size: 7.5pt, fill: accent)[bounded(N), drop stale]) + arr(14.1, 0.8, 16.6, accent) + box(16.6, 0.2, 3.0, [GUI], accent, accent) + })) + +#finding(2, 8, "MED · cpu + alloc", amber, + "Per-cell allocation in the canvas draw loop", + "crates/app/src/shell.rs:589", + [Each redraw calls `cell.c.to_string()` per non-blank cell and renders with + `Shaping::Advanced` — for single monospace glyphs.], + [An 80×24 grid is ~1,900 heap allocations *plus* advanced text-shaping per + frame, multiplied by frame rate during active output. Pure overhead in the + hottest rendering path; compounds finding 1.], + [Use `Shaping::Basic` for monospace single-char cells and avoid the per-cell + `String`. Cheaper alloc + cheaper shaping, identical output.], +) + +#finding(3, 8, "MED · correctness", amber, + "spawn() swallows a poisoned lock and returns Ok", + "crates/pty/src/lib.rs:354", + [`if let Ok(mut map) = self.sessions.lock()` skips the `insert` on a poisoned + mutex but the function still returns `Ok(())`.], + [After a panic poisons the lock, the reader/term threads run #text(style: "italic")[detached] + while the session is never registered. Every later `write` / `resize` / `kill` + returns `NoSuchSession` — a live-looking but dead tab.], + [Surface the poison as `PtyError::Io` like `write()`/`resize()` already do + (lib.rs:364/381) — or tear the orphaned threads down. Make the three lock + sites consistent.], +) + +#dia("finding 3 · diagram", "Same poison, two different answers", + [On a poisoned lock, `write()`/`resize()` return an error — but `spawn()` + swallows it and returns `Ok`, leaving threads running but unreachable.], + cetz.canvas(length: 0.82cm, { + import cetz.draw: * + let box(x, y, w, h, body, fc, sc, tc) = { + rect((x, y), (x + w, y + h), fill: fc, stroke: sc + 0.9pt, radius: 0.12) + content((x + w/2, y + h/2), text(font: mono, size: 8pt, fill: tc)[#body]) + } + let down(x, y1, y2, c) = line((x, y1), (x, y2), mark: (end: ">", fill: c), stroke: c + 1pt) + let branch(x1, y1, x2, y2, c) = line((x1, y1), (x2, y2), mark: (end: ">", fill: c), stroke: c + 1pt) + + box(6.1, 7.2, 4.0, 1.1, [sessions.lock()], panel, dim, fg) + content((10.4, 7.75), anchor: "west", text(size: 8pt, fill: dim)[returns Err — mutex poisoned]) + down(8.1, 7.2, 6.7, dim) + content((8.1, 6.35), text(font: mono, size: 8.5pt, fill: amber)[poisoned?]) + branch(7.2, 6.05, 4.0, 4.65, red) + branch(9.0, 6.05, 12.4, 4.65, accent) + + content((2.4, 5.0), text(font: mono, size: 9pt, fill: red, weight: "bold")[spawn()]) + box(1.6, 3.4, 4.8, 1.2, [skip insert \ return Ok(())], red.transparentize(82%), red, red) + down(4.0, 3.4, 2.8, red) + box(0.8, 1.4, 6.4, 1.3, [threads run detached; \ later write/resize/kill \ → NoSuchSession], panel, red, fg) + + line((8.4, 1.2), (8.4, 5.8), stroke: (paint: dim, dash: "dashed", thickness: 0.6pt)) + + content((12.4, 5.0), text(font: mono, size: 9pt, fill: accent, weight: "bold")[write() · resize()]) + box(10.0, 3.4, 4.8, 1.2, [return \ PtyError::Io], accent.transparentize(82%), accent, accent) + content((12.4, 2.7), text(size: 8pt, fill: accent)[✓ caller learns it failed]) + })) + +#finding(4, 8, "LOW · cleanup", cyan, + "Hardcoded palette constant drifts from pty", + "crates/app/src/shell.rs:584", + [Render skips the default background via `cell.bg != [0x11,0x13,0x18]` — + a magic literal duplicating private `pty::DEFAULT_BG` (and cursor `DEFAULT_FG`).], + [Change pty's default palette and the shell keeps comparing the old value: every + cell paints its background (or the wrong cells skip) with #text(style: "italic")[no compile + error]. Silent visual drift.], + [Make `DEFAULT_BG` / `DEFAULT_FG` `pub` in the `pty` crate and reference them + from the shell. One source of truth.], +) + +#finding(5, 8, "LOW · responsiveness", cyan, + "Blocking scan() inside an async task", + "crates/app/src/shell.rs:184", + [`Task::perform(async move { scanner.scan() … })` runs a synchronous fs walk + + JSONL parse directly inside an `async` block.], + [On a large `~/.claude/projects` tree the blocking walk ties up an iced executor + worker for its whole duration on every debounced rescan (FR2), delaying the + `ScanCompleted` result.], + [Wrap the blocking work in `spawn_blocking` (or a dedicated thread) so the + executor stays free. Idiomatic async hygiene.], +) + +#finding(6, 8, "NOTE · altitude", dim, + "Domain logic stranded in the GUI shell", + "crates/app/src/shell.rs:99 (Focus, selection_text)", + [The `Focus` enum (input routing) and `selection_text` / `selection_span` + (FR4) live in `shell.rs`, not in pure `core`.], + [They can't be unit-tested headlessly, contradicting AGENTS.md ("domain logic + lives behind `apply`"). When tabs/splits land (FR5+), per-pane focus and + selection must be duplicated or retrofitted into `core`.], + [Move focus routing into `core::Workspace` and selection into `core` (or a + tested helper). Pay the altitude cost now, before splits multiply it.], +) + +#dia("finding 6 · diagram", "Logic living in the wrong layer", + [Input routing and selection are domain decisions sitting in the GUI shell. + The hexagonal rule wants them in pure, testable `core`.], + cetz.canvas(length: 0.66cm, { + import cetz.draw: * + let band(y, h, fc, sc, label, sub) = { + rect((0, y), (18, y + h), fill: fc, stroke: sc + 1pt, radius: 0.15) + content((0.4, y + h - 0.45), anchor: "west", text(font: mono, size: 9pt, fill: sc, weight: "bold")[#label]) + content((0.4, y + 0.4), anchor: "west", text(size: 7.5pt, fill: dim)[#sub]) + } + let chip(x, y, w, body, c) = { + rect((x, y), (x + w, y + 0.95), fill: c.transparentize(78%), stroke: c + 0.8pt, radius: 0.1) + content((x + w/2, y + 0.48), text(font: mono, size: 8pt, fill: c)[#body]) + } + + band(7.0, 2.6, panel, cyan, "app / shell (iced GUI)", "translates events, performs effects") + chip(9.5, 7.55, 3.6, [Focus enum], red) + chip(13.4, 7.55, 4.2, [selection_text()], red) + + band(3.6, 2.6, accent.transparentize(90%), accent, "core (pure state machine)", "App::apply — headless, unit-testable") + content((13.0, 4.9), text(size: 8pt, fill: accent)[← input routing + selection belong here]) + + band(0.6, 2.0, panel, dim, "claude (pure codec)", "path · derive · digest · osc · jsonl") + + line((11.3, 7.5), (11.3, 6.2), mark: (end: ">", fill: amber), stroke: (paint: amber, thickness: 1.2pt)) + content((11.3, 6.85), anchor: "west", text(font: mono, size: 8pt, fill: amber)[ move]) + })) + +// ════════════════════════════════════════════════════════════════════════ +// PASS 2 — new code (ce13614: Attention status + sidebar badges) +// ════════════════════════════════════════════════════════════════════════ +#finding(7, 8, "MED? · ux — contingent", amber, + "Attention is sticky with no non-Busy escape", + "crates/pty/src/lib.rs · fold_status", + [*From the code:* any OSC 9 `Notification` → `Attention`; only `Busy` clears + it (not `Idle`); and `fold_status` discards the payload, so notification + types are indistinguishable.], + [*Contingent:* a "done"-type OSC 9 (if Claude sends one) leaves the row red + until the next `Busy` — maybe never, with no escape path. For a session + truly awaiting input, sticky is correct. So: a bug only given Claude's real + OSC 9 usage — which I did *not* verify.], + [First capture the OSC 9 payloads Claude actually emits. If non-attention + pings exist: branch on `Notification(payload)`, or let a focus/click + acknowledge `Attention` → `Idle`.], +) + +#dia("finding 7 · diagram", "Where Attention gets stuck", + [`Busy`⇄`Idle` track work. An OSC 9 ping jumps to `Attention`, which `Idle` + can't leave — only `Busy` clears it. The risk lands *only if* non-permission + pings exist.], + cetz.canvas(length: 0.82cm, { + import cetz.draw: * + let node(x, y, label, c) = { + circle((x, y), radius: 1.05, fill: c.transparentize(82%), stroke: c + 1.1pt) + content((x, y), text(font: mono, size: 8.5pt, fill: c, weight: "bold")[#label]) + } + let arr(a, b, c) = line(a, b, mark: (end: ">", fill: c), stroke: c + 1pt) + + node(4.5, 4.6, [Busy], amber) + node(11.5, 4.6, [Idle], accent) + node(4.5, 1.3, [Atten-\ntion], red) + + arr((5.55, 4.9), (10.45, 4.9), dim) + content((8.0, 5.25), text(font: mono, size: 7.5pt, fill: dim)[idle title]) + arr((10.45, 4.3), (5.55, 4.3), dim) + content((8.0, 3.95), text(font: mono, size: 7.5pt, fill: dim)[busy title]) + + arr((4.05, 3.55), (4.05, 2.35), red) + content((3.1, 3.0), text(font: mono, size: 7.5pt, fill: red)[OSC 9]) + arr((4.95, 2.35), (4.95, 3.55), accent) + content((6.0, 3.0), text(font: mono, size: 7.5pt, fill: accent)[Busy ✓]) + + arr((10.6, 3.7), (5.5, 1.75), red) + content((8.9, 2.95), text(font: mono, size: 7.5pt, fill: red)[OSC 9]) + + content((4.5, -0.15), text(font: mono, size: 7.5pt, fill: red)[↺ Idle keeps it here]) + + rect((8.4, 0.1), (17.6, 2.5), fill: red.transparentize(86%), stroke: red + 0.7pt, radius: 0.12) + content((13.0, 1.3), text(size: 8.5pt, fill: fg)[#box(width: 8.4cm)[ + *Risk (if "done" pings exist):* such a ping with no later `Busy` stays red + — and the payload that could say "done" vs "permission" is thrown away. + ]]) + })) + +#finding(8, 8, "NOTE · altitude", dim, + "The status state machine is split across two crates", + "pty · fold_status + core · StatusChanged", + [The rule "`Attention` is sticky and outranks `Idle`" lives in `pty`'s + `fold_status`; the rule "`Exited` is terminal" lives in `core`'s + `StatusChanged`. No single place holds the whole machine.], + [`core::apply` blindly accepts whatever status `pty` computed (except after + `Exited`), so the stickiness invariant can't be unit-tested in `core` and + can't be enforced if a second status producer ever appears. Reinforces + finding 6: domain rules leaking out of `core`. (Safe today — `pty` is the + sole producer and mirrors `core`.)], + [Move the transition rules (rank + stickiness) into a pure `core` helper that + `StatusChanged` calls; leave `pty` to emit raw signals. The whole machine + becomes one tested unit.], +) + +// ════════════════════════════════════════════════════════════════════════ +// PLAN +// ════════════════════════════════════════════════════════════════════════ +#slide(kick: "changes to make", title: "Recommended order")[ + #v(2pt) + #grid(columns: (auto, 1fr), row-gutter: 9pt, column-gutter: 12pt, + chip("1 · DO FIRST", red), + [*Coalesce snapshots + bounded channel* (#1). Then *cheap canvas cells* (#2). + Together these fix the hot path — the one change with real user impact.], + + chip("2 · QUICK WINS", amber), + [*Consistent lock handling in `spawn()`* (#3) and *exported palette + constants* (#4) — small, low-risk diffs. Plus *confirm Claude's OSC 9 + usage* (#7) before deciding whether Attention needs an escape path.], + + chip("3 · HYGIENE", cyan), + [*`spawn_blocking` for `scan()`* (#5). One-line idiomatic fix.], + + chip("4 · BEFORE M3", dim), + [*Lift focus + selection into `core`* (#6) and *fold the status machine into + `core`* (#8). Do it before tabs/splits make the duplication permanent.], + ) + #v(10pt) + #block(fill: accent.transparentize(88%), radius: 5pt, inset: 11pt, width: 100%, + stroke: 0.6pt + accent.transparentize(40%))[ + #text(fill: accent, weight: "bold")[Next step.] Findings #1 + #2 are the + highest-leverage pair. Say the word and I'll apply them to the working tree. + ] +] + +#page(footer: none)[ + #v(1fr) + #align(center)[ + #kicker("end") + #v(8pt) + #text(size: 22pt, weight: "bold")[Questions / pick the fixes to apply] + #v(6pt) + #text(font: mono, size: 10pt, fill: dim)[termherd · m0→m2 · medium-effort review] + ] + #v(1fr) +]