SLOPSHOPPER

riff-cockpit

RIFF band, /riff pane with tabs, /riff-status and /riff-context, read from the RIFF CLI

newpanebandguardcommandprocess
★ 1v0.2.1no licenseupdated 2026-10-07alexadark/riff-codex/riff/hosts/claude-code/riff-cockpit
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · riff-cockpit
│ ┃ riff ✕ › fix the failing auth test and add an audit log call │ ┃ Not a RIFF project: no .riff-cli or │ ┃ .riff-codex link at the repository root. ⏺ Read(src/auth.ts) │ ⎿ 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 │ │ › /riff │ ⎿ riff-cockpit: Not a RIFF project: no .riff-cli or .riff-codex li │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · riff
Not a RIFF project: no .riff-cli or .riff-codex link at the repository root.
README
██████╗ ██╗███████╗███████╗
██╔══██╗██║██╔════╝██╔════╝
██████╔╝██║█████╗  █████╗
██╔══██╗██║██╔══╝  ██╔══╝
██║  ██║██║██║     ██║
╚═╝  ╚═╝╚═╝╚═╝     ╚═╝

RIFF

Build like a band of six. Ship like one.

Turn an idea into working software, one checked step at a time, in Claude Code or Codex.

You describe what you want. RIFF helps your coding agent keep a clear plan, build it in useful steps, have another model review the result, and remember where to continue. You can follow the work without having to direct every move.

RIFF runs in two coding tools, called hosts: Claude Code and Codex. Connecting a project sets it up for both, so you can switch hosts without reinstalling. The slogan comes from the original RIFF framework; here, the agent in your host coordinates the work and brings in extra help when it's useful.

Visual field manual · Installation · Everyday use · Model advice · How it works

The visual manual is available online at riff-codex-doc.vercel.app.

What RIFF does for you

  • Plans the committed version. A product brief indexes stories, journeys, data and architecture contracts, wireframes, design references, risk checks and acceptance criteria.
  • Breaks the work into useful steps. Each step should produce something you can try.
  • Builds and checks. The agent implements a step, checks it, and gets an independent review before recording it as complete.
  • Has another model review the work. riff review run asks a model from another family, GPT-6 Astra by default, to attack each phase, dossier and final version read-only.
  • Remembers progress. You can return later, in either host, and continue the same project.
  • Keeps useful conventions. Project taste and stack research guide implementation; frontend work uses design skills and rendered browser checks. See taste and learn-stack.
  • Shows where things stand. A local dashboard displays the plan, completed work, reviews, and anything blocked.

You still need Claude Code or Codex installed and signed in. The Codex CLI is recommended in both cases, since it runs the default reviewer.

How the work flows

flowchart TD
    You[You describe what you want] --> Brief[Complete version dossier and design]
    Brief --> Plan[Roadmap and independent planning review]
    Plan --> Pick[Choose the next ready step]
    Pick --> Build[The agent builds it]
    Build --> Check[Check, then review by another model]
    Check -->|Pass| Save[Save the checked result and progress]
    Check -->|Fail| Fix[Try a focused correction]
    Fix --> CheckAgain[Check again]
    CheckAgain -->|Pass| Save
    CheckAgain -->|Still fails| Stop[Record the blocker]
    Save -->|More ready work| Pick
    Save -->|All phases complete| Final[Whole-version checks and independent review]
    Final -->|Pass| Done[Verified local result]
    Final -->|Defect| Correction[Plan an in-scope correction phase]
    Correction --> Plan
    Plan -.-> View[Dashboard: view progress]
    Save -.-> View
    Stop -.-> View

The brief is saved as PROJECT.md; the steps are saved as ROADMAP.yaml. For production applications, start prepares the complete version dossier and an independent planning review before implementation. ASCII wireframes can be handed to another design model; its returned reference and tokens become part of the dossier. The dashboard reads progress. You start the work in Claude Code or Codex.

By default, RIFF continues through ready steps automatically. You can choose guided mode if you prefer a pause between steps. Uploading code to GitHub or putting an application online still needs your instruction.

