SLOPSHOPPER

tracer

Merged is not deployed: follow a merged PR or commit through its workflow runs, deployments and an optional live URL with gh, on a pixel-art level map, and…

newpanecommandtoaststatusprocess
v0.1.0MITupdated 2026-10-04pourya7/claude-code-mods/tracer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tracer
│ ┃ TRACER ✕ › fix the failing auth test and add an audit log call │ ┃ T R A C E R POLL 60S / WAKE FINAL / GIVE UP │ ┃ ⏺ Read(src/auth.ts) │ ┃ NO COURSE LOADED. /trace 42 TO INSERT COIN ⎿ 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 │ │ › /trace │ ⎿ tracer: TRACER: nothing on the map. /trace 42 to insert coin. Us │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · TRACER
T R A C E R POLL 60S / WAKE FINAL / GIVE UP 120M NO COURSE LOADED. /trace 42 TO INSERT COIN
README
▀█▀ █▀█ ▄▀█ █▀▀ █▀▀ █▀█
 █  █▀▄ █▀█ █▄▄ ██▄ █▀▄   ★ COURSE CLEAR!

/trace 12 follows a merged PR from MERGED through BUILD to DEPLOY:PRODUCTION on the level map, then wakes the session once

tracer — merged is not deployed

The problem: a PR merges, the session moves on, and an hour later someone asks why the change is not there. The merge was only the first stage: the build on the merge commit, each deploy and the rollout still had to happen, and one of them failed or never ran. The study behind this library found "merged but I still can't see it" in 34 prompts across 14 sessions.

tracer follows a merged commit on a timer outside the model, with gh: its workflow runs, its GitHub deployments and, optionally, a URL that shows the running version. It draws the chain as a level map, MERGED ▸ BUILD ▸ DEPLOY:<env> ▸ LIVE, and wakes the session once, when the chain reaches its last stage or a stage fails.

Install

/plugin marketplace add pourya7/claude-code-mods
/plugin install tracer@claude-code-mods

It needs the GitHub CLI on PATH, signed in (gh auth login).

Commands

CommandWhat it does
/trace 42Follow PR #42 in the session's repo (found with gh repo view). The PR must be merged; tracer reads its merge commit with gh pr view 42 --json mergeCommit.
/trace owner/repo#42, /trace https://github.com/owner/repo/pull/42Follow a PR in a named repo.
/trace abc1234, /trace owner/repo@abc1234, a commit URLFollow a commit directly (7 to 40 hex characters, resolved to the full SHA). A bare number is always a PR.
/traceOpen the tracer pane.
/trace stop, /trace stop 42Stop every trace, or one (also [ STOP ] / [ CLEAR ] in the pane).

How it decides

Every intervalSeconds, each trace still on the road is polled:

  • BUILD. gh api repos/<repo>/actions/runs?head_sha=<sha>. No runs yet, or any run queued or in progress, is pending. It is done when every run finished as success, neutral or skipped. A run waiting for approval (action_required) keeps it pending. Any other conclusion (failure, timed_out, cancelled, startup_failure, ...) fails it, naming up to three runs.
  • DEPLOY:&lt;env&gt;. gh api repos/<repo>/deployments?sha=<sha>, one stage per environment. The environments in environments come first, in that order, and wait even before a deployment exists. Any other environment joins in the order it first appeared. For the newest deployment of each environment, tracer reads its newest status (deployments/<id>/statuses?per_page=1): success or inactive is done, failure or error fails, anything else (or no status yet) is pending.
  • LIVE (only when liveUrl is set). Once every earlier stage is done, tracer fetches liveUrl with {sha} and {short} filled in. It is live when the response is 2xx and the body holds the full SHA or its 7-character short form, or the liveMatch text when that is set. A failed fetch or a non-2xx status stays pending, and tracer tries again on the next poll.

No deployments configured → BUILD is the final stage. With environments empty, if no deployment of the commit exists by the time its runs pass, the chain ends at BUILD and the wake says No deployments to follow. Set environments when your deploys appear later than the build (for example from an outside deployer), so tracer waits for them.

Waking ($.prompt.submit, once per trace):

WhenPrompt
LIVE reachedTRACER: PR #42 (abc1234) is LIVE — MERGED ▸ BUILD ▸ DEPLOY:PRODUCTION ▸ LIVE in 14m.
Last stage reached, no LIVETRACER: PR #42 (abc1234) reached DEPLOY:PRODUCTION — MERGED ▸ BUILD ▸ DEPLOY:PRODUCTION in 9m.
BUILD is the last stageTRACER: PR #42 (abc1234) reached BUILD — MERGED ▸ BUILD in 6m. No deployments to follow.
A stage failedTRACER: PR #42 (abc1234) FAILED at BUILD — FAILED: "ci". Investigate.

Run and environment names come from whoever wrote the workflow, so before they reach a prompt each is cut to plain characters (letters, digits, space . : / ( ) _ -) and 40 columns.

Stopping. Polling for a trace stops when it is done or failed, or timeoutMinutes after /trace, which toasts TRACER #42 TIME UP AT <stage> and does not wake the session.

Stopping wins over a poll in flight. A trace stopped (or started again) while its poll is still waiting on gh is not woken, toasted or overwritten when that poll returns. If another hook refuses the wake prompt, tracer toasts TRACER: wake refused: <reason>.

The first poll is a baseline. /trace polls once at once and replies with where the chain stands. If the chain is already at its end (ALREADY THERE) or already failed (ALREADY FAILED), the reply says so and there is no wake.

gh failures. A missing gh, one that runs past its 30 s limit, and an unauthenticated gh each produce a one-line error with the fix:

  • /trace replies with the error and adds nothing when the PR or commit cannot be resolved.
  • During polling, tracer toasts the error once, shows ERR on the status line and keeps the trace, so it recovers by itself.

The mod never throws.

Configuration (userConfig)

OptionDefaultMeaning
intervalSeconds60Seconds between polls (15–3600).
timeoutMinutes120Stop following a trace this many minutes after /trace (1–1440).
environments""Comma-separated deployment environments to wait for, in order, e.g. staging, production. Names match without regard to case. Empty: follow whatever deployments appear.
liveUrl""Optional URL that shows the running version, e.g. https://api.example.com/version or https://app.example.com/health?v={short}. {sha} and {short} are filled in. Empty: no LIVE stage.
liveMatch""Optional text the live body must contain instead of the SHA, with {sha} and {short} filled in, e.g. "release":"r-{short}".
wakefinalfinal: submit the wake line when the chain reaches its last stage or a stage fails. never: toast and redraw only.

The UI

The pane (/trace) shows each trace as a level map. Each stage is a node on a dotted path:

  • a yellow coin with a shine: done
  • a blue node with a white centre: the stage tracer is waiting on now
  • a grey ring: still ahead
  • a red cross: failed

The path turns orange where it has been travelled.

When the map is wider than the pane (many environments), the deploy nodes fold into one DEPLOY node tagged with how many environments are done, e.g. 2/5; in a pane too narrow even for that, the map is left out. The per-stage lines under the map always list every stage.

T R A C E R  POLL 60S / WAKE FINAL / GIVE UP 120M

#42 Add login retry
▶ ON THE ROAD...  WORLD abc1234 · 6M

  ▄▀▀▀▀▄   ▄▀▀▀▀▄    ▄▀▀▀▀▄    ▄▀▀▀▀▄   ▄▀▀▀▀▄
  ▀▀▀▀▀▀ ▀ ▀▀▀▀▀▀ ▀  ▀▀▀▀▀▀ ▀  ▀▀▀▀▀▀ ▀ ▀▀▄▄▀▀
 ★ MERGED  ★ BUILD  ★ DEPLOY  ● DEPLOY  ● LIVE
                    STAGING   PRODUCT~
