diff --git a/docs/architecture.md b/docs/architecture.md index fe5a31e..6fe6e18 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -59,12 +59,20 @@ Multi-phase commands pause at gates, and gates are classified: **hard-outward** |---|---|---| | Attribution at the source | `attribution.text` (commit/pr) | the model never drafts the trailer the bash guard would deny | | Compaction keeps orc's place | `session.compact` (manual/auto) | the live `orc-state line` goes to the summarizer; the PreCompact bash hook checkpoints the same facts | -| `/orc` cockpit | `command.run` + `ui.render` Pane | tabs Flow · Slices · QA · PR · Agents · Decisions, no model turn; autopilot switch writes an `asked` decision via `orc-state`; text summary where nothing draws (`claude -p`, VS Code) | +| `/orc` cockpit | `command.run` + `ui.render` Pane | a dashboard drawn from `$.state` (no model turn): header strip (phase, slices, policy, model profile, context %, cost), bordered phase ladder beside the slice ledger (stacked under 72 columns), collapsible QA · PR · Agents · Decisions · Digest · Diff sections (`!` marks attention), `Select`s for autopilot and model profile (settled through `orc-state decision set`), `Link`s to PRs, the resume digest as `Markdown`, the current slice's diff as `Code`. Redraws itself when the model runs an `orc-state` write or a main-loop turn ends. Text summary where nothing draws (`claude -p`, VS Code). | +| orc outside the pane | `ui.render` Spinner · PromptHint · SessionMode · ToolUse | the spinner suffix carries `orc flow 5/9 implement · implementer`, the idle prompt hint names the flow, a non-manual policy shows in the mode footer, and orc's own CLI rows (`orc-state`, `orc-pr-size`, `orc-report`, `gh pr checks`) collapse to one dim line | +| Plugin contract | `types/index.d.ts` (`plugin.json` → `types`) | the `PluginState.orc` keys the mod keeps in `$.state`: `snapshot`, `sections`, `live`, `details`. The engine follows `$` only inside `register.ts`, so every hook and loader lives there; `cockpit.ts`, `pane.ts` and `state.ts` are pure | | Model profiles | `agent.spawn` | `model_profile`: `balanced` (frontmatter), `quality` (investigator on `best` — Fable, else Opus; reviewers on Opus), `economy` (Opus-tier agents on Sonnet); an explicit per-dispatch model always wins | -| Usage ledger | `agent.spawn` + `turn.complete` | every finished orc subagent run → `orc-state usage add` → `files/usage.jsonl`; the cockpit's Agents tab and model re-tuning read it | +| Usage ledger | `agent.spawn` + `turn.complete` | every finished orc subagent run → `orc-state usage add` → `files/usage.jsonl`; the cockpit's Agents section and model re-tuning read it | | Gate badges | `ui.render` AskUserQuestion | outward (`Publish`/`Post`/`Tracker`) and `Escalation` header chips (`orc:gates`) get a badge above the engine's own dialog | | CI watcher | `$.clock.every` + `$.store` lease | interactive sessions poll linked PRs' checks (one poller per PR across sessions); red/green → status line, toast, optional chime (`alerts_sound`), a dim `/orc:ci ` suggestion, and a red-CI band above the prompt with Diagnose / Dismiss. It never pushes, posts, or starts a turn. | +The `/orc` cockpit, docked beside the transcript (Claude Code 2.1.291, 132 columns): + +![orc cockpit — overview](images/orc-cockpit-overview.png) + +![orc cockpit — PR, Decisions, Digest and Diff sections open](images/orc-cockpit-sections.png) + `/orc:status` remains the full markdown report. Tests: `claude plugin test orc` (`hooks/mod/tests/*.test.ts`, headless). `claude plugin validate ./orc` lists the events it hooks and every API call it makes. **Model routing.** Agent frontmatter is the `balanced` default and stays put until the usage ledger and the eval suite justify a change; the `quality`/`economy` profiles carry the current hypotheses (investigator → Fable, code-fixer Haiku → Sonnet, …) so they can be measured per repo before they become defaults. diff --git a/docs/configuration.md b/docs/configuration.md index f4ca2b8..0d3f7e6 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -15,7 +15,7 @@ Prompted at plugin enable time (re-run via `/plugin`); exported to hooks and lib | `statusline_style` | `full` | `full` (two lines, width-aware) or `compact` (always a single dense line). | | `statusline_context_reserve` | `0` | 0–50. When >0, the context percentage is normalized against this reserved slice (e.g. your auto-compact reserve) and rendered with a `~` prefix. `0` shows the raw payload percentage. | | `mod_enabled` | `true` | Load the orc mod (`hooks/mod/`, Claude Code 2.1.287+): in-process event hooks and UI layered on the bash hooks. `false` keeps orc on its bash hooks only — the guardrails are identical either way. | -| `model_profile` | `balanced` | Which model orc agents run on when a dispatch names none (needs the orc mod): `balanced` (each agent's frontmatter), `quality` (investigator on `best` — Fable where available, else Opus — reviewers on Opus), `economy` (Opus-tier agents on Sonnet). An explicit per-dispatch model always wins. | +| `model_profile` | `balanced` | Which model orc agents run on when a dispatch names none (needs the orc mod): `balanced` (each agent's frontmatter), `quality` (investigator on `best` — Fable where available, else Opus — reviewers on Opus), `economy` (Opus-tier agents on Sonnet). An explicit per-dispatch model always wins; the `/orc` cockpit's Model profile select settles a per-branch override (`modelProfile` decision) that wins over this value. | | `alerts_sound` | `false` | A short chime when the orc mod's CI watcher sees a linked PR turn red or green. | ## Environment variables diff --git a/docs/images/orc-cockpit-overview.png b/docs/images/orc-cockpit-overview.png new file mode 100644 index 0000000..37e721b Binary files /dev/null and b/docs/images/orc-cockpit-overview.png differ diff --git a/docs/images/orc-cockpit-sections.png b/docs/images/orc-cockpit-sections.png new file mode 100644 index 0000000..7b856de Binary files /dev/null and b/docs/images/orc-cockpit-sections.png differ diff --git a/orc/.claude-plugin/plugin.json b/orc/.claude-plugin/plugin.json index 7b00cb6..1a8ae81 100644 --- a/orc/.claude-plugin/plugin.json +++ b/orc/.claude-plugin/plugin.json @@ -4,13 +4,21 @@ "displayName": "orc", "description": "Full-SDLC Claude Code plugin — plan → implement → QA → ship → CI → release, with root-cause debugging, incident response, and dependency care. Hook-enforced guardrails, curated skills, model/effort-tuned agents.", "version": "0.23.0", + "types": "./types/index.d.ts", "author": { "name": "Higor Alves", "email": "me@higoralves.dev" }, "homepage": "https://github.com/HigorAlves/orc", "repository": "https://github.com/HigorAlves/orc", - "keywords": ["workflow", "tdd", "code-review", "git", "pr", "orchestration"], + "keywords": [ + "workflow", + "tdd", + "code-review", + "git", + "pr", + "orchestration" + ], "license": "MIT", "userConfig": { "pr_size_budget": { diff --git a/orc/hooks/mod/cockpit.ts b/orc/hooks/mod/cockpit.ts index 9f4696f..ac67b28 100644 --- a/orc/hooks/mod/cockpit.ts +++ b/orc/hooks/mod/cockpit.ts @@ -2,31 +2,20 @@ // output (`get`, `slice list`, `decision get`), so register.ts only binds `$` // calls and draws. orc-state stays the single writer of .orc/. -export type Session = { - command: string - gitBranch: string - description?: string | null - status: string - phase: number | 'done' - phaseLabel?: string | null - totalPhases: number - jiraTicket?: string | null - linkedPRs?: { url?: string; number?: number; repo?: string }[] -} -export type Slice = { id: string; status: string; title: string; commit: string | null } -export type Decision = { key: string; value: string; provenance: string } -export type Criterion = { criterion: string; result: string; note: string; sliceId: number | string } -export type QaReport = { verdict: string; acceptance: Criterion[]; missing: string[] } -export type AgentUsage = { agent: string; runs: number; in: number; out: number; ms: number } -export type Size = { loc: number; budget: number } -export type Snapshot = { - session: Session | null - slices: Slice[] - decisions: Decision[] - qa?: QaReport | null - size?: Size | null - usage?: AgentUsage[] -} +import type { + OrcAgentUsage, OrcCriterion, OrcDecision, OrcMeter, OrcQaReport, OrcSession, OrcSize, OrcSlice, OrcSnapshot, +} from '../../types' +import type { Profile } from './profiles' + +export type Session = OrcSession +export type Slice = OrcSlice +export type Decision = OrcDecision +export type Criterion = OrcCriterion +export type QaReport = OrcQaReport +export type AgentUsage = OrcAgentUsage +export type Size = OrcSize +export type Meter = OrcMeter +export type Snapshot = OrcSnapshot export const POLICIES = ['manual', 'guided', 'auto'] as const export const EMPTY: Snapshot = { session: null, slices: [], decisions: [] } @@ -127,3 +116,86 @@ export function parseUsage(json: string): AgentUsage[] { const k = (n: number) => (n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n)) export const usageLine = (u: AgentUsage) => `${u.agent.replace(/^orc:/, '').padEnd(24)} ${String(u.runs).padStart(3)} run${u.runs === 1 ? ' ' : 's'} ${k(u.in)} in / ${k(u.out)} out ${Math.round(u.ms / 1000)}s` + +// $.session.usage() → the header's context % and cost; null where the host has none. +export function parseMeter(u: { context?: { percent?: number }; cost?: { usd: number } } | null | undefined): Meter | null { + if (!u) return null + const pct = u.context?.percent + return { contextPercent: typeof pct === 'number' ? Math.round(pct) : null, usd: u.cost ? Math.round(u.cost.usd * 100) / 100 : null } +} + +export type Cell = { text: string; bold?: boolean; dim?: boolean; color?: string } + +// Model profile in force: the branch's settled decision, else the userConfig value. +export const profileOf = (snap: Snapshot, fallback: Profile): Profile => { + const d = snap.decisions.find(d => d.key === 'modelProfile')?.value + return d === 'quality' || d === 'economy' || d === 'balanced' ? d : fallback +} + +// The header strip: one cell per fact, wrapping as the width allows. +export function headerCells(snap: Snapshot, profile: Profile): Cell[] { + const s = snap.session + if (!s) return [{ text: 'No orc session on this branch — /orc:flow or /orc:plan starts one.', dim: true }] + const phase = s.phase === 'done' ? 'done' : `${s.phase}/${s.totalPhases}${s.phaseLabel ? ' ' + s.phaseLabel : ''}` + const cells: Cell[] = [{ text: `${s.command} ${phase}`, bold: true }] + if (snap.slices.length) cells.push({ text: `slices ${snap.slices.filter(isDone).length}/${snap.slices.length}` }) + cells.push({ text: `policy ${policyOf(snap) ?? 'manual'}` }, { text: `profile ${profile}` }) + const m = snap.meter + if (m?.contextPercent != null) cells.push({ text: `ctx ${m.contextPercent}%`, color: m.contextPercent >= 85 ? 'red' : m.contextPercent >= 70 ? 'yellow' : undefined }) + if (m?.usd != null) cells.push({ text: `$${m.usd.toFixed(2)}` }) + if (s.jiraTicket) cells.push({ text: s.jiraTicket }) + cells.push({ text: s.gitBranch, dim: true }) + return cells +} + +// Ladder beside ledger when there is room for both (28 + 2 + ~42 columns); else stacked. +export const layoutFor = (placement: 'dock' | 'inline', bodyColumns: number): 'row' | 'column' => + bodyColumns >= 72 || (placement === 'dock' && bodyColumns >= 64) ? 'row' : 'column' + +export type SectionId = 'qa' | 'pr' | 'agents' | 'decisions' | 'digest' | 'diff' + +// Which sections need a look: a failing QA verdict, an over-budget diff or red CI. +export function attention(snap: Snapshot, ciRed: boolean): Set { + const out = new Set() + if (snap.qa && snap.qa.verdict !== 'pass') out.add('qa') + if ((snap.size && snap.size.loc > snap.size.budget) || ciRed) out.add('pr') + return out +} + +export const sectionLabel = (id: SectionId, open: boolean, flagged: boolean) => + `${open ? '▾' : '▸'} ${({ qa: 'QA', pr: 'PR', agents: 'Agents', decisions: 'Decisions', digest: 'Digest', diff: 'Diff' })[id]}${flagged ? ' !' : ''}` + +// The '## Resume digest' section of checkpoint.md, or null when there is none. +export function digestOf(markdown: string): string | null { + const m = /^## Resume digest[^\S\n]*\n([\s\S]*?)(?=^## |(?![\s\S]))/m.exec(markdown) + const text = m?.[1]?.trim() ?? '' + return text ? text : null +} + +// A unified diff bounded for a Code leaf: cut at the last file boundary under max. +export function cutDiff(text: string, max = 8000): { source: string; truncated: boolean } { + if (text.length <= max) return { source: text, truncated: false } + const at = text.lastIndexOf('\ndiff --git', max) + return { source: at > 0 ? text.slice(0, at + 1) : text.slice(0, text.indexOf('\n', max) + 1 || max), truncated: true } +} + +export const currentSlice = (slices: Slice[]): Slice | null => slices.find(x => !isDone(x)) ?? null + +// What the Diff section shows: the slice in progress (its working tree), else the last committed one (its commit). +export const diffSlice = (slices: Slice[]): Slice | null => + currentSlice(slices) ?? [...slices].reverse().find(x => x.commit) ?? null + +export const phaseShort = (s: Session) => + `${s.command} ${s.phase === 'done' ? 'done' : `${s.phase}/${s.totalPhases}`}${s.phaseLabel && s.phase !== 'done' ? ' ' + s.phaseLabel : ''}` +export const spinnerSuffix = (snap: Snapshot, agent: string | null) => + snap.session ? ` · orc ${phaseShort(snap.session)}${agent ? ' · ' + agent.replace(/^orc-/, '') : ''}` : '' +export const hintFor = (snap: Snapshot) => (snap.session ? `orc ${phaseShort(snap.session)} · /orc cockpit · /orc:resume` : null) + +const PLUMBING = /^\s*(?:\S*\/)?(?:orc-state|orc-pr-size|orc-report|orc-docker-env|orc-workspace-detect)\b|^\s*gh pr checks\b/ +// One dim line for orc's own CLI calls; null for anything else (the engine draws it). +export function plumbingLine(input: unknown, isRunning: boolean): string | null { + const command = (input as { command?: unknown })?.command + if (typeof command !== 'string' || !PLUMBING.test(command)) return null + const first = command.split('\n')[0]?.trim() ?? '' + return `○ orc · ${first.length > 100 ? first.slice(0, 99) + '…' : first}${isRunning ? ' …' : ''}` +} diff --git a/orc/hooks/mod/pane.ts b/orc/hooks/mod/pane.ts new file mode 100644 index 0000000..a724900 --- /dev/null +++ b/orc/hooks/mod/pane.ts @@ -0,0 +1,110 @@ +// The /orc cockpit's drawing: pure. register.ts owns every `$` call (the +// engine's scan follows `$` only inside that file), builds the callbacks, and +// hands this module the element table, the data and the actions. +import type { EngineInterface } from 'claude-code' +import type { OrcDetails, OrcSections } from '../../types' +import { POLICIES, attention, criterionLine, diffSlice, headerCells, isDone, layoutFor, phaseRows, policyOf, sectionLabel, sizeLine, sliceLine, usageLine, type SectionId, type Snapshot } from './cockpit' +import type { Profile } from './profiles' +import type { CiAlert } from './state' + +export const PANE = 'orc-cockpit' + +export type Els = ReturnType +export type PaneModel = { + snap: Snapshot + profile: Profile + placement: 'dock' | 'inline' + bodyColumns: number + sections: OrcSections + ciAlert: CiAlert + details: OrcDetails +} +export type PaneActions = { resume: () => void; toggle: (id: SectionId) => () => void; renderReport: () => void; settle: (key: string, value: string) => void } + +const PROFILES = ['balanced', 'quality', 'economy'] as const + +export function drawPane(els: Els, model: PaneModel, actions: PaneActions) { + const { Box, Text, Button, Link } = els + // The mobile app draws no Select yet; the section then shows the decisions as text only. + const Select = 'Select' in els ? els.Select : null + const Markdown = 'Markdown' in els ? els.Markdown : null + const Code = 'Code' in els ? els.Code : null + const { snap } = model + const s = snap.session + const line = (text: string, style: { bold?: boolean; dimColor?: boolean; color?: string } = {}) => Text({ ...style, children: [text] }) + + const header = Box({ flexDirection: 'row', flexWrap: 'wrap', columnGap: 2, children: + headerCells(snap, model.profile).map(c => line(c.text, { bold: c.bold, dimColor: c.dim, color: c.color })) }) + + const ladder = s + ? Box({ flexDirection: 'column', borderStyle: 'round', borderDimColor: true, paddingX: 1, width: 28, children: [ + line('Phases', { bold: true }), + ...phaseRows(s).map(r => line(r, r.startsWith('▶') ? { bold: true, color: 'cyan' } : r.startsWith('✓') ? { color: 'green' } : { dimColor: true })), + ] }) + : null + const ledger = Box({ flexDirection: 'column', borderStyle: 'round', borderDimColor: true, paddingX: 1, flexGrow: 1, children: [ + line('Slices', { bold: true }), + ...(snap.slices.length + ? snap.slices.map(x => line(sliceLine(x), { color: isDone(x) ? 'green' : x.status === 'pending' ? undefined : 'red' })) + : [line('No slice ledger yet — /orc:plan writes one.', { dimColor: true })]), + ] }) + const body = Box({ flexDirection: layoutFor(model.placement, model.bodyColumns), columnGap: 1, children: [...(ladder ? [ladder] : []), ledger] }) + + const flagged = attention(snap, model.ciAlert !== null) + const section = (id: SectionId, hotkey: string, children: () => ReturnType[]) => + Box({ flexDirection: 'column', children: [ + Button({ key: 'sec-' + id, plain: true, hotkey, label: sectionLabel(id, model.sections[id], flagged.has(id)), onPress: actions.toggle(id) }), + ...(model.sections[id] ? [Box({ flexDirection: 'column', paddingLeft: 2, children: children() })] : []), + ] }) + + const qa = section('qa', 'q', () => snap.qa + ? [line('Verdict: ' + snap.qa.verdict.toUpperCase(), { bold: true, color: snap.qa.verdict === 'pass' ? 'green' : 'red' }), + ...snap.qa.acceptance.map(c => line(criterionLine(c), { color: c.result === 'pass' ? 'green' : c.result === 'fail' ? 'red' : undefined })), + ...(snap.qa.missing.length ? [line('Missing evidence: ' + snap.qa.missing.join(', '), { color: 'red' })] : []), + Button({ key: 'qa-report', label: 'Render report', onPress: actions.renderReport })] + : [line('No QA packet yet — /orc:qa writes one.', { dimColor: true })]) + + const prs = s?.linkedPRs ?? [] + const alert = model.ciAlert + const pr = section('pr', 'p', () => [ + ...(snap.size ? [line('Size ' + sizeLine(snap.size), { color: snap.size.loc > snap.size.budget ? 'red' : undefined })] : []), + ...(prs.length + ? prs.map(p => Box({ flexDirection: 'row', columnGap: 1, children: [line(`#${p.number ?? '?'}`), ...(p.url ? [Link({ href: p.url, label: p.url })] : [])] })) + : [line('No PR linked yet — /orc:ship opens one.', { dimColor: true })]), + ...(alert ? [line(`CI: ${alert.summary.failing.length} failing (${alert.summary.failing.join(', ')})`, { color: 'red' })] : []), + ]) + + const agents = section('agents', 'g', () => snap.usage?.length + ? [line('agent runs tokens time', { dimColor: true }), ...snap.usage.map(u => line(usageLine(u)))] + : [line('No subagent runs recorded yet.', { dimColor: true })]) + + const policy = policyOf(snap) ?? 'manual' + const decisions = section('decisions', 'd', () => [ + ...(Select ? [Select({ key: 'policy', label: 'Autopilot', options: POLICIES.map(p => ({ value: p })), value: policy, + onSelect: (v: string) => { if (v !== policy) actions.settle('autopilotLevel', v) } })] : []), + ...(Select ? [Select({ key: 'profile', label: 'Model profile', options: PROFILES.map(p => ({ value: p })), value: model.profile, + onSelect: (v: string) => { if (v !== model.profile) actions.settle('modelProfile', v) } })] : []), + ...snap.decisions.map(d => line(`${d.key} = ${d.value} (${d.provenance})`, { dimColor: true })), + ]) + + const { details } = model + const loading = [line('Loading…', { dimColor: true })] + const digest = section('digest', 'c', () => details.digest === null + ? loading + : details.digest ? [Markdown ? Markdown({ text: details.digest }) : line(details.digest)] : [line('No resume digest yet.', { dimColor: true })]) + + const cur = diffSlice(snap.slices) + const diff = section('diff', 'x', () => details.diff === null + ? loading + : [line(cur ? `Slice #${cur.id} ${cur.title}${cur.commit ? ' · ' + cur.commit : ' · working tree'}` : 'Working tree', { bold: true }), + ...(details.diff ? [Code ? Code({ source: details.diff, format: 'diff', wrap: 'truncate-end' }) : line(details.diff)] : [line('No changes.', { dimColor: true })]), + ...(details.diffTruncated ? [line('truncated — open the file for the rest', { dimColor: true })] : [])]) + + const footer = Box({ flexDirection: 'row', columnGap: 2, children: [ + Button({ key: 'resume', variant: 'primary', label: s ? 'Resume (/orc:resume)' : 'New flow (/orc:flow)', hotkey: 'r', onPress: actions.resume }), + ] }) + + // Sections sit tight (one row each while collapsed); the gap only separates the strip, the body, the sections and the footer. + const sections = Box({ flexDirection: 'column', children: [qa, pr, agents, decisions, digest, diff] }) + return Box({ flexDirection: 'column', rowGap: 1, children: [header, body, sections, footer] }) +} diff --git a/orc/hooks/mod/register.ts b/orc/hooks/mod/register.ts index e60e48a..4a340e5 100644 --- a/orc/hooks/mod/register.ts +++ b/orc/hooks/mod/register.ts @@ -3,13 +3,20 @@ // mods can't load (Claude Code < 2.1.287, --safe-mode, disableAllHooks, an // org policy) or `mod_enabled` is false, orc behaves exactly as without it. import type { EngineInterface, On, PluginOptions } from 'claude-code' -import { LEASE_MS, POLL_MS, SETTLED_MS, alertFor, mayPoll, signature, statusLine, summarizeChecks, type CiRecord, type CiSummary } from './ci' +import { LEASE_MS, POLL_MS, SETTLED_MS, alertFor, mayPoll, signature, statusLine, summarizeChecks, type CiRecord } from './ci' import { - EMPTY, POLICIES, criterionLine, headline, isDone, parseDecisions, parseReport, parseSession, parseSize, parseSlices, parseUsage, - phaseRows, policyOf, sizeLine, sliceLine, summaryText, usageLine, type Session, type Snapshot, + EMPTY, cutDiff, diffSlice, digestOf, hintFor, parseDecisions, parseMeter, parseReport, parseSession, parseSize, parseSlices, parseUsage, plumbingLine, policyOf, profileOf, spinnerSuffix, summaryText, type SectionId, type Snapshot, } from './cockpit' import { gateBadge } from './gates' +import { PANE, drawPane } from './pane' import { modelFor, parseProfile, type Profile } from './profiles' +import { DETAILS_INITIAL, LIVE_INITIAL, SECTIONS_INITIAL, ciBox, isOrcStateWrite } from './state' + +// State refs: literals in this file, as the engine's scan requires. +const SNAPSHOT_REF = { plugin: 'orc', key: 'snapshot' } as const +const SECTIONS_REF = { plugin: 'orc', key: 'sections' } as const +const LIVE_REF = { plugin: 'orc', key: 'live' } as const +const DETAILS_REF = { plugin: 'orc', key: 'details' } as const let alertsSound = false @@ -20,7 +27,10 @@ export function register(on: On, options: PluginOptions) { register_attribution(on) register_compaction(on) register_agents(on, parseProfile(options.model_profile)) - register_cockpit(on) + register_refresh(on) + register_cockpit(on, parseProfile(options.model_profile)) + register_surfaces(on) + register_toolrows(on) register_gates(on) register_ci_band(on) } @@ -33,6 +43,7 @@ function register_session(on: On) { } catch { // name taken in this build: the markdown /orc:status stays the fallback } + void refresh($).catch(() => undefined) if (e.isInteractive) $.clock.every(POLL_MS, () => { void ci_tick($) }) return next(e) }) @@ -65,6 +76,20 @@ function register_compaction(on: On) { }) } +// Every orc-state write the model runs through Bash re-reads .orc/ into state; +// subscribed drawings redraw by themselves. (Main-loop turns refresh in the +// turn.complete hook below — one registration per event without a matcher.) +function register_refresh(on: On) { + on('tool.call', { tool: 'Bash' }, async ($, e, next) => { + const result = await next(e) + // 2.1.291 types the Bash call flat (`e.command`); older builds nested it under `input`. + const call = e as { command?: string; input?: { command?: string } } + const command = call.command ?? call.input?.command ?? '' + if (isOrcStateWrite(command)) await refresh($).catch(() => undefined) + return result + }) +} + // orc agents: model_profile picks the model when the Agent call named none // (never over an explicit one — an escalation step-up), and every finished orc // subagent run lands in the usage ledger (`orc-state usage add`) — the data the @@ -72,25 +97,71 @@ function register_compaction(on: On) { const agentTypes = new Map() function register_agents(on: On, profile: Profile) { + // Both hooks gate the engine (a dispatch, a turn's end): orc's bookkeeping + // never fails them, so every state call here is guarded. on('agent.spawn', async ($, e, next) => { - const model = e.model ? undefined : modelFor(profile, e.subagentType) + const snap = await $.state.get(SNAPSHOT_REF).then(r => r.value ?? EMPTY, () => EMPTY) + const model = e.model ? undefined : modelFor(profileOf(snap, profile), e.subagentType) const result = await next(model ? { ...e, model } : e) - if (result.agentId && /^(orc:)?orc-/.test(e.subagentType)) agentTypes.set(result.agentId, e.subagentType.replace(/^orc:/, '')) + if (result.agentId && /^(orc:)?orc-/.test(e.subagentType)) { + agentTypes.set(result.agentId, e.subagentType.replace(/^orc:/, '')) + await $.state.set(LIVE_REF, { agent: e.subagentType.replace(/^orc:/, ''), since: await $.clock.now().catch(() => null) }).catch(() => undefined) + } return result }) on('turn.complete', async ($, e, next) => { const result = await next(e) + if (!e.agentId) await refresh($).catch(() => undefined) const agent = e.agentId ? agentTypes.get(e.agentId) : undefined - if (agent && e.usage) { + if (agent) { + // Cleared on every finish — an abort or API error carries no usage but the agent is gone. agentTypes.delete(e.agentId as string) - await orc_state($, ['usage', 'add', '--agent', agent, '--model', e.usage.model, - '--in', String(e.usage.input_tokens), '--out', String(e.usage.output_tokens), '--ms', String(e.durationMs)]) + const remaining = [...agentTypes.values()].at(-1) ?? null + await $.state.set(LIVE_REF, remaining ? { agent: remaining, since: null } : LIVE_INITIAL).catch(() => undefined) + if (e.usage) { + await orc_state($, ['usage', 'add', '--agent', agent, '--model', e.usage.model, + '--in', String(e.usage.input_tokens), '--out', String(e.usage.output_tokens), '--ms', String(e.durationMs)]) + } } return result }) } +// orc outside the pane: the spinner's suffix, the idle prompt hint and the mode +// footer. Each is a prop rewrite; the engine keeps drawing its own line. +function register_surfaces(on: On) { + on('ui.render', { component: 'Spinner' }, async ($, e, next) => { + const snap = await $.state.get(SNAPSHOT_REF).then(r => r.value ?? EMPTY, () => EMPTY) + const live = await $.state.get(LIVE_REF).then(r => r.value ?? LIVE_INITIAL, () => LIVE_INITIAL) + const suffix = spinnerSuffix(snap, live.agent) + return suffix ? next({ ...e, props: { ...e.props, suffix: e.props.suffix + suffix } }) : next(e) + }) + + on('ui.render', { component: 'PromptHint' }, async ($, e, next) => { + if (e.props.isWorking || e.props.isDraft) return next(e) + const hint = hintFor(await $.state.get(SNAPSHOT_REF).then(r => r.value ?? EMPTY, () => EMPTY)) + return hint ? next({ ...e, props: { ...e.props, hint } }) : next(e) + }) + + on('ui.render', { component: 'SessionMode' }, async ($, e, next) => { + const policy = policyOf(await $.state.get(SNAPSHOT_REF).then(r => r.value ?? EMPTY, () => EMPTY)) + return policy && policy !== 'manual' ? next({ ...e, props: { ...e.props, modes: [...e.props.modes, 'orc ' + policy] } }) : next(e) + }) +} + +// ToolUse rows for orc's own plumbing (orc-state, orc-pr-size, orc-report, +// gh pr checks) collapse to one dim line; errors and everything else keep the engine row. +function register_toolrows(on: On) { + on('ui.render', { component: 'ToolUse' }, async ($, e, next) => { + if (e.props.tool !== 'Bash' || e.props.isErrored || e.props.isInterrupted) return next(e) + const text = plumbingLine(e.props.input, e.props.isRunning) + if (!text) return next(e) + const { Text } = $.ui.resolve(e) + return Text({ dimColor: true, children: [text] }) + }) +} + // Gate chrome: outward and escalation gates (orc:gates header chips) get a // badge above the engine's own dialog, which is kept exactly once. function register_gates(on: On) { @@ -104,7 +175,6 @@ function register_gates(on: On) { // --- CI watcher (one poller across sessions, via a $.store lease) ---------- let ciBusy = false -let ciAlert: { pr: number | string; summary: CiSummary } | null = null async function ci_tick($: EngineInterface): Promise { if (ciBusy) return @@ -133,12 +203,12 @@ async function ci_tick($: EngineInterface): Promise { await $.store.set(key, { lease: { sid, until: now + LEASE_MS }, summary, checkedAt: now, notified: kind ? signature(summary) : fresh.notified }) $.ui.status(statusLine(label, summary)) if (kind === 'red') { - ciAlert = { pr: label, summary } + ciBox.alert = { pr: label, summary } $.ui.toast(`CI red on #${label}: ${summary.failing.join(', ')} — /orc:ci to diagnose`) await $.prompt.suggest({ text: '/orc:ci ' + label }) if (alertsSound) await $.audio.play({ asset: 'hooks/mod/chime.wav' }) } else if (kind === 'green') { - ciAlert = null + ciBox.alert = null $.ui.toast(`CI green on #${label}`) if (alertsSound) await $.audio.play({ asset: 'hooks/mod/chime.wav' }) } @@ -154,38 +224,30 @@ async function ci_tick($: EngineInterface): Promise { // The band above the prompt carries the one actionable alert: red CI. function register_ci_band(on: On) { on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => { - if (!ciAlert) return next(e) + if (!ciBox.alert) return next(e) const { Box, Text, Button } = $.ui.resolve(e) - const alert = ciAlert + const alert = ciBox.alert return Box({ flexDirection: 'row', columnGap: 2, children: [ Text({ color: 'red', children: [`⛔ CI red on #${alert.pr}: ${alert.summary.failing.join(', ')}`] }), Button({ key: 'ci-diagnose', label: 'Diagnose', onPress: () => { $.prompt.fill({ text: '/orc:ci ' + alert.pr }) } }), - Button({ key: 'ci-dismiss', label: 'Dismiss', onPress: () => { ciAlert = null; $.ui.invalidate('ui.render') } }), + Button({ key: 'ci-dismiss', label: 'Dismiss', onPress: () => { ciBox.alert = null; $.ui.invalidate('ui.render') } }), await next(e), ] }) }) } // --- /orc cockpit ----------------------------------------------------------- -// A pane with the flow's phase ladder, the slice ledger, the QA criteria, the -// PR's size and CI, the agents' usage, and the settled decisions (autopilot -// switchable), drawn without a model turn. Where nothing can draw (VS Code, -// claude -p) it answers with a text summary. -const PANE = 'orc-cockpit' -type Tab = 'flow' | 'slices' | 'qa' | 'pr' | 'agents' | 'decisions' -const TABS: [Tab, string, string][] = [ - ['flow', 'Flow', '1'], ['slices', 'Slices', '2'], ['qa', 'QA', '3'], ['pr', 'PR', '4'], ['agents', 'Agents', '5'], ['decisions', 'Decisions', '6'], -] -let tab: Tab = 'flow' -let snap: Snapshot = EMPTY - -function register_cockpit(on: On) { +// A dashboard pane drawn from $.state, no model turn: header strip, phase +// ladder beside the slice ledger. Where nothing can draw (VS Code, claude -p) +// it answers with a text summary. +function register_cockpit(on: On, fallbackProfile: Profile) { on('command.run', { command: 'orc' }, async ($) => { - snap = await load_snapshot($) + await refresh($).catch(() => undefined) + const snap = await $.state.get(SNAPSHOT_REF).then(r => r.value ?? EMPTY, () => EMPTY) // A plain `claude -p` run has no surface (ui.open still reports "placed"). if ((await $.session.surfaces()).length) { try { - if ((await $.ui.open({ id: PANE, title: 'orc', focus: true, closeOnEscape: true })).isPlaced) return {} + if ((await $.ui.open({ id: PANE, title: 'orc', focus: true, closeOnEscape: true, rows: 16, columns: 76 })).isPlaced) return {} } catch { // the pane was refused: fall through to the text answer } @@ -195,79 +257,69 @@ function register_cockpit(on: On) { on('ui.render', { component: 'Pane' }, async ($, e, next) => { if (e.requestId !== PANE) return next(e) - const { Box, Text, Button } = $.ui.resolve(e) - const redraw = () => $.ui.invalidate('ui.render') - const line = (text: string, style: { bold?: boolean; dimColor?: boolean; color?: string } = {}) => Text({ ...style, children: [text] }) - const s = snap.session - let body = [line('Start one with /orc:flow .')] - if (s) { - if (tab === 'flow') { - body = [...(s.description ? [line(s.description, { dimColor: true })] : []), ...phaseRows(s).map(r => line(r, { bold: r.startsWith('▶') }))] - } else if (tab === 'slices') { - body = snap.slices.length - ? snap.slices.map(x => line(sliceLine(x), { color: isDone(x) ? 'green' : x.status === 'pending' ? undefined : 'red' })) - : [line('No slice ledger yet — /orc:plan writes one.', { dimColor: true })] - } else if (tab === 'qa') { - body = snap.qa - ? [line('Verdict: ' + snap.qa.verdict.toUpperCase(), { bold: true, color: snap.qa.verdict === 'pass' ? 'green' : 'red' }), - ...snap.qa.acceptance.map(c => line(criterionLine(c), { color: c.result === 'pass' ? 'green' : c.result === 'fail' ? 'red' : undefined })), - ...(snap.qa.missing.length ? [line('Missing evidence: ' + snap.qa.missing.join(', '), { color: 'red' })] : []), - Button({ key: 'qa-report', label: 'Render report', onPress: async () => { - const path = await orc_report_html($) - $.ui.toast(path ? 'QA report: ' + path : 'No QA packet to render') - } })] - : [line('No QA packet yet — /orc:qa writes one.', { dimColor: true })] - } else if (tab === 'pr') { - const prs = s.linkedPRs ?? [] - body = [ - ...(snap.size ? [line('Size ' + sizeLine(snap.size), { color: snap.size.loc > snap.size.budget ? 'red' : undefined })] : []), - ...(prs.length ? prs.map(p => line(`#${p.number ?? '?'} ${p.url ?? ''}`)) : [line('No PR linked yet — /orc:ship opens one.', { dimColor: true })]), - ...(ciAlert ? [line(`CI: ${ciAlert.summary.failing.length} failing (${ciAlert.summary.failing.join(', ')})`, { color: 'red' })] : []), - ] - } else if (tab === 'agents') { - body = snap.usage?.length - ? [line('agent runs tokens time', { dimColor: true }), ...snap.usage.map(u => line(usageLine(u)))] - : [line('No subagent runs recorded yet.', { dimColor: true })] - } else { - const current = policyOf(snap) ?? 'manual' - body = [Box({ flexDirection: 'row', columnGap: 2, children: [line('Autopilot:'), - ...POLICIES.map(p => Button({ key: 'policy-' + p, label: p, hotkey: p[0], plain: true, dimColor: p !== current, - onPress: async () => { await write_policy($, p); snap = await load_snapshot($); redraw() } }))] }), - ...snap.decisions.map(d => line(`${d.key} = ${d.value} (${d.provenance})`))] - } - } - return Box({ flexDirection: 'column', children: [ - line(headline(snap), { bold: true }), - Box({ flexDirection: 'row', columnGap: 3, children: TABS.map(([id, label, hotkey]) => - Button({ key: 'tab-' + id, label, hotkey, plain: true, dimColor: tab !== id, onPress: () => { tab = id; redraw() } })) }), - line(' '), - ...body, - line(' '), - Box({ flexDirection: 'row', columnGap: 2, children: [ - Button({ key: 'resume', label: s ? 'Resume (/orc:resume)' : 'New flow (/orc:flow)', hotkey: 'r', - onPress: () => { $.prompt.fill({ text: s ? '/orc:resume' : '/orc:flow ' }) } }), - Button({ key: 'refresh', label: 'Refresh', hotkey: 'f', onPress: async () => { snap = await load_snapshot($); redraw() } }), - ] }), - ] }) + const snap = (await $.state.get(SNAPSHOT_REF)).value ?? EMPTY + const sections = (await $.state.get(SECTIONS_REF)).value ?? SECTIONS_INITIAL + const details = (await $.state.get(DETAILS_REF)).value ?? DETAILS_INITIAL + const model = { snap, sections, details, ciAlert: ciBox.alert, profile: profileOf(snap, fallbackProfile), placement: e.props.placement, bodyColumns: e.props.bodyColumns } + return drawPane($.ui.resolve(e), model, { + resume: () => { $.prompt.fill({ text: snap.session ? '/orc:resume' : '/orc:flow ' }) }, + toggle: (id: SectionId) => async () => { + try { + const current = (await $.state.get(SECTIONS_REF)).value ?? SECTIONS_INITIAL + await $.state.set(SECTIONS_REF, { ...current, [id]: !current[id] }) + if (!current[id]) await load_details($, id) + } catch { + // a state or loader failure leaves the pane as drawn + } + }, + // The user picked it in the pane, so it settles as an asked decision. + settle: async (key: string, value: string) => { + await orc_state($, ['decision', 'set', key, value, '--provenance', 'asked', '--supersede']) + await refresh($).catch(() => undefined) + }, + renderReport: async () => { + const path = await orc_report_html($) + $.ui.toast(path ? 'QA report: ' + path : 'No QA packet to render') + }, + }) }) } +// --- Snapshot loader (the one writer of the `snapshot` atom) ---------------- +// Lives here, not in state.ts: the engine follows `$` only within this file. async function load_snapshot($: EngineInterface): Promise { const root = $.plugin.root + '/bin/' const run = (argv: string[]) => $.process.run([root + argv[0], ...argv.slice(1)], { timeoutMs: 10000 }).then(r => (r.exitCode === 0 ? r.stdout : ''), () => '') - const [session, slices, decisions, qa, loc, budget, usage] = await Promise.all([ - run(['orc-state', 'get']), run(['orc-state', 'slice', 'list']), run(['orc-state', 'decision', 'get']), run(['orc-report', 'json']), + const session = await run(['orc-state', 'get']) + if (!parseSession(session)) return EMPTY // no orc session on this branch: nothing else to read + const [slices, decisions, qa, loc, budget, usage, meter] = await Promise.all([ + run(['orc-state', 'slice', 'list']), run(['orc-state', 'decision', 'get']), run(['orc-report', 'json']), run(['orc-pr-size', 'loc']), run(['orc-pr-size', 'budget']), run(['orc-state', 'usage', 'summary']), + $.session.usage().then(u => u, () => null), ]) return { - session: parseSession(session) as Session | null, slices: parseSlices(slices), decisions: parseDecisions(decisions), - qa: parseReport(qa), size: parseSize(loc, budget), usage: parseUsage(usage), + session: parseSession(session), slices: parseSlices(slices), decisions: parseDecisions(decisions), + qa: parseReport(qa), size: parseSize(loc, budget), usage: parseUsage(usage), meter: parseMeter(meter), } } -// The user picked it in the pane, so it settles as an asked decision. -async function write_policy($: EngineInterface, value: string): Promise { - await $.process.run([$.plugin.root + '/bin/orc-state', 'decision', 'set', 'autopilotLevel', value, '--provenance', 'asked', '--supersede']) +// One loader at a time; a trigger during a load queues exactly one more. +let inflight: Promise | null = null +let dirty = false +function refresh($: EngineInterface): Promise { + if (inflight) { dirty = true; return inflight } + inflight = (async () => { + do { + dirty = false + await $.state.set(SNAPSHOT_REF, await load_snapshot($)) + // Open Digest/Diff sections reload against the new snapshot; closed ones start clean next time. + const open = (await $.state.get(SECTIONS_REF)).value ?? SECTIONS_INITIAL + if (!open.digest && !open.diff) await $.state.set(DETAILS_REF, DETAILS_INITIAL) + if (open.digest) await load_details($, 'digest') + if (open.diff) await load_details($, 'diff') + } while (dirty) + })().finally(() => { inflight = null }) + return inflight } async function orc_report_html($: EngineInterface): Promise { @@ -279,6 +331,36 @@ async function orc_report_html($: EngineInterface): Promise { } } +// Digest and diff load from the live snapshot when their section is open. +async function load_details($: EngineInterface, id: SectionId): Promise { + const snap = await $.state.get(SNAPSHOT_REF).then(r => r.value ?? EMPTY, () => EMPTY) + if (id === 'digest') await load_digest($, snap) + if (id === 'diff') await load_diff($, snap) +} + +// checkpoint.md's digest, read once per open; '' is drawn as "no digest". +async function load_digest($: EngineInterface, snap: Snapshot): Promise { + const branch = snap.session?.branch + if (!branch) return + // ORC_STATE_DIR is absolute when the SessionStart hook exported it; else orc-state's default, /.orc. + const env = await $.env.get('ORC_STATE_DIR').catch(() => undefined) + const top = env ? '' : await $.process.run(['git', 'rev-parse', '--show-toplevel'], { timeoutMs: 5000 }).then(r => (r.exitCode === 0 ? r.stdout.trim() : ''), () => '') + const root = env || (top ? top + '/.orc' : '.orc') + const text = await $.fs.read(`${root}/${branch}/files/checkpoint.md`).then(t => (typeof t === 'string' ? t : ''), () => '') + const d = (await $.state.get(DETAILS_REF)).value ?? DETAILS_INITIAL + await $.state.set(DETAILS_REF, { ...d, digest: digestOf(text) ?? '' }) +} + +// The current slice's diff: its commit when it has one, else the working tree. +async function load_diff($: EngineInterface, snap: Snapshot): Promise { + const cur = diffSlice(snap.slices) + const argv = cur?.commit ? ['git', 'show', '--no-color', '--format=', '-U1', cur.commit] : ['git', 'diff', '--no-color', '-U1'] + const out = await $.process.run(argv, { timeoutMs: 10000 }).then(r => (r.exitCode === 0 ? r.stdout : ''), () => '') + const { source, truncated } = cutDiff(out) + const d = (await $.state.get(DETAILS_REF)).value ?? DETAILS_INITIAL + await $.state.set(DETAILS_REF, { ...d, diff: source, diffTruncated: truncated }) +} + // One orc-state call; '' when it fails or there is no session. async function orc_state($: EngineInterface, argv: string[]): Promise { try { diff --git a/orc/hooks/mod/state.ts b/orc/hooks/mod/state.ts new file mode 100644 index 0000000..246850f --- /dev/null +++ b/orc/hooks/mod/state.ts @@ -0,0 +1,19 @@ +// The cockpit's data in $.state: the initial values of the `orc` keys the +// contract (types/index.d.ts) declares. Pure — the engine follows `$` only +// inside register.ts and wants state refs written as literals there, so the +// refs, the loader and the triggers all live in register.ts. +import type { OrcDetails, OrcLive, OrcSections } from '../../types' +import type { CiSummary } from './ci' + +export const SECTIONS_INITIAL: OrcSections = { qa: false, pr: false, agents: false, decisions: false, digest: false, diff: false } +export const LIVE_INITIAL: OrcLive = { agent: null, since: null } +export const DETAILS_INITIAL: OrcDetails = { digest: null, diff: null, diffTruncated: false } + +// The red-CI alert lives in module scope (the watcher runs on a timer, outside +// any event); the band and the PR section read it and ui.invalidate redraws. +export type CiAlert = { pr: number | string; summary: CiSummary } | null +export const ciBox: { alert: CiAlert } = { alert: null } + +// orc-state verbs that change what the cockpit shows. Reads (get, list, line…) never refresh. +const WRITE = /(^|[\s;&|/])orc-state["']?\s+(init|phase|status|link-pr|digest|checkpoint|usage\s+add|slice\s+(init|set)|jira|decision\s+set|migrate)\b/ +export const isOrcStateWrite = (command: string) => WRITE.test(command) diff --git a/orc/hooks/mod/tests/cockpit.test.ts b/orc/hooks/mod/tests/cockpit.test.ts index 020cb6b..89a2636 100644 --- a/orc/hooks/mod/tests/cockpit.test.ts +++ b/orc/hooks/mod/tests/cockpit.test.ts @@ -1,8 +1,11 @@ +import type { On } from 'claude-code' import { expect, test } from 'claude-code/testing' +import { ran } from './helpers' const SESSION = JSON.stringify({ - command: 'flow', gitBranch: 'feat/export', description: 'CSV export', status: 'in_progress', + command: 'flow', branch: 'feat-export', gitBranch: 'feat/export', description: 'CSV export', status: 'in_progress', phase: 5, phaseLabel: 'implement', totalPhases: 9, jiraTicket: null, + linkedPRs: [{ url: 'https://github.com/acme/app/pull/12', number: 12 }], }) const SLICES = '1\tcommitted\tstream rows\tabc1234\n2\tpending\texport job\t-\n3\tred\tdownload button\t-\n' const DECISIONS = JSON.stringify({ schema: 1, decisions: { autopilotLevel: { value: 'guided', provenance: 'flag' } } }) @@ -57,31 +60,42 @@ test('/orc without a session says how to start one', async ($, on) => { expect(out.text).toContain('No orc session on this branch') }) -test('the pane shows the phase ladder, then the slice ledger', async ($, on) => { +const USAGE_METER = { startedAt: 0, context: { window: 200000, tokens: 84000, percent: 42 }, rateLimits: [], cost: { usd: 0.4211 } } + +function surfaces(on: On, list: ('terminal' | 'desktop')[]) { + const opened: unknown[] = [] + on('session.surfaces', () => ({ value: list })) + on('session.usage', () => ({ value: USAGE_METER })) + on('ui.open', ($, e) => { opened.push(e); return { value: { isPlaced: true } } }) + return opened +} + +test('the dashboard draws the ladder and the ledger together, with the meter in the header', async ($, on) => { on('process.run', orcState([])) - on('session.surfaces', () => ({ value: ['terminal' as const] })) - on('ui.open', () => ({ value: { isPlaced: true } })) - const out = await $.command.run(RUN) - expect(out.text).toBeUndefined() - const ui = await $.ui.mount({ ...PANE, surface: 'terminal' }) + const opened = surfaces(on, ['terminal']) + await $.command.run(RUN) + expect(opened[0]).toMatchObject({ id: 'orc-cockpit', rows: 16, columns: 76 }) + const ui = await $.ui.mount({ ...PANE, surface: 'terminal', props: { ...PANE.props, placement: 'dock', bodyColumns: 120 } }) + expect(await ui.find({ type: 'Text', text: /ctx 42%/ })).toBeDefined() + expect(await ui.find({ type: 'Text', text: /\$0\.42/ })).toBeDefined() + expect(await ui.find({ type: 'Text', text: /profile balanced/ })).toBeDefined() expect(await ui.find({ type: 'Text', text: '▶ 5 implement' })).toBeDefined() - expect(await ui.find({ type: 'Text', text: '✓ 4' })).toBeDefined() - await ui.press({ key: 'tab-slices' }) expect(await ui.find({ type: 'Text', text: /#3 red\s+download button/ })).toBeDefined() + expect(await ui.find({ key: 'refresh' })).toBeUndefined() + expect(await ui.find({ key: 'resume' })).toBeDefined() await ui.unmount() }) -test('picking a policy settles it through orc-state as an asked decision', async ($, on) => { - const calls: string[][] = [] - on('process.run', orcState(calls)) - on('session.surfaces', () => ({ value: ['desktop' as const] })) - on('ui.open', () => ({ value: { isPlaced: true } })) +test('a narrow inline pane stacks the ladder over the ledger', async ($, on) => { + on('process.run', orcState([])) + surfaces(on, ['terminal']) await $.command.run(RUN) - const ui = await $.ui.mount({ ...PANE, surface: 'desktop' }) - await ui.press({ key: 'tab-decisions' }) - await ui.press({ key: 'policy-auto' }) - const write = calls.find(argv => argv[1] === 'decision' && argv[2] === 'set') - expect(write?.slice(1)).toEqual(['decision', 'set', 'autopilotLevel', 'auto', '--provenance', 'asked', '--supersede']) + const narrow = { ...PANE, props: { ...PANE.props, placement: 'inline' as const, bodyColumns: 60 } } + const ui = await $.ui.mount({ ...narrow, surface: 'terminal' }) + const drawn = await ui.drawn() + // Root: column -> [header, body, ...]; body is a column Box when narrow, a row Box when wide. + const body = (drawn as { children?: { type: string; props?: Record }[] }).children?.[1] + expect(body?.props?.flexDirection).toBe('column') await ui.unmount() }) @@ -93,20 +107,97 @@ test('a pane the terminal cannot place falls back to text', async ($, on) => { expect(out.text).toContain('flow 5/9 implement') }) -test('QA, PR and Agents tabs draw the report, the size budget, and the usage ledger', async ($, on) => { +test('sections start collapsed, mark attention, and toggle', async ($, on) => { on('process.run', orcState([])) - on('session.surfaces', () => ({ value: ['terminal' as const] })) - on('ui.open', () => ({ value: { isPlaced: true } })) + surfaces(on, ['terminal']) await $.command.run(RUN) const ui = await $.ui.mount({ ...PANE, surface: 'terminal' }) - await ui.press({ key: 'tab-qa' }) + expect(await ui.find({ key: 'sec-qa' })).toMatchObject({ props: { label: '▸ QA !' } }) + expect(await ui.find({ key: 'sec-pr' })).toMatchObject({ props: { label: '▸ PR !' } }) + expect(await ui.find({ key: 'sec-agents' })).toMatchObject({ props: { label: '▸ Agents' } }) + expect(await ui.find({ type: 'Text', text: /POST \/export returns 202/ })).toBeUndefined() + await ui.press({ key: 'sec-qa' }) + expect(await ui.find({ key: 'sec-qa' })).toMatchObject({ props: { label: '▾ QA !' } }) + expect(await ui.find({ type: 'Text', text: /✓ POST \/export returns 202/ })).toBeDefined() + expect(await ui.find({ type: 'Text', text: /✗ Download shows progress/ })).toBeDefined() expect(await ui.find({ type: 'Text', text: 'Verdict: FAIL' })).toBeDefined() - expect(await ui.find({ type: 'Text', text: '✓ POST /export returns 202 (slice 1)' })).toBeDefined() - expect(await ui.find({ type: 'Text', text: '✗ Download shows progress (slice 1) — bar never renders' })).toBeDefined() expect(await ui.find({ type: 'Text', text: 'Missing evidence: qa-feat-export.webm' })).toBeDefined() - await ui.press({ key: 'tab-pr' }) + await ui.press({ key: 'sec-qa' }) + expect(await ui.find({ type: 'Text', text: /POST \/export returns 202/ })).toBeUndefined() + await ui.press({ key: 'sec-pr' }) + expect(await ui.find({ type: 'Link' })).toMatchObject({ props: { href: 'https://github.com/acme/app/pull/12' } }) expect(await ui.find({ type: 'Text', text: /412\/300 LOC — over by 112$/ })).toBeDefined() - await ui.press({ key: 'tab-agents' }) + await ui.press({ key: 'sec-agents' }) expect(await ui.find({ type: 'Text', text: /orc-implementer\s+3 runs\s+52\.0k in \/ 9\.1k out\s+184s/ })).toBeDefined() await ui.unmount() }) + +test('the Decisions selects settle policy and profile through orc-state as asked decisions', async ($, on) => { + const calls: string[][] = [] + on('process.run', orcState(calls)) + surfaces(on, ['desktop']) + await $.command.run(RUN) + const ui = await $.ui.mount({ ...PANE, surface: 'desktop' }) + await ui.press({ key: 'sec-decisions' }) + await ui.select({ key: 'policy', value: 'auto' }) + const policy = calls.find(a => a[1] === 'decision' && a[2] === 'set') + expect(policy?.slice(1)).toEqual(['decision', 'set', 'autopilotLevel', 'auto', '--provenance', 'asked', '--supersede']) + await ui.select({ key: 'profile', value: 'economy' }) + const profile = calls.filter(a => a[1] === 'decision' && a[2] === 'set').at(-1) + expect(profile?.slice(3)).toEqual(['modelProfile', 'economy', '--provenance', 'asked', '--supersede']) + const before = calls.length + await ui.select({ key: 'policy', value: 'guided' }) // already current (DECISIONS fixture says guided) + expect(calls.length).toBe(before) + await ui.unmount() +}) + +test('digest and diff sections load on open', async ($, on) => { + const calls: string[][] = [] + const reads: string[] = [] + on('process.run', ($, e) => { + if (e.argv[0] === 'git') { calls.push([...e.argv]); return ran('diff --git a/x b/x\n--- a/x\n+++ b/x\n@@ -1 +1 @@\n-a\n+b\n') } + return orcState(calls)($, e) + }) + on('fs.read', ($, e) => { reads.push(e.path); return { value: '# x\n\n## Resume digest\nPhase 5.\n' } }) + on('env.get', () => ({ value: undefined })) + surfaces(on, ['terminal']) + await $.command.run(RUN) + const ui = await $.ui.mount({ ...PANE, surface: 'terminal' }) + await ui.press({ key: 'sec-digest' }) + expect(reads.length).toBe(1) + expect(reads[0]).toMatch(/\/\.orc\/feat-export\/files\/checkpoint\.md$/) + expect(await ui.find({ type: 'Markdown' })).toMatchObject({ props: { text: 'Phase 5.' } }) + await ui.press({ key: 'sec-diff' }) + // SLICES fixture: #1 committed, #2 pending, so the current slice is #2, uncommitted. + expect(calls.find(a => a[0] === 'git' && a[1] !== 'rev-parse')).toEqual(['git', 'diff', '--no-color', '-U1']) + expect(await ui.find({ type: 'Code' })).toMatchObject({ props: { format: 'diff' } }) + await ui.unmount() +}) + +test('an open digest section survives a refresh, and the diff shows the last commit once all slices are done', async ($, on) => { + const calls: string[][] = [] + let reads = 0 + const ALL_DONE = '1\tcommitted\tstream rows\tabc1234\n2\tcommitted\texport job\tdef5678\n' + on('process.run', ($, e) => { + if (e.argv[0] === 'git') { calls.push([...e.argv]); return e.argv[1] === 'rev-parse' ? ran('/repo') : ran('diff --git a/x b/x\n--- a/x\n+++ b/x\n@@ -1 +1 @@\n-a\n+b\n') } + if (e.argv[1] === 'slice') return ran(ALL_DONE) + return orcState(calls)($, e) + }) + on('fs.read', ($, e) => { reads += 1; return { value: e.path === '/repo/.orc/feat-export/files/checkpoint.md' ? '## Resume digest\nPhase 5.\n' : '' } }) + on('env.get', () => ({ value: undefined })) + on('turn.complete', () => ({ text: '' })) + surfaces(on, ['terminal']) + await $.command.run(RUN) + const ui = await $.ui.mount({ ...PANE, surface: 'terminal' }) + await ui.press({ key: 'sec-digest' }) + expect(await ui.find({ type: 'Markdown' })).toMatchObject({ props: { text: 'Phase 5.' } }) + // A main-loop turn ends: the snapshot refreshes and the open section reloads instead of sticking on "Loading…". + await $.turn.complete({ turnId: 't1', answer: '', durationMs: 1, isAborted: false, reason: 'answer' as const, + usage: { model: 'm', input_tokens: 1, output_tokens: 1, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 } }) + expect(reads).toBe(2) + expect(await ui.find({ type: 'Markdown' })).toMatchObject({ props: { text: 'Phase 5.' } }) + await ui.press({ key: 'sec-diff' }) + expect(calls.find(a => a[0] === 'git' && a[1] === 'show')).toEqual(['git', 'show', '--no-color', '--format=', '-U1', 'def5678']) + expect(await ui.find({ type: 'Text', text: /Slice #2 export job · def5678/ })).toBeDefined() + await ui.unmount() +}) diff --git a/orc/hooks/mod/tests/details.test.ts b/orc/hooks/mod/tests/details.test.ts new file mode 100644 index 0000000..0243b2f --- /dev/null +++ b/orc/hooks/mod/tests/details.test.ts @@ -0,0 +1,28 @@ +import { expect, test } from 'claude-code/testing' +import { cutDiff, currentSlice, digestOf } from '../cockpit' + +const CHECKPOINT = `---\ncommand: flow\n---\n# flow\n\n## Resume digest\nPhase 5 implementing slice 3.\nNext: /orc:resume\n\n## Auto-checkpoint\nold\n` + +test('digestOf keeps the Resume digest section alone', async () => { + expect(digestOf(CHECKPOINT)).toBe('Phase 5 implementing slice 3.\nNext: /orc:resume') + expect(digestOf('# nothing here')).toBeNull() +}) + +test('cutDiff cuts at a file boundary and says so', async () => { + const file = (n: number) => `diff --git a/f${n} b/f${n}\n--- a/f${n}\n+++ b/f${n}\n@@ -1 +1 @@\n-${'x'.repeat(3000)}\n+${'y'.repeat(3000)}\n` + const big = file(1) + file(2) + file(3) + const cut = cutDiff(big, 8000) + expect(cut.truncated).toBe(true) + expect(cut.source).toBe(file(1)) + expect(cutDiff('short', 8000)).toEqual({ source: 'short', truncated: false }) +}) + +test('currentSlice is the first slice not yet done', async () => { + const slices = [ + { id: '1', status: 'committed', title: 'a', commit: 'abc' }, + { id: '2', status: 'red', title: 'b', commit: null }, + { id: '3', status: 'pending', title: 'c', commit: null }, + ] + expect(currentSlice(slices)?.id).toBe('2') + expect(currentSlice([])).toBeNull() +}) diff --git a/orc/hooks/mod/tests/profiles.test.ts b/orc/hooks/mod/tests/profiles.test.ts index e1e6fb5..30dc85a 100644 --- a/orc/hooks/mod/tests/profiles.test.ts +++ b/orc/hooks/mod/tests/profiles.test.ts @@ -1,6 +1,6 @@ import type { On } from 'claude-code' import { expect, test } from 'claude-code/testing' -import { spawnOf } from './helpers' +import { ran, spawnOf } from './helpers' function recordSpawns(on: On, seen: (string | undefined)[]) { on('agent.spawn', ($, e) => { seen.push(e.model); return { model: e.model ?? 'frontmatter', agentId: 'a' + seen.length } }) @@ -36,3 +36,17 @@ test('non-orc agents are never touched', { options: { model_profile: 'quality' } await $.agent.spawn(spawnOf('Explore')) expect(seen).toEqual([undefined]) }) + +test('a settled modelProfile decision wins over the userConfig profile', async ($, on) => { + let dispatched: string | undefined + on('agent.spawn', ($, e) => { dispatched = e.model; return { model: e.model ?? 'frontmatter', agentId: 'a1' } }) + // Decisions are per branch: they only load alongside a live session. + on('process.run', ($, e) => e.argv[1] === 'decision' && e.argv[2] === 'get' + ? ran(JSON.stringify({ schema: 1, decisions: { modelProfile: { value: 'quality', provenance: 'asked' } } })) + : e.argv[1] === 'get' ? ran(JSON.stringify({ command: 'flow', branch: 'b', gitBranch: 'b', status: 'in_progress', phase: 1, totalPhases: 9 })) : ran('')) + on('session.usage', () => ({ value: { startedAt: 0, context: { window: 200000 }, rateLimits: [] } })) + on('session.surfaces', () => ({ value: [] })) + await $.command.run({ command: 'orc', args: '', origin: { kind: 'composer' as const }, presentation: { isFullscreen: false, columns: 80 } }) + await $.agent.spawn(spawnOf('orc:orc-pr-reviewer')) + expect(dispatched).toBe('opus') +}) diff --git a/orc/hooks/mod/tests/state.test.ts b/orc/hooks/mod/tests/state.test.ts new file mode 100644 index 0000000..9ebfbcf --- /dev/null +++ b/orc/hooks/mod/tests/state.test.ts @@ -0,0 +1,62 @@ +import type { On } from 'claude-code' +import { expect, test } from 'claude-code/testing' +import { ran } from './helpers' + +const SESSION = JSON.stringify({ + command: 'flow', branch: 'feat-export', gitBranch: 'feat/export', status: 'in_progress', + phase: 5, phaseLabel: 'implement', totalPhases: 9, jiraTicket: null, +}) +const USAGE = { startedAt: 0, context: { window: 200000, tokens: 84000, percent: 42 }, rateLimits: [], cost: { usd: 0.4211 } } +const RUN = { command: 'orc', args: '', origin: { kind: 'composer' as const }, presentation: { isFullscreen: false, columns: 120 } } + +// Count every orc-state `get`; answer the rest empty. +function world(on: On) { + const gets: number[] = [] + on('process.run', ($, e) => { + if (e.argv[1] === 'get') { gets.push(1); return ran(SESSION) } + return ran('') + }) + on('session.usage', () => ({ value: USAGE })) + on('session.surfaces', () => ({ value: [] })) + on('tool.call', () => ({ result: { stdout: '', stderr: '' } })) + on('turn.complete', () => ({ text: '' })) + return gets +} + +const bash = (command: string) => ({ tool: 'Bash' as const, command }) +const done = (agentId?: string) => ({ + turnId: 't1', answer: '', durationMs: 10, isAborted: false, reason: 'answer' as const, text: '', + usage: { model: 'claude-sonnet-5-5', input_tokens: 1, output_tokens: 1, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }, + ...(agentId ? { agentId } : {}), +}) + +test('/orc loads the snapshot into state, with the session meter', async ($, on) => { + const gets = world(on) + // The test's own $ has no state noun; the write is observed beneath the plugin instead. + const writes: unknown[] = [] + on('state.set', ($, e) => { writes.push(e.value); return { value: { isSet: true, version: writes.length } } }) + await $.command.run(RUN) + expect(gets.length).toBe(1) + const value = [...writes].reverse().find(w => w !== null && typeof w === 'object' && 'session' in w) as { session?: { phase?: number }; meter?: unknown } | undefined + expect(value?.session?.phase).toBe(5) + expect(value?.meter).toEqual({ contextPercent: 42, usd: 0.42 }) +}) + +test('an orc-state write through Bash refreshes the snapshot; other commands do not', async ($, on) => { + const gets = world(on) + await $.command.run(RUN) + await $.tool.call(bash('orc-state slice set 1 --status committed --commit abc1234')) + expect(gets.length).toBe(2) + await $.tool.call(bash('ls -la')) + await $.tool.call(bash('orc-state slice list')) + expect(gets.length).toBe(2) +}) + +test('a main-loop turn refreshes; a subagent turn does not', async ($, on) => { + const gets = world(on) + await $.command.run(RUN) + await $.turn.complete(done('a1')) + expect(gets.length).toBe(1) + await $.turn.complete(done()) + expect(gets.length).toBe(2) +}) diff --git a/orc/hooks/mod/tests/surfaces.test.ts b/orc/hooks/mod/tests/surfaces.test.ts new file mode 100644 index 0000000..340eb78 --- /dev/null +++ b/orc/hooks/mod/tests/surfaces.test.ts @@ -0,0 +1,93 @@ +import type { On } from 'claude-code' +import { expect, test } from 'claude-code/testing' +import { ran, spawnOf } from './helpers' + +const SESSION = JSON.stringify({ command: 'flow', branch: 'feat-export', gitBranch: 'feat/export', status: 'in_progress', phase: 5, phaseLabel: 'implement', totalPhases: 9 }) +const GUIDED = JSON.stringify({ schema: 1, decisions: { autopilotLevel: { value: 'guided', provenance: 'flag' } } }) +const RUN = { command: 'orc', args: '', origin: { kind: 'composer' as const }, presentation: { isFullscreen: false, columns: 120 } } + +// The beneath render stub plays core: it records the props the plugin handed down. +function world(on: On, opts: { session?: string; decisions?: string } = {}) { + const seen: Record[] = [] + on('process.run', ($, e) => { + if (e.argv[1] === 'get') return opts.session === '' ? ran('', 1) : ran(opts.session ?? SESSION) + if (e.argv[1] === 'decision') return ran(opts.decisions ?? '') + return ran('') + }) + on('session.usage', () => ({ value: { startedAt: 0, context: { window: 200000 }, rateLimits: [] } })) + on('session.surfaces', () => ({ value: [] })) + on('clock.now', () => ({ value: 1000 })) + on('agent.spawn', ($, e) => ({ model: e.model ?? 'x', agentId: 'a1' })) + on('turn.complete', () => ({ text: '' })) + on('ui.render', ($, e) => { seen.push(e.props as Record); return { type: 'engine' as const, ref: 0 } }) + return seen +} + +const SPINNER = { plugin: 'orc', surface: 'terminal' as const, component: 'Spinner' as const, props: { word: 'Thinking', message: null, suffix: '…', mode: 'thinking' as const } } +const HINT = { plugin: 'orc', surface: 'terminal' as const, component: 'PromptHint' as const, props: { isDraft: false, isWorking: false, hint: '? for shortcuts' } } +const MODE = { plugin: 'orc', surface: 'terminal' as const, component: 'SessionMode' as const, props: { modes: ['focus'] } } +const done = { turnId: 't1', answer: '', durationMs: 10, isAborted: false, reason: 'answer' as const, text: '', usage: { model: 'm', input_tokens: 1, output_tokens: 1, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 } } + +test('the spinner carries the phase, and the running orc agent while one runs', async ($, on) => { + const seen = world(on) + await $.command.run(RUN) + await (await $.ui.mount(SPINNER)).unmount() + expect(seen.at(-1)?.suffix).toBe('… · orc flow 5/9 implement') + await $.agent.spawn(spawnOf('orc:orc-implementer')) + await (await $.ui.mount(SPINNER)).unmount() + expect(seen.at(-1)?.suffix).toBe('… · orc flow 5/9 implement · implementer') + await $.turn.complete({ ...done, agentId: 'a1' }) + await (await $.ui.mount(SPINNER)).unmount() + expect(seen.at(-1)?.suffix).toBe('… · orc flow 5/9 implement') +}) + +test('the prompt hint names the flow when idle and is untouched while working', async ($, on) => { + const seen = world(on) + await $.command.run(RUN) + await (await $.ui.mount(HINT)).unmount() + expect(seen.at(-1)?.hint).toBe('orc flow 5/9 implement · /orc cockpit · /orc:resume') + await (await $.ui.mount({ ...HINT, props: { ...HINT.props, isWorking: true } })).unmount() + expect(seen.at(-1)?.hint).toBe('? for shortcuts') +}) + +test('the mode footer shows a non-manual policy', async ($, on) => { + const seen = world(on, { decisions: GUIDED }) + await $.command.run(RUN) + await (await $.ui.mount(MODE)).unmount() + expect(seen.at(-1)?.modes).toEqual(['focus', 'orc guided']) +}) + +test('with no session every surface passes through', async ($, on) => { + const seen = world(on, { session: '' }) + await $.command.run(RUN) + await (await $.ui.mount(SPINNER)).unmount() + await (await $.ui.mount(HINT)).unmount() + await (await $.ui.mount(MODE)).unmount() + expect(seen.map(p => p.suffix ?? p.hint ?? p.modes)).toEqual(['…', '? for shortcuts', ['focus']]) +}) + +const TOOL = (command: string, extra: Partial<{ isRunning: boolean; isErrored: boolean }> = {}) => ({ + plugin: 'orc', surface: 'terminal' as const, component: 'ToolUse' as const, requestId: 't1', + props: { tool_use_id: 't1', tool: 'Bash', input: { command }, isRunning: false, isErrored: false, isInterrupted: false, ...extra }, +}) + +test('orc plumbing rows collapse to one dim line; errors and other commands keep the engine row', async ($, on) => { + world(on) + const row = async (m: ReturnType) => { const ui = await $.ui.mount(m); const t = await ui.find({ type: 'Text' }); const d = await ui.drawn(); await ui.unmount(); return { t, d } } + expect((await row(TOOL('orc-state slice set 3 --status committed --commit abc1234'))).t?.text).toBe('○ orc · orc-state slice set 3 --status committed --commit abc1234') + expect((await row(TOOL('gh pr checks 12 --json name,bucket', { isRunning: true }))).t?.text).toBe('○ orc · gh pr checks 12 --json name,bucket …') + expect((await row(TOOL('/x/bin/orc-state get'))).t?.text).toBe('○ orc · /x/bin/orc-state get') + expect((await row(TOOL('orc-pr-size loc'))).t?.text).toBe('○ orc · orc-pr-size loc') + expect((await row(TOOL('orc-report json'))).t?.text).toBe('○ orc · orc-report json') + expect((await row(TOOL('orc-state get', { isErrored: true }))).d).toEqual({ type: 'engine', ref: 0 }) + expect((await row(TOOL('ls -la'))).d).toEqual({ type: 'engine', ref: 0 }) +}) + +test('an aborted orc agent (no usage) still leaves the spinner', async ($, on) => { + const seen = world(on) + await $.command.run(RUN) + await $.agent.spawn(spawnOf('orc:orc-implementer')) + await $.turn.complete({ turnId: 't1', answer: '', durationMs: 10, isAborted: true, reason: 'aborted' as const, agentId: 'a1' }) + await (await $.ui.mount(SPINNER)).unmount() + expect(seen.at(-1)?.suffix).toBe('… · orc flow 5/9 implement') +}) diff --git a/orc/hooks/mod/tests/usage.test.ts b/orc/hooks/mod/tests/usage.test.ts index 56d4150..ef8794e 100644 --- a/orc/hooks/mod/tests/usage.test.ts +++ b/orc/hooks/mod/tests/usage.test.ts @@ -26,5 +26,5 @@ test('main-loop turns and non-orc agents are not recorded', async ($, on) => { await $.agent.spawn({ ...spawnOf('Explore'), provider: ORIGIN }) await $.turn.complete(done('x1')) await $.turn.complete(done()) - expect(calls.filter(argv => argv[1] === 'usage')).toEqual([]) + expect(calls.filter(argv => argv[1] === 'usage' && argv[2] === 'add')).toEqual([]) }) diff --git a/orc/types/index.d.ts b/orc/types/index.d.ts new file mode 100644 index 0000000..0635478 --- /dev/null +++ b/orc/types/index.d.ts @@ -0,0 +1,45 @@ +// orc's plugin contract: the shapes the mod keeps in `$.state`, declared so +// `$.state.get/set({ plugin: 'orc', key })` type-check. No imports here (contract rule). + +export type OrcSession = { + command: string + branch: string + gitBranch: string + description?: string | null + status: string + phase: number | 'done' + phaseLabel?: string | null + totalPhases: number + jiraTicket?: string | null + linkedPRs?: { url?: string; number?: number; repo?: string }[] +} +export type OrcSlice = { id: string; status: string; title: string; commit: string | null } +export type OrcDecision = { key: string; value: string; provenance: string } +export type OrcCriterion = { criterion: string; result: string; note: string; sliceId: number | string } +export type OrcQaReport = { verdict: string; acceptance: OrcCriterion[]; missing: string[] } +export type OrcAgentUsage = { agent: string; runs: number; in: number; out: number; ms: number } +export type OrcSize = { loc: number; budget: number } +export type OrcMeter = { contextPercent: number | null; usd: number | null } +export type OrcSnapshot = { + session: OrcSession | null + slices: OrcSlice[] + decisions: OrcDecision[] + qa?: OrcQaReport | null + size?: OrcSize | null + usage?: OrcAgentUsage[] + meter?: OrcMeter | null +} +export type OrcSections = { qa: boolean; pr: boolean; agents: boolean; decisions: boolean; digest: boolean; diff: boolean } +export type OrcLive = { agent: string | null; since: number | null } +export type OrcDetails = { digest: string | null; diff: string | null; diffTruncated: boolean } + +declare module 'claude-code' { + interface PluginState { + orc: { + snapshot: OrcSnapshot + sections: OrcSections + live: OrcLive + details: OrcDetails + } + } +}