Optional model advice

RIFF can advise which available model and reasoning effort fit a task. Ask in ordinary language before a RIFF request or phase:

Before you start this RIFF phase, advise which model and reasoning effort fit it.
Do not begin the work or change models.

RIFF leaves the primary model selected in your host unchanged. During a wave it also advises profiles for the already planned subagents; the coordinating agent applies verified profiles through native launch tools when delegation is authorized. Under Claude Code, each Anthropic profile is installed as a subagent with its model and effort fixed, such as riff:sonnet-5-5-medium. A plan does not itself start work, create workers, or pause a loop. The model actually used remains unknown unless the runtime or the user supplies evidence.

The default local mode means the current agent applies RIFF's portable profile catalogue. It does not mean on-device inference. Optional jev mode sends only a curated decision summary to Jev through OpenRouter and requires an explicit project opt-in. OpenAI models continue to use the Codex subscription; conditional DeepSeek profiles use Ollama Cloud and are a separate form of consumption.

As a guide, Luna fits bounded work, Sol fits well-scoped work with several connected steps, and Astra fits planning or long runs with costly dependencies. On the Anthropic side, Haiku fits simple reading, Sonnet bounded changes and exploration, and Opus the default for coding and long work. High and XHigh reasoning are for a concrete difficulty, not task length alone. These are decision hypotheses, not measured success rates. See model advice for modes, privacy controls, natural-language examples, and the advanced CLI.

What the latest update changes

  • RIFF runs in Claude Code. riff init now sets up a project for Claude Code and Codex together. In Claude Code, skills are called as /riff:wave, model profiles are installed as subagents, and RIFF's context is injected when a session starts, resumes, or restarts after /clear or a compaction. Compaction is set to 400,000 tokens for the project. See installation.
  • The command is now riff. riff-codex remains an alias. Project state moves from .riff-codex-state/ to .riff-data/ on the next riff resync, with a backup.
  • Another model reviews the work. riff review run has GPT-6 Astra attack each phase, dossier and final version read-only through the Codex CLI, with GPT-6.1 Sol and then a fresh Claude session as fallbacks when a reviewer can't run. The receipt names the reviewer that really ran. Security findings are corrected without stopping the wave; points that need an expert ship with a documented interim decision and are listed at the end. See independent reviews.
  • Planning has a readiness gate. New production application plans include a manifest of their contracts, diagrams and design references. A content-bound independent review must match that dossier before implementation begins. Existing projects aren't silently enrolled.
  • Phases keep a durable checkpoint. The host's own compaction stays in use; RIFF doesn't relaunch the host or claim to reset a conversation. Checkpoints and targeted reference loading support recovery without the previous chat.
  • Parallel work has boundaries. Independent implementation packages use separate worktrees inside one active phase. The primary agent integrates and verifies the assembled result. Independent reviews can run in parallel on a frozen candidate.
  • The whole version is checked. After per-phase verification, the agent exercises the connected journeys and obtains an independent delivery review. Corrections follow the normal phase gates.
  • Completion needs evidence. A phase must pass executed checks and the required reviews for the exact version being delivered. Required browser or smoke checks need recorded results. Local verification reports make those results inspectable.
  • The agent handles observations every phase. It fixes confirmed problems within scope and records verified dispositions. Enrolled projects require explicit follow-up phases for nonblocking pending items; HIGH and CRITICAL findings are corrected before completion, except security points that need an expert, which get a documented interim decision. The dashboard preserves decisions and reopens new occurrences.
  • Existing products can evolve deliberately. evolve challenges a requested change against the current application, updates only the affected plan, and stops at planning readiness. wave remains the separate implementation step.
  • Fewer, broader skills. onboard now includes mapping, debug covers live incidents, and start covers promotion of a prototype to production. The former map, incident and promote skills are gone.

Do existing projects need a refresh?

For this update, run riff resync and riff doctor once in each connected project, after its active work finishes. This updates local installation files for both hosts, moves the project state to .riff-data/ with a backup, and preserves the project brief, roadmap and preferences. If a phase is still active, resync waits to move the state and says so.

