SLOPSHOPPER

gatekeeper

Your open PRs and what each waits on (/gates): the template boxes only you can tick, CodeRabbit threads and change requests, Codecov, CI, conflicts and drafts…

newpanebandguardcommandstatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · gatekeeper
│ ┃ Gates ✕ › fix the failing auth test and add an audit log call │ ┃ gates: all clear │ ┃ Not read yet. Refresh ⏺ 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 │ │ › /gates │ ⎿ gatekeeper: Gates opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Gates
gates: all clear Not read yet. Refresh
README

crab-workshop

Eleven Claude Code mods (function-hook plugins) built around one workflow: many parallel side sessions, upstream PRs behind CodeRabbit, Codecov and Codex reviews, an Unraid server with about 90 containers, Home Assistant, and a local LLM on an RTX 3070. They are siblings of session-crew and borrow its savvy-progress crabs.

The mod API is early access. These mods were built and tested against Claude Code 2.1.295 (the desktop app's CLI); the declaration file that version ships is the authority.

ModWhat you getCommands
where-am-iStatus line: 🍎 Mac · ha-unraid-management-agent · main · fork of tinkerologie. A note for Claude when, on the Mac, it SSHes to the Unraid host or the container, or runs the tests of a repo that is developed in the container./where
roll-callA pane where each side session's crab holds a sign: one line on where it stands, and a "needs you" lane (questions, approvals, boxes to tick). Lines come from Haiku, the local LLM, or the last message.`/rollcall [open\linked] [text], tool report`
fuel-gauge⛽ ctx 14% · 5h 26% (34m) · wk 36% in the status line; toasts at 80% and 95% of a plan window and at 85% context; a pane with extra usage and every running side session's context. A band appears only while something runs hot./fuel [text]
gpu-crabWhile a local-llm tool runs: a crab on a treadmill above the prompt with the model, VRAM, GPU/CPU split (amber when it spills to CPU) and elapsed time; afterwards, a tally of what local models read today instead of the cloud./gpu
vitals-stripUnraid and HA health above the prompt (array and pools, CPU and memory, unhealthy or missing containers, unread alerts, parity, HA repairs). Quiet by default: only what isn't green.`/vitals [show\quiet]`
reefAn aquarium pane: each container is a fish coloured by stack, unhealthy ones float belly-up, the GPU is a heater, HA is a lighthouse, side sessions are crabs on the sand. Hover a fish for its name./reef [text]
keymasterMasks API keys, tokens, passwords and private keys in tool output before Claude, the transcript or the screen sees them (‹masked arr-api-key 1a2b3c4d›), with a toast and a count. Withholds output it can't scan./keymaster, /keymaster allow <tag>
trailer-guardRefuses a git commit, gh pr create/edit or git push that would carry Co-Authored-By: Claude or the Claude Code footer into a repo whose remotes include tinkerologie, esphome or home-assistant (configurable)./trailers
gatekeeperA pane of your open PRs by lane: needs you (boxes only you tick, green drafts), needs work (red checks, Codecov, CodeRabbit threads, conflicts), waiting. A band when something needs you./gates [text], tool gates
codex-cornerA boxing ring above the prompt for Codex adversarial reviews: each run is a round with findings by severity, until the verdict is approve and the bot goes down. Kept per repo and branch./codex-card
house-rulesNotes, asks and refusals on tool calls: appdata paths (/mnt/user/appdata vs the nvme8tb pool), CLAUDE.local.md in git, force-push to upstream, HA restarts. Add your own./rules, /rules add …, /rules off <id>

Data sources

Everything is read-only.

ModReads
roll-call, fuel-gauge, reefThe desktop app's session server (ccd_session_mgmt: list_sessions, list_events, get_usage)
vitals-stripThe HA-MCP connector: one ha_eval_template over the Unraid Management Agent integration's entities, and ha_get_overview. Allow both tools in permissions so auto mode doesn't review each read
reefunraid-mcp (system array/metrics, docker list, notification overview, array parity_status) and the HA-MCP connector (ha_get_overview)
gpu-crab, reefOllama GET /api/ps at the ollamaUrl option (default http://localhost:11434)
gatekeepergh api graphql, one search query every 5 minutes and shortly after a push
where-am-i, trailer-guard, codex-cornergit in the session's checkout

Server names, URLs and thresholds are options (userConfig) on each mod. The defaults assume nothing about your network: Ollama on localhost, no Home Assistant connector (vitals-strip and reef's lighthouse stay off until you name one), and no repo or SSH rules for where-am-i. Set your values in ~/.claude/settings.json:

"pluginConfigs": {
  "gpu-crab": { "options": { "ollamaUrl": "http://192.168.1.50:11434" } },
  "vitals-strip": { "options": { "haServer": "<your HA-MCP connector, as /mcp lists it>" } },
  "where-am-i": { "options": { "homes": ["my-integration=container"], "containerTargets": ["my-nas"] } }
}

Trying them

For one terminal session:

claude --plugin-dir ~/dev/crab-workshop/plugins/where-am-i --plugin-dir ~/dev/crab-workshop/plugins/fuel-gauge

For desktop-app sessions, add the folders to CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json (colon-separated, next to session-crew's), then start a new session:

"env": {
  "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/session-crew:/path/to/crab-workshop/plugins/where-am-i:/path/to/crab-workshop/plugins/keymaster"
}

Or install from this folder as a marketplace, so /reload-plugins picks up edits:

claude plugin marketplace add ~/dev/crab-workshop

Then, in a terminal session, /plugin install keymaster@crab-workshop (and so on). Sessions on the UnRAID SSH connection run Claude Code inside the claude-code container, so the mods need the same setup there.

Developing

sh scripts/check.sh            # every mod: type-check, claude plugin validate, claude plugin test
sh scripts/check.sh gatekeeper # one mod

shared/ holds the crab art (copied from savvy-progress through session-crew; see THIRD_PARTY_NOTICES.md) and the drawing helpers. scripts/sync-shared.sh copies them into each mod's hooks/shared/, because a mod can only import its own files. Edit shared/, never the copies.

Known limits

  • The desktop Code tab's drawing of AbovePrompt bands and Svg was checked with the test kit only, not by eye in the app.
  • The Unraid Management Agent's own API (:8043) refused connections from the Mac, so vitals-strip and reef read through unraid-mcp instead.
  • keymaster masks what looks like a secret. A real value that slips past its patterns is not caught, and a fake one in a test fixture is masked until you /keymaster allow its tag.
  • gatekeeper reads the first 50 PRs of its search (newest first).

License

MIT, see LICENSE. The crab art is third-party; see THIRD_PARTY_NOTICES.md.

Source 6 files
hooks/register.tsx 257 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Gate, Gates, Lane } from '../types'
5import { QUERY, asText, bandText, chips, counts, parseGates, parseReadyLimits } from './gates'
6import type { ReadyLimits } from './gates'
7import { isRateLimited, parseShared, plan } from './cache'
8import type { SharedRead } from './cache'
9import { crab } from './shared/crab'
10import { FONT, ago, svg, widthFor, xml } from './shared/draw'
11
12const gates = atom({ plugin: 'gatekeeper', key: 'gates' } as const, { gates: [], total: 0, at: 0, isBusy: false })
13const now = atom({ plugin: 'gatekeeper', key: 'now' } as const, 0)
14
15const PANE = 'gatekeeper'
16const TITLE = 'Gates'
17
18let query = 'is:pr is:open author:@me archived:false sort:updated-desc'
19let readyLimits: ReadyLimits = { tinkerologie: 2 }
20let yours: string[] = []
21let pollMs = 900_000
22let fetching: Promise<string> | null = null
23let sharedPath: string | null = null
24let soon: { cancel: () => void } | null = null
25
26function refresh($: EngineInterface, force = false): Promise<string> {
27  if (!fetching)
28    fetching = refreshNow($, force).finally(() => {
29      fetching = null
30    })
31  return fetching
32}
33
34/** ~/.cache/crab-workshop/gatekeeper.json: the read every session shares. */
35async function pathOfShared($: EngineInterface): Promise<string | null> {
36  if (sharedPath) return sharedPath
37  const r = await $.process.run(['sh', '-c', 'printf %s "$HOME"'], { timeoutMs: 5_000 })
38  const home = r.exitCode === 0 ? r.stdout.trim() : ''
39  sharedPath = home ? `${home}/.cache/crab-workshop/gatekeeper.json` : null
40  return sharedPath
41}
42
43async function readShared($: EngineInterface, path: string | null): Promise<SharedRead> {
44  if (!path) return {}
45  try {
46    return (await $.fs.exists(path)) ? parseShared(await $.fs.read(path)) : {}
47  } catch {
48    return {}
49  }
50}
51
52async function writeShared($: EngineInterface, path: string | null, v: SharedRead): Promise<void> {
53  if (!path) return
54  try {
55    await $.fs.write(path, JSON.stringify(v))
56  } catch {
57    // A session that can't write just doesn't share; it still works.
58  }
59}
60
61async function show($: EngineInterface, v: SharedRead, error?: string): Promise<string> {
62  const list = v.gates ?? []
63  await update($, gates, g => ({ gates: list, total: v.total ?? list.length, at: v.at ?? g.at, isBusy: false, ...(error ? { error } : {}) }))
64  if (v.at) await update($, now, () => Math.max(v.at ?? 0, Date.now()))
65  $.ui.status(bandText(list))
66  return error ? `${error}${list.length ? `\n\n${asText(list, v.total ?? list.length)}` : ''}` : asText(list, v.total ?? list.length)
67}
68
69async function refreshNow($: EngineInterface, force: boolean): Promise<string> {
70  const path = await pathOfShared($)
71  const shared = await readShared($, path)
72  const at0 = await $.clock.now()
73  const p = plan(shared, at0, pollMs, force)
74  if (p.kind === 'reuse') {
75    const error = p.why === 'rate limited' ? `GitHub rate limit spent; next read after ${new Date(shared.backoffUntil ?? 0).toLocaleTimeString()}` : undefined
76    if (shared.at || error) return show($, shared, error)
77    // Nothing shared yet and another session is reading: wait for it next time.
78    return show($, shared)
79  }
80  await update($, gates, g => ({ ...g, isBusy: true }))
81  await writeShared($, path, { ...shared, fetchingSince: at0 })
82  try {
83    const r = await $.process.run(['gh', 'api', 'graphql', '-f', `query=${QUERY}`, '-f', `q=${query}`], { timeoutMs: 60_000 })
84    const parsed = r.exitCode === 0 ? parseGates(r.stdout, yours, readyLimits) : null
85    if (!parsed) {
86      const why = (r.stderr || r.stdout).trim()
87      if (isRateLimited(why)) {
88        // The rate_limit endpoint itself doesn't count against the limit.
89        const reset = await $.process.run(['gh', 'api', 'rate_limit', '--jq', '.resources.graphql.reset'], { timeoutMs: 15_000 })
90        const until = Number(reset.stdout.trim()) * 1000
91        const backoffUntil = Number.isFinite(until) && until > at0 ? until : at0 + 15 * 60_000
92        const next: SharedRead = { ...shared, backoffUntil }
93        delete next.fetchingSince
94        await writeShared($, path, next)
95        return show($, next, `GitHub rate limit spent; next read after ${new Date(backoffUntil).toLocaleTimeString()}`)
96      }
97      throw new Error(why.slice(0, 160) || `gh exited ${r.exitCode}`)
98    }
99    const at = await $.clock.now()
100    const next: SharedRead = { at, gates: parsed.gates, total: parsed.total }
101    await writeShared($, path, next)
102    return show($, next)
103  } catch (err) {
104    const error = (err instanceof Error ? err.message : String(err)).slice(0, 200)
105    const next: SharedRead = { ...shared }
106    delete next.fetchingSince
107    await writeShared($, path, next)
108    await update($, gates, g => ({ ...g, isBusy: false, error }))
109    return `Couldn't read your PRs: ${error}`
110  }
111}
112
113/** A refresh a little after a push or a gh pr command, once CI and CodeRabbit have had a moment. */
114function refreshSoon($: EngineInterface): void {
115  soon?.cancel()
116  soon = $.clock.after(20_000, () => void refresh($, true))
117}
118
119async function togglePane($: EngineInterface): Promise<boolean> {
120  if ((await $.ui.panes()).some(p => p.id === PANE)) {
121    await $.ui.close({ id: PANE })
122    return false
123  }
124  await update($, now, () => Date.now())
125  await $.ui.open({ id: PANE, title: TITLE })
126  return true
127}
128
129const LANE_TITLE: Record<Lane, string> = { you: 'Needs you', work: 'Needs work', waiting: 'Waiting', clear: 'Clear' }
130const LANE_COLOR: Record<Lane, string> = { you: 'red', work: 'yellow', waiting: 'gray', clear: 'green' }
131
132// The bouncer: holds the rope while anything needs you, cheers when the line is clear.
133function headSvg(W: number, g: Gate[]): string {
134  const c = counts(g)
135  const words = [`${c.you} need you`, `${c.work} need work`, `${c.waiting} waiting`, `${c.clear} clear`].join('  ·  ')
136  const status = c.you ? 'working' : c.work ? 'idle' : 'done'
137  return svg(
138    W,
139    40,
140    `${crab(0, 4, 'heavy', status, 1)}
141<rect x="36" y="22" width="${c.you ? 30 : 6}" height="3" rx="1.5" fill="#b3261e"/><circle cx="36" cy="23.5" r="3" fill="#c9a227"/>
142<text class="t" x="76" y="25" font-family="${FONT}" font-size="12" font-weight="600">${xml(words)}</text>`,
143  )
144}
145
146export const register: Register = (on, options) => {
147  query = String(options.query ?? query)
148  if (typeof options.readyLimits === 'string') readyLimits = parseReadyLimits(options.readyLimits)
149  yours = ((options.yourBoxes as readonly string[] | undefined) ?? []).map(String)
150  pollMs = Math.max(0, Number(options.pollMinutes ?? 15)) * 60_000
151
152  on('session.start', async ($, e, next) => {
153    const started = await next(e)
154    await $.command.register({ name: 'gates', description: 'Your open PRs and what each waits on: your checkboxes, CodeRabbit, Codecov, CI', argumentHint: '[text]' })
155    await $.tool.register({
156      name: 'gates',
157      description:
158        "David's open PRs and what each waits on: template boxes only he can tick, unresolved CodeRabbit threads, Codecov, CI, conflicts, drafts ready for review. " +
159        'Use it to answer "what\'s blocking my PRs" instead of calling gh for each. Never tick boxes or mark PRs ready yourself.',
160      inputSchema: { type: 'object', properties: { repo: { type: 'string', description: 'Only PRs in repos whose owner/name contains this.' } } },
161    })
162    $.clock.after(4000, () => void refresh($))
163    if (pollMs > 0) $.clock.every(Math.max(60_000, pollMs), () => void refresh($))
164    return started
165  })
166
167  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
168    const r = await next(e)
169    if (r.deny === undefined && /\b(git\s+push|gh\s+pr\s+(create|edit|ready|comment|review|merge|close))\b/.test(String(e.command ?? ''))) refreshSoon($)
170    return r
171  }).catch(($, e, next) => next(e))
172
173  on('tool.call', { tool: 'mcp__gatekeeper__gates' }, async ($, e) => {
174    const repo = String((e as unknown as { repo?: unknown }).repo ?? '').toLowerCase()
175    await refresh($)
176    const v = await read($, gates)
177    if (v.error && !v.at) return { result: `Couldn't read PRs: ${v.error}` }
178    const list = repo ? v.gates.filter(g => g.repo.toLowerCase().includes(repo)) : v.gates
179    return { result: asText(list, repo ? list.length : v.total) }
180  })
181
182  on('command.run', { command: 'gates' }, async ($, e) => {
183    if (/\btext\b/.test(e.args)) return { text: await refresh($, true) }
184    const isOpen = await togglePane($)
185    if (isOpen) $.clock.after(0, () => void refresh($))
186    return { text: isOpen ? 'Gates opened.' : 'Gates closed.' }
187  })
188
189  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
190    const ui = $.ui.resolve(e)
191    const { Box, Text, Button } = ui
192    const v: Gates = await read($, gates)
193    const at = Math.max(await read($, now), v.at)
194    const lanes = (['you', 'work', 'waiting', 'clear'] as Lane[]).map(l => [l, v.gates.filter(g => g.lane === l)] as const).filter(([, l]) => l.length)
195    const head =
196      e.surface === 'desktop' && 'Svg' in ui ? (
197        <ui.Svg key="head" source={headSvg(widthFor(e.props.bodyColumns), v.gates)} alt={bandText(v.gates) ?? 'No open PRs need anything.'} width={widthFor(e.props.bodyColumns)} height={40} />
198      ) : (
199        <Text key="head" bold>
200          {bandText(v.gates) ?? 'gates: all clear'}
201        </Text>
202      )
203    return (
204      <Box flexDirection="column">
205        {head}
206        <Box key="meta" flexDirection="row" gap={2}>
207          <Text dimColor>
208            {v.isBusy ? 'Reading GitHub…' : v.at ? `${v.gates.length}${v.total > v.gates.length ? ` of ${v.total}` : ''} open PRs · as of ${ago(at - v.at)}` : 'Not read yet.'}
209          </Text>
210          <Button key="refresh" label="Refresh" plain onPress={() => void refresh($, true)} />
211        </Box>
212        {v.error ? <Text color="red">{v.error}</Text> : null}
213        {lanes.map(([lane, list]) => (
214          <Box key={`lane-${lane}`} flexDirection="column" marginTop={1}>
215            <Text bold color={LANE_COLOR[lane]}>
216              {LANE_TITLE[lane]} · {list.length}
217            </Text>
218            {list.map(g => (
219              <Box key={`${g.repo}#${g.number}`} flexDirection="column">
220                <Box flexDirection="row" gap={1}>
221                  {'Link' in ui ? <ui.Link href={g.url}>{`${g.repo.split('/')[1]}#${g.number}`}</ui.Link> : <Text>{`${g.repo.split('/')[1]}#${g.number}`}</Text>}
222                  <Text wrap="truncate-end">{g.title}</Text>
223                </Box>
224                <Text dimColor wrap="truncate-end">
225                  {'  '}
226                  {g.why} · {chips(g).join(' · ')}
227                </Text>
228                {lane === 'you'
229                  ? g.yourBoxes.slice(0, 3).map((b, i) => (
230                      <Text key={`b${i}`} color="red" wrap="truncate-end">
231                        {'  ☐ '}
232                        {b}
233                      </Text>
234                    ))
235                  : null}
236              </Box>
237            ))}
238          </Box>
239        ))}
240      </Box>
241    )
242  })
243
244  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
245    const v = await read($, gates)
246    const c = counts(v.gates)
247    if (e.props.hasSurvey || !c.you) return next(e)
248    const { Box, Text } = $.ui.resolve(e)
249    return (
250      <Box>
251        <Text color="red">🚧 {bandText(v.gates)}</Text>
252        <Text dimColor> · /gates</Text>
253      </Box>
254    )
255  })
256}
257
hooks/gates.ts 215 lines
1// Pure logic for gatekeeper: the GraphQL query, and its answer into gates.
2
3import type { Gate, Lane } from '../types'
4
5export const QUERY = `query($q: String!) {
6  search(query: $q, type: ISSUE, first: 100) {
7    issueCount
8    nodes {
9      ... on PullRequest {
10        number title url isDraft body mergeable reviewDecision
11        repository { nameWithOwner viewerPermission }
12        commits(last: 1) { nodes { commit { statusCheckRollup { state contexts(first: 60) { nodes {
13          __typename
14          ... on CheckRun { name conclusion status }
15          ... on StatusContext { context state }
16        } } } } } }
17        reviewThreads(first: 100) { nodes { isResolved comments(first: 1) { nodes { author { login } } } } }
18        latestReviews(first: 20) { nodes { author { login } state } }
19      }
20    }
21  }
22}`
23
24type Check = { __typename?: string; name?: string; conclusion?: string | null; status?: string; context?: string; state?: string }
25type Node = {
26  number?: number
27  title?: string
28  url?: string
29  isDraft?: boolean
30  body?: string | null
31  mergeable?: string
32  reviewDecision?: string | null
33  repository?: { nameWithOwner?: string; viewerPermission?: string | null }
34  commits?: { nodes?: { commit?: { statusCheckRollup?: { state?: string; contexts?: { nodes?: Check[] } } | null } }[] }
35  reviewThreads?: { nodes?: { isResolved?: boolean; comments?: { nodes?: { author?: { login?: string } | null }[] } }[] }
36  latestReviews?: { nodes?: { author?: { login?: string } | null; state?: string }[] }
37}
38
39const RABBIT = /coderabbit/i
40
41/** Unticked checkboxes whose text matches one of `yours`. */
42export function yourUnticked(body: string, yours: readonly string[]): string[] {
43  const out: string[] = []
44  for (const line of body.split('\n')) {
45    const m = line.match(/^\s*[-*]\s+\[ \]\s+(.*)$/)
46    if (!m || !m[1]) continue
47    const text = m[1].replace(/<!--.*?-->/g, '').trim()
48    const low = text.toLowerCase()
49    if (yours.some(y => y && low.includes(y.toLowerCase()))) out.push(text.replace(/\*\*/g, '').slice(0, 90))
50  }
51  return out
52}
53
54const outcome = (c: Check): 'pass' | 'fail' | 'pending' | 'skip' => {
55  if (c.__typename === 'StatusContext' || c.context) {
56    const s = (c.state ?? '').toUpperCase()
57    return s === 'SUCCESS' ? 'pass' : s === 'PENDING' || s === 'EXPECTED' ? 'pending' : 'fail'
58  }
59  if ((c.status ?? '').toUpperCase() !== 'COMPLETED') return 'pending'
60  const k = (c.conclusion ?? '').toUpperCase()
61  return k === 'SUCCESS' || k === 'NEUTRAL' ? 'pass' : k === 'SKIPPED' || k === 'CANCELLED' ? 'skip' : 'fail'
62}
63
64const nameOf = (c: Check): string => c.name ?? c.context ?? '?'
65
66/** Several runs of one check (re-runs, matrix): the newest outcome per name wins, so a cancelled earlier run doesn't count as failing. */
67function settle(checks: Check[]): Map<string, 'pass' | 'fail' | 'pending' | 'skip'> {
68  const by = new Map<string, 'pass' | 'fail' | 'pending' | 'skip'>()
69  for (const c of checks) {
70    const o = outcome(c)
71    const prev = by.get(nameOf(c))
72    // pass beats skip; pending beats a finished fail of the same name (a re-run in flight).
73    if (!prev || o === 'pass' || o === 'pending' || (o === 'fail' && prev === 'skip')) by.set(nameOf(c), o)
74  }
75  return by
76}
77
78export function toGate(n: Node, yours: readonly string[]): Gate | null {
79  if (typeof n.number !== 'number' || !n.repository?.nameWithOwner) return null
80  const rollup = n.commits?.nodes?.[0]?.commit?.statusCheckRollup ?? null
81  const checks = settle(rollup?.contexts?.nodes ?? [])
82  const cov = [...checks.entries()].filter(([k]) => /codecov/i.test(k)).map(([, v]) => v)
83  const codecov = cov.length ? (cov.includes('fail') ? 'fail' : cov.includes('pending') ? 'pending' : 'pass') : undefined
84  const failing = [...checks.entries()].filter(([, v]) => v === 'fail').map(([k]) => k)
85  const pending = [...checks.values()].some(v => v === 'pending')
86  const ci: Gate['ci'] = checks.size ? (failing.length ? 'fail' : pending ? 'pending' : 'pass') : undefined
87  const threads = n.reviewThreads?.nodes ?? []
88  const rabbitOpen = threads.filter(t => !t.isResolved && RABBIT.test(t.comments?.nodes?.[0]?.author?.login ?? '')).length
89  const rabbitChanges = (n.latestReviews?.nodes ?? []).some(r => RABBIT.test(r.author?.login ?? '') && r.state === 'CHANGES_REQUESTED')
90  const g: Omit<Gate, 'lane' | 'why'> = {
91    repo: n.repository.nameWithOwner,
92    number: n.number,
93    title: n.title ?? '',
94    url: n.url ?? '',
95    isDraft: n.isDraft === true,
96    yourBoxes: yourUnticked(n.body ?? '', yours),
97    rabbitOpen,
98    rabbitChanges,
99    ...(codecov ? { codecov } : {}),
100    ...(ci ? { ci } : {}),
101    failing: failing.slice(0, 4),
102    hasConflicts: n.mergeable === 'CONFLICTING',
103    ...(n.reviewDecision ? { reviewDecision: n.reviewDecision } : {}),
104  }
105  return { ...g, ...laneOf(g) }
106}
107
108/** Which lane: work for Claude first (red checks, open threads, conflicts), else what only you can do, else waiting. */
109export function laneOf(g: Omit<Gate, 'lane' | 'why'>): { lane: Lane; why: string } {
110  const work: string[] = []
111  if (g.hasConflicts) work.push('merge conflicts')
112  if (g.ci === 'fail') work.push(`failing: ${g.failing.slice(0, 2).join(', ')}`)
113  if (g.codecov === 'fail' && !g.failing.some(f => /codecov/i.test(f))) work.push('codecov')
114  if (g.rabbitOpen) work.push(`${g.rabbitOpen} CodeRabbit thread${g.rabbitOpen === 1 ? '' : 's'}`)
115  else if (g.rabbitChanges) work.push('CodeRabbit asks for changes')
116  if (work.length) return { lane: 'work', why: work.join(' · ') }
117  if (g.yourBoxes.length) return { lane: 'you', why: `${g.yourBoxes.length} box${g.yourBoxes.length === 1 ? '' : 'es'} only you tick` }
118  if (g.isDraft && g.ci !== 'pending') return { lane: 'you', why: 'draft, checks green: review and mark ready' }
119  if (g.ci === 'pending' || g.codecov === 'pending') return { lane: 'waiting', why: 'checks running' }
120  if (g.reviewDecision === 'APPROVED') return { lane: 'clear', why: 'approved' }
121  return { lane: 'waiting', why: g.reviewDecision === 'CHANGES_REQUESTED' ? 'maintainer asked for changes' : 'waiting on maintainers' }
122}
123
124/** Repos (`owner/repo`) or owners (`owner`) that cap how many PRs a contributor without write access may have out of draft, with the cap. */
125export type ReadyLimits = Readonly<Record<string, number>>
126const WRITERS = new Set(['ADMIN', 'MAINTAIN', 'WRITE'])
127
128/** "tinkerologie=2, owner/repo=3" into { tinkerologie: 2, 'owner/repo': 3 }. */
129export function parseReadyLimits(spec: string): ReadyLimits {
130  const out: Record<string, number> = {}
131  for (const part of spec.split(/[,\s]+/)) {
132    const m = part.match(/^([\w.-]+(?:\/[\w.-]+)?)=(\d+)$/)
133    if (m && m[1] && m[2]) out[m[1].toLowerCase()] = Number(m[2])
134  }
135  return out
136}
137
138const limitFor = (repo: string, limits: ReadyLimits): number | undefined => limits[repo.toLowerCase()] ?? limits[repo.split('/')[0]?.toLowerCase() ?? '']
139
140/**
141 * A green draft is yours to mark ready, unless the repo won't let you: where
142 * it caps contributors' open, non-draft PRs, you can't write, and you are at
143 * the cap, the Ready button is disabled. Those drafts wait (for one of the
144 * others to merge or close) instead of asking you for something you can't do.
145 */
146export function holdAtReadyLimit(gates: Gate[], nodes: readonly Node[], limits: ReadyLimits): Gate[] {
147  const perm = new Map<string, string | null | undefined>()
148  for (const n of nodes) if (n.repository?.nameWithOwner) perm.set(n.repository.nameWithOwner, n.repository.viewerPermission)
149  const ready = new Map<string, number>()
150  for (const g of gates) if (!g.isDraft) ready.set(g.repo, (ready.get(g.repo) ?? 0) + 1)
151  return gates.map(g => {
152    const cap = limitFor(g.repo, limits)
153    const n = ready.get(g.repo) ?? 0
154    const capped = cap !== undefined && !WRITERS.has(String(perm.get(g.repo) ?? '').toUpperCase()) && n >= cap
155    if (!(capped && g.isDraft && g.lane === 'you' && !g.yourBoxes.length)) return g
156    return { ...g, lane: 'waiting' as Lane, why: `held: ${n} of ${cap} ready PRs already open here; mark ready when one merges` }
157  })
158}
159
160export function parseGates(text: string, yours: readonly string[], limits: ReadyLimits = {}): { gates: Gate[]; total: number } | null {
161  try {
162    const v = JSON.parse(text) as { data?: { search?: { issueCount?: number; nodes?: Node[] } }; errors?: { message?: string }[] }
163    const s = v.data?.search
164    if (!s) return null
165    const nodes = s.nodes ?? []
166    const gates = nodes.map(n => toGate(n, yours)).filter((g): g is Gate => g !== null)
167    return { gates: order(holdAtReadyLimit(gates, nodes, limits)), total: s.issueCount ?? gates.length }
168  } catch {
169    return null
170  }
171}
172
173const LANE_ORDER: Lane[] = ['you', 'work', 'waiting', 'clear']
174export const order = (gates: Gate[]): Gate[] => [...gates].sort((a, b) => LANE_ORDER.indexOf(a.lane) - LANE_ORDER.indexOf(b.lane) || a.repo.localeCompare(b.repo) || b.number - a.number)
175
176export function counts(gates: Gate[]): Record<Lane, number> {
177  const c: Record<Lane, number> = { you: 0, work: 0, waiting: 0, clear: 0 }
178  for (const g of gates) c[g.lane]++
179  return c
180}
181
182export function bandText(gates: Gate[]): string | undefined {
183  const c = counts(gates)
184  const parts = [c.you && `${c.you} need you`, c.work && `${c.work} need work`, c.waiting && `${c.waiting} waiting`].filter(Boolean)
185  return parts.length ? `gates: ${parts.join(' · ')}` : undefined
186}
187
188export function chips(g: Gate): string[] {
189  const out: string[] = []
190  if (g.yourBoxes.length) out.push(`☐ ${g.yourBoxes.length} yours`)
191  if (g.rabbitOpen) out.push(`🐇 ${g.rabbitOpen} open`)
192  else if (g.rabbitChanges) out.push('🐇 changes')
193  if (g.codecov) out.push(`codecov ${g.codecov === 'pass' ? '✓' : g.codecov === 'fail' ? '✗' : '…'}`)
194  if (g.ci) out.push(`CI ${g.ci === 'pass' ? '✓' : g.ci === 'fail' ? '✗' : '…'}`)
195  if (g.hasConflicts) out.push('conflicts')
196  if (g.isDraft) out.push('draft')
197  return out
198}
199
200export function asText(gates: Gate[], total: number): string {
201  if (!gates.length) return 'No open PRs.'
202  const lines = [`${bandText(gates) ?? 'gates: all clear'} (${gates.length}${total > gates.length ? ` of ${total}` : ''} open PRs)`]
203  const title: Record<Lane, string> = { you: 'Needs you', work: 'Needs work', waiting: 'Waiting', clear: 'Clear' }
204  for (const lane of ['you', 'work', 'waiting', 'clear'] as Lane[]) {
205    const list = gates.filter(g => g.lane === lane)
206    if (!list.length) continue
207    lines.push(`${title[lane]}:`)
208    for (const g of list) {
209      lines.push(`- ${g.repo}#${g.number} ${g.title}: ${g.why} [${chips(g).join(', ')}]`)
210      if (lane === 'you') for (const b of g.yourBoxes.slice(0, 3)) lines.push(`    ☐ ${b}`)
211    }
212  }
213  return lines.join('\n')
214}
215
hooks/cache.ts 45 lines
1// The read of your PRs that every session shares, so that one session asks
2// GitHub and the rest reuse its answer (each session polling on its own ran
3// the GraphQL rate limit dry with a few dozen sessions open).
4
5import type { Gate } from '../types'
6
7export type SharedRead = {
8  /** When GitHub was last read successfully (ms). */
9  at?: number
10  gates?: Gate[]
11  total?: number
12  /** A session started reading GitHub at this time and hasn't finished. */
13  fetchingSince?: number
14  /** GitHub said the rate limit is spent: nobody reads until then (ms). */
15  backoffUntil?: number
16}
17
18export type Plan = { kind: 'fetch' } | { kind: 'reuse'; why: 'fresh' | 'another session is reading' | 'rate limited' }
19
20/** A session already reading GitHub for longer than this has probably died. */
21export const FETCH_STALE_MS = 90_000
22/** Even a forced refresh (Refresh, /gates text, after a push) reuses a read this recent. */
23export const FORCE_MIN_GAP_MS = 60_000
24
25/** Whether this session should ask GitHub now, or reuse the shared read. */
26export function plan(shared: SharedRead, nowMs: number, pollMs: number, force: boolean): Plan {
27  if (shared.backoffUntil && nowMs < shared.backoffUntil) return { kind: 'reuse', why: 'rate limited' }
28  if (shared.fetchingSince && nowMs - shared.fetchingSince < FETCH_STALE_MS) return { kind: 'reuse', why: 'another session is reading' }
29  const age = shared.at ? nowMs - shared.at : Infinity
30  // A little slack, so a session whose timer fires just after another's read reuses it.
31  if (age < (force ? FORCE_MIN_GAP_MS : pollMs * 0.9)) return { kind: 'reuse', why: 'fresh' }
32  return { kind: 'fetch' }
33}
34
35export function parseShared(text: string): SharedRead {
36  try {
37    const v = JSON.parse(text) as SharedRead
38    return v && typeof v === 'object' ? v : {}
39  } catch {
40    return {}
41  }
42}
43
44export const isRateLimited = (text: string): boolean => /rate limit/i.test(text)
45
hooks/shared/crab.ts 230 lines
1// The crew's crabs: pixel art, costumes and motion copied from savvy-progress
2// (https://github.com/JohnnyVizz/claude-kit/tree/main/plugins/savvy-progress,
3// MIT License, Copyright (c) 2026 johnnyvizz; see THIRD_PARTY_NOTICES.md),
4// which credits the pixel crab to DockCrab (Clawdy).
5//
6// Changed here: a pose per session status (closed eyes and tucked legs to rest,
7// happy eyes, both claws up and a hop to celebrate), and costumes named for
8// what they wear instead of savvy-flow's tiers.
9
10/** A crab's pose, by what its owner is doing. */
11export type CrewStatus = 'working' | 'idle' | 'done' | 'proposed' | 'dismissed'
12
13// Pixel Clawd from DockCrab (Clawdy): a 24×18 crab on a 30×28 grid, one costume per tier.
14// The body keeps the brand clay; the tier's color lives in the costume's accent.
15const CLAY = '#D97757'
16const INK = '#1F1E1D'
17
18export const COSTUME_COLOR: Record<string, string> = {
19  fable: '#7F77DD',
20  heavy: '#D85A30',
21  careful: '#BA7517',
22  medium: '#378ADD',
23  light: '#1D9E75',
24  explore: '#55514C',
25  other: '#888780',
26}
27
28/** What a `faces` rule or `/crew face` may name, to the costume it draws. */
29export const COSTUME_NAMES: Record<string, string> = {
30  astronaut: 'fable',
31  detective: 'heavy',
32  engineer: 'careful',
33  chef: 'medium',
34  racer: 'light',
35  pirate: 'explore',
36  plain: 'other',
37  // savvy-flow's own tier names work too.
38  fable: 'fable',
39  heavy: 'heavy',
40  careful: 'careful',
41  medium: 'medium',
42  light: 'light',
43  explore: 'explore',
44  other: 'other',
45}
46
47export const WEARS: Record<string, string> = {
48  fable: 'astronaut',
49  heavy: 'detective',
50  careful: 'engineer',
51  medium: 'chef',
52  light: 'racer',
53  explore: 'pirate',
54  other: 'plain',
55}
56
57/** Sessions with no rule take these in turn, in the order they joined. */
58export const ROTATION = ['fable', 'heavy', 'careful', 'medium', 'light', 'explore'] as const
59
60export const costumeKey = (name: string): string | undefined => COSTUME_NAMES[name.trim().toLowerCase()]
61
62// `cls` puts a pixel in a named group: `bd` (the default) is the body and its
63// costume, `la`/`lb` the leg pairs, anything else a prop with its own motion.
64type Fill = (x: number, y: number, w: number, h: number, c: string, cls?: string) => void
65
66type Pose = { eyes: 'open' | 'closed' | 'happy'; isCheering: boolean }
67
68const stamp = (f: Fill, x: number, y: number, rows: string[], map: Record<string, string>, cls?: string): void =>
69  rows.forEach((row, dy) => [...row].forEach((ch, dx) => map[ch] && f(x + dx, y + dy, 1, 1, map[ch] ?? '', cls)))
70
71const eyes = (f: Fill, pose: Pose): void => {
72  if (pose.eyes === 'closed') {
73    f(9, 13, 2, 1, INK)
74    f(19, 13, 2, 1, INK)
75  } else if (pose.eyes === 'happy') {
76    f(9, 13, 1, 1, INK); f(10, 12, 1, 1, INK)
77    f(19, 12, 1, 1, INK); f(20, 13, 1, 1, INK)
78  } else {
79    f(9, 12, 2, 2, INK)
80    f(19, 12, 2, 2, INK)
81  }
82}
83
84// `armCls` lets a raised claw travel with the prop it holds. A cheering crab
85// raises the back claw too, in `ar`, which waves.
86const crabBody = (f: Fill, pose: Pose, armFront = 0, armCls?: string): void => {
87  f(7, 10, 16, 12, CLAY)
88  if (pose.isCheering) f(3, 10, 4, 4, CLAY, 'ar')
89  else f(3, 14, 4, 4, CLAY)
90  f(23, 14 + (pose.isCheering && !armCls ? -4 : armFront), 4, 4, CLAY, armCls ?? (pose.isCheering ? 'ar' : undefined))
91  eyes(f, pose)
92  f(7, 22, 2, 4, CLAY, 'la')
93  f(17, 22, 2, 4, CLAY, 'la')
94  f(11, 22, 2, 4, CLAY, 'lb')
95  f(21, 22, 2, 4, CLAY, 'lb')
96}
97
98// Pure CSS, run by the compositor: no redraws. Periods divide one second, so the
99// once-a-second redraw of a running row restarts them in phase. Every crab walks;
100// each costume adds its prop's own motion on top.
101export const CRAB_CSS = `<style>
102.run .la{animation:st .5s steps(1) infinite}.run .lb{animation:st .5s steps(1) infinite -.25s}
103.run .bd{animation:bob .5s steps(1) infinite -.125s}
104.run g{transform-box:fill-box}
105@keyframes st{50%{transform:translateY(-1px)}}@keyframes bob{50%{transform:translateY(1px)}}
106.c-fable.run{animation:float 1s ease-in-out infinite}
107.c-fable.run .la,.c-fable.run .lb,.c-fable.run .bd{animation:none}
108.c-fable.run .ant{animation:blink 1s steps(1) infinite}
109.c-fable.run .star{animation:blink .5s steps(1) infinite -.25s}
110@keyframes float{50%{transform:translateY(-2px)}}@keyframes blink{50%{opacity:.15}}
111.c-heavy.run .it{animation:scan 1s steps(1) infinite}
112.c-heavy.run .gl{animation:blink 1s steps(1) infinite -.5s}
113@keyframes scan{25%{transform:translate(-1px,1px)}50%{transform:translate(-2px,2px)}75%{transform:translate(-1px,1px)}}
114.c-careful.run .it{transform-origin:100% 100%;animation:twist .5s ease-in-out infinite}
115@keyframes twist{50%{transform:rotate(-35deg)}}
116.c-medium.run .pan{transform-origin:0 50%;animation:tilt 1s ease-in-out infinite}
117.c-medium.run .egg{animation:flip 1s ease-in-out infinite}
118@keyframes tilt{20%,40%{transform:rotate(-12deg)}}@keyframes flip{30%{transform:translateY(-5px) scaleY(-1)}60%{transform:translateY(0)}}
119.c-light.run .la{animation-duration:.25s}.c-light.run .lb{animation-duration:.25s;animation-delay:-.125s}
120.c-light.run .flag{transform-origin:0 50%;animation:wave .25s steps(1) infinite}
121@keyframes wave{50%{transform:skewY(-12deg) scaleX(.85)}}
122.c-explore.run .it{transform-origin:50% 100%;animation:fence .5s ease-in-out infinite}
123@keyframes fence{50%{transform:rotate(25deg)}}
124.rest g{transform-box:fill-box}
125.rest .bd{transform:translateY(2px)}.rest .la,.rest .lb{transform-origin:50% 100%;transform:scaleY(.5)}
126.rest{transform-box:fill-box;transform-origin:50% 100%;animation:breathe 4s ease-in-out infinite}
127@keyframes breathe{50%{transform:scaleY(.96)}}
128.cheer{animation:hop .6s ease-in-out infinite}.cheer g{transform-box:fill-box}
129.cheer .ar{animation:clap .3s steps(1) infinite}
130@keyframes hop{50%{transform:translateY(-3px)}}@keyframes clap{50%{transform:translateY(-1px)}}
131.z{opacity:0;animation:zz 3s linear infinite}.z2{animation-delay:1s}.z3{animation-delay:2s}
132@keyframes zz{0%{opacity:0;transform:translate(0,0)}25%{opacity:.9}100%{opacity:0;transform:translate(-5px,-12px)}}
133.cf{transform-box:fill-box;transform-origin:center;animation:fall 1.8s linear infinite}
134.cf1{animation-delay:-.4s}.cf2{animation-delay:-.9s}.cf3{animation-delay:-1.3s}
135@keyframes fall{0%{transform:translateY(-4px) rotate(0);opacity:1}100%{transform:translateY(28px) rotate(300deg);opacity:0}}
136.q{animation:blink 1.6s steps(1) infinite}
137@media (prefers-reduced-motion: reduce){.run,.run g,.rest,.cheer,.cheer g,.z,.cf,.q{animation:none!important}.z{opacity:.7}}
138</style>`
139
140const COSTUMES: Record<string, (f: Fill, t: string, pose: Pose) => void> = {
141  // Fable: astronaut in a glass dome; floats instead of walking, the antenna and the star blink.
142  fable: (f, t, pose) => {
143    crabBody(f, pose)
144    f(6, 7, 18, 1, '#E6E8EE'); f(5, 8, 1, 14, '#E6E8EE'); f(24, 8, 1, 14, '#E6E8EE'); f(6, 22, 18, 1, '#C9CCD2')
145    f(6, 8, 18, 14, 'rgba(169,214,245,.32)'); f(8, 9, 2, 1, '#fff'); f(8, 10, 1, 2, '#fff')
146    f(14, 4, 2, 3, '#C9CCD2'); f(14, 2, 2, 2, t, 'ant'); f(13, 18, 4, 2, t)
147    f(27, 3, 1, 3, '#F5C542', 'star'); f(26, 4, 3, 1, '#F5C542', 'star')
148  },
149  // Heavy: detective with a deerstalker; the magnifier sweeps and glints.
150  heavy: (f, t, pose) => {
151    crabBody(f, pose, -4, 'it')
152    stamp(f, 6, 3, ['......bbbbbb......', '....bbcbbcbbbb....', '...bbbbbbbbbbbb...', '..bcbbcbbcbbcbbb..', '.bbbbbbbbbbbbbbbb.', 'dddddddddddddddddd'], { b: '#7A4A26', c: '#A0703F', d: '#5A3519' })
153    f(6, 9, 18, 1, t)
154    stamp(f, 23, 1, ['.kkk.', 'k...k', 'k...k', 'k...k', '.kkk.'], { k: '#3A3A3C' }, 'it')
155    f(24, 2, 3, 3, 'rgba(169,214,245,.7)', 'it'); f(25, 6, 1, 4, '#7A4A26', 'it'); f(24, 2, 1, 1, '#fff', 'gl')
156  },
157  // Careful: engineer in a hard hat; the wrench turns a bolt.
158  careful: (f, t, pose) => {
159    crabBody(f, pose)
160    stamp(f, 6, 4, ['.....yyyyyyyy.....', '...yyyyyhhyyyyy...', '..yyyyyyhhyyyyyy..', '..yyyyyyhhyyyyyy..', '.yyyyyyyhhyyyyyyy.', 'dddddddddddddddddd'], { y: '#F5C542', h: '#FBE08A', d: '#C99A1E' })
161    f(13, 5, 4, 2, t)
162    stamp(f, 0, 10, ['.s.s', 'sss.', '.s..', '.s..'], { s: '#8E929A' }, 'it')
163  },
164  // Medium: chef, the toque traced from DockCrab's Sprites.chefHat; tosses the omelette.
165  medium: (f, t, pose) => {
166    crabBody(f, pose, -4, 'pan')
167    stamp(f, 6, 0, ['........lll.......', '.......lllll......', '.wwwwgwwwwwwgwwwww', 'wwwwwwwwwwwwwwwwww', 'wwwwwwwwwwwwwwwwww', 'wwwwwgwwwwwggwwwww', '.wwwwgwwwwwggwwwww', '.dddbbbbbbbbbbbbb.', '.dddbbbbbbbbbbbbb.', '.dddbbbbbbbbbbbbb.'], { w: '#F4F3EE', l: '#F7F6F2', g: '#D2D1C8', b: t, d: '#B45F43' })
168    f(22, 8, 7, 2, '#4A4A48', 'pan'); f(26, 10, 1, 1, '#4A4A48', 'pan'); f(24, 7, 3, 1, '#F5B731', 'egg')
169  },
170  // Light: racer in a helmet; runs at double pace, the checkered flag flutters.
171  light: (f, t, pose) => {
172    crabBody(f, pose, -4)
173    stamp(f, 6, 5, ['....rrrrrrrrrr....', '..rrrrrrwwrrrrrr..', '.rrrrrrrwwrrrrrrr.', '.rrrrrrrwwrrrrrrr.', '.rrrrrrrwwrrrrrrr.', '.kkkkkkkkkkkkkkkkr'], { r: t, w: '#F8F6F1', k: INK })
174    f(25, 1, 1, 9, '#8E929A')
175    stamp(f, 26, 1, ['wkwk', 'kwkw', 'wkwk'], { w: '#F8F6F1', k: INK }, 'flag')
176  },
177  // Explore: pirate scouting the code; the cutlass fences.
178  explore: (f, _t, pose) => {
179    crabBody(f, pose)
180    stamp(f, 5, 3, ['.kk..............kk.', '.kkk....kkkk....kkk.', '..kkkkkkkwwkkkkkkk..', '..kkkkkkkkkkkkkkkk..', '.gggggggggggggggggg.'], { k: '#55514C', w: '#F8F6F1', g: '#F5C542' })
181    f(7, 11, 11, 1, INK); f(18, 11, 4, 3, INK)
182    f(27, 6, 1, 9, '#C9CCD2', 'it'); f(26, 15, 3, 1, '#7A4A26', 'it')
183  },
184  other: (f, _t, pose) => crabBody(f, pose),
185}
186
187const MOTION: Record<CrewStatus, string> = {
188  working: ' run',
189  idle: ' rest',
190  done: ' cheer',
191  proposed: '',
192  dismissed: '',
193}
194
195// What floats around the crab, in its own 30×28 grid: top left, the one corner no costume's prop takes.
196const EXTRAS: Partial<Record<CrewStatus, string>> = {
197  idle:
198    `<text class="z" x="0" y="8" font-family="sans-serif" font-size="6" font-weight="700" fill="#9a9a96">z</text>` +
199    `<text class="z z2" x="0" y="8" font-family="sans-serif" font-size="7" font-weight="700" fill="#9a9a96">z</text>` +
200    `<text class="z z3" x="0" y="8" font-family="sans-serif" font-size="9" font-weight="700" fill="#9a9a96">Z</text>`,
201  done: ['#D85A30', '#378ADD', '#F5C542', '#7F77DD'].map((c, i) => `<rect class="cf cf${i}" x="${2 + i * 8}" y="0" width="2" height="3" fill="${c}"/>`).join(''),
202  proposed: `<text class="q" x="0" y="9" font-family="sans-serif" font-size="9" font-weight="700" fill="#BA7517">?</text>`,
203}
204
205export const CRAB_SCALE = 1.1
206
207/**
208 * One crab at (x, y) in `costume`, posed for `status`. Body and props nest
209 * inside `bd` so a prop rides the bob and adds its own motion; legs stay
210 * outside it and step on their own.
211 */
212export function crab(x: number, y: number, costume: string, status: CrewStatus, scale = CRAB_SCALE): string {
213  const pose: Pose = {
214    eyes: status === 'idle' ? 'closed' : status === 'done' ? 'happy' : 'open',
215    isCheering: status === 'done',
216  }
217  const groups = new Map<string, string[]>([['bd', []]])
218  const f: Fill = (cx, cy, w, h, c, cls = 'bd') => {
219    if (!groups.has(cls)) groups.set(cls, [])
220    groups.get(cls)?.push(`<rect x="${cx}" y="${cy}" width="${w}" height="${h}" fill="${c}"/>`)
221  }
222  const draw = COSTUMES[costume] ?? COSTUMES.other
223  draw?.(f, COSTUME_COLOR[costume] ?? '#888780', pose)
224  const group = (cls: string) => `<g class="${cls}">${(groups.get(cls) ?? []).join('')}</g>`
225  const props = [...groups.keys()].filter(k => k !== 'bd' && k !== 'la' && k !== 'lb')
226  const body = `<g class="bd">${(groups.get('bd') ?? []).join('')}${props.map(group).join('')}</g>`
227  const dim = status === 'proposed' ? ' opacity="0.45"' : status === 'dismissed' ? ' opacity="0.3"' : ''
228  return `<g transform="translate(${x},${y}) scale(${scale})" shape-rendering="crispEdges"><g${dim}><g class="c-${costume}${MOTION[status]}">${body}${group('la')}${group('lb')}</g></g>${EXTRAS[status] ?? ''}</g>`
229}
230
hooks/shared/draw.ts 50 lines
1// Shared drawing helpers for the crab-workshop mods (copied into each mod's
2// hooks/shared/ by scripts/sync-shared.sh; edit the copy in /shared).
3
4import { CRAB_CSS } from './crab'
5
6export const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',sans-serif"
7export const MONO = "ui-monospace,'SF Mono',Menlo,monospace"
8
9export const xml = (s: string): string =>
10  s.replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c)
11
12/** Cuts `s` to what fits `maxW` pixels at `size`, with an ellipsis. */
13export const fit = (s: string, size: number, maxW: number): string => {
14  const max = Math.max(4, Math.floor(maxW / (size * 0.56)))
15  return s.length > max ? s.slice(0, max - 1) + '…' : s
16}
17
18// Text and line colours for both themes; `.ok`, `.warn`, `.bad` are state colours.
19export const THEME_CSS = `<style>
20.t{fill:#1f1f1f}.s{fill:#6b6b68}.ln{stroke:#e4e4e1}.ok{fill:#2f8a57}.warn{fill:#b07012}.bad{fill:#c2412d}
21.trk{fill:#ececea}.bar{fill:#378ADD}
22@media (prefers-color-scheme: dark){.t{fill:#ececec}.s{fill:#a8a8a4}.ln{stroke:#333331}.ok{fill:#6fcf97}.warn{fill:#f0c46a}.bad{fill:#f28b75}.trk{fill:#2b2b29}.bar{fill:#6aaef0}}
23@media (prefers-reduced-motion: reduce){*{animation:none!important;transition:none!important}}
24</style>`
25
26/** An SVG document with the theme and crab styles, `extraCss` (a <style> block) after them. */
27export const svg = (W: number, H: number, body: string, extraCss = ''): string =>
28  `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${THEME_CSS}${CRAB_CSS}${extraCss}${body}</svg>`
29
30/** Width in CSS pixels for a pane or band `bodyColumns` wide. */
31export const widthFor = (bodyColumns: number | undefined, min = 240, max = 900): number =>
32  Math.max(min, Math.min(max, (bodyColumns || 40) * 8 - 8))
33
34/** A horizontal meter: track plus a bar `pct` full, coloured by thresholds. */
35export const meter = (x: number, y: number, w: number, h: number, pct: number, warnAt = 80, badAt = 95): string => {
36  const p = Math.max(0, Math.min(100, pct))
37  const cls = p >= badAt ? 'bad' : p >= warnAt ? 'warn' : 'bar'
38  return `<rect class="trk" x="${x}" y="${y}" width="${w}" height="${h}" rx="${h / 2}"/><rect class="${cls}" x="${x}" y="${y}" width="${Math.max(h, (w * p) / 100)}" height="${h}" rx="${h / 2}"/>`
39}
40
41/** "3m", "2h 5m", "4d" from milliseconds. */
42export const ago = (ms: number): string => {
43  const m = Math.round(ms / 60_000)
44  if (m < 1) return 'now'
45  if (m < 60) return `${m}m`
46  const h = Math.floor(m / 60)
47  if (h < 48) return m % 60 && h < 10 ? `${h}h ${m % 60}m` : `${h}h`
48  return `${Math.floor(h / 24)}d`
49}
50
types/index.d.ts 42 lines
1export type Lane = 'you' | 'work' | 'waiting' | 'clear'
2
3export type Gate = {
4  repo: string
5  number: number
6  title: string
7  url: string
8  isDraft: boolean
9  /** Unticked boxes that are yours to tick, by their text. */
10  yourBoxes: string[]
11  /** Unresolved CodeRabbit threads, and whether its latest review asks for changes. */
12  rabbitOpen: number
13  rabbitChanges: boolean
14  /** Codecov's patch or project check: pass, fail, pending, or absent. */
15  codecov?: 'pass' | 'fail' | 'pending'
16  ci?: 'pass' | 'fail' | 'pending'
17  /** Failing check names, a few. */
18  failing: string[]
19  hasConflicts: boolean
20  reviewDecision?: string
21  lane: Lane
22  /** Why it's in its lane, in a few words. */
23  why: string
24}
25
26export type Gates = {
27  gates: Gate[]
28  total: number
29  at: number
30  isBusy: boolean
31  error?: string
32}
33
34declare module 'claude-code' {
35  interface PluginState {
36    gatekeeper: {
37      gates: Gates
38      now: number
39    }
40  }
41}
42