Claude Skill

pr-babysitter

Monitors or repairs an open GitHub PR: CI failures, conflicts, review threads, and merge readiness, reporting state changes. Use when asked to "watch this PR", "fix CI", "resolve conflicts", or "address review comments". For PR metadata use pr-creator; for npm release PRs use aut

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download mblode-agent-skills-skills_pr-babysitter-24f4fd8.zip · 44 KB
Part of mblode/agent-skills — 22 skills

Install

skills CLI npx skills add https://github.com/mblode/agent-skills/tree/main/skills/pr-babysitter
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install mblode-agent-skills@llmmart
Git git clone https://github.com/mblode/agent-skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole mblode/agent-skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

PR Babysitter

  • IS: keeping one open PR moving: conflicts, CI across GitHub Actions/Buildkite/Vercel/Fly.io, inbound review comments, and merge readiness, as a background monitor or as one-shot fixes.
  • IS NOT: opening or editing the PR (pr-creator), reviewing the diff for bugs (pr-reviewer), applying a local pr-reviewer report (tidy), or npm release PRs (autoship watches its own release CI; never babysit a release or Version Packages PR it drives).

Mode Selection

Invocation Mode
"babysit", "watch this PR", "monitor", "keep it green" Monitor: Phase 1 once, then phases 2-5 on every event or tick
"fix CI", "why is CI red", "loop on CI" One-shot Phase 3 loop
"resolve conflicts", "rebase onto main", "update the branch" One-shot Phase 2
"address the comments", "reply to the reviewers", "triage review comments" One-shot Comment Triage Workflow
"is it ready", "what is blocking the merge" One-shot Phase 5 report

Standing rules, every mode:

  • Monitoring or fixing code does not by itself authorize posting replies. Post, resolve threads, or request reviews only when the user authorized that communication; otherwise prepare replies and report them.

  • Resolve scripts/fetch-comments.sh relative to this installed SKILL.md. ${CLAUDE_SKILL_DIR} below is a Claude Code adapter, not a portable environment variable.

  • No setup questions. Auto-detect the PR, the CI platforms, and the defaults (poll every 2 minutes, auto-resolve noise, no auto-merge), then start. Overrides arrive inline: "poll every 5 minutes", "enable auto-merge".

  • Skip closed or merged PRs. Skip drafts (isDraft) unless asked.

  • Comment triage runs autonomously; the plan file is an audit trail, not an approval gate.

  • Speak only on transitions. A quiet poll says nothing.

Reference Files

File Read when
references/monitoring-setup.md Phase 1: watch ladder detail, Monitor watch script, cron fallback, state file format, defaults
references/merge-conflicts.md Phase 2: mergeStateStatus table, rebase workflow, lockfile and generated-file resolution, abort criteria
references/ci-platforms.md Phase 3: gh pr checks fields and exit codes, per-platform log and retry commands, Buildkite auth chain, failure classification
scripts/fetch-comments.sh Comment triage: run ${CLAUDE_SKILL_DIR}/scripts/fetch-comments.sh {N} first. One JSON document of every review, thread, and issue comment; --help prints the output shape
references/github-api.md Comment triage: script output contract, manual GraphQL/REST fallback, thread accounting, anchor ladder, awaiting-reply rule, reply and resolve
references/bot-patterns.md Comment triage: reviewer detection, severity mapping, merge gates, noise markers, dedup, false positives
references/fix-plan-template.md Comment triage: plan file format and the legal ignore reasons
references/verification-gate.md Before any commit: lint, type-check, test, knip, stray-artifact sweep
references/git-resilience.md A git command hangs or fails transiently (fsmonitor wedge, stale index.lock, network blip)
evals/evals.json Only when changing this skill; never during a PR task

Monitor Loop

Phase 1 runs once in the foreground and starts the watch. Every event or tick then runs phases 2-5, diffs against the state file, and speaks only when something changed.

Copy this checklist to track progress:

PR babysit progress:
- [ ] Phase 1: Initialize (detect PR, pick watch mechanism, snapshot state)
- [ ] Phase 2: Conflict check
- [ ] Phase 3: CI check (diagnose, fix, gate, push)
- [ ] Phase 4: Comment check (triage new comments)
- [ ] Phase 5: Readiness check (report transitions, write state file)

Phase 1: Initialize

  1. gh pr view [N] --json number,url,title,state,isDraft,headRefName,baseRefName,headRefOid,mergeable,mergeStateStatus,reviewDecision. No PR for the branch: say so and stop.
  2. gh repo view --json owner,name for the calls that need owner/repo.
  3. Detect CI platforms from gh pr checks --json name,link (dispatch table in Phase 3).
  4. Pick the watch mechanism: first rung that applies.
Rung Available when Behaviour
Harness PR subscription A PR-subscription tool is exposed (cloud sessions: Claude_Code_Remote:subscribe_pr_activity; GitHub MCP server: github:subscribe_pr_activity), or the web session's Auto-fix toggle is on GitHub pushes review comments, CI failures, and check-suite success into the session. GitHub sends nothing when the base branch advances, so pair it with a slow Monitor poll (10 minutes) on mergeStateStatus. If the tool reports a PR Steward already watching, this session gets no events: say the PR is already covered and offer the one-shot modes instead of double-fixing
Monitor tool Monitor is in the tool list Start the watch script from the monitoring reference with persistent: true. Quiet polls never wake the agent; each emitted line runs phases 2-5
Cron CronCreate is in the tool list */2 * * * * running phases 2-5; every tick wakes the agent. Recurring tasks expire after 7 days
None Neither Do not claim monitor mode. Run the matching one-shot mode, or say this runtime cannot keep polling
  1. Snapshot state to .claude/pr-babysitter/babysit-pr-{N}.md: mechanism and ID, head SHA, mergeability, check states, open and awaiting-reply thread counts, review decision. This folder is never staged.
  2. Confirm in five lines: PR, watch mechanism and ID, detected CI, current state, defaults in effect.

Phase 2: Conflict Check

gh pr view --json mergeable,mergeStateStatus. DIRTY resolves; BEHIND updates; UNKNOWN means GitHub is still computing, recheck next tick; anything else moves on.

git fetch origin {base} && git rebase origin/{base}
git push --force-with-lease --force-if-includes
  • Clean rebase: push, notify.
  • Conflicts only in lockfiles, generated files, or changelogs: regenerate per the reference, continue the rebase, push.
  • Conflicts in source logic, migrations, or API contracts: git rebase --abort, then notify with the files and what each side changed. Human intent decides those.

Bare --force is never used. A refused lease means someone else pushed: abort and notify rather than overwrite their commits. More than one author on the branch means a rebase rewrites their commits: merge origin/{base} instead.

Phase 3: CI Check

  1. gh pr checks --json name,state,bucket,link,workflow. bucket is pass, fail, pending, skipping, or cancel; link is the details URL.
  2. Anything pending: wait. Diagnosing a half-finished run fixes the wrong thing.
  3. Every fail: fetch logs by platform.
Check name or link Platform Logs
buildkite/ prefix Buildkite bk CLI when bk auth status passes, else REST with BUILDKITE_API_TOKEN, else hand over the link
vercel in name or vercel.com in link Vercel vercel inspect --logs {deployment_url} (build logs; vercel logs is runtime)
fly- prefix or fly.io in link Fly.io flyctl logs --app {app} --no-tail
Anything else GitHub Actions gh run view {run_id} --log-failed
  1. Classify per the reference: flaky (re-run once), stale dependency (reinstall and rebuild before touching source), code error (fix), knip (delete dead code or configure), infrastructure (notify; not fixable from code).
  2. Fix, run the verification gate, commit, push. Flag regressions against the previous state (was passing, now failing).

One-shot loop ("fix CI"): after each push, gh pr checks --watch --fail-fast (exit 0 green, 1 a check failed, 8 still pending). Stop and summarize when checks are green, the failure is infrastructure, or the same check fails twice with the same error after a fix. Two identical failures is the signal to stop pushing, not to try a third variant.

Phase 4: Comment Check

  1. Count open threads and threads awaiting my reply (newest comment not mine, in any resolution state, minus a reviewer who resolved their own last comment).
  2. Compare both counts and the newest updated_at across review and issue comments against the state file. An edited-in-place bot comment and a reply on a resolved thread both have to register.
  3. Any increase: notify "N new review comments on PR #" and run the Comment Triage Workflow.

Phase 5: Readiness Check

  1. Ready means all of: mergeable == MERGEABLE, every required check pass, reviewDecision == APPROVED from a review whose commit_id is the head SHA, zero open blocking threads, zero threads awaiting my reply, every merge gate satisfied.
  2. A merge-gate comment reading "Human review required" is a blocker to report with the criteria that forced it, not a finding to fix.
  3. Ready: notify "PR # is ready to merge." Merge is a one-way door: gh pr merge --auto with the repo's merge method, and only when the user opted in.
  4. Not ready: name the blockers ("Waiting on: 2 checks pending", "Awaiting your answer: 2 questions from @reviewer", "Approval is stale: reviewed abc1234, head def5678").
  5. Write the state file for the next tick.

Comment Triage Workflow

Inline from Phase 4 or one-shot. Autonomous: no approval gate, the plan file is the audit trail.

Fetch

Run ${CLAUDE_SKILL_DIR}/scripts/fetch-comments.sh {N}. It pages every thread and every thread's comments, recovers anchors, buckets threads, and computes owedReply against your own login. A non-zero exit prints one sentence on stderr saying why; fall back to the manual queries in the API reference only when gh or jq cannot be installed.

Check reviewers[] before classifying: every login that spoke must appear in the output with findings, a verdict, or an explicit "no content". A reviewer with reviews but zero comments is a fetch that lost something. anchor.source == "needs-translation" means finish the anchor ladder against the working tree before judging that finding.

Early exit only when open threads, awaiting-reply threads, actionable reviews, and actionable issue comments are all zero.

Classify

  • Every inline comment from every author is read. An author absent from the bot table is unknown, not noise; noise needs a positive marker match.
  • Classify per comment, not per thread: a human reply inside a bot's thread carries full human weight.
  • Author type from content first, then login. github-actions[bot] is shared by reviewers and noise alike.
  • Severity from the source's own markers; unknown sources default to Major. Severity orders the queue; it never decides whether a comment is read.
  • Human intent: fix request, question, nitpick, or acknowledgement. A question gets an answer, not a code change. Human comments are never auto-ignored: fix unless the reviewer marked it optional.
  • Merge-gate verdicts are Phase 5 inputs: record, never fix, never reply, never resolve.
  • Deduplicate bots only (same path within 3 lines, keep the highest severity). A multi-location finding is one item.
  • Every ignore carries one of the legal reasons from the plan template. "Author unrecognized" and "thread already resolved" are not among them.

Fix

  1. Write the plan (references/fix-plan-template.md) to .claude/pr-babysitter/pr-{N}-review-plan.md, print the counts, proceed.
  2. Ignored threads: one-line reply, then resolve.
  3. Questions: post the answer, leave the thread open. The reviewer resolves it.
  4. Resolved threads with an unanswered human reply: reply in place, do not unresolve, note it in the report.
  5. Fixes, one commit per logical group. Run the verification gate before each commit and stage only that group's files.
  6. Reply, then resolve, each fixed thread.
  7. Re-run the script and report: open threads, threads still awaiting my reply, questions answered but not yet acknowledged, and current CI status. The re-run is the evidence; "addressed everything" is not.

Stopping

  • "Stop babysitting" / "cancel the monitor": read the mechanism and ID from the state file. Monitor watch: TaskStop. Cron: CronDelete. Harness subscription: the matching unsubscribe tool.
  • PR merged or closed: the watch script emits TERMINAL and exits; cron detects it next tick and self-cancels.
  • Session exit: watches and cron jobs are session-scoped and clean themselves up. Background tasks are not restored on --resume; re-run the skill.

On stop, report: polls or events handled, fixes applied, conflicts resolved, comments triaged, current state.

Gotchas

  • gh pr checks --json name,state,conclusion,detailsUrl errors: conclusion and detailsUrl belong to gh pr view --json statusCheckRollup. The check fields are bucket and link.
  • Filtering threads on isResolved == false: GitHub collapses resolved threads, so a human reply posted after the resolve is the comment most likely to go unread.
  • comments(first: 20): thread comments arrive oldest first, so a truncated page hides the newest comment, the one that decides whether you owe a reply. The script pages every thread to the end.
  • viewerDidAuthor returned false on the viewer's own comments. Compare author.login to gh api user --jq .login.
  • A null line means outdated or multi-line, not PR-level. Only a null path is PR-level. Recover the anchor before deciding anything.
  • Triaging a bot's review body: the body is a count. Codex, Devin, Copilot, and Bugbot all put findings inline. Four empty-body human reviews are one review pass with its content in threads, not four reviewers with nothing to say.
  • Bots that edit one comment in place (auto-approval assessments, DangerJS) keep the same id, so a state diff on ids sees nothing. Compare updated_at.
  • Counting a review whose commit_id is not the head SHA as an approval: branch protection with "dismiss stale approvals" drops it on the next push, and the PR reads ready until then.
  • vercel logs {url} streams runtime logs. A failed build lives in vercel inspect --logs {url}.
  • bk without bk auth status first: keychain tokens expire and a dead token stalls the cycle. Fall through to REST, then the check link.
  • A monorepo type-check failure pointing into a sibling package's dist is usually stale build output. Reinstall and rebuild before editing source.
  • git add -A after a fix commits hook artifacts (a root schema.gql) into the PR. Sweep git status --porcelain and stage paths.
  • Resolving a thread without replying first: the reviewer sees a silent resolve and unresolves it.
  • A subscription-only watch never sees conflicts: GitHub emits no webhook when the base branch advances into one.
  • Cron when Monitor is available wakes the agent on every quiet tick and burns tokens for no signal. Polling under 2 minutes does the same to the GitHub rate limit.

Related Skills

  • pr-creator: opens or edits the PR; babysitting starts after it exists
  • planning: writes plans a fresh session executes. The fix plan this skill writes is an audit trail for one PR, not a planning deliverable
  • pr-reviewer: local diff review for bugs; run it on monitor-authored fixes beyond a trivial patch
  • tidy: applies a pr-reviewer report to the working tree; this skill applies GitHub review comments
  • autoship: npm release pipelines; it watches its own release CI, so never babysit a release PR it drives
