g-cauc: Garrett's caucus of AI models. Spec, product pass, foreman, build and review skills, plus the /board pane and the permission advisor.

Garrett's caucus: AI models from different companies that have to agree before anything ships.
One way of working for every project Garrett runs: ideas go into GitHub issues, agents turn them into agreed specs, and approved specs get built, reviewed, merged and deployed to staging without anyone watching. Production is Garrett's tap until a project has 10 clean staging runs.
g-cauc is instructions, not software. The loop is Claude Desktop scheduled tasks, the queue is GitHub Issues, the judge is GitHub's required checks, and the workers are the Claude, Codex and Cursor command-line tools. Nothing here holds state; state lives in GitHub and git. Design and research: ~/code/nessy/docs/proposals/2026-10-01-no-harness.md. Diagram: https://claude.ai/artifact/TzKoUxmuhAmwQT2fWCHxEb
Status: v1, 2026-10-01, untested. CallFlow is the pilot.
| Path | What it is |
|---|---|
skills/spec | The CTO session. Garrett and Claude plan together: kickoff for a new project or phase, or one feature. Ends in a spec PR. |
skills/propose | The product pass, run hourly. Turns idea issues into spec PRs, gets the CTO's agreement, turns merged specs into build issues. |
skills/cto-review | The CTO's review of a spec, run unattended by the product pass on the other model family. |
skills/foreman | One pass over every project: carries failures back to builders, starts reviews, starts builds, exits. |
skills/build | One build issue to one pull request, tests first. Also used for fix rounds. |
skills/agent-review | Cross-family review of one pull request. Sets the agent-review status. |
skills/adopt | Brings a repo up to the contract below, as one PR. |
templates/ | The AGENTS.md outline, CLAUDE.md, the spec format, the label set. |
lanes.md | The lanes (CLI, model, subscription), how many jobs each may run at once, launch commands, and how to tell a lane is out. |
routing.md | Which model for which job, the fallback order when a lane is out, and the cross-family rule. |
BRAND.md | The board's colors, type and shapes: status colors for state, and each project's own color for which project a thing belongs to. |
writing.md | How agents write for people: the part of Simplified Technical English (ASD-STE100) that g-cauc uses, and the check to run before posting. |
vendor/simplified-technical-english/ | The full STE rules, word list and check tool, copied from github.com/0xpili/simplified-technical-english (MIT; the word list belongs to ASD, see its NOTICE.md). |
projects.md | The projects the product pass and foreman serve, in priority order. |
tasks/ | The instructions for the three Desktop scheduled tasks: product pass, foreman, 7am digest. |
hooks/ | The mod: /board, the quiet line above the prompt, and the permission advisor. /board has a page for each project (its phase, pipeline, agents, CI and merges), an All page with a card for each project, a Fleet page for what the projects share (lanes, running jobs, the Claude plan, the runner pool), Allow, and a job view with each worker's thinking and commands. Each project shows in its own color from projects.md. All sessions share its GitHub reads: one session reads at a time, every 5 minutes, or every 2 minutes while a board is open, and all stop for 10 minutes after a rate limit. On the Desktop app it draws SVG; in a terminal, colored text. Tests in tests/. |
.claude-plugin/ | The plugin and marketplace manifests, so Claude Code installs all of this as one plugin. |
scripts/install.sh | Links every skill into ~/.codex/skills for Codex. Claude gets them from the plugin. |
scripts/labels.sh | Creates or updates the label set on a repo. |
runner/ | The CI runner pool on Garrett's Mac: a Linux VM, a job image, and gcauc-runner, which gives every CI job a fresh container with a one-job runner registration. See runner/README.md. |
The kit never assumes a language or framework. It only calls these entry points and reads their exit codes, so a Python, TypeScript, Go or mixed project all look the same from outside.
| Entry point | What it must do |
|---|---|
scripts/done --fast | Lint, type check and the tests for what changed, fast enough to run many times per build. Exit 0 means green. |
scripts/done --full | Everything, including the full end-to-end suite on a fresh stack. CI runs exactly this. |
scripts/dev up / down | Boots the app and its services for one worktree, on ports that do not collide with another worktree. |
scripts/dev testdb -- CMD | Runs CMD against a throwaway database that is never the dev database. |
scripts/deploy staging | Deploys main to staging, then runs the smoke end-to-end pass against it. |
AGENTS.md | The map every tool reads (outline in templates/AGENTS.md), including a Test layers table and an Autonomy: line. |
CLAUDE.md | @AGENTS.md plus any Claude-only lines. |
| GitHub | Branch protection on main (PR only, CI and agent-review required, no bypass), auto-merge allowed, the label set, Issues on. |
| CI | Every workflow job that runs project code uses runs-on: [self-hosted, gcauc], the runner pool in runner/, not GitHub's hosted runners (hosted minutes ran out on CallFlow on 2026-10-04). |
Which test tools sit behind scripts/done is the project's choice and is written in its Test layers table: pytest, Vitest, Jest, Playwright, simulated phone calls, data checks. Every acceptance check in a spec names its layer and the command that proves it.
Autonomy: attended until the project's walking skeleton has passed every gate once with Garrett watching. Then Autonomy: unattended, which is what lets agents push feature branches and open PRs there without asking.
Ideas and specs:
idea: Garrett opened it (one line is enough). Any issue Garrett opens with no lifecycle label counts as an idea.proposed: the product pass opened a spec PR for it.agreed: product and CTO have both signed off on the spec PR's current commit. Waiting on Garrett.Build issues:
ready: the foreman may start it once everything in its Blocked by list is closed.building: a worker has it.in-review: its PR is open; CI and the cross-family review run.needs:garrett: parked with one question and a default. The queue keeps moving.The foreman never touches an issue that is not ready. Nothing is ready until its spec was agreed by product and CTO and merged by Garrett.
Other labels: spec (spec PRs), from-review (advisory findings, batched into one hardening spec), risk:high (data, auth, money, migrations, deploy: Garrett merges the build PR too unless he delegated it, as CallFlow's D23 does), size:s, size:m, size:l, lane:<name>, area:<name> (per project, so work in different areas runs in parallel).
Claude Code (the skills, /board and the permission advisor), once:
claude plugin marketplace add garrettyarmo/g-cauc
claude plugin install g-cauc@g-cauc
Install from GitHub, not from the local folder: the local folder may be on a feature branch, and the installed plugin would follow it. After a change lands on main, bump version in .claude-plugin/plugin.json, then claude plugin marketplace update g-cauc, claude plugin update g-cauc@g-cauc and /reload-plugins in open sessions. While working on the mod itself, load the folder directly with claude --plugin-dir ~/code/g-cauc and run claude plugin test in it.
Codex reads the same skills through links:
~/code/g-cauc/scripts/install.sh
~/code/g-cauc/scripts/labels.sh garrettyarmo/callflow
The CI runner pool (once per Mac; it serves every repo in runner/repos):
~/code/g-cauc/runner/gcauc-runner up
~/code/g-cauc/runner/gcauc-runner install
The kit ships with Garrett's setup as its defaults. To run it for yourself:
~/code/g-cauc, set the plugin's "g-cauc folder" in /config.projects.md with your repos.lanes.md to match your subscriptions: set Max jobs to 0 for any lane you do not have.scripts/labels.sh <owner>/<repo> on each repo, then the adopt skill in each one.hooks/register.js 951 lines1// g-cauc mod: the /board pane, the quiet line above the prompt, and the
2// permission advisor. It reads GitHub, the process list and job logs on a
3// timer; it holds no state of record (GitHub and git do), only a shared cache
4// in $.store so several open sessions make one set of gh calls.
5
6import {
7 parseProjects, parseLanes, summarizeProject, parseJobs, laneOf, modelName, parseEtime,
8 fmtDuration, parseLsof, parseLog, bar, bandText, suggestedRules, withAllowed, labelsOf, parseClaudeAgents,
9 asArray, sanitize, callKey, monogram,
10} from './lib.js'
11import { parseRuns, parseRunJobs, parseRunners, parseRepoSlots, doneTimings, budgetsFromDone, refusalMessage, summarizeCi, ciBand, vmName } from './ci.js'
12import { colored, progress, pill, cells, dots, budgetTone, badge, ring, pipeline, projectColor } from './draw.js'
13
14const PANE = 'g-cauc-board'
15// GitHub budget. Every Claude Code session on the Mac runs this module, and
16// all of them share one GraphQL limit (5,000 an hour, across Garrett's tokens),
17// so the GitHub part of a refresh is shared: one session reads GitHub at a
18// time (the lease), not more often than the cadence, and every session stops
19// for a while after GitHub says "rate limit". Local data (jobs, logs, lanes)
20// still updates on every tick.
21const GH_OPEN_MS = 2 * 60_000 // a /board pane is open in some session
22const GH_QUIET_MS = 5 * 60_000 // nobody is looking: only the quiet line reads it
23const VIEWER_MS = 3 * 60_000 // a pane counts as open this long after its last tick
24const LEASE_MS = 2 * 60_000 // how long one session may hold the GitHub read
25const PAUSE_MS = 10 * 60_000 // the stop after a rate limit
26const ME = Math.random().toString(36).slice(2)
27// The CI snapshot's store key names its shape, so a session never reads a
28// snapshot an older version of the board wrote.
29const CI_SNAPSHOT = 'ci-snapshot-2'
30const APPROVALS_TO_SUGGEST = 10
31
32let home = ''
33// Where projects.md, lanes.md and limits/ live: the plugin's kit_dir setting.
34let kitSetting = '~/code/g-cauc'
35let kitDir = ''
36let projects = []
37let jobs = []
38let recent = []
39let lanes = []
40let claudeLimits = []
41let refreshedAt = 0
42// CI across projects: { at, projects: [{ name, repo, key, vm, ci, error }], billing }. vm is the
43// state of the project's own runner VM, or '' when the pool does not serve the repo.
44let ci = { at: 0, projects: [], billing: null }
45let ghLogin = null
46let billingAt = 0
47let lastError = ''
48// Set when a command's output says GitHub's rate limit is spent; read after a gather.
49let rateLimited = false
50let pausedUntil = 0
51// Open PRs per repo from this gather's project read, so the CI read reuses them.
52const openPrsByRepo = new Map()
53let busy = false
54let again = false
55
56let paneOpen = false
57let tab = 'all'
58let selected = null
59let detail = null
60let showThinking = false
61
62// Tool calls that went to a permission prompt, by tool_use_id, until they run or expire.
63let asks = new Map()
64let allow = {}
65
66export function register(on, options) {
67 if (options && typeof options.kit_dir === 'string' && options.kit_dir.trim()) kitSetting = options.kit_dir.trim()
68 on('session.start', async ($, e, next) => {
69 const result = await next(e)
70 await $.command.register({ name: 'board', description: 'g-cauc: each project\'s plan, agents and CI; the shared lanes and pool; allow-list suggestions', argumentHint: '[refresh]', immediate: true })
71 home = (await $.env.get('HOME')) || ''
72 kitDir = kitSetting.replace(/^~(?=\/|$)/, home).replace(/\/$/, '')
73 $.clock.after(1500, () => refresh($, false))
74 $.clock.every(60_000, () => refresh($, false))
75 $.clock.every(4_000, () => followJob($))
76 return result
77 })
78
79 on('command.run', { command: 'board' }, async ($, e) => {
80 if (String(e.args || '').trim() === 'refresh') {
81 await refresh($, true)
82 return { text: 'g-cauc: refreshed' }
83 }
84 paneOpen = true
85 await $.ui.open({ id: PANE, title: 'g-cauc', focus: true, closeOnEscape: true })
86 // Opening the board shows the shared snapshot at once and reads GitHub only when it is due.
87 refresh($, false)
88 return {}
89 })
90
91 on('ui.close', async ($, e, next) => {
92 if (e.id === PANE) paneOpen = false
93 return next(e)
94 })
95
96 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
97 const line = bandText(projects, jobs)
98 const cib = ciBand(ci.projects)
99 const survey = (e.props && e.props.hasSurvey) || e.hasSurvey
100 if ((!line && !cib) || survey) return next(e)
101 const E = $.ui.resolve(e)
102 const theirs = await next(e)
103 const parts = [E.Text({ dimColor: true, children: [(line || 'g-cauc') + (cib ? ' · ' + cib.text : '')] })]
104 if (cib && cib.alert) parts.push(colored(E, e.surface, 'bad', ' · ' + cib.alert, { bold: true }))
105 parts.push(E.Text({ dimColor: true, children: [' /board'] }))
106 const mine = E.Box({ flexDirection: 'row', children: parts })
107 return E.Box({ flexDirection: 'column', children: theirs ? [theirs, mine] : [mine] })
108 })
109
110 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
111 if (e.requestId !== PANE) return next(e)
112 const E = $.ui.resolve(e)
113 const width = (e.props && e.props.bodyColumns) || 80
114 return boardView($, E, width, e.surface)
115 })
116
117 // Permission advisor: a dialog you answered, then the same call running,
118 // counts as one approval of each rule Claude Code suggested for it.
119 // The order for one call: tool.check says ask, the dialog opens
120 // (PermissionRequest), and only an approved call runs (PostToolUse, same id).
121 on('tool.check', async ($, e, next) => {
122 const verdict = await next(e)
123 if (verdict && verdict.decision === 'ask' && e.tool_use_id && next.origin && next.origin.plugin === 'engine') {
124 const now = await $.clock.now()
125 for (const [id, a] of asks) if (now - a.at > 30 * 60_000) asks.delete(id)
126 asks.set(e.tool_use_id, { tool: e.tool, key: callKey(e.tool, e.input), at: now, rules: null, cwd: '' })
127 }
128 return verdict
129 })
130
131 on('classic.PermissionRequest', async ($, e, next) => {
132 const rules = suggestedRules(e.permission_suggestions)
133 if (rules.length) {
134 // The dialog belongs to the open ask for the same call (tool and command
135 // or path). Anything else, including no match, counts nothing.
136 // A dialog opens seconds after its call is checked, so only calls checked in
137 // the last 2 minutes qualify (an ask the auto-mode classifier denied never
138 // gets a dialog and must not swallow a later one); the oldest of those wins,
139 // since dialogs arrive in call order.
140 const key = callKey(e.tool_name, e.tool_input)
141 const now = await $.clock.now()
142 const match = [...asks.values()].filter((a) => a.key === key && !a.rules && now - a.at < 120_000)
143 const ask = match.length ? match[0] : null
144 if (ask) {
145 ask.rules = rules
146 ask.cwd = e.cwd
147 }
148 }
149 return next(e)
150 })
151
152 on('classic.PostToolUse', async ($, e, next) => {
153 const ask = asks.get(e.tool_use_id)
154 if (ask) {
155 asks.delete(e.tool_use_id)
156 if (ask.rules) await countApproval($, ask)
157 }
158 return next(e)
159 })
160}
161
162// ---------- gathering ----------
163
164// stdout of a command that exited 0, or null.
165async function sh($, argv, cwd) {
166 try {
167 const r = await $.process.run(argv, cwd ? { cwd, timeoutMs: 30_000 } : { timeoutMs: 30_000 })
168 if (r.exitCode !== 0 && /API rate limit/i.test(String(r.stderr || '') + String(r.stdout || ''))) rateLimited = true
169 return r.exitCode === 0 ? r.stdout : null
170 } catch {
171 return null
172 }
173}
174
175// stdout whatever the exit code: ps and lsof exit 1 when one of several pids is gone.
176async function shAny($, argv) {
177 try {
178 const r = await $.process.run(argv, { timeoutMs: 30_000 })
179 return r.stdout || ''
180 } catch {
181 return ''
182 }
183}
184
185async function readText($, path) {
186 try {
187 if (!(await $.fs.exists(path))) return ''
188 return String(await $.fs.read(path))
189 } catch {
190 return ''
191 }
192}
193
194async function refresh($, force) {
195 if (busy) {
196 again = again || force
197 return
198 }
199 busy = true
200 let cached = null
201 let holding = false
202 try {
203 const now = await $.clock.now()
204 if (paneOpen) await $.store.set('viewer', { at: now })
205 const viewer = await $.store.get('viewer')
206 const every = viewer && now - viewer.at < VIEWER_MS ? GH_OPEN_MS : GH_QUIET_MS
207 const pause = await $.store.get('gh-pause')
208 pausedUntil = pause && now < pause.until ? pause.until : 0
209 cached = await $.store.get('snapshot')
210 const ciCached = await $.store.get(CI_SNAPSHOT)
211 lanes = parseLanes(await readText($, kitDir + '/lanes.md'))
212 // A forced refresh (the Refresh button) skips the cadence and the lease, never the pause.
213 // 5 seconds of slack, so a read lands on the minute tick it is due on, not the next one.
214 let due = !pausedUntil && (force || !cached || !ciCached || now - Math.min(cached.at, ciCached.at) >= every - 5_000)
215 if (due && !force) {
216 const lease = await $.store.get('gh-lease')
217 if (lease && lease.by !== ME && now - lease.at < LEASE_MS) due = false
218 }
219 if (due) {
220 holding = true
221 await $.store.set('gh-lease', { at: now, by: ME })
222 rateLimited = false
223 openPrsByRepo.clear()
224 projects = await gatherProjects($, cached)
225 await $.store.set('snapshot', { at: now, projects, lanes })
226 ci = await gatherCi($, ciCached, now)
227 await $.store.set(CI_SNAPSHOT, ci)
228 if (rateLimited) {
229 pausedUntil = now + PAUSE_MS
230 await $.store.set('gh-pause', { until: pausedUntil })
231 }
232 refreshedAt = now
233 } else {
234 if (cached) {
235 projects = cached.projects
236 refreshedAt = cached.at
237 }
238 if (ciCached) ci = ciCached
239 }
240 jobs = await gatherJobs($)
241 recent = await gatherRecent($)
242 lanes = await withLaneState($, lanes)
243 allow = await loadAllow($)
244 await toastNewNeeds($)
245 lastError = ''
246 } catch (err) {
247 lastError = String((err && err.message) || err)
248 // Nothing on screen yet (a cold start): show the last snapshot any session saved.
249 if (projects.length === 0 && cached && Array.isArray(cached.projects)) {
250 projects = cached.projects.map((p) => ({ ...p, stale: true }))
251 try {
252 lanes = await withLaneState($, cached.lanes || [])
253 } catch {
254 lanes = []
255 }
256 }
257 } finally {
258 if (holding) {
259 try {
260 await $.store.delete('gh-lease')
261 } catch {
262 // the lease expires by itself
263 }
264 }
265 busy = false
266 $.ui.invalidate('ui.render')
267 if (again) {
268 again = false
269 refresh($, true)
270 }
271 }
272}
273
274// A file as it is on origin/main (the checkout can lag behind), else as it is on disk.
275async function readMain($, path, file) {
276 const shown = await sh($, ['git', '-C', path, 'show', 'origin/main:' + file])
277 return shown !== null ? shown : readText($, path + '/' + file)
278}
279
280async function specFiles($, path) {
281 const listed = await sh($, ['git', '-C', path, 'ls-tree', '--name-only', 'origin/main', 'specs/'])
282 if (listed !== null) return listed.split('\n').map((l) => l.replace(/^specs\//, '').trim()).filter(Boolean)
283 try {
284 return (await $.fs.list(path + '/specs')).map((f) => f.name)
285 } catch {
286 return []
287 }
288}
289
290async function gatherProjects($, cached) {
291 const list = parseProjects(await readText($, kitDir + '/projects.md'), home)
292 if (list.length === 0) throw new Error('could not read any project from ' + kitDir.replace(home, '~') + '/projects.md. The board shows the last good data.')
293 const out = []
294 for (const p of list) {
295 try {
296 const issues = asArray(await sh($, ['gh', 'issue', 'list', '--repo', p.repo, '--state', 'all', '--limit', '300', '--json', 'number,title,state,labels,body,url']))
297 const openPrs = asArray(await sh($, ['gh', 'pr', 'list', '--repo', p.repo, '--state', 'open', '--limit', '50', '--json', 'number,title,labels,url,headRefName,mergeStateStatus,statusCheckRollup']))
298 if (openPrs) openPrsByRepo.set(p.repo, openPrs)
299 const mergedPrs = asArray(await sh($, ['gh', 'pr', 'list', '--repo', p.repo, '--state', 'merged', '--limit', '20', '--json', 'number,title,mergedAt,url']))
300 if (!issues || !openPrs || !mergedPrs) throw new Error('gh could not read ' + p.repo + '. The board shows the last good data.')
301 const roadmap = await readMain($, p.path, 'ROADMAP.md')
302 const agents = await readMain($, p.path, 'AGENTS.md')
303 const specs = []
304 for (const name of await specFiles($, p.path)) {
305 const m = name.match(/^(\d{3})-.*\.md$/)
306 if (!m) continue
307 const text = await readMain($, p.path, 'specs/' + name)
308 const title = ((text.match(/^#\s+(.+)$/m) || [])[1] || name).replace(/^(Spec\s+)?\d{3}\s*[:.]\s*/i, '')
309 specs.push({ num: m[1], file: name, title, text: text.slice(0, 2000) })
310 }
311 const summary = summarizeProject({
312 issues,
313 openPrs,
314 mergedPrs,
315 roadmap,
316 specs,
317 autonomy: ((agents.match(/^Autonomy:\s*(\w+)/m) || [])[1] || 'unknown'),
318 now: Date.now(),
319 })
320 for (const k of ['building', 'inReview', 'ready']) summary[k] = summary[k].map((i) => ({ number: i.number, title: i.title, url: i.url, labels: labelsOf(i), waitingOn: i.waitingOn || [] }))
321 // Titles of open issues and PRs by number (one numbering on GitHub), for job rows.
322 const titles = {}
323 for (const x of [...issues.filter((i) => i.state === 'OPEN'), ...openPrs]) titles[x.number] = sanitize(String(x.title || ''), 200)
324 out.push({ ...p, summary, titles, key: p.path.split('/').pop() })
325 } catch (err) {
326 const before = [...projects, ...((cached && cached.projects) || [])].find((x) => x.repo === p.repo)
327 const fallback = before ? before.summary : summarizeProject({ issues: [], openPrs: [], mergedPrs: [], roadmap: '', specs: [], autonomy: 'unknown' })
328 out.push({ ...p, key: p.path.split('/').pop(), summary: fallback, stale: true, error: String((err && err.message) || err) })
329 }
330 }
331 return out
332}
333
334async function gatherJobs($) {
335 const agents = await sh($, ['claude', 'agents', '--json'])
336 const claudeJobs = agents === null ? [] : parseClaudeAgents(agents, home, Date.now()).map((j) => ({ ...j, lane: 'claude' }))
337 // Without claude agents --json, fall back to Claude workers' own process lines.
338 const found = parseJobs(await shAny($, ['pgrep', '-lf', 'ak:']), { claude: agents === null })
339 if (found.length === 0) return claudeJobs
340 const pids = found.map((j) => j.pid).join(',')
341 const etimes = new Map()
342 for (const line of (await shAny($, ['ps', '-o', 'pid=,etime=', '-p', pids])).split('\n')) {
343 const m = line.trim().match(/^(\d+)\s+(\S+)/)
344 if (m) etimes.set(Number(m[1]), parseEtime(m[2]))
345 }
346 const logs = parseLsof(await shAny($, ['lsof', '-a', '-d', '1', '-Fpn', '-p', pids]))
347 return [...found.map((j) => ({ ...j, lane: laneOf(j.cli, j.model), elapsed: etimes.get(j.pid) || 0, log: logs.get(j.pid) || '' })), ...claudeJobs]
348}
349
350async function gatherRecent($) {
351 const out = []
352 for (const p of projects) {
353 const dir = p.path + '_wt/logs'
354 const names = ((await sh($, ['/bin/ls', '-t', dir])) || '').split('\n').filter(Boolean).slice(0, 8)
355 for (const n of names) out.push({ project: p.key, name: n, path: dir + '/' + n })
356 }
357 return out.slice(0, 12)
358}
359
360async function withLaneState($, base) {
361 const now = Date.now()
362 const result = []
363 for (const l of base) {
364 // The first line is the reset time; the foreman may add a line naming its test job.
365 const until = (await readText($, kitDir + '/limits/' + l.lane)).split('\n')[0].trim()
366 const out = until && Date.parse(until) > now ? until : ''
367 result.push({ ...l, running: jobs.filter((j) => j.lane === l.lane).length, outUntil: out })
368 }
369 try {
370 const u = await $.session.usage()
371 claudeLimits = (u && u.rateLimits) || []
372 } catch {
373 claudeLimits = []
374 }
375 return result
376}
377
378// ---------- CI ----------
379
380const repoOk = (r) => /^[\w.-]+\/[\w.-]+$/.test(r)
381
382// Facts about one finished run that never change: whether GitHub refused it,
383// and the done timings in its log. Cached per run in $.store.
384async function runFacts($, repo, run) {
385 const jobsOfRun = parseRunJobs(await sh($, ['gh', 'api', `repos/${repo}/actions/runs/${run.id}/jobs?per_page=50`]), run) || []
386 const facts = { refused: null, fast: null, full: null }
387 for (const j of jobsOfRun) {
388 if (j.conclusion === 'failure' && j.steps.length === 0) {
389 const msg = refusalMessage(asArray(await sh($, ['gh', 'api', `repos/${repo}/check-runs/${j.id}/annotations`])))
390 if (msg) facts.refused = msg
391 }
392 if (j.name === 'done' && j.steps.length && repoOk(repo)) {
393 // The job's log path is an argument, not part of the script, and a failed gh call exits 3.
394 const lines = await sh($, ['sh', '-c', 'out=$(gh api "$1") || exit 3; printf "%s\\n" "$out" | grep -E "done --(fast|full): " | tail -4; exit 0', 'sh', `repos/${repo}/actions/jobs/${Number(j.id)}/logs`])
395 if (lines === null) facts.retry = true
396 for (const t of doneTimings(lines || '')) facts[t.mode] = t
397 }
398 }
399 return facts
400}
401
402async function gatherCi($, cached, now) {
403 const repoSlots = parseRepoSlots(await readText($, kitDir + '/runner/repos'))
404 const out = []
405 for (const p of projects) {
406 const key = p.path.split('/').pop()
407 const vm = repoSlots.some((x) => x.repo === p.repo) ? ((await sh($, ['limactl', 'list', vmName(p.repo), '--format', '{{.Status}}'])) || '').trim() || 'not created' : ''
408 try {
409 if (!repoOk(p.repo)) throw new Error('not a GitHub repo name: ' + p.repo)
410 const runs = parseRuns(await sh($, ['gh', 'api', `repos/${p.repo}/actions/runs?per_page=40`]))
411 if (!runs) throw new Error('gh could not read the Actions runs of ' + p.repo + '. The board shows the last good data.')
412 const liveJobs = []
413 for (const r of runs.filter((x) => x.status !== 'completed').slice(0, 8)) {
414 liveJobs.push(...(parseRunJobs(await sh($, ['gh', 'api', `repos/${p.repo}/actions/runs/${r.id}/jobs?per_page=50`]), r) || []))
415 }
416 const runners = parseRunners(await sh($, ['gh', 'api', `repos/${p.repo}/actions/runners?per_page=100`])) || []
417 const prs = openPrsByRepo.get(p.repo) || asArray(await sh($, ['gh', 'pr', 'list', '--repo', p.repo, '--state', 'open', '--limit', '50', '--json', 'number,title,labels,url,headRefName,mergeStateStatus,statusCheckRollup'])) || []
418 // GitHub's single merge state hides BEHIND under BLOCKED, so ask git whether the head contains main.
419 const behind = {}
420 for (const pr of prs.slice(0, 30)) {
421 if (!/^[\w./-]+$/.test(String(pr.headRefName || ''))) continue
422 try {
423 const r = await $.process.run(['git', '-C', p.path, 'merge-base', '--is-ancestor', 'origin/main', 'origin/' + pr.headRefName], { timeoutMs: 10_000 })
424 if (r.exitCode === 0) behind[pr.number] = false
425 else if (r.exitCode === 1) behind[pr.number] = true
426 } catch {
427 // unknown: GitHub's merge state decides
428 }
429 }
430 const facts = {}
431 let fetched = 0
432 const keep = new Set()
433 for (const r of runs.filter((x) => x.status === 'completed').slice(0, 20)) {
434 const k = 'ci-run\u0000' + p.repo + '\u0000' + r.id
435 keep.add(k)
436 let f = await $.store.get(k)
437 if ((!f || (f.retry && (f.tries || 0) < 3)) && fetched < 4) {
438 fetched += 1
439 const tries = ((f && f.tries) || 0) + 1
440 f = { ...(await runFacts($, p.repo, r)), tries }
441 await $.store.set(k, f)
442 }
443 if (f) facts[r.id] = f
444 }
445 for (const k of await $.store.keys()) if (k.startsWith('ci-run\u0000' + p.repo + '\u0000') && !keep.has(k)) await $.store.delete(k)
446 const budgets = budgetsFromDone(await readMain($, p.path, 'scripts/done'))
447 const slotCount = (repoSlots.find((x) => x.repo === p.repo) || {}).slots ?? null
448 out.push({ name: p.name, repo: p.repo, key, vm, ci: summarizeCi({ runs, jobs: liveJobs, runners, slots: slotCount, prs, facts, budgets, now, behind }) })
449 } catch (err) {
450 const before = [...ci.projects, ...((cached && cached.projects) || [])].find((x) => x.repo === p.repo && x.ci)
451 out.push({ name: p.name, repo: p.repo, key, vm, ci: before ? before.ci : null, stale: true, error: String((err && err.message) || err) })
452 }
453 }
454 return { at: now, projects: out, billing: await billing($, cached) }
455}
456
457// Actions minutes this month, when the gh token has the user scope; checked every 10 minutes.
458async function billing($, cached) {
459 if (Date.now() - billingAt < 10 * 60_000) return (cached && cached.billing) || ci.billing
460 billingAt = Date.now()
461 if (!ghLogin) ghLogin = ((await sh($, ['gh', 'api', 'user', '--jq', '.login'])) || '').trim() || null
462 if (!ghLogin) return { error: 'gh is not signed in' }
463 const raw = await sh($, ['gh', 'api', `users/${ghLogin}/settings/billing/actions`])
464 try {
465 const b = JSON.parse(raw || '')
466 return { used: b.total_minutes_used ?? null, included: b.included_minutes ?? null, paid: b.total_paid_minutes_used ?? null }
467 } catch {
468 return { error: 'billing needs the gh "user" scope (gh auth refresh -s user)' }
469 }
470}
471
472async function toastNewNeeds($) {
473 const seen = await $.store.get('needsSeen')
474 const ok = projects.filter((p) => !p.stale)
475 const kept = Array.isArray(seen) ? seen.filter((k) => projects.some((p) => p.stale && k.startsWith(p.repo + '#'))) : []
476 const now = [...kept, ...ok.flatMap((p) => p.summary.needs.map((n) => `${p.repo}#${n.number}`))]
477 if (Array.isArray(seen)) {
478 const fresh = ok.flatMap((p) => p.summary.needs.filter((n) => !seen.includes(`${p.repo}#${n.number}`)).map((n) => `${p.name} #${n.number}`))
479 if (fresh.length) $.ui.toast('g-cauc: ' + fresh.join(', ') + (fresh.length === 1 ? ' needs you' : ' need you') + ' (/board)')
480 }
481 await $.store.set('needsSeen', now)
482}
483
484async function followJob($) {
485 if (!paneOpen || tab !== 'job' || !selected) return
486 const text = (await sh($, ['tail', '-c', '400000', selected.path])) || ''
487 detail = parseLog(text)
488 $.ui.invalidate('ui.render')
489}
490
491// ---------- permission advisor ----------
492
493// The working tree the session is in, so a rule takes effect where the prompts happen.
494async function treeRoot($, cwd) {
495 const top = ((await sh($, ['git', 'rev-parse', '--show-toplevel'], cwd)) || '').trim()
496 return top || cwd
497}
498
499// One store key per rule, read right before each write, so sessions racing
500// on different rules never overwrite each other.
501const allowKey = (root, rule) => 'allow\u0000' + root + '\u0000' + rule
502
503async function loadAllow($) {
504 const all = {}
505 for (const k of await $.store.keys()) {
506 if (!k.startsWith('allow\u0000')) continue
507 const [, root, rule] = k.split('\u0000')
508 all[root] = all[root] || {}
509 all[root][rule] = await $.store.get(k)
510 }
511 return all
512}
513
514async function countApproval($, ask) {
515 const root = await treeRoot($, ask.cwd)
516 for (const rule of ask.rules) {
517 const key = allowKey(root, rule)
518 const r = (await $.store.get(key)) || { count: 0, state: 'counting' }
519 r.count += 1
520 if (r.state === 'counting' && r.count >= APPROVALS_TO_SUGGEST) {
521 r.state = 'suggested'
522 $.ui.toast(`g-cauc: you approved ${rule} ${r.count} times. To stop the prompts for it, open /board → Allow.`)
523 }
524 await $.store.set(key, r)
525 }
526 allow = await loadAllow($)
527 $.ui.invalidate('ui.render')
528}
529
530async function decideRule($, root, rule, verdict) {
531 if (verdict === 'apply') {
532 const path = root + '/.claude/settings.json'
533 let current = {}
534 if (await $.fs.exists(path)) {
535 try {
536 current = JSON.parse(String(await $.fs.read(path)))
537 } catch {
538 $.ui.toast('g-cauc: .claude/settings.json is not valid JSON. The board did not change it.')
539 return
540 }
541 }
542 const next = withAllowed(current, [rule])
543 if (!next) {
544 $.ui.toast('g-cauc: .claude/settings.json has a permissions shape that the board does not edit. The board did not change it.')
545 return
546 }
547 await $.fs.write(path, JSON.stringify(next, null, 2) + '\n')
548 $.ui.toast(`g-cauc: added ${rule} to ${path.replace(home, '~')}. Commit it with this branch so every worktree gets it.`)
549 }
550 const key = allowKey(root, rule)
551 const r = (await $.store.get(key)) || { count: 0 }
552 await $.store.set(key, { ...r, state: verdict === 'apply' ? 'applied' : 'dismissed' })
553 allow = await loadAllow($)
554 $.ui.invalidate('ui.render')
555}
556
557// ---------- drawing ----------
558//
559// Pages: All (a card per project), one page per project (its plan, the agents
560// on it and its CI), Fleet (what the projects share: lanes, running jobs, the
561// Claude plan and the runner pool), Allow and Job. Each project is drawn in its
562// own color from projects.md; status colors mean only state (BRAND.md).
563
564function suggestions() {
565 const out = []
566 for (const [root, rules] of Object.entries(allow || {})) {
567 for (const [rule, r] of Object.entries(rules)) if (r.state === 'suggested') out.push({ root, rule, count: r.count })
568 }
569 return out
570}
571
572const projectTab = (p) => 'p-' + p.key
573const ciOf = (key) => ci.projects.find((c) => c.key === key) || null
574const projectOf = (key) => projects.find((p) => p.key === key) || null
575
576// A project's color, or the muted tone for a project the board does not list.
577function colorOf(key) {
578 const i = projects.findIndex((p) => p.key === key)
579 return i < 0 ? 'muted' : projectColor(projects[i], i)
580}
581
582function projectBadge(E, surface, key, size) {
583 const p = projectOf(key)
584 return badge(E, surface, monogram(p ? p.name : key), colorOf(key), size)
585}
586
587// What in a project wants Garrett: questions and specs to merge, and CI that cannot run.
588function attention(p) {
589 const c = ciOf(p.key)
590 const ciAlert = c && c.ci ? (c.ci.stuck ? `${c.ci.stuck} stuck` : c.ci.refusal ? 'CI refused' : '') : ''
591 return { needs: p.summary.needs.length, ci: ciAlert }
592}
593
594function boardView($, E, width, surface) {
595 const { Box, Text, Button } = E
596 const redraw = () => $.ui.invalidate('ui.render')
597 const sugg = suggestions()
598 if (tab.startsWith('p-') && !projects.some((p) => projectTab(p) === tab)) tab = 'all'
599 if ((tab === 'allow' && !sugg.length) || (tab === 'job' && !selected)) tab = 'all'
600 const navButton = (id, label, hotkey) => Button({ key: 'tab-' + id, label, ...(hotkey ? { hotkey } : {}), plain: true, dimColor: tab !== id, onPress: () => { tab = id; redraw() } })
601 const nav = [navButton('all', 'All', '0')]
602 projects.forEach((p, i) => {
603 const a = attention(p)
604 nav.push(row(E, [projectBadge(E, surface, p.key, 16), navButton(projectTab(p), p.name, i < 9 ? String(i + 1) : ''), ...(a.needs || a.ci ? [colored(E, surface, 'bad', '●')] : [])]))
605 })
606 nav.push(navButton('fleet', `Fleet ${jobs.length}`, 'f'))
607 if (sugg.length) nav.push(navButton('allow', `Allow ${sugg.length}`, 'a'))
608 if (selected) nav.push(navButton('job', 'Job', 'j'))
609 const age = refreshedAt ? fmtDuration((Date.now() - refreshedAt) / 1000) + ' ago' : 'loading'
610 const status = Box({
611 flexDirection: 'row',
612 columnGap: 2,
613 children: [
614 Text({ dimColor: true, children: [busy ? 'refreshing…' : 'updated ' + age] }),
615 Button({ key: 'refresh', label: 'Refresh', hotkey: 'r', plain: true, dimColor: true, onPress: () => { refresh($, true) } }),
616 ...(pausedUntil > Date.now() ? [colored(E, surface, 'warn', `GitHub rate limit: paused for ${Math.ceil((pausedUntil - Date.now()) / 60_000)} more min`)] : []),
617 ...(lastError ? [colored(E, surface, 'bad', lastError.slice(0, 80))] : []),
618 ],
619 })
620 let body
621 if (tab === 'fleet') body = fleetView($, E, width, surface)
622 else if (tab === 'allow') body = allowView($, E, sugg)
623 else if (tab === 'job') body = jobView($, E, width, surface)
624 else if (tab.startsWith('p-')) body = projectView($, E, projects.find((p) => projectTab(p) === tab), width, surface)
625 else body = overviewView($, E, width, surface)
626 return Box({ flexDirection: 'column', rowGap: 1, children: [Box({ flexDirection: 'row', columnGap: 3, children: nav }), status, Box({ flexDirection: 'column', rowGap: 1, children: body })] })
627}
628
629// Cut text to n columns, so a row stays one line in a narrow pane.
630function fit(text, n) {
631 const t = sanitize(String(text || ''), 2000).replace(/\n/g, ' ')
632 return t.length > n ? t.slice(0, Math.max(1, n - 1)) + '…' : t
633}
634
635function linkOrText(E, url, label, surface) {
636 const { Link, Text } = E
637 label = sanitize(label, 300).replace(/\n/g, ' ')
638 return surface === 'desktop' && /^https?:\/\//.test(String(url || '')) ? Link({ href: url, label }) : Text({ wrap: 'truncate-end', children: [label] })
639}
640
641// A row's main text: one line, cut at the end when the pane is narrow.
642function line(E, text, extra = {}) {
643 return E.Text({ ...extra, wrap: 'truncate-end', children: [sanitize(text, 2000)] })
644}
645
646// A titled group of rows. The title is short, uppercase and dim (BRAND.md).
647function section(E, title, children) {
648 const { Box, Text } = E
649 return Box({ flexDirection: 'column', children: [Text({ bold: true, dimColor: true, children: [title.toUpperCase()] }), ...children] })
650}
651
652function row(E, children) {
653 return E.Box({ flexDirection: 'row', columnGap: 1, alignItems: 'center', children })
654}
655
656const roleWord = (role) => (/review/.test(role) ? 'review' : /fix/.test(role) ? 'fix' : 'build')
657const roleLabel = (j) => roleWord(j.role) + (j.round ? ' r' + j.round : '')
658
659// The stages of a project's work, left to right.
660function stagesOf(s) {
661 return [
662 { label: 'ideas', n: s.ideas || 0 },
663 { label: 'ready', n: s.ready.filter((i) => !i.waitingOn.length).length },
664 { label: 'building', n: s.building.length },
665 { label: 'open PRs', n: Math.max(s.openPrs || 0, s.inReview.length) },
666 { label: 'merged 24h', n: s.mergedToday || 0 },
667 ]
668}
669
670function phaseBlock(E, s, hex, surface, size, width) {
671 const { Box, Text } = E
672 if (!s.phase) return Text({ dimColor: true, children: ['No current phase in ROADMAP.md'] })
673 return row(E, [
674 ring(E, surface, s.phase.done, s.phase.total, hex, size),
675 Box({ flexDirection: 'column', children: [Text({ bold: true, children: [fit(`Phase ${s.phase.n} · ${s.phase.title}`, Math.max(20, width))] }), Text({ dimColor: true, children: [`${s.phase.done} of ${s.phase.total} rows done`] })] }),
676 ])
677}
678
679// Busy slots filled in the project's color, idle ones outlined, offline ones empty.
680function poolCells(E, surface, c, hex, pad = 0) {
681 return cells(E, surface, c.pool.slots.map((st) => (st === 'busy' ? { fill: hex } : st === 'idle' ? { outline: hex } : {})), `${c.pool.busy} of ${c.pool.slots.length} slots busy`, pad)
682}
683
684// Running agents by model, as "grok 4.7 ×2, composer 2.5".
685function modelsOf(list) {
686 const counts = new Map()
687 for (const j of list) {
688 const m = modelName(j.model) || j.lane
689 counts.set(m, (counts.get(m) || 0) + 1)
690 }
691 return [...counts].map(([m, n]) => (n > 1 ? `${m} ×${n}` : m)).join(', ')
692}
693
694function overviewView($, E, width, surface) {
695 const { Text, Box } = E
696 if (projects.length === 0) return [Text({ dimColor: true, children: [refreshedAt ? 'No projects in ' + kitDir.replace(home, '~') + '/projects.md.' : 'Reading GitHub…'] })]
697 return projects.map((p) => projectCard($, E, p, width - 2, surface))
698}
699
700function projectCard($, E, p, inner, surface) {
701 const { Box, Text, Button } = E
702 const s = p.summary
703 const hex = colorOf(p.key)
704 const mine = jobs.filter((j) => j.project === p.key)
705 const c = ciOf(p.key)
706 const a = attention(p)
707 const children = [
708 row(E, [projectBadge(E, surface, p.key, 18), Text({ bold: true, children: [p.name] }), Text({ dimColor: true, children: [s.autonomy] }), ...(a.needs ? [pill(E, surface, `${a.needs} for you`, 'bad')] : [])]),
709 phaseBlock(E, s, hex, surface, 34, inner - 8),
710 pipeline(E, surface, stagesOf(s), hex),
711 row(E, [Text({ dimColor: true, children: ['Agents'] }), line(E, fit(mine.length ? modelsOf(mine) : 'none running', Math.max(10, inner - 8)))]),
712 ]
713 if (c && c.ci) {
714 children.push(row(E, [
715 Text({ dimColor: true, children: ['CI '] }),
716 ...(c.ci.pool.slots.length ? [poolCells(E, surface, c.ci, hex)] : []),
717 ...(c.ci.recent.length ? [dots(E, surface, c.ci.recent.slice(0, 10).map((r) => r.conclusion).reverse())] : [Text({ dimColor: true, children: ['no runs yet'] })]),
718 ...(a.ci ? [pill(E, surface, a.ci, 'bad')] : []),
719 ]))
720 }
721 children.push(Button({ key: 'open-' + p.key, label: `Open ${p.name} ›`, plain: true, dimColor: true, onPress: () => { tab = projectTab(p); $.ui.invalidate('ui.render') } }))
722 if (p.error) children.push(colored(E, surface, 'bad', fit(p.error, Math.max(20, inner))))
723 return Box({ key: 'card-' + p.key, flexDirection: 'column', children })
724}
725
726// An issue or PR title by number, from what the board already read.
727function titleOf(p, n) {
728 if (p.titles && p.titles[n]) return p.titles[n]
729 const s = p.summary
730 const i = [...s.building, ...s.inReview, ...s.ready].find((x) => x.number === n)
731 if (i) return i.title
732 const c = ciOf(p.key)
733 const pr = c && c.ci ? c.ci.prs.find((x) => x.number === n) : null
734 return pr ? pr.title : ''
735}
736
737function projectView($, E, p, width, surface) {
738 const { Text, Box, Button } = E
739 const s = p.summary
740 const hex = colorOf(p.key)
741 const out = []
742 out.push(row(E, [projectBadge(E, surface, p.key, 22), Text({ bold: true, children: [p.name] }), Text({ dimColor: true, children: [s.autonomy] }), ...(p.stale ? [pill(E, surface, 'last good data', 'warn')] : [])]))
743 const plan = [phaseBlock(E, s, hex, surface, 44, width - 12)]
744 if (s.phase && s.phase.exit) plan.push(Text({ dimColor: true, children: [fit('Exit: ' + s.phase.exit, Math.max(40, width - 2))] }))
745 for (const sp of s.specs) plan.push(row(E, [progress(E, surface, sp.done, sp.total, { width: 150, cells: 10, color: hex }), Text({ children: [`${sp.done}/${sp.total}`] }), line(E, fit(`spec ${sp.num} ${sp.title}`, Math.max(20, width - 30)), { dimColor: true })]))
746 out.push(Box({ flexDirection: 'column', children: plan }))
747 out.push(pipeline(E, surface, stagesOf(s), hex))
748 if (s.needs.length) out.push(section(E, 'Needs you', s.needs.map((n) => row(E, [pill(E, surface, n.kind === 'spec' ? 'spec to merge' : 'question', 'bad'), linkOrText(E, n.url, fit(`#${n.number} ${n.title}`, Math.max(20, width - 20)), surface)]))))
749 const mine = jobs.filter((j) => j.project === p.key)
750 const working = mine.map((j) => row(E, [
751 Button({ key: `row-${p.key}-${j.issue}`, plain: true, label: fit(`#${j.issue} ${titleOf(p, j.issue)}`.trim(), Math.max(20, width - 36)) + ' ›', onPress: () => openJob($, j) }),
752 pill(E, surface, roleLabel(j), 'muted'),
753 Text({ dimColor: true, children: [`${modelName(j.model) || j.lane} · ${fmtDuration(j.elapsed)}`] }),
754 ]))
755 for (const i of [...s.building, ...s.inReview]) {
756 if (mine.some((j) => j.issue === i.number)) continue
757 working.push(row(E, [line(E, fit(`#${i.number} ${i.title}`, Math.max(20, width - 24)), { dimColor: true }), Text({ dimColor: true, children: [i.labels.includes('in-review') ? 'in review, no agent' : 'building, no agent'] })]))
758 }
759 out.push(section(E, 'Working now', working.length ? working : [Text({ dimColor: true, children: [`No agent is working on ${p.name}.`] })]))
760 const next = s.ready.slice(0, 6)
761 if (next.length) out.push(section(E, 'Next', next.map((i) => row(E, [line(E, fit(`#${i.number} ${i.title}`, Math.max(20, width - 24))), i.waitingOn.length ? colored(E, surface, 'muted', `waits on #${i.waitingOn.join(', #')}`) : pill(E, surface, 'ready', 'good')]))))
762 out.push(section(E, 'CI', ciRows(E, ciOf(p.key), hex, width, surface)))
763 if (s.merged.length) out.push(section(E, 'Merged', s.merged.map((m) => row(E, [colored(E, surface, 'good', '✓'), Text({ dimColor: true, children: [fit(`#${m.number} ${m.title}`, width - 4)] })]))))
764 const logs = recent.filter((r) => r.project === p.key).slice(0, 4)
765 if (logs.length) out.push(section(E, 'Logs', logs.map((r) => Button({ key: 'log-' + r.path, plain: true, dimColor: true, label: fit(r.name, Math.max(40, width - 4)), onPress: () => openJob($, { path: r.path, project: p.key, title: `${p.name} ${r.name}` }) }))))
766 if (s.ideas) out.push(Text({ dimColor: true, children: [`${s.ideas} idea${s.ideas === 1 ? '' : 's'} waiting for the product pass`] }))
767 if (p.error) out.push(colored(E, surface, 'bad', p.error.slice(0, 100)))
768 return out
769}
770
771const checkTone = (state) => (state === 'pass' ? 'good' : state === 'fail' ? 'bad' : state === 'skip' ? 'muted' : 'warn')
772const checkMark = (state) => (state === 'pass' ? '✓' : state === 'fail' ? '✗' : state === 'skip' ? '-' : '…')
773
774// One project's CI: its slots, the jobs running and waiting, recent results
775// and timings, and its open PRs' checks (only the ones not yet passing).
776function ciRows(E, p, hex, width, surface) {
777 const { Text } = E
778 if (!p) return [Text({ dimColor: true, children: [refreshedAt ? 'No CI data yet.' : 'Reading GitHub…'] })]
779 if (!p.ci) return [colored(E, surface, 'bad', p.error || 'No CI data.')]
780 const c = p.ci
781 const rows = []
782 if (c.pool.slots.length) rows.push(row(E, [Text({ children: ['Pool'] }), poolCells(E, surface, c, hex), Text({ dimColor: true, children: [`${c.pool.busy} busy · ${c.pool.idle} idle${c.pool.offline ? ` · ${c.pool.offline} offline` : ''}`] })]))
783 if (c.refusal) rows.push(row(E, [pill(E, surface, 'refused', 'bad'), colored(E, surface, 'bad', `${c.refusal.message} (${c.refusal.runs} run${c.refusal.runs === 1 ? '' : 's'})`)]))
784 for (const j of c.running) {
785 rows.push(row(E, [
786 line(E, fit(`${j.prs.length ? 'PR ' + j.prs[0] : j.branch} · ${j.name}`, Math.max(20, width - 40))),
787 pill(E, surface, j.slot ? 'slot ' + j.slot : 'GitHub-hosted', 'muted'),
788 Text({ dimColor: true, children: [`${fmtDuration(j.elapsed || 0)}${j.step ? ' · ' + fit(j.step, 28) : ''}`] }),
789 ]))
790 }
791 for (const j of c.queued) {
792 rows.push(row(E, [line(E, fit(`${j.prs.length ? 'PR ' + j.prs[0] : j.branch} · ${j.name}`, Math.max(20, width - 30))), pill(E, surface, (j.stuck ? 'stuck ' : j.status === 'queued' ? 'queued ' : j.status + ' ') + fmtDuration(j.wait), j.stuck ? 'bad' : j.status === 'queued' ? 'warn' : 'muted')]))
793 if (j.stuck) rows.push(Text({ dimColor: true, children: [fit(` wants ${j.labels.join(', ') || 'no labels'} · pool has ${c.pool.labels.join(', ') || 'none'}`, width - 2)] }))
794 }
795 if (c.recent.length) {
796 const passed = c.recent.filter((r) => r.conclusion === 'success').length
797 rows.push(row(E, [dots(E, surface, c.recent.map((r) => r.conclusion).reverse()), Text({ dimColor: true, children: [`${passed} of ${c.recent.length} passed`] })]))
798 for (const mode of ['fast', 'full']) {
799 const list = c.timings[mode]
800 if (!list.length) continue
801 const last = list[0]
802 const budget = c.timings.budgets[mode]
803 const avg = Math.round(list.slice(0, 5).reduce((a, b) => a + b, 0) / Math.min(list.length, 5))
804 rows.push(row(E, [
805 progress(E, surface, budget ? last : 0, budget || 1, { width: 150, cells: 10, color: budgetTone(last, budget) }),
806 Text({ children: [budget ? `${last}s of ${budget}s` : `${last}s`] }),
807 Text({ dimColor: true, children: [`done --${mode} · avg ${avg}s`] }),
808 ]))
809 }
810 }
811 for (const pr of c.prs.slice(0, 8)) {
812 const states = Object.entries(pr.checks)
813 const open = states.filter(([, state]) => state !== 'pass')
814 rows.push(row(E, [
815 linkOrText(E, pr.url, fit(`#${pr.number} ${pr.title}`, Math.max(16, width - 52)), surface),
816 ...(states.length && !open.length ? [pill(E, surface, '✓ checks', 'good')] : open.map(([name, state]) => pill(E, surface, `${checkMark(state)} ${name}`, checkTone(state)))),
817 ...(pr.behind ? [pill(E, surface, 'behind main', 'warn')] : []),
818 ]))
819 }
820 if (!rows.length) rows.push(Text({ dimColor: true, children: ['Quiet: nothing running, no open PRs.'] }))
821 if (p.error) rows.push(colored(E, surface, 'bad', p.error.slice(0, 100)))
822 return rows
823}
824
825// What the projects share: the lanes (each cell a job, in its project's color),
826// the running jobs, the Claude plan, and the runner pool.
827function fleetView($, E, width, surface) {
828 const { Text, Button } = E
829 const out = []
830 const rank = (key) => {
831 const i = projects.findIndex((p) => p.key === key)
832 return i < 0 ? projects.length : i
833 }
834 if (projects.length) out.push(row(E, projects.map((p) => row(E, [projectBadge(E, surface, p.key, 14), Text({ dimColor: true, children: [p.name + ' '] })]))))
835 const laneSpan = Math.max(1, ...lanes.map((l) => Math.max(l.max, jobs.filter((j) => j.lane === l.lane).length)))
836 const laneRows = lanes.map((l) => {
837 const here = jobs.filter((j) => j.lane === l.lane).sort((a, b) => rank(a.project) - rank(b.project))
838 const n = Math.max(l.max, here.length)
839 const status = l.outUntil ? pill(E, surface, 'out until ' + l.outUntil.slice(5, 16).replace('T', ' '), 'bad') : l.max === 0 ? pill(E, surface, 'off', 'muted') : pill(E, surface, 'on', 'good')
840 return row(E, [
841 cells(E, surface, Array.from({ length: n }, (_, k) => (here[k] ? { fill: colorOf(here[k].project) } : {})), `${here.length} of ${l.max} ${l.lane} jobs`, laneSpan),
842 Text({ dimColor: true, children: [`${here.length}/${l.max}`] }),
843 Text({ dimColor: l.max === 0, bold: l.max > 0, children: [l.lane] }),
844 Text({ dimColor: true, children: [l.family] }),
845 status,
846 ])
847 })
848 out.push(section(E, 'Lanes', laneRows.length ? laneRows : [Text({ dimColor: true, children: ['No lanes in lanes.md.'] })]))
849 const running = jobs.map((j) => {
850 const p = projectOf(j.project)
851 return row(E, [
852 projectBadge(E, surface, j.project, 14),
853 Button({ key: 'job-' + j.pid, plain: true, label: `${p ? p.name : j.project} #${j.issue} ›`, onPress: () => openJob($, j) }),
854 pill(E, surface, roleLabel(j), 'muted'),
855 Text({ dimColor: true, children: [`${modelName(j.model) || j.lane} · ${fmtDuration(j.elapsed)}`] }),
856 ])
857 })
858 out.push(section(E, 'Running', running.length ? running : [Text({ dimColor: true, children: ['No g-cauc jobs running.'] })]))
859 if (claudeLimits.length) {
860 out.push(section(E, 'Claude plan', claudeLimits.map((r) => {
861 const pct = Math.round(r.percentUsed)
862 return row(E, [
863 progress(E, surface, pct, 100, { width: 150, cells: 10, color: pct < 70 ? 'good' : pct < 90 ? 'warn' : 'bad' }),
864 Text({ children: [`${pct}%`] }),
865 Text({ dimColor: true, children: [`${r.kind.replace(/_/g, ' ')}${r.resetsAt ? ' · resets ' + String(r.resetsAt).slice(5, 16).replace('T', ' ') : ''}`] }),
866 ])
867 })))
868 }
869 // Each project the pool serves has its own VM: its slots, then the VM's state.
870 const pools = ci.projects.filter((c) => c.vm || (c.ci && c.ci.pool.slots.length))
871 if (pools.length) {
872 const widest = Math.max(1, ...pools.map((c) => (c.ci ? c.ci.pool.slots.length : 0)))
873 const rows = pools.map((c) => row(E, [
874 projectBadge(E, surface, c.key, 14),
875 ...(c.ci ? [poolCells(E, surface, c.ci, colorOf(c.key), widest), Text({ dimColor: true, children: [`${c.ci.pool.busy} busy · ${c.ci.pool.idle} idle${c.ci.pool.offline ? ` · ${c.ci.pool.offline} offline` : ''}`] })] : []),
876 ...(c.vm ? [pill(E, surface, 'VM ' + c.vm.toLowerCase(), /running/i.test(c.vm) ? 'good' : 'bad')] : []),
877 ]))
878 out.push(section(E, 'Runner pool', rows))
879 }
880 const b = ci.billing
881 if (b) out.push(Text({ dimColor: true, children: [b.error ? 'Actions minutes: ' + b.error : `Actions minutes this month: ${b.used ?? '?'}${b.included ? ' of ' + b.included + ' included' : ''}${b.paid ? `, ${b.paid} paid` : ''}`] }))
882 return out
883}
884
885function allowView($, E, sugg) {
886 const { Text, Box, Button } = E
887 const out = [Text({ dimColor: true, children: [`Rules you approved ${APPROVALS_TO_SUGGEST} or more times. Allow writes them to the project's .claude/settings.json.`] })]
888 for (const s of sugg) {
889 out.push(Box({
890 flexDirection: 'row',
891 columnGap: 2,
892 children: [
893 Text({ children: [`${s.rule}`] }),
894 Text({ dimColor: true, children: [`${s.count}x ${s.root.replace(home, '~')}`] }),
895 Button({ key: `apply-${s.root}-${s.rule}`, label: 'Allow', onPress: () => decideRule($, s.root, s.rule, 'apply') }),
896 Button({ key: `dismiss-${s.root}-${s.rule}`, label: 'Dismiss', plain: true, dimColor: true, onPress: () => decideRule($, s.root, s.rule, 'dismiss') }),
897 ],
898 }))
899 }
900 return out
901}
902
903function openJob($, j) {
904 if (!j.path && !j.log) return
905 selected = {
906 project: j.project || '',
907 path: j.path || j.log,
908 title: j.title || `${j.project} #${j.issue} · ${j.role} round ${j.round} · ${modelName(j.model) || j.lane}`,
909 url: projects.find((p) => p.key === j.project) ? `https://github.com/${projects.find((p) => p.key === j.project).repo}/issues/${j.issue}` : '',
910 }
911 detail = null
912 tab = 'job'
913 followJob($)
914 $.ui.invalidate('ui.render')
915}
916
917function jobView($, E, width, surface) {
918 const { Text, Box, Button, Code } = E
919 if (!selected) return [Text({ dimColor: true, children: ['Pick a job on a project page or on Fleet.'] })]
920 const head = Box({
921 flexDirection: 'row',
922 columnGap: 2,
923 children: [
924 ...(selected.project ? [projectBadge(E, surface, selected.project, 16)] : []),
925 Text({ bold: true, children: [selected.title] }),
926 Button({ key: 'thinking', label: showThinking ? 'Hide thinking' : 'Show thinking', hotkey: 't', plain: true, dimColor: true, onPress: () => { showThinking = !showThinking; $.ui.invalidate('ui.render') } }),
927 ...(selected.url ? [linkOrText(E, selected.url, 'issue', surface)] : []),
928 ],
929 })
930 if (!detail) return [head, Text({ dimColor: true, children: ['Reading the log…'] })]
931 if (detail.format === 'empty') return [head, Text({ dimColor: true, children: ['The log is empty so far.'] })]
932 if (detail.format === 'text') return [head, Text({ dimColor: true, children: ['Plain-text log (launch with stream-json or --json for steps and thinking):'] }), Code({ source: sanitize(detail.steps[0].text, 9000) })]
933 const rows = []
934 for (const s of detail.steps.slice(-60)) {
935 if (s.kind === 'thinking') {
936 const lines = s.text.trim().split('\n').length
937 rows.push(showThinking ? Text({ dimColor: true, italic: true, children: [sanitize(s.text.trim(), 2000)] }) : Text({ dimColor: true, children: [`› thinking (${lines} line${lines === 1 ? '' : 's'})`] }))
938 } else if (s.kind === 'cmd') {
939 const cmd = Text({ children: [sanitize(fit(`$ ${s.text}`, Math.max(40, width - 24)), 400)] })
940 rows.push(s.exit === null ? cmd : row(E, [cmd, pill(E, surface, `exit ${s.exit}` + (s.ms ? ` · ${fmtDuration(s.ms / 1000)}` : ''), s.exit === 0 ? 'good' : 'bad')]))
941 } else if (s.kind === 'tool') {
942 rows.push(Text({ dimColor: true, children: [sanitize(`· ${s.text}`, 400)] }))
943 } else if (s.kind === 'say') {
944 rows.push(Text({ children: [sanitize(s.text.trim(), 3000)] }))
945 } else if (s.kind === 'result') {
946 rows.push(row(E, [pill(E, surface, s.text.startsWith('failed') ? 'failed' : 'finished', s.text.startsWith('failed') ? 'bad' : 'good'), Text({ children: [sanitize(s.text.replace(/^(failed|finished):?\s*/, ''), 300) + (s.ms ? ` in ${fmtDuration(s.ms / 1000)}` : '')] })]))
947 }
948 }
949 return [head, ...rows]
950}
951hooks/lib.js 452 lines1// Pure helpers for the g-cauc mod: parsing and summarizing only, no mods API.
2// register.js gathers the raw text (gh output, files, process lists) and these
3// functions turn it into what the board and the band show.
4
5// Rows of the first markdown table whose header row starts with `firstHeader`.
6export function tableRows(md, firstHeader) {
7 const lines = String(md || '').split('\n')
8 const rows = []
9 let inTable = false
10 for (const line of lines) {
11 const t = line.trim()
12 if (!t.startsWith('|')) {
13 if (inTable) break
14 continue
15 }
16 const cells = t.replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => c.trim())
17 if (!inTable) {
18 if (cells[0].toLowerCase() === firstHeader.toLowerCase()) inTable = true
19 continue
20 }
21 if (cells.every((c) => /^:?-+:?$/.test(c))) continue
22 rows.push(cells)
23 }
24 return rows
25}
26
27const strip = (s) => String(s || '').replace(/`/g, '').trim()
28const expandHome = (p, home) => strip(p).replace(/^~(?=\/|$)/, home)
29
30// projects.md: | Priority | Project | Path | Repo | Color | Notes |
31// Color is optional (an older file has Notes in its place): a hex, or null.
32export function parseProjects(md, home) {
33 return tableRows(md, 'Priority')
34 .map((c) => ({ priority: Number(c[0]) || 99, name: strip(c[1]), path: expandHome(c[2], home), repo: strip(c[3]), color: /^#[0-9a-f]{6}$/i.test(strip(c[4])) ? strip(c[4]).toUpperCase() : null }))
35 .filter((p) => p.name && p.path && p.repo)
36 .sort((a, b) => a.priority - b.priority)
37}
38
39// A project's badge: the first letters of its first two words, camel case
40// counting as two words ("CallFlow" CF, "Bison Brain" BB, "g-cauc" gc).
41export function monogram(name) {
42 const words = String(name || '').replace(/([a-z])([A-Z])/g, '$1 $2').split(/[\s_-]+/).filter(Boolean)
43 return (words.length > 1 ? words[0][0] + words[1][0] : (words[0] || '?').slice(0, 2)).slice(0, 2)
44}
45
46// lanes.md: | Lane | Family | Subscription | Weight | Max jobs | Status |
47export function parseLanes(md) {
48 return tableRows(md, 'Lane').map((c) => ({
49 lane: strip(c[0]),
50 family: strip(c[1]),
51 weight: strip(c[3]),
52 max: Number(strip(c[4])) || 0,
53 status: strip(c[5]).split(':')[0].split(';')[0],
54 }))
55}
56
57// ROADMAP.md: "## Phase N: Title" headings, each with an "Exit:" paragraph.
58export function parseRoadmap(md) {
59 const phases = []
60 let cur = null
61 let inExit = false
62 for (const line of String(md || '').split('\n')) {
63 const h = line.match(/^##\s+Phase\s+(\d+)\s*[:.]\s*(.+?)\s*$/i)
64 if (h) {
65 cur = { n: Number(h[1]), title: h[2].replace(/\s*\(.*\)\s*$/, ''), exit: '' }
66 phases.push(cur)
67 inExit = false
68 continue
69 }
70 if (/^##\s/.test(line)) {
71 cur = null
72 continue
73 }
74 if (!cur) continue
75 const ex = line.match(/^Exit:\s*(.*)$/)
76 if (ex) {
77 inExit = true
78 cur.exit = ex[1].trim()
79 continue
80 }
81 if (inExit && !cur.exit && line.trim()) cur.exit = line.replace(/^[-*]\s*/, '').trim()
82 }
83 return phases
84}
85
86// The phase a spec belongs to: a "Phase: N" line near its top, else a phase
87// whose title shares a word run with the spec's file name.
88export function specPhase(specFile, specText, phases) {
89 const head = String(specText || '').split('\n').slice(0, 15).join('\n')
90 const m = head.match(/Phase[:\s]+(\d+)/i)
91 if (m) return Number(m[1])
92 const slug = String(specFile).replace(/^\d+-/, '').replace(/\.md$/, '').replace(/-/g, ' ').toLowerCase()
93 const hit = phases.find((p) => slug && (p.title.toLowerCase().includes(slug) || slug.includes(p.title.toLowerCase())))
94 return hit ? hit.n : null
95}
96
97// gh output as an array, or null when the call failed or answered something else.
98export function asArray(json) {
99 if (typeof json !== 'string' || !json.trim()) return null
100 try {
101 const v = JSON.parse(json)
102 return Array.isArray(v) ? v : null
103 } catch {
104 return null
105 }
106}
107
108// Text from logs, made safe to draw: no ANSI escapes, no control characters
109// other than tab and newline, and no more than `max` characters.
110export function sanitize(text, max = 9000) {
111 const t = String(text || '').replace(/\u001b\[[0-9;?]*[ -\/]*[@-~]/g, '').replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, '')
112 return t.length > max ? '…' + t.slice(-(max - 1)) : t
113}
114
115const exitOf = (v) => (typeof v === 'number' && Number.isInteger(v) ? v : typeof v === 'string' && /^-?\d{1,10}$/.test(v) ? Number(v) : null)
116
117export const labelsOf = (issue) => (issue.labels || []).map((l) => (typeof l === 'string' ? l : l.name))
118
119// "Spec 002 row 7: ..." in the title, or "Spec: specs/002-name.md" in the body.
120export function issueSpec(issue) {
121 const t = String(issue.title || '').match(/Spec\s+(\d{3})\b/i)
122 if (t) return t[1]
123 const b = String(issue.body || '').match(/specs\/(\d{3})[-\w]*\.md/)
124 return b ? b[1] : null
125}
126
127// Issue numbers this issue waits on: "Blocked by #12" lines, or #refs in the
128// build plan row's "Blocked by" column.
129export function blockers(body) {
130 const text = String(body || '')
131 const found = new Set()
132 for (const m of text.matchAll(/Blocked by[^\n#]*((?:#\d+[\s,and]*)+)/gi)) {
133 for (const n of m[1].matchAll(/#(\d+)/g)) found.add(Number(n[1]))
134 }
135 const lines = text.split('\n')
136 const hi = lines.findIndex((l) => /\|\s*Blocked by\s*\|/i.test(l))
137 if (hi >= 0) {
138 const col = lines[hi].split('|').map((c) => c.trim().toLowerCase()).indexOf('blocked by')
139 for (const row of lines.slice(hi + 2)) {
140 if (!row.trim().startsWith('|')) break
141 const cell = row.split('|')[col] || ''
142 for (const n of cell.matchAll(/#(\d+)/g)) found.add(Number(n[1]))
143 }
144 }
145 return [...found]
146}
147
148// Everything the Plan tab and the band need for one project.
149export function summarizeProject({ issues, openPrs, mergedPrs, roadmap, specs, autonomy, now }) {
150 const phases = parseRoadmap(roadmap)
151 const byNumber = new Map(issues.map((i) => [i.number, i]))
152 const isOpen = (n) => {
153 const i = byNumber.get(n)
154 return i ? i.state === 'OPEN' : false
155 }
156 const specOf = new Map()
157 for (const s of specs) specOf.set(s.num, { ...s, phase: specPhase(s.file, s.text, phases) })
158
159 const build = issues.filter((i) => issueSpec(i) && !labelsOf(i).includes('idea') && !labelsOf(i).includes('spec'))
160 const perSpec = new Map()
161 for (const i of build) {
162 const num = issueSpec(i)
163 const p = perSpec.get(num) || { num, done: 0, total: 0 }
164 p.total += 1
165 if (i.state === 'CLOSED') p.done += 1
166 perSpec.set(num, p)
167 }
168 const specRows = [...perSpec.values()]
169 .map((p) => ({ ...p, title: (specOf.get(p.num) || {}).title || 'Spec ' + p.num, phase: (specOf.get(p.num) || {}).phase ?? null }))
170 .sort((a, b) => a.num.localeCompare(b.num))
171
172 const openSpecs = specRows.filter((s) => s.done < s.total)
173 const phaseNums = openSpecs.map((s) => s.phase).filter((n) => n !== null)
174 const currentN = phaseNums.length ? Math.min(...phaseNums) : null
175 const current = currentN !== null ? phases.find((p) => p.n === currentN) || null : null
176 const inPhase = current ? specRows.filter((s) => s.phase === current.n) : openSpecs
177 const phaseDone = inPhase.reduce((a, s) => a + s.done, 0)
178 const phaseTotal = inPhase.reduce((a, s) => a + s.total, 0)
179
180 const open = issues.filter((i) => i.state === 'OPEN')
181 const has = (i, l) => labelsOf(i).includes(l)
182 const ready = open
183 .filter((i) => has(i, 'ready'))
184 .map((i) => ({ ...i, waitingOn: blockers(i.body).filter(isOpen) }))
185 .sort((a, b) => a.waitingOn.length - b.waitingOn.length || a.number - b.number)
186 const needs = [
187 ...open.filter((i) => has(i, 'needs:garrett')).map((i) => ({ kind: 'issue', number: i.number, title: i.title, url: i.url })),
188 ...openPrs.filter((p) => labelsOf(p).includes('agreed')).map((p) => ({ kind: 'spec', number: p.number, title: p.title, url: p.url })),
189 ]
190 return {
191 autonomy,
192 phase: current ? { n: current.n, title: current.title, exit: current.exit, done: phaseDone, total: phaseTotal } : null,
193 specs: openSpecs,
194 building: open.filter((i) => has(i, 'building')),
195 inReview: open.filter((i) => has(i, 'in-review')),
196 ready,
197 needs,
198 ideas: open.filter((i) => has(i, 'idea')).length,
199 // Open PRs other than spec PRs: work in review or CI, however the project labels its issues.
200 openPrs: openPrs.filter((p) => !labelsOf(p).includes('spec')).length,
201 merged: mergedPrs.slice(0, 4),
202 // Merges in the 24 hours before now, from the PRs gh returned.
203 mergedToday: now ? mergedPrs.filter((p) => now - Date.parse(p.mergedAt) < 86_400_000).length : 0,
204 }
205}
206
207// `pgrep -lf ak:` output: keep the agent CLIs, drop the shells that launched them.
208export function parseJobs(out, { claude = false } = {}) {
209 const jobs = []
210 for (const line of String(out || '').split('\n')) {
211 const m = line.match(/^\s*(\d+)\s+(.*)$/)
212 if (!m) continue
213 const cmd = m[2]
214 const argv0 = cmd.split(/\s+/)[0]
215 const base = argv0.split('/').pop()
216 // Claude workers are listed by `claude agents --json` (parseClaudeAgents), so skip them here.
217 if (!['cursor-agent', 'codex', ...(claude ? ['claude'] : [])].includes(base)) continue
218 const mk = cmd.match(/\[ak:([\w.-]+)#(\d+):([\w-]+):(\d+)\]/)
219 if (!mk) continue
220 const model = (cmd.match(/(?:--model|\s-m)\s+(\S+)/) || [])[1] || ''
221 jobs.push({ pid: Number(m[1]), cli: base, model, project: mk[1], issue: Number(mk[2]), role: mk[3], round: Number(mk[4]) })
222 }
223 return jobs
224}
225
226// `claude agents --json`: background sessions the foreman named ak-<project>-<issue>-<role>.
227export function parseClaudeAgents(json, home, now) {
228 let list = []
229 try {
230 list = JSON.parse(json || '[]')
231 } catch {
232 return []
233 }
234 const out = []
235 for (const a of Array.isArray(list) ? list : []) {
236 const m = String(a.name || '').match(/^ak-([\w.]+)-(\d+)-([\w-]+)$/)
237 if (!m || !a.sessionId || !a.cwd) continue
238 out.push({
239 pid: a.pid || 0,
240 cli: 'claude',
241 model: 'opus',
242 project: m[1],
243 issue: Number(m[2]),
244 role: m[3],
245 round: 0,
246 elapsed: a.startedAt ? Math.max(0, (now - a.startedAt) / 1000) : 0,
247 log: `${home}/.claude/projects/${String(a.cwd).replace(/[^A-Za-z0-9]/g, '-')}/${a.sessionId}.jsonl`,
248 status: a.status || '',
249 })
250 }
251 return out
252}
253
254export function laneOf(cli, model) {
255 if (cli === 'claude') return 'claude'
256 if (cli === 'codex') return 'codex'
257 const m = String(model).toLowerCase()
258 if (m.startsWith('composer')) return 'composer'
259 if (m.includes('grok')) return 'grok'
260 if (m.startsWith('gemini')) return 'gemini'
261 return 'cursor'
262}
263
264// Friendly model names for the rows.
265export function modelName(model) {
266 const m = String(model).toLowerCase()
267 if (!m) return ''
268 if (m.startsWith('composer')) return 'Composer ' + (m.match(/composer-([\d.]+)/) || [])[1]
269 const g = m.match(/grok-([\d.]+)-?(\w+)?/)
270 if (g) return 'Grok ' + g[1] + (g[2] && !/fast/.test(g[2]) ? ' ' + g[2] : '')
271 if (m.startsWith('gemini')) return 'Gemini ' + (m.match(/gemini-([\d.]+)/) || [])[1]
272 if (m.startsWith('gpt')) return m.replace(/^gpt-/, 'GPT-').replace(/-(high|xhigh|low|medium)$/, ' $1')
273 if (m.includes('opus')) return 'Opus'
274 if (m.includes('sonnet')) return 'Sonnet'
275 return model
276}
277
278// ps etime "[[dd-]hh:]mm:ss" to seconds.
279export function parseEtime(s) {
280 const t = String(s || '').trim()
281 if (!t) return 0
282 const [d, rest] = t.includes('-') ? t.split('-') : ['0', t]
283 const parts = rest.split(':').map(Number)
284 while (parts.length < 3) parts.unshift(0)
285 return Number(d) * 86400 + parts[0] * 3600 + parts[1] * 60 + parts[2]
286}
287
288export function fmtDuration(sec) {
289 if (sec < 60) return Math.max(0, Math.round(sec)) + 's'
290 if (sec < 3600) return Math.round(sec / 60) + 'm'
291 const h = Math.floor(sec / 3600)
292 return h + 'h' + String(Math.round((sec % 3600) / 60)).padStart(2, '0')
293}
294
295// `lsof -Fpn -a -d 1 -p ...`: where each process sends its standard output.
296export function parseLsof(out) {
297 const map = new Map()
298 let pid = null
299 for (const line of String(out || '').split('\n')) {
300 if (line.startsWith('p')) pid = Number(line.slice(1))
301 else if (line.startsWith('n') && pid !== null) map.set(pid, line.slice(1))
302 }
303 return map
304}
305
306const clip = (s, n) => {
307 const t = String(s || '').replace(/\s+$/, '')
308 return t.length > n ? t.slice(0, n - 1) + '…' : t
309}
310
311// One job log, as a list of steps. Understands Cursor's stream-json, Codex's
312// --json events, and falls back to the plain text tail for anything else.
313export function parseLog(text) {
314 const lines = String(text || '').split('\n').filter((l) => l.trim())
315 const jsonish = lines.filter((l) => l.trim().startsWith('{')).length
316 if (lines.length === 0) return { format: 'empty', steps: [] }
317 if (jsonish < lines.length / 2) {
318 return { format: 'text', steps: [{ kind: 'text', text: sanitize(lines.slice(-120).join('\n'), 9000) }] }
319 }
320 const steps = []
321 let thinking = null
322 const flushThinking = () => {
323 if (thinking && thinking.text.trim()) steps.push(thinking)
324 thinking = null
325 }
326 for (const line of lines) {
327 let ev
328 try {
329 ev = JSON.parse(line)
330 } catch {
331 continue
332 }
333 // Cursor stream-json
334 if (ev.type === 'thinking') {
335 if (ev.subtype === 'delta') {
336 if (!thinking) thinking = { kind: 'thinking', text: '' }
337 thinking.text += ev.text || ''
338 } else flushThinking()
339 continue
340 }
341 if (ev.type === 'assistant' && ev.message) {
342 flushThinking()
343 // Cursor sends text only; a Claude transcript also carries thinking and tool calls.
344 for (const c of ev.message.content || []) {
345 if (c.type === 'thinking' && String(c.thinking || '').trim()) steps.push({ kind: 'thinking', text: c.thinking })
346 else if (c.type === 'text' && String(c.text || '').trim()) steps.push({ kind: 'say', text: c.text })
347 else if (c.type === 'tool_use') {
348 const inp = c.input || {}
349 if (c.name === 'Bash') steps.push({ kind: 'cmd', text: clip(inp.command, 140), exit: null, ms: null, out: '' })
350 else steps.push({ kind: 'tool', text: c.name + (inp.file_path || inp.pattern || inp.description ? ' ' + clip(inp.file_path || inp.pattern || inp.description, 100) : '') })
351 }
352 }
353 continue
354 }
355 if (ev.type === 'tool_call' && ev.subtype === 'completed' && ev.tool_call) {
356 flushThinking()
357 const [name, call] = Object.entries(ev.tool_call)[0] || []
358 const args = (call && call.args) || {}
359 const ok = (call && call.result && call.result.success) || {}
360 if (name === 'shellToolCall') {
361 steps.push({ kind: 'cmd', text: clip(args.command, 140), exit: exitOf(ok.exitCode), ms: exitOf(ok.executionTime), out: clip(ok.stdout || ok.stderr || '', 600) })
362 } else {
363 const target = args.path || args.filePath || args.pattern || args.query || ''
364 steps.push({ kind: 'tool', text: String(name || 'tool').replace(/ToolCall$/, '') + (target ? ' ' + clip(target, 100) : '') })
365 }
366 continue
367 }
368 if (ev.type === 'result') {
369 flushThinking()
370 steps.push({ kind: 'result', text: ev.is_error ? 'failed' : 'finished', ms: exitOf(ev.duration_ms) })
371 continue
372 }
373 // Codex exec --json
374 if (/^item\.(completed|updated)$/.test(ev.type || '') && ev.item) {
375 if (ev.type === 'item.updated') continue
376 flushThinking()
377 const it = ev.item
378 if (it.type === 'reasoning') steps.push({ kind: 'thinking', text: it.text || it.summary || '' })
379 else if (it.type === 'agent_message') steps.push({ kind: 'say', text: it.text || '' })
380 else if (it.type === 'command_execution') steps.push({ kind: 'cmd', text: clip(it.command, 140), exit: exitOf(it.exit_code), ms: null, out: clip(it.aggregated_output || '', 600) })
381 else if (it.type === 'file_change') steps.push({ kind: 'tool', text: 'edit ' + (it.changes || []).map((c) => c.path).join(', ') })
382 else steps.push({ kind: 'tool', text: it.type })
383 continue
384 }
385 if (ev.type === 'turn.completed') {
386 flushThinking()
387 steps.push({ kind: 'result', text: 'finished', ms: null })
388 }
389 if (ev.type === 'turn.failed' || ev.type === 'error') {
390 flushThinking()
391 steps.push({ kind: 'result', text: 'failed: ' + clip((ev.error && ev.error.message) || ev.message || '', 200), ms: null })
392 }
393 }
394 flushThinking()
395 return { format: 'json', steps: steps.map((st) => ({ ...st, text: sanitize(st.text, 9000), out: st.out ? sanitize(st.out, 600) : st.out })) }
396}
397
398export function bar(done, total, width = 10) {
399 if (!total) return '░'.repeat(width)
400 const full = Math.max(0, Math.min(width, Math.round((done / total) * width)))
401 return '█'.repeat(full) + '░'.repeat(width - full)
402}
403
404// The one dim line above the prompt, or null when there is nothing to say.
405export function bandText(projects, jobs) {
406 const running = jobs.length
407 const needs = projects.reduce((a, p) => a + p.summary.needs.length, 0)
408 if (running === 0 && needs === 0) return null
409 const lead = projects.find((p) => p.summary.phase) || projects[0]
410 const parts = ['g-cauc']
411 if (lead && lead.summary.phase) parts.push(`${lead.name} ${lead.summary.phase.done}/${lead.summary.phase.total}`)
412 if (running) parts.push(`${running} running`)
413 if (needs) parts.push(`${needs} need${needs === 1 ? 's' : ''} you`)
414 return parts.join(' · ')
415}
416
417// Permission advisor: the part of a tool call's input that identifies it, so a
418// permission dialog and the call it belongs to compare equal even when one
419// side carries extra fields.
420export function callKey(tool, input) {
421 const i = input && typeof input === 'object' ? input : {}
422 const skip = new Set(['description', 'timeout', 'run_in_background'])
423 const canon = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? Object.keys(v).filter((k) => !skip.has(k)).sort().map((k) => [k, canon(v[k])]) : v)
424 return tool + '\u0000' + JSON.stringify(canon(i))
425}
426
427// Permission advisor: the rule strings in a PermissionRequest's own
428// "don't ask again" suggestions, such as "Bash(gh pr view:*)".
429export function suggestedRules(suggestions) {
430 const out = []
431 for (const s of suggestions || []) {
432 if (!s || s.type !== 'addRules' || s.behavior !== 'allow') continue
433 for (const r of s.rules || []) out.push(r.ruleContent ? `${r.toolName}(${r.ruleContent})` : r.toolName)
434 }
435 return [...new Set(out)]
436}
437
438// Add rules to a settings object's permissions.allow, keeping everything else.
439// Returns null when the file's shape is not one this can safely extend.
440export function withAllowed(settings, rules) {
441 const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
442 if (settings !== undefined && settings !== null && !isObj(settings)) return null
443 const s = settings ? JSON.parse(JSON.stringify(settings)) : {}
444 if (s.permissions !== undefined && !isObj(s.permissions)) return null
445 s.permissions = s.permissions || {}
446 if (s.permissions.allow !== undefined && !Array.isArray(s.permissions.allow)) return null
447 const allow = Array.isArray(s.permissions.allow) ? s.permissions.allow : []
448 for (const r of rules) if (!allow.includes(r)) allow.push(r)
449 s.permissions.allow = allow
450 return s
451}
452hooks/ci.js 248 lines1// Pure helpers for the CI tab: parse what `gh` returns about Actions runs,
2// jobs, runners and pull request checks, and summarize it per project.
3// register.js makes the calls; nothing here touches the mods API.
4
5const ACTIVE = new Set(['queued', 'in_progress', 'waiting', 'requested', 'pending'])
6const secs = (a, b) => (a && b ? Math.max(0, Math.round((Date.parse(b) - Date.parse(a)) / 1000)) : null)
7
8function json(text) {
9 if (typeof text !== 'string' || !text.trim()) return null
10 try {
11 return JSON.parse(text)
12 } catch {
13 return null
14 }
15}
16
17// `gh api repos/R/actions/runs`: the runs, newest first.
18export function parseRuns(text) {
19 const v = json(text)
20 if (!v || !Array.isArray(v.workflow_runs)) return null
21 return v.workflow_runs.map((r) => ({
22 id: r.id,
23 workflow: r.name || '',
24 status: r.status || '',
25 conclusion: r.conclusion || null,
26 branch: r.head_branch || '',
27 title: r.display_title || '',
28 event: r.event || '',
29 createdAt: r.created_at || null,
30 startedAt: r.run_started_at || r.created_at || null,
31 updatedAt: r.updated_at || null,
32 url: r.html_url || '',
33 prs: (r.pull_requests || []).map((p) => p.number),
34 }))
35}
36
37// `gh api repos/R/actions/runs/ID/jobs`: one run's jobs, with their steps.
38export function parseRunJobs(text, run) {
39 const v = json(text)
40 if (!v || !Array.isArray(v.jobs)) return null
41 return v.jobs.map((j) => {
42 const steps = (j.steps || []).map((s) => ({ name: s.name || '', status: s.status || '', conclusion: s.conclusion || null }))
43 const current = steps.find((s) => s.status === 'in_progress')
44 return {
45 id: j.id,
46 runId: run ? run.id : j.run_id,
47 workflow: run ? run.workflow : j.workflow_name || '',
48 branch: run ? run.branch : j.head_branch || '',
49 prs: run ? run.prs : [],
50 name: j.name || '',
51 status: j.status || '',
52 conclusion: j.conclusion || null,
53 createdAt: j.created_at || null,
54 startedAt: j.started_at || null,
55 completedAt: j.completed_at || null,
56 runner: j.runner_name || null,
57 labels: Array.isArray(j.labels) ? j.labels.map(String) : typeof j.labels === 'string' ? [j.labels] : [],
58 steps,
59 step: current ? current.name : null,
60 url: j.html_url || '',
61 }
62 })
63}
64
65// `gh api repos/R/actions/runners`: the pool's registrations for one repo.
66export function parseRunners(text) {
67 const v = json(text)
68 if (!v || !Array.isArray(v.runners)) return null
69 return v.runners.map((r) => ({
70 name: r.name || '',
71 status: r.status || '',
72 busy: Boolean(r.busy),
73 labels: (Array.isArray(r.labels) ? r.labels : []).map((l) => (typeof l === 'string' ? l : l && l.name)).filter(Boolean),
74 slot: Number(((r.name || '').match(/-(\d+)-\d+$/) || [])[1]) || null,
75 }))
76}
77
78// The runner VM of a repo, named as runner/gcauc-runner names it: gcauc-<repo>, lower case,
79// anything but a letter, a digit or a dash made a dash.
80export function vmName(repo) {
81 return 'gcauc-' + String(repo || '').split('/').pop().toLowerCase().replace(/[^a-z0-9-]/g, '-')
82}
83
84// runner/repos: "owner/repo slots [cpus] [memory GiB]" per line, # comments.
85export function parseRepoSlots(text) {
86 return String(text || '')
87 .split('\n')
88 .map((l) => l.replace(/#.*/, '').trim())
89 .filter(Boolean)
90 .map((l) => l.split(/\s+/))
91 .filter((p) => /^[\w.-]+\/[\w.-]+$/.test(p[0]))
92 .map((p) => ({ repo: p[0], slots: Number(p[1]) || 0 }))
93}
94
95// The lines scripts/done prints at the end:
96// "done --fast: green in 127s" or "done --fast: 230s is over the 200s budget".
97export function doneTimings(text) {
98 const out = []
99 for (const line of String(text || '').split('\n')) {
100 const green = line.match(/done --(fast|full): green in (\d+)s/)
101 if (green) {
102 out.push({ mode: green[1], seconds: Number(green[2]), green: true })
103 continue
104 }
105 const over = line.match(/done --(fast|full): (\d+)s is over the (\d+)s budget/)
106 if (over) out.push({ mode: over[1], seconds: Number(over[2]), green: false, budget: Number(over[3]) })
107 }
108 return out
109}
110
111// Budgets from a project's scripts/done defaults, such as ${CALLFLOW_FAST_BUDGET:-200}.
112export function budgetsFromDone(text) {
113 const t = String(text || '')
114 const fast = t.match(/FAST_BUDGET:-(\d+)/)
115 const full = t.match(/FULL_BUDGET:-(\d+)/)
116 return { fast: fast ? Number(fast[1]) : null, full: full ? Number(full[1]) : null }
117}
118
119// A job GitHub never started: it failed with no steps, and an annotation says why.
120export function refusalMessage(annotations) {
121 for (const a of Array.isArray(annotations) ? annotations : []) {
122 const m = String(a.message || '')
123 if (a.annotation_level === 'failure' && /not started|spending limit|payments have failed|billing/i.test(m)) {
124 return /spending limit|payments have failed|billing/i.test(m) ? 'GitHub refused jobs at the spending limit' : m.slice(0, 120)
125 }
126 }
127 return null
128}
129
130// The latest state of each named check on a PR's head commit.
131export function latestChecks(rollup) {
132 const out = {}
133 const done = (c) => !(c.status && c.status !== 'COMPLETED') && !(c.state && /PENDING|EXPECTED/.test(c.state))
134 // Unfinished runs rank above every finished one; finished ones rank by time, then order.
135 const rank = (c, i) => (!done(c) ? Number.MAX_SAFE_INTEGER - 1000 + i : Date.parse(c.completedAt || c.startedAt || '') || i)
136 const seen = {}
137 ;(Array.isArray(rollup) ? rollup : []).forEach((c, i) => {
138 const name = c.name || c.context
139 if (!name) return
140 const when = rank(c, i)
141 if (seen[name] !== undefined && seen[name] > when) return
142 seen[name] = when
143 let state
144 if (c.__typename === 'StatusContext' || c.state) {
145 state = { SUCCESS: 'pass', FAILURE: 'fail', ERROR: 'fail', PENDING: 'running', EXPECTED: 'running' }[c.state] || 'running'
146 } else if (c.status && c.status !== 'COMPLETED') {
147 state = 'running'
148 } else {
149 state = { SUCCESS: 'pass', NEUTRAL: 'pass', SKIPPED: 'skip' }[c.conclusion] || 'fail'
150 }
151 out[name] = state
152 })
153 return out
154}
155
156const KEY_CHECKS = ['done', 'agent-review', 'guard']
157
158// One state per pool slot. A slot can hold more than one registration at a
159// time (each job gets a fresh one-job runner), so a slot is busy when any of
160// its registrations is busy, idle when one is online, and offline otherwise.
161// Slots run from 1 to the configured count, or higher when a runner sits on a
162// higher slot; a runner whose name carries no slot number is a slot of its own.
163export function slotStates(runners, configured) {
164 const bySlot = new Map()
165 const loose = []
166 for (const r of runners) {
167 if (r.slot) bySlot.set(r.slot, [...(bySlot.get(r.slot) || []), r])
168 else loose.push([r])
169 }
170 const total = Math.max(configured || 0, ...bySlot.keys(), 0)
171 const groups = []
172 for (let n = 1; n <= total; n++) groups.push(bySlot.get(n) || [])
173 groups.push(...loose)
174 return groups.map((regs) => (regs.some((r) => r.busy) ? 'busy' : regs.some((r) => r.status === 'online') ? 'idle' : 'offline'))
175}
176
177// Everything the CI tab shows for one project.
178export function summarizeCi({ runs, jobs, runners, slots, prs, facts, budgets, now, behind }) {
179 const pool = (runners || []).filter((r) => r.labels.includes('gcauc'))
180 const states = slotStates(pool, slots ?? null)
181 const idleSlot = states.includes('idle')
182 const poolLabels = [...new Set(pool.flatMap((r) => r.labels))]
183 const live = (jobs || []).filter((j) => ACTIVE.has(j.status))
184 const running = live
185 .filter((j) => j.status === 'in_progress')
186 .map((j) => {
187 const r = pool.find((x) => x.name === j.runner)
188 return { ...j, slot: r ? r.slot : null, hosted: !r, elapsed: secs(j.startedAt, new Date(now).toISOString()) }
189 })
190 const queued = live
191 .filter((j) => j.status !== 'in_progress')
192 .map((j) => {
193 const wait = secs(j.createdAt, new Date(now).toISOString()) || 0
194 return { ...j, wait, stuck: j.status === 'queued' && wait > 120 && idleSlot }
195 })
196 const completed = (runs || []).filter((r) => r.status === 'completed').slice(0, 20)
197 const recent = completed.map((r) => ({ id: r.id, conclusion: r.conclusion, seconds: secs(r.startedAt, r.updatedAt), title: r.title, workflow: r.workflow }))
198 const timings = { fast: [], full: [], budgets: budgets || { fast: null, full: null } }
199 let refusedRuns = 0
200 let refusedMessage = null
201 for (const r of completed) {
202 const f = (facts || {})[r.id]
203 if (!f) continue
204 if (f.fast) timings.fast.push(f.fast.seconds)
205 if (f.full) timings.full.push(f.full.seconds)
206 if (f.refused) {
207 refusedRuns += 1
208 refusedMessage = refusedMessage || f.refused
209 }
210 }
211 const prRows = (prs || []).map((p) => {
212 const checks = latestChecks(p.statusCheckRollup)
213 const shown = {}
214 for (const k of KEY_CHECKS) if (checks[k]) shown[k] = checks[k]
215 for (const [k, v] of Object.entries(checks)) if (v === 'fail' && !shown[k]) shown[k] = v
216 const known = behind ? behind[p.number] : undefined
217 return { number: p.number, title: p.title || '', url: p.url || '', behind: typeof known === 'boolean' ? known : p.mergeStateStatus === 'BEHIND', checks: shown }
218 })
219 return {
220 pool: { configured: slots ?? null, slots: states, busy: states.filter((s) => s === 'busy').length, idle: states.filter((s) => s === 'idle').length, offline: states.filter((s) => s === 'offline').length, labels: poolLabels, runners: pool },
221 running,
222 queued,
223 stuck: queued.filter((j) => j.stuck).length,
224 recent,
225 timings,
226 refusal: refusedRuns ? { runs: refusedRuns, message: refusedMessage } : null,
227 prs: prRows,
228 }
229}
230
231// The CI part of the quiet line, and whether it needs attention.
232export function ciBand(projects) {
233 const all = (projects || []).filter((p) => p.ci)
234 if (all.length === 0) return null
235 const running = all.reduce((a, p) => a + p.ci.running.length, 0)
236 const queued = all.reduce((a, p) => a + p.ci.queued.length, 0)
237 const busy = all.reduce((a, p) => a + p.ci.pool.busy, 0)
238 const total = all.reduce((a, p) => a + p.ci.pool.slots.length, 0)
239 const stuck = all.reduce((a, p) => a + p.ci.stuck, 0)
240 const refused = all.some((p) => p.ci.refusal)
241 if (!running && !queued && !stuck && !refused) return null
242 const parts = [`CI ${running} running`]
243 if (queued) parts.push(`${queued} queued`)
244 if (total) parts.push(`pool ${busy}/${total}`)
245 const alert = stuck ? `${stuck} stuck` : refused ? 'refused by GitHub' : ''
246 return { text: parts.join(' · '), alert }
247}
248hooks/draw.js 193 lines1// Drawing helpers for the board. On the Desktop app they draw small SVGs
2// (badges, rings, pipeline strips, bars, pills, cells, result dots); in a
3// terminal the same calls draw colored text. Every color here comes from
4// BRAND.md: status tones for state, and each project's own color (from
5// projects.md) for which project a thing belongs to. Each helper takes the
6// element table from $.ui.resolve(e) and the surface name; none touches the
7// mods API.
8
9import { sanitize } from './lib.js'
10
11const TONES = {
12 good: { hex: '#22A06B', ink: '#FFFFFF', term: 'green' },
13 warn: { hex: '#E0A21A', ink: '#111111', term: 'yellow' },
14 bad: { hex: '#E5484D', ink: '#FFFFFF', term: 'red' },
15 muted: { hex: '#8A8F98', ink: '#FFFFFF', term: 'gray' },
16}
17// Project colors when projects.md gives none, in priority order.
18const PROJECT_FALLBACK = ['#017272', '#F48327', '#5B5BD6', '#C2185B', '#2E7D32', '#0277BD']
19const TRACK = 'rgba(127,127,127,0.24)'
20const LABEL = TONES.muted.hex
21const FONT = "-apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif"
22
23const esc = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
24const isDesktop = (surface, E) => surface === 'desktop' && Boolean(E.Svg)
25const isHex = (c) => /^#[0-9a-f]{6}$/i.test(String(c || ''))
26
27function luminance(hex) {
28 const lin = (v) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4)
29 const [r, g, b] = [1, 3, 5].map((i) => lin(parseInt(hex.slice(i, i + 2), 16) / 255))
30 return 0.2126 * r + 0.7152 * g + 0.0722 * b
31}
32
33// Text on a filled shape: white or near black, whichever contrasts more.
34export function ink(hex) {
35 const l = luminance(hex)
36 return 1.05 / (l + 0.05) >= (l + 0.05) / 0.05 ? '#FFFFFF' : '#111111'
37}
38
39// The nearest named terminal color to a hex, by hue.
40export function termName(hex) {
41 const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255)
42 const max = Math.max(r, g, b)
43 const d = max - Math.min(r, g, b)
44 if (d < 0.08) return 'gray'
45 const h = (max === r ? ((g - b) / d) % 6 : max === g ? (b - r) / d + 2 : (r - g) / d + 4) * 60
46 const hue = (h + 360) % 360
47 return hue < 15 || hue >= 330 ? 'red' : hue < 70 ? 'yellow' : hue < 165 ? 'green' : hue < 200 ? 'cyan' : hue < 260 ? 'blue' : 'magenta'
48}
49
50// A status tone by name, or a project color given as a hex.
51function tone(name) {
52 if (isHex(name)) return { hex: name.toUpperCase(), ink: ink(name), term: termName(name) }
53 return TONES[name] || TONES.muted
54}
55
56// A project's color: its own from projects.md, else the fallback for its place.
57export function projectColor(p, index) {
58 return p && isHex(p.color) ? p.color.toUpperCase() : PROJECT_FALLBACK[Math.max(0, index) % PROJECT_FALLBACK.length]
59}
60
61function svg(E, w, h, body, alt) {
62 return E.Svg({ source: `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">${body}</svg>`, alt: sanitize(alt, 200), width: w, height: h })
63}
64
65// Text in a tone: a hex on the Desktop app, a named color in a terminal.
66export function colored(E, surface, name, text, extra = {}) {
67 text = sanitize(text, 2000)
68 const t = tone(name)
69 return E.Text({ ...extra, color: surface === 'desktop' ? t.hex : t.term, children: [text] })
70}
71
72// A project's badge: its monogram on a rounded square in its color.
73export function badge(E, surface, label, hex, size = 18) {
74 label = sanitize(label, 4)
75 const t = tone(hex)
76 if (isDesktop(surface, E)) {
77 const w = Math.round(size * 1.25)
78 return svg(E, w, size, `<rect width="${w}" height="${size}" rx="4" fill="${t.hex}"/><text x="${w / 2}" y="${size / 2 + 3.8}" text-anchor="middle" font-family="${esc(FONT)}" font-size="${Math.round(size * 0.58)}" font-weight="600" fill="${t.ink}">${esc(label)}</text>`, label)
79 }
80 return E.Box({ flexShrink: 0, children: [E.Text({ bold: true, color: t.term, children: [label] })] })
81}
82
83// A ring for a fraction done, in a project's color. The count goes beside it, as text.
84export function ring(E, surface, done, total, hex, size = 40) {
85 const frac = total > 0 ? Math.max(0, Math.min(1, done / total)) : 0
86 if (isDesktop(surface, E)) {
87 const r = size / 2 - 3
88 const len = 2 * Math.PI * r
89 const arc = frac > 0 ? `<circle cx="${size / 2}" cy="${size / 2}" r="${r}" fill="none" stroke="${tone(hex).hex}" stroke-width="4" stroke-linecap="round" stroke-dasharray="${(len * frac).toFixed(1)} ${len.toFixed(1)}" transform="rotate(-90 ${size / 2} ${size / 2})"/>` : ''
90 return svg(E, size, size, `<circle cx="${size / 2}" cy="${size / 2}" r="${r}" fill="none" stroke="${TRACK}" stroke-width="4"/>${arc}`, `${done} of ${total} done`)
91 }
92 return E.Text({ color: tone(hex).term, children: [`${Math.round(frac * 100)}%`] })
93}
94
95// Where a project's work sits: stages joined by a line, each node filled in
96// the project's color when it holds work, with its count inside.
97export function pipeline(E, surface, stages, hex) {
98 const t = tone(hex)
99 if (isDesktop(surface, E)) {
100 const step = 66
101 const w = stages.length * step
102 const nodes = stages
103 .map((s, i) => {
104 const x = i * step + step / 2
105 const full = s.n > 0
106 const node = full ? `<circle cx="${x}" cy="11" r="9.5" fill="${t.hex}"/>` : `<circle cx="${x}" cy="11" r="8.75" fill="none" stroke="${TRACK}" stroke-width="1.5"/>`
107 const count = `<text x="${x}" y="14.6" text-anchor="middle" font-family="${esc(FONT)}" font-size="10" font-weight="600" fill="${full ? t.ink : LABEL}">${s.n > 99 ? '99+' : s.n}</text>`
108 const label = `<text x="${x}" y="33" text-anchor="middle" font-family="${esc(FONT)}" font-size="10" font-weight="500" fill="${LABEL}">${esc(s.label)}</text>`
109 return node + count + label
110 })
111 .join('')
112 const line = `<line x1="${step / 2}" y1="11" x2="${w - step / 2}" y2="11" stroke="${TRACK}" stroke-width="2"/>`
113 return svg(E, w, 38, line + nodes, stages.map((s) => `${s.n} ${s.label}`).join(', '))
114 }
115 const parts = []
116 stages.forEach((s, i) => {
117 if (i) parts.push(E.Text({ dimColor: true, children: [' › '] }))
118 parts.push(E.Text({ dimColor: s.n === 0, color: s.n ? t.term : undefined, bold: s.n > 0, children: [`${s.n}`] }))
119 parts.push(E.Text({ dimColor: true, children: [' ' + s.label] }))
120 })
121 return E.Box({ flexDirection: 'row', children: parts })
122}
123
124// A progress bar. Desktop: a rounded SVG bar. Terminal: heavy line characters.
125export function progress(E, surface, done, total, { width = 160, cells = 12, color = 'muted' } = {}) {
126 const frac = total > 0 ? Math.max(0, Math.min(1, done / total)) : 0
127 if (isDesktop(surface, E)) {
128 const w = Math.round(width)
129 const fill = Math.round(w * frac)
130 return svg(E, w, 8, `<rect width="${w}" height="8" rx="4" fill="${TRACK}"/>${fill > 0 ? `<rect width="${Math.max(fill, 8)}" height="8" rx="4" fill="${tone(color).hex}"/>` : ''}`, `${done} of ${total}`)
131 }
132 const full = Math.round(cells * frac)
133 return E.Box({
134 flexDirection: 'row',
135 children: [E.Text({ color: tone(color).term, children: ['━'.repeat(full)] }), E.Text({ dimColor: true, children: ['━'.repeat(cells - full)] })],
136 })
137}
138
139// A status pill. Desktop: a rounded filled label. Terminal: a colored dot and word.
140export function pill(E, surface, label, name = 'muted') {
141 label = sanitize(label, 80).replace(/\n/g, ' ')
142 const t = tone(name)
143 if (isDesktop(surface, E)) {
144 const w = Math.round(String(label).length * 6.3 + 16)
145 return svg(E, w, 18, `<rect width="${w}" height="18" rx="9" fill="${t.hex}"/><text x="${w / 2}" y="12.5" text-anchor="middle" font-family="${esc(FONT)}" font-size="11" font-weight="600" fill="${t.ink}">${esc(label)}</text>`, String(label))
146 }
147 return E.Box({ flexShrink: 0, children: [E.Text({ color: t.term, children: [`● ${label}`] })] })
148}
149
150// A row of square cells, each { fill } (a hex: busy), { outline } (a hex: idle)
151// or {} (empty): pool slots, and lane capacity colored by the project using it.
152// `pad` keeps the strip as wide as `pad` cells, so rows of strips line up.
153export function cells(E, surface, list, alt, pad = 0) {
154 list = list.slice(0, 48)
155 const span = Math.min(48, Math.max(list.length, pad, 1))
156 if (isDesktop(surface, E)) {
157 const w = span * 18
158 const body = list
159 .map((c, i) => {
160 const x = i * 18 + 1
161 if (c.fill) return `<rect x="${x}" y="1" width="14" height="14" rx="4" fill="${tone(c.fill).hex}"/>`
162 if (c.outline) return `<rect x="${x + 0.75}" y="1.75" width="12.5" height="12.5" rx="3.5" fill="none" stroke="${tone(c.outline).hex}" stroke-width="1.5"/>`
163 return `<rect x="${x}" y="1" width="14" height="14" rx="4" fill="${TRACK}"/>`
164 })
165 .join('')
166 return svg(E, w, 16, body, alt || `${list.filter((c) => c.fill).length} of ${list.length} in use`)
167 }
168 return E.Box({
169 flexDirection: 'row',
170 children: [
171 ...list.map((c) => E.Text({ color: c.fill || c.outline ? tone(c.fill || c.outline).term : 'gray', dimColor: !c.fill && !c.outline, children: [c.fill ? '■ ' : '□ '] })),
172 ...(span > list.length ? [E.Text({ children: [' '.repeat(span - list.length)] })] : []),
173 ],
174 })
175}
176
177// Recent results, oldest to newest, as dots colored by conclusion.
178export function dots(E, surface, results) {
179 const toneOf = (c) => (c === 'success' ? 'good' : c === 'failure' || c === 'timed_out' || c === 'startup_failure' ? 'bad' : c === 'cancelled' ? 'muted' : 'warn')
180 if (isDesktop(surface, E)) {
181 const w = Math.max(results.length, 1) * 11
182 return svg(E, w, 12, results.map((c, i) => `<circle cx="${i * 11 + 5}" cy="6" r="4" fill="${tone(toneOf(c)).hex}"/>`).join(''), `${results.filter((c) => c === 'success').length} of ${results.length} recent runs passed`)
183 }
184 return E.Box({ flexDirection: 'row', children: results.map((c) => E.Text({ color: tone(toneOf(c)).term, children: ['●'] })) })
185}
186
187// The tone for a time against its budget: green under 70 percent, amber under 90, red after.
188export function budgetTone(seconds, budget) {
189 if (!budget) return 'muted'
190 const r = seconds / budget
191 return r < 0.7 ? 'good' : r < 0.9 ? 'warn' : 'bad'
192}
193