rook by TestMu AI inside Claude Code: Claude runs rook against the agent it is building and gets failures back grouped into root-cause clusters with rook's…

Claude builds your agent. rook tests it. Same terminal.
A Claude Code mod for rook, TestMu AI's agent-assurance CLI. It puts rook's verdicts where your agent is being written. Claude can run the scenarios, read rook's evidence and fix what failed, and you watch it happen in a live pane.
New here? Watch v0.3 in 6 minutes, v0.2 in 4 minutes and the setup walkthrough and read Getting started: one path for a repository with no rook tests, one for a repository that has them.
Status: early access, like Claude Code's function-hooks API it is built on. Requires Claude Code 2.1.288 or newer and the rook CLI on
PATH(npm install -g @testmuai/rook, orbrew install lambdatest/rook/rookon macOS and Linux).
| Claude runs rook | Fourteen tools in all. The core five: run (execute scenarios, get verdicts back), report (read a finished run from disk, no credits), status, scenarios (every scenario with its latest verdict, so Claude picks only without guessing) and generate (write scenarios). Failures come back grouped into rook's clusters. With rca, each cluster has its cause, fault, files and the proposed diff from remedies/CL-xx.md, followed by rook's narrative and next steps. run also takes tags, profile, concurrency, resume, continueRun + phases, and a free-text instruction. | ||||||
| Setup inside Claude Code | Three more tools for a repository with no rook setup: project (list, use or create the rook project), explore (rook explore .: find the agents, write their features; spends credits, takes minutes) and profile_test (list profiles, use one, or test one: a single call to the agent, under the production guard). Adding a profile needs your connection details, so Claude asks you to type ! rook profile add <name> --from connection.md yourself. | ||||||
| Pane tabs | Health (below), Runs (finished runs with counts, pass rate, credits and duration; open one for Report to Claude, Explain with rca, Compare with…; pick two to see per scenario what was fixed, regressed, new, missing or still failing), Scenarios (filter all · failing · unable to verify · never run · functional · adversarial; select and Run selected, Exclude/Include selected; open one for its goal, criteria and latest verdict with Fix this with Claude, Turn into regression test and Re-run 3×, which marks it flaky when verdicts disagree; a box to generate new scenarios), Setup (the checklist; each profile with verified state, target and its variables set or unset in rook's env store, never their values; Use, Test; a profile wizard with the exact ! rook profile add … and ! rook env set … lines; Sync upstream; the session budget). /rook tab <name> opens one. | ||||||
| Confirm before spending | Run all, Re-run failed, Re-test these, Run selected, Re-run 3×, profile Test and the generate box park in a confirm bar with a credit estimate (the latest run's credits per scenario) until you press Confirm. A run started from the pane or /rook run has Cancel. | ||||||
| Verdict card | When Claude runs or reports a rook run, the transcript shows a card instead of the plain result: Pass / Fail / Unable to Verify, pass rate, credits, duration, the top clusters or failures, Open in pane and Fix with Claude. While Claude's run is in flight, its tool row shows how many scenarios are judged. Claude still reads the same result text. | ||||||
| Live progress for generate and explore | A lane per feature (generate: planned → writing → done, with scenarios written / planned) or per agent (explore), read from rook's --verbose output and from the files it writes; the spinner shows the same when Claude called the tool, e.g. rook generate · F-003 writing · 3/7 · 2m10s. | ||||||
| History | The runs tool and /rook runs [N] list the agent's runs on disk, newest first (size, Pass / Fail / Unable to Verify, credits, local test runs), so Claude can name an older run for report or its rca option. Each scenario's latest verdict looks back over the newest 30 runs, and keeps looking up to 300 for scenarios those did not judge. Scenarios or features changed outside the session (rook generate in another terminal) are picked up on the next poll. When the selected project has no runs but another project folder here does, the pane says where they are. | ||||||
| Depth | report with rca: true runs rook report <run> --rca on a finished run: rook explains its clusters without calling the agent again (free when that agent version was explained already, otherwise it costs credits); the pane's Explain with rca does the same. agent lists the project's agents and switches with use (the pane offers the switch when there is more than one). curate excludes or includes scenarios; delete is left to the terminal. The pane header and status show the credit balance (rook plan), and a run's result warns when it will not cover the re-test. | ||||||
| Two views: QE and Developer | The first tab follows who is looking (the lens option, or l in the pane, kept between sessions). Release (QE): ship or not, with pass rate beside trusted pass rate (passes with a forbidden hit, a criterion passed at Low confidence or under 100% compliance do not count); Blockers (agent bugs to file) with each scenario's last verdicts; Verdicts to check (passed-but, judge unsure, profile broke); Suite health (scenarios that never passed, flaky ones) and coverage gaps by feature; Draft bug reports, Generate for gaps. My change (Developer): the tracked files edited since the last green run, each with the scenarios it reaches and ⧗ on verdicts older than the edit; what the change broke, with the failing criterion; what it cost in tokens and latency (like for like); what is not your code; Fix with Claude, Re-test affected. rook's clusters and What nobody looked at stay in both. | ||||||
| Who acts on a verdict | Every non-passing scenario is marked agent bug, scenario wrong (never passed in 4 runs), profile broke (a hook failed or no reply was recorded), judge unsure (could not check) or passed, but…, from the evidence on disk; rook's own root-cause fault wins when --rca explained it. The main button follows the mark. | ||||||
| Scenario drill-down | Any scenario in any tab opens one screen: why it has that mark, the files changed since it last passed, the profile's hooks as a timeline with the failed one marked, the conversation with the turns the judge quoted marked, every criterion with its confidence (weak passes flagged), its history and its cost. A QE sees criteria first and Draft bug report; a developer sees changed files and the conversation first and Fix with Claude. Prompts carry only what the evidence shows, with the file paths. | ||||||
| Trends | Scenarios × recent runs as a grid (hover, click or arrows and Enter on terminals that support it; a text grid elsewhere): flaky, regressed, never passed and fixed at a glance. Pass rate, trusted pass rate and tokens per run. | ||||||
| Live | A run in flight shows above every tab: progress, time left, the lanes, and each verdict as it lands (open any of them). The band above the prompt follows the run with Open and Cancel. A run from another terminal or CI that lands with a new failure keeps a band until dismissed. Edits made by Claude, the shell or an editor mark the affected verdicts stale within one poll. | ||||||
| Keyboard | /rook, a band's Open and the card's Open in pane give the pane the keys; when rook opens it on its own it does not take your typing. Every action has a key (shown at the pane's foot); Esc returns to the prompt. | ||||||
| Health tab | (the first tab, as Release or My change) Clusters and failed scenarios open to their criteria, evidence and remedy. What nobody looked at groups each gap by cause (agent never answered, tool calls nobody reported, not observable, undecidable, passed with parts unchecked) with the remedy, Fix with Claude and Re-test these N. | ||||||
| Spinner progress | While Claude waits on a run, the turn's spinner shows rook 3/8 · SC-004 execute · 1m12s, so progress is visible even where the pane can't dock. | ||||||
| Re-test prompt | When Claude edits a file the agent is built from (agent.yaml → source.tracks), a band above the prompt names the scenarios it may affect: those whose features cite the file; for shared code no feature cites, those whose features cite a file importing it, or whose expected tool calls and text name what the file defines. It says how it chose, and falls back to the whole agent saying why. Each scenario is a toggle with its cost; Details lists why each matched; Re-test N asks Claude to run only the ticked ones. | ||||||
| Failures as context | If you start a run in another terminal, the mod notices when it finishes. If anything failed, Claude gets the clusters and failing criteria without any copy-pasting. | ||||||
| Status line | Picks what matters now. A fresh repo: not set up · next: select a project · found agent + PRD. Pulled but never run: 21 scenarios · never run · next: test the profile. Running: ◐ 5/12 ✗1 · SC-006 execute 12s · ETA 3m. Just finished (10 minutes): ✓ 11/12 2m ago · fixed SC-004 · ✗ SC-009. Idle for a QE: ✓7 ✗3 ?2 58% (42% trusted) ▂▄▅▆ · 2 blockers · 1 flaky; for a developer: 2 regressed since green · Δ3 files · 2 stale · tokens +11%. Low credits and the session budget are added when there is room; on a narrow line the less important parts drop first. | ||||||
| More tools | compare (two runs from disk, no credits), sync (record the project upstream with rook sync, no credits, needs a plan with Rook cloud; check reads the state only) and ci (preview or write a GitHub Actions workflow). | ||||||
| CI | /rook ci previews .github/workflows/rook.yml: on pull requests it installs rook from npm (@testmuai/rook, your version or latest), signs in from a ROOK_AUTH secret, sets the profile's variables from secrets of the same names, runs rook run --test --json on the functional scenarios (or ROOK_ONLY), uploads the run folder and fails on Fail verdicts (rook's exit code is 0 either way, so it reads the report). Unable to Verify fails the job only with --strict. /rook ci write writes it and never overwrites without --force. | ||||||
| Budget | /rook budget 50 caps what runs and generates may spend this session, measured from the credit balance; one that would go past it is refused. /rook budget off lifts it. | ||||||
/rook | pane · status · scenarios [exclude <ids>] · scenarios [include <ids>] · agent [use <id>] · report [run] [--rca] · explain [run] [--rca] · run [--only …] [--class …] [--category …] [--tag …] [--profile …] [--name …] [--concurrency N] [--resume <run>] [--run <run> --phases …] [--test] [--rca] [-- instruction] · generate [--total N] [--class …] [--category …] [--force] [-- what to cover] · `tab health\ | runs\ | scenarios\ | setup\ | trends · compare [base] [head] · flaky <id> [N] · sync · budget [<credits>\ | off\ | status] · ci [write] [--force] [--strict] · ui · confirm-prod [profile] · help` |
/rook setup | `project [list\ | use <id>\ | create <name>] · explore [--force] [-- instruction] · profile [list\ | use <id>\ | test [id] [-- goal]]` | ||
| Production guard | Refuses rook run (from Claude's shell or the tool, for the active profile or the one the run names) while the profile's target looks like production, until you type /rook confirm-prod. That opens a 15-minute window. The agent's writes are real and rook cannot roll them back. |
It keeps rook's vocabulary. Unable to Verify is not Fail: it is counted and shown separately, and failure notes to Claude say so.
From GitHub, inside Claude Code:
/plugin marketplace add LambdaTest/rook-claude-plugin
/plugin install rook@rook-claude-plugin
Or from a clone, for one session: claude --plugin-dir <clone>/plugins/rook.
Open Claude Code in your agent's repository. With a rook workspace (.testmuai/rook/) the pane opens by itself when the terminal is wide enough to dock it; otherwise type /rook. Without one, the status line says what rook found in the repository (agent code, a requirements document, a connection file, CI) and the next setup step; /rook shows the checklist with a button for that step.
Set them in /config or under pluginConfigs.rook in settings.
| Option | Default | |
|---|---|---|
rookPath | rook | The CLI to run: a name on PATH or an absolute path |
pane | auto | auto opens the pane in a rook workspace; command opens it only on /rook; off never opens it |
lens | qe | Who the first tab leads for: qe (Release) or dev (My change); l in the pane switches and is kept |
statusLine | true | Show the score in the status line |
retestBand | true | Track edits to the agent and offer a re-test |
failureContext | true | Hand failures of runs started elsewhere to Claude |
prodGuard | true | Refuse runs against a production-looking target until confirmed |
prodPatterns | prod,production,live | Whole words that mark a target as production |
allowRules | (empty) | rook's own tool calls during run and generate are approved with --yes; list rules (;-separated) to pass --allow for each instead, and rook declines the rest |
The plugin itself makes no network requests and holds no keys. Everything below is visible with claude plugin validate . (its hooks: and calls: lines).
Runs
rook CLI (or the path you set in rookPath), in the session's directory. When a session starts it runs rook --version, rook auth status and rook plan --json (credits left), and runs plan again after anything that spends credits. The pane's refresh reads files only. rook run, generate, explore, report --rca and profile test run only when you or Claude ask, and Claude Code asks your permission before Claude calls a tool that spends credits.rook ui --local --no-open, only when you open the evidence viewer: rook's read-only viewer, served on localhost.rook generate and rook explore with --verbose, so the pane can draw progress; rook writes those lines to stderr.rook sync and rook status --json when you press Sync upstream or Check sync state, type /rook sync, or Claude calls the sync tool. Sync records the project's features, scenarios and profiles with TestMu AI's rook service; it spends no credits. It needs an Agent Assurance plan with Rook cloud: on Starter, rook refuses it with an upgrade link and runs stay on this machine.rook --version when you preview a CI workflow, to pin its npm install -g @testmuai/rook to your version (or latest).rook plan --json before each run or generate while a session budget is set.What rook does when it runs (rook's behaviour, not the plugin's): it sends your agent's code, features and scenarios to TestMu AI's rook service to explore, plan and judge, and it calls your agent through the profile you configured. A run's actions on your agent are real.
Reads
.testmuai/rook/ in the session's directory: settings, scenarios, features, profiles, runs, verdicts and remedies.~/.testmuai/rook/env.json (rook's variable store, written by rook env set), for the production guard only. It reads the values of the variables the active profile declares, checks them for production markers and never shows, stores or sends a value.TMPDIR environment variable, to place profile-test state folders.Writes
.github/workflows/rook.yml, only when you type /rook ci write or Claude calls the ci tool with write, and never over an existing file without --force / force. Nothing else in your repository: rook writes .testmuai/rook/ itself.rook-profile-test-<time>/ folder under the system temp directory for each profile test it starts, passed to rook as ROOK_STATE_DIR / ROOK_RUN_STATE_DIR.Inside the session
mcp__rook__*) and the /rook command.Bash calls only to refuse a rook run against a production-looking target until you type /rook confirm-prod. It never approves a tool call.run and report tools as a verdict card. The text Claude reads is unchanged.failureContext.Only through rook's published contract:
--json documents: rook run --json, rook generate --json, rook status --json, rook scenarios list --json, rook scenarios exclude|include --json, rook plan --json, plus rook ui --local --no-open for the viewer's address. rook agent and rook report --rca answer in prose, so the mod reads the agent list from it (or the agent directories) and re-reads the explained run from disk.rook project --json (or the plain rook project listing of an older rook), rook project use|create, rook explore . --json, rook profile, rook profile use|test. explore and profile test print prose even with --json, so the mod reads their closing lines. An older rook also answers generate --json with a prose line (7 scenario(s) written, 0 already current, 553.12 credits); the mod reads that line instead of reporting nothing written..testmuai/rook/ layout: run.yaml, report.yaml (written last, so its presence means finished; its clusters, narrative and next), remedies/<cluster>.md, and scenarios/<id>/verdict.yaml. A scenario folder with no verdict yet is in flight. Its phase comes from hooks.json, request.json and response.json.The mod holds no prompts and no model keys. It passes rook only validated flags:
SC-001 form-A free-text instruction goes after --. Anything else is refused before argv is built.
Profile tests and state. rook profile test does not give a profile's scripts the state directory a run does, so a profile that keeps its session in ROOK_STATE_DIR fails it with "ROOK_STATE_DIR is required". The mod sets a fresh ROOK_STATE_DIR / ROOK_RUN_STATE_DIR under the system temp directory for each profile test it starts. rook passes its own environment to the scripts and sets these itself during a run, so runs keep their per-scenario directories.
It reads the active profile's target.endpoint, domain, command, server, url and base_url, plus hook_env. It also reads the values of the variables the profile declares under env, taken from rook's own store (~/.testmuai/rook/env.json, written by rook env set).
A refusal names the field or variable and the marker word. It never includes the value, because a declared variable may be a credential. localhost and 127.0.0.1 targets are never flagged.
It does not read your shell's environment. A target whose only production marker is a variable exported in your shell will not be caught.
It is a speed bump, not a sandbox, and it does not make a run safe. Point your profiles at staging.
claude plugin validate . # what the module hooks and calls; what the engine would refuse
claude plugin test . # logic, pane and band on terminal and desktop, tools, guard, /rook
The YAML reader handles the subset rook writes. It was checked against 3,949 real rook files and matched PyYAML on all of them, apart from trailing whitespace inside long descriptions. Its run totals were also checked against rook report --json on 52 real runs, with no mismatches.
Apache-2.0, like rook.
hooks/register.tsx 4733 lines1import { atom, read, update } from 'claude-code'
2import type { CommandRunInput, EngineInterface, Register } from 'claude-code'
3
4import type { RookCluster, RookConfirm, RookReadiness, RookRunView, RookScenarioRow, RookSnapshot, RookStepId, RookTab } from '../types'
5import type { El } from './views/kit'
6import { TabBar } from './views/tabs'
7import { ConfirmBar } from './views/confirm'
8import { SetupTab } from './views/setup'
9// ── imports: progress
10import { JobLanes } from './views/job'
11import { featureEntries, jobFromDisk, jobFromLine, jobSpinnerText, lineSplitter, newJob, scenarioEntries, scenarioKeys } from './progress'
12import type { DiskEntry } from './progress'
13import type { RookJob } from '../types'
14import type { Timer } from 'claude-code'
15// ── end imports: progress
16
17// ── imports: health
18import { estimateOf, gapGroups, notesOf, plural, unverifiedPrompt, untilAborted } from './health'
19import type { GapCause } from './health'
20// ── end imports: health
21
22// ── imports: runs tab
23import { RunsTab } from './views/runs'
24import { comparePair, compareText, diffRuns, historyOf, historySignature, ordered } from './history'
25import type { RunDiff } from './history'
26// ── end imports: runs tab
27
28// ── imports: scenarios tab
29import { ScenariosTab } from './views/scenarios'
30import type { ScenarioDetail } from './views/scenarios'
31import { FLAKY_DEFAULT, filterScenarios, flakyText, isFilter, parseFlakyArgs, readScenarios, regressionPrompt, toggled, withAll, withVerdicts } from './scenarios'
32import type { ScenarioInfo } from './scenarios'
33import { failureNote as scenarioFailureNote } from './format'
34import { rowOf } from './workspace'
35import type { RookStatus } from '../types'
36// ── end imports: scenarios tab
37
38// ── imports: setup tab
39import { budgetRefusal, budgetText, CONNECTION_FILES, GENERATE_ESTIMATE, parseBudget, profileView, spentOf, syncArgs, syncStateOf, syncStateText, syncText, verifiedProfiles, wizardOf } from './setup'
40import type { SyncState } from './setup'
41import type { SetupPanel } from './views/setup'
42// ── end imports: setup tab
43
44// ── imports: band
45import { changedSources, sourceStamps } from './sources'
46import { mergeReach, reach } from './impact'
47import type { Reach } from './impact'
48import { tickedOf } from './format'
49import { RetestBand } from './views/band'
50// ── end imports: band
51
52// ── imports: status line
53import { elapsed as liveTime } from './format'
54import { composeStatus, recentRuns } from './statusline'
55import type { TrendPoint } from './statusline'
56// ── end imports: status line
57
58// ── imports: card
59import type { RenderElement, RenderInput } from 'claude-code'
60import { CARD_RUN_TOOL, CARD_TOOL, cardFacts, cardProgress, runIdOf } from './card'
61import { RunProgressRow, VerdictCard } from './views/card'
62// ── end imports: card
63
64// ── imports: ci
65import { parseCiArgs, previewText, WORKFLOW_PATH, workflowYaml, writtenText } from './ci'
66import type { CiPlan, CiRequest } from './ci'
67// ── end imports: ci
68
69// ── imports: shared lens and drill-down
70import { readEvidence, verdictHistory } from './evidence'
71import type { RookLens } from '../types'
72// ── end imports: shared lens and drill-down
73
74// ── imports: drill-down
75import { ScenarioDrillDown } from './views/detail'
76import { detailModel } from './detail'
77import type { DetailActionId, DetailModel } from './detail'
78import { bugReportPrompt, fixAgentPrompt, fixProfilePrompt, fixScenarioPrompt } from './prompts'
79import type { PromptAbout } from './prompts'
80import type { RookEvidence } from '../types'
81import type { ScenarioInfo as DrillScenarioInfo } from './scenarios'
82// ── end imports: drill-down
83
84// ── imports: home
85import { ChangeView, ReleaseView } from './views/home'
86import { blockersReportPrompt, costOf, coverageOf, fixChangePrompt, gapsInstruction, isGreenRun, isStaleVerdict, lastGreenOf, notYoursLine, ownedOf, readFeatures, regressionsOf, releaseOf } from './home'
87import type { ChangedFile, HomeFeature, HomeInput, HomeRow } from './home'
88import { changedSince, runStartMs, verdictsBefore } from './changes'
89import { historyRuns } from './evidence'
90// ── end imports: home
91
92// ── imports: trends
93import { TrendsTab } from './views/trends'
94import { heatGrid, heatMessageOf, previousRunId, TAB_KEYS, trendRuns, versusPrevious } from './trends'
95import type { TrendRun } from './trends'
96// ── end imports: trends
97
98// ── imports: live
99import { freshnessText, isInFlight, isOwnRun, justJudged, liveHeadline, nextLive, runBandText } from './live'
100import type { LiveState } from './live'
101import { etaMs } from './statusline'
102import { LiveBlock, NewFailBand, RunBand } from './views/live'
103// ── end imports: live
104
105// ── imports: keys
106import { listingKey, nextStep, scanRepo, START_PROMPTS, startSteps } from './repo'
107import type { StartAction, StartFacts } from './repo'
108import { FreshStart, KeysHint, NextStep } from './views/fresh'
109import { LENS_LABEL } from './views/tabs'
110import type { SettingRow } from './views/setup'
111import type { RookRepoFound } from '../types'
112// ── end imports: keys
113import {
114 agentsText,
115 balanceText,
116 balanceWarning,
117 clip,
118 clusterNote,
119 curateText,
120 creditsPerScenario,
121 duration,
122 excerpt,
123 exploreText,
124 failureContext,
125 failureNote,
126 gapText,
127 generateText,
128 isExplained,
129 isReporting,
130 metricsLine,
131 orderedClusters,
132 profilesText,
133 profileTestText,
134 progressBar,
135 projectsText,
136 rcaLine,
137 reasonText,
138 REPORTING,
139 retestPrompt,
140 runSummary,
141 runsText,
142 scenariosText,
143 spinnerText,
144 statusText,
145 unlooked,
146} from './format'
147import { assess, declaredVariables, isRunCommand } from './guard'
148import { indexAgent, relativeTo } from './impact'
149import { authOf, blockedText, blockerOf, checklistText, diskFacts, learned, NEEDS, readinessOf, setupLine, versionOf } from './readiness'
150import type { CliFacts } from './readiness'
151import type { AgentIndex } from './impact'
152import {
153 agentUseArgs,
154 allowRulesOf,
155 balanceOf,
156 CLI_ENV,
157 curateArgs,
158 exploreArgs,
159 failureOf,
160 generateArgs,
161 jsonOf,
162 parseAgentList,
163 parseExploreFlags,
164 parseGenerateFlags,
165 parseRunFlags,
166 profileArgs,
167 projectArgs,
168 rcaOutcome,
169 reportRcaArgs,
170 runArgs,
171} from './rook'
172import type { Approval, CliResult, ExploreRequest, GenerateRequest, ProfileRequest, ProjectRequest, RunRequest } from './rook'
173import { agentIdsOf, changesIn, countsOf, currentVerdicts, locate, projectDirName, readRun, ROOT, RUN_ID, runIds, runsElsewhere, runSummaries, scenarioSignature } from './workspace'
174import type { Io, Located, RowCache } from './workspace'
175
176/**
177 * rook inside Claude Code.
178 *
179 * 1. Tools the model calls — `run`, `report`, `status`, `scenarios`,
180 * `generate` — so Claude can test the agent it is building, read rook's
181 * evidence and root-cause clusters back, and write scenarios for it;
182 * `project`, `explore`, `profile_test` so a repository with no rook
183 * setup gets one without leaving Claude Code.
184 * 2. A live pane: every scenario's latest verdict, the run in flight lane by
185 * lane, clusters with rook's remedies, failures you can open, and what
186 * nobody looked at. The turn's spinner carries the run's progress too.
187 * 3. A band above the prompt when Claude edits a file the agent is built
188 * from: which scenarios touch it, and a button to re-test them.
189 * 4. Failures of a run started elsewhere handed to Claude as context.
190 * 5. A status line score over every scenario's latest verdict, with what the
191 * latest run fixed or regressed.
192 * 6. `/rook` — pane, status, report, explain, run, scenarios, agent,
193 * generate, project, explore, profile, ui, confirm-prod.
194 * 7. A guard that refuses runs against a production-looking target until
195 * the person confirms.
196 * 8. Depth: `report --rca` on a finished run, agent switching, scenario
197 * curation (`agent`, `curate` tools), and the credit balance.
198 * 9. A setup checklist (installed, signed in, project, agent, scenarios,
199 * profile) wherever rook would refuse, with the one next step.
200 *
201 * Everything rook-side goes through the published CLI contract (`--json`
202 * documents) and the documented `.testmuai/rook/` layout. The mod holds no
203 * prompts and no keys: rook's controller supplies those.
204 */
205
206const PANE = 'rook'
207const POLL_MS = 3_000
208const CONFIRM_MS = 15 * 60_000
209const VIEWER_START_MS = 15_000
210
211const snapshotAtom = atom({ plugin: 'rook', key: 'snapshot' } as const, null)
212const staleAtom = atom({ plugin: 'rook', key: 'stale' } as const, null)
213const runningAtom = atom({ plugin: 'rook', key: 'running' } as const, null)
214const confirmedAtom = atom({ plugin: 'rook', key: 'prodConfirmed' } as const, null)
215const seenAtom = atom({ plugin: 'rook', key: 'seenFinished' } as const, null)
216const bandHiddenAtom = atom({ plugin: 'rook', key: 'isBandHidden' } as const, false)
217const expandedAtom = atom({ plugin: 'rook', key: 'expanded' } as const, null)
218const viewerAtom = atom({ plugin: 'rook', key: 'viewerUrl' } as const, null)
219const tickAtom = atom({ plugin: 'rook', key: 'tick' } as const, 0)
220const lastErrorAtom = atom({ plugin: 'rook', key: 'lastError' } as const, null)
221const agentsAtom = atom({ plugin: 'rook', key: 'agents' } as const, [])
222const balanceAtom = atom({ plugin: 'rook', key: 'balance' } as const, null)
223const explainingAtom = atom({ plugin: 'rook', key: 'explaining' } as const, null)
224const tabAtom = atom({ plugin: 'rook', key: 'tab' } as const, 'health')
225const confirmAtom = atom({ plugin: 'rook', key: 'confirm' } as const, null)
226
227// ── atoms: progress
228const jobAtom = atom({ plugin: 'rook', key: 'job' } as const, null)
229// ── end atoms: progress
230
231// ── atoms: health
232// ── end atoms: health
233
234// ── atoms: runs tab
235const historyAtom = atom({ plugin: 'rook', key: 'history' } as const, null)
236const compareAtom = atom({ plugin: 'rook', key: 'compare' } as const, [])
237const runOpenAtom = atom({ plugin: 'rook', key: 'runOpen' } as const, null)
238const runDiffAtom = atom({ plugin: 'rook', key: 'runDiff' } as const, null)
239// ── end atoms: runs tab
240
241// ── atoms: scenarios tab
242const selectedAtom = atom({ plugin: 'rook', key: 'selected' } as const, [])
243const filterAtom = atom({ plugin: 'rook', key: 'filter' } as const, 'all')
244const draftAtom = atom({ plugin: 'rook', key: 'draft' } as const, '')
245const flakyAtom = atom({ plugin: 'rook', key: 'flaky' } as const, {})
246const flakyPriorAtom = atom({ plugin: 'rook', key: 'flakyPrior' } as const, {})
247const scenarioDetailAtom = atom({ plugin: 'rook', key: 'scenarioDetail' } as const, null)
248// ── end atoms: scenarios tab
249
250// ── atoms: setup tab
251const budgetAtom = atom({ plugin: 'rook', key: 'budget' } as const, null)
252const syncAtom = atom({ plugin: 'rook', key: 'sync' } as const, null)
253// ── end atoms: setup tab
254
255// ── atoms: band
256const untickedAtom = atom({ plugin: 'rook', key: 'unticked' } as const, [])
257const bandOpenAtom = atom({ plugin: 'rook', key: 'isBandOpen' } as const, false)
258// ── end atoms: band
259
260// ── atoms: status line
261// ── end atoms: status line
262
263// ── atoms: card
264// ── end atoms: card
265
266// ── atoms: ci
267// ── end atoms: ci
268
269// ── atoms: shared lens and drill-down
270const lensAtom = atom({ plugin: 'rook', key: 'lens' } as const, 'qe')
271const detailAtom = atom({ plugin: 'rook', key: 'detail' } as const, null)
272const evidenceAtom = atom({ plugin: 'rook', key: 'evidence' } as const, null)
273const verdictsAtom = atom({ plugin: 'rook', key: 'verdicts' } as const, [])
274// ── end atoms: shared lens and drill-down
275
276// ── atoms: drill-down
277// ── end atoms: drill-down
278
279// ── atoms: home
280// ── end atoms: home
281
282// ── atoms: trends
283const runVersusAtom = atom({ plugin: 'rook', key: 'runVersus' } as const, null)
284// ── end atoms: trends
285
286// ── atoms: live
287const liveAtom = atom({ plugin: 'rook', key: 'live' } as const, null)
288// ── end atoms: live
289
290// ── atoms: keys
291// ── end atoms: keys
292
293// The plugin's own tools as exact patterns: the engine's tool table is laid
294// when the mod loads, before session.start registers them.
295const RUN_TOOL = /^mcp__rook__run$/
296const REPORT_TOOL = /^mcp__rook__report$/
297const STATUS_TOOL = /^mcp__rook__status$/
298const SCENARIOS_TOOL = /^mcp__rook__scenarios$/
299const RUNS_TOOL = /^mcp__rook__runs$/
300const GENERATE_TOOL = /^mcp__rook__generate$/
301const PROJECT_TOOL = /^mcp__rook__project$/
302const EXPLORE_TOOL = /^mcp__rook__explore$/
303const PROFILE_TOOL = /^mcp__rook__profile_test$/
304const AGENT_TOOL = /^mcp__rook__agent$/
305const CURATE_TOOL = /^mcp__rook__curate$/
306// ── tool patterns: runs tab
307const COMPARE_TOOL = /^mcp__rook__compare$/
308// ── end tool patterns: runs tab
309// ── tool patterns: setup tab
310const SYNC_TOOL = /^mcp__rook__sync$/
311// ── end tool patterns: setup tab
312// ── tool patterns: ci
313const CI_TOOL = /^mcp__rook__ci$/
314// ── end tool patterns: ci
315/** Every tool that writes a file; MultiEdit exists on some builds only. */
316const EDIT_TOOLS = /^(Edit|Write|MultiEdit|NotebookEdit)$/
317
318const USAGE = [
319 '/rook open the live verdict pane',
320 '/rook tab health|runs|scenarios|setup open the pane on a tab',
321 '/rook status agents, scenarios and sync state',
322 '/rook scenarios every scenario with its latest verdict',
323 '/rook runs [N] the agent\'s runs on disk, newest first (default 20)',
324 '/rook scenarios exclude|include SC-001 [SC-002 …] leave scenarios out of runs, or bring them back',
325 '/rook agent [list|use <id>] the project\'s agents; switch the active one',
326 '/rook report [run] the latest (or named) run: clusters, verdicts, gaps, credits',
327 "/rook explain [run] hand the run's failures (and rook's remedies) to Claude as fix context",
328 '/rook report|explain [run] --rca have rook explain the clusters first, without calling the agent',
329 ' (free if this agent version was explained already, otherwise it costs credits)',
330 '/rook run [--only SC-001,SC-002] [--class …] [--category …] [--tag …] [--profile …] [--name …]',
331 ' [--concurrency 1-8] [--resume <run>] [--run <run> --phases collect,judge] [--test] [--rca] [-- <instruction>]',
332 '/rook generate [--total N] [--class …] [--category …] [--force] [-- <what to cover>]',
333 '/rook project [list|use <id>|create <name>] the rook project this workspace records to',
334 "/rook explore [--force] [-- <instruction>] read this repository: find the agents, write their features",
335 '/rook profile [list|use <id>|test [id] [-- <goal>]] how rook reaches the agent (add one: ! rook profile add …)',
336 '/rook compare [base] [head] what changed between two runs, per scenario (default: the two newest finished runs)',
337 '/rook flaky SC-001 [N] re-run one scenario N times in a row (default 3, 2-10) and say whether the verdicts disagree',
338 ' (spends credits each run)',
339 '/rook sync record this project upstream (rook sync): every agent, one write. No credits; needs a plan with Rook cloud',
340 '/rook budget [<credits>|off|status] cap what runs and generates may spend this session',
341 '/rook ci [write] [--force] [--strict] preview (or write) .github/workflows/rook.yml: rook as a pull-request check',
342 '/rook ui open the on-disk results viewer (rook ui --local)',
343 '/rook confirm-prod [profile] allow runs against a production-looking target for 15 minutes',
344 '',
345 "Runs, generate, explore and profile test approve rook's own tool calls with --yes, unless allowRules is set in /config.",
346].join('\n')
347
348/** The options and the caches of one load of the module; what a drawing reads lives in $.state. */
349type Ctx = {
350 bin: string
351 paneMode: string
352 isStatusLine: boolean
353 isRetestBand: boolean
354 isFailureContext: boolean
355 isProdGuard: boolean
356 prodPatterns: string
357 approval: Approval
358 cwd: string
359 located: Located | undefined
360 agentIndex: AgentIndex | undefined
361 isPolling: boolean
362 isPrimed: boolean
363 /** Set when something changed that the settled check cannot see (new scenarios). */
364 isDirty: boolean
365 /** The scenario and feature files as last indexed, to notice changes made elsewhere. */
366 indexSignature: string | undefined
367 /** The `rook ui --local` child, while it serves. */
368 viewer: AbortController | undefined
369 rows: RowCache
370 /** Installed and signed in, as the CLI last said: probed at start and when they block, not every poll. */
371 cli: CliFacts
372 /** The agent's runs list as the Runs tab last read it, to skip re-reading when nothing changed. */
373 historySignature?: string
374 /** Runs the person cancelled (`<agent dir>#<run id>`, kept in `$.store`): shown as stopped, though rook left them unfinished. */
375 cancelled?: Set<string>
376 /** Modification times of the agent's tracked sources at the last poll, keyed by agent directory. */
377 sourceStamps?: { agentDir: string; stamps: Map<string, number> }
378 /** The status line's recent finished runs, re-read only when the runs on disk change. */
379 statusTrend?: { key: string; points: TrendPoint[] }
380 /** Ends the background run the person started (pane, /rook run), for its Cancel. */
381 runAbort?: AbortController
382 /** The agent's scenario files as last read for the Scenarios tab, keyed by agent directory and index signature. */
383 scenarioList?: { key: string; list: ScenarioInfo[] }
384 /** The `lens` option: who the pane leads for until the person toggles it (the toggle is kept in $.store). */
385 lensDefault: RookLens
386 /** The runs the verdict history was last read from, to skip re-reading when nothing changed. */
387 verdictSignature?: string
388 /** When this session started: the change's baseline when no run was green. */
389 sessionStartedAt?: number
390 /** Minutes since the snapshot last changed, as the pane header last drew them. */
391 freshMinute?: number
392 /** Verdict statuses by file path, for the history: a verdict is written once. */
393 verdictCache?: Map<string, RookStatus>
394 /** feature: keys — the fresh-repo scan and the settings /config would not keep. */
395 keys?: KeysCache
396}
397
398/** Claude Code labels a plugin's toast with its name: drop rook's own prefix so it reads once. */
399function toastText(text: string): string {
400 return text.replace(/^rook:\s*/, '')
401}
402
403function textOption(value: unknown, fallback: string): string {
404 return typeof value === 'string' && value.trim() !== '' ? value.trim() : fallback
405}
406
407function flagOption(value: unknown, fallback: boolean): boolean {
408 return typeof value === 'boolean' ? value : fallback
409}
410
411/** Paths rooted at the session's directory: a relative path would follow the process's, which need not be it. */
412function ioOf($: EngineInterface, ctx: Ctx): Io {
413 const at = (path: string) => (path.startsWith('/') || ctx.cwd === '' ? path : `${ctx.cwd.replace(/\/$/, '')}/${path}`)
414
415 return {
416 read: path =>
417 $.fs.read(at(path)).then(
418 text => (typeof text === 'string' ? text : undefined),
419 () => undefined,
420 ),
421 list: path => $.fs.list(at(path)).catch(() => []),
422 }
423}
424
425// ── the rook CLI ─────────────────────────────────────────────────────────────
426
427/** A short rook command (status): bounded wait, whole output. */
428async function rookJson($: EngineInterface, ctx: Ctx, args: string[]): Promise<CliResult> {
429 try {
430 const ran = await $.process.run([ctx.bin, ...args], { env: CLI_ENV, stdin: '', timeoutMs: 60_000, ...(ctx.cwd !== '' && { cwd: ctx.cwd }) })
431
432 return { exitCode: ran.exitCode, doc: jsonOf(ran.stdout), stderr: ran.stderr, stdout: ran.stdout }
433 } catch (error) {
434 return { exitCode: 1, doc: undefined, stderr: `could not run ${ctx.bin}: ${String(error)}` }
435 }
436}
437
438/**
439 * A run can outlast `process.run`'s ten-minute ceiling, so it is streamed: the
440 * child lives as long as the loop reading it, and `signal` ends both.
441 */
442async function rookRun(
443 $: EngineInterface,
444 ctx: Ctx,
445 argv: string[],
446 signal?: AbortSignal,
447 env: Record<string, string> = {},
448 onLine?: (line: string) => void,
449): Promise<CliResult> {
450 const stream = $.process.spawn({ argv: [ctx.bin, ...argv], env: { ...CLI_ENV, ...env }, input: '', ...(ctx.cwd !== '' && { cwd: ctx.cwd }) })
451 const iterator = stream[Symbol.asyncIterator]()
452 let stdout = ''
453 let stderr = ''
454 // Whole lines as they arrive, each stream split on its own: progress for a caller that draws it.
455 const lines = onLine === undefined ? undefined : { stdout: lineSplitter(onLine), stderr: lineSplitter(onLine) }
456
457 for (;;) {
458 if (signal?.aborted) {
459 await iterator.return?.(undefined as never)
460
461 return { exitCode: 130, doc: undefined, stderr: `${stderr}\ninterrupted` }
462 }
463
464 const step = await untilAborted(iterator.next(), signal)
465
466 if (step === undefined) {
467 void iterator.return?.(undefined as never)?.catch(() => undefined)
468
469 return { exitCode: 130, doc: undefined, stderr: `${stderr}\ninterrupted` }
470 }
471
472 if (step.done) {
473 const ended = step.value as { code: number | null } | undefined
474 lines?.stdout.flush()
475 lines?.stderr.flush()
476
477 return { exitCode: ended?.code ?? 1, doc: jsonOf(stdout), stderr, stdout }
478 }
479
480 lines?.[step.value.stream === 'stdout' ? 'stdout' : 'stderr'].push(step.value.text)
481
482 if (step.value.stream === 'stdout') {
483 stdout += step.value.text
484 } else {
485 stderr = (stderr + step.value.text).slice(-20_000)
486 }
487 }
488}
489
490// ── reading the workspace ────────────────────────────────────────────────────
491
492async function where($: EngineInterface, ctx: Ctx): Promise<Located | undefined> {
493 ctx.located = await locate(ioOf($, ctx))
494
495 return ctx.located
496}
497
498// 5 · the status line
499async function showStatus($: EngineInterface, ctx: Ctx): Promise<void> {
500 // Every poll ends here: the run in flight and the one that landed are folded in first, line or not.
501 const live = await liveTrack($, ctx)
502
503 if (!ctx.isStatusLine) {
504 return
505 }
506
507 const snapshot = await read($, snapshotAtom)
508
509 // A workspace that cannot run yet says which step is missing; outside one, nothing.
510 const line = composeStatus({
511 lens: await read($, lensAtom),
512 live,
513 verdicts: await read($, verdictsAtom),
514 rowAt: rowAtOf(ctx),
515 ...(sourceStampsNow(ctx) !== undefined && { stamps: sourceStampsNow(ctx)! }),
516 snapshot,
517 running: await read($, runningAtom),
518 stale: await read($, staleAtom),
519 balance: await read($, balanceAtom),
520 budget: await read($, budgetAtom),
521 job: await read($, jobAtom),
522 trend: await statusTrend($, ctx, snapshot),
523 setup: setupLine(snapshot?.readiness),
524 now: await $.clock.now(),
525 })
526
527 $.ui.status(line)
528}
529
530// ── readiness ────────────────────────────────────────────────────────────────
531
532/** Ask the CLI what only it knows: installed, signed in. Two short calls, never on the poll. */
533async function probe($: EngineInterface, ctx: Ctx): Promise<void> {
534 const run = (args: string[]) =>
535 $.process.run([ctx.bin, ...args], { env: CLI_ENV, stdin: '', timeoutMs: 20_000, ...(ctx.cwd !== '' && { cwd: ctx.cwd }) }).catch(() => undefined)
536 const version = await run(['--version'])
537 const installed = version === undefined ? null : versionOf(version.exitCode, version.stdout)
538
539 if (installed === null) {
540 ctx.cli = { ...ctx.cli, version: null }
541
542 return
543 }
544
545 const auth = await run(['auth', 'status'])
546
547 ctx.cli = { ...ctx.cli, version: installed, auth: auth === undefined ? 'unknown' : authOf(auth.exitCode, `${auth.stdout}\n${auth.stderr}`) }
548}
549
550async function readinessNow($: EngineInterface, ctx: Ctx): Promise<RookReadiness | undefined> {
551 await poll($, ctx)
552
553 return (await read($, snapshotAtom))?.readiness
554}
555
556/**
557 * Why an action cannot be taken yet, in the checklist's words, or undefined.
558 * A step the CLI decides is probed again before refusing: the person may have
559 * run `! rook login` since.
560 */
561async function setupBlock($: EngineInterface, ctx: Ctx, need: readonly RookStepId[], doing: string): Promise<string | undefined> {
562 let readiness = await readinessNow($, ctx)
563 const step = blockerOf(readiness, need)
564
565 if (step?.id === 'installed' || step?.id === 'signed_in') {
566 await probe($, ctx)
567 readiness = await readinessNow($, ctx)
568 }
569
570 return blockedText(readiness, need, doing)
571}
572
573/** A failed rook command that names a setup step (a remedy code, a missing binary) updates the checklist. */
574async function learn($: EngineInterface, ctx: Ctx, result: CliResult): Promise<RookReadiness | undefined> {
575 const next = learned(ctx.cli, await diskFacts(ioOf($, ctx)), result)
576
577 if (next === undefined) {
578 return undefined
579 }
580
581 ctx.cli = next
582
583 return readinessNow($, ctx)
584}
585
586/** A failure with the setup step it revealed, when it revealed one. */
587async function explained($: EngineInterface, ctx: Ctx, result: CliResult, problem: string, need: readonly RookStepId[], doing: string): Promise<string> {
588 const blocked = blockedText(await learn($, ctx, result), need, doing)
589
590 return blocked === undefined ? problem : `${problem}\n${blocked}`
591}
592
593/** "Check again" in the pane's checklist. */
594async function recheck($: EngineInterface, ctx: Ctx): Promise<void> {
595 ctx.cli = { ...ctx.cli, projectRefused: undefined }
596 await probe($, ctx)
597 await poll($, ctx)
598}
599
600async function poll($: EngineInterface, ctx: Ctx): Promise<void> {
601 if (ctx.isPolling) {
602 return
603 }
604
605 ctx.isPolling = true
606
607 try {
608 const io = ioOf($, ctx)
609 const readiness = readinessOf(await diskFacts(io), ctx.cli)
610 const loc = await where($, ctx)
611 const before = await read($, snapshotAtom)
612
613 if (loc === undefined) {
614 // No agent to read: the snapshot carries only the checklist, and what the repository holds that rook could start from.
615 const repoFound = await repoFoundNow($, ctx)
616 const bare: RookSnapshot = { current: [], neverRun: 0, checkedAt: await $.clock.now(), readiness, repoFound }
617
618 if (JSON.stringify({ ...before, checkedAt: 0 }) !== JSON.stringify({ ...bare, checkedAt: 0 })) {
619 await update($, snapshotAtom, () => bare)
620 }
621
622 return
623 }
624
625 await syncAgents($, ctx, loc)
626 await watchSources($, ctx, loc)
627
628 // Scenarios or features changed outside this session (rook generate in a
629 // terminal, an exclude): re-read the index and redraw.
630 const signature = `${loc.agentDir}#${await scenarioSignature(io, loc.agentDir)}`
631
632 if (ctx.indexSignature !== signature) {
633 if (ctx.indexSignature !== undefined) {
634 ctx.agentIndex = undefined
635 ctx.isDirty = true
636 }
637
638 ctx.indexSignature = signature
639 }
640
641 const ids = await runIds(io, loc.agentDir)
642 const latestId = ids[0]
643 const isSettled =
644 !ctx.isDirty && before?.agentId === loc.agentId && before.latest?.runId === latestId && before.latest?.finished === true
645
646 // `! rook env set` changes no workspace file: read the names each poll so the pane and the line follow it.
647 const unsetVariables = await unsetOfActive($, ctx, loc)
648
649 if (isSettled) {
650 if (JSON.stringify(before.readiness) !== JSON.stringify(readiness) || JSON.stringify(before.unsetVariables ?? []) !== JSON.stringify(unsetVariables)) {
651 await update($, snapshotAtom, snapshot => (snapshot === null ? null : { ...snapshot, readiness, unsetVariables }))
652 }
653
654 return
655 }
656
657 ctx.isDirty = false
658
659 const read0 = latestId === undefined ? undefined : await readRun(io, loc.agentDir, latestId, ctx.rows)
660 const latest = read0 === undefined ? undefined : await liveOrStopped($, ctx, loc, read0)
661 const index = ctx.agentIndex ?? (await indexAgent(io, loc.agentDir))
662 ctx.agentIndex = index
663 const current = await currentVerdicts(io, loc.agentDir, ids, ctx.rows, new Set(index.scenarios.map(scenario => scenario.id)))
664 const judged = new Set(current.map(row => row.id))
665 const profileId = (await io.read(`${loc.agentDir}/profiles/active`))?.trim()
666 const snapshot: RookSnapshot = {
667 agentId: loc.agentId,
668 ...(profileId !== undefined && profileId !== '' && { profileId }),
669 ...(latest !== undefined && { latest }),
670 current,
671 neverRun: index.scenarios.filter(scenario => !judged.has(scenario.id)).length,
672 runCount: ids.length,
673 // Only looked for when this project has no runs: it explains an empty pane.
674 ...(ids.length === 0 && { elsewhere: await runsElsewhere(io, loc.projectDir) }),
675 checkedAt: await $.clock.now(),
676 readiness,
677 unsetVariables,
678 }
679
680 if (JSON.stringify({ ...before, checkedAt: 0 }) !== JSON.stringify({ ...snapshot, checkedAt: 0 })) {
681 await update($, snapshotAtom, () => snapshot)
682 }
683
684 // Elapsed times in the lanes and the spinner move while anything is in flight.
685 if (latest !== undefined && !latest.finished) {
686 await update($, tickAtom, () => snapshot.checkedAt)
687 }
688
689 if (latest?.finished && latest.stopped !== true) {
690 // An agent seen for the first time (a workspace that appeared, `rook agent
691 // use`) brings its history with it: its latest run is not news.
692 await finished($, ctx, loc, latest, before?.agentId !== loc.agentId)
693 }
694 } finally {
695 await refreshHistory($, ctx)
696 await refreshVerdicts($, ctx)
697 await redrawEachMinute($, ctx)
698 ctx.isPolling = false
699 await showStatus($, ctx)
700 }
701}
702
703// 4 · failures of a run started elsewhere
704/** A run reached report.yaml. Report it once, and never one from before this session. */
705async function finished($: EngineInterface, ctx: Ctx, loc: Located, run: RookRunView, isNewAgent: boolean): Promise<void> {
706 if ((await read($, seenAtom)) === run.runId) {
707 return
708 }
709
710 await update($, seenAtom, () => run.runId)
711
712 if (!ctx.isPrimed || isNewAgent) {
713 return
714 }
715
716 if ((await read($, staleAtom)) !== null) {
717 await update($, staleAtom, () => null)
718 }
719
720 if ((await read($, runningAtom))?.source === 'tool') {
721 return // the tool's own result carries the verdicts
722 }
723
724 const { pass, fail, unverifiable } = run.counts
725 $.ui.toast(toastText(`rook: run ${run.runId} finished — ${pass} Pass · ${fail} Fail · ${unverifiable} Unable to Verify`))
726
727 if (ctx.isFailureContext && fail > 0) {
728 const text = `<rook-run-result>\n${failureContext(run, loc.agentDir, loc.agentId)}\n</rook-run-result>`
729
730 await $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } }).catch(() => undefined)
731 }
732}
733
734// ── 7 · production guard ─────────────────────────────────────────────────────
735
736/** The active profile's declared variables that rook's env store has no value for here: names only. */
737/** The header's "updated Nm ago" moves once a minute even when nothing else changes. */
738async function redrawEachMinute($: EngineInterface, ctx: Ctx): Promise<void> {
739 const checkedAt = (await read($, snapshotAtom))?.checkedAt
740
741 if (checkedAt === undefined) {
742 return
743 }
744
745 const minute = Math.floor(Math.max(0, (await $.clock.now()) - checkedAt) / 60_000)
746
747 if (minute !== ctx.freshMinute) {
748 ctx.freshMinute = minute
749 await update($, tickAtom, () => checkedAt + minute)
750 }
751}
752
753async function unsetOfActive($: EngineInterface, ctx: Ctx, loc: Located): Promise<string[]> {
754 const io = ioOf($, ctx)
755 const active = (await io.read(`${loc.agentDir}/profiles/active`))?.trim()
756
757 if (active === undefined || active === '' || !/^[\w.][\w.-]{0,63}$/.test(active)) {
758 return []
759 }
760
761 const declared = declaredVariables((await io.read(`${loc.agentDir}/profiles/${active}.yaml`)) ?? '')
762 const set = await variableValues($, ctx, declared)
763
764 return declared.filter(name => set[name] === undefined)
765}
766
767async function variableValues($: EngineInterface, ctx: Ctx, names: readonly string[]): Promise<Record<string, string>> {
768 const values: Record<string, string> = {}
769
770 if (names.length === 0) {
771 return values
772 }
773
774 const home = (await $.env.get('ROOK_HOME')) ?? `${(await $.env.get('HOME')) ?? ''}/.testmuai/rook`
775 let stored: Record<string, unknown> = {}
776
777 try {
778 const envFile = JSON.parse((await ioOf($, ctx).read(`${home}/env.json`)) ?? '{}') as { projects?: Record<string, Record<string, unknown>> }
779 stored = envFile.projects?.[ctx.cwd] ?? {}
780 } catch {
781 stored = {}
782 }
783
784 // `rook env set` keeps the values here, keyed by the directory rook ran in.
785 // rook takes its workspace from the working directory with no upward
786 // search, so that is this session's directory too. The shell's own environment is not
787 // read, since a module may only read variables it names literally.
788 for (const name of names) {
789 const value = stored[name]
790
791 if (typeof value === 'string') {
792 values[name] = value
793 }
794 }
795
796 return values
797}
798
799/** Why a run must not start now, or undefined when it may. */
800/** The profile a run would use: the one named, or the agent's active one. */
801async function profileOf($: EngineInterface, ctx: Ctx, profileRef?: string): Promise<{ loc: Located; profileId: string } | undefined> {
802 const loc = ctx.located ?? (await where($, ctx))
803
804 if (loc === undefined) {
805 return undefined
806 }
807
808 const profileId = profileRef ?? (await ioOf($, ctx).read(`${loc.agentDir}/profiles/active`))?.trim()
809
810 return profileId === undefined || !/^[\w.][\w.-]{0,63}$/.test(profileId) ? undefined : { loc, profileId }
811}
812
813async function prodBlock($: EngineInterface, ctx: Ctx, profileRef?: string): Promise<string | undefined> {
814 if (!ctx.isProdGuard) {
815 return undefined
816 }
817
818 const target = await profileOf($, ctx, profileRef)
819
820 if (target === undefined) {
821 return undefined
822 }
823
824 const { loc, profileId } = target
825 const confirmed = await read($, confirmedAtom)
826
827 // A confirmation covers the directory and profile it was given for, nothing else.
828 if (confirmed !== null && confirmed.cwd === ctx.cwd && confirmed.profile === profileId && confirmed.until > (await $.clock.now())) {
829 return undefined
830 }
831
832 const io = ioOf($, ctx)
833
834 const profileText = await io.read(`${loc.agentDir}/profiles/${profileId}.yaml`)
835
836 if (profileText === undefined) {
837 return undefined
838 }
839
840 const verdict = assess(profileText, await variableValues($, ctx, declaredVariables(profileText)), ctx.prodPatterns)
841
842 return verdict.isRisky
843 ? `rook: profile "${profileId}" looks like it targets production (${verdict.reasons.join('; ')}). ` +
844 "The agent's writes are real and rook cannot roll them back. Point the profile at staging, " +
845 `or ask the person to type /rook confirm-prod ${profileId} to allow runs with it for 15 minutes.`
846 : undefined
847}
848
849// ── running ──────────────────────────────────────────────────────────────────
850
851async function backgroundRun($: EngineInterface, ctx: Ctx, argv: string[], signal?: AbortSignal): Promise<void> {
852 try {
853 const result = await rookRun($, ctx, argv, signal)
854 const problem = failureOf(result)
855
856 if (signal?.aborted) {
857 $.ui.toast(toastText('rook: run cancelled'))
858 } else if (problem !== undefined) {
859 // A toast vanishes; the pane keeps the failure until the next run starts.
860 const text = await explained($, ctx, result, `run failed: ${problem}`, NEEDS.run, 'run')
861
862 const at = await $.clock.now()
863
864 await update($, lastErrorAtom, () => ({ source: 'run', text, at }))
865 $.ui.toast(toastText(`rook: ${clip(text, 200)}`))
866 }
867 } catch (error) {
868 const text = `could not start ${ctx.bin} — ${clip(String(error), 120)}`
869
870 const at = await $.clock.now()
871
872 await update($, lastErrorAtom, () => ({ source: 'run', text, at }))
873 $.ui.toast(toastText(`rook: ${text}`))
874 } finally {
875 if (signal !== undefined && ctx.runAbort?.signal === signal) {
876 ctx.runAbort = undefined
877 }
878
879 await update($, runningAtom, () => null)
880 await poll($, ctx)
881 await refreshBalance($, ctx)
882 }
883}
884
885/** A run the person started (pane button, /rook run): in the background, reported when it lands. */
886async function startRun($: EngineInterface, ctx: Ctx, request: RunRequest, source: 'command' | 'pane'): Promise<string> {
887 if ((await read($, runningAtom)) !== null) {
888 return 'rook: a run is already in progress.'
889 }
890
891 const args = runArgs(request, ctx.approval)
892
893 if ('error' in args) {
894 return `rook: ${args.error}`
895 }
896
897 const unready = await setupBlock($, ctx, NEEDS.run, 'run')
898
899 if (unready !== undefined) {
900 return `rook: ${unready}`
901 }
902
903 const blocked = (await prodBlock($, ctx, request.profile)) ?? (await budgetBlock($, ctx, 'run', request.only?.length))
904
905 if (blocked !== undefined) {
906 return blocked
907 }
908
909 const label = request.only ? request.only.join(', ') : 'all runnable scenarios'
910
911 const startedAt = await $.clock.now()
912
913 await update($, runningAtom, () => ({ startedAt, label, source }))
914 await update($, lastErrorAtom, () => null)
915 await showStatus($, ctx)
916 const abort = new AbortController()
917
918 ctx.runAbort = abort
919 $.clock.after(0, () => backgroundRun($, ctx, args.argv, abort.signal))
920
921 return `rook: running ${label} in the background. Progress shows in the pane and the status line.`
922}
923
924// 1 · the model's run: inline, so the verdicts come back as the tool's result
925async function toolRun($: EngineInterface, ctx: Ctx, request: RunRequest, signal: AbortSignal): Promise<{ result: string } | { deny: string }> {
926 if ((await read($, runningAtom)) !== null) {
927 return { deny: 'rook: a run is already in progress; wait for it, then read it with the rook report tool.' }
928 }
929
930 const args = runArgs(request, ctx.approval)
931
932 if ('error' in args) {
933 return { deny: `rook: ${args.error}` }
934 }
935
936 const unready = await setupBlock($, ctx, NEEDS.run, 'run')
937
938 if (unready !== undefined) {
939 return { deny: `rook ${unready}` }
940 }
941
942 const blocked = (await prodBlock($, ctx, request.profile)) ?? (await budgetBlock($, ctx, 'run', request.only?.length))
943
944 if (blocked !== undefined) {
945 return { deny: blocked }
946 }
947
948 const startedAt = await $.clock.now()
949
950 await update($, runningAtom, () => ({ startedAt, label: request.only?.join(', ') ?? 'all', source: 'tool' }))
951 await update($, lastErrorAtom, () => null)
952 await showStatus($, ctx)
953
954 // Claude asked, not the person: the pane seats only where there is room for
955 // an unasked one. The spinner carries the progress everywhere else.
956 if (ctx.paneMode !== 'off') {
957 void openPane($).catch(() => undefined)
958 }
959
960 try {
961 const result = await rookRun($, ctx, args.argv, signal)
962 const problem = failureOf(result)
963 const doc = result.doc as { run_id?: string; halted?: boolean; credits?: number } | undefined
964 const runId = doc?.run_id
965
966 if (problem !== undefined && runId === undefined) {
967 return { result: await explained($, ctx, result, `rook run did not complete: ${problem}`, NEEDS.run, 'run') }
968 }
969
970 const loc = ctx.located ?? (await where($, ctx))
971
972 if (runId === undefined || loc === undefined || !RUN_ID.test(runId)) {
973 return { result: `rook run finished, but returned no run id to read.${problem ? ` ${problem}` : ''}` }
974 }
975
976 await update($, seenAtom, () => runId)
977 const run = await readRun(ioOf($, ctx), loc.agentDir, runId, ctx.rows)
978
979 if (run === undefined) {
980 return { result: `rook run ${runId} finished, but ${loc.agentDir}/runs/${runId}/ could not be read.` }
981 }
982
983 if ((await read($, staleAtom)) !== null) {
984 await update($, staleAtom, () => null)
985 }
986
987 const halted = doc?.halted ? '\nThe run HALTED before finishing: treat it as incomplete, not as a full suite.' : ''
988 const short = balanceWarning(await refreshBalance($, ctx), creditsPerScenario(run), run.counts.fail > 0 ? run.counts.fail : run.done)
989
990 return { result: runSummary(run, loc.agentDir, loc.agentId, doc?.credits) + halted + (short === undefined ? '' : `\n${short}`) }
991 } finally {
992 await update($, runningAtom, () => null)
993 await poll($, ctx)
994 }
995}
996
997async function reportText($: EngineInterface, ctx: Ctx, runRef: string | undefined): Promise<{ text: string; context?: string }> {
998 const loc = await where($, ctx)
999
1000 if (loc === undefined) {
1001 const readiness = await readinessNow($, ctx)
1002
1003 return { text: readiness === undefined ? 'no rook agent in this directory.' : checklistText(readiness) }
1004 }
1005
1006 if (runRef !== undefined && !RUN_ID.test(runRef)) {
1007 return { text: `rook: "${clip(runRef, 40)}" is not a run id (2026-09-28T15-54-56Z form).` }
1008 }
1009
1010 const io = ioOf($, ctx)
1011 const ids = await runIds(io, loc.agentDir)
1012 const runId = runRef ?? ids[0]
1013
1014 if (runId === undefined || !ids.includes(runId)) {
1015 return { text: runRef ? `rook: no run ${runRef} for agent ${loc.agentId}.` : `rook: agent ${loc.agentId} has no runs yet.` }
1016 }
1017
1018 const run = await readRun(io, loc.agentDir, runId, ctx.rows)
1019
1020 if (run === undefined) {
1021 return { text: `rook: run ${runId} has no run.yaml.` }
1022 }
1023
1024 if (!run.finished) {
1025 return { text: `rook: run ${runId} is still running — ${run.done}/${run.planned} judged, ${run.counts.fail} failing so far.` }
1026 }
1027
1028 const summary = runSummary(run, loc.agentDir, loc.agentId, run.credits)
1029
1030 return { text: summary, ...(run.counts.fail > 0 && { context: failureContext(run, loc.agentDir, loc.agentId) }) }
1031}
1032
1033async function statusReply($: EngineInterface, ctx: Ctx): Promise<string> {
1034 // `rook status` refuses without a project; the checklist is the better answer then.
1035 if ((await setupBlock($, ctx, NEEDS.status, 'read status')) !== undefined) {
1036 const readiness = await read($, snapshotAtom)
1037
1038 return readiness?.readiness === undefined ? 'status is not available yet.' : checklistText(readiness.readiness)
1039 }
1040
1041 const status = await rookJson($, ctx, ['status', '--json'])
1042 const problem = failureOf(status)
1043
1044 if (status.doc === undefined || problem !== undefined) {
1045 const learnt = await learn($, ctx, status)
1046
1047 return learnt === undefined ? `status failed: ${problem ?? 'no output'}` : checklistText(learnt)
1048 }
1049
1050 return statusText(status.doc)
1051}
1052
1053/** Every scenario with its latest verdict: rook's list, or the files on disk when rook cannot answer. */
1054/** The agent's runs, newest first, from run.yaml and report.yaml: no CLI call, no credits. */
1055async function runsReply($: EngineInterface, ctx: Ctx, limit: number): Promise<string> {
1056 const blocked = await setupBlock($, ctx, ['agent'], 'list runs')
1057 const loc = await where($, ctx)
1058
1059 if (blocked !== undefined || loc === undefined) {
1060 return `rook: ${blocked ?? "can't list runs yet: no agent here."}`
1061 }
1062
1063 const io = ioOf($, ctx)
1064 const ids = await runIds(io, loc.agentDir)
1065
1066 return runsText(loc.agentId, await runSummaries(io, loc.agentDir, ids, limit), ids.length)
1067}
1068
1069async function scenariosReply($: EngineInterface, ctx: Ctx): Promise<string> {
1070 await poll($, ctx)
1071 const snapshot = await read($, snapshotAtom)
1072 const current = snapshot?.current ?? []
1073 // Without a project rook only refuses: read the files on disk straight away.
1074 const listed = blockerOf(snapshot?.readiness, NEEDS.status) === undefined ? await rookJson($, ctx, ['scenarios', 'list', '--json']) : undefined
1075
1076 if (listed !== undefined && listed.doc !== undefined && failureOf(listed) === undefined) {
1077 return scenariosText(listed.doc, current)
1078 }
1079
1080 const loc = ctx.located ?? (await where($, ctx))
1081
1082 if (loc === undefined) {
1083 const readiness = await readinessNow($, ctx)
1084
1085 return readiness === undefined ? 'no rook agent in this directory.' : checklistText(readiness)
1086 }
1087
1088 const index = ctx.agentIndex ?? (await indexAgent(ioOf($, ctx), loc.agentDir))
1089 ctx.agentIndex = index
1090 if (listed !== undefined) {
1091 await learn($, ctx, listed)
1092 }
1093
1094 const unready = await setupBlock($, ctx, NEEDS.run, 'run them')
1095
1096 return (
1097 scenariosText({ agent_id: loc.agentId, total: index.scenarios.length, scenarios: index.scenarios.map(s => ({ scenario_id: s.id, title: s.title, feature_id: s.featureId })) }, current) +
1098 (listed === undefined ? '\n(from the files on disk)' : `\n(from the files on disk: rook scenarios list failed — ${clip(failureOf(listed) ?? 'no output', 160)})`) +
1099 (unready === undefined ? '' : `\n${unready}`)
1100 )
1101}
1102
1103/** `rook generate`, streamed like a run: it reads the code and writes scenarios, which takes minutes. */
1104async function generateRun($: EngineInterface, ctx: Ctx, request: GenerateRequest, signal?: AbortSignal, source: NonNullable<RookJob['source']> = 'tool'): Promise<string> {
1105 const args = generateArgs(request, ctx.approval)
1106
1107 if ('error' in args) {
1108 return `rook: ${args.error}`
1109 }
1110
1111 const unready = (await setupBlock($, ctx, NEEDS.generate, 'generate scenarios')) ?? (await budgetBlock($, ctx, 'generate'))
1112
1113 if (unready !== undefined) {
1114 return `rook: ${unready.replace(/^rook:\s*/, '')}`
1115 }
1116
1117 const job = await jobBegin($, ctx, 'generate', jobLabel(ctx, 'generate', request.instruction), source)
1118
1119 try {
1120 const result = await rookRun($, ctx, args.argv, signal, {}, line => jobLine($, job, line))
1121 const problem = failureOf(result)
1122
1123 return problem !== undefined && (result.doc === undefined || (result.doc as { ok?: unknown }).ok === false)
1124 ? await explained($, ctx, result, `rook generate did not complete: ${problem}`, NEEDS.generate, 'generate scenarios')
1125 : generateText(result.doc, result.stdout ?? '') + (problem ? `\n${problem}` : '')
1126 } finally {
1127 await jobEnd($, job)
1128 ctx.agentIndex = undefined // the scenario set changed
1129 ctx.isDirty = true
1130 await poll($, ctx)
1131 await refreshBalance($, ctx)
1132 }
1133}
1134
1135/**
1136 * `rook ui --local`: rook's read-only viewer over what is on disk, with each
1137 * scenario's request, response and evidence files. It serves until the child
1138 * ends, so the child lives as long as the loop reading it.
1139 */
1140async function startViewer($: EngineInterface, ctx: Ctx): Promise<string> {
1141 const known = await read($, viewerAtom)
1142
1143 if (known !== null && ctx.viewer !== undefined) {
1144 return known
1145 }
1146
1147 const stop = new AbortController()
1148 ctx.viewer = stop
1149 const stream = $.process.spawn({ argv: [ctx.bin, 'ui', '--local', '--no-open'], env: CLI_ENV, input: '', ...(ctx.cwd !== '' && { cwd: ctx.cwd }) })
1150 const iterator = stream[Symbol.asyncIterator]()
1151 let said = ''
1152
1153 // A rook that prints no address (a prompt, a lock) must not hold the command forever.
1154 let isLate = false
1155 const late = new Promise<{ done: true; value: undefined }>(resolve =>
1156 $.clock.after(VIEWER_START_MS, () => {
1157 isLate = true
1158 resolve({ done: true, value: undefined })
1159 }),
1160 )
1161 const url = await (async () => {
1162 for (;;) {
1163 const step = await Promise.race([iterator.next(), late])
1164
1165 if (step.done) {
1166 return undefined
1167 }
1168
1169 said = (said + step.value.text).slice(-4000)
1170 const found = /http:\/\/(127\.0\.0\.1|localhost):\d+\/?/.exec(said)
1171
1172 if (found) {
1173 return found[0]
1174 }
1175 }
1176 })()
1177
1178 if (url === undefined) {
1179 ctx.viewer = undefined
1180 // Ended, not awaited: a child stuck where it cannot be interrupted must not hold the command either.
1181 void Promise.resolve(iterator.return?.(undefined as never)).catch(() => undefined)
1182
1183 const last = said.trim() ? ` — ${clip(said.trim().split('\n').pop() ?? '', 160)}` : ''
1184
1185 return isLate ? `rook: the viewer printed no address within ${VIEWER_START_MS / 1000}s${last}` : `rook: the viewer did not start${last}`
1186 }
1187
1188 await update($, viewerAtom, () => url)
1189
1190 // Keep reading so the child keeps serving; forget the address when it ends.
1191 void (async () => {
1192 try {
1193 while (!stop.signal.aborted && !(await iterator.next()).done) {
1194 // drained
1195 }
1196 } finally {
1197 await iterator.return?.(undefined as never)
1198 if (ctx.viewer === stop) {
1199 ctx.viewer = undefined
1200 await update($, viewerAtom, () => null)hooks/views/kit.ts 11 lines1import type { Elements } from 'claude-code'
2
3/**
4 * The elements a pane view draws with, handed down from the render hook's
5 * `$.ui.resolve(e)`. Views never take `$`: the engine follows `$` only into
6 * functions declared in register.tsx, so every action a view offers arrives
7 * as a callback built there.
8 */
9export type El = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button' | 'Link' | 'Code' | 'Markdown'> &
10 Partial<Pick<Elements['terminal'], 'Input' | 'Select'>>
11hooks/views/tabs.tsx 46 lines1import type { RookLens, RookTab } from '../../types'
2import type { El } from './kit'
3
4export const TABS: { id: RookTab; label: string; hotkey: string }[] = [
5 { id: 'health', label: 'Health', hotkey: '1' },
6 { id: 'runs', label: 'Runs', hotkey: '2' },
7 { id: 'scenarios', label: 'Scenarios', hotkey: '3' },
8 { id: 'setup', label: 'Setup', hotkey: '4' },
9 { id: 'trends', label: 'Trends', hotkey: '5' },
10]
11
12/** The first tab leads with release readiness for a QE and with the effect of their change for a developer. */
13export const tabLabel = (id: RookTab, label: string, lens: RookLens | undefined): string =>
14 id === 'health' && lens !== undefined ? (lens === 'qe' ? 'Release' : 'My change') : label
15
16export const LENS_LABEL: Record<RookLens, string> = { qe: 'QE', dev: 'Developer' }
17
18/** The pane's tab row: each tab a [ button ], the open one marked ▸ in the accent colour, then the view switch on `l`, and how to drive it. */
19export function TabBar(props: { el: El; tab: RookTab; lens?: RookLens; onTab: (tab: RookTab) => void; onLens?: () => void }) {
20 const { Box, Text, Button } = props.el
21
22 return (
23 <Box flexDirection="column">
24 <Box flexDirection="row" gap={1} flexWrap="wrap">
25 {TABS.map(t =>
26 t.id === props.tab ? (
27 <Text key={`tab-${t.id}`} bold color="cyan">
28 [ ▸ {tabLabel(t.id, t.label, props.lens)} ]
29 </Text>
30 ) : (
31 <Button key={`tab-${t.id}`} label={tabLabel(t.id, t.label, props.lens)} hotkey={t.hotkey} onPress={() => props.onTab(t.id)} />
32 ),
33 )}
34 </Box>
35 <Box key="tabs-hint-row" flexDirection="row" gap={1} flexWrap="wrap">
36 <Text key="tabs-hint" dimColor>
37 Tab move · Enter press · 1-5 tabs · Esc prompt ·
38 </Text>
39 {props.lens !== undefined && props.onLens !== undefined && (
40 <Button key="lens" plain dimColor label={`view: ${LENS_LABEL[props.lens]}`} hotkey="l" onPress={props.onLens} />
41 )}
42 </Box>
43 </Box>
44 )
45}
46hooks/views/confirm.tsx 23 lines1import type { RookConfirm } from '../../types'
2import type { El } from './kit'
3
4/** A credit-spending action waiting for the person's yes. */
5export function ConfirmBar(props: { el: El; confirm: RookConfirm; onConfirm: () => void; onCancel: () => void }) {
6 const { Box, Text, Button } = props.el
7 const { confirm } = props
8 const cost = confirm.credits === undefined ? 'spends credits' : `~${Math.round(confirm.credits * 100) / 100} credits`
9
10 return (
11 <Box flexDirection="column">
12 <Text color="yellow" wrap="wrap">
13 ◆ {confirm.label} · {cost}
14 {confirm.action === 'generate' || confirm.action === 'explore' ? '' : ' · calls your agent for real'}
15 </Text>
16 <Box flexDirection="row" gap={1}>
17 <Button key="confirm-yes" label="Confirm" variant="primary" hotkey="y" onPress={props.onConfirm} />
18 <Button key="confirm-no" label="Cancel" hotkey="n" role="dismiss" onPress={props.onCancel} />
19 </Box>
20 </Box>
21 )
22}
23hooks/views/setup.tsx 307 lines1import type { RookReadiness } from '../../types'
2import { needsSync, PROFILE_STEPS, SYNC_NOTE, treeWords } from '../setup'
3import type { ProfileView, SyncState, Wizard } from '../setup'
4import type { El } from './kit'
5
6/** One plugin option as the Setup tab shows it, with where a change goes. */
7export type SettingRow = {
8 id: 'pane' | 'retestBand' | 'failureContext' | 'prodGuard' | 'lens'
9 label: string
10 value: string
11 /** `kept`: written to /config (or $.store, for the lens); `this session`: /config refused, held until the session ends. */
12 scope: 'kept' | 'this session'
13 hotkey?: string
14 /** Why it has no key, or what the key is elsewhere. */
15 note?: string
16}
17
18/** Everything the Setup tab shows past the checklist, once there is an agent. Built in the render hook. */
19export type SetupPanel = {
20 profiles: ProfileView[]
21 wizard: Wizard | undefined
22 budgetLine: string
23 sync: SyncState | null
24 /** Nothing in flight that a profile switch or a sync would disturb. */
25 canAct: boolean
26 /** The plugin options that change what the pane and the session do (feature: keys). */
27 settings?: SettingRow[]
28 /** A session budget is set: offer to lift it. */
29 hasBudget?: boolean
30}
31
32/** The Setup tab's keys: one per action, from the letters and digits no other feature takes. */
33export const SETUP_KEYS = { use: 'u', test: 'i', refresh: 'h', sync: 'q', checkSync: '0', budgetOff: 'z' } as const
34
35/** The Setup tab's buttons, as closures built in the render hook. */
36export type SetupActions = {
37 onUse: (id: string) => void
38 onTest: (id: string) => void
39 onSync: () => void
40 onCheckSync: () => void
41 onRefresh: () => void
42 onSetting?: (id: SettingRow['id']) => void
43 onBudget?: (credits: string) => void
44 onBudgetOff?: () => void
45}
46
47/** The setup checklist, the next step highlighted. Shown alone with no agent, and as the Setup tab. Owned by the setup-tab feature. */
48export function SetupTab(props: { el: El; readiness: RookReadiness | undefined; onRecheck: () => void; panel?: SetupPanel; actions?: SetupActions; title?: string }) {
49 const { Box, Text, Button } = props.el
50 const { readiness } = props
51 const panel = props.panel !== undefined && props.actions !== undefined ? { ...props.panel, ...props.actions } : undefined
52 const nextId = readiness?.steps.find(step => !step.ok)?.id
53 const isCliStep = nextId === 'installed' || nextId === 'signed_in'
54
55 return (
56 <Box flexDirection="column">
57 <Text bold>{props.title ?? 'rook · setup'}</Text>
58 {readiness === undefined && <Text dimColor>Checking this directory…</Text>}
59 {readiness?.steps.map(step => (
60 <Box key={`s-${step.id}`}>
61 <Text color={step.ok ? 'green' : step.id === nextId ? 'yellow' : undefined} dimColor={!step.ok && step.id !== nextId} bold={step.id === nextId}>
62 {step.ok ? '✓' : '✗'} {step.label}
63 </Text>
64 </Box>
65 ))}
66 {readiness?.next !== undefined && (
67 <Box key="next">
68 <Text wrap="wrap">
69 <Text bold>Next: </Text>
70 {readiness.next}
71 </Text>
72 </Box>
73 )}
74 {isCliStep && <Button key="recheck" label="Check again" onPress={props.onRecheck} />}
75 {panel !== undefined && <Profiles el={props.el} panel={panel} />}
76 {panel?.wizard !== undefined && <ProfileWizard el={props.el} wizard={panel.wizard} />}
77 {panel !== undefined && <Sync el={props.el} panel={panel} />}
78 {panel !== undefined && <Budget el={props.el} panel={panel} />}
79 {panel?.settings !== undefined && <Settings el={props.el} panel={panel} settings={panel.settings} />}
80 </Box>
81 )
82}
83
84/** The session's credit cap: what is left, a box to set one, and Budget off. */
85function Budget(props: { el: El; panel: SetupPanel & SetupActions }) {
86 const { Box, Text, Button } = props.el
87 const Input = props.el.Input
88 const { panel } = props
89
90 return (
91 <Box key="budget" flexDirection="column">
92 <Text bold>Budget</Text>
93 <Box key="budget-line">
94 <Text wrap="wrap">{panel.budgetLine}</Text>
95 </Box>
96 <Box flexDirection="row" gap={1}>
97 {Input !== undefined && panel.onBudget !== undefined && (
98 <Input key="budget-input" placeholder="credits this session may spend" submitLabel="set budget" onSubmit={text => panel.onBudget!(text)} />
99 )}
100 {panel.hasBudget === true && panel.onBudgetOff !== undefined && (
101 <Button key="budget-off" label="Budget off" hotkey={SETUP_KEYS.budgetOff} onPress={panel.onBudgetOff} />
102 )}
103 </Box>
104 </Box>
105 )
106}
107
108/** The plugin options that matter here, their values, and a key to change each; where the change is kept. */
109function Settings(props: { el: El; panel: SetupPanel & SetupActions; settings: SettingRow[] }) {
110 const { Box, Text, Button } = props.el
111 const { panel } = props
112
113 return (
114 <Box key="settings" flexDirection="column">
115 <Text bold>Settings</Text>
116 {props.settings.map(row => (
117 <Box key={`set-${row.id}`} flexDirection="row" gap={1}>
118 {panel.onSetting !== undefined ? (
119 <Button key={`setting-${row.id}`} plain label={`${row.label}: ${row.value}`} {...(row.hotkey !== undefined && { hotkey: row.hotkey })} onPress={() => panel.onSetting!(row.id)} />
120 ) : (
121 <Text>
122 {row.label}: {row.value}
123 </Text>
124 )}
125 <Text dimColor wrap="truncate-end">
126 {row.scope}
127 {row.note === undefined ? '' : ` · ${row.note}`}
128 </Text>
129 </Box>
130 ))}
131 </Box>
132 )
133}
134
135/** The active agent's profiles: target, verified, variables set or unset. Never a value. */
136function Profiles(props: { el: El; panel: SetupPanel & SetupActions }) {
137 const { Box, Text, Button } = props.el
138 const { panel } = props
139 // One key each: Test for the active profile (else the first), Use for the first other one.
140 const tested = (panel.profiles.find(profile => profile.isActive) ?? panel.profiles[0])?.id
141 const firstOther = panel.profiles.find(profile => !profile.isActive)?.id
142
143 return (
144 <Box key="profiles" flexDirection="column">
145 <Box flexDirection="row" gap={1}>
146 <Text bold>Profiles</Text>
147 <Button key="refresh-setup" plain label="refresh" hotkey={SETUP_KEYS.refresh} onPress={panel.onRefresh} />
148 </Box>
149 {panel.profiles.length === 0 && <Text dimColor>none on disk for this agent.</Text>}
150 {panel.profiles.map(profile => (
151 <Box key={`p-${profile.id}`} flexDirection="column">
152 <Box key={`pl-${profile.id}`}>
153 <Text wrap="truncate-end">
154 <Text color={profile.isActive ? 'green' : undefined} bold={profile.isActive}>
155 {profile.isActive ? '● ' : '○ '}
156 {profile.id}
157 </Text>
158 {profile.isActive ? <Text dimColor> active</Text> : ''}
159 <Text color={profile.isVerified ? 'green' : 'yellow'}> · {profile.isVerified ? 'verified' : 'not verified'}</Text>
160 <Text dimColor> · {profile.target}</Text>
161 </Text>
162 </Box>
163 {profile.steps.length > 0 && (
164 <Box key={`ps-${profile.id}`} paddingLeft={2}>
165 <Text dimColor wrap="truncate-end">
166 steps: {profile.steps.join(', ')}
167 </Text>
168 </Box>
169 )}
170 {profile.variables.length > 0 && (
171 <Box key={`pv-${profile.id}`} flexDirection="row" flexWrap="wrap" columnGap={2} paddingLeft={2}>
172 {profile.variables.map(variable => (
173 <Box key={`v-${profile.id}-${variable.name}`}>
174 <Text color={variable.isSet ? 'green' : 'yellow'}>
175 {variable.isSet ? '✓' : '✗'} {variable.name} {variable.isSet ? 'set' : 'unset'}
176 </Text>
177 </Box>
178 ))}
179 </Box>
180 )}
181 {profile.variables.some(variable => !variable.isSet) && (
182 <Box key={`pe-${profile.id}`} paddingLeft={2}>
183 <Text wrap="wrap">
184 type: {`! rook env set '{${profile.variables.filter(variable => !variable.isSet).map(variable => `"${variable.name}":"…"`).join(',')}}'`}
185 </Text>
186 </Box>
187 )}
188 <Box flexDirection="row" gap={1}>
189 {!profile.isActive && panel.canAct && (
190 <Button key={`use-${profile.id}`} label="Use" {...(profile.id === firstOther && { hotkey: SETUP_KEYS.use })} onPress={() => panel.onUse(profile.id)} />
191 )}
192 {panel.canAct && (
193 <Button
194 key={`test-${profile.id}`}
195 label="Test"
196 {...(profile.id === tested && { hotkey: SETUP_KEYS.test })}
197 {...(profile.id === tested && !profile.isVerified && { autoFocus: true as const })}
198 onPress={() => panel.onTest(profile.id)}
199 />
200 )}
201 </Box>
202 </Box>
203 ))}
204 </Box>
205 )
206}
207
208/** What rook needs to reach the agent, and the exact lines to type. */
209function ProfileWizard(props: { el: El; wizard: Wizard }) {
210 const { Box, Text } = props.el
211 const { wizard } = props
212 const defined = new Set(wizard.defined ?? [])
213
214 return (
215 <Box key="wizard" flexDirection="column">
216 <Text bold color="yellow">
217 Reaching the agent: {wizard.reason}
218 </Text>
219 <Text dimColor wrap="wrap">
220 A profile is scripts rook runs at five steps of every scenario:
221 </Text>
222 {PROFILE_STEPS.map(step => (
223 <Box key={`ws-${step.id}`} paddingLeft={2}>
224 <Text wrap="truncate-end" dimColor={wizard.defined !== undefined && !defined.has(step.id)}>
225 {wizard.defined === undefined ? '·' : defined.has(step.id) ? '✓' : '–'} {step.id}: {step.what}
226 </Text>
227 </Box>
228 ))}
229 {wizard.connectionFile !== undefined && (
230 <Box key="w-file">
231 <Text>found {wizard.connectionFile} in the repository root</Text>
232 </Box>
233 )}
234 <Text bold>Type at the prompt:</Text>
235 {wizard.commands.map((command, at) => (
236 <Box key={`w-cmd-${at}`} flexDirection="column" paddingLeft={2}>
237 <Box key={`w-text-${at}`}>
238 <Text color="cyan" wrap="wrap">
239 {command.text}
240 </Text>
241 </Box>
242 <Box key={`w-why-${at}`} paddingLeft={2}>
243 <Text dimColor wrap="wrap">
244 {command.why}
245 </Text>
246 </Box>
247 </Box>
248 ))}
249 </Box>
250 )
251}
252
253/** Where this machine stands against upstream, and the button that records it. */
254function Sync(props: { el: El; panel: SetupPanel & SetupActions }) {
255 const { Box, Text, Button } = props.el
256 const { sync, canAct } = props.panel
257 const isBehind = sync?.agents.some(agent => needsSync(agent.tree)) ?? false
258
259 return (
260 <Box key="sync" flexDirection="column">
261 <Text bold>Sync</Text>
262 <Text dimColor wrap="wrap">
263 {SYNC_NOTE}
264 </Text>
265 {sync === null && <Text dimColor>sync state not checked yet.</Text>}
266 {sync?.offline === true && <Text color="yellow">could not reach upstream: whether it moved is unknown.</Text>}
267 {sync?.agents.map(agent => (
268 <Box key={`sy-${agent.id}`} flexDirection="column">
269 <Text wrap="truncate-end" color={needsSync(agent.tree) ? 'yellow' : agent.tree === 'clean' ? 'green' : undefined}>
270 {agent.id}
271 {agent.version === undefined ? ' never synced' : ` v${agent.version}`} · {treeWords(agent.tree)}
272 </Text>
273 {agent.changes.slice(0, 4).map((line, at) => (
274 <Box key={`sy-${agent.id}-${at}`} paddingLeft={2}>
275 <Text dimColor wrap="truncate-end">
276 {line}
277 </Text>
278 </Box>
279 ))}
280 </Box>
281 ))}
282 {sync?.said !== undefined && (
283 <Box key="sync-said">
284 <Text dimColor wrap="wrap">
285 {sync.said}
286 </Text>
287 </Box>
288 )}
289 {sync?.error !== undefined && (
290 <Box key="sync-error">
291 <Text color="red" wrap="wrap">
292 {sync.error}
293 </Text>
294 </Box>
295 )}
296 {sync?.isSyncing === true ? (
297 <Text color="cyan">▸ syncing upstream…</Text>
298 ) : (
299 <Box flexDirection="row" gap={1}>
300 {canAct && <Button key="sync-upstream" label="Sync upstream" variant={isBehind ? 'primary' : undefined} hotkey={SETUP_KEYS.sync} onPress={props.panel.onSync} />}
301 <Button key="check-sync" label="Check sync state" hotkey={SETUP_KEYS.checkSync} onPress={props.panel.onCheckSync} />
302 </Box>
303 )}
304 </Box>
305 )
306}
307hooks/views/job.tsx 49 lines1import type { RookJob } from '../../types'
2import { progressBar } from '../format'
3import { countText, elapsed, isLaneDone, shownLanes } from '../progress'
4import type { El } from './kit'
5
6/** A generate or explore in flight: one line, then a lane per step. Owned by the progress feature. */
7export function JobLanes(props: { el: El; job: RookJob; now: number; width?: number }) {
8 const { Box, Text } = props.el
9 const { job, now } = props
10 const width = props.width ?? 64
11 const { lanes, hidden } = shownLanes(job)
12 const count = countText(job)
13 const hasBar = job.planned !== undefined && job.planned > 0
14
15 return (
16 <Box flexDirection="column">
17 <Text color="cyan" wrap="truncate-end">
18 ▸ rook {job.kind} · {job.label} · {elapsed(now - job.startedAt)}
19 {count !== undefined && !hasBar ? ` · ${count}` : ''}
20 </Text>
21 {hasBar ? (
22 <Text key="job-bar" wrap="truncate-end">
23 {progressBar(job.done ?? 0, job.planned!, Math.min(30, width - 16))} {count}
24 </Text>
25 ) : null}
26 {lanes.map(lane => (
27 <Text key={`job-lane-${lane.id}`} wrap="truncate-end" dimColor={isLaneDone(lane)}>
28 {isLaneDone(lane) ? ' ✓ ' : ' ◌ '}
29 {lane.id} · {lane.phase}
30 {lane.planned !== undefined ? ` ${lane.done ?? 0}/${lane.planned}` : ''}
31 {isLaneDone(lane) ? '' : ` · ${elapsed(now - lane.since)}`}
32 {lane.label ? ` · ${lane.label}` : ''}
33 </Text>
34 ))}
35 {hidden > 0 ? (
36 <Text key="job-more" dimColor>
37 {' '}…and {hidden} more
38 </Text>
39 ) : null}
40 {job.last !== undefined ? (
41 <Text key="job-last" dimColor wrap="truncate-end">
42 {' '}
43 {job.last}
44 </Text>
45 ) : null}
46 </Box>
47 )
48}
49hooks/progress.ts 324 lines1import type { RookJob, RookJobLane } from '../types'
2import { clip } from './format'
3import { diskFacts } from './readiness'
4import { agentIdsOf, projectDirName, ROOT, SCENARIO_ID } from './workspace'
5import type { Io } from './workspace'
6import { parseMap, str } from './yaml'
7
8/**
9 * Progress for `rook generate` and `rook explore`, which run for minutes.
10 *
11 * Two sources, both pure here:
12 * - rook's streamed lines. Without `--verbose`, generate prints its plan
13 * (`plan: 7 scenario(s) across 3 feature(s)`, then ` F-001 4 happy_path 2,
14 * negative 2` per feature) and explore one line per agent (`<id>: 4
15 * features`, `<id>: unchanged, not re-analysed`). With `--verbose` (stderr)
16 * every subagent's start and end: ` · write_scenarios F-001 happy_path`,
17 * ` · write_scenarios done — …`. Both are parsed.
18 * - the disk: scenario files (generate) and feature files (explore) that
19 * appear or are rewritten while the job runs.
20 */
21
22/** The phases a lane moves through. */
23export const PHASE = {
24 planned: 'planned',
25 planning: 'planning',
26 writing: 'writing',
27 checking: 'checking',
28 scanning: 'finding agents',
29 describing: 'describing',
30 extracting: 'extracting features',
31 done: 'done',
32 unchanged: 'unchanged',
33 failed: 'failed',
34} as const
35
36const FINISHED: ReadonlySet<string> = new Set([PHASE.done, PHASE.unchanged, PHASE.failed])
37
38export const isLaneDone = (lane: RookJobLane): boolean => FINISHED.has(lane.phase)
39
40/** Splits streamed chunks into whole lines; `flush` hands back the unterminated rest. */
41export function lineSplitter(onLine: (line: string) => void): { push: (text: string) => void; flush: () => void } {
42 let rest = ''
43
44 return {
45 push: text => {
46 rest += text
47 const lines = rest.split(/\r?\n/)
48 rest = lines.pop() ?? ''
49 for (const line of lines) {
50 onLine(line)
51 }
52 },
53 flush: () => {
54 if (rest !== '') {
55 const line = rest
56 rest = ''
57 onLine(line)
58 }
59 },
60 }
61}
62
63export function newJob(kind: RookJob['kind'], label: string, startedAt: number, source: NonNullable<RookJob['source']>): RookJob {
64 return { kind, label, startedAt, lanes: [], source }
65}
66
67function withLane(job: RookJob, id: string, change: Partial<RookJobLane>, now: number): RookJob {
68 const at = job.lanes.findIndex(lane => lane.id === id)
69
70 if (at < 0) {
71 return { ...job, lanes: [...job.lanes, { id, label: '', phase: PHASE.planned, since: now, ...change }] }
72 }
73
74 const was = job.lanes[at]!
75 const lane = { ...was, ...change, since: change.phase !== undefined && change.phase !== was.phase ? now : was.since }
76
77 return { ...job, lanes: job.lanes.map((other, i) => (i === at ? lane : other)) }
78}
79
80/** A line that is part of the final `--json` document, not something rook said. */
81const isJsonLine = (line: string): boolean => /^\s*[{}[\]"]/.test(line)
82
83const ID = '[A-Za-z0-9][\\w.-]*'
84const WRITERS = /^\s*· (write_scenarios|write_attacks) (F-\d+)\b\s*(.*)$/
85const SUBAGENT_START = new RegExp(`^\\s*· (plan_scenarios|check_scenarios|find_agents|describe_agent|extract_features) (.*)$`)
86const SUBAGENT_END = /^\s*· (\w+) (done|failed)\b(?: — (.*))?$/
87const PLAN_TOTAL = /^plan: (\d+) scenario\(s\) across (\d+) feature\(s\)/
88const PLAN_ENTRY = /^\s+(F-\d+)\s{2,}(\d+)\s{2,}(.+?)\s*$/
89const PLAN_NONE = /^\s+(F-\d+)\s{2,}none\b/
90const AGENT_FEATURES = new RegExp(`^(${ID}): (\\d+) features\\s*$`)
91const AGENT_UNCHANGED = new RegExp(`^(${ID}): unchanged, not re-analysed\\s*$`)
92const AGENT_REANALYSE = new RegExp(`^(${ID}): re-analysing`)
93
94/** The lane a verbose start line names, and its phase. */
95function startedLane(kind: RookJob['kind'], role: string, label: string): { id: string; phase: string; label: string } {
96 switch (role) {
97 case 'plan_scenarios':
98 return { id: 'plan', phase: PHASE.planning, label }
99 case 'check_scenarios':
100 return { id: 'check', phase: PHASE.checking, label }
101 case 'find_agents':
102 return { id: 'scan', phase: PHASE.scanning, label: kind === 'explore' ? 'the repository' : label }
103 case 'describe_agent':
104 return { id: label.split(/\s+/)[0] || role, phase: PHASE.describing, label: '' }
105 default:
106 return { id: label.split(/\s+/)[0] || role, phase: PHASE.extracting, label: '' }
107 }
108}
109
110/** Which lane a verbose role's end closes: the oldest still open in that role's phase. */
111function endedLane(job: RookJob, role: string): RookJobLane | undefined {
112 const phase =
113 role === 'write_scenarios' || role === 'write_attacks'
114 ? PHASE.writing
115 : role === 'plan_scenarios'
116 ? PHASE.planning
117 : role === 'check_scenarios'
118 ? PHASE.checking
119 : role === 'find_agents'
120 ? PHASE.scanning
121 : role === 'describe_agent'
122 ? PHASE.describing
123 : role === 'extract_features'
124 ? PHASE.extracting
125 : undefined
126
127 return phase === undefined ? undefined : job.lanes.filter(lane => lane.phase === phase).sort((a, b) => a.since - b.since)[0]
128}
129
130/** One line rook printed, folded into the job. */
131export function jobFromLine(job: RookJob, raw: string, now: number): RookJob {
132 const line = raw.replace(/\s+$/, '')
133
134 if (line.trim() === '' || isJsonLine(line)) {
135 return job
136 }
137
138 const said = { ...job, last: clip(line, 160) }
139 let found: RegExpExecArray | null
140
141 if ((found = PLAN_TOTAL.exec(line))) {
142 return { ...said, planned: Number(found[1]), done: said.done ?? 0 }
143 }
144
145 if ((found = PLAN_NONE.exec(line))) {
146 return said
147 }
148
149 if ((found = PLAN_ENTRY.exec(line))) {
150 return withLane(said, found[1]!, { planned: Number(found[2]), label: clip(found[3]!, 60) }, now)
151 }
152
153 if ((found = WRITERS.exec(line))) {
154 const id = found[2]!
155 const lane = said.lanes.find(l => l.id === id)
156
157 return withLane(said, id, { phase: PHASE.writing, ...(lane?.label ? {} : { label: found[3]!.trim() }) }, now)
158 }
159
160 if ((found = SUBAGENT_END.exec(line))) {
161 const lane = endedLane(said, found[1]!)
162 const isWriter = found[1] === 'write_scenarios' || found[1] === 'write_attacks'
163
164 // A feature's writers are one per category: the lane stays `writing` until the disk says it is full.
165 return lane === undefined || (isWriter && found[2] === 'done')
166 ? said
167 : withLane(said, lane.id, { phase: found[2] === 'done' ? PHASE.done : PHASE.failed }, now)
168 }
169
170 if ((found = SUBAGENT_START.exec(line))) {
171 const lane = startedLane(job.kind, found[1]!, found[2]!.trim())
172
173 return withLane(said, lane.id, { phase: lane.phase, ...(lane.label ? { label: lane.label } : {}) }, now)
174 }
175
176 if (job.kind === 'explore') {
177 if ((found = AGENT_FEATURES.exec(line))) {
178 return withLane(said, found[1]!, { phase: PHASE.done, label: `${found[2]} features` }, now)
179 }
180
181 if ((found = AGENT_UNCHANGED.exec(line))) {
182 return withLane(said, found[1]!, { phase: PHASE.unchanged, label: 'not re-analysed' }, now)
183 }
184
185 if ((found = AGENT_REANALYSE.exec(line))) {
186 return withLane(said, found[1]!, { phase: PHASE.describing }, now)
187 }
188 }
189
190 return said
191}
192
193/** A file under the watched directory: its key (`SC-004`, `agent/F-001`), when it last changed, and whose it is. */
194export type DiskEntry = { key: string; mtimeMs: number; owner?: string }
195
196/** What the disk says the job has done so far: files new since it started, or rewritten by it. */
197export function jobFromDisk(job: RookJob, before: ReadonlySet<string>, entries: readonly DiskEntry[], now: number): RookJob {
198 const touched = entries.filter(entry => !before.has(entry.key) || entry.mtimeMs >= job.startedAt)
199 const byOwner = new Map<string, number>()
200
201 for (const entry of touched) {
202 if (entry.owner !== undefined) {
203 byOwner.set(entry.owner, (byOwner.get(entry.owner) ?? 0) + 1)
204 }
205 }
206
207 let next: RookJob = { ...job, done: touched.length }
208
209 for (const [owner, count] of byOwner) {
210 const lane = next.lanes.find(l => l.id === owner)
211
212 if (job.kind === 'generate') {
213 const isFull = lane?.planned !== undefined && count >= lane.planned
214 const phase = isFull ? PHASE.done : lane !== undefined && isLaneDone(lane) ? lane.phase : PHASE.writing
215
216 next = withLane(next, owner, { done: count, phase }, now)
217 } else {
218 // Explore writes an agent's features one file at a time: extracting until rook says how many.
219 next = withLane(next, owner, { done: count, ...(lane === undefined || !isLaneDone(lane) ? { phase: PHASE.extracting, label: `${count} features so far` } : {}) }, now)
220 }
221 }
222
223 return next
224}
225
226/** `45s`, `2m10s`: whole seconds, for lines that redraw every second. */
227export function elapsed(ms: number): string {
228 const seconds = Math.max(0, Math.floor(ms / 1000))
229
230 return seconds < 60 ? `${seconds}s` : seconds < 3600 ? `${Math.floor(seconds / 60)}m${String(seconds % 60).padStart(2, '0')}s` : `${Math.floor(seconds / 3600)}h${String(Math.floor(seconds / 60) % 60).padStart(2, '0')}m`
231}
232
233/** The lanes worth drawing: those in flight first (oldest first), then the finished, newest first. */
234export function shownLanes(job: RookJob, max = 8): { lanes: RookJobLane[]; hidden: number } {
235 const open = job.lanes.filter(lane => !isLaneDone(lane)).sort((a, b) => a.since - b.since)
236 const closed = job.lanes.filter(isLaneDone).sort((a, b) => b.since - a.since)
237 const all = [...open, ...closed]
238
239 return { lanes: all.slice(0, max), hidden: Math.max(0, all.length - max) }
240}
241
242/** `3/7` when the job knows both, `3 written` when only the count. */
243export function countText(job: RookJob): string | undefined {
244 const noun = job.kind === 'generate' ? 'scenarios' : 'feature files'
245
246 if (job.planned !== undefined && job.planned > 0) {
247 return `${job.done ?? 0}/${job.planned}`
248 }
249
250 return job.done !== undefined && job.done > 0 ? `${job.done} ${noun}` : undefined
251}
252
253/** The spinner while Claude waits on a generate or explore: `rook generate · F-003 writing · 3/7 · 2m10s`. */
254export function jobSpinnerText(job: RookJob, now: number): string {
255 const { lanes } = shownLanes(job, job.lanes.length)
256 const lane = lanes.find(l => !isLaneDone(l))
257 const more = lanes.filter(l => !isLaneDone(l)).length - 1
258 const doing = lane === undefined ? undefined : `${lane.id} ${lane.phase}${more > 0 ? ` +${more}` : ''}`
259 const count = countText(job)
260
261 return [`rook ${job.kind}`, doing, count, elapsed(now - job.startedAt)].filter(Boolean).join(' · ')
262}
263
264const yamlEntries = async (io: Io, dir: string) => (await io.list(dir).catch(() => [])).filter(entry => entry.kind === 'file' && entry.name.endsWith('.yaml'))
265
266/**
267 * The agent's scenario files, each with the feature it tests. Only files the
268 * job touched are opened, once each: `owners` remembers what was read.
269 */
270export async function scenarioEntries(io: Io, agentDir: string, before: ReadonlySet<string>, startedAt: number, owners: Map<string, string | undefined>): Promise<DiskEntry[]> {
271 const entries: DiskEntry[] = []
272
273 for (const file of await yamlEntries(io, `${agentDir}/scenarios`)) {
274 const key = file.name.replace(/\.yaml$/, '')
275
276 if (!SCENARIO_ID.test(key)) {
277 continue
278 }
279
280 const mtimeMs = file.mtimeMs ?? 0
281
282 if (before.has(key) && mtimeMs < startedAt) {
283 entries.push({ key, mtimeMs })
284 continue
285 }
286
287 if (!owners.has(key)) {
288 owners.set(key, str(parseMap((await io.read(`${agentDir}/scenarios/${file.name}`)) ?? '').feature_id))
289 }
290
291 const owner = owners.get(key)
292 entries.push({ key, mtimeMs, ...(owner !== undefined && { owner }) })
293 }
294
295 return entries
296}
297
298/** The agent's scenario ids on disk now, without opening a file: what a generate starts from. */
299export async function scenarioKeys(io: Io, agentDir: string): Promise<string[]> {
300 return (await yamlEntries(io, `${agentDir}/scenarios`)).map(file => file.name.replace(/\.yaml$/, '')).filter(key => SCENARIO_ID.test(key))
301}
302
303/** Every agent's feature files in the selected project, keyed `agent/F-001`. */
304export async function featureEntries(io: Io): Promise<DiskEntry[]> {
305 const facts = await diskFacts(io)
306 const projects = (await io.list(`${ROOT}/projects`).catch(() => [])).filter(entry => entry.kind === 'dir').map(entry => entry.name)
307 const project = projectDirName(projects, facts.projectId)
308
309 if (project === undefined) {
310 return []
311 }
312
313 const projectDir = `${ROOT}/projects/${project}`
314 const entries: DiskEntry[] = []
315
316 for (const agent of await agentIdsOf(io, projectDir)) {
317 for (const file of await yamlEntries(io, `${projectDir}/agents/${agent}/features`)) {
318 entries.push({ key: `${agent}/${file.name.replace(/\.yaml$/, '')}`, mtimeMs: file.mtimeMs ?? 0, owner: agent })
319 }
320 }
321
322 return entries
323}
324hooks/health.ts 178 lines1import type { RookScenarioRow } from '../types'
2import { isMap, parseMap, str } from './yaml'
3import type { YamlValue } from './yaml'
4
5/**
6 * The Health tab's "What nobody looked at", made actionable: every gap the
7 * latest run left grouped by why nobody could look, each with the remedy in
8 * plain words, a prompt that hands the group to Claude, and a re-test.
9 */
10
11/** Why a scenario was not (fully) judged, from the most to the least specific. */
12export type GapCause = 'agent_never_ran' | 'tool_calls' | 'not_observable' | 'undecidable' | 'unchecked'
13
14export type GapGroup = {
15 cause: GapCause
16 /** A few words for the group's heading. */
17 title: string
18 /** What to do about it, in plain words. */
19 remedy: string
20 rows: RookScenarioRow[]
21}
22
23const ORDER: readonly GapCause[] = ['agent_never_ran', 'tool_calls', 'not_observable', 'undecidable', 'unchecked']
24
25const TITLES: Record<GapCause, string> = {
26 agent_never_ran: 'the agent never answered',
27 tool_calls: "rook could not see the agent's tool calls",
28 not_observable: 'the evidence was not observable',
29 undecidable: 'the evidence did not settle it',
30 unchecked: 'passed with parts unchecked',
31}
32
33const REMEDIES: Record<GapCause, string> = {
34 agent_never_ran: "The scenario crashed before the agent answered: open its response.json (and hooks.json) to see why, fix the profile or the agent's start-up, then re-test.",
35 tool_calls:
36 "Nothing reported the agent's tool calls. Make the profile's collect step (or its execute hook) report them as calls: [{ name, arguments }], then re-test.",
37 not_observable: 'The judge could not see what the criteria need. Have the profile capture it (a collect step that reads the resulting state), then re-test.',
38 undecidable: "The evidence was there but did not decide the criterion. Capture sharper evidence or make the criterion concrete, without loosening what it checks.",
39 unchecked: 'These passed, but rook could not check every part. Make the missing evidence observable so the pass means what it says.',
40}
41
42/** rook's words for a tool-call expectation nobody could check (judge.ts, unobservableCriteria). */
43const TOOL_CALLS = /tool[- ]calls?|what the agent called|nothing reported the calls|proxy in front of the MCP/i
44
45export function causeOf(row: RookScenarioRow): GapCause {
46 if (row.reason === 'agent_never_ran') {
47 return 'agent_never_ran'
48 }
49
50 if ([...row.gaps, ...row.unchecked].some(note => TOOL_CALLS.test(note))) {
51 return 'tool_calls'
52 }
53
54 if (row.reason === 'not_observable' || row.reason === 'undecidable') {
55 return row.reason
56 }
57
58 return row.status === 'Unable to Verify' ? 'not_observable' : 'unchecked'
59}
60
61/** The rows grouped by cause, in a fixed order, empty groups left out. */
62export function gapGroups(rows: readonly RookScenarioRow[]): GapGroup[] {
63 return ORDER.map(cause => ({ cause, title: TITLES[cause], remedy: REMEDIES[cause], rows: rows.filter(row => causeOf(row) === cause) })).filter(
64 group => group.rows.length > 0,
65 )
66}
67
68/** Every note rook left on a row, its gaps and its unchecked hints, without repeats. */
69export function notesOf(row: RookScenarioRow): string[] {
70 return [...new Set([...row.gaps, ...row.unchecked].map(note => note.trim()).filter(Boolean))]
71}
72
73const pathOf = (value: YamlValue | undefined): string | undefined => str(value) ?? (isMap(value) ? str(value.script) : undefined)
74
75/** The scripts a profile's hooks run (`hooks.execute`, `hooks.collect`, …), as paths from the agent's directory. */
76export function hookScripts(profileText: string, agentDir: string): string[] {
77 let hooks: YamlValue | undefined
78
79 try {
80 hooks = parseMap(profileText).hooks
81 } catch {
82 return []
83 }
84
85 if (!isMap(hooks)) {
86 return []
87 }
88
89 return Object.entries(hooks).flatMap(([phase, value]) => {
90 const path = pathOf(value)
91
92 return path === undefined ? [] : [`${phase}: ${path.startsWith('/') ? path : `${agentDir}/${path}`}`]
93 })
94}
95
96export type UnverifiedAsk = {
97 runId: string
98 agentId: string
99 agentDir: string
100 group: GapGroup
101 profileId?: string
102 profileText?: string
103}
104
105/** What "Fix with Claude" hands Claude: the gaps, where the evidence is, and the profile to change. */
106export function unverifiedPrompt(ask: UnverifiedAsk): string {
107 const { group, agentDir, runId } = ask
108 const runDir = `${agentDir}/runs/${runId}`
109 const profilePath = ask.profileId === undefined ? undefined : `${agentDir}/profiles/${ask.profileId}.yaml`
110 const scripts = ask.profileText === undefined ? [] : hookScripts(ask.profileText, agentDir)
111 const ids = group.rows.map(row => row.id)
112
113 return [
114 `rook run ${runId} against agent ${ask.agentId}: ${ids.length} scenario${ids.length === 1 ? '' : 's'} where ${group.title}.`,
115 `Run directory: ${runDir}`,
116 '',
117 ...group.rows.flatMap(row => [
118 `${row.id}${row.title ? ` — ${row.title}` : ''} (${row.status}${row.reason ? `, ${row.reason}` : ''})`,
119 ...notesOf(row).map(note => ` - ${note}`),
120 ` verdict: ${runDir}/scenarios/${row.id}/verdict.yaml`,
121 ` reply: ${runDir}/scenarios/${row.id}/response.json`,
122 ` hooks: ${runDir}/scenarios/${row.id}/hooks.json`,
123 ]),
124 '',
125 profilePath === undefined ? 'No active profile was found.' : `Active profile: ${profilePath}`,
126 ...(scripts.length > 0 ? ['Its hook scripts:', ...scripts.map(script => ` ${script}`)] : profilePath === undefined ? [] : ['It runs no hook scripts yet.']),
127 '',
128 `What to do: ${group.remedy}`,
129 group.cause === 'tool_calls' || group.cause === 'not_observable'
130 ? "rook reads tool calls from a hook's result: an execute or collect hook that returns { calls: [{ name, arguments }] } (collect for a trace fetched after the turn) lands in hooks.json and the judge sees it."
131 : '',
132 '',
133 'Make the evidence observable; do not weaken, delete or loosen any criterion or scenario to make it pass. ' +
134 `When done, re-test with the rook run tool, passing only: ${ids.join(', ')}.`,
135 ]
136 .filter((line, at, lines) => line !== '' || (lines[at - 1] ?? '') !== '')
137 .join('\n')
138 .trim()
139}
140
141export const plural = (n: number, word: string): string => `${n} ${word}${n === 1 ? '' : 's'}`
142
143/** The confirm bar's estimate: the latest run's credits per scenario times how many. */
144export const estimateOf = (rate: number | undefined, count: number): { credits: number } | Record<string, never> =>
145 rate === undefined ? {} : { credits: rate * count }
146
147/** `pending`, or undefined as soon as `signal` aborts: a quiet child must not hold a cancel up. */
148export function untilAborted<T>(pending: Promise<T>, signal: AbortSignal | undefined): Promise<T | undefined> {
149 if (signal === undefined) {
150 return pending
151 }
152
153 if (signal.aborted) {
154 pending.catch(() => undefined)
155
156 return Promise.resolve(undefined)
157 }
158
159 return new Promise<T | undefined>((resolve, reject) => {
160 const onAbort = () => {
161 pending.catch(() => undefined)
162 resolve(undefined)
163 }
164
165 signal.addEventListener('abort', onAbort, { once: true })
166 pending.then(
167 value => {
168 signal.removeEventListener('abort', onAbort)
169 resolve(value)
170 },
171 error => {
172 signal.removeEventListener('abort', onAbort)
173 reject(error)
174 },
175 )
176 })
177}
178hooks/views/runs.tsx 246 lines1import type { RookRunSummary, RookRunView, RookStatus } from '../../types'
2import { credits, duration } from '../format'
3import { deltaLine, pct } from '../history'
4import type { RunDiff, RunDiffRow } from '../history'
5import type { El } from './kit'
6
7export type RunsTabProps = {
8 el: El
9 history: RookRunSummary[] | null
10 /** The run opened to its detail. */
11 open: RookRunView | null
12 /** Up to two run ids picked for comparing. */
13 compare: string[]
14 diff: RunDiff | null
15 /** A run rca is explaining now. */
16 explaining: string | null
17 onOpen: (runId: string) => void
18 onBack: () => void
19 onReport: () => void
20 onExplain: () => void
21 onCompareWith: () => void
22 onPick: (runId: string) => void
23 onClearCompare: () => void
24 /** The opened run against the finished run before it: who regressed, who was fixed. */
25 versus?: { previous: string; regressed: string[]; fixed: string[] } | null
26 /** Open one scenario of the opened run in the drill-down. */
27 onScenario?: (id: string) => void
28}
29
30const ICON: Record<RookStatus, { glyph: string; color: string }> = {
31 Pass: { glyph: '✓', color: 'green' },
32 Fail: { glyph: '✗', color: 'red' },
33 'Unable to Verify': { glyph: '?', color: 'yellow' },
34}
35
36/** A scenario row of a run's detail: `✗ SC-004 Manager-approval override…`. */
37export const scenarioLine = (row: { id: string; title: string; status: RookStatus }): string => `${ICON[row.status].glyph} ${row.id}${row.title ? ` ${row.title}` : ''}`
38
39/** `vs each scenario's verdict before: regressed SC-002 · fixed SC-004`, or that nothing moved. */
40export const versusLine = (versus: { regressed: readonly string[]; fixed: readonly string[] }): string =>
41 versus.regressed.length === 0 && versus.fixed.length === 0
42 ? "vs each scenario's verdict before: none regressed or was fixed"
43 : `vs each scenario's verdict before: ${[
44 versus.regressed.length > 0 ? `regressed ${versus.regressed.join(', ')}` : undefined,
45 versus.fixed.length > 0 ? `fixed ${versus.fixed.join(', ')}` : undefined,
46 ]
47 .filter(Boolean)
48 .join(' · ')}`
49
50/** `2026-09-28 15:49` from rook's ISO `created`; the run id's own date when there is none. */
51const when = (run: RookRunSummary): string => {
52 const at = run.created ?? run.runId.replace(/T(\d{2})-(\d{2})-(\d{2})Z.*/, 'T$1:$2:$3Z')
53
54 return at.replace('T', ' ').slice(0, 16)
55}
56
57/** One line of the list: the run, when, ✓/✗/?, pass rate, credits, duration, test. */
58export const runLine = (run: RookRunSummary): string =>
59 [
60 run.name ? `${run.name} (${run.runId})` : run.runId,
61 when(run),
62 `✓${run.counts.pass} ✗${run.counts.fail} ?${run.counts.unverifiable}`,
63 run.passRate === undefined ? undefined : pct(run.passRate),
64 credits(run.credits),
65 run.durationMs === undefined ? undefined : duration(run.durationMs),
66 run.isTest ? 'test' : undefined,
67 ]
68 .filter(Boolean)
69 .join(' · ')
70
71/** The Runs tab: run history, a run's detail, and two runs compared. Owned by the runs-tab feature. */
72export function RunsTab(props: RunsTabProps) {
73 const { Box, Text, Button } = props.el
74 const { history, open, compare, diff } = props
75
76 if (diff !== null && compare.length === 2) {
77 return <CompareView {...props} diff={diff} />
78 }
79
80 const picking = compare.length === 1 ? compare[0] : undefined
81
82 if (open !== null && picking === undefined) {
83 return <RunDetail {...props} run={open} />
84 }
85
86 if (history === null) {
87 return <Text dimColor>Reading the runs on disk…</Text>
88 }
89
90 if (history.length === 0) {
91 return <Text dimColor>No finished runs yet.</Text>
92 }
93
94 return (
95 <Box flexDirection="column">
96 {picking === undefined ? (
97 <Text dimColor wrap="truncate-end">
98 {history.length} finished run{history.length === 1 ? '' : 's'}, newest first. Open one for its detail.
99 </Text>
100 ) : (
101 <Box key="picking" flexDirection="row" gap={1}>
102 <Text color="cyan" wrap="truncate-end">
103 Compare {picking} with…
104 </Text>
105 <Button key="pick-cancel" plain label="Cancel" role="dismiss" onPress={props.onClearCompare} />
106 </Box>
107 )}
108 {history.map(run =>
109 run.runId === picking ? (
110 <Text key={`run-${run.runId}`} bold wrap="truncate-end">
111 ▸ {runLine(run)}
112 </Text>
113 ) : (
114 <Button
115 key={`run-${run.runId}`}
116 plain
117 label={runLine(run)}
118 onPress={() => (picking === undefined ? props.onOpen(run.runId) : props.onPick(run.runId))}
119 />
120 ),
121 )}
122 </Box>
123 )
124}
125
126function RunDetail(props: RunsTabProps & { run: RookRunView }) {
127 const { Box, Text, Button } = props.el
128 const { run } = props
129 const metrics = [
130 run.passRate === undefined ? undefined : `pass rate ${pct(run.passRate)}`,
131 credits(run.credits),
132 run.durationMs === undefined ? undefined : duration(run.durationMs),
133 ]
134 .filter(Boolean)
135 .join(' · ')
136 const canExplain = run.finished && (run.counts.fail > 0 || run.clusters.length > 0) && !(run.clusters.length > 0 && run.clusters.every(cluster => cluster.remedy !== undefined))
137
138 return (
139 <Box flexDirection="column">
140 <Text bold wrap="truncate-end">
141 {run.name ? `${run.name} · ` : ''}
142 {run.runId}
143 {run.finished ? '' : ' · unfinished'}
144 </Text>
145 {run.headline !== undefined && <Text wrap="wrap">{run.headline}</Text>}
146 <Text key="run-counts">
147 <Text color="green">✓ {run.counts.pass} Pass</Text> · <Text color="red">✗ {run.counts.fail} Fail</Text> ·{' '}
148 <Text color="yellow">? {run.counts.unverifiable} Unable to Verify</Text>
149 </Text>
150 {metrics !== '' && <Text dimColor>{metrics}</Text>}
151 <Text key="run-clusters" dimColor>
152 {run.clusters.length} cluster{run.clusters.length === 1 ? '' : 's'}
153 {run.clusters.some(cluster => cluster.remedy !== undefined) ? ' (explained)' : ''}
154 </Text>
155 {props.versus !== undefined && props.versus !== null && props.versus.previous !== run.runId && (
156 <Box key="run-versus">
157 <Text wrap="wrap">
158 {versusLine(props.versus)}
159 </Text>
160 </Box>
161 )}
162 <Box flexDirection="row" gap={1}>
163 <Button key="run-report" label="Report to Claude" hotkey="s" onPress={props.onReport} />
164 {canExplain &&
165 (props.explaining === run.runId ? (
166 <Text key="run-explaining" color="cyan">
167 explaining…
168 </Text>
169 ) : (
170 <Button key="run-explain" label="Explain with rca" hotkey="w" onPress={props.onExplain} />
171 ))}
172 <Button key="run-compare" label="Compare with…" hotkey="m" onPress={props.onCompareWith} />
173 <Button key="run-back" plain label="Back" hotkey="b" role="dismiss" onPress={props.onBack} />
174 </Box>
175 {run.rows.length > 0 && (
176 <Box key="run-scenarios" flexDirection="column">
177 <Text dimColor>
178 {run.rows.length} scenario{run.rows.length === 1 ? '' : 's'} judged · open one for its evidence
179 </Text>
180 {run.rows.map(row =>
181 props.onScenario === undefined ? (
182 <Text key={`run-sc-${row.id}`} color={ICON[row.status].color} wrap="truncate-end">
183 {scenarioLine(row)}
184 </Text>
185 ) : (
186 <Button key={`run-sc-${row.id}`} plain label={scenarioLine(row)} onPress={() => props.onScenario!(row.id)} />
187 ),
188 )}
189 </Box>
190 )}
191 </Box>
192 )
193}
194
195function CompareView(props: RunsTabProps & { diff: RunDiff }) {
196 const { Box, Text, Button } = props.el
197 const { diff } = props
198 const short = (status: RunDiffRow['base']) => (status === undefined ? '–' : status === 'Unable to Verify' ? '?' : status)
199 const section = (key: string, title: string, color: string | undefined, rows: readonly RunDiffRow[]) =>
200 rows.length === 0 ? null : (
201 <Box key={key} flexDirection="column">
202 <Text bold color={color}>
203 {title} ({rows.length})
204 </Text>
205 {rows.map(row => (
206 <Box key={`${key}-${row.id}`}>
207 <Text wrap="truncate-end">
208 {' '}
209 {row.id} {short(row.base)} → {short(row.head)}
210 {row.title ? <Text dimColor> {row.title}</Text> : ''}
211 </Text>
212 </Box>
213 ))}
214 </Box>
215 )
216 const side = (label: string, s: RunDiff['base']) => (
217 <Text key={`cmp-${label}`} wrap="truncate-end">
218 <Text dimColor>{label} </Text>
219 {s.name ? `${s.name} · ` : ''}
220 {s.runId} · ✓{s.counts.pass} ✗{s.counts.fail} ?{s.counts.unverifiable} · {pct(s.passRate)}
221 {s.credits === undefined ? '' : ` · ${credits(s.credits)}`}
222 </Text>
223 )
224
225 return (
226 <Box flexDirection="column">
227 <Box flexDirection="row" gap={1}>
228 <Text bold>Compare</Text>
229 <Button key="cmp-back" plain label="Done" role="dismiss" onPress={props.onClearCompare} />
230 </Box>
231 {side('base', diff.base)}
232 {side('head', diff.head)}
233 <Text key="cmp-delta" wrap="wrap">
234 {deltaLine(diff)}
235 </Text>
236 {section('cmp-fixed', 'Fixed', 'green', diff.fixed)}
237 {section('cmp-regressed', 'Regressed', 'red', diff.regressed)}
238 {section('cmp-added', 'New in head', undefined, diff.added)}
239 {section('cmp-missing', 'Not re-run in head', 'yellow', diff.missing)}
240 {section('cmp-still', 'Still failing', 'red', diff.stillFailing)}
241 {section('cmp-unverified', 'Still Unable to Verify (not Fail)', 'yellow', diff.stillUnverified ?? [])}
242 <Text dimColor>{diff.stillPassing} still passing</Text>
243 </Box>
244 )
245}
246hooks/history.ts 221 lines1import type { PluginState } from 'claude-code'
2
3import type { RookRunSummary, RookRunView, RookStatus } from '../types'
4import { credits, duration } from './format'
5import { compareRunIds, plannedIds } from './workspace'
6import type { Io } from './workspace'
7import { isMap, num, parseMap, str } from './yaml'
8
9/**
10 * The Runs tab's pure half: the agent's finished runs as a list, and what
11 * changed between two of them, scenario by scenario. Reads files through an
12 * `Io` only; never the engine.
13 */
14
15/** How many runs back the Runs tab lists. */
16export const HISTORY_LIMIT = 50
17
18/** The agent's finished runs, newest first, from run.yaml and report.yaml (never the verdicts). */
19export async function historyOf(io: Io, agentDir: string, ids: readonly string[], limit = HISTORY_LIMIT): Promise<RookRunSummary[]> {
20 const out: RookRunSummary[] = []
21
22 for (const runId of ids) {
23 if (out.length >= limit) {
24 break
25 }
26
27 const runDir = `${agentDir}/runs/${runId}`
28 const runText = await io.read(`${runDir}/run.yaml`)
29 const reportText = runText === undefined ? undefined : await io.read(`${runDir}/report.yaml`)
30
31 if (runText === undefined || reportText === undefined) {
32 continue // not a run, or not finished
33 }
34
35 const run = parseMap(runText)
36 const report = parseMap(reportText)
37 const totals = isMap(report.totals) ? report.totals : {}
38 const metrics = isMap(report.metrics) ? report.metrics : {}
39 const name = str(run.name)
40 const created = str(run.created)
41 const passRate = num(totals.pass_rate)
42 const spent = num(metrics.credits)
43 const durationMs = num(metrics.duration_ms)
44
45 out.push({
46 runId,
47 ...(name !== undefined && name !== '' && { name }),
48 ...(created !== undefined && { created }),
49 planned: num(totals.planned) ?? plannedIds(run).length,
50 counts: { pass: num(totals.passed) ?? 0, fail: num(totals.failed) ?? 0, unverifiable: num(totals.unverifiable) ?? 0 },
51 ...(passRate !== undefined && { passRate }),
52 ...(spent !== undefined && { credits: spent }),
53 ...(durationMs !== undefined && { durationMs }),
54 ...(run.test_mode === true && { isTest: true }),
55 })
56 }
57
58 return out
59}
60
61/** What a list signature of the agent's runs looks like: cheap to compare every poll. */
62export const historySignature = (agentDir: string, ids: readonly string[], isLatestFinished: boolean): string =>
63 `${agentDir}#${ids.join(',')}#${isLatestFinished ? 'done' : 'open'}`
64
65/** Two runs diffed scenario by scenario; declared in types/index.d.ts (PluginState rook.runDiff). */
66export type RunDiff = NonNullable<PluginState['rook']['runDiff']>
67
68/** One scenario between two runs. */
69export type RunDiffRow = RunDiff['fixed'][number]
70
71type DiffSide = Pick<RookRunView, 'runId' | 'name' | 'counts' | 'passRate' | 'credits' | 'durationMs' | 'finished'> & {
72 rows: readonly { id: string; title: string; status: RookStatus }[]
73}
74
75const sideOf = (run: DiffSide): RunDiff['base'] => ({
76 runId: run.runId,
77 ...(run.name !== undefined && run.name !== '' && { name: run.name }),
78 counts: run.counts,
79 ...(run.passRate !== undefined && { passRate: run.passRate }),
80 ...(run.credits !== undefined && { credits: run.credits }),
81 ...(run.durationMs !== undefined && { durationMs: run.durationMs }),
82 finished: run.finished,
83})
84
85/** Scenario by scenario, what changed from `base` to `head`. */
86export function diffRuns(base: DiffSide, head: DiffSide): RunDiff {
87 const before = new Map(base.rows.map(row => [row.id, row]))
88 const after = new Map(head.rows.map(row => [row.id, row]))
89 const ids = [...new Set([...before.keys(), ...after.keys()])].sort((a, b) => a.localeCompare(b))
90 const diff: RunDiff = {
91 base: sideOf(base),
92 head: sideOf(head),
93 fixed: [],
94 regressed: [],
95 added: [],
96 missing: [],
97 stillFailing: [],
98 stillUnverified: [],
99 stillPassing: 0,
100 delta: {
101 pass: head.counts.pass - base.counts.pass,
102 fail: head.counts.fail - base.counts.fail,
103 unverifiable: head.counts.unverifiable - base.counts.unverifiable,
104 ...(base.passRate !== undefined && head.passRate !== undefined && { passRate: head.passRate - base.passRate }),
105 ...(base.credits !== undefined && head.credits !== undefined && { credits: head.credits - base.credits }),
106 },
107 }
108
109 for (const id of ids) {
110 const was = before.get(id)
111 const now = after.get(id)
112 const row: RunDiffRow = { id, title: now?.title || was?.title || '', ...(was && { base: was.status }), ...(now && { head: now.status }) }
113
114 if (was === undefined) {
115 diff.added.push(row)
116 } else if (now === undefined) {
117 diff.missing.push(row)
118 } else if (was.status === 'Pass' && now.status === 'Pass') {
119 diff.stillPassing += 1
120 } else if (now.status === 'Pass') {
121 diff.fixed.push(row)
122 } else if (was.status === 'Pass') {
123 diff.regressed.push(row)
124 } else if (now.status === 'Fail') {
125 diff.stillFailing.push(row)
126 } else {
127 diff.stillUnverified!.push(row)
128 }
129 }
130
131 return diff
132}
133
134/** Which two runs a compare means: `[base, head]`, or why it cannot. Defaults to the two newest. */
135export function comparePair(ids: readonly string[], baseRef?: string, headRef?: string): { base: string; head: string } | { error: string } {
136 for (const ref of [baseRef, headRef]) {
137 if (ref !== undefined && !ids.includes(ref)) {
138 return { error: `no run ${ref} for this agent.` }
139 }
140 }
141
142 if (baseRef !== undefined && headRef !== undefined) {
143 return baseRef === headRef ? { error: 'pick two different runs.' } : { base: baseRef, head: headRef }
144 }
145
146 if (baseRef !== undefined) {
147 const head = ids.find(id => id !== baseRef && compareRunIds(id, baseRef) > 0) ?? ids.find(id => id !== baseRef)
148
149 return head === undefined ? { error: 'there is no other run to compare with.' } : { base: baseRef, head }
150 }
151
152 if (headRef !== undefined) {
153 const base = ids.find(id => compareRunIds(id, headRef) < 0) ?? ids.find(id => id !== headRef)
154
155 return base === undefined ? { error: 'there is no other run to compare with.' } : { base, head: headRef }
156 }
157
158 return ids.length < 2 ? { error: 'comparing needs two runs; this agent has fewer.' } : { base: ids[1]!, head: ids[0]! }
159}
160
161/** Two picked runs, oldest first: the base is the earlier run. */
162export const ordered = (a: string, b: string): [string, string] => (compareRunIds(a, b) <= 0 ? [a, b] : [b, a])
163
164const signed = (n: number, digits = 0): string => {
165 const fixed = Number(n.toFixed(digits))
166
167 return fixed > 0 ? `+${fixed}` : String(fixed)
168}
169
170export const pct = (rate: number | undefined): string => (rate === undefined ? '–' : `${Math.round(rate * 100)}%`)
171
172/** `+1 Pass · −1 Fail · ±0 ?` style, with pass-rate and credits deltas. */
173export function deltaLine(diff: RunDiff): string {
174 const { delta } = diff
175
176 return [
177 `Pass ${signed(delta.pass)}`,
178 `Fail ${signed(delta.fail)}`,
179 `Unable to Verify ${signed(delta.unverifiable)}`,
180 delta.passRate === undefined ? undefined : `pass rate ${pct(diff.base.passRate)} → ${pct(diff.head.passRate)} (${signed(delta.passRate * 100)} pts)`,
181 delta.credits === undefined ? undefined : `credits ${signed(delta.credits, 2)}`,
182 ]
183 .filter(Boolean)
184 .join(' · ')
185}
186
187const short = (status: RookStatus | undefined): string => (status === undefined ? '–' : status === 'Unable to Verify' ? '?' : status)
188
189const rowLine = (row: RunDiffRow): string => ` ${row.id}${row.title ? ` — ${row.title}` : ''} (${short(row.base)} → ${short(row.head)})`
190
191const sideLine = (label: string, side: RunDiff['base']): string =>
192 `${label} ${side.runId}${side.name ? ` "${side.name}"` : ''}${side.finished ? '' : ' (unfinished)'}: ` +
193 [
194 `${side.counts.pass} Pass · ${side.counts.fail} Fail · ${side.counts.unverifiable} Unable to Verify`,
195 side.passRate === undefined ? undefined : `pass rate ${pct(side.passRate)}`,
196 credits(side.credits),
197 side.durationMs === undefined ? undefined : duration(side.durationMs),
198 ]
199 .filter(Boolean)
200 .join(' · ')
201
202/** The diff as text: for `/rook compare` and the compare tool. */
203export function compareText(agentId: string, diff: RunDiff): string {
204 const section = (title: string, rows: readonly RunDiffRow[]) => (rows.length === 0 ? [] : [`${title} (${rows.length}):`, ...rows.map(rowLine)])
205
206 return [
207 `rook compare for agent ${agentId}:`,
208 sideLine('base', diff.base),
209 sideLine('head', diff.head),
210 `Change: ${deltaLine(diff)}`,
211 ...section('Fixed (Fail or Unable to Verify → Pass)', diff.fixed),
212 ...section('Regressed (Pass → Fail or Unable to Verify)', diff.regressed),
213 ...section('New in head', diff.added),
214 ...section('Not re-run in head', diff.missing),
215 ...section('Still failing', diff.stillFailing),
216 ...section('Still Unable to Verify (not Fail)', diff.stillUnverified ?? []),
217 `${diff.stillPassing} still passing.`,
218 'Unable to Verify is not Fail. Read either run with the rook report tool and its run_id.',
219 ].join('\n')
220}
221hooks/views/scenarios.tsx 198 lines1import type { RookScenarioRow, RookStatus } from '../../types'
2import { FILTERS, isFlaky } from '../scenarios'
3import type { ScenarioView } from '../scenarios'
4import type { El } from './kit'
5
6/** The open scenario: its file, its newest verdict when it has one, and where that verdict lies. */
7export type ScenarioDetail = { scenario: ScenarioView; row?: RookScenarioRow; verdictPath?: string }
8
9export type ScenariosTabProps = {
10 el: El
11 /** The scenarios the filter lets through. */
12 rows: ScenarioView[]
13 total: number
14 filter: string
15 selected: string[]
16 flaky: Record<string, RookStatus[]>
17 /** Each checked scenario's verdict from before its flaky check. */
18 flakyPrior?: Record<string, RookStatus>
19 detail: ScenarioDetail | undefined
20 draft: string
21 /** Nothing in flight and the checklist allows a run. */
22 canRun: boolean
23 /** Credits per scenario at the latest run's rate. */
24 rate: number | undefined
25 width: number
26 onFilter: (filter: string) => void
27 onToggle: (id: string) => void
28 onOpen: (id: string) => void
29 onSelectAll: () => void
30 onClear: () => void
31 onRunSelected: () => void
32 onCurate: (verb: 'exclude' | 'include') => void
33 onFix: (id: string) => void
34 onRegression: (id: string) => void
35 onFlaky: (id: string) => void
36 onDraft: (text: string) => void
37 onGenerate: (text?: string) => void
38}
39
40const MAX_ROWS = 60
41
42/** Each filter's key in the pane: digits past the tabs' 1-5, then j. */
43export const FILTER_KEYS: Record<string, string> = { all: '0', failing: '6', unverifiable: '7', never: '8', functional: '9', adversarial: 'j' }
44
45/** The tab's own actions' keys: r runs (as Run all does elsewhere), the rest from the letters no other feature takes. */
46export const SCENARIO_KEYS = { selectAll: 'h', clear: 'z', runSelected: 'r', exclude: 'q', include: 'u', generate: 'i' } as const
47
48const mark = (status: RookStatus | undefined): { icon: string; color?: string } =>
49 status === 'Pass' ? { icon: '✓', color: 'green' } : status === 'Fail' ? { icon: '✗', color: 'red' } : status === 'Unable to Verify' ? { icon: '?', color: 'yellow' } : { icon: '·' }
50
51const clipped = (text: string, max: number): string => (text.length <= max ? text : `${text.slice(0, Math.max(1, max - 1))}…`)
52
53/** A flaky check's verdicts with the one from before it, which counts toward the judgement. */
54const withPrior = (verdicts: RookStatus[] | undefined, prior: RookStatus | undefined): RookStatus[] | undefined =>
55 prior === undefined || verdicts === undefined || verdicts.length === 0 ? verdicts : [prior, ...verdicts]
56
57/** The Scenarios tab: filter, select, run selected, detail, flaky checks, generate. Owned by the scenarios-tab feature. */
58export function ScenariosTab(props: ScenariosTabProps) {
59 const { Box, Text, Button } = props.el
60 const Input = props.el.Input
61 const { rows, selected, flaky, detail } = props
62 const credits = props.rate === undefined || selected.length === 0 ? '' : ` · ~${Math.round(props.rate * selected.length * 100) / 100} credits`
63
64 const detailView = (d: ScenarioDetail) => {
65 const s = d.scenario
66 const verdicts = flaky[s.id]
67
68 return (
69 <Box key={`scd-${s.id}`} flexDirection="column" paddingLeft={2}>
70 {s.description !== undefined && <Text wrap="wrap">{clipped(s.description, 600)}</Text>}
71 {s.why !== undefined && (
72 <Text dimColor wrap="wrap">
73 why: {clipped(s.why, 400)}
74 </Text>
75 )}
76 {s.criteria.length > 0 && <Text bold>Criteria</Text>}
77 {s.criteria.slice(0, 10).map((c, at) => (
78 <Text key={`scd-${s.id}-c${at}`} wrap="wrap">
79 · {clipped(c, 300)}
80 </Text>
81 ))}
82 <Text color={mark(s.status).color} wrap="truncate-end">
83 {s.status === undefined ? 'never run' : `latest: ${s.status} · run ${s.runId ?? '?'}`}
84 </Text>
85 {d.row?.failing.slice(0, 6).map(c => (
86 <Box key={`scd-${s.id}-${c.id}`} flexDirection="column">
87 <Text wrap="wrap">
88 <Text bold>{c.id}</Text> {clipped(c.criterion, 300)}
89 </Text>
90 <Text wrap="wrap">
91 <Text color="green">expected </Text>
92 {clipped(c.expected, 400)}
93 </Text>
94 <Text wrap="wrap">
95 <Text color="red">achieved </Text>
96 {clipped(c.achieved, 400)}
97 </Text>
98 </Box>
99 ))}
100 {d.row !== undefined && d.row.failing.length === 0 && d.row.summary !== '' && <Text wrap="wrap">{clipped(d.row.summary, 400)}</Text>}
101 {d.verdictPath !== undefined && (
102 <Text dimColor wrap="truncate-end">
103 verdict: {d.verdictPath}
104 </Text>
105 )}
106 {verdicts !== undefined && verdicts.length > 0 && (
107 <Text color={isFlaky(verdicts) ? 'yellow' : undefined} wrap="wrap">
108 flaky check: {props.flakyPrior?.[s.id] === undefined ? '' : `earlier ${props.flakyPrior[s.id]}, then `}
109 {verdicts.join(', ')}
110 {isFlaky(withPrior(verdicts, props.flakyPrior?.[s.id])) ? ' · flaky' : ''}
111 </Text>
112 )}
113 <Box flexDirection="row" gap={1}>
114 {s.status === 'Fail' && <Button key={`fix-sc-${s.id}`} label="Fix this with Claude" variant="primary" onPress={() => props.onFix(s.id)} />}
115 {s.status === 'Fail' && <Button key={`regress-${s.id}`} label="Turn into regression test" onPress={() => props.onRegression(s.id)} />}
116 {props.canRun && !s.excluded && <Button key={`flaky-${s.id}`} label="Re-run 3×" onPress={() => props.onFlaky(s.id)} />}
117 </Box>
118 </Box>
119 )
120 }
121
122 return (
123 <Box flexDirection="column">
124 <Box flexDirection="row" gap={1}>
125 {FILTERS.map(f =>
126 f.id === props.filter ? (
127 <Text key={`filter-${f.id}`} bold underline>
128 {FILTER_KEYS[f.id]}: {f.label}
129 </Text>
130 ) : (
131 <Button key={`filter-${f.id}`} plain label={f.label} hotkey={FILTER_KEYS[f.id]} onPress={() => props.onFilter(f.id)} />
132 ),
133 )}
134 </Box>
135 <Text dimColor>
136 {rows.length} of {props.total} shown · {selected.length} selected
137 </Text>
138 {props.total === 0 && <Text dimColor>No scenarios yet. Describe what to cover below and press Generate.</Text>}
139 {rows.slice(0, MAX_ROWS).flatMap(s => {
140 const m = mark(s.status)
141 const verdicts = flaky[s.id]
142 const tags = [s.featureId, [s.class, s.category].filter(Boolean).join('/') || undefined, s.excluded ? 'excluded' : undefined, isFlaky(withPrior(verdicts, props.flakyPrior?.[s.id])) ? 'flaky' : undefined]
143 .filter(Boolean)
144 .join(' · ')
145 const isOpen = detail?.scenario.id === s.id
146
147 return [
148 <Box key={`scr-${s.id}`} flexDirection="row" gap={1}>
149 <Button key={`sel-${s.id}`} plain label={`${selected.includes(s.id) ? '☑' : '☐'} ${s.id}`} onPress={() => props.onToggle(s.id)} />
150 <Text color={m.color}>{m.icon}</Text>
151 <Button
152 key={`sc-${s.id}`}
153 plain
154 label={`${isOpen ? '▾' : '▸'} ${clipped(s.title || s.id, Math.max(20, props.width - 16 - tags.length))}${tags ? ` · ${tags}` : ''}`}
155 onPress={() => props.onOpen(s.id)}
156 />
157 </Box>,
158 ...(isOpen && detail !== undefined ? [detailView(detail)] : []),
159 ]
160 })}
161 {rows.length > MAX_ROWS && <Text dimColor>… {rows.length - MAX_ROWS} more: narrow the filter.</Text>}
162 <Box flexDirection="row" gap={1}>
163 {/* The focus ring starts on Select all, on Run selected once something is ticked. */}
164 {rows.length > 0 && (
165 <Button key="select-all" label="Select all shown" hotkey={SCENARIO_KEYS.selectAll} {...(selected.length === 0 && { autoFocus: true as const })} onPress={props.onSelectAll} />
166 )}
167 {selected.length > 0 && <Button key="select-clear" label="Clear" hotkey={SCENARIO_KEYS.clear} onPress={props.onClear} />}
168 {props.canRun && selected.length > 0 && (
169 <Button
170 key="run-selected"
171 label={`Run selected (${selected.length})${credits}`}
172 variant="primary"
173 hotkey={SCENARIO_KEYS.runSelected}
174 autoFocus
175 onPress={props.onRunSelected}
176 />
177 )}
178 {selected.length > 0 && <Button key="exclude-selected" label="Exclude selected" hotkey={SCENARIO_KEYS.exclude} onPress={() => props.onCurate('exclude')} />}
179 {selected.length > 0 && <Button key="include-selected" label="Include selected" hotkey={SCENARIO_KEYS.include} onPress={() => props.onCurate('include')} />}
180 </Box>
181 <Text bold>Generate</Text>
182 <Box flexDirection="row" gap={1}>
183 {Input !== undefined && (
184 <Input
185 key="gen-draft"
186 placeholder="what the new scenarios should cover"
187 value={props.draft}
188 submitLabel="generate"
189 onInput={text => props.onDraft(text)}
190 onSubmit={text => props.onGenerate(text)}
191 />
192 )}
193 <Button key="generate" label="Generate" hotkey={SCENARIO_KEYS.generate} onPress={() => props.onGenerate()} />
194 </Box>
195 </Box>
196 )
197}
198hooks/scenarios.ts 185 lines1import { isMap, list, parseMap, str } from './yaml'
2import { clip } from './format'
3import type { Io } from './workspace'
4import { SCENARIO_ID } from './workspace'
5import type { RookCurrent, RookScenarioRow, RookStatus } from '../types'
6
7/**
8 * The Scenarios tab's pure side: every scenario of the agent as its YAML
9 * describes it (excluded ones too), the filters, flaky checks and the prompts
10 * the tab hands Claude. No `$`: register.tsx reads through `Io`.
11 */
12
13export type ScenarioInfo = {
14 id: string
15 title: string
16 featureId?: string
17 /** `functional`, `non-functional` or `adversarial`. */
18 class?: string
19 category?: string
20 excluded: boolean
21 /** `description`, or the scenario's `goal`: what is asked of the agent. */
22 description?: string
23 /** Why rook wrote it: the failure it is meant to catch. */
24 why?: string
25 /** The acceptance criteria's statements. */
26 criteria: string[]
27}
28
29/** A scenario beside its newest verdict. */
30export type ScenarioView = ScenarioInfo & { status?: RookStatus; runId?: string }
31
32export const FILTERS = [
33 { id: 'all', label: 'all' },
34 { id: 'failing', label: 'failing' },
35 { id: 'unverifiable', label: 'unable to verify' },
36 { id: 'never', label: 'never run' },
37 { id: 'functional', label: 'functional' },
38 { id: 'adversarial', label: 'adversarial' },
39] as const
40
41export type ScenarioFilter = (typeof FILTERS)[number]['id']
42
43export const isFilter = (value: string): value is ScenarioFilter => FILTERS.some(f => f.id === value)
44
45/** One scenario file, read as rook writes it. */
46export function scenarioOf(id: string, text: string): ScenarioInfo {
47 const doc = parseMap(text)
48 const featureId = str(doc.feature_id)
49 const cls = str(doc.class)
50 const category = str(doc.category)
51 const description = str(doc.description) ?? str(doc.goal)
52 const why = str(doc.why)
53 const criteria = list(doc.acceptance_criteria ?? doc.criteria).flatMap(item =>
54 typeof item === 'string' ? [item] : isMap(item) && str(item.statement ?? item.criterion) !== undefined ? [str(item.statement ?? item.criterion)!] : [],
55 )
56
57 return {
58 id,
59 title: str(doc.title) ?? '',
60 ...(featureId !== undefined && { featureId }),
61 ...(cls !== undefined && { class: cls }),
62 ...(category !== undefined && { category }),
63 excluded: doc.excluded === true,
64 ...(description !== undefined && { description: description.trim() }),
65 ...(why !== undefined && { why: why.trim() }),
66 criteria,
67 }
68}
69
70/** Every scenario under `<agent>/scenarios/`, excluded ones included, by id. */
71export async function readScenarios(io: Io, agentDir: string): Promise<ScenarioInfo[]> {
72 const names = (await io.list(`${agentDir}/scenarios`).catch(() => []))
73 .filter(entry => entry.kind === 'file' && entry.name.endsWith('.yaml'))
74 .map(entry => entry.name.replace(/\.yaml$/, ''))
75 .filter(id => SCENARIO_ID.test(id))
76 const found: ScenarioInfo[] = []
77
78 for (const id of names) {
79 const text = await io.read(`${agentDir}/scenarios/${id}.yaml`)
80
81 if (text !== undefined) {
82 found.push(scenarioOf(id, text))
83 }
84 }
85
86 return found.sort((a, b) => a.id.localeCompare(b.id))
87}
88
89/** The scenarios beside their newest verdicts. */
90export function withVerdicts(scenarios: readonly ScenarioInfo[], current: readonly RookCurrent[]): ScenarioView[] {
91 const latest = new Map(current.map(row => [row.id, row]))
92
93 return scenarios.map(s => {
94 const row = latest.get(s.id)
95
96 return row === undefined ? { ...s } : { ...s, status: row.status, runId: row.runId }
97 })
98}
99
100export function filterScenarios(rows: readonly ScenarioView[], filter: string): ScenarioView[] {
101 switch (filter) {
102 case 'failing':
103 return rows.filter(row => row.status === 'Fail')
104 case 'unverifiable':
105 return rows.filter(row => row.status === 'Unable to Verify')
106 case 'never':
107 return rows.filter(row => row.status === undefined)
108 case 'functional':
109 case 'adversarial':
110 return rows.filter(row => row.class === filter)
111 default:
112 return [...rows]
113 }
114}
115
116/** The selection with `ids` toggled, `add`ed or removed. */
117export const toggled = (selected: readonly string[], id: string): string[] =>
118 selected.includes(id) ? selected.filter(s => s !== id) : [...selected, id].sort()
119
120export const withAll = (selected: readonly string[], ids: readonly string[]): string[] => [...new Set([...selected, ...ids])].sort()
121
122/** Verdicts that disagree: the scenario is flaky. */
123export const isFlaky = (verdicts: readonly RookStatus[] | undefined): boolean => new Set(verdicts ?? []).size > 1
124
125export const FLAKY_DEFAULT = 3
126export const FLAKY_MAX = 10
127
128/** `/rook flaky <id> [n]`. */
129export function parseFlakyArgs(args: readonly string[]): { id: string; times: number } | { error: string } {
130 const [id, times] = args
131
132 if (id === undefined || !/^SC-\d{3,6}$/.test(id)) {
133 return { error: 'usage: /rook flaky SC-001 [times] (runs one scenario several times in a row; spends credits each time)' }
134 }
135
136 if (times === undefined) {
137 return { id, times: FLAKY_DEFAULT }
138 }
139
140 const n = Number(times)
141
142 return Number.isInteger(n) && n >= 2 && n <= FLAKY_MAX ? { id, times: n } : { error: `times is a whole number from 2 to ${FLAKY_MAX}.` }
143}
144
145/** What a finished flaky check says; `prior` is the scenario's newest verdict from before the check. */
146export function flakyText(id: string, verdicts: readonly RookStatus[], times: number, prior?: RookStatus): string {
147 const said = verdicts.join(', ')
148
149 if (verdicts.length < times) {
150 return `flaky check of ${id} stopped after ${verdicts.length}/${times} runs${said ? `: ${said}` : ''}.`
151 }
152
153 const before = prior === undefined ? '' : `earlier ${prior}, then `
154
155 if (isFlaky(prior === undefined ? verdicts : [prior, ...verdicts])) {
156 return isFlaky(verdicts) ? `${id} is FLAKY: ${before}${times} runs disagreed (${said}).` : `${id} is FLAKY: ${before}${said}. Its verdict changed between runs.`
157 }
158
159 return `${id} is stable: ${before}${times} runs, all ${verdicts[0] ?? '?'}.`
160}
161
162/** The instruction for `rook generate` that turns a failure into a regression scenario of its feature. */
163export function regressionInstruction(info: ScenarioInfo, row: RookScenarioRow | undefined): string {
164 const broke = row?.failing[0]
165 const what = broke !== undefined ? `${clip(broke.criterion, 240)} (achieved instead: ${clip(broke.achieved, 240)})` : clip(row?.summary || info.title, 300)
166
167 return (
168 `Regression scenario for feature ${info.featureId ?? '(the feature of ' + info.id + ')'}: pin the bug ${info.id} found — ${what}. ` +
169 'Write one focused scenario that fails while this bug is present and passes once it is fixed. Cover only this feature.'
170 )
171}
172
173/** The prompt that asks Claude to write the regression scenario with the rook generate tool. */
174export function regressionPrompt(info: ScenarioInfo, row: RookScenarioRow | undefined, verdictPath: string | undefined): string {
175 return [
176 `rook scenario ${info.id}${info.title ? ` (${info.title})` : ''} failed${info.featureId ? ` in feature ${info.featureId}` : ''}.`,
177 ...(verdictPath === undefined ? [] : [`Its verdict: ${verdictPath}`]),
178 'Turn this bug into a regression test: call the rook generate tool with this instruction (keep it scoped to the feature):',
179 '',
180 regressionInstruction(info, row),
181 '',
182 'It spends credits; do not pass force. Then tell me the new scenario id.',
183 ].join('\n')
184}
185