SLOPSHOPPER

athena

Chief of staff: launches visible worker Claude sessions in herdr worktree spaces from Linear tickets and supervises them to merge-ready PRs.

bandrowsstatuspromptprocesstimer
★ 1v0.5.0no licenseupdated 2026-10-22Delitefully/athena
A shopper browsing a rack in a slop shop
README

<img src="assets/logo-lockup.svg" alt="athena" width="360">

A chief of staff for Claude Code in herdr. You hand athena Linear tickets. For each one it launches a visible worker Claude in its own git worktree and herdr space, shows the workers as columns beside itself, and supervises them to merge-ready PRs. It asks you only when a decision is yours, and you can type into any worker at any time.

athena's red thread runs through three workers, brings you a question, ties off at the merge

Requirements

  • macOS or Linux, with Python 3.9 or later as /usr/bin/python3.
  • herdr 0.9.1 or later (athena init offers to install it).
  • Claude Code as claude, 2.1.286 or later for the watch mod.
  • gh, signed in (gh auth login), and a git identity: workers push branches and open PRs.
  • Go, only for the dashboard (built on first use).

Install

git clone https://github.com/Delitefully/athena.git
mkdir -p ~/.local/bin && ln -sf "$PWD/athena/bin/athena" ~/.local/bin/athena   # ~/.local/bin on your PATH
claude plugin marketplace add "$PWD/athena"
claude plugin install athena@athena

Quickstart

athena init   # checks what athena needs, asks for your folders, merge rules and model
athena        # starts herdr if needed, opens the athena space, attaches

Then tell athena what to start: "start abc-101 and abc-102". Detach with ctrl+b q; everything keeps running, and athena brings you back.