Projects linked to the same RIFF checkout already read its updated files; they don't each need a Git pull or a reinstall. Start a fresh session to load the updated skill instructions. A separately installed plugin or another checkout must be updated separately.

To use Claude Code, also register the plugin marketplace once per computer, as shown in Install. If a dashboard was already running, restart that dashboard process and reload the page to load server changes. See Update RIFF for commands and hook approval details.

Install

You need Git, Node.js 20 or newer (which includes npm), Bun for the dashboard and the full installation check, and Claude Code or Codex. The repository is currently private, so your GitHub account needs access. The installation guide explains each requirement.

1. In Terminal, download RIFF once. Keep this folder in place; your projects will use it.

git clone https://github.com/alexadark/riff-codex.git ~/riff-codex
cd ~/riff-codex
npm install
npm link

2. For Claude Code, register the plugin once per computer. Skip this if you only use Codex.

claude plugin marketplace add ~/riff-codex/riff

3. In Terminal, connect your project to RIFF. Replace the example path with your project's folder. It must already use Git; the guide includes the new-folder setup.

cd /path/to/your-project
riff init

Choose your languages, how much explanation you want, and whether RIFF should continue automatically. Press Enter to accept the suggested choices. Then check the setup:

riff doctor

4. Open the project in your host.

  • Claude Code: start a new session in the project. Claude Code installs the RIFF plugin on the first session, and the hooks are already declared in .claude/settings.local.json.
  • Codex: start a fresh session so it finds the installed RIFF skills. If riff doctor reports system-managed hooks, reload Codex configuration; no individual hook approval is required. Otherwise, open /hooks, review and approve RIFF's automatic checks, then record that approval in the project's terminal with riff doctor --record-hooks-approved. This records your approval and checks the setup; it doesn't approve anything for you.

5. Recommended: sign the Codex CLI in to ChatGPT with codex login, even if you build in Claude Code. It runs the default reviewer. Without it, reviews fall back to a fresh Claude session, labeled same-family.

Start using it

The examples below go in the conversation, not Terminal. Claude Code uses /riff:... and Codex uses $riff:...; the skills are the same. In Codex, if those names are unavailable, choose the corresponding RIFF skill from the skill picker; the installation guide explains the two ways to make Codex skills available.

For a new idea:

/riff:start I want a simple app where I can save and find my recipes.

For an application that already exists:

/riff:onboard Map the current application and establish its existing behavior as the RIFF baseline.
/riff:evolve Plan how to make finding a recipe easier.

Production start prepares and reviews the complete dossier, including design before building. onboard maps the application and preserves its documents and behavior. Once preparation is complete, start building:

/riff:wave

A “wave” means working through the next ready steps. Run the same command when you return to continue unfinished work. If RIFF has recorded a blocker, it needs to be resolved before that work can resume.

Branches and pull requests

RIFF keeps one branch and one PR for a coherent evolution or initial version across successive waves. When you authorize publication, it opens a draft after the first validated phase and updates that same PR as later phases complete. In loop, creating a PR doesn't pause work; guided keeps its existing between-phase pauses. After whole-version verification and finish --check, RIFF marks the PR ready for review and returns its verified URL. Merge and deployment require their own authorization.

Without publication authorization, RIFF completes local work and prepares the PR description before requesting delivery. A standalone quick change follows the same branch/PR workflow with its bounded checks. The skills perform Git and GitHub operations; the CLI verification commands don't push or create PRs themselves. See the Git delivery contract for resume and concurrent-work rules.

For an existing application that isn't connected to RIFF, the complete path is explicit:

riff init                       # Terminal, once for this project
/riff:onboard                   # Conversation: map the system and establish the baseline
/riff:evolve Add team invitations while preserving individual accounts.
/riff:wave                      # Conversation: implement the reviewed plan

