Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/check-task-board.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@rrlab/cli": minor
"@rrlab/biome-plugin": minor
"@rrlab/oxc-plugin": minor
"@rrlab/ts-plugin": minor
"@rrlab/tsdown-plugin": minor
---

Rework the output of `rr check`, `lint`, `format`, `jsc`, `tsc`, `pack`, and `doctor` into a live task board. Each tool runs captured and renders a spinner row that collapses to ✔/✖, with its output flushed below (dimmed on a pass, full brightness on a failure) and a one-line summary. In a monorepo, `tsc` shows one row per package so it's clear which one failed.

Every command and subcommand now reads identically: a single-target row is `<command> (<tool>) · <package>` (e.g. `lint (biome) · dx`, `doctor (biome) · dx`), a fan-out is `<command> (<tool>) · <n> packages`, and each run shows the underlying `$ <command>` it executed. `rr check` runs `jsc` then `tsc` as framed sections and closes with one overall verdict (`✔ check passed` / `✖ check failed · <section>`). The verdict is always the tool's exit code — never parsed from output. `clean` stays logger-based (it has no pass/fail verdict).

Plugin SDK: the capability verbs plus `Packer.pack()` and `Doctor.doctor()` now return a `RunReport` (`{ ok, output }`); `DoctorResult`/`DoctorOutput` are removed. See decisions 012–014.
5 changes: 5 additions & 0 deletions .changeset/task-board.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@vlandoss/clibuddy": minor
---

Add `runTaskBoard`: a TTY-aware parallel task board. It runs N labelled tasks concurrently and renders one live spinner row each — collapsing to ✔/✖ with a duration — then flushes their captured output (dimmed on pass, full brightness on fail, capped with a `+N more lines` note) and a one-line summary. Non-TTY/CI prints each row once in input order, so logs stay deterministic. The `┌ │ └` frame is opt-in via `frame: true` (to divide composed sections); otherwise a lone task renders compactly and a multi-row run as a plain title + rows + summary. Glyphs mirror `@clack/prompts` (the `◒◐◓◑` spinner and gray `│ ┌ └` gutter). Also adds `palette.error`.
31 changes: 31 additions & 0 deletions decisions/012-check-task-board-placement.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# 012: Which package owns the parallel task-board that powers `rr check`'s per-project progress UX?

- **Date**: 2026-05-26
- **Status**: Pending human review
- **Files affected**: `shared/clibuddy/src/*` (new task-board service), `run-run/cli/src/program/commands/{check,tscheck,jscheck}.ts` (consumers)

## Context

`rr check` fans out per-package type checks with `Promise.all` and `stdio: "inherit"` (`tscheck.ts:95`), so in a 16-package monorepo all subprocesses interleave their output on one TTY with no per-package attribution, and `log.start`/`log.success` render static consola icons (`loggy.ts:63-69`), not a live spinner. The agreed UX is a TTY-aware parallel task-board: one live spinner row per project, collapsing to a status glyph with a duration, then each task's captured output flushed grouped under its package label + a summary; non-TTY/CI falls back to sequential grouped output. Its glyphs mirror `@clack/prompts` (the ◒◐◓◑ spinner, the gray `│ ┌ └` gutter) so it reads as one family with the clack-driven `rr plugins` flow. The board is tool-agnostic orchestration+presentation — the question is which layer owns it.

## Options considered

- **A**: `@vlandoss/loggy` — extend the logger with a multi-row live renderer. Shares the consola output surface.
- **B**: `@vlandoss/clibuddy` — a new service alongside `ShellService`, `palette`, `hasTTY`/`isCI`.
- **C**: `@rrlab/cli` kernel — a tool-agnostic `Reporter`/`TaskBoard` service next to its only caller.

## Decision: Option B (`@vlandoss/clibuddy`)

clibuddy is the only package that already owns every primitive the board composes: `ShellService` (produces the captured streams), `hasTTY`/`isCI` (`env.ts` — selects the render path), and `palette`/`colorize` (`colors.ts`). loggy's `AnyLogger` (`types.ts:9`) is a flat severity interface; a task orchestrator that runs N async tasks, owns concurrency, and captures subprocess output has no place on it. The kernel deliberately keeps its surface minimal and already sources terminal ergonomics from clibuddy (every plugin imports `colorize`/`isCI` from `@vlandoss/clibuddy`, e.g. `biome-plugin/src/index.ts:19`); a stateful renderer is pure terminal presentation, not plugin orchestration, so it's the wrong layer for the kernel. Housing it in clibuddy also keeps it reusable by `@vlandoss/vland`, which has the same fan-out-and-report need.

The board owns the terminal region while live and only hands back to `logger` on settle (clibuddy renders the live region; loggy prints the final grouped diagnostics + summary). That boundary is a deliberate contract, not a leak.

## Alternatives rejected

