SLOPSHOPPER

rook

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…

newpanebandspinnerrowsguard
★ 1v0.3.2Apache-2.0updated 2026-10-23LambdaTest/rook-claude-plugin/plugins/rook
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · rook
│ ┃ rook ✕ › fix the failing auth test and add an audit log call │ ┃ rook isn't set up in this folder │ ┃ rook · setup ⏺ Read(src/auth.ts) │ ┃ ✓ rook installed ⎿ Read 6 lines │ ┃ ✓ sign-in not checked ⏺ Update(src/auth.ts) │ ┃ ✗ no project selected ⎿ Added 2 lines, removed 1 line │ ┃ ✗ no agent yet ⏺ Bash(bun test) │ ┃ ✗ no scenarios ⎿ 3 pass, 1 fail │ ┃ ✗ no profile │ ┃ Next: type `/rook project` to see your ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ projects and `/rook project use <id>` to │ ┃ pick one, or ask Claude to set rook up ✻ Worked for 42s · done 4:20 PM │ ┃ Found in this repo │ ┃ no agent code, requirements, connection doc › /rook │ ┃ or CI found at the top level or in src/. ⎿ rook: pane opened. │ ┃ [ Create project “app” ] [ Pick one of mine ⎿ rook: setup is not finished: │ ⎿ rook: ✓ rook installed │ ⎿ rook: ✓ sign-in not checked │ ⎿ rook: ✗ no project selected │ ⎿ rook: ✗ no agent yet │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · rook
rook isn't set up in this folder rook · setup ✓ rook installed ✓ sign-in not checked ✗ no project selected ✗ no agent yet ✗ no scenarios ✗ no profile Next: type `/rook project` to see your projects and `/rook project use <id>` to pick one, or ask Claude to set rook up Found in this repo no agent code, requirements, connection doc or CI found at the top level or in src/. [ Create project “app” ] [ Pick one of mine ]
README

rook for Claude Code

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, or brew install lambdatest/rook/rook on macOS and Linux).

What it does

Claude runs rookFourteen 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 CodeThree 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 tabsHealth (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 spendingRun 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 cardWhen 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 exploreA 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.
HistoryThe 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.
Depthreport 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 DeveloperThe 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 verdictEvery 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-downAny 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.
TrendsScenarios × 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.
LiveA 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 progressWhile 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 promptWhen 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 contextIf 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 linePicks 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 toolscompare (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.
/rookpane · 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 guardRefuses 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.

Install

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.

Options

Set them in /config or under pluginConfigs.rook in settings.

OptionDefault
rookPathrookThe CLI to run: a name on PATH or an absolute path
paneautoauto opens the pane in a rook workspace; command opens it only on /rook; off never opens it
lensqeWho the first tab leads for: qe (Release) or dev (My change); l in the pane switches and is kept
statusLinetrueShow the score in the status line
retestBandtrueTrack edits to the agent and offer a re-test
failureContexttrueHand failures of runs started elsewhere to Claude
prodGuardtrueRefuse runs against a production-looking target until confirmed
prodPatternsprod,production,liveWhole 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

What this plugin runs, reads and sends

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

  • The 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.
  • The 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.
  • A fresh 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

  • Tools and command: it adds the rook tools (mcp__rook__*) and the /rook command.
  • Production guard: it watches Claude's 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.
  • Re-test band: it watches Claude's file edits only to offer a re-test of the scenarios an edited file affects.
  • Prompts it sends: it submits a prompt to Claude only when you press a pane, band or card button such as Re-test, Fix with Claude or Turn into regression test.
  • Transcript: it redraws the result row of its own run and report tools as a verdict card. The text Claude reads is unchanged.
  • Messages it adds: it adds a run's failures to the conversation when a run started elsewhere finishes with failures. Turn this off with failureContext.

How it talks to rook

Only through rook's published contract:

  • The CLI's --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.
  • For setup: 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.
  • The documented .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:

  • scenario ids in SC-001 form
  • the known classes and phases
  • one-word categories and tags
  • profile ids
  • run ids in rook's form
  • concurrency from 1 to 8
  • printable names and rules that cannot start with -

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.

What the production guard reads, and what it does not

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.

Development

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.

Source 42 files
hooks/register.tsx 4733 lines
1import { 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 lines
1import 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'>>
11
hooks/views/tabs.tsx 46 lines
1import 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}
46
hooks/views/confirm.tsx 23 lines
1import 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}
23
hooks/views/setup.tsx 307 lines
1import 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}
307
hooks/views/job.tsx 49 lines
1import 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}
49
hooks/progress.ts 324 lines
1import 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}
324
hooks/health.ts 178 lines
1import 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}
178
hooks/views/runs.tsx 246 lines
1import 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}
246
hooks/history.ts 221 lines
1import 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}
221
hooks/views/scenarios.tsx 198 lines
1import 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}
198
hooks/scenarios.ts 185 lines
1import { 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