Watch CI
Monitor GitHub PR CI checks in the background, notify on completion via macOS system notification. Also supports watching CI on the merge target branch when a PR is already merged.
The polling itself is a pure shell loop (gh CLI + jq) launched via Bash with runinbackground: true. No subagent is spawned — token cost is paid only at launch and at completion, not on every poll cycle.
On Claude Code on the web ($CLAUDECODEREMOTE=true): follow [web/web-mode.md](../../web/web-mode.md). gh is unavailable, so the gh-based poll scripts won't run — poll CI through the GitHub MCP instead (pullrequestread with getcheckruns, or actions* / getjob_logs), or the Monitor tool. There is no macOS notifier on web; just report status when checks finish. When this watch is part of a -m merge flow (/pr-complete -c -w, or the Merge Mode of /x-as-pr / /x-wt-teams), follow web-mode.md §8: poll in-turn and block until the checks are terminal — Step 3's "launch a background poll and end the turn" model is terminal-only, because web has no background-task wakeup, so a background watch + turn-end would leave the merge unfired. Stay in the turn until terminal, then let the caller merge in the same run.
Scripts
scripts/notify.sh — macOS notification helper
scripts/poll-pr-checks.sh <pr-number> [max-min] — poll an open PR's checks until terminal, then notify
scripts/poll-runs.sh <branch> <commit-sha> [max-min] — poll workflow runs on a branch+commit until terminal, then notify
All scripts default to a 60-minute cap and a 30-second poll interval.
False-pass guards — do NOT "simplify" these away
A green report from this skill is often what authorizes a merge, so a false pass is the expensive failure mode. Both poll scripts carry three guards, added after a real incident where a PR was reported "All CI passed" while its actual workflow had not started:
- A skipped check is not a passing check. The original arithmetic was
passed = total - pending - failed, which folded skips into the pass count. A single third-party skipping check therefore read as "1/1 ok". Both scripts now count the pass / success state explicitly and report skips as their own number.
- Checks that GitHub has not registered yet are invisible.
gh pr checks lists only
registered checks, so a queued workflow (or one waiting on a concurrency group) does not appear at all — leaving one unrelated check to stand in for the whole PR. poll-pr-checks.sh therefore cross-checks gh run list for the head SHA and refuses to conclude while any run for that commit is not completed. Related: the server-side gh run list --commit <sha> filter has been observed to omit queued runs that --branch returns, so both scripts list by branch and match the SHA in jq.
- A terminal-looking state must hold for 2 consecutive polls before it is believed,
since both the check list and the run list lag a push by seconds.
Exit codes: 0 passed · 1 failed · 2 timeout · 3 inconclusive · 64 bad args.
Exit 3 (RESULT: INCONCLUSIVE) means checks reached a terminal state but nothing produced a result — only skips, or no real checks. That is legitimate for a fully path-filtered PR, but it is not a pass and must never be reported as one: the caller decides what it means. Treat it the same as a red result for any merge decision.
When reporting a pass to the user, sanity-check the job count. If a repo normally runs N workflows and the result names fewer, say so rather than reporting green — that mismatch is exactly how the original bug surfaced.
Workflow
Step 1: Identify the PR
Determine which PR to watch:
# If user provides a PR number or URL, use it directly
# Otherwise, detect from current branch
gh pr view --json number,title,url,headRefName,baseRefName,state,mergeCommit --jq '{number,title,url,headRefName,baseRefName,state,mergeCommit}'
If no PR is found for the current branch, inform the user and stop.
Check the PR state:
- If
state is "OPEN" → proceed to Step 2 (normal PR watch)
- If
state is "MERGED" → proceed to Step 2b (merged PR: watch target branch CI)
- If
state is "CLOSED" (not merged) → inform the user the PR was closed without merging and stop
Step 2: Show Initial Status (Open PR)
Show the current state:
gh pr checks <PR_NUMBER> --json name,state,bucket,workflow
Report to the user: PR number/title, total checks, current status breakdown (passed/pending/failed).
If checks have already failed, skip to Step 5. If they look all-green, go to Step 4 — but read its two confirmations first: an initial snapshot cannot distinguish "everything passed" from "the real workflow has not registered yet", and taking the fast path on that snapshot is the documented false-pass bug. Otherwise proceed to Step 3.
Step 2b: Merged PR — Switch to Target Branch CI
When the PR is already merged:
- Get the base branch and merge commit SHA from Step 1 output
- Inform the user: "PR #123 is already merged into
main. Watching CI on main for merge commit abc1234..."
- Show initial status, filtered by the merge commit SHA:
``bash # Filter on headSha CLIENT-side. Server-side --commit <sha> has been observed to OMIT # queued runs that --branch does return (poll-runs.sh guard 2) — using it here would # make a still-queued workflow invisible and push this step into the missing-run branch # below for a run that is simply not started yet. gh run list --branch <base-branch> --limit 40 --json databaseId,name,headSha,status,conclusion \ | jq --arg sha '<merge-commit-sha>' '[.[] | select(.headSha | startswith($sha))]' ``
Runs take a few seconds to register after a merge. If none are found, retry this same SHA-filtered query 2-3 more times with a short delay (~10s) before concluding anything. Do not fall back to an unfiltered gh run list --branch <base-branch> as the result path — a run belonging to an unrelated commit can surface there and get misreported as this merge's outcome. A broader, unfiltered listing may still be shown alongside the determination below for context, but never in place of it.
- If the SHA-filtered query still returns nothing after retrying, do not report "no
CI detected" from that alone. Run the push-trigger-check determination — see [references/push-trigger-check.md](references/push-trigger-check.md), the single source of truth for this classification — and report one of its three outcomes:
EXPECTEDRUNMISSING → report a failure. A workflow should have run for this
merge commit and did not; name the likely cause per the reference (a skip marker in the merge commit message, or a merge performed by Actions using GITHUB_TOKEN).
BENIGNNOTRIGGER → report "no CI expected" and name the specific reason (no
matching push trigger, excluded by a branches/paths filter, a disabled workflow, or Actions disabled repo-wide). Never report bare "no CI detected" without the reason.
INCONCLUSIVE → surface it as unresolved — never as a pass, never as benign. State
what could not be determined and why (unparseable workflow YAML, an API error, a truncated changed-file list).
One escape hatch back to Step 3: the reference's Step 5 may find a run for this SHA that this step's listing missed and whose status is not yet completed (queued, in_progress, waiting, requested). That is "not yet", not a verdict — do not classify it into any of the three outcomes. Go to Step 3 and poll it instead. Otherwise do not proceed to Step 3 in this branch — there is nothing to poll.
- If runs were found (on the initial query or a retry), proceed to Step 3 to poll
them to a terminal state.
Step 3: Launch Background Poll (CLI-only, no subagent)
Do NOT launch a subagent. Use the Bash tool with runinbackground: true to run the polling shell script. The script polls gh directly, exits when checks reach a terminal state, fires a macOS notification, and prints a final RESULT: line.
For an open PR:
bash $HOME/.claude/skills/watch-ci/scripts/poll-pr-checks.sh <PR_NUMBER>
For a merged PR:
bash $HOME/.claude/skills/watch-ci/scripts/poll-runs.sh <BASE_BRANCH> <MERGE_SHA>
Behaviour:
- Polls every 30 seconds (open PR:
gh pr checks; merged PR: gh run list --branch ... with the SHA matched client-side in jq — not --commit, which can hide queued runs)
- On success:
notify.sh success (Glass sound) + RESULT: PASSED to stdout, exit 0
- On failure:
notify.sh error (Basso sound) with failed check names + RESULT: FAILED (<names>), exit 1
- On timeout (default 60 min):
notify.sh warning (Purr sound) + RESULT: TIMEOUT, exit 2
- On inconclusive (runs completed but none produced a
success, only skips): notify.sh warning + RESULT: INCONCLUSIVE (...), exit 3 — this is poll-runs.sh's own guard against reporting a fluke pass (see the false-pass guards above); it is not a pass
After launching, tell the user: "Watching CI in background. You'll be notified when it completes."
When the background task completes you'll be notified automatically. Read its output file to see the RESULT: line, report to the user, and — if FAILED or INCONCLUSIVE — treat it as a problem, not a green light: for FAILED, proceed to Step 5's investigation steps; for INCONCLUSIVE, report it as unresolved and do not authorize a merge on it (same handling as the INCONCLUSIVE push-trigger-check outcome in Step 2b).
Step 4: All Checks Passed (Foreground Fast Path)
This fast path is where the false-pass bug bites hardest, because it skips the polling loop that carries the guards. An initial gh pr checks snapshot showing "everything green" is exactly what an un-started workflow looks like. So before taking it:
- Confirm nothing is still queued for the head commit — a run that is
queued /
in_progress means CI has not finished, whatever the check list says:
``bash SHA=$(gh pr view <PRNUMBER> --json headRefOid -q .headRefOid) BR=$(gh pr view <PRNUMBER> --json headRefName -q .headRefName) gh run list --branch "$BR" --limit 40 --json headSha,name,status,conclusion \ | jq --arg sha "$SHA" '[.[] | select(.headSha == $sha)]' ``
- Confirm at least one check actually passed — not merely "none failed". A list of
only skipping buckets is RESULT: INCONCLUSIVE, not a pass.
If either confirmation fails, do not use this fast path — fall through to Step 3 and let the poll script settle it.
Once both hold:
- Send notification:
``bash bash $HOME/.claude/skills/watch-ci/scripts/notify.sh success "All CI checks passed! PR #<number>" ``
- Report the final status summary, naming the jobs that passed and any that were skipped.
Step 5: CI Check Failed (Foreground Fast Path)
If checks already failed at Step 2/2b:
- Send notification:
``bash bash $HOME/.claude/skills/watch-ci/scripts/notify.sh error "CI check failed: <check-name>. PR #<number>" ``
- Investigate the failure:
``bash gh pr checks <PR_NUMBER> --json name,state,bucket,link --jq '[.[] | select(.bucket == "fail" or .bucket == "cancel")]' gh run list --branch <branch> --status failure --limit 5 --json databaseId,name,conclusion gh run view <run-id> --log-failed ``
- Analyze and report. For open PRs: offer to fix. For merged PRs: report only, do NOT auto-fix on the target branch.
Notes
- Polling is a pure shell loop run via
Bash runinbackground: true — no subagent, no per-cycle token cost. The main conversation only pays at launch and at completion.
- System notifications use macOS
osascript via notify.sh
- The
gh CLI must be authenticated and have access to the repository
- 30-second polling interval balances responsiveness with API rate limits
- Default cap 60 minutes; override with the optional
[max-min] arg to either script
- For merged PRs, watches workflow runs on the target branch filtered by merge commit SHA
- The script's stdout (progress lines + final
RESULT: line) is captured by the background task — read the output file when you get the completion notification
- Each progress line reports
pass / fail / skip / pending separately rather than a single
N/M ok ratio, so a run carrying skipped checks is legible at a glance instead of being rounded up into the pass count
RESULT: PASSED is the only line that authorizes a merge. RESULT: INCONCLUSIVE
(exit 3) means nothing actually ran — treat it as not-green and investigate
- On web, where these
gh scripts can't run, the MCP polling that replaces them needs the
same three guards — skips aren't passes, queued runs block the verdict, and let the state settle before believing it