Display-only ApexYard status band above the prompt: active ticket, PRs waiting for /approve-merge, reviewer lock, optional build count.

An optional Claude Code plugin for an ApexYard ops fork. It draws one line above the prompt:
ApexYard apexyard#1541 Optional status band · 1 waiting for your /approve-merge: me2resh/apexyard#12 · review lock: me2resh/apexyard#13 (rex)
The line shows:
repo#N and a short title.The plugin shows a toast when a new Rex marker appears and when a build process ends. It refreshes after each turn and every 30 seconds.
Run /orbit-progress to open the ORBIT initiatives pane. Each row shows the initiative title from the Plan record. Expand a row to see its Plan ID, revision, outcomes, criteria, evidence, and slices. Press 1–9 or select a row to expand it.
Each row also shows open slice tickets, linked open PRs, approval counts, and the last activity time. The plugin matches exact slice IDs in issue bodies. It matches ticket references in PR titles or bodies. Counts exclude closed tickets and PRs. Duplicate tickets and PRs count once per initiative. Approval counts use GitHub's review decision. Ready counts require a clean merge state and a non-draft PR. These counts describe GitHub status. ApexYard merge gates still apply. The activity time is the latest returned issue or PR update time. Failed GitHub queries show (offline) instead of zero counts.
The expanded row shows the reconciled date and the last no-change check date together. The check date comes from ## Checks in docs/orbit/plans/<plan-id>.notes.md.
The pane reads records from existing local origin/main and origin/dev refs every two minutes. It does not fetch or change Git refs. Fetch the project clones separately to update their records. It reads the registry at apexyard.projects.yaml, or the portfolio.registry path in .claude/project-config.json. Relative workspace paths resolve from the registry directory.
ps when you set a pattern. It never writes a file. It never replaces a shell hook. All gates stay in .claude/hooks/.gh pr list --repo <owner/repo> --state open --json number,headRefOid --limit 200. The plugin runs it only for a repo that has a candidate PR, at most once every 120 seconds for each repo, with a 5-second time limit. It keeps the result in memory only. It never calls a write endpoint.gh is missing, is not signed in, fails or is too slow, the band counts from the markers only and shows (offline) next to the count.gh search issues and gh pr list. Each query is cached in memory for 120 seconds, including failures. Each call has a 5-second timeout and a 20-result limit. Counts describe those returned results. Expanding a slice reuses the same cache.Run these two commands from the ops-fork root. /setup runs them for you when you answer "y" to the status-band question.
claude plugin marketplace add ./ --scope local
claude plugin install apexyard-status@apexyard --scope local
local scope writes only to .claude/settings.local.json, which git ignores. The band is on only for you, on this machine. The committed .claude/settings.json does not enable it.
To remove the band, run claude plugin uninstall apexyard-status@apexyard --scope local.
Claude Code reads the plugin from the fork folder. After /update, run /reload-plugins. You do not need to reinstall.
Set these in /plugin configure apexyard-status@apexyard, or with --config KEY=VALUE at install.
| Setting | Default | Meaning | |
|---|---|---|---|
build_process_pattern | empty (off) | A regular expression. The plugin counts the running processes whose command line matches it. Example: `codex exec\ | cursor-agent`. |
waiting_window_days | 3 | A Rex marker older than this number of days does not count as waiting. |
| Item | Source |
|---|---|
| Ops fork | The session pin ~/.claude/apexyard/ops-root-<session> (or $APEXYARD_OPS_PIN_DIR). APEXYARD_OPS_DISABLE_PIN=1 ignores the pin, as in the hooks. If the pin is missing or stale, a walk up from the working directory to a .apexyard-fork file, or to onboarding.yaml and apexyard.projects.yaml together. |
| Active ticket | The framework ticket-marker resolver, .claude/hooks/_lib-active-ticket.sh (AgDR-0222). The plugin runs active_ticket_lookup for the working directory and shows the marker that the resolver returns. The resolver applies the location rules, so the band shows the same ticket that the hooks use. |
| Waiting PRs | Candidates: .claude/session/reviews/<owner>__<repo>__<pr>-rex.approved (AgDR-0060 names) with no matching -ceo.approved file, inside the waiting window. A candidate waits for /approve-merge only if the PR is open and its head is the SHA in the Rex marker. An open PR with another head needs re-review. A merged or closed PR is dropped. A marker stays after a PR merges outside /approve-merge, so the markers alone show stale requests. |
| Reviewer lock | One file per review in .claude/session/active-reviewers/<session>/ (active_reviewer_marker_path). The band lists every review. The plugin also reads the legacy .claude/session/active-reviewer.<session> file during the transition. |
| Build count | ps -axo command=, only when build_process_pattern is set. |
claude plugin validate --strict plugins/apexyard-status
claude plugin test plugins/apexyard-status
The Claude Code plugin hook API is early access. A Claude Code release can change it. If it breaks, the band disappears. No gate is affected.
See AgDR-0227 for where fork-only plugins live.
hooks/register.tsx 537 lines1import type { OrbitPlanView, OrbitView, SliceLink, SliceLinks } from '../types'
2import { bar, clip, mergeRecords, pickPlan, shortLabel, pickReconciliation, statusColor, statusMark, summarize, uncoveredOutcomes, checksDate, inFlightText } from './orbit'
3import { GH_CACHE_MS } from './band'
4// ApexYard status band. Display only: it reads files under the ops fork,
5// runs read-only commands (the ticket resolver, one cached open-PR list per
6// repo, and ps when a build pattern is set) and draws one line above the
7// prompt. It never writes a file and never replaces a shell hook. All gates
8// stay in .claude/hooks/.
9import { atom, read, update } from 'claude-code'
10import type { EngineInterface, Register } from 'claude-code'
11
12import type { Band } from '../types'
13import {
14 candidatesFrom,
15 classify,
16 countBuilds,
17 GH_TIMEOUT_MS,
18 markerSha,
19 openPrsFor,
20 reReviewText,
21 findOpsRoot,
22 lockLabels,
23 markerRef,
24 newMarkers,
25 rexMarkers,
26 safeSessionId,
27 ticketLabel,
28 waitingText,
29} from './band'
30import type { GhCache, OpenPrs } from './band'
31
32const band = atom({ plugin: 'apexyard-status', key: 'band' } as const, null)
33
34const orbit = atom({ plugin: 'apexyard-status', key: 'orbit' } as const, null)
35const ORBIT_PANE = 'orbit-progress'
36const ORBIT_REFRESH_MS = 2 * 60 * 1000
37const sliceLinks = atom({ plugin: 'apexyard-status', key: 'sliceLinks' } as const, {})
38const expanded = atom({ plugin: 'apexyard-status', key: 'expanded' } as const, {})
39
40const orbitState: { lastRun: number | null; root: string | null; repo: string | null } = {
41 lastRun: null, root: null, repo: null,
42}
43
44// "name/repo/workspace" triples from the portfolio registry (simple line parser).
45export function parseRegistry(yaml: string): { repo: string; workspace: string }[] {
46 const out: { repo: string; workspace: string }[] = []
47 let repo = '', ws = ''
48 const flush = () => { if (repo && ws) out.push({ repo, workspace: ws }); repo = ''; ws = '' }
49 for (const line of yaml.split('\n')) {
50 if (/^\s*- name:/.test(line)) flush()
51 const r = line.match(/^\s+repo:\s*["']?([^"'\s#]+)/)
52 if (r?.[1]) repo = r[1]
53 const w = line.match(/^\s+workspace:\s*["']?([^"'\s#]+)/)
54 if (w?.[1]) ws = w[1]
55 }
56 flush()
57 return out
58}
59
60// Branches the ORBIT pane reads, oldest first. Framework repos merge to dev
61// and release to main, so dev records appear before a release.
62const ORBIT_BRANCHES = ['main', 'dev']
63
64async function gitShow($: EngineInterface, clone: string, ref: string, path: string): Promise<unknown | null> {
65 try {
66 const r = await $.process.run(['git', '-C', clone, 'show', `origin/${ref}:${path}`], { timeoutMs: 5000 })
67 return r.exitCode === 0 ? JSON.parse(r.stdout) : null
68 } catch {
69 return null
70 }
71}
72
73async function gitList($: EngineInterface, clone: string, ref: string, dir: string): Promise<string[]> {
74 try {
75 const r = await $.process.run(['git', '-C', clone, 'ls-tree', '--name-only', `origin/${ref}`, `${dir}/`], { timeoutMs: 5000 })
76 return r.exitCode === 0 ? r.stdout.split('\n').filter(l => l.endsWith('.json')) : []
77 } catch {
78 return []
79 }
80}
81
82async function loadRecords($: EngineInterface, clone: string, dir: string) {
83 const lists: Record<string, unknown>[][] = []
84 for (const ref of ORBIT_BRANCHES) {
85 const out: Record<string, unknown>[] = []
86 for (const f of await gitList($, clone, ref, `docs/orbit/${dir}`)) {
87 const j = await gitShow($, clone, ref, f)
88 if (j && typeof j === 'object') out.push(j as Record<string, unknown>)
89 }
90 lists.push(out)
91 }
92 return mergeRecords(lists)
93}
94
95export async function refreshOrbit($: EngineInterface, root: string, activeRepo: string | null, force = false) {
96 const now = await $.clock.now()
97 if (!force && root === orbitState.root && activeRepo === orbitState.repo && orbitState.lastRun !== null && now - orbitState.lastRun < ORBIT_REFRESH_MS) return
98 orbitState.lastRun = now
99 orbitState.repo = activeRepo
100 orbitState.root = root
101 let registry = `${root}/apexyard.projects.yaml`
102 try {
103 const config = JSON.parse(await $.fs.read(`${root}/.claude/project-config.json`))
104 const path = config.portfolio?.registry
105 if (typeof path === 'string') registry = path.startsWith('/') ? path : `${root}/${path}`
106 } catch { /* Missing config uses the single-fork registry. */ }
107 const portfolio = registry.slice(0, registry.lastIndexOf('/'))
108 let entries: { repo: string; workspace: string }[] = []
109 try { entries = parseRegistry(await $.fs.read(registry)) } catch { /* A missing registry produces an empty pane. */ }
110 const plans: OrbitPlanView[] = []
111 for (const e of entries) {
112 const clone = e.workspace.startsWith('/') ? e.workspace : `${portfolio}/${e.workspace}`
113 if (!(await exists($, clone))) continue
114 const all = await loadRecords($, clone, 'plans')
115 if (all.length === 0) continue
116 const recs = await loadRecords($, clone, 'reconciliations')
117 const slices = await loadRecords($, clone, 'slices')
118 const ids = [...new Set(all.map(p => (typeof p.id === 'string' ? p.id : '')))].filter(Boolean).sort()
119 for (const id of ids) {
120 const plan = pickPlan(all.filter(p => p.id === id))
121 if (!plan) continue
122 const summary = summarize(plan, pickReconciliation(recs, id), slices)
123 for (const ref of ORBIT_BRANCHES) {
124 try {
125 const r = await $.process.run(['git', '-C', clone, 'show', `origin/${ref}:docs/orbit/plans/${id}.notes.md`], { timeoutMs: GH_TIMEOUT_MS })
126 const date = r.exitCode === 0 ? checksDate(r.stdout) : null
127 if (date && (!summary.checkedAt || date > summary.checkedAt)) summary.checkedAt = date
128 } catch { /* Notes are optional. */ }
129 }
130 for (const sl of summary.slices) await lookupSlice($, e.repo, sl.id)
131 plans.push({ key: `${e.repo}#${id}`, repo: e.repo, summary })
132 }
133 }
134 plans.sort((x, y) => (x.repo === activeRepo ? -1 : y.repo === activeRepo ? 1 : x.key.localeCompare(y.key)))
135 const view: OrbitView = { activeRepo, plans, updatedAt: now }
136 await update($, orbit, () => view)
137}
138
139
140const queryCache = new Map<string, { at: number; value: unknown[] | null }>()
141export async function ghJson($: EngineInterface, argv: string[]): Promise<unknown[] | null> {
142 const key = JSON.stringify(argv)
143 const now = await $.clock.now()
144 const hit = queryCache.get(key)
145 if (hit && now - hit.at < GH_CACHE_MS) return hit.value
146 let value: unknown[] | null = null
147 try {
148 const r = await $.process.run(['gh', ...argv], { timeoutMs: GH_TIMEOUT_MS })
149 const v: unknown = r.exitCode === 0 ? JSON.parse(r.stdout) : null
150 if (Array.isArray(v)) value = v
151 } catch { /* Failed queries remain offline until the cache expires. */ }
152 queryCache.set(key, { at: now, value })
153 return value
154}
155
156// True when the text contains the slice ID as a whole token (slice-t1 does not match slice-t10).
157export function namesSlice(text: string, sliceId: string): boolean {
158 const escaped = sliceId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
159 return new RegExp(`(^|[^A-Za-z0-9_-])${escaped}($|[^A-Za-z0-9_-])`).test(text)
160}
161
162// Issues whose body names the slice id, and PRs whose title or body names "#<n>".
163export async function lookupSlice($: EngineInterface, owner: string, sliceId: string) {
164 const key = `${owner}/${sliceId}`
165 const found = await ghJson($, ['search', 'issues', `"${sliceId}"`, '--repo', owner,
166 '--json', 'repository,number,title,state,body,updatedAt', '--limit', '20'])
167 const issues: SliceLinks['issues'] = []
168 let offline = found === null
169 for (const raw of found ?? []) {
170 const i = raw as { repository?: { nameWithOwner?: string }; number?: number; title?: string; state?: string; body?: string; updatedAt?: string }
171 if (!namesSlice(i.body ?? '', sliceId)) continue
172 const repo = i.repository?.nameWithOwner ?? owner
173 if (typeof i.number !== 'number') continue
174 const prsRaw = await ghJson($, ['pr', 'list', '--repo', repo, '--state', 'all',
175 '--search', `#${i.number} in:title,body`, '--json', 'number,title,body,state,updatedAt,reviewDecision,mergeStateStatus,isDraft', '--limit', '20'])
176 if (prsRaw === null) offline = true
177 const prs: SliceLink[] = []
178 for (const rawPr of prsRaw ?? []) {
179 const pr = rawPr as { number?: number; title?: string; body?: string; state?: string; updatedAt?: string; reviewDecision?: string; mergeStateStatus?: string; isDraft?: boolean }
180 if (typeof pr.number !== 'number' || !new RegExp(`#${i.number}(?![0-9])`).test(`${pr.title ?? ''} ${pr.body ?? ''}`)) continue
181 prs.push({ repo, number: pr.number, title: pr.title ?? '', state: (pr.state ?? '').toLowerCase(),
182 updatedAt: pr.updatedAt, approved: pr.reviewDecision === 'APPROVED', ready: pr.mergeStateStatus === 'CLEAN' && pr.isDraft === false })
183 }
184 issues.push({ repo, number: i.number, title: i.title ?? '', state: (i.state ?? '').toLowerCase(), updatedAt: i.updatedAt, prs })
185 }
186 issues.sort((a, b) => a.state === b.state ? b.number - a.number : a.state === 'open' ? -1 : 1)
187 const done: SliceLinks = { status: offline ? 'error' : 'done', issues }
188 await update($, sliceLinks, cur => ({ ...cur, [key]: done }))
189}
190
191
192const REFRESH_MS = 30_000
193
194type Settings = { pattern: string; windowDays: number }
195
196export function readSettings(options: Readonly<Record<string, unknown>> | undefined): Settings {
197 const p = options?.build_process_pattern
198 const w = options?.waiting_window_days
199 const days = typeof w === 'number' ? w : Number(w)
200 return {
201 pattern: typeof p === 'string' ? p : '',
202 windowDays: Number.isFinite(days) && days >= 0 ? days : 3,
203 }
204}
205
206async function readText($: EngineInterface, path: string): Promise<string | null> {
207 try {
208 return await $.fs.read(path)
209 } catch {
210 return null
211 }
212}
213
214async function exists($: EngineInterface, path: string): Promise<boolean> {
215 try {
216 return await $.fs.exists(path)
217 } catch {
218 return false
219 }
220}
221
222// The resolver command. It sources the framework's ticket-marker resolver
223// (_lib-active-ticket.sh, AgDR-0222) from the ops fork and prints the marker
224// that governs the working directory. The resolver applies the location rules
225// (the marker in the git dir of the working tree first, then the old layout)
226// and validates the tree. Read only: init and lookup write nothing.
227export const RESOLVE_TICKET =
228 '. "$1/.claude/hooks/_lib-active-ticket.sh" && active_ticket_init "$2" && active_ticket_lookup "$2" && cat "$REPLY"'
229
230// The text of the ticket marker that governs `cwd`, or null.
231async function resolvedTicket($: EngineInterface, root: string, cwd: string): Promise<string | null> {
232 if (!(await exists($, `${root}/.claude/hooks/_lib-active-ticket.sh`))) return null
233 try {
234 const r = await $.process.run(['bash', '-c', RESOLVE_TICKET, '_', root, cwd], { cwd, timeoutMs: 5000 })
235 return r.exitCode === 0 && r.stdout.trim() ? r.stdout : null
236 } catch {
237 return null
238 }
239}
240
241function markerRepo(text: string | null): string | null {
242 return text?.match(/^repo=(.+)$/m)?.[1]?.trim() ?? null
243}
244
245type Seen = { rex: Set<string> | null; builds: number | null }
246
247// Module state. A reload of the module starts these over, so the first
248// refresh after a reload seeds them and does not toast.
249const seen: Seen = { rex: null, builds: null }
250// Open PRs per repo, in memory only. openPrsFor refreshes an entry at most
251// once every GH_CACHE_MS.
252const ghCache: GhCache = new Map()
253let settings: Settings = readSettings(undefined)
254let busy = false
255
256async function refresh($: EngineInterface) {
257 if (busy) return
258 busy = true
259 try {
260 await refreshOnce($)
261 } finally {
262 busy = false
263 }
264}
265
266async function refreshOnce($: EngineInterface) {
267 const cwd = await $.session.cwd()
268 const sid = await $.session.id()
269 const home = await $.env.get('HOME')
270 const pinDir = (await $.env.get('APEXYARD_OPS_PIN_DIR')) || (home ? `${home}/.claude/apexyard` : '')
271 // APEXYARD_OPS_DISABLE_PIN=1 ignores the pin, as resolve_ops_root does.
272 const pinOff = (await $.env.get('APEXYARD_OPS_DISABLE_PIN')) === '1'
273 const pinned = pinDir && !pinOff ? await readText($, `${pinDir}/ops-root-${sid}`) : null
274 const root = await findOpsRoot(pinned, cwd, p => exists($, p))
275 if (!root) {
276 await update($, band, () => null)
277 await update($, orbit, () => null)
278 return
279 }
280 const session = `${root}/.claude/session`
281
282 const marker = await resolvedTicket($, root, cwd)
283 const ticket = marker ? ticketLabel(marker) : null
284
285 let waiting: string[] = []
286 let reReview: string[] = []
287 let offline = false
288 let rexNow = new Set<string>()
289 try {
290 const reviews = `${session}/reviews`
291 const entries = await $.fs.list(reviews)
292 const now = await $.clock.now()
293 rexNow = rexMarkers(entries)
294 const cands = candidatesFrom(entries, now, settings.windowDays)
295 const shas = new Map<string, string | null>()
296 const open = new Map<string, OpenPrs | null>()
297 for (const c of cands) {
298 shas.set(c.name, markerSha(await readText($, `${reviews}/${c.name}`)))
299 if (!open.has(c.repo)) {
300 open.set(c.repo, await openPrsFor(ghCache, c.repo, now, argv => $.process.run(argv, { timeoutMs: GH_TIMEOUT_MS })))
301 }
302 }
303 const q = classify(cands, shas, open)
304 waiting = q.waiting
305 reReview = q.reReview
306 offline = q.offline
307 } catch {}
308
309 // One marker file per review (me2resh/apexyard#1649), plus the legacy single
310 // file that is still read during the transition.
311 const safeSid = safeSessionId(sid)
312 const lockTexts: Array<string | null> = []
313 try {
314 const setDir = `${session}/active-reviewers/${safeSid}`
315 for (const name of await $.fs.list(setDir)) lockTexts.push(await readText($, `${setDir}/${name}`))
316 } catch {}
317 lockTexts.push(await readText($, `${session}/active-reviewer.${safeSid}`))
318 const reviewLock = lockLabels(lockTexts)
319
320 let builds: number | null = null
321 if (settings.pattern.trim()) {
322 try {
323 const r = await $.process.run(['ps', '-axo', 'command='], { timeoutMs: 5000 })
324 if (r.exitCode === 0) builds = countBuilds(r.stdout, settings.pattern)
325 } catch {}
326 }
327
328 for (const name of newMarkers(seen.rex, rexNow)) {
329 $.ui.toast(`Rex approved ${markerRef(name) ?? name}. It waits for your /approve-merge.`)
330 }
331 if (seen.builds !== null && builds !== null && builds < seen.builds) {
332 const ended = seen.builds - builds
333 $.ui.toast(`${ended} build process${ended === 1 ? '' : 'es'} ended. ${builds} still running.`)
334 }
335 seen.rex = rexNow
336 seen.builds = builds
337
338 const next: Band = { ticket, waiting, reReview, offline, reviewLock, builds }
339 await update($, band, () => next)
340 await refreshOrbit($, root, markerRepo(marker))
341}
342
343export const register: Register = (on, options) => {
344
345 on('command.run', { command: 'orbit-progress' }, async $ => {
346 await $.ui.open({ id: ORBIT_PANE, title: 'ORBIT initiatives' })
347 return { text: 'ORBIT initiatives pane opened (read-only).' }
348 })
349
350 on('ui.render', { component: 'Pane', requestId: ORBIT_PANE }, async ($, e) => {
351 const { Box, Text, Button } = $.ui.resolve(e)
352 const v = await read($, orbit)
353 const open = await read($, expanded)
354 const links_ = await read($, sliceLinks)
355 if (!v) return <Text dimColor>Loading ORBIT initiatives…</Text>
356 if (v.plans.length === 0) return <Text color="yellow">No ORBIT initiatives found in the registered repos (docs/orbit on origin/main and origin/dev).</Text>
357 const isOpen = (key: string, dflt: boolean) => open[key] ?? dflt
358 const toggle = (key: string, dflt: boolean) => () => update($, expanded, cur => ({ ...cur, [key]: !(cur[key] ?? dflt) }))
359 const repos = new Set(v.plans.map(p => p.repo)).size
360 const width = e.props.bodyColumns ?? e.viewport?.columns ?? 100
361 // Column widths: size each label column to its longest entry so bars line up.
362 const planLabel = (p: (typeof v.plans)[number]) => `${p.repo} · ${p.summary.title || "Untitled initiative"}`
363 const planW = Math.min(Math.max(...v.plans.map(p => planLabel(p).length)), Math.max(30, width - 40))
364 const outcomeW = Math.min(Math.max(20, ...v.plans.flatMap(p => p.summary.outcomes.map(o => o.id.length))), Math.max(20, width - 40))
365 return (
366 <Box flexDirection="column">
367 <Text color="cyan">ORBIT initiatives · {v.plans.length} in {repos} repos · press 1–9 or click ▸ to expand</Text>
368 <Text dimColor>read-only · origin/main + origin/dev · refreshes every 2 minutes</Text>
369 <Text> </Text>
370 {v.plans.map((p, i) => {
371 const s = p.summary
372 const planDefault = p.repo === v.activeRepo && i === 0
373 const planOpen = isOpen(p.key, planDefault)
374 const t = s.totals
375 const pct = t.total === 0 ? 0 : Math.round(((t.achieved + t.partly * 0.5) / t.total) * 100)
376 return (
377 <Box key={p.key} flexDirection="column">
378 <Box flexDirection="row">
379 <Button
380 key={`plan-${p.key}`}
381 plain
382 hotkey={i < 9 ? String(i + 1) : undefined}
383 label={`${planOpen ? '▾' : '▸'} ${clip(planLabel(p), planW).padEnd(planW)}`}
384 onPress={toggle(p.key, planDefault)}
385 />
386 <Text> </Text>
387 <Text color={t.contradicted ? 'red' : 'cyan'}>{bar(pct, 10)}</Text>
388 <Text> {String(pct).padStart(3)}% {`${t.achieved}/${t.total}`.padStart(5)}{t.partly ? ` · ${t.partly} partly` : ''}{t.contradicted ? ` · ${t.contradicted} contradicted` : ''}</Text>
389 </Box>
390 <Text dimColor> {inFlightText(s.slices.map(sl => links_[`${p.repo}/${sl.id}`]))}</Text>
391 {planOpen ? (
392 <Box flexDirection="column">
393 <Text dimColor> {s.planId} r{s.revision} · reconciled {s.reconciledAt ?? 'never'} · checked {s.checkedAt ?? 'never'}</Text>
394 {s.outcomes.map(o => {
395 const noSlice = uncoveredOutcomes(s).some(u => u.id === o.id)
396 const okey = `${p.key}/${o.id}`
397 const oOpen = isOpen(okey, false)
398 return (
399 <Box key={okey} flexDirection="column">
400 <Box flexDirection="row">
401 <Text> </Text>
402 <Button key={`o-${okey}`} plain label={`${oOpen ? '▾' : '▸'} ${clip(o.id, outcomeW).padEnd(outcomeW)}`} onPress={toggle(okey, false)} />
403 <Text color={o.pct >= 100 ? 'green' : o.contradicted ? 'red' : 'cyan'}> {bar(o.pct, 10)}</Text>
404 <Text> {String(o.pct).padStart(3)}% {`${o.achieved}/${o.total}`.padStart(5)}</Text>
405 {noSlice ? <Text dimColor> · no slice</Text> : null}
406 </Box>
407 {oOpen ? (
408 <Box flexDirection="column">
409 {o.title ? <Text dimColor> {clip(o.title, width - 8)}</Text> : null}
410 {o.criteria.map(c => {
411 const ckey = `${okey}/${c.id}`
412 const cOpen = isOpen(ckey, false)
413 return (
414 <Box key={c.id} flexDirection="column" paddingLeft={8}>
415 <Box flexDirection="row">
416 <Text color={statusColor(c.status)}>{statusMark(c.status)} </Text>
417 <Button
418 key={`c-${ckey}`}
419 plain
420 label={`${c.id.padEnd(6)} ${shortLabel(c.statement, Math.max(30, width - 20))}`}
421 onPress={toggle(ckey, false)}
422 />
423 </Box>
424 {c.shownBy ? (
425 <Box paddingLeft={9}>
426 <Text dimColor>proof: {clip(c.shownBy, Math.max(20, width - 26))}</Text>
427 </Box>
428 ) : null}
429 {cOpen ? (
430 <Box flexDirection="column" paddingLeft={9} width={Math.max(20, width - 10)}>
431 <Text dimColor>{c.statement}</Text>
432 </Box>
433 ) : null}
434 </Box>
435 )
436 })}
437 </Box>
438 ) : null}
439 </Box>
440 )
441 })}
442 {s.slices.map(sl => {
443 const skey = `${p.key}/slice/${sl.id}`
444 const sOpen = isOpen(skey, false)
445 const links = links_[`${p.repo}/${sl.id}`]
446 const owner = p.repo
447 return (
448 <Box key={skey} flexDirection="column" paddingLeft={4}>
449 <Button
450 key={`s-${skey}`}
451 plain
452 label={`${sOpen ? '▾' : '▸'} ⬡ ${sl.id} → ${sl.contributesTo.join(', ')}`}
453 onPress={async () => {
454 await toggle(skey, false)()
455 if (!sOpen) await lookupSlice($, owner, sl.id)
456 }}
457 />
458 {sOpen ? (
459 <Box flexDirection="column" paddingLeft={4}>
460 {!links || links.status === 'loading' ? <Text dimColor>{links?.issues.length ? 'refreshing…' : 'looking up issues…'}</Text> : null}
461 {links?.status === 'error' ? <Text dimColor>(offline)</Text> : null}
462 {links && links.status === 'done' && links.issues.length === 0 ? <Text dimColor>no issues mention this slice yet</Text> : null}
463 {links ? links.issues.map(i => (
464 <Box key={`${i.repo}#${i.number}`} flexDirection="column">
465 <Text>
466 <Text color={i.state === 'open' ? 'green' : 'gray'}>{i.state === 'open' ? '●' : '○'} </Text>
467 {i.repo}#{i.number} <Text dimColor>{clip(i.title, Math.max(20, width - i.repo.length - 20))}</Text>
468 </Text>
469 {i.prs.map(pr => (
470 <Text key={`pr-${pr.number}`} dimColor>
471 {' '}↳ PR #{pr.number} [{pr.state}] {clip(pr.title, Math.max(20, width - 30))}
472 </Text>
473 ))}
474 </Box>
475 )) : null}
476 </Box>
477 ) : null}
478 </Box>
479 )
480 })}
481 <Text> </Text>
482 </Box>
483 ) : null}
484 </Box>
485 )
486 })}
487 <Text dimColor>✓ achieved ½ partly × contradicted · not verified</Text>
488 </Box>
489 )
490 })
491
492
493 settings = readSettings(options)
494
495 on('session.start', async ($, e, next) => {
496 await $.command.register({ name: 'orbit-progress', description: 'Show ORBIT initiatives (display only)' })
497 const result = await next(e)
498 await refresh($)
499 $.clock.every(REFRESH_MS, () => {
500 void refresh($)
501 })
502 return result
503 })
504
505 on('turn.complete', async ($, e, next) => {
506 const result = await next(e)
507 await refresh($)
508 return result
509 })
510
511 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
512 const b = await read($, band)
513 if (e.props.hasSurvey || b === null) return next(e)
514 const { Box, Text } = $.ui.resolve(e)
515 return (
516 <Box flexDirection="row" flexWrap="wrap">
517 <Text key="brand" color="cyan">ApexYard </Text>
518 <Text key="ticket">{b.ticket ?? 'no active ticket'}</Text>
519 <Text key="sep1" dimColor> · </Text>
520 <Text key="waiting" color={b.waiting.length ? 'yellow' : undefined} dimColor={b.waiting.length === 0}>
521 {waitingText(b.waiting, b.offline)}
522 </Text>
523 {b.reReview.length ? <Text key="sep4" dimColor> · </Text> : null}
524 {b.reReview.length ? <Text key="rereview" color="magenta">{reReviewText(b.reReview)}</Text> : null}
525 {b.reviewLock ? <Text key="sep2" dimColor> · </Text> : null}
526 {b.reviewLock ? <Text key="lock" color="magenta">review lock: {b.reviewLock}</Text> : null}
527 {b.builds !== null ? <Text key="sep3" dimColor> · </Text> : null}
528 {b.builds !== null ? (
529 <Text key="builds" dimColor={b.builds === 0}>
530 {b.builds} build{b.builds === 1 ? '' : 's'} running
531 </Text>
532 ) : null}
533 </Box>
534 )
535 })
536}
537hooks/orbit.ts 173 lines1// Pure ORBIT summary logic: same rules as `orbit progress` (achieved 1, partly 0.5).
2import type { OrbitOutcome, OrbitSummary, SliceLinks } from '../types'
3
4type Json = Record<string, unknown>
5
6const str = (v: unknown): string => (typeof v === 'string' ? v : '')
7const arr = (v: unknown): unknown[] => (Array.isArray(v) ? v : [])
8
9export type CriterionStatus = 'not-verified' | 'partially-verified' | 'achieved' | 'contradicted'
10
11// Merge records read from several branches. A later list wins for the same
12// record id and revision, so pass the branches oldest-first (main, then dev).
13export function mergeRecords(lists: Json[][]): Json[] {
14 const out = new Map<string, Json>()
15 for (const list of lists) {
16 for (const r of list) {
17 const id = str(r.id)
18 if (!id) continue
19 out.set(`${id}@${typeof r.revision === 'number' ? r.revision : ''}`, r)
20 }
21 }
22 return [...out.values()]
23}
24
25export function pickPlan(plans: Json[]): Json | null {
26 let best: Json | null = null
27 for (const p of plans) {
28 const rev = typeof p.revision === 'number' ? p.revision : 0
29 const bestRev = best && typeof best.revision === 'number' ? best.revision : -1
30 if (!best || rev > bestRev) best = p
31 }
32 return best
33}
34
35export function pickReconciliation(recs: Json[], planId: string): Json | null {
36 let best: Json | null = null
37 for (const r of recs) {
38 if (str(r.planId) !== planId) continue
39 if (!best || str(r.reconciledAt) > str(best.reconciledAt)) best = r
40 }
41 return best
42}
43
44export function summarize(plan: Json, rec: Json | null, slices: Json[]): OrbitSummary {
45 const planId = str(plan.id)
46 const status = new Map<string, CriterionStatus>()
47 for (const a of arr(rec?.criterionAssessments)) {
48 const o = a as Json
49 const s = str(o.status) as CriterionStatus
50 status.set(str(o.criterionId), s || 'not-verified')
51 }
52 const criteria = arr(plan.acceptanceCriteria).map(c => c as Json)
53 const outcomes: OrbitOutcome[] = arr(plan.outcomes).map(raw => {
54 const o = raw as Json
55 const id = str(o.id)
56 const own = criteria.filter(c => str(c.outcomeId) === id).map(c => {
57 const { statement, shownBy } = splitShownBy(str(c.statement))
58 return { id: str(c.id), status: status.get(str(c.id)) ?? 'not-verified', statement, shownBy }
59 })
60 const achieved = own.filter(c => c.status === 'achieved').length
61 const partly = own.filter(c => c.status === 'partially-verified').length
62 const contradicted = own.filter(c => c.status === 'contradicted').length
63 const total = own.length
64 const pct = total === 0 ? 0 : Math.round(((achieved + partly * 0.5) / total) * 100)
65 return { id, title: str(o.title), pct, achieved, partly, contradicted, total, criteria: own }
66 })
67 const all = outcomes.flatMap(o => o.criteria)
68 return {
69 title: str(plan.title),
70 checkedAt: null,
71 planId,
72 revision: typeof plan.revision === 'number' ? plan.revision : 0,
73 reconciledAt: rec ? str(rec.reconciledAt) : null,
74 outcomes,
75 slices: slices
76 .filter(s => str(s.planId) === planId)
77 .map(s => ({ id: str(s.id), contributesTo: arr(s.contributesTo).map(str) }))
78 .sort((a, b) => a.id.localeCompare(b.id, undefined, { numeric: true })),
79 totals: {
80 achieved: all.filter(c => c.status === 'achieved').length,
81 partly: all.filter(c => c.status === 'partially-verified').length,
82 contradicted: all.filter(c => c.status === 'contradicted').length,
83 total: all.length,
84 },
85 }
86}
87
88// "Do X. Shown by: a test." -> { statement: "Do X.", shownBy: "a test." }
89export function splitShownBy(text: string): { statement: string; shownBy: string } {
90 const i = text.indexOf(' Shown by:')
91 if (i < 0) return { statement: text.trim(), shownBy: '' }
92 return { statement: text.slice(0, i).trim(), shownBy: text.slice(i + ' Shown by:'.length).trim() }
93}
94
95// A one-line label: the first sentence of the statement.
96export function shortLabel(statement: string, max = 70): string {
97 const m = statement.match(/^(.+?[.?!])(\s|$)/)
98 const head = (m?.[1] ?? statement).trim().replace(/[.]$/, '')
99 return clip(head, max)
100}
101
102export function clip(text: string, width: number): string {
103 if (width < 4 || text.length <= width) return text
104 return `${text.slice(0, width - 1).trimEnd()}…`
105}
106
107export function bar(pct: number, width = 12): string {
108 const filled = Math.round((Math.max(0, Math.min(100, pct)) / 100) * width)
109 return '█'.repeat(filled) + '░'.repeat(width - filled)
110}
111
112export function statusColor(s: string): string {
113 if (s === 'achieved') return 'green'
114 if (s === 'partially-verified') return 'yellow'
115 if (s === 'contradicted') return 'red'
116 return 'gray'
117}
118
119export function statusMark(s: string): string {
120 if (s === 'achieved') return '✓'
121 if (s === 'partially-verified') return '½'
122 if (s === 'contradicted') return '×'
123 return '·'
124}
125
126export function bandText(s: OrbitSummary): string {
127 const t = s.totals
128 const parts = [`${s.planId} r${s.revision}`, `${t.achieved}/${t.total} achieved`]
129 if (t.partly) parts.push(`${t.partly} partly`)
130 if (t.contradicted) parts.push(`${t.contradicted} contradicted`)
131 return parts.join(' · ')
132}
133
134// Outcomes with unmet criteria that no slice contributes to. The pane flags
135// them so a missing slice is visible, not only an unused number.
136export function uncoveredOutcomes(s: OrbitSummary): OrbitOutcome[] {
137 const covered = new Set(s.slices.flatMap(sl => sl.contributesTo))
138 return s.outcomes.filter(o => o.pct < 100 && !o.criteria.some(c => covered.has(c.id)))
139}
140
141// Only no-change entries inside Checks count. Compare dates, not file order.
142export function checksDate(notes: string): string | null {
143 let inside = false
144 let latest: string | null = null
145 for (const line of notes.split('\n')) {
146 if (/^##\s+Checks\s*$/.test(line)) { inside = true; continue }
147 if (/^#{1,2}\s/.test(line)) inside = false
148 if (!inside) continue
149 const date = line.match(/^\s*(?:-\s*)?(\d{4}-\d{2}-\d{2}),\s*[^:]+:\s*checked, no change\.\s*$/)?.[1]
150 if (date && (!latest || date > latest)) latest = date
151 }
152 return latest
153}
154
155export function inFlightText(links: Array<SliceLinks | undefined>): string {
156 if (links.some(l => !l || l.status === 'error')) return 'in flight (offline)'
157 if (links.some(l => l?.status === 'loading')) return 'in flight: loading…'
158 const issues = new Map<string, SliceLinks['issues'][number]>()
159 const prs = new Map<string, SliceLinks['issues'][number]['prs'][number]>()
160 let last = ''
161 for (const l of links) for (const i of l?.issues ?? []) {
162 if (i.updatedAt && i.updatedAt > last) last = i.updatedAt
163 if (i.state === 'open') issues.set(`${i.repo}#${i.number}`, i)
164 for (const pr of i.prs) {
165 if (pr.updatedAt && pr.updatedAt > last) last = pr.updatedAt
166 if (i.state === 'open' && pr.state === 'open') prs.set(`${pr.repo}#${pr.number}`, pr)
167 }
168 }
169 const approved = [...prs.values()].filter(p => p.approved).length
170 const ready = [...prs.values()].filter(p => p.ready).length
171 return `in flight: ${issues.size} open tickets · ${prs.size} open PRs · ${approved} approved · ${ready} ready · last activity ${last || 'none'}`
172}
173hooks/band.ts 253 lines1// Pure helpers for the ApexYard status band. No engine calls here, so the
2// tests can drive every rule with plain values.
3
4const DAY_MS = 24 * 60 * 60 * 1000
5const TITLE_MAX = 48
6
7export type Entry = { name: string; mtimeMs: number }
8
9// AgDR-0060 marker name: <owner>__<repo>__<pr>-<role>.approved
10const MARKER = /^(.+)__(.+)__(\d+)-(rex|ceo)\.approved$/
11
12// "me2resh__apexyard__1541-rex.approved" -> "me2resh/apexyard#1541".
13// Returns null for a name that does not follow AgDR-0060.
14export function markerRef(name: string): string | null {
15 const m = name.match(MARKER)
16 if (!m) return null
17 return `${m[1]}/${m[2]}#${m[3]}`
18}
19
20export type Candidate = { name: string; ref: string; repo: string; number: number }
21
22// The Rex markers newer than the window that have no CEO marker. These are
23// candidates only: a marker stays after a PR merges outside /approve-merge.
24export function candidatesFrom(entries: Entry[], now: number, windowDays: number): Candidate[] {
25 const names = new Set(entries.map(e => e.name))
26 const windowMs = Math.max(0, windowDays) * DAY_MS
27 const out: Candidate[] = []
28 for (const e of entries) {
29 const m = e.name.match(MARKER)
30 if (!m || m[4] !== 'rex') continue
31 if (now - e.mtimeMs > windowMs) continue
32 if (names.has(e.name.replace(/-rex\.approved$/, '-ceo.approved'))) continue
33 const repo = `${m[1]}/${m[2]}`
34 out.push({ name: e.name, ref: `${repo}#${m[3]}`, repo, number: Number(m[3]) })
35 }
36 return out.sort((x, y) => x.ref.localeCompare(y.ref))
37}
38
39// A GitHub owner/repo slug. Anything else is never passed to gh.
40export function validRepo(repo: string): boolean {
41 return /^[A-Za-z0-9_.][A-Za-z0-9_.-]*\/[A-Za-z0-9_.][A-Za-z0-9_.-]*$/.test(repo) && !repo.split('/').some(p => p === '.' || p === '..')
42}
43
44// The reviewed commit in a Rex marker (AgDR-0161: the bare SHA), or null.
45export function markerSha(text: string | null): string | null {
46 const t = text?.trim().split('\n')[0]?.trim() ?? ''
47 return /^[0-9a-f]{7,40}$/i.test(t) ? t.toLowerCase() : null
48}
49
50export type OpenPrs = Map<number, string>
51
52// The JSON list of open PRs ({ number, headRefOid } each) -> PR number to
53// head SHA. Null when the output is not that shape.
54export function parseOpenPrs(stdout: string): OpenPrs | null {
55 let v: unknown
56 try {
57 v = JSON.parse(stdout)
58 } catch {
59 return null
60 }
61 if (!Array.isArray(v)) return null
62 const out: OpenPrs = new Map()
63 for (const x of v) {
64 const o = x as { number?: unknown; headRefOid?: unknown }
65 if (typeof o.number === 'number' && typeof o.headRefOid === 'string') out.set(o.number, o.headRefOid.toLowerCase())
66 }
67 return out
68}
69
70export const GH_CACHE_MS = 120_000
71export const GH_TIMEOUT_MS = 5000
72
73export type GhResult = { exitCode: number; stdout: string }
74export type GhRun = (argv: readonly string[]) => Promise<GhResult>
75export type GhCache = Map<string, { at: number; open: OpenPrs | null }>
76
77// The read-only list command for one repo. An argument array, no shell.
78export function ghArgv(repo: string): string[] {
79 return ['gh', 'pr', 'list', '--repo', repo, '--state', 'open', '--json', 'number,headRefOid', '--limit', '200']
80}
81
82// The open PRs of one repo, from the cache when the last lookup is newer than
83// GH_CACHE_MS. Null when gh is missing, fails or times out, or the repo is not
84// a valid slug. A failed lookup is cached too, so gh runs at most once per
85// window for each repo.
86export async function openPrsFor(cache: GhCache, repo: string, now: number, run: GhRun): Promise<OpenPrs | null> {
87 if (!validRepo(repo)) return null
88 const hit = cache.get(repo)
89 if (hit && now - hit.at < GH_CACHE_MS) return hit.open
90 let open: OpenPrs | null = null
91 try {
92 const r = await run(ghArgv(repo))
93 open = r.exitCode === 0 ? parseOpenPrs(r.stdout) : null
94 } catch {
95 open = null
96 }
97 cache.set(repo, { at: now, open })
98 return open
99}
100
101export type Queue = { waiting: string[]; reReview: string[]; offline: boolean }
102
103// Sort the candidates with the open PRs of their repos:
104// - open, and the head is the commit Rex reviewed: waiting for /approve-merge
105// - open, with another head (or no SHA in the marker): needs re-review
106// - not open (merged or closed): dropped
107// A repo with no lookup (null) falls back to the marker only: its candidates
108// count as waiting and the queue is marked offline.
109export function classify(
110 cands: Candidate[],
111 shas: Map<string, string | null>,
112 open: Map<string, OpenPrs | null>,
113): Queue {
114 const q: Queue = { waiting: [], reReview: [], offline: false }
115 for (const c of cands) {
116 const prs = open.get(c.repo) ?? null
117 if (prs === null) {
118 q.waiting.push(c.ref)
119 q.offline = true
120 continue
121 }
122 const head = prs.get(c.number)
123 if (head === undefined) continue
124 const sha = shas.get(c.name) ?? null
125 if (sha && head.startsWith(sha)) q.waiting.push(c.ref)
126 else q.reReview.push(c.ref)
127 }
128 return q
129}
130
131// The Rex marker names in a directory listing (AgDR-0060 names only).
132export function rexMarkers(entries: Entry[]): Set<string> {
133 return new Set(entries.filter(e => e.name.endsWith('-rex.approved') && markerRef(e.name)).map(e => e.name))
134}
135
136// The Rex markers in `now` that were not in `before`. An empty result when
137// `before` is null, so the first refresh of a session never toasts.
138export function newMarkers(before: Set<string> | null, now: Set<string>): string[] {
139 if (!before) return []
140 return [...now].filter(n => !before.has(n)).sort()
141}
142
143export type TicketFields = Record<string, string>
144
145export function parseFields(text: string): TicketFields {
146 const fields: TicketFields = {}
147 for (const line of text.split('\n')) {
148 const i = line.indexOf('=')
149 if (i > 0) fields[line.slice(0, i).trim()] = line.slice(i + 1).trim()
150 }
151 return fields
152}
153
154// "repo=me2resh/apexyard\nnumber=1541\ntitle=[Feature] Optional status band"
155// -> "apexyard#1541 Optional status band". Null when repo or number is missing.
156export function ticketLabel(text: string): string | null {
157 const f = parseFields(text)
158 if (!f.repo || !f.number) return null
159 const repo = f.repo.split('/').pop() || f.repo
160 const title = (f.title ?? '').replace(/^\[[^\]]+\]\s*/, '')
161 const short = title.length > TITLE_MAX ? `${title.slice(0, TITLE_MAX - 1)}…` : title
162 return short ? `${repo}#${f.number} ${short}` : `${repo}#${f.number}`
163}
164
165// The parent directory of an absolute path, or null at the root.
166export function parentDir(path: string): string | null {
167 const p = path.replace(/\/+$/, '')
168 const i = p.lastIndexOf('/')
169 if (i <= 0) return p === '' || i < 0 ? null : '/'
170 return p.slice(0, i)
171}
172
173// Same anchors as resolve_ops_root_walk in .claude/hooks/_lib-ops-root.sh:
174// a .apexyard-fork file, or onboarding.yaml and apexyard.projects.yaml together.
175export async function isOpsFork(dir: string, exists: (p: string) => Promise<boolean>): Promise<boolean> {
176 if (await exists(`${dir}/.apexyard-fork`)) return true
177 return (await exists(`${dir}/onboarding.yaml`)) && (await exists(`${dir}/apexyard.projects.yaml`))
178}
179
180// The ops fork: the session pin first, then a walk up from the working directory.
181export async function findOpsRoot(
182 pinned: string | null,
183 cwd: string,
184 exists: (p: string) => Promise<boolean>,
185): Promise<string | null> {
186 const pin = pinned?.split('\n')[0]?.trim() ?? ''
187 if (pin.startsWith('/') && (await isOpsFork(pin, exists))) return pin.replace(/\/+$/, '') || '/'
188 let dir: string | null = cwd.replace(/\/+$/, '') || '/'
189 while (dir) {
190 if (await isOpsFork(dir, exists)) return dir
191 dir = parentDir(dir)
192 }
193 return null
194}
195
196// Same character rule as active_reviewer_marker_path in _lib-review-markers.sh.
197export function safeSessionId(id: string): string {
198 return id.replace(/[^A-Za-z0-9._-]/g, '_')
199}
200
201// Number of process command lines that match the pattern. Null when the
202// pattern is empty or not a valid regular expression (the count is off).
203export function countBuilds(psOutput: string, pattern: string): number | null {
204 if (!pattern.trim()) return null
205 let re: RegExp
206 try {
207 re = new RegExp(pattern)
208 } catch {
209 return null
210 }
211 let n = 0
212 for (const line of psOutput.split('\n')) {
213 if (line.trim() && re.test(line)) n += 1
214 }
215 return n
216}
217
218// "me2resh/apexyard#1541:rex" -> "me2resh/apexyard#1541 (rex)".
219export function lockLabel(text: string): string | null {
220 const t = text.trim().split('\n')[0]?.trim() ?? ''
221 if (!t) return null
222 const m = t.match(/^(.+#\d+):(\w+)$/)
223 return m ? `${m[1]} (${m[2]})` : t
224}
225
226// All reviewer locks of a session: one label per marker, in order, no duplicates.
227// Texts come from the per-review files and the legacy single file.
228export function lockLabels(texts: Array<string | null>): string | null {
229 const labels: string[] = []
230 for (const text of texts) {
231 const label = text ? lockLabel(text) : null
232 if (label && !labels.includes(label)) labels.push(label)
233 }
234 return labels.length > 0 ? labels.join(', ') : null
235}
236
237// The text of the "waiting" segment. Lists up to three references.
238export function waitingText(waiting: string[], offline = false): string {
239 const tag = offline ? ' (offline)' : ''
240 if (waiting.length === 0) return `nothing waiting for /approve-merge${tag}`
241 const shown = waiting.slice(0, 3).join(', ')
242 const more = waiting.length > 3 ? ` +${waiting.length - 3}` : ''
243 return `${waiting.length} waiting for your /approve-merge${tag}: ${shown}${more}`
244}
245
246// The text of the "needs re-review" segment, or null when it is empty.
247export function reReviewText(refs: string[]): string | null {
248 if (refs.length === 0) return null
249 const shown = refs.slice(0, 3).join(', ')
250 const more = refs.length > 3 ? ` +${refs.length - 3}` : ''
251 return `${refs.length} need${refs.length === 1 ? 's' : ''} re-review: ${shown}${more}`
252}
253types/index.d.ts 65 lines1export type Band = {
2 // "apexyard#1541 Optional status band", or null when no marker covers the session.
3 ticket: string | null
4 // PR references ("owner/repo#N") that are open, with the head Rex reviewed
5 // and no CEO marker. Offline: every recent Rex marker with no CEO marker.
6 waiting: string[]
7 // Open PRs with a Rex marker whose head moved after the review.
8 reReview: string[]
9 // True when the open-PR lookup failed for a repo (gh missing, failed or slow).
10 offline: boolean
11 // The content of this session's reviewer lock ("owner/repo#N:rex"), or null.
12 reviewLock: string | null
13 // Number of running build processes, or null when the build count is off.
14 builds: number | null
15}
16
17export type OrbitCriterion = { id: string; status: string; statement: string; shownBy: string }
18
19export type OrbitOutcome = {
20 id: string
21 title: string
22 pct: number
23 achieved: number
24 partly: number
25 contradicted: number
26 total: number
27 criteria: OrbitCriterion[]
28}
29
30export type OrbitSummary = {
31 title?: string
32 checkedAt?: string | null
33 planId: string
34 revision: number
35 reconciledAt: string | null
36 outcomes: OrbitOutcome[]
37 slices: { id: string; contributesTo: string[] }[]
38 totals: { achieved: number; partly: number; contradicted: number; total: number }
39}
40
41export type OrbitPlanView = {
42 key: string
43 repo: string
44 summary: OrbitSummary
45}
46
47export type OrbitView = {
48 activeRepo: string | null
49 plans: OrbitPlanView[]
50 updatedAt: number
51}
52
53export type SliceLink = { repo: string; number: number; title: string; state: string; updatedAt?: string; approved?: boolean; ready?: boolean }
54
55export type SliceLinks = {
56 status: 'loading' | 'done' | 'error'
57 issues: (SliceLink & { prs: SliceLink[] })[]
58}
59
60declare module 'claude-code' {
61 interface PluginState {
62 'apexyard-status': { band: Band | null; orbit: OrbitView | null; expanded: Record<string, boolean>; sliceLinks: Record<string, SliceLinks> }
63 }
64}
65