SLOPSHOPPER

orc

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

newpanebandspinnerrowsguard
★ 6v0.25.0MITupdated 2026-10-07HigorAlves/orc/orc
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · orc
│ ┃ orc ✕ › fix the failing auth test and add an audit log call │ ┃ No orc session on this branch — /orc:flow or │ ┃ /orc:plan starts one. ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ╭──────────────────────────────────────────╮ ⏺ Update(src/auth.ts) │ ┃ │ Slices │ ⎿ Added 2 lines, removed 1 line │ ┃ │ No slice ledger yet — /orc:plan writes │ ⏺ Bash(bun test) │ ┃ │ one. │ ⎿ 3 pass, 1 fail │ ┃ ╰──────────────────────────────────────────╯ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ q: ▸ QA │ ┃ p: ▸ PR ✻ Worked for 42s · done 4:20 PM │ ┃ g: ▸ Agents │ ┃ d: ▸ Decisions › /orc │ ┃ c: ▸ Digest │ ┃ x: ▸ Diff │ ┃ │ ┃ [ New flow (/orc:flow) ] │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · orc
No orc session on this branch — /orc:flow or /orc:plan starts one. ╭──────────────────────────────────────────────────────────╮ │ Slices │ │ No slice ledger yet — /orc:plan writes one. │ ╰──────────────────────────────────────────────────────────╯ q: ▸ QA p: ▸ PR g: ▸ Agents d: ▸ Decisions c: ▸ Digest x: ▸ Diff [ New flow (/orc:flow) ]
README

<img src=".github/images/second-icon.png" alt="orc" width="360" />

<h1 align="center">orc</h1>

<strong>"Zug zug."</strong> &nbsp;·&nbsp; <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> &nbsp;·&nbsp; <a href="#install"><strong>Install</strong></a> &nbsp;·&nbsp; <a href="./docs/commands.md"><strong>Commands</strong></a> &nbsp;·&nbsp; <a href="./docs/safety.md#faq"><strong>FAQ</strong></a> &nbsp;·&nbsp; <a href="./docs/examples/README.md"><strong>Examples</strong></a>

<code>84 skills</code> &nbsp;·&nbsp; <code>31 commands</code> &nbsp;·&nbsp; <code>14 agents</code> &nbsp;·&nbsp; <code>9 hooks</code> &nbsp;·&nbsp; <code>0 telemetry</code> &nbsp;·&nbsp; <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.

Is it safe to use?

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.

What it does

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:

  • Fix & review — /orc:debug (root-cause first), /orc:code-review, /orc:address, /orc:stack-pr, /orc:fan-out
  • Author — /orc:prd · /orc:trd · /orc:adr · /orc:rfc · /orc:postmortem · /orc:scaffold
  • Operate — /orc:env, /orc:incident, /orc:deps, /orc:release, /orc:ci
  • Track — /orc:jira, /orc:jira-breakdown, /orc:evidence, /orc:setup, /orc:triage, /orc:wayfinder
  • Sessions — /orc:resume, /orc:status, /orc:cleanup — multi-phase work checkpoints to .orc/ and survives interruption

The 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.

Install

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.

Statusline

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.

Iron rules (enforced by hooks + the using-orc skill)

  1. No commits to main/master/develop — the PreToolUse hook downgrades them to a confirm prompt; approve only with explicit consent.
  2. No code without a failing test first.
  3. No claims without verification (run the command, read the output).
  4. No fixes without a found root cause.
  5. No AI attribution in code, commits, or PRs.
  6. No multi-phase work without .orc/ checkpoints.
  7. No silent broadcast in workspace mode — repo-touching commands need an explicit target flag or a confirming prompt.
  8. No PR over the size budget (default 300 LOC) without a recorded choice — stack it, record a Size-budget-override: trailer, or abort.

Designed to stay lean

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.

Docs

PageWhat's in it
docs/install.mdStep-by-step install with verification checkpoints, marketplace-only path, requirements table
docs/commands.mdFull command table, the specialist agents, the skill library, the CLI reference
docs/examples19 scenario walk-throughs — start here for usage
docs/configuration.mdPlugin settings, env vars, statusline tuning
docs/safety.mdThe safety case in depth + FAQ
docs/architecture.mdWhy the layout is what it is; hooks, state, and the .orc/ lifecycle
docs/contributing.mdAdding skills, commands, agents, and hooks

Development

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.

License

MIT — see LICENSE.

Source 8 files
hooks/mod/register.ts 373 lines
1// 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}
373
hooks/mod/ci.ts 46 lines
1// 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}
46
hooks/mod/cockpit.ts 202 lines
1// 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}
202
hooks/mod/gates.ts 15 lines
1// 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}
15
hooks/mod/pane.ts 111 lines
1// 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}
111
hooks/mod/profiles.ts 39 lines
1// 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}
39
hooks/mod/state.ts 20 lines
1// 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)
20
types/index.d.ts 46 lines
1// 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