- Option A (loggy): widens a focused logger interface with orchestration + subprocess concepts it has no reason to know.
- Option C (kernel): unreusable by `@vlandoss/vland`, and grows kernel surface for pure terminal presentation that the repo already houses in clibuddy.

## Notes for human review

- This *adds* a clibuddy service; it does not refactor clibuddy's existing API, so it stays within the `run-run/CLAUDE.md` "don't refactor clibuddy while doing run-run work" guard. It still lands as its own clibuddy change with its own changeset, then consumed by the kernel.
- Paired with [013](013-check-stream-to-capture-contract.md), which covers the stream→capture change the board depends on.
48 changes: 48 additions & 0 deletions decisions/013-check-stream-to-capture-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# 013: How does the task-board capture per-task output, decide pass/fail, and force color?

- **Date**: 2026-05-26
- **Status**: Pending human review
- **Files affected**: `run-run/cli/src/plugin/tool-service.ts`, `run-run/cli/src/types/tool.ts` (`RunReport`), `run-run/{biome,oxc,ts}-plugin/src/index.ts`, `run-run/cli/src/program/{board,composed-jsc}.ts` + `commands/{lint,format,jscheck,tscheck,check}.ts`

## Context

Every tool ran with `stdio: "inherit"` (`shell.ts:45`) — live stream. To attribute output per package and render a board ([012](012-check-task-board-placement.md)), the parallel fan-out tasks must capture stdout/stderr instead. Two follow-on questions: how do we decide a task's verdict, and how do we keep color once the child loses its TTY?

## Options considered

- **Verdict source**: (A) parse each tool's summary text for warning/error counts; (B) use the exit code — the tool's own verdict — and never parse.
- **Structured output**: investigated `--reporter`/`--format json`. biome (`--reporter=json`, exact `summary`) and oxlint (`--format=json`, severities) support it; **oxfmt and tsc have no machine output at all**.
- **What to show**: (C) only on failure; (D) always flush the captured output grouped under the package.
- **Force color**: (E) per-plugin flag; (F) runner injects `FORCE_COLOR=1`.
- **Shell surface**: reuse `runCaptured` vs a new method.

## Decision: B + D + E, capturing via `runCaptured`

