SLOPSHOPPER

apexyard-status

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

newpanebandcommandtoastprocess
★ 529v0.1.0MITupdated 2026-10-15me2resh/apexyard/plugins/apexyard-status
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · apexyard-status
│ ┃ ORBIT initiatives ✕ › fix the failing auth test and add an audit log call │ ┃ Loading ORBIT initiatives… │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /orbit-progress │ ⎿ apexyard-status: ORBIT initiatives pane opened (read-only). │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · ORBIT initiatives
Loading ORBIT initiatives…
README

apexyard-status

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:

  • The active ticket of the working tree, as repo#N and a short title.
  • The open PRs that Rex approved at their current head and that have no human-approval marker.
  • The open PRs whose head moved after the Rex review ("needs re-review").
  • The reviewer lock of this session, when it is set.
  • The number of running build processes, when you set a build-process pattern.

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.

ORBIT initiatives

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.

Rules

  • Display only. The plugin reads files. It runs read-only commands only: the ticket resolver, cached GitHub queries, Git record reads, and ps when you set a pattern. It never writes a file. It never replaces a shell hook. All gates stay in .claude/hooks/.
  • Band network call, read only: 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.
  • If 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.
  • Pane network calls use 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.
  • Fork only. Outside an ops fork, the plugin draws nothing.

Install

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.

Update

Claude Code reads the plugin from the fork folder. After /update, run /reload-plugins. You do not need to reinstall.

Settings

Set these in /plugin configure apexyard-status@apexyard, or with --config KEY=VALUE at install.

SettingDefaultMeaning
build_process_patternempty (off)A regular expression. The plugin counts the running processes whose command line matches it. Example: `codex exec\cursor-agent`.
waiting_window_days3A Rex marker older than this number of days does not count as waiting.

Where the data comes from

ItemSource
Ops forkThe 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 ticketThe 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 PRsCandidates: .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 lockOne 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 countps -axo command=, only when build_process_pattern is set.

Tests

claude plugin validate --strict plugins/apexyard-status
claude plugin test plugins/apexyard-status

Risk

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.

Source 4 files
hooks/register.tsx 537 lines
1import 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}
537
hooks/orbit.ts 173 lines
1// 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}
173
hooks/band.ts 253 lines
1// 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}
253
types/index.d.ts 65 lines
1export 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