SLOPSHOPPER

g-cauc

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

newpanebandguardcommandtoast
★ 1v0.6.5no licenseupdated 2026-10-08garrettyarmo/g-cauc
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · g-cauc
│ ┃ g-cauc ✕ › fix the failing auth test and add an audit log call │ ┃ 0: All f: Fleet 0 │ ┃ ⏺ Read(src/auth.ts) │ ┃ refreshing… r: Refresh ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Reading GitHub… ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /board │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · g-cauc
0: All f: Fleet 0 refreshing… r: Refresh Reading GitHub…
README

g-cauc

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.

What is here

PathWhat it is
skills/specThe CTO session. Garrett and Claude plan together: kickoff for a new project or phase, or one feature. Ends in a spec PR.
skills/proposeThe product pass, run hourly. Turns idea issues into spec PRs, gets the CTO's agreement, turns merged specs into build issues.
skills/cto-reviewThe CTO's review of a spec, run unattended by the product pass on the other model family.
skills/foremanOne pass over every project: carries failures back to builders, starts reviews, starts builds, exits.
skills/buildOne build issue to one pull request, tests first. Also used for fix rounds.
skills/agent-reviewCross-family review of one pull request. Sets the agent-review status.
skills/adoptBrings a repo up to the contract below, as one PR.
templates/The AGENTS.md outline, CLAUDE.md, the spec format, the label set.
lanes.mdThe lanes (CLI, model, subscription), how many jobs each may run at once, launch commands, and how to tell a lane is out.
routing.mdWhich model for which job, the fallback order when a lane is out, and the cross-family rule.
BRAND.mdThe board's colors, type and shapes: status colors for state, and each project's own color for which project a thing belongs to.
writing.mdHow 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.mdThe 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.shLinks every skill into ~/.codex/skills for Codex. Claude gets them from the plugin.
scripts/labels.shCreates 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 contract every project meets

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 pointWhat it must do
scripts/done --fastLint, type check and the tests for what changed, fast enough to run many times per build. Exit 0 means green.
scripts/done --fullEverything, including the full end-to-end suite on a fresh stack. CI runs exactly this.
scripts/dev up / downBoots the app and its services for one worktree, on ports that do not collide with another worktree.
scripts/dev testdb -- CMDRuns CMD against a throwaway database that is never the dev database.
scripts/deploy stagingDeploys main to staging, then runs the smoke end-to-end pass against it.
AGENTS.mdThe 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.
GitHubBranch protection on main (PR only, CI and agent-review required, no bypass), auto-merge allowed, the label set, Issues on.
CIEvery 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.

The lifecycle, as labels

Ideas and specs:

  1. idea: Garrett opened it (one line is enough). Any issue Garrett opens with no lifecycle label counts as an idea.
  2. proposed: the product pass opened a spec PR for it.
  3. agreed: product and CTO have both signed off on the spec PR's current commit. Waiting on Garrett.
  4. Garrett merges the spec PR. That is the approval. Comment instead to reshape it.
  5. The product pass creates the build issues from the spec's build plan and closes the idea with links.

Build issues:

  1. ready: the foreman may start it once everything in its Blocked by list is closed.
  2. building: a worker has it.
  3. in-review: its PR is open; CI and the cross-family review run.
  4. Closed when the PR merges. Staging deploys from main.
  5. 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).

Install

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

Make it yours

The kit ships with Garrett's setup as its defaults. To run it for yourself:

  1. Clone it anywhere. If that is not ~/code/g-cauc, set the plugin's "g-cauc folder" in /config.
  2. Replace the rows in projects.md with your repos.
  3. Edit lanes.md to match your subscriptions: set Max jobs to 0 for any lane you do not have.
  4. Run scripts/labels.sh <owner>/<repo> on each repo, then the adopt skill in each one.

Rules for changing the kit

  1. No code that holds state. If a fix seems to need a program, look for a vendor feature first; otherwise a script under about 100 lines with no state, or nothing.
  2. A line enters a skill or template only after a real failure, and says which failure. Once a week, delete lines that never fired.
  3. Every skill change is tested on one real issue before it is trusted unattended.
Source 4 files
hooks/register.js 951 lines
1// 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}
951
hooks/lib.js 452 lines
1// 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}
452
hooks/ci.js 248 lines
1// 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}
248
hooks/draw.js 193 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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