Files (agent-skills)
  • evals
    • evals.json 2.4 KB
      {
        "skill_name": "pr-babysitter",
        "evals": [
          {
            "id": 1,
            "prompt": "CI is red on my branch again, can you figure out why and fix it?",
            "expected_output": "One-shot Phase 3 loop: gh pr checks with the bucket/link fields, failed-step logs fetched for each failing check, the failure classified before any edit, the verification gate run before the push, and gh pr checks --watch --fail-fast after it.",
            "assertions": [
              "Runs gh pr checks with --json name,state,bucket,link (not conclusion or detailsUrl)",
              "Fetches logs with gh run view --log-failed or the platform equivalent before editing source",
              "Does not push a second fix for the same check failing with the same error",
              "Does not start a monitor watch or cron job"
            ],
            "files": []
          },
          {
            "id": 2,
            "prompt": "Address the review comments on PR 418 and reply to each reviewer.",
            "expected_output": "Comment Triage Workflow: fetch-comments.sh run first, every reviewer in reviewers[] accounted for, questions answered without code changes and left unresolved, fixes grouped into commits behind the verification gate, reply before resolve, and a re-fetch as the closing report.",
            "assertions": [
              "Runs scripts/fetch-comments.sh 418 before classifying anything",
              "Every login in reviewers[] appears in the plan with findings, a verdict, or an explicit no-content note",
              "A reviewer question receives a reply and its thread is not resolved by the agent",
              "No thread is resolved without a reply posted first",
              "The final report comes from a second run of the script, not from memory"
            ],
            "files": []
          },
          {
            "id": 3,
            "prompt": "Babysit this PR until it is ready to merge.",
            "expected_output": "Monitor mode: PR auto-detected, the watch ladder walked from harness subscription to Monitor to cron, state file written under .claude/pr-babysitter/, and output only on transitions; ready is reported, never merged, without an explicit opt-in.",
            "assertions": [
              "Detects the PR with gh pr view and asks no setup questions",
              "Checks for a PR-subscription tool before starting a Monitor watch, and for Monitor before CronCreate",
              "Writes .claude/pr-babysitter/babysit-pr-{N}.md and never stages it",
              "Reports 'ready to merge' without calling gh pr merge"
            ],
            "files": []
          }
        ]
      }
      
  • references
    • bot-patterns.md 19.7 KB
      # Bot Patterns
      
      Detection, severity, deduplication, and false-positive rules for PR review bots and human reviewers.
      
      ## Contents
      
      - [Triage default: unlisted reviewers are findings](#triage-default-unlisted-reviewers-are-findings)
      - [Bot classification](#bot-classification)
      - [Shared identity: github-actions bot](#shared-identity-github-actions-bot)
      - [Active review bots](#active-review-bots)
      - [Merge-gate bots](#merge-gate-bots)
      - [Noise bots](#noise-bots)
      - [Check-only bots](#check-only-bots)
      - [Human review patterns](#human-review-patterns)
      - [Severity normalization](#severity-normalization)
      - [Deduplication](#deduplication)
      - [False positive detection](#false-positive-detection)
      
      ## Triage default: unlisted reviewers are findings
      
      The tables below are a shortcut for stripping boilerplate and mapping severity. They are not an allowlist of who counts. **Any author not listed, bot or human, is triaged as an active reviewer, and every one of its inline comments is read in full.**
      
      For an unrecognized author:
      
      1. Treat each inline comment as a real finding at Major unless its own text says otherwise.
      2. Take severity from whichever of these the body carries: a bold `**<Word> Severity**` line, a `P1`/`P2`/`P3` or `high`/`medium`/`low` token (including inside a badge image URL), a coloured circle emoji, or the words critical, blocker, major, minor, nit.
      3. Strip generically: HTML comments, `<details>` blocks, `<sup>` and `<sub>` footers, lines that are only a link image ("Fix in ...", "Open in ..."), `Reviewed by ... for commit <sha>` footers, "Comment `@x review` to trigger another review" invitations, "Was this helpful?" prompts. What survives is the finding.
      4. Label it in the report as `unlisted reviewer: <login>` so the user can have it added here.
      
      **Noise requires a positive match on a detection marker below.** Never classify an author as noise because it is missing from a table. A bot listed here as Noise or Check-only that posts an inline review comment is a stale row: triage the comment on its content and flag the drift in the report.
      
      ## Bot classification
      
      Classify by **content first**, then username. `github-actions[bot]` is a shared identity: some workflows are active reviewers, others pure noise.
      
      | Tier | Behavior | Action |
      |------|----------|--------|
      | Active reviewer | Posts findings with severity or actionable suggestions | Parse and triage |
      | Merge gate | States a verdict on whether the PR may merge | Record for the readiness check, never fix |
      | Noise | Linkbacks, deployment status, CI notifications, rate limits | Ignore, on a positive marker match only |
      | Check-only | Appears in PR checks but has left no comments | Skip while that holds |
      | Unlisted / unknown | Not in any table below | Triage as an active reviewer, per the section above |
      
      Identity hints, never verdicts: a `[bot]` login suffix and GraphQL `author { __typename } == "Bot"` both suggest automation, but machine users such as `developer-platform-actions` are plain `User` logins, and `github-actions[bot]` is shared across unrelated workflows. Content decides.
      
      ## Shared identity: github-actions bot
      
      Match comment **content** to determine which workflow posted.
      
      ### DangerJS: active reviewer
      
      Detection:
      - `DangerID: danger-id-` in an HTML comment at the top of the body
      - HTML table with `data-danger-table="true"`
      - footer `Generated by :no_entry_sign: dangerJS`
      
      Severity: parse from the HTML comment metadata:
      ```
      <!--
        0 failure:
        1 warning:  Multiple MFEs are...
        DangerID: danger-id-Danger;
      -->
      ```
      - `failure` count > 0 → major
      - `warning` count > 0 → minor
      - table cells use `:warning:` emoji for warnings
      
      Body is an HTML table of actionable warnings; treat as real findings. Updates in place: the same comment ID gets new content on each push, so compare `updated_at`, not just the ID.
      
      ### Schema compatibility checker: active reviewer
      
      Detection:
      - heading with a schema filename in `<code>` tags (e.g. `## <code>profileShareIntended_2-0-2.schema.json</code>`)
      - `### ⚠️ Warnings` or `### 🔴 Errors` section headers
      - table columns: Field, Type, Status, Details
      
      Severity markers in the Status column:
      - `🟡 Warning` → minor
      - `🔴 Error` → major (breaking changes)
      - `> 💡 **Tip**:` blockquotes are informational, not findings
      
      ### Event-lib RC trigger: noise
      
      Detection: single-line comment starting with "Event-lib RC version is triggered", then a Buildkite URL.
      
      ### Changeset releases: noise
      
      Detection: comment body starts with "This PR was opened by the [Changesets release]" or contains a `# Releases` heading with version changelogs.
      
      ### Empty-body approvals: noise (any automation identity)
      
      Detection: a review with state `APPROVED`, an empty body, and no inline comments of its own, from any automation identity. Keyed on shape, not login: `github-actions[bot]`, `developer-platform-actions`, and any other machine account qualify. Skip it as a finding, but record that it fired: it is often the evidence that a merge gate cleared.
      
      An empty-body `APPROVED` that **does** have inline comments is not this pattern. Triage the inline comments.
      
      ## Active review bots
      
      For every bot here, findings live in the **inline review comments**, not in the review body. The body is a count or a summary. Never triage a bot on its review body alone, and never conclude a bot found nothing because its body reads like a header.
      
      ### Codex (`chatgpt-codex-connector[bot]`)
      
      Detection:
      - review body contains `### 💡 Codex Review` and `**Reviewed commit:** `<sha>``
      - inline comments open with a shields.io priority badge: `**<sub><sub>![P2 Badge](https://img.shields.io/badge/P2-yellow?style=flat)</sub></sub>  <Title>**`
      
      Severity from the badge token in the image URL:
      
      | Badge | Severity |
      |-------|----------|
      | `P1` | Critical |
      | `P2` | Major |
      | `P3` | Minor |
      | No badge | Major (default) |
      
      Structure: badge, then the bold title, then the finding in the following paragraph.
      
      Strip: the badge markup and `<sub>` wrappers, "Here are some automated review suggestions for this pull request.", the `<details> <summary>ℹ️ About Codex in GitHub</summary>` block.
      
      Rate limit, noise but **not silence**: a body that is only "You have reached your Codex usage limits", with no `### 💡 Codex Review` heading and no inline comments. Skip it as a finding, and report once that `Codex did not review this PR (usage limit)`, so the user knows a reviewer was silent rather than satisfied.
      
      ### Devin (`devin-ai-integration[bot]`)
      
      Posts a PR review (state: `COMMENTED`); may inject a badge into the PR body. Findings arrive as **inline comments on specific file lines**, not the review-level body.
      
      **No findings (noise):**
      - review body starts with `## ✅ Devin Review: No Issues Found` **and** no inline threads
      - "View in Devin Review to see N additional findings" (paywalled teaser) with no inline detail → noise
      
      **Findings (active reviewer):**
      - review body reads `**Devin Review** found N potential issue(s).` → expect N inline comments. Fewer threads than N means the remainder is paywalled: report the gap rather than assuming N was covered
      - each inline comment opens with metadata, then a `🔍 **<Title>**` heading:
      
      ```
      <!-- devin-review-comment {"id": "BUG_pr-review-job-<hash>_0001", "file_path": "...", "start_line": 78, "end_line": 78, "side": "RIGHT"} -->
      🔍 **<Title>**
      ```
      
      - that JSON is the **authoritative anchor**: `file_path`, `start_line`, `end_line`, and `side` hold even when the thread's `line` is null
      - the `id` prefix classifies the **kind**: `BUG_` → a defect, default Major; `ANALYSIS_` → an observation, default Minor unless the text names a defect
      - the emoji on the title line carries the **severity**, and takes precedence over the kind's default: 🔴 Critical, 🟠 Major, 🟡 Minor. `🔍` is a marker, not a severity
      - kind and severity are independent, so both tokens can appear together and neither is redundant. A `BUG_` titled 🟡 is a real defect at Minor
      - downgrade to Minor when the language is advisory ("worth a follow-up", "pre-existing", "outside the PR diff")
      
      Strip: the HTML comment, the `🔍` title prefix, trailing "Was this helpful?" reaction prompts.
      
      **Badge injection (always ignore):** `<!-- devin-review-badge-begin -->` / `<!-- devin-review-badge-end -->` in the PR body, with `<picture>` elements linking to `app.devin.ai`. This is in the PR description, not a comment.
      
      **CI check:** `check-devin-approval` gates merging on Devin's review. Not a comment; skip.
      
      ### CodeRabbit (`coderabbitai[bot]`)
      
      If present, parse inline review comments. Severity markers (emoji in body):
      
      | Pattern | Severity |
      |---------|----------|
      | `🔴` or `_🔴 Critical_` | Critical |
      | `🟠` or `_🟠 Major_` | Major |
      | `🟡` or `_🟡 Minor_` | Minor |
      | `_⚠️ Potential issue_` (alone) | Major (default) |
      
      Comment structure:
      - multi-section with collapsible `<details>` blocks
      - "Analysis chain": skip (internal reasoning)
      - bold imperative summary: the finding
      - "Proposed fix": diff block (` ```diff `)
      - "Prompt for AI Agents": extract for fix guidance
      
      PR-level summary comments (walkthrough narratives, base64 metadata in HTML comments): skip, not findings.
      
      ### Gemini Code Assist (`gemini-code-assist[bot]`)
      
      If present, parse inline review comments. Severity markers (SVG image refs):
      
      | Image URL pattern | Severity |
      |-------------------|----------|
      | `high-priority.svg` | Critical |
      | `medium-priority.svg` | Major |
      | `low-priority.svg` | Minor |
      
      Scan for `gstatic.com/codereviewagent/` in image URLs.
      
      Uses native ` ```suggestion ` blocks with the proposed fix. Summary comment starts with "Hello @author, I'm Gemini Code Assist!"; skip it.
      
      ### Cursor Bugbot (`cursor[bot]`)
      
      Inline comments only, no summary body and no review verdict, so a PR with zero Bugbot comments means Bugbot found nothing (or has not run: the `Cursor Bugbot` check shows which). Severity is a bold line in the body:
      
      | Marker | Severity |
      |--------|----------|
      | `**High Severity**` | Critical |
      | `**Medium Severity**` | Major |
      | `**Low Severity**` | Minor |
      
      Strip: the `<sub>` wrappers around the severity line, the "Fix in Cursor" / "Fix in Web" link line (`cursor.com/agents`), and the `Bugbot Autofix is OFF` / `ON` footer. A comment posted by the Autofix agent reporting a pushed fix is status, not a finding. Re-request a review by commenting `bugbot run`.
      
      ### GitHub Copilot (`copilot-pull-request-reviewer[bot]`)
      
      Always a `COMMENTED` review, never `APPROVED` or `CHANGES_REQUESTED`, so its verdict is never a blocker. The review body is a summary of the PR; findings are inline, usually with a native ` ```suggestion ` block. Copilot marks no severity: default Major, downgrade to Minor when the text is advisory ("Consider...", "Optionally..."). Re-request with `gh pr edit --add-reviewer @copilot`.
      
      ## Merge-gate bots
      
      A comment that states a verdict about whether the PR **may merge** (auto-approval, merge freeze, release window, CODEOWNERS coverage) is a readiness-check input, not a finding. Record the verdict, never open a fix item from it, never reply to it, never resolve it.
      
      The entries below are one organization's gates, kept as worked examples of the shape. A repo with its own gate bot gets a new entry here with the same fields: detection marker, verdict table, and what would flip the verdict.
      
      ### Auto-approval assessment (`linktree-stamp[bot]`)
      
      Detection: an **issue-level comment**, not a review, containing `<!-- stamp-assessment -->`. Heading is `## ✅ Auto-Approval Assessment ([`<sha>`](...))` or `## 👀 Auto-Approval Assessment (...)`.
      
      **Edited in place on each commit:** the comment ID never changes and only the body does, so a state comparison keyed on comment IDs sees nothing new. Compare `updated_at`, and compare the commit SHA in the heading against the head SHA to spot a stale assessment.
      
      | `**Verdict:**` | Meaning | Handling |
      |----------------|---------|----------|
      | `Auto-approved (LLM assessment)` | the gate will approve | Satisfied gate, record it |
      | `Human review required` | no auto-approval, a human must review | **Blocking merge gate.** Name the `**Path criteria:**` value that forced it |
      
      Carry these fields verbatim into the report when present: `**Custom criteria:**`, `**Path criteria:**`, `**Change type:**`, `**Blast radius:**`, `**Sensitive domains:**`.
      
      `### Reasoning` bullets are context, not findings. Do not open fix items from them.
      
      `### <emoji> Path-specific review: <criteria>` sections: 🟢 means that path review found nothing. **Any other colour, or an explicit issue list, means treat the section body as findings and triage at Major.**
      
      Strip the `> If you're unsure ... post in #ask-developer-platform` footer.
      
      The approving identity is separate: `developer-platform-actions` posts the empty-body `APPROVED` review once the gate clears. That review is noise as a finding and evidence as a gate.
      
      ### mergefreeze
      
      Merge gate, status only ("Ok to merge"). A freeze verdict blocks merging and is not fixable from code.
      
      ## Noise bots
      
      Always ignore, on a positive marker match only; no actionable findings:
      
      | Bot | Detection | What it posts |
      |-----|-----------|---------------|
      | `linear-code[bot]` | `<!-- linear-linkback -->` in body | Linkback to Linear ticket |
      | `vercel[bot]` | Body starts with `[vc]: #` | Deployment status tables with base64 metadata |
      | `renovate[bot]` | Author match | Dependency update descriptions, artifact failures |
      | `dependabot[bot]` | Author match | Dependency bump descriptions, `@dependabot` commands |
      
      Match the **content marker**, not the login: the Linear bot's login has already drifted once (from `linear[bot]`) while `<!-- linear-linkback -->` survived.
      
      Removed from this table on purpose: `chatgpt-codex-connector[bot]` is an active reviewer above. Only its zero-finding and rate-limit bodies are noise.
      
      ## Check-only bots
      
      Appear in `gh pr checks` but have left no comments. Nothing to triage:
      
      - **Buildkite**: CI pipeline checks (`buildkite/{project}/{step}`)
      - **Telemetry service attributes**: service metadata validation from `blstrco/telemetry`
      
      Check-only means "has posted no comments on this PR", which is an observation about today, not a property of the bot. **A review or an inline comment from any of these promotes it to active reviewer**, triaged on content under the unlisted-reviewer procedure.
      
      ## Human review patterns
      
      **An empty review body is the normal case, not an absence of content.** A human `COMMENTED` review with an empty body means all of the content is in inline thread comments. Several empty-body reviews from the same person in a row are **one review pass**, not several: group them by author. Never report that a reviewer left no comments on the strength of an empty body.
      
      **Replies nested in bot threads are human comments.** A thread's author is its first comment's author, so a human reply inside a `devin-ai-integration[bot]` thread sits under a bot's name. It carries full human weight: never deduplicated, never auto-resolved, never skipped because the thread's author is a bot. Classify per comment, not per thread.
      
      **Intent decides what handling means.** Severity says how urgent; intent says what the reviewer wants back.
      
      | Text signal | Intent | Handling |
      |-------------|--------|----------|
      | `Nit:`, "nitpick", "up to you", "non-blocking" | Nitpick | Fix if cheap, else reply with why not |
      | Imperative ("use X", "please rename", "this should") | Fix request | Fix, reply, resolve |
      | Interrogative, ends in `?` ("Are these properties tethered?", "is this a restriction we're comfortable with longer term?") | Question | Answer in a reply. Invent no code change. Do not resolve; the reviewer resolves after reading the answer. Surface it under Questions in the report |
      | "Are you sure", "did you consider", "what happens if" | Latent-defect probe | Check the code, then either fix and reply with the finding, or reply explaining why it holds |
      | Praise ("nice", "LGTM", "👍") | Acknowledgement | No action, no reply |
      
      A question with no code change is **not** an ignored item. It goes in the plan's Questions section and gets a reply. Reporting "0 items to fix" while a reviewer is waiting on an answer is a triage failure, not a clean run.
      
      Classify by review state and context for blocking status:
      
      | Pattern | Severity | Blocking? |
      |---------|----------|-----------|
      | `CHANGES_REQUESTED` review with body text | Major | Yes (merge blocked) |
      | `CHANGES_REQUESTED` review, empty body + inline comments | Major | Yes |
      | `COMMENTED` review + inline question → then `APPROVED` seconds later | Minor | No (conversational) |
      | `APPROVED` review, empty body | n/a | No (skip only when it has no inline comments) |
      | Issue-level comment with soft language ("I'd also...", "but up to you") | Minor | No |
      | Issue-level comment with directive language ("please use...", "must...") | Major | Depends on review state |
      
      Human comments default to Major unless language or context signals otherwise.
      
      ## Severity normalization
      
      Unified four-level scale:
      
      | Source | Critical | Major | Minor | Nitpick |
      |--------|----------|-------|-------|---------|
      | Codex | `P1` badge | `P2` badge | `P3` badge | n/a |
      | CodeRabbit | 🔴 | 🟠 | 🟡 | n/a |
      | Gemini | high-priority | medium-priority | low-priority | n/a |
      | Bugbot | High Severity | Medium Severity | Low Severity | n/a |
      | Copilot | n/a | Default | advisory language | n/a |
      | DangerJS | n/a | failure count > 0 | warning count > 0 | n/a |
      | Schema checker | n/a | 🔴 Error | 🟡 Warning | n/a |
      | Devin | 🔴 | 🟠, or `BUG_` with no emoji | 🟡, or `ANALYSIS_` prefix | n/a |
      | Unlisted reviewer | n/a | Default | advisory language | "nit" |
      | Human (CHANGES_REQUESTED) | "critical"/"blocker" | Default | "nit"/"minor" | "nit"/"nitpick" |
      | Human (APPROVED + question) | n/a | n/a | Minor (default) | n/a |
      | Human question | n/a | n/a | n/a | No severity; tracked as a question |
      
      When multiple sources flag the same issue, use the highest severity.
      
      **Severity orders the fix queue. It never decides whether a comment gets read.**
      
      ## Deduplication
      
      Multiple bots may flag the same issue on overlapping lines.
      
      **Grouping rule:** comments on the same `path` within a 3-line range likely address one issue.
      
      **Within each group:**
      1. keep the highest-severity comment
      2. prefer one with a `suggestion` block or diff (most actionable)
      3. mark others `ignore-duplicate`, referencing the kept thread ID
      
      **Exceptions:**
      - never deduplicate human comments; each gets its own entry
      - human + bot on the same issue: keep both (the human confirms the bot, raising confidence)
      - a bot finding listing several `LOCATIONS` is **one item with several sites**. Do not split it, and do not treat the extra sites as duplicates
      - a bot re-reviewing a new commit opens a fresh thread on the same lines while the old one goes `isOutdated`. Keep the newest, mark the older `ignore-superseded`, and quote its thread ID
      
      ## False positive detection
      
      ### Pre-existing code
      
      The flagged line was not added or modified in this PR.
      
      ```bash
      gh api "repos/{owner}/{repo}/pulls/{pr}/files" --paginate
      ```
      
      Each file's `patch` has diff hunks. If the flagged line is not in an added (`+`) range, mark `ignore-pre-existing`.
      
      ### Irrelevant file types
      
      Bot comments on these are almost always false positives:
      - `.md`, `.json`, `.yaml`, `.yml` config files (unless security-related)
      - `.lock` files (auto-generated)
      - `.sql` migration files (auto-generated)
      - files with "auto-generated" or "DO NOT EDIT" headers
      
      ### Convention contradictions
      
      If a bot finding contradicts a rule in the project's `CLAUDE.md` or `AGENTS.md`, mark `ignore-contradicts-conventions`.
      
      ### Outdated threads
      
      `isOutdated == true` is **not** grounds to ignore anything. It means GitHub can no longer map the thread onto the current diff, which is also why `line` comes back null. Recover the anchor first via the ladder in the GitHub API reference, read the current file, and mark `ignore-outdated` only when the flagged construct is genuinely gone. An outdated thread carrying a human reply is never ignored.
      
    • ci-platforms.md 7.5 KB
      # CI/CD Platforms
      
      `gh` for GitHub (PRs, Actions, checks); platform-native CLIs for Buildkite, Vercel, Fly.io.
      
      ## Contents
      
      - [Universal status check](#universal-status-check)
      - [GitHub Actions](#github-actions)
      - [Buildkite](#buildkite)
      - [Vercel](#vercel)
      - [Fly.io](#flyio)
      - [Failure classification](#failure-classification)
      
      ## Universal status check
      
      Every platform registers as a GitHub check. Status:
      
      ```bash
      gh pr checks --json name,state,bucket,link,workflow
      ```
      
      Fields: `name`, `state` (raw GitHub status or conclusion), `bucket` (`pass`, `fail`, `pending`, `skipping`, `cancel`), `link` (details URL), `workflow`, `description`, `startedAt`, `completedAt`, `event`. There is no `conclusion` or `detailsUrl` field here; those live in `gh pr view --json statusCheckRollup`.
      
      Exit codes: 0 all passed, 1 a check failed, 8 checks still pending, 16 no checks found. The JSON prints in every case, so in a script read stdout and ignore the exit status; in the fix loop use the exit status:
      
      ```bash
      gh pr checks --watch --fail-fast              # returns on the first failure
      gh pr checks --watch --required               # required checks only
      ```
      
      `--interval` defaults to 10 seconds. Fine-grained personal access tokens cannot run this command (no `checks:read` scope); classic tokens and `gh auth login` OAuth can.
      
      Identify the platform from the check `name` or `link`:
      
      | Pattern | Platform |
      |---------|----------|
      | `buildkite/` prefix | Buildkite |
      | `vercel` in name, or `vercel.com` in link | Vercel |
      | `fly-` prefix, or `fly.io` in link | Fly.io |
      | Everything else | GitHub Actions |
      
      ## GitHub Actions
      
      Find the failing run for the branch:
      
      ```bash
      gh run list --branch {branch} --limit 5 --json databaseId,name,status,conclusion,headSha
      ```
      
      Failed-step logs only (the diagnosis command; the full `--log` is thousands of lines re-sent every turn):
      
      ```bash
      gh run view {run_id} --log-failed
      gh run view {run_id} --job {job_database_id} --log-failed   # one job
      ```
      
      `--job` takes the job's `databaseId` (from `gh run view {run_id} --json jobs`), not the number in the browser URL, which 404s.
      
      Re-run failed jobs only, once, for a flaky failure:
      
      ```bash
      gh run rerun {run_id} --failed
      ```
      
      `gh run watch {run_id} --exit-status --compact` waits on a single run; `gh pr checks --watch` waits on the PR.
      
      ## Buildkite
      
      Registers as `buildkite/{org}/{pipeline}` checks, so pass/fail always comes from `gh pr checks`. Logs and retries need Buildkite auth: try the chain in order and stop at the first that works.
      
      **1. `bk` CLI**
      
      ```bash
      bk auth status
      ```
      
      `bk` keeps its token in the OS credential store (macOS Keychain); tokens expire or get revoked, and a dead one stalls the cycle. On any error skip to option 2; do not prompt for re-auth mid-cycle. Tell the user once: "Run `bk auth login` (or `bk configure`) to restore Buildkite access; using the fallback until then." Re-test on the next cycle.
      
      When auth is valid:
      
      ```bash
      bk build view {build_number} --pipeline {pipeline} --job-states failed --output json
      bk job log {job_id} --agent            # --agent strips ANSI and trims for an LLM
      bk job retry {job_id}                  # each job id retries once; the response carries the new id
      bk build rebuild {build_number} --pipeline {pipeline}
      ```
      
      **2. REST API** (`BUILDKITE_API_TOKEN` set; scopes `read_builds`, `read_build_logs`, `write_builds`)
      
      ```bash
      BK="https://api.buildkite.com/v2/organizations/{org}/pipelines/{pipeline}"
      curl -sH "Authorization: Bearer $BUILDKITE_API_TOKEN" "$BK/builds/{build_number}"
      curl -sH "Authorization: Bearer $BUILDKITE_API_TOKEN" -H "Accept: text/plain" \
        "$BK/builds/{build_number}/jobs/{job_id}/log"
      curl -sH "Authorization: Bearer $BUILDKITE_API_TOKEN" -X PUT "$BK/builds/{build_number}/jobs/{job_id}/retry"
      curl -sH "Authorization: Bearer $BUILDKITE_API_TOKEN" -X PUT "$BK/builds/{build_number}/rebuild"
      ```
      
      `Accept: text/plain` returns the raw log; without it the log arrives as JSON with `content`, `size`, and `header_times`.
      
      **3. Status only**
      
      No auth at all: report pass/fail from `gh pr checks` and hand over the `link`: "Buildkite build failed. No Buildkite auth to fetch logs. See: {link}". Retry is impossible without auth.
      
      **Parsing the link:** `https://buildkite.com/{org}/{pipeline}/builds/{build_number}` maps to the API path `organizations/{org}/pipelines/{pipeline}/builds/{build_number}`.
      
      ## Vercel
      
      Deployment status arrives as GitHub checks and as a `vercel[bot]` comment (noise, per bot-patterns). Logs need the `vercel` CLI, and the two log commands are not interchangeable:
      
      ```bash
      vercel inspect --logs {deployment_url}     # build logs: why the deployment failed
      vercel inspect --logs --wait {deployment_url}   # still building: wait for completion
      vercel logs {deployment_url}               # runtime request logs of a live deployment
      vercel ls --limit 5                        # recent deployments
      ```
      
      Missing environment variable: `vercel env ls`, then notify; the fix is in the dashboard, not the repo. Build timeout: infrastructure, notify.
      
      ## Fly.io
      
      ```bash
      flyctl status --app {app}
      flyctl logs --app {app} --no-tail           # buffered logs, no stream
      flyctl releases --app {app}
      flyctl checks list --app {app}
      ```
      
      App name comes from `app = "..."` in `fly.toml` at the repo root; if absent, say so rather than guessing. OOM (`Out of memory`, `killed`) means raise memory in `fly.toml`: notify, it is a config decision. Health-check or migration failures need the log read and usually a human.
      
      ## Failure classification
      
      Decide in this order; the first match wins:
      
      1. **"flaky", "timeout", a known flaky pattern** → re-run once (`gh run rerun --failed`, `bk job retry`). A second identical failure is a real failure
      2. **Type error pointing into a sibling workspace package, "Cannot find module", missing generated types** → stale dependency, not a code bug. Refresh below; code error only if it persists
      3. **Compilation, type, or lint error (not stale)** → read the error, fix the file, gate, commit, push
      4. **`knip` failure (unused files, exports, dependencies)** → delete the dead code. When it is intentional (a public entry point), add it to the `knip` config `entry` or `ignore`. `knip` exits 1 on any issue; `--production` narrows to production files if the repo runs it that way
      5. **"rate limit", "quota", "service unavailable", 5xx from a registry** → infrastructure, notify
      6. **"npm ERR!", "peer dep", "resolution"** → reinstall with the repo's package manager; in a monorepo rebuild dependency packages so workspace types resolve
      7. **"OOM", "memory", "killed"** → infrastructure or runner config, notify
      8. **Test assertion failure (not flaky)** → read the failing test and the source it exercises, fix, gate, commit, push
      9. **Unknown** → fetch the full log, diagnose, notify if still unsure
      
      Wait for any re-run to finish before diagnosing again.
      
      ### Stale-dependency type-check failures
      
      In a monorepo, type-check fails when a dependency package's build output or generated types are stale, not because the changed code is wrong. Symptoms: errors pointing into `node_modules` or `dist` of a sibling package, types present in source but missing from the resolved declaration, a green editor and a red CI type-check.
      
      Refresh before editing source:
      
      ```bash
      npm ci                                  # or: yarn install --immutable / pnpm install --frozen-lockfile
      turbo run build --filter=...[origin/{base}]   # or: nx affected -t build / make build-deps
      # regenerate codegen types the repo defines (GraphQL, OpenAPI), then re-run the type-check
      ```
      
      Passes after the refresh: stale-dependency issue, no source change. Still fails: a real code error (item 3).
      
    • fix-plan-template.md 5.4 KB
      # Fix Plan Template
      
      Write the fix plan to `.claude/pr-babysitter/pr-{N}-review-plan.md` (create the folder if missing; it is never staged).
      
      The plan is an audit trail: triage proceeds without waiting for approval. If the user edits the file mid-run, re-read it before the Fix step and respect their edits.
      
      Length follows the comments, not the template: drop any section with nothing in it (a PR with no questions has no Questions section), and keep each item to the fields that carry information for that item.
      
      ## Contents
      
      - [Template](#template)
      - [Legal ignore reasons](#legal-ignore-reasons)
      - [Template notes](#template-notes)
      
      ## Template
      
      ```markdown
      # PR #{N} Review Comment Plan
      
      **PR:** {title} (#{N})
      **Branch:** {branch}
      **URL:** {pr_url}
      **Head:** {sha}
      **Threads:** {open} open, {awaiting} awaiting my reply, {resolved} resolved ({with_reply} replied after resolve), {outdated} outdated, {collapsed} collapsed
      **Reviews:** {review_count} ({changes_requested} requesting changes, {stale} stale)
      **Reviewers:** @{human} (3 findings, 1 question), @{reviewer-bot} (1 critical), unlisted: @{login}
      **Merge gates:** {gate}: {verdict}
      **Generated:** {date}
      
      ## Summary
      
      | Disposition | Critical | Major | Minor | Nitpick | Total |
      |-------------|----------|-------|-------|---------|-------|
      | Fix         |          |       |       |         |       |
      | Answer (no code change) |  |    |       |         |       |
      | Ignore      |          |       |       |         |       |
      
      ---
      
      ## Questions Awaiting an Answer
      
      Reviewer questions get a reply, not a code change. Listed first so they are not buried under bot findings.
      
      ### Q1. @{author} on `{path}:{line}`
      
      - **Thread:** {thread_node_id}
      - **Anchor:** {path}:{line} (source: {line | startLine | originalLine | diffHunk | path-only})
      - **Question:** {verbatim quote}
      - **Answer to post:** {the actual answer, not an acknowledgement}
      - **Code change needed:** {no | yes, see Fix #N}
      - **Resolve:** no. The reviewer resolves after reading the answer
      
      ### Q2. ...
      
      ---
      
      ## Merge Gates
      
      ### G1. {gate name} ({source comment id})
      
      - **Verdict:** {verbatim}
      - **Criteria that forced it:** {path criteria / change type / blast radius}
      - **Blocking:** {yes | no}
      - **What would flip it:** {a human review from ... | nothing, informational}
      - **Action:** none. Not a fix item, not replied to, not resolved
      
      ---
      
      ## Issues to Fix
      
      Severity order (critical first), grouped by file.
      
      ### 1. [{severity}] {short title}
      
      - **Thread:** {thread_node_id}
      - **Anchor:** `{path}:{line}` (source: {ladder rung})
      - **Thread state:** {open | resolved-with-unanswered-reply | outdated}
      - **Last comment:** @{author} at {time}
      - **Author:** @{author} ({human | bot_name | unlisted reviewer})
      - **Reviewed commit:** {sha} (head {sha})
      - **Category:** {bug | security | performance | style | correctness | docs | test-coverage}
      - **Finding:** {one-sentence description}
      - **Fix approach:** {concrete description of what to change}
      - **Commit group:** {group_label}
      
      > Original: {relevant excerpt from comment, boilerplate stripped}
      
      ---
      
      ### 2. ...
      
      ---
      
      ## Conversation Items (no thread, reply only)
      
      From issue-level comments or review bodies. No GraphQL resolve action; reply to acknowledge only.
      
      ### C1. [{severity}] {short title}
      
      - **Source:** {issue comment | review body (CHANGES_REQUESTED)}
      - **Comment ID:** {comment_id or review_id}
      - **Author:** @{author}
      - **Finding:** {one-sentence description}
      - **Fix approach:** {concrete description of what to change}
      - **Reply to post:** "{acknowledgment message}"
      - **Commit group:** {group_label}
      
      > Original: {relevant excerpt}
      
      ### C2. ...
      
      ---
      
      ## Ignored
      
      ### I1. [{reason}] @{author} on `{path}:{line}`
      
      - **Thread:** {thread_node_id}
      - **Reason:** {specific explanation}
      - **Reply to post:** "{brief resolution comment}"
      
      ### I2. ...
      ```
      
      ## Legal ignore reasons
      
      These are the only ones:
      
      | Reason | Means |
      |--------|-------|
      | `ignore-duplicate` | Another thread covers it; quote the kept thread ID |
      | `ignore-superseded` | A re-review opened a newer thread on the same lines |
      | `ignore-pre-existing` | The flagged line is not in a `+` hunk of this PR |
      | `ignore-outdated` | The anchor was recovered, the file was read, and the construct is genuinely gone |
      | `ignore-contradicts-conventions` | Contradicts a rule in `CLAUDE.md` or `AGENTS.md` |
      | `ignore-noise-marker` | Positive match on a documented noise marker |
      
      **Not ignore reasons:** "author unrecognized", "thread already resolved", "no line number". An unrecognized author is triaged as a reviewer, a resolved thread with an unanswered reply is triaged, and a finding without a line number is reported with `anchor: path-only`.
      
      ## Template notes
      
      - Replace all `{placeholders}` with actual values
      - Thread IDs are GraphQL node IDs (for resolve mutations in the Fix step)
      - Comment IDs are REST `id`/`databaseId` fields (for reply endpoints)
      - Commit group labels batch related fixes into one commit (e.g., "golden-events", "lint-cleanup")
      - Keep resolution reply comments to one sentence
      - The summary table gives the user a quick overview before the details
      - Every reviewer in the header appears in at least one section below; an unaccounted reviewer means the fetch lost something
      - A question is never an Ignored item
      - If the user moves items between Fix/Questions/Conversation/Ignore sections, respect their edits
      - Purely informational conversation items (soft "up to you" suggestions) may be moved to Ignored by the user
      
    • git-resilience.md 2 KB
      # Git Resilience
      
      Recover when a git command hangs or fails transiently inside the poll cycle. A hung `git` call should retry, not abort the monitor.
      
      ## Contents
      
      - [core.fsmonitor hangs](#corefsmonitor-hangs)
      - [Stale index.lock contention](#stale-indexlock-contention)
      - [Transient IPC hiccups](#transient-ipc-hiccups)
      - [Safe-retry posture](#safe-retry-posture)
      
      ## core.fsmonitor hangs
      
      **Symptom:** `git status`, `git fetch`, `git rebase`, or `git commit` stalls with no output. Common in large monorepos when the fsmonitor daemon wedges.
      
      **Diagnosis:**
      
      ```bash
      git config --get core.fsmonitor    # true / a hook path = fsmonitor is active
      ```
      
      **Recovery:** disable for the session, then retry:
      
      ```bash
      git config core.fsmonitor false
      # kill any wedged daemon, then retry
      pkill -f 'fsmonitor--daemon' 2>/dev/null || true
      ```
      
      For read-only status calls, skip lock acquisition entirely:
      
      ```bash
      GIT_OPTIONAL_LOCKS=0 git status --porcelain
      ```
      
      ## Stale index.lock contention
      
      **Symptom:** `fatal: Unable to create '.../.git/index.lock': File exists`.
      
      **Recovery:** remove the lock **only** when no git process runs; deleting it under a live process corrupts the index.
      
      ```bash
      pgrep -f '[g]it ' && echo "git running: wait, do not delete lock" || rm -f .git/index.lock
      ```
      
      Then retry.
      
      ## Transient IPC hiccups
      
      Brief, self-clearing failures (intermittent `gh`/`git` IPC error, momentary `git fetch` network blip). Retry with backoff, don't treat the first failure as terminal:
      
      ```bash
      for attempt in 1 2 3; do
        git fetch origin "$base_branch" && break
        sleep $((attempt * 2))
      done
      ```
      
      ## Safe-retry posture
      
      - Treat a hang or transient git error as retryable: apply the recovery above, then retry once or twice with backoff.
      - Abort the current phase (and notify the user) only if it still fails after recovery + retries; never let one transient git failure kill the monitor.
      - Keep recovery changes session-local (`git config core.fsmonitor false` affects local repo config only); don't push config changes as part of a PR.
      
    • github-api.md 16.6 KB
      # GitHub API Reference
      
      Fetch, reply to, and resolve PR review threads, comments, and reviews. Every command here is `gh`: `gh api` for REST, `gh api graphql` for GraphQL, `gh pr view` for PR state.
      
      ## Contents
      
      - [Script output contract](#script-output-contract)
      - [Extract owner, repo, and PR number](#extract-owner-repo-and-pr-number)
      - [Fetch review threads (GraphQL)](#fetch-review-threads-graphql)
      - [Thread accounting](#thread-accounting)
      - [Anchor recovery ladder](#anchor-recovery-ladder)
      - [Awaiting my reply](#awaiting-my-reply)
      - [Fetch PR reviews (REST)](#fetch-pr-reviews-rest)
      - [Fetch issue-level comments (REST)](#fetch-issue-level-comments-rest)
      - [Reply to a thread](#reply-to-a-thread)
      - [Reply to an issue-level comment](#reply-to-an-issue-level-comment)
      - [Resolve a thread](#resolve-a-thread)
      - [Pagination pattern](#pagination-pattern)
      
      ## Script output contract
      
      `${CLAUDE_SKILL_DIR}/scripts/fetch-comments.sh [<pr>] [--repo owner/name]` does everything in this reference up to and including the awaiting-reply computation, then prints one JSON document (`--help` prints the shape). Prefer it; the sections below are the manual fallback when `jq` or `gh` cannot be installed. A non-zero exit carries one sentence on stderr naming the cause; fix that cause (usually `gh auth login`) rather than falling back.
      
      ```
      { me, repo, pr, headRefOid, counts, reviewers, reviews[], threads[], issueComments[] }
      ```
      
      `counts`: `threads`, `open`, `resolvedWithUnansweredReply`, `resolvedQuiet`, `prLevel`, `outdated`, `collapsed`, `awaitingReply`, `anchorsNeedingWork`, `reviews`, `staleReviews`, `issueComments`.
      
      `reviewers[]`: `login` (canonicalized, `[bot]` suffix stripped), `isBot`, `isMe`, `reviews`, `inlineComments`, `issueComments`, `emptyBodyReviews`. Reconcile against this: a reviewer with reviews but no comments and no verdict means something was missed.
      
      `threads[]`: `id`, `path`, `subjectType`, `isResolved`, `isOutdated`, `isCollapsed`, `resolvedBy`, `anchor {line, startLine, endLine, side, source}`, `threadAuthor`, `lastComment {author, isMe, isBot, createdAt, url}`, `owedReply`, `bucket`, `commentCount`, `comments[]`.
      
      `comments[]`: `databaseId`, `author`, `authorTypename`, `isMe`, `isBot`, `createdAt`, `url`, `replyTo`, `commit`, `originalCommit`, `line`, `startLine`, `originalLine`, `originalStartLine`, `outdated`, `diffHunk`, `body`, `bodyStripped`, `severityHints[]`, `embeddedAnchors[]`.
      
      Two things the script deliberately does not do:
      
      - **`severityHints` is an array of raw tokens, verbatim** (`"High Severity"`, `"P2"`, `"BUG_"`, `"🟡"`), not a severity. It never picks a winner, because one comment can carry two complementary tokens. Mapping and precedence are the bot-patterns rules.
      - **`anchor.source` of `needs-translation`** means rungs 1 to 3 missed and only `originalLine`/`diffHunk` remain. Those rungs need the working tree, so finish them yourself. `path-only` means the ladder is exhausted.
      
      `bucket` and `owedReply` apply the accounting and reply rules below, including the `resolvedBy` carve-out. `bodyStripped` uses generic strippers only, so a bot-specific footer may survive; strip the rest per its bot's entry.
      
      ## Extract owner, repo, and PR number
      
      Auto-detect from the current branch:
      
      ```bash
      gh pr view --json number,url,title,headRefName,baseRefName,headRefOid
      ```
      
      Owner and repo:
      
      ```bash
      gh repo view --json owner,name --jq '"\(.owner.login)/\(.name)"'
      ```
      
      User-provided PR number: use directly. Else parse `number` from the `gh pr view` output.
      
      Keep `headRefOid`: the staleness rules below compare review and comment commits against it.
      
      ## Fetch review threads (GraphQL)
      
      Only reliable source of thread resolution status; REST does not expose `isResolved`.
      
      ```graphql
      query($owner: String!, $repo: String!, $pr: Int!, $cursor: String) {
        repository(owner: $owner, name: $repo) {
          pullRequest(number: $pr) {
            headRefOid
            reviewThreads(first: 100, after: $cursor) {
              pageInfo { hasNextPage endCursor }
              nodes {
                id
                isResolved
                isOutdated
                isCollapsed
                resolvedBy { login }
                subjectType
                path
                line
                startLine
                originalLine
                originalStartLine
                diffSide
                comments(first: 100) {
                  totalCount
                  pageInfo { hasNextPage endCursor }
                  nodes {
                    databaseId
                    author { login __typename }
                    body
                    line
                    startLine
                    originalLine
                    originalStartLine
                    diffHunk
                    outdated
                    createdAt
                    url
                    replyTo { databaseId }
                    commit { oid }
                    originalCommit { oid }
                  }
                }
              }
            }
          }
        }
      }
      ```
      
      Invoke:
      
      ```bash
      gh api graphql \
        -f query='...' \
        -f owner="$OWNER" \
        -f repo="$REPO" \
        -F pr="$PR_NUMBER"
      ```
      
      `comments(first: 20)` was a bug worth naming: a thread connection returns comments **oldest first**, so a truncated page hands you the opening comment and hides the newest one, which is exactly the comment that decides whether you owe a reply. 100 is the connection maximum.
      
      When a thread reports `comments.totalCount > 100` or `comments.pageInfo.hasNextPage`, page that thread on its own before computing anything:
      
      ```graphql
      query($threadId: ID!, $cursor: String) {
        node(id: $threadId) {
          ... on PullRequestReviewThread {
            comments(first: 100, after: $cursor) {
              pageInfo { hasNextPage endCursor }
              nodes { databaseId author { login } body createdAt url }
            }
          }
        }
      }
      ```
      
      `author { __typename }` returns `Bot` for GitHub App identities and `User` otherwise. A hint only: machine users such as `developer-platform-actions` come back as `User`.
      
      ## Thread accounting
      
      Put every thread in exactly one bucket and report all the counts. **Never drop a bucket silently.**
      
      | Bucket | Predicate | Handling |
      |--------|-----------|----------|
      | Open | `isResolved == false` | Triage |
      | Resolved with an unanswered reply | `isResolved == true`, the newest comment's author is neither you nor a bot, and `resolvedBy.login` is **not** that same author | Triage. Reply without unresolving, and say in the report that it was already resolved |
      | Resolved and quiet | `isResolved == true` otherwise | Count only |
      | PR-level | `path == null` | Not inline. Reply only, no resolve |
      
      `isResolved == true` means someone pressed a button, not that the conversation ended. GitHub collapses resolved threads out of sight, so a human reply landing after a resolve is the single comment most likely to go unread. `isCollapsed` is a display state that follows resolution and outdatedness; it is never a filter, only a number to report.
      
      The `resolvedBy` carve-out matters: a reviewer who writes the last comment **and** resolves the thread is closing the conversation themselves ("Fixed in abc1234", then resolve). Without the carve-out those threads count as awaiting your reply forever and readiness never clears. GitHub exposes no resolution timestamp, so this cannot distinguish a reply posted after a resolve; when the last comment reads like it expects an answer, treat it as awaiting regardless of who resolved it.
      
      Report line:
      
      ```
      Threads: {open} open, {resolved} resolved ({with_reply} with a reply after the resolve), {outdated} outdated, {collapsed} collapsed
      ```
      
      ## Anchor recovery ladder
      
      `line` comes back `null` for outdated and multi-line threads. Walk this ladder, stop at the first rung that hits, and record which rung produced the anchor:
      
      1. `line`, with `startLine` when the thread spans a range. Anchors on the current diff.
      2. `startLine` alone, when `line` is null but `startLine` is set.
      3. `subjectType == FILE`: there is no line to find. Anchor at the path and stop, ahead of the translation rungs below.
      4. `originalLine` / `originalStartLine` with the comment's `originalCommit.oid`: the line number as of the commit the comment was written against. Translate with `git diff <original_commit>..HEAD -- <path>`.
      5. `diffHunk`: its last line is the commented line. Grep that text in the current file to get today's line number. This is the rung that survives a rebase renumbering the whole file.
      6. Nothing left: anchor at `path`, mark the item `anchor: path-only`, and say so in the plan.
      
      **A null `line` is not a PR-level comment. Only a null `path` is.** Never drop a finding for want of a line number, and never guess one: an unanchored finding is reported with `anchor: path-only`, not ignored.
      
      Cross-check with REST when the nulls are confusing:
      
      ```bash
      gh api --paginate "repos/{owner}/{repo}/pulls/{pr}/comments?per_page=100"
      ```
      
      It returns the same anchors under `line`, `original_line`, `start_line`, `original_start_line`, `position`, `original_position`, `diff_hunk`, `side`, `in_reply_to_id`, `commit_id`, and `original_commit_id`, flat per comment and easier to `jq`.
      
      ## Awaiting my reply
      
      Identify yourself first:
      
      ```bash
      ME=$(gh api user --jq .login)
      ```
      
      Do not use `viewerDidAuthor`: it returned `false` on the viewer's own PR in testing, so it cannot identify your own comments. Compare `author.login` against `$ME`.
      
      Sort each thread's comments by `createdAt` and take the last:
      
      | Newest comment's author | Thread state | You owe |
      |-------------------------|--------------|---------|
      | Not you, human | Any resolution state, **unless** they resolved their own last comment | **A reply.** The strongest signal in the whole fetch |
      | Not you, human | Resolved, and they are also `resolvedBy` | Nothing. They closed the conversation themselves |
      | Not you, bot | Open | A fix or a reasoned dismissal, then reply and resolve |
      | Not you, bot | Resolved | Nothing. A bot is not waiting on an answer |
      | You | Open | Nothing until the reviewer answers. Do not re-reply |
      
      This is one predicate, not two. The same carve-out that keeps a self-closed thread out of the accounting buckets has to keep it out of the awaiting count, or a PR whose reviewer resolved their own threads never reaches ready.
      
      ```bash
      jq --arg me "$ME" '
        [ .[] | . as $t
          | ($t.comments.nodes | sort_by(.createdAt) | last) as $last
          | { id: $t.id, path: $t.path, resolved: $t.isResolved, outdated: $t.isOutdated,
              lastAuthor: $last.author.login, lastUrl: $last.url,
              owedReply: ($last.author.login != $me) } ]' <<<"$all_threads"
      ```
      
      Truncated comment pages invalidate this computation: the last comment you fetched is not the last comment on the thread. Page every thread with `hasNextPage` first.
      
      A thread whose newest comment is not yours is unanswered whether or not it is resolved, and whether or not it sits under a bot's finding. Count these separately and list every one. **This is the number the user means when they ask whether you read the comments.**
      
      ## Fetch PR reviews (REST)
      
      Reviews carry the overall verdict plus possibly actionable body text (especially `CHANGES_REQUESTED`).
      
      ```bash
      gh api --paginate "repos/{owner}/{repo}/pulls/{pr}/reviews?per_page=100"
      ```
      
      Each review has:
      - `state`: `APPROVED`, `CHANGES_REQUESTED`, `COMMENTED`, `DISMISSED` (`PENDING` is a draft only its author can see)
      - `body`: review-level comment (often empty, because the content went into inline comments instead)
      - `user.login`: reviewer username
      - `commit_id`: the commit the review was submitted against
      
      Triage rules:
      - `CHANGES_REQUESTED`, non-empty body → actionable, classify the body
      - `CHANGES_REQUESTED` or `COMMENTED` from a human with an **empty body** → the content is inline, not absent. Pull it with the review-comments endpoint below. Several empty-body reviews from one author are **one review pass**
      - `APPROVED`, empty body, no inline comments, from any automation identity → skip (keyed on shape, not login)
      - `COMMENTED` from a bot → the body is usually a count or summary; the findings are inline
      - `COMMENTED` from a human plus an immediate `APPROVED` → non-blocking question
      
      Inline comments from a specific review:
      
      ```bash
      gh api "repos/{owner}/{repo}/pulls/{pr}/reviews/{review_id}/comments"
      ```
      
      **Staleness.** Compare each review's `commit_id` with `headRefOid`. When they differ the review predates HEAD: its findings may already be fixed, and its approval may be dropped by branch protection with "dismiss stale reviews" enabled. Record `reviewed {sha} vs head {sha}` per review, re-verify a finding against the current file before fixing it, and report an approval as **stale** rather than as an approval.
      
      **Reviewer reconciliation.** Fetch reviews before threads and keep the set of reviewer logins. Every reviewer in that set must appear in the triage output with either findings, a stated verdict, or an explicit "no content" reached only after checking its review-comments endpoint. A reviewer with zero threads and an empty body is a fetch that lost something, not a reviewer with nothing to say.
      
      ## Fetch issue-level comments (REST)
      
      Top-level PR conversation comments (not inline review threads):
      
      ```bash
      gh api --paginate "repos/{owner}/{repo}/issues/{pr}/comments?per_page=100"
      ```
      
      Cannot be resolved via the thread mechanism: they need a reply, not a resolve mutation. Include in triage.
      
      **Do not filter by author type.** Human and bot issue-level comments may both be actionable:
      - `github-actions[bot]` posts DangerJS warnings and schema-compat checks
      - `linktree-stamp[bot]` posts the auto-approval verdict
      - Human reviewers post suggestions and questions
      - `linear-code[bot]` posts linkbacks (noise; classify by content)
      
      **Some bots edit one comment in place on every commit** (auto-approval assessments, DangerJS). Their `id` never changes, so comparing IDs against the previous poll shows nothing new. Compare `updated_at` as well, and re-read the body.
      
      Issue-level comments carry **merge-gate verdicts** as well as findings. A verdict is recorded for the readiness check, not replied to and not fixed.
      
      Classify each comment by content using the rules in `bot-patterns.md`.
      
      ## Reply to a thread
      
      REST reply endpoint (most reliable):
      
      ```bash
      gh api "repos/{owner}/{repo}/pulls/{pr}/comments/{comment_database_id}/replies" \
        -X POST \
        -f body="Done: fixed in latest push."
      ```
      
      `comment_database_id` is the `databaseId` of the thread's last comment (reply to the most recent message).
      
      GraphQL alternative (use if REST fails):
      
      ```graphql
      mutation($threadId: ID!, $body: String!) {
        addPullRequestReviewThreadReply(input: {
          pullRequestReviewThreadId: $threadId
          body: $body
        }) {
          comment { id }
        }
      }
      ```
      
      Replying to an already-resolved thread does not unresolve it, so a reply is always safe there.
      
      ## Reply to an issue-level comment
      
      Different endpoint, no thread mechanism; post a new comment on the PR:
      
      ```bash
      gh api "repos/{owner}/{repo}/issues/{pr}/comments" \
        -X POST \
        -f body="Acknowledged: addressed in latest push."
      ```
      
      For a contextual reply, quote the original in the body.
      
      ## Resolve a thread
      
      ```graphql
      mutation($threadId: ID!) {
        resolveReviewThread(input: { threadId: $threadId }) {
          thread { isResolved }
        }
      }
      ```
      
      Invoke:
      
      ```bash
      gh api graphql \
        -f query='mutation($threadId: ID!) { resolveReviewThread(input: { threadId: $threadId }) { thread { isResolved } } }' \
        -f threadId="$THREAD_ID"
      ```
      
      Always reply before resolving so the reviewer sees the reason.
      
      Never resolve:
      - a thread where you answered a reviewer's **question**. Only the reviewer knows whether the answer landed
      - a **merge-gate** status comment, and never reply to one either: a reply does not change the verdict
      
      Issue-level comments and review bodies have no thread mechanism: reply to acknowledge, but there is no "resolve" action.
      
      ## Pagination pattern
      
      100 threads per page is the GraphQL maximum. `gh api graphql --paginate` walks the cursor itself when the query declares a variable named exactly `$endCursor` and selects `pageInfo { hasNextPage endCursor }`; `--slurp` wraps the pages in one array:
      
      ```bash
      gh api graphql --paginate --slurp \
        -f query='query($owner:String!,$repo:String!,$pr:Int!,$endCursor:String){
          repository(owner:$owner,name:$repo){ pullRequest(number:$pr){
            reviewThreads(first:100, after:$endCursor){
              pageInfo{ hasNextPage endCursor }
              nodes{ id isResolved path comments(first:100){ pageInfo{ hasNextPage endCursor } nodes{ databaseId author{login} createdAt } } } } } } }' \
        -f owner="$OWNER" -f repo="$REPO" -F pr="$PR" \
        --jq '[.[].data.repository.pullRequest.reviewThreads.nodes[]]'
      ```
      
      Most PRs fit in one page; the cursor walk costs nothing when they do.
      
      Two loops, not one: `--paginate` follows only the outer `reviewThreads` cursor. Any node whose `comments.pageInfo.hasNextPage` is true still needs the per-thread query above, run by hand. The awaiting-reply computation runs only after both loops finish, because it depends on having each thread's true last comment.
      
    • merge-conflicts.md 4.6 KB
      # Merge Conflicts
      
      Detection, resolution, and safety guardrails for keeping a PR branch current.
      
      ## Contents
      
      - [Conflict Detection](#conflict-detection)
      - [Resolution Strategy](#resolution-strategy)
      - [Rebase Workflow](#rebase-workflow)
      - [Auto-Resolvable Conflicts](#auto-resolvable-conflicts)
      - [Do Not Auto-Resolve](#do-not-auto-resolve)
      - [Safety Guardrails](#safety-guardrails)
      
      ## Conflict Detection
      
      ```bash
      gh pr view --json mergeable,mergeStateStatus
      ```
      
      | `mergeStateStatus` | Meaning | Action |
      |--------------------|---------|--------|
      | `CLEAN` | Mergeable, checks passing | Skip |
      | `BEHIND` | Base has advanced, no conflict yet | Rebase to update (required when branch protection demands an up-to-date branch) |
      | `DIRTY` | The merge commit cannot be created: real conflicts | Resolve |
      | `UNSTABLE` | Mergeable, a check is failing | Skip (Phase 3 owns checks) |
      | `BLOCKED` | Branch protection blocks the merge (review, checks) | Skip |
      | `DRAFT` | Draft PR | Skip unless the user asked for drafts |
      | `HAS_HOOKS` | Mergeable, pre-receive hooks pending | Skip |
      | `UNKNOWN` | GitHub is computing | Recheck next tick |
      
      `mergeable` is `UNKNOWN` (REST: `null`) while GitHub runs the mergeability job in the background. Requesting the PR is what starts that job, so a second `gh pr view` a few seconds later usually has the answer.
      
      ## Resolution Strategy
      
      **Default to rebase.** Merge only when the branch is shared, because a rebase rewrites commits other people based work on:
      
      ```bash
      git log origin/{base_branch}..HEAD --format='%ae' | sort -u
      ```
      
      More than one author email means shared: `git merge origin/{base_branch}` instead. An explicit user preference for merge also overrides the default.
      
      ## Rebase Workflow
      
      ```bash
      git stash push --include-untracked      # only if the tree is dirty
      git fetch origin {base_branch}
      git rebase origin/{base_branch}
      # clean:
      git push --force-with-lease --force-if-includes
      # conflicts: resolve per the sections below, then per conflicted commit:
      git add {resolved files} && GIT_EDITOR=true git rebase --continue
      # unsafe to resolve:
      git rebase --abort
      git stash pop                            # if you stashed
      ```
      
      `--force-if-includes` is a no-op without `--force-with-lease`; together they refuse the push when the remote tip was fetched but never integrated locally, which is exactly the state a monitor loop can drift into between a fetch and a rebase.
      
      During a rebase, `--ours` is the base branch and `--theirs` is the commit being replayed. Name sides by branch in notifications, not by ours/theirs.
      
      ## Auto-Resolvable Conflicts
      
      **Lockfiles** (`package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`): generated output, so never hand-edit the markers. Each package manager resolves a conflicted lockfile itself:
      
      ```bash
      npm install --package-lock-only     # npm >= 5.7 merges both sides' resolutions
      yarn install                        # Yarn 1 and Berry rewrite the conflicted file
      pnpm install                        # pnpm merges; its docs ask you to review the result
      ```
      
      If `package.json` also conflicts, resolve it first (both sides' additions), then run the install. Prove the result with a frozen install (`npm ci`, `yarn install --immutable`, `pnpm install --frozen-lockfile`) before `git add` and `git rebase --continue`.
      
      **Generated files** (`*.generated.*`, `schema.graphql`, codegen output): take either side, re-run the project's generation command, stage the output.
      
      **Changelogs** (`CHANGELOG.md`, `CHANGES.md`): keep both sides, newest entry first.
      
      **Config files with additive changes** (both sides added different keys): keep both additions, check the result still parses.
      
      ## Do Not Auto-Resolve
      
      Abort and notify, with the conflicting files, the conflicting lines, and what each side changed:
      
      - Source code where both sides modified the same function body
      - Database migrations: ordering matters, a bad resolution breaks the chain
      - API contracts and OpenAPI specs: semantic changes need a human
      - Both sides deleted and added on overlapping lines: intent is ambiguous
      - Test files with conflicting assertions: the right assertion depends on intent
      
      ## Safety Guardrails
      
      1. `--force-with-lease --force-if-includes`, never bare `--force`. A refused lease means someone else pushed since your fetch: abort and notify, do not overwrite their commits
      2. Stash before rebasing a dirty tree; pop after
      3. Any resolution that fails or looks wrong: `git rebase --abort` restores the branch
      4. Never rebase a shared branch (detection above); merge instead
      5. After pushing, confirm `gh pr view --json mergeable` reports `MERGEABLE`
      6. One resolution per cycle: if the base advances again, resolve again on the next tick rather than batching
      
    • monitoring-setup.md 9.5 KB
      # Monitoring Setup
      
      Watch setup for monitor mode: the watch ladder, the Monitor watch script, the cron fallback, the state file format, defaults, and lifecycle.
      
      ## Contents
      
      - [Watch Ladder](#watch-ladder)
      - [Harness PR Subscription](#harness-pr-subscription)
      - [Monitor Watch Script](#monitor-watch-script)
      - [Cron Fallback](#cron-fallback)
      - [State File Format](#state-file-format)
      - [Auto-Detection Defaults](#auto-detection-defaults)
      - [Stopping](#stopping)
      - [Session Lifecycle](#session-lifecycle)
      
      ## Watch Ladder
      
      Checked once in Phase 1, first rung that applies. The mechanism and its ID go in the state file so Stopping can find them.
      
      | Rung | Available when | Wakes the agent on |
      |------|----------------|--------------------|
      | 1. Harness PR subscription | A PR-subscription tool is exposed, or the web session's Auto-fix toggle is on | Review comments, CI failures, check-suite success, pushed by GitHub |
      | 2. Monitor tool | `Monitor` is in the tool list | A `CHANGED` or `TERMINAL` line from the watch script below |
      | 3. Cron | `CronCreate` is in the tool list | Every tick |
      | 4. None | Neither | Nothing. Run one-shot modes only and say so |
      
      ## Harness PR Subscription
      
      Cloud and remote sessions expose `Claude_Code_Remote:subscribe_pr_activity` (owner, repo, pullNumber); the GitHub MCP server exposes `github:subscribe_pr_activity`. Claude Code on the web has the same thing as an Auto-fix toggle in the CI status bar, and `/autofix-pr` from a terminal spawns a cloud session with it on. Events arrive as external-event envelopes; each one runs phases 2-5.
      
      Two limits of this rung:
      
      - GitHub emits no webhook when the base branch advances into a conflict. Pair the subscription with a Monitor poll of `gh pr view --json mergeable,mergeStateStatus` every 10 minutes (a base branch rarely moves faster, and the watch has nothing else to do), or check conflicts on every event that does arrive.
      - If the subscribe call reports that a PR Steward already watches this PR, this session receives no events. Do not claim monitor mode; say the PR is already covered and offer the one-shot modes, because two agents pushing fixes to one branch trip each other's leases.
      
      Stop with the matching unsubscribe tool (`Claude_Code_Remote:unsubscribe_pr_activity` or `github:unsubscribe_pr_activity`).
      
      ## Monitor Watch Script
      
      Start it with `persistent: true` (the default watch ends at its timeout) and a `description` naming the PR. Monitor commands run under the same permission rules as Bash.
      
      The script polls, fingerprints PR state, and emits one line only when the fingerprint changes. It never fixes or classifies anything; on each emitted line, run phases 2-5, which diff against the state file for the detailed comparison and write it back.
      
      Substitute `{N}`, `{owner}`, `{repo}`, and the interval (respect inline overrides like "poll every 5 minutes"):
      
      ```bash
      PR={N}; OWNER={owner}; REPO={repo}
      # 120s: GitHub takes a minute or two to recompute checks and mergeability after
      # a push, so polling faster returns the same answer and spends rate limit.
      INTERVAL=120
      ME=$(gh api user --jq .login)
      prev=""
      while true; do
        view=$(gh pr view "$PR" --repo "$OWNER/$REPO" \
          --json state,headRefOid,mergeable,mergeStateStatus,reviewDecision 2>/dev/null) \
          || { sleep "$INTERVAL"; continue; }
        state=$(jq -r .state <<<"$view")
        if [ "$state" != "OPEN" ]; then echo "TERMINAL: PR $state"; exit 0; fi
        # gh pr checks exits 1 on a failed check and 8 while pending; the JSON is
        # printed either way, so read the pipeline's output and ignore its status.
        checks=$(gh pr checks "$PR" --repo "$OWNER/$REPO" --json name,bucket 2>/dev/null \
          | jq -c 'sort_by(.name)')
        tdata=$(gh api graphql \
          -f query='query($o:String!,$r:String!,$n:Int!){repository(owner:$o,name:$r){pullRequest(number:$n){reviewThreads(first:100){nodes{isResolved comments(last:1){nodes{author{login}}}}}}}}' \
          -f o="$OWNER" -f r="$REPO" -F n="$PR" 2>/dev/null)
        nodes='.data.repository.pullRequest.reviewThreads.nodes[]'
        threads=$(jq "[$nodes | select(.isResolved | not)] | length" <<<"$tdata")
        awaiting=$(jq --arg me "$ME" \
          "[$nodes | select(.comments.nodes[0].author.login != \$me)] | length" <<<"$tdata")
        # Review comments support sort=updated; issue comments do not, so page them
        # and take the max. Both feeds matter: bots edit issue comments in place.
        newest_review=$(gh api "repos/$OWNER/$REPO/pulls/$PR/comments?per_page=1&sort=updated&direction=desc" \
          --jq '.[0].updated_at // empty' 2>/dev/null)
        newest_issue=$(gh api --paginate "repos/$OWNER/$REPO/issues/$PR/comments?per_page=100" \
          --jq '.[].updated_at' 2>/dev/null | sort | tail -1)
        newest=$(printf '%s\n%s\n' "$newest_review" "$newest_issue" | sort | tail -1)
        fp="$(jq -r '[.headRefOid,.mergeable,.mergeStateStatus,.reviewDecision] | join("|")' <<<"$view")"
        fp="$fp|$checks|threads=$threads|awaiting=$awaiting|newest=$newest"
        if [ -n "$prev" ] && [ "$fp" != "$prev" ]; then echo "CHANGED: $fp"; fi
        prev="$fp"
        sleep "$INTERVAL"
      done
      ```
      
      `threads` alone was blind to the two events that matter most. A human reply on an already-resolved thread and a bot comment edited in place both leave the head SHA, mergeability, review decision, check buckets, and unresolved count **all unchanged**, so the watch never woke. `awaiting` and `newest` are what move on those events.
      
      Both probes are deliberately coarse: `awaiting` counts every thread whose newest comment is not yours, resolved or not, with no `resolvedBy` carve-out. A fingerprint only has to **change**, so over-counting costs nothing and under-counting loses an event. Precise bucketing happens in Phase 4.
      
      Emitted lines:
      
      | Line | Meaning | React by |
      |------|---------|----------|
      | `CHANGED: {fingerprint}` | Head SHA, mergeability, review decision, a check bucket, the unresolved count, the awaiting count, or the newest comment timestamp changed | Run phases 2-5 |
      | `TERMINAL: PR MERGED` / `TERMINAL: PR CLOSED` | PR left the OPEN state; the script exits and the watch ends | Report the final summary, stop |
      
      Transient `gh` failures skip the iteration and retry next interval; they never emit.
      
      ## Cron Fallback
      
      `CronCreate` with a 5-field expression; the prompt below runs phases 2-5 on every tick.
      
      | User intent | Cron expression |
      |-------------|-----------------|
      | Every 2 minutes (default) | `*/2 * * * *` |
      | Every 5 minutes | `*/5 * * * *` |
      | Every 10 minutes | `*/10 * * * *` |
      | Every hour | `7 * * * *` |
      
      The scheduler jitters recurring tasks by up to half the interval (up to 30 minutes for hourly and slower), derived from the task ID, so a 2-minute cron fires somewhere inside each 2-minute window rather than on the minute. Pick an off-minute like `7` for hourly jobs; `:00` and `:30` carry extra jitter.
      
      Recurring tasks expire 7 days after creation (one final fire, then self-delete). Re-run the skill if the PR is still open.
      
      Prompt template:
      
      ```text
      Check PR #{N} in {owner}/{repo}. Run pr-babysitter monitor phases 2-5:
      1. Conflicts: gh pr view --json mergeable,mergeStateStatus; resolve if safe
      2. CI: gh pr checks --json name,state,bucket,link; diagnose failures, Buildkite auth chain if needed
      3. Comments: compare open and awaiting-reply counts and newest updated_at with the state file; triage autonomously
      4. Readiness: report only transitions
      State file: .claude/pr-babysitter/babysit-pr-{N}.md
      Auto-resolve noise: yes
      Auto-merge: no
      ```
      
      ## State File Format
      
      Write to `.claude/pr-babysitter/babysit-pr-{N}.md` (create the folder; never stage it).
      
      ```markdown
      # Babysit PR #{N}
      
      **PR:** {title} (#{N})
      **URL:** {pr_url}
      **Branch:** {head_branch} -> {base_branch}
      **Watch:** {subscription|monitor|cron} ({id})
      **Started:** {timestamp}
      **Last Poll:** {timestamp}
      
      ## Preferences
      
      - Auto-resolve noise: yes
      - Auto-merge when ready: no
      - Poll interval: every 2 minutes
      
      ## Current State
      
      - **HEAD:** {sha}
      - **Mergeable:** {MERGEABLE|CONFLICTING|UNKNOWN}
      - **Review Decision:** {APPROVED|CHANGES_REQUESTED|REVIEW_REQUIRED}
      - **Unresolved Threads:** {count}
      - **Awaiting My Reply:** {count}
      - **Merge Gate:** {verdict or none}
      - **Newest Comment:** {timestamp}
      - **Checks:**
        - {check_name}: {pass|fail|pending|skipping|cancel} ({platform})
      
      ## History
      
      | Time | Event |
      |------|-------|
      | {timestamp} | {state change description} |
      ```
      
      Keep the history to the last 20 entries.
      
      ## Auto-Detection Defaults
      
      | Setting | Default | Override |
      |---------|---------|----------|
      | PR | Current branch | Pass PR number as argument |
      | Poll interval | Every 2 minutes | "Poll every 5 minutes" |
      | Auto-resolve noise | Yes | "Don't auto-resolve noise" |
      | Auto-merge | No | "Enable auto-merge" (then `gh pr merge --auto` with the repo's merge method once Phase 5 says ready) |
      | CI platforms | From `gh pr checks` names and links | Always auto-detected |
      
      Overrides given inline when invoking: "babysit PR #42, poll every 5 minutes, enable auto-merge."
      
      ## Stopping
      
      1. Read the watch mechanism and ID from the state file
      2. Monitor watch: `TaskStop` with that ID. Cron: `CronDelete` with the job ID. Subscription: the matching unsubscribe tool
      3. Report: polls or events handled, conflicts resolved, CI failures fixed, comments triaged, current PR state
      
      ## Session Lifecycle
      
      - Monitor watches, cron jobs, and subscriptions are session-scoped
      - Monitor watch: ends on `TaskStop`, session exit, or script exit (`TERMINAL` line); without `persistent: true` it dies at the default timeout
      - Cron: 7-day expiry; restored on `--resume` if unexpired. Background Monitor tasks are never restored on resume
      - An event or tick arriving while the agent is busy is handled when it goes idle; there is no catch-up for missed fires
      
    • verification-gate.md 4.4 KB
      # Verification Gate
      
      Checks that must pass before any commit the monitor pushes, plus the stray-artifact sweep before commit. Pushing red work or stray files wastes a whole poll cycle.
      
      ## Contents
      
      - [When the gate runs](#when-the-gate-runs)
      - [Detect available checks](#detect-available-checks)
      - [Run order and scope](#run-order-and-scope)
      - [Stray-artifact sweep](#stray-artifact-sweep)
      - [Pre-commit hooks that emit artifacts](#pre-commit-hooks-that-emit-artifacts)
      - [Gate failure handling](#gate-failure-handling)
      
      ## When the gate runs
      
      Run after applying a fix (Phase 3 CI fix, or a triage review-comment fix) and **before** that fix's commit/push. On failure: fix, re-run, do not push until green. Local verification; CI is the backstop, not the first line of defence.
      
      ## Detect available checks
      
      Read the project's task runner; run only checks that exist. Do not assume fixed script names.
      
      ```bash
      # npm/yarn/pnpm projects: read the scripts block
      jq -r '.scripts | keys[]' package.json 2>/dev/null
      ```
      
      Map common names (a project may use any subset):
      
      | Check       | Common script names                          |
      | ----------- | -------------------------------------------- |
      | lint        | `lint`, `lint:fix`, `eslint`, `oxlint`       |
      | type-check  | `typecheck`, `type-check`, `tsc`, `check`    |
      | test        | `test`, `test:unit`, `vitest`, `jest`        |
      | dead code   | `knip`                                       |
      
      Non-npm runners: `turbo run <task>`, `nx run <task>`, `make <target>`. If no checks exist, say so and skip the gate rather than inventing commands.
      
      ## Run order and scope
      
      Run in increasing cost order; stop and fix on the first failure.
      
      1. **type-check**: fastest signal on a fix. Scope to changed files where the tooling supports it, else run the project script.
      2. **lint**: scope to changed files (`eslint <files>`, `oxlint <files>`) when possible.
      3. **test**: run the project test script. Scope to affected tests where an affected mode exists, else run the full suite. Use the quiet reporter (`--reporter=dot`, `--silent`) or capture a log and inspect its tail while preserving the original exit status: the full output is re-sent on every later turn of the monitor.
      4. **knip**: run last (project-wide by design). Handling is the `knip` item of the failure classification in `ci-platforms.md`.
      
      **All present checks must pass before committing.** A type-check failure may be a stale-dependency issue, not a code bug; check the stale-dependency branch in `ci-platforms.md` first.
      
      ## Stray-artifact sweep
      
      Pre-commit hooks and build/check steps can dirty the working tree with files **not** part of the intended fix (canonical case: a root-level `schema.gql` or similar generated output emitted by a hook). Committing these pollutes the PR and trips reviewers.
      
      After checks and hooks, inspect the tree:
      
      ```bash
      git status --porcelain
      ```
      
      For each untracked or modified file, decide:
      
      - **Intended**: part of the fix, or a tracked generated file the change is supposed to update. Keep it.
      - **Stray**: generated output or formatter churn introduced by this run. Leave unrelated work unstaged. Remove only newly generated files whose ownership is established, and reverse only this run's hunks in pre-existing files. Do not restore whole files against HEAD.
      
      Stage only the fix's files: `git add <paths>`, never `git add -A`, so stray files are never committed. The skill's own state and plan files under `.claude/pr-babysitter/` are always stray: they never go in the PR.
      
      ## Pre-commit hooks that emit artifacts
      
      A hook can dirty the tree *during* the commit, after your sweep. Re-check after committing:
      
      ```bash
      git status --porcelain
      ```
      
      Inspect the commit diff separately from the working tree. Uncommitted artifacts do not require an amend. If the monitor's own unpushed commit accidentally included an artifact, stage only its correction and amend that commit. Preserve any pre-existing index entries; never clear the whole index as cleanup.
      
      ## Gate failure handling
      
      - **Lint/type/test failure on the fix**: read the error, fix, re-run the gate. Do not push.
      - **Failure unrelated to the fix** (flaky test, pre-existing type error elsewhere): note it; don't expand scope to fix unrelated breakage in a comment-triage commit. If it blocks the gate, surface it to the user instead of pushing past it.
      - **Gate can't run** (no scripts, missing deps): say so in the notification; rely on CI and flag that local verification was unavailable.
      
  • scripts
    • fetch-comments.sh 16.4 KB
      #!/usr/bin/env bash
      # Normalized dump of every review, review thread, and issue comment on a PR.
      #
      # Extracts tokens and anchors. It does NOT classify: `severityHints` holds every
      # raw token found in the body, verbatim, and mapping those to a severity is the
      # job of references/bot-patterns.md. No intent, no dedupe, no ignore reasons, no
      # verdict interpretation.
      #
      # usage: fetch-comments.sh [<pr-number>] [--repo owner/name]
      # stdout: one JSON document. stderr: one sentence, on failure only.
      # exit:   0 on success; 1 on any failure (missing tool, auth, bad args, API error).
      set -euo pipefail
      
      die() { printf '%s\n' "$1" >&2; exit 1; }
      
      usage() {
        cat <<'USAGE'
      usage: fetch-comments.sh [<pr-number>] [--repo owner/name]
      
        <pr-number>   defaults to the PR of the current branch (gh pr view)
        --repo        defaults to the repo of the current checkout (gh repo view)
      
      Needs gh (authenticated) and jq. Prints one JSON document:
      
        { me, repo, pr, headRefOid,
          counts: { threads, open, resolvedWithUnansweredReply, resolvedQuiet,
                    prLevel, outdated, collapsed, awaitingReply, anchorsNeedingWork,
                    reviews, staleReviews, issueComments },
          reviewers[]:     { login, isBot, isMe, reviews, inlineComments,
                             issueComments, emptyBodyReviews },
          reviews[]:       { id, author, isMe, state, submittedAt, commitId, isStale,
                             bodyEmpty, body, bodyStripped, severityHints[] },
          threads[]:       { id, path, subjectType, isResolved, isOutdated, isCollapsed,
                             resolvedBy, anchor{line,startLine,endLine,side,source},
                             threadAuthor, lastComment, owedReply, bucket,
                             commentCount, comments[] },
          issueComments[]: { id, author, isMe, createdAt, updatedAt, wasEdited, url,
                             body, bodyStripped, severityHints[] } }
      
      bucket is one of open | resolved-with-unanswered-reply | resolved-quiet | pr-level.
      anchor.source is one of line | startLine | embedded-<bot> | file | needs-translation | path-only.
      severityHints are raw tokens, not a severity; bot-patterns.md maps them.
      
      exit 0 on success, 1 on any failure with one sentence on stderr.
      USAGE
      }
      
      # One temp file so gh's own stderr can be folded into our single sentence
      # rather than leaking alongside it.
      ERRF=$(mktemp) || die "could not create a temp file"
      trap 'rm -f "$ERRF"' EXIT
      ghwhy() { sed -e 's/^gh: //' -e 's/^GraphQL: //' "$ERRF" | tr '\n' ' ' | sed 's/ *$//'; }
      
      PR=""; REPO=""
      while [ $# -gt 0 ]; do
        case "$1" in
          --repo) REPO="${2:-}"; shift 2 ;;
          -h|--help) usage; exit 0 ;;
          -*) die "unknown flag '$1'. usage: fetch-comments.sh [<pr-number>] [--repo owner/name]" ;;
          *) PR="$1"; shift ;;
        esac
      done
      
      command -v gh >/dev/null 2>&1 || die "gh not found: install the GitHub CLI from https://cli.github.com"
      command -v jq >/dev/null 2>&1 || die "jq not found: install jq"
      gh auth status >/dev/null 2>&1 || die "gh not authenticated: run gh auth login"
      
      if [ -z "$REPO" ]; then
        REPO=$(gh repo view --json owner,name --jq '"\(.owner.login)/\(.name)"' 2>/dev/null) \
          || die "could not detect the repo: run inside a git checkout or pass --repo owner/name"
      fi
      OWNER="${REPO%%/*}"; NAME="${REPO##*/}"
      { [ -n "$OWNER" ] && [ -n "$NAME" ] && [ "$OWNER" != "$REPO" ]; } \
        || die "bad --repo value '$REPO': expected owner/name"
      
      if [ -z "$PR" ]; then
        PR=$(gh pr view --json number --jq .number 2>/dev/null) \
          || die "no PR number given and none found for the current branch: pass <pr-number>"
      fi
      case "$PR" in ''|*[!0-9]*) die "bad PR number '$PR': expected digits" ;; esac
      
      ME=$(gh api user --jq .login 2>/dev/null) || die "could not resolve your login: check gh auth status"
      
      # first:100 on both connections is the GraphQL page maximum. Thread comments
      # arrive oldest first, so anything smaller hides the newest comment, which is
      # the one that decides whether a reply is owed; the loops below page past 100.
      # shellcheck disable=SC2016  # the $ names are GraphQL variables, not shell
      THREAD_Q='query($o:String!,$r:String!,$n:Int!,$c:String){
        repository(owner:$o,name:$r){ pullRequest(number:$n){
          headRefOid
          reviewThreads(first:100, after:$c){
            pageInfo{ hasNextPage endCursor }
            nodes{
              id isResolved isOutdated isCollapsed resolvedBy{login} subjectType
              path line startLine originalLine originalStartLine diffSide
              comments(first:100){
                totalCount pageInfo{ hasNextPage endCursor }
                nodes{
                  databaseId author{login __typename} body
                  line startLine originalLine originalStartLine diffHunk outdated
                  createdAt url replyTo{databaseId} commit{oid} originalCommit{oid}
                } } } } } } }'
      
      # shellcheck disable=SC2016  # GraphQL variables again
      COMMENT_Q='query($t:ID!,$c:String){ node(id:$t){ ... on PullRequestReviewThread {
        comments(first:100, after:$c){ pageInfo{ hasNextPage endCursor }
          nodes{ databaseId author{login __typename} body line startLine originalLine
                 originalStartLine diffHunk outdated createdAt url replyTo{databaseId}
                 commit{oid} originalCommit{oid} } } } } }'
      
      gql_threads() {
        if [ -z "$1" ]; then
          gh api graphql -f query="$THREAD_Q" -f o="$OWNER" -f r="$NAME" -F n="$PR"
        else
          gh api graphql -f query="$THREAD_Q" -f o="$OWNER" -f r="$NAME" -F n="$PR" -f c="$1"
        fi
      }
      
      # Page the thread list.
      threads="[]"; cursor=""; head_oid=""
      while :; do
        res=$(gql_threads "$cursor" 2>"$ERRF") \
          || die "thread fetch failed for $REPO#$PR: $(ghwhy)"
        node=$(jq -c '.data.repository.pullRequest // empty' <<<"$res")
        [ -n "$node" ] || die "PR #$PR not found in $REPO"
        head_oid=$(jq -r '.headRefOid' <<<"$node")
        threads=$(jq -c -n --argjson a "$threads" \
          --argjson b "$(jq -c '.reviewThreads.nodes' <<<"$node")" '$a + $b')
        [ "$(jq -r '.reviewThreads.pageInfo.hasNextPage' <<<"$node")" = "true" ] || break
        cursor=$(jq -r '.reviewThreads.pageInfo.endCursor' <<<"$node")
      done
      
      # Page any thread whose comments were truncated. Thread comments come oldest
      # first, so a truncated page hides the newest comment, which is the one that
      # decides whether a reply is owed.
      for tid in $(jq -r '.[] | select(.comments.pageInfo.hasNextPage) | .id' <<<"$threads"); do
        ccur=$(jq -r --arg t "$tid" '.[] | select(.id == $t) | .comments.pageInfo.endCursor' <<<"$threads")
        extra="[]"
        while [ -n "$ccur" ] && [ "$ccur" != "null" ]; do
          cres=$(gh api graphql -f query="$COMMENT_Q" -f t="$tid" -f c="$ccur" 2>"$ERRF") \
            || die "comment paging failed on thread $tid: $(ghwhy)"
          extra=$(jq -c -n --argjson a "$extra" \
            --argjson b "$(jq -c '.data.node.comments.nodes' <<<"$cres")" '$a + $b')
          if [ "$(jq -r '.data.node.comments.pageInfo.hasNextPage' <<<"$cres")" = "true" ]; then
            ccur=$(jq -r '.data.node.comments.pageInfo.endCursor' <<<"$cres")
          else
            ccur=""
          fi
        done
        threads=$(jq -c --arg t "$tid" --argjson extra "$extra" \
          'map(if .id == $t then .comments.nodes += $extra else . end)' <<<"$threads")
      done
      
      # per_page=100 is the REST maximum; --paginate follows the Link header and
      # emits one array per page, which jq -s 'flatten(1)' joins into one list.
      reviews=$(gh api --paginate "repos/$REPO/pulls/$PR/reviews?per_page=100" 2>"$ERRF" \
        | jq -c -s 'flatten(1)') || die "could not fetch reviews for $REPO#$PR: $(ghwhy)"
      issue_comments=$(gh api --paginate "repos/$REPO/issues/$PR/comments?per_page=100" 2>"$ERRF" \
        | jq -c -s 'flatten(1)') || die "could not fetch issue comments for $REPO#$PR: $(ghwhy)"
      
      jq -n \
        --arg me "$ME" --arg repo "$REPO" --arg head "$head_oid" --argjson pr "$PR" \
        --argjson threads "$threads" --argjson reviews "$reviews" \
        --argjson issue_comments "$issue_comments" '
      
      # Normalize bot login suffixes across REST and GraphQL responses.
      def canon: sub("\\[bot\\]$"; "");
      
      # Generic markup. Anything narrower belongs in bot-patterns.md.
      def strip_markup:
        gsub("<!--(?:.|\n)*?-->"; "")
        | gsub("<details[^>]*>(?:.|\n)*?</details>"; "")
        | gsub("<sup>(?:.|\n)*?</sup>"; "")
        | gsub("<sub>(?:.|\n)*?</sub>"; "")
        | gsub("<picture>(?:.|\n)*?</picture>"; "")
        | gsub("<a\\s[^>]*>(?:.|\n)*?</a>"; "")
        | gsub("!\\[[^\\]]*\\]\\([^)]*\\)"; "")
        # Nested <sub><sub>...</sub></sub> leaves an orphan closing tag behind, since
        # the non-greedy block match stops at the first close. Sweep what is left.
        | gsub("</?(sub|sup|div|br|p|picture|source|summary|details|a|img)[^>]*>"; "")
        | gsub("(?i)_?Was this helpful\\?[^\n]*"; "")
        | gsub("(?i)_?Comment `@[A-Za-z0-9_-]+ review`[^\n]*"; "")
        | gsub("(?i)Want higher recall\\?[^\n]*"; "")
        | gsub("(?i)Reviewed by[^\n]*for commit[^\n]*"; "")
        | gsub("(?i)Bugbot Autofix is OFF[^\n]*"; "")
        | gsub("[ \t]+\n"; "\n")
        | gsub("[ \t]{2,}"; " ")
        | gsub("\n{3,}"; "\n\n")
        | sub("^\\s+"; "") | sub("\\s+$"; "");
      
      # Every raw token found, verbatim, in no particular order. Not a severity, and
      # deliberately not a single winner: one comment can carry two complementary
      # tokens (Devin pairs a BUG_/ANALYSIS_ kind with a colour severity emoji).
      # Resolving precedence is bot-patterns.md, not this script.
      def severity_hints:
        . as $b
        | [ ( $b | capture("\\*\\*(?<s>(High|Medium|Low) Severity)\\*\\*") | .s )?
          , ( $b | capture("img\\.shields\\.io/badge/(?<s>P[0-9])") | .s )?
          , ( $b | capture("(?<s>(high|medium|low)-priority)\\.svg") | .s )?
          , ( ($b | capture("\"id\":\\s*\"(?<s>BUG|ANALYSIS)_") | .s + "_") )?
          , ( if ($b | test("🔴")) then "🔴" else empty end )
          , ( if ($b | test("🟠")) then "🟠" else empty end )
          , ( if ($b | test("🟡")) then "🟡" else empty end )
          , ( if ($b | test("⚠️ Potential issue")) then "⚠️ Potential issue" else empty end )
          # Severity-adjacent context only. A bare word match reports "critical" for
          # both "not critical at all" and "the critical path".
          , ( $b | capture("(?i)(^|\\*\\*|\\b(severity|priority)\\s*[:=]\\s*)(?<s>critical|blocker)\\b") | .s )?
          , ( if ($b | test("(?i)\\bnit(pick)?\\b")) then "nit" else empty end )
          ] | unique;
      
      # Anchors a bot wrote into its own body. Authoritative when line is null.
      def embedded_anchors:
        . as $b
        | [ $b
            | match("<!--\\s*devin-review-comment\\s*(\\{[^}]*\\})\\s*-->"; "g")
            | .captures[0].string
            | try (fromjson
                   | {kind:"devin", path:.file_path, line:.start_line,
                      endLine:.end_line, side:(.side // "RIGHT")}) catch empty ]
      ;
      
      def norm_comment($me):
        (.body // "") as $b
        | { databaseId, author: (.author.login // "ghost"),
            authorTypename: (.author.__typename // "User"),
            isMe: ((.author.login // "") == $me),
            isBot: ((.author.__typename // "User") == "Bot"),
            createdAt, url,
            replyTo: (.replyTo.databaseId // null),
            commit: (.commit.oid // null),
            originalCommit: (.originalCommit.oid // null),
            line, startLine, originalLine, originalStartLine, outdated,
            diffHunk: (.diffHunk // null),
            body: $b,
            bodyStripped: ($b | strip_markup),
            severityHints: ($b | severity_hints),
            embeddedAnchors: ($b | embedded_anchors) };
      
      # Rungs resolvable without a checkout. originalLine and diffHunk are handed
      # back as needs-translation for the agent to finish against the working tree.
      def anchor($t; $first):
        ($first.embeddedAnchors // []) as $emb
        | if $t.line != null then
            {line: $t.line, startLine: $t.startLine, endLine: $t.line,
             side: $t.diffSide, source: "line"}
          elif $t.startLine != null then
            {line: $t.startLine, startLine: $t.startLine, endLine: $t.startLine,
             side: $t.diffSide, source: "startLine"}
          elif ($emb | length) > 0 then
            ($emb[0] | {line, endLine, startLine: .line, side,
                        source: ("embedded-" + .kind)})
          elif $t.subjectType == "FILE" then
            {line: null, startLine: null, endLine: null, side: $t.diffSide, source: "file"}
          elif ($t.originalLine != null or ($first.diffHunk // "") != "") then
            {line: $t.originalLine, startLine: $t.originalStartLine,
             endLine: $t.originalLine, side: $t.diffSide, source: "needs-translation"}
          else
            {line: null, startLine: null, endLine: null, side: $t.diffSide,
             source: "path-only"} end;
      
      ($threads | map(
        (.comments.nodes | map(norm_comment($me))) as $cs
        | ($cs | sort_by(.createdAt) | last) as $last
        | ($cs | first) as $first
        # One predicate for "a reply is owed", mirroring the awaiting-reply table.
        # A bot only owes on an open thread. A human owes in any resolution state,
        # unless they resolved their own last comment, which is them closing the
        # conversation: without that carve-out, readiness never clears.
        | ($last != null and ($last.isMe | not)
           and (if $last.isBot then (.isResolved | not)
                else ((.isResolved and ((.resolvedBy.login // null) == $last.author)) | not)
                end)) as $owed
        | {
            id, path, subjectType,
            isResolved, isOutdated, isCollapsed,
            resolvedBy: (.resolvedBy.login // null),
            anchor: anchor(.; $first),
            threadAuthor: ($first.author // null),
            lastComment: (if $last then
                {author: $last.author, isMe: $last.isMe, isBot: $last.isBot,
                 createdAt: $last.createdAt, url: $last.url} else null end),
            owedReply: $owed,
            bucket: (
              if .path == null then "pr-level"
              elif (.isResolved | not) then "open"
              elif $owed then "resolved-with-unanswered-reply"
              else "resolved-quiet" end),
            commentCount: ($cs | length),
            comments: $cs
          }
      )) as $t
      | ($reviews | map({
          # user is null for a deleted account; "ghost" keeps canon (a string sub)
          # from failing on it and matches what GitHub shows in the UI.
          id, author: (.user.login // "ghost"),
          isMe: ((.user.login // "") == $me),
          state, submittedAt,
          commitId: .commit_id,
          isStale: ((.commit_id // "") != $head),
          bodyEmpty: (((.body // "") | gsub("\\s"; "")) == ""),
          body: (.body // ""),
          bodyStripped: ((.body // "") | strip_markup),
          severityHints: ((.body // "") | severity_hints)
        })) as $r
      | ($issue_comments | map({
          # user is null for a deleted account; "ghost" keeps canon (a string sub)
          # from failing on it and matches what GitHub shows in the UI.
          id, author: (.user.login // "ghost"),
          isMe: ((.user.login // "") == $me),
          createdAt, updatedAt: .updated_at,
          wasEdited: (.updated_at != .created_at),
          url: .html_url,
          body: (.body // ""),
          bodyStripped: ((.body // "") | strip_markup),
          severityHints: ((.body // "") | severity_hints)
        })) as $ic
      | {
          me: $me, repo: $repo, pr: $pr, headRefOid: $head,
          counts: {
            threads: ($t | length),
            open: ([$t[] | select(.bucket == "open")] | length),
            resolvedWithUnansweredReply:
              ([$t[] | select(.bucket == "resolved-with-unanswered-reply")] | length),
            resolvedQuiet: ([$t[] | select(.bucket == "resolved-quiet")] | length),
            prLevel: ([$t[] | select(.bucket == "pr-level")] | length),
            outdated: ([$t[] | select(.isOutdated)] | length),
            collapsed: ([$t[] | select(.isCollapsed)] | length),
            awaitingReply: ([$t[] | select(.owedReply)] | length),
            anchorsNeedingWork:
              ([$t[] | select(.anchor.source == "needs-translation"
                              or .anchor.source == "path-only")] | length),
            reviews: ($r | length),
            staleReviews: ([$r[] | select(.isStale)] | length),
            issueComments: ($ic | length)
          },
          # Every login that spoke, canonicalized. An uncanonicalized index splits
          # bot identities in two and makes reviewer reconciliation cry wolf.
          # A reviewer here with no findings and no verdict is a fetch that lost
          # something, not a reviewer with nothing to say.
          reviewers: (
            ([$r[] | .author] + [$t[] | .comments[] | .author] + [$ic[] | .author]) as $raw
            | ($raw | map(canon) | unique)
            | map(. as $login | {
                login: $login,
                isMe: ($login == ($me | canon)),
                # Hint, not a verdict: machine users such as developer-platform-actions
                # have no [bot] suffix and report as User.
                isBot: (([$raw[] | select(canon == $login) | test("\\[bot\\]$")] | any)
                        or ([$t[] | .comments[] | select((.author | canon) == $login) | .isBot]
                            | any)),
                reviews: ([$r[] | select((.author | canon) == $login)] | length),
                inlineComments:
                  ([$t[] | .comments[] | select((.author | canon) == $login)] | length),
                issueComments: ([$ic[] | select((.author | canon) == $login)] | length),
                emptyBodyReviews:
                  ([$r[] | select((.author | canon) == $login and .bodyEmpty)] | length)
              })
          ),
          reviews: $r,
          threads: $t,
          issueComments: $ic
        }
      '
      
  • SKILL.md 15.6 KB
    ---
    name: pr-babysitter
    description: "Monitors or repairs an open GitHub PR: CI failures, conflicts, review threads, and merge readiness, reporting state changes. Use when asked to \"watch this PR\", \"fix CI\", \"resolve conflicts\", or \"address review comments\". For PR metadata use pr-creator; for npm release PRs use autoship."
    compatibility: Requires a Git checkout, authenticated GitHub CLI, and jq. Continuous monitoring also needs a supported scheduler or event subscription.
    ---
    
    # PR Babysitter
    
    - **IS:** keeping one open PR moving: conflicts, CI across GitHub Actions/Buildkite/Vercel/Fly.io, inbound review comments, and merge readiness, as a background monitor or as one-shot fixes.
    - **IS NOT:** opening or editing the PR (`pr-creator`), reviewing the diff for bugs (`pr-reviewer`), applying a local `pr-reviewer` report (`tidy`), or npm release PRs (`autoship` watches its own release CI; never babysit a release or Version Packages PR it drives).
    
    ## Mode Selection
    
    | Invocation | Mode |
    |------------|------|
    | "babysit", "watch this PR", "monitor", "keep it green" | Monitor: Phase 1 once, then phases 2-5 on every event or tick |
    | "fix CI", "why is CI red", "loop on CI" | One-shot Phase 3 loop |
    | "resolve conflicts", "rebase onto main", "update the branch" | One-shot Phase 2 |
    | "address the comments", "reply to the reviewers", "triage review comments" | One-shot Comment Triage Workflow |
    | "is it ready", "what is blocking the merge" | One-shot Phase 5 report |
    
    Standing rules, every mode:
    
    - Monitoring or fixing code does not by itself authorize posting replies. Post, resolve threads, or request reviews only when the user authorized that communication; otherwise prepare replies and report them.
    - Resolve `scripts/fetch-comments.sh` relative to this installed SKILL.md. `${CLAUDE_SKILL_DIR}` below is a Claude Code adapter, not a portable environment variable.
    
    - No setup questions. Auto-detect the PR, the CI platforms, and the defaults (poll every 2 minutes, auto-resolve noise, no auto-merge), then start. Overrides arrive inline: "poll every 5 minutes", "enable auto-merge".
    - Skip closed or merged PRs. Skip drafts (`isDraft`) unless asked.
    - Comment triage runs autonomously; the plan file is an audit trail, not an approval gate.
    - Speak only on transitions. A quiet poll says nothing.
    
    ## Reference Files
    
    | File | Read when |
    |------|-----------|
    | `references/monitoring-setup.md` | Phase 1: watch ladder detail, Monitor watch script, cron fallback, state file format, defaults |
    | `references/merge-conflicts.md` | Phase 2: `mergeStateStatus` table, rebase workflow, lockfile and generated-file resolution, abort criteria |
    | `references/ci-platforms.md` | Phase 3: `gh pr checks` fields and exit codes, per-platform log and retry commands, Buildkite auth chain, failure classification |
    | `scripts/fetch-comments.sh` | Comment triage: run `${CLAUDE_SKILL_DIR}/scripts/fetch-comments.sh {N}` first. One JSON document of every review, thread, and issue comment; `--help` prints the output shape |
    | `references/github-api.md` | Comment triage: script output contract, manual GraphQL/REST fallback, thread accounting, anchor ladder, awaiting-reply rule, reply and resolve |
    | `references/bot-patterns.md` | Comment triage: reviewer detection, severity mapping, merge gates, noise markers, dedup, false positives |
    | `references/fix-plan-template.md` | Comment triage: plan file format and the legal ignore reasons |
    | `references/verification-gate.md` | Before any commit: lint, type-check, test, `knip`, stray-artifact sweep |
    | `references/git-resilience.md` | A git command hangs or fails transiently (fsmonitor wedge, stale `index.lock`, network blip) |
    | `evals/evals.json` | Only when changing this skill; never during a PR task |
    
    ## Monitor Loop
    
    Phase 1 runs once in the foreground and starts the watch. Every event or tick then runs phases 2-5, diffs against the state file, and speaks only when something changed.
    
    Copy this checklist to track progress:
    
    ```text
    PR babysit progress:
    - [ ] Phase 1: Initialize (detect PR, pick watch mechanism, snapshot state)
    - [ ] Phase 2: Conflict check
    - [ ] Phase 3: CI check (diagnose, fix, gate, push)
    - [ ] Phase 4: Comment check (triage new comments)
    - [ ] Phase 5: Readiness check (report transitions, write state file)
    ```
    
    ### Phase 1: Initialize
    
    1. `gh pr view [N] --json number,url,title,state,isDraft,headRefName,baseRefName,headRefOid,mergeable,mergeStateStatus,reviewDecision`. No PR for the branch: say so and stop.
    2. `gh repo view --json owner,name` for the calls that need `owner/repo`.
    3. Detect CI platforms from `gh pr checks --json name,link` (dispatch table in Phase 3).
    4. Pick the watch mechanism: first rung that applies.
    
    | Rung | Available when | Behaviour |
    |------|----------------|-----------|
    | Harness PR subscription | A PR-subscription tool is exposed (cloud sessions: `Claude_Code_Remote:subscribe_pr_activity`; GitHub MCP server: `github:subscribe_pr_activity`), or the web session's Auto-fix toggle is on | GitHub pushes review comments, CI failures, and check-suite success into the session. GitHub sends nothing when the base branch advances, so pair it with a slow Monitor poll (10 minutes) on `mergeStateStatus`. If the tool reports a PR Steward already watching, this session gets no events: say the PR is already covered and offer the one-shot modes instead of double-fixing |
    | Monitor tool | `Monitor` is in the tool list | Start the watch script from the monitoring reference with `persistent: true`. Quiet polls never wake the agent; each emitted line runs phases 2-5 |
    | Cron | `CronCreate` is in the tool list | `*/2 * * * *` running phases 2-5; every tick wakes the agent. Recurring tasks expire after 7 days |
    | None | Neither | Do not claim monitor mode. Run the matching one-shot mode, or say this runtime cannot keep polling |
    
    5. Snapshot state to `.claude/pr-babysitter/babysit-pr-{N}.md`: mechanism and ID, head SHA, mergeability, check states, open and awaiting-reply thread counts, review decision. This folder is never staged.
    6. Confirm in five lines: PR, watch mechanism and ID, detected CI, current state, defaults in effect.
    
    ### Phase 2: Conflict Check
    
    `gh pr view --json mergeable,mergeStateStatus`. `DIRTY` resolves; `BEHIND` updates; `UNKNOWN` means GitHub is still computing, recheck next tick; anything else moves on.
    
    ```bash
    git fetch origin {base} && git rebase origin/{base}
    git push --force-with-lease --force-if-includes
    ```
    
    - Clean rebase: push, notify.
    - Conflicts only in lockfiles, generated files, or changelogs: regenerate per the reference, continue the rebase, push.
    - Conflicts in source logic, migrations, or API contracts: `git rebase --abort`, then notify with the files and what each side changed. Human intent decides those.
    
    Bare `--force` is never used. A refused lease means someone else pushed: abort and notify rather than overwrite their commits. More than one author on the branch means a rebase rewrites their commits: merge `origin/{base}` instead.
    
    ### Phase 3: CI Check
    
    1. `gh pr checks --json name,state,bucket,link,workflow`. `bucket` is `pass`, `fail`, `pending`, `skipping`, or `cancel`; `link` is the details URL.
    2. Anything `pending`: wait. Diagnosing a half-finished run fixes the wrong thing.
    3. Every `fail`: fetch logs by platform.
    
    | Check `name` or `link` | Platform | Logs |
    |------------------------|----------|------|
    | `buildkite/` prefix | Buildkite | `bk` CLI when `bk auth status` passes, else REST with `BUILDKITE_API_TOKEN`, else hand over the `link` |
    | `vercel` in name or `vercel.com` in link | Vercel | `vercel inspect --logs {deployment_url}` (build logs; `vercel logs` is runtime) |
    | `fly-` prefix or `fly.io` in link | Fly.io | `flyctl logs --app {app} --no-tail` |
    | Anything else | GitHub Actions | `gh run view {run_id} --log-failed` |
    
    4. Classify per the reference: flaky (re-run once), stale dependency (reinstall and rebuild before touching source), code error (fix), `knip` (delete dead code or configure), infrastructure (notify; not fixable from code).
    5. Fix, run the verification gate, commit, push. Flag regressions against the previous state (was passing, now failing).
    
    **One-shot loop ("fix CI"):** after each push, `gh pr checks --watch --fail-fast` (exit 0 green, 1 a check failed, 8 still pending). Stop and summarize when checks are green, the failure is infrastructure, or the same check fails twice with the same error after a fix. Two identical failures is the signal to stop pushing, not to try a third variant.
    
    ### Phase 4: Comment Check
    
    1. Count open threads and threads awaiting my reply (newest comment not mine, in any resolution state, minus a reviewer who resolved their own last comment).
    2. Compare both counts and the newest `updated_at` across review and issue comments against the state file. An edited-in-place bot comment and a reply on a resolved thread both have to register.
    3. Any increase: notify "N new review comments on PR #{N}" and run the Comment Triage Workflow.
    
    ### Phase 5: Readiness Check
    
    1. Ready means all of: `mergeable == MERGEABLE`, every required check `pass`, `reviewDecision == APPROVED` from a review whose `commit_id` is the head SHA, zero open blocking threads, zero threads awaiting my reply, every merge gate satisfied.
    2. A merge-gate comment reading "Human review required" is a blocker to report with the criteria that forced it, not a finding to fix.
    3. Ready: notify "PR #{N} is ready to merge." Merge is a one-way door: `gh pr merge --auto` with the repo's merge method, and only when the user opted in.
    4. Not ready: name the blockers ("Waiting on: 2 checks pending", "Awaiting your answer: 2 questions from @reviewer", "Approval is stale: reviewed abc1234, head def5678").
    5. Write the state file for the next tick.
    
    ## Comment Triage Workflow
    
    Inline from Phase 4 or one-shot. Autonomous: no approval gate, the plan file is the audit trail.
    
    ### Fetch
    
    Run `${CLAUDE_SKILL_DIR}/scripts/fetch-comments.sh {N}`. It pages every thread and every thread's comments, recovers anchors, buckets threads, and computes `owedReply` against your own login. A non-zero exit prints one sentence on stderr saying why; fall back to the manual queries in the API reference only when `gh` or `jq` cannot be installed.
    
    Check `reviewers[]` before classifying: every login that spoke must appear in the output with findings, a verdict, or an explicit "no content". A reviewer with reviews but zero comments is a fetch that lost something. `anchor.source == "needs-translation"` means finish the anchor ladder against the working tree before judging that finding.
    
    Early exit only when open threads, awaiting-reply threads, actionable reviews, and actionable issue comments are all zero.
    
    ### Classify
    
    - Every inline comment from every author is read. An author absent from the bot table is unknown, not noise; noise needs a positive marker match.
    - Classify per comment, not per thread: a human reply inside a bot's thread carries full human weight.
    - Author type from content first, then login. `github-actions[bot]` is shared by reviewers and noise alike.
    - Severity from the source's own markers; unknown sources default to Major. Severity orders the queue; it never decides whether a comment is read.
    - Human intent: fix request, question, nitpick, or acknowledgement. A question gets an answer, not a code change. Human comments are never auto-ignored: fix unless the reviewer marked it optional.
    - Merge-gate verdicts are Phase 5 inputs: record, never fix, never reply, never resolve.
    - Deduplicate bots only (same path within 3 lines, keep the highest severity). A multi-location finding is one item.
    - Every ignore carries one of the legal reasons from the plan template. "Author unrecognized" and "thread already resolved" are not among them.
    
    ### Fix
    
    1. Write the plan (`references/fix-plan-template.md`) to `.claude/pr-babysitter/pr-{N}-review-plan.md`, print the counts, proceed.
    2. Ignored threads: one-line reply, then resolve.
    3. Questions: post the answer, leave the thread open. The reviewer resolves it.
    4. Resolved threads with an unanswered human reply: reply in place, do not unresolve, note it in the report.
    5. Fixes, one commit per logical group. Run the verification gate before each commit and stage only that group's files.
    6. Reply, then resolve, each fixed thread.
    7. Re-run the script and report: open threads, threads still awaiting my reply, questions answered but not yet acknowledged, and current CI status. The re-run is the evidence; "addressed everything" is not.
    
    ## Stopping
    
    - "Stop babysitting" / "cancel the monitor": read the mechanism and ID from the state file. Monitor watch: `TaskStop`. Cron: `CronDelete`. Harness subscription: the matching unsubscribe tool.
    - PR merged or closed: the watch script emits `TERMINAL` and exits; cron detects it next tick and self-cancels.
    - Session exit: watches and cron jobs are session-scoped and clean themselves up. Background tasks are not restored on `--resume`; re-run the skill.
    
    On stop, report: polls or events handled, fixes applied, conflicts resolved, comments triaged, current state.
    
    ## Gotchas
    
    - `gh pr checks --json name,state,conclusion,detailsUrl` errors: `conclusion` and `detailsUrl` belong to `gh pr view --json statusCheckRollup`. The check fields are `bucket` and `link`.
    - Filtering threads on `isResolved == false`: GitHub collapses resolved threads, so a human reply posted after the resolve is the comment most likely to go unread.
    - `comments(first: 20)`: thread comments arrive oldest first, so a truncated page hides the newest comment, the one that decides whether you owe a reply. The script pages every thread to the end.
    - `viewerDidAuthor` returned `false` on the viewer's own comments. Compare `author.login` to `gh api user --jq .login`.
    - A null `line` means outdated or multi-line, not PR-level. Only a null `path` is PR-level. Recover the anchor before deciding anything.
    - Triaging a bot's review body: the body is a count. Codex, Devin, Copilot, and Bugbot all put findings inline. Four empty-body human reviews are one review pass with its content in threads, not four reviewers with nothing to say.
    - Bots that edit one comment in place (auto-approval assessments, DangerJS) keep the same `id`, so a state diff on ids sees nothing. Compare `updated_at`.
    - Counting a review whose `commit_id` is not the head SHA as an approval: branch protection with "dismiss stale approvals" drops it on the next push, and the PR reads ready until then.
    - `vercel logs {url}` streams runtime logs. A failed build lives in `vercel inspect --logs {url}`.
    - `bk` without `bk auth status` first: keychain tokens expire and a dead token stalls the cycle. Fall through to REST, then the check `link`.
    - A monorepo type-check failure pointing into a sibling package's `dist` is usually stale build output. Reinstall and rebuild before editing source.
    - `git add -A` after a fix commits hook artifacts (a root `schema.gql`) into the PR. Sweep `git status --porcelain` and stage paths.
    - Resolving a thread without replying first: the reviewer sees a silent resolve and unresolves it.
    - A subscription-only watch never sees conflicts: GitHub emits no webhook when the base branch advances into one.
    - Cron when Monitor is available wakes the agent on every quiet tick and burns tokens for no signal. Polling under 2 minutes does the same to the GitHub rate limit.
    
    ## Related Skills
    
    - `pr-creator`: opens or edits the PR; babysitting starts after it exists
    - `planning`: writes plans a fresh session executes. The fix plan this skill writes is an audit trail for one PR, not a `planning` deliverable
    - `pr-reviewer`: local diff review for bugs; run it on monitor-authored fixes beyond a trivial patch
    - `tidy`: applies a `pr-reviewer` report to the working tree; this skill applies GitHub review comments
    - `autoship`: npm release pipelines; it watches its own release CI, so never babysit a release PR it drives
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related