Full-SDLC Claude Code plugin — plan → implement → QA → ship → CI → release, with root-cause debugging, incident response, and dependency care. Hook-enforced…

<img src=".github/images/second-icon.png" alt="orc" width="360" />
<h1 align="center">orc</h1>
<strong>"Zug zug."</strong> · <em>Let the orcs do the work.</em>
The senior-developer workflow, encoded as a Claude Code plugin — <em>plan → debug → verify → ship</em> — with hard guardrails that keep it off <code>main</code>, out of your git attribution, and entirely on your own machine.
<em>Built for the senior-developer day. Safe enough for the security review.</em>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-2ea043.svg" alt="License: MIT"></a> <img src="https://img.shields.io/github/v/tag/HigorAlves/orc?filter=orc--v*&label=plugin&color=2ea043" alt="plugin version"> <img src="https://img.shields.io/badge/runs-100%25%20local-2ea043.svg" alt="runs 100% local"> <img src="https://img.shields.io/badge/telemetry-none-2ea043.svg" alt="no telemetry"> <img src="https://img.shields.io/badge/platform-macOS%20%C2%B7%20Linux-555.svg" alt="platform macOS · Linux"> <img src="https://img.shields.io/badge/Claude%20Code-plugin-D97757.svg" alt="Claude Code plugin">
<a href="#is-it-safe-to-use"><strong>Is it safe?</strong></a> · <a href="#install"><strong>Install</strong></a> · <a href="./docs/commands.md"><strong>Commands</strong></a> · <a href="./docs/safety.md#faq"><strong>FAQ</strong></a> · <a href="./docs/examples/README.md"><strong>Examples</strong></a>
<code>84 skills</code> · <code>31 commands</code> · <code>14 agents</code> · <code>9 hooks</code> · <code>0 telemetry</code> · <code>MIT</code>
orc is a full-SDLC workflow plugin for Claude Code: 84 curated skills, 31 composite slash commands, 14 specialist subagents, and 10 hook scripts that quietly enforce discipline (no commits to main, no AI-attribution trailers, destructive git commands gated, a dependency pre-flight, core rules injected at every session start). One umbrella command — /orc:flow — drives the full feature lifecycle from "I want to do X" to "PR merged", with the orc-implementer agent writing the code slice-by-slice in between — and the lifecycle no longer stops at PR-open: /orc:ci, /orc:release, /orc:deps, and /orc:incident cover what happens after.
It exists for one reason: every time a senior developer sits down to work, they should already know how the next hour goes — write the plan, watch the test fail, fix the cause (not the symptom), verify with evidence, ship the PR. orc encodes that loop.
Yes — and you can verify every claim in this repo in about five minutes. orc is a workflow layer, not a service: no backend, no telemetry, no analytics. Network activity is limited to GitHub (fetching releases) and any MCP servers you add. Every guardrail is a short, readable shell script that Claude Code runs as a real hook — not a suggestion the model can talk itself out of: protected-branch commits and destructive git downgraded to confirm prompts, AI-attribution trailers refused outright, dependencies pre-flighted, the iron rules injected at every session start.
The full claim-by-claim breakdown — guardrail table, supply-chain hardening, FAQ — is in docs/safety.md.
orc maps the senior IC / tech-lead / architect day to a small set of composite commands. Most work fits this loop:
flowchart LR
plan["/orc:plan"] --> start["/orc:start"] --> impl["implement"] --> qa["/orc:qa"] --> ship["/orc:ship"] --> ci["/orc:ci"] --> cleanup["/orc:cleanup"]
cleanup -.->|"interrupted? /orc:resume"| plan
cleanup -.->|"need status? /orc:status"| plan
Or skip the per-phase invocations and use /orc:flow to drive the whole loop — a gate at every phase, autonomous implementation in between via orc-implementer, and an interaction_policy ladder (manual/guided/auto) that decides how often it asks.
Outside the loop, commands cover the rest of the day:
/orc:debug (root-cause first), /orc:code-review, /orc:address, /orc:stack-pr, /orc:fan-out/orc:prd · /orc:trd · /orc:adr · /orc:rfc · /orc:postmortem · /orc:scaffold/orc:env, /orc:incident, /orc:deps, /orc:release, /orc:ci/orc:jira, /orc:jira-breakdown, /orc:evidence, /orc:setup, /orc:triage, /orc:wayfinder/orc:resume, /orc:status, /orc:cleanup — multi-phase work checkpoints to .orc/ and survives interruptionThe full command table, the 14 agents behind it, and the 81-skill library live in docs/commands.md. Prefer learning by scenario? docs/examples walks 18 real situations end-to-end.
Three commands from zero to working:
curl -fsSL https://raw.githubusercontent.com/HigorAlves/orc/main/cli/install.sh | sh # 1. the orc CLI
orc doctor --fix # 2. runtime tools (git, jq, + recommended)
orc install # 3. register marketplace + enable plugin
Then restart Claude Code (or /reload-plugins) and type /orc: — the command palette should populate. That's the whole setup.
[!TIP] Pin a version with
orc install --ref orc--v0.25.0, install without the CLI (/plugin marketplace add HigorAlves/orc), or build from source — the step-by-step guide with verification checkpoints, the marketplace-only path, and the full requirements table are in docs/install.md.
While orc is enabled, Claude Code's status bar shows the work and the machine at a glance — no configuration needed (the plugin ships the default; your own statusLine always wins):
orc flow 6/9 implement │ PROJ-142 │ slices 3/7 │ auto │ ⎇ feat/checkout-retry* │ #128 ✔ approved
Opus 4.5 high think │ ctx ▓▓▓▓▓▓▓░░░ 68% │ $4.83 +412/−88 │ 5h 71%
Line 1 is the work: live orc session, Jira ticket, slice progress, autopilot level, branch, and the open PR with its review state. Line 2 is the machine: model + effort, an honest context bar, session cost, and your worst rate limit past 50%. It adapts to terminal width, honors NO_COLOR, and never breaks a render — and it feeds a context-monitor bridge that nudges the agent to checkpoint when context runs red. Tuning knobs in docs/configuration.md.
using-orc skill)main/master/develop — the PreToolUse hook downgrades them to a confirm prompt; approve only with explicit consent..orc/ checkpoints.Size-budget-override: trailer, or abort.Claude Code loads every skill/command/agent description at session start (that's how it routes you), but loads a skill's body only when invoked. orc is built around that split: a ~3.5 KB always-on core (iron rules + routing), progressive disclosure for large reference skills, ≤200-char descriptions everywhere. Net: a fresh orc session pays only a few thousand baseline tokens before you type anything.
| Page | What's in it |
|---|---|
| docs/install.md | Step-by-step install with verification checkpoints, marketplace-only path, requirements table |
| docs/commands.md | Full command table, the specialist agents, the skill library, the CLI reference |
| docs/examples | 19 scenario walk-throughs — start here for usage |
| docs/configuration.md | Plugin settings, env vars, statusline tuning |
| docs/safety.md | The safety case in depth + FAQ |
| docs/architecture.md | Why the layout is what it is; hooks, state, and the .orc/ lifecycle |
| docs/contributing.md | Adding skills, commands, agents, and hooks |
See docs/contributing.md for conventions and docs/architecture.md for the why behind the layout. Developing on this repo? Load the plugin straight from your checkout instead of the marketplace:
claude --plugin-dir /path/to/your/clone/of/orc
Reload after edits without restarting: /reload-plugins.
MIT — see LICENSE.
hooks/mod/register.ts 373 lines1// The orc mod: in-process event hooks layered on top of orc's bash settings
2// hooks, which stay the safety floor. Nothing here replaces a guard — when
3// mods can't load (Claude Code < 2.1.287, --safe-mode, disableAllHooks, an
4// org policy) or `mod_enabled` is false, orc behaves exactly as without it.
5import type { EngineInterface, On, PluginOptions } from 'claude-code'
6import { LEASE_MS, POLL_MS, SETTLED_MS, alertFor, mayPoll, signature, statusLine, summarizeChecks, type CiRecord } from './ci'
7import {
8 EMPTY, cutDiff, diffSlice, digestOf, hintFor, parseDecisions, parseMeter, parseReport, parseSession, parseSize, parseSlices, parseUsage, plumbingLine, policyOf, profileOf, spinnerSuffix, summaryText, type SectionId, type Snapshot,
9} from './cockpit'
10import { gateBadge } from './gates'
11import { PANE, drawPane } from './pane'
12import { modelFor, parseProfile, type Profile } from './profiles'
13import { DETAILS_INITIAL, LIVE_INITIAL, SECTIONS_INITIAL, ciBox, isOrcStateWrite } from './state'
14
15// State refs: literals in this file, as the engine's scan requires.
16const SNAPSHOT_REF = { plugin: 'orc', key: 'snapshot' } as const
17const SECTIONS_REF = { plugin: 'orc', key: 'sections' } as const
18const LIVE_REF = { plugin: 'orc', key: 'live' } as const
19const DETAILS_REF = { plugin: 'orc', key: 'details' } as const
20
21let alertsSound = false
22
23export function register(on: On, options: PluginOptions) {
24 if (options.mod_enabled === false) return
25 alertsSound = options.alerts_sound === true
26 register_session(on)
27 register_attribution(on)
28 register_compaction(on)
29 register_agents(on, parseProfile(options.model_profile))
30 register_refresh(on)
31 register_cockpit(on, parseProfile(options.model_profile))
32 register_surfaces(on)
33 register_toolrows(on)
34 register_gates(on)
35 register_ci_band(on)
36}
37
38// One session.start hook: add /orc, and in an interactive session start the CI watcher.
39function register_session(on: On) {
40 on('session.start', async ($, e, next) => {
41 try {
42 await $.command.register({ name: 'orc', description: 'orc cockpit — phase, slices, QA, PR, agents, decisions (no model turn)', immediate: true })
43 } catch {
44 // name taken in this build: the markdown /orc:status stays the fallback
45 }
46 void refresh($).catch(() => undefined)
47 if (e.isInteractive) $.clock.every(POLL_MS, () => { void ci_tick($) })
48 return next(e)
49 })
50}
51
52// Iron rule 5 at the source: blank the attribution text the model is told to
53// write into commits and PRs, so it never drafts a trailer that
54// pre-commit-no-ai-attribution.sh then has to deny. The engine's own gate
55// sentences (`exemption`, `remedy`) pass through; the bash deny stays as backstop.
56function register_attribution(on: On) {
57 on('attribution.text', { kind: ['commit', 'pr'] }, async ($, e, next) => {
58 if ((await $.env.get('ORC_ALLOW_AI_ATTRIBUTION')) === '1') return next(e)
59 return { text: '' }
60 })
61}
62
63// Compaction keeps orc's place: the summarizer is told the live session state
64// (`orc-state line` — command, phase, slices, policy) so the post-compact
65// transcript still knows where the flow is. The PreCompact bash hook writes the
66// same facts to checkpoint.md for /orc:resume; this covers the live session.
67// Speculative precomputes and subagent transcripts are left alone.
68function register_compaction(on: On) {
69 on('session.compact', { trigger: ['manual', 'auto'] }, async ($, e, next) => {
70 if (e.agentId) return next(e)
71 const line = await orc_state($, ['line'])
72 if (!line) return next(e)
73 const note = 'orc session state (authoritative, from orc-state): ' + line +
74 ' — keep the current phase, slice progress, and any pending gate decision in the summary.'
75 return next({ ...e, instructions: e.instructions ? e.instructions + '\n\n' + note : note })
76 })
77}
78
79// Every orc-state write the model runs through Bash re-reads .orc/ into state;
80// subscribed drawings redraw by themselves. (Main-loop turns refresh in the
81// turn.complete hook below — one registration per event without a matcher.)
82function register_refresh(on: On) {
83 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
84 const result = await next(e)
85 // 2.1.291 types the Bash call flat (`e.command`); older builds nested it under `input`.
86 const call = e as { command?: string; input?: { command?: string } }
87 const command = call.command ?? call.input?.command ?? ''
88 if (isOrcStateWrite(command)) await refresh($).catch(() => undefined)
89 return result
90 })
91}
92
93// orc agents: model_profile picks the model when the Agent call named none
94// (never over an explicit one — an escalation step-up), and every finished orc
95// subagent run lands in the usage ledger (`orc-state usage add`) — the data the
96// routing is re-tuned from.
97const agentTypes = new Map<string, string>()
98
99function register_agents(on: On, profile: Profile) {
100 // Both hooks gate the engine (a dispatch, a turn's end): orc's bookkeeping
101 // never fails them, so every state call here is guarded.
102 on('agent.spawn', async ($, e, next) => {
103 const snap = await $.state.get(SNAPSHOT_REF).then(r => r.value ?? EMPTY, () => EMPTY)
104 const model = e.model ? undefined : modelFor(profileOf(snap, profile), e.subagentType)
105 const result = await next(model ? { ...e, model } : e)
106 if (result.agentId && /^(orc:)?orc-/.test(e.subagentType)) {
107 agentTypes.set(result.agentId, e.subagentType.replace(/^orc:/, ''))
108 await $.state.set(LIVE_REF, { agent: e.subagentType.replace(/^orc:/, ''), since: await $.clock.now().catch(() => null) }).catch(() => undefined)
109 }
110 return result
111 })
112
113 on('turn.complete', async ($, e, next) => {
114 const result = await next(e)
115 if (!e.agentId) await refresh($).catch(() => undefined)
116 const agent = e.agentId ? agentTypes.get(e.agentId) : undefined
117 if (agent) {
118 // Cleared on every finish — an abort or API error carries no usage but the agent is gone.
119 agentTypes.delete(e.agentId as string)
120 const remaining = [...agentTypes.values()].at(-1) ?? null
121 await $.state.set(LIVE_REF, remaining ? { agent: remaining, since: null } : LIVE_INITIAL).catch(() => undefined)
122 if (e.usage) {
123 await orc_state($, ['usage', 'add', '--agent', agent, '--model', e.usage.model,
124 '--in', String(e.usage.input_tokens), '--out', String(e.usage.output_tokens), '--ms', String(e.durationMs)])
125 }
126 }
127 return result
128 })
129}
130
131// orc outside the pane: the spinner's suffix, the idle prompt hint and the mode
132// footer. Each is a prop rewrite; the engine keeps drawing its own line.
133function register_surfaces(on: On) {
134 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
135 const snap = await $.state.get(SNAPSHOT_REF).then(r => r.value ?? EMPTY, () => EMPTY)
136 const live = await $.state.get(LIVE_REF).then(r => r.value ?? LIVE_INITIAL, () => LIVE_INITIAL)
137 const suffix = spinnerSuffix(snap, live.agent)
138 return suffix ? next({ ...e, props: { ...e.props, suffix: e.props.suffix + suffix } }) : next(e)
139 })
140
141 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
142 if (e.props.isWorking || e.props.isDraft) return next(e)
143 const hint = hintFor(await $.state.get(SNAPSHOT_REF).then(r => r.value ?? EMPTY, () => EMPTY))
144 return hint ? next({ ...e, props: { ...e.props, hint } }) : next(e)
145 })
146
147 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
148 const policy = policyOf(await $.state.get(SNAPSHOT_REF).then(r => r.value ?? EMPTY, () => EMPTY))
149 return policy && policy !== 'manual' ? next({ ...e, props: { ...e.props, modes: [...e.props.modes, 'orc ' + policy] } }) : next(e)
150 })
151}
152
153// ToolUse rows for orc's own plumbing (orc-state, orc-pr-size, orc-report,
154// gh pr checks) collapse to one dim line; errors and everything else keep the engine row.
155function register_toolrows(on: On) {
156 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
157 if (e.props.tool !== 'Bash' || e.props.isErrored || e.props.isInterrupted) return next(e)
158 const text = plumbingLine(e.props.input, e.props.isRunning)
159 if (!text) return next(e)
160 const { Text } = $.ui.resolve(e)
161 return Text({ dimColor: true, children: [text] })
162 })
163}
164
165// Gate chrome: outward and escalation gates (orc:gates header chips) get a
166// badge above the engine's own dialog, which is kept exactly once.
167function register_gates(on: On) {
168 on('ui.render', { component: 'AskUserQuestion' }, async ($, e, next) => {
169 const badge = gateBadge(e.props.questions)
170 if (!badge) return next(e)
171 const { Box, Text } = $.ui.resolve(e)
172 return Box({ flexDirection: 'column', children: [Text({ bold: true, color: 'yellow', children: [badge] }), await next(e)] })
173 })
174}
175
176// --- CI watcher (one poller across sessions, via a $.store lease) ----------
177let ciBusy = false
178
179async function ci_tick($: EngineInterface): Promise<void> {
180 if (ciBusy) return
181 ciBusy = true
182 try {
183 const session = parseSession(await orc_state($, ['get']))
184 const prs = (session?.linkedPRs ?? []).filter(p => p.url)
185 if (!prs.length) return
186 const sid = await $.session.id()
187 for (const pr of prs) {
188 const key = 'ci:' + pr.url
189 const label = pr.number ?? (pr.url as string)
190 const now = await $.clock.now()
191 const rec = ((await $.store.get(key)) ?? {}) as CiRecord
192 const settled = rec.summary && rec.summary.state !== 'pending' && rec.checkedAt !== undefined && now - rec.checkedAt < SETTLED_MS
193 if (!mayPoll(rec.lease, sid, now) || settled) {
194 if (rec.summary) $.ui.status(statusLine(label, rec.summary))
195 continue
196 }
197 await $.store.set(key, { ...rec, lease: { sid, until: now + LEASE_MS } })
198 // The URL resolves from any directory; a bare number needs the cwd's remote.
199 const r = await $.process.run(['gh', 'pr', 'checks', String(pr.url ?? label), '--json', 'name,bucket'], { timeoutMs: 20000 })
200 const summary = summarizeChecks(r.stdout)
201 const fresh = ((await $.store.get(key)) ?? {}) as CiRecord
202 const kind = alertFor(fresh.summary, summary, fresh.notified)
203 await $.store.set(key, { lease: { sid, until: now + LEASE_MS }, summary, checkedAt: now, notified: kind ? signature(summary) : fresh.notified })
204 $.ui.status(statusLine(label, summary))
205 if (kind === 'red') {
206 ciBox.alert = { pr: label, summary }
207 $.ui.toast(`CI red on #${label}: ${summary.failing.join(', ')} — /orc:ci to diagnose`)
208 await $.prompt.suggest({ text: '/orc:ci ' + label })
209 if (alertsSound) await $.audio.play({ asset: 'hooks/mod/chime.wav' })
210 } else if (kind === 'green') {
211 ciBox.alert = null
212 $.ui.toast(`CI green on #${label}`)
213 if (alertsSound) await $.audio.play({ asset: 'hooks/mod/chime.wav' })
214 }
215 $.ui.invalidate('ui.render')
216 }
217 } catch {
218 // gh missing, offline, not authenticated: stay quiet and try next tick
219 } finally {
220 ciBusy = false
221 }
222}
223
224// The band above the prompt carries the one actionable alert: red CI.
225function register_ci_band(on: On) {
226 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
227 if (!ciBox.alert) return next(e)
228 const { Box, Text, Button } = $.ui.resolve(e)
229 const alert = ciBox.alert
230 return Box({ flexDirection: 'row', columnGap: 2, children: [
231 Text({ color: 'red', children: [`⛔ CI red on #${alert.pr}: ${alert.summary.failing.join(', ')}`] }),
232 Button({ key: 'ci-diagnose', label: 'Diagnose', onPress: () => { $.prompt.fill({ text: '/orc:ci ' + alert.pr }) } }),
233 Button({ key: 'ci-dismiss', label: 'Dismiss', onPress: () => { ciBox.alert = null; $.ui.invalidate('ui.render') } }),
234 await next(e),
235 ] })
236 })
237}
238
239// --- /orc cockpit -----------------------------------------------------------
240// A dashboard pane drawn from $.state, no model turn: header strip, phase
241// ladder beside the slice ledger. Where nothing can draw (VS Code, claude -p)
242// it answers with a text summary.
243function register_cockpit(on: On, fallbackProfile: Profile) {
244 on('command.run', { command: 'orc' }, async ($) => {
245 await refresh($).catch(() => undefined)
246 const snap = await $.state.get(SNAPSHOT_REF).then(r => r.value ?? EMPTY, () => EMPTY)
247 // A plain `claude -p` run has no surface (ui.open still reports "placed").
248 if ((await $.session.surfaces()).length) {
249 try {
250 if ((await $.ui.open({ id: PANE, title: 'orc', focus: true, closeOnEscape: true, rows: 16, columns: 76 })).isPlaced) return {}
251 } catch {
252 // the pane was refused: fall through to the text answer
253 }
254 }
255 return { text: summaryText(snap) }
256 })
257
258 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
259 if (e.requestId !== PANE) return next(e)
260 const snap = (await $.state.get(SNAPSHOT_REF)).value ?? EMPTY
261 const sections = (await $.state.get(SECTIONS_REF)).value ?? SECTIONS_INITIAL
262 const details = (await $.state.get(DETAILS_REF)).value ?? DETAILS_INITIAL
263 const model = { snap, sections, details, ciAlert: ciBox.alert, profile: profileOf(snap, fallbackProfile), placement: e.props.placement, bodyColumns: e.props.bodyColumns }
264 return drawPane($.ui.resolve(e), model, {
265 resume: () => { $.prompt.fill({ text: snap.session ? '/orc:resume' : '/orc:flow ' }) },
266 toggle: (id: SectionId) => async () => {
267 try {
268 const current = (await $.state.get(SECTIONS_REF)).value ?? SECTIONS_INITIAL
269 await $.state.set(SECTIONS_REF, { ...current, [id]: !current[id] })
270 if (!current[id]) await load_details($, id)
271 } catch {
272 // a state or loader failure leaves the pane as drawn
273 }
274 },
275 // The user picked it in the pane, so it settles as an asked decision.
276 settle: async (key: string, value: string) => {
277 await orc_state($, ['decision', 'set', key, value, '--provenance', 'asked', '--supersede'])
278 await refresh($).catch(() => undefined)
279 },
280 renderReport: async () => {
281 const path = await orc_report_html($)
282 $.ui.toast(path ? 'QA report: ' + path : 'No QA packet to render')
283 },
284 })
285 })
286}
287
288// --- Snapshot loader (the one writer of the `snapshot` atom) ----------------
289// Lives here, not in state.ts: the engine follows `$` only within this file.
290async function load_snapshot($: EngineInterface): Promise<Snapshot> {
291 const root = $.plugin.root + '/bin/'
292 const run = (argv: string[]) => $.process.run([root + argv[0], ...argv.slice(1)], { timeoutMs: 10000 }).then(r => (r.exitCode === 0 ? r.stdout : ''), () => '')
293 const session = await run(['orc-state', 'get'])
294 if (!parseSession(session)) return EMPTY // no orc session on this branch: nothing else to read
295 const [slices, decisions, qa, loc, budget, usage, meter] = await Promise.all([
296 run(['orc-state', 'slice', 'list']), run(['orc-state', 'decision', 'get']), run(['orc-report', 'json']),
297 run(['orc-pr-size', 'loc']), run(['orc-pr-size', 'budget']), run(['orc-state', 'usage', 'summary']),
298 $.session.usage().then(u => u, () => null),
299 ])
300 return {
301 session: parseSession(session), slices: parseSlices(slices), decisions: parseDecisions(decisions),
302 qa: parseReport(qa), size: parseSize(loc, budget), usage: parseUsage(usage), meter: parseMeter(meter),
303 }
304}
305
306// One loader at a time; a trigger during a load queues exactly one more.
307let inflight: Promise<void> | null = null
308let dirty = false
309function refresh($: EngineInterface): Promise<void> {
310 if (inflight) { dirty = true; return inflight }
311 inflight = (async () => {
312 do {
313 dirty = false
314 await $.state.set(SNAPSHOT_REF, await load_snapshot($))
315 // Open Digest/Diff sections reload against the new snapshot; closed ones start clean next time.
316 const open = (await $.state.get(SECTIONS_REF)).value ?? SECTIONS_INITIAL
317 if (!open.digest && !open.diff) await $.state.set(DETAILS_REF, DETAILS_INITIAL)
318 if (open.digest) await load_details($, 'digest')
319 if (open.diff) await load_details($, 'diff')
320 } while (dirty)
321 })().finally(() => { inflight = null })
322 return inflight
323}
324
325async function orc_report_html($: EngineInterface): Promise<string> {
326 try {
327 const r = await $.process.run([$.plugin.root + '/bin/orc-report', 'html'], { timeoutMs: 10000 })
328 return r.exitCode === 0 ? r.stdout.trim() : ''
329 } catch {
330 return ''
331 }
332}
333
334// Digest and diff load from the live snapshot when their section is open.
335async function load_details($: EngineInterface, id: SectionId): Promise<void> {
336 const snap = await $.state.get(SNAPSHOT_REF).then(r => r.value ?? EMPTY, () => EMPTY)
337 if (id === 'digest') await load_digest($, snap)
338 if (id === 'diff') await load_diff($, snap)
339}
340
341// checkpoint.md's digest, read once per open; '' is drawn as "no digest".
342async function load_digest($: EngineInterface, snap: Snapshot): Promise<void> {
343 const branch = snap.session?.branch
344 if (!branch) return
345 // ORC_STATE_DIR is absolute when the SessionStart hook exported it; else orc-state's default, <git root>/.orc.
346 const env = await $.env.get('ORC_STATE_DIR').catch(() => undefined)
347 const top = env ? '' : await $.process.run(['git', 'rev-parse', '--show-toplevel'], { timeoutMs: 5000 }).then(r => (r.exitCode === 0 ? r.stdout.trim() : ''), () => '')
348 const root = env || (top ? top + '/.orc' : '.orc')
349 const text = await $.fs.read(`${root}/${branch}/files/checkpoint.md`).then(t => (typeof t === 'string' ? t : ''), () => '')
350 const d = (await $.state.get(DETAILS_REF)).value ?? DETAILS_INITIAL
351 await $.state.set(DETAILS_REF, { ...d, digest: digestOf(text) ?? '' })
352}
353
354// The current slice's diff: its commit when it has one, else the working tree.
355async function load_diff($: EngineInterface, snap: Snapshot): Promise<void> {
356 const cur = diffSlice(snap.slices)
357 const argv = cur?.commit ? ['git', 'show', '--no-color', '--format=', '-U1', cur.commit] : ['git', 'diff', '--no-color', '-U1']
358 const out = await $.process.run(argv, { timeoutMs: 10000 }).then(r => (r.exitCode === 0 ? r.stdout : ''), () => '')
359 const { source, truncated } = cutDiff(out)
360 const d = (await $.state.get(DETAILS_REF)).value ?? DETAILS_INITIAL
361 await $.state.set(DETAILS_REF, { ...d, diff: source, diffTruncated: truncated })
362}
363
364// One orc-state call; '' when it fails or there is no session.
365async function orc_state($: EngineInterface, argv: string[]): Promise<string> {
366 try {
367 const r = await $.process.run([$.plugin.root + '/bin/orc-state', ...argv], { timeoutMs: 5000 })
368 return r.exitCode === 0 ? r.stdout.trim() : ''
369 } catch {
370 return ''
371 }
372}
373hooks/mod/ci.ts 46 lines1// CI watcher logic: summarize `gh pr checks --json name,bucket`, decide when a
2// change deserves an alert, and share one poller across sessions through a
3// lease in $.store (every session on the machine shares it).
4
5export type CiState = 'pass' | 'fail' | 'pending' | 'none'
6export type CiSummary = { state: CiState; failing: string[]; pending: number; total: number }
7export type Lease = { sid: string; until: number }
8export type CiRecord = { lease?: Lease; summary?: CiSummary; checkedAt?: number; notified?: string }
9
10export const POLL_MS = 60_000
11export const LEASE_MS = 90_000
12export const SETTLED_MS = 300_000 // a settled PR is re-checked every 5 minutes (a new push restarts CI)
13
14export function summarizeChecks(json: string): CiSummary {
15 let rows: { name?: string; bucket?: string }[] = []
16 try {
17 rows = JSON.parse(json)
18 } catch {
19 return { state: 'none', failing: [], pending: 0, total: 0 }
20 }
21 if (!Array.isArray(rows) || rows.length === 0) return { state: 'none', failing: [], pending: 0, total: 0 }
22 const failing = rows.filter(r => r.bucket === 'fail' || r.bucket === 'cancel').map(r => String(r.name)).sort()
23 const pending = rows.filter(r => r.bucket === 'pending').length
24 const state: CiState = failing.length ? 'fail' : pending ? 'pending' : 'pass'
25 return { state, failing, pending, total: rows.length }
26}
27
28export const signature = (s: CiSummary) => s.state + ':' + s.failing.join(',')
29
30// Another live session holds the lease: read its result instead of polling.
31export const mayPoll = (lease: Lease | undefined, sid: string, now: number) => !lease || lease.sid === sid || lease.until < now
32
33// Red: failing now, with a failure set not yet alerted. Green: passing after we saw it not pass.
34export function alertFor(prev: CiSummary | undefined, next: CiSummary, notified?: string): 'red' | 'green' | null {
35 if (next.state === 'fail' && notified !== signature(next)) return 'red'
36 if (next.state === 'pass' && prev && prev.state !== 'pass' && notified !== signature(next)) return 'green'
37 return null
38}
39
40export function statusLine(pr: number | string, s: CiSummary): string {
41 if (s.state === 'fail') return `CI #${pr}: ${s.failing.length} failing (${s.failing.join(', ')})`
42 if (s.state === 'pending') return `CI #${pr}: running (${s.pending}/${s.total} pending)`
43 if (s.state === 'pass') return `CI #${pr}: green`
44 return `CI #${pr}: no checks`
45}
46hooks/mod/cockpit.ts 202 lines1// The /orc cockpit's data and view model: pure functions over orc-state's CLI
2// output (`get`, `slice list`, `decision get`), so register.ts only binds `$`
3// calls and draws. orc-state stays the single writer of .orc/.
4
5import type {
6 OrcAgentUsage, OrcCriterion, OrcDecision, OrcMeter, OrcQaReport, OrcSession, OrcSize, OrcSlice, OrcSnapshot,
7} from '../../types'
8import type { Profile } from './profiles'
9
10export type Session = OrcSession
11export type Slice = OrcSlice
12export type Decision = OrcDecision
13export type Criterion = OrcCriterion
14export type QaReport = OrcQaReport
15export type AgentUsage = OrcAgentUsage
16export type Size = OrcSize
17export type Meter = OrcMeter
18export type Snapshot = OrcSnapshot
19
20export const POLICIES = ['manual', 'guided', 'auto'] as const
21export const EMPTY: Snapshot = { session: null, slices: [], decisions: [] }
22
23export function parseSession(stdout: string): Session | null {
24 try {
25 const s = JSON.parse(stdout)
26 return s && typeof s.command === 'string' ? s : null
27 } catch {
28 return null
29 }
30}
31
32// `orc-state slice list`: id<TAB>status<TAB>title<TAB>commit|-
33export function parseSlices(tsv: string): Slice[] {
34 return tsv.split('\n').filter(Boolean).map(line => {
35 const [id = '', status = '', title = '', commit = '-'] = line.split('\t')
36 return { id, status, title, commit: commit === '-' ? null : commit }
37 })
38}
39
40export function parseDecisions(json: string): Decision[] {
41 try {
42 const d = JSON.parse(json).decisions ?? {}
43 return Object.keys(d).sort().map(key => ({ key, value: String(d[key].value), provenance: String(d[key].provenance) }))
44 } catch {
45 return []
46 }
47}
48
49export const isDone = (s: Slice) => s.status === 'committed' || s.status === 'skipped'
50export const policyOf = (snap: Snapshot) => snap.decisions.find(d => d.key === 'autopilotLevel')?.value
51
52// One row per phase: ✓ done, ▶ current (with its label), · ahead.
53export function phaseRows(s: Session): string[] {
54 const current = s.phase === 'done' ? s.totalPhases + 1 : Number(s.phase)
55 return Array.from({ length: s.totalPhases }, (_, i) => {
56 const n = i + 1
57 const mark = n < current ? '✓' : n === current ? '▶' : '·'
58 return `${mark} ${n}${n === current && s.phaseLabel ? ' ' + s.phaseLabel : ''}`
59 })
60}
61
62export function headline(snap: Snapshot): string {
63 const s = snap.session
64 if (!s) return 'No orc session on this branch — start one with /orc:flow or /orc:plan.'
65 const phase = s.phase === 'done' ? 'done' : `${s.phase}/${s.totalPhases}${s.phaseLabel ? ' ' + s.phaseLabel : ''}`
66 const slices = snap.slices.length ? `slices ${snap.slices.filter(isDone).length}/${snap.slices.length}` : ''
67 const policy = policyOf(snap)
68 return [`${s.command} ${phase}`, slices, s.jiraTicket ?? '', policy ? `policy ${policy}` : '', s.gitBranch]
69 .filter(Boolean)
70 .join(' · ')
71}
72
73export const sliceLine = (s: Slice) => `#${s.id} ${s.status.padEnd(9)} ${s.title}${s.commit ? ' ' + s.commit : ''}`
74
75// The text answer where no pane can be drawn (VS Code, claude -p, a refused pane).
76export function summaryText(snap: Snapshot): string {
77 return [headline(snap), ...snap.slices.map(sliceLine)].join('\n')
78}
79
80// --- QA / PR / Agents tabs ---------------------------------------------------
81
82export function parseReport(json: string): QaReport | null {
83 try {
84 const r = JSON.parse(json)
85 return Array.isArray(r.acceptance) ? { verdict: String(r.verdict ?? ''), acceptance: r.acceptance, missing: r.missing ?? [] } : null
86 } catch {
87 return null
88 }
89}
90
91const MARK: Record<string, string> = { pass: '✓', fail: '✗', skipped: '–' }
92export const criterionLine = (c: Criterion) =>
93 `${MARK[c.result] ?? '?'} ${c.criterion} (slice ${c.sliceId})${c.note ? ' — ' + c.note : ''}`
94
95export function parseSize(loc: string, budget: string): Size | null {
96 const l = Number(loc.trim()), b = Number(budget.trim())
97 return Number.isFinite(l) && Number.isFinite(b) && b > 0 && loc.trim() !== '' ? { loc: l, budget: b } : null
98}
99
100// A 20-cell bar: filled share of the budget, then the numbers.
101export function sizeLine(s: Size): string {
102 const filled = Math.min(20, Math.round((s.loc / s.budget) * 20))
103 const over = s.loc - s.budget
104 return `${'█'.repeat(filled)}${'░'.repeat(20 - filled)} ${s.loc}/${s.budget} LOC${over > 0 ? ` — over by ${over}` : ''}`
105}
106
107export function parseUsage(json: string): AgentUsage[] {
108 try {
109 const rows = JSON.parse(json)
110 return Array.isArray(rows) ? rows : []
111 } catch {
112 return []
113 }
114}
115
116const k = (n: number) => (n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n))
117export const usageLine = (u: AgentUsage) =>
118 `${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`
119
120// $.session.usage() → the header's context % and cost; null where the host has none.
121export function parseMeter(u: { context?: { percent?: number }; cost?: { usd: number } } | null | undefined): Meter | null {
122 if (!u) return null
123 const pct = u.context?.percent
124 return { contextPercent: typeof pct === 'number' ? Math.round(pct) : null, usd: u.cost ? Math.round(u.cost.usd * 100) / 100 : null }
125}
126
127export type Cell = { text: string; bold?: boolean; dim?: boolean; color?: string }
128
129// Model profile in force: the branch's settled decision, else the userConfig value.
130export const profileOf = (snap: Snapshot, fallback: Profile): Profile => {
131 const d = snap.decisions.find(d => d.key === 'modelProfile')?.value
132 return d === 'quality' || d === 'economy' || d === 'balanced' ? d : fallback
133}
134
135// The header strip: one cell per fact, wrapping as the width allows.
136export function headerCells(snap: Snapshot, profile: Profile): Cell[] {
137 const s = snap.session
138 if (!s) return [{ text: 'No orc session on this branch — /orc:flow or /orc:plan starts one.', dim: true }]
139 const phase = s.phase === 'done' ? 'done' : `${s.phase}/${s.totalPhases}${s.phaseLabel ? ' ' + s.phaseLabel : ''}`
140 const cells: Cell[] = [{ text: `${s.command} ${phase}`, bold: true }]
141 if (snap.slices.length) cells.push({ text: `slices ${snap.slices.filter(isDone).length}/${snap.slices.length}` })
142 cells.push({ text: `policy ${policyOf(snap) ?? 'manual'}` }, { text: `profile ${profile}` })
143 const m = snap.meter
144 if (m?.contextPercent != null) cells.push({ text: `ctx ${m.contextPercent}%`, color: m.contextPercent >= 85 ? 'red' : m.contextPercent >= 70 ? 'yellow' : undefined })
145 if (m?.usd != null) cells.push({ text: `$${m.usd.toFixed(2)}` })
146 if (s.jiraTicket) cells.push({ text: s.jiraTicket })
147 cells.push({ text: s.gitBranch, dim: true })
148 return cells
149}
150
151// Ladder beside ledger when there is room for both (28 + 2 + ~42 columns); else stacked.
152export const layoutFor = (placement: 'dock' | 'inline', bodyColumns: number): 'row' | 'column' =>
153 bodyColumns >= 72 || (placement === 'dock' && bodyColumns >= 64) ? 'row' : 'column'
154
155export type SectionId = 'qa' | 'pr' | 'agents' | 'decisions' | 'digest' | 'diff'
156
157// Which sections need a look: a failing QA verdict, an over-budget diff or red CI.
158export function attention(snap: Snapshot, ciRed: boolean): Set<SectionId> {
159 const out = new Set<SectionId>()
160 if (snap.qa && snap.qa.verdict !== 'pass') out.add('qa')
161 if ((snap.size && snap.size.loc > snap.size.budget) || ciRed) out.add('pr')
162 return out
163}
164
165export const sectionLabel = (id: SectionId, open: boolean, flagged: boolean) =>
166 `${open ? '▾' : '▸'} ${({ qa: 'QA', pr: 'PR', agents: 'Agents', decisions: 'Decisions', digest: 'Digest', diff: 'Diff' })[id]}${flagged ? ' !' : ''}`
167
168// The '## Resume digest' section of checkpoint.md, or null when there is none.
169export function digestOf(markdown: string): string | null {
170 const m = /^## Resume digest[^\S\n]*\n([\s\S]*?)(?=^## |(?![\s\S]))/m.exec(markdown)
171 const text = m?.[1]?.trim() ?? ''
172 return text ? text : null
173}
174
175// A unified diff bounded for a Code leaf: cut at the last file boundary under max.
176export function cutDiff(text: string, max = 8000): { source: string; truncated: boolean } {
177 if (text.length <= max) return { source: text, truncated: false }
178 const at = text.lastIndexOf('\ndiff --git', max)
179 return { source: at > 0 ? text.slice(0, at + 1) : text.slice(0, text.indexOf('\n', max) + 1 || max), truncated: true }
180}
181
182export const currentSlice = (slices: Slice[]): Slice | null => slices.find(x => !isDone(x)) ?? null
183
184// What the Diff section shows: the slice in progress (its working tree), else the last committed one (its commit).
185export const diffSlice = (slices: Slice[]): Slice | null =>
186 currentSlice(slices) ?? [...slices].reverse().find(x => x.commit) ?? null
187
188export const phaseShort = (s: Session) =>
189 `${s.command} ${s.phase === 'done' ? 'done' : `${s.phase}/${s.totalPhases}`}${s.phaseLabel && s.phase !== 'done' ? ' ' + s.phaseLabel : ''}`
190export const spinnerSuffix = (snap: Snapshot, agent: string | null) =>
191 snap.session ? ` · orc ${phaseShort(snap.session)}${agent ? ' · ' + agent.replace(/^orc-/, '') : ''}` : ''
192export const hintFor = (snap: Snapshot) => (snap.session ? `orc ${phaseShort(snap.session)} · /orc cockpit · /orc:resume` : null)
193
194const PLUMBING = /^\s*(?:\S*\/)?(?:orc-state|orc-pr-size|orc-report|orc-docker-env|orc-workspace-detect)\b|^\s*gh pr checks\b/
195// One dim line for orc's own CLI calls; null for anything else (the engine draws it).
196export function plumbingLine(input: unknown, isRunning: boolean): string | null {
197 const command = (input as { command?: unknown })?.command
198 if (typeof command !== 'string' || !PLUMBING.test(command)) return null
199 const first = command.split('\n')[0]?.trim() ?? ''
200 return `○ orc · ${first.length > 100 ? first.slice(0, 99) + '…' : first}${isRunning ? ' …' : ''}`
201}
202hooks/mod/gates.ts 15 lines1// Gate chrome: the class of an orc gate, read from the AskUserQuestion header
2// chips orc:gates reserves (a closed vocabulary), so the dialog can say which
3// gates no autopilot level will ever answer. Other dialogs get no badge.
4
5const OUTWARD = new Set(['Publish', 'Post', 'Tracker'])
6
7export function gateBadge(questions: readonly unknown[]): string | null {
8 const headers = questions.map(q => (q && typeof q === 'object' ? (q as { header?: unknown }).header : undefined))
9 if (headers.some(h => typeof h === 'string' && OUTWARD.has(h))) {
10 return '⛔ orc · outward gate — this writes outside your machine; no autopilot level answers it'
11 }
12 if (headers.includes('Escalation')) return '▲ orc · escalation — the run stopped and needs your decision'
13 return null
14}
15hooks/mod/pane.ts 111 lines1// The /orc cockpit's drawing: pure. register.ts owns every `$` call (the
2// engine's scan follows `$` only inside that file), builds the callbacks, and
3// hands this module the element table, the data and the actions.
4import type { EngineInterface } from 'claude-code'
5import type { OrcDetails, OrcSections } from '../../types'
6import { POLICIES, attention, criterionLine, diffSlice, headerCells, isDone, layoutFor, phaseRows, policyOf, sectionLabel, sizeLine, sliceLine, usageLine, type SectionId, type Snapshot } from './cockpit'
7import type { Profile } from './profiles'
8import type { CiAlert } from './state'
9
10export const PANE = 'orc-cockpit'
11
12export type Els = ReturnType<EngineInterface['ui']['resolve']>
13export type PaneModel = {
14 snap: Snapshot
15 profile: Profile
16 placement: 'dock' | 'inline'
17 bodyColumns: number
18 sections: OrcSections
19 ciAlert: CiAlert
20 details: OrcDetails
21}
22export type PaneActions = { resume: () => void; toggle: (id: SectionId) => () => void; renderReport: () => void; settle: (key: string, value: string) => void }
23
24const PROFILES = ['balanced', 'quality', 'economy'] as const
25
26export function drawPane(els: Els, model: PaneModel, actions: PaneActions) {
27 const { Box, Text, Button, Link } = els
28 // The mobile app draws no Select yet; the section then shows the decisions as text only.
29 const Select = 'Select' in els ? els.Select : null
30 const Markdown = 'Markdown' in els ? els.Markdown : null
31 const Code = 'Code' in els ? els.Code : null
32 const { snap } = model
33 const s = snap.session
34 const line = (text: string, style: { bold?: boolean; dimColor?: boolean; color?: string } = {}) => Text({ ...style, children: [text] })
35
36 const header = Box({ flexDirection: 'row', flexWrap: 'wrap', columnGap: 2, children:
37 headerCells(snap, model.profile).map(c => line(c.text, { bold: c.bold, dimColor: c.dim, color: c.color })) })
38
39 const ladder = s
40 ? Box({ flexDirection: 'column', borderStyle: 'round', borderDimColor: true, paddingX: 1, width: 28, children: [
41 line('Phases', { bold: true }),
42 ...phaseRows(s).map(r => line(r, r.startsWith('▶') ? { bold: true, color: 'cyan' } : r.startsWith('✓') ? { color: 'green' } : { dimColor: true })),
43 ] })
44 : null
45 const ledger = Box({ flexDirection: 'column', borderStyle: 'round', borderDimColor: true, paddingX: 1, flexGrow: 1, children: [
46 line('Slices', { bold: true }),
47 ...(snap.slices.length
48 ? snap.slices.map(x => line(sliceLine(x), { color: isDone(x) ? 'green' : x.status === 'pending' ? undefined : 'red' }))
49 : [line('No slice ledger yet — /orc:plan writes one.', { dimColor: true })]),
50 ] })
51 const body = Box({ flexDirection: layoutFor(model.placement, model.bodyColumns), columnGap: 1, children: [...(ladder ? [ladder] : []), ledger] })
52
53 const flagged = attention(snap, model.ciAlert !== null)
54 const section = (id: SectionId, hotkey: string, children: () => ReturnType<typeof Box>[]) =>
55 Box({ flexDirection: 'column', children: [
56 Button({ key: 'sec-' + id, plain: true, hotkey, label: sectionLabel(id, model.sections[id], flagged.has(id)), onPress: actions.toggle(id) }),
57 ...(model.sections[id] ? [Box({ flexDirection: 'column', paddingLeft: 2, children: children() })] : []),
58 ] })
59
60 const qa = section('qa', 'q', () => snap.qa
61 ? [line('Verdict: ' + snap.qa.verdict.toUpperCase(), { bold: true, color: snap.qa.verdict === 'pass' ? 'green' : 'red' }),
62 ...snap.qa.acceptance.map(c => line(criterionLine(c), { color: c.result === 'pass' ? 'green' : c.result === 'fail' ? 'red' : undefined })),
63 ...(snap.qa.missing.length ? [line('Missing evidence: ' + snap.qa.missing.join(', '), { color: 'red' })] : []),
64 Button({ key: 'qa-report', label: 'Render report', onPress: actions.renderReport })]
65 : [line('No QA packet yet — /orc:qa writes one.', { dimColor: true })])
66
67 const prs = s?.linkedPRs ?? []
68 const alert = model.ciAlert
69 const pr = section('pr', 'p', () => [
70 ...(snap.size ? [line('Size ' + sizeLine(snap.size), { color: snap.size.loc > snap.size.budget ? 'red' : undefined })] : []),
71 ...(prs.length
72 ? prs.map(p => Box({ flexDirection: 'row', columnGap: 1, children: [line(`#${p.number ?? '?'}`), ...(p.url ? [Link({ href: p.url, label: p.url })] : [])] }))
73 : [line('No PR linked yet — /orc:ship opens one.', { dimColor: true })]),
74 ...(alert ? [line(`CI: ${alert.summary.failing.length} failing (${alert.summary.failing.join(', ')})`, { color: 'red' })] : []),
75 ])
76
77 const agents = section('agents', 'g', () => snap.usage?.length
78 ? [line('agent runs tokens time', { dimColor: true }), ...snap.usage.map(u => line(usageLine(u)))]
79 : [line('No subagent runs recorded yet.', { dimColor: true })])
80
81 const policy = policyOf(snap) ?? 'manual'
82 const decisions = section('decisions', 'd', () => [
83 ...(Select ? [Select({ key: 'policy', label: 'Autopilot', options: POLICIES.map(p => ({ value: p })), value: policy,
84 onSelect: (v: string) => { if (v !== policy) actions.settle('autopilotLevel', v) } })] : []),
85 ...(Select ? [Select({ key: 'profile', label: 'Model profile', options: PROFILES.map(p => ({ value: p })), value: model.profile,
86 onSelect: (v: string) => { if (v !== model.profile) actions.settle('modelProfile', v) } })] : []),
87 ...snap.decisions.map(d => line(`${d.key} = ${d.value} (${d.provenance})`, { dimColor: true })),
88 ])
89
90 const { details } = model
91 const loading = [line('Loading…', { dimColor: true })]
92 const digest = section('digest', 'c', () => details.digest === null
93 ? loading
94 : details.digest ? [Markdown ? Markdown({ text: details.digest }) : line(details.digest)] : [line('No resume digest yet.', { dimColor: true })])
95
96 const cur = diffSlice(snap.slices)
97 const diff = section('diff', 'x', () => details.diff === null
98 ? loading
99 : [line(cur ? `Slice #${cur.id} ${cur.title}${cur.commit ? ' · ' + cur.commit : ' · working tree'}` : 'Working tree', { bold: true }),
100 ...(details.diff ? [Code ? Code({ source: details.diff, format: 'diff', wrap: 'truncate-end' }) : line(details.diff)] : [line('No changes.', { dimColor: true })]),
101 ...(details.diffTruncated ? [line('truncated — open the file for the rest', { dimColor: true })] : [])])
102
103 const footer = Box({ flexDirection: 'row', columnGap: 2, children: [
104 Button({ key: 'resume', variant: 'primary', label: s ? 'Resume (/orc:resume)' : 'New flow (/orc:flow)', hotkey: 'r', onPress: actions.resume }),
105 ] })
106
107 // Sections sit tight (one row each while collapsed); the gap only separates the strip, the body, the sections and the footer.
108 const sections = Box({ flexDirection: 'column', children: [qa, pr, agents, decisions, digest, diff] })
109 return Box({ flexDirection: 'column', rowGap: 1, children: [header, body, sections, footer] })
110}
111hooks/mod/profiles.ts 39 lines1// model_profile: which model each orc agent runs on when the Agent call gave
2// none. `balanced` keeps the agents' frontmatter (the floor every profile falls
3// back to); `quality` steps investigators/reviewers up; `economy` steps down.
4// Model only — effort stays in frontmatter. An explicit per-call model (an
5// escalation re-dispatch) always wins.
6
7export type Profile = 'balanced' | 'quality' | 'economy'
8
9const QUALITY: Record<string, string> = {
10 'orc-debug-investigator': 'best', // Fable where available, else Opus: investigate-before-acting root cause
11 'orc-pr-reviewer': 'opus',
12 'orc-ci-investigator': 'opus',
13 'orc-test-author': 'opus',
14 'orc-code-fixer': 'sonnet',
15 'orc-reply-drafter': 'sonnet',
16}
17
18const ECONOMY: Record<string, string> = {
19 'orc-debug-investigator': 'sonnet',
20 'orc-jira-architect': 'sonnet',
21 'orc-prd-analyzer': 'sonnet',
22 'orc-refactor-architect': 'sonnet',
23 'orc-security-reviewer': 'sonnet',
24 'orc-stack-analyzer': 'sonnet',
25 'orc-env-provisioner': 'haiku',
26}
27
28export function parseProfile(value: unknown): Profile {
29 return value === 'quality' || value === 'economy' ? value : 'balanced'
30}
31
32// `orc:orc-implementer` and `orc-implementer` name the same agent.
33export function modelFor(profile: Profile, subagentType: string): string | undefined {
34 const name = subagentType.replace(/^orc:/, '')
35 if (profile === 'quality') return QUALITY[name]
36 if (profile === 'economy') return ECONOMY[name]
37 return undefined
38}
39hooks/mod/state.ts 20 lines1// The cockpit's data in $.state: the initial values of the `orc` keys the
2// contract (types/index.d.ts) declares. Pure — the engine follows `$` only
3// inside register.ts and wants state refs written as literals there, so the
4// refs, the loader and the triggers all live in register.ts.
5import type { OrcDetails, OrcLive, OrcSections } from '../../types'
6import type { CiSummary } from './ci'
7
8export const SECTIONS_INITIAL: OrcSections = { qa: false, pr: false, agents: false, decisions: false, digest: false, diff: false }
9export const LIVE_INITIAL: OrcLive = { agent: null, since: null }
10export const DETAILS_INITIAL: OrcDetails = { digest: null, diff: null, diffTruncated: false }
11
12// The red-CI alert lives in module scope (the watcher runs on a timer, outside
13// any event); the band and the PR section read it and ui.invalidate redraws.
14export type CiAlert = { pr: number | string; summary: CiSummary } | null
15export const ciBox: { alert: CiAlert } = { alert: null }
16
17// orc-state verbs that change what the cockpit shows. Reads (get, list, line…) never refresh.
18const 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/
19export const isOrcStateWrite = (command: string) => WRITE.test(command)
20types/index.d.ts 46 lines1// orc's plugin contract: the shapes the mod keeps in `$.state`, declared so
2// `$.state.get/set({ plugin: 'orc', key })` type-check. No imports here (contract rule).
3
4export type OrcSession = {
5 command: string
6 branch: string
7 gitBranch: string
8 description?: string | null
9 status: string
10 phase: number | 'done'
11 phaseLabel?: string | null
12 totalPhases: number
13 jiraTicket?: string | null
14 linkedPRs?: { url?: string; number?: number; repo?: string }[]
15}
16export type OrcSlice = { id: string; status: string; title: string; commit: string | null }
17export type OrcDecision = { key: string; value: string; provenance: string }
18export type OrcCriterion = { criterion: string; result: string; note: string; sliceId: number | string }
19export type OrcQaReport = { verdict: string; acceptance: OrcCriterion[]; missing: string[] }
20export type OrcAgentUsage = { agent: string; runs: number; in: number; out: number; ms: number }
21export type OrcSize = { loc: number; budget: number }
22export type OrcMeter = { contextPercent: number | null; usd: number | null }
23export type OrcSnapshot = {
24 session: OrcSession | null
25 slices: OrcSlice[]
26 decisions: OrcDecision[]
27 qa?: OrcQaReport | null
28 size?: OrcSize | null
29 usage?: OrcAgentUsage[]
30 meter?: OrcMeter | null
31}
32export type OrcSections = { qa: boolean; pr: boolean; agents: boolean; decisions: boolean; digest: boolean; diff: boolean }
33export type OrcLive = { agent: string | null; since: number | null }
34export type OrcDetails = { digest: string | null; diff: string | null; diffTruncated: boolean }
35
36declare module 'claude-code' {
37 interface PluginState {
38 orc: {
39 snapshot: OrcSnapshot
40 sections: OrcSections
41 live: OrcLive
42 details: OrcDetails
43 }
44 }
45}
46