Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <n>` 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.
Expand Down
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Binary file added docs/images/orc-cockpit-overview.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/orc-cockpit-sections.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 9 additions & 1 deletion orc/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
122 changes: 97 additions & 25 deletions orc/hooks/mod/cockpit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [] }
Expand Down Expand Up @@ -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<SectionId> {
const out = new Set<SectionId>()
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 ? ' …' : ''}`
}
110 changes: 110 additions & 0 deletions orc/hooks/mod/pane.ts
Original file line number Diff line number Diff line change
@@ -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<EngineInterface['ui']['resolve']>
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<typeof Box>[]) =>
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] })
}
Loading
Loading