★ MERGED abc1234
★ BUILD: 2/2 RUNS PASSED
★ DEPLOY:STAGING: SUCCESS
● DEPLOY:PRODUCTION: IN PROGRESS
● LIVE: WAITS FOR DEPLOY
[ STOP ]

A failed build:

#42 Add login retry
✕ GAME OVER: BUILD FAILED  WORLD abc1234 · 3M

  ▄▀▀▀▀▄   ▀▀▄▄▀▀
  ▀▀▀▀▀▀ ▀ ▄▀▀▀▀▄
 ★ MERGED  ✕ BUILD
★ MERGED abc1234
✕ BUILD: FAILED: "ci"
[ CLEAR ]

These captures are plain text. In the terminal the nodes and labels are drawn in the PICO-8 palette, with tracer's signature yellow. A finished trace reads ★ COURSE CLEAR! in lime, and a timed-out one ● TIME UP in orange. With nothing traced, the pane reads NO COURSE LOADED. /trace 42 TO INSERT COIN.

Status line (the newest trace, then +n more; under 40 columns):

TRACER #42 ★★★●● DEPLOY:PRODUCTION
TRACER #42 ★★★★★ CLEAR!
TRACER #42 ★✕ BUILD FAILED
TRACER abc1234 ★● TIME UP
TRACER #42 ★● BUILD ERR

A toast fires when a stage moves, e.g. TRACER #42 ★ BUILD or TRACER #42 ✕ DEPLOY:STAGING FAILED.

VS Code and claude -p have no pane, so the status line, the toasts and the /trace replies are what you see there.

Permissions

NetworkRuns processesFilesCalls a modelAuto-submits promptsData leaving the machine
Through gh, to the GitHub API. One $.http.fetch GET of liveUrl per poll, only when you set it and only once the earlier stages are done; no credentials are attached.gh only: gh repo view, gh pr view, gh api repos/…/commits/<sha>, gh api repos/…/actions/runs, gh api repos/…/deployments, gh api repos/…/deployments/<id>/statusesNone read or written. The traces live in session state ($.state); nothing goes to $.store.NoYes: one wake line per trace when the chain ends or fails; set wake: never to turn it offRepo name, PR number and commit SHA, sent by gh to GitHub under your own gh login. The SHA, if your liveUrl contains {sha} or {short}, to the host you configured. The wake prompt goes to your own session's model.

Limits

  • One page. Workflow runs and deployments are read 100 per call, one page. A commit with more than 100 of either is judged on the first 100.
  • GitHub deployments only. A deploy that never creates a GitHub deployment is invisible to DEPLOY stages. Use liveUrl to see it go live instead.
  • Cancelled runs fail BUILD. A newer merge that cancels this commit's run (a concurrency group) fails the trace; trace the newer commit instead.
  • At most 5 traces are kept, newest first. Starting a sixth drops the oldest.
  • Session only. Traces survive a hot reload of the mod, which re-arms the timer, but not a new session.

Development

claude plugin validate tracer
claude plugin test tracer

Pure logic lives in hooks/chain.ts (stages, outcome and every line of text), hooks/gh.ts (what /trace accepts, argv builders and output readers) and hooks/pixels.ts (the level map and the half-block renderer). hooks/register.tsx connects them to the engine. The tests use mock.clock, answer gh and the live URL through process.run and http.fetch hooks, and mount the pane on both terminal and desktop.

