SLOPSHOPPER

ship-progress

Shows above the prompt where each pull request stands on its way to production: PR CI, merged, main CI, deploys and custom checks. Watch only.

bandguardcommandtoolprocessnetwork
v0.1.0MITupdated 2026-10-19toml0006/ship-progress
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ship-progress
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /track ⎿ ship-progress: no PRs tracked (/track <PR#>) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

ship-progress

A Claude Code mod that shows, above the prompt, where each pull request stands on its way to production:

#612 ■■■▣□  deploying (web) 4m 5e1f2a3b
#610 ■□□□□  awaiting merge 7m 33a1c9d0
#609 ■■■■■  live 8b7c6d5e
#605 ✕□□□□  PR CI failed 2m 41f0e2aa ⚠ CI / unit failed

A live session: PRs tracked above the prompt, a new one followed through its CI run

It follows each PR through PR CI → merged → main CI → deploys → custom checks. It polls gh on a timer, so the agent doesn't spend turns watching CI, and it alerts when a stage fails or a PR goes live: it wakes the agent, raises a macOS notification, and posts to Slack if you configure a webhook.

It only watches. It never merges, reruns, retargets, cancels, approves or pushes. A CI guard (scripts/check-watch-only.sh) fails the build if its source ever names such a command.

Install

At the prompt of a terminal session:

/plugin install ship-progress --marketplace toml0006/ship-progress

Answer y to add the marketplace, then pick a scope. From a shell: claude plugin install ship-progress --marketplace toml0006/ship-progress.

It needs the GitHub CLI signed in. The mod uses your gh as is and handles no tokens itself.

Use

  • /track 608 (or #608, owner/repo#608, a PR URL) follows a PR. /track alone lists what is tracked and what shipped in the last 7 days.
  • /untrack 608 stops following it.
  • PRs the session creates, merges, readies or pushes (gh pr create, gh pr merge, gh pr ready, git push) are tracked automatically.
  • /ship-progress prints the status; /ship-progress clear drops task lists; /ship-progress trust approves this repo's custom checks (below).

The bar shows one line per PR or task list, most recent first, at most five. Pips: ■ passed, ▣ running, □ pending, ✕ failed, ↷ superseded or skipped. It is hidden when nothing is in flight, and a finished PR stays visible for doneLingerMinutes (default 10).

The agent tool

The model gets one tool, ship_progress, with an action:

actioninputwhat it does
track / untrack{ pr }add or remove a PR
annotate{ pr, stage?, note }attach a short note to a PR or one of its stages
add_step{ pr, name, after? }add a step the agent owns to a PR's pipeline
set_step`{ pr \list, name, state, note? }`set one of the agent's steps
list_set{ list, title, steps: [{ name, state?, after? }] }declare a task list with no GitHub involvement
status{}every track and list as JSON, so the agent never polls gh itself

Configure a repo

Commit .claude/ship-progress.json so everyone who installs the mod sees the same pipeline. Without it, PR CI is every check on the PR, main CI is every push-triggered workflow on the default branch, and there are no deploys or checks.

{
  "$schema": "https://raw.githubusercontent.com/toml0006/ship-progress/main/schema/ship-progress.schema.json",
  "version": 1,
  "ci": { "workflow": "CI" },
  "deploys": [
    { "name": "web", "workflow": "Deploy web (staging)",
      "deployedSha": { "from": "log", "pattern": "HEAD is now at (\\w+)" } }
  ],
  "checks": [
    { "name": "workers drained", "argv": ["./scripts/check-drained.sh", "{mergeSha}"],
      "intervalSeconds": 60, "timeoutMinutes": 20 }
  ],
  "notify": { "macos": true, "wakeAgent": true, "slack": { "webhookEnv": "SHIP_PROGRESS_SLACK_WEBHOOK" } }
}

The full schema, with defaults, is in schema/ship-progress.schema.json. A bad file falls back to the defaults and the bar says which field is wrong. To try a config before committing it, point SHIP_PROGRESS_CONFIG at a file anywhere.

Deploys

A deploy passes when a run that started after the merge succeeds and the commit it deployed contains the merge commit (checked with git merge-base --is-ancestor in your clone, or GitHub's compare API).

  • "deployedSha": { "from": "headSha" } trusts the run's headSha. Fine for push-triggered deploys.
  • "deployedSha": { "from": "log", "pattern": "…" } reads the SHA from the run's log (the last match, capture group 1). Use it for workflow_run deploys, whose headSha is the branch tip, not necessarily what was deployed. Each run's log is fetched once and cached.
  • A cancelled run (a concurrency group dropped it) shows as superseded and the stage keeps waiting. A skipped run is ignored: on workflow_run deploys most runs are skipped because CI finished on some other branch.
  • Only a failed run whose deployed SHA contains the merge fails the stage. A deploy that never arrives fails after timeoutMinutes (default 45).

Custom checks

Checks run after every deploy passed, from the repo root, with no shell: argv is the command, and {mergeSha}, {prNumber}, {repo} and {deployedSha:<deploy>} are filled in as whole arguments. Exit 0 is green; anything else retries every intervalSeconds until timeoutMinutes.

Because checks run commands from a committed file, they don't run until you type /ship-progress trust yourself. The mod refuses a trust that the agent or another plugin queued. The approval is a hash of the checks array, so any change asks again.

Alerts

Alerts fire when a stage moves into a state listed in notify.on (default failed, warning, done), once per stage, state and run.

  • Wake the agent: a short message is submitted to the session, so the agent can read the failing log without being asked.
  • macOS: a notification through osascript.
  • Slack: set the environment variable the config names (default SHIP_PROGRESS_SLACK_WEBHOOK) to an incoming-webhook URL before starting Claude Code. The URL is read from the environment only; it is never written to the config, the store or a log.

How it stays cheap

Hooks only change state; the clock does the polling. Every 5 s the mod checks whether a poll is due: every 20 s while a stage runs, every 60 s while stages wait, never when nothing is tracked. Each poll handles at most 4 PRs. gh errors double the interval up to 5 minutes and show as ⚠ gh: … on the bar. State is kept per repo in the plugin's store, so a new session resumes where the last one stopped.

Differences from SPEC.md

  • Default CI. With no ci set, PR CI is every check on the PR's head commit and main CI is every push-triggered workflow on the merge commit, rather than "the one workflow that runs on pull_request". It needs no workflow-file parsing and works for repos with several.
  • Skipped deploy runs are ignored, not superseded (see Deploys).
  • No main CI. With no ci set and no push run 10 minutes after the merge, main CI counts as skipped, so repos without branch CI still reach their deploys.
  • Merged before PR CI passed. That stage is marked skipped with a note instead of blocking the rest of the pipeline.
  • Run logs get a 20 s timeout instead of 8 s. They are large (around 150 KB), are read once per run, and are read on the clock, never in a hook.
  • A custom webhookEnv name is read with printenv, because the mods API reads only environment variables named as literals in the source. The default name is read directly.

Known limitations

  • Two sessions in one repo each poll the same PRs and each send alerts, so you get every alert twice, and the session that saves last wins the stored state. Fixing this needs a lease shared between sessions through the plugin store.

Development

claude --plugin-dir /path/to/ship-progress   # load it; edits reload when a turn ends
claude plugin validate .
claude plugin test .                         # 55 tests, gh answered from fixtures
scripts/check-watch-only.sh && scripts/check-public.sh

Type-check with npx -p typescript tsc -p . once Claude Code has loaded the mod and laid its typings in .claude-plugin/types/. SPEC.md is the design.

License

MIT

Source 11 files
hooks/register.tsx 441 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Meta, TaskList, Track } from '../types'
5import { INPUT_SCHEMA, parseInput, type ToolInput } from './lib/input'
6import { joinSegments, listLine, parsePr, plainLine, repoFromRemote, trackLine, type Segment } from './lib/model'
7import { osascriptArgv, transitions, type Alert } from './lib/alerts'
8import { matchCommand, type Hit } from './lib/autotrack'
9import { checksHash } from './lib/checks'
10import { DEFAULT_CONFIG, parseConfig, type Config } from './lib/config'
11import { GhError, type Runner } from './lib/gh'
12import { addTrackStep, annotate, emptyPersisted, listSet, migrate, newTrack, setListStep, setTrackStep, sweep, type Persisted } from './lib/ops'
13import { apply, gather, shapeStages } from './lib/pipeline'
14
15const tracksAtom = atom({ plugin: 'ship-progress', key: 'tracks' } as const, [])
16const listsAtom = atom({ plugin: 'ship-progress', key: 'lists' } as const, [])
17const metaAtom = atom({ plugin: 'ship-progress', key: 'meta' } as const, { repo: null, ghError: null, configError: null, now: 0 })
18
19const TOOL = 'mcp__ship-progress__ship_progress'
20const MAX_LINES = 5
21const TICK_MS = 5_000
22const RUNNING_MS = 20_000
23const PENDING_MS = 60_000
24const MAX_BACKOFF_MS = 5 * 60_000
25const TRACKS_PER_TICK = 4
26const GH_TIMEOUT_MS = 8_000
27
28type $ = EngineInterface
29
30// The source of truth for this session's repo; mirrored to $.state for drawing
31// and to $.store for the next session. Reloaded from the store on every load.
32let p: Persisted = emptyPersisted()
33let storeKey = 'repo:-'
34let repo: string | null = null
35let config: Config = DEFAULT_CONFIG
36let configPath: string | null = null
37let configMtime = -1
38/** The hash of the config's checks; matches p.trust once the person ran /ship-progress trust. */
39let checksDigest = ''
40let repoRoot: string | null = null
41const checkLastRun: Record<string, number> = {}
42
43// Alerts: whether this host is macOS (asked once), and whether the missing-webhook note was logged.
44let isDarwin: boolean | undefined
45let isSlackNoteLogged = false
46const DEFAULT_WEBHOOK_ENV = 'SHIP_PROGRESS_SLACK_WEBHOOK'
47
48/** Directories where a `git push` or a `gh pr` command ran, and whether a merged PR counts; looked up on the next tick. */
49const branchLookups = new Map<string, boolean>()
50
51// Polling: when the next poll is due, the interval after a gh error, and the round-robin cursor.
52let nextPollAt = 0
53let backoffMs = 0
54let cursor = 0
55let isPolling = false
56/** Set when something asks for a poll now; a poll in progress does not overwrite it. */
57let isPollRequested = false
58const defaultBranches = new Map<string, string>()
59
60const lingerMs = () => config.doneLingerMinutes * 60_000
61
62function requestPoll() {
63  nextPollAt = 0
64  isPollRequested = true
65}
66
67/** Config applies to the session's own repo; tracks in other repos get the defaults. */
68const configFor = (r: string) => (r === repo ? config : DEFAULT_CONFIG)
69
70function runner($: $): Runner {
71  return (argv, opts) => $.process.run(argv, { ...opts, timeoutMs: opts?.timeoutMs ?? GH_TIMEOUT_MS })
72}
73
74/** Reads `.claude/ship-progress.json` when its mtime changed; a bad file falls back to the defaults and says why. */
75async function loadConfig($: $) {
76  if (!configPath) return
77  const stat = await $.fs.stat(configPath).catch(() => undefined)
78  const mtime = stat?.mtimeMs ?? 0
79  if (mtime === configMtime) return
80  configMtime = mtime
81  let error: string | null = null
82  try {
83    const next = stat ? parseConfig(await $.fs.read(configPath)) : DEFAULT_CONFIG
84    const digest = await checksHash(next.checks)
85    // Assigned together, after every await, so a poll never pairs new checks with the old approval.
86    config = next
87    checksDigest = digest
88  } catch (err) {
89    // A broken edit keeps the last good config: dropping its deploy stages would end tracks early.
90    error = `${configPath.split('/').slice(-2).join('/')}: ${err instanceof Error ? err.message : String(err)}`
91  }
92  await setMeta($, { configError: error })
93  requestPoll()
94}
95
96function now($: $) {
97  return $.clock.now()
98}
99
100/** Applies `fn` to the persisted state, then mirrors it to $.state and $.store. */
101const commit = async ($: $, fn: (p: Persisted) => Persisted) => {
102  p = fn(p)
103  const snapshot = p
104  await Promise.all([
105    update($, tracksAtom, () => snapshot.tracks),
106    update($, listsAtom, () => snapshot.lists),
107    $.store.set(storeKey, snapshot),
108  ])
109}
110
111function setMeta($: $, patch: Partial<Meta>) {
112  return update($, metaAtom, m => ({ ...m, ...patch }))
113}
114
115async function tick($: $) {
116  const t = await now($)
117  // Everything still in p is in flight or lingering: the sweep drops the rest.
118  const shown = p.tracks.length > 0 || p.lists.length > 0
119  const swept = sweep(p, t, lingerMs())
120  if (swept.tracks.length !== p.tracks.length || swept.lists.length !== p.lists.length) await commit($, () => swept)
121  // Redraw elapsed times only while something is on the bar.
122  if (shown) await setMeta($, { now: t })
123  await loadConfig($)
124  if (branchLookups.size && !isPolling) await resolveBranches($)
125  if (t >= nextPollAt && !isPolling) {
126    isPolling = true
127    try {
128      await poll($, t)
129    } finally {
130      isPolling = false
131    }
132  }
133}
134
135/** Polls up to TRACKS_PER_TICK active tracks, round-robin, then schedules the next poll. */
136async function poll($: $, t: number) {
137  isPollRequested = false
138  const active = p.tracks.filter(x => x.status === 'active')
139  if (active.length === 0) {
140    nextPollAt = Number.POSITIVE_INFINITY
141    return
142  }
143  const batch = Array.from({ length: Math.min(TRACKS_PER_TICK, active.length) }, (_, i) => active[(cursor + i) % active.length]!)
144  cursor = (cursor + batch.length) % Math.max(1, active.length)
145  const run = runner($)
146  const cache = { logShas: { ...p.logShas }, contains: { ...p.contains } }
147  // One PR's gh failure does not stop the others; any failure backs the next poll off.
148  let failure: string | null = null
149  for (const track of batch) {
150    const cfg = configFor(track.repo)
151    const checks = track.repo === repo && repoRoot ? { isTrusted: p.trust === checksDigest, cwd: repoRoot, lastRun: checkLastRun } : undefined
152    let facts
153    try {
154      facts = await gather(track, { run, config: cfg, now: t, defaultBranches, localRepo: repo, cache, checks })
155    } catch (err) {
156      if (!(err instanceof GhError)) throw err
157      failure = err.message
158      continue
159    }
160    let alerts: Alert[] = []
161    await commit($, x => {
162      const tracks = x.tracks.map(y => {
163        if (y.key !== track.key) return y
164        const next = apply(y, facts, cfg, t)
165        // Alert preferences are the session's, whichever repo the PR is in.
166        alerts = transitions(y, next, config.notify, repo).filter(a => !x.alerts.includes(a.key))
167        return next
168      })
169      return { ...x, tracks, logShas: cache.logShas, contains: cache.contains, alerts: [...x.alerts, ...alerts.map(a => a.key)] }
170    })
171    for (const a of alerts) void sendAlert($, a, config).catch(err => $.ui.log(`ship-progress alert failed: ${err instanceof Error ? err.message : 'error'}`, { to: 'debug' }))
172  }
173  backoffMs = failure ? Math.min(MAX_BACKOFF_MS, Math.max(RUNNING_MS, backoffMs) * 2) : 0
174  await setMeta($, { ghError: failure })
175  const isRunning = p.tracks.some(x => x.status === 'active' && x.stages.some(s => s.state === 'running'))
176  nextPollAt = isPollRequested ? 0 : t + (backoffMs || (isRunning ? RUNNING_MS : PENDING_MS))
177}
178
179/** The webhook URL from the environment variable the config names. Never stored, logged or shown. */
180async function webhookUrl($: $, name: string) {
181  if (name === DEFAULT_WEBHOOK_ENV) return $.env.get('SHIP_PROGRESS_SLACK_WEBHOOK')
182  // $.env.get takes literal names only; a custom name is read from a child's environment.
183  const out = await $.process.run(['printenv', name], { timeoutMs: 2_000 }).catch(() => undefined)
184  return out?.exitCode === 0 ? out.stdout.trim() || undefined : undefined
185}
186
187/** Wakes the agent, raises a macOS banner and posts to Slack, as the config allows. */
188async function sendAlert($: $, a: Alert, cfg: Config) {
189  if (cfg.notify.wakeAgent) {
190    // A fixed template: PR titles, job names and check output stay out of the agent's prompt.
191    const text = `ship-progress: ${a.wake}${a.url ? ` ${a.url}` : ''}\nCall ship_progress with action "status" for details. Watch only: the mod changed nothing; decide whether to act.`
192    void $.prompt.submit({ text }).catch(() => undefined)
193  }
194  if (cfg.notify.macos) {
195    isDarwin ??= (await $.process.run(['uname', '-s'], { timeoutMs: 2_000 }).catch(() => undefined))?.stdout.trim() === 'Darwin'
196    if (isDarwin) await $.process.run(osascriptArgv(a.text), { timeoutMs: 5_000 }).catch(() => undefined)
197  }
198  if (cfg.notify.slack) {
199    const url = await webhookUrl($, cfg.notify.slack.webhookEnv)
200    if (!url) {
201      if (!isSlackNoteLogged) $.ui.log(`ship-progress: ${cfg.notify.slack.webhookEnv} is not set; Slack alerts are off`, { to: 'debug' })
202      isSlackNoteLogged = true
203      return
204    }
205    const text = a.url ? `ship-progress: ${a.text} <${a.url}|open>` : `ship-progress: ${a.text}`
206    const res = await $.http.fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text }) }).catch(() => undefined)
207    if (!res?.ok) $.ui.log(`ship-progress: slack webhook POST failed (status ${res?.status ?? 'none'})`, { to: 'debug' })
208  }
209}
210
211/** A Bash call named a PR, or pushed a branch: track it now, or look the branch's PR up on a tick. */
212async function noteHit($: $, hit: Hit) {
213  if (hit.kind === 'pr') {
214    await startTrack($, hit.ref).catch(() => undefined)
215    return
216  }
217  const cwd = await $.session.cwd()
218  branchLookups.set(cwd, hit.isMergedOk || branchLookups.get(cwd) === true)
219}
220
221/** Looks up the current branch's PR where a push ran; tracks it when it is open or just merged. */
222async function resolveBranches($: $) {
223  const dirs = [...branchLookups]
224  branchLookups.clear()
225  for (const [cwd, isMergedOk] of dirs) {
226    const out = await $.process.run(['gh', 'pr', 'view', '--json', 'url,state'], { cwd, timeoutMs: GH_TIMEOUT_MS }).catch(() => undefined)
227    if (out?.exitCode !== 0) continue // no PR for this branch
228    try {
229      const pr = JSON.parse(out.stdout) as { url: string; state: string }
230      if (pr.state === 'OPEN' || (isMergedOk && pr.state === 'MERGED')) await startTrack($, pr.url)
231    } catch {
232      // unreadable output: nothing to track
233    }
234  }
235}
236
237/** Starts following a PR; the next tick polls it. */
238async function startTrack($: $, ref: string) {
239  const r = parsePr(ref, repo)
240  if ('error' in r) throw new Error(r.error)
241  const key = `${r.repo}#${r.number}`
242  if (p.tracks.some(x => x.key === key)) return `${key} is already tracked`
243  const t = await now($)
244  const track = { ...newTrack(r.repo, r.number, t) }
245  track.stages = shapeStages(track.stages, configFor(r.repo))
246  // Checked again at the write: a Bash hit, /track and a branch lookup can overlap.
247  await commit($, x => (x.tracks.some(y => y.key === key) ? x : { ...x, tracks: [...x.tracks, track] }))
248  requestPoll()
249  return `tracking ${key}`
250}
251
252async function stopTrack($: $, ref: string) {
253  const r = parsePr(ref, repo)
254  if ('error' in r) throw new Error(r.error)
255  const key = `${r.repo}#${r.number}`
256  const track = p.tracks.find(x => x.key === key)
257  if (!track) return `${key} is not tracked`
258  const t = await now($)
259  await commit($, x => ({
260    ...x,
261    tracks: x.tracks.filter(y => y.key !== key),
262    history: [...x.history, { key, title: track.title, status: 'untracked', sha: track.mergeSha, endedAt: t }],
263  }))
264  return `stopped tracking ${key}`
265}
266
267/** `/track` with no argument: what is tracked now, and what shipped in the last 7 days. */
268async function listing($: $) {
269  const t = await now($)
270  const lines = p.tracks.map(x => plainLine(trackLine(x, t, repo)))
271  const recent = [...p.history].reverse().slice(0, 10).map(h => `  ${h.key} ${h.status}${h.sha ? ` ${h.sha.slice(0, 8)}` : ''}${h.title ? `  ${h.title}` : ''}`)
272  return [
273    lines.length ? lines.join('\n') : 'no PRs tracked (/track <PR#>)',
274    ...(recent.length ? ['recent:', ...recent] : []),
275  ].join('\n')
276}
277
278async function runTool($: $, input: ToolInput): Promise<string> {
279  const t = await now($)
280  const pr = (raw: string) => {
281    const r = parsePr(raw, repo)
282    if ('error' in r) throw new Error(r.error)
283    return `${r.repo}#${r.number}`
284  }
285  switch (input.action) {
286    case 'list_set':
287      await commit($, x => ({ ...x, lists: listSet(x.lists, input.list, input.title, input.steps, t) }))
288      return `ok: list "${input.list}" has ${input.steps.length} steps`
289    case 'set_step':
290      if (input.list !== undefined) {
291        const list = input.list
292        await commit($, x => ({ ...x, lists: setListStep(x.lists, list, input.name, input.state, input.note, t) }))
293      } else {
294        const key = pr(input.pr!)
295        await commit($, x => ({ ...x, tracks: setTrackStep(x.tracks, key, input.name, input.state, input.note, t) }))
296      }
297      return `ok: ${input.name} → ${input.state}`
298    case 'add_step': {
299      const key = pr(input.pr)
300      await commit($, x => ({ ...x, tracks: addTrackStep(x.tracks, key, input.name, input.after, t) }))
301      return `ok: added step "${input.name}" to ${key}`
302    }
303    case 'annotate': {
304      const key = pr(input.pr)
305      await commit($, x => ({ ...x, tracks: annotate(x.tracks, key, input.stage, input.note, t) }))
306      return `ok: noted on ${key}${input.stage ? ` (${input.stage})` : ''}`
307    }
308    case 'status':
309      return JSON.stringify({ repo, tracks: p.tracks, lists: p.lists, history: p.history }, null, 1)
310    case 'track':
311      return startTrack($, input.pr)
312    case 'untrack':
313      return stopTrack($, input.pr)
314  }
315}
316
317export const register: Register = on => {
318  on('session.start', async ($, e, next) => {
319    const started = await next(e)
320    const r = await $.session.repo()
321    repo = repoFromRemote(r?.remote)
322    repoRoot = r?.root ?? null
323    // SHIP_PROGRESS_CONFIG points at a config outside the repo, to try one before committing it.
324    configPath = (await $.env.get('SHIP_PROGRESS_CONFIG')) || (r ? `${r.root}/.claude/ship-progress.json` : null)
325    configMtime = -1
326    config = DEFAULT_CONFIG
327    storeKey = `repo:${repo ?? '-'}`
328    await setMeta($, { repo, ghError: null, configError: null, now: await now($) })
329    await loadConfig($)
330    p = sweep(migrate(await $.store.get(storeKey)), await now($), lingerMs())
331    requestPoll()
332    await commit($, x => x)
333
334    await $.tool.register({
335      name: 'ship_progress',
336      description:
337        'Track pull requests to production and show task progress in the bar above the prompt. ' +
338        'The mod polls GitHub itself (PR CI → merged → main CI → deploys → checks) and wakes you when a stage fails or a PR goes live, ' +
339        'so do not poll gh yourself for tracked PRs: call action "status" instead. ' +
340        'Actions: track {pr}; untrack {pr}; annotate {pr, stage?, note}; add_step {pr, name, after?} adds a step you own to a PR; ' +
341        'set_step {pr|list, name, state, note?} sets one of your steps; list_set {list, title, steps:[{name, state?, after?}]} declares a task list with no GitHub involvement; ' +
342        'status {} returns every track and list as JSON. Watch only: it never merges, reruns or edits anything.',
343      inputSchema: INPUT_SCHEMA,
344    })
345    await $.command.register({ name: 'track', description: 'ship-progress: follow a PR to production, or list tracked PRs', argumentHint: '[PR#|url]' })
346    await $.command.register({ name: 'untrack', description: 'ship-progress: stop following a PR', argumentHint: '<PR#>' })
347    await $.command.register({ name: 'ship-progress', description: 'ship-progress: show status, trust this repo\'s checks, or clear task lists', argumentHint: '[status|trust|clear]' })
348
349    $.clock.every(TICK_MS, () => void tick($).catch(err => $.ui.log(`ship-progress tick failed: ${String(err)}`, { to: 'debug' })))
350    return started
351  })
352
353  // Auto-tracking: read each Bash call after it ran. Parsing only; gh lookups wait for a tick.
354  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
355    const r = await next(e)
356    if (r.deny === undefined && !r.isError) {
357      const hit = matchCommand(e.command, r.text ?? '')
358      if (hit) await noteHit($, hit)
359    }
360    return r
361  }).catch(($, e, next) => next(e))
362
363  on('tool.call', { tool: TOOL }, async ($, e) => {
364    try {
365      return { result: await runTool($, parseInput(e)) }
366    } catch (err) {
367      return { result: `error: ${err instanceof Error ? err.message : String(err)}` }
368    }
369  })
370
371  on('command.run', { command: 'track' }, async ($, e) => {
372    const arg = e.args.trim()
373    try {
374      return { text: arg ? await startTrack($, arg) : await listing($) }
375    } catch (err) {
376      return { text: err instanceof Error ? err.message : String(err) }
377    }
378  })
379
380  on('command.run', { command: 'untrack' }, async ($, e) => {
381    try {
382      return { text: await stopTrack($, e.args.trim()) }
383    } catch (err) {
384      return { text: err instanceof Error ? err.message : String(err) }
385    }
386  })
387
388  on('command.run', { command: 'ship-progress' }, async ($, e) => {
389    const [sub = 'status'] = e.args.trim().split(/\s+/).filter(Boolean)
390    if (sub === 'trust') {
391      // Checks run commands from a committed file: only the person, typing at their own prompt, approves them.
392      const who = e.origin?.kind
393      if (who !== 'composer' && who !== 'bridge') return { text: 'only the person at the prompt can trust checks: type /ship-progress trust' }
394      if (config.checks.length === 0) return { text: 'this repo configures no checks' }
395      await commit($, x => ({ ...x, trust: checksDigest }))
396      requestPoll()
397      const list = config.checks.map(c => `  ${c.name}: ${JSON.stringify(c.argv)}${c.env ? ` env ${JSON.stringify(Object.keys(c.env))}` : ''}`)
398      return { text: [`trusted ${config.checks.length} check(s) for ${repo ?? 'this repo'}; any change to them asks again:`, ...list].join('\n') }
399    }
400    if (sub === 'clear') {
401      const n = p.lists.length
402      await commit($, x => ({ ...x, lists: [] }))
403      return { text: `cleared ${n} task list(s); PR tracks are kept (use /untrack)` }
404    }
405    if (sub !== 'status') return { text: `unknown subcommand "${sub}" (status, trust, clear)` }
406    const t = await now($)
407    const lines = [...p.tracks.map(x => trackLine(x, t, repo)), ...p.lists.map(x => listLine(x, t))]
408    return { text: lines.length ? lines.map(plainLine).join('\n') : 'nothing tracked' }
409  })
410
411  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
412    const [tracks, lists, meta] = await Promise.all([read($, tracksAtom), read($, listsAtom), read($, metaAtom)])
413    if (e.props.hasSurvey) return next(e)
414    const t = meta.now
415    const items: { at: number; line: Segment[] }[] = [
416      ...tracks.map((x: Track) => ({ at: x.updatedAt, line: trackLine(x, t, meta.repo) })),
417      ...lists.map((x: TaskList) => ({ at: x.updatedAt, line: listLine(x, t) })),
418    ].sort((a, b) => b.at - a.at)
419    if (items.length === 0) return next(e)
420
421    const { Box, Text } = $.ui.resolve(e)
422    const rows = items.slice(0, MAX_LINES).map(i => i.line)
423    if (meta.ghError) rows.push([{ text: `⚠ gh: ${meta.ghError}`, color: 'warning' }])
424    if (meta.configError) rows.push([{ text: `⚠ ${meta.configError}`, color: 'warning' }])
425    return (
426      <Box flexDirection="column">
427        {rows.map((line, r) => (
428          <Text key={`l${r}`} wrap="truncate-end">
429            {joinSegments(line).map((s, i) => (
430              <Text key={`s${i}`} color={s.color} dimColor={s.dim} bold={s.bold}>
431                {s.text}
432              </Text>
433            ))}
434          </Text>
435        ))}
436        {items.length > MAX_LINES && <Text dimColor>+{items.length - MAX_LINES} more</Text>}
437      </Box>
438    )
439  })
440}
441
hooks/lib/input.ts 142 lines
1import type { StageState } from '../../types'
2
3export const ACTIONS = ['track', 'untrack', 'annotate', 'add_step', 'set_step', 'list_set', 'status'] as const
4export type Action = (typeof ACTIONS)[number]
5
6/** States an agent may set on its own steps; `superseded` is the mod's alone. */
7export const STEP_STATES = ['pending', 'running', 'passed', 'failed', 'skipped', 'warning'] as const
8
9export type StepSpec = { name: string; state?: StageState; after?: string }
10
11export type ToolInput =
12  | { action: 'track'; pr: string }
13  | { action: 'untrack'; pr: string }
14  | { action: 'annotate'; pr: string; stage?: string; note: string }
15  | { action: 'add_step'; pr: string; name: string; after?: string }
16  | { action: 'set_step'; pr?: string; list?: string; name: string; state: StageState; note?: string }
17  | { action: 'list_set'; list: string; title: string; steps: StepSpec[] }
18  | { action: 'status' }
19
20const FIELDS: Record<Action, { required: string[]; optional: string[] }> = {
21  track: { required: ['pr'], optional: [] },
22  untrack: { required: ['pr'], optional: [] },
23  annotate: { required: ['pr', 'note'], optional: ['stage'] },
24  add_step: { required: ['pr', 'name'], optional: ['after'] },
25  set_step: { required: ['name', 'state'], optional: ['pr', 'list', 'note'] },
26  list_set: { required: ['list', 'title', 'steps'], optional: [] },
27  status: { required: [], optional: [] },
28}
29
30/** Fields the engine puts beside a tool's arguments on `e`; never the model's. */
31const RESERVED = new Set(['tool', 'tool_use_id', 'agentId', 'requestMeta'])
32
33/** Some models send nested values as JSON text; read those back. */
34const unstring = (value: unknown) => {
35  if (typeof value !== 'string') return value
36  const t = value.trim()
37  if (!(t.startsWith('{') || t.startsWith('['))) return value
38  try {
39    return JSON.parse(t) as unknown
40  } catch {
41    return value
42  }
43}
44
45const text = (v: unknown, field: string, max = 200): string => {
46  if (typeof v === 'number' && field === 'pr') return String(v)
47  if (typeof v !== 'string' || !v.trim()) throw new Error(`"${field}" must be a non-empty string`)
48  if (v.length > max) throw new Error(`"${field}" is longer than ${max} characters`)
49  return v.trim()
50}
51
52const state = (v: unknown, field: string): StageState => {
53  if (!(STEP_STATES as readonly unknown[]).includes(v)) throw new Error(`"${field}" must be one of ${STEP_STATES.join(', ')}`)
54  return v as StageState
55}
56
57const step = (raw: unknown, i: number): StepSpec => {
58  const v = unstring(raw)
59  if (typeof v === 'string') return { name: text(v, `steps[${i}]`, 80) }
60  if (typeof v !== 'object' || v === null || Array.isArray(v)) throw new Error(`steps[${i}] must be an object { name, state?, after? }`)
61  const o = v as Record<string, unknown>
62  const extra = Object.keys(o).filter(k => !['name', 'state', 'after'].includes(k))
63  if (extra.length) throw new Error(`steps[${i}] has unknown field(s): ${extra.join(', ')}`)
64  return {
65    name: text(o.name, `steps[${i}].name`, 80),
66    ...(o.state !== undefined && { state: state(o.state, `steps[${i}].state`) }),
67    ...(o.after !== undefined && { after: text(o.after, `steps[${i}].after`, 80) }),
68  }
69}
70
71/** Validates the `ship_progress` tool's input against a closed schema; throws a readable error. */
72export const parseInput = (raw: unknown): ToolInput => {
73  const top = unstring(raw)
74  if (typeof top !== 'object' || top === null || Array.isArray(top)) throw new Error('input must be an object')
75  const o = Object.fromEntries(Object.entries(top as Record<string, unknown>).filter(([k]) => !RESERVED.has(k)))
76  const action = o.action
77  if (!(ACTIONS as readonly unknown[]).includes(action)) throw new Error(`"action" must be one of ${ACTIONS.join(', ')}`)
78  const spec = FIELDS[action as Action]
79  const allowed = new Set(['action', ...spec.required, ...spec.optional])
80  const unknown = Object.keys(o).filter(k => !allowed.has(k) && o[k] !== undefined && o[k] !== null)
81  if (unknown.length) throw new Error(`unknown field(s) for "${String(action)}": ${unknown.join(', ')} (allowed: ${[...allowed].join(', ')})`)
82  const missing = spec.required.filter(k => o[k] === undefined || o[k] === null)
83  if (missing.length) throw new Error(`"${String(action)}" needs: ${missing.join(', ')}`)
84  const opt = (k: string, max?: number) => (o[k] === undefined || o[k] === null ? {} : { [k]: text(o[k], k, max) })
85
86  switch (action as Action) {
87    case 'track':
88    case 'untrack':
89      return { action: action as 'track', pr: text(o.pr, 'pr') }
90    case 'annotate':
91      return { action: 'annotate', pr: text(o.pr, 'pr'), note: text(o.note, 'note', 160), ...opt('stage') }
92    case 'add_step':
93      return { action: 'add_step', pr: text(o.pr, 'pr'), name: text(o.name, 'name', 80), ...opt('after') }
94    case 'set_step': {
95      const hasPr = o.pr !== undefined && o.pr !== null
96      const hasList = o.list !== undefined && o.list !== null
97      if (hasPr === hasList) throw new Error('"set_step" needs exactly one of "pr" or "list"')
98      return { action: 'set_step', name: text(o.name, 'name', 80), state: state(o.state, 'state'), ...opt('pr'), ...opt('list'), ...opt('note', 160) }
99    }
100    case 'list_set': {
101      const steps = unstring(o.steps)
102      if (!Array.isArray(steps) || steps.length === 0) throw new Error('"steps" must be a non-empty array')
103      if (steps.length > 30) throw new Error('"steps" holds at most 30 steps')
104      return { action: 'list_set', list: text(o.list, 'list', 60), title: text(o.title, 'title', 60), steps: steps.map(step) }
105    }
106    case 'status':
107      return { action: 'status' }
108  }
109}
110
111/** The tool's JSON schema, as the model sees it. */
112export const INPUT_SCHEMA = {
113  type: 'object',
114  additionalProperties: false,
115  properties: {
116    action: { type: 'string', enum: [...ACTIONS] },
117    pr: { type: 'string', description: 'A PR: 608, #608, owner/repo#608 or its URL.' },
118    list: { type: 'string', description: 'A task list id (list_set, set_step).' },
119    title: { type: 'string', description: 'The task list title (list_set).' },
120    name: { type: 'string', description: 'A step name (add_step, set_step).' },
121    stage: { type: 'string', description: 'A stage id to annotate: pr-ci, merged, main-ci, deploy:<name>, check:<name>, or a step name.' },
122    after: { type: 'string', description: 'Place the new step after this stage id or step name (add_step).' },
123    state: { type: 'string', enum: [...STEP_STATES] },
124    note: { type: 'string', description: 'A short note, under 160 characters.' },
125    steps: {
126      type: 'array',
127      description: 'The list steps in order (list_set).',
128      items: {
129        type: 'object',
130        additionalProperties: false,
131        properties: {
132          name: { type: 'string' },
133          state: { type: 'string', enum: [...STEP_STATES] },
134          after: { type: 'string' },
135        },
136        required: ['name'],
137      },
138    },
139  },
140  required: ['action'],
141} as const
142
hooks/lib/model.ts 157 lines
1import type { Stage, StageState, TaskList, Track } from '../../types'
2
3export const PLUGIN = 'ship-progress'
4export const DEFAULT_LINGER_MS = 10 * 60_000
5
6export const isSettled = (state: StageState) => state === 'passed' || state === 'skipped'
7
8/** The stage the track is waiting on: the first one that has not passed. */
9export const currentStage = (stages: readonly Stage[]): Stage | undefined =>
10  stages.find(s => !isSettled(s.state))
11
12export const PIPS: Record<StageState, string> = {
13  passed: '■',
14  skipped: '↷',
15  running: '▣',
16  pending: '□',
17  warning: '▣',
18  failed: '✕',
19  superseded: '↷',
20}
21
22/** Theme keys, so the bar follows the person's theme. Undefined means dim. */
23export const COLORS: Record<StageState, 'success' | 'warning' | 'error' | undefined> = {
24  passed: 'success',
25  skipped: 'success',
26  running: 'warning',
27  warning: 'warning',
28  failed: 'error',
29  pending: undefined,
30  superseded: undefined,
31}
32
33export const shortSha = (sha: string | undefined) => (sha ? sha.slice(0, 8) : '')
34
35export const elapsed = (ms: number) => {
36  const s = Math.max(0, Math.round(ms / 1000))
37  if (s < 60) return `${s}s`
38  const m = Math.floor(s / 60)
39  if (m < 60) return `${m}m`
40  const h = Math.floor(m / 60)
41  return `${h}h${String(m % 60).padStart(2, '0')}`
42}
43
44/** A piece of a bar line: text, and the theme color it is drawn in (none = plain, dim = dimmed). */
45export type Segment = { text: string; color?: 'success' | 'warning' | 'error'; dim?: boolean; bold?: boolean }
46
47const VERB: Partial<Record<StageState, string>> = {
48  running: 'running',
49  pending: 'waiting',
50  failed: 'failed',
51  warning: '',
52  superseded: 'superseded',
53}
54
55const stageWords = (stage: Stage) => {
56  if (stage.id === 'merged') return stage.state === 'pending' ? 'awaiting merge' : `merge ${VERB[stage.state] ?? ''}`.trim()
57  if (stage.id.startsWith('deploy:') && stage.state === 'running') return `deploying (${stage.label})`
58  const verb = VERB[stage.state]
59  return verb ? `${stage.label} ${verb}` : stage.label
60}
61
62const pips = (stages: readonly Stage[]): Segment[] =>
63  stages.map(s => ({ text: PIPS[s.state], color: COLORS[s.state], dim: COLORS[s.state] === undefined }))
64
65/** The status words of a track or list whose stages are `stages`, and their color. */
66const status = (stages: readonly Stage[], isClosed: boolean, now: number): Segment[] => {
67  if (isClosed) return [{ text: 'closed', color: 'error' }]
68  const stage = currentStage(stages)
69  if (!stage) return [{ text: 'live', color: 'success', bold: true }]
70  const out: Segment[] = [{ text: stageWords(stage), color: COLORS[stage.state], dim: COLORS[stage.state] === undefined }]
71  if (stage.startedAt !== undefined && (stage.state === 'running' || stage.state === 'pending')) {
72    out.push({ text: elapsed(now - stage.startedAt), dim: true })
73  }
74  return out
75}
76
77/** The first warning or failure note among the stages, for the ` ⚠ <warning>` suffix. */
78const warningOf = (stages: readonly Stage[]) =>
79  stages.find(s => (s.state === 'warning' || s.state === 'failed') && s.note)?.note
80
81export const trackLine = (track: Track, now: number, repo: string | null): Segment[] => {
82  const name = track.repo === repo ? `#${track.number}` : `${track.repo}#${track.number}`
83  const out: Segment[] = [{ text: name, bold: true }, ...pips(track.stages)]
84  out.push(...status(track.stages, track.status === 'closed', now))
85  const sha = track.deployedSha ?? track.mergeSha ?? track.headSha
86  if (sha) out.push({ text: shortSha(sha), dim: true })
87  if (track.status === 'done' && track.stages.some(s => s.id.startsWith('check:'))) out.splice(out.length - 1, 0, { text: 'checks ✓', color: 'success' })
88  const warning = warningOf(track.stages)
89  if (warning) out.push({ text: `⚠ ${warning}`, color: 'warning' })
90  return out
91}
92
93export const listLine = (list: TaskList, now: number): Segment[] => {
94  const done = list.steps.filter(s => isSettled(s.state)).length
95  const out: Segment[] = [{ text: list.title, bold: true }, ...pips(list.steps)]
96  out.push(...status(list.steps, false, now))
97  out.push({ text: `${done}/${list.steps.length}`, dim: true })
98  const warning = warningOf(list.steps)
99  if (warning) out.push({ text: `⚠ ${warning}`, color: 'warning' })
100  return out
101}
102
103/** Pips are one glyph each and sit together; every other segment is space-separated. */
104export const joinSegments = (segments: readonly Segment[]): Segment[] => {
105  const out: Segment[] = []
106  segments.forEach((seg, i) => {
107    const prev = segments[i - 1]
108    const isPip = seg.text.length === 1 && Object.values(PIPS).includes(seg.text)
109    const prevIsPip = prev !== undefined && prev.text.length === 1 && Object.values(PIPS).includes(prev.text)
110    if (i > 0 && !(isPip && prevIsPip)) out.push({ text: isPip ? ' ' : prevIsPip ? '  ' : ' ' })
111    out.push(seg)
112  })
113  return out
114}
115
116export const plainLine = (segments: readonly Segment[]) => joinSegments(segments).map(s => s.text).join('')
117
118/** `608`, `#608`, `owner/repo#608` or a PR URL, read against the session's repo. */
119export const parsePr = (raw: unknown, repo: string | null): { repo: string; number: number } | { error: string } => {
120  const text = String(raw ?? '').trim()
121  const url = /github\.com\/([\w.-]+\/[\w.-]+)\/pull\/(\d+)/.exec(text)
122  if (url) return { repo: url[1]!, number: Number(url[2]) }
123  const full = /^([\w.-]+\/[\w.-]+)#(\d+)$/.exec(text)
124  if (full) return { repo: full[1]!, number: Number(full[2]) }
125  const bare = /^#?(\d+)$/.exec(text)
126  if (bare) {
127    if (!repo) return { error: `can't tell which repo PR ${text} is in; use owner/repo#${bare[1]}` }
128    return { repo, number: Number(bare[1]) }
129  }
130  return { error: `not a PR reference: "${text}" (use 608, #608, owner/repo#608 or a PR URL)` }
131}
132
133export const trackKey = (repo: string, number: number) => `${repo}#${number}`
134
135/** `owner/repo` from a GitHub remote URL (ssh or https), or null. */
136export const repoFromRemote = (remote: string | null | undefined) => {
137  const m = /github\.com[:/]([\w.-]+\/[\w.-]+?)(?:\.git)?\/?$/.exec(remote ?? '')
138  return m ? m[1]! : null
139}
140
141/** Inserts `stage` after the stage whose id or label is `after`, or at the end. */
142export const insertAfter = (stages: readonly Stage[], stage: Stage, after?: string) => {
143  const i = after === undefined ? -1 : stages.findIndex(s => s.id === after || s.label === after || s.id === `step:${after}`)
144  return i < 0 ? [...stages, stage] : [...stages.slice(0, i + 1), stage, ...stages.slice(i + 1)]
145}
146
147/** Stamps start and end times on a stage moving to `state`. */
148export const moveStage = (stage: Stage, state: StageState, now: number, extra: Partial<Stage> = {}): Stage => {
149  const next: Stage = { ...stage, ...extra, state }
150  if (state !== stage.state) {
151    if (state === 'running' || (stage.startedAt === undefined && state !== 'pending')) next.startedAt ??= now
152    if (isSettled(state) || state === 'failed') next.endedAt = now
153    else delete next.endedAt
154  }
155  return next
156}
157
hooks/lib/alerts.ts 36 lines
1import type { Stage, Track } from '../../types'
2import type { Config } from './config'
3import { shortSha } from './model'
4
5/** `text` is for the person (banner, Slack); `wake` is the agent's prompt and holds no text from GitHub or a check. */
6export type Alert = { key: string; text: string; wake: string; url?: string }
7
8const name = (t: Track, repo: string | null) => (t.repo === repo ? `#${t.number}` : `${t.repo}#${t.number}`)
9
10const stageText = (s: Stage) => `${s.id} ${s.state}${s.note ? `: ${s.note}` : ''}`
11
12/**
13 * The alerts a poll raised: each stage that moved into a state `notify.on` lists,
14 * and the track going live. The key names the track, stage, state and what the
15 * state is about (a run, a head SHA), so each fires once and a new failure on a
16 * new run fires again.
17 */
18export const transitions = (prev: Track | undefined, next: Track, notify: Config['notify'], repo: string | null): Alert[] => {
19  const out: Alert[] = []
20  for (const s of next.stages) {
21    const before = prev?.stages.find(x => x.id === s.id)
22    if (before?.state === s.state || !notify.on.includes(s.state)) continue
23    out.push({ key: `${next.key}|${s.id}|${s.state}|${s.ref ?? ''}`, text: `${name(next, repo)} ${stageText(s)}`, wake: `${name(next, repo)} ${s.id} ${s.state}`, url: s.url ?? next.url })
24  }
25  if (next.status === 'done' && prev?.status !== 'done' && notify.on.includes('done')) {
26    const sha = next.deployedSha ?? next.mergeSha
27    out.push({ key: `${next.key}|done`, text: `${name(next, repo)} live${sha ? ` (${shortSha(sha)})` : ''}${next.title ? `: ${next.title}` : ''}`, wake: `${name(next, repo)} live${sha ? ` (${shortSha(sha)})` : ''}`, url: next.url })
28  }
29  return out
30}
31
32/** Escapes text for an AppleScript string literal. */
33export const appleScriptString = (text: string) => `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/[\r\n]+/g, ' ')}"`
34
35export const osascriptArgv = (text: string) => ['osascript', '-e', `display notification ${appleScriptString(text)} with title "ship-progress"`]
36
hooks/lib/autotrack.ts 76 lines
1/** What a Bash command tells us about a PR to track. */
2export type Hit =
3  /** The PR is known from the command or its output. */
4  | { kind: 'pr'; ref: string }
5  /** The PR is the current branch's; resolve it with `gh pr view` on a tick. A push tracks only an open one. */
6  | { kind: 'branch'; isMergedOk: boolean }
7
8const PR_URL = /https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/pull\/\d+/
9/** `gh pr merge` prints `✓ Squashed and merged pull request owner/repo#123 (title)`. */
10const PR_REF = /\b([\w.-]+\/[\w.-]+)#(\d+)\b/
11
12/** The command's segments: split on `&&`, `||`, `;`, `|` and newlines. */
13const segments = (command: string) => command.split(/&&|\|\||[;|\n]/).map(s => s.trim()).filter(Boolean)
14
15const words = (segment: string): string[] => segment.match(/"[^"]*"|'[^']*'|\S+/g) ?? []
16
17/** The PR argument after `gh pr <verb>`, skipping flags (and a flag's value where it takes one). */
18const prArg = (args: string[]) => {
19  const valued = new Set(['-R', '--repo', '-t', '--title', '-b', '--body', '-F', '--body-file', '--subject', '--author-email', '--match-head-commit'])
20  for (let i = 0; i < args.length; i++) {
21    const a = args[i]!
22    if (valued.has(a)) i++
23    else if (!a.startsWith('-')) return a.replace(/^['"]|['"]$/g, '')
24  }
25  return undefined
26}
27
28const repoFlag = (args: string[]) => {
29  const i = args.findIndex(a => a === '-R' || a === '--repo')
30  if (i >= 0) return args[i + 1]
31  return args.find(a => a.startsWith('--repo='))?.slice('--repo='.length)
32}
33
34/** The subcommand after `git`, skipping global options (`-C <dir>`, `-c <k=v>`, `--no-pager`). */
35const gitSubcommand = (w: string[]) => {
36  const at = w.indexOf('git')
37  if (at < 0) return undefined
38  for (let i = at + 1; i < w.length; i++) {
39    const a = w[i]!
40    if (a === '-C' || a === '-c') i++
41    else if (!a.startsWith('-')) return a
42  }
43  return undefined
44}
45
46/**
47 * Reads one Bash call: `gh pr create`, `gh pr merge`, `gh pr ready` and `git push`.
48 * The output is read first (it names the PR exactly); then the argument; then the branch.
49 */
50export const matchCommand = (command: string, output: string): Hit | undefined => {
51  for (const seg of segments(command)) {
52    const w = words(seg)
53    const at = w.findIndex((x, i) => x === 'gh' && w[i + 1] === 'pr')
54    if (at >= 0) {
55      const verb = w[at + 2]
56      const args = w.slice(at + 3)
57      if (!['create', 'merge', 'ready'].includes(verb ?? '') || args.includes('--help') || args.includes('-h')) continue
58      const url = PR_URL.exec(output)
59      if (url) return { kind: 'pr', ref: url[0] }
60      const ref = PR_REF.exec(output)
61      if (ref && verb !== 'create') return { kind: 'pr', ref: `${ref[1]}#${ref[2]}` }
62      const arg = verb === 'create' ? undefined : prArg(args)
63      if (arg && /^(#?\d+|https:\/\/github\.com\/\S+\/pull\/\d+)$/.test(arg)) {
64        const repo = repoFlag(args)
65        return { kind: 'pr', ref: repo && /^#?\d+$/.test(arg) ? `${repo}#${arg.replace('#', '')}` : arg }
66      }
67      return { kind: 'branch', isMergedOk: verb === 'merge' }
68    }
69    if (gitSubcommand(w) === 'push') {
70      if (w.includes('--tags') || w.includes('--delete') || w.includes('-d')) continue
71      return { kind: 'branch', isMergedOk: false }
72    }
73  }
74  return undefined
75}
76
hooks/lib/checks.ts 50 lines
1import type { Track } from '../../types'
2import type { CheckConfig } from './config'
3import type { Runner } from './gh'
4import type { StageFact } from './pipeline'
5
6/** The most a single check run may take; its interval and timeout bound it further. */
7const CHECK_RUN_MAX_MS = 60_000
8
9/** Fills whole-element placeholders. Unknown or unresolved placeholders are an error, never passed through. */
10export const substitute = (argv: readonly string[], track: Track): string[] => {
11  const deployed = Object.fromEntries(track.stages.filter(s => s.id.startsWith('deploy:') && s.sha).map(s => [s.label, s.sha!]))
12  return argv.map(arg => {
13    const m = /^\{(mergeSha|prNumber|repo|deployedSha:([\w .:-]+))\}$/.exec(arg)
14    if (!m) {
15      if (/^\{[^}]*\}$/.test(arg)) throw new Error(`unknown placeholder ${arg}`)
16      return arg
17    }
18    const value = m[2] !== undefined ? deployed[m[2]] : m[1] === 'mergeSha' ? track.mergeSha : m[1] === 'prNumber' ? String(track.number) : track.repo
19    if (!value) throw new Error(`${arg} has no value yet`)
20    return value
21  })
22}
23
24/** SHA-256 of the checks array: what `/ship-progress trust` approves. */
25export const checksHash = async (checks: readonly CheckConfig[]) => {
26  const bytes = new TextEncoder().encode(JSON.stringify(checks))
27  const digest = await crypto.subtle.digest('SHA-256', bytes)
28  return [...new Uint8Array(digest)].map(b => b.toString(16).padStart(2, '0')).join('')
29}
30
31const lastLine = (text: string) => text.trim().split('\n').at(-1)?.trim().slice(0, 80) ?? ''
32
33/** Runs one check once: exit 0 passes; anything else keeps it running with the output's last line. */
34export const runCheck = async (run: Runner, check: CheckConfig, track: Track, cwd: string): Promise<StageFact> => {
35  let argv: string[]
36  try {
37    argv = substitute(check.argv, track)
38  } catch (err) {
39    return { state: 'failed', note: err instanceof Error ? err.message : String(err) }
40  }
41  const timeoutMs = Math.min(CHECK_RUN_MAX_MS, check.intervalSeconds * 1000)
42  try {
43    const out = await run(argv, { cwd, timeoutMs, ...(check.env && { env: check.env }) })
44    if (out.exitCode === 0) return { state: 'passed' }
45    return { state: 'running', note: `exit ${out.exitCode}${lastLine(out.stderr || out.stdout) ? `: ${lastLine(out.stderr || out.stdout)}` : ''}` }
46  } catch (err) {
47    return { state: 'running', note: /time/i.test(String(err)) ? `no answer within ${timeoutMs / 1000}s` : `could not run: ${lastLine(String(err))}` }
48  }
49}
50
hooks/lib/config.ts 169 lines
1import type { StageState } from '../../types'
2
3export type DeployConfig = {
4  name: string
5  workflow: string
6  branch?: string
7  deployedSha: { from: 'headSha' } | { from: 'log'; pattern: string }
8  timeoutMinutes: number
9}
10
11export type CheckConfig = {
12  name: string
13  argv: string[]
14  intervalSeconds: number
15  timeoutMinutes: number
16  env?: Record<string, string>
17}
18
19export type Config = {
20  version: 1
21  ci?: { workflow: string }
22  deploys: DeployConfig[]
23  checks: CheckConfig[]
24  notify: {
25    macos: boolean
26    slack?: { webhookEnv: string }
27    wakeAgent: boolean
28    on: Array<StageState | 'done'>
29  }
30  doneLingerMinutes: number
31}
32
33export const DEFAULT_CONFIG: Config = {
34  version: 1,
35  deploys: [],
36  checks: [],
37  notify: { macos: true, wakeAgent: true, on: ['failed', 'warning', 'done'] },
38  doneLingerMinutes: 10,
39}
40
41const STATES: readonly string[] = ['pending', 'running', 'passed', 'failed', 'skipped', 'superseded', 'warning', 'done']
42const NAME = /^[\w .:-]{1,40}$/
43
44type Obj = Record<string, unknown>
45const isObj = (v: unknown): v is Obj => typeof v === 'object' && v !== null && !Array.isArray(v)
46
47const closed = (o: Obj, at: string, allowed: readonly string[]) => {
48  const extra = Object.keys(o).filter(k => !allowed.includes(k))
49  if (extra.length) throw new Error(`${at}: unknown field(s) ${extra.join(', ')}`)
50}
51const str = (v: unknown, at: string) => {
52  if (typeof v !== 'string' || !v.trim()) throw new Error(`${at} must be a non-empty string`)
53  return v
54}
55const name = (v: unknown, at: string) => {
56  const s = str(v, at)
57  if (!NAME.test(s)) throw new Error(`${at} "${s}" may hold only letters, digits, spaces and _ . : - (40 at most)`)
58  return s
59}
60const num = (v: unknown, at: string, fallback: number, min: number, max: number) => {
61  if (v === undefined) return fallback
62  if (typeof v !== 'number' || !Number.isFinite(v) || v < min || v > max) throw new Error(`${at} must be a number from ${min} to ${max}`)
63  return v
64}
65const unique = (names: string[], at: string) => {
66  const dup = names.find((n, i) => names.indexOf(n) !== i)
67  if (dup) throw new Error(`${at}: "${dup}" is listed twice`)
68}
69
70const deploy = (v: unknown, i: number): DeployConfig => {
71  const at = `deploys[${i}]`
72  if (!isObj(v)) throw new Error(`${at} must be an object`)
73  closed(v, at, ['name', 'workflow', 'branch', 'deployedSha', 'timeoutMinutes'])
74  let deployedSha: DeployConfig['deployedSha'] = { from: 'headSha' }
75  if (v.deployedSha !== undefined) {
76    const d = v.deployedSha
77    if (!isObj(d)) throw new Error(`${at}.deployedSha must be an object`)
78    closed(d, `${at}.deployedSha`, ['from', 'pattern'])
79    if (d.from === 'headSha') deployedSha = { from: 'headSha' }
80    else if (d.from === 'log') {
81      const pattern = str(d.pattern, `${at}.deployedSha.pattern`)
82      try {
83        new RegExp(pattern)
84      } catch {
85        throw new Error(`${at}.deployedSha.pattern is not a valid regular expression`)
86      }
87      if (!/\((?!\?)/.test(pattern)) throw new Error(`${at}.deployedSha.pattern needs a capture group for the SHA`)
88      deployedSha = { from: 'log', pattern }
89    } else throw new Error(`${at}.deployedSha.from must be "headSha" or "log"`)
90  }
91  return {
92    name: name(v.name, `${at}.name`),
93    workflow: str(v.workflow, `${at}.workflow`),
94    ...(v.branch !== undefined && { branch: str(v.branch, `${at}.branch`) }),
95    deployedSha,
96    timeoutMinutes: num(v.timeoutMinutes, `${at}.timeoutMinutes`, 45, 1, 24 * 60),
97  }
98}
99
100const check = (v: unknown, i: number): CheckConfig => {
101  const at = `checks[${i}]`
102  if (!isObj(v)) throw new Error(`${at} must be an object`)
103  closed(v, at, ['name', 'argv', 'intervalSeconds', 'timeoutMinutes', 'env'])
104  if (!Array.isArray(v.argv) || v.argv.length === 0 || !v.argv.every(a => typeof a === 'string')) throw new Error(`${at}.argv must be a non-empty array of strings`)
105  let env: Record<string, string> | undefined
106  if (v.env !== undefined) {
107    if (!isObj(v.env) || !Object.values(v.env).every(x => typeof x === 'string')) throw new Error(`${at}.env must map names to strings`)
108    env = v.env as Record<string, string>
109  }
110  return {
111    name: name(v.name, `${at}.name`),
112    argv: v.argv as string[],
113    intervalSeconds: num(v.intervalSeconds, `${at}.intervalSeconds`, 60, 10, 3600),
114    timeoutMinutes: num(v.timeoutMinutes, `${at}.timeoutMinutes`, 20, 1, 24 * 60),
115    ...(env && { env }),
116  }
117}
118
119/** Parses `.claude/ship-progress.json` against its closed schema; throws a message naming the field. */
120export const parseConfig = (text: string): Config => {
121  let raw: unknown
122  try {
123    raw = JSON.parse(text)
124  } catch (err) {
125    throw new Error(`not valid JSON (${err instanceof Error ? err.message : String(err)})`)
126  }
127  if (!isObj(raw)) throw new Error('must be a JSON object')
128  closed(raw, 'config', ['$schema', 'version', 'ci', 'deploys', 'checks', 'notify', 'doneLingerMinutes'])
129  if (raw.version !== 1) throw new Error('version must be 1')
130  const out: Config = { ...DEFAULT_CONFIG, notify: { ...DEFAULT_CONFIG.notify } }
131  if (raw.ci !== undefined) {
132    if (!isObj(raw.ci)) throw new Error('ci must be an object')
133    closed(raw.ci, 'ci', ['workflow'])
134    out.ci = { workflow: str(raw.ci.workflow, 'ci.workflow') }
135  }
136  if (raw.deploys !== undefined) {
137    if (!Array.isArray(raw.deploys)) throw new Error('deploys must be an array')
138    out.deploys = raw.deploys.map(deploy)
139    unique(out.deploys.map(d => d.name), 'deploys')
140  }
141  if (raw.checks !== undefined) {
142    if (!Array.isArray(raw.checks)) throw new Error('checks must be an array')
143    out.checks = raw.checks.map(check)
144    unique(out.checks.map(c => c.name), 'checks')
145  }
146  if (raw.notify !== undefined) {
147    const n = raw.notify
148    if (!isObj(n)) throw new Error('notify must be an object')
149    closed(n, 'notify', ['macos', 'slack', 'wakeAgent', 'on'])
150    for (const k of ['macos', 'wakeAgent'] as const) {
151      if (n[k] !== undefined && typeof n[k] !== 'boolean') throw new Error(`notify.${k} must be true or false`)
152      if (typeof n[k] === 'boolean') out.notify[k] = n[k]
153    }
154    if (n.slack !== undefined) {
155      if (!isObj(n.slack)) throw new Error('notify.slack must be an object')
156      closed(n.slack, 'notify.slack', ['webhookEnv'])
157      const env = str(n.slack.webhookEnv, 'notify.slack.webhookEnv')
158      if (!/^[A-Z_][A-Z0-9_]*$/.test(env)) throw new Error('notify.slack.webhookEnv must be an environment variable NAME (A-Z, 0-9, _), never the URL')
159      out.notify.slack = { webhookEnv: env }
160    }
161    if (n.on !== undefined) {
162      if (!Array.isArray(n.on) || !n.on.every(s => STATES.includes(s as string))) throw new Error(`notify.on must list states from: ${STATES.join(', ')}`)
163      out.notify.on = n.on as Config['notify']['on']
164    }
165  }
166  out.doneLingerMinutes = num(raw.doneLingerMinutes, 'doneLingerMinutes', 10, 0, 24 * 60)
167  return out
168}
169
hooks/lib/gh.ts 109 lines
1/** Runs a command by argv (no shell). The mod's runner is `$.process.run`; tests pass fixtures. */
2export type Runner = (argv: readonly string[], opts?: { timeoutMs?: number; cwd?: string; env?: Record<string, string> }) => Promise<{ exitCode: number; stdout: string; stderr: string }>
3
4/** A run's log can be large; it gets longer than an ordinary gh call. */
5export const LOG_TIMEOUT_MS = 20_000
6
7/** A `gh` failure, with a message short enough for the bar. */
8export class GhError extends Error {}
9
10/** The config names a workflow GitHub does not have (yet): a config problem for one stage, not an outage. */
11export class MissingWorkflow extends GhError {}
12
13const firstLine = (text: string) => (text.split('\n').find(l => l.trim()) ?? '').trim().slice(0, 80)
14
15export const gh = async (run: Runner, args: readonly string[], opts?: { timeoutMs?: number }): Promise<string> => {
16  let out
17  try {
18    out = await run(['gh', ...args], opts)
19  } catch (err) {
20    throw new GhError(/time/i.test(String(err)) ? 'timed out' : firstLine(String(err)) || 'could not run gh')
21  }
22  if (out.exitCode !== 0) throw new GhError(firstLine(out.stderr) || `exit ${out.exitCode}`)
23  return out.stdout
24}
25
26const json = async <T>(run: Runner, args: readonly string[]): Promise<T> => {
27  const text = await gh(run, args)
28  try {
29    return JSON.parse(text) as T
30  } catch {
31    throw new GhError(`unreadable output from gh ${args[0]} ${args[1] ?? ''}`.trim())
32  }
33}
34
35export type RollupItem = {
36  __typename?: string
37  name?: string
38  context?: string
39  workflowName?: string
40  status?: string
41  conclusion?: string | null
42  state?: string
43  detailsUrl?: string
44  targetUrl?: string
45  startedAt?: string
46}
47
48export type PrInfo = {
49  number: number
50  title: string
51  url: string
52  state: 'OPEN' | 'CLOSED' | 'MERGED'
53  isDraft: boolean
54  headRefOid: string
55  baseRefName: string
56  mergeCommit: { oid: string } | null
57  mergedAt: string | null
58  statusCheckRollup: RollupItem[] | null
59}
60
61export type Run = {
62  databaseId: number
63  workflowName?: string
64  status: string
65  conclusion: string | null
66  headSha: string
67  event: string
68  createdAt: string
69  url: string
70}
71
72const PR_FIELDS = 'number,title,url,state,isDraft,headRefOid,baseRefName,mergeCommit,mergedAt,statusCheckRollup'
73const RUN_FIELDS = 'databaseId,workflowName,status,conclusion,headSha,event,createdAt,url'
74
75export const prView = (run: Runner, repo: string, number: number) =>
76  json<PrInfo>(run, ['pr', 'view', String(number), '--repo', repo, '--json', PR_FIELDS])
77
78/** Runs, newest first, filtered by `--workflow`, `--commit`, `--branch`, `--event` as given. */
79export const runList = async (run: Runner, repo: string, filter: { workflow?: string; commit?: string; branch?: string; event?: string; limit?: number }) => {
80  try {
81    return await json<Run[]>(run, [
82      'run', 'list', '--repo', repo, '--json', RUN_FIELDS, '--limit', String(filter.limit ?? 20),
83      ...(filter.workflow ? ['--workflow', filter.workflow] : []),
84      ...(filter.commit ? ['--commit', filter.commit] : []),
85      ...(filter.branch ? ['--branch', filter.branch] : []),
86      ...(filter.event ? ['--event', filter.event] : []),
87    ])
88  } catch (err) {
89    if (err instanceof GhError && /could not find any workflows/i.test(err.message)) throw new MissingWorkflow(`no workflow "${filter.workflow}" on GitHub`)
90    throw err
91  }
92}
93
94export const defaultBranch = async (run: Runner, repo: string) =>
95  (await json<{ defaultBranchRef: { name: string } }>(run, ['repo', 'view', repo, '--json', 'defaultBranchRef'])).defaultBranchRef.name
96
97/** Whether a PR whose head is `branch` has merged: the base of a stacked PR. */
98export const branchPrMerged = async (run: Runner, repo: string, branch: string) =>
99  (await json<unknown[]>(run, ['pr', 'list', '--repo', repo, '--head', branch, '--state', 'merged', '--json', 'number', '--limit', '1'])).length > 0
100
101/** The text of a completed run's log. Large; read once per run and cache what it yields. */
102export const runLog = (run: Runner, repo: string, id: string) => gh(run, ['run', 'view', id, '--repo', repo, '--log'], { timeoutMs: LOG_TIMEOUT_MS })
103
104/** Whether `ancestor` is contained in `sha`, by the compare API (`ahead` or `identical`). */
105export const compareContains = async (run: Runner, repo: string, ancestor: string, sha: string) => {
106  const status = (await gh(run, ['api', `repos/${repo}/compare/${ancestor}...${sha}`, '--jq', '.status'])).trim()
107  return status === 'ahead' || status === 'identical'
108}
109
hooks/lib/ops.ts 168 lines
1import type { Stage, StageState, TaskList, Track } from '../../types'
2import type { StepSpec } from './input'
3import { DEFAULT_LINGER_MS, insertAfter, isSettled, moveStage, trackKey } from './model'
4
5export const HISTORY_MS = 7 * 24 * 3_600_000
6export const STORE_VERSION = 1
7
8export type HistoryEntry = {
9  key: string
10  title?: string
11  status: 'done' | 'closed' | 'untracked'
12  sha?: string
13  endedAt: number
14}
15
16/** Everything kept per repo in `$.store`, under `repo:<owner/repo>`. */
17export type Persisted = {
18  version: typeof STORE_VERSION
19  tracks: Track[]
20  lists: TaskList[]
21  history: HistoryEntry[]
22  /** Deployed SHA read from a deploy run's log, by run id; a run's log is read once. */
23  logShas: Record<string, string>
24  /** Whether `<merge>..<deployed>` holds the merge commit, by `merge:deployed`. */
25  contains: Record<string, boolean>
26  /** Alert dedupe keys already sent. */
27  alerts: string[]
28  /** Content hash of the `checks` array the person trusted, or null. */
29  trust: string | null
30}
31
32export const emptyPersisted = (): Persisted => ({
33  version: STORE_VERSION,
34  tracks: [],
35  lists: [],
36  history: [],
37  logShas: {},
38  contains: {},
39  alerts: [],
40  trust: null,
41})
42
43const isObj = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
44
45/** Reads a stored value: version 1 as is (fields defaulted), anything else as empty. */
46export const migrate = (raw: unknown): Persisted => {
47  if (!isObj(raw) || raw.version !== STORE_VERSION) return emptyPersisted()
48  const base = emptyPersisted()
49  return {
50    version: STORE_VERSION,
51    tracks: Array.isArray(raw.tracks) ? (raw.tracks as Track[]).filter(t => isObj(t) && typeof t.key === 'string') : base.tracks,
52    lists: Array.isArray(raw.lists) ? (raw.lists as TaskList[]).filter(l => isObj(l) && typeof l.id === 'string') : base.lists,
53    history: Array.isArray(raw.history) ? (raw.history as HistoryEntry[]) : base.history,
54    logShas: isObj(raw.logShas) ? (raw.logShas as Record<string, string>) : base.logShas,
55    contains: isObj(raw.contains) ? (raw.contains as Record<string, boolean>) : base.contains,
56    alerts: Array.isArray(raw.alerts) ? (raw.alerts as string[]) : base.alerts,
57    trust: typeof raw.trust === 'string' ? raw.trust : null,
58  }
59}
60
61/** Keeps a record bounded: the newest `max` entries by insertion order. */
62export const capRecord = <T>(rec: Record<string, T>, max: number) => {
63  const keys = Object.keys(rec)
64  return keys.length <= max ? rec : Object.fromEntries(keys.slice(-max).map(k => [k, rec[k] as T]))
65}
66
67/** Moves tracks that lingered past `lingerMs` into history and drops history older than 7 days. */
68export const sweep = (p: Persisted, now: number, lingerMs = DEFAULT_LINGER_MS): Persisted => {
69  const gone = p.tracks.filter(t => t.endedAt !== undefined && now - t.endedAt >= lingerMs)
70  const history = [
71    ...p.history,
72    ...gone.map(t => ({ key: t.key, title: t.title, status: t.status === 'closed' ? 'closed' : 'done', sha: t.deployedSha ?? t.mergeSha, endedAt: t.endedAt! }) as HistoryEntry),
73  ].filter(h => now - h.endedAt < HISTORY_MS)
74  return {
75    ...p,
76    tracks: p.tracks.filter(t => !gone.includes(t)),
77    lists: p.lists.filter(l => l.endedAt === undefined || now - l.endedAt < lingerMs),
78    history,
79    logShas: capRecord(p.logShas, 500),
80    contains: capRecord(p.contains, 500),
81    alerts: p.alerts.slice(-500),
82  }
83}
84
85const step = (name: string, state: StageState, now: number): Stage =>
86  moveStage({ id: `step:${name}`, label: name, owner: 'agent', state: 'pending' }, state, now)
87
88const listEnd = (steps: readonly Stage[], now: number, prev?: number) =>
89  steps.length > 0 && steps.every(s => isSettled(s.state)) ? (prev ?? now) : undefined
90
91export const listSet = (lists: readonly TaskList[], id: string, title: string, specs: readonly StepSpec[], now: number): TaskList[] => {
92  const prev = lists.find(l => l.id === id)
93  let steps: Stage[] = []
94  for (const spec of specs) {
95    const old = prev?.steps.find(s => s.label === spec.name)
96    const base = old ?? step(spec.name, 'pending', now)
97    steps = insertAfter(steps, spec.state ? moveStage(base, spec.state, now) : base, spec.after)
98  }
99  const list: TaskList = { id, title, steps, createdAt: prev?.createdAt ?? now, updatedAt: now }
100  const endedAt = listEnd(steps, now)
101  if (endedAt !== undefined) list.endedAt = endedAt
102  return [...lists.filter(l => l.id !== id), list]
103}
104
105/** Sets an agent-owned step's state; throws when the step or its owner is unknown. */
106export const setListStep = (lists: readonly TaskList[], id: string, name: string, state: StageState, note: string | undefined, now: number): TaskList[] => {
107  const list = lists.find(l => l.id === id)
108  if (!list) throw new Error(`no task list "${id}"; declare it with list_set first`)
109  if (!list.steps.some(s => s.label === name)) throw new Error(`list "${id}" has no step "${name}" (steps: ${list.steps.map(s => s.label).join(', ')})`)
110  const steps = list.steps.map(s => (s.label === name ? moveStage(s, state, now, note === undefined ? {} : { note }) : s))
111  const next: TaskList = { ...list, steps, updatedAt: now }
112  const endedAt = listEnd(steps, now, list.endedAt)
113  if (endedAt === undefined) delete next.endedAt
114  else next.endedAt = endedAt
115  return lists.map(l => (l.id === id ? next : l))
116}
117
118export const newTrack = (repo: string, number: number, now: number): Track => ({
119  key: trackKey(repo, number),
120  repo,
121  number,
122  stages: [
123    { id: 'pr-ci', label: 'PR CI', owner: 'mod', state: 'pending', startedAt: now },
124    { id: 'merged', label: 'merged', owner: 'mod', state: 'pending' },
125    { id: 'main-ci', label: 'main CI', owner: 'mod', state: 'pending' },
126  ],
127  notes: [],
128  status: 'active',
129  createdAt: now,
130  updatedAt: now,
131})
132
133const findTrack = (tracks: readonly Track[], key: string) => {
134  const t = tracks.find(x => x.key === key)
135  if (!t) throw new Error(`${key} is not tracked; track it first`)
136  return t
137}
138
139const stageOf = (track: Track, ref: string) =>
140  track.stages.find(s => s.id === ref || s.label === ref || s.id === `step:${ref}`)
141
142const replaceTrack = (tracks: readonly Track[], next: Track) => tracks.map(t => (t.key === next.key ? next : t))
143
144export const addTrackStep = (tracks: readonly Track[], key: string, name: string, after: string | undefined, now: number): Track[] => {
145  const t = findTrack(tracks, key)
146  if (stageOf(t, name)) throw new Error(`${key} already has a stage "${name}"`)
147  if (after !== undefined && !stageOf(t, after)) throw new Error(`${key} has no stage "${after}" (stages: ${t.stages.map(s => s.id).join(', ')})`)
148  const stages = insertAfter(t.stages, step(name, 'pending', now), after)
149  return replaceTrack(tracks, { ...t, stages, status: t.status === 'done' ? 'active' : t.status, endedAt: undefined, updatedAt: now })
150}
151
152export const setTrackStep = (tracks: readonly Track[], key: string, name: string, state: StageState, note: string | undefined, now: number): Track[] => {
153  const t = findTrack(tracks, key)
154  const s = stageOf(t, name)
155  if (!s) throw new Error(`${key} has no step "${name}"; add it with add_step first`)
156  if (s.owner !== 'agent') throw new Error(`"${s.id}" is computed from GitHub; only steps added with add_step can be set`)
157  const stages = t.stages.map(x => (x === s ? moveStage(x, state, now, note === undefined ? {} : { note }) : x))
158  return replaceTrack(tracks, { ...t, stages, updatedAt: now })
159}
160
161export const annotate = (tracks: readonly Track[], key: string, stage: string | undefined, note: string, now: number): Track[] => {
162  const t = findTrack(tracks, key)
163  if (stage === undefined) return replaceTrack(tracks, { ...t, notes: [...t.notes, note].slice(-5), updatedAt: now })
164  const s = stageOf(t, stage)
165  if (!s) throw new Error(`${key} has no stage "${stage}" (stages: ${t.stages.map(x => x.id).join(', ')})`)
166  return replaceTrack(tracks, { ...t, stages: t.stages.map(x => (x === s ? { ...x, annotation: note } : x)), updatedAt: now })
167}
168
hooks/lib/pipeline.ts 360 lines
1import type { Stage, StageState, Track } from '../../types'
2import { runCheck } from './checks'
3import type { Config, DeployConfig } from './config'
4import { branchPrMerged, compareContains, defaultBranch, MissingWorkflow, prView, runList, runLog, type PrInfo, type RollupItem, type Run, type Runner } from './gh'
5import { currentStage, isSettled, moveStage } from './model'
6
7/** What GitHub says about one stage, before it is folded into the track. */
8export type StageFact = { state: StageState; note?: string; ref?: string; url?: string; deployedSha?: string }
9
10export type Facts = {
11  pr: PrInfo
12  defaultBranch: string
13  /** For a stacked PR: whether the PR its base branch belongs to has merged. */
14  baseMerged: boolean
15  prCi: StageFact
16  /** Present once the PR has merged. */
17  mainCi?: StageFact
18  /** By deploy name; present once main CI has passed. */
19  deploys: Record<string, StageFact>
20  /** By check name; present for the checks run (or refused) on this poll. */
21  checks: Record<string, StageFact>
22}
23
24export type Ctx = {
25  run: Runner
26  config: Config
27  now: number
28  /** Default branch by repo, filled on first use. */
29  defaultBranches: Map<string, string>
30  /** The session's repo: containment is asked of the local clone first for this one. */
31  localRepo?: string | null
32  /** Read and filled while gathering; the caller persists them. */
33  cache: { logShas: Record<string, string>; contains: Record<string, boolean> }
34  /** Custom checks: whether the person trusted this config's checks, where to run them, and when each last ran. */
35  checks?: { isTrusted: boolean; cwd: string; lastRun: Record<string, number> }
36}
37
38/** How long after a merge, with no CI config and no push run, main CI counts as absent. */
39const NO_MAIN_CI_MS = 10 * 60_000
40
41type Phase = 'running' | 'passed' | 'skipped' | 'failed' | 'cancelled'
42type Entry = { name: string; phase: Phase; url?: string; ref?: string }
43
44const rollupEntry = (i: RollupItem): Entry => {
45  const name = i.workflowName ? `${i.workflowName}${i.name ? ` / ${i.name}` : ''}` : (i.name ?? i.context ?? 'check')
46  const url = i.detailsUrl ?? i.targetUrl
47  if (i.__typename === 'StatusContext' || (i.state !== undefined && i.status === undefined)) {
48    const s = (i.state ?? '').toUpperCase()
49    return { name, url, phase: s === 'SUCCESS' ? 'passed' : s === 'PENDING' || s === 'EXPECTED' ? 'running' : 'failed' }
50  }
51  if ((i.status ?? '').toUpperCase() !== 'COMPLETED') return { name, url, phase: 'running' }
52  const c = (i.conclusion ?? '').toUpperCase()
53  return { name, url, phase: c === 'SUCCESS' || c === 'NEUTRAL' ? 'passed' : c === 'SKIPPED' ? 'skipped' : c === 'CANCELLED' ? 'cancelled' : 'failed' }
54}
55
56export const runPhase = (r: Pick<Run, 'status' | 'conclusion'>): Phase => {
57  if (r.status !== 'completed') return 'running'
58  const c = r.conclusion ?? ''
59  return c === 'success' || c === 'neutral' ? 'passed' : c === 'skipped' ? 'skipped' : c === 'cancelled' ? 'cancelled' : 'failed'
60}
61
62const runEntry = (r: Run): Entry => ({ name: r.workflowName ?? 'run', phase: runPhase(r), url: r.url, ref: String(r.databaseId) })
63
64/** Rollup items repeat across reruns: keep the newest per workflow and job. */
65const latestRollup = (items: readonly RollupItem[]) => {
66  const by = new Map<string, RollupItem>()
67  for (const i of items) {
68    const k = `${i.workflowName ?? ''}|${i.name ?? i.context ?? ''}`
69    const prev = by.get(k)
70    if (!prev || (i.startedAt ?? '') >= (prev.startedAt ?? '')) by.set(k, i)
71  }
72  return [...by.values()]
73}
74
75/** Runs come newest first: keep the newest per workflow. */
76const latestRuns = (runs: readonly Run[]) => {
77  const seen = new Set<string>()
78  return runs.filter(r => {
79    const k = r.workflowName ?? String(r.databaseId)
80    if (seen.has(k)) return false
81    seen.add(k)
82    return true
83  })
84}
85
86/** Folds CI entries into one stage: any failure fails it, anything running keeps it running. */
87export const aggregate = (entries: readonly Entry[], ref?: string): StageFact => {
88  if (entries.length === 0) return { state: 'pending', note: undefined, ref }
89  const failed = entries.find(e => e.phase === 'failed')
90  if (failed) {
91    // The failing job (or run), so a rerun that fails again on the same SHA is a new alert.
92    const attempt = /\/job\/(\d+)/.exec(failed.url ?? '')?.[1] ?? failed.ref
93    return { state: 'failed', note: `${failed.name} failed`, url: failed.url, ref: attempt ? `${ref ?? ''}:${attempt}` : ref }
94  }
95  const running = entries.find(e => e.phase === 'running')
96  if (running) return { state: 'running', url: running.url, ref }
97  const cancelled = entries.find(e => e.phase === 'cancelled')
98  if (cancelled) return { state: 'superseded', note: `${cancelled.name} cancelled`, url: cancelled.url, ref }
99  return { state: entries.some(e => e.phase === 'passed') ? 'passed' : 'skipped', ref }
100}
101
102const prCiFact = async (ctx: Ctx, repo: string, pr: PrInfo): Promise<StageFact> => {
103  const rollup = latestRollup(pr.statusCheckRollup ?? [])
104  const wf = ctx.config.ci?.workflow
105  if (!wf) return aggregate(rollup.map(rollupEntry), pr.headRefOid)
106  const named = rollup.filter(i => i.workflowName === wf)
107  // The rollup names workflows; one given by file name is asked for by its runs on the head SHA.
108  if (named.length || !/\.ya?ml$/.test(wf)) return aggregate(named.map(rollupEntry), pr.headRefOid)
109  const runs = await runList(ctx.run, repo, { workflow: wf, commit: pr.headRefOid, limit: 5 })
110  return aggregate(runs.slice(0, 1).map(runEntry), pr.headRefOid)
111}
112
113const mainCiFact = async (ctx: Ctx, repo: string, pr: PrInfo, branch: string): Promise<StageFact> => {
114  const sha = pr.mergeCommit!.oid
115  const wf = ctx.config.ci?.workflow
116  if (wf) {
117    const runs = await runList(ctx.run, repo, { workflow: wf, commit: sha, limit: 5 })
118    return aggregate(runs.slice(0, 1).map(runEntry), sha)
119  }
120  // No CI configured: every push-triggered workflow on the merge commit.
121  const runs = latestRuns((await runList(ctx.run, repo, { commit: sha, branch, event: 'push', limit: 30 })).filter(r => r.event === 'push'))
122  const mergedAt = pr.mergedAt ? Date.parse(pr.mergedAt) : ctx.now
123  if (runs.length === 0 && ctx.now - mergedAt > NO_MAIN_CI_MS) return { state: 'skipped', note: `no CI ran on ${branch}`, ref: sha }
124  return aggregate(runs.map(runEntry), sha)
125}
126
127/** The SHA a completed run deployed: its headSha, or the last pattern match in its log (read once, cached). */
128const deployedShaOf = async (ctx: Ctx, repo: string, run: Run, d: DeployConfig) => {
129  if (d.deployedSha.from === 'headSha') return run.headSha
130  const id = String(run.databaseId)
131  if (id in ctx.cache.logShas) return ctx.cache.logShas[id] || undefined
132  const sha = lastMatch(await runLog(ctx.run, repo, id), d.deployedSha.pattern)
133  ctx.cache.logShas[id] = sha ?? ''
134  return sha
135}
136
137/** Longest log line the committed pattern is run on: bounds what a pathological regex can cost. */
138const MAX_LINE = 2_000
139
140export const lastMatch = (log: string, pattern: string) => {
141  const re = new RegExp(pattern)
142  let sha: string | undefined
143  for (const line of log.split('\n')) {
144    const m = re.exec(line.length > MAX_LINE ? line.slice(0, MAX_LINE) : line)
145    if (m?.[1] && /^[0-9a-f]{7,40}$/i.test(m[1])) sha = m[1].toLowerCase()
146  }
147  return sha
148}
149
150/** Whether `deployed` contains `merge`: the local clone first, then the compare API. Cached. */
151export const contains = async (ctx: Ctx, repo: string, merge: string, deployed: string) => {
152  if (merge.startsWith(deployed) || deployed.startsWith(merge)) return true
153  const key = `${merge}:${deployed}`
154  if (key in ctx.cache.contains) return ctx.cache.contains[key]!
155  let answer: boolean | undefined
156  if (ctx.localRepo === repo) {
157    const out = await ctx.run(['git', 'merge-base', '--is-ancestor', merge, deployed]).catch(() => undefined)
158    // 0: ancestor; 1: not; anything else (a commit this clone has not fetched): ask GitHub.
159    if (out?.exitCode === 0) answer = true
160    else if (out?.exitCode === 1) answer = false
161  }
162  answer ??= await compareContains(ctx.run, repo, merge, deployed)
163  ctx.cache.contains[key] = answer
164  return answer
165}
166
167/** Slack between a merge and the deploy run it triggers, for clock skew. */
168const RUN_SKEW_MS = 60_000
169
170/**
171 * One deploy stage. Runs since the merge, newest first: a success whose deployed
172 * SHA contains the merge passes; a failure that contains it fails; a cancelled
173 * run (a concurrency group dropped it) is superseded and the stage keeps waiting.
174 */
175export const deployFact = async (ctx: Ctx, repo: string, pr: PrInfo, branch: string, d: DeployConfig): Promise<StageFact> => {
176  const merge = pr.mergeCommit!.oid
177  const since = (pr.mergedAt ? Date.parse(pr.mergedAt) : 0) - RUN_SKEW_MS
178  const runs = (await runList(ctx.run, repo, { workflow: d.workflow, branch: d.branch ?? branch, limit: 20 })).filter(r => Date.parse(r.createdAt) >= since)
179  let running: Run | undefined
180  let superseded: Run | undefined
181  let unreadable: Run | undefined
182  for (const r of runs) {
183    const phase = runPhase(r)
184    if (phase === 'running') {
185      running ??= r
186      continue
187    }
188    // A skipped run is usually a workflow_run trigger whose job `if` did not apply
189    // (CI finishing on another branch): noise, not a deploy of anything.
190    if (phase === 'skipped') continue
191    if (phase === 'cancelled') {
192      superseded ??= r
193      continue
194    }
195    const sha = await deployedShaOf(ctx, repo, r, d)
196    if (!sha) {
197      if (phase === 'failed') unreadable ??= r
198      continue
199    }
200    if (!(await contains(ctx, repo, merge, sha))) continue
201    const ref = String(r.databaseId)
202    if (phase === 'passed') return { state: 'passed', deployedSha: sha, ref, url: r.url }
203    return { state: 'failed', note: `run ${ref} failed`, deployedSha: sha, ref, url: r.url }
204  }
205  if (running) return { state: 'running', ref: String(running.databaseId), url: running.url }
206  if (superseded) return { state: 'superseded', note: `run ${superseded.databaseId} ${superseded.conclusion}; waiting for a later run`, ref: String(superseded.databaseId), url: superseded.url }
207  if (unreadable) return { state: 'warning', note: `run ${unreadable.databaseId} failed before reporting a SHA`, ref: String(unreadable.databaseId), url: unreadable.url }
208  return { state: 'pending' }
209}
210
211/** Runs the checks that are due, once main CI and every deploy have passed. */
212const checkFacts = async (ctx: Ctx, track: Track, f: Facts): Promise<Record<string, StageFact>> => {
213  const out: Record<string, StageFact> = {}
214  if (ctx.config.checks.length === 0 || !ctx.checks) return out
215  // The track as this poll leaves its deploys, so {deployedSha:<name>} resolves.
216  const stages = track.stages.map(s => {
217    const d = s.id.startsWith('deploy:') ? f.deploys[s.label] : undefined
218    return d ? { ...s, state: d.state, sha: d.deployedSha } : s
219  })
220  if (!stages.filter(s => s.id.startsWith('deploy:')).every(s => s.state === 'passed')) return out
221  const now = { ...track, stages, mergeSha: f.pr.mergeCommit?.oid }
222  for (const c of ctx.config.checks) {
223    const key = `${track.key}|${c.name}`
224    if (stages.some(s => s.id === `check:${c.name}` && s.state === 'passed')) continue
225    if (!ctx.checks.isTrusted) {
226      out[c.name] = { state: 'warning', note: 'checks not trusted: type /ship-progress trust' }
227      continue
228    }
229    if (ctx.now - (ctx.checks.lastRun[key] ?? 0) < c.intervalSeconds * 1000) continue
230    ctx.checks.lastRun[key] = ctx.now
231    out[c.name] = await runCheck(ctx.run, c, now, ctx.checks.cwd)
232  }
233  return out
234}
235
236/** A workflow the config names but GitHub lacks is a warning on that stage alone. */
237const orMissing = (fact: Promise<StageFact>): Promise<StageFact> =>
238  fact.catch(err => {
239    if (err instanceof MissingWorkflow) return { state: 'warning', note: err.message }
240    throw err
241  })
242
243/** Asks GitHub about one track. Throws GhError when gh fails; the caller backs off. */
244export const gather = async (track: Track, ctx: Ctx): Promise<Facts> => {
245  const pr = await prView(ctx.run, track.repo, track.number)
246  let branch = ctx.defaultBranches.get(track.repo)
247  if (!branch) {
248    branch = await defaultBranch(ctx.run, track.repo)
249    ctx.defaultBranches.set(track.repo, branch)
250  }
251  const baseMerged = pr.state === 'OPEN' && pr.baseRefName !== branch ? await branchPrMerged(ctx.run, track.repo, pr.baseRefName) : false
252  const facts: Facts = { pr, defaultBranch: branch, baseMerged, prCi: await orMissing(prCiFact(ctx, track.repo, pr)), deploys: {}, checks: {} }
253  if (pr.state === 'MERGED' && pr.mergeCommit) {
254    facts.mainCi = await orMissing(mainCiFact(ctx, track.repo, pr, branch))
255    if (facts.mainCi.state === 'passed' || facts.mainCi.state === 'skipped') {
256      for (const d of ctx.config.deploys) {
257        // A deploy that passed stays passed: later runs ship later commits.
258        if (track.stages.some(s => s.id === `deploy:${d.name}` && s.state === 'passed')) continue
259        facts.deploys[d.name] = await orMissing(deployFact(ctx, track.repo, pr, branch, d))
260      }
261      facts.checks = await checkFacts(ctx, track, facts)
262    }
263  }
264  return facts
265}
266
267/** The mod's stages for a config, in pipeline order. */
268export const modStages = (config: Config): Stage[] => [
269  { id: 'pr-ci', label: 'PR CI', owner: 'mod', state: 'pending' },
270  { id: 'merged', label: 'merged', owner: 'mod', state: 'pending' },
271  { id: 'main-ci', label: 'main CI', owner: 'mod', state: 'pending' },
272  ...config.deploys.map((d): Stage => ({ id: `deploy:${d.name}`, label: d.name, owner: 'mod', state: 'pending' })),
273  ...config.checks.map((c): Stage => ({ id: `check:${c.name}`, label: c.name, owner: 'mod', state: 'pending' })),
274]
275
276/** Keeps agent steps where they are, drops mod stages the config no longer has, adds new ones in order. */
277export const shapeStages = (stages: readonly Stage[], config: Config): Stage[] => {
278  const want = modStages(config)
279  const ids = new Set(want.map(s => s.id))
280  const out = stages.filter(s => s.owner === 'agent' || ids.has(s.id))
281  want.forEach((s, i) => {
282    if (out.some(x => x.id === s.id)) return
283    const prev = i === 0 ? -1 : out.findIndex(x => x.id === want[i - 1]!.id)
284    out.splice(prev + 1, 0, s)
285  })
286  return out
287}
288
289const factFor = (stage: Stage, f: Facts, branch: string): StageFact | undefined => {
290  const { pr } = f
291  switch (stage.id) {
292    case 'pr-ci': {
293      const fact = f.prCi
294      if (pr.state === 'MERGED' && !isSettled(fact.state)) return { ...fact, state: 'skipped', note: 'merged before PR CI passed' }
295      if (pr.isDraft && fact.state !== 'failed') return { ...fact, state: 'warning', note: 'draft: full suite not run' }
296      return fact
297    }
298    case 'merged':
299      if (pr.state === 'MERGED') return { state: 'passed', ref: pr.mergeCommit?.oid }
300      if (pr.state === 'CLOSED') return { state: 'failed', note: 'closed without merge' }
301      if (f.baseMerged) return { state: 'warning', note: `base merged; retarget to ${branch}` }
302      return { state: 'pending' }
303    case 'main-ci':
304      return f.mainCi ?? { state: 'pending' }
305    default:
306      if (stage.id.startsWith('deploy:')) {
307        if (stage.state === 'passed') return undefined
308        return f.deploys[stage.label] ?? { state: 'pending' }
309      }
310      // A check not run on this poll keeps its state; a passed one stays passed.
311      if (stage.id.startsWith('check:') && stage.state !== 'passed') return f.checks[stage.label]
312      return undefined
313  }
314}
315
316/** A deploy or check that has waited past its timeout without passing fails. Untrusted checks wait for the person. */
317const timedOut = (s: Stage, config: Config, now: number): Stage | undefined => {
318  if (isSettled(s.state) || s.state === 'failed' || s.startedAt === undefined) return undefined
319  const d = config.deploys.find(x => `deploy:${x.name}` === s.id)
320  const c = config.checks.find(x => `check:${x.name}` === s.id)
321  const minutes = d?.timeoutMinutes ?? c?.timeoutMinutes
322  if (minutes === undefined || now - s.startedAt < minutes * 60_000) return undefined
323  if (c && s.state === 'warning') return undefined
324  const what = d ? `no deploy within ${minutes} min` : `still failing after ${minutes} min`
325  return moveStage(s, 'failed', now, { note: `${what}${s.note ? ` (${s.note})` : ''}` })
326}
327
328/** Folds facts into a track: stage states, SHAs, and whether it is done or closed. Pure. */
329export const apply = (track: Track, f: Facts, config: Config, now: number): Track => {
330  const stages = shapeStages(track.stages, config).map(s => {
331    if (s.owner !== 'mod') return s
332    const fact = factFor(s, f, f.defaultBranch)
333    let next = s
334    if (fact) {
335      const { state, deployedSha, ...extra } = fact
336      next = moveStage(s, state, now, { note: undefined, ref: undefined, url: undefined, sha: deployedSha, ...extra })
337    }
338    return timedOut(next, config, now) ?? next
339  })
340  // The stage being waited on shows how long it has waited.
341  const current = currentStage(stages)
342  if (current && current.startedAt === undefined) stages[stages.indexOf(current)] = { ...current, startedAt: now }
343
344  const deployed = stages.filter(s => s.id.startsWith('deploy:') && s.sha).at(-1)?.sha
345  const next: Track = {
346    ...track,
347    title: f.pr.title,
348    url: f.pr.url,
349    headSha: f.pr.headRefOid,
350    mergeSha: f.pr.mergeCommit?.oid ?? track.mergeSha,
351    deployedSha: deployed ?? track.deployedSha,
352    stages,
353    updatedAt: JSON.stringify(stages) === JSON.stringify(track.stages) ? track.updatedAt : now,
354  }
355  if (f.pr.state === 'CLOSED') return { ...next, status: 'closed', endedAt: track.endedAt ?? now }
356  if (stages.every(s => isSettled(s.state))) return { ...next, status: 'done', endedAt: track.endedAt ?? now }
357  const { endedAt: _e, ...active } = next
358  return { ...active, status: 'active' }
359}
360
types/index.d.ts 72 lines
1/** A stage's state. `warning` blocks the pipeline like `pending` but is shown in yellow. */
2export type StageState = 'pending' | 'running' | 'passed' | 'failed' | 'skipped' | 'superseded' | 'warning'
3
4/** One stage of a PR track, or one step of a task list. */
5export type Stage = {
6  /** `pr-ci`, `merged`, `main-ci`, `deploy:<name>`, `check:<name>` or `step:<name>`. */
7  id: string
8  label: string
9  /** `mod` stages are computed from GitHub; `agent` stages are set through the tool. */
10  owner: 'mod' | 'agent'
11  state: StageState
12  note?: string
13  /** The agent's note from `annotate`; polling never touches it. */
14  annotation?: string
15  /** What the state is about (a run id), so a repeat failure on a new run alerts again. */
16  ref?: string
17  url?: string
18  /** For a deploy stage, the commit the run actually deployed. */
19  sha?: string
20  startedAt?: number
21  endedAt?: number
22}
23
24export type Track = {
25  /** `owner/repo#number`. */
26  key: string
27  repo: string
28  number: number
29  title?: string
30  url?: string
31  headSha?: string
32  mergeSha?: string
33  deployedSha?: string
34  stages: Stage[]
35  notes: string[]
36  /** `active` is polled; `done` and `closed` linger, then move to history. */
37  status: 'active' | 'done' | 'closed'
38  createdAt: number
39  updatedAt: number
40  endedAt?: number
41}
42
43export type TaskList = {
44  id: string
45  title: string
46  steps: Stage[]
47  createdAt: number
48  updatedAt: number
49  endedAt?: number
50}
51
52export type Meta = {
53  /** `owner/repo` of the session's repository, or null outside one. */
54  repo: string | null
55  /** Short text of the last `gh` failure, shown on the bar; null when healthy. */
56  ghError: string | null
57  /** Why `.claude/ship-progress.json` was not used; null when it loaded or is absent. */
58  configError: string | null
59  /** The clock, ticked while something is in flight so elapsed times redraw. */
60  now: number
61}
62
63declare module 'claude-code' {
64  interface PluginState {
65    'ship-progress': {
66      tracks: Track[]
67      lists: TaskList[]
68      meta: Meta
69    }
70  }
71}
72