Using it day to day

  • Start work: tell athena the tickets. It reads Linear, writes a brief, shows a launch card and starts a worker.
  • Answer questions: when a worker needs a decision, athena asks you in its own pane and passes your answer on.
  • Step in: each worker is a full Claude session; type into its column whenever you like.
  • Review and merge: athena checks CI and has a fresh reviewer read each PR. Then you merge, or tell athena to.
  • prefix+shift+c (or athena hq): open or focus athena's space.
  • prefix+shift+b (or athena board toggle): show or hide the worker columns. To select text in a column, hold Shift while you drag (the column's herdr terminal attach takes the mouse; Ghostty and most terminals let Shift through to their own selection).
  • prefix+shift+h (or athena hq restart): update Claude Code and the plugins, then restart athena in place.
  • athena hq rotate: replace a long HQ conversation with a fresh one that starts from a handoff written from athena's state, instead of compacting it again.
  • athena dash open: the dashboard in your browser: what needs you, what is in progress, what is done.
  • Remote Control: athena's own session always connects to Remote Control, so you can reach it from your phone or claude.ai/code; workers never do, whatever remoteControlAtStartup says in ~/.claude/settings.json. A worker that was already running keeps its connection until it next starts.

Learn more

  • How it works: the supervision loop, the watch mod, what it may say and what it keeps quiet (logs/watch-suppressed.log).
  • Commands: every athena command and flag.
  • Configuration: athena init, config.json, environment variables, state files.
  • Dashboard: what each row means, and how the page is built.
  • Troubleshooting: common errors and their fixes.
  • Development: the code layout, tests, the lab, and updating after an edit.
  • Decisions: the choices athena was built on.
Source 2 files
hooks/watch-mod/register.tsx 422 lines
1// athena-watch: in the HQ session alone (ATHENA_ROLE=hq), runs `athena watch --tagged` for the session's life,
2// draws a one-line status band above the prompt, and wakes HQ with one short prompt only for lines that need it,
3// each event once in ten minutes. A wake draws as athena's own compact row, without the engine's plugin frame.
4// It also hides any Monitor rows left in HQ's transcript (the call, its result and its notifications). In a worker
5// (ATHENA_WORKER set, or its cwd in a live worker's worktree), it draws the ATHENA-REPORT line as a completion block. Every other session sees no change.
6import type { EngineInterface, Register } from 'claude-code'
7
8import type { Board, Part, Segment } from './core'
9import {
10  boardHref,
11  clockText,
12  eventParts,
13  fitStatus,
14  hyperlinks,
15  isMonitorNotice,
16  LINK_ENV,
17  linkParts,
18  parseWake,
19  readBoard,
20  readRecords,
21  repoSlug,
22  reportView,
23  restartDelay,
24  splitReport,
25  Waker,
26} from './core'
27
28/** A run shorter than this counts as a quick exit, and the next start waits longer. */
29const STEADY_MS = 60000
30
31/** athena's colour (docs/brand.md): lit madder, which reads on a dark ground and a light one. */
32const MADDER = '#E0573D'
33/** athena's mark in a terminal: one continuous looped thread, in any font. */
34const MARK = '∞'
35/** The store key for what was told in the last ten minutes, kept across a reload or an HQ restart; one per athena
36 *  state dir, since every session running the plugin shares its store. */
37async function toldKey($: EngineInterface): Promise<string> {
38  return `told:${(await $.env.get('ATHENA_STATE_DIR')) || 'default'}`
39}
40
41let role: Promise<boolean> | undefined
42let isWatching = false
43let waker: Waker | undefined
44/** What the band shows: the board from the last status record, or a note while the watch starts or restarts. */
45let board: Board | undefined
46let note: string | undefined
47/** The last wake, drawn as athena's styled row at the top of the band until the person next types a prompt. */
48let lastWake: { time: string | undefined; events: string[] } | undefined
49
50/** The `env` of the settings files, or nothing where they cannot be read. */
51async function settingsEnv($: EngineInterface): Promise<Record<string, unknown>> {
52  const env = await $.settings.read().then(s => s.env as Record<string, unknown> | undefined, () => undefined)
53  return env ?? {}
54}
55
56// HQ only: ATHENA_ROLE=hq, and never a worker, even one that inherited HQ's environment.
57async function readRole($: EngineInterface): Promise<boolean> {
58  const role = (await $.env.get('ATHENA_ROLE')) ?? (await settingsEnv($)).ATHENA_ROLE
59  return role === 'hq' && !(await readWorker($))
60}
61
62let worker: Promise<boolean> | undefined
63
64// A worker session: ATHENA_WORKER set, in its environment or its settings; else `athena whoami` finds it by its
65// worktree in the ledger, as the status hook does. herdr's bare `claude --resume` after a restart drops both.
66async function readWorker($: EngineInterface): Promise<boolean> {
67  if ((await $.env.get('ATHENA_WORKER')) ?? (await settingsEnv($)).ATHENA_WORKER) return true
68  const found = await $.process.run([`${$.plugin.root}/bin/athena`, 'whoami']).catch(() => undefined)
69  return found?.exitCode === 0 && found.stdout.trim() !== ''
70}
71
72function isWorker($: EngineInterface): Promise<boolean> {
73  worker ??= readWorker($).catch(() => false)
74  return worker
75}
76
77// Read lazily and once: a render on --resume can come before session.start settles.
78function isHq($: EngineInterface): Promise<boolean> {
79  role ??= readRole($).catch(() => false)
80  return role
81}
82
83let linking: Promise<boolean> | undefined
84
85// Whether a Link draws as a masked OSC 8 span here, read once from the variables Claude Code decides it by.
86async function readLinking($: EngineInterface): Promise<boolean> {
87  // Each name a literal, so validate lists what the mod reads.
88  const read: Record<(typeof LINK_ENV)[number], string | undefined> = {
89    FORCE_HYPERLINK: await $.env.get('FORCE_HYPERLINK'),
90    TERM_PROGRAM: await $.env.get('TERM_PROGRAM'),
91    TERM_PROGRAM_VERSION: await $.env.get('TERM_PROGRAM_VERSION'),
92    LC_TERMINAL: await $.env.get('LC_TERMINAL'),
93    TERM: await $.env.get('TERM'),
94    TERMINAL_EMULATOR: await $.env.get('TERMINAL_EMULATOR'),
95    WT_SESSION: await $.env.get('WT_SESSION'),
96    TMUX: await $.env.get('TMUX'),
97    VTE_VERSION: await $.env.get('VTE_VERSION'),
98  }
99  // A settings file's env (HQ's sets FORCE_HYPERLINK) counts as the engine's own.
100  const settings = await settingsEnv($)
101  const env: Record<string, string | undefined> = {}
102  for (const key of LINK_ENV) {
103    const value = read[key] ?? settings[key]
104    env[key] = typeof value === 'string' ? value : undefined
105  }
106  return hyperlinks(env)
107}
108
109function isLinking($: EngineInterface): Promise<boolean> {
110  linking ??= readLinking($).catch(() => false)
111  return linking
112}
113
114let workerRepo: Promise<string | undefined> | undefined
115
116// A worker's own repo, `owner/repo` from its worktree's origin remote: what its report's PR links into.
117function readWorkerRepo($: EngineInterface): Promise<string | undefined> {
118  workerRepo ??= $.process
119    .run(['git', 'remote', 'get-url', 'origin'])
120    .then(r => (r.exitCode === 0 ? repoSlug(r.stdout) : undefined))
121    .catch(() => undefined)
122  return workerRepo
123}
124
125type Elements = ReturnType<EngineInterface['ui']['resolve']>
126
127/**
128 * Drawn parts: each one with an `href` a masked link where the terminal draws one, plain text otherwise (never the
129 * engine's `label URL` fallback). Display only: the text a wake submits never carries a link.
130 */
131function drawParts(ui: Elements, parts: readonly Part[], isOn: boolean, style: { color?: string; dimColor?: boolean } = {}) {
132  const { Text, Link } = ui
133  return parts.map(part =>
134    part.href && isOn ? (
135      <Link href={part.href}>
136        <Text {...style}>{part.text}</Text>
137      </Link>
138    ) : (
139      <Text {...style}>{part.text}</Text>
140    ),
141  )
142}
143
144function show(next: { board?: Board; note?: string }, $: EngineInterface): void {
145  board = next.board ?? board
146  note = next.note
147  $.ui.invalidate('ui.render')
148}
149
150/** Minutes east of UTC: the module's own clock reads UTC, so ask the system once. */
151async function utcOffset($: EngineInterface): Promise<number> {
152  try {
153    const m = /^([+-])(\d\d)(\d\d)$/.exec((await $.process.run(['date', '+%z'])).stdout.trim())
154    if (m) return (m[1] === '-' ? -1 : 1) * (Number(m[2]) * 60 + Number(m[3]))
155  } catch {
156    // fall through
157  }
158  return -new Date().getTimezoneOffset()
159}
160
161async function told($: EngineInterface, key: string): Promise<Record<string, number>> {
162  const value = await $.store.get(key).catch(() => undefined)
163  if (!value || typeof value !== 'object') return {}
164  return Object.fromEntries(Object.entries(value).filter((kv): kv is [string, number] => typeof kv[1] === 'number'))
165}
166
167async function watch($: EngineInterface): Promise<void> {
168  $.ui.status(undefined) // the band replaces the engine's status line
169  const offset = await utcOffset($)
170  const key = await toldKey($)
171  const w = (waker = new Waker({
172    // asUser: stored bare, so the row shows the `athena watch HH:MM:` prompt without the engine's plugin frame and
173    // its "This is how Claude Code surfaces a prompt..." sentence. The header names the mod; hq.md and the skill tell the
174    // model it is the mod's notice, not the human.
175    submit: text => {
176      lastWake = parseWake(text)
177      $.ui.invalidate('ui.render')
178      return $.prompt.submit({ text, asUser: true })
179    },
180    after: (ms, fn) => $.clock.after(ms, fn),
181    now: () => Date.now(),
182    stamp: () => clockText(Date.now(), offset),
183    sent: await told($, key),
184    remember: sent => void $.store.set(key, sent).catch(() => undefined),
185  }))
186  const command = (await $.env.get('ATHENA_WATCH_CMD')) || `${$.plugin.root}/bin/athena`
187  let quickExits = 0
188  for (;;) {
189    show({ note: 'watch starting' }, $)
190    const startedAt = Date.now()
191    let rest = ''
192    let errors = ''
193    let how = ''
194    try {
195      const child = $.process.spawn({ argv: [command, 'watch', '--tagged'] })
196      for (;;) {
197        const step = await child.next()
198        if (step.done) {
199          how = step.value.signal ? `signal ${step.value.signal}` : `code ${step.value.code}`
200          break
201        }
202        const { stream, text } = step.value
203        if (stream === 'stderr') {
204          errors = (errors + text).slice(-2000)
205          continue
206        }
207        const read = readRecords(rest, text)
208        rest = read.rest
209        for (const r of read.records) {
210          if ('status' in r) show({ board: readBoard(r.board), note: readBoard(r.board) ? undefined : r.status }, $)
211          else if (r.wake) w.add(r.line)
212          else w.settle(r.line)
213        }
214      }
215    } catch (err) {
216      how = `could not start ${command}: ${err instanceof Error ? err.message : String(err)}`
217    }
218    quickExits = Date.now() - startedAt < STEADY_MS ? quickExits + 1 : 0
219    const delay = restartDelay(Math.max(quickExits, 1))
220    const lastError = errors.trim().split('\n').pop()
221    // Told once per run of quick exits, so a watch that cannot start does not wake HQ every minute.
222    if (quickExits <= 1) w.add(`watch error: exited (${how})${lastError ? `: ${lastError}` : ''}; restarting`)
223    show({ note: `watch exited (${how}); restarting in ${Math.round(delay / 1000)}s` }, $)
224    await new Promise<void>(resolve => $.clock.after(delay, resolve))
225  }
226}
227
228async function hidden($: EngineInterface, e: Parameters<EngineInterface['ui']['resolve']>[0]) {
229  const { Box } = $.ui.resolve(e)
230  return <Box display="none" />
231}
232
233const TONE: Record<Segment['tone'], string | undefined> = { needs: 'warning', active: undefined, done: 'success', dash: undefined }
234
235export const register: Register = on => {
236  on('session.start', async ($, e, next) => {
237    const started = await next(e)
238    if (!isWatching && (await isHq($))) {
239      isWatching = true
240      void watch($)
241    }
242    return started
243  })
244
245  // The person typed: they have seen the last wake, so its row leaves the band.
246  on('prompt.submit', async ($, e, next) => {
247    if (e.origin.kind === 'composer' && lastWake) {
248      lastWake = undefined
249      $.ui.invalidate('ui.render')
250    }
251    return next(e)
252  }).catch(($, e, next) => next(e)) // never in the way of a prompt
253
254  on('turn.start', async ($, e, next) => {
255    waker?.turnStarted()
256    return next(e)
257  })
258
259  on('turn.complete', async ($, e, next) => {
260    if (e.agentId === undefined) waker?.turnEnded()
261    return next(e)
262  })
263
264  // A wake drawn as athena's own compact row: its mark, a dim time, one event per line (every event, so ctrl+o shows
265  // the same; isExpanded is also true under a speaker label, which this row has). Claude Code 2.1.296 raises no ui.render for a plugin's prompt row (its own `› Prompt from the
266  // athena plugin` block), so this waits for a build that does; until then `asUser` keeps the frame off the row.
267  on('ui.render', { component: 'UserMessage', props: { origin: { kind: 'plugin', name: 'athena' } } }, async ($, e, next) => {
268    const wake = parseWake(e.props.text)
269    if (!wake || !(await isHq($))) return next(e)
270    const ui = $.ui.resolve(e)
271    const { Box, Text } = ui
272    const isOn = await isLinking($)
273    return (
274      <Box flexDirection="row" columnGap={1}>
275        <Text color={MADDER} bold>
276          {`${MARK} athena`}
277        </Text>
278        {wake.time ? <Text dimColor>{wake.time}</Text> : null}
279        <Box flexDirection="column">
280          {wake.events.map(event => (
281            <Text>{drawParts(ui, eventParts(event, board), isOn)}</Text>
282          ))}
283        </Box>
284      </Box>
285    )
286  })
287
288  // The band above the prompt. On top, the last wake as athena's own row: its mark, a dim time, one event per line
289  // (the transcript's row of it is the engine's to draw). Then the status: what needs the human (warning), the
290  // active workers, and the done ones (success), folded to fit one line. Another plugin's band still draws under it.
291  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
292    if (e.props.hasSurvey || !(board || note || lastWake) || !(await isHq($))) return next(e)
293    const ui = $.ui.resolve(e)
294    const { Box, Text } = ui
295    const isOn = await isLinking($)
296    const head = `${MARK} athena`
297    const indent = ' '.repeat(head.length + 1)
298    const segments = board && !note ? fitStatus(board, e.props.bodyColumns - head.length - 1) : []
299    const status = note ? (
300      <Text dimColor>{note}</Text>
301    ) : (
302      segments.map((s, i) => (
303        <Text>
304          {i ? <Text dimColor>{' · '}</Text> : ''}
305          {s.tone === 'dash'
306            ? drawParts(ui, [{ text: s.text, href: s.href }], isOn, { dimColor: true })
307            : drawParts(ui, linkParts(s.text, boardHref(board)), isOn, { color: TONE[s.tone] })}
308        </Text>
309      ))
310    )
311    const theirs = await next(e)
312    return (
313      <Box flexDirection="column">
314        {lastWake ? (
315          <Box flexDirection="row" columnGap={1}>
316            <Box flexShrink={0}>
317              <Text color={MADDER} bold>
318                {head}
319              </Text>
320            </Box>
321            {lastWake.time ? (
322              <Box flexShrink={0}>
323                <Text dimColor>{lastWake.time}</Text>
324              </Box>
325            ) : null}
326            <Box flexDirection="column" flexShrink={1}>
327              {lastWake.events.map(event => (
328                <Text wrap="truncate-end">{drawParts(ui, eventParts(event, board), isOn)}</Text>
329              ))}
330            </Box>
331          </Box>
332        ) : null}
333        {board || note ? (
334          <Box flexDirection="row">
335            <Text wrap="truncate-end">
336              {lastWake ? (
337                <Text>{indent}</Text>
338              ) : (
339                <Text color={MADDER} bold>
340                  {head}
341                </Text>
342              )}
343              {lastWake ? '' : ' '}
344              {status}
345            </Text>
346          </Box>
347        ) : null}
348        {theirs}
349      </Box>
350    )
351  })
352
353  // In a worker, the ATHENA-REPORT line its final message ends with draws as a completion block: the status in
354  // its colour, the PR and head, the verify line, the decisions and the concerns, each one line. Drawing only: the stored message,
355  // what the hooks parse and what athena reads are unchanged. A line that does not parse keeps the engine's drawing.
356  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
357    if (e.props.isSummary || !e.props.text.includes('ATHENA-REPORT')) return next(e)
358    const split = splitReport(e.props.text)
359    if (!split || !(await isWorker($))) return next(e)
360    const v = reportView(split.report, await readWorkerRepo($))
361    const ui = $.ui.resolve(e)
362    const { Box, Text } = ui
363    const isOn = await isLinking($)
364    const rest = [split.before, split.after].filter(Boolean).join('\n\n')
365    const drawn = rest ? await next({ ...e, props: { ...e.props, text: rest } }) : null
366    const row = (label: string, value: ReturnType<typeof Text>) => (
367      <Box flexDirection="row">
368        <Box width={10} flexShrink={0}>
369          <Text dimColor>{label}</Text>
370        </Box>
371        <Box flexDirection="column" flexShrink={1}>
372          {value}
373        </Box>
374      </Box>
375    )
376    const bullets = (items: string[], color?: string) => (
377      <Box flexDirection="column">
378        {items.map(item => (
379          <Text color={color} wrap="truncate-end">{`• ${item}`}</Text>
380        ))}
381      </Box>
382    )
383    return (
384      <Box flexDirection="column">
385        {drawn}
386        <Box flexDirection="column" borderStyle="round" borderColor={MADDER} paddingX={1} marginTop={rest ? 1 : 0}>
387          <Box flexDirection="row" columnGap={1}>
388            <Text color={MADDER} bold>
389              {`${MARK} athena report`}
390            </Text>
391            <Text color={v.status.color} bold>
392              {v.status.text}
393            </Text>
394          </Box>
395          {v.pr ? row('PR', <Text>{drawParts(ui, [{ text: v.pr.label, href: v.pr.href }], isOn)}</Text>) : null}
396          {v.head ? row('head', <Text dimColor>{v.head}</Text>) : null}
397          {v.verify ? row('verify', <Text>{v.verify}</Text>) : null}
398          {v.decisions.length ? row('decisions', bullets(v.decisions)) : null}
399          {v.concerns.length ? row('concerns', bullets(v.concerns, 'warning')) : null}
400        </Box>
401      </Box>
402    )
403  })
404
405  // Monitor rows from before the mod (or from the manual fallback) are drawn as nothing in HQ.
406  on('ui.render', { component: 'ToolUse', props: { tool: 'Monitor' } }, async ($, e, next) =>
407    (await isHq($)) ? hidden($, e) : next(e),
408  )
409
410  on('ui.render', { component: 'ToolResult', props: { tool: 'Monitor' } }, async ($, e, next) =>
411    (await isHq($)) ? hidden($, e) : next(e),
412  )
413
414  // A Monitor's events, end and expiry notices are task-notification rows. Drawing changes the row alone, never
415  // what the model read; under ctrl+o (isExpanded) they draw in full.
416  on('ui.render', { component: 'UserMessage', props: { origin: { kind: 'task-notification' } } }, async ($, e, next) =>
417    // The task carries no tool name (2.1.296: id, status, type, toolUseId; a Monitor's event rows only an id), so
418    // a Monitor's rows are told by the engine's own text for them.
419    !e.props.isExpanded && e.props.task && isMonitorNotice(e.props.text) && (await isHq($)) ? hidden($, e) : next(e),
420  )
421}
422
hooks/watch-mod/core.ts 691 lines
1// The athena-watch mod's logic, free of the engine so it is tested on its own: reading `athena watch --tagged`
2// output, and turning its actionable lines into as few HQ prompts as possible.
3
4export type WatchRecord = { line: string; wake: boolean } | { status: string; board?: unknown }
5
6/** Splits what the child wrote into whole lines; a line that is not a record of ours is an actionable line as is. */
7export function readRecords(rest: string, text: string): { records: WatchRecord[]; rest: string } {
8  const parts = (rest + text).split('\n')
9  const tail = parts.pop() ?? ''
10  const records: WatchRecord[] = []
11  for (const raw of parts) {
12    const line = raw.trim()
13    if (!line) continue
14    records.push(parseRecord(line))
15  }
16  return { records, rest: tail }
17}
18
19function parseRecord(line: string): WatchRecord {
20  try {
21    const value: unknown = JSON.parse(line)
22    if (value && typeof value === 'object') {
23      const v = value as Record<string, unknown>
24      if (typeof v.status === 'string') return { status: v.status, board: v.board }
25      if (typeof v.line === 'string') return { line: v.line, wake: v.wake === true }
26    }
27  } catch {
28    // not JSON: fall through
29  }
30  return { line, wake: true }
31}
32
33export const MAX_LINES = 8
34const MAX_LINE = 200
35
36/**
37 * One prompt for a batch: a stamped header, then one event per line. The model reads it as is; the HQ transcript
38 * draws it as athena's own row (parseWake reads it back).
39 *
40 *     athena watch 22:19:
41 *     abc-101 report DONE
42 *     pr47 checks pending->failure
43 */
44export function promptText(lines: readonly string[], at?: string): string {
45  // A doorbell's ring is never cut: the watch does not ring for it again, and athena status does not show it.
46  const isRing = (l: string) => DOORBELL.test(l) || SLACK.test(l)
47  const rings = lines.filter(isRing)
48  const others = lines.filter(l => !isRing(l))
49  const kept = others.slice(0, Math.max(MAX_LINES - rings.length, 0))
50  const shown = [...rings, ...kept.map(l => (l.length > MAX_LINE ? l.slice(0, MAX_LINE - 1) + '…' : l))]
51  const more = others.length - kept.length
52  if (more > 0) shown.push(`+${more} more (athena status)`)
53  return `athena watch${at ? ` ${at}` : ''}:\n${shown.join('\n')}`
54}
55
56const HEADER = /^athena watch(?: (\d{1,2}:\d{2}))?:\s*(.*)$/
57const FRAME = /^(The \S+ plugin sent a message|This is how Claude Code surfaces)/
58
59/** A wake prompt read back from a transcript row: its stamp and events. Tolerates the engine's frame around it and
60 *  the one-line `athena watch: a · b` prompts sent before the stamp. Undefined for anything else. */
61export function parseWake(text: string): { time: string | undefined; events: string[] } | undefined {
62  const lines = text.split('\n')
63  const at = lines.findIndex(l => HEADER.test(l.trim()))
64  if (at < 0) return undefined
65  const [, time, rest] = HEADER.exec(lines[at]!.trim())!
66  const events = rest ? rest.split(' · ') : []
67  for (const raw of lines.slice(at + 1)) {
68    const line = raw.trim()
69    if (FRAME.test(line)) break
70    if (line) events.push(line)
71  }
72  return events.length ? { time, events } : undefined
73}
74
75/** `HH:MM` of a moment, in the zone `offsetMinutes` east of UTC (the module's clock reads UTC). */
76export function clockText(ms: number, offsetMinutes: number): string {
77  const d = new Date(ms + offsetMinutes * 60000)
78  return `${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}`
79}
80
81// The words a wake may carry. HQ reads a wake as the person's own words (it is submitted asUser), so no text a
82// worker, GitHub or a PR controls may reach it: every line is rebuilt from these, and anything else is held back.
83const NAME = '[a-z][a-z0-9_-]{0,31}' // as paths.NAME_RE
84const STATE = '(?:working|idle|done|blocked|exited|starting|unknown)'
85const REPORT = '(?:DONE|DONE_WITH_CONCERNS|BLOCKED|NEEDS_CONTEXT)'
86const CHECK = '(?:none|pending|success|failure)'
87const EXIT = '(?:code -?\\d{1,3}|signal SIG[A-Z0-9]{1,10})'
88
89/**
90 * The dashboard's doorbell: the human answered there, and `athena ask queue` holds the answers up to this seq. It
91 * carries no text of theirs, and `dash-queue` is no worker's name (athena spawn refuses it).
92 */
93const DOORBELL_RE = 'dash-queue \\d{1,9}'
94const DOORBELL = new RegExp(`^${DOORBELL_RE}$`)
95
96/**
97 * The Slack doorbell: new messages in a channel or DM the watch polls, by its id and a count (` dm` for a DM or group
98 * DM), never their text or who wrote them. `slack-new` is no worker's name either (athena spawn refuses it).
99 */
100const SLACK_RE = 'slack-new ([A-Z][A-Z0-9]{5,20}) (\\d{1,6})( dm)?'
101const SLACK = new RegExp(`^${SLACK_RE}$`)
102const SLACK_MAX = 999999
103
104/** A CI workflow's name in the words a wake may carry: runs of anything else fold to `-`, at most 32 characters. */
105export function safeWorkflow(name: string): string {
106  const safe = name.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+/, '').slice(0, 32).replace(/-+$/, '')
107  return safe || 'workflow'
108}
109
110/** Lines allowed as they are, each regex anchored. */
111const EXACT = [
112  `${NAME} report ${REPORT}`,
113  `${NAME} state ${STATE}->${STATE}(?: \\(startup prompt\\))?`,
114  `${NAME} pr (?:merged|closed|open)`,
115  `${NAME} pr conflicts #\\d{1,7}`,
116  `${NAME} checks ${CHECK}->${CHECK}`,
117  `${NAME} review (?:CHANGES_REQUESTED|APPROVED|REVIEW_REQUIRED)`,
118  `${NAME} review comments \\+\\d{1,4}`,
119  `[A-Za-z0-9._-]{1,64} main moved [0-9a-f]{7}\\.\\.[0-9a-f]{7}`,
120  `${NAME} gone from the ledger`,
121  `${NAME} new worker \\(${STATE}(?:, startup prompt)?\\)`,
122  `${NAME} goal sent`,
123  `watch error: exited \\(${EXIT}\\); restarting`,
124  DOORBELL_RE,
125  SLACK_RE,
126].map(r => new RegExp(`^${r}$`))
127
128/** Lines that carry free text, reduced to their allowlisted part. */
129const REDUCED: [RegExp, (m: RegExpExecArray) => string][] = [
130  [new RegExp(`^(${NAME}) pr opened https://github\\.com/[\\w.-]+/[\\w.-]+/pull/(\\d{1,7})$`), m => `${m[1]} pr opened #${m[2]}`],
131  [
132    /^stack [\w.-]+\/[\w.-]+#(\d{1,7}): \S.* -> (OPEN|MERGED|CLOSED) base=\S+(?: DRAFT)? (MERGEABLE|CONFLICTING|UNKNOWN)$/,
133    m => (m[2] === 'OPEN' ? `stack #${m[1]}: OPEN ${m[3]}` : `stack #${m[1]}: ${m[2]}`),
134  ],
135  [
136    /^([A-Za-z0-9._-]{1,64}) main ci failed (.{1,200}?) (\d{1,12})( \(attempt \d{1,3}\))?$/,
137    m => `${m[1]} main ci failed ${safeWorkflow(m[2]!)} ${m[3]}${m[4] ?? ''}`,
138  ],
139  [new RegExp(`^(${NAME}) goal not sent: .+$`), m => `${m[1]} goal not sent`],
140  [
141    new RegExp(`^(${NAME}) goal unconfirmed \\(.*\\): check its pane, do not resend blindly$`),
142    m => `${m[1]} goal unconfirmed: check its pane, do not resend blindly`,
143  ],
144  [new RegExp(`^watch error: exited \\((${EXIT})\\): .*; restarting$`), m => `watch error: exited (${m[1]}); restarting`],
145  [/^watch error\b.*$/, () => 'watch error (athena status)'],
146]
147
148/** What a wake says in place of a line it may not carry, so HQ still looks. */
149export const HELD = 'a watch line not shown here (athena status)'
150
151/** A watch line in the words a wake may carry, or undefined when it holds anything else. */
152export function allowLine(line: string): string | undefined {
153  if (/[\r\n]/.test(line)) return undefined
154  if (EXACT.some(r => r.test(line))) return line
155  for (const [r, rebuild] of REDUCED) {
156    const m = r.exec(line)
157    if (m) return rebuild(m)
158  }
159  return undefined
160}
161
162export type Timer = { cancel: () => void }
163
164export type WakerOptions = {
165  /** Submits one prompt; resolves once its turn has started (or it was refused). */
166  submit: (text: string) => Promise<unknown>
167  after: (ms: number, fn: () => void) => Timer
168  now: () => number
169  /** The `HH:MM` a prompt is stamped with. */
170  stamp?: () => string
171  /** Lines arriving within this window go out as one prompt. */
172  batchMs?: number
173  /** A line told within this window is not told again: the same worker and event wakes HQ once. */
174  repeatMs?: number
175  /** A Slack channel (not a DM) wakes HQ at most once in this window; its rings meanwhile add up into the next. */
176  slackMs?: number
177  /** What was told, and when, from before a reload or an HQ restart. */
178  sent?: Readonly<Record<string, number>>
179  /** Called with what was told in the window each time it changes, to keep it across a reload. */
180  remember?: (sent: Record<string, number>) => void
181}
182
183/**
184 * Holds actionable lines and submits them as one prompt: after a short quiet window, never while HQ runs a turn
185 * (they wait for its end, then go out together), and never while an earlier submit is still on its way. Each line
186 * is rebuilt from allowLine's words first (one it cannot rebuild goes out as HELD); a line already told in the last
187 * ten minutes is dropped, so a worker that ends blocked twice wakes HQ once.
188 */
189export class Waker {
190  private pending: string[] = []
191  /** What each pending line is remembered by: the line, or a held line's own raw text. */
192  private keys: string[] = []
193  private timer: Timer | undefined
194  private isBusy = false
195  private isSending = false
196  private told: Map<string, number>
197  /** Per Slack channel waiting out its window: the count rung meanwhile, and the timer that lets it go. */
198  private held = new Map<string, { count: number; timer: Timer }>()
199  private readonly batchMs: number
200  private readonly repeatMs: number
201  private readonly slackMs: number
202
203  constructor(private readonly opts: WakerOptions) {
204    this.batchMs = opts.batchMs ?? 3000
205    this.repeatMs = opts.repeatMs ?? 10 * 60 * 1000
206    this.slackMs = opts.slackMs ?? 5 * 60 * 1000
207    this.told = new Map(Object.entries(opts.sent ?? {}))
208  }
209
210  add(raw: string): void {
211    const safe = allowLine(raw)
212    if (safe && (DOORBELL.test(safe) || SLACK.test(safe))) {
213      // A doorbell's ring is always new (the dashboard's seq only grows; Slack's are new messages, and the watch
214      // keeps what it counted across restarts), so the ten-minute memory never drops one. A DM wakes HQ at once; a
215      // channel at most once in slackMs, the rest of its count held for its next wake.
216      const slack = SLACK.exec(safe)
217      if (slack && !slack[3]) {
218        const [, channel, count] = slack
219        const last = this.told.get(`slack ${channel}`)
220        const wait = last === undefined ? 0 : last + this.slackMs - this.opts.now()
221        if (wait > 0) {
222          this.hold(channel!, Number(count), wait)
223          return
224        }
225        if (!this.keys.includes(`slack ${channel}`)) this.keys.push(`slack ${channel}`)
226      }
227      this.place(safe)
228      this.schedule()
229      return
230    }
231    const line = safe ?? HELD
232    // A held line is remembered by its raw text (kept here, never sent), so one unknown line is told once too.
233    const key = safe ?? `held:${raw.slice(0, 500)}`
234    const last = this.told.get(key)
235    if (last !== undefined && this.opts.now() - last < this.repeatMs) return
236    if (!this.keys.includes(key)) this.keys.push(key)
237    this.place(line)
238    this.schedule()
239  }
240
241  private hold(channel: string, count: number, wait: number): void {
242    const held = this.held.get(channel)
243    if (held) {
244      held.count = Math.min(held.count + count, SLACK_MAX)
245      return
246    }
247    const timer = this.opts.after(wait, () => {
248      const done = this.held.get(channel)
249      this.held.delete(channel)
250      if (done) this.add(`slack-new ${channel} ${done.count}`)
251    })
252    this.held.set(channel, { count, timer })
253  }
254
255  /**
256   * Puts a line in the pending batch. The dashboard's ring goes first and the newest stands for the rest, since HQ
257   * reads the whole queue; Slack's rings go right after it, one per channel, their counts added up; any other line
258   * goes at the end, once.
259   */
260  private place(line: string): void {
261    if (DOORBELL.test(line)) {
262      this.pending = [line, ...this.pending.filter(l => !DOORBELL.test(l))]
263      return
264    }
265    const slack = SLACK.exec(line)
266    if (slack) {
267      const [, channel, count, dm] = slack
268      const same = this.pending.find(l => SLACK.exec(l)?.[1] === channel)
269      const total = Math.min(Number(count) + (same ? Number(SLACK.exec(same)![2]) : 0), SLACK_MAX)
270      const merged = `slack-new ${channel} ${total}${dm ?? ''}`
271      if (same) {
272        this.pending = this.pending.map(l => (l === same ? merged : l))
273        return
274      }
275      const at = this.pending.findIndex(l => !DOORBELL.test(l) && !SLACK.test(l))
276      this.pending = at < 0 ? [...this.pending, merged] : [...this.pending.slice(0, at), merged, ...this.pending.slice(at)]
277      return
278    }
279    if (!this.pending.includes(line)) this.pending.push(line)
280  }
281
282  /**
283   * A routine line: the worker (or stacked PR) it names moved on, so what was told about it may be told again. A
284   * check that fails, goes pending after a fix and fails again wakes HQ twice; a replay with no change between, once.
285   */
286  settle(raw: string): void {
287    const name = new RegExp(`^(${NAME}) `).exec(raw)?.[1]
288    const pr = /^stack [\w.-]+\/[\w.-]+#(\d{1,7}):/.exec(raw)?.[1]
289    const prefix = name ? `${name} ` : pr ? `stack #${pr}:` : undefined
290    if (!prefix) return
291    const before = this.told.size
292    for (const key of [...this.told.keys()]) if (key.startsWith(prefix)) this.told.delete(key)
293    if (this.told.size !== before) this.opts.remember?.(Object.fromEntries(this.told))
294  }
295
296  turnStarted(): void {
297    this.isBusy = true
298  }
299
300  turnEnded(): void {
301    this.isBusy = false
302    if (this.pending.length) this.schedule()
303  }
304
305  get waiting(): readonly string[] {
306    return this.pending
307  }
308
309  private schedule(): void {
310    if (this.timer) return
311    this.timer = this.opts.after(this.batchMs, () => {
312      this.timer = undefined
313      this.flush()
314    })
315  }
316
317  private mark(keys: readonly string[], at: number | undefined): void {
318    const now = this.opts.now()
319    for (const [key, when] of this.told) if (now - when >= this.repeatMs) this.told.delete(key)
320    for (const key of keys) {
321      if (at === undefined) this.told.delete(key)
322      else this.told.set(key, at)
323    }
324    this.opts.remember?.(Object.fromEntries(this.told))
325  }
326
327  private flush(): void {
328    if (this.isBusy || this.isSending || !this.pending.length) return
329    const lines = this.pending
330    const keys = this.keys
331    this.pending = []
332    this.keys = []
333    this.isSending = true
334    // Told from the moment it is sent: a repeat arriving while the submit waits for its turn is dropped too.
335    this.mark(keys, this.opts.now())
336    this.opts
337      .submit(promptText(lines, this.opts.stamp?.()))
338      .catch(() => {
339        this.mark(keys, undefined)
340        const later = this.pending
341        this.pending = []
342        for (const l of [...lines, ...later]) this.place(l)
343        // Not told after all: what a channel held back since goes with it now.
344        for (const key of keys) {
345          const channel = key.startsWith('slack ') ? key.slice(6) : undefined
346          const held = channel ? this.held.get(channel) : undefined
347          if (!held) continue
348          held.timer.cancel()
349          this.held.delete(channel!)
350          this.place(`slack-new ${channel} ${held.count}`)
351        }
352        this.keys = [...keys, ...this.keys.filter(k => !keys.includes(k))]
353      })
354      .finally(() => {
355        this.isSending = false
356        if (this.pending.length) this.schedule()
357      })
358  }
359}
360
361/** The status line's parts as `athena watch --tagged` reports them, most urgent first. */
362export type Board = {
363  /** `<worker> blocked`, `<worker> needs context`, `<worker> exited` */
364  needs: string[]
365  /** `#n` per PR waiting on the human */
366  prs: string[]
367  /** [state, workers] per active state: working, idle, starting, in review */
368  active: [string, string[]][]
369  done: string[]
370  /** `owner/repo` per PR number the watch knows from the ledger: a live worker's repo, or a stack's. */
371  links?: Record<string, string>
372  /** `owner/repo` per live worker, from its repo's origin remote. */
373  repos?: Record<string, string>
374  /** The local dashboard's URL while it runs, as `athena dash show` reads it. */
375  dash?: string
376}
377
378export type Segment = { text: string; tone: 'needs' | 'active' | 'done' | 'dash'; href?: string }
379
380/** One run of drawn text: plain, or a masked link to `href`. */
381export type Part = { text: string; href?: string }
382
383const SLUG = /^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/
384
385/**
386 * A PR's page, built only from an `owner/repo` the watch read from the ledger (never from worker, GitHub or PR
387 * text) and a number. Undefined when either is not one, so the PR draws as plain `#n`.
388 */
389export function prUrl(repo: string | undefined, n: string | number): string | undefined {
390  const num = String(n)
391  if (!repo || !SLUG.test(repo) || !/^\d{1,7}$/.test(num)) return undefined
392  return `https://github.com/${repo}/pull/${num}`
393}
394
395/** `owner/repo` of a GitHub remote, https or ssh; undefined for any other remote. */
396export function repoSlug(remote: string): string | undefined {
397  const m = /^(?:https:\/\/github\.com\/|git@github\.com:|ssh:\/\/git@github\.com\/)([\w.-]+\/[\w.-]+?)(?:\.git)?\/?$/.exec(remote.trim())
398  return m && SLUG.test(m[1]!) ? m[1] : undefined
399}
400
401/** A drawn text split around each `#n`, each with its link where `href` knows one. */
402export function linkParts(text: string, href: (n: string) => string | undefined): Part[] {
403  const parts: Part[] = []
404  let at = 0
405  for (const m of text.matchAll(/#(\d{1,7})\b/g)) {
406    if (m.index! > at) parts.push({ text: text.slice(at, m.index) })
407    const url = href(m[1]!)
408    parts.push(url ? { text: m[0], href: url } : { text: m[0] })
409    at = m.index! + m[0].length
410  }
411  if (at < text.length || !parts.length) parts.push({ text: text.slice(at) })
412  return parts
413}
414
415/** A PR number's link on the band: by the board's links, plain when it knows none. */
416export function boardHref(b: Board | undefined): (n: string) => string | undefined {
417  return n => prUrl(b?.links?.[n], n)
418}
419
420/** A wake event's parts: a PR it names links by the worker the event is about, else by the board's links. */
421export function eventParts(event: string, b: Board | undefined): Part[] {
422  // `stack #n:` and `watch error` lines name no worker, whatever workers are called.
423  const worker = /^(?!stack #|watch error)([a-z][a-z0-9_-]{0,31}) /.exec(event)?.[1]
424  const repo = worker ? b?.repos?.[worker] : undefined
425  return linkParts(event, n => prUrl(repo ?? b?.links?.[n], n))
426}
427
428/** FORCE_HYPERLINK, then the terminals Claude Code 2.1.296 draws OSC 8 links for (else it adds the raw URL). */
429const LINKING = ['ghostty', 'Hyper', 'kitty', 'alacritty', 'iTerm.app', 'iTerm2', 'WarpTerminal', 'WezTerm', 'vscode']
430
431/**
432 * Whether the engine draws a `Link` as a masked OSC 8 span: a mirror of Claude Code's own check, read from the same
433 * variables. Where it does not (herdr unless FORCE_HYPERLINK is set), the mod draws plain text instead of the
434 * engine's `label URL` fallback. A wrong answer costs little: false draws plain text, true the engine's fallback.
435 */
436export function hyperlinks(env: Readonly<Record<string, string | undefined>>): boolean {
437  const force = env.FORCE_HYPERLINK
438  if (force) return parseInt(force, 10) !== 0 // empty counts as unset, as in the engine
439  const program = env.TERM_PROGRAM
440  if (program && LINKING.includes(program)) return true
441  if (env.LC_TERMINAL && LINKING.includes(env.LC_TERMINAL)) return true
442  if (env.TERM?.includes('kitty') || env.TERM === 'alacritty') return true
443  if (env.TERMINAL_EMULATOR === 'JetBrains-JediTerm') return true
444  if (env.WT_SESSION && program !== 'tmux' && !env.TMUX) return true
445  if (program === 'tmux') {
446    const [major, minor] = (env.TERM_PROGRAM_VERSION ?? '').split('.').map(x => parseInt(x, 10))
447    if (major! > 3 || (major === 3 && minor! >= 4)) return true
448  }
449  if (env.VTE_VERSION && env.VTE_VERSION !== '0.50.0') {
450    const v = env.VTE_VERSION
451    const minor = /^\d{3,4}$/.test(v) ? parseInt(v.slice(0, -2), 10) : parseInt(v.split('.')[1] ?? '', 10)
452    const major = /^\d{3,4}$/.test(v) ? 0 : parseInt(v.split('.')[0] ?? '', 10)
453    if (major > 0 || minor >= 50) return true
454  }
455  return false
456}
457
458/** The variables `hyperlinks` reads. */
459export const LINK_ENV = [
460  'FORCE_HYPERLINK',
461  'TERM_PROGRAM',
462  'TERM_PROGRAM_VERSION',
463  'LC_TERMINAL',
464  'TERM',
465  'TERMINAL_EMULATOR',
466  'WT_SESSION',
467  'TMUX',
468  'VTE_VERSION',
469] as const
470
471export const DASH_LINK = 'open dash →'
472
473/** Width of the ` · ` between segments. */
474export const GAP = 3
475
476function needsCounted(b: Board): string[] {
477  const kinds = new Map<string, number>()
478  for (const item of b.needs) {
479    const kind = item.slice(item.indexOf(' ') + 1)
480    kinds.set(kind, (kinds.get(kind) ?? 0) + 1)
481  }
482  return [...kinds].map(([kind, n]) => `${n} ${kind}`)
483}
484
485function prsNamed(b: Board): string[] {
486  return b.prs.length ? [`${b.prs.join(' ')} ${b.prs.length === 1 ? 'needs' : 'need'} you`] : []
487}
488
489function prsCounted(b: Board): string[] {
490  return b.prs.length ? [b.prs.length === 1 ? '1 PR needs you' : `${b.prs.length} PRs need you`] : []
491}
492
493/**
494 * The status line, laid out for `columns`: what needs the human, then the active workers, then the done ones,
495 * each named while the line fits, then the dash link. As it narrows, the dash link goes first, then the done ones
496 * fold to a count, then the active ones, then the PRs, then the needs; whatever is still too wide is cut by the drawing.
497 */
498export function fitStatus(b: Board, columns: number): Segment[] {
499  const dash: Segment[] = b.dash ? [{ text: DASH_LINK, tone: 'dash', href: b.dash }] : []
500  if (!b.needs.length && !b.prs.length && !b.active.length && !b.done.length) {
501    return [{ text: 'no live workers', tone: 'active' }, ...dash]
502  }
503  const activeNamed = b.active.map(([state, names]) => `${names.join(', ')} ${state}`)
504  const activeCounted = b.active.map(([state, names]) => `${names.length} ${state}`)
505  const doneNamed = b.done.length ? [`✓ ${b.done.join(', ')} done`] : []
506  const doneCounted = b.done.length ? [`✓ ${b.done.length} done`] : []
507  const levels: [string[], string[], string[], string[]][] = [
508    [b.needs, prsNamed(b), activeNamed, doneNamed],
509    [b.needs, prsNamed(b), activeNamed, doneCounted],
510    [b.needs, prsNamed(b), activeCounted, doneCounted],
511    [b.needs, prsCounted(b), activeCounted, doneCounted],
512    [needsCounted(b), prsCounted(b), activeCounted, doneCounted],
513  ]
514  // The dash link always ends the line.
515  const segments = ([needs, prs, active, done]: [string[], string[], string[], string[]]): Segment[] => {
516    const urgent = [...needs, ...prs].map(text => ({ text, tone: 'needs' as const }))
517    const rest = [...active.map(text => ({ text, tone: 'active' as const })), ...done.map(text => ({ text, tone: 'done' as const }))]
518    return [...urgent, ...rest, ...dash]
519  }
520  const width = (s: Segment[]) => s.reduce((n, x) => n + x.text.length, 0) + GAP * (s.length - 1)
521  // Names and `#n` outrank the dash link: each fold is tried with it, then without it, before folding further.
522  for (const level of levels) {
523    const s = segments(level)
524    if (width(s) <= columns) return s
525    const bare = s.filter(x => x.tone !== 'dash')
526    if (bare.length < s.length && width(bare) <= columns) return bare
527  }
528  return segments(levels[levels.length - 1]!).filter(s => s.tone !== 'dash')
529}
530
531/** Reads a `board` record, or undefined when it is not one. */
532export function readBoard(value: unknown): Board | undefined {
533  if (!value || typeof value !== 'object') return undefined
534  const v = value as Record<string, unknown>
535  const strings = (x: unknown) => (Array.isArray(x) ? x.filter((s): s is string => typeof s === 'string') : [])
536  const active = Array.isArray(v.active)
537    ? v.active.flatMap(g => (Array.isArray(g) && typeof g[0] === 'string' ? [[g[0], strings(g[1])] as [string, string[]]] : []))
538    : []
539  const map = (x: unknown, key: RegExp) =>
540    x && typeof x === 'object' && !Array.isArray(x)
541      ? Object.fromEntries(Object.entries(x).filter((kv): kv is [string, string] => key.test(kv[0]) && typeof kv[1] === 'string' && SLUG.test(kv[1])))
542      : {}
543  const dash = typeof v.dash === 'string' && /^http:\/\/(?:127\.0\.0\.1|localhost):\d{1,5}\/$/.test(v.dash) ? v.dash : undefined
544  return {
545    needs: strings(v.needs),
546    prs: strings(v.prs),
547    active,
548    done: strings(v.done),
549    links: map(v.links, /^\d{1,7}$/),
550    repos: map(v.repos, /^[a-z][a-z0-9_-]{0,31}$/),
551    dash,
552  }
553}
554
555/** How long to wait before starting the watch again: doubles per quick exit, from 5 s up to a minute. */
556export function restartDelay(quickExits: number): number {
557  return Math.min(5000 * 2 ** Math.max(0, quickExits - 1), 60000)
558}
559
560/** A Monitor task's notification row: `Monitor event: ...`, `Monitor "<name>" stream ended`, its expiry. */
561export function isMonitorNotice(text: string): boolean {
562  return /^\[?Monitor (event:|")/.test(text) || /^\[?Monitor expired\b/.test(text)
563}
564
565/** A worker's ATHENA-REPORT, as its final message carries it. */
566export type Report = {
567  status: string
568  pr?: string
569  head?: string
570  verify?: string
571  decisions: string[]
572  concerns: string[]
573}
574
575const REPORT_LINE = /^`?ATHENA-REPORT\s+(\{.*\})`?$/
576const FENCE = /^```\w*$/
577
578/**
579 * The ATHENA-REPORT line of a message, parsed, with the text before and after it (a code fence around the line
580 * goes with it). Undefined when there is none or its JSON does not parse to an object with a status.
581 */
582export function splitReport(text: string): { before: string; after: string; report: Report } | undefined {
583  const lines = text.split('\n')
584  for (let i = lines.length - 1; i >= 0; i--) {
585    const m = REPORT_LINE.exec(lines[i]!.trim())
586    if (!m) continue
587    let value: unknown
588    try {
589      value = JSON.parse(m[1]!)
590    } catch {
591      return undefined
592    }
593    if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
594    const v = value as Record<string, unknown>
595    if (typeof v.status !== 'string') return undefined
596    const str = (x: unknown) => (typeof x === 'string' ? x : undefined)
597    const list = (x: unknown) => (Array.isArray(x) ? x.filter((s): s is string => typeof s === 'string') : [])
598    const fenced = i > 0 && i < lines.length - 1 && FENCE.test(lines[i - 1]!.trim()) && lines[i + 1]!.trim() === '```'
599    const from = fenced ? i - 1 : i
600    const to = fenced ? i + 2 : i + 1
601    return {
602      before: lines.slice(0, from).join('\n').trim(),
603      after: lines.slice(to).join('\n').trim(),
604      report: {
605        status: v.status,
606        pr: str(v.pr),
607        head: str(v.head),
608        verify: str(v.verify),
609        decisions: list(v.decisions),
610        concerns: list(v.concerns),
611      },
612    }
613  }
614  return undefined
615}
616
617/** Needs context: an orange between the theme's warning and error. */
618export const ORANGE = '#E8913A'
619
620const LOOKS: Record<string, { text: string; color: string }> = {
621  DONE: { text: 'Done', color: 'success' },
622  DONE_WITH_CONCERNS: { text: 'Done with concerns', color: 'warning' },
623  BLOCKED: { text: 'Blocked', color: 'error' },
624  NEEDS_CONTEXT: { text: 'Needs context', color: ORANGE },
625}
626
627export type ReportView = {
628  status: { text: string; color: string | undefined }
629  pr: { href: string | undefined; label: string } | undefined
630  head: string | undefined
631  verify: string | undefined
632  decisions: string[]
633  concerns: string[]
634}
635
636/** C0 and C1 controls, and the bidi and zero-width characters that can make a line say one thing and show another. */
637const UNSAFE = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff]/g
638
639/** A report field as it may be drawn: controls, bidi and zero-width characters removed. */
640export function clean(text: string): string {
641  return text.replace(UNSAFE, '')
642}
643
644/** The most a report's decision or concern draws, as worker.md asks of each entry. */
645export const REPORT_ITEM_MAX = 80
646
647/**
648 * A decision or concern as one line, so an old verbose report still draws compactly: line breaks and runs of
649 * spaces fold to one space, and past REPORT_ITEM_MAX characters it is cut, the last an ellipsis.
650 */
651export function oneLine(text: string, max = REPORT_ITEM_MAX): string {
652  const s = clean(text.replace(/[\t\n\v\f\r ]+/g, ' ')).trim()
653  const chars = [...s]
654  return chars.length <= max ? s : chars.slice(0, max - 1).join('').trimEnd() + '…'
655}
656
657/**
658 * What the completion block shows: the status in its word and colour, the PR as `#n` with its head, the rest. The
659 * PR links to its page in `repo`, the worker's own repo from its origin remote: the report's URL gives only the
660 * number, and a URL into another repo draws as plain `owner/repo#n`.
661 */
662export function reportView(raw: Report, repo?: string): ReportView {
663  const r: Report = {
664    status: clean(raw.status),
665    pr: raw.pr === undefined ? undefined : clean(raw.pr),
666    head: raw.head === undefined ? undefined : clean(raw.head),
667    verify: raw.verify === undefined ? undefined : clean(raw.verify),
668    decisions: raw.decisions.map(s => oneLine(s)),
669    concerns: raw.concerns.map(s => oneLine(s)),
670  }
671  const look = LOOKS[r.status.toUpperCase()] ?? { text: r.status, color: undefined }
672  return {
673    status: look,
674    pr: r.pr ? prView(r.pr, repo) : undefined,
675    head: r.head ? r.head.slice(0, 7) : undefined,
676    verify: r.verify || undefined,
677    decisions: r.decisions,
678    concerns: r.concerns,
679  }
680}
681
682function prView(pr: string, repo: string | undefined): { href: string | undefined; label: string } {
683  const url = /^https:\/\/github\.com\/([\w.-]+\/[\w.-]+)\/pull\/(\d{1,7})\/?$/.exec(pr)
684  const n = url?.[2] ?? /^#?(\d{1,7})$/.exec(pr)?.[1]
685  if (!n) return { href: undefined, label: pr }
686  if (url && url[1]!.toLowerCase() !== repo?.toLowerCase()) {
687    return { href: undefined, label: repo ? `${url[1]}#${n}` : `#${n}` }
688  }
689  return { href: prUrl(repo, n), label: `#${n}` }
690}
691