Portable Agent Skills, Agent Plugins, and vendor adapters for Infiquetra workflows

Portable Agent Skills, Agent Plugins, and vendor adapters for Infiquetra engineering workflows.
This public repository is the design and source catalog for behavior that does not belong to one coding-agent vendor. It complements, and is intended to eventually generate parts of, the existing Claude Code, Codex, Antigravity, OpenCode, and Hermes plugin repositories. Those repositories remain the runtime sources of truth until a recorded custody decision moves that authority here, and no such decision has been made.
Custody moved here on 2026-09-22. This repository is the source of truth for every Infiquetra plugin. The thirteen live plugins of infiquetra-claude-plugins (commit acc99fe7; the fourteenth, team-execution, had already been archived upstream) were imported once, package by package, and are authored here from that commit: there is no upstream pin and no provenance manifest any more. The decision, its rationale, and the four rules it changed are the 2026-09-22 entry in DECISIONS.md; the run plan is docs/plans/2026-09-22-custody-move-and-claude-plugins-retirement-plan.md.
What every package looks like now:
plugin.json, skills/, scripts/, references/, tests/). A package whose upstream shipped only Claude commands now also carries a portable skill over its scripts, so the four skill-scoped harnesses (OpenCode, Gemini CLI, Muse, Hermes) can reach them.com.infiquetra.claude/ (commands, agents, hooks, MCP registration, output styles, Claude Code mods: TypeScript hooks modules under mods/ and a types/index.d.ts state contract). The root .claude-plugin/plugin.json and the generated root marketplace carry paths and identity only (scripts/sync_marketplace.py, checked by check_repo.py).plugins/<pkg>/.codex-plugin/plugin.json and the root .agents/plugins/marketplace.json, both generated (scripts/sync_codex_packaging.py), for the same reason and under the same paths-only rule._bundled/ directory (scripts/bundle_fleet_module.py); it is not installed as a plugin by any harness. The upstream discovery shim (fleet_commons_shim.py) does not cross the boundary.plugins/<pkg>/tests/ and run in CI with pytest's importlib import mode; repository-level tests stay standard-library-only.How a harness on a machine installs the catalog is docs/runbooks/install-clients.md (scripts/install_client.py, with --check readback and a legacy uninstall for placements that recorded the old repository).
Compatibility evidence binds to a released package version rather than to every tree (2026-09-22 decision). The ten-client matrices under docs/evidence/ that predate the import are superseded and kept as historical context; fresh assessments for the authored versions are queued work, not a claim this README makes.
The record of the work, in the order a new reader should take it:
docs/engineering-journal/narratives/ (2026-09-22-import-<package>.md).| Package | Version | Description | Installable surfaces |
|---|---|---|---|
plugins/agent-launcher/ | 1.7.1 | Create one verified coding-agent session through the agents wrapper and Herdr | Claude, Codex, 1 skill |
plugins/agy/ | 0.6.2 | Antigravity-backed coder reviewer bridge agents that run in a disposable clone and… | Claude, Codex, 1 skill |
plugins/codex/ | 0.1.5 | Portable Codex delegation wrapper | Claude, Codex, 1 skill |
plugins/deploy/ | 0.2.3 | Tag-promotion deployment operations for Infiquetra repositories | Claude, Codex, 1 skill |
plugins/fleet-core/ | 0.32.0 | Authored Fleet Core library: staffing (work shape, role, and lens to model and… | Codex |
plugins/hermes-profile-evolution/ | 0.1.5 | Portable request adapter for target-sovereign Hermes profile evolution | Claude, Codex, 1 skill |
plugins/home-lab-ops/ | 1.2.2 | Proxmox VE cluster operations, Ansible pre-flight validation, Ceph management,… | Claude, Codex, 6 skills |
plugins/house-style/ | 0.1.1 | Claude-only package | Claude, Codex |
plugins/mission-control/ | 2.21.1 | SDLC management for Operations, Asgard, and CAMPPS: prepared issue drafts, live schema… | Claude, Codex, 8 skills |
plugins/orchestrate/ | 6.0.1 | Run one piece of work across several herdr agent sessions, one git worktree per unit | Claude, Codex, 1 skill |
plugins/redis-channel/ | 0.5.3 | Portable Redis Streams bridge | Claude, Codex, 1 skill |
plugins/saga/ | 1.2.1 | Infiquetra lifecycle plugin: one automatic run per issue — admission, plan, plan… | Claude, Codex, 14 skills |
plugins/unifi/ | 2.0.7 | Portable UniFi Network and Protect package: two Agent Skills with their bundled Python… | Claude, Codex, 2 skills |
plugins/voice/ | 0.4.0 | Portable voice package: a spoken conversational loop for one explicitly bound,… | Claude, Codex, 1 skill |
The full Fleet Core module set at the import commit is here (plugins/fleet-core/scripts/fleet_commons/, plus scripts/jev.py and references/). What is deliberately absent is stated in plugins/fleet-core/DEFERRED.md: the Claude-specific discovery shim, and one residual data file kept only while a consumer still declares it.
The portable UniFi package can read an optional operator site profile — a JSON file describing one site's topology and intent — from a machine-local path. The profile is optional in the strong sense: a runtime with no profile anywhere loads successfully in discovery-only mode and infers no trust role, criticality, or ownership rather than guessing a default.
How a profile is authored and deployed is the operator's business. The Infiquetra instance keeps its profile in a private repository and renders JSON at deployment time through an existing Ansible harness. That arrangement is one operator's custody instance and is not required by the portable contract, which knows only a path. See plugins/unifi/references/site-profile.md for the contract itself.
| Path | Purpose |
|---|---|
plugins/ | Portable packages, authored here since the 2026-09-22 custody move |
ports/ | One port descriptor per package: identity, custody, and assessment settings |
schemas/ | JSON Schemas for the contracts this repository validates |
scripts/ | Validation, synchronization, bundling, and inventory tools |
docs/runbooks/install-clients.md | Place this catalog on each harness, read the placement back, and remove the old marketplace |
docs/ | Architecture, public guidance, and durable repository knowledge |
docs/plans/ | Approved implementation plans |
docs/evidence/ | Assessment records, written under the public evidence rules |
docs/engineering-journal/ | Learnings, decisions, queued work, and archive |
.github/ | Pull request, issue, and validation workflow configuration |
Client-specific material lives in an explicit adapter directory inside the package that needs it, never at the package root. plugins/unifi/com.infiquetra.claude/ and plugins/mission-control/com.infiquetra.claude/ are Claude adapters; the portable manifests, skills, schemas, and scripts beside them carry no client-specific assumption.
Run the same checks used by continuous integration (CI):
python3 scripts/check_repo.py
python3 -m unittest discover -s tests -v
git diff --check
The validation script checks the repository baseline, local Markdown links, the Agent Plugin manifests under plugins/, each package's provenance manifest, the build declarations and generated bundle stamps, the portable skills' frontmatter, and that no TypeScript or JavaScript module source (nor the tsconfig.json Claude Code writes at a package root) sits outside a Claude adapter. It installs nothing and makes no network call, so this baseline cannot be broken by a package index outage. A second continuous integration job pins the catalog's declared floor, python>=3.12, installs requests, urllib3, and pytest, and runs the ported plugin tests. The pin is the floor itself rather than the newest interpreter, because a floor that is never exercised is not a floor. The floor is a single value with a single owner, tests/test_python_floor.py, and every place the catalog states it is checked against that owner.
A third continuous integration job, claude-mods, installs Claude Code at the recorded floor build (2.1.286) and at the pinned build (2.1.289) and, for every package whose Claude adapter names a hooks module, runs claude plugin validate --strict and claude plugin test. Neither command needs a login.
AGENTS.md before changing the repository.Public development guidance is summarized in docs/public-safe-summary.md.
mods/index.ts 28 lines1// Saga's one hooks module, named from `../hooks/hooks.json` under `modules`.
2//
3// The engine loads exactly one module per plugin, so every saga mod registers
4// from here: each mod lives in its own file beside this one and exports a
5// function this `register` calls. Mods display saga state and collect answers;
6// saga's scripts own that state and enforce every policy (see `run-record.ts`).
7
8import type { Register } from 'claude-code'
9import { registerAdmissionReview } from './admission-review.tsx'
10import { registerAgentTypes } from './agent-types.ts'
11import { registerMergeConfirmation } from './merge-confirmation.tsx'
12import { registerPlanViewer } from './plan-viewer.tsx'
13import { registerReviewPane } from './review-pane.tsx'
14import { registerSetupPane } from './setup-pane.tsx'
15import { registerUsageCapture } from './usage-capture.ts'
16import { registerRunBand } from './run-band.tsx'
17
18export const register: Register = (on) => {
19 registerPlanViewer(on)
20 registerAdmissionReview(on)
21 registerMergeConfirmation(on)
22 registerReviewPane(on)
23 registerSetupPane(on)
24 registerUsageCapture(on)
25 registerRunBand(on)
26 registerAgentTypes(on)
27}
28mods/admission-review.tsx 509 lines1// The admission review pane (issue #103): admission's staffing answer
2// (question 4, `staffing_overrides`) and lens answer (question 5,
3// `lens_declaration`) as two tables with a control per row.
4//
5// The model calls the tool `mcp__saga__review_admission` with an issue number.
6// The tool runs `scripts/admission.py --dry-run --render json`, draws the pane
7// from that document alone (schema `admission_review.v1`), and waits for the
8// operator. Submit hands the answers to `admission.py --answers -` on standard
9// input, so the script validates and records them; the tool then returns them
10// to the model as its result. Closing the pane returns `dismissed`, and the plan
11// skill prints admission's fixed tables instead.
12//
13// The mod decides nothing. Every choice a Select offers comes from the palette
14// the script printed, never a list written here; the script is the one place an
15// answer is checked, and a refusal it prints is shown in the pane as printed.
16//
17// Waiting for a person: a hook's own time is capped at ten seconds per
18// dispatch, and the clock stops only while a `$` call is in flight. Awaiting a
19// bare Promise therefore fails the hook at ten seconds; the tool waits on
20// `$.process.run(['sleep', '1'])` instead, one second at a time, up to
21// WAIT_CAP_SECONDS (LEARNINGS.md, 2026-10-04).
22
23import { atom, read, update } from 'claude-code'
24import type { On, ProcessRunResult } from 'claude-code'
25import type {
26 SagaAdmissionAnswers,
27 SagaAdmissionPalette,
28 SagaAdmissionReviewData,
29 SagaAdmissionReviewOutcome,
30 SagaAdmissionSelections,
31 SagaAdmissionTier,
32} from '../types/index.d.ts'
33import { sagaScriptArgv } from './run-record.ts'
34
35/** The pane's id. */
36export const PANE = 'saga-admission'
37
38/** The tool's short name; the model calls it as `mcp__saga__review_admission`. */
39export const TOOL = 'review_admission'
40
41/** The review document version this module reads; `REVIEW_SCHEMA` in `scripts/admission.py`. */
42export const REVIEW_SCHEMA = 'admission_review.v1'
43
44/** The two questions the pane answers. With neither outstanding there is nothing to review. */
45export const REVIEWED_QUESTIONS = ['staffing_overrides', 'lens_declaration'] as const
46
47/** How long the tool waits for the operator before it returns `timed-out`: thirty minutes. */
48export const WAIT_CAP_SECONDS = 1800
49
50/** How long one admission run may take; it reads the issue with `gh`. */
51const ADMISSION_TIMEOUT_MS = 120_000
52
53/** The two decisions a conditional lens takes. */
54const INCLUDE_CHOICES = ['yes', 'no'] as const
55
56const review = atom({ plugin: 'saga', key: 'admissionReview' } as const, null)
57
58/**
59 * The outcome of the review in flight, by issue. A module value, not `$.state`:
60 * the waiting hook reads it between its sleeps, and a state read inside one
61 * dispatch sees one frozen moment. A hot reload drops it with the pane.
62 */
63const outcomes = new Map<number, SagaAdmissionReviewOutcome>()
64
65/** The issue whose review is open, or null. One review at a time. */
66let active: number | null = null
67
68// ---------------------------------------------------------------------------
69// Pure helpers: no `$`, so a test calls them directly
70// ---------------------------------------------------------------------------
71
72/** The argv that prints the review document for an issue without writing the record. */
73export function reviewArgv(pluginRoot: string, issue: number, repo: string | null): string[] {
74 return sagaScriptArgv(pluginRoot, 'admission.py', [
75 '--issue',
76 String(issue),
77 ...(repo ? ['--repo', repo] : []),
78 '--dry-run',
79 '--render',
80 'json',
81 ])
82}
83
84/** The argv that records the answers read from standard input. */
85export function answersArgv(pluginRoot: string, issue: number, repo: string | null): string[] {
86 return sagaScriptArgv(pluginRoot, 'admission.py', [
87 '--issue',
88 String(issue),
89 ...(repo ? ['--repo', repo] : []),
90 '--answers',
91 '-',
92 ])
93}
94
95type Ran = Pick<ProcessRunResult, 'exitCode' | 'stdout' | 'stderr' | 'isStdoutTruncated'>
96
97/** Turn what `admission.py --render json` did into the review document, or why there is none. */
98export function parseReview(
99 ran: Ran,
100): { ok: true; data: SagaAdmissionReviewData } | { ok: false; reason: string; exitCode?: number; stderr?: string } {
101 const stderr = ran.stderr.trim()
102 if (ran.exitCode !== 0) {
103 return { ok: false, reason: `admission.py exited ${ran.exitCode}`, exitCode: ran.exitCode, stderr }
104 }
105 if (ran.isStdoutTruncated) {
106 return { ok: false, reason: 'admission.py printed more than the engine keeps (4 MiB); the review was cut off' }
107 }
108 let parsed: unknown
109 try {
110 parsed = JSON.parse(ran.stdout)
111 } catch (err) {
112 return { ok: false, reason: `admission.py did not print JSON: ${String(err)}` }
113 }
114 if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
115 return { ok: false, reason: 'admission.py did not print a JSON object' }
116 }
117 const schema = (parsed as { schema?: unknown }).schema
118 if (schema !== REVIEW_SCHEMA) {
119 return { ok: false, reason: `review version ${JSON.stringify(schema)} is not ${REVIEW_SCHEMA}` }
120 }
121 return { ok: true, data: parsed as SagaAdmissionReviewData }
122}
123
124/** The efforts the palette allows for one model, in palette order. */
125export function effortsFor(palette: SagaAdmissionPalette, model: string): string[] {
126 return palette.pairs.filter((pair) => pair.model === model).map((pair) => pair.effort)
127}
128
129/** A tier the palette allows: the effort kept when the model takes it, else the model's ceiling. */
130export function clampTier(palette: SagaAdmissionPalette, model: string, effort: string | null): { model: string; effort: string } {
131 const allowed = effortsFor(palette, model)
132 if (effort !== null && allowed.includes(effort)) return { model, effort }
133 const ceiling = palette.effort_ceilings[model]
134 if (ceiling !== undefined && allowed.includes(ceiling)) return { model, effort: ceiling }
135 return { model, effort: allowed[allowed.length - 1] ?? '' }
136}
137
138function pickFrom(palette: SagaAdmissionPalette, tier: SagaAdmissionTier | null): { model: string; effort: string } {
139 if (tier && palette.models.includes(tier.model)) return clampTier(palette, tier.model, tier.effort)
140 const first = palette.pairs[0]
141 return first ? { model: first.model, effort: first.effort } : { model: '', effort: '' }
142}
143
144/**
145 * The picks the pane opens with: each role's
146 * proposed tier, and each conditional lens as declared; an undeclared lens is
147 * included when Jev pre-checked it (issue #110) and left out, with no reason
148 * yet, otherwise.
149 */
150export function initialSelections(data: SagaAdmissionReviewData): SagaAdmissionSelections {
151 const palette = data.palette
152 const staffing: SagaAdmissionSelections['staffing'] = {}
153 if (palette) {
154 for (const row of data.staffing.rows) staffing[row.role] = pickFrom(palette, row.proposed ?? row.default)
155 }
156 const lenses: SagaAdmissionSelections['lenses'] = {}
157 for (const row of data.lenses.rows) {
158 if (row.always_on) continue
159 if (row.include === 'yes' || row.include === 'no') {
160 lenses[row.lens] = { include: row.include, reason: row.reason }
161 } else {
162 lenses[row.lens] = { include: row.jev.band === 'pre-checked' ? 'yes' : 'no', reason: '' }
163 }
164 }
165 return { staffing, lenses }
166}
167
168/**
169 * The picks "Accept all" submits: every proposal that exists, applied over the
170 * operator's current picks. Each role takes its proposed tier, and each lens
171 * that is declared or that Jev pre-checked takes that answer; a lens with no
172 * proposal keeps the operator's own include choice and reason.
173 */
174export function acceptAllSelections(
175 data: SagaAdmissionReviewData,
176 current: SagaAdmissionSelections,
177): SagaAdmissionSelections {
178 const proposed = initialSelections(data)
179 const lenses: SagaAdmissionSelections['lenses'] = { ...proposed.lenses }
180 for (const row of data.lenses.rows) {
181 if (row.always_on) continue
182 const hasProposal = row.include === 'yes' || row.include === 'no' || row.jev.band === 'pre-checked'
183 const own = current.lenses[row.lens]
184 if (!hasProposal && own) lenses[row.lens] = own
185 }
186 return { staffing: proposed.staffing, lenses }
187}
188
189/** The conditional lenses left out with no reason yet, in table order. */
190export function unexplainedExclusions(selections: SagaAdmissionSelections): string[] {
191 return Object.entries(selections.lenses)
192 .filter(([, pick]) => pick.include === 'no' && pick.reason.trim() === '')
193 .map(([lens]) => lens)
194}
195
196/**
197 * The answers to record: the complete role map, so every role's source becomes
198 * the operator, and the lens declaration in the shape the review reads.
199 */
200export function buildAnswers(data: SagaAdmissionReviewData, selections: SagaAdmissionSelections): SagaAdmissionAnswers {
201 const vendor = data.palette?.vendor ?? ''
202 const staffing_overrides: SagaAdmissionAnswers['staffing_overrides'] = {}
203 for (const row of data.staffing.rows) {
204 const pick = selections.staffing[row.role]
205 if (!pick) continue
206 staffing_overrides[row.role] = {
207 vendor: row.vendor ?? row.proposed?.vendor ?? row.default?.vendor ?? vendor,
208 model: pick.model,
209 effort: pick.effort,
210 }
211 }
212 const conditional_applies: Record<string, string> = {}
213 const conditional_does_not_apply: Record<string, string> = {}
214 for (const row of data.lenses.rows) {
215 const pick = selections.lenses[row.lens]
216 if (row.always_on || !pick) continue
217 if (pick.include === 'yes') conditional_applies[row.lens] = pick.reason.trim()
218 else conditional_does_not_apply[row.lens] = pick.reason.trim()
219 }
220 return {
221 staffing_overrides,
222 lens_declaration: {
223 always_on: data.lenses.rows.filter((row) => row.always_on).map((row) => row.lens),
224 conditional_applies,
225 conditional_does_not_apply,
226 },
227 }
228}
229
230function tierText(tier: SagaAdmissionTier | null): string {
231 if (!tier) return 'not configured'
232 return `${tier.vendor ? `${tier.vendor} ` : ''}${tier.model}/${tier.effort ?? 'default'}`
233}
234
235function issueOf(input: unknown): number | null {
236 const issue = (input as { issue?: unknown }).issue
237 return typeof issue === 'number' && Number.isInteger(issue) && issue > 0 ? issue : null
238}
239
240function repoOf(input: unknown): string | null {
241 const repo = (input as { repo?: unknown }).repo
242 return typeof repo === 'string' && /^[\w.-]+\/[\w.-]+$/.test(repo) ? repo : null
243}
244
245// ---------------------------------------------------------------------------
246// The hooks
247// ---------------------------------------------------------------------------
248
249export function registerAdmissionReview(on: On): void {
250 on('session.start', async ($, e, next) => {
251 await $.tool.register({
252 name: TOOL,
253 description:
254 "Opens saga's admission review pane for an issue: the staffing table (admission question 4, " +
255 'staffing_overrides) and the lens table (question 5, lens_declaration), each row with its ' +
256 "own control. The operator's answers are recorded through scripts/admission.py, which " +
257 'validates them. Returns status submitted (with the answers recorded), dismissed, ' +
258 'not-placed, unavailable, nothing-to-review, timed-out or error; on anything but ' +
259 'submitted, print admission.py --render tables and collect the answers in the conversation.',
260 inputSchema: {
261 type: 'object',
262 properties: {
263 issue: { type: 'integer', minimum: 1, description: 'The issue number admission runs for.' },
264 repo: { type: 'string', description: 'owner/name; defaults to the origin remote.' },
265 },
266 required: ['issue'],
267 },
268 })
269 return next(e)
270 })
271
272 on('tool.call', { tool: 'mcp__saga__review_admission' }, async ($, e, next) => {
273 const issue = issueOf(e)
274 const repo = repoOf(e)
275 if (issue === null) {
276 return { result: { status: 'error', reason: 'issue must be a positive whole number' } }
277 }
278 if (active !== null) {
279 return { result: { status: 'error', reason: `the review pane is already open for issue ${active}` } }
280 }
281 const surfaces = await $.session.surfaces()
282 if (!surfaces.some((surface) => surface === 'terminal' || surface === 'desktop')) {
283 return {
284 result: { status: 'unavailable', reason: 'no terminal or desktop surface is attached to draw the pane' },
285 }
286 }
287
288 let ran: ProcessRunResult
289 try {
290 ran = await $.process.run(reviewArgv($.plugin.root, issue, repo), { timeoutMs: ADMISSION_TIMEOUT_MS })
291 } catch (err) {
292 return { result: { status: 'error', reason: `admission.py did not run: ${String(err)}` } }
293 }
294 const parsed = parseReview(ran)
295 if (!parsed.ok) {
296 const { ok: _ok, ...failure } = parsed
297 return { result: { status: 'error', ...failure } }
298 }
299 const data = parsed.data
300 if (!REVIEWED_QUESTIONS.some((question) => data.pending_questions.includes(question))) {
301 return { result: { status: 'nothing-to-review' } }
302 }
303 if (data.palette === null) {
304 return { result: { status: 'error', reason: 'admission could not load the tier palette' } }
305 }
306 if (data.staffing.rows.length === 0) {
307 return { result: { status: 'error', reason: 'admission could not reach the staffing component' } }
308 }
309
310 active = issue
311 outcomes.delete(issue)
312 await update($, review, () => ({ issue, repo, data, selections: initialSelections(data), error: null }))
313 let outcome: SagaAdmissionReviewOutcome | undefined
314 try {
315 const opened = await $.ui.open({ id: PANE, title: `Admission #${issue}: staffing and lenses`, focus: true })
316 if (!opened.isPlaced) {
317 return { result: { status: 'not-placed', reason: opened.reason } }
318 }
319 let waited = 0
320 while (!outcomes.has(issue) && !next.signal.aborted && waited < WAIT_CAP_SECONDS) {
321 await $.process.run(['sleep', '1'])
322 waited += 1
323 }
324 outcome = outcomes.get(issue)
325 return { result: outcome ?? { status: next.signal.aborted ? 'dismissed' : 'timed-out' } }
326 } catch (err) {
327 return { result: { status: 'error', reason: `the review pane failed: ${String(err)}` } }
328 } finally {
329 active = null
330 outcomes.delete(issue)
331 await $.ui.close({ id: PANE })
332 await update($, review, () => null)
333 }
334 })
335
336 on('ui.close', { id: PANE }, ($, e, next) => {
337 // Every close but the tool's own after an outcome: the operator's close mark or Escape.
338 if (active !== null && !outcomes.has(active)) outcomes.set(active, { status: 'dismissed' })
339 return next(e)
340 })
341
342 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
343 const state = await read($, review)
344 if (e.surface !== 'terminal' && e.surface !== 'desktop') {
345 const { Text } = $.ui.resolve(e)
346 return <Text>Answer the admission review in the terminal or the desktop app.</Text>
347 }
348 const { Box, Button, Input, Select, Text } = $.ui.resolve(e)
349 if (state === null || state.data.palette === null) {
350 return <Text dimColor>No admission review is open.</Text>
351 }
352 const { data, selections } = state
353 const palette = state.data.palette
354 const issue = state.issue
355
356 const pickModel = (role: string, model: string) =>
357 update($, review, (current) => {
358 if (!current || !palette.models.includes(model)) return current
359 const effort = current.selections.staffing[role]?.effort ?? null
360 const staffing = { ...current.selections.staffing, [role]: clampTier(palette, model, effort) }
361 return { ...current, selections: { ...current.selections, staffing }, error: null }
362 })
363 const pickEffort = (role: string, effort: string) =>
364 update($, review, (current) => {
365 const pick = current?.selections.staffing[role]
366 if (!current || !pick || !effortsFor(palette, pick.model).includes(effort)) return current
367 const staffing = { ...current.selections.staffing, [role]: { model: pick.model, effort } }
368 return { ...current, selections: { ...current.selections, staffing }, error: null }
369 })
370 const setLens = (lens: string, change: { include?: string; reason?: string }) =>
371 update($, review, (current) => {
372 const pick = current?.selections.lenses[lens]
373 if (!current || !pick) return current
374 const include = change.include ?? pick.include
375 if (include !== 'yes' && include !== 'no') return current
376 const lenses = { ...current.selections.lenses, [lens]: { include, reason: change.reason ?? pick.reason } }
377 return { ...current, selections: { ...current.selections, lenses }, error: null }
378 })
379 const setError = (error: string) => update($, review, (current) => current && { ...current, error })
380
381 const submit = async (acceptAll: boolean) => {
382 const current = await read($, review)
383 if (!current || current.issue !== issue) return
384 const picks = acceptAll ? acceptAllSelections(current.data, current.selections) : current.selections
385 const missing = unexplainedExclusions(picks)
386 if (missing.length > 0) {
387 await setError(`Give a reason for each lens left out: ${missing.join(', ')}`)
388 return
389 }
390 if (acceptAll) await update($, review, (now) => now && { ...now, selections: picks })
391 const answers = buildAnswers(current.data, picks)
392 let ran: ProcessRunResult
393 try {
394 ran = await $.process.run(answersArgv($.plugin.root, issue, current.repo), {
395 stdin: JSON.stringify(answers),
396 timeoutMs: ADMISSION_TIMEOUT_MS,
397 })
398 } catch (err) {
399 await setError(`admission.py did not run: ${String(err)}`)
400 return
401 }
402 if (ran.exitCode !== 0) {
403 await setError(ran.stderr.trim() || `admission.py exited ${ran.exitCode}`)
404 return
405 }
406 outcomes.set(issue, { status: 'submitted', issue, answers, source: 'operator', summary: ran.stdout.trim() })
407 await $.ui.close({ id: PANE })
408 }
409 const dismiss = async () => {
410 outcomes.set(issue, { status: 'dismissed' })
411 await $.ui.close({ id: PANE })
412 }
413
414 const modelOptions = palette.models.map((value) => ({ value }))
415 const includeOptions = INCLUDE_CHOICES.map((value) => ({ value }))
416
417 return (
418 <Box flexDirection="column" width={e.props.bodyColumns}>
419 <Text bold>Staffing (answer: staffing_overrides)</Text>
420 {data.staffing.rows.map((row) => {
421 const pick = selections.staffing[row.role] ?? { model: '', effort: '' }
422 return (
423 <Box flexDirection="column" marginTop={1}>
424 <Text>
425 <Text bold>{row.role}</Text> default {tierText(row.default)}, proposed {tierText(row.proposed)}
426 </Text>
427 <Box key={`staff:${row.role}:jev`}>
428 <Text>Jev: {row.jev.cell}</Text>
429 </Box>
430 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
431 <Select
432 key={`staff:${row.role}:model`}
433 label="model "
434 options={modelOptions}
435 value={pick.model}
436 onSelect={(value) => pickModel(row.role, value)}
437 />
438 <Select
439 key={`staff:${row.role}:effort`}
440 label="effort "
441 options={effortsFor(palette, pick.model).map((value) => ({ value }))}
442 value={pick.effort}
443 onSelect={(value) => pickEffort(row.role, value)}
444 />
445 </Box>
446 <Text dimColor wrap="wrap">
447 {row.why}
448 </Text>
449 </Box>
450 )
451 })}
452 <Box marginTop={1}>
453 <Text bold>Lenses (answer: lens_declaration)</Text>
454 </Box>
455 {data.lenses.rows.map((row) => {
456 if (row.always_on) {
457 return (
458 <Box flexDirection="row" columnGap={2}>
459 <Text bold>{row.lens}</Text>
460 <Box key={`lens:${row.lens}:always-on`}>
461 <Text>always on</Text>
462 </Box>
463 </Box>
464 )
465 }
466 const pick = selections.lenses[row.lens] ?? { include: 'no', reason: '' }
467 return (
468 <Box flexDirection="column" marginTop={1}>
469 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
470 <Text bold>{row.lens}</Text>
471 <Box key={`lens:${row.lens}:jev`}>
472 <Text>Jev: {row.jev.cell}</Text>
473 </Box>
474 <Select
475 key={`lens:${row.lens}:include`}
476 label="include "
477 options={includeOptions}
478 value={pick.include}
479 onSelect={(value) => setLens(row.lens, { include: value })}
480 />
481 </Box>
482 <Input
483 key={`lens:${row.lens}:reason`}
484 label="reason "
485 placeholder={pick.include === 'no' ? 'why it does not apply (required)' : 'why it applies'}
486 value={pick.reason}
487 onInput={(value) => setLens(row.lens, { reason: value })}
488 onSubmit={(value) => setLens(row.lens, { reason: value })}
489 />
490 </Box>
491 )
492 })}
493 {state.error !== null && (
494 <Box key="error" marginTop={1}>
495 <Text color="red" wrap="wrap">
496 {state.error}
497 </Text>
498 </Box>
499 )}
500 <Box flexDirection="row" columnGap={2} marginTop={1}>
501 <Button key="accept-all" label="Accept all" hotkey="a" onPress={() => submit(true)} />
502 <Button key="submit" label="Submit" hotkey="s" variant="primary" onPress={() => submit(false)} />
503 <Button key="dismiss" label="Dismiss" hotkey="d" role="dismiss" onPress={dismiss} />
504 </Box>
505 </Box>
506 )
507 })
508}
509mods/agent-types.ts 203 lines1// Saga's roles as Claude Code agent types that carry a real model and effort (issue #106).
2//
3// The Agent tool takes a model per call but no effort, so a plain subagent can
4// only be asked to try harder in its prompt (the effort rider, a labeled proxy;
5// `plugins/fleet-core/references/staffing.md`). An agent type registered here
6// carries both. For each role of this checkout's active saga run the mod
7// registers `saga:<role>` (`saga:worker`, `saga:planner`, ...) with the role's
8// prompt and the model and effort the run is staffed at.
9//
10// What to register is the script's answer, never the mod's:
11// `scripts/role_agent_types.py --json` decides whether a run is active, reads
12// each role's tier (the run record's staffing, completed by the staffing
13// resolver) and reads each prompt from the roles library file at that moment.
14// The mod registers what it printed, when it printed something new.
15//
16// With no active run the script answers no types and nothing is registered, so
17// a session with no saga run sees no new agent type. A type cannot be
18// unregistered, so once a run closes the mod hides its types from the model.
19//
20// A subagent of a registered type is checked on every request it makes: when
21// the model or effort actually sent is not the type's resolved tier, the mod
22// shows a toast and writes a `tiering-drift[claude-agent-type]` line into the
23// transcript (`$.ui.log`, drawn as a notice the model never reads). It never
24// rewrites the request: the mod reports, and policy stays in the scripts.
25
26import type { EngineInterface, On, ProcessRunResult } from 'claude-code'
27import type { SagaAgentTypesState, SagaRegisteredAgentType, SagaRoleAgentTypes } from '../types/index.d.ts'
28import { sagaScriptArgv } from './run-record.ts'
29
30/** The spawn kind `fleet_commons.effort_rider` names this route by; it leads every drift line. */
31export const SPAWN_KIND = 'claude-agent-type'
32
33/** How long the script may take before the mod gives up on this refresh. */
34const REFRESH_TIMEOUT_MS = 30_000
35
36/** How long a drift toast stays up. */
37const DRIFT_TOAST_MS = 8_000
38
39const AGENT_TYPES = { plugin: 'saga', key: 'agentTypes' } as const
40
41/** The argv that prints this checkout's role agent types. */
42export function roleAgentTypesArgv(pluginRoot: string, cwd: string): string[] {
43 return sagaScriptArgv(pluginRoot, 'role_agent_types.py', ['--cwd', cwd, '--json'])
44}
45
46export type RoleAgentTypesRead = { ok: true; answer: SagaRoleAgentTypes } | { ok: false; detail: string }
47
48/** Turn what `role_agent_types.py` did into its answer, or the reason there is none. */
49export function parseRoleAgentTypes(ran: Pick<ProcessRunResult, 'exitCode' | 'stdout' | 'stderr'>): RoleAgentTypesRead {
50 if (ran.exitCode !== 0) {
51 return { ok: false, detail: `role_agent_types exited ${ran.exitCode}: ${ran.stderr.trim()}` }
52 }
53 let parsed: unknown
54 try {
55 parsed = JSON.parse(ran.stdout)
56 } catch (err) {
57 return { ok: false, detail: `role_agent_types printed no JSON: ${String(err)}` }
58 }
59 const answer = parsed as Partial<SagaRoleAgentTypes> | null
60 if (!answer || answer.schema !== 'saga_role_agent_types.v1' || !Array.isArray(answer.types)) {
61 return { ok: false, detail: 'role_agent_types printed an answer this mod does not know' }
62 }
63 if (answer.error) return { ok: false, detail: answer.error }
64 return { ok: true, answer: answer as SagaRoleAgentTypes }
65}
66
67/**
68 * Whether a request's model and effort are a registered type's resolved tier.
69 * A request names a resolved model id (`claude-opus-5-5`) and a type an alias
70 * (`opus`), so the model matches when the id carries the alias. A request that
71 * sends no effort does not match.
72 */
73export function matchesTier(resolved: SagaRegisteredAgentType, model: string, effort: string | number | undefined): boolean {
74 return model.toLowerCase().includes(resolved.model.toLowerCase()) && effort === resolved.effort
75}
76
77/** The drift line: the same `tiering-drift[<spawn kind>]` name `effort_rider.reconcile_effort` uses. */
78export function driftLine(
79 type: string,
80 resolved: SagaRegisteredAgentType,
81 model: string,
82 effort: string | number | undefined,
83 agentId: string,
84): string {
85 const sent = `${model}/${effort === undefined ? 'no effort sent' : String(effort)}`
86 return `tiering-drift[${SPAWN_KIND}]: ${type} resolved ${resolved.model}/${resolved.effort}, request sent ${sent} (agent ${agentId})`
87}
88
89/**
90 * What this load of the module knows. A reload starts it afresh, and also
91 * unloads the plugin's types while the session's state keeps the last answer,
92 * so `registered` (not the state) says whether the types are in place.
93 */
94const loaded = {
95 /** The answer this load registered, as `<active>:<fingerprint>`. */
96 registered: null as string | null,
97 /** Every type this load registered; one the current answer no longer names is hidden. */
98 everRegistered: new Set<string>(),
99 /** A listed subagent's registered type, looked up once; null for one of no saga type. */
100 typeOfAgent: new Map<string, string | null>(),
101 /** Agents already reported, so a drifted subagent raises one toast, not one per request. */
102 reported: new Set<string>(),
103}
104
105/** Register what the script answers for `cwd`, when it answers something this load has not. */
106async function refresh($: EngineInterface, cwd: string): Promise<void> {
107 let read: RoleAgentTypesRead
108 try {
109 read = parseRoleAgentTypes(
110 await $.process.run(roleAgentTypesArgv($.plugin.root, cwd), { timeoutMs: REFRESH_TIMEOUT_MS }),
111 )
112 } catch (err) {
113 read = { ok: false, detail: `role_agent_types did not run: ${err instanceof Error ? err.message : String(err)}` }
114 }
115 if (!read.ok) {
116 $.ui.log(`saga agent types: none registered; ${read.detail}`)
117 return
118 }
119 const { answer } = read
120 const answerKey = `${answer.active}:${answer.fingerprint}`
121 if (loaded.registered === answerKey) return
122
123 // A closed run keeps its types' tiers, so a subagent still running is still checked.
124 const { value: held } = await $.state.get(AGENT_TYPES)
125 const byType: Record<string, SagaRegisteredAgentType> = answer.active ? {} : { ...held?.byType }
126 for (const t of answer.active ? answer.types : []) {
127 try {
128 const { agent } = await $.agent.register({
129 name: t.role,
130 description: t.description,
131 prompt: t.prompt,
132 model: t.model,
133 effort: t.effort,
134 })
135 byType[agent] = { role: t.role, model: t.model, effort: t.effort }
136 loaded.everRegistered.add(agent)
137 } catch (err) {
138 $.ui.log(`saga agent types: saga:${t.role} not registered; ${err instanceof Error ? err.message : String(err)}`)
139 }
140 }
141 const state: SagaAgentTypesState = {
142 active: answer.active,
143 issue: answer.issue,
144 fingerprint: answer.fingerprint,
145 byType,
146 }
147 await $.state.set(AGENT_TYPES, state)
148 loaded.registered = answerKey
149}
150
151export function registerAgentTypes(on: On): void {
152 // A matcher that takes every directory: the engine refuses a second matcherless
153 // `session.start` in one plugin, and the admission review mod holds that slot.
154 on('session.start', { cwd: /^/ }, async ($, e, next) => {
155 await refresh($, e.cwd)
156 return next(e)
157 })
158
159 // The run record's staffing changes between turns (admission, an operator's
160 // answer); a registered type takes effect from the next turn on, so the end
161 // of each main-loop turn is the earliest a change can matter.
162 on('turn.complete', async ($, e, next) => {
163 const result = await next(e)
164 if (!e.agentId) await refresh($, await $.session.cwd())
165 return result
166 })
167
168 // A type cannot be unregistered, so one the current answer does not name (the
169 // run closed, or the role is no longer staffed on this vendor) is kept from the model.
170 on('agent.offer', async ($, e, next) => {
171 if (!loaded.everRegistered.has(e.agent)) return next(e)
172 const { value: held } = await $.state.get(AGENT_TYPES)
173 if (held?.active && held.byType[e.agent]) return next(e)
174 return { isOffered: false }
175 })
176
177 on('turn.step', async function* ($, e, next) {
178 if (e.agentId && !loaded.reported.has(e.agentId)) {
179 const { value: held } = await $.state.get(AGENT_TYPES)
180 if (held && Object.keys(held.byType).length > 0) {
181 let type = loaded.typeOfAgent.get(e.agentId)
182 if (type === undefined) {
183 const agents = await $.agent.list()
184 const found = agents.find((a) => a.id === e.agentId)?.type
185 type = found !== undefined && held.byType[found] ? found : null
186 // An agent not listed yet is looked up again on its next request.
187 if (found !== undefined) loaded.typeOfAgent.set(e.agentId, type)
188 }
189 const resolved = type ? held.byType[type] : undefined
190 if (type && resolved && !matchesTier(resolved, e.model, e.effort)) {
191 loaded.reported.add(e.agentId)
192 const line = driftLine(type, resolved, e.model, e.effort, e.agentId)
193 $.ui.toast(line, { timeoutMs: DRIFT_TOAST_MS })
194 // A transcript line drawn as a notice: the model never reads it, and a
195 // headless host receives it as `ui_log`.
196 $.ui.log(line)
197 }
198 }
199 }
200 return yield* next(e)
201 })
202}
203mods/merge-confirmation.tsx 463 lines1// The merge-confirmation pane (issue #165): the grades, the blocking items
2// left at an early stop or the round limit, disputed declarations,
3// consequence disagreements, the fix-later list, degraded inputs and the cost.
4//
5// The model calls the tool `mcp__saga__review_merge` with an issue number and
6// the repository. The tool reads the `review_state.v1` document through
7// `scripts/run_status.py review` and draws the pane from that document alone.
8// Submit hands the answers to `review_state.py answers` on standard input, so
9// the script validates and records them; the tool then returns them to the
10// model as its result. Closing the pane returns `dismissed`, and the work
11// skill prints the script's numbered questions instead. A thirty-minute wait
12// submits `{"answers": {}, "pane_timeout": true}` to the script — the
13// unattended rules file only security-guard items — and returns `timed-out`,
14// which the skill never re-asks.
15//
16// The mod decides nothing. Every choice a Select offers is one of the script's
17// fixed answers; the script is the one place an answer is checked, and a
18// refusal it prints is shown in the pane as printed. The pane itself files and
19// merges nothing: every durable effect is the script's.
20//
21// Waiting for a person: a hook's own time is capped at ten seconds per
22// dispatch, and the clock stops only while a `$` call is in flight. The tool
23// waits on `$.process.run(['sleep', '1'])` instead, one second at a time, up
24// to WAIT_CAP_SECONDS (LEARNINGS.md, 2026-10-04).
25
26import { atom, read, update } from 'claude-code'
27import type { EngineInterface, On } from 'claude-code'
28import type {
29 SagaMergeAnswers,
30 SagaMergeReviewOutcome,
31 SagaMergeSelections,
32 SagaReviewState,
33} from '../types/index.d.ts'
34import { costLine, gradeLabel } from './review-findings.ts'
35import { readStateReviewWith, sagaScriptArgv } from './run-record.ts'
36
37/** The pane's id. */
38export const PANE = 'saga-merge'
39
40/** The tool's short name; the model calls it as `mcp__saga__review_merge`. */
41export const TOOL = 'review_merge'
42
43/** How long the tool waits for the operator before it submits the timeout: thirty minutes. */
44export const WAIT_CAP_SECONDS = 1800
45
46/** How long one answers run may take; it reads the issue and may file through mission-control. */
47const ANSWERS_TIMEOUT_MS = 120_000
48
49/** The blocking question's choice key in `pending_choices`. */
50export const MERGE_CHOICE = 'merge-blocking'
51
52/** The fix-later choice keys start with this prefix, then the finding identity. */
53export const FIX_LATER_PREFIX = 'fix-later:'
54
55/** The only choices a fix-later item takes; the script refuses anything else. */
56export const FIX_LATER_CHOICES = ['fix-now', 'file-as-issue', 'leave'] as const
57
58/** The only merge decisions; the script refuses anything else. */
59export const MERGE_DECISIONS = ['merge-with-reason', 'stop-card'] as const
60
61const merge = atom({ plugin: 'saga', key: 'mergeReview' } as const, null)
62
63/**
64 * The outcome of the confirmation in flight, by issue. A module value, not
65 * `$.state`: the waiting hook reads it between its sleeps, and a state read
66 * inside one dispatch sees one frozen moment. A hot reload drops it with the pane.
67 */
68const outcomes = new Map<number, SagaMergeReviewOutcome>()
69
70/** The issue whose confirmation is open, or null. One confirmation at a time. */
71let active: number | null = null
72
73// ---------------------------------------------------------------------------
74// Pure helpers: no `$`, so a test calls them directly
75// ---------------------------------------------------------------------------
76
77/**
78 * The argv that records the answers read from standard input. The repository
79 * always rides along: the script refuses answers without both issue and
80 * repository.
81 */
82export function answersArgv(pluginRoot: string, issue: number, repo: string): string[] {
83 return sagaScriptArgv(pluginRoot, 'review_state.py', [
84 'answers',
85 '--issue',
86 String(issue),
87 '--repo',
88 repo,
89 '--answers',
90 '-',
91 ])
92}
93
94/** Whether the document still has the blocking question pending. */
95export function mergeQuestionPending(data: SagaReviewState): boolean {
96 return data.pending_choices.includes(MERGE_CHOICE)
97}
98
99/** The fix-later finding identities with pending choices, in document order. */
100export function fixLaterIds(data: SagaReviewState): string[] {
101 return data.pending_choices
102 .filter((key) => key.startsWith(FIX_LATER_PREFIX))
103 .map((key) => key.slice(FIX_LATER_PREFIX.length))
104}
105
106/** A recorded outcome back to the choice that made it, or '' when unanswered. */
107function choiceForOutcome(outcome: Record<string, unknown> | null): string {
108 switch (outcome?.outcome) {
109 case 'fixed-now':
110 return 'fix-now'
111 case 'filed':
112 return 'file-as-issue'
113 case 'left':
114 return 'leave'
115 default:
116 return ''
117 }
118}
119
120/**
121 * The picks the pane opens with: each pending fix-later item's recorded
122 * choice, or unanswered; re-sending a recorded choice is idempotent and never
123 * refiles. The merge decision always opens unanswered: only the operator can
124 * merge with a reason or stop the card.
125 */
126export function initialSelections(data: SagaReviewState): SagaMergeSelections {
127 const byId = new Map(data.findings.map((finding) => [finding.id, finding]))
128 const fix_later: SagaMergeSelections['fix_later'] = {}
129 for (const id of fixLaterIds(data)) {
130 fix_later[id] = choiceForOutcome(byId.get(id)?.merge_outcome ?? null)
131 }
132 return { fix_later, merge_blocking: { decision: '', reason: '' } }
133}
134
135/**
136 * The answers to record: only the answered keys. A fix-later pick of '' is
137 * unanswered and left out; the merge answer rides only when the operator
138 * chose a decision. `pane_timeout` is always false here; the wait, not the
139 * pane, sends the timeout.
140 */
141export function buildAnswers(selections: SagaMergeSelections): SagaMergeAnswers {
142 const answers: SagaMergeAnswers['answers'] = {}
143 for (const [id, choice] of Object.entries(selections.fix_later)) {
144 if (choice !== '') answers[`${FIX_LATER_PREFIX}${id}`] = choice
145 }
146 const { decision, reason } = selections.merge_blocking
147 if (decision === 'merge-with-reason') answers[MERGE_CHOICE] = { decision, reason: reason.trim() }
148 else if (decision === 'stop-card') answers[MERGE_CHOICE] = { decision }
149 return { answers, pane_timeout: false }
150}
151
152function issueOf(input: unknown): number | null {
153 const issue = (input as { issue?: unknown }).issue
154 return typeof issue === 'number' && Number.isInteger(issue) && issue > 0 ? issue : null
155}
156
157function repoOf(input: unknown): string | null {
158 const repo = (input as { repo?: unknown }).repo
159 return typeof repo === 'string' && /^[\w.-]+\/[\w.-]+$/.test(repo) ? repo : null
160}
161
162// ---------------------------------------------------------------------------
163// The hooks
164// ---------------------------------------------------------------------------
165
166export function registerMergeConfirmation(on: On): void {
167 // The matcher is load-bearing: the engine refuses a second matcher-less
168 // `session.start` registration, and admission's holds that one slot.
169 on('session.start', { cwd: /^/ }, async ($, e, next) => {
170 await $.tool.register({
171 name: TOOL,
172 description:
173 "Opens saga's merge-confirmation pane for an issue: the lens grades, blocking items left at an " +
174 'early stop or the round limit, disputed declarations, consequence disagreements, the fix-later ' +
175 "list with three choices per item, degraded inputs and the cost. The operator's answers are recorded " +
176 'through scripts/review_state.py, which validates them. Returns status submitted (with the answers ' +
177 'recorded), dismissed, not-placed, unavailable, nothing-to-review, timed-out or error; on timed-out ' +
178 "the unattended rules already ran and the merge follows the run's merge setting, so do not re-ask; " +
179 'on anything but submitted or timed-out, run review_state.py render --render markdown and collect ' +
180 'the answers in the conversation.',
181 inputSchema: {
182 type: 'object',
183 properties: {
184 issue: { type: 'integer', minimum: 1, description: 'The issue number to confirm the merge for.' },
185 repo: { type: 'string', description: "owner/name; must match the review document's repository, else the call is refused." },
186 },
187 required: ['issue'],
188 },
189 })
190 return next(e)
191 })
192
193 on('tool.call', { tool: 'mcp__saga__review_merge' }, async ($, e, next) => {
194 const issue = issueOf(e)
195 if (issue === null) {
196 return { result: { status: 'error', reason: 'issue must be a positive whole number' } }
197 }
198 if (active !== null) {
199 return { result: { status: 'error', reason: `a merge confirmation is already open for issue ${active}` } }
200 }
201 const surfaces = await $.session.surfaces()
202 if (!surfaces.some((surface) => surface === 'terminal' || surface === 'desktop')) {
203 return {
204 result: { status: 'unavailable', reason: 'no terminal or desktop surface is attached to draw the pane' },
205 }
206 }
207
208 const cwd = await $.session.cwd()
209 const read = await readStateReviewWith((argv) => $.process.run(argv), $.plugin.root, { repoRoot: cwd, issue })
210 if (!read.ok) {
211 return { result: { status: 'error', reason: `run_status review failed (${read.reason}): ${read.detail}` } }
212 }
213 const data = read.review.state
214 if (data.pending_choices.length === 0) {
215 return { result: { status: 'nothing-to-review' } }
216 }
217 // The document owns the repository: `answers` reads the intent envelope from
218 // `gh issue view --repo`, so a model-supplied repo would select the policy
219 // behind the operator's back. A present input that differs is refused.
220 const inputRepo = (e as { repo?: unknown }).repo
221 const documentRepo = repoOf({ repo: data.repo })
222 if (inputRepo !== undefined && inputRepo !== documentRepo) {
223 return {
224 result: {
225 status: 'error',
226 reason: `the tool repo ${JSON.stringify(inputRepo)} does not match the review document's repository ${JSON.stringify(data.repo)}`,
227 },
228 }
229 }
230 const repo = documentRepo
231 if (repo === null) {
232 return { result: { status: 'error', reason: 'could not determine the owner/name repository for the answers call' } }
233 }
234
235 active = issue
236 outcomes.delete(issue)
237 await update($, merge, () => ({ issue, repo, data, selections: initialSelections(data), error: null }))
238 let outcome: SagaMergeReviewOutcome | undefined
239 try {
240 const opened = await $.ui.open({ id: PANE, title: `Merge confirmation · #${issue}`, focus: true })
241 if (!opened.isPlaced) {
242 return { result: { status: 'not-placed', reason: opened.reason } }
243 }
244 let waited = 0
245 while (!outcomes.has(issue) && !next.signal.aborted && waited < WAIT_CAP_SECONDS) {
246 await $.process.run(['sleep', '1'])
247 waited += 1
248 }
249 outcome = outcomes.get(issue)
250 if (outcome === undefined && !next.signal.aborted) {
251 outcome = await submitTimeout($, issue, repo)
252 }
253 return { result: outcome ?? { status: next.signal.aborted ? 'dismissed' : 'timed-out' } }
254 } catch (err) {
255 return { result: { status: 'error', reason: `the merge confirmation failed: ${String(err)}` } }
256 } finally {
257 active = null
258 outcomes.delete(issue)
259 await $.ui.close({ id: PANE })
260 await update($, merge, () => null)
261 }
262 })
263
264 on('ui.close', { id: PANE }, ($, e, next) => {
265 // Every close but the tool's own after an outcome: the operator's close mark or Escape.
266 if (active !== null && !outcomes.has(active)) outcomes.set(active, { status: 'dismissed' })
267 return next(e)
268 })
269
270 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
271 const state = await read($, merge)
272 if (e.surface !== 'terminal' && e.surface !== 'desktop') {
273 const { Text } = $.ui.resolve(e)
274 return <Text>Answer the merge confirmation in the terminal or the desktop app.</Text>
275 }
276 const { Box, Button, Input, Select, Text } = $.ui.resolve(e)
277 if (state === null) {
278 return <Text dimColor>No merge confirmation is open.</Text>
279 }
280 const { data, selections } = state
281 const issue = state.issue
282 const repo = state.repo
283
284 const setFixLater = (id: string, choice: string) =>
285 update($, merge, (current) => {
286 if (!current || !(FIX_LATER_CHOICES as readonly string[]).includes(choice)) return current
287 return { ...current, selections: { ...current.selections, fix_later: { ...current.selections.fix_later, [id]: choice } }, error: null }
288 })
289 const setDecision = (decision: string) =>
290 update($, merge, (current) => {
291 if (!current || !(MERGE_DECISIONS as readonly string[]).includes(decision)) return current
292 const blocking = { ...current.selections.merge_blocking, decision }
293 return { ...current, selections: { ...current.selections, merge_blocking: blocking }, error: null }
294 })
295 const setReason = (reason: string) =>
296 update($, merge, (current) => {
297 if (!current) return current
298 const blocking = { ...current.selections.merge_blocking, reason }
299 return { ...current, selections: { ...current.selections, merge_blocking: blocking }, error: null }
300 })
301 const setError = (error: string) => update($, merge, (current) => current && { ...current, error })
302
303 const submit = async () => {
304 const current = await read($, merge)
305 if (!current || current.issue !== issue) return
306 const picks = current.selections
307 if (picks.merge_blocking.decision === 'merge-with-reason' && picks.merge_blocking.reason.trim() === '') {
308 await setError('Give a reason for the merge, or stop the card instead.')
309 return
310 }
311 const answers = buildAnswers(picks)
312 let ran
313 try {
314 ran = await $.process.run(answersArgv($.plugin.root, issue, repo ?? ''), {
315 stdin: JSON.stringify(answers),
316 timeoutMs: ANSWERS_TIMEOUT_MS,
317 })
318 } catch (err) {
319 await setError(`review_state.py did not run: ${String(err)}`)
320 return
321 }
322 if (ran.exitCode !== 0) {
323 await setError(ran.stderr.trim() || `review_state.py exited ${ran.exitCode}`)
324 return
325 }
326 outcomes.set(issue, { status: 'submitted', issue, answers, source: 'operator', summary: ran.stdout.trim() })
327 await $.ui.close({ id: PANE })
328 }
329 const dismiss = async () => {
330 outcomes.set(issue, { status: 'dismissed' })
331 await $.ui.close({ id: PANE })
332 }
333
334 const byId = new Map(data.findings.map((finding) => [finding.id, finding]))
335 const disagreements = data.consequence_disagreements
336 return (
337 <Box flexDirection="column" width={e.props.bodyColumns}>
338 <Text bold>Grades</Text>
339 {data.lenses.map((lens) => (
340 <Box key={`grade:${lens.lens}`}>
341 <Text>{gradeLabel(lens)}</Text>
342 </Box>
343 ))}
344 {mergeQuestionPending(data) && (
345 <Box flexDirection="column" marginTop={1}>
346 <Text bold>Blocking items (answer: merge-blocking)</Text>
347 {data.merge_blocking.map((id) => (
348 <Box key={`blocking:${id}`}>
349 <Text>{` ${id} — ${byId.get(id)?.statement ?? '(no statement recorded)'}`}</Text>
350 </Box>
351 ))}
352 <Select
353 key="merge:decision"
354 label="Decision"
355 value={selections.merge_blocking.decision}
356 options={MERGE_DECISIONS.map((value) => ({ value }))}
357 onSelect={(value: string) => setDecision(value)}
358 />
359 <Input
360 key="merge:reason"
361 label="reason "
362 placeholder="why the merge is safe (required)"
363 value={selections.merge_blocking.reason}
364 onInput={setReason}
365 onSubmit={setReason}
366 />
367 </Box>
368 )}
369 {data.disputes.length > 0 && (
370 <Box flexDirection="column" marginTop={1}>
371 <Text bold>Disputed declarations</Text>
372 {data.disputes.map((id) => (
373 <Box key={`dispute:${id}`}>
374 <Text>{` ${id}`}</Text>
375 </Box>
376 ))}
377 </Box>
378 )}
379 {disagreements.length > 0 && (
380 <Box flexDirection="column" marginTop={1}>
381 <Text bold>Consequence disagreements</Text>
382 {disagreements.map((row) => (
383 <Box key={`disagreement:${row.id}`}>
384 <Text>{` ${row.id}: model ${row.llm}, classifier ${row.jev}`}</Text>
385 </Box>
386 ))}
387 </Box>
388 )}
389 {data.unconfirmed.length > 0 && (
390 <Box flexDirection="column" marginTop={1}>
391 <Text bold>Unconfirmed consequences</Text>
392 {data.unconfirmed.map((id) => (
393 <Box key={`unconfirmed:${id}`}>
394 <Text>{` ${id}`}</Text>
395 </Box>
396 ))}
397 </Box>
398 )}
399 <Box flexDirection="column" marginTop={1}>
400 <Text bold>Fix later</Text>
401 {fixLaterIds(data).map((id) => (
402 <Box key={`fix:${id}`} flexDirection="column">
403 <Text>{`${id} — ${byId.get(id)?.statement ?? '(no statement recorded)'}`}</Text>
404 <Select
405 key={`fix:${id}:choice`}
406 label="Choice"
407 value={selections.fix_later[id] ?? ''}
408 options={FIX_LATER_CHOICES.map((value) => ({ value }))}
409 onSelect={(value: string) => setFixLater(id, value)}
410 />
411 </Box>
412 ))}
413 </Box>
414 {data.degraded_inputs.length > 0 && (
415 <Box flexDirection="column" marginTop={1}>
416 <Text bold>Degraded inputs</Text>
417 {data.degraded_inputs.map((input, i) => (
418 <Box key={`degraded:${i}`}>
419 <Text>{` ${JSON.stringify(input)}`}</Text>
420 </Box>
421 ))}
422 </Box>
423 )}
424 <Box marginTop={1}>
425 <Text dimColor>{costLine(data.cost)}</Text>
426 </Box>
427 {state.error !== null && (
428 <Box key="error" marginTop={1}>
429 <Text color="red" wrap="wrap">
430 {state.error}
431 </Text>
432 </Box>
433 )}
434 <Box key="actions" flexDirection="row" columnGap={2} marginTop={1}>
435 <Button key="submit" label="Submit" hotkey="s" variant="primary" onPress={() => submit()} />
436 <Button key="dismiss" label="Dismiss" hotkey="d" role="dismiss" onPress={() => dismiss()} />
437 </Box>
438 </Box>
439 )
440 })
441}
442
443/**
444 * Submit the timeout the wait expired on: empty answers with the sentinel, so
445 * the script applies the unattended rules itself. A failed submit returns an
446 * error the skill falls back from, never a timed-out the script never saw.
447 */
448async function submitTimeout($: EngineInterface, issue: number, repo: string): Promise<SagaMergeReviewOutcome> {
449 let ran
450 try {
451 ran = await $.process.run(answersArgv($.plugin.root, issue, repo), {
452 stdin: JSON.stringify({ answers: {}, pane_timeout: true }),
453 timeoutMs: ANSWERS_TIMEOUT_MS,
454 })
455 } catch (err) {
456 return { status: 'error', reason: `the timeout submit did not run: ${String(err)}` }
457 }
458 if (ran.exitCode !== 0) {
459 return { status: 'error', reason: ran.stderr.trim() || `the timeout submit exited ${ran.exitCode}` }
460 }
461 return { status: 'timed-out' }
462}
463mods/plan-viewer.tsx 328 lines1// The plan viewer pane (issue #104): `/plan-view` reads a saga plan one
2// section at a time inside Claude Code.
3//
4// Read-only. The plan file is the one thing it reads besides the run view
5// `scripts/run_status.py summary` prints; the only thing it writes is the
6// operator's own prompt, when they press a file reference or "Discuss this".
7// The plain fallback on every other harness is the plan file itself, whose path
8// `run_status.py summary` prints.
9//
10// - `/plan-view` opens the run's plan; `/plan-view #N` issue N's;
11// `/plan-view <path>` any Markdown file, relative to the session's directory.
12// - The pane lists the sections by heading; choosing one draws it as Markdown,
13// paged under the engine's 10,000-character limit.
14// - An Edit or Write of the plan file reloads it, and so does a change of its
15// modification time, polled every three seconds while the pane is open.
16// - When `/plan` saves a plan tick, the pane opens unasked. The engine places
17// an unasked pane only from 144 columns; below that it is closed again and a
18// toast names the command instead.
19// - The run status band's Plan button (issue #105) opens the run's plan here:
20// this module answers the press on `band-plan-<issue>` itself, because a
21// plugin's own `$.command.run` never reaches its own command hook.
22
23import { atom, read, update } from 'claude-code'
24import type { EngineInterface, On } from 'claude-code'
25
26import type { SagaPlanView } from '../types/index.d.ts'
27import {
28 discussPromptText,
29 fitTables,
30 linkifyRefs,
31 pageText,
32 planSavePath,
33 refFromHref,
34 refPromptText,
35 splitSections,
36} from './plan-sections.ts'
37import type { PlanRef } from './plan-sections.ts'
38import { readRunStatusWith } from './run-record.ts'
39
40/** The pane's id, and the `requestId` its `ui.render` hook matches. */
41export const PLAN_PANE = 'saga-plan'
42
43/** The run status band's Plan button, `band-plan-<issue>` (issue #105). */
44export const BAND_PLAN_ELEMENT = /^band-plan-\d+$/
45
46/** How often an open pane checks the plan file's modification time. */
47export const PLAN_POLL_MS = 3_000
48
49/** The most reference Buttons drawn under a page; the links in the page are all pressable. */
50const MAX_REF_BUTTONS = 20
51
52const planView = atom({ plugin: 'saga', key: 'planView' } as const, null)
53const planSelected = atom({ plugin: 'saga', key: 'planSelected' } as const, -1)
54const planPage = atom({ plugin: 'saga', key: 'planPage' } as const, 0)
55
56type Loaded = { ok: true; view: SagaPlanView } | { ok: false; detail: string }
57
58/** `path` taken from `cwd` unless it is absolute. */
59export function absolutePath(cwd: string, path: string): string {
60 return path.startsWith('/') ? path : `${cwd.replace(/\/+$/, '')}/${path.replace(/^\.\//, '')}`
61}
62
63function errorText(err: unknown): string {
64 return err instanceof Error ? err.message : String(err)
65}
66
67/** Read and split the plan at `file`; a failure is a reason, never a throw. */
68async function loadPlan($: EngineInterface, path: string, file: string, repoRoot: string): Promise<Loaded> {
69 try {
70 const stat = await $.fs.stat(file, { resolve: true })
71 if (stat.kind !== 'file') return { ok: false, detail: `${path} is not a file` }
72 const absPath = stat.realPath ?? file
73 const text = await $.fs.read(absPath)
74 return { ok: true, view: { path, absPath, repoRoot, mtimeMs: stat.mtimeMs, sections: splitSections(text) } }
75 } catch (err) {
76 return { ok: false, detail: `could not read ${path}: ${errorText(err)}` }
77 }
78}
79
80/** Show `view` from its section list. */
81async function showPlan($: EngineInterface, view: SagaPlanView): Promise<void> {
82 await update($, planView, () => view)
83 await update($, planSelected, () => -1)
84 await update($, planPage, () => 0)
85}
86
87/** Read the shown plan again, keeping the reader on the section they were reading. */
88async function reloadPlan($: EngineInterface, view: SagaPlanView): Promise<void> {
89 const loaded = await loadPlan($, view.path, view.absPath, view.repoRoot)
90 // A file caught mid-write or deleted keeps its last good version on screen.
91 if (!loaded.ok) return
92 const selected = await read($, planSelected)
93 const was = view.sections[selected]
94 const sections = loaded.view.sections
95 let now = -1
96 if (was !== undefined) {
97 const same = sections.findIndex((one) => one.level === was.level && one.title === was.title)
98 now = same >= 0 ? same : Math.min(selected, sections.length - 1)
99 }
100 await update($, planView, () => loaded.view)
101 if (now !== selected) {
102 await update($, planSelected, () => now)
103 await update($, planPage, () => 0)
104 }
105}
106
107/** Put text in the operator's prompt, saying so when the prompt cannot take it. */
108async function fillPrompt($: EngineInterface, text: string): Promise<void> {
109 const filled = await $.prompt.fill({ text, mode: 'insert' })
110 if (!filled.isFilled) $.ui.toast('plan-view: the prompt cannot take text right now')
111}
112
113/**
114 * Open the pane on `args` as `/plan-view` takes them: nothing, `#N` or a path.
115 * Says what it did; `isOpened` is false when there was nothing to open.
116 */
117async function openPlanView($: EngineInterface, args: string): Promise<{ text: string; isOpened: boolean }> {
118 const cwd = await $.session.cwd()
119 const target = args.trim()
120 let path: string
121 let file: string
122 let repoRoot = cwd
123
124 const issueArg = /^#?(\d+)$/.exec(target)
125 if (target === '' || issueArg !== null) {
126 const issue = issueArg === null ? undefined : Number(issueArg[1])
127 const ran = await readRunStatusWith((argv) => $.process.run(argv), $.plugin.root, { repoRoot: cwd, issue })
128 if (!ran.ok) return { text: `plan-view: could not read the saga run (${ran.reason}): ${ran.detail}`, isOpened: false }
129 const run = ran.view.runs[0]
130 if (run === undefined) {
131 const which = issue === undefined ? 'for this checkout' : `for #${issue} in this checkout`
132 return { text: `plan-view: no saga run ${which}. Name the plan instead: /plan-view docs/plans/<file>.md`, isOpened: false }
133 }
134 if (run.plan_file === null || run.plan_path === null) {
135 return { text: `plan-view: #${run.issue} has no plan recorded yet. Name one: /plan-view docs/plans/<file>.md`, isOpened: false }
136 }
137 path = run.plan_path
138 file = run.plan_file
139 repoRoot = ran.view.repo_root
140 } else {
141 path = target
142 file = absolutePath(cwd, target)
143 }
144
145 const loaded = await loadPlan($, path, file, repoRoot)
146 if (!loaded.ok) return { text: `plan-view: ${loaded.detail}`, isOpened: false }
147 await showPlan($, loaded.view)
148 await $.ui.open({ id: PLAN_PANE, title: `Plan · ${path.split('/').pop()}` })
149 const count = loaded.view.sections.length
150 return { text: `plan-view: ${path}, ${count} section${count === 1 ? '' : 's'}.`, isOpened: true }
151}
152
153export function registerPlanViewer(on: On): void {
154 on('session.start', { cwd: /^/ }, async ($, e, next) => {
155 await $.command.register({
156 name: 'plan-view',
157 description: 'Read the saga plan one section at a time in a pane',
158 argumentHint: '[path | #issue]',
159 immediate: true,
160 })
161
162 // Catches what no tool call shows: an edit from another session, an editor, git.
163 async function poll(): Promise<void> {
164 const view = await read($, planView)
165 if (view === null) return
166 const panes = await $.ui.panes()
167 if (!panes.some((pane) => pane.id === PLAN_PANE)) return
168 try {
169 const stat = await $.fs.stat(view.absPath)
170 if (stat.mtimeMs === view.mtimeMs) return
171 } catch {
172 return
173 }
174 await reloadPlan($, view)
175 }
176 $.clock.every(PLAN_POLL_MS, () => {
177 void poll()
178 })
179
180 return next(e)
181 })
182
183 on('command.run', { command: 'plan-view' }, async ($, e) => {
184 const opened = await openPlanView($, e.args)
185 return { text: opened.text }
186 })
187
188 // The run status band's Plan button (issue #105): a press on `band-plan-N` opens #N's plan.
189 on('ui.press', { plugin: 'saga', element: BAND_PLAN_ELEMENT }, async ($, e) => {
190 const issue = /(\d+)$/.exec(e.element)?.[1] ?? ''
191 const opened = await openPlanView($, `#${issue}`)
192 if (!opened.isOpened) $.ui.toast(opened.text)
193 return { element: e.element }
194 })
195
196 // An edit of the plan file reloads the pane.
197 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
198 const ran = await next(e)
199 if (ran.deny !== undefined || ran.isError === true) return ran
200 const view = await read($, planView)
201 if (view === null) return ran
202 try {
203 const stat = await $.fs.stat(e.file_path, { resolve: true })
204 if (stat.realPath === view.absPath) await reloadPlan($, view)
205 } catch {
206 // The poll catches what this misses.
207 }
208 return ran
209 })
210
211 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
212 const ran = await next(e)
213 if (ran.deny !== undefined || ran.isError === true) return ran
214 const view = await read($, planView)
215 if (view === null) return ran
216 try {
217 const stat = await $.fs.stat(e.file_path, { resolve: true })
218 if (stat.realPath === view.absPath) await reloadPlan($, view)
219 } catch {
220 // The poll catches what this misses.
221 }
222 return ran
223 })
224
225 // `/plan` saving a plan tick opens the pane unasked, where there is room.
226 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
227 const ran = await next(e)
228 if (ran.deny !== undefined || ran.isError === true) return ran
229 const saved = planSavePath(e.command)
230 if (saved === null) return ran
231 const cwd = await $.session.cwd()
232 const loaded = await loadPlan($, saved, absolutePath(cwd, saved), cwd)
233 if (!loaded.ok) return ran
234 await showPlan($, loaded.view)
235 const opened = await $.ui.open({ id: PLAN_PANE, title: `Plan · ${saved.split('/').pop()}` })
236 if (!opened.isPlaced) {
237 // A pane that appears later, when the terminal widens, would be a surprise.
238 await $.ui.close({ id: PLAN_PANE })
239 $.ui.toast('Plan saved: run /plan-view to read it here')
240 }
241 return ran
242 })
243
244 on('ui.render', { component: 'Pane', requestId: PLAN_PANE }, async ($, e) => {
245 const { Box, Button, Markdown, Text } = $.ui.resolve(e)
246 const view = await read($, planView)
247 if (view === null) {
248 return (
249 <Box flexDirection="column">
250 <Text dimColor>No plan loaded. Run /plan-view, /plan-view #issue or /plan-view path.</Text>
251 </Box>
252 )
253 }
254
255 const selected = await read($, planSelected)
256 const section = view.sections[selected]
257 if (section === undefined) {
258 const count = view.sections.length
259 return (
260 <Box flexDirection="column">
261 <Text dimColor>
262 {view.path} · {count} section{count === 1 ? '' : 's'}
263 </Text>
264 {count === 0 && <Text>This plan has no headings.</Text>}
265 {view.sections.map((one, i) => (
266 <Button
267 key={`sec-${i}`}
268 label={`${' '.repeat(Math.max(0, one.level - 1))}${one.title}`}
269 plain
270 onPress={async () => {
271 await update($, planSelected, () => i)
272 await update($, planPage, () => 0)
273 }}
274 />
275 ))}
276 </Box>
277 )
278 }
279
280 const pages = pageText(section.text)
281 const page = Math.min(Math.max(0, await read($, planPage)), pages.length - 1)
282 const linked = linkifyRefs(pages[page] ?? '', view.repoRoot)
283 // Link first, then measure: a file reference drawn without OSC 8 is wider than its label.
284 // bodyColumns is the pane. viewport.columns is the transcript beside a docked pane.
285 const text = e.surface === 'terminal' ? fitTables(linked.text, e.props.bodyColumns) : linked.text
286 const { refs } = linked
287 const pressRef = async (ref: PlanRef | null) => {
288 if (ref !== null) await fillPrompt($, refPromptText(ref))
289 }
290
291 return (
292 <Box flexDirection="column">
293 <Box flexDirection="row" gap={1}>
294 <Button key="back" label="Sections" onPress={() => update($, planSelected, () => -1)} />
295 {page > 0 && <Button key="page-prev" label="Previous page" onPress={() => update($, planPage, () => page - 1)} />}
296 {page < pages.length - 1 && (
297 <Button key="page-next" label="Next page" onPress={() => update($, planPage, () => page + 1)} />
298 )}
299 <Button
300 key="discuss"
301 label="Discuss this"
302 variant="primary"
303 onPress={() => fillPrompt($, discussPromptText(view.path, section, page, pages.length))}
304 />
305 </Box>
306 <Text dimColor>
307 {section.title} · page {page + 1} of {pages.length}
308 </Text>
309 {refs.length > 0 ? (
310 <Markdown
311 key="section"
312 text={text}
313 pressableLinks={refs.map((ref) => ref.href)}
314 onLinkPress={(link) => {
315 void pressRef(refFromHref(link.href, view.repoRoot, refs))
316 }}
317 />
318 ) : (
319 <Markdown key="section" text={text} />
320 )}
321 {refs.slice(0, MAX_REF_BUTTONS).map((ref, k) => (
322 <Button key={`ref-${k}`} label={ref.label} plain dimColor onPress={() => pressRef(ref)} />
323 ))}
324 </Box>
325 )
326 })
327}
328mods/review-pane.tsx 343 lines1// The review findings pane (issue #108, live in issue #165): `/review-view`
2// shows the run's latest code review inside Claude Code.
3//
4// Read-only. It reads the review through `scripts/run_status.py review`. For a
5// record with C1 review runs the view embeds the whole `review_state.v1`
6// document, and the pane draws that alone: grades, where-to-look states,
7// tools, round and cost, and per-round deltas. For an older record it draws
8// today's lens list from the latest `review_result.v2` entry. The pane shows
9// those answers and never recomputes them. The only thing it writes is the
10// operator's own prompt, when they press a finding's Quote. No acceptance
11// decision and no finding edits happen here. The plain fallback on every other
12// harness is `run_status.py review`'s table.
13//
14// - `/review-view` opens the checkout's run; `/review-view #N` issue N's.
15// - A state review shows each lens's grade with its blocking and fix-later
16// counts. An old-shape lens row reads met, not met, not run or unscored,
17// with its finding count and its top findings; a lens that did not run, or
18// has no recorded result, says so and never shows a number that could read
19// as a low score.
20// - Choosing a lens lists its findings; Quote puts one in the prompt.
21// - A Bash call that ran a script which writes review runs reloads the open
22// pane, and so does a change of the run record's modification time, polled
23// every five seconds while the pane is open.
24// - The run status band's Review button (issue #105) opens this pane: this
25// module answers the press on `band-review-<issue>` itself, because a
26// plugin's own `$.command.run` never reaches its own command hook.
27
28import { atom, read, update } from 'claude-code'
29import type { EngineInterface, On } from 'claude-code'
30
31import type { SagaReviewView } from '../types/index.d.ts'
32import { fitTables } from './plan-sections.ts'
33import {
34 costLine,
35 findingHeading,
36 findingLine,
37 findingsFor,
38 findingText,
39 gradeLabel,
40 lensLabel,
41 OTHER_FINDINGS,
42 quoteText,
43 reviewHeading,
44 roundLabel,
45 stateFindingHeading,
46 stateFindingText,
47 stateHeading,
48 stateLensFindings,
49 stateQuoteText,
50 whereToLookLabel,
51} from './review-findings.ts'
52import { isStateReview, readEitherReviewWith } from './run-record.ts'
53import type { ReviewViewQuery } from './run-record.ts'
54
55/** The pane's id, and the `requestId` its `ui.render` hook matches. */
56export const REVIEW_PANE = 'saga-review'
57
58/** The run status band's Review button, `band-review-<issue>` (issue #105). */
59export const BAND_REVIEW_ELEMENT = /^band-review-\d+$/
60
61/** How often an open pane checks the run record's modification time. */
62export const REVIEW_POLL_MS = 5_000
63
64/** A Bash command that ran a script which writes review runs or answers. */
65const WRITES_REVIEW = /\b(review_result|review_command|review_state)\.py\b/
66
67const reviewView = atom({ plugin: 'saga', key: 'reviewView' } as const, null)
68const reviewLens = atom({ plugin: 'saga', key: 'reviewLens' } as const, null)
69const reviewMtimeMs = atom({ plugin: 'saga', key: 'reviewMtimeMs' } as const, 0)
70
71/** The run record's modification time, or 0 when it cannot be read. */
72async function recordMtime($: EngineInterface, view: SagaReviewView): Promise<number> {
73 if (view.record_path === null) return 0
74 try {
75 return (await $.fs.stat(view.record_path)).mtimeMs
76 } catch {
77 return 0
78 }
79}
80
81/** Read the review again for the shown issue, keeping the reader on their lens when it is still there. */
82async function reloadReview($: EngineInterface, shown: SagaReviewView): Promise<void> {
83 const query: ReviewViewQuery = { repoRoot: shown.repo_root }
84 if (shown.issue !== null) query.issue = shown.issue
85 const ran = await readEitherReviewWith((argv) => $.process.run(argv), $.plugin.root, query)
86 // A record caught mid-write keeps its last good review on screen.
87 if (!ran.ok || ran.view.review === null) return
88 const view = ran.view
89 await update($, reviewView, () => view)
90 const mtime = await recordMtime($, view)
91 await update($, reviewMtimeMs, () => mtime)
92 const lens = await read($, reviewLens)
93 const review = view.review
94 if (lens !== null && review !== null && lens !== OTHER_FINDINGS && !review.lenses.some((one) => one.lens === lens)) {
95 await update($, reviewLens, () => null)
96 }
97}
98
99async function isOpen($: EngineInterface): Promise<boolean> {
100 const panes = await $.ui.panes()
101 return panes.some((pane) => pane.id === REVIEW_PANE)
102}
103
104/** Put text in the operator's prompt, saying so when the prompt cannot take it. */
105async function fillPrompt($: EngineInterface, text: string): Promise<void> {
106 const filled = await $.prompt.fill({ text, mode: 'insert' })
107 if (!filled.isFilled) $.ui.toast('review-view: the prompt cannot take text right now')
108}
109
110/**
111 * Open the pane on `args` as `/review-view` takes them: nothing or `#N`.
112 * Says what it did; `isOpened` is false when there was nothing to open.
113 */
114async function openReviewView($: EngineInterface, args: string): Promise<{ text: string; isOpened: boolean }> {
115 const cwd = await $.session.cwd()
116 const target = args.trim()
117 const issueArg = /^#?(\d+)$/.exec(target)
118 if (target !== '' && issueArg === null) return { text: 'review-view: name an issue, as in /review-view #108', isOpened: false }
119 const query: ReviewViewQuery = { repoRoot: cwd }
120 if (issueArg !== null) query.issue = Number(issueArg[1])
121
122 const ran = await readEitherReviewWith((argv) => $.process.run(argv), $.plugin.root, query)
123 if (!ran.ok) return { text: `review-view: could not read the review (${ran.reason}): ${ran.detail}`, isOpened: false }
124 const view = ran.view
125 const review = view.review
126 if (view.issue === null) return { text: 'review-view: no saga run for this checkout. Name one: /review-view #N', isOpened: false }
127 if (review === null) return { text: `review-view: no review result recorded for #${view.issue}.`, isOpened: false }
128
129 await update($, reviewView, () => view)
130 await update($, reviewLens, () => null)
131 const mtime = await recordMtime($, view)
132 await update($, reviewMtimeMs, () => mtime)
133 if (isStateReview(review)) {
134 await $.ui.open({ id: REVIEW_PANE, title: `Review · #${view.issue} round ${review.round}` })
135 const blocking = review.lenses.reduce((sum, lens) => sum + lens.blocking, 0)
136 const fixLater = review.lenses.reduce((sum, lens) => sum + lens.fix_later, 0)
137 return {
138 text: `review-view: #${view.issue} round ${review.round}, ${review.outcome}, ${review.lenses.length} lenses, ${blocking} blocking, ${fixLater} fix later.`,
139 isOpened: true,
140 }
141 }
142 await $.ui.open({ id: REVIEW_PANE, title: `Review · #${view.issue} cycle ${review.cycle}` })
143 const findings = review.lenses.reduce((sum, lens) => sum + lens.finding_count, 0) + review.unattributed_findings.length
144 return {
145 text: `review-view: #${view.issue} cycle ${review.cycle}, ${review.outcome}, ${review.lenses.length} lenses, ${findings} findings.`,
146 isOpened: true,
147 }
148}
149
150export function registerReviewPane(on: On): void {
151 on('session.start', { cwd: /^/ }, async ($, e, next) => {
152 await $.command.register({
153 name: 'review-view',
154 description: 'Show the latest saga code review, lens by lens, in a pane',
155 argumentHint: '[#issue]',
156 immediate: true,
157 })
158
159 // Catches a review written by another session: a stat, never a read of the record.
160 async function poll(): Promise<void> {
161 const view = await read($, reviewView)
162 if (view === null || view.record_path === null) return
163 if (!(await isOpen($))) return
164 const mtime = await recordMtime($, view)
165 if (mtime === 0 || mtime === (await read($, reviewMtimeMs))) return
166 await reloadReview($, view)
167 }
168 $.clock.every(REVIEW_POLL_MS, () => {
169 void poll()
170 })
171
172 return next(e)
173 })
174
175 on('command.run', { command: 'review-view' }, async ($, e) => {
176 const opened = await openReviewView($, e.args)
177 return { text: opened.text }
178 })
179
180 // The run status band's Review button (issue #105): a press on `band-review-N` opens #N's review.
181 on('ui.press', { plugin: 'saga', element: BAND_REVIEW_ELEMENT }, async ($, e) => {
182 const issue = /(\d+)$/.exec(e.element)?.[1] ?? ''
183 const opened = await openReviewView($, `#${issue}`)
184 if (!opened.isOpened) $.ui.toast(opened.text)
185 return { element: e.element }
186 })
187
188 // `review_result.py` writing a result reloads the open pane at once.
189 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
190 const ran = await next(e)
191 if (ran.deny !== undefined || ran.isError === true) return ran
192 if (!WRITES_REVIEW.test(e.command)) return ran
193 const view = await read($, reviewView)
194 if (view === null || !(await isOpen($))) return ran
195 await reloadReview($, view)
196 return ran
197 })
198
199 on('ui.render', { component: 'Pane', requestId: REVIEW_PANE }, async ($, e) => {
200 const { Box, Button, Markdown, Text } = $.ui.resolve(e)
201 const view = await read($, reviewView)
202 const review = view?.review ?? null
203 if (view === null || review === null) {
204 return (
205 <Box flexDirection="column">
206 <Text dimColor>No review loaded. Run /review-view or /review-view #issue.</Text>
207 </Box>
208 )
209 }
210
211 const lens = await read($, reviewLens)
212 if (isStateReview(review)) {
213 const state = review.state
214 if (lens === null) {
215 const others = stateLensFindings(state, OTHER_FINDINGS)
216 const ran = Object.entries(state.tools.ran)
217 return (
218 <Box flexDirection="column">
219 <Text dimColor>{stateHeading(view.issue, review)}</Text>
220 <Text bold>Grades</Text>
221 {state.lenses.map((one) => (
222 <Box key={`row-${one.lens}`} flexDirection="column">
223 <Button key={`lens-${one.lens}`} label={gradeLabel(one)} plain onPress={() => update($, reviewLens, () => one.lens)} />
224 </Box>
225 ))}
226 {others.length > 0 && (
227 <Button
228 key="lens-other"
229 label={`${OTHER_FINDINGS} ${others.length} finding${others.length === 1 ? '' : 's'}`}
230 plain
231 onPress={() => update($, reviewLens, () => OTHER_FINDINGS)}
232 />
233 )}
234 <Text bold>Where to look</Text>
235 {state.where_to_look.length === 0 && <Text dimColor>None.</Text>}
236 {state.where_to_look.map((item, i) => (
237 <Box key={`wtl-${i}`}>
238 <Text dimColor>{whereToLookLabel(item)}</Text>
239 </Box>
240 ))}
241 <Text bold>Tools</Text>
242 {ran.map(([name, version]) => (
243 <Box key={`tool-${name}`}>
244 <Text dimColor>{`${name} ${version}`}</Text>
245 </Box>
246 ))}
247 {state.tools.missing_notice !== null && <Text>{state.tools.missing_notice}</Text>}
248 {state.tools.missing_notice === null && state.tools.missing_tools.length > 0 && (
249 <Text dimColor>{`missing: ${state.tools.missing_tools.join(', ')}`}</Text>
250 )}
251 <Text bold>{`Round ${state.round}`}</Text>
252 <Text dimColor>{costLine(state.cost)}</Text>
253 {state.rounds.map((round) => (
254 <Box key={`round-${round.round}`} flexDirection="column">
255 <Text>{roundLabel(round)}</Text>
256 {round.new_blocking.map((id) => (
257 <Box key={`new-${id}`}>
258 <Text dimColor>{` + ${id}`}</Text>
259 </Box>
260 ))}
261 {round.cleared_blocking.map((id) => (
262 <Box key={`cleared-${id}`}>
263 <Text dimColor>{` - ${id}`}</Text>
264 </Box>
265 ))}
266 {round.new_blocking.length === 0 && round.cleared_blocking.length === 0 && (
267 <Text dimColor> nothing added or cleared</Text>
268 )}
269 </Box>
270 ))}
271 </Box>
272 )
273 }
274 const stateFindings = stateLensFindings(state, lens)
275 const shownGrade = state.lenses.find((one) => one.lens === lens)
276 return (
277 <Box flexDirection="column">
278 <Button key="back" label="Grades" onPress={() => update($, reviewLens, () => null)} />
279 <Text bold>{shownGrade === undefined ? lens : gradeLabel(shownGrade)}</Text>
280 {stateFindings.length === 0 && <Text dimColor>No findings.</Text>}
281 {stateFindings.map((finding, i) => (
282 <Box key={`finding-${i}`} flexDirection="column">
283 <Text key={`heading-${i}`}>{stateFindingHeading(finding)}</Text>
284 <Markdown
285 key={`text-${i}`}
286 text={e.surface === 'terminal' ? fitTables(stateFindingText(finding), e.props.bodyColumns) : stateFindingText(finding)}
287 />
288 <Button key={`quote-${i}`} label="Quote" onPress={() => fillPrompt($, stateQuoteText(finding))} />
289 </Box>
290 ))}
291 </Box>
292 )
293 }
294 if (lens === null) {
295 const others = review.unattributed_findings
296 return (
297 <Box flexDirection="column">
298 <Text dimColor>{reviewHeading(view.issue, review)}</Text>
299 {review.lenses.length === 0 && <Text>This review has no lens results.</Text>}
300 {review.lenses.map((one) => (
301 <Box key={`row-${one.lens}`} flexDirection="column">
302 <Button key={`lens-${one.lens}`} label={lensLabel(one)} plain onPress={() => update($, reviewLens, () => one.lens)} />
303 {one.top.map((finding, i) => (
304 <Text key={`top-${one.lens}-${i}`} dimColor>
305 {` ${findingLine(finding)}`}
306 </Text>
307 ))}
308 </Box>
309 ))}
310 {others.length > 0 && (
311 <Button
312 key="lens-other"
313 label={`${OTHER_FINDINGS} ${others.length} finding${others.length === 1 ? '' : 's'}`}
314 plain
315 onPress={() => update($, reviewLens, () => OTHER_FINDINGS)}
316 />
317 )}
318 </Box>
319 )
320 }
321
322 const findings = findingsFor(review, lens)
323 const shown = review.lenses.find((one) => one.lens === lens)
324 return (
325 <Box flexDirection="column">
326 <Button key="back" label="Lenses" onPress={() => update($, reviewLens, () => null)} />
327 <Text bold>{shown === undefined ? lens : lensLabel(shown)}</Text>
328 {findings.length === 0 && <Text dimColor>No findings.</Text>}
329 {findings.map((finding, i) => (
330 <Box key={`finding-${i}`} flexDirection="column">
331 <Text key={`heading-${i}`}>{findingHeading(finding)}</Text>
332 <Markdown
333 key={`text-${i}`}
334 text={e.surface === 'terminal' ? fitTables(findingText(finding), e.props.bodyColumns) : findingText(finding)}
335 />
336 <Button key={`quote-${i}`} label="Quote" onPress={() => fillPrompt($, quoteText(lens, finding))} />
337 </Box>
338 ))}
339 </Box>
340 )
341 })
342}
343mods/setup-pane.tsx 347 lines1// The setup pane (issue #165): C3's survey rows with a checkbox each, an
2// Install button that runs the ticked tools, and the first-session offer.
3//
4// The model calls the tool `mcp__saga__setup` with the checkout. The tool runs
5// `saga_setup.py survey --format json` and draws the pane from that document
6// alone: each tool's id, status, lens and pinned version with a checkbox,
7// pre-ticked when the status is not installed. A row with no install command
8// has no checkbox and shows the script's install sentence instead. Install
9// runs one `install --tools <id>` call per ticked tool, in pane order,
10// showing each row's progress, and stops at the first failure; Done returns
11// the installed and failed lists to the model. Dismissed, timed-out and the
12// error paths match the merge pane: closing records nothing, and a timeout
13// returns timed-out with whatever already installed left installed.
14//
15// The offer: when C3's machine record shows setup has never run — and neither
16// the machine record nor the mod's `$.store` remembers the offer — the first
17// session start toasts `Run /saga:setup to check this machine's tools.`, then
18// records the offer in the machine record through `record-offer` and in the
19// store, machine first. The offer check never runs `survey`: the survey
20// persists the machine record with `ran` set, so checking with it would mark
21// setup run. Any store failure suppresses the offer path without throwing, so
22// a suite or build without the store stays silent and green.
23//
24// Waiting for a person: the tool waits on `$.process.run(['sleep', '1'])`, one
25// second at a time, up to WAIT_CAP_SECONDS (LEARNINGS.md, 2026-10-04).
26
27import { atom, read, update } from 'claude-code'
28import type { EngineInterface, On } from 'claude-code'
29import type { SagaSetupOutcome, SagaSetupSurvey, SagaSetupToolRow } from '../types/index.d.ts'
30import {
31 readOfferStatusWith,
32 readSetupSurveyWith,
33 setupInstallArgv,
34 setupRecordOfferArgv,
35} from './run-record.ts'
36
37/** The pane's id. */
38export const PANE = 'saga-setup'
39
40/** The tool's short name; the model calls it as `mcp__saga__setup`. */
41export const TOOL = 'setup'
42
43/** How long the tool waits for the operator before it returns timed-out: thirty minutes. */
44export const WAIT_CAP_SECONDS = 1800
45
46/** How long one tool install may take. */
47const INSTALL_TIMEOUT_MS = 300_000
48
49/** The `$.store` key remembering the setup offer. */
50export const OFFER_STORE_KEY = 'setup_offered'
51
52/** The offer toasted once, on the first session of a machine setup never ran on. */
53export const OFFER_TEXT = "Run /saga:setup to check this machine's tools."
54
55const setup = atom({ plugin: 'saga', key: 'setupReview' } as const, null)
56
57/**
58 * The outcome of the setup in flight. A module value, not `$.state`: the
59 * waiting hook reads it between its sleeps, and a state read inside one
60 * dispatch sees one frozen moment. A hot reload drops it with the pane.
61 */
62let outcome: SagaSetupOutcome | undefined
63
64/** Whether a setup pane is open. One setup at a time. */
65let active = false
66
67/**
68 * The Install run's epoch. Each Install bumps it and orphans any older loop,
69 * so a loop outlived by its pane can never mark another setup's rows.
70 */
71let installEpoch = 0
72
73// ---------------------------------------------------------------------------
74// Pure helpers: no `$`, so a test calls them directly
75// ---------------------------------------------------------------------------
76
77/** The ticks the pane opens with: every installable row whose status is not installed. */
78export function initialTicks(survey: SagaSetupSurvey): Record<string, boolean> {
79 const ticked: Record<string, boolean> = {}
80 for (const row of survey.tools) {
81 ticked[row.id] = row.has_install && row.status !== 'installed'
82 }
83 return ticked
84}
85
86/** The ticked installable ids, in survey order: exactly what Install passes, one argv each. */
87export function tickedIds(survey: SagaSetupSurvey, ticked: Record<string, boolean>): string[] {
88 return survey.tools.filter((row) => row.has_install && ticked[row.id] === true).map((row) => row.id)
89}
90
91/** One row's checkbox label: the tick box, id, status and lens. */
92export function rowLabel(row: SagaSetupToolRow, ticked: boolean): string {
93 return `${ticked ? '[x]' : '[ ]'} ${row.id} ${row.status} ${row.lens}`
94}
95
96/** The last lines of a row's install output that the pane shows. */
97export function shownOutput(output: string): string {
98 return output.length > 2000 ? `\u2026${output.slice(-2000)}` : output
99}
100
101// ---------------------------------------------------------------------------
102// The hooks
103// ---------------------------------------------------------------------------
104
105export function registerSetupPane(on: On): void {
106 // The matcher is load-bearing: the engine refuses a second matcher-less
107 // `session.start` registration, and admission's holds that one slot.
108 on('session.start', { cwd: /^/ }, async ($, e, next) => {
109 await $.tool.register({
110 name: TOOL,
111 description:
112 "Opens saga's setup pane for a checkout: C3's surveyed tool rows with a checkbox each, and an " +
113 'Install button that runs the ticked tools through scripts/saga_setup.py and shows live progress. ' +
114 'Returns status submitted (with the installed and failed ids), dismissed, not-placed, unavailable, ' +
115 'nothing-to-review, timed-out or error; on anything but submitted, print the survey table and ask ' +
116 'which tools to install instead.',
117 inputSchema: {
118 type: 'object',
119 properties: {
120 repo: { type: 'string', description: 'The checkout to set up; defaults to the session directory.' },
121 },
122 },
123 })
124 const out = await next(e)
125 await maybeOfferSetup($)
126 return out
127 })
128
129 on('tool.call', { tool: 'mcp__saga__setup' }, async ($, e, next) => {
130 if (active) {
131 return { result: { status: 'error', reason: 'a setup pane is already open' } }
132 }
133 const surfaces = await $.session.surfaces()
134 if (!surfaces.some((surface) => surface === 'terminal' || surface === 'desktop')) {
135 return {
136 result: { status: 'unavailable', reason: 'no terminal or desktop surface is attached to draw the pane' },
137 }
138 }
139 const cwd = await $.session.cwd()
140 const input = (e as { repo?: unknown }).repo
141 const repo = typeof input === 'string' && input !== '' ? input : cwd
142
143 const read = await readSetupSurveyWith((argv) => $.process.run(argv), $.plugin.root, repo)
144 if (!read.ok) {
145 return { result: { status: 'error', reason: `saga_setup survey failed (${read.reason}): ${read.detail}` } }
146 }
147 if (read.survey.tools.length === 0) {
148 return { result: { status: 'nothing-to-review' } }
149 }
150
151 active = true
152 outcome = undefined
153 await update($, setup, () => ({
154 repo,
155 survey: read.survey,
156 ticked: initialTicks(read.survey),
157 progress: null,
158 error: null,
159 }))
160 try {
161 const opened = await $.ui.open({ id: PANE, title: 'Setup', focus: true })
162 if (!opened.isPlaced) {
163 return { result: { status: 'not-placed', reason: opened.reason } }
164 }
165 let waited = 0
166 while (outcome === undefined && !next.signal.aborted && waited < WAIT_CAP_SECONDS) {
167 await $.process.run(['sleep', '1'])
168 waited += 1
169 }
170 return { result: outcome ?? { status: next.signal.aborted ? 'dismissed' : 'timed-out' } }
171 } catch (err) {
172 return { result: { status: 'error', reason: `the setup pane failed: ${String(err)}` } }
173 } finally {
174 active = false
175 outcome = undefined
176 installEpoch += 1
177 await $.ui.close({ id: PANE })
178 await update($, setup, () => null)
179 }
180 })
181
182 on('ui.close', { id: PANE }, ($, e, next) => {
183 // Every close but the tool's own after an outcome: the operator's close mark or Escape.
184 if (active && outcome === undefined) outcome = { status: 'dismissed' }
185 return next(e)
186 })
187
188 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
189 const state = await read($, setup)
190 if (e.surface !== 'terminal' && e.surface !== 'desktop') {
191 const { Text } = $.ui.resolve(e)
192 return <Text>Answer the setup pane in the terminal or the desktop app.</Text>
193 }
194 const { Box, Button, Text } = $.ui.resolve(e)
195 if (state === null) {
196 return <Text dimColor>No setup is open.</Text>
197 }
198 const { repo, survey } = state
199
200 const toggle = (id: string) =>
201 update($, setup, (current) => {
202 if (!current || current.progress !== null) return current
203 const row = current.survey.tools.find((one) => one.id === id)
204 if (!row || !row.has_install) return current
205 return { ...current, ticked: { ...current.ticked, [id]: current.ticked[id] !== true }, error: null }
206 })
207 const setError = (error: string) => update($, setup, (current) => current && { ...current, error })
208 const markRow = (id: string, progress: { state: 'queued' | 'installing' | 'done' | 'failed'; output: string }) =>
209 update($, setup, (current) => {
210 if (!current || current.progress === null) return current
211 return { ...current, progress: { ...current.progress, [id]: progress } }
212 })
213
214 const install = async () => {
215 const current = await read($, setup)
216 if (!current || current.repo !== repo || current.progress !== null) return
217 // Bumped after the guards, so a second press that finds work started
218 // returns without orphaning the loop already running.
219 const epoch = ++installEpoch
220 const ids = tickedIds(current.survey, current.ticked)
221 if (ids.length === 0) {
222 await setError('Tick at least one tool with an install command.')
223 return
224 }
225 const progress: Record<string, { state: 'queued' | 'installing' | 'done' | 'failed'; output: string }> = {}
226 for (const id of ids) progress[id] = { state: 'queued', output: '' }
227 await update($, setup, (shown) => (shown ? { ...shown, progress, error: null } : shown))
228 for (const id of ids) {
229 if (epoch !== installEpoch) return
230 await markRow(id, { state: 'installing', output: '' })
231 let ran
232 try {
233 ran = await $.process.run(setupInstallArgv($.plugin.root, id, repo), { timeoutMs: INSTALL_TIMEOUT_MS })
234 } catch (err) {
235 if (epoch !== installEpoch) return
236 await markRow(id, { state: 'failed', output: String(err) })
237 return
238 }
239 if (epoch !== installEpoch) return
240 const output = `${ran.stdout}${ran.stderr}`.trim()
241 if (ran.exitCode !== 0) {
242 await markRow(id, { state: 'failed', output: output === '' ? `install exited ${ran.exitCode}` : output })
243 return
244 }
245 await markRow(id, { state: 'done', output })
246 }
247 }
248 const done = async () => {
249 const current = await read($, setup)
250 if (!current) return
251 const installed: string[] = []
252 const failed: string[] = []
253 for (const [id, row] of Object.entries(current.progress ?? {})) {
254 if (row.state === 'done') installed.push(id)
255 else if (row.state === 'failed') failed.push(id)
256 }
257 outcome = { status: 'submitted', installed, failed }
258 await $.ui.close({ id: PANE })
259 }
260 const dismiss = async () => {
261 outcome = { status: 'dismissed' }
262 await $.ui.close({ id: PANE })
263 }
264
265 const running = state.progress !== null && Object.values(state.progress).some((row) => row.state === 'installing' || row.state === 'queued')
266 return (
267 <Box flexDirection="column" width={e.props.bodyColumns}>
268 <Text bold>Tools</Text>
269 {survey.tools.map((row) => (
270 <Box key={`row:${row.id}`} flexDirection="column">
271 {row.has_install ? (
272 <Button
273 key={`tick:${row.id}`}
274 label={rowLabel(row, state.ticked[row.id] === true)}
275 plain
276 onPress={() => toggle(row.id)}
277 />
278 ) : (
279 <Text dimColor>{`${row.id} ${row.status} ${row.lens}`}</Text>
280 )}
281 <Text dimColor>
282 {` ${row.version ?? 'no version'} pinned ${row.pinned_version}${row.has_install ? '' : ` ${row.install ?? 'no install command'}`}`}
283 </Text>
284 {state.progress?.[row.id] && (
285 <Text key={`progress:${row.id}`} dimColor>
286 {` ${state.progress[row.id]?.state}: ${shownOutput(state.progress[row.id]?.output ?? '')}`}
287 </Text>
288 )}
289 </Box>
290 ))}
291 {state.error !== null && (
292 <Box key="error" marginTop={1}>
293 <Text color="red" wrap="wrap">
294 {state.error}
295 </Text>
296 </Box>
297 )}
298 <Box key="actions" flexDirection="row" columnGap={2} marginTop={1}>
299 <Button key="install" label={running ? 'Installing…' : 'Install'} variant="primary" onPress={() => install()} />
300 <Button key="done" label="Done" hotkey="d" onPress={() => done()} />
301 <Button key="dismiss" label="Dismiss" role="dismiss" onPress={() => dismiss()} />
302 </Box>
303 </Box>
304 )
305 })
306}
307
308/**
309 * Toast the setup offer once, on the first session of a machine setup never
310 * ran on. The store is read first: when it remembers the offer the script is
311 * never called. Any store failure suppresses the path without throwing.
312 */
313async function maybeOfferSetup($: EngineInterface): Promise<void> {
314 let remembered: unknown
315 try {
316 remembered = await $.store.get(OFFER_STORE_KEY)
317 } catch {
318 return
319 }
320 if (remembered === true) return
321 const read = await readOfferStatusWith((argv) => $.process.run(argv), $.plugin.root)
322 if (!read.ok) {
323 await $.ui.toast(`saga: could not read the machine record (${read.reason}): ${read.detail}`)
324 return
325 }
326 if (read.status.ran || read.status.offered) return
327 await $.ui.toast(OFFER_TEXT)
328 let recorded
329 try {
330 recorded = await $.process.run(setupRecordOfferArgv($.plugin.root))
331 } catch (err) {
332 await $.ui.toast(`saga: could not record the setup offer: ${String(err)}`)
333 return
334 }
335 if (recorded.exitCode !== 0) {
336 const stderr = recorded.stderr.trim()
337 await $.ui.toast(`saga: could not record the setup offer: ${stderr === '' ? `record-offer exited ${recorded.exitCode}` : stderr}`)
338 return
339 }
340 try {
341 await $.store.set(OFFER_STORE_KEY, true)
342 } catch {
343 // The machine record already remembers; a store that fails just costs one
344 // offer-status call next session.
345 }
346}
347mods/usage-capture.ts 319 lines1// Live token capture (issue #107): what each model request of a unit session
2// costs, appended to that unit's row of the run record as the session runs.
3//
4// Saga's scripts own the record; this mod only collects and hands over.
5//
6// - At session start it asks `run_status.py unit-for` which unit row the
7// session's directory is working: the row whose worktree it is, else the row
8// whose branch is checked out there. No row, and the mod records nothing:
9// no timer, no queue, no write. The coordinator in the primary checkout is
10// such a session.
11// - Every model request (`turn.step`), on the main thread and in every
12// subagent's loop, adds its usage to a bucket in `$.state`: one bucket per
13// model session, role, model and effort, the identity of a `usage add` entry.
14// `turn.step` rather than `turn.complete`, because only the request carries
15// the effort it asked for, and a subagent's turns complete inside the
16// parent's.
17// - Every minute, and at session end, each bucket is written through
18// `run_record.py usage add`, the one portable writer, which takes the
19// record's lock. One process per bucket per minute, never one per request.
20// A failed write keeps its bucket for the next tick; a written one is
21// subtracted, so requests that landed during the write are kept.
22//
23// Claude Code reports cache writes as one count, without the five-minute and
24// one-hour split the record prices apart. They are recorded as one-hour writes,
25// the dearer rate, so a report built on them can overstate cache-write spend
26// but never understate it (`references/run-record.md`).
27//
28// The plain fallback on every other harness is the same writer, called by the
29// harness's own integration with the counts it reads (`usage add --help`).
30
31import { atom, read, update } from 'claude-code'
32import type { EngineInterface, On } from 'claude-code'
33
34import type { SagaUsageBucket, SagaUsageTarget } from '../types/index.d.ts'
35import { KNOWN_RUN_STATUS_SCHEMA, sagaScriptArgv } from './run-record.ts'
36import type { ProcessResult } from './run-record.ts'
37
38/** How often the queue is written. */
39export const USAGE_FLUSH_MS = 60_000
40
41/** How long one timed write may run before it is abandoned and retried next tick. */
42const FLUSH_TIMEOUT_MS = 10_000
43
44/** One write at session end, inside the engine's 1.5-second budget for every hook. */
45const END_TIMEOUT_MS = 1_000
46
47/** The vendor every entry this mod writes names. */
48export const USAGE_VENDOR = 'claude'
49
50/** The `usage add` flag Claude Code's single cache-write count goes under (see the header). */
51export const CACHE_WRITE_FLAG = '--cache-write-1h'
52
53/** The effort a request recorded when its model takes none: `usage add` needs a name. */
54export const NO_EFFORT = 'none'
55
56/** A saga role registered as a Claude agent type (`saga:<role>`) records as that role. */
57const SAGA_AGENT_TYPE = /^saga:([a-z][a-z0-9-]*)$/
58
59const usageTarget = atom({ plugin: 'saga', key: 'usageTarget' } as const, null)
60const usageQueue = atom({ plugin: 'saga', key: 'usageQueue' } as const, [])
61
62/** One request's usage, as `addStep` folds it into the queue. */
63export type UsageStep = {
64 sessionId: string
65 agentId: string | null
66 role: string
67 model: string
68 effort: string
69 uncachedInput: number
70 cacheRead: number
71 cacheWrite: number
72 output: number
73}
74
75/** The argv that asks which unit row a session at `cwd` is working. */
76export function unitForArgv(pluginRoot: string, cwd: string): string[] {
77 return sagaScriptArgv(pluginRoot, 'run_status.py', ['--repo-root', cwd, 'unit-for', '--json'])
78}
79
80/**
81 * The unit `run_status.py unit-for --json` matched, or null. Anything but a
82 * clean match of the known view version is null: a mod that cannot tell which
83 * unit a session works records nothing rather than guessing.
84 */
85export function parseUnitFor(ran: ProcessResult): SagaUsageTarget | null {
86 if (ran.exitCode !== 0 || ran.isStdoutTruncated) return null
87 let parsed: unknown
88 try {
89 parsed = JSON.parse(ran.stdout)
90 } catch {
91 return null
92 }
93 if (typeof parsed !== 'object' || parsed === null) return null
94 const view = parsed as { schema?: unknown; match?: unknown }
95 if (view.schema !== KNOWN_RUN_STATUS_SCHEMA) return null
96 const match = view.match as Partial<SagaUsageTarget> | null | undefined
97 if (typeof match !== 'object' || match === null) return null
98 const { issue, unit, role, store_root } = match
99 if (!Number.isInteger(issue) || (issue as number) <= 0) return null
100 if (typeof unit !== 'string' || unit === '' || typeof role !== 'string' || role === '') return null
101 if (typeof store_root !== 'string' || store_root === '') return null
102 return match as SagaUsageTarget
103}
104
105/** The effort a request asked for, as `usage add --effort` takes it. */
106export function effortName(effort: string | number | undefined): string {
107 return effort === undefined ? NO_EFFORT : String(effort)
108}
109
110/** The role a subagent's requests record: its saga role, else the session's. */
111export function subagentRole(agentType: string | undefined, sessionRole: string): string {
112 const saga = agentType === undefined ? null : SAGA_AGENT_TYPE.exec(agentType)
113 return saga === null ? sessionRole : saga[1]
114}
115
116/** The session id an entry records: the session's own, or `<session>/<agent>` for a subagent's loop. */
117export function entrySessionId(bucket: Pick<SagaUsageBucket, 'sessionId' | 'agentId'>): string {
118 return bucket.agentId === null ? bucket.sessionId : `${bucket.sessionId}/${bucket.agentId}`
119}
120
121function sameEntry(a: Omit<UsageStep, 'uncachedInput' | 'cacheRead' | 'cacheWrite' | 'output'>, b: typeof a): boolean {
122 return (
123 a.sessionId === b.sessionId &&
124 a.agentId === b.agentId &&
125 a.role === b.role &&
126 a.model === b.model &&
127 a.effort === b.effort
128 )
129}
130
131/** The queue with one request's usage summed into its bucket. */
132export function addStep(queue: readonly SagaUsageBucket[], step: UsageStep): SagaUsageBucket[] {
133 const at = queue.findIndex((bucket) => sameEntry(bucket, step))
134 if (at < 0) return [...queue, { ...step, steps: 1 }]
135 const was = queue[at]
136 const sum: SagaUsageBucket = {
137 ...was,
138 uncachedInput: was.uncachedInput + step.uncachedInput,
139 cacheRead: was.cacheRead + step.cacheRead,
140 cacheWrite: was.cacheWrite + step.cacheWrite,
141 output: was.output + step.output,
142 steps: was.steps + 1,
143 }
144 return queue.map((bucket, i) => (i === at ? sum : bucket))
145}
146
147/**
148 * The queue after `written` reached the record: its counts subtracted from its
149 * bucket, which goes once nothing is left in it. What a request added while the
150 * write ran stays.
151 */
152export function subtractWritten(queue: readonly SagaUsageBucket[], written: SagaUsageBucket): SagaUsageBucket[] {
153 const out: SagaUsageBucket[] = []
154 for (const bucket of queue) {
155 if (!sameEntry(bucket, written)) {
156 out.push(bucket)
157 continue
158 }
159 const left: SagaUsageBucket = {
160 ...bucket,
161 uncachedInput: bucket.uncachedInput - written.uncachedInput,
162 cacheRead: bucket.cacheRead - written.cacheRead,
163 cacheWrite: bucket.cacheWrite - written.cacheWrite,
164 output: bucket.output - written.output,
165 steps: bucket.steps - written.steps,
166 }
167 if (left.steps > 0) out.push(left)
168 }
169 return out
170}
171
172/** The argv that appends one bucket to the target unit's usage block. */
173export function usageAddArgv(pluginRoot: string, target: SagaUsageTarget, bucket: SagaUsageBucket): string[] {
174 return sagaScriptArgv(pluginRoot, 'run_record.py', [
175 '--store-root',
176 target.store_root,
177 'usage',
178 'add',
179 String(target.issue),
180 '--unit',
181 target.unit,
182 '--session-id',
183 entrySessionId(bucket),
184 '--role',
185 bucket.role,
186 '--vendor',
187 USAGE_VENDOR,
188 '--model',
189 bucket.model,
190 '--effort',
191 bucket.effort,
192 '--uncached-input',
193 String(bucket.uncachedInput),
194 '--cache-read',
195 String(bucket.cacheRead),
196 CACHE_WRITE_FLAG,
197 String(bucket.cacheWrite),
198 '--output',
199 String(bucket.output),
200 ])
201}
202
203function errorText(err: unknown): string {
204 return err instanceof Error ? err.message : String(err)
205}
206
207// Module variables, on purpose: a hot reload stops the module's timers and
208// resets these with them, so the next request starts the timer again. The
209// queue itself is in `$.state` and survives the reload.
210let timerRunning = false
211let flushChain: Promise<void> = Promise.resolve()
212let flushPending = false
213const agentRoles = new Map<string, string>()
214
215/** Write every bucket in the queue, one `usage add` each, stopping at the first failure. */
216async function writeQueue($: EngineInterface, timeoutMs: number): Promise<void> {
217 const target = await read($, usageTarget)
218 if (target === null) return
219 const queue = await read($, usageQueue)
220 for (const bucket of queue ?? []) {
221 let ran: ProcessResult
222 try {
223 ran = await $.process.run(usageAddArgv($.plugin.root, target, bucket), { timeoutMs })
224 } catch (err) {
225 $.ui.log(`usage-capture: usage add did not run: ${errorText(err)}`, { to: 'debug' })
226 return
227 }
228 if (ran.exitCode !== 0) {
229 $.ui.log(`usage-capture: usage add exited ${ran.exitCode}: ${ran.stderr.trim()}`, { to: 'debug' })
230 return
231 }
232 await update($, usageQueue, (now) => subtractWritten(now ?? [], bucket))
233 }
234}
235
236/** Queue one write of the queue after any already running, so two never interleave. */
237function flushUsage($: EngineInterface, timeoutMs: number): Promise<void> {
238 flushPending = true
239 flushChain = flushChain
240 .then(() => {
241 flushPending = false
242 return writeQueue($, timeoutMs)
243 })
244 .catch((err) => $.ui.log(`usage-capture: flush failed: ${errorText(err)}`, { to: 'debug' }))
245 return flushChain
246}
247
248function startTimer($: EngineInterface): void {
249 if (timerRunning) return
250 timerRunning = true
251 $.clock.every(USAGE_FLUSH_MS, () => {
252 // A tick while a write is still waiting adds nothing: that write takes the whole queue.
253 if (!flushPending) void flushUsage($, FLUSH_TIMEOUT_MS)
254 })
255}
256
257/** The role a subagent's requests record, looked up once per subagent. */
258async function roleOf($: EngineInterface, agentId: string, sessionRole: string): Promise<string> {
259 const known = agentRoles.get(agentId)
260 if (known !== undefined) return known
261 let agentType: string | undefined
262 try {
263 agentType = (await $.agent.list()).find((agent) => agent.id === agentId)?.type
264 } catch {
265 agentType = undefined
266 }
267 const role = subagentRole(agentType, sessionRole)
268 agentRoles.set(agentId, role)
269 return role
270}
271
272export function registerUsageCapture(on: On): void {
273 on('session.start', { cwd: /^/ }, async ($, e, next) => {
274 let target: SagaUsageTarget | null = null
275 try {
276 target = parseUnitFor(await $.process.run(unitForArgv($.plugin.root, e.cwd), { timeoutMs: FLUSH_TIMEOUT_MS }))
277 } catch (err) {
278 $.ui.log(`usage-capture: unit-for did not run: ${errorText(err)}`, { to: 'debug' })
279 }
280 await update($, usageTarget, () => target)
281 if (target !== null) {
282 if (target.ambiguous) {
283 $.ui.log(`usage-capture: several unit rows match; recording to #${target.issue} ${target.unit}`, { to: 'debug' })
284 }
285 startTimer($)
286 }
287 return next(e)
288 })
289
290 on('turn.step', { model: /^/ }, async function* ($, e, next) {
291 const result = yield* next(e)
292 const usage = result.usage
293 // No response, or one without usage (interrupted, an API error): nothing was billed to read.
294 if (usage === null) return result
295 const target = await read($, usageTarget)
296 if (target === null) return result
297 const agentId = e.agentId ?? null
298 const step: UsageStep = {
299 sessionId: await $.session.id(),
300 agentId,
301 role: agentId === null ? target.role : await roleOf($, agentId, target.role),
302 model: usage.model,
303 effort: effortName(e.effort),
304 uncachedInput: usage.input_tokens,
305 cacheRead: usage.cache_read_input_tokens,
306 cacheWrite: usage.cache_creation_input_tokens,
307 output: usage.output_tokens,
308 }
309 await update($, usageQueue, (queue) => addStep(queue ?? [], step))
310 startTimer($)
311 return result
312 })
313
314 on('session.end', { sessionId: /^/ }, async ($, e, next) => {
315 await flushUsage($, END_TIMEOUT_MS)
316 return next(e)
317 })
318}
319mods/run-band.tsx 219 lines1// The run status band (issue #105, review words and notice in issue #165): one
2// line per active saga run above the prompt, and the issue, the phase and
3// C3's missing-tools notice in saga's status-line entry.
4//
5// Read-only. Every word comes from `scripts/run_status.py summary --all-active`,
6// whose `band_line` the script renders once for every harness: the build loop's
7// latest pass, the review cycle against its allowance, and how many lenses met
8// their bar by the verdict's own rule. The band never recomputes any of it and
9// no button here changes the run. The plain fallback on every other harness is
10// `run_status.py summary --band`.
11//
12// - Refreshes after the session starts, every minute, after each main-loop turn
13// completes, and after a Bash call that ran one of saga's or orchestrate's
14// state scripts. A refresh is queued on the clock so it never holds up a turn.
15// - With no active run the band passes and the status line is cleared.
16// - Plan and Review open the plan viewer (issue #104) and the review findings
17// pane (issue #108) for the first run; those modules answer the press. Record
18// shows the raw record as `run_record.py show` prints it; Hide hides the band
19// for the session.
20// - This is the only saga mod that sets saga's status line: the engine keeps
21// one line per plugin, so a second writer would overwrite this one.
22
23import { atom, read, update } from 'claude-code'
24import type { EngineInterface, On } from 'claude-code'
25
26import type { SagaRunStatus } from '../types/index.d.ts'
27import { readRunRecordWith, readRunStatusWith } from './run-record.ts'
28
29/** The raw record pane's id, and the `requestId` its `ui.render` hook matches. */
30export const RECORD_PANE = 'saga-record'
31
32/** How often the band reads the runs again with nothing else prompting it. */
33export const BAND_REFRESH_MS = 60_000
34
35/** A Code block holds at most 10,000 characters; a page of the record stays under it. */
36export const RECORD_PAGE_CHARS = 9_000
37
38/** A Bash command that ran a script which writes saga run state. */
39const WRITES_RUN_STATE = /\b(run_record|build_loop|review_result|admission|merge_turn|release_step|saga|orchestrate)\.py\b/
40
41const runStatus = atom({ plugin: 'saga', key: 'runStatus' } as const, [])
42const bandHidden = atom({ plugin: 'saga', key: 'bandHidden' } as const, false)
43const recordView = atom({ plugin: 'saga', key: 'recordView' } as const, null)
44
45/**
46 * Saga's status-line entry for the first run: the issue, its phase, and C3's
47 * missing-tools notice verbatim when the run has one. This stays the only saga
48 * writer of the status line: the engine keeps one line per plugin.
49 */
50export function statusText(runs: readonly SagaRunStatus[]): string | undefined {
51 const run = runs[0]
52 if (run === undefined) return undefined
53 const base = run.phase ? `saga #${run.issue} · ${run.phase}` : `saga #${run.issue}`
54 const notice = run.setup_notice?.text
55 return notice ? `${base} · ${notice}` : base
56}
57
58/** A line cut to `columns` cells, an ellipsis marking the cut. */
59export function fitLine(line: string, columns: number): string {
60 if (columns <= 0) return ''
61 const chars = [...line]
62 return chars.length <= columns ? line : `${chars.slice(0, columns - 1).join('')}…`
63}
64
65/** A record's JSON cut into pages a Code block can hold, breaking at a newline where one is near. */
66export function recordPages(json: string, limit = RECORD_PAGE_CHARS): string[] {
67 const pages: string[] = []
68 let rest = json
69 while (rest.length > limit) {
70 const cut = rest.lastIndexOf('\n', limit - 1)
71 let end = cut > limit / 2 ? cut + 1 : limit
72 // Never split a surrogate pair across two pages.
73 if (end === limit && /[\uD800-\uDBFF]/.test(rest[end - 1] ?? '')) end -= 1
74 pages.push(rest.slice(0, end))
75 rest = rest.slice(end)
76 }
77 pages.push(rest)
78 return pages
79}
80
81/**
82 * Read the runs again and redraw. A read that fails keeps what the band showed:
83 * a record caught mid-write must not blank it, and the next refresh retries.
84 */
85async function refreshBand($: EngineInterface): Promise<void> {
86 const cwd = await $.session.cwd()
87 const ran = await readRunStatusWith((argv) => $.process.run(argv), $.plugin.root, { repoRoot: cwd, allActive: true })
88 if (!ran.ok) return
89 const runs = ran.view.runs
90 await update($, runStatus, () => runs)
91 $.ui.status(statusText(runs))
92}
93
94/**
95 * The Plan and Review buttons' keys. The plan viewer and the review pane answer
96 * a press on these themselves (`BAND_PLAN_ELEMENT`, `BAND_REVIEW_ELEMENT`): a
97 * plugin's own `$.command.run` never reaches its own command hook, while a
98 * press is raised by the engine and reaches every hook.
99 */
100export function bandPlanKey(issue: number): string {
101 return `band-plan-${issue}`
102}
103
104export function bandReviewKey(issue: number): string {
105 return `band-review-${issue}`
106}
107
108/** The bottom of a press the pane modules answer; it runs only if neither is registered. */
109function paneNotLoaded(): void {}
110
111/** Load issue `issue`'s raw run record into the record pane and open it. */
112async function openRecord($: EngineInterface, issue: number): Promise<void> {
113 const loaded = await readRunRecordWith((argv) => $.process.run(argv), $.plugin.root, issue)
114 if (!loaded.ok) {
115 $.ui.toast(`saga: could not read the run record for #${issue} (${loaded.reason}): ${loaded.detail}`)
116 return
117 }
118 const pages = recordPages(JSON.stringify(loaded.record, null, 2))
119 await update($, recordView, () => ({ issue, pages, page: 0 }))
120 await $.ui.open({ id: RECORD_PANE, title: `Run record · #${issue}` })
121}
122
123/** Whether a refresh is running, and whether another was asked for while it ran. */
124let refreshing = false
125let refreshAgain = false
126
127/**
128 * Queue a refresh on the clock, so the dispatch that asked is not held up. One
129 * runs at a time; requests made meanwhile fold into one more read after it.
130 */
131function requestRefresh($: EngineInterface): void {
132 $.clock.after(0, async () => {
133 if (refreshing) {
134 refreshAgain = true
135 return
136 }
137 refreshing = true
138 try {
139 do {
140 refreshAgain = false
141 await refreshBand($)
142 } while (refreshAgain)
143 } finally {
144 refreshing = false
145 }
146 })
147}
148
149export function registerRunBand(on: On): void {
150 on('session.start', { cwd: /^/ }, async ($, e, next) => {
151 requestRefresh($)
152 $.clock.every(BAND_REFRESH_MS, () => {
153 requestRefresh($)
154 })
155 return next(e)
156 })
157
158 // A subagent's turn ending is not the run moving; the main loop's is.
159 on('turn.complete', { turnId: /^/ }, async ($, e, next) => {
160 if (e.agentId === undefined) requestRefresh($)
161 return next(e)
162 })
163
164 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
165 const ran = await next(e)
166 if (ran.deny === undefined && WRITES_RUN_STATE.test(e.command)) requestRefresh($)
167 return ran
168 })
169
170 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
171 if (e.props.hasSurvey || (await read($, bandHidden))) return next(e)
172 const runs = await read($, runStatus)
173 const first = runs[0]
174 if (first === undefined) return next(e)
175
176 const { Box, Button, Text } = $.ui.resolve(e)
177 const shown = runs.slice(0, Math.max(1, e.props.maxRows - 1))
178 return (
179 <Box flexDirection="column">
180 {shown.map((run) => (
181 <Text key={`run-${run.issue}`}>{fitLine(run.band_line, e.props.bodyColumns)}</Text>
182 ))}
183 <Box key="actions" flexDirection="row" gap={1}>
184 <Button key={bandPlanKey(first.issue)} label="Plan" hotkey="p" onPress={paneNotLoaded} />
185 <Button key={bandReviewKey(first.issue)} label="Review" hotkey="r" onPress={paneNotLoaded} />
186 <Button key="record" label="Record" hotkey="o" onPress={() => openRecord($, first.issue)} />
187 <Button key="hide" label="Hide" hotkey="h" onPress={() => update($, bandHidden, () => true)} />
188 </Box>
189 </Box>
190 )
191 })
192
193 on('ui.render', { component: 'Pane', requestId: RECORD_PANE }, async ($, e) => {
194 const { Box, Button, Code, Text } = $.ui.resolve(e)
195 const view = await read($, recordView)
196 if (view === null) {
197 return (
198 <Box flexDirection="column">
199 <Text dimColor>No run record loaded. Press Record on the saga run band.</Text>
200 </Box>
201 )
202 }
203 const page = Math.min(view.page, view.pages.length - 1)
204 const turn = (to: number) => update($, recordView, (now) => (now === null ? now : { ...now, page: to }))
205 return (
206 <Box flexDirection="column">
207 <Text dimColor>{`#${view.issue} · run_record.py show · page ${page + 1} of ${view.pages.length}`}</Text>
208 <Code source={view.pages[page] ?? ''} language="json" />
209 {view.pages.length > 1 && (
210 <Box key="pages" flexDirection="row" gap={1}>
211 {page > 0 && <Button key="prev" label="Prev" onPress={() => turn(page - 1)} />}
212 {page < view.pages.length - 1 && <Button key="next" label="Next" onPress={() => turn(page + 1)} />}
213 </Box>
214 )}
215 </Box>
216 )
217 })
218}
219types/index.d.ts 670 lines1// Saga's state contract for Claude Code mods.
2//
3// Saga's scripts own its state; a mod only reads it, by running
4// `scripts/run_record.py show <issue>` and parsing the JSON it prints. These
5// types describe that JSON. The authority is `plugins/saga/scripts/run_record.py`
6// (`SCHEMA` and `TOP_LEVEL_KEYS`); when that file adds a record version, this
7// contract and `mods/run-record.ts` change with it.
8//
9// It also declares the session state the saga mods keep (`PluginState`, at the
10// end). Self-contained on purpose: no import and no reference, as the engine
11// requires of a plugin's `types` file. Every exported name is led by `Saga`.
12
13/** The one record version this contract reads. */
14export type SagaRunRecordSchema = 'run_record.v1'
15
16/**
17 * One issue's saga run record, as `run_record.py show` prints it. `to_dict`
18 * always writes all twelve keys, so every one is required here;
19 * `plugins/saga/tests/test_mod_run_record_contract.py` checks this list against
20 * `TOP_LEVEL_KEYS`. Fields a newer writer added are kept under the index
21 * signature.
22 */
23export type SagaRunRecord = {
24 schema: SagaRunRecordSchema
25 issue: number
26 repo: string
27 created_at: string
28 updated_at: string
29 admission: Record<string, unknown>
30 run_configuration: Record<string, unknown>
31 approval_scope: Record<string, unknown>
32 roster: unknown[]
33 units: unknown[]
34 review_cycles: unknown[]
35 next_step: string
36 [field: string]: unknown
37}
38
39/** Why a read did not produce a record. */
40export type SagaRunReadFailure =
41 /** The script ran and found no record for the issue. */
42 | 'no-record'
43 /** The record (or the script's loader) names a version this contract does not know. */
44 | 'unknown-version'
45 /** The script exited 0 but did not print one JSON object. */
46 | 'unreadable'
47 /** Any other failure, such as running outside a git repository. */
48 | 'error'
49
50/** The outcome of reading one run record. A mod shows `detail` rather than guessing. */
51export type SagaRunRead =
52 | { ok: true; record: SagaRunRecord }
53 | { ok: false; reason: SagaRunReadFailure; detail: string }
54
55/** The version token `scripts/run_status.py summary --json` prints. */
56export type SagaRunStatusSchema = 'run_status.v1'
57
58/**
59 * Where a run's build loop stands (issue #105). One unit is described (`unit`
60 * and the iteration fields set) when its worktree is the checkout or it is the
61 * only unit that ran the loop; otherwise only the green count is.
62 */
63export type SagaRunBuildLoop = {
64 unit: string | null
65 /** The latest iteration's number. */
66 pass: number | null
67 failing: number | null
68 /** Checks that could not execute, never counted as failing. */
69 could_not_execute: number | null
70 green: boolean | null
71 units_total: number
72 units_green: number
73}
74
75/**
76 * The latest code review cycle against its allowance, and how many lenses met
77 * their bar by the verdict's own rule (issue #105). A record with C1 review
78 * runs carries the round and grades instead (issue #165); the cycle keys stay
79 * unset and `round` and `grades` carry the state branch.
80 */
81export type SagaRunReviewProgress = {
82 unit: string | null
83 cycle: number | null
84 standard_allowance: number
85 escalated_allowance: number
86 /** The cycle is past the standard allowance. */
87 is_escalated: boolean
88 outcome: string | null
89 lenses_met: number
90 lenses_total: number
91 lenses_not_run: number
92 /** The newest review round, or null for an old-shape record. */
93 round: number | null
94 /** One `{lens, grade}` pair per lens in document order; empty for an old-shape record. */
95 grades: { lens: string; grade: string }[]
96}
97
98/**
99 * One run as `run_status.py summary --json` prints it. The run record supplies
100 * `next_step`, `updated_at`, `record_path`, `build_loop` and `review`; the saga
101 * envelope supplies `phase`, `plan_path` (as recorded, relative to the
102 * checkout) and `plan_file` (absolute). Each is null when its store does not
103 * know the run. `band_line` is the status band's line, rendered by the script
104 * so every harness shows the same words. `setup_notice` is the one admission
105 * notice for missing tools and the reproduction sandbox, or null when the
106 * record has none.
107 */
108export type SagaSetupNotice = {
109 text: string
110 missing_tools: string[]
111 sandbox_unavailable: boolean
112}
113
114export type SagaRunStatus = {
115 issue: number
116 repo: string | null
117 next_step: string
118 updated_at: string | null
119 record_path: string | null
120 phase: string | null
121 plan_path: string | null
122 plan_file: string | null
123 build_loop: SagaRunBuildLoop | null
124 review: SagaRunReviewProgress | null
125 setup_notice: SagaSetupNotice | null
126 band_line: string
127}
128
129/** What `run_status.py summary --json` prints. */
130export type SagaRunStatusView = {
131 schema: SagaRunStatusSchema
132 repo_root: string
133 runs: SagaRunStatus[]
134}
135
136/**
137 * One section of a plan as the plan viewer splits it: the frontmatter
138 * (`level` 0), or a heading of level 1 to 3 and the text up to the next
139 * heading of the same or a higher level. `text` starts with the heading line.
140 */
141export type SagaPlanSection = {
142 level: 0 | 1 | 2 | 3
143 title: string
144 text: string
145}
146
147/** The plan the viewer pane shows: where it is, when it was read, its sections. */
148export type SagaPlanView = {
149 /** The path as the operator or the run named it. */
150 path: string
151 /** The file, every link resolved; what an edit's `file_path` is compared with. */
152 absPath: string
153 /** The checkout a `path:line` reference in the plan is relative to. */
154 repoRoot: string
155 mtimeMs: number
156 sections: SagaPlanSection[]
157}
158
159// ---------------------------------------------------------------------------
160// The admission review pane (issue #103)
161// ---------------------------------------------------------------------------
162//
163// `scripts/admission.py --dry-run --render json` prints one document of schema
164// `admission_review.v1`; the pane draws from it and nothing else. The authority
165// is `review_data` in that script. A field the pane does not draw is left
166// untyped under the index signatures.
167
168/** The one review document version the pane reads. */
169export type SagaAdmissionReviewSchema = 'admission_review.v1'
170
171/** One tier as admission prints it: vendor, model and effort. */
172export type SagaAdmissionTier = { vendor: string | null; model: string; effort: string | null }
173
174/** A Jev cell: the text to show, and the state a pane branches on instead of the text. */
175export type SagaAdmissionJevCell = {
176 cell: string
177 state: string
178 [field: string]: unknown
179}
180
181/** One staffing row: a role, its default, Jev's cell, the tier proposed, and why. */
182export type SagaAdmissionStaffingRow = {
183 role: string
184 vendor: string | null
185 default: SagaAdmissionTier | null
186 proposed: SagaAdmissionTier | null
187 jev: SagaAdmissionJevCell
188 why: string
189}
190
191/** One lens row. `include` is `always on`, `yes`, `no` or `undeclared`. */
192export type SagaAdmissionLensRow = {
193 lens: string
194 always_on: boolean
195 include: string
196 reason: string
197 /** `band` is `pre-checked`, `consider` or null (issue #110's lens proposal). */
198 jev: SagaAdmissionJevCell & { probability: number | null; band?: string | null }
199}
200
201/** The tiers an operator may pick: models, efforts, and the pairs the palette allows. */
202export type SagaAdmissionPalette = {
203 vendor: string
204 models: string[]
205 efforts: string[]
206 effort_ceilings: Record<string, string>
207 pairs: { model: string; effort: string }[]
208}
209
210/** What `admission.py --render json` prints. */
211export type SagaAdmissionReviewData = {
212 schema: SagaAdmissionReviewSchema
213 issue: number
214 repo: string
215 pending_questions: string[]
216 staffing: { source: string; status: string; rows: SagaAdmissionStaffingRow[] }
217 lenses: { source: string; status: string; catalogue_version: string | null; rows: SagaAdmissionLensRow[] }
218 /** Null when the bundled tier palette could not be loaded. */
219 palette: SagaAdmissionPalette | null
220 [field: string]: unknown
221}
222
223/** The operator's picks while the pane is open: per role a tier, per conditional lens a decision. */
224export type SagaAdmissionSelections = {
225 staffing: Record<string, { model: string; effort: string }>
226 lenses: Record<string, { include: 'yes' | 'no'; reason: string }>
227}
228
229/** The pane's session state: the document it draws, the picks, and the last refusal shown. */
230export type SagaAdmissionReview = {
231 issue: number
232 repo: string | null
233 data: SagaAdmissionReviewData
234 selections: SagaAdmissionSelections
235 error: string | null
236}
237
238/** The answers the pane hands to `admission.py --answers -`. */
239export type SagaAdmissionAnswers = {
240 staffing_overrides: Record<string, { vendor: string; model: string; effort: string }>
241 lens_declaration: {
242 always_on: string[]
243 conditional_applies: Record<string, string>
244 conditional_does_not_apply: Record<string, string>
245 }
246}
247
248/** What `mcp__saga__review_admission` returns to the model. */
249export type SagaAdmissionReviewOutcome =
250 | { status: 'submitted'; issue: number; answers: SagaAdmissionAnswers; source: 'operator'; summary: string }
251 | { status: 'dismissed' | 'timed-out' | 'nothing-to-review' }
252 | { status: 'not-placed' | 'unavailable'; reason: string }
253 | { status: 'error'; reason: string; exitCode?: number; stderr?: string }
254
255/** The version token `scripts/run_status.py review --json` prints. */
256export type SagaReviewViewSchema = 'review_view.v1'
257
258/**
259 * Where a lens stands against its bar. `not_run` (the lens did not execute, or
260 * the result has no row for a selected lens) and `unscored` (it ran and
261 * reported findings but sets no bar) carry no score, so neither can read as a
262 * low one.
263 */
264export type SagaReviewLensState = 'met' | 'not_met' | 'not_run' | 'unscored'
265
266/** One finding as `run_status.py review` prints it; `FINDING_FIELDS` there. */
267export type SagaReviewFinding = {
268 id: string
269 severity: string
270 path: string
271 line: number | string
272 category: string
273 dimension: string
274 evidence: string
275 impact: string
276 status: string
277 confidence: string
278}
279
280/** One lens of the review: its state, why when it has no usable result, and its findings. */
281export type SagaReviewLens = {
282 lens: string
283 state: SagaReviewLensState
284 reason: string | null
285 /** Only a `met` or `not_met` lens carries its score. */
286 derived_overall: number | null
287 finding_count: number
288 /** The first few findings, most severe first. */
289 top: SagaReviewFinding[]
290 findings: SagaReviewFinding[]
291}
292
293/** The latest review result in one loop of the run record. */
294export type SagaReview = {
295 unit: string
296 cycle: number
297 loop: string
298 revision: string
299 outcome: string
300 reason: string | null
301 lenses: SagaReviewLens[]
302 /** Findings whose lens is not one of `lenses`. */
303 unattributed_findings: SagaReviewFinding[]
304 advisory_count: number
305 duplicate_count: number
306}
307
308/** What `run_status.py review --json` prints. `review` is null when no result is recorded. */
309export type SagaReviewView = {
310 schema: SagaReviewViewSchema
311 repo_root: string
312 issue: number | null
313 record_path: string | null
314 legacy_entries: number
315 review: SagaReview | SagaStateReview | null
316}
317
318// ---------------------------------------------------------------------------
319// The review-state document (issue #165)
320//
321// `scripts/review_state.py` renders a review's state and pending choices as one
322// document of schema `review_state.v1`; `run_status.py review` embeds it under
323// `state` for records with C1 review runs. A pane draws from it and nothing
324// else. The authority is `build_document` in that script. A field the panes do
325// not draw is left untyped under the index signatures.
326// ---------------------------------------------------------------------------
327
328/** The one review-state document version the panes read. */
329export type SagaReviewStateSchema = 'review_state.v1'
330
331/** One lens grade as the document prints it. */
332export type SagaStateLens = {
333 lens: string
334 grade: string
335 blocking: number
336 fix_later: number
337 [field: string]: unknown
338}
339
340/** One finding of the newest run, as the document prints it. */
341export type SagaStateFinding = {
342 id: string
343 lens: string
344 severity: string
345 statement: string
346 guard: boolean
347 merge_outcome: Record<string, unknown> | null
348 [field: string]: unknown
349}
350
351/** One where-to-look item: `file:start-end`, its state, and the fired question ids. */
352export type SagaStateWhereToLook = {
353 lens: string
354 location: string
355 questions: string[]
356 state: 'answered' | 'cleared'
357 finding_id?: string | null
358 reason?: string | null
359 [field: string]: unknown
360}
361
362/** Which tools ran, and C3's missing-tools notice verbatim. */
363export type SagaStateTools = {
364 ran: Record<string, string>
365 missing_notice: string | null
366 missing_tools: string[]
367 [field: string]: unknown
368}
369
370/** What one round added and cleared, against the round before. */
371export type SagaStateRound = {
372 round: number
373 new_blocking: string[]
374 cleared_blocking: string[]
375 [field: string]: unknown
376}
377
378/** Tokens, dollars and seconds summed across the stored runs. */
379export type SagaStateCost = {
380 tokens_in: number
381 tokens_out: number
382 cost_usd: number
383 seconds: number
384 [field: string]: unknown
385}
386
387/** Whether the merge waits, and why. */
388export type SagaStateMerge = {
389 waiting: boolean
390 reason: string
391 [field: string]: unknown
392}
393
394/** One consequence the LLM and the Jev classifier disagreed on. */
395export type SagaConsequenceDisagreement = {
396 id: string
397 llm: string
398 jev: string
399 [field: string]: unknown
400}
401
402/** The `review_state.v1` document. */
403export type SagaReviewState = {
404 schema: SagaReviewStateSchema
405 card: number
406 repo: string
407 round: number
408 lenses: SagaStateLens[]
409 findings: SagaStateFinding[]
410 pending_choices: string[]
411 merge_blocking: string[]
412 merge: SagaStateMerge
413 disputes: string[]
414 consequence_disagreements: SagaConsequenceDisagreement[]
415 unconfirmed: string[]
416 where_to_look: SagaStateWhereToLook[]
417 tools: SagaStateTools
418 degraded_inputs: unknown[]
419 rounds: SagaStateRound[]
420 cost: SagaStateCost
421 unattended: boolean | null
422 [field: string]: unknown
423}
424
425/** The state review as `run_status.py review` prints it for records with C1 runs. */
426export type SagaStateReview = {
427 state_schema: SagaReviewStateSchema
428 round: number
429 loop: string
430 revision: string
431 outcome: string
432 lenses: SagaStateLens[]
433 pending_choices: string[]
434 merge: SagaStateMerge
435 state: SagaReviewState
436}
437
438/** The answers the merge pane hands to `review_state.py answers --answers -`. */
439export type SagaMergeAnswers = {
440 answers: Record<string, string | { decision: 'merge-with-reason' | 'stop-card'; reason?: string }>
441 pane_timeout: boolean
442}
443
444/** The operator's picks while the merge pane is open. */
445export type SagaMergeSelections = {
446 fix_later: Record<string, string>
447 merge_blocking: { decision: string; reason: string }
448}
449
450/** The merge pane's session state: the document it draws, the picks, and the last refusal shown. */
451export type SagaMergeReview = {
452 issue: number
453 repo: string | null
454 data: SagaReviewState
455 selections: SagaMergeSelections
456 error: string | null
457}
458
459/** What `mcp__saga__review_merge` returns to the model. */
460export type SagaMergeReviewOutcome =
461 | { status: 'submitted'; issue: number; answers: SagaMergeAnswers; source: 'operator'; summary: string }
462 | { status: 'dismissed' | 'timed-out' | 'nothing-to-review' }
463 | { status: 'not-placed' | 'unavailable'; reason: string }
464 | { status: 'error'; reason: string; exitCode?: number; stderr?: string }
465
466// ---------------------------------------------------------------------------
467// The setup survey and the machine offer (issue #165)
468//
469// `scripts/saga_setup.py survey --format json` prints one document of schema
470// `setup_survey.v1`; the setup pane draws its tool rows and nothing else. The
471// authority is `collect` in that script. `offer-status` prints the machine
472// record's `ran` and `offered` without writing.
473// ---------------------------------------------------------------------------
474
475/** The one survey document version the setup pane reads. */
476export type SagaSetupSurveySchema = 'setup_survey.v1'
477
478/** The one machine-record version the offer reads. */
479export type SagaMachineRecordSchema = 'machine_record.v1'
480
481/** One surveyed tool row as the setup pane draws it. */
482export type SagaSetupToolRow = {
483 id: string
484 lens: string
485 status: string
486 version: string | null
487 pinned_version: string
488 has_install: boolean
489 install: string | null
490 [field: string]: unknown
491}
492
493/** What `saga_setup.py survey --format json` prints. */
494export type SagaSetupSurvey = {
495 schema: SagaSetupSurveySchema
496 tools: SagaSetupToolRow[]
497 [field: string]: unknown
498}
499
500/** What `saga_setup.py offer-status` prints. */
501export type SagaOfferStatus = {
502 schema: SagaMachineRecordSchema
503 ran: boolean
504 offered: boolean
505}
506
507/** One tool's install progress while Install runs. */
508export type SagaSetupRowProgress = {
509 state: 'queued' | 'installing' | 'done' | 'failed'
510 output: string
511}
512
513/** The setup pane's session state: the survey it draws, the ticks, and the Install progress. */
514export type SagaSetupReview = {
515 repo: string
516 survey: SagaSetupSurvey
517 ticked: Record<string, boolean>
518 progress: Record<string, SagaSetupRowProgress> | null
519 error: string | null
520}
521
522/** What `mcp__saga__setup` returns to the model. */
523export type SagaSetupOutcome =
524 | { status: 'submitted'; installed: string[]; failed: string[] }
525 | { status: 'dismissed' | 'timed-out' | 'nothing-to-review' }
526 | { status: 'not-placed' | 'unavailable'; reason: string }
527 | { status: 'error'; reason: string; exitCode?: number; stderr?: string }
528
529/**
530 * The unit row a session is working, as `run_status.py --repo-root <cwd>
531 * unit-for --json` prints it under `match` (issue #107). The token-capture mod
532 * appends this session's usage to that row through `run_record.py usage add`.
533 */
534export type SagaUsageTarget = {
535 issue: number
536 /** The row's identity as `usage add --unit` takes it: `id`, else `name`, else `unit_id`. */
537 unit: string
538 /** The staffing role the session records: the row's `role`, else `worker` (`merging-worker` in a merge turn). */
539 role: string
540 /** How the row matched: its worktree, or only its branch. */
541 matched_by: 'worktree' | 'branch'
542 record_path: string
543 /** The record store, passed back as `--store-root` so a write resolves nothing again. */
544 store_root: string
545 /** More than one row matched; this is the strongest, active and newest. */
546 ambiguous: boolean
547}
548
549/** What `run_status.py unit-for --json` prints. */
550export type SagaUnitForView = {
551 schema: SagaRunStatusSchema
552 repo_root: string
553 branch: string
554 match: SagaUsageTarget | null
555}
556
557/**
558 * Token counts not yet written to the run record, summed per model session:
559 * one bucket per session id (a subagent's loop is its own), role, model and
560 * effort, the identity of a `usage add` entry. The four counts are the ones a
561 * Claude Code `turn.step` result reports.
562 */
563export type SagaUsageBucket = {
564 sessionId: string
565 /** The subagent's id, or null on the main thread. */
566 agentId: string | null
567 role: string
568 model: string
569 effort: string
570 uncachedInput: number
571 cacheRead: number
572 cacheWrite: number
573 output: number
574 /** How many model requests the counts sum. */
575 steps: number
576}
577
578/** The raw run record pane: the record as JSON, cut into pages a Code block can hold. */
579export type SagaRecordView = {
580 issue: number
581 pages: string[]
582 page: number
583}
584
585/**
586 * One role's agent type, as `scripts/role_agent_types.py` prints it: the role's
587 * prompt (a saga hosting preamble, then the roles-library file's body) and the
588 * model and effort the run is staffed at.
589 */
590export type SagaRoleAgentType = {
591 /** The staffing role (`worker`); the registered type is `saga:<role>`. */
592 role: string
593 /** The roles library's id for it (`implementer`). */
594 role_id: string
595 readable_role: string
596 prompt_path: string
597 prompt: string
598 model: string
599 effort: string
600 /** The resolver's tier source: `operator`, `overlay`, `jev-raise` or `policy`. */
601 source: string
602 description: string
603}
604
605/** What `scripts/role_agent_types.py --json` prints: `saga_role_agent_types.v1`. */
606export type SagaRoleAgentTypes = {
607 schema: 'saga_role_agent_types.v1'
608 /** Whether this checkout has an active run; with none, `types` is empty. */
609 active: boolean
610 issue: number | null
611 types: SagaRoleAgentType[]
612 skipped: { role: string; reason: string }[]
613 /** A hash of every type's role, tier and prompt; it changes only when one of them does. */
614 fingerprint: string
615 /** Why no types could be answered, when that is the case. */
616 error?: string
617}
618
619/** One registered agent type's resolved tier, by its full name (`saga:worker`). */
620export type SagaRegisteredAgentType = {
621 role: string
622 model: string
623 effort: string
624}
625
626/** What the agent-types mod last registered, kept in the session's state. */
627export type SagaAgentTypesState = {
628 active: boolean
629 issue: number | null
630 fingerprint: string
631 byType: Record<string, SagaRegisteredAgentType>
632}
633
634declare module 'claude-code' {
635 interface PluginState {
636 saga: {
637 /** The plan the `/plan-view` pane shows, or null before one is loaded. */
638 planView: SagaPlanView | null
639 /** The section shown, by index into `planView.sections`; -1 is the section list. */
640 planSelected: number
641 /** The page of the shown section, from 0. */
642 planPage: number
643 /** The admission review pane's state (issue #103), or null when no review is open. */
644 admissionReview: SagaAdmissionReview | null
645 /** The review the `/review-view` pane shows, or null before one is loaded. */
646 reviewView: SagaReviewView | null
647 /** The lens whose findings are listed; null is the lens list. */
648 reviewLens: string | null
649 /** The run record's modification time when `reviewView` was read. */
650 reviewMtimeMs: number
651 /** The unit this session's usage goes to; null when it works none (or before it is resolved). */
652 usageTarget: SagaUsageTarget | null
653 /** Usage read from `turn.step` and not yet written through `usage add`. */
654 usageQueue: SagaUsageBucket[]
655 /** This checkout's active runs as the status band last read them (issue #105). */
656 runStatus: SagaRunStatus[]
657 /** The operator pressed the band's Hide; it stays hidden for the session. */
658 bandHidden: boolean
659 /** The raw run record the band's Record button opened, or null. */
660 recordView: SagaRecordView | null
661 /** What the agent-types mod last registered (issue #106). */
662 agentTypes: SagaAgentTypesState
663 /** The merge-confirmation pane's state (issue #165), or null when no confirmation is open. */
664 mergeReview: SagaMergeReview | null
665 /** The setup pane's state (issue #165), or null when no setup is open. */
666 setupReview: SagaSetupReview | null
667 }
668 }
669}
670mods/run-record.ts 539 lines1// The shared way a saga mod reads saga state and writes answers back.
2//
3// Saga's scripts own the run record. A mod never opens the files under
4// `.claude/saga/runs/` and never parses prose: it runs
5// `python3 <plugin root>/scripts/run_record.py show <issue>` and parses the
6// JSON that prints. (One pinned stderr prefix is the single exception; see
7// `EXIT_RECORD_ERROR`.) An answer goes back the same way, through a script's
8// command line, built with `sagaScriptArgv`.
9//
10// The engine's validator follows `$` only into functions declared in the same
11// file as the hook, never across an import, so nothing here takes `$` itself.
12// A closure over `$.process.run` can cross the import, though, so the guarded
13// reader lives here once and a mod's hook passes it the runner:
14//
15// import { readRunRecordWith } from './run-record.ts'
16//
17// const read = await readRunRecordWith((argv) => $.process.run(argv), $.plugin.root, issue)
18//
19// `readRunRecordWith` owns the catch: `$.process.run` rejects when the command
20// cannot start (no `python3` on the session's PATH) or outlasts its timeout (30
21// seconds by default), and the reader turns that into reason 'error' so the mod
22// falls back to its plain behaviour instead of throwing. No mod writes its own
23// try/catch around the run.
24//
25// A mod's test can drive the real path too: stub the engine's process runner
26// with `on('process.run', async (_$, e) => ({ value: { exitCode, stdout, stderr,
27// isStdoutTruncated, isStderrTruncated } }))`, see the argv the reader built in
28// `e.argv`, and drive the mod's hook. `plugins/saga/tests/test_mod_run_record_contract.py`
29// pins what the real script prints and exits with.
30
31import type { ProcessRunResult } from 'claude-code'
32import type {
33 SagaMachineRecordSchema,
34 SagaOfferStatus,
35 SagaReview,
36 SagaReviewStateSchema,
37 SagaReviewView,
38 SagaReviewViewSchema,
39 SagaRunRead,
40 SagaRunRecord,
41 SagaRunRecordSchema,
42 SagaRunStatusSchema,
43 SagaRunStatusView,
44 SagaSetupSurvey,
45 SagaSetupSurveySchema,
46 SagaStateReview,
47} from '../types/index.d.ts'
48
49/** The record version this module reads; `SCHEMA` in `scripts/run_record.py`. */
50export const KNOWN_SCHEMA: SagaRunRecordSchema = 'run_record.v1'
51
52/** What `$.process.run` resolves to, narrowed to the fields the parser reads. */
53export type ProcessResult = Pick<ProcessRunResult, 'exitCode' | 'stdout' | 'stderr' | 'isStdoutTruncated'>
54
55/** `run_record.py show` exits 3 when its loader meets a record version it does not know. */
56const EXIT_UNKNOWN_VERSION = 3
57
58/**
59 * Exit 2 covers both "no record" and every other loader failure, and the script
60 * has no exit code of its own for a missing record, so the start of its stderr
61 * message tells them apart. This is the one place the reader matches script
62 * text; `test_mod_run_record_contract.py` pins the prefix against the real
63 * script (DECISIONS.md, 2026-10-04).
64 */
65const EXIT_RECORD_ERROR = 2
66const NO_RECORD_PREFIX = 'run_record: no record for issue '
67
68/** A script under `scripts/` is named by its file name alone. */
69const SCRIPT_NAME = /^[A-Za-z0-9_][A-Za-z0-9_.-]*\.py$/
70
71function requireIssue(issue: number): void {
72 if (!Number.isInteger(issue) || issue <= 0) {
73 throw new RangeError(`not an issue number: ${issue}`)
74 }
75}
76
77/**
78 * The argv that runs one of saga's scripts. `$.process.run` takes argv with no
79 * shell, so nothing here is quoted; a script name that could leave `scripts/`
80 * is refused.
81 */
82export function sagaScriptArgv(pluginRoot: string, script: string, args: readonly string[]): string[] {
83 if (!SCRIPT_NAME.test(script) || script.includes('..')) {
84 throw new RangeError(`not a saga script name: ${script}`)
85 }
86 return ['python3', `${pluginRoot}/scripts/${script}`, ...args]
87}
88
89/** The argv that prints one issue's run record as JSON. */
90export function runRecordShowArgv(pluginRoot: string, issue: number): string[] {
91 requireIssue(issue)
92 return sagaScriptArgv(pluginRoot, 'run_record.py', ['show', String(issue)])
93}
94
95/** The read a mod reports when `$.process.run` rejected, so the script never ran to an exit. */
96export function runRecordRunFailed(err: unknown): SagaRunRead {
97 const detail = err instanceof Error ? err.message : String(err)
98 return { ok: false, reason: 'error', detail: `run_record show did not run: ${detail}` }
99}
100
101/** Turn what `run_record.py show` did into a record, or the reason there is none. */
102export function parseRunRecordShow(ran: ProcessResult): SagaRunRead {
103 const detail = ran.stderr.trim()
104 if (ran.exitCode === EXIT_UNKNOWN_VERSION) return { ok: false, reason: 'unknown-version', detail }
105 if (ran.exitCode === EXIT_RECORD_ERROR && detail.startsWith(NO_RECORD_PREFIX)) {
106 return { ok: false, reason: 'no-record', detail }
107 }
108 if (ran.exitCode !== 0) return { ok: false, reason: 'error', detail }
109 if (ran.isStdoutTruncated) {
110 // The engine keeps only the first 4 MiB of standard output; the JSON is cut.
111 return { ok: false, reason: 'unreadable', detail: 'run_record show printed more than the engine keeps (4 MiB); the record was cut off' }
112 }
113
114 let parsed: unknown
115 try {
116 parsed = JSON.parse(ran.stdout)
117 } catch (err) {
118 return { ok: false, reason: 'unreadable', detail: String(err) }
119 }
120 if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
121 return { ok: false, reason: 'unreadable', detail: 'run_record show did not print a JSON object' }
122 }
123 const schema = (parsed as { schema?: unknown }).schema
124 if (schema !== KNOWN_SCHEMA) {
125 return { ok: false, reason: 'unknown-version', detail: `record version ${JSON.stringify(schema)} is not ${KNOWN_SCHEMA}` }
126 }
127 return { ok: true, record: parsed as SagaRunRecord }
128}
129
130/** Runs one argv and resolves to its result; a mod passes `(argv) => $.process.run(argv)`. */
131export type ProcessRunner = (argv: string[]) => Promise<ProcessResult>
132
133/**
134 * Read one issue's run record through `run`. Never rejects: a run that could
135 * not start or timed out, and an issue number that is not one, come back as
136 * reason 'error', so every mod keeps its plain fallback without a catch of its own.
137 */
138export async function readRunRecordWith(run: ProcessRunner, pluginRoot: string, issue: number): Promise<SagaRunRead> {
139 try {
140 return parseRunRecordShow(await run(runRecordShowArgv(pluginRoot, issue)))
141 } catch (err) {
142 return runRecordRunFailed(err)
143 }
144}
145
146// ---------------------------------------------------------------------------
147// `run_status.py summary`: the read-only run view (issue #104)
148//
149// The run record has no plan path and no lifecycle phase; the saga envelope
150// does. `scripts/run_status.py summary --json` joins the two, so a mod reads
151// both through one script, the same guarded way it reads the record.
152// ---------------------------------------------------------------------------
153
154/** The view version this module reads; `SCHEMA` in `scripts/run_status.py`. */
155export const KNOWN_RUN_STATUS_SCHEMA: SagaRunStatusSchema = 'run_status.v1'
156
157/** Which runs `run_status.py summary` reports, and from which checkout. */
158export type RunStatusQuery = {
159 /** The checkout whose saga envelopes are read: the session's directory. */
160 repoRoot: string
161 /** One issue; left out, the issue the checkout's active saga or `issue/N` branch names. */
162 issue?: number
163 /** Every run with a next step, the resolved issue first. */
164 allActive?: boolean
165}
166
167/** The outcome of reading the run view. A mod shows `detail` rather than guessing. */
168export type RunStatusRead =
169 | { ok: true; view: SagaRunStatusView }
170 | { ok: false; reason: 'unknown-version' | 'unreadable' | 'error'; detail: string }
171
172/** The argv that prints the run view as JSON. */
173export function runStatusSummaryArgv(pluginRoot: string, query: RunStatusQuery): string[] {
174 const args = ['--repo-root', query.repoRoot, 'summary']
175 if (query.issue !== undefined) {
176 requireIssue(query.issue)
177 args.push('--issue', String(query.issue))
178 }
179 if (query.allActive === true) args.push('--all-active')
180 args.push('--json')
181 return sagaScriptArgv(pluginRoot, 'run_status.py', args)
182}
183
184/** Why a `run_status.py` command produced no view. */
185type RunStatusFailure = { ok: false; reason: 'unknown-version' | 'unreadable' | 'error'; detail: string }
186
187type RunStatusObjectRead = { ok: true; value: Record<string, unknown> } | RunStatusFailure
188
189/**
190 * The JSON object a `run_status.py` command printed, or why there is none.
191 * `command` names it in every detail, as in "run_status summary".
192 */
193function parseRunStatusObject(ran: ProcessResult, command: string): RunStatusObjectRead {
194 const detail = ran.stderr.trim()
195 if (ran.exitCode === EXIT_UNKNOWN_VERSION) return { ok: false, reason: 'unknown-version', detail }
196 if (ran.exitCode !== 0) return { ok: false, reason: 'error', detail }
197 if (ran.isStdoutTruncated) {
198 return { ok: false, reason: 'unreadable', detail: `${command} printed more than the engine keeps (4 MiB)` }
199 }
200 let parsed: unknown
201 try {
202 parsed = JSON.parse(ran.stdout)
203 } catch (err) {
204 return { ok: false, reason: 'unreadable', detail: String(err) }
205 }
206 if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
207 return { ok: false, reason: 'unreadable', detail: `${command} did not print a JSON object` }
208 }
209 return { ok: true, value: parsed as Record<string, unknown> }
210}
211
212/** Turn what `run_status.py summary --json` did into the view, or the reason there is none. */
213export function parseRunStatusSummary(ran: ProcessResult): RunStatusRead {
214 const parsed = parseRunStatusObject(ran, 'run_status summary')
215 if (!parsed.ok) return parsed
216 const view = parsed.value
217 if (view.schema !== KNOWN_RUN_STATUS_SCHEMA) {
218 return {
219 ok: false,
220 reason: 'unknown-version',
221 detail: `run view version ${JSON.stringify(view.schema)} is not ${KNOWN_RUN_STATUS_SCHEMA}`,
222 }
223 }
224 if (!Array.isArray(view.runs)) {
225 return { ok: false, reason: 'unreadable', detail: 'run_status summary printed no runs list' }
226 }
227 return { ok: true, view: view as unknown as SagaRunStatusView }
228}
229
230/** Read the run view through `run`. Never rejects, as `readRunRecordWith`. */
231export async function readRunStatusWith(
232 run: ProcessRunner,
233 pluginRoot: string,
234 query: RunStatusQuery,
235): Promise<RunStatusRead> {
236 try {
237 return parseRunStatusSummary(await run(runStatusSummaryArgv(pluginRoot, query)))
238 } catch (err) {
239 const detail = err instanceof Error ? err.message : String(err)
240 return { ok: false, reason: 'error', detail: `run_status summary did not run: ${detail}` }
241 }
242}
243
244// ---------------------------------------------------------------------------
245// `run_status.py review`: the latest code review, lens by lens (issue #108)
246//
247// Whether a lens met its bar is the verdict's own rule
248// (`review_consensus.lens_outcomes_for_result`); the script applies it and a
249// mod shows the answer, never recomputing it.
250// ---------------------------------------------------------------------------
251
252/** The view version this module reads; `REVIEW_SCHEMA` in `scripts/run_status.py`. */
253export const KNOWN_REVIEW_VIEW_SCHEMA: SagaReviewViewSchema = 'review_view.v1'
254
255/** Which review `run_status.py review` reports, and from which checkout. */
256export type ReviewViewQuery = {
257 /** The checkout whose run is read: the session's directory. */
258 repoRoot: string
259 /** One issue; left out, the issue the checkout's active saga or `issue/N` branch names. */
260 issue?: number
261}
262
263/** The outcome of reading the review view. A mod shows `detail` rather than guessing. */
264export type ReviewViewRead =
265 | { ok: true; view: SagaReviewView }
266 | { ok: false; reason: 'unknown-version' | 'unreadable' | 'error'; detail: string }
267
268/** The argv that prints the latest code review as JSON. */
269export function runStatusReviewArgv(pluginRoot: string, query: ReviewViewQuery): string[] {
270 const args = ['--repo-root', query.repoRoot, 'review']
271 if (query.issue !== undefined) {
272 requireIssue(query.issue)
273 args.push('--issue', String(query.issue))
274 }
275 args.push('--json')
276 return sagaScriptArgv(pluginRoot, 'run_status.py', args)
277}
278
279/** Turn what `run_status.py review --json` did into the view, or the reason there is none. */
280export function parseRunStatusReview(ran: ProcessResult): ReviewViewRead {
281 const parsed = parseRunStatusObject(ran, 'run_status review')
282 if (!parsed.ok) return parsed
283 const view = parsed.value
284 if (view.schema !== KNOWN_REVIEW_VIEW_SCHEMA) {
285 return {
286 ok: false,
287 reason: 'unknown-version',
288 detail: `review view version ${JSON.stringify(view.schema)} is not ${KNOWN_REVIEW_VIEW_SCHEMA}`,
289 }
290 }
291 const review = view.review as { lenses?: unknown } | null | undefined
292 if (review === undefined || (review !== null && !Array.isArray(review.lenses))) {
293 return { ok: false, reason: 'unreadable', detail: 'run_status review printed no lens list' }
294 }
295 return { ok: true, view: view as unknown as SagaReviewView }
296}
297
298/** Read the review view through `run`. Never rejects, as `readRunRecordWith`. */
299export async function readReviewViewWith(
300 run: ProcessRunner,
301 pluginRoot: string,
302 query: ReviewViewQuery,
303): Promise<ReviewViewRead> {
304 try {
305 return parseRunStatusReview(await run(runStatusReviewArgv(pluginRoot, query)))
306 } catch (err) {
307 const detail = err instanceof Error ? err.message : String(err)
308 return { ok: false, reason: 'error', detail: `run_status review did not run: ${detail}` }
309 }
310}
311
312// ---------------------------------------------------------------------------
313// The review-state document embedded in `run_status.py review` (issue #165)
314//
315// For records with C1 review runs the review view carries the whole
316// `review_state.v1` document under `state`. The panes read the document
317// through this reader alone, never by running the review-state script.
318// ---------------------------------------------------------------------------
319
320/** The document version this module reads; `SCHEMA` in `scripts/review_state.py`. */
321export const KNOWN_REVIEW_STATE_SCHEMA: SagaReviewStateSchema = 'review_state.v1'
322
323/** The outcome of reading the state review. A mod shows `detail` rather than guessing. */
324export type StateReviewRead =
325 | { ok: true; view: SagaReviewView; review: SagaStateReview }
326 | { ok: false; reason: 'unknown-version' | 'unreadable' | 'error'; detail: string }
327
328/** Whether the review is a state review carrying the embedded document. */
329export function isStateReview(review: SagaReview | SagaStateReview | null): review is SagaStateReview {
330 return (
331 review !== null &&
332 typeof (review as SagaStateReview).state_schema === 'string' &&
333 typeof (review as SagaStateReview).state === 'object' &&
334 (review as SagaStateReview).state !== null
335 )
336}
337
338/** Turn what `run_status.py review --json` did into the state review, or the reason there is none. */
339export function parseRunStatusStateReview(ran: ProcessResult): StateReviewRead {
340 const parsed = parseRunStatusObject(ran, 'run_status review')
341 if (!parsed.ok) return parsed
342 const view = parsed.value
343 if (view.schema !== KNOWN_REVIEW_VIEW_SCHEMA) {
344 return {
345 ok: false,
346 reason: 'unknown-version',
347 detail: `review view version ${JSON.stringify(view.schema)} is not ${KNOWN_REVIEW_VIEW_SCHEMA}`,
348 }
349 }
350 const review = view.review as SagaStateReview | null | undefined
351 if (review === null || review === undefined) {
352 return { ok: false, reason: 'unreadable', detail: 'run_status review recorded no review' }
353 }
354 if (!isStateReview(review)) {
355 return { ok: false, reason: 'unreadable', detail: 'run_status review printed no state review' }
356 }
357 if (review.state_schema !== KNOWN_REVIEW_STATE_SCHEMA || review.state.schema !== KNOWN_REVIEW_STATE_SCHEMA) {
358 return {
359 ok: false,
360 reason: 'unknown-version',
361 detail: `review-state document version ${JSON.stringify(review.state_schema)} is not ${KNOWN_REVIEW_STATE_SCHEMA}`,
362 }
363 }
364 return { ok: true, view: view as unknown as SagaReviewView, review }
365}
366
367/** Read the state review through `run`. Never rejects, as `readRunRecordWith`. */
368export async function readStateReviewWith(
369 run: ProcessRunner,
370 pluginRoot: string,
371 query: ReviewViewQuery,
372): Promise<StateReviewRead> {
373 try {
374 return parseRunStatusStateReview(await run(runStatusReviewArgv(pluginRoot, query)))
375 } catch (err) {
376 const detail = err instanceof Error ? err.message : String(err)
377 return { ok: false, reason: 'error', detail: `run_status review did not run: ${detail}` }
378 }
379}
380
381/**
382 * Read the review in either shape through `run`, enforcing the document
383 * version for a state review. One run, two parses: the legacy parse validates
384 * the envelope for both shapes, and a state review is then checked against
385 * the known document token before anything draws from it. Never rejects.
386 */
387export async function readEitherReviewWith(
388 run: ProcessRunner,
389 pluginRoot: string,
390 query: ReviewViewQuery,
391): Promise<ReviewViewRead> {
392 let completed: ProcessResult
393 try {
394 completed = await run(runStatusReviewArgv(pluginRoot, query))
395 } catch (err) {
396 const detail = err instanceof Error ? err.message : String(err)
397 return { ok: false, reason: 'error', detail: `run_status review did not run: ${detail}` }
398 }
399 const parsed = parseRunStatusReview(completed)
400 if (!parsed.ok) return parsed
401 const review = parsed.view.review
402 if (review !== null && isStateReview(review)) {
403 const checked = parseRunStatusStateReview(completed)
404 if (!checked.ok) return checked
405 }
406 return parsed
407}
408
409// ---------------------------------------------------------------------------
410// The setup survey and the machine offer (issue #165)
411//
412// The setup pane draws `saga_setup.py survey --format json` through these
413// readers alone, and the first-session offer reads `offer-status` through one.
414// Recording the offer rides `record-offer`'s exit code, which needs no reader.
415// ---------------------------------------------------------------------------
416
417/** The survey version this module reads; `SURVEY_SCHEMA` in `scripts/saga_setup.py`. */
418export const KNOWN_SETUP_SURVEY_SCHEMA: SagaSetupSurveySchema = 'setup_survey.v1'
419
420/** The machine-record version the offer reads; `MACHINE_SCHEMA` in `scripts/saga_setup.py`. */
421export const KNOWN_MACHINE_RECORD_SCHEMA: SagaMachineRecordSchema = 'machine_record.v1'
422
423/** The outcome of reading the setup survey. A mod shows `detail` rather than guessing. */
424export type SetupSurveyRead =
425 | { ok: true; survey: SagaSetupSurvey }
426 | { ok: false; reason: 'unknown-version' | 'unreadable' | 'error'; detail: string }
427
428/** The outcome of reading the machine offer status. */
429export type OfferStatusRead =
430 | { ok: true; status: SagaOfferStatus }
431 | { ok: false; reason: 'unknown-version' | 'unreadable' | 'error'; detail: string }
432
433/**
434 * The argv that surveys the checkout's tools as JSON. The survey persists the
435 * machine record with `ran` set, so it never serves as the offer check.
436 */
437export function setupSurveyArgv(pluginRoot: string, repo: string): string[] {
438 return sagaScriptArgv(pluginRoot, 'saga_setup.py', ['survey', '--repo', repo, '--format', 'json'])
439}
440
441/** The argv that installs one tool. Install runs one call per ticked tool, in order. */
442export function setupInstallArgv(pluginRoot: string, tool: string, repo: string): string[] {
443 return sagaScriptArgv(pluginRoot, 'saga_setup.py', ['install', '--tools', tool, '--repo', repo])
444}
445
446/** The argv that prints the machine record's `ran` and `offered` without writing. */
447export function setupOfferStatusArgv(pluginRoot: string): string[] {
448 return sagaScriptArgv(pluginRoot, 'saga_setup.py', ['offer-status'])
449}
450
451/** The argv that stamps `offered` on the machine record. */
452export function setupRecordOfferArgv(pluginRoot: string): string[] {
453 return sagaScriptArgv(pluginRoot, 'saga_setup.py', ['record-offer'])
454}
455
456/** The JSON object one setup verb printed, or why there is none. */
457function setupOutput(
458 ran: ProcessResult,
459 command: string,
460): { ok: true; value: Record<string, unknown> } | { ok: false; reason: 'unreadable' | 'error'; detail: string } {
461 if (ran.exitCode !== 0) {
462 const stderr = ran.stderr.trim()
463 return { ok: false, reason: 'error', detail: stderr === '' ? `${command} exited ${ran.exitCode}` : stderr }
464 }
465 if (ran.isStdoutTruncated) {
466 return { ok: false, reason: 'unreadable', detail: `${command} printed past the stdout cap` }
467 }
468 let value: unknown
469 try {
470 value = JSON.parse(ran.stdout)
471 } catch {
472 value = undefined
473 }
474 if (typeof value !== 'object' || value === null || Array.isArray(value)) {
475 return { ok: false, reason: 'unreadable', detail: `${command} printed no JSON object` }
476 }
477 return { ok: true, value: value as Record<string, unknown> }
478}
479
480/** Turn what `saga_setup.py survey --format json` did into the survey, or the reason there is none. */
481export function parseSetupSurvey(ran: ProcessResult): SetupSurveyRead {
482 const parsed = setupOutput(ran, 'saga_setup survey')
483 if (!parsed.ok) return parsed
484 const survey = parsed.value
485 if (survey.schema !== KNOWN_SETUP_SURVEY_SCHEMA) {
486 return {
487 ok: false,
488 reason: 'unknown-version',
489 detail: `setup survey version ${JSON.stringify(survey.schema)} is not ${KNOWN_SETUP_SURVEY_SCHEMA}`,
490 }
491 }
492 if (!Array.isArray(survey.tools)) {
493 return { ok: false, reason: 'unreadable', detail: 'saga_setup survey printed no tool rows' }
494 }
495 return { ok: true, survey: survey as unknown as SagaSetupSurvey }
496}
497
498/** Turn what `saga_setup.py offer-status` did into the offer status, or the reason there is none. */
499export function parseOfferStatus(ran: ProcessResult): OfferStatusRead {
500 const parsed = setupOutput(ran, 'saga_setup offer-status')
501 if (!parsed.ok) return parsed
502 const status = parsed.value
503 if (status.schema !== KNOWN_MACHINE_RECORD_SCHEMA) {
504 return {
505 ok: false,
506 reason: 'unknown-version',
507 detail: `machine record version ${JSON.stringify(status.schema)} is not ${KNOWN_MACHINE_RECORD_SCHEMA}`,
508 }
509 }
510 if (typeof status.ran !== 'boolean' || typeof status.offered !== 'boolean') {
511 return { ok: false, reason: 'unreadable', detail: 'saga_setup offer-status printed no ran and offered flags' }
512 }
513 return { ok: true, status: status as unknown as SagaOfferStatus }
514}
515
516/** Read the setup survey through `run`. Never rejects, as `readRunRecordWith`. */
517export async function readSetupSurveyWith(
518 run: ProcessRunner,
519 pluginRoot: string,
520 repo: string,
521): Promise<SetupSurveyRead> {
522 try {
523 return parseSetupSurvey(await run(setupSurveyArgv(pluginRoot, repo)))
524 } catch (err) {
525 const detail = err instanceof Error ? err.message : String(err)
526 return { ok: false, reason: 'error', detail: `saga_setup survey did not run: ${detail}` }
527 }
528}
529
530/** Read the machine offer status through `run`. Never rejects, as `readRunRecordWith`. */
531export async function readOfferStatusWith(run: ProcessRunner, pluginRoot: string): Promise<OfferStatusRead> {
532 try {
533 return parseOfferStatus(await run(setupOfferStatusArgv(pluginRoot)))
534 } catch (err) {
535 const detail = err instanceof Error ? err.message : String(err)
536 return { ok: false, reason: 'error', detail: `saga_setup offer-status did not run: ${detail}` }
537 }
538}
539mods/review-findings.ts 155 lines1// The words the review findings pane (issue #108) draws, kept apart from the
2// pane so they are plain functions of the review view and test without an engine.
3//
4// Nothing here decides anything: whether a lens met its bar arrives from
5// `scripts/run_status.py review`, which applies the verdict's own rule. These
6// only turn that view into labels and prompt text.
7
8import type {
9 SagaReview,
10 SagaReviewFinding,
11 SagaReviewLens,
12 SagaReviewLensState,
13 SagaReviewState,
14 SagaStateCost,
15 SagaStateFinding,
16 SagaStateLens,
17 SagaStateReview,
18 SagaStateRound,
19 SagaStateWhereToLook,
20} from '../types/index.d.ts'
21
22/** The mod's own page budget is 10,000 characters; a finding's text stays well under it. */
23export const FINDING_TEXT_LIMIT = 1_500
24
25/** The pseudo-lens that lists findings whose lens is not in the review's lens list. */
26export const OTHER_FINDINGS = '(other findings)'
27
28/** How each state reads in the pane; `run_status.py`'s `STATE_WORDS` says the same. */
29export const STATE_WORDS: Record<SagaReviewLensState, string> = {
30 met: 'met',
31 not_met: 'not met',
32 not_run: 'not run',
33 unscored: 'unscored',
34}
35
36function plural(count: number, word: string): string {
37 return `${count} ${word}${count === 1 ? '' : 's'}`
38}
39
40/**
41 * One lens's row in the lens list. A lens with no usable result says why
42 * instead of showing any number, so it cannot read as a low score; a lens that
43 * did not run shows a finding count only when it somehow has findings.
44 */
45export function lensLabel(lens: SagaReviewLens): string {
46 const parts = [lens.lens, STATE_WORDS[lens.state] ?? lens.state]
47 if (lens.state !== 'not_run' || lens.finding_count > 0) parts.push(plural(lens.finding_count, 'finding'))
48 if ((lens.state === 'not_run' || lens.state === 'unscored') && lens.reason) parts.push(`(${lens.reason})`)
49 return parts.join(' ')
50}
51
52/** `P1 path:line category`, one dim line under a lens row. */
53export function findingLine(finding: SagaReviewFinding): string {
54 return `${finding.severity} ${finding.path}:${finding.line} ${finding.category}`
55}
56
57/** `P1 · path:line · category`, the heading of a finding in a lens's list. */
58export function findingHeading(finding: SagaReviewFinding): string {
59 return `${finding.severity} · ${finding.path}:${finding.line} · ${finding.category}`
60}
61
62/** `text`, cut at `limit` characters with an ellipsis. */
63export function truncate(text: string, limit: number = FINDING_TEXT_LIMIT): string {
64 return text.length <= limit ? text : `${text.slice(0, limit - 1)}…`
65}
66
67/** A finding's evidence and impact as the Markdown drawn under its heading. */
68export function findingText(finding: SagaReviewFinding): string {
69 return truncate(`${finding.evidence}\n\n**Impact:** ${finding.impact}`)
70}
71
72function quoted(text: string): string {
73 return text
74 .split('\n')
75 .map((line) => (line === '' ? '>' : `> ${line}`))
76 .join('\n')
77}
78
79/** The text a finding's Quote button puts in the prompt: a block quote naming its lens and place. */
80export function quoteText(lens: string, finding: SagaReviewFinding): string {
81 const head = `[${finding.severity} · ${lens}] ${finding.path}:${finding.line} — ${finding.category}`
82 return `${quoted(`${head}\n${finding.evidence}\nimpact: ${finding.impact}`)}\n\n`
83}
84
85/** The header line over the lens list: issue, cycle, outcome, and the reviewed revision. */
86export function reviewHeading(issue: number | null, review: SagaReview): string {
87 const parts = [`#${issue ?? '?'}`, review.unit, `cycle ${review.cycle}`, review.outcome, review.revision.slice(0, 12)]
88 return parts.join(' · ')
89}
90
91/** The findings listed for `lens`, which may be `OTHER_FINDINGS`. */
92export function findingsFor(review: SagaReview, lens: string): SagaReviewFinding[] {
93 if (lens === OTHER_FINDINGS) return review.unattributed_findings
94 return review.lenses.find((one) => one.lens === lens)?.findings ?? []
95}
96
97// ---------------------------------------------------------------------------
98// The live review pane (issue #165): labels over the review-state document.
99// ---------------------------------------------------------------------------
100
101/** `testing A 0 blocking 1 fix later`, one row in the grades list. */
102export function gradeLabel(lens: SagaStateLens): string {
103 return `${lens.lens} ${lens.grade} ${lens.blocking} blocking ${lens.fix_later} fix later`
104}
105
106/** `lens file:start-end answered finding-id questions: a, b`, one where-to-look row. */
107export function whereToLookLabel(item: SagaStateWhereToLook): string {
108 const verdict =
109 item.state === 'answered' ? `answered ${item.finding_id ?? '?'}` : `cleared: ${item.reason ?? 'no reason'}`
110 const questions = item.questions.length > 0 ? ` questions: ${item.questions.join(', ')}` : ''
111 return `${item.lens} ${item.location} ${verdict}${questions}`
112}
113
114/** `round 2 1 new blocking 0 cleared`, the heading of one round block. */
115export function roundLabel(round: SagaStateRound): string {
116 return `round ${round.round} ${round.new_blocking.length} new blocking ${round.cleared_blocking.length} cleared`
117}
118
119/** `tokens 10 in / 20 out $0.05 30s`, the cost so far. */
120export function costLine(cost: SagaStateCost): string {
121 return `tokens ${cost.tokens_in} in / ${cost.tokens_out} out $${cost.cost_usd} ${cost.seconds}s`
122}
123
124/** The header line over the live pane: issue, round, outcome, and the reviewed revision. */
125export function stateHeading(issue: number | null, review: SagaStateReview): string {
126 const parts = [`#${issue ?? '?'}`, `round ${review.round}`, review.outcome, review.revision.slice(0, 12)]
127 return parts.join(' · ')
128}
129
130/** `fix-later · testing · rf:abc… · guard`, the heading of a document finding. */
131export function stateFindingHeading(finding: SagaStateFinding): string {
132 return `${finding.severity} · ${finding.lens} · ${finding.id}${finding.guard ? ' · guard' : ''}`
133}
134
135/** A document finding's statement and merge outcome as the Markdown drawn under its heading. */
136export function stateFindingText(finding: SagaStateFinding): string {
137 const outcome =
138 finding.merge_outcome === null ? 'unanswered' : `merge outcome: ${JSON.stringify(finding.merge_outcome)}`
139 return truncate(`${finding.statement}\n\n**Outcome:** ${outcome}`)
140}
141
142/** The text a document finding's Quote button puts in the prompt. */
143export function stateQuoteText(finding: SagaStateFinding): string {
144 return `${quoted(`[${finding.severity} · ${finding.lens}] ${finding.id} — ${finding.statement}`)}\n\n`
145}
146
147/** The document findings listed for `lens`, which may be `OTHER_FINDINGS`. */
148export function stateLensFindings(state: SagaReviewState, lens: string): SagaStateFinding[] {
149 if (lens === OTHER_FINDINGS) {
150 const known = new Set(state.lenses.map((one) => one.lens))
151 return state.findings.filter((finding) => !known.has(finding.lens))
152 }
153 return state.findings.filter((finding) => finding.lens === lens)
154}
155