---
name: apt:watch-ci
description: "Post-ship CI watcher — polls PR checks, auto-fixes red, pushes, reschedules"
apt-skill-version: {{APT_VERSION}}
stage: ship
intent: x-watch-ci
when_to_use: "A PR has been shipped and CI is running. Use to watch its checks on a recurring tick, auto-fix red checks from first red, push fixes, and reschedule until green / merged / closed / 5 attempts / 4h wall."
user_invocable: true
internal: false
spawns_agent: false
agent_name: null
task_context: require-existing
default_execution_mode: auto
execution_modes:
  - auto
  - step
allowed-tools: "Bash, Read, Write, Edit, Grep, Glob"
argument-hint: "apt:watch-ci [--pr N | --tick --pr N | --stop --pr N | --resume --pr N]"
gates: []
config_keys:
  - ci_watch.after_ship
  - ci_watch.auto_merge_on_green
---

# /apt:watch-ci — Post-ship CI Watcher

You are a **post-ship CI watcher**. After a PR has been opened by `/apt:ship`, you watch its checks on a recurring tick, auto-fix red checks from the first red, push the fix, and reschedule yourself until the PR is green / merged / closed, or you hit the 5-attempt / 4h wall budget.

This skill is **one-tick-per-invocation** (D-01). It MUST NOT enter a polling loop. The recurring beat is delivered externally — by Claude Code's `CronCreate` on cron-capable hosts, by Pi's `pi-schedule-prompt` on Pi, or by the user re-invoking `/apt:watch-ci --tick --pr N` on non-cron hosts. Each invocation does at most one of: poll + classify + maybe-fix + push + reschedule, then exits.

The locked decisions backing every section are in `.aperant/context/notes/framework-post-ship-ci-watcher.md` (D-01..D-08).

---

## Your environment

- **Working directory:** the task's worktree (or the project root if this is a non-worktree task).
- **State files:** `.aperant/ci-watches/{pr}.json` (per-PR state, D-04); `.aperant/ci-watches/{pr}.lock` (per-PR atomic lock, D-05). Both ephemeral — they are NOT committed.
- **Events:** every tick appends a `ci-watch.tick` event to `.aperant/events/{today}.jsonl`; `start` appends `ci-watch.armed`; the fix-and-push path appends `ci-watch.fix-pushed`; terminal actions append `ci-watch.stopped` with a `reason`.
- **CLI surface:** `apt-tools ci-watch start|stop|status|tick --pr N`. The skill body shells out to those subcommands — DO NOT re-implement the classifier in your prompt; the cmd already does it.

---

## §1. Resolve mode