onboard inspects the current system and, when the map will be reused, saves it as .riff-data/MAP.md. It records the existing behavior as a baseline; it doesn't invent historical delivery phases. If the application is already onboarded, use evolve directly for a product change that needs reconsideration or affects more than one future phase. Evolve analyzes the current code, data and access boundaries, preserves completed and active phase contracts, and ends at planning readiness. It never starts a wave itself.

What to ask next

You want to…Claude CodeCodex
See progress and the next step/riff:status$riff:status
Open the dashboard/riff:dashboard$riff:dashboard
Make a small, separate change/riff:quick Make the empty screen easier to understand.$riff:quick ...
Fix something that is broken/riff:debug The save button does nothing.$riff:debug ...
Add one already-clear outcome/riff:add-phase Let people share a recipe.$riff:add-phase ...
Reconsider an existing product change/riff:evolve Let recipe owners invite teammates.$riff:evolve ...
Continue building/riff:wave$riff:wave

You can also open the dashboard from Terminal with riff dashboard. It normally opens at http://127.0.0.1:4000 on your computer.

Read more when you need it

GuideWhat it answers
Visual field manualSee the whole workflow, explore the diagram, and copy starter commands.
InstallationWhat do I need? How do I connect a project in each host, set up reviews, update RIFF, or fix setup problems?
Everyday useWhat should I type for a new project, existing app, fix, or interruption?
Model adviceHow do I ask for advice, choose a mode, and understand what was actually selected?
How it worksWho does what? Where does the plan live? How do checks, reviews and progress connect?
Technical referenceWhich files, checks, and internal rules power RIFF?

Already using the original RIFF framework, with its .riff and .riff-state/ folders? Both can share the same project brief and roadmap. Use only one of them to work on a given step at a time. See the coexistence details.