Source 5 files
hooks/register.tsx 420 lines
1// tracer: merged is not deployed. Follows a merged commit through its
2// workflow runs, deployments and an optional live URL, on a timer outside the
3// model, and wakes the session once when the chain ends.
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, Register, Timer } from 'claude-code'
6
7import type { TracerOutcome, TracerTrace } from '../types'
8import type { DeploymentState, LiveResult } from './chain'
9import {
10  GLYPH,
11  buildStages,
12  chainText,
13  currentStage,
14  isLiveBody,
15  latestPerEnvironment,
16  liveUrlFor,
17  needsLiveCheck,
18  outcomeOf,
19  parseEnvironments,
20  shortSha,
21  stageLabel,
22  statusLine,
23  toastText,
24  wakeLine,
25} from './chain'
26import {
27  REPO_VIEW_ARGV,
28  commitArgv,
29  deploymentStatusArgv,
30  deploymentsArgv,
31  describeGhFailure,
32  describeSpawnFailure,
33  parseJson,
34  parseTraceRef,
35  prViewArgv,
36  readCommit,
37  readDeployments,
38  readLatestStatus,
39  readPrView,
40  readRuns,
41  runsArgv,
42} from './gh'
43import type { TraceRef } from './gh'
44import { PICO, SIGNATURE, levelMap } from './pixels'
45
46const PANE = 'tracer'
47const GH_TIMEOUT_MS = 30_000
48const MAX_TRACES = 5
49const USAGE = 'Use /trace 42, /trace owner/repo#42, /trace abc1234, a PR or commit URL, or /trace stop.'
50
51const traces = atom({ plugin: 'tracer', key: 'traces' } as const, [] as TracerTrace[])
52
53type Settings = {
54  intervalMs: number
55  timeoutMs: number
56  environments: string[]
57  liveUrl: string
58  liveMatch: string
59  wake: string
60}
61type Gh = { stdout: string } | { error: string }
62type Polled = { trace: TracerTrace; toast: string | null; wake: string | null }
63
64// Timers cannot live in $.state; a reload cancels this one, and session.start
65// (raised on a reload too) arms it again.
66let timer: Timer | null = null
67let isPolling = false
68
69/** Runs gh; every failure (missing, timed out, unauthenticated, any non-zero exit) becomes `{ error }`. */
70async function gh($: EngineInterface, argv: readonly string[]): Promise<Gh> {
71  const startedAt = await $.clock.now()
72  try {
73    const ran = await $.process.run(argv, { timeoutMs: GH_TIMEOUT_MS })
74    return ran.exitCode === 0 ? { stdout: ran.stdout } : { error: describeGhFailure(ran.stderr) }
75  } catch (error) {
76    const elapsedMs = (await $.clock.now()) - startedAt
77    return { error: describeSpawnFailure(error instanceof Error ? error.message : String(error), elapsedMs, GH_TIMEOUT_MS) }
78  }
79}
80
81async function ghJson($: EngineInterface, argv: readonly string[]): Promise<{ json: unknown } | { error: string }> {
82  const ran = await gh($, argv)
83  return 'error' in ran ? ran : { json: parseJson(ran.stdout) }
84}
85
86type Target = { repo: string; sha: string; pr: number | null; title: string }
87
88/** The repo and full merge commit SHA a /trace argument names, or why not. */
89async function resolveTarget($: EngineInterface, ref: TraceRef): Promise<Target | { error: string }> {
90  let repo = ref.repo
91  if (repo === null) {
92    const found = await gh($, REPO_VIEW_ARGV)
93    if ('error' in found) return { error: `${found.error} (or name the repo: owner/repo#42)` }
94    repo = found.stdout.trim()
95    if (repo === '') return { error: 'gh found no GitHub repo here: name it, e.g. owner/repo#42.' }
96  }
97  if (ref.kind === 'pr') {
98    const ran = await ghJson($, prViewArgv(repo, ref.number))
99    if ('error' in ran) return ran
100    const pr = readPrView(ran.json)
101    if (pr === null) return { error: `PR #${ref.number} in ${repo}: unreadable gh output` }
102    if (pr.state !== 'MERGED' || pr.mergeSha === null) {
103      return { error: `PR #${ref.number} in ${repo} is not merged yet (${pr.state || 'unknown'}). Trace it once it merges.` }
104    }
105    return { repo, sha: pr.mergeSha, pr: ref.number, title: pr.title }
106  }
107  const ran = await ghJson($, commitArgv(repo, ref.sha))
108  if ('error' in ran) return ran
109  const commit = readCommit(ran.json)
110  if (commit === null) return { error: `no commit ${ref.sha} in ${repo}` }
111  return { repo, sha: commit.sha, pr: null, title: commit.title }
112}
113
114async function checkLive($: EngineInterface, settings: Settings, sha: string): Promise<LiveResult> {
115  try {
116    const response = await $.http.fetch(liveUrlFor(settings.liveUrl, sha))
117    if (!response.ok) return { isLive: false, detail: `HTTP ${response.status}` }
118    if (isLiveBody(response.text, sha, settings.liveMatch)) return { isLive: true, detail: settings.liveMatch ? 'MATCH SEEN' : 'SHA SEEN' }
119    return { isLive: false, detail: `HTTP ${response.status}, ${settings.liveMatch ? 'MATCH' : 'SHA'} NOT SEEN YET` }
120  } catch {
121    return { isLive: false, detail: 'FETCH FAILED' }
122  }
123}
124
125/** One read of the chain: runs, deployments (newest per environment) with their statuses, then the live URL. */
126async function readChain($: EngineInterface, settings: Settings, trace: TracerTrace) {
127  const runsRan = await ghJson($, runsArgv(trace.repo, trace.sha))
128  if ('error' in runsRan) return runsRan
129  const runs = readRuns(runsRan.json)
130  if (runs === null) return { error: 'unreadable workflow runs' }
131  const deploymentsRan = await ghJson($, deploymentsArgv(trace.repo, trace.sha))
132  if ('error' in deploymentsRan) return deploymentsRan
133  const listed = readDeployments(deploymentsRan.json)
134  if (listed === null) return { error: 'unreadable deployments' }
135  const deployments: DeploymentState[] = []
136  for (const deployment of latestPerEnvironment(listed)) {
137    const statusRan = await ghJson($, deploymentStatusArgv(trace.repo, deployment.id))
138    if ('error' in statusRan) return statusRan
139    const status = readLatestStatus(statusRan.json)
140    if (status === null) return { error: `unreadable status of deployment ${deployment.id}` }
141    deployments.push({ ...deployment, state: status.state })
142  }
143  const input = { runs, deployments, environments: settings.environments, hasLiveUrl: settings.liveUrl !== '', live: null }
144  const stages = buildStages(input)
145  if (!needsLiveCheck(stages)) return { stages }
146  return { stages: buildStages({ ...input, live: await checkLive($, settings, trace.sha) }) }
147}
148
149/**
150 * Polls one trace and says what moved (the toast and the wake line, for the
151 * caller to use only if the trace is still on the map). The first poll (from
152 * /trace) is a baseline: no toast and no wake, the reply says where it stands.
153 */
154async function pollTrace($: EngineInterface, settings: Settings, trace: TracerTrace, isFirst: boolean): Promise<Polled> {
155  const now = await $.clock.now()
156  if (!isFirst && now - trace.startedAt >= settings.timeoutMs) {
157    const next: TracerTrace = { ...trace, outcome: 'timeout', checkedAt: now }
158    return { trace: next, toast: toastText(trace, next), wake: null }
159  }
160  const read = await readChain($, settings, trace)
161  if ('error' in read) {
162    const toast = !isFirst && read.error !== trace.error ? `TRACER ${trace.pr === null ? shortSha(trace.sha) : `#${trace.pr}`}: ${read.error}` : null
163    return { trace: { ...trace, error: read.error, checkedAt: now }, toast, wake: null }
164  }
165  const outcome: TracerOutcome = outcomeOf(read.stages, trace.startedAt, now, isFirst ? Number.POSITIVE_INFINITY : settings.timeoutMs)
166  const isFinal = outcome === 'done' || outcome === 'failed'
167  const next: TracerTrace = { ...trace, stages: read.stages, outcome, error: null, checkedAt: now, hasWoken: trace.hasWoken || isFinal }
168  if (isFirst) return { trace: next, toast: null, wake: null }
169  return { trace: next, toast: toastText(trace, next), wake: isFinal && !trace.hasWoken ? wakeLine(next, now - trace.startedAt) : null }
170}
171
172async function refreshStatus($: EngineInterface) {
173  $.ui.status(statusLine(await read($, traces)))
174}
175
176/**
177 * Puts a polled trace back, but only over the same trace: one stopped while
178 * the poll was in flight stays gone, and one started again (a new startedAt)
179 * is not overwritten by the stale copy. Says whether it replaced anything.
180 */
181async function replaceTrace($: EngineInterface, trace: TracerTrace): Promise<boolean> {
182  let isReplaced = false
183  await update($, traces, list =>
184    list.map(one => {
185      if (one.sha !== trace.sha || one.repo !== trace.repo || one.startedAt !== trace.startedAt) return one
186      isReplaced = true
187      return trace
188    }),
189  )
190  return isReplaced
191}
192
193/** The timer's work: poll every trace still on the road, then wake once with every final line. */
194async function tick($: EngineInterface, settings: Settings) {
195  if (isPolling) return
196  isPolling = true
197  try {
198    const live = (await read($, traces)).filter(trace => trace.outcome === 'tracing')
199    if (live.length === 0) return
200    const lines: string[] = []
201    for (const trace of live) {
202      const polled = await pollTrace($, settings, trace, false)
203      if (!(await replaceTrace($, polled.trace))) continue
204      if (polled.toast !== null) $.ui.toast(polled.toast)
205      if (polled.wake !== null) lines.push(polled.wake)
206    }
207    await refreshStatus($)
208    if (settings.wake === 'final' && lines.length > 0) {
209      try {
210        const sent = await $.prompt.submit({ text: lines.join('\n') })
211        if (sent.drop !== undefined) $.ui.toast(`TRACER: wake refused: ${sent.drop}`)
212      } catch {
213        $.ui.toast('TRACER: could not submit the wake prompt')
214      }
215    }
216  } finally {
217    isPolling = false
218  }
219}
220
221function who(trace: TracerTrace): string {
222  return trace.pr === null ? shortSha(trace.sha) : `PR #${trace.pr} (${shortSha(trace.sha)})`
223}
224
225async function startTrace($: EngineInterface, settings: Settings, argument: string): Promise<string> {
226  const ref = parseTraceRef(argument)
227  if (ref === null) return `tracer: "${argument}" is not a PR or a commit. ${USAGE}`
228  const target = await resolveTarget($, ref)
229  if ('error' in target) return `tracer: not tracing: ${target.error}`
230  const now = await $.clock.now()
231  const fresh: TracerTrace = {
232    ...target,
233    startedAt: now,
234    checkedAt: now,
235    stages: buildStages({ runs: [], deployments: [], environments: settings.environments, hasLiveUrl: settings.liveUrl !== '', live: null }),
236    outcome: 'tracing',
237    hasWoken: false,
238    error: null,
239  }
240  const { trace } = await pollTrace($, settings, fresh, true)
241  await update($, traces, list =>
242    [trace, ...list.filter(one => !(one.sha === trace.sha && one.repo === trace.repo))].slice(0, MAX_TRACES),
243  )
244  await refreshStatus($)
245  const head = `${trace.repo} ${who(trace)}${trace.title ? ` "${trace.title.slice(0, 60)}"` : ''}`
246  const chain = chainText(trace.stages)
247  if (trace.error !== null) return `TRACER ON THE ROAD: ${head}, but GitHub could not be read yet: ${trace.error}. Retrying every ${Math.round(settings.intervalMs / 1000)}s.`
248  if (trace.outcome === 'done') return `TRACER: ${head} is ALREADY THERE — ${chain}.`
249  if (trace.outcome === 'failed') {
250    const failed = trace.stages.find(stage => stage.state === 'failed')
251    return `TRACER: ${head} ALREADY FAILED at ${failed ? stageLabel(failed) : 'a stage'} — ${failed?.detail ?? ''}. ${chain}.`
252  }
253  const promise = settings.wake === 'final' ? 'I will wake you when it gets there or a stage fails.' : 'Watch the status line (wake is off).'
254  return `TRACER ON THE ROAD: ${head} — ${chain}. ${promise}`
255}
256
257async function stopTraces($: EngineInterface, argument: string): Promise<string> {
258  const ref = argument === '' ? null : parseTraceRef(argument)
259  if (argument !== '' && ref === null) return `tracer: "${argument}" is not a PR or a commit. ${USAGE}`
260  const isGone = (trace: TracerTrace) =>
261    ref === null ||
262    (ref.kind === 'pr' ? trace.pr === ref.number : trace.sha.startsWith(ref.sha)) && (ref.repo === null || ref.repo === trace.repo)
263  const list = await read($, traces)
264  const gone = list.filter(isGone)
265  if (gone.length === 0) return 'tracer: nothing to stop.'
266  await update($, traces, all => all.filter(trace => !isGone(trace)))
267  await refreshStatus($)
268  return `tracer: stopped ${gone.map(trace => (trace.pr === null ? shortSha(trace.sha) : `#${trace.pr}`)).join(', ')}.`
269}
270
271async function removeTrace($: EngineInterface, trace: TracerTrace) {
272  await update($, traces, list => list.filter(one => !(one.sha === trace.sha && one.repo === trace.repo)))
273  await refreshStatus($)
274}
275
276const OUTCOME: Record<TracerOutcome, { label: (trace: TracerTrace) => string; color: string }> = {
277  tracing: { label: () => '▶ ON THE ROAD...', color: PICO.yellow },
278  done: { label: () => '★ COURSE CLEAR!', color: PICO.lime },
279  failed: {
280    label: trace => {
281      const stage = trace.stages.find(one => one.state === 'failed')
282      return `✕ GAME OVER: ${stage ? stageLabel(stage) : ''} FAILED`
283    },
284    color: PICO.red,
285  },
286  timeout: { label: () => '● TIME UP', color: PICO.orange },
287}
288
289export const register: Register = (on, options) => {
290  const settings: Settings = {
291    intervalMs: Math.max(15, Number(options.intervalSeconds ?? 60) || 60) * 1000,
292    timeoutMs: Math.max(1, Number(options.timeoutMinutes ?? 120) || 120) * 60_000,
293    environments: parseEnvironments(options.environments),
294    liveUrl: String(options.liveUrl ?? '').trim(),
295    liveMatch: String(options.liveMatch ?? ''),
296    wake: String(options.wake ?? 'final'),
297  }
298
299  on('session.start', async ($, e, next) => {
300    try {
301      await $.command.register({
302        name: 'trace',
303        description: 'tracer: follow a merged PR or commit until it is deployed; no argument opens the map',
304        argumentHint: '[pr | sha | stop]',
305      })
306    } catch {
307      $.ui.toast('TRACER: /trace could not be registered')
308    }
309    timer?.cancel()
310    timer = $.clock.every(settings.intervalMs, () => {
311      void tick($, settings).catch(() => undefined)
312    })
313    await refreshStatus($)
314    return next(e)
315  })
316
317  on('command.run', { command: 'trace' }, async ($, e) => {
318    const argument = (e.args ?? '').trim()
319    const [word = '', ...rest] = argument.split(/\s+/)
320    if (argument === '') {
321      await $.ui.open({ id: PANE, title: 'TRACER' })
322      const list = await read($, traces)
323      return {
324        text:
325          list.length === 0
326            ? `TRACER: nothing on the map. /trace 42 to insert coin. ${USAGE}`
327            : list.map(trace => `TRACER ${who(trace)}: ${chainText(trace.stages)}`).join('\n'),
328      }
329    }
330    if (word.toLowerCase() === 'stop' || word.toLowerCase() === 'off') return { text: await stopTraces($, rest.join(' ')) }
331    return { text: await startTrace($, settings, argument) }
332  })
333
334  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
335    const { Box, Button, Text } = $.ui.resolve(e)
336    const list = await read($, traces)
337    const now = await $.clock.now()
338    const width = Math.max(20, e.props.bodyColumns - 6)
339
340    return (
341      <Box flexDirection="column">
342        <Box flexDirection="row">
343          <Text bold color={SIGNATURE}>
344            T R A C E R
345          </Text>
346          <Text dimColor>
347            {'  '}POLL {Math.round(settings.intervalMs / 1000)}S / WAKE {settings.wake.toUpperCase()} / GIVE UP {Math.round(settings.timeoutMs / 60_000)}M
348          </Text>
349        </Box>
350        {list.length === 0 && (
351          <Box key="empty" marginTop={1}>
352            <Text color={PICO.lightGrey}>NO COURSE LOADED. /trace 42 TO INSERT COIN</Text>
353          </Box>
354        )}
355        {list.map(trace => {
356          const map = levelMap(trace.stages, trace.outcome === 'tracing', e.props.bodyColumns)
357          const outcome = OUTCOME[trace.outcome]
358          const elapsed = (trace.outcome === 'tracing' ? now : trace.checkedAt) - trace.startedAt
359          const title = trace.title.length > width ? `${trace.title.slice(0, width - 1)}~` : trace.title
360          const current = currentStage(trace.stages)
361          return (
362            <Box key={`trace-${trace.sha}`} flexDirection="column" marginTop={1}>
363              <Box flexDirection="row">
364                <Text bold color={SIGNATURE}>
365                  {trace.pr === null ? shortSha(trace.sha) : `#${trace.pr}`}{' '}
366                </Text>
367                <Text wrap="truncate-end">{title}</Text>
368              </Box>
369              <Box flexDirection="row">
370                <Box key={`outcome-${trace.sha}`}>
371                  <Text bold color={outcome.color}>
372                    {outcome.label(trace)}
373                  </Text>
374                </Box>
375                <Text dimColor>
376                  {'  '}WORLD {shortSha(trace.sha)} · {Math.floor(elapsed / 60_000)}M
377                </Text>
378              </Box>
379              {map.pixels.length > 0 && (
380                <Box key={`map-${trace.sha}`} flexDirection="column" marginTop={1}>
381                  {map.pixels.map(runs => (
382                    <Box flexDirection="row">
383                      {runs.map(run => (
384                        <Text color={run.color} backgroundColor={run.backgroundColor}>
385                          {run.text}
386                        </Text>
387                      ))}
388                    </Box>
389                  ))}
390                  {map.labels.map(cells => (
391                    <Box flexDirection="row">
392                      {cells.map(cell => (
393                        <Text color={cell.color}>{cell.text}</Text>
394                      ))}
395                    </Box>
396                  ))}
397                </Box>
398              )}
399              {trace.stages.map(stage => (
400                <Text color={stage.state === 'failed' ? PICO.red : stage === current && trace.outcome === 'tracing' ? PICO.blue : PICO.lightGrey}>
401                  {GLYPH[stage.state]} {stageLabel(stage)}{stage.kind === 'merged' ? ` ${shortSha(trace.sha)}` : `: ${stage.detail}`}
402                </Text>
403              ))}
404              {trace.error !== null && <Text color={PICO.red}>{trace.error}</Text>}
405              <Box flexDirection="row">
406                <Button
407                  key={`stop-${trace.sha}`}
408                  label={trace.outcome === 'tracing' ? 'STOP' : 'CLEAR'}
409                  dimColor
410                  onPress={() => removeTrace($, trace)}
411                />
412              </Box>
413            </Box>
414          )
415        })}
416      </Box>
417    )
418  })
419}
420
hooks/chain.ts 240 lines
1// The chain MERGED ▸ BUILD ▸ DEPLOY:<env> ▸ LIVE: stages from GitHub data,
2// the outcome, and every line of text tracer says. Pure: no `$` here.
3import type { TracerOutcome, TracerStage, TracerTrace } from '../types'
4
5/** One workflow run on the merge commit, as `actions/runs?head_sha=` lists it. */
6export type WorkflowRun = { name: string; status: string; conclusion: string | null }
7
8/** One deployment of the merge commit, with the state of its newest status (null: none yet). */
9export type DeploymentState = { id: number; environment: string; createdAt: string; state: string | null }
10
11/** What the live URL said, or null when it was not fetched. */
12export type LiveResult = { isLive: boolean; detail: string }
13
14export type ChainInput = {
15  /** Workflow runs on the SHA. */
16  runs: WorkflowRun[]
17  deployments: DeploymentState[]
18  /** `userConfig.environments`: deploy stages to wait for, in order. */
19  environments: string[]
20  hasLiveUrl: boolean
21  live: LiveResult | null
22}
23
24const PASSED = new Set(['success', 'neutral', 'skipped'])
25const WAITING = new Set(['action_required'])
26const DEPLOY_DONE = new Set(['success', 'inactive'])
27const DEPLOY_FAILED = new Set(['failure', 'error'])
28
29export const GLYPH: Record<TracerStage['state'], string> = { pending: '●', done: '★', failed: '✕' }
30
31/** Workflow and environment names come from whoever wrote them: plain characters, 40 columns. */
32export function cleanName(name: string): string {
33  return name.replace(/[^A-Za-z0-9 .:/()_-]/g, '').trim().slice(0, 40)
34}
35
36function quotedList(names: readonly string[]): string {
37  const unique = [...new Set(names.map(cleanName))]
38  const shown = unique.slice(0, 3).map(name => `"${name}"`)
39  return unique.length > 3 ? `${shown.join(', ')} +${unique.length - 3} more` : shown.join(', ')
40}
41
42function buildStage(runs: readonly WorkflowRun[]): TracerStage {
43  const stage = (state: TracerStage['state'], detail: string): TracerStage => ({ id: 'build', kind: 'build', state, detail })
44  if (runs.length === 0) return stage('pending', 'NO RUNS YET')
45  const completed = runs.filter(run => run.status === 'completed')
46  const failed = completed.filter(run => !PASSED.has(run.conclusion ?? '') && !WAITING.has(run.conclusion ?? ''))
47  if (failed.length > 0) return stage('failed', `FAILED: ${quotedList(failed.map(run => run.name))}`)
48  const passed = completed.filter(run => PASSED.has(run.conclusion ?? '')).length
49  const waiting = completed.length - passed
50  const running = runs.length - completed.length
51  if (running === 0 && waiting === 0) return stage('done', `${passed}/${runs.length} RUNS PASSED`)
52  const parts = [running > 0 ? `${running} RUNNING` : '', waiting > 0 ? `${waiting} WAITING FOR APPROVAL` : '', passed > 0 ? `${passed} PASSED` : '']
53  return stage('pending', parts.filter(part => part !== '').join(', '))
54}
55
56function newest<T extends { id: number; createdAt: string }>(a: T, b: T): T {
57  if (a.createdAt !== b.createdAt) return a.createdAt > b.createdAt ? a : b
58  return a.id > b.id ? a : b
59}
60
61type Dated = { id: number; environment: string; createdAt: string }
62
63/** The newest deployment of each environment (names compared without case), so only those need a status read. */
64export function latestPerEnvironment<T extends Dated>(deployments: readonly T[]): T[] {
65  const latest = new Map<string, T>()
66  for (const deployment of deployments) {
67    const key = deployment.environment.toLowerCase()
68    const known = latest.get(key)
69    latest.set(key, known === undefined ? deployment : newest(known, deployment))
70  }
71  return [...latest.values()]
72}
73
74function deployStage(environment: string, deployment: DeploymentState | undefined): TracerStage {
75  const base = { id: `deploy:${environment}`, kind: 'deploy' as const, environment }
76  if (deployment === undefined) return { ...base, state: 'pending', detail: 'NO DEPLOYMENT YET' }
77  const state = deployment.state
78  if (state === null) return { ...base, state: 'pending', detail: 'NO STATUS YET' }
79  const detail = state.replace(/_/g, ' ').toUpperCase()
80  if (DEPLOY_DONE.has(state)) return { ...base, state: 'done', detail }
81  if (DEPLOY_FAILED.has(state)) return { ...base, state: 'failed', detail }
82  return { ...base, state: 'pending', detail }
83}
84
85/**
86 * MERGED (always done: only merged commits are traced), BUILD, one DEPLOY per
87 * environment (the configured ones first, then any others in the order they
88 * appeared; the newest deployment of each wins) and LIVE when a URL is set.
89 */
90export function buildStages(input: ChainInput): TracerStage[] {
91  const latest = new Map<string, DeploymentState>()
92  const firstSeen: string[] = []
93  const ordered = [...input.deployments].sort((a, b) => (a.createdAt === b.createdAt ? a.id - b.id : a.createdAt < b.createdAt ? -1 : 1))
94  for (const deployment of ordered) {
95    const key = deployment.environment.toLowerCase()
96    const known = latest.get(key)
97    if (known === undefined) firstSeen.push(deployment.environment)
98    latest.set(key, known === undefined ? deployment : newest(known, deployment))
99  }
100  const configured = new Set(input.environments.map(name => name.toLowerCase()))
101  const environments = [...input.environments, ...firstSeen.filter(name => !configured.has(name.toLowerCase()))]
102
103  const stages: TracerStage[] = [
104    { id: 'merged', kind: 'merged', state: 'done', detail: 'MERGED' },
105    buildStage(input.runs),
106    ...environments.map(name => deployStage(name, latest.get(name.toLowerCase()))),
107  ]
108  if (input.hasLiveUrl) {
109    const live = input.live
110    stages.push({
111      id: 'live',
112      kind: 'live',
113      state: live?.isLive ? 'done' : 'pending',
114      detail: live === null ? 'WAITS FOR DEPLOY' : live.detail,
115    })
116  }
117  return stages
118}
119
120/** True when every stage before LIVE is done, so the live URL is worth a fetch. */
121export function needsLiveCheck(stages: readonly TracerStage[]): boolean {
122  const live = stages.at(-1)
123  if (live?.kind !== 'live' || live.state === 'done') return false
124  return stages.slice(0, -1).every(stage => stage.state === 'done')
125}
126
127/** The first stage not done yet: where the player stands on the map. */
128export function currentStage(stages: readonly TracerStage[]): TracerStage | undefined {
129  return stages.find(stage => stage.state !== 'done')
130}
131
132export function outcomeOf(stages: readonly TracerStage[], startedAt: number, now: number, timeoutMs: number): TracerOutcome {
133  if (stages.some(stage => stage.state === 'failed')) return 'failed'
134  if (stages.every(stage => stage.state === 'done')) return 'done'
135  return now - startedAt >= timeoutMs ? 'timeout' : 'tracing'
136}
137
138export function stageLabel(stage: TracerStage): string {
139  if (stage.kind === 'deploy') return `DEPLOY:${cleanName(stage.environment ?? '').toUpperCase()}`
140  return stage.kind.toUpperCase()
141}
142
143export function shortSha(sha: string): string {
144  return sha.slice(0, 7)
145}
146
147/** The live URL with `{sha}` and `{short}` filled in. */
148export function liveUrlFor(template: string, sha: string): string {
149  return fillSha(template, sha, encodeURIComponent)
150}
151
152function fillSha(template: string, sha: string, encode: (text: string) => string = text => text): string {
153  return template.replace(/\{sha\}/g, encode(sha)).replace(/\{short\}/g, encode(shortSha(sha)))
154}
155
156/** Live when the body holds `liveMatch` (placeholders filled), or else the full or short SHA. */
157export function isLiveBody(body: string, sha: string, liveMatch: string): boolean {
158  if (liveMatch.trim() !== '') return body.includes(fillSha(liveMatch, sha))
159  return body.includes(sha) || body.includes(shortSha(sha))
160}
161
162export function parseEnvironments(text: unknown): string[] {
163  if (typeof text !== 'string') return []
164  return text
165    .split(',')
166    .map(name => name.trim())
167    .filter(name => name !== '')
168}
169
170function who(trace: TracerTrace): string {
171  return trace.pr === null ? shortSha(trace.sha) : `#${trace.pr}`
172}
173
174function whoLong(trace: TracerTrace): string {
175  return trace.pr === null ? shortSha(trace.sha) : `PR #${trace.pr} (${shortSha(trace.sha)})`
176}
177
178export function glyphs(stages: readonly TracerStage[]): string {
179  return stages.map(stage => GLYPH[stage.state]).join('')
180}
181
182/** `★ MERGED ▸ ★ BUILD ▸ ● LIVE` for replies and the pane. */
183export function chainText(stages: readonly TracerStage[]): string {
184  return stages.map(stage => `${GLYPH[stage.state]} ${stageLabel(stage)}`).join(' ▸ ')
185}
186
187function where(trace: TracerTrace): string {
188  if (trace.outcome === 'done') return 'CLEAR!'
189  if (trace.outcome === 'failed') {
190    // The stage that failed, not the first one still pending ahead of it.
191    const failed = trace.stages.find(one => one.state === 'failed')
192    return `${failed ? stageLabel(failed) : ''} FAILED`
193  }
194  if (trace.outcome === 'timeout') return 'TIME UP'
195  const stage = currentStage(trace.stages)
196  return stage ? stageLabel(stage) : ''
197}
198
199const STATUS_COLUMNS = 40
200
201/** `TRACER #42 ★★●● DEPLOY:PRODUCTION` for the newest trace, then `+n` more; under 40 columns. */
202export function statusLine(traces: readonly TracerTrace[]): string | undefined {
203  const [first, ...rest] = traces
204  if (first === undefined) return undefined
205  const tail = `${first.error !== null && first.outcome === 'tracing' ? ' ERR' : ''}${rest.length > 0 ? ` +${rest.length}` : ''}`
206  const line = `TRACER ${who(first)} ${glyphs(first.stages)} ${where(first)}`
207  const room = STATUS_COLUMNS - tail.length
208  return `${line.length > room ? `${line.slice(0, room - 1)}~` : line}${tail}`
209}
210
211function minutes(elapsedMs: number): string {
212  return `${Math.max(0, Math.floor(elapsedMs / 60_000))}m`
213}
214
215/** The one line the session is woken with, when the chain is done or failed. */
216export function wakeLine(trace: TracerTrace, elapsedMs: number): string {
217  const path = trace.stages.map(stageLabel).join(' ▸ ')
218  if (trace.outcome === 'failed') {
219    const stage = trace.stages.find(one => one.state === 'failed')
220    return `TRACER: ${whoLong(trace)} FAILED at ${stage ? stageLabel(stage) : 'a stage'} — ${stage?.detail ?? 'unknown'}. Investigate.`
221  }
222  const last = trace.stages.at(-1)
223  if (last?.kind === 'live') return `TRACER: ${whoLong(trace)} is LIVE — ${path} in ${minutes(elapsedMs)}.`
224  const note = last?.kind === 'build' ? ' No deployments to follow.' : ''
225  return `TRACER: ${whoLong(trace)} reached ${last ? stageLabel(last) : 'the end'} — ${path} in ${minutes(elapsedMs)}.${note}`
226}
227
228/** What moved between two polls, or null when nothing did. */
229export function toastText(before: TracerTrace, after: TracerTrace): string | null {
230  if (after.outcome === 'timeout' && before.outcome !== 'timeout') {
231    const stage = currentStage(after.stages)
232    return `TRACER ${who(after)} TIME UP AT ${stage ? stageLabel(stage) : 'THE END'}`
233  }
234  const previous = new Map(before.stages.map(stage => [stage.id, stage.state]))
235  const moved = after.stages.filter(stage => stage.state !== 'pending' && previous.get(stage.id) !== stage.state)
236  if (moved.length === 0) return null
237  const parts = moved.map(stage => (stage.state === 'failed' ? `✕ ${stageLabel(stage)} FAILED` : `★ ${stageLabel(stage)}`))
238  return `TRACER ${who(after)} ${parts.join(' ')}`
239}
240
hooks/gh.ts 142 lines
1// gh: what /trace accepts, argv builders, output readers and failure
2// messages. Pure: no `$` here.
3import type { WorkflowRun } from './chain'
4
5export type TraceRef =
6  | { kind: 'pr'; number: number; repo: string | null }
7  | { kind: 'sha'; sha: string; repo: string | null }
8
9const REPO = String.raw`[\w.-]+\/[\w.-]+`
10const PR_URL = new RegExp(String.raw`^(?:https?:\/\/)?(?:www\.)?github\.com\/(${REPO})\/pull\/(\d+)(?:[/?#].*)?$`, 'i')
11const COMMIT_URL = new RegExp(String.raw`^(?:https?:\/\/)?(?:www\.)?github\.com\/(${REPO})\/commit\/([0-9a-f]{7,40})(?:[/?#].*)?$`, 'i')
12const PR_SLUG = new RegExp(String.raw`^(${REPO})#(\d+)$`)
13const SHA_SLUG = new RegExp(String.raw`^(${REPO})@([0-9a-f]{7,40})$`, 'i')
14const PR_NUMBER = /^#?(\d+)$/
15const SHA = /^[0-9a-f]{7,40}$/i
16
17function prRef(digits: string | undefined, repo: string | null): TraceRef | null {
18  const number = Number(digits)
19  return Number.isInteger(number) && number > 0 ? { kind: 'pr', number, repo } : null
20}
21
22/**
23 * `42`, `#42`, `owner/repo#42` or a PR URL (a PR); `abc1234`, `owner/repo@abc1234`
24 * or a commit URL (a SHA, 7 to 40 hex characters). All digits reads as a PR.
25 */
26export function parseTraceRef(argument: string): TraceRef | null {
27  const text = argument.trim()
28  let match = PR_URL.exec(text) ?? PR_SLUG.exec(text)
29  if (match) return prRef(match[2], match[1] ?? null)
30  match = PR_NUMBER.exec(text)
31  if (match) return prRef(match[1], null)
32  match = COMMIT_URL.exec(text) ?? SHA_SLUG.exec(text)
33  if (match) return { kind: 'sha', sha: (match[2] ?? '').toLowerCase(), repo: match[1] ?? null }
34  if (SHA.test(text)) return { kind: 'sha', sha: text.toLowerCase(), repo: null }
35  return null
36}
37
38export const REPO_VIEW_ARGV = ['gh', 'repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner'] as const
39
40export function prViewArgv(repo: string, number: number): string[] {
41  return ['gh', 'pr', 'view', String(number), '--repo', repo, '--json', 'number,title,state,mergeCommit']
42}
43
44export function commitArgv(repo: string, sha: string): string[] {
45  return ['gh', 'api', `repos/${repo}/commits/${sha}`]
46}
47
48export function runsArgv(repo: string, sha: string): string[] {
49  return ['gh', 'api', `repos/${repo}/actions/runs?head_sha=${sha}&per_page=100`]
50}
51
52export function deploymentsArgv(repo: string, sha: string): string[] {
53  return ['gh', 'api', `repos/${repo}/deployments?sha=${sha}&per_page=100`]
54}
55
56/** The newest status of one deployment (GitHub lists them newest first). */
57export function deploymentStatusArgv(repo: string, id: number): string[] {
58  return ['gh', 'api', `repos/${repo}/deployments/${id}/statuses?per_page=1`]
59}
60
61/** JSON.parse that survives raw control characters in strings (each becomes a space). */
62export function parseJson(text: string): unknown {
63  try {
64    // eslint-disable-next-line no-control-regex
65    return JSON.parse(text.replace(/[\u0000-\u001f]/g, ' '))
66  } catch {
67    return null
68  }
69}
70
71function field(value: unknown, key: string): unknown {
72  return typeof value === 'object' && value !== null ? (value as Record<string, unknown>)[key] : undefined
73}
74
75export type PrView = { number: number; title: string; state: string; mergeSha: string | null }
76
77export function readPrView(json: unknown): PrView | null {
78  const number = field(json, 'number')
79  if (typeof number !== 'number') return null
80  const oid = field(field(json, 'mergeCommit'), 'oid')
81  return {
82    number,
83    title: String(field(json, 'title') ?? ''),
84    state: String(field(json, 'state') ?? ''),
85    mergeSha: typeof oid === 'string' && oid !== '' ? oid.toLowerCase() : null,
86  }
87}
88
89export function readCommit(json: unknown): { sha: string; title: string } | null {
90  const sha = field(json, 'sha')
91  if (typeof sha !== 'string' || sha === '') return null
92  const message = String(field(field(json, 'commit'), 'message') ?? '')
93  return { sha: sha.toLowerCase(), title: message.split('\n')[0]?.trim() ?? '' }
94}
95
96export function readRuns(json: unknown): WorkflowRun[] | null {
97  const list = field(json, 'workflow_runs')
98  if (!Array.isArray(list)) return null
99  return list.map(run => ({
100    name: String(field(run, 'name') ?? ''),
101    status: String(field(run, 'status') ?? ''),
102    conclusion: (field(run, 'conclusion') as string | null | undefined) ?? null,
103  }))
104}
105
106export type Deployment = { id: number; environment: string; createdAt: string }
107
108export function readDeployments(json: unknown): Deployment[] | null {
109  if (!Array.isArray(json)) return null
110  return json.flatMap(item => {
111    const id = field(item, 'id')
112    if (typeof id !== 'number') return []
113    return [{ id, environment: String(field(item, 'environment') ?? 'unknown'), createdAt: String(field(item, 'created_at') ?? '') }]
114  })
115}
116
117export function readLatestStatus(json: unknown): { state: string | null } | null {
118  if (!Array.isArray(json)) return null
119  const state = field(json[0], 'state')
120  return { state: typeof state === 'string' ? state : null }
121}
122
123const UNAUTHENTICATED = /gh auth login|not logged in|authentication|bad credentials|HTTP 401|GH_TOKEN/i
124
125/** A gh run that exited non-zero, said in one line. */
126export function describeGhFailure(stderr: string): string {
127  if (UNAUTHENTICATED.test(stderr)) return 'gh is not authenticated: run `gh auth login`, then /trace again.'
128  const first = stderr.trim().split('\n')[0]?.trim() ?? ''
129  return first === '' ? 'gh failed with no message' : `gh failed: ${first.slice(0, 200)}`
130}
131
132const TIMED_OUT = /timed? ?out|timeout|ETIMEDOUT|still running/i
133
134/** A gh run that rejected: it timed out, or it could not start at all. */
135export function describeSpawnFailure(message: string, elapsedMs = 0, timeoutMs = Number.POSITIVE_INFINITY): string {
136  if (elapsedMs >= timeoutMs || TIMED_OUT.test(message)) {
137    const seconds = Number.isFinite(timeoutMs) ? `${Math.round(timeoutMs / 1000)}s` : 'its time limit'
138    return `gh timed out after ${seconds} (network?); tracer will try again next poll.`
139  }
140  return `gh could not run (${message.slice(0, 120)}): install the GitHub CLI (cli.github.com) and put it on PATH.`
141}
142
hooks/pixels.ts 200 lines
1// Pixel art: the PICO-8 palette, the level-map nodes and a half-block renderer.
2import type { TracerStage } from '../types'
3import { GLYPH, cleanName, currentStage } from './chain'
4
5export const PICO = {
6  black: '#000000',
7  navy: '#1D2B53',
8  plum: '#7E2553',
9  green: '#008751',
10  brown: '#AB5236',
11  darkGrey: '#5F574F',
12  lightGrey: '#C2C3C7',
13  white: '#FFF1E8',
14  red: '#FF004D',
15  orange: '#FFA300',
16  yellow: '#FFEC27',
17  lime: '#00E436',
18  blue: '#29ADFF',
19  lavender: '#83769C',
20  pink: '#FF77A8',
21  peach: '#FFCCAA',
22} as const
23
24/** tracer's signature colour. */
25export const SIGNATURE = PICO.yellow
26
27const KEYS: Record<string, string> = {
28  k: PICO.black,
29  y: PICO.yellow,
30  w: PICO.white,
31  r: PICO.red,
32  b: PICO.blue,
33  d: PICO.darkGrey,
34  o: PICO.orange,
35}
36
37/** Map nodes, 6 x 4 pixels (2 terminal rows). `.` is transparent. */
38const NODE = {
39  // A coin with a shine: a stage cleared.
40  done: ['.yyyy.', 'yywwyy', 'yyyyyy', '.yyyy.'],
41  // An empty ring: a stage still ahead.
42  pending: ['.dddd.', 'dd..dd', 'dd..dd', '.dddd.'],
43  // The player: the stage tracer is waiting on now.
44  current: ['.bbbb.', 'bbwwbb', 'bbwwbb', '.bbbb.'],
45  // A red cross: the stage failed.
46  failed: ['rr..rr', '.rrrr.', '.rrrr.', 'rr..rr'],
47} as const
48
49const NODE_WIDTH = 6
50
51/** One run of same-styled cells in a terminal row. */
52export type PixelRun = { text: string; color?: string; backgroundColor?: string }
53
54/**
55 * Two pixel rows per terminal row: `▀` with the top pixel as `color` and the
56 * bottom as `backgroundColor`; `▄` when only the bottom is set; a space when
57 * neither. Adjacent cells with the same style merge into one run.
58 */
59export function halfBlockRows(grid: readonly string[]): PixelRun[][] {
60  const width = Math.max(0, ...grid.map(line => line.length))
61  const rows: PixelRun[][] = []
62  for (let top = 0; top < grid.length; top += 2) {
63    const runs: PixelRun[] = []
64    for (let column = 0; column < width; column += 1) {
65      const upper = KEYS[grid[top]?.[column] ?? '.']
66      const lower = KEYS[grid[top + 1]?.[column] ?? '.']
67      const cell: PixelRun =
68        upper !== undefined
69          ? lower !== undefined
70            ? { text: '▀', color: upper, backgroundColor: lower }
71            : { text: '▀', color: upper }
72          : lower !== undefined
73            ? { text: '▄', color: lower }
74            : { text: ' ' }
75      const last = runs[runs.length - 1]
76      if (last && last.color === cell.color && last.backgroundColor === cell.backgroundColor && last.text[0] === cell.text) {
77        last.text += cell.text
78      } else {
79        runs.push(cell)
80      }
81    }
82    rows.push(runs)
83  }
84  return rows
85}
86
87type Look = keyof typeof NODE
88
89function lookOf(stage: TracerStage, current: TracerStage | undefined, isTracing: boolean): Look {
90  if (stage.state === 'done') return 'done'
91  if (stage.state === 'failed') return 'failed'
92  return isTracing && stage === current ? 'current' : 'pending'
93}
94
95const LABEL_COLOR: Record<Look, string> = {
96  done: PICO.yellow,
97  failed: PICO.red,
98  current: PICO.blue,
99  pending: PICO.lightGrey,
100}
101
102function center(text: string, width: number): string {
103  const left = Math.floor((width - text.length) / 2)
104  return `${' '.repeat(left)}${text}`.padEnd(width)
105}
106
107function environmentTag(stage: TracerStage): string {
108  if (stage.kind !== 'deploy') return ''
109  const name = cleanName(stage.environment ?? '').toUpperCase()
110  return name.length > 8 ? `${name.slice(0, 7)}~` : name
111}
112
113export type LabelCell = { text: string; color: string }
114
115/** One node to draw: a stage, or every deploy stage folded into one. */
116type MapNode = { state: TracerStage['state']; look: Look; kind: string; tag: string }
117
118function nodeOf(stage: TracerStage, current: TracerStage | undefined, isTracing: boolean): MapNode {
119  const kind = `${GLYPH[stage.state]} ${stage.kind === 'deploy' ? 'DEPLOY' : stage.kind.toUpperCase()}`
120  return { state: stage.state, look: lookOf(stage, current, isTracing), kind, tag: environmentTag(stage) }
121}
122
123/** Every DEPLOY node folded into one `DEPLOY` node tagged `2/5` (environments done of all). */
124function foldedNodes(stages: readonly TracerStage[], current: TracerStage | undefined, isTracing: boolean): MapNode[] {
125  const deploys = stages.filter(stage => stage.kind === 'deploy')
126  if (deploys.length < 2) return stages.map(stage => nodeOf(stage, current, isTracing))
127  const state: TracerStage['state'] = deploys.some(stage => stage.state === 'failed')
128    ? 'failed'
129    : deploys.every(stage => stage.state === 'done')
130      ? 'done'
131      : 'pending'
132  const look: Look = state !== 'pending' ? state : isTracing && current?.kind === 'deploy' ? 'current' : 'pending'
133  const done = deploys.filter(stage => stage.state === 'done').length
134  const folded: MapNode = { state, look, kind: `${GLYPH[state]} DEPLOY`, tag: `${done}/${deploys.length}` }
135  const nodes: MapNode[] = []
136  for (const stage of stages) {
137    if (stage.kind !== 'deploy') nodes.push(nodeOf(stage, current, isTracing))
138    else if (stage === deploys[0]) nodes.push(folded)
139  }
140  return nodes
141}
142
143function nodeWidth(node: MapNode): number {
144  return Math.max(NODE_WIDTH, node.kind.length, node.tag.length) + 2
145}
146
147function mapWidth(nodes: readonly MapNode[]): number {
148  return nodes.reduce((sum, node) => sum + nodeWidth(node), 0)
149}
150
151/**
152 * The level map: one node per stage joined by a dotted path (orange where it
153 * has been travelled), two terminal rows of half-block pixels, then a row of
154 * `★ BUILD`-style labels and a row of deploy environment names.
155 *
156 * Wider than `maxColumns`, the deploy nodes fold into one `DEPLOY 2/5` node;
157 * still wider, there is no map (the per-stage lines below it carry it all).
158 */
159export function levelMap(
160  stages: readonly TracerStage[],
161  isTracing: boolean,
162  maxColumns = Number.POSITIVE_INFINITY,
163): { pixels: PixelRun[][]; labels: LabelCell[][] } {
164  const current = currentStage(stages)
165  let nodes = stages.map(stage => nodeOf(stage, current, isTracing))
166  if (mapWidth(nodes) > maxColumns) nodes = foldedNodes(stages, current, isTracing)
167  if (mapWidth(nodes) > maxColumns) return { pixels: [], labels: [] }
168  const columns = nodes.map(node => ({ ...node, width: nodeWidth(node) }))
169  const total = mapWidth(nodes)
170  const grid = [0, 1, 2, 3].map(() => Array.from({ length: total }, () => '.'))
171  let left = 0
172  let previousRight = -1
173  columns.forEach(column => {
174    const start = left + Math.floor((column.width - NODE_WIDTH) / 2)
175    if (previousRight >= 0) {
176      const road = column.state === 'done' ? 'o' : 'd'
177      // A dotted path at the nodes' waist, one pixel every other column.
178      for (let x = previousRight + 1; x < start - 1; x += 2) grid[2]![x] = road
179    }
180    NODE[column.look].forEach((line, row) => {
181      for (let x = 0; x < NODE_WIDTH; x += 1) grid[row]![start + x] = line[x] ?? '.'
182    })
183    previousRight = start + NODE_WIDTH
184    left += column.width
185  })
186  return {
187    pixels: halfBlockRows(grid.map(row => row.join(''))),
188    labels: [
189      columns.map(column => ({ text: center(column.kind, column.width), color: LABEL_COLOR[column.look] })),
190      columns.map(column => ({ text: center(column.tag, column.width), color: LABEL_COLOR[column.look] })),
191    ],
192  }
193}
194
195/** The plain-text capture of the map (what a monochrome terminal shows). */
196export function plainMap(stages: readonly TracerStage[], isTracing: boolean, maxColumns = Number.POSITIVE_INFINITY): string[] {
197  const map = levelMap(stages, isTracing, maxColumns)
198  return [...map.pixels, ...map.labels].map(runs => runs.map(run => run.text).join(''))
199}
200
types/index.d.ts 41 lines
1/** Where one stage of the chain stands: ● pending, ★ done, ✕ failed. */
2export type TracerStageState = 'pending' | 'done' | 'failed'
3
4/** One node on the level map: MERGED, BUILD, DEPLOY:<env> or LIVE. */
5export type TracerStage = {
6  /** `merged`, `build`, `deploy:<env>` or `live`. */
7  id: string
8  kind: 'merged' | 'build' | 'deploy' | 'live'
9  /** The environment name, for a deploy stage. */
10  environment?: string
11  state: TracerStageState
12  /** One short line on why the stage stands where it does. */
13  detail: string
14}
15
16/** How a trace ended, or `tracing` while it still polls. */
17export type TracerOutcome = 'tracing' | 'done' | 'failed' | 'timeout'
18
19/** One merged commit being followed until it is deployed (and optionally live). */
20export type TracerTrace = {
21  repo: string
22  /** The full merge commit SHA. */
23  sha: string
24  pr: number | null
25  title: string
26  startedAt: number
27  checkedAt: number
28  stages: TracerStage[]
29  outcome: TracerOutcome
30  /** Set once the session was woken for this trace, so it is never woken twice. */
31  hasWoken: boolean
32  /** Why the last poll could not read GitHub (or the live URL), or null. */
33  error: string | null
34}
35
36declare module 'claude-code' {
37  interface PluginState {
38    tracer: { traces: TracerTrace[] }
39  }
40}
41