- **B — verdict from the exit code, no parsing.** Text summaries are unstable across versions and *not uniform*: the same strategy must apply to all four tools (operator's call), and since tsc/oxfmt emit nothing machine-readable, a JSON path can't be uniform. So we drop count-parsing entirely. `RunReport` is just `{ ok, output }` where `ok` is `exitCode === 0`. No guessing.
- **D — always flush the captured output** grouped under the package label (pass or fail). The board owns the verdict (✔/✖) and progress; the tool owns what it prints. A clean tool that still prints a summary line (e.g. oxlint's `Found 0 warnings…`) is shown — accepted as the tool's own output, not noise we invented. Warnings that don't fail the exit code stay visible this way; if a user wants warnings to *fail*, they configure their linter (`--error-on-warnings`, `--max-warnings 0`) — `rr` does not impose that policy.
- **E — per-plugin force-color.** Tool-specific, so the kernel can't hold it (`run-run/CLAUDE.md`). biome already has `--colors=force`; ts adds `--pretty` (forces color off a TTY). oxlint/oxfmt expose no force-color flag and ignore `FORCE_COLOR`, so their captured output is monochrome — readable (ASCII miette frames), just uncolored. `FORCE_COLOR=1` injection is rejected: it doesn't help oxlint and inverts tool ownership.
- **Shell** — `ToolService.runReport(args, { cwd })` wraps `runCaptured` (`throwOnError: false`) so every task settles; the command aggregates `process.exitCode`. No new `ShellService` method.

## Alternatives rejected

- Parse tool summaries (A): fragile and non-uniform (tsc/oxfmt have no machine output) — exactly the guessing the operator vetoed.
- JSON reporters for biome/oxlint: deterministic, but (1) not uniform across the four tools, and (2) single-run JSON replaces the human output, so we'd have to re-render diagnostics ourselves or run twice (the slow oxlint type-aware path × N packages makes a second run unacceptable).
- Show output only on failure (C): would hide non-failing warnings — losing the "which packages have warnings" signal the operator wanted.
- `FORCE_COLOR=1` (F): no effect on oxlint, and violates the kernel-agnostic rule.

## Notes for human review

- Capture replaces streaming for the four check-family verbs (lint/format/static-check/type-check) in both single-app and monorepo — these are batch tools (no incremental output), so nothing live is lost and the board's spinner is a better progress signal. `pack` (tsdown) and user pre-scripts keep streaming.
- Contract change (`RunReport` return) propagated across all 4 plugins + `composedJscProvider` in one commit; no deprecation shims.
- Integration tests that asserted the streamed `$ <tool>` echo now assert the board summary / the row label (the tool `ui`) / the flushed tool output, since the `$` echo no longer prints under capture.

### Post-review refinements (TUI/UX review, 2026-05-26)

Three TUI-design reviews of the dogfooded output drove these follow-ups, all consistent with the exit-code-verdict rule (still no output parsing):

- **A three-state board (✔/⚠/✖) was tried and reverted.** The reviews pushed for a `warn` state so non-failing findings don't read as a green pass. We tried it via "exit 0 + has output ⟹ warning", suppressing the clean trailers of biome/oxfmt (`quietWhenOk`). It doesn't hold: **oxlint prints a "Found 0 warnings… Finished in…" summary even when clean** (and exits 0 on real warnings), so "passing + has output" can't distinguish a clean trailer from a warning *without parsing* — exactly what 013 forbids. Reverted to **two states by exit code (✔/✖)** and we surface findings by **always flushing the tool's own output** (dimmed on a pass so it recedes but stays visible — it's also the user-requested proof that the tool ran; full brightness on a failure). A user who wants warnings to *fail* configures their linter (`--max-warnings 0` / biome `--error-on-warnings`); `rr` stays faithful to the exit code.
- **Composite `rr check` verdict (kept).** `check` prints one final line (`✔ check passed` / `✖ check failed · <section>`) so a passing section can't be the last line of a run that failed in another section. It correlates each section's `BoardResult` (collected by `runCheckSections`) back to the section name.
- **Command stays visible under capture.** The streaming path prints `$ <cmd>` via `printCmdLine`; the captured path lost it. Rather than add a `command` field to `RunReport` (the command is *how* it ran, not part of the *result*), `ToolService.runReport` leads its captured `output` with a dim `$ <bin> <args>` line. The board then hoists a leading line shared by every task (the identical command across a monorepo's packages) so it shows once, not per package.
- **The frame is for composition, not row count.** The `┌ │ └` frame is opt-in (`frame: true`) and only `rr check` sets it, to divide its `jsc`/`tsc` sections. A standalone command never frames — even a monorepo `rr tsc` with N package rows is *one* command, so it renders as a plain title + rows + summary (no gutter). Single-task standalone runs stay fully compact.
- **Robustness/polish (kept):** the gutter moved from a fixed truecolor hex to the 16-color `gray` (theme-adaptive, degrades on non-truecolor/CI); failing detail is capped (`+N more lines`); the framed-single section closes with a summary instead of a bare `└`; the summary duration is the wall-clock span, not a single task's time.
- **Known limitations (accepted):** the `(tool)` label suffix reflects the configured plugin, so it differs across repos using different tools; there is no ASCII fallback for the unicode glyphs (the audience matches `@clack/prompts`' footprint); the board's `│` gutter and biome's own `│`/`━━━` frames stack on failure detail — kept because frame-continuity through the detail was an explicit product call; oxlint suppresses its summary when captured (non-TTY), so a clean `tsc` row may show no proof-of-work while biome's always does — a per-tool behaviour we don't override.
48 changes: 48 additions & 0 deletions decisions/014-unified-command-ui.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# 014: How do all commands share one board UI without repeating the wiring?

- **Date**: 2026-05-26
- **Status**: Pending human review
- **Files affected**: `run-run/cli/src/program/board.ts` (`runToolCommand`), `run-run/cli/src/program/commands/{lint,format,jscheck,pack,doctor}.ts`, `run-run/cli/src/types/tool.ts` (`Packer.pack` → `RunReport`; `Doctor.doctor` → `RunReport`; `DoctorResult`/`DoctorOutput` removed), `run-run/cli/src/plugin/tool-service.ts` (`doctor()` → `RunReport`), `run-run/cli/src/program/composed-jsc.ts`, `run-run/tsdown-plugin/src/index.ts`

## Context

After the board redesign (012/013), `lint`/`format`/`jsc`/`tsc` render the task board but each command repeats the same action wiring (`missingPluginError` → `runBoard([reportTask(withTarget(`<name> (<ui>)`, appPkg), verb)])` → `process.exitCode`). `pack` still streams (returns `void`), and `doctor` prints via `logger`. The goal: every command shares the same UI, with a reusable abstraction. Invoked `arch-critic`.

## Options considered

- **A**: A `UIService` on `Context` (`ctx.ui.run(...)`). A class wrapping the board.
- **B**: Free functions in `board.ts` — one `runToolCommand(ctx, spec)` absorbing the repeated action body; commands keep their own `createCommand().summary().description().option()` scaffold.
- **C**: A declarative `createToolCommand({ name, kind, verb, … })` factory building the whole command from a spec.

## Decision: Option B

- The board is stateless; the only kernel state (`collector` in `board.ts`) is deliberately module-scoped because it spans `runCheckSections` → nested `runBoard` calls across the `parseAsync` sibling-dispatch boundary in `check`. A class instance can't hold that without becoming a second singleton — and the repo reserves singletons for `logger` alone. A `Service` with no constructor deps is a namespace cosplaying as a service. So **no `UIService`**.
- Option C fits only the three trivial commands: `jsc` needs custom provider resolution (`composedJscProvider`), `tsc` needs per-package fan-out + pre-scripts + monorepo branching, `check` resolves no provider. A factory would force escape hatches for every command that has real logic — abstraction pointed at the wrong cases.
- Option B extends the idiom already in `board.ts` (`reportTask`, `runBoard` are free functions). `runToolCommand(ctx, { name, kind, provider, run })` absorbs the genuinely-repeated action body; `tsc`/`check` keep calling `runBoard` directly (they have bespoke logic and should). The `.summary().description().option()` chain stays per-command — those strings are the command's identity, and the `doctor` subcommand + `addHelpText` block is 4 lines not worth hiding.

#### One canonical labeller

The labels were the real duplication — each command built `<command> (<tool>) · <package>` (or a fan-out title) by hand, and they'd drifted (`rr jsc doctor` showed bare `biome`; single-app `tsc` lacked the `· <pkg>`). Two free functions in `board.ts` are now the single source of truth, and every command/subcommand routes through them:
- `targetLabel(command, provider, appPkg)` → `<command> (<tool>) · <package>` for any single-target row (lint, format, jsc, pack, single-app tsc, every `doctor` subcommand). Dedups to just `<command>` when the tool's binary *is* the command (so `tsc`, not `tsc (tsc)`).
- `fanoutTitle(command, provider?, count, unit)` → `<command> (<tool>) · <n> <unit>` for fan-out section titles (monorepo `tsc` → `tsc (oxlint) · 8 packages`; `rr doctor` → `doctor · 3 tools`, tool omitted because the rows span several tools and carry the per-tool name).

### Scope (which commands join the board)

- **lint, format, jsc, tsc, pack** — the plugin-backed tool runners (one exit-code verdict + captured output). `pack` joins via the contract change below.
- **doctor** — yes; it already fans out across every distinct provider in parallel, which is one-row-per-tool by nature. Adopted at the call site only (map `DoctorResult` → a board task); no contract change.
- **clean** — no. It's a filesystem op with no pass/fail verdict and no fan-out; a one-row always-passing board would be theatre. Stays `logger`-based. **Rule:** a command joins the board when it has ≥1 task with an independent pass/fail verdict; pure side-effect commands (`clean`, `config`, `completion`, `plugins`) stay on `logger`/clack.

### Contract changes

- **`Packer.pack()` → `Promise<RunReport>`.** Unifies the five tool-running verbs under one return type so `runToolCommand` covers all five. `TsdownService.pack` calls `runReport` instead of `exec`. Propagated in one commit (kernel-internal contract; only tsdown implements `pack`).
- **`Doctor.doctor()` → `Promise<RunReport>` (collapsed from `DoctorResult`).** arch-critic recommended keeping `DoctorResult` (for the subcommand's `process.exit(exitCode)` and the "healthy vs passed" semantic line). But once `doctor` rendered on the board it stopped using the structured exit code (the board aggregates `process.exitCode = 1`), and the human asked for full UI consolidation — specifically that `doctor` show the same `$ <command>` line as every other command. Keeping `DoctorResult` left `doctor` as the one verb whose output didn't flow through `runReport`'s `$ <cmd>` prepend. So `doctor()` now returns a `RunReport` whose `output` leads with `$ <bin> --help` (the liveness probe; the tool's full help text is dropped as noise) plus the error on failure. This removes the `DoctorResult`/`DoctorOutput` types entirely and lets `doctor` flow through `reportTask` like the verbs. Reverses arch-critic's Decision 3 at the human's direction.

## Alternatives rejected

- Option A (`UIService`): a stateless, dep-less class is ceremony; can't hold the `collector` ambient state without a forbidden second singleton.
- Option C (factory): only serves the already-short commands; leaks for `tsc`/`jsc`/`check`.
- Keeping `DoctorResult` distinct from `RunReport` (arch-critic's pick): rejected at the human's direction — see Decision 3; it kept `doctor` from showing the `$ <command>` line every other command shows, and its structured exit code went unused once `doctor` rendered on the board.

## Notes for human review

- **`pack` streaming tradeoff (overrides 013's carve-out).** Decision 013 kept `pack` streaming on purpose — a build emits incremental progress worth watching live, unlike the batch check tools. Moving `pack` to the captured board trades tsdown's live build log for a spinner + flushed-at-end output. The human chose UI uniformity over live build streaming; recorded here because it reverses a 013 note. The fallback (if live build feedback is later judged more valuable) is to leave `pack` as the one streaming command and not board-wire it.
Loading
Loading