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.

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

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.
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.
/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.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 model gets one tool, ship_progress, with an action:
| action | input | what 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 |
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.
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.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.timeoutMinutes (default 45).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 fire when a stage moves into a state listed in notify.on (default failed, warning, done), once per stage, state and run.
osascript.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.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.
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.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.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.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.
MIT
hooks/register.tsx 441 lines1import { 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}
441hooks/lib/input.ts 142 lines1import 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
142hooks/lib/model.ts 157 lines1import 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}
157hooks/lib/alerts.ts 36 lines1import 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"`]
36hooks/lib/autotrack.ts 76 lines1/** 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}
76hooks/lib/checks.ts 50 lines1import 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}
50hooks/lib/config.ts 169 lines1import 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}
169hooks/lib/gh.ts 109 lines1/** 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}
109hooks/lib/ops.ts 168 lines1import 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}
168hooks/lib/pipeline.ts 360 lines1import 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}
360types/index.d.ts 72 lines1/** 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