Source 2 files
hooks/register.ts 163 lines
1// RIFF cockpit: a band above the prompt with the project's RIFF progress, a /riff pane
2// with Roadmap, Phase, Findings and Reviews tabs, and /riff-status and /riff-context,
3// which answer at once without a Claude turn. Outside a RIFF project it draws nothing.
4//
5// Refreshed when the session starts, after each main turn and after each Bash call that
6// runs the RIFF CLI. The mod never writes RIFF state: it runs the project's own CLI
7// through the `.riff-cli` link (`.riff-codex` before the rename), so it shows exactly what `status --json` and
8// `wave context --json` report.
9
10import {
11  clip,
12  describe,
13  describeContext,
14  parseContext,
15  parseStatus,
16  summary,
17  tabLines,
18  TAB_LABELS,
19  TABS,
20  type RiffStatus,
21  type Tab,
22  type WaveContext,
23} from './core.ts'
24
25// Node may be missing from the app's PATH (nvm), so known locations follow.
26const NODES = ['node', '/usr/local/bin/node', '/opt/homebrew/bin/node']
27const PANE = 'riff'
28const NOT_RIFF = 'Not a RIFF project: no .riff-cli or .riff-codex link at the repository root.'
29// Projects not yet migrated by `riff resync` only have the pre-rename link.
30const LINKS = ['.riff-cli', '.riff-codex']
31const RIFF_COMMAND = /(^|[\s;&|/])(riff|riff-codex|riff\.mjs)(\s|$)/
32
33let status: RiffStatus | null = null
34let raw: string | null = null
35let context: WaveContext | null = null
36let contextError: string | null = null
37let tab: Tab = 'roadmap'
38
39export function register(on) {
40  on('session.start', async ($, e, next) => {
41    const result = await next(e)
42    await $.command.register({ name: 'riff', description: 'Open the RIFF cockpit: roadmap, phase, findings, reviews' })
43    await $.command.register({ name: 'riff-status', description: 'RIFF progress for this project, read from the RIFF CLI' })
44    await $.command.register({ name: 'riff-context', description: 'The RIFF phase in progress, its checkpoint and references' })
45    await refresh($)
46    return result
47  })
48
49  on('turn.complete', async ($, e, next) => {
50    const result = await next(e)
51    if (!e.agentId) await refresh($)
52    return result
53  })
54
55  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
56    const ran = await next(e)
57    if (RIFF_COMMAND.test(e.command ?? '')) await refresh($)
58    return ran
59  })
60
61  on('command.run', { command: 'riff-status' }, async ($) => {
62    await refresh($)
63    if (status) return { text: describe(status) }
64    return { text: raw ?? NOT_RIFF }
65  })
66
67  on('command.run', { command: 'riff-context' }, async ($) => {
68    await refresh($)
69    return { text: status ? describeContext(context, contextError) : NOT_RIFF }
70  })
71
72  on('command.run', { command: 'riff' }, async ($) => {
73    await refresh($)
74    if (!status) return { text: NOT_RIFF }
75    const opened = await $.ui.open({ id: PANE, title: `RIFF · ${status.name}`, closeOnEscape: true })
76    if (opened.isPlaced) return { text: 'RIFF cockpit opened.' }
77    // No pane here (narrow terminal, an extension, a headless run): answer in text.
78    return { text: TABS.map((one) => `## ${TAB_LABELS[one]}\n${tabLines(one, status!, context, contextError).join('\n')}`).join('\n\n') }
79  })
80
81  on('ui.render', { component: 'Pane', requestId: PANE }, ($, e) => {
82    const { Box, Text, Button } = $.ui.resolve(e)
83    if (!status) return Text({ dimColor: true, children: NOT_RIFF })
84    const columns = e.props?.bodyColumns ?? e.viewport?.columns ?? 80
85    const room = Math.max(1, (e.viewport?.rows ?? 24) - 4)
86    const tabs = TABS.map((one, index) =>
87      Button({
88        key: `tab-${one}`,
89        hotkey: String(index + 1),
90        plain: true,
91        dimColor: one !== tab,
92        label: TAB_LABELS[one],
93        onPress: () => {
94          tab = one
95          $.ui.invalidate('ui.render')
96        },
97      }),
98    )
99    const lines = tabLines(tab, status, context, contextError)
100    return Box({
101      flexDirection: 'column',
102      children: [
103        Box({ flexDirection: 'row', gap: 2, children: tabs }),
104        ...lines.slice(0, room).map((line) => Text({ children: clip(line, Math.max(20, columns - 2)) })),
105        ...(lines.length > room ? [Text({ dimColor: true, children: `… ${lines.length - room} more` })] : []),
106      ],
107    })
108  })
109
110  on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => {
111    if ((e.props?.hasSurvey ?? e.hasSurvey) || !status) return next(e)
112    const { Box, Text } = $.ui.resolve(e)
113    const columns = e.props?.bodyColumns ?? e.bodyColumns ?? 80
114    const rows = [
115      Box({
116        flexDirection: 'row',
117        children: [
118          Text({ color: 'magenta', bold: true, children: 'RIFF  ' }),
119          Text({ children: summary(status, columns) }),
120        ],
121      }),
122    ]
123    if (status.humanAction) {
124      rows.push(Text({ color: 'red', children: clip(`Needs you: ${status.humanAction}`, Math.max(20, columns - 2)) }))
125    }
126    return Box({ flexDirection: 'column', paddingX: 1, children: rows })
127  })
128}
129
130async function refresh($) {
131  const cwd = await $.session.cwd()
132  const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd, timeoutMs: 5000 })
133  const root = top.exitCode === 0 ? top.stdout.trim() : null
134  let script: string | null = null
135  for (const link of root ? LINKS : []) {
136    if (await $.fs.exists(`${root}/${link}/bin/riff.mjs`)) { script = `${root}/${link}/bin/riff.mjs`; break }
137  }
138  // An older CLI ignores --json on status and prints its text output, which parseStatus also reads.
139  const statusRun = script ? await runCli($, script, root, ['status', '--json']) : null
140  raw = statusRun?.ok ? statusRun.text : null
141  status = raw ? parseStatus(raw) : null
142  // `wave context` has always printed JSON; no flag keeps older CLIs answering.
143  const contextRun = status ? await runCli($, script, root, ['wave', 'context']) : null
144  context = contextRun?.ok ? parseContext(contextRun.text) : null
145  contextError = contextRun && !contextRun.ok ? contextRun.text : null
146  $.ui.invalidate('ui.render')
147}
148
149// The CLI's answer, or the first line of its error so the cockpit can say why it failed.
150async function runCli($, script: string, root: string, args: string[]): Promise<{ ok: boolean; text: string }> {
151  let failure = { ok: false, text: 'node was not found' }
152  for (const node of NODES) {
153    try {
154      const run = await $.process.run([node, script, ...args], { cwd: root, timeoutMs: 10000 })
155      if (run.exitCode === 0) return { ok: true, text: run.stdout.trim() }
156      failure = { ok: false, text: (run.stderr || run.stdout).trim().split('\n')[0] || `exit code ${run.exitCode}` }
157    } catch {
158      // try the next location
159    }
160  }
161  return failure
162}
163
hooks/core.ts 156 lines
1// Pure rules for the RIFF cockpit: read `riff status --json` and `wave context --json`,
2// decide what the band, the pane tabs and the text commands show.
3// The CLI stays the source of truth; the text parser only serves projects linked to an older CLI.
4
5type Phase = { id: string; title: string | null; status: string }
6type Finding = { id: string; severity: string | null; summary: string | null; phase: string | null }
7type Review = { phase: string; status: string; summary: string | null; valid: boolean }
8
9export type RiffStatus = {
10  name: string
11  objective: string | null
12  completed: number
13  total: number
14  active: string | null
15  next: string | null
16  humanAction: string | null
17  // Absent when the project's CLI predates these fields.
18  phases: Phase[] | null
19  findings: Finding[] | null
20  reviews: { functional: Review | null; security: Review | null } | null
21}
22
23export type WaveContext = {
24  phase: { id: string; title: string | null; status: string; outcome: string | null } | null
25  checkpoint: { summary?: string; next?: string } | null
26  checkpointStale: boolean
27  references: string[]
28}
29
30export const TABS = ['roadmap', 'phase', 'findings', 'reviews'] as const
31export type Tab = (typeof TABS)[number]
32export const TAB_LABELS: Record<Tab, string> = { roadmap: 'Roadmap', phase: 'Phase', findings: 'Findings', reviews: 'Reviews' }
33
34const none = (value: string) => (value === 'none' ? null : value)
35
36/** Reads `riff status --json`, falling back to the text output of CLIs older than the JSON contract. */
37export function parseStatus(text: string): RiffStatus | null {
38  return parseStatusJson(text) ?? parseStatusText(text)
39}
40
41function parseStatusJson(text: string): RiffStatus | null {
42  let data
43  try {
44    data = JSON.parse(text)
45  } catch {
46    return null
47  }
48  if (data?.schema !== 'riff.status/1') return null
49  return {
50    name: data.project?.name ?? 'Unshaped project',
51    objective: data.project?.objective ?? null,
52    completed: data.progress.completed,
53    total: data.progress.total,
54    active: data.active ?? null,
55    next: data.next?.id ?? null,
56    humanAction: data.humanAction?.reason ?? null,
57    phases: data.phases ?? null,
58    findings: data.findings ?? null,
59    reviews: data.reviews ?? null,
60  }
61}
62
63function parseStatusText(text: string): RiffStatus | null {
64  const lines = String(text).trim().split('\n')
65  const progress = lines[1]?.match(/^(\d+)\/(\d+) phases completed\. Active: (.+?)\. Next: (.+?)\.$/)
66  const human = lines[2]?.match(/^Human action: (.+)$/)
67  if (!progress || !human) return null
68  const split = lines[0].indexOf(': ')
69  return {
70    name: split > 0 ? lines[0].slice(0, split) : lines[0],
71    objective: split > 0 ? none(lines[0].slice(split + 2)) : null,
72    completed: Number(progress[1]),
73    total: Number(progress[2]),
74    active: none(progress[3]),
75    next: none(progress[4]),
76    humanAction: none(human[1]),
77    phases: null,
78    findings: null,
79    reviews: null,
80  }
81}
82
83export function parseContext(text: string): WaveContext | null {
84  try {
85    const data = JSON.parse(text)
86    return data && typeof data === 'object' && 'phase' in data ? data : null
87  } catch {
88    return null
89  }
90}
91
92/** The /riff-status answer, in the same three lines as `riff status`. */
93export function describe(status: RiffStatus): string {
94  return [
95    `${status.name}: ${status.objective ?? 'no objective yet'}`,
96    `${status.completed}/${status.total} phases completed. Active: ${status.active ?? 'none'}. Next: ${status.next ?? 'none'}.`,
97    `Human action: ${status.humanAction ?? 'none'}`,
98  ].join('\n')
99}
100
101export function clip(text: string, max: number): string {
102  return text.length <= max ? text : `${text.slice(0, Math.max(0, max - 1))}…`
103}
104
105/** The band's summary line, shortened to the terminal width. */
106export function summary(status: RiffStatus, columns: number): string {
107  const parts = [`${status.completed}/${status.total} phases`]
108  if (status.active) parts.push(`active ${status.active}`)
109  else if (status.next) parts.push(`next ${status.next}`)
110  const findings = status.findings?.length ?? 0
111  if (findings) parts.push(`${findings} open finding${findings > 1 ? 's' : ''}`)
112  return clip(`${status.name} · ${parts.join(' · ')}`, Math.max(20, columns - 8))
113}
114
115const OLD_CLI = 'Update the RIFF CLI linked by .riff-cli to see this tab.'
116
117/** The lines of one pane tab; also the text of /riff where no pane can be drawn. */
118export function tabLines(tab: Tab, status: RiffStatus, context: WaveContext | null, contextError: string | null = null): string[] {
119  if (tab === 'roadmap') {
120    if (!status.phases) return [OLD_CLI]
121    if (!status.phases.length) return ['No phases yet.']
122    return status.phases.map((phase) => `${mark(phase.status)} ${phase.id}  ${phase.title ?? ''}`.trimEnd())
123  }
124  if (tab === 'phase') return describeContext(context, contextError).split('\n')
125  if (tab === 'findings') {
126    if (!status.findings) return [OLD_CLI]
127    if (!status.findings.length) return ['No open findings.']
128    return status.findings.map((f) => `${f.severity ?? '?'}  ${f.summary ?? f.id}${f.phase ? `  (${f.phase})` : ''}`)
129  }
130  if (!status.reviews) return [OLD_CLI]
131  return (['functional', 'security'] as const).map((type) => {
132    const review = status.reviews![type]
133    if (!review) return `${type}: none yet`
134    return `${type}: ${review.status} on ${review.phase}${review.valid ? '' : ' (stale)'}${review.summary ? ` · ${review.summary}` : ''}`
135  })
136}
137
138/** The /riff-context answer: the phase in progress or next, its checkpoint and references. */
139export function describeContext(context: WaveContext | null, error: string | null = null): string {
140  if (!context) return error ? `No wave context. ${error}` : 'No wave context: the RIFF CLI did not answer.'
141  if (!context.phase) return 'No active or ready phase.'
142  const { phase, checkpoint } = context
143  const lines = [`${phase.id} (${phase.status}): ${phase.title ?? ''}`.trimEnd()]
144  if (phase.outcome) lines.push(`Outcome: ${phase.outcome}`)
145  if (checkpoint) {
146    lines.push(`Checkpoint${context.checkpointStale ? ' (stale: the code changed since)' : ''}: ${checkpoint.summary ?? ''}`)
147    if (checkpoint.next) lines.push(`Next: ${checkpoint.next}`)
148  } else lines.push('No checkpoint yet.')
149  if (context.references.length) lines.push(`References: ${context.references.join(', ')}`)
150  return lines.join('\n')
151}
152
153function mark(status: string): string {
154  return { completed: '✓', skipped: '–', active: '▶', blocked: '!', parked: '‖', awaiting_human: '?' }[status] ?? '·'
155}
156