SLOPSHOPPER

com.infiquetra.claude

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

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · com.infiquetra.claude
│ ┃ saga-plan ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ No plan loaded. Run /plan-view, /plan-view │ com.infiquetra.claude │ │ ┃ #issue or /plan-view path. ● com.infiquetra.claud│ saga: could not read the machine record │ │ ● com.infiquetra.claud│ (unreadable): saga_setup offer-status │ │ ⏺ Read(src/auth.ts) │ printed no JSON object │ │ ⎿ Read 6 lines ╰────────────────────────────────────────────╯ │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /plan-view │ ⎿ com.infiquetra.claude: plan-view: could not read the saga run (u │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · saga-plan
No plan loaded. Run /plan-view, /plan-view #issue or /plan-view path.
Pane · saga-admission
No admission review is open.
Pane · saga-merge
No merge confirmation is open.
Pane · saga-review
No review loaded. Run /review-view or /review-view #issue.
Pane · saga-setup
No setup is open.
Pane · saga-record
No run record loaded. Press Record on the saga run band.
README

Infiquetra Agent Plugins

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.

Status

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:

  • Vendor-neutral core at the package root (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.
  • Claude-only behaviour under 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).
  • Codex packaging at 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.
  • Fleet Core is a library, bundled at build time into each consumer's _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.
  • Tests travel with the package under 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:

Packages

PackageVersionDescriptionInstallable surfaces
plugins/agent-launcher/1.7.1Create one verified coding-agent session through the agents wrapper and HerdrClaude, Codex, 1 skill
plugins/agy/0.6.2Antigravity-backed coder reviewer bridge agents that run in a disposable clone and…Claude, Codex, 1 skill
plugins/codex/0.1.5Portable Codex delegation wrapperClaude, Codex, 1 skill
plugins/deploy/0.2.3Tag-promotion deployment operations for Infiquetra repositoriesClaude, Codex, 1 skill
plugins/fleet-core/0.32.0Authored Fleet Core library: staffing (work shape, role, and lens to model and…Codex
plugins/hermes-profile-evolution/0.1.5Portable request adapter for target-sovereign Hermes profile evolutionClaude, Codex, 1 skill
plugins/home-lab-ops/1.2.2Proxmox VE cluster operations, Ansible pre-flight validation, Ceph management,…Claude, Codex, 6 skills
plugins/house-style/0.1.1Claude-only packageClaude, Codex
plugins/mission-control/2.21.1SDLC management for Operations, Asgard, and CAMPPS: prepared issue drafts, live schema…Claude, Codex, 8 skills
plugins/orchestrate/6.0.1Run one piece of work across several herdr agent sessions, one git worktree per unitClaude, Codex, 1 skill
plugins/redis-channel/0.5.3Portable Redis Streams bridgeClaude, Codex, 1 skill
plugins/saga/1.2.1Infiquetra lifecycle plugin: one automatic run per issue — admission, plan, plan…Claude, Codex, 14 skills
plugins/unifi/2.0.7Portable UniFi Network and Protect package: two Agent Skills with their bundled Python…Claude, Codex, 2 skills
plugins/voice/0.4.0Portable voice package: a spoken conversational loop for one explicitly bound,…Claude, Codex, 1 skill

Portable Fleet Core scope

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.

Operator site profile

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.

Repository layout

PathPurpose
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.mdPlace 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.

Validation

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.

Development

  • Read AGENTS.md before changing the repository.
  • Use conventional commit messages.
  • Use pull requests after the initial repository bootstrap.
  • Update the engineering journal when work creates a durable learning, repository decision, or deferred item.
  • Do not commit credentials, generated installed copies, or local agent state.

Public development guidance is summarized in docs/public-safe-summary.md.

Source 13 files
mods/index.ts 28 lines
1// 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}
28
mods/admission-review.tsx 509 lines
1// 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}
509
mods/agent-types.ts 203 lines
1// 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}
203
mods/merge-confirmation.tsx 463 lines
1// 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}
463
mods/plan-viewer.tsx 328 lines
1// 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}
328
mods/review-pane.tsx 343 lines
1// 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}
343
mods/setup-pane.tsx 347 lines
1// 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}
347
mods/usage-capture.ts 319 lines
1// 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}
319
mods/run-band.tsx 219 lines
1// 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}
219
types/index.d.ts 670 lines
1// 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}
670
mods/run-record.ts 539 lines
1// 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}
539
mods/review-findings.ts 155 lines
1// 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