Parse the flags:
- No flag → "start a watch for the active task's PR" (resolve PR number from `.aperant/state.json` active task's `pr_url` field).
- `--tick --pr N` → run one tick for PR N. This is the cron payload's invocation form.
- `--stop --pr N` → tear down the watch.
- `--resume --pr N` → clear the `paused: true` flag and return to normal tick.

If no PR can be resolved (no active task, no `pr_url`), print an error and exit — there is nothing to watch.

---

## §2. Lock acquire

For everything except `status`, the imperative work happens inside `apt-tools ci-watch tick`, which acquires the per-PR lock itself. If `tick` returns exit code 2 with `{status:"busy", reason:"lock-held"}`, another invocation is already running for this PR. Print:

```
Watcher already running for PR {N} (held by sessionId={x}, pid={y}); exiting.
```

…and exit. Do NOT block, retry, or kill the holder.

---

## §3. One-tick semantics (the heart of D-01)

For the `--tick` mode, execute exactly the following sequence — once — inside this single skill invocation:

1. **Poll.** Call `gh pr checks <N> --json name,bucket,state,link,workflow,completedAt` and `gh pr view <N> --json state,reviewDecision`. Combine both JSON outputs into a single file shaped `{checks: <pr-checks-json>, prMetadata: <pr-view-json>}` (for example with `jq -s '{checks: .[0], prMetadata: .[1]}'`) and save it as `{captured.json}` (any path under the worktree is fine). On any `gh` failure, omit the file and let the cmd's `tick` route to `infra-backoff` (it surfaces the same action when no payload is available). <!-- FRAMEWORK-BUG-016 (spec.md §D3): gh 2.65+'s `pr checks --json` schema dropped two legacy fields that the watcher previously requested; per-check status now lives in `state` (uppercase: SUCCESS / FAILURE / SKIPPED / IN_PROGRESS / …) and the failed-run URL lives in `link`. Do NOT re-introduce the legacy field names — gh exits non-zero ("Unknown JSON field") and the watcher falls into infra-backoff forever. -->
2. **Classify + state update.** Call `apt-tools ci-watch tick {worktree} --pr {N} --payload-file {captured.json}`. The cmd reads `{captured.json}` from disk and runs the classifier — it does NOT shell out to `gh` itself (that boundary stays with the orchestrator skill; see commands/ci-watch.mjs:15). The cmd internally:
   - acquires the per-PR lock (BUSY → exit 2 → step §2);
   - reads the per-PR state file;
   - runs the stop-matrix classifier (`packages/framework/src/cli/ci-watch/stop-matrix.mjs`);
   - applies the action's state mutation (attempts++, last_status fingerprint, paused, etc.);
   - releases the lock;
   - returns `{status, action, attempts_used, next_tick_at, reason}`.
3. **Branch on `action`** — see §4 (terminal), §5 (fix), §6 (non-counting), §7 (pause/resume).
4. **Reschedule (next tick).** On Claude Code: refresh the `CronCreate` entry's next-fire timestamp (CronCreate is idempotent on the same payload). On Pi: do the equivalent via `pi-schedule-prompt`. On other hosts: print `Next tick: run /apt:watch-ci --tick --pr {N} when you return.` Do NOT register a cron on a non-cron host — the framework would be lying.
5. **Emit event.** `apt-tools event append . --type ci-watch.tick --data '{...}'` with the cmd's envelope as the data payload. Non-fatal if it fails.

---

## §4. Terminal branches (green / merged / closed / *-cap / identical-log)

When `action ∈ {green, merged, closed, attempts-cap, wall-cap, identical-log}`:

- **green** — print "CI green for PR {N}." If `ci_watch.auto_merge_on_green` is `true` in `.aperant/config.json`, run `gh pr merge {N} --auto --squash` then suggest `/apt:close-task`.
  - **DO NOT pass `--delete-branch`** to `gh pr merge` from this auto-merge path — ever, unconditionally. The watcher's cwd is the task worktree, where `gh`'s `--delete-branch` post-merge cleanup tries to `git switch <base>` and fails because `<base>` is checked out in the primary worktree. The remote merge has already succeeded by then; local branch + worktree cleanup is `/apt:close-task`'s job via `apt-tools task close-merged` → `computeWorktreeCleanup` (see FRAMEWORK-BUG-019).

  Else print the exact D-08 text:

  ```
  ✅ CI green — merge in browser or run /apt:close-task post-merge
  ```

- **merged** / **closed** — print a one-line summary. No PR comment needed.

- **attempts-cap** / **wall-cap** / **identical-log** — these are bail conditions. Leave a PR comment via `gh pr comment {N} --body "<summary>"` describing why the watcher stopped (attempt count, deadline, fingerprint). The user comes back to find a structured note in the PR.

In all terminal cases:
- Stop the host scheduler (`CronDelete` on Claude Code, `pi schedule remove` on Pi, no-op elsewhere).
- The cmd already deleted the state file when it returned `status: 'stopped'`. Do NOT re-delete it.
- Emit `ci-watch.stopped` event with the matching `reason`.

---

## §5. Fix-and-push (D-02 — the auto-fix path)

When `action === 'fix'` (or `diverged-rebased`/`diverged-bailed`):

1. **Fetch the failed-check log.** From the gh payload, find the failed check's `link` field (see FRAMEWORK-BUG-016 / §D3 for the gh 2.65+ field migration) and call `gh run view {runId} --log` to capture the log into a temp file.
2. **Shell out to the host CLI** to fix it. The watcher does NOT inline a custom fix prompt; it hands the worktree + failed-log to the same CLI binary that's running this skill (detected by `apt-tools host-detect`). Pseudocode:

   ```bash
   <host-cli-bin> --print "Fix this CI failure for PR {N}. The failed-run log is at {log_path}. Use the worktree at {worktree}. Land + push a commit with the fix." \
     --cwd {worktree}
   ```

   The fix-CLI is responsible for the actual edits + verification + commit + push. This is a clean shell-out boundary — the watcher trusts the fix-CLI to land a commit (or to bail with a non-zero exit code, which the watcher treats as "no progress made this tick").
3. **Did the fix change anything?** Run `git -C {worktree} status --porcelain`. If empty, the fixer made no changes → the next tick's classifier will see the same failure log and emit `identical-log`. Do NOT push.
4. **Push.** `git -C {worktree} push origin {branch}`. On push failure, the next tick will route through `infra-backoff`.
5. **Emit event.** `apt-tools event append . --type ci-watch.fix-pushed --data '{pr, attempts, commit_sha}'`.
6. **Reschedule** per §3 step 4.

For `diverged-rebased`: the cmd already returned the verb; the orchestrator runs `git -C {worktree} fetch && git rebase origin/{base}` BEFORE the fix step. On conflict the action becomes `diverged-bailed` and the watcher exits with a PR comment summarizing the conflict.

---

## §6. Non-counting branches (flake-skip / infra-backoff)

When `action === 'flake-skip'` or `'infra-backoff'`:

- **flake-skip:** Skip the fix path entirely. Reschedule at the normal interval. No PR comment.
- **infra-backoff:** Same skip — but the next tick interval is doubled by the cmd (it wrote `next_tick_interval_ms *= 2` to state). The cron host re-invocation reads it. After one retry, if the same infra signal persists, post a PR comment marking the failure as `infra-incident`; do NOT keep doubling forever.

---

## §7. --stop / --resume flags

- `--stop --pr N` — releases scheduler handle (`CronDelete` on CC, `pi schedule remove` on Pi, no-op elsewhere), deletes state file, emits `ci-watch.stopped` event with `reason:"user-stop"`. Idempotent — calling `--stop` on a PR with no active watch is fine.
- `--resume --pr N` — only valid when state has `paused: true` (set by `reviewer-pause`). Clears the flag, returns to normal tick. Error envelope if no paused state for the PR.

When the classifier returns `reviewer-pause`, the state's `paused: true` is set by the cmd, but the scheduler is NOT removed — the cron payload keeps firing, and each tick the cmd checks `paused` and exits early with action `reviewer-pause`. Cheaper than re-registering on `--resume`.

---

## §8. Single-execution-mode contract

Reiterating because this is the most easily-broken invariant:

- One tick = one invocation. **Do not** wrap the §3 sequence in a `while`/`until` loop.
- **Do not** sleep + re-invoke yourself inside a single session.
- The recurring beat is delivered by host cron OR by the user re-invoking the skill manually. The watcher's value comes from the host scheduler — it does not impersonate one.

If you find yourself wanting to add "and then wait 5 minutes and run again," STOP. The skill is finished after one tick. Exit.

---

## Notes for the orchestrator

- All gh CLI calls run with the host's existing auth. The watcher introduces no new secret material (per task `dimensions_covered.token-encryption.crossed: false`).
- `auto_merge_on_green` is snapshotted from `.aperant/config.json` at `start` time (state file's `auto_merge_on_green` field). Changing the config mid-watch does NOT retroactively affect armed watches — re-run `start` to pick up the new value.
- The fix-CLI shell-out is the cleanest abstraction we have for portability. It's NOT a sub-agent. On Claude Code that means another `claude` invocation; on Codex/Gemini/OpenCode/Pi, the equivalent binary. Detection lives in `apt-tools host-detect`'s `cli` field.
