From be6f6662c3dd69cf232b109b563f5f6277198fcc Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sat, 11 Jul 2026 07:37:04 -0400 Subject: [PATCH 01/12] feat: add source-control plugin (commit, pull-request, worktree skills) Migrates the medley commit, pull-request, and worktree skills into one repo-agnostic delivery plugin. De-couplings: consumer conventions (commit-msg hook, branch naming, PR template, merge style, bot identity) read from the consuming project's CLAUDE.md/rules; push-channel monitoring generalized to any GitHub-events MCP channel with Monitor-tool and gh-poll fallbacks; bundled scripts write scratch to CLAUDE_PLUGIN_DATA and carry a self-contained test-helpers.sh; babysit self-identity resolved from gh api user (+ BABYSIT_SELF_LOGINS/--self override). Co-Authored-By: Claude Fable 5 (1M context) --- .claude-plugin/marketplace.json | 6 + README.md | 1 + .../source-control/.claude-plugin/plugin.json | 12 + plugins/source-control/README.md | 98 +++++ plugins/source-control/skills/commit/SKILL.md | 136 ++++++ .../skills/pull-request/SKILL.md | 286 ++++++++++++ .../skills/pull-request/reference/babysit.md | 409 ++++++++++++++++++ .../skills/pull-request/reference/create.md | 232 ++++++++++ .../skills/pull-request/reference/merge.md | 108 +++++ .../skills/pull-request/reference/monitor.md | 400 +++++++++++++++++ .../skills/pull-request/reference/prep.md | 63 +++ .../pull-request/reference/readiness.md | 171 ++++++++ .../scripts/babysit-readiness-gate.sh | 233 ++++++++++ .../scripts/babysit-readiness-gate.test.sh | 216 +++++++++ .../pull-request/scripts/discover-prs.sh | 113 +++++ .../pull-request/scripts/discover-prs.test.sh | 121 ++++++ .../scripts/fetch-all-pr-comments.sh | 183 ++++++++ .../scripts/fetch-all-pr-comments.test.sh | 206 +++++++++ .../pull-request/scripts/fetch-annotations.sh | 179 ++++++++ .../scripts/fetch-annotations.test.sh | 298 +++++++++++++ .../pull-request/scripts/fetch-failed-logs.sh | 399 +++++++++++++++++ .../scripts/fetch-failed-logs.test.sh | 294 +++++++++++++ .../scripts/parse-branch-issue.sh | 27 ++ .../scripts/parse-branch-issue.test.sh | 43 ++ .../pull-request/scripts/test-helpers.sh | 141 ++++++ .../pull-request/templates/checklist.md | 27 ++ .../source-control/skills/worktree/SKILL.md | 117 +++++ .../skills/worktree/context/audit.md | 36 ++ .../skills/worktree/context/cleanup.md | 99 +++++ .../skills/worktree/context/create.md | 74 ++++ .../skills/worktree/context/status.md | 53 +++ 31 files changed, 4781 insertions(+) create mode 100644 plugins/source-control/.claude-plugin/plugin.json create mode 100644 plugins/source-control/README.md create mode 100644 plugins/source-control/skills/commit/SKILL.md create mode 100644 plugins/source-control/skills/pull-request/SKILL.md create mode 100644 plugins/source-control/skills/pull-request/reference/babysit.md create mode 100644 plugins/source-control/skills/pull-request/reference/create.md create mode 100644 plugins/source-control/skills/pull-request/reference/merge.md create mode 100644 plugins/source-control/skills/pull-request/reference/monitor.md create mode 100644 plugins/source-control/skills/pull-request/reference/prep.md create mode 100644 plugins/source-control/skills/pull-request/reference/readiness.md create mode 100644 plugins/source-control/skills/pull-request/scripts/babysit-readiness-gate.sh create mode 100644 plugins/source-control/skills/pull-request/scripts/babysit-readiness-gate.test.sh create mode 100644 plugins/source-control/skills/pull-request/scripts/discover-prs.sh create mode 100644 plugins/source-control/skills/pull-request/scripts/discover-prs.test.sh create mode 100644 plugins/source-control/skills/pull-request/scripts/fetch-all-pr-comments.sh create mode 100644 plugins/source-control/skills/pull-request/scripts/fetch-all-pr-comments.test.sh create mode 100644 plugins/source-control/skills/pull-request/scripts/fetch-annotations.sh create mode 100644 plugins/source-control/skills/pull-request/scripts/fetch-annotations.test.sh create mode 100644 plugins/source-control/skills/pull-request/scripts/fetch-failed-logs.sh create mode 100644 plugins/source-control/skills/pull-request/scripts/fetch-failed-logs.test.sh create mode 100644 plugins/source-control/skills/pull-request/scripts/parse-branch-issue.sh create mode 100644 plugins/source-control/skills/pull-request/scripts/parse-branch-issue.test.sh create mode 100644 plugins/source-control/skills/pull-request/scripts/test-helpers.sh create mode 100644 plugins/source-control/skills/pull-request/templates/checklist.md create mode 100644 plugins/source-control/skills/worktree/SKILL.md create mode 100644 plugins/source-control/skills/worktree/context/audit.md create mode 100644 plugins/source-control/skills/worktree/context/cleanup.md create mode 100644 plugins/source-control/skills/worktree/context/create.md create mode 100644 plugins/source-control/skills/worktree/context/status.md diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 976997747..ac4e5b9d9 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -102,6 +102,12 @@ "source": "./plugins/context7", "category": "utilities", "tags": ["context7", "documentation", "library-docs", "api-reference", "tooling", "skill"] + }, + { + "name": "source-control", + "source": "./plugins/source-control", + "category": "development", + "tags": ["delivery", "git", "github", "commit", "pull-request", "worktree", "ci", "skill"] } ] } diff --git a/README.md b/README.md index 1ed4eca93..4a848f162 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ Browse and manage with `/plugin`. To refresh after updates: `/plugin marketplace | [`prototype`](plugins/prototype) | Skills | Builds throwaway code to answer a design question before committing to architecture. Ships two skills: `/prototype:logic` (an interactive terminal app over a portable state model) and `/prototype:ui` (radically different visual variants on one route). | | [`book-distill`](plugins/book-distill) | Skill | Distills a technical book (PDF or EPUB) into concept-organized, author-attributed skill reference files through a structured multi-session read-write pipeline, updating the target skill's routing table. | | [`context7`](plugins/context7) | Skill | Looks up current library documentation, API references, and code examples via Context7 — a two-step resolve-then-query workflow over the `ctx7` CLI or the consumer's Context7 MCP server, plus an upstream drift-check `update` action. | +| [`source-control`](plugins/source-control) | Skills | Git/GitHub delivery workflow in three skills: `/source-control:commit` (Conventional Commits + Co-Authored-By trailer via safe heredoc mechanics), `/source-control:pull-request` (prep, create, CI monitoring, review-comment triage, merge, multi-PR babysit loop), and `/source-control:worktree` (create/status/cleanup/audit for parallel-session isolation). | Install one: `/plugin install @melodic-software`. diff --git a/plugins/source-control/.claude-plugin/plugin.json b/plugins/source-control/.claude-plugin/plugin.json new file mode 100644 index 000000000..07a3ae577 --- /dev/null +++ b/plugins/source-control/.claude-plugin/plugin.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", + "name": "source-control", + "version": "0.1.0", + "description": "Git and GitHub delivery workflow: /commit (Conventional Commits + Co-Authored-By trailer via safe heredoc mechanics), /pull-request (prep, create, CI monitoring, review-comment triage, merge, multi-PR babysit loop), and /worktree (create, status, cleanup, audit for parallel-session isolation).", + "author": { + "name": "Melodic Software", + "email": "info@melodicsoftware.com" + }, + "license": "MIT", + "keywords": ["git", "github", "commit", "pull-request", "worktree", "ci", "code-review", "delivery", "skill"] +} diff --git a/plugins/source-control/README.md b/plugins/source-control/README.md new file mode 100644 index 000000000..1a58382f7 --- /dev/null +++ b/plugins/source-control/README.md @@ -0,0 +1,98 @@ +# source-control + +A Claude Code plugin bundling the git/GitHub delivery workflow as three +composable skills — commit mechanics, the full PR lifecycle, and worktree +lifecycle management. + +## Skills + +### `/source-control:commit` + +Builds a commit the safe way: drafts a **Conventional Commits** subject +(11-type vocabulary, consumer convention wins), pre-checks it against the +pattern before git runs, appends a `Co-Authored-By: Claude …` trailer, and +feeds the message via Bash heredoc (`git commit -F - --cleanup=verbatim`) — +never PowerShell here-strings, never scratch files in `.git/`. Stages +surgically (`git add `, never `-A`), and supports pathspec-limited +commits when the index is shared with a concurrent session. + +### `/source-control:pull-request` + +Orchestrates the PR lifecycle with two non-negotiable gates — every review +finding is verified before it is presented, and every CI fix is +research-gated: + +- **prep** — review the branch diff (via your review agents/skills when + installed, inline otherwise), verify findings, simplify, then run the + project's build+test+lint gate as a hard block. +- **create** — branch-name check, default-branch rebase, unrelated-changes + triage, `Closes #N` derivation from the branch name (validated against the + live issue), safely-assembled PR body, `gh pr create`. +- **monitor** — async event loop over CI checks + review comments. Event + delivery prefers a push channel when your environment ships one, falls back + to a session-persistent Monitor watch (30s `gh` poll), or plain `gh` + polling in cloud sessions. CI failures are read from complete logs via the + bundled annotation/ZIP fetch scripts (`gh run view --log-failed` + truncates); every reviewer comment gets explore → research → classify → + react → reply → fix → verify-on-GitHub treatment. +- **merge** — 6-gate readiness re-verification, squash merge, worktree + reuse/cleanup, post-merge CI health check. Never auto-merges. +- **babysit** — self-pacing all-PR loop (designed for + `/loop /pull-request babysit`): discovers every open PR, checks each out, + processes every finding individually with GitHub-verified evidence, and is + mechanically gated by the bundled `babysit-readiness-gate.sh` (classification + rows must cover source findings before readiness can be declared). Never + merges. +- **fetch-logs** — tiered CI-log retrieval (annotations → full untruncated + ZIP via the REST API → per-job text). + +### `/source-control:worktree` + +Git worktree lifecycle for parallel-session isolation: `create` (guided +naming, EnterWorktree, post-create setup checks), `status` (porcelain parse, +batched PR cross-reference, staleness classification), `cleanup` +(file-lock-aware removal that never counts a Windows husk as deleted, emits +destructive branch deletion for the user), `audit` (configuration health). + +## Works in any repo + +- **Self-contained.** Everything runs on `git`, `gh`, and scripts bundled + under `${CLAUDE_PLUGIN_ROOT}`; transient CI-log scratch goes to + `${CLAUDE_PLUGIN_DATA}` (or `mktemp`). +- **Graceful degrade.** Adjacent capabilities — review agents, a simplifier, + a verify skill, a research skill, a work-item tracker, a CI-log-audit + agent, a GitHub-events push channel — are used when your environment + provides them and replaced by inline guidance when absent. No phase blocks + on a missing tool. +- **Reads your conventions, assumes none.** Commit-message convention, branch + naming, PR template, merge style, and bot-identity wrappers come from the + consuming project's own `CLAUDE.md`, rules, and hooks. Defaults ( + Conventional Commits, squash merge) apply only when the project declares + nothing. + +## Install + +```shell +/plugin marketplace add melodic-software/claude-code-plugins +/plugin install source-control@melodic-software +``` + +## Configuration + +No `userConfig`. Optional environment variables: + +| Variable | Used by | Effect | +|---|---|---| +| `WORKTREE_STALE_DAYS` | `/worktree status` | Staleness threshold (default 14 days) | +| `BABYSIT_SELF_LOGINS` | babysit readiness gate | Extra posting identities (csv) whose replies count as your classification rows — e.g. a project bot account (default: your `gh api user` login) | +| `FETCH_LOGS_SCRATCH` / `FETCH_LOGS_REPO` / `FETCH_LOGS_MAX_BYTES` | `fetch-logs` | Scratch dir, repo override, size cap for CI-log ZIPs | + +## Security + +- No hooks, no MCP servers, no telemetry, no outbound network beyond `git` + and `gh` against the repository the session already targets. +- Writes to GitHub (comments, reactions, thread resolution, PR creation, + merge) happen only inside the documented `/pull-request` phases, with the + merge decision always behind a human gate. +- Bundled scripts are read-only against the GitHub API except where the + skill body documents a write. diff --git a/plugins/source-control/skills/commit/SKILL.md b/plugins/source-control/skills/commit/SKILL.md new file mode 100644 index 000000000..5593f6228 --- /dev/null +++ b/plugins/source-control/skills/commit/SKILL.md @@ -0,0 +1,136 @@ +--- +name: commit +description: "Create a git commit with a Conventional Commits subject, a Claude Co-Authored-By trailer, and surgical staging (never `git add -A`), feeding the message to git via Bash heredoc. Use when: 'commit this', 'make a commit', 'commit with message ' — not for push, branch creation, or PR creation (use /pull-request)." +argument-hint: "[message-hint]" +user-invocable: true +--- + +## Pre-computed context + +Current branch: !`git branch --show-current 2>/dev/null || echo "unknown"` +Staged: !`git diff --cached --stat 2>/dev/null | tail -1 || echo "nothing staged"` +Unstaged: !`git status --short 2>/dev/null | head -20 || echo "clean"` +Recent commits: !`git log --oneline -5 2>/dev/null || echo "no commits"` + +## Purpose + +Encapsulates the canonical mechanic for building a commit message that honors a Conventional Commits subject convention, appending a `Co-Authored-By:` trailer, and feeding the result to `git commit` via stdin — without these failure modes: + +- **PowerShell here-string syntax (`@'...'@`) inside a Bash tool call** produces `unexpected EOF` and triggers fallback to writing the message to `.git/.txt`. `.git/` is git's internal directory; scratch files there collide with `COMMIT_EDITMSG` and other internals. +- **`git commit -m ""`** flattens newlines unpredictably across shells. +- **`git add -A` / `git add .`** stages secrets, build artifacts, unrelated edits — the convention is surgical staging. + +The default subject convention (WHAT shape a subject must take) is **Conventional Commits (11-type vocabulary)**. Every subject must match this anchored pattern: + +```text +^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?!?: .+ +``` + +Compliant examples: + +- `feat(auth): add OAuth login flow` +- `fix(api): handle null user in /me endpoint` +- `docs: clarify rebase guidance` +- `refactor(skills)!: rename /simplify to /code-review` + +**Consumer convention wins.** If the consuming project's own `CLAUDE.md`, rules, or commit-msg hook declare a different message convention, follow that instead — this skill's pattern is the default, not an override. When the project enforces its convention with a commit-msg git hook (lefthook, husky, commitlint, plain `.git/hooks/`), that hook is the authoritative gate; this skill's pre-check exists to fast-fail client-side before the hook round-trip. + +## Task + +1. Survey working tree (`git status`, `git diff --cached --stat`) to confirm what's staged. +2. Draft a subject + optional body, scoped to the staged diff, shaped to satisfy the active subject convention (default: the Conventional Commits pattern above). +3. Pre-check the subject against the pattern (fast-fail before invoking git). +4. Invoke `git commit -F -` via the Bash tool, heredoc-piped, with `--trailer` for `Co-Authored-By` per the trailer template below. +5. Surface the resulting commit SHA + subject to the user. + +## Canonical bash form + +Use the **Bash tool**, not the PowerShell tool. Bash heredoc is the canonical form across all platforms (Git Bash, Linux, macOS). + +```bash +git commit -F - --cleanup=verbatim \ + --trailer "" \ + <<'EOF' + + + +EOF +``` + +The `--trailer` argument body is this template: + +```text +Co-Authored-By: Claude () +``` + +Fill the `` / `` placeholders from session knowledge before passing the string to `--trailer` (see "Trailer model + context lookup" below). + +PowerShell here-string is shown only for reference — `git commit -F - ` with `@'...'@` would work in a pure PowerShell tool call, but **never mix syntaxes inside one tool invocation**. A PowerShell `@'...'@` block inside a Bash tool call leaves the bash parser unable to terminate the heredoc. + +**Hard rules:** + +- Invoke via the **Bash tool**. +- Heredoc delimiter is single-quoted: `<<'EOF' ... EOF`. Single quotes prevent `$variable` and backtick interpolation inside the message. +- `--cleanup=verbatim` preserves the message exactly — no auto-stripping of comments or whitespace. +- Never write the message to `.git/.txt`. If a real file is unavoidable, use `mktemp`. +- Never mix Bash heredoc with PowerShell `@'...'@` inside one invocation. + +## Pre-check + +Before invoking `git commit`, regex-match the drafted subject against the active convention's pattern (default: the Conventional Commits pattern above). + +On mismatch, surface the convention name and the compliant examples as actionable guidance, and ask for a compliant subject before invoking git. The pre-check is shape-only; the project's `commit-msg` git hook (when one exists) is the authoritative gate at commit time. The pre-check exists to save the hook-startup floor when the drafted subject is obviously wrong, plus to give the user a fast actionable error rather than an opaque hook failure. + +## Trailer model + context lookup + +The trailer body is `Co-Authored-By: Claude () `. Fill the `` and `` placeholders from your own knowledge of the running session — e.g. model = `Opus 4.8` or `Fable 5`, context = `1M context`. If uncertain, invoke `/usage` to confirm before committing. + +There is no environment variable that auto-fills these — the trailer is part of the message body sent to `git commit`, not git config. Hardcoding stale values is worse than asking; the trailer becomes a git-history claim about which model / context authored the change. If the consuming project's conventions specify a different attribution trailer (or none), follow those. + +## Unrelated uncommitted changes + +If the working tree contains unstaged or untracked files that fall outside this commit's scope, classify each before staging per `/pull-request create` (its unrelated-changes classification: include / stash / separate-commit / discard). Do not duplicate that classification here; invoke `/pull-request create` to surface it to the user. + +## Staging discipline + +Always `git add `, never `git add -A` or `git add .`. The risk is including secrets, build artifacts, or unrelated changes that the user did not approve for this commit. If multiple files are intentionally part of the commit, stage them by explicit list, not by wildcard. + +## Pathspec-limited commits (dirty shared index) + +When the index already holds staged files OUTSIDE this commit's scope — concurrent Claude Code sessions on the same branch, pre-existing mixed WIP — a bare `git commit` would sweep them all in. Instead, limit the commit by pathspec: + +```bash +git commit -F - --cleanup=verbatim \ + --trailer "" \ + -- [...] <<'EOF' + + + +EOF +``` + +Semantics (per `git-commit(1)` default `--only` mode): the commit records the **working-tree content** of the named paths, disregarding what is staged for all OTHER paths — concurrent-session staged work stays staged, untouched. Untracked files still need `git add` first; pathspec alone never picks them up. + +**Safety preconditions — all required before offering this path:** + +- Every named path is fully this commit's work — no overlap with another session's in-flight scope (when unsure which session owns a file, ask). +- For each named path, working tree == intended content (pathspec commits the worktree version, silently superseding any different staged version of that same path). +- Verify scope with `git diff --cached --stat -- ` and surface that stat in the review gate — the user greenlights exactly what the pathspec captures. +- A directory pathspec (`-- path/to/dir/`) is acceptable only after confirming via `git status --porcelain -- ` that nothing under it belongs to another scope; otherwise enumerate files. + +Default remains the plain index commit; reach for the pathspec form only when the index is verifiably shared/dirty. + +## Composition policy + +This skill is the single source of truth for the commit mechanic. Other skills should compose `/commit` by natural-language reference rather than invoking `git commit` directly — direct calls bypass the pre-check, trailer logic, and surgical-staging discipline this skill exists to enforce. + +Workflow skills without explicit commit semantics should report status at phase boundaries and let the user or the next workflow stage decide commit timing; a skill with commit semantics in its documented contract (e.g. `/pull-request create`) composes this one. + +## What this skill does NOT do + +- **No `git push`** — that's `/pull-request create`. +- **No branch creation** — that's the project's branch-naming / branch-protection mechanisms (or `/worktree create`). +- **No PR body composition** — that's `/pull-request create`. +- **No `git merge` / `gh pr merge`** — that's `/pull-request merge`. +- **No rebase** — that's `/pull-request create`. +- **No `--no-verify` or hook bypass** — if the project's `commit-msg` hook rejects the message, surface the error and re-draft; never bypass. diff --git a/plugins/source-control/skills/pull-request/SKILL.md b/plugins/source-control/skills/pull-request/SKILL.md new file mode 100644 index 000000000..78ceaf2b3 --- /dev/null +++ b/plugins/source-control/skills/pull-request/SKILL.md @@ -0,0 +1,286 @@ +--- +name: pull-request +description: "Orchestrate the full PR lifecycle: prep (review + verify), create, monitor CI + review comments, merge, fetch CI logs, and babysit all open PRs in a self-pacing loop. Use when: 'create pr', 'ship it', 'pr prep', 'fix CI', 'address comments', 'monitor PR', 'babysit PRs', 'merge this', 'check pr status' — not for branch/worktree lifecycle (use /worktree) or committing without a PR (use /commit)." +user-invocable: true +disable-model-invocation: false +argument-hint: " [args] (e.g., /pull-request prep, /pull-request create, /pull-request monitor, /pull-request merge, /pull-request full, /pull-request status)" +--- + +## Pre-computed context + +Current branch: !`git branch --show-current 2>/dev/null || echo "unknown"` +Recent commits: !`git log --oneline -5 2>/dev/null || echo "no commits"` +Working tree status: !`git status --porcelain 2>/dev/null || echo "clean"` +Changed files (staged+unstaged): !`git diff --name-only HEAD 2>/dev/null || echo "none"` + +## Purpose + +Orchestrate the PR lifecycle from quality review through merge and cleanup, with smart state detection and resume capability. + +**The two non-negotiable gates:** + +1. **Finding verification** (prep phase) — agent review findings have a demonstrated error rate. Every finding is verified against current docs and actual code before being presented to the user. +2. **Research-gated CI fixes** (monitor phase) — no code fix without researched multi-source consensus on the root cause. Unresearched "obvious" CI fixes are how wrong fixes ship. + +## Adapting to your environment (graceful degrade) + +This skill is self-contained: it runs on `git`, `gh`, and its own bundled scripts (under `${CLAUDE_PLUGIN_ROOT}/skills/pull-request/scripts/`). Where a phase names an adjacent capability — a code-review skill or agents, a simplifier, a build/test/lint verifier, an external research skill, an exploration skill, a work-item tracker, a CI-log-audit agent, a GitHub-events push channel — treat it as **optional**: if your environment provides it (a skill, plugin, agent, or MCP server), invoke it; otherwise proceed with the inline guidance, which stands on its own. Never block a phase because an adjacent tool is absent. + +Consumer conventions come from the consuming project's own `CLAUDE.md` and rules — notably: PR body template, branch naming, merge style (this skill defaults to squash), review-reply identity (some projects post bot-identity replies via a wrapper; default is plain `gh`), and any extra pre-PR gates. Read them before creating or merging. + +## Emit checklist + +For PR lifecycle runs spanning 3+ phases, copy `${CLAUDE_PLUGIN_ROOT}/skills/pull-request/templates/checklist.md` into your project's working-notes location (or track it inline) and tick each `- [ ]` as the phase produces its output. Stateful surface; survives `/clear`. + +## Arguments + +`$ARGUMENTS` — action selector: + +| Action | Entry point | Use case | +|--------|-------------|----------| +| *(empty)* | Smart default | Detect current state, resume from right phase | +| `prep` | Phase 1 | Review + verify findings + simplify + verify build | +| `prep quick` | Phase 1 (fast) | Code errors only, skip simplify | +| `prep review-only` | Phase 1 (partial) | Just review + verify findings | +| `prep simplify-only` | Phase 1 (partial) | Just simplify + re-verify | +| `create` | Phase 2 | Branch-name check + commit + push + `gh pr create`. Reports the PR URL and stops | +| `monitor` | Phase 3 | Watch CI, fix failures, evaluate comments. **Three-tier event delivery: (1) push channel** when your environment ships a GitHub-events channel (an MCP server delivering webhook events into the session) — ~0 idle requests; **(2) Monitor tool** fallback (30s `gh` poll); **(3) plain `gh` polling** in cloud/headless sessions. Check the push channel FIRST per [monitor.md](reference/monitor.md) §3.0.05 before falling back | +| `comments` | Phase 3.5 | Evaluate/respond to PR comments only | +| `merge` | Phase 4 | Squash merge + worktree cleanup + verify | +| `status` | Report only | Unified status across all phases | +| `full` | Phase 1-4 | Run prep → create → monitor → merge end-to-end | +| `fetch-logs [--raw\|--job ]` | CI log retrieval | Pull failed-CI evidence: default = `::error`/`::warning` annotations only (cheapest); `--raw` = full ZIP dump for archive review; `--job ` = per-job plain text | +| `babysit` | Phase 3+ (all-PR loop) | Discover all open PRs → checkout each branch → run monitor per-iteration checklist → fix valid bot findings → move to next. Designed for a self-pacing loop (`/loop /pull-request babysit`). Never merges. See [reference/babysit.md](reference/babysit.md) | + +## Action defaults + +- **Merge mode:** `merge` squash-merges (one squashed commit per PR onto the default branch) — see [reference/merge.md](reference/merge.md) §4.2. Follow the consuming project's convention when it differs. +- **Monitor cadence:** `monitor` polls `gh pr checks` and comment fetches every 30 seconds — see [reference/monitor.md](reference/monitor.md) §3.1. +- **Required reviewers:** `create` requests no reviewers (runs `gh pr create` without `--reviewer`) — see [reference/create.md](reference/create.md) §2.4.3. + +## PR identity resolution + +PR identity (number, URL) resolves **live via `gh` CLI** at every phase that needs it — `gh` is the authoritative source. No caching, no state files. + +**Standard pattern** (used in every phase): + +```bash +PR_NUMBER=$(gh pr view --json number -q '.number') +``` + +When `gh pr view` (no positional arg) is ambiguous — multiple open PRs, stale checkout, returning days later — pass the branch explicitly: + +```bash +PR_NUMBER=$(gh pr view "$(git branch --show-current)" --json number -q '.number') +``` + +After resolving once at phase entry, **pass `` explicitly to all subsequent `gh` calls** within that phase (`gh pr checks `, `gh pr merge `, etc.). Never rely on bare-branch resolution mid-phase. + +**Why explicit PR numbers?** `gh pr view` (no args) resolves by HEAD branch — fragile when: the worktree was cleaned up (branch context lost), multiple open PRs exist (wrong match), returning days later (stale checkout), or another session's PR merged first. Resolving once at phase entry and threading the number through bounds this risk to one point per phase. + +--- + +## Phase 0: Parse action and detect state + +Parse `$ARGUMENTS` to extract the action (first token) and any sub-arguments. + +**Smart default** (empty args): read live state via `gh` to determine the right phase: + +```text +1. Check git branch — on the default branch? → "Create a worktree or branch first" +2. Resolve PR for current branch: + gh pr view --json state,number 2>/dev/null + a. exit non-zero → no PR → continue to step 3 (Phase 1 prep) + b. state = MERGED → skip to Phase 4.3 (cleanup only — pull default branch, delete branch, prune) + c. state = CLOSED → report "PR was closed without merging" and stop + d. state = OPEN → capture pr_number, continue to step 4 +3. Check CI status (gh pr checks ) — still running? → start at monitor +5. Check for unaddressed comments → start at monitor (comments sub-phase) +6. CI green + comments addressed → suggest merge +``` + +Present detected state and proceed to the appropriate phase. In interactive mode, announce which phase is starting. In autonomous mode (`full`), proceed without pausing — phase transitions are not decision points. + +**Status action**: query `gh pr view --json number,url,state` for PR number/URL, then report current state across all phases. + +--- + +## Phases + +Execute in order. Each phase is self-contained — read the relevant file for detailed steps: + +| Phase | File | Entry actions | +|-------|------|--------------| +| 1. Prep | [reference/prep.md](reference/prep.md) | `prep`, `prep quick`, `prep review-only`, `prep simplify-only` | +| 2. Create | [reference/create.md](reference/create.md) | `create` | +| 3. Monitor | [reference/monitor.md](reference/monitor.md) | `monitor`, `comments` | +| 3+. Babysit | [reference/babysit.md](reference/babysit.md) | `babysit` | +| 4. Merge | [reference/merge.md](reference/merge.md) | `merge` | + +--- + +## Monitor entry checklist (MANDATORY — execute in order before ANY monitoring work) + +When entering Phase 3 (`monitor`, `comments`, or `full` reaching monitor), complete EVERY step below. Do NOT skip to CI polling or comment evaluation. + +- [ ] **Step 0 — Checkout the PR source branch (DEFAULT):** monitoring a PR means working ON its head branch — exploration, research, and any fix must run against the PR's actual code, not whatever branch you happen to be on. Resolve the head branch (`gh pr view --json headRefName -q .headRefName`) and check it out. This is the default, not an exception. + - **Pre-check `git worktree list`:** if the branch is already checked out in another worktree, work there (or process read-only — no fix — if you can't). If you're already on the PR branch, no-op. + - **Dirty tree with unrelated WIP** (staged/unstaged/untracked from other work): do NOT switch — surface the WIP to the user and proceed read-only. Never `git stash` another session's WIP. + - **Interactive session** (human present): changing branches re-points the working tree, so confirm the target branch with the user FIRST — UNLESS the invoking message already named the checkout (invoking `/pull-request monitor ` against a specific PR is intent, but the target-branch confirmation gate still governs the mechanical switch). + - **Autonomous session** (e.g. `CLAUDE_CODE_REMOTE=true`): check out without prompting. + - **Babysit** runs its own per-PR checkout (babysit §5.1.2 Step 0.2) — this Step 0 is the single-PR `monitor` equivalent; don't double-checkout when reaching here from babysit. +- [ ] **Step 1 — Cloud check:** if `CLAUDE_CODE_REMOTE=true`, use §3.0.0 `gh` polling. Skip remaining steps +- [ ] **Step 2 — Push-channel gate (§3.0.05):** if your environment ships a GitHub-events push channel (an MCP server that delivers webhook events into the session), verify it is healthy per its own docs and this skill's §3.0.05 guidance (broker alive, subscriber fresh). No channel available → skip to Step 3's fallback +- [ ] **Step 3 — Arm event delivery:** channel healthy → arm its PR filter for ``; channel absent/unhealthy → arm the §3.0.1 Monitor tool watch +- [ ] **Step 4 — Proceed to §3.1 monitoring loop** + +**Why this exists:** event-delivery setup gets skipped in practice — the model reads the action table and jumps straight to `gh` polling. The checklist in this always-loaded surface prevents the skip. + +## Per-iteration monitoring checklist (MANDATORY — on every CI/comment event) + +When a channel event, Monitor notification, or poll iteration fires, complete ALL applicable steps before declaring readiness or reporting status. + +- [ ] **A — Terminal state:** `gh pr view --json state -q .state` — MERGED/CLOSED → self-terminate +- [ ] **B — CI checks:** `gh pr checks ` — classify EVERY non-pending check (pass/fail/skipped). Read logs for ANY failure per §3.1 fetch chain +- [ ] **C — Fetch ALL comments from ALL sources:** read every update on the PR regardless of author or format. Three API surfaces + reviews: + - [ ] C1 — Review-thread comments: `gh api repos///pulls//comments --paginate` + - [ ] C2 — Issue-level comments: `gh api repos///issues//comments --paginate` (includes AI-review summaries, user replies, bot task-completion posts) + - [ ] C3 — PR reviews: `gh api repos///pulls//reviews --paginate` (review bodies contain findings — APPROVED/CHANGES_REQUESTED/COMMENTED reviews all may carry actionable content) + - [ ] C4 — Read every comment body in full. Summaries and review posts from ANY AI agent (claude[bot], codex, cursor, copilot) contain findings that require classification — these are NOT informational. **Extract individual findings** per [babysit.md](reference/babysit.md) §5.0.4 — one comment with N findings = N work items, each needing individual D1-D7. **For ≥3 findings, MANDATORY subagent dispatch** per §5.0.4 — preserves main session context, structurally enforces per-finding ledger shape +- [ ] **D — For EACH unaddressed **finding** (not comment — one comment may contain multiple findings):** + - [ ] D1 — Read full finding context (parent comment body + surrounding findings). For multi-finding comments dispatched to a subagent (§5.0.4), this work is in the subagent; the main session receives the ledger + - [ ] D2 — Explore referenced code (must be on the PR branch for accurate results) + - [ ] D3 — **Validate the claim** before trusting: verify the assertion against actual code, run the command, check the file. Research non-trivial claims against official docs. Never implement a fix based solely on a bot's assertion — confirm it is correct first + - [ ] D4 — Classify: VALID (fix now) / VALID (defer) / INCORRECT / UNCERTAIN. Classification MUST cite evidence from D2-D3 + - [ ] D4.5 — React to the parent comment: `+1` VALID, `-1` INCORRECT, `eyes` UNCERTAIN (via `gh api .../reactions`). One reaction per comment. Mixed findings: `+1` if any VALID. Verify the reaction posted via a GET on the same endpoint — non-zero confirms + - [ ] D5 — Reply with a per-finding classification table + evidence (before fixing). **Route by comment type — REQUIRED, not interchangeable:** inline review comments MUST reply THREADED via `gh api repos///pulls//comments//replies`; issue-level / review-level → `gh pr comment `. Answering an inline finding with a detached `pr comment` is a routing error, not a style choice. Use the project's bot-identity wrapper for these writes when it has one; plain `gh` otherwise + - [ ] **Verify reply exists:** `gh api repos///issues//comments --jq '.[].body'` — confirm the reply text appears on GitHub + - [ ] D6 — Fix if VALID (fix now) — edit, `git add `, commit, push + - [ ] **Verify commit pushed:** `gh api "repos///commits?sha=&per_page=1" --jq '.[0].sha'` — confirm the fix commit SHA appears on the remote + - [ ] D7 — Post a follow-up reply citing the fix commit SHA + - [ ] **Verify follow-up reply posted:** `gh api repos///issues//comments --jq '.[-1].body'` — confirm the follow-up with SHA appears on GitHub + - [ ] D7.5 — Resolve review thread — **author-conditional, inline only**. Resolve threads opened by a BOT reviewer that you addressed. NEVER resolve HUMAN-authored threads (the human resolves their own). NEVER resolve your OWN (your posting identity — bot or personal). Detect bot via the API surface in use — REST `user.type==Bot`; GraphQL `author.__typename==Bot` (resolution runs via GraphQL). Verify `isResolved == true` via GraphQL +- [ ] **E — Readiness gate:** ALL checks terminal + ALL comments addressed + 2-min cooldown since last activity per [readiness.md](reference/readiness.md) +- [ ] **F — Report:** present the full readiness table OR list remaining blockers + +**Receiving an event is NOT processing it.** Each event must drive at LEAST steps A-C. New comment events must drive D1-D7 for that comment. Declaring "ready to merge" without completing E is a checklist violation. + +## Babysit per-PR checklist (MANDATORY — each PR within babysit loop) + +When running the `babysit` action, execute these steps for EACH PR discovered. The monitor entry checklist and per-iteration checklist apply per-PR — babysit wraps them in a multi-PR orchestration loop. + +- [ ] **Step 0 — PR discovery:** `gh pr list` filtered (skip draft, oldest-first). See [reference/babysit.md](reference/babysit.md) §5.0.2 +- [ ] **Step 0.1 — Evidence-based fresh rescan:** fetch ALL comments via the bundled `fetch-all-pr-comments.sh`, classify each as addressed/unaddressed by checking GitHub for substantive replies with classification + evidence. GitHub is the source of truth, not model memory. See §5.0.3 +- [ ] **Step 0.2 — Branch checkout:** `git fetch origin ` then `git checkout `. MANDATORY before any comment investigation — exploration and research must run against PR branch code. Pre-check `git worktree list` — if the branch is checked out elsewhere, process read-only (no fix). See §5.1.2 +- [ ] **Step 0.3 — Branch freshness:** `git fetch origin ` then `git merge-base --is-ancestor origin/ HEAD`. If behind: integrate (merge vs rebase per the project's convention and the branch's own history — see §5.1.2), resolving conflicts conservatively. Report status: current/rebased/conflict-attempting/conflict-aborted +- [ ] **Steps 1-4 — Monitor entry checklist** (above) — run per-PR. A push channel re-arms its PR filter for each PR +- [ ] **Steps A-F — Per-iteration monitoring checklist** (above) — run per-PR. Extract individual findings from each comment per §5.0.4 (one comment with N findings = N work items). **For any comment with ≥3 findings, MANDATORY subagent dispatch** per §5.0.4. Run D1-D7 per-finding, not per-comment. Verify each action landed on GitHub (D4.5/D5/D6/D7/D7.5 verification gates) +- [ ] **Step 5 — Commit + push** fixes on the PR branch. Clean working tree. Post follow-up replies with commit SHAs (D7) +- [ ] **Step 6 — PR transition:** advance to the next-oldest PR needing attention (round-robin). See §5.1.6 +- [ ] **Step 7 — Self-pace:** call `ScheduleWakeup` per the cadence table in §5.3 after all PRs are processed + +**Babysit NEVER merges.** Readiness gate pass → report ready → move to next PR. The user merges via `/pull-request merge` or `gh pr merge` manually. + +**Execution discipline:** babysit's primary failure mode is claiming to process findings without actually running per-finding D1-D7. Every iteration MUST output a completed checklist with evidence per step (see [babysit.md](reference/babysit.md) §5.5). "Done" means GitHub shows evidence — model memory of "I replied" or "I pushed" is not evidence. Re-query the API to verify each action landed. + +--- + +## Full lifecycle (`/pull-request full`) + +Run Phase 1 → Phase 2 → Phase 3 → Phase 4 as a continuous flow. Phase transitions are automatic — don't pause between phases except at **decision gates** where the outcome could vary, plus one interactive-only checkpoint at the create→monitor boundary. + +**Create→monitor checkpoint (`full` only):** + +After Phase 2 reports the PR URL, detect session mode: + +- **Interactive** (no autonomous-session marker like `CLAUDE_CODE_REMOTE=true`): ask the user whether to proceed to Phase 3 (monitor) in this session. Acceptable responses: proceed (continue to Phase 3) / stop (end after create) / handoff (end; another session/routine will pick up monitoring). Default on no-response is stop. +- **Autonomous** (`CLAUDE_CODE_REMOTE=true` or equivalent): no prompt; continue to Phase 3 without pausing. There is no user to ask. + +Standalone `create` (not invoked inside `full`) always stops after Phase 2 — see [reference/create.md](reference/create.md) §2.6. + +**Decision gates (pause for user):** + +| Gate | Why it needs input | +|------|-------------------| +| Prep findings have VALID fixes | User decides which to fix vs defer | +| Commit message content | User may want different wording | +| Create→monitor (interactive only, `full` mode) | User may want to hand off monitoring to another session/routine | +| CI failure fix proposal | Fix approach has multiple options | +| Merge confirmation | Irreversible action | + +**NOT gates (proceed automatically):** + +| Transition | Just do it | +|-----------|-----------| +| Prep complete → create | Obvious next step | +| All [readiness gates](reference/readiness.md) pass → suggest merge | Report with full readiness verdict | +| Comment classified INCORRECT → react + reply | Evidence already gathered | +| Fix pushed → re-monitor | New push = new cycle | + +**NEVER auto-proceed on these (even in `full` mode):** + +| Condition | Why it's NOT a gate pass | +|-----------|------------------------| +| CI green + no comments yet | Reviewers may not have posted — cooldown required | +| CI green + failing security scan | Security findings MUST be evaluated before merge | +| CI green + unclassified failures | Every FAILURE needs explicit classification | + +In a non-interactive context (cloud session, CI action), minimize gates to merge-only. + +--- + +## Fetch CI logs (`/pull-request fetch-logs [--raw|--job ]`) + +Public action for retrieving failed-CI evidence. Tiered fetch chain — cheapest signal first; escalate only when the lower tier is insufficient. The skill body chooses which internal helper to invoke based on flags; consumers describe WHAT they want and the skill picks HOW. + +**Behaviors:** + +| Invocation | What it returns | When to use | +|------------|-----------------|-------------| +| `fetch-logs ` (default) | `::error::` + `::warning::` annotations across all failed jobs | First-pass — usually enough to identify the cause | +| `fetch-logs --failed` | Annotations from failed jobs only | When the run has many jobs and noise is a concern | +| `fetch-logs --raw` | Full GitHub Actions log ZIP, dumped in scope | When annotations are sparse / missing — debug-grade detail | +| `fetch-logs --job ` | Plain-text log of one job | Targeted dive after seeing which job failed | + +`` and `` are interchangeable inputs — the skill resolves the latest run for a PR when given a PR number. + +**Composition with `monitor`:** `monitor` invokes this action internally on CI failure. Direct `fetch-logs` invocation is for ad-hoc post-mortem (e.g., reviewing a closed PR's CI failure, auditing a green run for warnings). + +**Implementation note:** the skill body delegates to the bundled `fetch-annotations.sh` and `fetch-failed-logs.sh` scripts. Those are private — consumers MUST NOT cite script paths directly. Use this action. + +--- + +## Important notes + +- **Side effects** — this skill commits, pushes, creates PRs, and merges. User approval gates at each dangerous step (commit message, CI fix, merge) provide safety — the skill itself enforces human checkpoints +- **Finding verification is non-negotiable** — agent recommendations have demonstrated error rates. Skipping verification presents potentially wrong advice +- **Research-driven fixes** are the entire point of the monitor phase. The cost of a short research burst is near-zero; the cost of an unresearched fix is high +- **Max 3 CI fix iterations** — prevents infinite fix-push-fail loops +- **Findings triage**: + - **Bot comments classified CORRECT** (Codex, claude-review, etc. — after evidence-based verification against actual code): **auto-fix + test + push + react 👍 + reply in the same turn**. No user-approval pause. The safety gate is the CORRECT/INCORRECT classification, not a separate confirmation. Applies when the fix is small and scoped (<~50 LOC, single concern); pause for cross-cutting refactors even when CORRECT + - **Bot comments classified INCORRECT**: autonomous 👎 reaction + reply with research-backed counter-evidence. Never silently ignore + - **Human reviewer comments**: always pause for user approval before reacting or fixing, regardless of classification +- **Docs-only changes skip the review/simplify work** — no code review needed for markdown/config-only PRs; the verify gate reduces to lint. Any extra project-specific prep-evidence requirements come from the consuming project's own hooks + +--- + +## Gotchas + +Failure patterns encountered in real sessions. Add to this section when new gotchas are discovered. + +- **Agent review findings are wrong by default.** Validation of one review batch found 0/5 specific fixes were correct. Every finding MUST be verified against current docs and actual code before presenting. Never skip the 1.3 verification step +- **Never guess at CI failure causes.** Use monitor.md §3.2's prioritized fetch chain: annotations → full ZIP via REST API → `gh run view --log-failed` as last resort. `gh run view --log-failed` truncates at the CLI display layer (~4MB cap, cli/cli #11059 #10551 #7771); the script-based paths return complete data. Do NOT use broad keyword grep (`error|fail|...`) — false matches from cleanup steps, variable names, and incidental output +- **OIDC-based workflows fail when the PR modifies the workflow file.** The workflow file must match the default branch for OIDC token exchange to succeed. GitHub limitation — classify as informational when it applies +- **`gh pr view` without a PR number is fragile.** Branch-based resolution fails when: the worktree is cleaned up, multiple PRs exist for the branch, or returning days later. Resolve `` once at phase entry (per "PR identity resolution" above) and pass it explicitly to every subsequent `gh` call. No state file — `gh` is authoritative +- **Squash merge needs `git branch -D`, not `-d`.** After squash merge, the local branch commit doesn't appear in the default branch's history (different SHA). `-d` says "not fully merged." `-D` is safe because you already confirmed the merge +- **`git add -A` and `git add .` are banned.** Risk of committing secrets, build artifacts, or unrelated changes. Always `git add ` +- **Bot comments need reactions AND replies.** React (👍/👎/👀) on every substantive comment AND reply with a per-finding classification table with evidence. Don't skip any reviewer — each gets individual attention. Verify BOTH the reaction and the reply landed on GitHub via API query. After fixing, resolve the thread IF bot-authored — never human-authored, never your own (D7.5, author-conditional) +- **Monitor is async, not serial.** Process comments as they arrive while CI is still running. Don't wait for all checks to complete before reading comments — bots post at different times +- **Check mergeable BEFORE polling CI.** `gh pr view --json mergeable` — if `CONFLICTING`, GitHub won't trigger workflows. Integrate the default branch first, then poll +- **Never merge with unclassified FAILURE check runs.** Every FAILURE must be investigated, classified (real failure vs informational), and documented before merge is even suggested. See [readiness.md](reference/readiness.md) for the full 6-gate checklist +- **"No comments" does NOT mean "ready."** Comment-only actors post at unpredictable times. A 2-minute cooldown after the last check-run completion or comment arrival prevents the race condition. See readiness.md Gate 5 +- **Security scans are always blocking.** Any check run or bot comment reporting security findings (secrets, vulnerabilities) triggers mandatory triage — even if the finding is a false positive, it must be explicitly classified and documented before merge +- **Discover actors, don't hardcode them.** Security tools and AI reviewers change over time. Monitor discovers actors from `gh pr checks` and PR comments, classifies them by category (CI, security, review), and evaluates accordingly +- **Uncommitted changes are silently lost on branch deletion.** `git reflog` cannot recover uncommitted edits — only commits. Before staging (Phase 2.3.1) and before post-merge cleanup (Phase 4.3), check `git status --porcelain` for unrelated uncommitted changes. Stash them (`git stash push -m "desc" -- `) — stashes survive branch deletion. Never silently ignore uncommitted changes +- **Cloud sessions use `gh` polling, not event subscription.** Autonomous cloud sessions (`CLAUDE_CODE_REMOTE=true`) poll `gh pr checks` + `gh api` on a fixed 60-90s cadence (§3.0.0 of monitor.md) +- **Monitor MUST check the push channel FIRST, then fall back.** Three-tier hierarchy on local CLI sessions: (1) **push channel** (when your environment ships a GitHub-events MCP channel — ~0 idle requests), (2) **Monitor tool** (session-persistent `Monitor(persistent: true, ...)` watch; 30s `gh` poll fires on real CI/comment events; cancel via `TaskStop`), (3) fixed-interval cron polling (deprecated — wasteful). Do NOT skip straight to the Monitor tool without checking for a channel — polling wastes ~1 request per 30s interval vs ~0 idle with push delivery diff --git a/plugins/source-control/skills/pull-request/reference/babysit.md b/plugins/source-control/skills/pull-request/reference/babysit.md new file mode 100644 index 000000000..6363f2e9f --- /dev/null +++ b/plugins/source-control/skills/pull-request/reference/babysit.md @@ -0,0 +1,409 @@ +# Phase 3+: Babysit (all-PR continuous loop) + +Multi-PR orchestration layer wrapping the existing single-PR monitor infrastructure. Designed for `/loop /pull-request babysit` (dynamic, self-pacing via ScheduleWakeup). Processes every open PR in the repo — discovers, checks out, monitors, fixes, moves to next. Never merges. + +## 5.0 Focus-first rule + +**Process the oldest PR with unaddressed comments to completion before advancing to the next PR.** "Completion" = every comment on that PR has been: read in full, code explored, claim investigated, classified (VALID/INCORRECT/UNCERTAIN), reacted to, replied to with evidence, and fixed if VALID. Only after ALL comments on the current PR are resolved, move to the next oldest. + +A shallow survey of all PRs is NOT babysitting. Reporting "bot findings need classification" without classifying is NOT babysitting. Babysit means actively working each comment. + +### 5.0.1 Iteration entry — round-robin flow + +Each `/loop` wake-up runs one full babysit iteration. Round-robin from oldest to newest: + +1. **Discover** all open PRs (§5.0.2) +2. **Focus** the oldest PR with unaddressed comments or failing CI +3. **Checkout** the PR branch (§5.1.2) — mandatory for accurate exploration + research +4. **Process** all current comments on that PR (one wave — §5.1.3 checklist) +5. **Commit + push** fixes on the PR branch (§5.1.4) +6. **Advance** to the next-oldest PR needing attention — repeat steps 3-5 +7. **Skip** PRs with all comments addressed + CI green + no new activity +8. **Park** on the home branch after all PRs are processed (§5.2) +9. **Schedule** the next wake (§5.3) + +Keep circling — each iteration processes one wave per PR. New CI results and review comments from pushed fixes are picked up on the next iteration. + +### 5.0.2 PR discovery + +```bash +gh pr list --state open --limit 200 --json number,title,headRefName,isDraft,author \ + --jq '[.[] | select(.isDraft == false)] | sort_by(.number)' +``` + +Oldest-first (FIFO) — lowest PR number processed first. + +Deterministic equivalent: `bash "${CLAUDE_PLUGIN_ROOT}/skills/pull-request/scripts/discover-prs.sh"` runs this exact filter (open, skip draft, oldest-first; `--prs-json ` for offline/testing). + +**Filters:** + +- Skip `isDraft` PRs (signal "not ready for review") +- Every open PR is in scope regardless of author or label — Dependabot included. A Dependabot PR with failing CI needs the same diagnose-and-fix attention as any other; auto-merge (where configured) only fires once CI is green, so babysit owns the red ones + +**Zero-PR fast path:** if discovery returns an empty list, report `No open non-draft PRs need attention.` and call `ScheduleWakeup(delaySeconds=1200, reason="no open PRs", prompt="/pull-request babysit")`. Exit the iteration. + +### 5.0.3 Evidence-based fresh rescan + +Every iteration rescans ALL comments on every non-terminal PR. GitHub is the source of truth — not model memory, not prior-iteration state, not comment counts. + +**Why full rescan:** compaction loses prior-iteration classification state. Comment-count heuristics miss edits, deletions, and multi-finding comments. An evidence-based fresh rescan from GitHub every iteration defeats both failure modes. + +**Per-PR rescan flow:** + +1. **Terminal check** — `gh pr view --json state -q '.state'`. MERGED/CLOSED → skip +2. **CI check** — `gh pr checks --json bucket -q '[.[] | .bucket] | unique'` +3. **Fetch ALL comments** — run `bash "${CLAUDE_PLUGIN_ROOT}/skills/pull-request/scripts/fetch-all-pr-comments.sh" ` to retrieve every comment from all 3 API surfaces (review-thread, issue-level, PR reviews). Full bodies, not counts +4. **Filter out own prior replies** — comments authored by your own posting identities (`gh api user --jq .login`, plus any project bot identity — the same set the readiness gate's `--self` / `BABYSIT_SELF_LOGINS` covers) that ARE classification replies (contain the `| # | Finding | Classification |` table pattern) are NOT findings — skip them. Own follow-up replies citing commit SHAs are also not findings. Only process comments from OTHER authors as potential finding sources +5. **Classify each remaining comment** as "addressed" or "unaddressed" by checking GitHub for evidence: + - **Addressed (skip)** — the comment has a substantive reply (from ANY author) containing BOTH: (a) a classification token (VALID, INCORRECT, or UNCERTAIN), AND (b) evidence (code reference, test output, or reasoning) + - **Unaddressed (process)** — no reply meeting both criteria. "Noted" or "will fix" without classification + evidence does NOT count +6. **Extract findings** per §5.0.4 — one comment may contain multiple work items + +**Needs attention when ANY of:** + +- CI has `fail` / `pending` / `in_progress` bucket entries +- Any comment has unaddressed findings (per the classification above) + +**Skip when ALL of:** + +- State is terminal (MERGED/CLOSED) +- All checks pass/skipping AND zero unaddressed findings + +PRs not needing attention are reported in a one-line status summary and skipped. + +### 5.0.4 Structured finding extraction + +AI review summaries (claude[bot], codex, cursor, etc.) and detailed human reviews often pack multiple findings into a single comment — markdown tables, numbered lists of severity items, or multi-paragraph analyses. Each finding is a separate work item requiring individual D1-D7. + +**Extraction rules:** + +- One comment with N findings = N entries in the work item list +- Each finding gets its own D1-D7 cycle (read, explore, validate, classify, reply, fix, follow-up) +- Findings are tracked individually — addressing 3 of 5 findings in a comment means 2 remain unaddressed +- Reply with a per-finding classification table (not one blanket reply for the whole comment) + +**Finding identification signals:** + +- Numbered items with severity labels (CRITICAL, IMPORTANT, SUGGESTION, P1/P2/P3) +- Markdown table rows with file/line/description columns +- Bullet lists where each bullet describes a distinct code concern +- Multiple `###` sub-headings each addressing different files or concerns + +**Per-finding classification table format** (reply on the comment): + +```text +| # | Finding | Classification | Evidence | Reacted | +|---|---------|---------------|----------|---------| +| 1 | | VALID — fixing | | 👍 | +| 2 | | INCORRECT | | 👎 | +| 3 | | VALID (defer) | | 👍 | +``` + +The reaction is per-comment (GitHub allows one reaction type per user per comment). Post the reaction BEFORE the reply — reviewers scanning a PR see 👍/👎 at a glance without expanding threads. + +**MANDATORY subagent dispatch for multi-finding comments (≥3 findings):** + +When a single PR comment packs 3+ findings, dispatch a finding-extractor subagent rather than attempting inline extraction. The subagent: + +1. Preserves main session context — large comment bodies + per-finding investigation evidence stay in the subagent's context window; only the structured ledger returns +2. Structurally enforces the per-finding work-item shape — the subagent returns a fixed-schema ledger; missing entries trigger main-session escalation +3. Is scope-fenced — ALLOWED: read PR-branch files + `gh api` against the specific PR; FORBIDDEN: edits, commits, pushes, reactions, replies on GitHub (those stay in the main session) + +**Subagent dispatch prompt (compose verbatim, substitute `` and `` / ``):** + +```text +Extract individual findings from the multi-finding bot/human review at: + https://github.com///pull/#issuecomment- + (or pull/#pullrequestreview-) + +ALLOWED scope (read-only on PR branch ): +- `gh api repos///issues//comments` and per-id endpoints +- `gh api repos///pulls//{comments,reviews}` and per-id endpoints +- `Read` / `Grep` / `Glob` against the repo working tree +- `Bash` for git inspection (`git show`, `git log`, `git diff`) — NEVER state-mutating + +FORBIDDEN: +- Any Edit / Write of repo files +- Any `git add` / `git commit` / `git push` +- Any reaction / reply / comment POST to GitHub +- Any Skill invocation other than read-only exploration + +Return a SINGLE markdown ledger with this exact shape (one row per finding): + +| # | Severity | File:Line | Finding (≤120 chars) | Validation status | Evidence | Suggested classification | +|---|---|---|---|---|---|---| +| 1 | CRITICAL | path/to/file.cs:42 | | VERIFIED — code matches claim | | VALID — fix now | +| 2 | IMPORTANT | path/to/file.cs:73 | | INCORRECT — code already does X | | INCORRECT | +| 3 | SUGGESTION | path/to/file.md:12 | | UNCERTAIN — behavior depends on Y | | UNCERTAIN | + +CRITICAL constraints on the ledger: +- Severity column MUST match the parent comment's severity labels verbatim (CRITICAL / IMPORTANT / SUGGESTION / P1 / P2 / P3) +- Validation status MUST come from your own code reading, not a paraphrase of the bot claim +- Evidence MUST cite line numbers + verbatim snippets (≤3 lines) OR direct command output +- Suggested classification MUST be one of: VALID — fix now | VALID (defer) | INCORRECT | UNCERTAIN +- One row per finding. If the parent comment has 6 findings, the ledger has 6 rows. No collapsing. + +If the parent comment is genuinely single-finding, return a 1-row ledger anyway. + +Report ONLY the ledger + a one-line summary count ("Extracted N findings: X CRITICAL, Y IMPORTANT, Z SUGGESTION"). No prose framing. +``` + +**Main-session contract after the subagent returns:** + +1. Receive the ledger. Verify the row count matches the source comment's finding count (independent count via grep on the parent comment body for severity markers) +2. For each ledger row, the main session runs D4.5 (react) + D5 (reply with the per-finding sub-row from the ledger) + D6 (fix if VALID — fix now) + D7 (follow-up SHA) with verification gates between each step +3. The subagent ledger is the D1-D4 work product. The main session NEVER skips D4.5-D7 by trusting the ledger alone — the ledger feeds the work, it doesn't replace it + +**Single-finding comments** (1-2 findings): inline extraction in the main session is fine; subagent overhead is not warranted. + +**Why a subagent for ≥3 findings:** empirically, multi-finding comments treated as single work items in the main session produce near-zero per-finding D1-D7 cycles — dozens of findings glossed in one pass. Subagent dispatch structurally forces the per-finding shape because the ledger contract demands it. + +**Mechanical enforcement (gate, not prose):** advisory "MANDATORY" wording alone still under-decomposed in practice. So enforcement is a gate: `babysit-readiness-gate.sh ` (run at §5.1.3 step E) counts source findings (severity markers in reviewer comments) vs classification rows (VALID/INCORRECT/UNCERTAIN in your replies) and exits non-zero when rows < findings. The subagent-dispatch rule above tells you HOW to decompose; the gate enforces THAT you did — readiness cannot be declared while it reports `READINESS_BLOCKED`. + +## 5.1 Per-PR processing + +For each PR needing attention (oldest first): + +### 5.1.1 Event-delivery gate + +Run the **Monitor entry checklist** from SKILL.md — the identical 4-step sequence: + +1. Cloud check (skip if `CLAUDE_CODE_REMOTE=true`) +2. Push-channel gate (§3.0.05) — when your environment ships one +3. Arm event delivery for this PR (channel PR filter, or Monitor watch) +4. Proceed to monitoring + +A push channel arms for ONE PR at a time. Re-arm for each new PR in the loop. + +### 5.1.2 Branch checkout (MANDATORY for accurate exploration) + +(`main` below — substitute the repo's default branch.) + +```bash +# Pre-check: is branch checked out in another worktree? +BRANCH="" +if git worktree list | grep -q "\[$BRANCH\]"; then + echo "Branch $BRANCH checked out in another worktree — processing read-only" + CHECKOUT_MODE="read-only" +else + git fetch origin "$BRANCH" + git fetch origin main + # Ensure clean working tree + index before checkout + if [ -n "$(git status --porcelain)" ]; then + git reset --hard HEAD + git clean -fd + fi + git checkout "$BRANCH" + + # Branch freshness — rebase if behind main + if ! git merge-base --is-ancestor origin/main HEAD; then + echo "Branch $BRANCH is behind origin/main — rebasing" + if git rebase origin/main; then + REBASE_STATUS="rebased" + git push --force-with-lease origin "$BRANCH" + else + # Graduated conflict handling — attempt simple, abort complex + CONFLICT_COUNT=$(git diff --name-only --diff-filter=U | grep -c . || true) + if [ "$CONFLICT_COUNT" -le 3 ]; then + echo "Simple conflict ($CONFLICT_COUNT files) — attempting resolution" + REBASE_STATUS="conflict-attempting" + else + echo "Complex conflict ($CONFLICT_COUNT files) — aborting rebase" + git rebase --abort + REBASE_STATUS="conflict-aborted" + fi + fi + else + REBASE_STATUS="current" + fi + + if [ "$REBASE_STATUS" = "conflict-attempting" ] || [ "$REBASE_STATUS" = "conflict-aborted" ]; then + CHECKOUT_MODE="read-only" + else + CHECKOUT_MODE="full" + fi +fi +``` + +**Rebase conflict handling (graduated).** Check for merge commits first (`git log --merges origin/main..HEAD`) — a branch that previously merged main integrates via `git merge origin/main`, not rebase. Then: + +- **Zero conflicts** (`REBASE_STATUS=rebased`) — rebase succeeded, force-push with lease, continue normally +- **Simple conflicts** (≤3 files, `REBASE_STATUS=conflict-attempting`) — attempt resolution; if ANY file requires intent judgment, abort to conflict-aborted +- **Complex conflicts** (>3 files, `REBASE_STATUS=conflict-aborted`) — abort the rebase, post a PR comment: `"⚠️ Branch is behind main with merge conflicts ({N} files). Manual rebase required before CI will trigger."`. If an interactive terminal, also surface to the user directly. Process comments read-only (classification + reply, no fixes — the code may be stale) +- **Already current** (`REBASE_STATUS=current`) — no action needed + +**Why mandatory:** exploration and research read files from the working tree. Without checkout, findings are validated against the wrong code. Branch freshness prevents CI failures from stale code and ensures conflict detection happens proactively. + +**Read-only mode:** investigate comments, explore referenced code via `git show origin/:`, research claims, classify, reply with evidence — the full D1-D5 workflow. Only D6-D7 (edit + commit + push + follow-up reply) are blocked. Read-only is NOT passive — every comment still gets investigated and replied to. Fixes that can't be pushed are described in the reply with exact code changes so the user or the PR's own worktree session can apply them. + +**Full mode:** full flow including the fix cycle (D1-D7). Commit and push on the PR branch after each wave of fixes. + +### 5.1.3 Per-finding D1-D7 with verification gates + +D steps operate **per-finding**, not per-comment. One comment with 5 findings = 5 individual D1-D7 cycles. Must be on the PR branch (§5.1.2) before starting. + +- [ ] **A** — Terminal state check (`gh pr view --json state`) +- [ ] **B** — CI checks — classify every non-pending check (pass/fail/skipped) +- [ ] **C** — Fetch ALL comments and extract findings: + - [ ] C1 — Run the bundled `fetch-all-pr-comments.sh ` (all 3 API surfaces) + - [ ] C2 — Read every comment body in full + - [ ] C3 — Extract individual findings per §5.0.4 + - [ ] C4 — Build the work-item list: one entry per finding, each needing D1-D7 +- [ ] **D** — For EACH unaddressed **finding** (not comment): + - [ ] D1 — Read full finding context (parent comment body + surrounding findings) + - [ ] D2 — Explore referenced code on the PR branch + - [ ] D3 — **Validate the claim** — verify against actual code before trusting. Research non-trivial claims + - [ ] D4 — Classify with evidence: VALID (fix now) / VALID (defer) / INCORRECT / UNCERTAIN + - [ ] D4.5 — React to the parent comment via `gh api .../reactions`. One reaction per comment (not per finding). **Tiebreaker for mixed-finding comments:** `+1` if ANY finding is VALID (signals action taken), `-1` only when ALL are INCORRECT, `eyes` when all UNCERTAIN or a mix of UNCERTAIN + INCORRECT with zero VALID + - [ ] **verify reaction exists:** GET the same reactions endpoint filtered by your posting identities — non-zero confirms. Use `pulls/comments//reactions` for inline review comments + - [ ] D5 — Reply with the per-finding classification table + evidence (before fixing). Table format per §5.0.4 — includes the Reacted column. **Route the reply by comment type — REQUIRED, not interchangeable:** inline review comments (diff-anchored, `pulls/comments`) MUST reply THREADED via `gh api repos/{owner}/{repo}/pulls//comments//replies -f body='...'` so the reply lands under the source thread — NEVER a detached `pr comment`. Issue-level / review-level comments (no thread) → `gh pr comment --body '...'`. Use the project's bot-identity wrapper for these writes when it has one. Answering an inline finding with a detached issue comment orphans the reply from the thread the reviewer tracks — a routing error, not a style choice + - [ ] **verify reply exists:** `gh api repos/{owner}/{repo}/issues//comments --jq '.[].body'` — confirm the reply text on GitHub. For inline replies: `gh api repos/{owner}/{repo}/pulls//comments --jq '.[] | select(.in_reply_to_id == )'` + - [ ] D6 — Fix if VALID → edit, `git add `, commit, push + - [ ] **verify commit pushed:** `gh api "repos/{owner}/{repo}/commits?sha=&per_page=1" --jq '.[0].sha'` — confirm the fix commit SHA on the remote + - [ ] D7 — Post a follow-up reply citing the fix commit SHA + - [ ] **verify follow-up reply posted:** `gh api repos/{owner}/{repo}/issues//comments --jq '.[-1].body'` — confirm the follow-up with SHA on GitHub + - [ ] D7.5 — Resolve review thread — **author-conditional** (canonical policy: SKILL.md D7.5), inline review comments only. Resolve ONLY threads whose OPENING comment is authored by a BOT reviewer that you addressed. NEVER resolve HUMAN-authored threads — the human resolves their own after verifying the fix. NEVER resolve your OWN threads (any of your posting identities — same self set as §5.0.3 step 4). Skip issue-level comments (no thread). **Thread author = login of the THREAD-OPENING comment** (replying into it does not change the author). **Bot detection is API-surface-specific:** resolution runs via GraphQL (the threadId fetch), where bot authors have `author.__typename == "Bot"` and `login` omits the `[bot]` suffix; REST surfaces show the suffix. When fetching the threadId, also select `author{__typename login}` to apply the conditional in one query + - [ ] **verify thread resolved:** query the thread node via `gh api graphql` — `isResolved` must be `true` +- [ ] **E** — Readiness gate. Run `bash "${CLAUDE_PLUGIN_ROOT}/skills/pull-request/scripts/babysit-readiness-gate.sh" ` — exit 0 `READINESS_OK` is REQUIRED to proceed. Exit 1 `READINESS_BLOCKED reason=under-decomposed` means classification rows < source findings → decompose + classify the missing findings, then re-run. THEN confirm: all checks terminal + 2-min cooldown +- [ ] **F** — Per-finding classification table + readiness report (see §5.5) + +**"Done" means GitHub shows evidence.** A per-finding work item is addressed only when the verification sub-step confirms the action landed on GitHub. Model memory of "I posted a reply" is not evidence — re-query the API. + +### 5.1.4 Fix cycle (full mode only) + +When on the PR branch AND a comment is classified VALID after D3 validation: + +- [ ] Edit code to fix the issue +- [ ] `git add ` (never `-A` or `.`) +- [ ] `git commit -m ": "` +- [ ] `git push` +- [ ] Post a follow-up reply citing the commit SHA (D7) + +**One wave at a time:** address all current comments on this PR → commit + push → then round-robin to the next PR. Don't jump between PRs mid-wave. After pushing, new CI runs trigger — those results are checked on the next babysit iteration (or the next round-robin pass if processing multiple PRs). + +**Re-review trigger after a fix push:** bots that reviewed the PR may need an explicit trigger to re-evaluate fixes. After pushing, check each bot's trigger mode per readiness.md "Expected PR actors": + +- **"On every push" trigger** — re-reviews automatically, just wait +- **Manual/smart trigger** (e.g., Codex) — post `@codex review` (or the bot's equivalent) as a PR comment to request a re-review + +Per monitor §3.2: research-gate non-trivial fixes (multi-source consensus). Max 3 CI fix iterations per PR per babysit pass. Inline-vs-subagent choice for CI log fetching per monitor.md "Inline vs subagent dispatch decision". + +### 5.1.5 Human comments + +Classify but DO NOT auto-fix. Reply with investigation findings per step D. Note: D4.5 reactions proceed autonomously for human reviewer comments (no approval gate — babysit runs without a user present). This differs from monitor.md step 4, which pauses for approval in interactive sessions. Report to the user in the babysit iteration output — human review items are surfaced, not silently skipped. + +### 5.1.6 PR done — transition to next + +When the readiness gate passes OR all actionable items are handled for this PR: + +1. If on a PR branch with uncommitted changes from a failed fix: `git reset --hard HEAD` then `git clean -fd` (unstage + revert tracked + remove untracked) +2. Report PR status (ready / blockers remaining / items deferred to human) +3. Move to the next PR in the discovery list + +## 5.2 Parking + +After all PRs are processed (or none needed attention), return to the worktree's home branch. Record at iteration start: + +```bash +PARKING_BRANCH=$(git rev-parse --abbrev-ref HEAD) +``` + +After processing all PRs: + +```bash +git checkout "$PARKING_BRANCH" +``` + +## 5.3 Self-pacing (ScheduleWakeup) + +At the end of each iteration, schedule the next wake based on observed state: + +| Condition | Delay | Reason | +|-----------|-------|--------| +| Active events flowing (CI running, fresh comments arrived during this iteration) | 60s | Stay responsive to in-flight activity | +| PRs exist but all currently quiet (no new events, no pending checks) | 270s | Check back soon without idle churn | +| No PRs need attention (all ready, all terminal, or zero open PRs) | 1200s | Long idle — conserve request budget | + +```text +ScheduleWakeup( + delaySeconds: , + reason: "", + prompt: "/pull-request babysit" +) +``` + +## 5.4 NEVER-do list + +These constraints override any other instruction within babysit mode: + +- **Never declare readiness or schedule the next wake without a passing `babysit-readiness-gate.sh ` run** (exit 0 `READINESS_OK`). The gate counts classification rows vs source findings and blocks under-decomposition. "I classified them" is not evidence — the gate exit code is. See §5.1.3 step E +- **Never survey-and-report without investigating** — every unaddressed comment gets D1-D7 (read, explore, validate, classify, reply, fix, follow-up). "Bot findings need classification" without classifying is a violation +- **Never trust a finding without validating** — bot/AI assertions have demonstrated error rates. Always verify against actual code (D3) before implementing. Explore the referenced code; research non-trivial claims +- **Never process comments from the wrong branch** — must be on the PR branch before D2-D3. Exploring code on the default branch or another branch produces wrong classifications +- **Never advance to the next PR with unaddressed comments on the current PR** — focus-first rule (§5.0). Complete the current wave before moving on +- **Never skip AI review summaries** — AI-reviewer posts (issue-level comments with severity-labeled findings) are actionable comments requiring D1-D7. Same for every AI reviewer +- **Never `gh pr merge`** — babysit declares readiness; the user merges +- **Never `git add -A` or `git add .`** — specific files only +- **Never auto-fix human reviewer comments** — classify + reply + report to the user +- **Never skip the event-delivery gate** — run the Monitor entry checklist for every PR +- **Never exceed 3 CI fix iterations** per PR per babysit pass +- **Never leave uncommitted changes** on a PR branch when transitioning to the next PR +- **Never skip emoji reactions** — every classified finding gets a reaction on its parent comment (+1 VALID, -1 INCORRECT, eyes UNCERTAIN). Reactions are the fastest audit signal for reviewers scanning a PR +- **Never skip the branch freshness check** — always `git fetch origin ` + `git merge-base --is-ancestor origin/ HEAD` after checkout. Stale branches cause CI failures; proactive integration is cheaper than a reactive fix. See §5.1.2 +- **Never skip reply verification** — after posting a reply (D5) or follow-up (D7), verify it landed on GitHub via API query. Model memory of "I replied" across compaction is not evidence +- **Never skip resolving a BOT-authored thread; never resolve a HUMAN or OWN thread** — after fixing + replying to an inline review comment opened by a bot reviewer, resolve that thread (D7.5, author-conditional). Leave HUMAN-authored threads for the human to close; never resolve your own. Open bot-thread count is a visible signal to reviewers +- **Never process your own prior replies as findings** — filter out comments from your own posting identities that match the classification reply pattern. See §5.0.3 step 4 + +## 5.5 Checklist-driven output format + +Every iteration MUST output a completed checklist with evidence per step. Free-form narrative reports are not acceptable — they hide skipped steps. + +**Gate-enforced:** readiness requires a passing `babysit-readiness-gate.sh ` run (§5.1.3 step E). To mechanically gate checklist completeness too, write this iteration's checklist to a file in your working-notes location and pass `--checklist ` — the gate exits non-zero while any `- [ ]` box is unticked, so an incomplete checklist cannot be declared "ready". + +```text +## Babysit iteration [] + +### A. PR Discovery +- [ ] Fetched open PRs: total, needing attention, skipped +- [ ] Processing order (oldest first): #, #, ... + +### B. Per-PR Processing + +#### PR # (<branch>) +- [ ] **Branch:** checked out <branch> (mode: full/read-only) +- [ ] **Branch freshness:** <current/rebased/conflict-attempting/conflict-aborted> — evidence: `git merge-base` output +- [ ] **CI:** <pass/fail/pending> — evidence: `gh pr checks <N>` output +- [ ] **Comments fetched:** <N> total from all 3 API surfaces (<M> self-replies filtered) +- [ ] **Findings extracted:** <M> individual findings from <K> comments + +##### Per-finding classification table +| # | Source | Finding | Classification | Evidence | Reacted | Action | +|---|--------|---------|---------------|----------|---------|--------| +| 1 | comment <id> | <summary> | VALID | <evidence> | 👍 | Fixed: <sha> | +| 2 | comment <id> | <summary> | INCORRECT | <evidence> | 👎 | Replied | +| 3 | comment <id> | <summary> | UNCERTAIN | <evidence> | 👀 | Deferred | + +##### Verification evidence +- [ ] All reactions verified on GitHub: YES/NO +- [ ] All replies verified on GitHub: YES/NO +- [ ] All commits verified pushed: YES/NO +- [ ] All follow-ups verified posted: YES/NO +- [ ] All addressed BOT-authored inline threads resolved (human + own threads excluded): YES/NO/N/A + +##### PR status +- [ ] Readiness: ready for merge / <remaining blockers> + +### C. Iteration Summary +- [ ] All PRs processed: YES/NO +- [ ] Parked on home branch: YES +- [ ] **Next wake:** <delay>s — <reason> +``` + +Every `- [ ]` must be ticked `- [x]` with evidence before the iteration ends. Unticked boxes = incomplete iteration — do not schedule the next wake until addressed or explicitly deferred with reason. + +## 5.6 Performance notes + +- **Do not skip verification steps.** The D5/D6/D7 verification sub-steps exist because model memory is unreliable across compaction boundaries. One API call to confirm costs seconds; acting on false memory costs an entire re-processing cycle +- **Quality over speed.** Processing 3 findings thoroughly with verified evidence is better than "processing" 10 findings with blanket classifications and no verification +- **One finding at a time.** Complete per-finding D1-D7 for finding N before starting finding N+1. Interleaving findings across comments produces partial work that looks complete but isn't +- **Evidence-based state, not memory-based state.** Never say "I already replied to that" — check GitHub. Never say "I already pushed that fix" — check the remote. GitHub is the state store; this session's memory is ephemeral diff --git a/plugins/source-control/skills/pull-request/reference/create.md b/plugins/source-control/skills/pull-request/reference/create.md new file mode 100644 index 000000000..abbea541e --- /dev/null +++ b/plugins/source-control/skills/pull-request/reference/create.md @@ -0,0 +1,232 @@ +# Phase 2: Create (commit + push + PR) + +## 2.1 Pre-flight + +1. **Prep completed?** Prep produces no state file — its outputs (verified findings + clean verify-gate results) live in conversation context. If neither has been run this session, suggest `/pull-request prep` first; in `full` mode this phase is preceded by prep automatically. Skip the review prompt for docs-only PRs (Phase 1.1 skips review/simplify). +2. **Changes exist?** `git status --porcelain` must show changes or commits ahead of remote. +3. **Not on the default branch?** If on it, suggest a branch/worktree. +4. **Branch naming?** If the branch name doesn't fit the project's convention (common default: `<type>/<kebab-description>`; Claude Code's auto-created worktree branches may be named `worktree-*`), rename before push: `git branch -m <old> <type>/<description>`. Derive `<type>` from commit content (feat/fix/chore/etc.) and `<description>` from the commit subject. If no commits exist yet (empty branch), prompt the user for a branch name — auto-derivation has no input without commits. Present the rename for awareness, not approval. +5. **Worktreeinclude file sync?** If in a worktree, check for modified gitignored files that won't survive worktree removal. These files were copied at worktree creation via `.worktreeinclude` — changes made during the session exist only in the worktree and will be lost on cleanup. + + **Detection:** + + ```bash + # Am I in a worktree? + GIT_COMMON=$(git rev-parse --git-common-dir 2>/dev/null) + GIT_DIR=$(git rev-parse --git-dir 2>/dev/null) + if [[ "$GIT_COMMON" != "$GIT_DIR" ]]; then + MAIN_ROOT=$(git worktree list | head -1 | awk '{print $1}') + # Read .worktreeinclude patterns (one per line, .gitignore syntax) + while IFS= read -r pattern; do + [[ -z "$pattern" || "$pattern" == \#* ]] && continue + # For each matching file, diff worktree vs main + for f in $pattern; do + [[ -f "$f" && -f "$MAIN_ROOT/$f" ]] && diff -q "$f" "$MAIN_ROOT/$f" >/dev/null 2>&1 || echo "CHANGED: $f" + done + done < .worktreeinclude + fi + ``` + + **If differences found:** + + 1. Show diff for each changed file (`diff --unified "$MAIN_ROOT/$f" "$f"`) + 2. Show active worktrees (`git worktree list`) — if >1 worktree exists beyond main, warn: *"Other active worktrees have their own copies of this file. Overwriting main's copy won't affect existing worktrees but will affect future ones."* + 3. Present options per file: + - **Copy to main** — overwrite main's copy with worktree's version. Safe for cosmetic changes (reordering), new additions, or when this is the only active session + - **Skip** — proceed without syncing. User accepts that worktree changes will be lost on cleanup + 4. If user chooses "copy to main": `cp "$f" "$MAIN_ROOT/$f"` + + **Why here (not WorktreeRemove hook):** this is the last intentional checkpoint where user is engaged and can inspect a diff. WorktreeRemove hooks cannot block removal or prompt — a silent copy could overwrite concurrent session changes. One mechanism per concern. + + **Skip conditions:** not in a worktree, no `.worktreeinclude` file exists, no differences found. + +## 2.2 Rebase onto the latest default branch + +Ensure the branch is current with the default branch before committing and pushing. Prevents merge conflicts and stale-branch CI failures. (`main` below — substitute the repo's default branch.) + +```bash +git fetch origin main +MERGE_BASE=$(git merge-base HEAD origin/main) +ORIGIN_MAIN=$(git rev-parse origin/main) + +if [ "$MERGE_BASE" != "$ORIGIN_MAIN" ]; then + BEHIND=$(git rev-list --count HEAD..origin/main) + echo "Branch is $BEHIND commit(s) behind origin/main. Rebasing..." + git rebase origin/main +fi +``` + +**Prefer `git merge origin/main` over rebase when the branch already contains a merge commit** (`git log --merges origin/main..HEAD` non-empty) — replaying pre-merge commits produces avoidable conflict slogs, and under squash-merge linear branch history buys nothing. + +**If conflicts occur:** resolve conservatively — take both sides where independent, pause and present to the user whenever intent is unclear. `git rebase --abort` / `git merge --abort` when resolution needs judgment you don't have. + +**Skip conditions:** branch has zero commits ahead (nothing to rebase), or merge-base already equals `origin/main` (branch is current). + +## 2.3 Stage and commit + +### 2.3.1 Unrelated uncommitted changes check (MANDATORY) + +Before staging, run `git status --porcelain` and classify every modified/untracked file as either **PR-related** or **unrelated**. Unrelated changes are files modified during the session that don't belong in this PR — pre-existing edits from other sessions, hook auto-fixes, exploratory changes, or work from a different task. + +**Why this matters:** After merge, branch gets deleted. Uncommitted changes on that branch are lost forever — `git reflog` cannot recover uncommitted edits, only commits. `git stash` survives branch deletion (stashes stored in `.git/refs/stash`, not tied to branches), but only if stash is created before checkout/deletion. + +**If unrelated uncommitted changes exist**, present them and offer options: + +| Option | When to use | Command | +|--------|-------------|---------| +| **Include in PR** | Changes are small, related enough, and won't pollute the PR | Stage them with the PR files | +| **Stash** | Changes should be preserved but don't belong in this PR | `git stash push -u -m "unrelated: <description>" -- <files>` | +| **Separate commit** | Changes are valuable and self-contained — commit on this branch as a separate commit (squash merge collapses anyway) | `git add <files> && git commit -m "chore: <description>"` | +| **Discard** | Changes are throwaway (build artifacts, experimental edits) | `git checkout -- <files>` | + +**Default recommendation:** stash with a descriptive message. Use `-u` to include untracked files — without it, `git stash push -- <files>` silently skips untracked files (`pathspec did not match`). Stashes persist across branch switches and deletion, and `git stash list` shows them from any branch. User can `git stash pop` after switching to a new branch. + +**Never silently ignore uncommitted changes.** Agent must either include them, stash them, or get explicit user confirmation to discard. Silent data loss is the worst outcome. + +### 2.3.2 Stage and commit PR changes + +Stage specific files (never `git add -A`). Then invoke `/commit` (this plugin's sibling skill) for the commit step — it handles message drafting, the Conventional Commits regex pre-check, the `Co-Authored-By` trailer, and the canonical bash heredoc form. **Wait for user approval on the proposed commit message inside `/commit`.** Do NOT bypass `/commit` by invoking `git commit` directly from this phase — the canonical bash mechanic + trailer + sanity-check are encapsulated there. + +**When NOT to delegate:** if `/commit` is unavailable (e.g. skill discovery broken), inline the same heredoc form (`git commit -F - --cleanup=verbatim <<'EOF' ... EOF`) and proceed — but note the fallback to the user. + +## 2.4 Push, create PR, and persist PR number + +### 2.4.0 Resolve linked issue(s) + +Before building PR body, parse branch for primary issue number and prompt for any additional closures. Keyword line is injected at top of body in §2.4.1. + +```bash +ISSUE_NUM=$(bash "${CLAUDE_PLUGIN_ROOT}/skills/pull-request/scripts/parse-branch-issue.sh" 2>/dev/null || true) +CLOSES_LINE="" +if [[ -n "$ISSUE_NUM" ]]; then + # Validate issue exists in current repo BEFORE shipping `Closes #N`. + # GitHub auto-close only fires when the issue exists, lives in this repo, + # and is open at merge time. A typo'd branch like + # `feat/99999-foo` would otherwise ship a misleading `Closes #99999` line. + if gh issue view "$ISSUE_NUM" --json state,number >/dev/null 2>&1; then + CLOSES_LINE="Closes #${ISSUE_NUM}" + else + echo "⚠ Branch suggests Closes #${ISSUE_NUM} but no such issue in this repo. Falling back to interactive prompt." >&2 + ISSUE_NUM="" # fall through to orphan-PR 3-option prompt below + fi +fi +# If still empty, the orphan-PR prompt populates CLOSES_LINE below. +``` + +**Single-issue branch:** parser returns `N` from `<type>/<N>-<slug>` (and `chore/routine-issue-<N>-<slug>` for cloud routines). When `gh issue view` confirms issue exists, `${CLOSES_LINE}` becomes `Closes #N`. If issue doesn't exist (typo / wrong repo / closed-and-deleted), flow falls through to orphan-PR prompt — never ship an unverified keyword. + +**Multi-issue PR (same branch closes 2+ issues):** after primary line is set, ask user inline: + +> *"This PR closes #N. Any other issues to close on merge? List them one per line (`Closes #X`), use `Refs #Y` to link without closing, or `no` to skip."* + +Append each accepted line to `${CLOSES_LINE}` (newline-separated). GitHub accepts one keyword per issue, comma- or newline-separated. + +**Branch lacks issue number (orphan PR — drift sweep, hotfix, refactor):** prompt with three options: + +1. `Closes #<N>` — provide a number to auto-close on merge +2. `Refs #<N>` — link without closing +3. `No related issue: <reason>` — orphan PR, no linkage + +Persist chosen line(s) into `${CLOSES_LINE}`. NEVER wrap a closing keyword in an HTML comment — `<!-- Closes #N -->` is parsed as a valid keyword and will auto-close the issue on merge. Fenced code blocks ARE inert, so example snippets are safe. + +### 2.4.1 Push and assemble PR body + +```bash +git push -u origin <branch-name> +``` + +Derive PR title from commit (Conventional Commits format). Build body with `${CLOSES_LINE}` at top, followed by Summary + Test plan + Claude Code attribution: + +```bash +# Quoted heredoc — body template is inert; nothing inside expands. +# Safe even if surrounding template prose contains $vars or $(cmds). +TEMPLATE=$(cat <<'EOF' +## Summary +... + +## Test plan +- ... +EOF +) + +# Concat CLOSES_LINE in front of TEMPLATE via bash parameter expansion. +# Parameter expansion of "${VAR}" does NOT re-evaluate the expanded value +# — if CLOSES_LINE contains literal "$(rm -rf ~)" (e.g. user typed it into +# the orphan-PR or multi-issue prompt), it stays a literal string and is +# never executed. This is the defense against shell injection through +# user-supplied prompt input. +BODY="" +[[ -n "$CLOSES_LINE" ]] && BODY="${CLOSES_LINE}"$'\n\n' +BODY+="$TEMPLATE" +``` + +**Why quoted heredoc + concat (not `<<EOF`):** unquoted heredoc `<<EOF` evaluates `$(...)`, `${...}`, and `` `...` `` *inside the body content itself* (POSIX heredoc semantics — `<<EOF` is treated as if double-quoted). If `${CLOSES_LINE}` ever contains shell-meta from interactive prompt input, an unquoted heredoc would execute it. Quoted `<<'EOF'` is inert; splicing `${CLOSES_LINE}` via parameter expansion + concat keeps user input as literal text. + +`gh pr create --body` fully overrides `.github/PULL_REQUEST_TEMPLATE.md` (cli/cli #10751) — body assembly above is the canonical path for skill-driven PRs; the template is the web-UI backstop. When the consuming project ships a PR template, mirror its section shape in the assembled body. + +### 2.4.2 Verify closing-keyword line (pre-create gate) + +Before invoking `gh pr create`, grep assembled `$BODY` for a valid closing keyword OR an opt-out marker. Catches branches where §2.4.0 fell through (issue-existence check failed without orphan-PR prompt running, user dismissed the prompt, `$CLOSES_LINE` is empty) and prevents shipping a PR with no linkage signal. + +```bash +# Case-insensitive — covers ALL 9 valid keywords (close/closes/closed/fix/ +# fixes/fixed/resolve/resolves/resolved) with optional colon, per GitHub's +# linked-issues docs. The 3-keyword shortcut (Closes|Fixes|Resolves) +# misses 6 valid forms GitHub auto-close honors. +KEYWORD_REGEX='^(close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved):? #[0-9]+' +OPTOUT_REGEX='^(Refs #[0-9]+|No related issue:)' + +if printf '%s\n' "$BODY" | grep -iE "$KEYWORD_REGEX" >/dev/null; then + : # closing keyword present — gate passes +elif printf '%s\n' "$BODY" | grep -E "$OPTOUT_REGEX" >/dev/null; then + : # explicit opt-out present — gate passes +else + # No closing keyword AND no opt-out marker. §2.4.0's orphan-PR prompt + # should have populated one. If we reach here, either the prompt was + # skipped or `$CLOSES_LINE` is empty. + echo "⚠ PR body lacks a closing keyword (Closes/Fixes/Resolves #N, case-insensitive, optional colon) AND no opt-out marker (Refs #N / No related issue:)." >&2 + echo " Re-run §2.4.0's orphan-PR prompt to choose: Closes #N | Refs #N | No related issue: <reason>" >&2 + echo " Aborting PR creation. (Silent proceed would orphan the PR from any tracked issue.)" >&2 + exit 1 +fi +``` + +When user explicitly selected `Refs #<N>` or `No related issue: <reason>` in §2.4.0, gate passes silently — opt-out is a legitimate path for refactors, drift sweeps, and hotfixes. Gate exists to catch the case where §2.4.0 fell through without populating `$CLOSES_LINE`. + +### 2.4.3 Create PR + +```bash +# Identity: plain `gh` (the human PR author) by default. If the consuming +# project's conventions route automation writes through a bot identity +# wrapper, follow those for comments/reactions — PR creation itself is +# normally authored by the human account. +# No reviewers are auto-requested by default — `gh pr create` runs without +# `--reviewer`. Reviews come from whatever AI reviewers the repo wires up +# and any humans who opt in. +PR_URL=$(gh pr create --title "<type>: <description>" --body "$BODY") + +# Extract PR number from URL (gh pr create outputs the URL on success). +# This number is the source of truth for the rest of this phase — pass it +# explicitly to every subsequent gh call. +PR_NUMBER=$(basename "$PR_URL") +``` + +PR identity (number + URL) is queried live from `gh pr view --json number,url` whenever a later phase needs it. We do not persist it to a state file — `gh` is authoritative source. + +**All subsequent phases MUST use `<pr_number>` explicitly** — never bare `gh pr view` / `gh pr checks` / `gh pr merge` without PR number argument. + +## 2.5 Record expected CI workflows + +Classify changed files → predict expected workflows by reading the repo's own `.github/workflows/` triggers (path filters, `on:` events). Typical shape: + +| File patterns | Expected workflow (example) | +|---------------|-------------------| +| Language sources (`*.cs`, `*.py`, `*.ts`, …) | that ecosystem's CI workflow | +| Shell scripts | shell lint workflow | +| Any PR | always-on workflows (AI review, CI gateway) | + +Record the expected set for comparison in Phase 3. + +## 2.6 Report and stop + +Report the PR URL, captured `<pr_number>`, and recorded list of expected CI workflows. End Phase 2 there. Monitor (Phase 3), if needed, is invoked explicitly via `/pull-request monitor` or `/pull-request full`. diff --git a/plugins/source-control/skills/pull-request/reference/merge.md b/plugins/source-control/skills/pull-request/reference/merge.md new file mode 100644 index 000000000..9781354c2 --- /dev/null +++ b/plugins/source-control/skills/pull-request/reference/merge.md @@ -0,0 +1,108 @@ +# Phase 4: Merge (squash + cleanup) + +## 4.1 Pre-merge checks (readiness re-verification) + +Resolve `<pr_number>` via `gh pr view --json number -q '.number'`. Pass it explicitly to every `gh` call in this phase. + +**Re-run full [readiness checklist](readiness.md) before merge execution.** This is the second run (first was in monitor 3.4). Catches late-arriving comments, status changes between monitor completion and merge, and race condition where a comment-only actor posts after readiness was declared. + +```bash +# 1. Re-check all check runs for any state changes +gh pr checks <pr_number> --json name,state,bucket + +# 2. Re-check for new comments since monitoring completed (all 3 sources, paginated) +gh api --paginate repos/{owner}/{repo}/pulls/<pr_number>/reviews | jq -r '.[].user.login' +gh api --paginate repos/{owner}/{repo}/pulls/<pr_number>/comments | jq -r '.[].user.login' +gh api --paginate repos/{owner}/{repo}/issues/<pr_number>/comments | jq -r '.[].user.login' +``` + +**If any readiness gate fails on re-verification:** + +- New failing check → return to monitor (Phase 3) +- New unprocessed comment → process per 3.3, then re-verify +- New security finding → evaluate per 3.1.5, then re-verify + +**Only after all 6 readiness gates pass on this re-verification:** + +1. Present merge summary including: + - Check run status (all classified) + - Security scan disposition (all findings classified) + - Comment coverage (all reviewers processed) + - Any deferred items (tracked work items) +2. **Comprehension quiz (default-on, self-enforced)** — when the PR carries substantial work the user didn't author line-by-line (multi-file feature/refactor, or a long agent session outran the user's reading), generate a self-contained HTML change report + quiz before asking for merge approval: the report explains the change with context and intuition (what was done, why, which existing code paths it leans on); the quiz at the bottom tests exactly that. The user merges after passing — self-enforced, no tooling gate; "skip quiz" skips it explicitly. Exemption is calibrated by size and blast radius, NOT by file type: exempt only diffs the user can genuinely review at a glance (single-file, mechanical, or a handful of small localized edits). A large multi-file instruction-only change (skills, rules, agent instructions from a long session) gets the quiz even though it is docs-only — instruction surfaces steer future agent behavior, so unread changes there carry real blast radius +3. Wait for user approval — merge is an irreversible action + +## 4.2 Squash merge + +Default merge mode is squash — one squashed commit per PR onto the default branch. Follow the consuming project's convention when it differs (merge commit / rebase-merge). + +```bash +gh pr merge <pr_number> --squash --delete-branch +``` + +**Always use the explicit `<pr_number>` resolved at phase entry.** The PR title (conventional commits format) becomes the squash commit message. + +## 4.3 Worktree transition and next-task setup + +Detect if currently in a worktree (`git worktree list`). + +**If in a worktree (primary pattern — worktree reuse):** + +Reuse the worktree for next task by creating a new branch from latest main. Faster than remove+recreate and preserves gitignored files. + +```bash +# 1. Get latest main +git fetch origin main + +# 2. Create new branch from latest main (NOT checkout main — that's blocked) +git checkout -b <new-type>/<new-desc> origin/main + +# 3. Delete old merged branch (squash merge needs -D not -d) +git branch -D <old-branch> +``` + +Then report the transition and suggest `/clear` for fresh conversation context (`/clear` fires any SessionStart hooks the project registers). Use `-D` not `-d` because squash merge changes the commit SHA. + +Worktree reuse (new branch from latest default branch in the same directory) is faster than remove+recreate and preserves gitignored files; the alternative is `ExitWorktree` + a fresh `EnterWorktree` for a clean slate. + +**If on a regular branch (not in worktree):** + +1. **Check for uncommitted changes BEFORE checkout** — `git status --porcelain`. If uncommitted changes exist, they will be lost on `git checkout main` (conflicting changes fail, non-conflicting changes silently carry over to main's working tree — neither desirable). Stash first: `git stash push -u -m "pre-merge-cleanup: <branch-name>"` (`-u` includes untracked files — without it, new files are silently skipped). Stashes survive branch deletion (stored in `.git/refs/stash`, not tied to branches) +2. `git checkout main` +3. `git pull --ff-only` +4. `git branch -D <merged-branch>` +5. If a stash was created in step 1, inform user: "Stashed N uncommitted changes. Run `git stash list` to see them, `git stash pop` to restore on a new branch." + +## 4.4 Run a session retrospective (optional) + +If your environment provides a retrospective skill (e.g. a `/retro` command), invoke it **after the worktree transition (worktree reuse) or after merge (non-worktree)**. With worktree reuse, `CLAUDE_PROJECT_DIR` stays valid because the worktree directory persists — skills remain fully discoverable. If no such capability exists, skip this step. + +If the user declines or says "skip", proceed to step 4.5. In `full` mode, run automatically without pausing. + +**Exception:** if using `ExitWorktree` instead of worktree reuse (rare), run the retrospective BEFORE merge in Phase 4.1 — worktree removal orphans `CLAUDE_PROJECT_DIR` and breaks skill discovery. + +## 4.5 Verify clean state and offer next action + +```bash +git status # should be clean +git worktree list # should show only main + other active worktrees +git branch # merged branch should be gone, new branch active +``` + +**Post-merge CI health check** — verify CI on main is green after merge commit lands: + +```bash +gh run list --branch main --limit 1 --json conclusion,displayTitle \ + --jq '.[0] | "\(.conclusion): \(.displayTitle)"' +``` + +If latest run shows `failure`, flag it immediately — the merge may have introduced a regression on main. If run is still `in_progress`, note it and suggest checking back. + +Report: merge complete, transition successful, state verified. + +**Then offer next-task transition:** + +> "PR merged and worktree ready for next task. What's next?" +> +> 1. **Continue in this session** — `/clear` for fresh context, then start the new task on the branch we just created +> 2. **End session** — close and start fresh next time diff --git a/plugins/source-control/skills/pull-request/reference/monitor.md b/plugins/source-control/skills/pull-request/reference/monitor.md new file mode 100644 index 000000000..9aede8e2b --- /dev/null +++ b/plugins/source-control/skills/pull-request/reference/monitor.md @@ -0,0 +1,400 @@ +# Phase 3: Monitor (CI + comments + fixes) + +Phase 3 is an **async event loop**, not a sequential pipeline. After every push (initial PR creation, CI fix, comment fix), monitor CI status AND process comments concurrently as they arrive. Don't wait for all CI checks to complete before reading comments — bots post at different times. + +## 3.0 Expected PR actors and merge readiness + +**Before polling, know who you're waiting for.** The [readiness checklist](readiness.md) defines the authoritative registry of all expected PR actors — CI workflows, security scanners, AI reviewers, and comment-only bots. Read that file before starting the monitoring loop. + +**Key principle: "no comments" ≠ "ready to merge."** An empty comment list may mean reviewers haven't posted yet, not that there are no issues. The readiness checklist includes a **cooldown period** (minimum 2 minutes after the last check-run completion or comment arrival) to prevent the race condition where monitor declares readiness before all actors post. + +**Bounded autonomy — NEVER auto-merge.** Monitor is a co-pilot, not an autopilot. It evaluates, classifies, and recommends — it does not merge. The merge decision is always a human gate (Phase 4), even in `full` mode. The only difference in `full` mode: readiness gates are checked automatically — never relaxed. The user must explicitly approve every merge via `/pull-request merge` or manual `gh pr merge`. No auto-merge, no `--auto` flag, no autonomous merge under any condition. + +## 3.0.0 Cloud session baseline poll + +**Platform-conditional:** cloud/headless sessions (`CLAUDE_CODE_REMOTE=true`) have no push-channel capability. They use `gh` CLI polling as the only PR-activity source. + +**If `CLAUDE_CODE_REMOTE=true` (cloud session):** + +Establish a baseline poll: `gh pr checks <N>` + the three comment-surface fetches (per-iteration checklist steps C1-C3) every 60-90s in a blocking loop until all readiness gates pass. + +**If local CLI session (`CLAUDE_CODE_REMOTE` not set or `false`):** skip this section. Event delivery is handled by the push-channel primary path (§3.0.05) when available, otherwise by the Monitor watch (§3.0.1). + +## 3.0.05 Push-channel primary path (local CLI sessions, optional) + +**Preferred over §3.0.1 Monitor watch — when your environment provides it.** Some environments ship a GitHub-events push channel: an MCP server paired with a webhook forwarder (e.g. the `cli/gh-webhook` gh extension) that delivers `check_run` / `workflow_run` / `pull_request*` / `issue_comment` events straight into the active session — zero idle polling, ~0 request cost between events. + +**Activation gate — verify, never assume:** + +1. Confirm the channel's MCP server is registered in this session (its status tool responds). +2. Verify its delivery pipeline is healthy per the channel's own docs (broker/forwarder process alive, subscriber connected to the LIVE broker — a stale subscriber whose connection looks "open" against a dead or replaced broker is indistinguishable from a healthy one without a health cross-check; when the channel exposes a broker address, cross-check it against the live process before trusting it). +3. Arm the channel's PR filter for `<N>` so events scope to the monitored PR. + +**If all checks pass → channel mode active:** + +- Skip §3.0.1 Monitor-watch arming entirely +- Process channel event arrivals per §3.1 (each event triggers a single iteration; zero polling between events) +- Continue to honor §3.0.5 loop-aware self-termination — channel mode does not change merge gating + +**If the environment has no such channel, or any check fails and can't be remediated → fall through to §3.0.1 Monitor watch** with a one-line note: `Push notifications unavailable — using Monitor tool (30s poll).` + +## 3.0.1 Auto-watch setup (Monitor tool) + +**Every monitor invocation MUST ensure a session-persistent event watch exists.** Runs immediately after 3.0.0 — before terminal state checks, CI polling, and comment processing. + +1. Resolve PR identity: `PR_NUMBER=$(gh pr view --json number -q '.number' | tr -d '\r')`, `OWNER=$(gh repo view --json owner -q .owner.login)`, `REPO=$(gh repo view --json name -q .name)` +2. Check if a Monitor watch is already running for this PR: `TaskList` and look for a task whose description contains `PR #$PR_NUMBER CI + comments` +3. **If a matching task exists** → skip (watch already active). Proceed to 3.0.5 +4. **If no matching task exists** → arm the watch: + + ```text + Monitor( + description: "PR #<N> CI + comments", + persistent: true, + command: <poll script below> + ) + ``` + + The poll script (inline in the `command` parameter): + + ```bash + PR_NUMBER=<N> + OWNER=$(gh repo view --json owner -q .owner.login) + REPO=$(gh repo view --json name -q .name) + prev_checks="" + last_comment_ts=$(date -u +%Y-%m-%dT%H:%M:%SZ) + + while true; do + # Terminal state check — exit watch if PR closed/merged + state=$(gh pr view "$PR_NUMBER" --json state -q '.state' 2>/dev/null | tr -d '\r') + if [ "$state" = "MERGED" ] || [ "$state" = "CLOSED" ]; then + echo "PR #$PR_NUMBER $state — watch complete" + exit 0 + fi + + # CI check-run changes (emit on any new terminal bucket) + cur_checks=$(gh pr checks "$PR_NUMBER" --json name,bucket \ + --jq '.[] | select(.bucket != "pending" and .bucket != "in_progress") | "\(.name): \(.bucket)"' \ + 2>/dev/null | tr -d '\r' | sort || true) + if [ "$cur_checks" != "$prev_checks" ]; then + comm -13 <(echo "$prev_checks") <(echo "$cur_checks") | \ + grep --line-buffered -E 'failure|cancelled|timed_out|startup_failure|action_required|success|skipped' \ + || true + prev_checks="$cur_checks" + fi + + # New comments (emit login + first 80 chars of body) + now=$(date -u +%Y-%m-%dT%H:%M:%SZ) + gh api "repos/$OWNER/$REPO/issues/$PR_NUMBER/comments?since=$last_comment_ts" \ + --jq '.[] | "COMMENT \(.user.login): \(.body[:80])"' \ + 2>/dev/null | tr -d '\r' | grep --line-buffered . || true + last_comment_ts="$now" + + sleep 30 + done + ``` + + Capture the returned task id. Report: `Monitor watch armed (task <id>, PR #<N>). Fires on CI check completion and new comments. Stop with TaskStop <id> or end session.` + +5. Proceed with the current monitoring iteration normally + +**Why Monitor over fixed-interval cron:** a cron fires every N minutes regardless of PR activity. Monitor fires only when the filter emits — typically 5-15 times per PR lifecycle. Zero request cost during idle periods. + +**Re-arm after `--resume`:** Monitor is session-scoped and does NOT restore on `--resume`. On any `/pull-request monitor` invocation in a new or resumed session, the §3.0.1 idempotency check (step 2) detects no watch and re-arms automatically. + +## 3.0.5 Loop-aware monitoring (self-termination support) + +When `/pull-request monitor` runs in a loop (either auto-created by 3.0.1 or user-created via `/loop`), each iteration should be lightweight and self-terminating. Runs **after** 3.0.1 on every iteration. + +**Terminal state pre-check (MANDATORY first action on every iteration):** + +```bash +gh pr view <pr_number> --json state -q '.state' +``` + +| State | Action | +|-------|--------| +| `OPEN` | Proceed to 3.1 monitoring loop as normal | +| `MERGED` | Output final report (see below), self-terminate the loop | +| `CLOSED` | Output final report (see below), self-terminate the loop | + +**Readiness-pass check (OPEN PRs only):** if the previous iteration already presented "All readiness gates passed. Recommend merge." and no new activity has occurred since (no new check-run completions, no new comments, no new pushes), self-terminate the loop using the same protocol below. Continued polling after readiness-pass is a no-op — the user has all information needed to merge. If a new push occurs later, the next `/pull-request monitor` invocation re-creates the loop via 3.0.1. + +**Self-termination protocol** (when PR is MERGED or CLOSED): + +1. Output a brief completion message: + + ```text + PR #N — MERGED. Monitoring complete. Stopping watch. + ``` + +2. Call `TaskList` to find the Monitor watch task for this PR (description contains `PR #<N> CI + comments`) +3. If found, call `TaskStop <task_id>` to kill the background watch process +4. If no matching task found (manual invocation, watch already stopped): skip steps 2-3, just output the completion message + +**Minimal output for no-change iterations** — when the Monitor watch emits nothing and there are no new CI state changes or comments since the last check, output a single status line: + +``` +PR #N monitoring: OPEN | CI: 3/8 complete | Comments: 0 new | Next check in ~2m +``` + +Keeps context cost low (~50 tokens per iteration) instead of a full monitoring report. + +## 3.0.6 Multi-PR scan (after readiness-pass, merge, or close) + +When current-PR monitoring ends (readiness gates pass, PR merged, or PR closed), scan for other open PRs needing attention before going idle: + +```bash +gh pr list --state open --json number,title,headRefName,statusCheckRollup \ + --jq '.[] | "\(.number) \(.headRefName) \(.title)"' +``` + +For each open PR found, report a one-line status: + +```text +Other open PRs: + #101 feat/add-auth — 2 failing checks, 1 unresolved comment + #103 fix/null-check — all checks green, awaiting review +``` + +**Constraint: Monitor watches are branch-locked.** Monitor MUST run in the session that owns the branch (§3.5). Scanning is READ-ONLY — you cannot arm a Monitor watch for a PR on a different branch from this worktree. Report status and suggest: *"Switch to the worktree for `<branch>` to monitor PR #N."* + +**When NO other open PRs found:** report `No other open PRs need attention.` and let the session idle. + +**DO NOT just report status and ask.** Monitor's job is to DO the work — evaluate comments (explore → research → classify), react, reply, fix VALID findings, and push. Status reporting without action defeats the entire purpose of autonomous monitoring. The only time to pause for user input is at explicit decision gates (CI fix proposals with multiple viable approaches, merge confirmation). "Want me to start evaluating?" is NEVER a valid question — the answer is always yes. Execute the full 3.1-3.4 workflow on every iteration with state changes. + +## 3.1 Monitoring loop (per-push) + +**PR number**: resolve once at phase entry via `gh pr view --json number -q '.number'` (or `gh pr view "$(git branch --show-current)" --json number -q '.number'` if multiple PRs are open against this checkout). Capture into a shell var and pass explicitly to every subsequent `gh pr checks` / `gh pr view` call within this phase. + +After each push, run this loop until convergence (**every** check in a terminal state + all comments addressed): + +1. **Mergeable pre-check (MANDATORY before polling)** — `gh pr view <N> --json mergeable,mergeStateStatus` FIRST. If `mergeable == "CONFLICTING"`, GitHub will NOT trigger workflows — integrate the default branch, resolve conflicts, force-push with lease, and restart the loop. Only proceed to CI polling when `mergeable == "MERGEABLE"`. **Never blame the platform for missing CI runs before checking this.** +2. **Poll CI** — `gh pr checks <N>` every 30s (the standard monitor cadence), max 15 minutes per cycle. **Wait for ALL checks to reach a terminal state** (pass/fail/skipped) before suggesting merge — no exceptions, regardless of PR type. Never merge while any check is still pending or in_progress +3. **Check for new comments** — on each poll, also fetch new review comments (`gh api repos/<owner>/<repo>/pulls/<N>/comments --paginate`) +4. **Process comments immediately** — if a bot comments while CI is still running, start evaluating/researching that comment now. Don't wait for CI +5. **On CI failure** — route to 3.2 (research-driven fix) +6. **On new comment** — route to 3.3 (evaluate + respond) +7. **After any fix push** — restart the loop (new push = new monitoring cycle) + +Compare triggered workflows against the expected set from Phase 2.5. Flag mismatches. + +**When ANY check shows `fail` — ALWAYS read actual logs before classifying.** Use the prioritized fetch chain — `gh run view --log-failed` is the LAST resort because it truncates at the CLI display layer (~4MB cap, cli/cli #11059, #10551, #7771, #7642). The REST API path returns complete logs every time: + +```bash +# Tier 1 — Annotations API (path/line/level/title/message — fix-location data) +# Sometimes alone is enough to classify (lint failures, type errors) +bash "${CLAUDE_PLUGIN_ROOT}/skills/pull-request/scripts/fetch-annotations.sh" <pr-number> --failed + +# Tier 2 — Full failure ZIP via direct gh api (complete, untruncated) +bash "${CLAUDE_PLUGIN_ROOT}/skills/pull-request/scripts/fetch-failed-logs.sh" <run-id> + +# Tier 3 — LAST RESORT interactive eyeball (TRUNCATES on large logs) +gh run view <run-id> --log-failed 2>&1 | grep '##\[error\]' +``` + +**CRITICAL: Do NOT use `grep -i "error\|fail\|..."` on CI logs.** It produces false matches from cleanup steps, variable names, and incidental output (e.g., "Bad credentials" from a token cleanup step when the real error is a workflow validation failure). GitHub Actions marks real errors with `##[error]` annotations — grep for those first. Only fall back to broader searches if `##[error]` returns nothing. + +**Stop at the first complete picture.** Tier 1 annotations are usually sufficient for "what failed in this job?". Escalate to the Tier 2 full ZIP only when annotations don't pinpoint the cause. + +### Inline vs subagent dispatch decision + +Monitor uses two execution paths for log work — inline in the main session for fast classification, and a CI-log-audit subagent (when your environment provides one) for verbose audits. Choose based on uncertainty + token budget: + +| Situation | Path | Why | +|---|---|---| +| Single failing check with a clear `##[error]` marker | **inline** Tier 1 → Tier 2 | The annotations + full-ZIP path is ~3-5K tokens; the agent needs the result NOW for the next action. Subagent overhead buys nothing | +| Default `fetch-failed-logs.sh <run-id>` (errors+warnings) | **inline** | Same as above | +| `--raw` mode (full ZIP dump) | **subagent** (or read selectively) | 50-500K tokens — pollutes main context with content the agent only needs to grep through | +| `--audit` mode (groups + timing + suspicious patterns) | **subagent** | Verbose multi-section output | +| "Why did this PR pass when something looks off?" | **subagent** | Cross-job mask detection, perf-vs-baseline comparison, annotation-gap analysis | + +**Why not a subagent for everything:** spawning a subagent for a single-response classification task is an anti-pattern — the default mode's 3-5K-token output IS the answer the agent needs to act on. A subagent justifies its cost only when (a) verbose output protects main context, (b) persistent memory pays off, or (c) parallel work is happening. No audit subagent available → do the audit inline with the bundled script's `--audit` flags. + +**Never guess at failure causes.** Common always-on-review workflow failures and their log signatures: + +| Log signature | Meaning | Action | +|--------------|---------|--------| +| `Workflow validation failed` on an OIDC-based review action | PR modifies the workflow file — OIDC requires the file to match the default branch | Informational — expected when the PR touches that workflow | +| Usage/quota exhaustion messages (e.g. `out of extra usage`) | The review bot's subscription limit | Informational — report accurately, wait for reset or merge without the second review | +| `error_max_turns` or similar truncation | Reviewer ran out of turns before completing | Informational — the review may be incomplete; check whether a comment was posted | +| OIDC / authentication errors | Token-exchange failure | Informational — often intermittent; retry or classify | +| Actual code/tool errors | Real failure | Investigate | + +Report the **exact error message** from logs — not a classification label. + +## 3.1.5 Security scan evaluation (MANDATORY) + +**Security scan results are ALWAYS blocking — they must be evaluated before merge, regardless of PR type.** Applies to any actor performing security scanning — identify them by check-run names containing "security", "guardian", "CodeQL", "Snyk", "Dependabot", or similar, and by bot comments about secrets or vulnerabilities. + +**Discovery, not hardcoding:** security tools change over time. The principle: any check run or bot comment reporting a security finding triggers mandatory triage. Don't skip a finding because the tool isn't in a hardcoded list. + +For each security finding: + +1. **Read the full PR comment** — scanners post finding details (secret type, file, commit SHA) +2. **Read the check-run details** — `gh pr checks <pr_number> --json name,state,bucket` +3. **Classify each finding:** + - **True positive** (actual secret leaked / real vulnerability) → BLOCK merge. Remove the secret, rotate credentials, then push a fix. Route through the 3.2 research-driven fix cycle + - **False positive** (code examples, test fixtures, documentation) → document the rationale, and note that the repo owner should dismiss it in the scanning tool's UI/dashboard or its ignore config + - **Not applicable** → document why +4. **Every finding must have an explicit classification** — no unclassified findings before merge + +**When a security check run shows `FAILURE`:** that does NOT mean the PR is broken — it means the scanner found something needing evaluation. The failure is the *trigger* for triage, not an automatic merge block. After classification, include the disposition in the readiness verdict (Gate 3 in [readiness.md](readiness.md)). + +## 3.2 CI failure resolution (RESEARCH-GATED) + +**Rule: no edit without research.** For each failed check: + +1. **Read full failure context (MANDATORY)** — the prioritized chain in §3.1 above (annotations → full ZIP → last-resort CLI view). Never broad keyword grep +2. **Explore (MANDATORY)** — read source files, check similar code, review the project's own rules, check `git log` +3. **Research (MANDATORY — HARD GATE)** — research the specific error in the exact framework/version, via your environment's research skill when one exists, otherwise direct doc lookups. Require multi-source consensus (aim for 3 sources). Non-optional +4. **Present the proposed fix with evidence** — error, root cause, proposed fix, sources with URLs, confidence level (HIGH/MEDIUM/LOW). If LOW, escalate. If MEDIUM, present trade-offs +5. **Implement** (only after 1-4) — make the change, re-run the project's build/test/lint gate, commit, push +6. **Loop restarts** — new push triggers 3.1 again. Track iteration count + +**Stale branch recovery** — if CI fails because the branch is out of date with the default branch (merge conflicts, "branch is not up to date" errors, or tests failing due to default-branch-only changes): integrate (merge or rebase per the project's convention and the branch's own history), resolve conflicts conservatively, force-push with lease, restart the monitor loop from 3.1. Distinct from code failures — no research gate for the integration itself, only for conflicts requiring intent judgment. + +**Escalation guard** — after **3 fix iterations**, STOP. Present a history table. The root cause may be environmental. + +## 3.3 PR comment evaluation (WORKFLOW-GATED) + +**Fetch all comments deterministically** via the bundled script — never select API surfaces by agent judgment: + +```bash +bash "${CLAUDE_PLUGIN_ROOT}/skills/pull-request/scripts/fetch-all-pr-comments.sh" <pr-number> +``` + +Output: a JSON array sorted by `created_at`. Each object carries `type` (`general` | `review` | `inline`), `author`, `body`, `path`, `line`, `id`. The script hits all 3 GitHub API surfaces (issue-level comments, review-level comments, inline review comments) — no surface can be accidentally skipped. + +Every comment from an AI reviewer or human reviewer gets the **full workflow treatment** — not a quick glance and a thumbs-up. Review-bot findings trigger urgency bias ("respond fast") and confidence illusion ("this looks right, skip verification"). Both are traps — bot findings have a demonstrated error rate, and the workflow gate exists precisely because "obvious" fixes can be wrong. + +### 3.3.1 Phase A: Evaluate ALL comments (batch) + +Process every comment before fixing any. Produces a complete picture of what needs attention. + +For **every substantive comment from every participant** (bot accounts with the `[bot]` suffix, human reviewers, AND the PR author's own comments — skip only LGTM/empty/emoji-only): + +**Finding extraction for multi-finding comments:** AI review summaries often pack multiple findings into a single comment — markdown tables, numbered severity items, multi-paragraph analyses. Extract each finding as a separate work item. One comment with N findings = N individual evaluate cycles below. Reply with a per-finding classification table, not one blanket reply. See [babysit.md](babysit.md) §5.0.4 for extraction rules. + +1. **Explore** — read the referenced file/line, understand the surrounding code, check related files. Don't evaluate a comment about line 42 without understanding lines 1-100 +2. **Research** — verify the specific technical claim against official docs (via a research skill when available). No assumptions, no "this looks right." The sequence is: explore → research → classify. Never: read → classify +3. **Classify** with evidence: + - **VALID (fix now)** — research confirms the finding. Document: what's wrong, why, what the fix is + - **VALID (defer)** — research confirms but the fix is out of scope for this PR. File it in your work-item tracker with evidence and the PR link + - **INCORRECT** — research disproves the finding. Document: why the comment is wrong, with sources + - **UNCERTAIN** — research inconclusive. Escalate to the user + + **"Non-blocking" / "optional" / "nice-to-have" does NOT mean "ignore".** These modifiers describe merge-blocking status — not whether the finding is worth acting on. When research confirms a finding is valid: small + directly related → VALID (fix now), include in this PR; larger or tangential → VALID (defer) + tracked work item. **Never merge past a confirmed-valid finding with neither a fix nor a tracked issue.** The choice is always "fix now or ticket it". +4. **React to the specific comment** via `gh api` reactions (`+1` VALID, `-1` INCORRECT, `eyes` UNCERTAIN). For **bot accounts** (login ends in `[bot]`): react autonomously. Mixed-finding comments: `+1` if ANY VALID. For **human reviewers**: pause for user approval before reacting. **Verify the reaction posted** via a GET on the same endpoint filtered by your login — the POST can silently fail (rate limit, permission) +5. **Reply with evidence** — every comment gets a direct reply with research backing. Use the consuming project's bot-identity wrapper for these writes when it has one; plain `gh` otherwise. **Route by comment source — REQUIRED, not interchangeable:** **inline review comments** (diff-anchored, `pulls/comments`) MUST reply THREADED → `gh api repos/{owner}/{repo}/pulls/<pr_number>/comments/{comment_id}/replies -f body='...'` so the reply lands under the source thread — NEVER a detached issue comment. **General PR comments** (`issues/comments`, no thread) → post a new issue-level comment with thread context in the body. **Review-level comments** (`pulls/reviews`, no thread) → post a new issue-level comment addressing the review. Answering an inline finding with a detached issue comment orphans the reply from the thread the reviewer tracks — a routing error + +**After evaluating ALL comments**, present a classification table: + +```markdown +| # | Reviewer | Comment | Classification | Evidence | +|---|----------|---------|---------------|----------| +| 1 | claude[bot] | "Missing null check on line 42" | INCORRECT — parameter is non-nullable by type | [sources] | +| 2 | chatgpt-codex-connector[bot] | "Race condition in handler" | VALID (fix now) — confirmed by research | [sources] | +| 3 | human-reviewer | "Consider extracting to helper" | VALID (defer) — refactor, not bug | Tracked work item | +``` + +### 3.3.2 Phase B: Fix ALL valid findings (batch, then single push) + +After all comments are evaluated and responded to, implement all VALID (fix now) fixes in a single batch: + +1. **For each VALID (fix now) finding**, follow the full workflow: explore the fix context, verify the *fix* approach (not just the finding), implement, re-run the project's build/test gate after each fix +2. **Stage all fixes together** — `git add <specific-files>` for each changed file +3. **Single commit** — one commit addressing all review comments: `fix: address PR review findings` +4. **Single push** — all fixes go up in one push, triggering one new monitoring cycle + +**Why batch?** Each push restarts the monitoring loop (3.1). Fixing comments one-by-one with individual pushes creates N monitoring cycles instead of 1. Batch fixes, push once, then re-monitor. + +### 3.3.3 Phase C: Re-monitor (loop restarts) + +After the push: + +1. The monitoring loop (3.1) restarts automatically — new push = new cycle +2. CI runs against the updated code +3. **Request re-review from comment-only actors** — if a bot posted findings that were fixed, request a fresh review so the bot can validate the fixes: + - If the bot's trigger is **"on every push"**: it will re-review automatically — just wait + - If the bot's trigger is **manual/smart**: post a comment with its trigger phrase (e.g. `@codex review`) to request a re-review. Don't assume it will re-fire on its own +4. Security scans re-run +5. **Repeat from 3.3.1** if new substantive comments arrive +6. Continue until no new comments arrive and all readiness gates pass + +**This is the convergence loop:** evaluate → respond → fix → push → re-monitor → repeat until clear. The monitoring report (3.4) is only produced when no more comments need attention and the full readiness checklist passes. + +### 3.3.4 Comment evaluation gotchas + +- **NEVER react or classify before researching.** No thumbs-up, no thumbs-down, no "VALID" or "INCORRECT" label until exploration and research complete. Not even if a prior cycle researched the same pattern — each finding gets its own verification. The sequence is always: explore → research → classify → react → reply +- **Zero false positives in classification.** An INCORRECT classification that's wrong is worse than a VALID classification that's wrong — the first dismisses a real issue, the second just does extra work. When in doubt, classify as UNCERTAIN and escalate +- **Don't trust AI reviewer confidence.** A bot saying "critical bug" with high confidence doesn't make it critical. Research first, classify second +- **Don't fix what research says is wrong.** If research disproves a comment, reply with evidence and react with thumbs-down. Don't implement a "fix" for a non-issue just because a bot said so +- **Verify empirically when possible.** For claims about CLI behavior, API responses, or tool output, run the actual command and check. Empirical evidence > documentation > prior research > intuition +- **Escalation guard** — after **3 evaluate-fix-push cycles** with the same reviewer posting new comments, STOP. The reviewer may be generating noise, or there may be a fundamental disagreement. Escalate to the user +- **Codex signals via emoji reactions, not comments.** `chatgpt-codex-connector[bot]` uses emoji reactions on the PR: 👍 = no findings, approved; 👀 = still reviewing. A thumbs-up reaction with no posted comments means Codex reviewed and found nothing — treat as approval. Don't wait for a comment that won't arrive +- **Codex may not auto-fire on PR creation.** If its commit status stays `PENDING` with no emoji reaction on the PR body after ~3 minutes, it likely didn't trigger. Post a PR comment with `@codex review` to trigger manually; check reactions on that trigger comment specifically +- **NEVER select API surfaces by judgment — use the script.** `gh pr view --json comments,reviews` MISSES inline review comments. Always invoke the bundled `fetch-all-pr-comments.sh`, which deterministically hits all 3 surfaces. Observed failure mode: an agent chose `gh pr view --json comments,reviews`, missed 2 valid inline findings, and declared "no comments to address" +- **Never mark a comment addressed without verifiable evidence on GitHub.** Model memory of "I replied" or "I pushed the fix" is not evidence — compaction can lose that state between iterations. Re-query GitHub to verify: reaction exists, reply exists, commit pushed, follow-up posted, bot-authored thread resolved (inline only; human/own excluded). "Done" = GitHub shows evidence. See [babysit.md](babysit.md) §5.1.3 verification gates +- **Resolve BOT-authored inline threads after fix; never human or own.** After the D6 fix + D7 follow-up on an inline review comment opened by a bot reviewer, resolve that thread (D7.5, author-conditional). Leave HUMAN-authored threads for the human to close; never resolve your own. Detect bot at resolution time via GraphQL `author.__typename == "Bot"` (GraphQL login omits the `[bot]` suffix REST shows). Open bot-thread count is a visible signal to reviewers — leaving bot threads unresolved after fixing undermines the audit trail +- **Filter your own prior replies during rescan.** Comments from your own posting identity matching the classification-table pattern (`| # | Finding | Classification |`) are NOT findings — they are prior replies. Skip them during finding extraction. See [babysit.md](babysit.md) §5.0.3 step 4 + +## 3.4 Final monitoring report (readiness-gated) + +**Do NOT declare convergence until the full [readiness checklist](readiness.md) passes.** Run all 6 gates from that file before presenting the monitoring report. Hard requirement — no "close enough" for merge readiness. + +**The readiness checklist includes a 2-minute cooldown** after the last check-run completion or comment arrival. If a new comment or check result arrives during cooldown, restart the cooldown. + +When all readiness gates pass: + +```markdown +## PR Monitoring Complete — All Readiness Gates Passed + +**PR:** #N — title +**Check runs:** X passed, Y skipped, Z failed-informational +**Security:** [scanner] evaluated — N findings classified +**Comments:** X from N reviewers — Y fixed, Z deferred, W incorrect +**Cooldown:** 2+ min since last activity +**Fix iterations:** N +**Failures classified:** +- `<check>`: FAILURE — [exact reason from logs] +**All readiness gates passed. Recommend merge.** +``` + +**After presenting the readiness report, self-terminate the Monitor watch** (same protocol as 3.0.5). Continued watching after readiness-pass adds no value. If a new push occurs after readiness-pass, the next `/pull-request monitor` invocation re-arms via 3.0.1. + +**If any gate fails**, present which gates failed and what action is needed. Never suggest merge with open gates — even in `full` mode. + +## 3.5 Monitor integration + +The monitor phase automatically arms a session-persistent background watch via §3.0.1. The user does NOT need to invoke `/loop` manually — the watch is self-configuring and event-driven. + +**Where to run it — the same session that owns the branch.** + +Monitor MUST run in the session that created the PR. Not a preference — a constraint: + +1. Monitor writes to the PR branch (pushes CI fixes, rebases, posts comments) +2. Writing requires being checked out on that branch +3. Git enforces one-branch-per-worktree — no second session can check out the same branch +4. Therefore: monitor runs in the session that owns the branch + +```text +Session A: feat/feature-x → create PR → /pull-request monitor (arms watch) → keep working or idle +Session B: feat/feature-y → different branch, different worktree → code the next thing +``` + +Watch notifications arrive between turns. If you're mid-response on a complex task, the notification queues until your turn completes. + +**For read-only status checks from any session:** use `/pull-request status` — a read-only action that only calls `gh` commands. Safe from any terminal, any time, no branch checkout required. + +**Key behaviors:** + +- **Self-termination on merge/close/readiness-pass** — the poll script exits on MERGED/CLOSED; `TaskStop` also fires from monitoring logic +- **Zero cost during idle periods** — Monitor fires only when the filter emits +- **Full monitoring on state changes** — when a check run completes or a new comment lands, the emitted line wakes the model and the full 3.1-3.4 logic runs +- **Session-scoped** — the watch terminates when the session exits; no orphaned background processes. It does not restore on `--resume` — §3.0.1's idempotency check re-arms it +- **Manual cancel** — "stop the PR monitor" or `TaskStop <id>` + +**Cloud sessions (`CLAUDE_CODE_REMOTE=true`):** §3.0.0's baseline poll handles event delivery via `gh`; the Monitor tool is not needed — check `CLAUDE_CODE_REMOTE` before arming. + +**Legacy `/loop` pattern:** `/loop 2m /pull-request monitor` still works but costs a full model turn per interval. Monitor is preferred for active CLI sessions; `/loop` remains a manual override if Monitor is unavailable. diff --git a/plugins/source-control/skills/pull-request/reference/prep.md b/plugins/source-control/skills/pull-request/reference/prep.md new file mode 100644 index 000000000..f938bc952 --- /dev/null +++ b/plugins/source-control/skills/pull-request/reference/prep.md @@ -0,0 +1,63 @@ +# Phase 1: Prep (review + verify + simplify) + +Pre-PR quality phase: review, verify, and simplify changes before creating the PR. + +## 1.1 Detect changed files + +```bash +git diff --cached --name-only && git diff --name-only && git ls-files --others --exclude-standard +``` + +Classify files: **code** (source files — `.cs`, `.py`, `.ts`, `.js`, `.sh`, `.ps1`, project files), **tests** (paths containing `/tests/`, `*Tests.*`, `*.test.*`), **config/doc** (`.md`, `.json`, `.yml`). + +**Zero code files?** Skip review/simplify (1.2–1.4); the verify gate (1.5) reduces to lint. Proceed to PR creation. If the consuming project layers extra prep-evidence requirements on PR creation (hooks, gates), satisfy those per its own docs. + +## 1.2 Review the changes + +Run the strongest review capability your environment provides, scoped to the branch diff: + +- A PR-review skill or plugin (e.g. a `review-pr` command), or review agents (code-reviewer, security-reviewer, architecture-reviewer) when installed +- Otherwise: review the diff inline — correctness, error handling, security-sensitive surfaces, test coverage for new logic, convention adherence against the project's own rules + +Auto-scale aspects to the diff: always check code errors; add test-focused review when test files changed; add type-design review for new type-heavy files. Collect findings. + +## 1.3 Verify EVERY finding (CRITICAL) + +For each finding: + +1. Extract the specific claim (API, pattern, behavior assertion) +2. Verify against official docs and actual source for the exact versions in use (dispatch parallel verification agents when your environment supports them — up to 3 at a time) +3. Cross-reference against the project's own conventions/rules +4. Classify: **VERIFIED** (evidence confirms), **INCORRECT** (evidence contradicts), **UNCERTAIN** (cannot confirm) + +**Drop INCORRECT findings entirely.** Flag UNCERTAIN with a note. + +Present verified findings in a structured table. Pause for user review and fixes. + +## 1.4 Simplify, review, and verify + +Unless `quick` or `review-only` scope: + +1. Run your environment's simplify/refine capability over the branch diff when one exists (a `/simplify`-style skill); otherwise do a manual pass for dead code, needless indirection, and duplication introduced by the branch +2. **Show the simplify diff** — run `git diff` and present what changed. Automated simplification fixes are NOT research-verified; treat them like any code-review finding: inspect each change, approve or revert +3. **Pause for user review** — let the user approve/reject simplify changes before proceeding +4. Re-run tests on approved changes +5. Run the verify gate (1.5) + +## 1.5 Verify gate (HARD — blocks PR creation) + +Run the project's full build + test + lint surface — via its verify skill when one exists (e.g. a `/verify-changes` or `/build` command), otherwise the ecosystem-native commands (`dotnet build && dotnet test`, `npm test`, `pytest`, shellcheck, markdownlint, …) for every ecosystem the branch touches. **All results must be clean before proceeding to PR creation.** + +**Run the full cross-cutting surface, not just the "obvious" ecosystem.** A branch that "looks dotnet-only" can still break CI through a touched README, an unmarked `.sh` script, or a modified workflow file. Mirror locally whatever CI will run — the project's CI workflows are the canonical list of what must pass. + +**Decision rule:** + +- Any FAIL → STOP. Address each before reattempting. Do not proceed to PR creation +- Any skip due to "tool missing" → install the tool OR document why the skip is acceptable in this PR (rare — almost always faster to install) +- All clean (or only non-applicable skips like "no `.md` changes") → proceed to PR creation + +**Why this gate is hard:** cost asymmetry. Each mechanical issue caught locally costs seconds; the same issue in CI burns a full multi-minute round trip plus rebase/repush overhead. A single sloppy PR can waste half a dozen CI cycles on issues that were all catchable locally. + +## 1.6 Report + +Report: findings verified/dropped, simplify ran/skipped, verify gate pass/fail per ecosystem. Proceed to PR creation. diff --git a/plugins/source-control/skills/pull-request/reference/readiness.md b/plugins/source-control/skills/pull-request/reference/readiness.md new file mode 100644 index 000000000..7474562f8 --- /dev/null +++ b/plugins/source-control/skills/pull-request/reference/readiness.md @@ -0,0 +1,171 @@ +# PR Merge Readiness Checklist + +Single source of truth for merge readiness. Both monitor.md (Phase 3.4) and merge.md (Phase 4.1) reference this file. **Every item must be satisfied before suggesting merge — no exceptions, regardless of PR type or `full` mode.** + +## Expected PR actors + +Monitor must discover and track every actor that participates in PRs. Actors fall into three categories based on how they report: + +### Actor categories + +| Category | How they report | How to discover | Timing | +|----------|----------------|-----------------|--------| +| **Check-run actors** | `gh pr checks` — status/conclusion fields | Poll `gh pr checks <pr_number>` until all reach terminal state | Deterministic — GitHub triggers them on push | +| **Check-run + comment actors** | Both a check run AND a PR comment | Poll checks AND comments | Check run arrives first, comment follows | +| **Comment-only actors** | PR comments only — no check run | Poll `gh api repos/{owner}/{repo}/issues/<pr_number>/comments` | Non-deterministic — arrives at unpredictable time | + +### Discovery (not hardcoded) + +**Don't assume a fixed list of actors.** On each monitoring cycle, discover what's present: + +1. **Check runs**: `gh pr checks <pr_number> --json name,state,bucket` — shows ALL check runs and commit statuses. Every entry here must reach terminal state and be classified +2. **Comments**: `gh api repos/{owner}/{repo}/issues/<pr_number>/comments` — every comment from a `[bot]` account is a PR actor needing evaluation +3. **Security scans**: any check run containing "security", "guardian", "CodeQL", "Snyk", "Dependabot", or similar in the name is a security actor — these get mandatory triage (see Gate 3) + +**Required vs soft heuristic:** + +- Check runs showing `FAILURE` → **required** — must be investigated and classified before merge +- Check runs showing `SUCCESS` or `SKIPPED` → **pass** — no action needed +- Security-related check runs (any state) → **required** — must evaluate findings even on SUCCESS (confirm no suppressions are hiding issues) +- Comment-only bot comments → **soft** — evaluate if posted, but don't block forever waiting. Apply cooldown period (Gate 5) to give them time to arrive +- CI gateway check (whatever it's named) → **required** — must pass + +### Common actors (reference shapes) + +Reference shapes — the discovery logic above is authoritative, not this table. The consuming repo's own workflow set defines the real actor list. + +| Actor | Reports as | Notes | +|-------|-----------|-------| +| CI workflows | Check runs (names vary by ecosystem) | Repos often aggregate into a single required gateway check | +| Claude review | Check run + may post comments | May fail on usage limits — classify from logs | +| Codex | Commit status (`codex-review`) + PR review | Posts via the Reviews API (not issue comments). Trigger configurable: "On every push" recommended for re-review after fixes. **Emoji signals:** 👀 (eyes) = reviewing, will post comments — MUST wait for comments before declaring ready; 👍 (thumbs-up) = approves, no findings, no comments coming. **Comment timing:** the `codex-review` check can pass BEFORE inline comments are posted — check-run `pass` does NOT mean "no comments." When the 👀 emoji is present, poll for codex bot comments until they arrive or a 3-min timeout elapses. **May not auto-fire** — if no reaction after ~3 min, post `@codex review` as a PR comment to trigger manually. Codex reacts to the trigger comment (not the PR body) | +| Security scanners (GitGuardian, Snyk, CodeQL, …) | Check run + comment | Mandatory triage per Gate 3 when present | + +### When actors change + +When a security scanner or reviewer is added, replaced, or removed: + +1. Discovery logic handles it automatically — new check runs appear in `gh pr checks`, new bot comments appear in the comments API +2. If a new actor is comment-only and critical, consider converting it to a required status check via a GitHub Action + +## The readiness checklist + +Run this checklist **twice**: once when monitor declares convergence (3.4), and again immediately before merge execution (4.1). Second run catches late-arriving comments or status changes between monitor completion and merge. + +### Gate 1: All check runs in terminal state + +```bash +gh pr checks <pr_number> --json name,state,bucket +``` + +- [ ] Every check run is in a terminal state (`SUCCESS`, `FAILURE`, `SKIPPED`) — none `PENDING` or `IN_PROGRESS` +- [ ] No unexpected checks missing (compare against expected actors table) + +**Gotcha — `codex-review` may show duplicate entries (`SUCCESS` check-run + stuck `PENDING` commit-status).** `gh pr checks` aggregates BOTH workflow check-runs AND external commit-statuses. `codex-review.yml` workflow posts a real check-run that resolves cleanly; the external Codex bot ALSO posts a redundant commit status that may never finalize (sits at `PENDING` indefinitely). When you see two `codex-review` rows — one `pass|SUCCESS` with a `link`, one `pending|PENDING` with no link — treat check-run as authoritative. Verify via: + +```bash +gh api repos/{owner}/{repo}/commits/<sha>/check-runs --jq '.check_runs[] | select(.name | test("codex"; "i")) | "\(.status) \(.conclusion)"' +``` + +If `completed success`, the stuck commit-status is the redundant external bot — classify as non-blocking, document, and proceed. `mergeStateStatus=UNSTABLE` will reflect the stuck status but does NOT block merge when the repo's required checks are green. + +### Gate 2: All failures evaluated + +For every check run with `bucket == "fail"`: + +```bash +gh pr checks <pr_number> --json name,state,bucket --jq '.[] | select(.bucket == "fail")' +``` + +- [ ] Each failure has been **investigated** (logs read via `gh run view <run-id> --log-failed`) +- [ ] Each failure is **classified**: real failure (fix required) OR informational (document why safe to proceed) +- [ ] Informational failures explicitly documented in monitoring report with exact error message +- [ ] **No unclassified failures** — every `FAILURE` state must have an explicit disposition + +### Gate 3: Security scans evaluated + +Identify all security-related actors (check runs with "security", "guardian", "CodeQL", "Snyk", "Dependabot", etc. in the name, plus any `[bot]` comments about secrets/vulnerabilities). + +- [ ] Every security actor's check run status checked +- [ ] If a security actor posted a comment: **read full comment**, identify each finding +- [ ] Each finding classified: **true positive** (BLOCK — fix or remove the secret/vulnerability), **false positive** (document why — e.g., "code examples in course-digest, not actual secrets"), or **not applicable** +- [ ] True positives resolved before merge — no exceptions +- [ ] False positives documented in monitoring report (rationale for dismissal) +- [ ] Findings dismissed in scanning tool's UI/dashboard as appropriate (e.g., "Skip: false positive" for GitGuardian, "Dismiss alert" for CodeQL/Dependabot) + +### Gate 4: All comments processed + +```bash +# PR reviews (review body — bots like Codex post here) +gh api --paginate repos/{owner}/{repo}/pulls/<pr_number>/reviews \ + | jq -r '.[] | "\(.user.login): \(.state) — \(.body[:100])"' + +# Inline review comments (diff-level) +gh api --paginate repos/{owner}/{repo}/pulls/<pr_number>/comments \ + | jq -r '.[] | "\(.user.login): \(.body[:100])"' + +# General PR comments (conversation tab) +gh api --paginate repos/{owner}/{repo}/issues/<pr_number>/comments \ + | jq -r '.[] | "\(.user.login): \(.body[:100])"' +``` + +- [ ] Every substantive comment from every reviewer (bot or human) has been: + - Read and understood + - Classified per monitor.md 3.3 (VALID fix now / VALID defer / INCORRECT / UNCERTAIN) + - Reacted to (thumbs up/down for bots, user approval for humans) + - Replied to with evidence +- [ ] No unprocessed comments exist +- [ ] Comment-only actors (Codex) waited for per timeout in expected actors table + +### Gate 5: Cooldown period + +- [ ] **Minimum 2 minutes** have elapsed since last check-run completion or comment arrival +- [ ] Prevents race condition where an actor hasn't posted yet but will shortly +- [ ] If a new comment or check result arrives during cooldown, **restart cooldown** +- [ ] **Codex comment wait:** if `codex-review` check passed AND codex reacted with 👀 (eyes), wait for codex inline comments to arrive — up to 3-min timeout after check completion. 👍 (thumbs-up) without 👀 = no comments expected, skip wait. **Scope to current push:** filter by `commit_id` matching current HEAD SHA (codex comments carry the reviewed commit's SHA). On PRs with prior codex comments from earlier pushes, unscoped poll short-circuits on stale comments: + + ```bash + HEAD_SHA=$(git rev-parse HEAD) + gh api repos/{owner}/{repo}/pulls/<pr>/comments \ + --jq "[.[] | select(.user.login == \"chatgpt-codex-connector[bot]\" and .commit_id == \"$HEAD_SHA\")] | length" + ``` + +### Gate 6: No pending work + +- [ ] No fix pushes are in flight (a push restarts the entire monitoring loop) +- [ ] No VALID (fix now) comments remain unaddressed +- [ ] No UNCERTAIN classifications remain unresolved (escalate to user) + +## Readiness verdict + +Only when ALL gates pass, present: + +```markdown +## PR Ready for Merge + +**PR:** #N — title +**Check runs:** X passed, Y skipped, Z failed-informational +**Security:** GitGuardian [evaluated — N findings: X false positive, Y not applicable] +**Comments:** X from N reviewers — Y fixed, Z deferred, W incorrect +**Cooldown:** 2+ min since last activity +**Failures classified:** +- `review`: FAILURE — usage limit (informational, safe to proceed) +- [any other failures with classification] + +**All readiness gates passed. Recommend merge.** +``` + +If ANY gate fails, present which gates failed and what action is needed. **Never suggest merge with open gates.** + +## `full` mode behavior + +In `full` mode, readiness gates are NOT relaxed. Only difference: transition from monitor → merge is automatic **when all gates pass**. If any gate fails, `full` mode pauses and reports — it does not skip gates. + +## Anti-patterns (from an observed incident) + +These specific failures must never recur: + +1. **Merging with FAILURE check runs** — an observed PR had two FAILURE check runs visible in `gh pr checks` and was merged anyway. Monitor must NEVER suggest merge when any check shows FAILURE without explicit classification +2. **Ignoring security scan results** — a security scanner posted both a check run and a comment. Neither was evaluated before merge +3. **Not waiting for comment-only actors** — a review bot posted 8 minutes after PR creation. Monitor declared readiness before bot had a chance to post +4. **Treating "no comments" as "ready"** — "No comments" may mean reviewers haven't posted yet, not that there are no issues. Cooldown period prevents this race condition diff --git a/plugins/source-control/skills/pull-request/scripts/babysit-readiness-gate.sh b/plugins/source-control/skills/pull-request/scripts/babysit-readiness-gate.sh new file mode 100644 index 000000000..5d908af9f --- /dev/null +++ b/plugins/source-control/skills/pull-request/scripts/babysit-readiness-gate.sh @@ -0,0 +1,233 @@ +#!/usr/bin/env bash +# babysit-readiness-gate.sh — deterministic readiness pre-gate for babysit. +# +# Converts the three weakest tier-9 babysit steps into a tier-5 mechanical +# block (per the 2026-05-28 audit, AUDIT.md, findings R1+R5+R6 — one mechanism): +# +# R1 finding decomposition — every source finding must be individually +# classified, not batch-glossed (babysit.md §5.0.4) +# R5 addressed/unaddressed — findings present but unclassified = unaddressed +# R6 checklist completeness — the §5.5 iteration checklist has no unticked box +# +# Why a gate, not prose: the audit proved the advisory "MANDATORY subagent +# dispatch for >=3 findings" rule produced ZERO of its mandated per-finding +# tables across multi-finding PRs — one audit classified 16 findings of ~32. +# Prose "MANDATORY" is a word; this gate counts rows, not intentions, and +# refuses to let readiness be declared while the counts don't add up. +# +# DETECTION (aggregate, schema-grounded on fetch-all-pr-comments.sh output, +# which carries {id,type,author,body,...} but NOT reply-thread links): +# findings = OCCURRENCES of a severity marker (CRITICAL|IMPORTANT|SUGGESTION, +# or codex P1/P2/P3 per babysit.md §5.0.4) across all NON-self +# comments — counted per match, not per line, so N findings on one +# line each count (else a multi-finding line under-counts and the +# gate false-passes) +# classified = OCCURRENCES of a classification token (VALID|INCORRECT| +# UNCERTAIN) across all SELF replies (the per-finding table rows). +# Word-boundary matched so "INVALID" does not count as "VALID" +# BLOCK when findings > 0 AND classified < findings (under-decomposed / +# unaddressed — R1+R5), OR when a --checklist file has any "- [ ]" (R6). +# +# This is a PURE PREDICATE — detection only, no GitHub writes. The skill runs +# it before declaring readiness / scheduling the next wake. +# +# Usage: +# babysit-readiness-gate.sh <pr> +# babysit-readiness-gate.sh <pr> --comments-json <file> # skip network (tests/reuse) +# babysit-readiness-gate.sh <pr> --checklist <file> # also gate R6 +# babysit-readiness-gate.sh <pr> --self 'login,login2' # self authors (else +# BABYSIT_SELF_LOGINS csv env + `gh api user` login) +# babysit-readiness-gate.sh --help +# +# Stdout (machine-readable, always emitted on a check run): +# READINESS_OK findings=<n> classified=<n> checklist=<clean|n/a> +# READINESS_BLOCKED reason=<under-decomposed|checklist-incomplete> findings=<n> classified=<n> unticked=<n> +# +# Exit codes: +# 0 ready (decomposition satisfied + checklist clean/absent) +# 1 blocked (READINESS_BLOCKED names the reason) +# 3 invalid argument +# 4 prerequisite missing (jq) + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +PR_NUMBER="" +COMMENTS_JSON="" +CHECKLIST="" +SELF_CSV="" + +usage() { + sed -n '2,47p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' + exit 0 +} + +# Assert a flag's value argument is present and not itself a flag, else exit 3. +require_value() { + [[ -n "${2:-}" && "$2" != -* ]] || { + printf 'babysit-readiness-gate: %s requires an argument\n' "$1" >&2 + exit 3 + } +} + +while (($# > 0)); do + case "$1" in + -h | --help) usage ;; + --comments-json) + require_value "$1" "${2:-}" + COMMENTS_JSON="$2" + shift 2 + ;; + --checklist) + require_value "$1" "${2:-}" + CHECKLIST="$2" + shift 2 + ;; + --self) + require_value "$1" "${2:-}" + SELF_CSV="$2" + shift 2 + ;; + -*) + printf 'babysit-readiness-gate: unknown flag %q (use --help)\n' "$1" >&2 + exit 3 + ;; + *) + if [[ -z "$PR_NUMBER" ]]; then + PR_NUMBER="$1" + else + printf 'babysit-readiness-gate: unexpected argument %q\n' "$1" >&2 + exit 3 + fi + shift + ;; + esac +done + +have() { command -v "$1" >/dev/null 2>&1; } +have jq || { + printf 'babysit-readiness-gate: jq required\n' >&2 + exit 4 +} + +if [[ -z "$PR_NUMBER" && -z "$COMMENTS_JSON" ]]; then + printf 'babysit-readiness-gate: <pr> required (or --comments-json <file>)\n' >&2 + exit 3 +fi + +# --- Resolve comments JSON (fixture file OR live fetch) ----------------------- + +COMMENTS="" +if [[ -n "$COMMENTS_JSON" ]]; then + if [[ ! -f "$COMMENTS_JSON" ]]; then + printf 'babysit-readiness-gate: --comments-json file not found: %s\n' "$COMMENTS_JSON" >&2 + exit 3 + fi + COMMENTS="$(cat "$COMMENTS_JSON")" +else + COMMENTS="$(bash "$SCRIPT_DIR/fetch-all-pr-comments.sh" "$PR_NUMBER")" || { + printf 'babysit-readiness-gate: fetch-all-pr-comments.sh failed for PR %s\n' "$PR_NUMBER" >&2 + exit 4 + } +fi + +# --- Resolve self authors (whose replies are the classification rows) --------- + +SELF_LOGINS=() +if [[ -n "$SELF_CSV" ]]; then + IFS=',' read -r -a SELF_LOGINS <<<"$SELF_CSV" +else + # BABYSIT_SELF_LOGINS (csv) covers extra posting identities — e.g. a project + # bot account whose replies carry the classification tables. + if [[ -n "${BABYSIT_SELF_LOGINS:-}" ]]; then + IFS=',' read -r -a SELF_LOGINS <<<"$BABYSIT_SELF_LOGINS" + fi + personal_login="$(gh api user --jq .login 2>/dev/null | tr -d '\r')" + [[ -n "$personal_login" ]] && SELF_LOGINS+=("$personal_login") +fi +if [[ ${#SELF_LOGINS[@]} -eq 0 ]]; then + printf 'babysit-readiness-gate: cannot resolve self identity (pass --self or set BABYSIT_SELF_LOGINS)\n' >&2 + exit 3 +fi +SELF_JSON="$(printf '%s\n' "${SELF_LOGINS[@]}" | jq -R . | jq -s .)" + +# --- Split bodies by author class -------------------------------------------- + +# Matches BOTH reviewer severity vocabularies, counting ONE marker per finding, +# PORTABLY. BSD grep on macOS does NOT support the `\b` word-boundary escape (it +# matches a literal "b"); POSIX `-w` whole-word matching works on GNU + BSD +# (codex r3327816802). claude uses the words CRITICAL|IMPORTANT|SUGGESTION, +# matched whole-word so "INVALID" is not a finding and the lowercase +# priority:p0-critical .. p3-low labels some repos use do not false-count. codex +# uses a P0|P1|P2|P3 shields.io badge (per babysit.md §5.0.4): keyed on the +# shield-URL segment `/badge/P{N}-`, which appears exactly once per finding (the +# alt-text `![PN Badge]` carries the token a second time, so a bare `P[0-3]` would # spellchecker:disable-line +# double-count), is the rigid badge-template structure, and is unambiguous — +# `/badge/P0-` never matches a lowercase priority:p0-critical label, so a codex P0 +# finding is counted (r3327701794). Words and badges are counted separately and +# summed because `-w` cannot bound the badge URL (its match starts right after a +# word char in `shields.io/badge/...`). +SEVERITY_WORDS_RE='CRITICAL|IMPORTANT|SUGGESTION' +SEVERITY_BADGE_RE='/badge/P[0-3]-' +CLASSIFY_RE='VALID|INCORRECT|UNCERTAIN' + +# Findings are counted across ALL comment bodies, not just non-self ones: in an +# interactive babysit run SELF_JSON includes the authenticated gh user, and a +# maintainer can author a SOURCE finding (a severity / badge), not only a +# classification reply. Counting findings over every body keeps those self +# findings visible (codex r3327878326). A classification reply carries a +# VALID/INCORRECT/UNCERTAIN token, NOT a severity/badge, so it never inflates the +# finding count; classifications are still counted only from self bodies. +all_bodies="$(printf '%s' "$COMMENTS" | + jq -r '.[] | .body // ""' 2>/dev/null)" +self_bodies="$(printf '%s' "$COMMENTS" | + jq -r --argjson self "$SELF_JSON" ' + .[] | select((.author as $a | $self | index($a))) | .body // ""' 2>/dev/null)" + +# grep -o ... | grep -c . counts OCCURRENCES (one match per output line), not +# input lines — a single line with two markers must count as two findings, or +# the gate under-counts and false-passes (the very R1 decomposition gap it gates). +# `-w` = POSIX whole-word (portable to BSD grep); the badge URL is counted with a +# plain `-o` grep (no `-w`) and summed in. +sev_words=$(printf '%s\n' "$all_bodies" | grep -owE "$SEVERITY_WORDS_RE" | grep -c . || true) +sev_badges=$(printf '%s\n' "$all_bodies" | grep -oE "$SEVERITY_BADGE_RE" | grep -c . || true) +classified=$(printf '%s\n' "$self_bodies" | grep -owE "$CLASSIFY_RE" | grep -c . || true) +sev_words=${sev_words//[^0-9]/} +sev_badges=${sev_badges//[^0-9]/} +classified=${classified//[^0-9]/} +findings=$((${sev_words:-0} + ${sev_badges:-0})) +classified=${classified:-0} + +# --- R6: checklist completeness ---------------------------------------------- + +unticked=0 +checklist_state="n/a" +if [[ -n "$CHECKLIST" ]]; then + if [[ ! -f "$CHECKLIST" ]]; then + printf 'babysit-readiness-gate: --checklist file not found: %s\n' "$CHECKLIST" >&2 + exit 3 + fi + unticked=$(grep -cE '^[[:space:]]*-[[:space:]]\[[[:space:]]\]' "$CHECKLIST" || true) + unticked=${unticked//[^0-9]/} + unticked=${unticked:-0} + ((unticked == 0)) && checklist_state="clean" || checklist_state="incomplete" +fi + +# --- Verdict ------------------------------------------------------------------ + +if ((findings > 0 && classified < findings)); then + printf 'READINESS_BLOCKED reason=under-decomposed findings=%s classified=%s unticked=%s\n' \ + "$findings" "$classified" "$unticked" + exit 1 +fi + +if ((unticked > 0)); then + printf 'READINESS_BLOCKED reason=checklist-incomplete findings=%s classified=%s unticked=%s\n' \ + "$findings" "$classified" "$unticked" + exit 1 +fi + +printf 'READINESS_OK findings=%s classified=%s checklist=%s\n' \ + "$findings" "$classified" "$checklist_state" +exit 0 diff --git a/plugins/source-control/skills/pull-request/scripts/babysit-readiness-gate.test.sh b/plugins/source-control/skills/pull-request/scripts/babysit-readiness-gate.test.sh new file mode 100644 index 000000000..6ca167822 --- /dev/null +++ b/plugins/source-control/skills/pull-request/scripts/babysit-readiness-gate.test.sh @@ -0,0 +1,216 @@ +#!/usr/bin/env bash +# Regression tests for babysit-readiness-gate.sh. +# Black-box: feed fixture comment JSON via --comments-json, control self authors +# via --self (no network). Asserts the decomposition + checklist verdicts. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +GATE="$SCRIPT_DIR/babysit-readiness-gate.sh" +TEST_TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TEST_TMPDIR"' EXIT + +FAILED=0 +CASE_NUM=0 +# shellcheck source=test-helpers.sh +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/test-helpers.sh" + +# run_gate <fixture-json> [extra args...] — emits stdout then "EXIT:<code>". +run_gate() { + local fixture="$1" + shift + local out + out=$(bash "$GATE" 123 --comments-json "$fixture" --self 'me[bot]' "$@" 2>/dev/null) + local code=$? + printf '%s\nEXIT:%s' "$out" "$code" +} + +mkjson() { # mkjson <name> <jq-array-expr> + local f="$TEST_TMPDIR/$1.json" + jq -n "$2" >"$f" + printf '%s' "$f" +} + +# --- Case: --help --- +help_out=$(bash "$GATE" --help 2>&1) +assert_exit "--help exit 0" 0 "$?" +assert_contains "--help describes the gate" "$help_out" "readiness pre-gate" + +# --- Case: unknown flag --- +bash "$GATE" 123 --bogus >/dev/null 2>&1 +assert_exit "unknown flag exit 3" 3 "$?" + +# --- Case: no pr + no comments-json --- +bash "$GATE" >/dev/null 2>&1 +assert_exit "no args exit 3" 3 "$?" + +# --- Case: under-decomposed (three findings, one classification) --- +F=$(mkjson under '[ + {author:"claude[bot]", body:"### 1. [CRITICAL] a\n### 2. [IMPORTANT] b\n### 3. [SUGGESTION] c"}, + {author:"me[bot]", body:"| 1 | a | VALID | fixed |"} +]') +r=$(run_gate "$F") +assert_contains "under -> BLOCKED under-decomposed" "$r" "READINESS_BLOCKED reason=under-decomposed" +assert_contains "under findings=3" "$r" "findings=3" +assert_contains "under classified=1" "$r" "classified=1" +assert_contains "under exit 1" "$r" "EXIT:1" + +# --- Case: fully decomposed (2 findings, 2 classifications) --- +F=$(mkjson ok '[ + {author:"claude[bot]", body:"### 1. [CRITICAL] a\n### 2. [IMPORTANT] b"}, + {author:"me[bot]", body:"| 1 | a | VALID | fixed |\n| 2 | b | INCORRECT | refuted |"} +]') +r=$(run_gate "$F") +assert_contains "ok -> READINESS_OK" "$r" "READINESS_OK" +assert_contains "ok findings=2 classified=2" "$r" "findings=2 classified=2" +assert_contains "ok exit 0" "$r" "EXIT:0" + +# --- Case: zero findings (nothing to decompose) --- +F=$(mkjson empty '[ + {author:"human", body:"LGTM, nice work"}, + {author:"me[bot]", body:"thanks"} +]') +r=$(run_gate "$F") +assert_contains "zero-findings -> READINESS_OK" "$r" "READINESS_OK" +assert_contains "zero-findings findings=0" "$r" "findings=0" +assert_contains "zero-findings exit 0" "$r" "EXIT:0" + +# --- Case: checklist incomplete (decomposed, but unticked box) --- +F=$(mkjson ckl '[ + {author:"claude[bot]", body:"### 1. [CRITICAL] a"}, + {author:"me[bot]", body:"| 1 | a | VALID | fixed |"} +]') +CK="$TEST_TMPDIR/checklist.md" +printf -- '- [x] done\n- [ ] not done\n' >"$CK" +r=$(run_gate "$F" --checklist "$CK") +assert_contains "checklist-incomplete -> BLOCKED" "$r" "READINESS_BLOCKED reason=checklist-incomplete" +assert_contains "checklist unticked=1" "$r" "unticked=1" +assert_contains "checklist-incomplete exit 1" "$r" "EXIT:1" + +# --- Case: checklist clean --- +CK2="$TEST_TMPDIR/checklist-clean.md" +printf -- '- [x] done\n- [x] also done\n' >"$CK2" +r=$(run_gate "$F" --checklist "$CK2") +assert_contains "checklist-clean -> READINESS_OK" "$r" "READINESS_OK" +assert_contains "checklist-clean state" "$r" "checklist=clean" +assert_contains "checklist-clean exit 0" "$r" "EXIT:0" + +# --- Case: two findings on ONE line count as 2, not 1 (occurrence counting) -- +# Old `grep -cE` counted the line once (findings=1) and false-passed; occurrence +# counting reports findings=2, correctly BLOCKING when only 1 is classified. +F=$(mkjson multiline '[ + {author:"claude[bot]", body:"CRITICAL: fix null check AND IMPORTANT: add validation"}, + {author:"me[bot]", body:"| 1 | null check | VALID | fixed |"} +]') +r=$(run_gate "$F") +assert_contains "one-line-two-findings -> BLOCKED" "$r" "READINESS_BLOCKED reason=under-decomposed" +assert_contains "one-line-two-findings findings=2" "$r" "findings=2" + +# --- Case: priority labels / lowercase / INVALID are NOT counted --- +# "P0" (dropped from SEVERITY_RE), "p0-critical" (lowercase, RE is case-sensitive) +# and "INVALID" (word-boundary) must all count as zero — old regex false-counted. +F=$(mkjson nofalsepos '[ + {author:"human", body:"P0 blocker on priority:p0-critical, prior call was INVALID"}, + {author:"me[bot]", body:"acknowledged"} +]') +r=$(run_gate "$F") +assert_contains "no-false-positive findings=0" "$r" "findings=0" +assert_contains "no-false-positive -> READINESS_OK" "$r" "READINESS_OK" + +# --- Case: codex P-severity findings ARE counted --------------------------- +# Codex marks findings with P1/P2/P3 shields.io badges, not CRITICAL/IMPORTANT/ +# SUGGESTION. Dropping P[0-3] entirely blinded the gate to codex-only findings +# (findings=0 -> false READINESS_OK). Keying on the shield-URL `/badge/PN-` +# counts each once without re-matching lowercase priority:p0-critical labels. +# Two real badges (with URLs) -> 2. Uses the production badge markdown. +F=$(mkjson codexfmt '[ + {author:"chatgpt-codex-connector[bot]", body:"![P1 Badge](https://img.shields.io/badge/P1-red?style=flat) Count findings AND ![P2 Badge](https://img.shields.io/badge/P2-yellow?style=flat) validate port"}, + {author:"me[bot]", body:"| 1 | count | VALID | fixed |"} +]') +r=$(run_gate "$F") +assert_contains "codex P-severity -> BLOCKED (findings counted)" "$r" "READINESS_BLOCKED reason=under-decomposed" +assert_contains "codex P-severity findings=2" "$r" "findings=2" + +# --- Case: ONE codex badge counts ONCE --------------------------------------- +# Real codex badge markdown carries the severity token twice: alt-text +# `![P2 Badge]` AND shield URL `/badge/P2-yellow`. A bare `P[1-3]` regex counted +# both -> findings=2 for ONE finding -> gate stayed BLOCKED after the single +# finding was classified. Keying on the shield-URL `/badge/PN-` counts it once. +F=$(mkjson codexbadgeurl '[ + {author:"chatgpt-codex-connector[bot]", body:"**<sub><sub>![P2 Badge](https://img.shields.io/badge/P2-yellow?style=flat)</sub></sub> Propagate failed restarts**"}, + {author:"me[bot]", body:"| 1 | propagate | VALID | fixed |"} +]') +r=$(run_gate "$F") +assert_contains "codex-badge-with-url findings=1 (not double-counted)" "$r" "findings=1" +assert_contains "codex-badge-with-url -> READINESS_OK" "$r" "READINESS_OK" + +# --- Case: codex P0 badge IS counted ----------------------------------------- +# P0 was excluded under the old bare-P[1-3] / alt-text approach to avoid matching +# lowercase priority:p0-critical labels. The shield-URL form `/badge/P0-` is +# unambiguous, so a codex P0 finding must count — else an unclassified P0 +# false-passes the gate. +F=$(mkjson codexp0 '[ + {author:"chatgpt-codex-connector[bot]", body:"**<sub><sub>![P0 Badge](https://img.shields.io/badge/P0-red?style=flat)</sub></sub> Critical finding**"}, + {author:"me[bot]", body:"acknowledged, investigating"} +]') +r=$(run_gate "$F") +assert_contains "codex-P0 findings=1 (counted, not dropped)" "$r" "findings=1" +assert_contains "codex-P0 unclassified -> BLOCKED" "$r" "READINESS_BLOCKED reason=under-decomposed" + +# --- Case: lowercase priority:p0-critical still does NOT false-count ---------- +# The URL form `/badge/P0-` must not match a lowercase priority label. +F=$(mkjson p0label '[ + {author:"human", body:"this is priority:p0-critical per our triage"}, + {author:"me[bot]", body:"noted"} +]') +r=$(run_gate "$F") +assert_contains "p0-label findings=0 (no false-count)" "$r" "findings=0" + +# --- Case: self-authored SOURCE finding IS counted --------------------------- +# In interactive runs SELF includes the gh user; a self-authored FINDING (a +# severity, not a classification reply) must still count — else it false-passes +# the gate. me[bot] is in SELF, so under the old non-self-only count this CRITICAL +# was dropped (findings=0 -> READINESS_OK); counting over ALL bodies catches it. +F=$(mkjson selffinding '[ + {author:"me[bot]", body:"### 1. [CRITICAL] a self-authored source finding"}, + {author:"human", body:"ack"} +]') +r=$(run_gate "$F") +assert_contains "self-finding counted -> findings=1" "$r" "findings=1" +assert_contains "self-finding unclassified -> BLOCKED" "$r" "READINESS_BLOCKED reason=under-decomposed" + +# --- Case: self CLASSIFICATION reply does NOT inflate findings ---------------- +# A self classification reply carries VALID/INCORRECT/UNCERTAIN, not a severity, +# so counting findings over ALL bodies must not turn it into a finding. +F=$(mkjson selfclass '[ + {author:"claude[bot]", body:"### 1. [CRITICAL] a"}, + {author:"me[bot]", body:"| 1 | a | VALID | fixed |"} +]') +r=$(run_gate "$F") +assert_contains "self-classification not a finding -> findings=1" "$r" "findings=1" +assert_contains "self-classification -> READINESS_OK" "$r" "READINESS_OK" + +# --- Case: --comments-json without an argument -> exit 3 --------------------- +bash "$GATE" 123 --comments-json >/dev/null 2>&1 +assert_exit "--comments-json missing arg exit 3" 3 "$?" + +# --- Case: portable whole-word matching, no \b (BSD grep) -------------------- +# BSD grep on macOS does not support the `\b` escape; the gate must use POSIX +# `-w` whole-word matching, which works on GNU + BSD. +GATE_BODY=$(cat "$GATE") +if [[ "$GATE_BODY" == *'grep -owE'* ]]; then + pass "portability: uses POSIX grep -ow whole-word matching" +else + fail "portability: uses POSIX grep -ow" "present" "missing" +fi + +# --- Case: adjacent severity words BOTH count (whole-word, no shared-boundary loss) - +# `grep -ow` matches each word even when adjacent; an alternation-boundary regex +# (^|[^w])WORD([^w]|$) would consume the shared space and undercount. +F=$(mkjson adjacent '[ + {author:"claude[bot]", body:"CRITICAL IMPORTANT both flagged"}, + {author:"me[bot]", body:"| 1 | a | VALID | fixed |"} +]') +r=$(run_gate "$F") +assert_contains "adjacent words -> findings=2" "$r" "findings=2" + +[[ $FAILED -eq 0 ]] || exit 1 diff --git a/plugins/source-control/skills/pull-request/scripts/discover-prs.sh b/plugins/source-control/skills/pull-request/scripts/discover-prs.sh new file mode 100644 index 000000000..9b59bfcfa --- /dev/null +++ b/plugins/source-control/skills/pull-request/scripts/discover-prs.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# discover-prs.sh — deterministic PR discovery for the babysit loop. +# +# Implements the babysit "§5.0.2 PR discovery" filter as a script: lists OPEN +# PRs, drops drafts, returns oldest-first (lowest PR number first / FIFO). +# Dependabot and every other author are in scope (babysit covers all PR authors). +# Per babysit.md §5.0.2. +# +# The raw PR array is resolved from EITHER a fixture file (--prs-json, for +# offline tests / reuse) OR a live `gh pr list` call. The FILTER itself runs +# in this script body (NOT in `gh --jq`) so the fixture path exercises the +# exact same filter logic the live path does — that split is what makes the +# black-box test offline-capable. +# +# Owner/repo are NOT hardcoded: `gh pr list` auto-resolves the repository from +# the local git remote, so no -R flag or `gh repo view` round-trip is needed. +# +# Usage: +# discover-prs.sh # live: gh pr list, then filter +# discover-prs.sh --prs-json <file> # offline: read raw array from file +# discover-prs.sh --help +# +# Output (stdout): JSON array of {number,title,headRefName,isDraft,author} +# objects, drafts + Dependabot removed, sorted ascending by number. Empty +# discovery yields `[]`. +# +# Exit codes: +# 0 success (zero or more PRs emitted) +# 1 invalid argument or malformed input +# 2 gh api call failed +# 5 prerequisite missing (gh, jq) + +set -uo pipefail # -e omitted: gh failure explicitly guarded with || { exit N } + +PRS_JSON="" + +usage() { + sed -n '2,31p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' + exit 0 +} + +while (($# > 0)); do + case "$1" in + -h | --help) usage ;; + --prs-json) + if [[ $# -lt 2 ]]; then + printf 'discover-prs: --prs-json requires an argument (use --help)\n' >&2 + exit 1 + fi + PRS_JSON="$2" + shift 2 + ;; + -*) + printf 'discover-prs: unknown flag %q (use --help)\n' "$1" >&2 + exit 1 + ;; + *) + printf 'discover-prs: unexpected argument %q (use --help)\n' "$1" >&2 + exit 1 + ;; + esac +done + +have() { command -v "$1" >/dev/null 2>&1; } +have jq || { + printf 'discover-prs: jq required\n' >&2 + exit 5 +} + +# --- Resolve raw PR array (fixture file OR live fetch) ------------------------ + +RAW="" +if [[ -n "$PRS_JSON" ]]; then + if [[ ! -f "$PRS_JSON" ]]; then + printf 'discover-prs: --prs-json file not found: %s\n' "$PRS_JSON" >&2 + exit 1 + fi + RAW="$(cat "$PRS_JSON")" +else + have gh || { + printf 'discover-prs: gh CLI required\n' >&2 + exit 5 + } + # No --jq here: the filter runs below so the fixture path tests the same + # logic. Repo auto-resolved from the local git remote (no hardcode). + RAW="$(gh pr list --state open --limit 200 \ + --json number,title,headRefName,isDraft,author 2>/dev/null)" || { + printf 'discover-prs: gh pr list failed\n' >&2 + exit 2 + } +fi + +# --- Validate input is an array, then filter --------------------------------- + +if ! printf '%s' "$RAW" | jq -e 'type == "array"' >/dev/null 2>&1; then + printf 'discover-prs: malformed PR JSON (expected an array)\n' >&2 + exit 1 +fi + +# Drop drafts only, sort oldest-first (lowest number first / FIFO). Dependabot +# and every other author are in scope — babysit ALL open PRs so CI-failing +# dependency PRs get diagnosed + fixed too, not just auto-merged. Empty discovery +# yields `[]` and exits 0. Mirrors babysit.md §5.0.2 verbatim. +if ! printf '%s' "$RAW" | jq ' + [.[] + | select(.isDraft == false)] + | sort_by(.number) +'; then + printf 'discover-prs: jq failed filtering PR JSON\n' >&2 + exit 1 +fi + +exit 0 diff --git a/plugins/source-control/skills/pull-request/scripts/discover-prs.test.sh b/plugins/source-control/skills/pull-request/scripts/discover-prs.test.sh new file mode 100644 index 000000000..d55a18c12 --- /dev/null +++ b/plugins/source-control/skills/pull-request/scripts/discover-prs.test.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# Regression tests for discover-prs.sh. +# Black-box: feed fixture PR JSON via --prs-json (no network). Asserts the +# §5.0.2 filter contract — drop drafts, oldest-first. Dependabot (and every +# other author) is in scope; no author filter (babysit covers all PR authors). +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DISCOVER="$SCRIPT_DIR/discover-prs.sh" +TEST_TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TEST_TMPDIR"' EXIT + +FAILED=0 +CASE_NUM=0 +# shellcheck source=test-helpers.sh +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/test-helpers.sh" + +# run_discover <fixture-json> — emits stdout then "EXIT:<code>". +run_discover() { + local fixture="$1" + local out + out=$(bash "$DISCOVER" --prs-json "$fixture" 2>/dev/null) + local code=$? + printf '%s\nEXIT:%s' "$out" "$code" +} + +# numbers_of <fixture-json> — output PR numbers as a comma-joined string. +numbers_of() { + local fixture="$1" + bash "$DISCOVER" --prs-json "$fixture" 2>/dev/null | jq -r '[.[].number] | join(",")' +} + +mkjson() { # mkjson <name> <jq-array-expr> + local f="$TEST_TMPDIR/$1.json" + jq -n "$2" >"$f" + printf '%s' "$f" +} + +# --- Case: --help exits 0, non-empty, describes the skill --- +help_out=$(bash "$DISCOVER" --help 2>&1) +assert_exit "--help exit 0" 0 "$?" +assert_contains "--help non-empty / describes discovery" "$help_out" "PR discovery" + +# --- Case: unknown flag --- +bash "$DISCOVER" --bogus >/dev/null 2>&1 +assert_exit "unknown flag exit 1" 1 "$?" + +# --- Case: unexpected positional argument --- +bash "$DISCOVER" 123 >/dev/null 2>&1 +assert_exit "positional arg exit 1" 1 "$?" + +# --- Case: missing fixture file --- +bash "$DISCOVER" --prs-json "$TEST_TMPDIR/nope.json" >/dev/null 2>&1 +assert_exit "missing fixture exit 1" 1 "$?" + +# --- Case: --prs-json with no argument must error, not hang --- +# Regression: `shift 2` with $#==1 under `set -uo pipefail` (no -e) left $# +# unchanged and looped forever (exit 124). The guard must exit 1 immediately. +# timeout bounds the regression so a reintroduced hang fails the suite rather +# than blocking it; degrade to skip on platforms lacking timeout (BSD macOS). +if command -v timeout >/dev/null 2>&1; then + timeout 5 bash "$DISCOVER" --prs-json >/dev/null 2>&1 + assert_exit "--prs-json without arg errors (no infinite loop)" 1 "$?" +else + skip_case "timeout unavailable — cannot bound --prs-json infinite-loop regression" +fi + +# --- Case: combined filter — drop draft only, oldest-first; Dependabot KEPT --- +# Out-of-order numbers, one draft, one Dependabot PR mixed in. The draft (#18) +# is dropped; the Dependabot PR (#5) is KEPT. Surviving set {5, 7, 12, 30} in +# ascending order proves drop-draft + keep-all-authors + oldest-first together. +F=$(mkjson combined '[ + {number:30, title:"c", headRefName:"feat/c", isDraft:false, author:{login:"alice"}}, + {number:7, title:"a", headRefName:"feat/a", isDraft:false, author:{login:"bob"}}, + {number:18, title:"draft", headRefName:"feat/d", isDraft:true, author:{login:"carol"}}, + {number:5, title:"dep", headRefName:"dependabot/nuget/x", isDraft:false, author:{login:"app/dependabot"}}, + {number:12, title:"b", headRefName:"feat/b", isDraft:false, author:{login:"alice"}} +]') +assert_eq "combined -> drop draft, keep dependabot, oldest-first" "5,7,12,30" "$(numbers_of "$F")" +r=$(run_discover "$F") +assert_contains "combined exit 0" "$r" "EXIT:0" + +# --- Case: Dependabot PR is KEPT; only the draft is dropped (codex r3327055703) - +# Babysit covers ALL PR authors incl Dependabot. The non-draft +# Dependabot PR (#3) survives; the draft (#2) is dropped. +F=$(mkjson depkept '[ + {number:2, title:"draft", headRefName:"feat/d", isDraft:true, author:{login:"alice"}}, + {number:3, title:"dep", headRefName:"dependabot/npm/y", isDraft:false, author:{login:"app/dependabot"}} +]') +assert_eq "dependabot kept, draft dropped" "3" "$(numbers_of "$F")" +r=$(run_discover "$F") +assert_contains "dependabot-kept exit 0" "$r" "EXIT:0" + +# --- Case: all-draft input -> empty list ([] from non-empty input) --- +F=$(mkjson alldraft '[ + {number:2, title:"draft", headRefName:"feat/d", isDraft:true, author:{login:"alice"}} +]') +assert_eq "all-draft -> empty number list" "" "$(numbers_of "$F")" +r=$(run_discover "$F") +assert_contains "all-draft -> []" "$r" "[]" +assert_contains "all-draft exit 0" "$r" "EXIT:0" + +# --- Case: empty discovery (zero open PRs) --- +F=$(mkjson empty '[]') +r=$(run_discover "$F") +assert_contains "empty -> []" "$r" "[]" +assert_contains "empty exit 0" "$r" "EXIT:0" + +# --- Case: null author kept (no author filter at all) --- +# Only drafts are dropped, so a PR with a null author survives regardless. +F=$(mkjson nullauthor '[ + {number:9, title:"ghost", headRefName:"feat/g", isDraft:false, author:null} +]') +assert_eq "null-author kept" "9" "$(numbers_of "$F")" + +# --- Case: malformed input (object, not array) --- +F=$(mkjson malformed '{number:1}') +bash "$DISCOVER" --prs-json "$F" >/dev/null 2>&1 +assert_exit "malformed input exit 1" 1 "$?" + +[[ $FAILED -eq 0 ]] || exit 1 diff --git a/plugins/source-control/skills/pull-request/scripts/fetch-all-pr-comments.sh b/plugins/source-control/skills/pull-request/scripts/fetch-all-pr-comments.sh new file mode 100644 index 000000000..ecd1c16ed --- /dev/null +++ b/plugins/source-control/skills/pull-request/scripts/fetch-all-pr-comments.sh @@ -0,0 +1,183 @@ +#!/usr/bin/env bash +# fetch-all-pr-comments.sh — Fetch ALL PR comments across 3 GitHub API surfaces. +# +# Deterministic replacement for agent judgment in endpoint selection. +# Hits all three surfaces that can carry review feedback: +# 1. General PR comments (issue-level conversation tab) +# 2. Review-level comments (review summaries) +# 3. Inline review comments (line-anchored on the diff) +# +# Output: unified JSON array sorted by creation date. Each object: +# {"id":N,"type":"general|review|inline","author":"login","body":"...","path":"...","line":N,"created_at":"ISO"} +# +# Usage: +# fetch-all-pr-comments.sh <pr-number> +# +# Env overrides: +# FETCH_COMMENTS_OWNER default `gh repo view --json owner -q .owner.login` +# FETCH_COMMENTS_REPO default `gh repo view --json name -q .name` +# +# Exit codes: +# 0 success (zero or more comments emitted) +# 1 invalid argument +# 2 gh api call failed +# 5 prerequisite missing (gh, jq) + +set -uo pipefail # -e omitted: gh api failures explicitly guarded with || { exit N } + +# --- Argument parsing -------------------------------------------------------- + +PR_NUMBER="" + +usage() { + sed -n '2,18p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' + exit 0 +} + +while (($# > 0)); do + case "$1" in + -h | --help) usage ;; + --) + shift + if [[ -z "$PR_NUMBER" && $# -gt 0 ]]; then + PR_NUMBER="$1" + shift + fi + if [[ $# -gt 0 ]]; then + printf 'fetch-all-pr-comments: unexpected argument after --: %q\n' "$1" >&2 + exit 1 + fi + break + ;; + -*) + printf 'fetch-all-pr-comments: unknown flag %q (use --help)\n' "$1" >&2 + exit 1 + ;; + *) + if [[ -z "$PR_NUMBER" ]]; then + PR_NUMBER="$1" + else + printf 'fetch-all-pr-comments: unexpected argument %q\n' "$1" >&2 + exit 1 + fi + shift + ;; + esac +done + +if [[ -z "$PR_NUMBER" ]]; then + printf 'fetch-all-pr-comments: <pr-number> required\n' >&2 + exit 1 +fi + +# --- Prerequisites ----------------------------------------------------------- + +have() { command -v "$1" >/dev/null 2>&1; } + +have gh || { + printf 'fetch-all-pr-comments: gh CLI required\n' >&2 + exit 5 +} +have jq || { + printf 'fetch-all-pr-comments: jq required\n' >&2 + exit 5 +} + +# --- Repo resolution --------------------------------------------------------- + +if [[ -n "${FETCH_COMMENTS_OWNER:-}" ]]; then + OWNER="$FETCH_COMMENTS_OWNER" +else + OWNER=$(gh repo view --json owner -q .owner.login 2>/dev/null | tr -d '\r\n') +fi + +if [[ -n "${FETCH_COMMENTS_REPO:-}" ]]; then + REPO="$FETCH_COMMENTS_REPO" +else + REPO=$(gh repo view --json name -q .name 2>/dev/null | tr -d '\r\n') +fi + +if [[ -z "$OWNER" || -z "$REPO" ]]; then + printf 'fetch-all-pr-comments: cannot resolve owner/repo (set FETCH_COMMENTS_OWNER + FETCH_COMMENTS_REPO)\n' >&2 + exit 2 +fi + +# --- Surface 1: General PR comments (issue-level) ---------------------------- + +GENERAL_RAW=$(gh api --paginate "repos/$OWNER/$REPO/issues/$PR_NUMBER/comments" 2>/dev/null) || { + printf 'fetch-all-pr-comments: gh api issues/%s/comments failed\n' "$PR_NUMBER" >&2 + exit 2 +} + +if ! GENERAL=$(printf '%s' "$GENERAL_RAW" | jq -c ' + if type == "array" then .[] else . end + | { + id: .id, + type: "general", + author: .user.login, + body: .body, + path: null, + line: null, + created_at: .created_at + } +' 2>/dev/null); then + printf 'fetch-all-pr-comments: jq failed parsing issues/%s/comments response\n' "$PR_NUMBER" >&2 + exit 2 +fi + +# --- Surface 2: Review-level comments ---------------------------------------- + +REVIEWS_RAW=$(gh api --paginate "repos/$OWNER/$REPO/pulls/$PR_NUMBER/reviews" 2>/dev/null) || { + printf 'fetch-all-pr-comments: gh api pulls/%s/reviews failed\n' "$PR_NUMBER" >&2 + exit 2 +} + +if ! REVIEWS=$(printf '%s' "$REVIEWS_RAW" | jq -c ' + if type == "array" then .[] else . end + | select(.body != null and .body != "") + | { + id: .id, + type: "review", + author: .user.login, + body: .body, + path: null, + line: null, + created_at: (.submitted_at // .created_at) + } +' 2>/dev/null); then + printf 'fetch-all-pr-comments: jq failed parsing pulls/%s/reviews response\n' "$PR_NUMBER" >&2 + exit 2 +fi + +# --- Surface 3: Inline review comments (line-anchored) ----------------------- + +INLINE_RAW=$(gh api --paginate "repos/$OWNER/$REPO/pulls/$PR_NUMBER/comments" 2>/dev/null) || { + printf 'fetch-all-pr-comments: gh api pulls/%s/comments failed\n' "$PR_NUMBER" >&2 + exit 2 +} + +if ! INLINE=$(printf '%s' "$INLINE_RAW" | jq -c ' + if type == "array" then .[] else . end + | { + id: .id, + type: "inline", + author: .user.login, + body: .body, + path: .path, + line: (.line // .original_line), + created_at: .created_at + } +' 2>/dev/null); then + printf 'fetch-all-pr-comments: jq failed parsing pulls/%s/comments response\n' "$PR_NUMBER" >&2 + exit 2 +fi + +# --- Merge and sort by created_at -------------------------------------------- + +{ + [[ -n "$GENERAL" ]] && printf '%s\n' "$GENERAL" + [[ -n "$REVIEWS" ]] && printf '%s\n' "$REVIEWS" + [[ -n "$INLINE" ]] && printf '%s\n' "$INLINE" +} | jq -s 'sort_by(.created_at)' + +exit 0 diff --git a/plugins/source-control/skills/pull-request/scripts/fetch-all-pr-comments.test.sh b/plugins/source-control/skills/pull-request/scripts/fetch-all-pr-comments.test.sh new file mode 100644 index 000000000..fc254bfc3 --- /dev/null +++ b/plugins/source-control/skills/pull-request/scripts/fetch-all-pr-comments.test.sh @@ -0,0 +1,206 @@ +#!/usr/bin/env bash +# Regression tests for fetch-all-pr-comments.sh. +# +# Black-box: invokes the script as a subprocess with a stubbed `gh` on PATH. +# Covers: +# +# 1. All 3 surfaces appear in output (general, review, inline) +# 2. Output is sorted by created_at +# 3. Missing PR number — exits 1 +# 4. gh api failure — exits 2 +# 5. Output schema — every object has required fields +# 6. Empty PR (no comments) — exits 0 with empty array + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPT="$SCRIPT_DIR/fetch-all-pr-comments.sh" +TEST_TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TEST_TMPDIR"' EXIT + +# shellcheck source=test-helpers.sh +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/test-helpers.sh" + +command -v jq >/dev/null 2>&1 || skip_suite "jq not installed" + +# ---- Build fixtures --------------------------------------------------------- + +# Surface 1: issue-level comments +FIXTURE_GENERAL="$TEST_TMPDIR/general.json" +cat >"$FIXTURE_GENERAL" <<'JSON' +[ + { + "id": 1001, + "user": {"login": "alice"}, + "body": "Looks good overall", + "created_at": "2026-05-20T10:00:00Z" + } +] +JSON + +# Surface 2: reviews (one with body, one empty — empty should be filtered) +FIXTURE_REVIEWS="$TEST_TMPDIR/reviews.json" +cat >"$FIXTURE_REVIEWS" <<'JSON' +[ + { + "id": 2001, + "user": {"login": "bob"}, + "body": "Request changes on auth flow", + "submitted_at": "2026-05-20T11:00:00Z" + }, + { + "id": 2002, + "user": {"login": "charlie"}, + "body": "", + "submitted_at": "2026-05-20T11:30:00Z" + } +] +JSON + +# Surface 3: inline review comments +FIXTURE_INLINE="$TEST_TMPDIR/inline.json" +cat >"$FIXTURE_INLINE" <<'JSON' +[ + { + "id": 3001, + "user": {"login": "codex[bot]"}, + "body": "Missing null check here", + "path": "src/Auth.cs", + "line": 42, + "original_line": null, + "created_at": "2026-05-20T09:30:00Z" + }, + { + "id": 3002, + "user": {"login": "codex[bot]"}, + "body": "Stale count reference", + "path": "src/Counter.cs", + "line": null, + "original_line": 15, + "created_at": "2026-05-20T12:00:00Z" + } +] +JSON + +# ---- Build gh stub ---------------------------------------------------------- + +PR_NUM=42 + +GH_STUB="$TEST_TMPDIR/bin/gh" +mkdir -p "$TEST_TMPDIR/bin" +cat >"$GH_STUB" <<STUB +#!/usr/bin/env bash +case "\$*" in + *"repo view"*owner*) + printf 'testowner' + ;; + *"repo view"*name*) + printf 'testrepo' + ;; + *issues/${PR_NUM}/comments*) + cat "$FIXTURE_GENERAL" + ;; + *pulls/${PR_NUM}/reviews*) + cat "$FIXTURE_REVIEWS" + ;; + *pulls/${PR_NUM}/comments*) + cat "$FIXTURE_INLINE" + ;; + *) + printf 'gh stub: unhandled: %s\n' "\$*" >&2 + exit 1 + ;; +esac +STUB +chmod +x "$GH_STUB" + +# ---- Tests ------------------------------------------------------------------ + +# Case 1: missing PR number — capture exit code via subshell +rc=$( + PATH="$TEST_TMPDIR/bin:$PATH" bash "$SCRIPT" 2>/dev/null + echo $? +) +assert_eq "missing PR number exits 1" "1" "$rc" + +# Case 2: all 3 surfaces in output +out=$(PATH="$TEST_TMPDIR/bin:$PATH" bash "$SCRIPT" "$PR_NUM" 2>/dev/null) +rc=$? +assert_eq "success exit 0" "0" "$rc" + +type_count=$(printf '%s' "$out" | jq '[.[] | .type] | unique | length') +assert_eq "3 surface types present" "3" "$type_count" + +has_general=$(printf '%s' "$out" | jq '[.[] | select(.type == "general")] | length') +assert_eq "general comments present" "1" "$has_general" + +has_review=$(printf '%s' "$out" | jq '[.[] | select(.type == "review")] | length') +assert_eq "review comments present (empty filtered)" "1" "$has_review" + +has_inline=$(printf '%s' "$out" | jq '[.[] | select(.type == "inline")] | length') +assert_eq "inline comments present" "2" "$has_inline" + +# Case 3: sorted by created_at (earliest first) +first_created=$(printf '%s' "$out" | jq -r '.[0].created_at') +last_created=$(printf '%s' "$out" | jq -r '.[-1].created_at') +assert_eq "first is earliest" "2026-05-20T09:30:00Z" "$first_created" +assert_eq "last is latest" "2026-05-20T12:00:00Z" "$last_created" + +# Case 4: schema check — all objects have required fields +missing_fields=$(printf '%s' "$out" | jq '[.[] | select(.id == null or .type == null or .author == null or .body == null or .created_at == null)] | length') +assert_eq "all objects have required fields" "0" "$missing_fields" + +# Case 5: inline comments carry path and line +inline_with_path=$(printf '%s' "$out" | jq '[.[] | select(.type == "inline" and .path != null)] | length') +assert_eq "inline comments have path" "2" "$inline_with_path" + +# Case 6: fallback to original_line when line is null +fallback_line=$(printf '%s' "$out" | jq '[.[] | select(.id == 3002)] | .[0].line') +assert_eq "original_line fallback" "15" "$fallback_line" + +# Case 7: gh api failure exits 2 +FAIL_STUB="$TEST_TMPDIR/bin-fail/gh" +mkdir -p "$TEST_TMPDIR/bin-fail" +cat >"$FAIL_STUB" <<'STUB' +#!/usr/bin/env bash +case "$*" in + *"repo view"*"owner"*) printf 'testowner' ;; + *"repo view"*"name"*) printf 'testrepo' ;; + *) exit 1 ;; +esac +STUB +chmod +x "$FAIL_STUB" + +rc=$( + PATH="$TEST_TMPDIR/bin-fail:$PATH" bash "$SCRIPT" "$PR_NUM" 2>/dev/null + echo $? +) +assert_eq "gh api failure exits 2" "2" "$rc" + +# Case 8: empty PR (no comments on any surface) +EMPTY_STUB="$TEST_TMPDIR/bin-empty/gh" +mkdir -p "$TEST_TMPDIR/bin-empty" +cat >"$EMPTY_STUB" <<'STUB' +#!/usr/bin/env bash +case "$*" in + *"repo view"*"owner"*) printf 'testowner' ;; + *"repo view"*"name"*) printf 'testrepo' ;; + *) printf '[]' ;; +esac +STUB +chmod +x "$EMPTY_STUB" + +out=$(PATH="$TEST_TMPDIR/bin-empty:$PATH" bash "$SCRIPT" "$PR_NUM" 2>/dev/null) +rc=$? +assert_eq "empty PR exits 0" "0" "$rc" +empty_count=$(printf '%s' "$out" | jq 'length') +assert_eq "empty PR returns empty array" "0" "$empty_count" + +# ---- Summary ---------------------------------------------------------------- + +if [[ "$FAILED" -eq 0 ]]; then + printf '\nAll %d checks passed.\n' "$CASE_NUM" + exit 0 +fi +printf '\n%d/%d checks failed.\n' "$FAILED" "$CASE_NUM" >&2 +exit 1 diff --git a/plugins/source-control/skills/pull-request/scripts/fetch-annotations.sh b/plugins/source-control/skills/pull-request/scripts/fetch-annotations.sh new file mode 100644 index 000000000..5fe5d77d9 --- /dev/null +++ b/plugins/source-control/skills/pull-request/scripts/fetch-annotations.sh @@ -0,0 +1,179 @@ +#!/usr/bin/env bash +# fetch-annotations.sh — Fetch GitHub Actions check-run annotations for a PR. +# +# Annotations carry path/line/level/title/message data the agent uses to +# locate fix sites without grepping log files. Output is JSONL (one +# annotation per line) for easy programmatic consumption + cheap to scan +# visually. +# +# The agent uses this when classifying CI failures (see +# reference/monitor.md §3.2 of the pull-request skill). +# +# Usage: +# fetch-annotations.sh <pr-number> # all check-runs on PR head SHA +# fetch-annotations.sh <pr-number> --failed # only failure-conclusion check-runs +# +# Env overrides: +# FETCH_ANNOT_REPO default `gh repo view --json nameWithOwner -q .nameWithOwner` +# +# Exit codes: +# 0 success (zero or more annotations emitted) +# 1 invalid argument +# 2 gh api call failed +# 5 prerequisite missing (gh, jq) + +set -uo pipefail + +# --- Argument parsing -------------------------------------------------------- + +PR_NUMBER="" +FAILED_ONLY=0 + +usage() { + sed -n '2,20p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' + exit 0 +} + +while (($# > 0)); do + case "$1" in + -h | --help) usage ;; + --failed) + FAILED_ONLY=1 + shift + ;; + --) + shift + break + ;; + -*) + printf 'fetch-annotations: unknown flag %q (use --help)\n' "$1" >&2 + exit 1 + ;; + *) + if [[ -z "$PR_NUMBER" ]]; then + PR_NUMBER="$1" + else + printf 'fetch-annotations: unexpected argument %q\n' "$1" >&2 + exit 1 + fi + shift + ;; + esac +done + +if [[ -z "$PR_NUMBER" ]]; then + printf 'fetch-annotations: <pr-number> required\n' >&2 + exit 1 +fi + +# --- Prerequisites ----------------------------------------------------------- + +have() { command -v "$1" >/dev/null 2>&1; } + +have gh || { + printf 'fetch-annotations: gh CLI required\n' >&2 + exit 5 +} +have jq || { + printf 'fetch-annotations: jq required\n' >&2 + exit 5 +} + +# --- Repo resolution --------------------------------------------------------- + +if [[ -n "${FETCH_ANNOT_REPO:-}" ]]; then + REPO="$FETCH_ANNOT_REPO" +else + REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null | tr -d '\r\n') +fi + +if [[ -z "$REPO" || "$REPO" != */* ]]; then + printf 'fetch-annotations: cannot resolve owner/repo (set FETCH_ANNOT_REPO=owner/name)\n' >&2 + exit 2 +fi + +# --- Resolve PR head SHA ----------------------------------------------------- + +HEAD_SHA=$(gh pr view "$PR_NUMBER" --json headRefOid -q .headRefOid 2>/dev/null | tr -d '\r\n') +if [[ -z "$HEAD_SHA" ]]; then + printf 'fetch-annotations: gh pr view %s failed (no head SHA)\n' "$PR_NUMBER" >&2 + exit 2 +fi + +# --- Fetch check-runs for head SHA ------------------------------------------ + +# Returns list of check_run records: id, name, conclusion, status. Apply jq +# client-side (avoids gh's --jq flag for stubbing simplicity). `--paginate` +# walks all pages so PRs with >100 check-runs aren't silently truncated. The +# `gh api` exit code is captured via PIPESTATUS so transport/auth/rate-limit +# failures surface as exit 2 (per the docstring contract) instead of falling +# through the empty-output branch as a misleading success. +CHECK_RUNS_RAW=$(gh api --paginate "repos/$REPO/commits/$HEAD_SHA/check-runs?per_page=100" 2>/dev/null) +api_rc=$? +if [[ $api_rc -ne 0 ]]; then + printf 'fetch-annotations: gh api check-runs failed (exit %d)\n' "$api_rc" >&2 + exit 2 +fi + +CHECK_RUNS_JSON=$(printf '%s' "$CHECK_RUNS_RAW" | + jq -c '.check_runs[] | {id: .id, name: .name, conclusion: .conclusion, status: .status}') + +if [[ -z "$CHECK_RUNS_JSON" ]]; then + # Empty is not an error — PR may have no check-runs yet. + exit 0 +fi + +# Filter to failed-only when requested. jq -c keeps each record on one line. +if [[ "$FAILED_ONLY" -eq 1 ]]; then + FILTERED=$(printf '%s\n' "$CHECK_RUNS_JSON" | + jq -c 'select(.conclusion == "failure" or .conclusion == "timed_out" or .conclusion == "action_required" or .conclusion == "cancelled")') +else + FILTERED=$(printf '%s\n' "$CHECK_RUNS_JSON" | jq -c '.') +fi + +if [[ -z "$FILTERED" ]]; then + exit 0 +fi + +# --- Walk each check-run, fetch annotations, emit JSONL --------------------- + +# Output schema (one JSON object per line): +# {"check_run_id":N,"check_run_name":"x","level":"failure|warning|notice", +# "path":"x","start_line":N,"end_line":N,"title":"x","message":"x"} + +while IFS= read -r cr; do + cr_id=$(printf '%s' "$cr" | jq -r '.id') + cr_name=$(printf '%s' "$cr" | jq -r '.name') + [[ -z "$cr_id" || "$cr_id" == "null" ]] && continue + + # `--paginate` walks all annotation pages — large lint/test failures can + # exceed the 100/page cap and silently drop diagnostics otherwise. Per-page + # response is `[...]`; jq's `if type == "array" then .[] else . end` + # already flattens the multi-array stream paginate emits. Capture the API + # exit code separately so transport/auth/rate-limit failures (403/429/etc.) + # surface as exit 2 per the docstring contract instead of silently emitting + # partial JSONL. The trailing `|| true` on the jq pipe still tolerates + # benign per-record decode quirks without aborting the whole walk. + ANNOT_RAW=$(gh api --paginate "repos/$REPO/check-runs/$cr_id/annotations?per_page=100" 2>/dev/null) + annot_rc=$? + if [[ $annot_rc -ne 0 ]]; then + printf 'fetch-annotations: gh api annotations for check-run %s failed (exit %d)\n' "$cr_id" "$annot_rc" >&2 + exit 2 + fi + printf '%s' "$ANNOT_RAW" | + jq -c --argjson cr_id "$cr_id" --arg cr_name "$cr_name" ' + if type == "array" then .[] else . end + | { + check_run_id: $cr_id, + check_run_name: $cr_name, + level: .annotation_level, + path: .path, + start_line: .start_line, + end_line: .end_line, + title: .title, + message: .message + } + ' 2>/dev/null || true +done <<<"$FILTERED" + +exit 0 diff --git a/plugins/source-control/skills/pull-request/scripts/fetch-annotations.test.sh b/plugins/source-control/skills/pull-request/scripts/fetch-annotations.test.sh new file mode 100644 index 000000000..7b15372dc --- /dev/null +++ b/plugins/source-control/skills/pull-request/scripts/fetch-annotations.test.sh @@ -0,0 +1,298 @@ +#!/usr/bin/env bash +# Regression tests for fetch-annotations.sh. +# +# Black-box: invokes the script as a subprocess with a stubbed `gh` on PATH. +# Covers: +# +# 1. PR with mixed-conclusion check-runs — emits annotations for all +# 2. --failed flag — filters to only failure/timed_out/cancelled/action_required +# 3. PR with no check-runs — exits 0 with no output +# 4. Missing PR number — exits 1 +# 5. gh pr view failure — exits 2 +# 6. JSONL output schema — every line is valid JSON with required fields + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPT="$SCRIPT_DIR/fetch-annotations.sh" +# POSIX path for stub PATH entries (Git Bash PATH lookup fails on Windows-form +# paths like C:/Users/...). Production script accepts either form via env vars. +TEST_TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TEST_TMPDIR"' EXIT + +# shellcheck source=test-helpers.sh +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/test-helpers.sh" + +# Skip suite if jq missing — script depends on it. +command -v jq >/dev/null 2>&1 || skip_suite "jq not installed" + +# ---- Build fixtures --------------------------------------------------------- + +# check-runs response — three runs with different conclusions +FIXTURE_CHECK_RUNS="$TEST_TMPDIR/check-runs.json" +cat >"$FIXTURE_CHECK_RUNS" <<'JSON' +{ + "total_count": 3, + "check_runs": [ + {"id": 100, "name": "build", "conclusion": "success", "status": "completed"}, + {"id": 200, "name": "test", "conclusion": "failure", "status": "completed"}, + {"id": 300, "name": "lint", "conclusion": "neutral", "status": "completed"} + ] +} +JSON + +# Annotations per check-run id +FIXTURE_ANNOT_100="$TEST_TMPDIR/annot-100.json" +echo '[]' >"$FIXTURE_ANNOT_100" + +FIXTURE_ANNOT_200="$TEST_TMPDIR/annot-200.json" +cat >"$FIXTURE_ANNOT_200" <<'JSON' +[ + { + "path": "src/Foo.cs", + "start_line": 42, + "end_line": 42, + "annotation_level": "failure", + "title": "CS0246", + "message": "type or namespace 'Bar' could not be found" + }, + { + "path": "src/Foo.cs", + "start_line": 99, + "end_line": 100, + "annotation_level": "warning", + "title": "CS0168", + "message": "variable declared but never used" + } +] +JSON + +FIXTURE_ANNOT_300="$TEST_TMPDIR/annot-300.json" +cat >"$FIXTURE_ANNOT_300" <<'JSON' +[ + { + "path": "README.md", + "start_line": 10, + "end_line": 10, + "annotation_level": "notice", + "title": "MD013", + "message": "Line length exceeds 80 chars" + } +] +JSON + +# check-runs response with a single failure-conclusion run whose id (999) +# the annotations stub treats as a simulated 403/rate-limit (Case 8). +FIXTURE_ANNOTFAIL_CHECK_RUNS="$TEST_TMPDIR/check-runs-annotfail.json" +cat >"$FIXTURE_ANNOTFAIL_CHECK_RUNS" <<'JSON' +{ + "total_count": 1, + "check_runs": [ + {"id": 999, "name": "rate-limited-check", "conclusion": "failure", "status": "completed"} + ] +} +JSON + +# Empty check-runs response (Case 3) +FIXTURE_EMPTY_CHECK_RUNS="$TEST_TMPDIR/check-runs-empty.json" +cat >"$FIXTURE_EMPTY_CHECK_RUNS" <<'JSON' +{"total_count": 0, "check_runs": []} +JSON + +# ---- Stub `gh` -------------------------------------------------------------- +# +# Dispatch table: +# pr view 1 --json headRefOid -q .headRefOid → "abc1234" +# pr view 2 --json headRefOid -q .headRefOid → echo nothing, exit 1 (failure case) +# pr view 3 --json headRefOid -q .headRefOid → "empty5678" +# api repos/.../commits/abc1234/check-runs → fixture with 3 runs +# api repos/.../commits/empty5678/check-runs → fixture with 0 runs +# api repos/.../check-runs/100/annotations → [] +# api repos/.../check-runs/200/annotations → 2 annotations +# api repos/.../check-runs/300/annotations → 1 annotation +# repo view --json nameWithOwner ... → example-org/example-repo + +STUB_DIR="$TEST_TMPDIR/stubs" +mkdir -p "$STUB_DIR" +cat >"$STUB_DIR/gh" <<STUB_EOF +#!/usr/bin/env bash +set -uo pipefail +case "\$1" in + pr) + if [[ "\$2" == "view" ]]; then + pr_num="\$3" + case "\$pr_num" in + 1) echo "abc1234" ;; + 2) exit 1 ;; + 3) echo "empty5678" ;; + 4) echo "fail9999" ;; + 5) echo "annotfail99" ;; + *) printf 'gh-stub: unknown pr number %q\n' "\$pr_num" >&2; exit 1 ;; + esac + exit 0 + fi + ;; + api) + # Skip --paginate flag if present + args=() + for a in "\$@"; do + [[ "\$a" == "--paginate" ]] && continue + args+=("\$a") + done + set -- "\${args[@]}" + case "\$2" in + *commits/abc1234/check-runs*) cat "$FIXTURE_CHECK_RUNS"; exit 0 ;; + *commits/empty5678/check-runs*) cat "$FIXTURE_EMPTY_CHECK_RUNS"; exit 0 ;; + *commits/annotfail99/check-runs*) cat "$FIXTURE_ANNOTFAIL_CHECK_RUNS"; exit 0 ;; + *check-runs/100/annotations*) cat "$FIXTURE_ANNOT_100"; exit 0 ;; + *check-runs/200/annotations*) cat "$FIXTURE_ANNOT_200"; exit 0 ;; + *check-runs/300/annotations*) cat "$FIXTURE_ANNOT_300"; exit 0 ;; + *check-runs/999/annotations*) printf 'simulated 403 rate-limit\n' >&2; exit 1 ;; + *) printf 'gh-stub: unknown api path %q\n' "\$2" >&2; exit 1 ;; + esac + ;; + repo) + if [[ "\$2" == "view" ]]; then + echo "example-org/example-repo" + exit 0 + fi + ;; + *) printf 'gh-stub: unknown command %q\n' "\$1" >&2; exit 1 ;; +esac +STUB_EOF +chmod +x "$STUB_DIR/gh" + +run_script() { + PATH="$STUB_DIR:$PATH" \ + FETCH_ANNOT_REPO="example-org/example-repo" \ + bash "$SCRIPT" "$@" 2>&1 +} + +# Variant for cases that need to capture exit code without folding stderr. +# Output discarded; only exit code returned via $?. +run_script_silent() { + PATH="$STUB_DIR:$PATH" \ + FETCH_ANNOT_REPO="example-org/example-repo" \ + bash "$SCRIPT" "$@" >/dev/null 2>&1 +} + +# ---- Cases ------------------------------------------------------------------ + +# Case 1: PR with mixed conclusions — emits annotations for all 3 check-runs +out=$(run_script 1) +# build (id=100) had no annotations; test (200) has 2; lint (300) has 1 → 3 total +line_count=$(printf '%s\n' "$out" | grep -c '^{' || true) +if [[ "$line_count" -eq 3 ]]; then + pass "all-mode emits annotations from every check-run with non-empty list" +else + fail "all-mode emits annotations from every check-run with non-empty list" "3 JSONL lines" "$line_count lines: $out" +fi + +# Case 2: --failed filter — only failure-conclusion check-runs (test, id=200) +out=$(run_script 1 --failed) +line_count=$(printf '%s\n' "$out" | grep -c '^{' || true) +if [[ "$line_count" -eq 2 ]]; then + pass "--failed filters to failure-conclusion only" +else + fail "--failed filters to failure-conclusion only" "2 JSONL lines (test annotations)" "$line_count lines: $out" +fi + +# Case 3: PR with no check-runs — exits 0 with no output +out=$(run_script 3) +ec=$? +if [[ $ec -eq 0 && -z "${out//[[:space:]]/}" ]]; then + pass "empty check-runs exits 0 with no output" +else + fail "empty check-runs exits 0 with no output" "exit 0 + empty out" "exit $ec, out: $out" +fi + +# Case 4: Missing PR number — exits 1 +run_script_silent +assert_exit "missing pr-number exits 1" 1 "$?" + +# Case 5: gh pr view failure — exits 2 +run_script_silent 2 +assert_exit "gh pr view failure exits 2" 2 "$?" + +# Case 8: per-check-run annotations API failure exits 2 (regression — `|| true` +# on the jq pipeline used to mask gh api auth/rate-limit failures and silently +# emit partial JSONL instead of failing the script per the docstring contract) +run_script_silent 5 +assert_exit "annotations API failure exits 2" 2 "$?" + +# Case 7: check-runs API failure exits 2 (regression — used to silently exit 0) +run_script_silent 4 +assert_exit "check-runs API failure exits 2" 2 "$?" + +# Case 6: JSONL output schema — every line valid JSON with required keys +out=$(run_script 1) +schema_ok=1 +while IFS= read -r line; do + [[ -z "$line" ]] && continue + if ! printf '%s' "$line" | + jq -e 'has("check_run_id") and has("check_run_name") and has("level") and has("path") and has("start_line") and has("title") and has("message")' \ + >/dev/null 2>&1; then + schema_ok=0 + break + fi +done <<<"$out" +if [[ $schema_ok -eq 1 ]]; then + pass "every JSONL line has required schema fields" +else + fail "every JSONL line has required schema fields" "all lines pass jq schema check" "$out" +fi + +# ---- Integration cases (opt-in: INTEGRATION=1) ----------------------------- +# +# Hits real GitHub API against a known PR in this repo. Skipped by default. +# Run with: INTEGRATION=1 bash fetch-annotations.test.sh +# Optionally pin a specific PR: INTEGRATION_PR=999 + +if [[ "${INTEGRATION:-0}" == "1" ]]; then + if ! command -v gh >/dev/null 2>&1; then + skip_case "INTEGRATION mode but gh CLI not available" + elif [[ -z "${GH_TOKEN:-}" ]]; then + skip_case "INTEGRATION mode but GH_TOKEN not set" + elif [[ -z "${INTEGRATION_REPO:-}" ]]; then + skip_case "INTEGRATION mode but INTEGRATION_REPO not set (owner/name)" + else + # Default: query gh for the most recent merged PR. The script handles a + # zero-annotations PR by exiting 0 with empty output (Case 3 covers that). + REAL_PR="${INTEGRATION_PR:-}" + if [[ -z "$REAL_PR" ]]; then + REAL_PR=$(gh pr list --repo "$INTEGRATION_REPO" --state merged --limit 1 --json number --jq '.[0].number' 2>/dev/null | tr -d '\r\n') + fi + if [[ -z "$REAL_PR" || "$REAL_PR" == "null" ]]; then + skip_case "INTEGRATION mode but could not resolve a merged PR" + else + out=$(FETCH_ANNOT_REPO="$INTEGRATION_REPO" \ + bash "$SCRIPT" "$REAL_PR" 2>&1) + ec=$? + # Pass criterion: exit 0 + (empty output OR every output line is valid JSON + # with the expected schema). Empty is acceptable — many PRs have zero + # annotations. + schema_ok=1 + if [[ -n "${out//[[:space:]]/}" ]]; then + while IFS= read -r line; do + [[ -z "$line" ]] && continue + if ! printf '%s' "$line" | + jq -e 'has("check_run_id") and has("level") and has("path")' \ + >/dev/null 2>&1; then + schema_ok=0 + break + fi + done <<<"$out" + fi + if [[ $ec -eq 0 && $schema_ok -eq 1 ]]; then + line_count=$(printf '%s\n' "$out" | grep -c '^{' || true) + pass "INTEGRATION: PR #$REAL_PR returned $line_count annotations via API (schema OK)" + else + fail "INTEGRATION: PR #$REAL_PR via API" "exit 0 + schema OK" "exit $ec, schema_ok=$schema_ok, head: $(printf '%s' "$out" | head -3)" + fi + fi + fi +fi + +# Final +[[ $FAILED -eq 0 ]] || exit 1 +exit 0 diff --git a/plugins/source-control/skills/pull-request/scripts/fetch-failed-logs.sh b/plugins/source-control/skills/pull-request/scripts/fetch-failed-logs.sh new file mode 100644 index 000000000..9c9b25871 --- /dev/null +++ b/plugins/source-control/skills/pull-request/scripts/fetch-failed-logs.sh @@ -0,0 +1,399 @@ +#!/usr/bin/env bash +# fetch-failed-logs.sh — Fetch complete, untruncated GitHub Actions logs +# for a workflow run via the GitHub REST API. +# +# Replaces the truncation-prone `gh run view <id> --log-failed` (CLI display +# layer caps at ~4MB; cli/cli #11059 #10551 #7771 #7642). Uses direct +# `gh api .../runs/{id}/logs` (full ZIP via Azure Blob 302) or +# `gh api .../jobs/{id}/logs` (per-job plain text) — both complete. +# +# Default mode greps `##[error]` and `##[warning]` per file. Audit flags +# expose more observability surface for a CI-log-audit agent (if your +# environment ships one) and for ad-hoc inline use. +# +# Usage: +# fetch-failed-logs.sh <run-id> # default: ##[error] + ##[warning] +# fetch-failed-logs.sh --job <job-id> # single-job plain-text mode +# fetch-failed-logs.sh <run-id> --errors-only # ##[error] only (no warnings) +# fetch-failed-logs.sh <run-id> --notices # also include ##[notice] +# fetch-failed-logs.sh <run-id> --groups # ##[group]/##[endgroup] step structure +# fetch-failed-logs.sh <run-id> --timing # per-group durations from ISO timestamps +# fetch-failed-logs.sh <run-id> --suspicious # grep retry/deprecation/0-tests/timeout patterns +# fetch-failed-logs.sh <run-id> --audit # macro: warnings + groups + timing + suspicious +# fetch-failed-logs.sh <run-id> --keep-zip # leave ZIP under scratch/ for re-grep +# fetch-failed-logs.sh <run-id> --raw # dump full ZIP contents to stdout (no grep) +# +# Env overrides: +# FETCH_LOGS_MAX_BYTES default 52428800 (50 MiB) — abort if response larger +# FETCH_LOGS_REPO default `gh repo view --json nameWithOwner -q .nameWithOwner` +# FETCH_LOGS_SCRATCH destination for cached ZIPs — default +# $CLAUDE_PLUGIN_DATA/scratch when set, else mktemp -d +# +# Exit codes: +# 0 log fetch + extraction succeeded +# 1 invalid argument +# 2 gh api call failed (auth, network, 404 etc.) +# 3 log payload exceeded FETCH_LOGS_MAX_BYTES +# 4 ZIP extraction failed +# 5 prerequisite missing (gh, unzip) + +set -uo pipefail + +# --- Argument parsing -------------------------------------------------------- + +RUN_ID="" +JOB_ID="" +KEEP_ZIP=0 +RAW=0 +ERRORS_ONLY=0 +NOTICES=0 +SHOW_GROUPS=0 +TIMING=0 +SUSPICIOUS=0 + +usage() { + sed -n '2,41p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' + exit 0 +} + +while (($# > 0)); do + case "$1" in + -h | --help) usage ;; + --job) + [[ $# -ge 2 ]] || { + printf 'fetch-failed-logs: --job needs an argument\n' >&2 + exit 1 + } + JOB_ID="$2" + shift 2 + ;; + --keep-zip) + KEEP_ZIP=1 + shift + ;; + --raw) + RAW=1 + shift + ;; + --errors-only) + ERRORS_ONLY=1 + shift + ;; + --notices) + NOTICES=1 + shift + ;; + --groups) + SHOW_GROUPS=1 + shift + ;; + --timing) + TIMING=1 + shift + ;; + --suspicious) + SUSPICIOUS=1 + shift + ;; + --audit) + # Macro: comprehensive observability beyond default error+warning grep. + SHOW_GROUPS=1 + TIMING=1 + SUSPICIOUS=1 + shift + ;; + --) + shift + break + ;; + -*) + printf 'fetch-failed-logs: unknown flag %q (use --help)\n' "$1" >&2 + exit 1 + ;; + *) + if [[ -z "$RUN_ID" ]]; then + RUN_ID="$1" + else + printf 'fetch-failed-logs: unexpected argument %q\n' "$1" >&2 + exit 1 + fi + shift + ;; + esac +done + +# Compose marker regex based on flags. ERRORS_ONLY suppresses warning; +# NOTICES adds notice. Default = error + warning (preserving v1 behavior). +if [[ "$ERRORS_ONLY" -eq 1 && "$NOTICES" -eq 1 ]]; then + MARKER_RE='##\[(error|notice)\]' +elif [[ "$ERRORS_ONLY" -eq 1 ]]; then + MARKER_RE='##\[error\]' +elif [[ "$NOTICES" -eq 1 ]]; then + MARKER_RE='##\[(error|warning|notice)\]' +else + MARKER_RE='##\[(error|warning)\]' +fi + +# Suspicious-pattern regex — case-insensitive grep targets commonly-missed +# signal: retry loops, deprecation warnings, "0 tests" lies, timeouts, +# exit-code mismatches not flagged as ##[error]. +SUSPICIOUS_RE='(retry|retrying|attempt [0-9]+ of [0-9]+|exponential backoff|deprecat|0 tests|no tests (collected|run)|nothing to do|skipped due to|timed out|connection refused|fallback)' # spellchecker:disable-line + +if [[ -z "$RUN_ID" && -z "$JOB_ID" ]]; then + printf 'fetch-failed-logs: <run-id> or --job <job-id> required\n' >&2 + exit 1 +fi + +# --- Prerequisites ----------------------------------------------------------- + +have() { command -v "$1" >/dev/null 2>&1; } + +# emit_group_timing — parse leading ISO 8601 timestamp on each line, compute +# duration per ##[group]/##[endgroup] section. Pure awk for cross-platform. +# GH Actions logs prefix every line with `2026-MM-DDThh:mm:ss.NZ ` followed +# by the message. We diff first/last timestamp inside each group block. +emit_group_timing() { + local file="$1" + awk ' + function ts_to_ms(ts, s, ms, parts) { + # Parse 2026-05-07T15:24:44.3187916Z → epoch_ms (approximate using only + # hh:mm:ss.fff for relative duration; date arithmetic stays inside the + # same UTC day for typical CI runs). + if (ts !~ /T[0-9][0-9]:[0-9][0-9]:[0-9][0-9]/) return 0 + n = split(ts, parts, "T") + if (n < 2) return 0 + time = parts[2] + hh = substr(time, 1, 2) + 0 + mm = substr(time, 4, 2) + 0 + ss = substr(time, 7, 2) + 0 + frac = 0 + if (substr(time, 9, 1) == ".") { + # take up to 3 decimal digits for ms + f = substr(time, 10, 3) + gsub(/[^0-9]/, "", f) + if (length(f) > 0) frac = f + 0 + } + return ((hh * 3600 + mm * 60 + ss) * 1000) + frac + } + { + # Capture timestamp prefix + if (match($0, /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]+Z/)) { + ts = substr($0, 1, RLENGTH) + msg = substr($0, RLENGTH + 2) + } else { + next + } + if (match(msg, /##\[group\]/)) { + gname = substr(msg, RSTART + RLENGTH) + gstart = ts_to_ms(ts) + in_group = 1 + next + } + if (in_group && match(msg, /##\[endgroup\]/)) { + gend = ts_to_ms(ts) + dur = gend - gstart + if (dur < 0) dur = dur + 86400000 # crossed UTC midnight + printf "%6d ms %s\n", dur, gname + in_group = 0 + } + } + ' "$file" +} + +have gh || { + printf 'fetch-failed-logs: gh CLI required\n' >&2 + exit 5 +} +have unzip || { + printf 'fetch-failed-logs: unzip required\n' >&2 + exit 5 +} + +MAX_BYTES="${FETCH_LOGS_MAX_BYTES:-52428800}" + +# --- Repo resolution --------------------------------------------------------- + +if [[ -n "${FETCH_LOGS_REPO:-}" ]]; then + REPO="$FETCH_LOGS_REPO" +else + REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null | tr -d '\r\n') +fi + +if [[ -z "$REPO" || "$REPO" != */* ]]; then + printf 'fetch-failed-logs: cannot resolve owner/repo (set FETCH_LOGS_REPO=owner/name)\n' >&2 + exit 2 +fi + +# --- Scratch dir resolution -------------------------------------------------- +# +# Cached ZIPs and extracted logs are transient. Precedence: explicit +# FETCH_LOGS_SCRATCH override > the plugin's persistent data directory +# (CLAUDE_PLUGIN_DATA, set when running as an installed plugin) > mktemp. + +SCRATCH="${FETCH_LOGS_SCRATCH:-}" +if [[ -z "$SCRATCH" ]]; then + if [[ -n "${CLAUDE_PLUGIN_DATA:-}" ]]; then + SCRATCH="$CLAUDE_PLUGIN_DATA/scratch" + else + SCRATCH=$(mktemp -d) + fi +fi +mkdir -p "$SCRATCH" + +# --- Per-job mode ------------------------------------------------------------ + +if [[ -n "$JOB_ID" ]]; then + out_path="$SCRATCH/job-${JOB_ID}-logs.txt" + if ! gh api "repos/$REPO/actions/jobs/$JOB_ID/logs" >"$out_path" 2>/dev/null; then + printf 'fetch-failed-logs: gh api failed for job %s\n' "$JOB_ID" >&2 + rm -f "$out_path" + exit 2 + fi + size=$(wc -c <"$out_path" | tr -d ' \r\n') + if [[ "$size" -gt "$MAX_BYTES" ]]; then + printf 'fetch-failed-logs: job log %s bytes > max %s — wrote to %s, not printing\n' \ + "$size" "$MAX_BYTES" "$out_path" >&2 + exit 3 + fi + if [[ "$RAW" -eq 1 ]]; then + cat "$out_path" + else + grep -E "$MARKER_RE" "$out_path" || true + if [[ "$SHOW_GROUPS" -eq 1 ]]; then + printf '\n----- groups -----\n' + grep -E '##\[(group|endgroup)\]' "$out_path" || true + fi + if [[ "$TIMING" -eq 1 ]]; then + printf '\n----- timing (per group) -----\n' + emit_group_timing "$out_path" + fi + if [[ "$SUSPICIOUS" -eq 1 ]]; then + printf '\n----- suspicious patterns -----\n' + grep -iE "$SUSPICIOUS_RE" "$out_path" || true + fi + fi + exit 0 +fi + +# --- Full-run ZIP mode ------------------------------------------------------- + +ZIP_PATH="$SCRATCH/run-${RUN_ID}-logs.zip" + +if ! gh api "repos/$REPO/actions/runs/$RUN_ID/logs" >"$ZIP_PATH" 2>/dev/null; then + printf 'fetch-failed-logs: gh api failed for run %s\n' "$RUN_ID" >&2 + rm -f "$ZIP_PATH" + exit 2 +fi + +size=$(wc -c <"$ZIP_PATH" | tr -d ' \r\n') +if [[ "$size" -gt "$MAX_BYTES" ]]; then + printf 'fetch-failed-logs: run log ZIP %s bytes > max %s — file kept at %s, not extracted\n' \ + "$size" "$MAX_BYTES" "$ZIP_PATH" >&2 + exit 3 +fi + +if [[ "$size" -lt 22 ]]; then + # ZIP minimum is 22 bytes (empty central directory). Anything smaller is an + # API error response that gh somehow piped through. + printf 'fetch-failed-logs: response %s bytes — likely an API error, not a ZIP\n' "$size" >&2 + printf ' raw response: %s\n' "$(head -c 200 "$ZIP_PATH" || true)" >&2 + rm -f "$ZIP_PATH" + exit 2 +fi + +# Extract to a temporary directory and walk per-job folders. +EXTRACT_DIR=$(mktemp -d) +# shellcheck disable=SC2329 # invoked via trap, not direct call +cleanup_extract() { rm -rf "$EXTRACT_DIR"; } +trap cleanup_extract EXIT INT TERM + +if ! unzip -q "$ZIP_PATH" -d "$EXTRACT_DIR" 2>/dev/null; then + printf 'fetch-failed-logs: unzip failed (corrupt ZIP at %s)\n' "$ZIP_PATH" >&2 + exit 4 +fi + +# Real GitHub Actions ZIP layout (verified 2026-05-08 against run 25505236665): +# TOP-LEVEL: <step-num>_<job-name>.txt consolidated step log (errors live here) +# PER-JOB: <job-name>/system.txt agent metadata only +# Errors appear in the top-level consolidated files. Walk all *.txt recursively +# rather than per-job dirs (the prior fixture-only logic missed real layouts). + +if [[ "$RAW" -eq 1 ]]; then + # Dump every text file with a header for orientation. NUL-delimited to + # survive filenames with spaces (real ZIPs have them — e.g. "shell _ Bash"). + while IFS= read -r -d '' f; do + rel="${f#"$EXTRACT_DIR"/}" + printf '\n===== %s =====\n' "$rel" + cat "$f" + done < <(find "$EXTRACT_DIR" -type f -name '*.txt' -print0 | sort -z) +else + # Walk every .txt file recursively, grep for markers, group output by file. + found_any=0 + while IFS= read -r -d '' f; do + rel="${f#"$EXTRACT_DIR"/}" + matches=$(grep -E "$MARKER_RE" "$f" 2>/dev/null || true) + if [[ -n "$matches" ]]; then + printf '\n===== %s =====\n%s\n' "$rel" "$matches" + found_any=1 + fi + done < <(find "$EXTRACT_DIR" -type f -name '*.txt' -print0 | sort -z) + + # --- Audit-flag sections (when requested) --- + if [[ "$SHOW_GROUPS" -eq 1 ]]; then + printf '\n===== groups (step structure) =====\n' + while IFS= read -r -d '' f; do + rel="${f#"$EXTRACT_DIR"/}" + g=$(grep -E '##\[(group|endgroup)\]' "$f" 2>/dev/null || true) + [[ -n "$g" ]] && printf '\n--- %s ---\n%s\n' "$rel" "$g" + done < <(find "$EXTRACT_DIR" -type f -name '*.txt' -print0 | sort -z) + fi + + if [[ "$TIMING" -eq 1 ]]; then + printf '\n===== timing (per group, ms) =====\n' + while IFS= read -r -d '' f; do + rel="${f#"$EXTRACT_DIR"/}" + timing=$(emit_group_timing "$f") + [[ -n "$timing" ]] && printf '\n--- %s ---\n%s\n' "$rel" "$timing" + done < <(find "$EXTRACT_DIR" -type f -name '*.txt' -print0 | sort -z) + fi + + if [[ "$SUSPICIOUS" -eq 1 ]]; then + printf '\n===== suspicious patterns =====\n' + while IFS= read -r -d '' f; do + rel="${f#"$EXTRACT_DIR"/}" + sus=$(grep -iE "$SUSPICIOUS_RE" "$f" 2>/dev/null || true) + [[ -n "$sus" ]] && printf '\n--- %s ---\n%s\n' "$rel" "$sus" + done < <(find "$EXTRACT_DIR" -type f -name '*.txt' -print0 | sort -z) + fi + + if [[ "$found_any" -eq 0 ]]; then + # No marker found — surface tail of the largest non-system log file. Use + # `wc -c` for cross-platform portability (find -printf is GNU-only; macOS + # BSD find rejects it). + largest="" + largest_size=0 + while IFS= read -r -d '' f; do + [[ "$(basename "$f")" == "system.txt" ]] && continue + size=$(wc -c <"$f" 2>/dev/null | tr -d ' \r\n') + [[ -z "$size" ]] && continue + if [[ "$size" -gt "$largest_size" ]]; then + largest="$f" + largest_size="$size" + fi + done < <(find "$EXTRACT_DIR" -type f -name '*.txt' -print0) + + if [[ -n "$largest" ]]; then + printf 'fetch-failed-logs: no ##[error]/##[warning] markers found. Tail of %s:\n' \ + "${largest#"$EXTRACT_DIR"/}" >&2 + tail -30 "$largest" >&2 + else + printf 'fetch-failed-logs: ZIP extracted but no .txt files found\n' >&2 + fi + fi +fi + +if [[ "$KEEP_ZIP" -eq 0 ]]; then + rm -f "$ZIP_PATH" +fi + +exit 0 diff --git a/plugins/source-control/skills/pull-request/scripts/fetch-failed-logs.test.sh b/plugins/source-control/skills/pull-request/scripts/fetch-failed-logs.test.sh new file mode 100644 index 000000000..655a116f6 --- /dev/null +++ b/plugins/source-control/skills/pull-request/scripts/fetch-failed-logs.test.sh @@ -0,0 +1,294 @@ +#!/usr/bin/env bash +# Regression tests for fetch-failed-logs.sh. +# +# Black-box: invokes the script as a subprocess with a stubbed `gh` on PATH +# that emits fixture data instead of calling the real GitHub API. Covers: +# +# 1. Full-run ZIP mode — extracts ##[error] markers per job folder +# 2. Per-job mode — emits failure markers from plain-text response +# 3. --raw flag — dumps unfiltered content +# 4. --keep-zip flag — leaves ZIP under scratch/ +# 5. Size cap (FETCH_LOGS_MAX_BYTES) — aborts with exit 3 +# 6. gh api failure — exits 2 +# 7. Tiny non-ZIP response — exits 2 with diagnostic +# 8. Missing arguments — exits 1 + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPT="$SCRIPT_DIR/fetch-failed-logs.sh" +# POSIX path for stub PATH entries (Git Bash PATH lookup fails on Windows-form +# paths like C:/Users/...). Production script accepts either form via env vars. +TEST_TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TEST_TMPDIR"' EXIT + +# shellcheck source=test-helpers.sh +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/test-helpers.sh" + +# Skip suite if `unzip` (production dep) or `zip` (test fixture builder) is +# missing. CI runners have them preinstalled. Windows Git Bash users install +# via `winget install gnuwin32.zip`. +command -v unzip >/dev/null 2>&1 || skip_suite "unzip not installed" +command -v zip >/dev/null 2>&1 || skip_suite "zip not installed" + +# ---- Build fixture ZIP ------------------------------------------------------ +# +# Layout mirrors REAL GitHub Actions logs ZIP layout (verified 2026-05-08 +# against example-org/example-repo run 25505236665): +# TOP-LEVEL: <step-num>_<job-name>.txt consolidated step log (errors here) +# PER-JOB: <job-name>/system.txt agent metadata only +# Filenames may contain SPACES + special chars (matrix expansions encoded with +# underscores). Earlier fixture used <jobN>/01_step.txt — that layout does not +# exist in real ZIPs and silently passed the test while the production logic +# missed real errors. + +FIXTURE_BUILD="$TEST_TMPDIR/fixture-build" +mkdir -p "$FIXTURE_BUILD/build/" "$FIXTURE_BUILD/shell _ Bash (tests)/" +# Top-level consolidated step logs (where ##[error] markers actually live) +{ + printf '2026-05-08T10:00:00Z setup\n' + printf '##[error]CS0246: type or namespace not found\n' + printf '##[warning]NU1701: package downgrade\n' +} >"$FIXTURE_BUILD/0_build.txt" +{ + printf '2026-05-08T10:01:00Z tests starting\n' + printf '##[error]Assert.Equal failure in MyTest\n' + printf '##[error]Process completed with exit code 1.\n' +} >"$FIXTURE_BUILD/4_shell _ Bash (tests).txt" +# Per-job system.txt — metadata only, no error markers +printf 'Hosted Compute Agent\nVersion: 20260507.1.0\n' >"$FIXTURE_BUILD/build/system.txt" +printf 'Hosted Compute Agent\nVersion: 20260507.1.0\n' >"$FIXTURE_BUILD/shell _ Bash (tests)/system.txt" + +FIXTURE_ZIP="$TEST_TMPDIR/run-logs.zip" +(cd "$FIXTURE_BUILD" && zip -qr "$FIXTURE_ZIP" .) + +# ---- Build fixture per-job text --------------------------------------------- + +FIXTURE_JOB_TXT="$TEST_TMPDIR/job-logs.txt" +{ + printf '2026-05-08T10:00:00Z job starting\n' + printf '##[error]ENOENT path not found\n' + printf '##[warning]NETSDK1206: deprecated framework\n' + printf 'job done\n' +} >"$FIXTURE_JOB_TXT" + +# ---- Stub `gh` -------------------------------------------------------------- +# +# The stub reads its first arg ('api' or 'repo'), then dispatches: +# api repos/<owner>/<repo>/actions/runs/<id>/logs → cat fixture ZIP +# api repos/<owner>/<repo>/actions/jobs/<id>/logs → cat fixture text +# api repos/<owner>/<repo>/actions/runs/FAIL/logs → exit 1 (failure) +# api repos/<owner>/<repo>/actions/runs/TINY/logs → emit 5 bytes (not a ZIP) +# repo view --json nameWithOwner ... → echo example-org/example-repo + +STUB_DIR="$TEST_TMPDIR/stubs" +mkdir -p "$STUB_DIR" +cat >"$STUB_DIR/gh" <<STUB_EOF +#!/usr/bin/env bash +set -uo pipefail +case "\$1" in + api) + case "\$2" in + *runs/FAIL/logs) exit 1 ;; + *runs/TINY/logs) printf 'BAD!\n'; exit 0 ;; + *runs/*/logs) cat "$FIXTURE_ZIP"; exit 0 ;; + *jobs/FAIL/logs) exit 1 ;; + *jobs/*/logs) cat "$FIXTURE_JOB_TXT"; exit 0 ;; + *) printf 'gh-stub: unknown api path %q\n' "\$2" >&2; exit 1 ;; + esac + ;; + repo) + if [[ "\$2" == "view" ]]; then + echo "example-org/example-repo" + exit 0 + fi + ;; + *) printf 'gh-stub: unknown command %q\n' "\$1" >&2; exit 1 ;; +esac +STUB_EOF +chmod +x "$STUB_DIR/gh" + +run_script() { + PATH="$STUB_DIR:$PATH" \ + FETCH_LOGS_REPO="example-org/example-repo" \ + FETCH_LOGS_SCRATCH="$TEST_TMPDIR/scratch" \ + bash "$SCRIPT" "$@" 2>&1 +} + +# Variant that lets a case pick its own scratch dir (case isolation for +# size-cap / failure paths) and discards output. Returns exit code via $?. +run_script_with_scratch_silent() { + local scratch="$1" + shift + PATH="$STUB_DIR:$PATH" \ + FETCH_LOGS_REPO="example-org/example-repo" \ + FETCH_LOGS_SCRATCH="$scratch" \ + bash "$SCRIPT" "$@" >/dev/null 2>&1 +} + +# Variant that lets a case pick its own scratch dir and capture combined output. +run_script_with_scratch() { + local scratch="$1" + shift + PATH="$STUB_DIR:$PATH" \ + FETCH_LOGS_REPO="example-org/example-repo" \ + FETCH_LOGS_SCRATCH="$scratch" \ + bash "$SCRIPT" "$@" 2>&1 +} + +# ---- Cases ------------------------------------------------------------------ + +# Case 1: Full-run ZIP — extracts ##[error] from real-shape top-level files +out=$(run_script 12345) +if [[ "$out" == *"0_build.txt"* && "$out" == *"##[error]CS0246"* && + "$out" == *"4_shell _ Bash (tests).txt"* && "$out" == *"##[error]Assert.Equal"* ]]; then + pass "full-run ZIP emits error markers from real-shape top-level files" +else + fail "full-run ZIP emits error markers from real-shape top-level files" "filename headers + error markers" "$out" +fi + +# Case 2: Per-job mode — emits ##[error] from plain text +out=$(run_script --job 99999) +if [[ "$out" == *"##[error]ENOENT"* && "$out" != *"job done"* ]]; then + pass "per-job mode greps error/warning markers only" +else + fail "per-job mode greps error/warning markers only" "ENOENT marker but no plain content" "$out" +fi + +# Case 3: --raw mode dumps everything (no grep filter, includes system.txt) +out=$(run_script 12345 --raw) +if [[ "$out" == *"Hosted Compute Agent"* && "$out" == *"##[error]CS0246"* ]]; then + pass "--raw dumps full content unfiltered (incl. per-job system.txt)" +else + fail "--raw dumps full content unfiltered (incl. per-job system.txt)" "all txt files dumped" "$out" +fi + +# Case 4: --keep-zip leaves ZIP under scratch +out=$(run_script 22222 --keep-zip) +if [[ -f "$TEST_TMPDIR/scratch/run-22222-logs.zip" ]]; then + pass "--keep-zip preserves ZIP under scratch/" +else + fail "--keep-zip preserves ZIP under scratch/" "ZIP at scratch/run-22222-logs.zip" "missing" +fi + +# Case 5: Size cap aborts with exit 3 +FETCH_LOGS_MAX_BYTES=10 run_script_with_scratch_silent "$TEST_TMPDIR/scratch5" 33333 +assert_exit "size cap returns exit 3" 3 "$?" + +# Case 6: gh api failure — exits 2 +run_script_with_scratch_silent "$TEST_TMPDIR/scratch6" FAIL +assert_exit "gh api failure returns exit 2" 2 "$?" + +# Case 7: Tiny non-ZIP response (5 bytes) — exits 2 with diagnostic +out=$(run_script_with_scratch "$TEST_TMPDIR/scratch7" TINY) +ec=$? +if [[ $ec -eq 2 && "$out" == *"likely an API error"* ]]; then + pass "tiny non-ZIP response exits 2 with diagnostic" +else + fail "tiny non-ZIP response exits 2 with diagnostic" "exit 2 + diagnostic" "exit $ec, out: $out" +fi + +# Case 8: Missing arguments — exits 1 +ec=0 +run_script_with_scratch_silent "$TEST_TMPDIR/scratch8" || ec=$? +assert_exit "missing run-id and --job exits 1" 1 "$ec" + +# ---- Audit flag fixture extension ---- +# Add a step file with: a ##[group]/##[endgroup] pair with timestamps, +# a ##[notice] marker, and a "0 tests" suspicious-pattern line. Reuses the +# existing fixture path so existing assertions keep working. +{ + printf '2026-05-08T10:02:00.000Z ##[group]Restore packages\n' + printf '2026-05-08T10:02:01.500Z restoring...\n' + printf '2026-05-08T10:02:03.250Z ##[endgroup]\n' + printf '2026-05-08T10:02:04.000Z ##[notice]informational note\n' + printf '2026-05-08T10:02:05.000Z 0 tests passed\n' + printf '2026-05-08T10:02:06.000Z Retrying download (attempt 2 of 3)\n' +} >>"$FIXTURE_BUILD/0_build.txt" +# Rebuild ZIP with extended fixture +(cd "$FIXTURE_BUILD" && zip -qr "$FIXTURE_ZIP" .) + +# Case 9: --errors-only suppresses warnings +out=$(run_script 12345 --errors-only) +if [[ "$out" == *"##[error]CS0246"* && "$out" != *"##[warning]NU1701"* ]]; then + pass "--errors-only excludes warning markers" +else + fail "--errors-only excludes warning markers" "errors yes warnings no" "$out" +fi + +# Case 10: --notices includes ##[notice] +out=$(run_script 12345 --notices) +if [[ "$out" == *"##[notice]informational"* ]]; then + pass "--notices surfaces notice markers" +else + fail "--notices surfaces notice markers" "informational note included" "$out" +fi + +# Case 11: --groups shows step structure +out=$(run_script 12345 --groups) +if [[ "$out" == *"##[group]Restore packages"* && "$out" == *"##[endgroup]"* ]]; then + pass "--groups extracts group/endgroup structure" +else + fail "--groups extracts group/endgroup structure" "group markers" "$out" +fi + +# Case 12: --timing computes per-group duration +out=$(run_script 12345 --timing) +# Restore packages spans 10:02:00.000 → 10:02:03.250 = 3250 ms +if [[ "$out" == *"timing"* && "$out" =~ [[:space:]]+3250[[:space:]]+ms[[:space:]]+Restore\ packages ]]; then + pass "--timing computes 3250 ms duration for Restore packages group" +else + fail "--timing computes 3250 ms duration for Restore packages group" "3250 ms Restore packages" "$out" +fi + +# Case 13: --suspicious surfaces retry/0-tests patterns +out=$(run_script 12345 --suspicious) +if [[ "$out" == *"0 tests"* && "$out" == *"Retrying"* ]]; then + pass "--suspicious greps retry + 0-tests patterns" +else + fail "--suspicious greps retry + 0-tests patterns" "0 tests + Retrying" "$out" +fi + +# Case 14: --audit macro fires groups + timing + suspicious sections +out=$(run_script 12345 --audit) +if [[ "$out" == *"groups (step structure)"* && "$out" == *"timing (per group"* && "$out" == *"suspicious patterns"* ]]; then + pass "--audit macro emits groups + timing + suspicious sections" +else + fail "--audit macro emits groups + timing + suspicious sections" "all 3 sections" "$out" +fi + +# ---- Integration cases (opt-in: INTEGRATION=1) ----------------------------- +# +# These hit the REAL GitHub API against a known-failed run in this repo. +# Skipped by default to keep unit tests fast + offline. Run with: +# INTEGRATION=1 bash fetch-failed-logs.test.sh +# +# Point INTEGRATION_REPO + INTEGRATION_RUN_ID at a repo/run you can read +# (the run should contain ##[error] markers). + +if [[ "${INTEGRATION:-0}" == "1" ]]; then + if ! command -v gh >/dev/null 2>&1; then + skip_case "INTEGRATION mode but gh CLI not available" + elif [[ -z "${GH_TOKEN:-}" ]]; then + skip_case "INTEGRATION mode but GH_TOKEN not set" + elif [[ -z "${INTEGRATION_REPO:-}" || -z "${INTEGRATION_RUN_ID:-}" ]]; then + skip_case "INTEGRATION mode but INTEGRATION_REPO / INTEGRATION_RUN_ID not set" + else + REAL_RUN_ID="$INTEGRATION_RUN_ID" + INT_SCRATCH="$TEST_TMPDIR/int-scratch" + out=$(FETCH_LOGS_REPO="$INTEGRATION_REPO" \ + FETCH_LOGS_SCRATCH="$INT_SCRATCH" \ + bash "$SCRIPT" "$REAL_RUN_ID" 2>&1) + ec=$? + if [[ $ec -eq 0 && "$out" == *"##[error]"* ]]; then + pass "INTEGRATION: real run $REAL_RUN_ID returns error markers via API" + else + fail "INTEGRATION: real run $REAL_RUN_ID returns error markers via API" \ + "exit 0 + error marker in output" "exit $ec, head: $(printf '%s' "$out" | head -3)" + fi + fi +fi + +# Final +[[ $FAILED -eq 0 ]] || exit 1 +exit 0 diff --git a/plugins/source-control/skills/pull-request/scripts/parse-branch-issue.sh b/plugins/source-control/skills/pull-request/scripts/parse-branch-issue.sh new file mode 100644 index 000000000..5b67f5d00 --- /dev/null +++ b/plugins/source-control/skills/pull-request/scripts/parse-branch-issue.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Parse an issue number from a branch name following the convention +# `<type>/<N>-<slug>` or the cloud-routine variant `<type>/routine-issue-<N>-<slug>`. +# +# Usage: +# parse-branch-issue.sh [branch-name] +# +# With no arg, falls back to `git branch --show-current`. +# Prints the captured issue number on stdout and exits 0 on match. +# Exits 1 with no output if the branch lacks an issue number. +set -uo pipefail + +BRANCH="${1:-}" +if [[ -z "$BRANCH" ]]; then + BRANCH="$(git branch --show-current 2>/dev/null || true)" +fi + +if [[ -z "$BRANCH" ]]; then + exit 1 +fi + +if [[ "$BRANCH" =~ ^[a-z]+/(routine-issue-)?([0-9]+)- ]]; then + echo "${BASH_REMATCH[2]}" + exit 0 +fi + +exit 1 diff --git a/plugins/source-control/skills/pull-request/scripts/parse-branch-issue.test.sh b/plugins/source-control/skills/pull-request/scripts/parse-branch-issue.test.sh new file mode 100644 index 000000000..bb7902ced --- /dev/null +++ b/plugins/source-control/skills/pull-request/scripts/parse-branch-issue.test.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Tests for parse-branch-issue.sh. +# Each case: PASS prints, FAIL prints. Non-zero exit on any FAIL. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PARSER="${SCRIPT_DIR}/parse-branch-issue.sh" + +if [[ ! -x "$PARSER" ]]; then + echo "FAIL: parser missing or not executable: $PARSER" >&2 + exit 1 +fi + +PASS=0 +FAIL=0 + +run_test() { + local desc="$1" input="$2" expected_out="$3" expected_exit="$4" + local actual_out actual_exit + actual_out=$(bash "$PARSER" "$input" 2>/dev/null) + actual_exit=$? + if [[ "$actual_out" == "$expected_out" && "$actual_exit" -eq "$expected_exit" ]]; then + echo "PASS: $desc" + PASS=$((PASS + 1)) + else + echo "FAIL: $desc" + echo " input='$input'" + echo " got out='$actual_out' exit=$actual_exit" + echo " wanted out='$expected_out' exit=$expected_exit" + FAIL=$((FAIL + 1)) + fi +} + +run_test "feat/42-new-rule emits 42" "feat/42-new-rule" "42" 0 +run_test "fix/123-analyzer-fp emits 123" "fix/123-analyzer-fp" "123" 0 +run_test "chore/789-rename-skill emits 789" "chore/789-rename-skill" "789" 0 +run_test "chore/routine-issue-555-tidy emits 555" "chore/routine-issue-555-tidy" "555" 0 +run_test "feat/just-a-feature no number" "feat/just-a-feature" "" 1 +run_test "worktree-foo-bar wrong prefix" "worktree-foo-bar" "" 1 +run_test "cursor/abc-xyz cloud-agent no number" "cursor/abc-xyz" "" 1 + +echo +echo "Results: ${PASS} passed, ${FAIL} failed" +[[ $FAIL -eq 0 ]] diff --git a/plugins/source-control/skills/pull-request/scripts/test-helpers.sh b/plugins/source-control/skills/pull-request/scripts/test-helpers.sh new file mode 100644 index 000000000..159c3ed80 --- /dev/null +++ b/plugins/source-control/skills/pull-request/scripts/test-helpers.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +# test-helpers.sh — shared assertion primitives for this plugin's *.test.sh +# suites. Self-contained — no host-repo assertion library. Sourced, not +# executed; the plugin test runner ignores it via the *.test.sh glob. +# +# Each test file owns its own FAILED / CASE_NUM counters (initialized here +# defensively). PASS lines go to stdout, FAIL lines to stderr. Test files +# exit non-zero at the end: [[ $FAILED -eq 0 ]] || exit 1 +# +# Param order: subject (haystack/actual) BEFORE expected (needle), except +# assert_eq / assert_exit which take (label, expected, actual). + +[[ -n "${_TESTS_LIB_LOADED:-}" ]] && return 0 +readonly _TESTS_LIB_LOADED=1 + +# Strip inherited git-hook context so fixture `git init` / ref writes never +# resolve to the real repo when a test runs under a git hook chain. +unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_PREFIX GIT_OBJECT_DIRECTORY + +: "${FAILED:=0}" +: "${CASE_NUM:=0}" + +# pass <label> +pass() { + CASE_NUM=$((CASE_NUM + 1)) + printf 'PASS: [%d] %s\n' "$CASE_NUM" "$1" +} + +# fail <label> <expected> <actual> +fail() { + CASE_NUM=$((CASE_NUM + 1)) + printf 'FAIL: [%d] %s — expected %q got %q\n' \ + "$CASE_NUM" "$1" "$2" "$3" >&2 + FAILED=$((FAILED + 1)) +} + +# skip_suite <reason> — print SKIP marker and exit 0 (missing toolchain etc.). +skip_suite() { + printf 'SKIP: %s\n' "$1" >&2 + exit 0 +} + +# skip_case <reason> — skip one case without exiting. +skip_case() { + printf 'SKIP: %s\n' "$1" >&2 +} + +# assert_eq <label> <expected> <actual> +assert_eq() { + local label="$1" expected="$2" actual="$3" + if [[ "$actual" == "$expected" ]]; then + pass "$label" + else + fail "$label" "$expected" "$actual" + fi +} + +# assert_contains <label> <haystack> <needle> +assert_contains() { + CASE_NUM=$((CASE_NUM + 1)) + local label="$1" haystack="$2" needle="$3" + if [[ "$haystack" == *"$needle"* ]]; then + printf 'PASS: [%d] %s\n' "$CASE_NUM" "$label" + else + printf 'FAIL: [%d] %s — expected %q in: %s\n' \ + "$CASE_NUM" "$label" "$needle" "$haystack" >&2 + FAILED=$((FAILED + 1)) + fi +} + +# assert_not_contains <label> <haystack> <needle> +assert_not_contains() { + CASE_NUM=$((CASE_NUM + 1)) + local label="$1" haystack="$2" needle="$3" + if [[ "$haystack" != *"$needle"* ]]; then + printf 'PASS: [%d] %s\n' "$CASE_NUM" "$label" + else + printf 'FAIL: [%d] %s — forbidden %q present in: %s\n' \ + "$CASE_NUM" "$label" "$needle" "$haystack" >&2 + FAILED=$((FAILED + 1)) + fi +} + +# assert_silent <label> <output> — output is empty / whitespace-only. +assert_silent() { + CASE_NUM=$((CASE_NUM + 1)) + local label="$1" output="$2" + local trimmed="${output#"${output%%[![:space:]]*}"}" + trimmed="${trimmed%"${trimmed##*[![:space:]]}"}" + if [[ -z "$trimmed" ]]; then + printf 'PASS: [%d] %s\n' "$CASE_NUM" "$label" + else + printf 'FAIL: [%d] %s — expected empty/whitespace, got: %s\n' \ + "$CASE_NUM" "$label" "$output" >&2 + FAILED=$((FAILED + 1)) + fi +} + +# assert_exit <label> <expected_code> <actual_code> +assert_exit() { + local label="$1" expected="$2" actual="$3" + CASE_NUM=$((CASE_NUM + 1)) + if [[ "$actual" == "$expected" ]]; then + printf 'PASS: [%d] %s\n' "$CASE_NUM" "$label" + else + printf 'FAIL: [%d] %s — exit expected %s got %s\n' \ + "$CASE_NUM" "$label" "$expected" "$actual" >&2 + FAILED=$((FAILED + 1)) + fi +} + +# assert_stdout_contains <label> <output> <needle> — alias of assert_contains. +assert_stdout_contains() { + assert_contains "$@" +} + +# assert_file_exists <label> <path> +assert_file_exists() { + CASE_NUM=$((CASE_NUM + 1)) + local label="$1" path="$2" + if [[ -f "$path" ]]; then + printf 'PASS: [%d] %s\n' "$CASE_NUM" "$label" + else + printf 'FAIL: [%d] %s — expected file %s\n' \ + "$CASE_NUM" "$label" "$path" >&2 + FAILED=$((FAILED + 1)) + fi +} + +# assert_file_absent <label> <path> +assert_file_absent() { + CASE_NUM=$((CASE_NUM + 1)) + local label="$1" path="$2" + if [[ ! -f "$path" ]]; then + printf 'PASS: [%d] %s\n' "$CASE_NUM" "$label" + else + printf 'FAIL: [%d] %s — expected absent, found %s\n' \ + "$CASE_NUM" "$label" "$path" >&2 + FAILED=$((FAILED + 1)) + fi +} diff --git a/plugins/source-control/skills/pull-request/templates/checklist.md b/plugins/source-control/skills/pull-request/templates/checklist.md new file mode 100644 index 000000000..bd74e3594 --- /dev/null +++ b/plugins/source-control/skills/pull-request/templates/checklist.md @@ -0,0 +1,27 @@ +# /pull-request Checklist + +Copy into your project's working-notes location (or track inline). Tick each box as the corresponding action completes. + +## Lifecycle + +- [ ] Phase 0: Parse action + detect state — live `gh pr view` lookup, branch check, route to appropriate phase +- [ ] Phase 1: Prep — review (agents/skill when available); verify findings; simplify; run the project's build+test+lint gate +- [ ] Phase 2: Create — branch-name conformance check; `git push -u`; `gh pr create` with `Closes #N` if the branch carries an issue number +- [ ] Phase 3: Monitor — push channel (when available) OR Monitor watch fallback; CI watch + comment response loop; research before any fix +- [ ] Phase 3.5: Comments — evaluate/respond to PR comments only (sub-phase of monitor) +- [ ] Phase 4: Merge — `gh pr merge --squash --delete-branch`; worktree cleanup; verify + +## Skip criteria + +- Phase 1 sub-steps may use `prep quick` / `prep review-only` / `prep simplify-only` variants for partial coverage +- Phase 3.5 SKIPPED when no review comments received +- Phase 4 NEVER skipped (merge + cleanup non-negotiable) + +## Non-negotiable gates + +1. Finding verification before user presentation (Phase 1) +2. Research-gated CI fixes (Phase 3) — no fix without researched multi-source consensus + +## How to use + +Copy + tick + survive `/clear`. diff --git a/plugins/source-control/skills/worktree/SKILL.md b/plugins/source-control/skills/worktree/SKILL.md new file mode 100644 index 000000000..af1790126 --- /dev/null +++ b/plugins/source-control/skills/worktree/SKILL.md @@ -0,0 +1,117 @@ +--- +name: worktree +description: "Manage git worktree lifecycle for parallel-session isolation: create (guided naming via EnterWorktree), status (PR + staleness inventory), cleanup (file-lock-aware removal), audit (infrastructure health). Use when: 'create worktree', 'worktree status', 'clean up worktrees', 'orphaned worktrees', or proactively when on main before writing code — not for PR lifecycle (use /pull-request)." +user-invocable: true +disable-model-invocation: false +argument-hint: "<action> [args] (e.g., /worktree create feat/my-feature, /worktree status, /worktree cleanup, /worktree audit)" +--- + +## Pre-computed context + +Current branch: !`git branch --show-current 2>/dev/null || echo "unknown"` +Worktree inventory: !`git worktree list 2>/dev/null | head -30 || echo "not a git repo"` +In a worktree: !`test "$(git rev-parse --git-dir 2>/dev/null)" != "$(git rev-parse --git-common-dir 2>/dev/null)" && echo "yes" || echo "no"` + +## Purpose + +Orchestrate git worktree lifecycle from creation through cleanup. **Front-half** of the development workflow — gets you into a worktree and keeps them healthy. `/pull-request` is the **back-half** — handles prep, PR creation, monitoring, merge. + +**Why this exists:** worktrees are the isolation mechanism for parallel code changes — multiple Claude Code sessions on different tasks without stepping on each other. In repos where branch protection blocks direct commits to main, every feature, fix, or refactor starts with a worktree or branch; this skill makes that seamless. + +## Adapting to your environment (graceful degrade) + +This skill is self-contained — every action runs on plain `git`, plus `gh` for PR cross-referencing where available. Where it mentions an adjacent capability (an issue tracker, a build/lint verifier, a session-start setup hook), treat it as optional: use it when your environment provides it, proceed without it otherwise. Project-specific conventions — branch naming, worktree layout, which gitignored files a fresh worktree needs — come from the consuming project's own `CLAUDE.md`, rules, and hooks; read them before creating or removing anything. + +## Arguments + +`$ARGUMENTS` — action selector. Parse first token as action, remainder as arguments. + +| Action | Entry point | Use case | +|--------|-------------|----------| +| *(empty)* | Smart default | Detect current state, suggest appropriate action | +| `create [name]` | Create worktree | Validate name, explain setup, call EnterWorktree | +| `status` | Inventory | List all worktrees with PR status and staleness | +| `cleanup [--dry-run]` | Remove stale | Prune orphans, detect merged PRs, remove with confirmation | +| `audit` | Health check | Run status + verify configuration health | + +--- + +## Action: Smart Default (empty args) + +Detect current state and guide user to the right action. + +1. **Check git repo**: `git rev-parse --is-inside-work-tree`. If not in a repo → "Not in a git repository." + +2. **Detect current branch**: `git rev-parse --abbrev-ref HEAD` + +3. **Check if in a worktree**: `git rev-parse --git-dir` differs from `git rev-parse --git-common-dir` in any linked worktree. Common layouts: a `.worktrees/` directory sibling to the repo's `.claude/`, Claude Code's default `.claude/worktrees/`, or a bare-clone hub (`git rev-parse --git-common-dir` ends in `.bare` and worktrees are siblings of `.bare/`). + +4. **Branch-based guidance**: + + - **On the default branch** → "You're on `<default-branch>`. Create a branch (`git checkout -b <type>/<description>`) or use `/worktree create` if you need parallel session isolation." + - **In a worktree** → Show current worktree info: branch name, last commit, associated PR (via `gh pr list --head <branch> --json number,title,state`). If a PR exists, suggest the next `/pull-request` phase. + - **On a feature branch (not worktree)** → Show branch info and any associated PR. + +5. **Check for stale/prunable worktrees**: Run a `git worktree list --porcelain` scan. If any worktrees are prunable or branches have merged PRs → suggest `/worktree cleanup`. + +6. **Otherwise** → Show brief status summary (worktree count, any needing attention). + +--- + +## Action: `create [name]` + +Create a new worktree with guided naming and setup verification. Full procedure — pre-flight guards (already-in-worktree, mid-session transition), name validation (EnterWorktree schema constraints), base-ref notes, the explain-before-create block, directory-rename caveats, and post-create setup checks: [context/create.md](context/create.md). + +**Safety invariant create MUST honor:** Call `EnterWorktree(name: "<validated-name>")` as the **final action** — working directory changes and session state transitions on that call, so nothing may execute after it. + +--- + +## Action: `status` + +Inventory all worktrees with PR association and staleness detection. Collect Tier-0 facts with plain git + gh (`git worktree list --porcelain` parse, one batched `gh pr list`, last-commit dates), then apply the 6-status classification table (`active` / `stale` / `in-review` / `merged` / `prunable` / `locked`), staleness threshold (14-day default, `WORKTREE_STALE_DAYS` override), and presentation schema per [context/status.md](context/status.md). `audit` Step 1 invokes this logic internally. + +--- + +## Action: `cleanup [--dry-run]` + +Remove stale worktrees, orphaned metadata, and branches from merged PRs. Full 5-step procedure — prune orphaned metadata → identify candidates (4 detection reasons: orphaned dir / prunable / PR-merged / stale) → present → execute (4a release file locks, 4b remove, 4c emit branch deletion for the user) → verify physical deletion: [context/cleanup.md](context/cleanup.md). `--dry-run` reports candidates and takes no action. + +**Safety invariants cleanup MUST honor** (full detail in context/cleanup.md): + +- **Release OS file locks BEFORE `git worktree remove --force`** (Step 4a) — on Windows `--force` unregisters the worktree from git but leaves a husk on disk if a process holds a file handle. Stop build servers (`dotnet build-server shutdown`, Gradle `--stop`, or your stack's equivalent) and worktree-rooted daemons/MCP servers first; stop ONLY those (never another live worktree's processes). +- **Never swallow removal stderr** (`2>/dev/null`) — a failed removal must surface so Step 5 reports husks honestly rather than counting one as removed. +- **Emit `git branch -D` + self-worktree removal for the USER to run, never inline** (Step 4c) — deleting a branch is destructive (and the consuming project's hooks may block it mid-session); a worktree can't delete itself (the running Claude Code session holds its handle). `-D` (not `-d`) is needed because squash-merge changes the SHA. + +--- + +## Action: `audit` + +Periodic health check for worktree infrastructure — suitable as a recurring work item in your tracker. **Step 1:** run the `status` action internally, flagging any worktrees with issues (stale, merged-not-cleaned, prunable). The Step 2 configuration-health checklist (`delete_branch_on_merge`, gitignored-file propagation) and the Step 3 findings presentation: [context/audit.md](context/audit.md). + +--- + +## What this skill does NOT do + +- **Does not push, create, merge, or close PRs** — `/pull-request` owns the back-half (prep, create, monitor, merge). +- **Does not commit or stage code** — staging and committing stay user-controlled; `/commit` owns the commit mechanic. +- **Does not run CI, build, test, or lint** — use your project's build/test/lint tooling or skills. +- **Does not manage remote branches** — GitHub's `delete_branch_on_merge` handles remote cleanup on merge (when enabled); local `git branch -D` is emitted for the user, never run inline. +- **Does not enforce branch naming** — the consuming project's hooks and CI are the gates. This skill only surfaces the project's convention (read it from the project's `CLAUDE.md` / rules; default suggestion: `<type>/<kebab-description>` with a Conventional Commits type prefix). + +## Integration Points + +This skill complements other workflow components — it does not duplicate their logic. + +| Component | Relationship | +|-----------|-------------| +| `/pull-request merge` (Phase 4) | Handles post-merge cleanup as part of PR lifecycle. `/worktree cleanup` is the standalone version for ad-hoc or batch cleanup | +| `/pull-request create` (Phase 2.1) | Detects default-branch checkout and suggests `/worktree create` | +| Project session-start hooks (if any) | May warn on main or auto-configure fresh worktrees; this skill verifies setup ran per context/create.md's post-create checks | +| Recurring maintenance tracker items | Can invoke `/worktree audit` periodically | + +## Graceful Degradation + +- **`gh` CLI unavailable or fails**: `status` and `cleanup` work with git-only data. PR cross-reference and the `delete_branch_on_merge` check are skipped with note: "GitHub API unavailable — PR status unknown." +- **Not in a git repo**: All actions exit immediately with "Not in a git repository." +- **`WORKTREE_STALE_DAYS` invalid**: Falls back to 14-day default silently. +- **No worktrees exist**: `status` reports "No linked worktrees found." `cleanup` reports "Nothing to clean up." diff --git a/plugins/source-control/skills/worktree/context/audit.md b/plugins/source-control/skills/worktree/context/audit.md new file mode 100644 index 000000000..7630221e1 --- /dev/null +++ b/plugins/source-control/skills/worktree/context/audit.md @@ -0,0 +1,36 @@ +# Worktree `audit` — configuration health checks and findings presentation + +Full detail for the `/worktree audit` action. SKILL.md carries the headline plus Step 1 (run `status` internally); this file carries the Step 2 configuration-health checklist and the Step 3 findings presentation. + +Periodic health check for worktree infrastructure. Suitable as a recurring item in your work-item tracker. + +## Step 2: Check configuration health + +| Check | How | Expected | +|-------|-----|----------| +| `delete_branch_on_merge` | `gh api repos/{owner}/{repo} --jq '.delete_branch_on_merge'` | `true` recommended — remote branches auto-delete on merge, so cleanup only handles local branches | +| Gitignored-file propagation | Check whether a `.worktreeinclude` file exists at the repo root | Optional — suggest when the project keeps local secrets/config in gitignored files (e.g. `.claude/settings.local.json`); Claude Code copies matching gitignored files into new worktrees | +| Project worktree hooks | If the project registers `WorktreeCreate` / SessionStart setup hooks in its settings, confirm they are present as its docs expect | Per project convention — skip when the project has none | +| Stale metadata | `git worktree list --porcelain` shows no `prunable` entries | Clean — otherwise suggest `git worktree prune` via `/worktree cleanup` | + +## Step 3: Present findings + +```markdown +## Worktree Audit + +### Infrastructure +| Check | Status | +|-------|--------| +| delete_branch_on_merge | OK (enabled) | +| .worktreeinclude | SUGGEST — gitignored local settings exist but no .worktreeinclude | +| Stale metadata | OK (none prunable) | + +### Worktree Health +- 3 worktrees total +- 1 stale (> 14 days, no PR) — consider `/worktree cleanup` +- 0 prunable + +### Recommendations +- Create `.worktreeinclude` with your local-settings pattern for automatic propagation +- Run `/worktree cleanup` to remove the stale worktree +``` diff --git a/plugins/source-control/skills/worktree/context/cleanup.md b/plugins/source-control/skills/worktree/context/cleanup.md new file mode 100644 index 000000000..eebae6dee --- /dev/null +++ b/plugins/source-control/skills/worktree/context/cleanup.md @@ -0,0 +1,99 @@ +# Worktree `cleanup` — full 5-step procedure + +Full detail for the `/worktree cleanup [--dry-run]` action. SKILL.md carries the headline plus the safety invariants; this file carries the complete step-by-step (prune → identify → present → execute → verify), including the Windows file-lock handling and the user-emitted branch deletion. + +Remove stale worktrees, orphaned metadata, and branches from merged PRs. + +## Step 1: Prune orphaned metadata + +```bash +git worktree prune +``` + +Cleans up worktree administrative records for directories that no longer exist on disk (e.g., manually deleted via `rm -rf`). + +## Step 2: Identify cleanup candidates + +Run `status` logic internally and identify candidates: + +| Reason | Detection method | +|--------|-----------------| +| **Orphaned directory** | Directory exists under a worktree root but NOT in `git worktree list` output. Scan every root your project uses — common layouts: (1) `<repo-root>/.worktrees/`; (2) Claude Code's default `<repo-root>/.claude/worktrees/`; (3) bare-clone hub `<hub-root>/<name>/` — siblings of `.bare/`, found by detecting the hub (`git rev-parse --git-common-dir` ends in `.bare`) and resolving `<hub-root>` as its parent (same detection the Smart Default + `create` pre-flight already use). Empty shells are left when Claude Code's built-in cleanup removes worktree contents but the directory husk persists — from terminal kill without clean exit, OR a file lock blocking deletion (release per Step 4a first). Safe to remove once unlocked | +| **Prunable** | `git worktree list --porcelain` shows `prunable` flag | +| **PR merged** | `gh pr list --state merged --head <branch>` returns non-empty result | +| **Stale** | Last commit > threshold days, no open PR, no locked flag | + +Extract actual branch name from porcelain output (`branch refs/heads/<name>`), not from directory name — they may differ if branch was renamed. + +## Step 3: Present candidates + +```markdown +## Cleanup Candidates + +| # | Worktree | Branch | Reason | +|---|----------|--------|--------| +| 1 | <worktree-root>/old-fix | fix/old-thing | PR #18 merged 5d ago | +| 2 | <worktree-root>/moonlit-popping-pike | — | Orphaned directory (empty, no git ref) | +| 3 | (orphaned metadata) | — | Directory no longer exists | + +**Action:** Remove these 3 items? (yes/no/select) +``` + +## Step 4: Execute or report + +- **`--dry-run`**: Report candidates only, take no action. Exit. +- **Otherwise**: Ask for confirmation. On "yes", run each candidate through phases 4a → 4b → 4c. + +### Step 4a: Release file locks first (Windows-critical) + +`git worktree remove --force` overrides git's dirty/locked-worktree check but does NOT release OS file handles. On Windows, any process holding a file under the worktree blocks directory deletion ("Permission denied" / "being used by another process") — `--force` then unregisters the worktree from git but leaves a husk on disk. Before removing a candidate, stop the processes rooted in its path: + +- **Build servers** holding compiled output — e.g. `dotnet build-server shutdown` (.NET / VBCSCompiler + MSBuild), Gradle `--stop`, or your stack's equivalent. They hold bin/output DLLs open. +- **Long-lived daemons / MCP servers** started inside the worktree — identify processes whose executable path or command line is under the candidate directory, and stop ONLY those (never processes belonging to other live worktrees). + +Skipping 4a is the usual reason a previous `/worktree cleanup` left husks behind — Step 5 then reports them honestly rather than hiding the failure. + +### Step 4b: Remove the worktree + +```bash +# Orphaned directory (on disk, not in `git worktree list`): remove the husk +rm -rf <path> + +# Git-tracked worktree — escalate only as far as needed: +git worktree remove <path> \ + || git worktree remove --force <path> \ + || git worktree remove --force --force <path> # second --force required for LOCKED worktrees (git-scm) +``` + +Do NOT swallow stderr with `2>/dev/null` — a failed removal must surface so Step 5 can report it honestly. + +### Step 4c: Emit branch + current-worktree deletion for the user (do not run inline) + +Branch deletion is destructive (and the consuming project's hooks may block `git branch -D` mid-session), and the worktree a session runs in cannot delete itself — the running Claude Code process holds its directory handle. Surface these for the user to run from a main-repo terminal (or via the `!` prompt prefix) rather than executing them inline: + +```bash +# Run from main repo / another terminal: +git branch -D <branch-name> # -D needed (squash-merge changes SHA) +git worktree remove <current-worktree-path> # only if the active worktree was itself a candidate +``` + +Remote branch cleanup is not needed when the repo has `delete_branch_on_merge` enabled (GitHub deletes the remote branch on merge) — check via `gh api repos/{owner}/{repo} --jq .delete_branch_on_merge`; otherwise also emit `git push origin --delete <branch-name>`. + +## Step 5: Verify physical deletion, prune, and report + +```bash +git worktree prune # clears admin metadata for working trees now missing +``` + +`git worktree prune` clears metadata but cannot delete a husk a process still holds, so verify each removed candidate's directory is actually gone: + +```bash +test -d <path> && echo "HUSK REMAINS: <path>" || echo "removed: <path>" # PowerShell: Test-Path <path> +``` + +Report honestly — never count a husk as removed: + +- **Fully removed** — directory gone AND metadata pruned. +- **Unregistered, husk remains** — `git worktree list` is clean but the directory is still on disk (a lock survived Step 4a). Surface the path; the user removes it after closing the holding process. + +Report: "Removed N worktrees (M fully deleted, K husks remaining — paths above). Run `/worktree status` to verify." diff --git a/plugins/source-control/skills/worktree/context/create.md b/plugins/source-control/skills/worktree/context/create.md new file mode 100644 index 000000000..633c76c21 --- /dev/null +++ b/plugins/source-control/skills/worktree/context/create.md @@ -0,0 +1,74 @@ +# Worktree `create` — pre-flight, naming, base-ref, setup verification + +Full detail for the `/worktree create [name]` action. SKILL.md carries the headline plus the `EnterWorktree`-as-final-action safety invariant; this file carries the pre-flight guards, name validation, base-ref selection, the explain-before-create block, the directory-rename caveats, and the post-create setup checks. + +Create a new worktree with guided naming and setup verification. + +## Pre-flight checks + +1. **Already in a worktree?** Check whether CWD is a linked worktree: `git rev-parse --git-dir` differs from `git rev-parse --git-common-dir` (covers every layout — `.worktrees/`, `.claude/worktrees/`, bare-clone hub). If yes → "Already in a worktree (`<current-branch>`). Use `ExitWorktree` to leave this one first, then `/worktree create` again." + +2. **Mid-session transition?** If the session previously used `ExitWorktree` (CWD is now the main repo root, not a worktree), this is a worktree transition — fully supported. Session context persists across the transition. Proceed normally. + +3. **Name provided?** If `$ARGUMENTS` has a name after `create`, use it. Otherwise, prompt the user for a name following the project's branch naming convention (read it from the project's `CLAUDE.md` / rules; common default: `<type>/<kebab-description>` with a Conventional Commits type prefix — `feat/`, `fix/`, `chore/`, etc.). Passing a convention-conforming name matters because the worktree's branch is derived from it. + +## Name validation + +The name passed to `EnterWorktree` has these constraints (from the tool schema): + +- Each `/`-separated segment may contain only **letters, digits, dots, underscores, and dashes** +- Max **64 characters** total +- `/` is a valid segment separator (enables `feat/my-feature` format) + +Validate the name against these rules. If invalid, explain what's wrong and ask for correction. + +## Base branch + +By default, Claude Code's `worktree.baseRef` setting governs the base: `fresh` (default) branches new worktrees from `origin/<default-branch>`; `head` branches from the local `HEAD` so unpushed commits carry in. Consuming projects may override worktree creation with their own `WorktreeCreate` hook (custom path layout, branch derivation) — when such a hook exists, its behavior wins; read the project's docs. To start from a different base explicitly, create manually: `git worktree add -b <type>/<desc> <path> <base>`. + +## Explain what will happen + +Before calling EnterWorktree, tell the user: + +```text +Creating worktree: + Directory: <worktree-path>/ (Claude Code default, or your project's + WorktreeCreate-hook layout) + Branch: <name> (derived from the name you pass) + Setup: your project's session-start hooks (if any) run on next SessionStart; + mid-session EnterWorktree may need a manual setup re-run + +Optional renames after creation: + git branch -m <old> <type>/<description> # sharpen the branch name + git worktree move <old-path> <new-path> # rename the directory +``` + +**Directory renaming via `git worktree move`:** rename at any time with `git worktree move <old-path> <new-path>` — updates Git's internal references automatically. Run it from outside the worktree being moved (e.g., from main). Caveats: + +- **Session history**: Claude Code's `~/.claude/projects/` directory is keyed by worktree filesystem path. Moving the directory orphans the old project key — `--resume`/`--continue` from a new session won't find the old transcript. Auto-memory and project config are shared at repo level and are NOT affected. +- **Windows**: works on Git Bash/NTFS with no known issues. Use forward slashes or quote paths with spaces. +- **Cannot move**: the main worktree, or worktrees containing submodules. +- **Locked worktrees**: require `--force --force` (twice). + +## Create the worktree + +Call `EnterWorktree(name: "<validated-name>")` as the **final action**. Nothing should execute after this call because the working directory changes and session state transitions. + +If the project has session-start setup hooks, they run on the next SessionStart; for mid-session `EnterWorktree`, SessionStart may not fire — run the project's setup steps manually if the checks below fail. + +**Universal checks** (apply in every worktree regardless of ecosystem): + +| Check | Command | Fix hint | +|-------|---------|----------| +| Local settings/secrets present | e.g. `test -f .claude/settings.local.json` (when the project uses one) | Copy from the main repo checkout, or rely on the project's `.worktreeinclude` (Claude Code copies matching gitignored files at creation) | +| Git hooks installed | Depends on the project's hook manager (e.g. `lefthook list`, `husky` install state) | Run the project's hook-install command | + +**Ecosystem checks** (each gated on a trigger glob — skip silently if no matching files exist in the worktree root): + +| Ecosystem | Trigger glob | Check | Command | +|-----------|--------------|-------|---------| +| .NET | `*.sln`, `*.slnx` | dependencies restored | `dotnet restore` | +| Node | `package.json` | dependencies installed | `npm install` (or the project's package manager) | +| Python | `pyproject.toml` | environment synced | `uv sync` / `pip install -e .` | + +Gitignored files (secrets, `.venv/`, `node_modules/`, build output) do NOT propagate to a fresh worktree — that is what these checks catch. diff --git a/plugins/source-control/skills/worktree/context/status.md b/plugins/source-control/skills/worktree/context/status.md new file mode 100644 index 000000000..ba09047da --- /dev/null +++ b/plugins/source-control/skills/worktree/context/status.md @@ -0,0 +1,53 @@ +# Worktree `status` — data collection, classification, presentation + +Full detail for the `/worktree status` action. SKILL.md carries the headline; this file carries the porcelain-parse fields, the staleness math, the 6-status classification table, and the output schema. + +## Data collection + +1. **Worktree list**: Run `git worktree list --porcelain` and parse entries. Each entry is separated by blank line and contains: + - `worktree <path>` — filesystem path + - `HEAD <sha>` — current commit + - `branch refs/heads/<name>` — checked-out branch (absent if detached) + - `detached` — flag if HEAD is detached + - `locked` — flag if worktree is locked (optional reason on same line) + - `prunable` — flag if worktree can be pruned (optional reason on same line) + + Always `| tr -d '\r'` on Windows/Git Bash to strip carriage returns. + + `git worktree list --porcelain` emits correct absolute paths for every layout (standard clone, bare-clone hub, `.claude/worktrees/`), so `status` and `audit` need no layout-specific detection here — unlike Smart Default / `create` / `cleanup`, which resolve the hub root (`git rev-parse --git-common-dir` ending in `.bare`) for path construction. + +2. **PR cross-reference**: Run `gh pr list --state all --json number,title,state,headRefName` once (not per-branch — batch is more efficient). Match each worktree's branch name against `headRefName`. Graceful degradation: if `gh` fails, skip PR info and note "GitHub API unavailable." + +3. **Last commit date**: For each worktree branch, get date of last commit: + + ```bash + git log -1 --format='%ci' <branch> 2>/dev/null + ``` + +4. **Staleness**: Compare last commit date to today. Default threshold: **14 days** (configurable via `WORKTREE_STALE_DAYS` env var, falling back to 14 if unset or invalid). + +## Status classification + +| Status | Condition | +|--------|-----------| +| `active` | Recent commits, no issues | +| `stale` | Last commit > threshold days ago, no open PR | +| `in-review` | Has an open PR (regardless of commit age) | +| `merged` | PR was merged but worktree/branch not cleaned up | +| `prunable` | Git flagged as prunable (directory missing or corrupted) | +| `locked` | Explicitly locked by user | + +## Presentation + +```markdown +## Worktree Status + +| # | Path | Branch | PR | Last Commit | Status | +|---|------|--------|----|-------------|--------| +| 1 | <worktree-root>/feat-auth | feat/add-auth | #21 OPEN | 2d ago | in-review | +| 2 | <worktree-root>/old-fix | worktree-old-fix | — | 23d ago | stale | + +**Summary:** 2 worktrees (1 active, 1 stale) +``` + +If issues are found, suggest actions: `/worktree cleanup` for stale/merged, `git worktree unlock` for locked. From ebd451ee32d39abc31b9b47e9730c03a24c256b7 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sat, 11 Jul 2026 07:41:39 -0400 Subject: [PATCH 02/12] fix: exec bits, sourced-lib header, static-analyzable worktree preamble Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com> --- .../skills/pull-request/scripts/babysit-readiness-gate.sh | 0 .../skills/pull-request/scripts/babysit-readiness-gate.test.sh | 0 .../source-control/skills/pull-request/scripts/discover-prs.sh | 0 .../skills/pull-request/scripts/discover-prs.test.sh | 0 .../skills/pull-request/scripts/fetch-all-pr-comments.sh | 0 .../skills/pull-request/scripts/fetch-all-pr-comments.test.sh | 0 .../skills/pull-request/scripts/fetch-annotations.sh | 0 .../skills/pull-request/scripts/fetch-annotations.test.sh | 0 .../skills/pull-request/scripts/fetch-failed-logs.sh | 0 .../skills/pull-request/scripts/fetch-failed-logs.test.sh | 0 .../skills/pull-request/scripts/parse-branch-issue.sh | 0 .../skills/pull-request/scripts/parse-branch-issue.test.sh | 0 .../source-control/skills/pull-request/scripts/test-helpers.sh | 2 +- plugins/source-control/skills/worktree/SKILL.md | 3 ++- 14 files changed, 3 insertions(+), 2 deletions(-) mode change 100644 => 100755 plugins/source-control/skills/pull-request/scripts/babysit-readiness-gate.sh mode change 100644 => 100755 plugins/source-control/skills/pull-request/scripts/babysit-readiness-gate.test.sh mode change 100644 => 100755 plugins/source-control/skills/pull-request/scripts/discover-prs.sh mode change 100644 => 100755 plugins/source-control/skills/pull-request/scripts/discover-prs.test.sh mode change 100644 => 100755 plugins/source-control/skills/pull-request/scripts/fetch-all-pr-comments.sh mode change 100644 => 100755 plugins/source-control/skills/pull-request/scripts/fetch-all-pr-comments.test.sh mode change 100644 => 100755 plugins/source-control/skills/pull-request/scripts/fetch-annotations.sh mode change 100644 => 100755 plugins/source-control/skills/pull-request/scripts/fetch-annotations.test.sh mode change 100644 => 100755 plugins/source-control/skills/pull-request/scripts/fetch-failed-logs.sh mode change 100644 => 100755 plugins/source-control/skills/pull-request/scripts/fetch-failed-logs.test.sh mode change 100644 => 100755 plugins/source-control/skills/pull-request/scripts/parse-branch-issue.sh mode change 100644 => 100755 plugins/source-control/skills/pull-request/scripts/parse-branch-issue.test.sh diff --git a/plugins/source-control/skills/pull-request/scripts/babysit-readiness-gate.sh b/plugins/source-control/skills/pull-request/scripts/babysit-readiness-gate.sh old mode 100644 new mode 100755 diff --git a/plugins/source-control/skills/pull-request/scripts/babysit-readiness-gate.test.sh b/plugins/source-control/skills/pull-request/scripts/babysit-readiness-gate.test.sh old mode 100644 new mode 100755 diff --git a/plugins/source-control/skills/pull-request/scripts/discover-prs.sh b/plugins/source-control/skills/pull-request/scripts/discover-prs.sh old mode 100644 new mode 100755 diff --git a/plugins/source-control/skills/pull-request/scripts/discover-prs.test.sh b/plugins/source-control/skills/pull-request/scripts/discover-prs.test.sh old mode 100644 new mode 100755 diff --git a/plugins/source-control/skills/pull-request/scripts/fetch-all-pr-comments.sh b/plugins/source-control/skills/pull-request/scripts/fetch-all-pr-comments.sh old mode 100644 new mode 100755 diff --git a/plugins/source-control/skills/pull-request/scripts/fetch-all-pr-comments.test.sh b/plugins/source-control/skills/pull-request/scripts/fetch-all-pr-comments.test.sh old mode 100644 new mode 100755 diff --git a/plugins/source-control/skills/pull-request/scripts/fetch-annotations.sh b/plugins/source-control/skills/pull-request/scripts/fetch-annotations.sh old mode 100644 new mode 100755 diff --git a/plugins/source-control/skills/pull-request/scripts/fetch-annotations.test.sh b/plugins/source-control/skills/pull-request/scripts/fetch-annotations.test.sh old mode 100644 new mode 100755 diff --git a/plugins/source-control/skills/pull-request/scripts/fetch-failed-logs.sh b/plugins/source-control/skills/pull-request/scripts/fetch-failed-logs.sh old mode 100644 new mode 100755 diff --git a/plugins/source-control/skills/pull-request/scripts/fetch-failed-logs.test.sh b/plugins/source-control/skills/pull-request/scripts/fetch-failed-logs.test.sh old mode 100644 new mode 100755 diff --git a/plugins/source-control/skills/pull-request/scripts/parse-branch-issue.sh b/plugins/source-control/skills/pull-request/scripts/parse-branch-issue.sh old mode 100644 new mode 100755 diff --git a/plugins/source-control/skills/pull-request/scripts/parse-branch-issue.test.sh b/plugins/source-control/skills/pull-request/scripts/parse-branch-issue.test.sh old mode 100644 new mode 100755 diff --git a/plugins/source-control/skills/pull-request/scripts/test-helpers.sh b/plugins/source-control/skills/pull-request/scripts/test-helpers.sh index 159c3ed80..75c229c4b 100644 --- a/plugins/source-control/skills/pull-request/scripts/test-helpers.sh +++ b/plugins/source-control/skills/pull-request/scripts/test-helpers.sh @@ -1,4 +1,4 @@ -#!/usr/bin/env bash +# shellcheck shell=bash # test-helpers.sh — shared assertion primitives for this plugin's *.test.sh # suites. Self-contained — no host-repo assertion library. Sourced, not # executed; the plugin test runner ignores it via the *.test.sh glob. diff --git a/plugins/source-control/skills/worktree/SKILL.md b/plugins/source-control/skills/worktree/SKILL.md index af1790126..798b22d56 100644 --- a/plugins/source-control/skills/worktree/SKILL.md +++ b/plugins/source-control/skills/worktree/SKILL.md @@ -10,7 +10,8 @@ argument-hint: "<action> [args] (e.g., /worktree create feat/my-feature, /worktr Current branch: !`git branch --show-current 2>/dev/null || echo "unknown"` Worktree inventory: !`git worktree list 2>/dev/null | head -30 || echo "not a git repo"` -In a worktree: !`test "$(git rev-parse --git-dir 2>/dev/null)" != "$(git rev-parse --git-common-dir 2>/dev/null)" && echo "yes" || echo "no"` +Git dir: !`git rev-parse --git-dir 2>/dev/null || echo "none"` +Git common dir (differs from git dir when in a linked worktree): !`git rev-parse --git-common-dir 2>/dev/null || echo "none"` ## Purpose From 4fcdfb70652cbe340d8652d999abb78f2eb346f9 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sat, 11 Jul 2026 07:59:07 -0400 Subject: [PATCH 03/12] fix: address Codex review findings (WIP-safe babysit checkout, all-surface watch polling, clean-tree rebase ordering) Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com> --- .../skills/pull-request/reference/babysit.md | 12 ++++++------ .../skills/pull-request/reference/create.md | 4 +++- .../skills/pull-request/reference/monitor.md | 11 ++++++++++- 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/plugins/source-control/skills/pull-request/reference/babysit.md b/plugins/source-control/skills/pull-request/reference/babysit.md index 6363f2e9f..dbab42002 100644 --- a/plugins/source-control/skills/pull-request/reference/babysit.md +++ b/plugins/source-control/skills/pull-request/reference/babysit.md @@ -180,19 +180,19 @@ A push channel arms for ONE PR at a time. Re-arm for each new PR in the loop. (`main` below — substitute the repo's default branch.) ```bash -# Pre-check: is branch checked out in another worktree? +# Pre-check 1: is branch checked out in another worktree? +# Pre-check 2: does THIS worktree have uncommitted changes? They may be +# another session's WIP — never reset/clean work this loop did not create. BRANCH="<headRefName>" if git worktree list | grep -q "\[$BRANCH\]"; then echo "Branch $BRANCH checked out in another worktree — processing read-only" CHECKOUT_MODE="read-only" +elif [ -n "$(git status --porcelain)" ]; then + echo "Working tree has uncommitted changes (possibly another session's WIP) — no checkout, processing read-only" + CHECKOUT_MODE="read-only" else git fetch origin "$BRANCH" git fetch origin main - # Ensure clean working tree + index before checkout - if [ -n "$(git status --porcelain)" ]; then - git reset --hard HEAD - git clean -fd - fi git checkout "$BRANCH" # Branch freshness — rebase if behind main diff --git a/plugins/source-control/skills/pull-request/reference/create.md b/plugins/source-control/skills/pull-request/reference/create.md index abbea541e..ccd180622 100644 --- a/plugins/source-control/skills/pull-request/reference/create.md +++ b/plugins/source-control/skills/pull-request/reference/create.md @@ -42,7 +42,9 @@ ## 2.2 Rebase onto the latest default branch -Ensure the branch is current with the default branch before committing and pushing. Prevents merge conflicts and stale-branch CI failures. (`main` below — substitute the repo's default branch.) +Ensure the branch is current with the default branch before pushing. Prevents merge conflicts and stale-branch CI failures. (`main` below — substitute the repo's default branch.) + +**Ordering — rebase needs a clean tree.** `git rebase` refuses to run with unstaged changes (`error: cannot rebase: You have unstaged changes.`). On the normal `create` path the PR changes are still uncommitted when this phase starts — in that case run 2.3 (classify unrelated changes + stage + commit) FIRST, then return here and integrate before the 2.4 push. Run 2.2 in the listed order only when the tree is already clean (all work committed). ```bash git fetch origin main diff --git a/plugins/source-control/skills/pull-request/reference/monitor.md b/plugins/source-control/skills/pull-request/reference/monitor.md index 9aede8e2b..f5a2175ce 100644 --- a/plugins/source-control/skills/pull-request/reference/monitor.md +++ b/plugins/source-control/skills/pull-request/reference/monitor.md @@ -83,11 +83,20 @@ Establish a baseline poll: `gh pr checks <N>` + the three comment-surface fetche prev_checks="$cur_checks" fi - # New comments (emit login + first 80 chars of body) + # New comments — ALL THREE review surfaces (issue-level, inline + # review comments, review bodies). Watching only issues/comments + # misses inline findings posted with no CI state change. now=$(date -u +%Y-%m-%dT%H:%M:%SZ) gh api "repos/$OWNER/$REPO/issues/$PR_NUMBER/comments?since=$last_comment_ts" \ --jq '.[] | "COMMENT \(.user.login): \(.body[:80])"' \ 2>/dev/null | tr -d '\r' | grep --line-buffered . || true + gh api "repos/$OWNER/$REPO/pulls/$PR_NUMBER/comments?since=$last_comment_ts" \ + --jq '.[] | "INLINE-COMMENT \(.user.login): \(.body[:80])"' \ + 2>/dev/null | tr -d '\r' | grep --line-buffered . || true + # Reviews API has no `since` param — filter client-side on submitted_at + gh api "repos/$OWNER/$REPO/pulls/$PR_NUMBER/reviews" \ + --jq ".[] | select(.submitted_at > \"$last_comment_ts\") | \"REVIEW \(.user.login) [\(.state)]: \(.body[:80])\"" \ + 2>/dev/null | tr -d '\r' | grep --line-buffered . || true last_comment_ts="$now" sleep 30 From c47b4ded103c34027dc7113ac74c72a4a9e9d3ab Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sat, 11 Jul 2026 08:06:57 -0400 Subject: [PATCH 04/12] fix: address Codex round-2 findings (terminal rebase states, inline-reply verify surface, table-row classification counting) Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com> --- .../skills/pull-request/SKILL.md | 6 ++--- .../skills/pull-request/reference/babysit.md | 17 +++++++++---- .../scripts/babysit-readiness-gate.sh | 24 ++++++++++++------- .../scripts/babysit-readiness-gate.test.sh | 14 +++++++++++ 4 files changed, 46 insertions(+), 15 deletions(-) diff --git a/plugins/source-control/skills/pull-request/SKILL.md b/plugins/source-control/skills/pull-request/SKILL.md index 78ceaf2b3..8adf50338 100644 --- a/plugins/source-control/skills/pull-request/SKILL.md +++ b/plugins/source-control/skills/pull-request/SKILL.md @@ -154,11 +154,11 @@ When a channel event, Monitor notification, or poll iteration fires, complete AL - [ ] D4 — Classify: VALID (fix now) / VALID (defer) / INCORRECT / UNCERTAIN. Classification MUST cite evidence from D2-D3 - [ ] D4.5 — React to the parent comment: `+1` VALID, `-1` INCORRECT, `eyes` UNCERTAIN (via `gh api .../reactions`). One reaction per comment. Mixed findings: `+1` if any VALID. Verify the reaction posted via a GET on the same endpoint — non-zero confirms - [ ] D5 — Reply with a per-finding classification table + evidence (before fixing). **Route by comment type — REQUIRED, not interchangeable:** inline review comments MUST reply THREADED via `gh api repos/<owner>/<repo>/pulls/<N>/comments/<id>/replies`; issue-level / review-level → `gh pr comment <N>`. Answering an inline finding with a detached `pr comment` is a routing error, not a style choice. Use the project's bot-identity wrapper for these writes when it has one; plain `gh` otherwise - - [ ] **Verify reply exists:** `gh api repos/<owner>/<repo>/issues/<N>/comments --jq '.[].body'` — confirm the reply text appears on GitHub + - [ ] **Verify reply exists — on the surface it was posted to:** inline threaded replies land on the review-comment surface — `gh api repos/<owner>/<repo>/pulls/<N>/comments --jq '.[] | select(.in_reply_to_id == <original-id>)'`; issue-level replies — `gh api repos/<owner>/<repo>/issues/<N>/comments --jq '.[].body'`. Querying only issues/comments false-fails a correctly posted inline reply - [ ] D6 — Fix if VALID (fix now) — edit, `git add <files>`, commit, push - [ ] **Verify commit pushed:** `gh api "repos/<owner>/<repo>/commits?sha=<branch>&per_page=1" --jq '.[0].sha'` — confirm the fix commit SHA appears on the remote - [ ] D7 — Post a follow-up reply citing the fix commit SHA - - [ ] **Verify follow-up reply posted:** `gh api repos/<owner>/<repo>/issues/<N>/comments --jq '.[-1].body'` — confirm the follow-up with SHA appears on GitHub + - [ ] **Verify follow-up reply posted — same surface routing as D5:** inline thread → `pulls/<N>/comments` filtered by `in_reply_to_id`; issue-level → `gh api repos/<owner>/<repo>/issues/<N>/comments --jq '.[-1].body'` — confirm the follow-up with SHA appears on GitHub - [ ] D7.5 — Resolve review thread — **author-conditional, inline only**. Resolve threads opened by a BOT reviewer that you addressed. NEVER resolve HUMAN-authored threads (the human resolves their own). NEVER resolve your OWN (your posting identity — bot or personal). Detect bot via the API surface in use — REST `user.type==Bot`; GraphQL `author.__typename==Bot` (resolution runs via GraphQL). Verify `isResolved == true` via GraphQL - [ ] **E — Readiness gate:** ALL checks terminal + ALL comments addressed + 2-min cooldown since last activity per [readiness.md](reference/readiness.md) - [ ] **F — Report:** present the full readiness table OR list remaining blockers @@ -172,7 +172,7 @@ When running the `babysit` action, execute these steps for EACH PR discovered. T - [ ] **Step 0 — PR discovery:** `gh pr list` filtered (skip draft, oldest-first). See [reference/babysit.md](reference/babysit.md) §5.0.2 - [ ] **Step 0.1 — Evidence-based fresh rescan:** fetch ALL comments via the bundled `fetch-all-pr-comments.sh`, classify each as addressed/unaddressed by checking GitHub for substantive replies with classification + evidence. GitHub is the source of truth, not model memory. See §5.0.3 - [ ] **Step 0.2 — Branch checkout:** `git fetch origin <branch>` then `git checkout <branch>`. MANDATORY before any comment investigation — exploration and research must run against PR branch code. Pre-check `git worktree list` — if the branch is checked out elsewhere, process read-only (no fix). See §5.1.2 -- [ ] **Step 0.3 — Branch freshness:** `git fetch origin <default-branch>` then `git merge-base --is-ancestor origin/<default-branch> HEAD`. If behind: integrate (merge vs rebase per the project's convention and the branch's own history — see §5.1.2), resolving conflicts conservatively. Report status: current/rebased/conflict-attempting/conflict-aborted +- [ ] **Step 0.3 — Branch freshness:** `git fetch origin <default-branch>` then `git merge-base --is-ancestor origin/<default-branch> HEAD`. If behind: integrate (merge vs rebase per the project's convention and the branch's own history — see §5.1.2), resolving conflicts conservatively. Report status: current/rebased/conflict-aborted - [ ] **Steps 1-4 — Monitor entry checklist** (above) — run per-PR. A push channel re-arms its PR filter for each PR - [ ] **Steps A-F — Per-iteration monitoring checklist** (above) — run per-PR. Extract individual findings from each comment per §5.0.4 (one comment with N findings = N work items). **For any comment with ≥3 findings, MANDATORY subagent dispatch** per §5.0.4. Run D1-D7 per-finding, not per-comment. Verify each action landed on GitHub (D4.5/D5/D6/D7/D7.5 verification gates) - [ ] **Step 5 — Commit + push** fixes on the PR branch. Clean working tree. Post follow-up replies with commit SHAs (D7) diff --git a/plugins/source-control/skills/pull-request/reference/babysit.md b/plugins/source-control/skills/pull-request/reference/babysit.md index dbab42002..7d792cc8a 100644 --- a/plugins/source-control/skills/pull-request/reference/babysit.md +++ b/plugins/source-control/skills/pull-request/reference/babysit.md @@ -202,7 +202,10 @@ else REBASE_STATUS="rebased" git push --force-with-lease origin "$BRANCH" else - # Graduated conflict handling — attempt simple, abort complex + # Graduated conflict handling — attempt simple, abort complex. + # conflict-attempting is a TRANSIENT state: resolve it (rebase + # --continue) or abort BEFORE any further processing — never leave a + # rebase in progress (unmerged paths break later checkouts + parking). CONFLICT_COUNT=$(git diff --name-only --diff-filter=U | grep -c . || true) if [ "$CONFLICT_COUNT" -le 3 ]; then echo "Simple conflict ($CONFLICT_COUNT files) — attempting resolution" @@ -217,7 +220,13 @@ else REBASE_STATUS="current" fi - if [ "$REBASE_STATUS" = "conflict-attempting" ] || [ "$REBASE_STATUS" = "conflict-aborted" ]; then + # conflict-attempting: resolve NOW — per file, take the mechanical + # resolution; if ANY file needs intent judgment, `git rebase --abort` and + # set REBASE_STATUS="conflict-aborted". On success: `git add <files>` + + # `git rebase --continue` + `git push --force-with-lease origin "$BRANCH"`, + # then set REBASE_STATUS="rebased". Only terminal states pass this point. + + if [ "$REBASE_STATUS" = "conflict-aborted" ]; then CHECKOUT_MODE="read-only" else CHECKOUT_MODE="full" @@ -228,7 +237,7 @@ fi **Rebase conflict handling (graduated).** Check for merge commits first (`git log --merges origin/main..HEAD`) — a branch that previously merged main integrates via `git merge origin/main`, not rebase. Then: - **Zero conflicts** (`REBASE_STATUS=rebased`) — rebase succeeded, force-push with lease, continue normally -- **Simple conflicts** (≤3 files, `REBASE_STATUS=conflict-attempting`) — attempt resolution; if ANY file requires intent judgment, abort to conflict-aborted +- **Simple conflicts** (≤3 files, `REBASE_STATUS=conflict-attempting`) — TRANSIENT: attempt resolution immediately; on success `git rebase --continue` + force-push with lease → `rebased`; if ANY file requires intent judgment, `git rebase --abort` → `conflict-aborted`. Never proceed to comment processing, parking, or the next PR with a rebase in progress - **Complex conflicts** (>3 files, `REBASE_STATUS=conflict-aborted`) — abort the rebase, post a PR comment: `"⚠️ Branch is behind main with merge conflicts ({N} files). Manual rebase required before CI will trigger."`. If an interactive terminal, also surface to the user directly. Process comments read-only (classification + reply, no fixes — the code may be stale) - **Already current** (`REBASE_STATUS=current`) — no action needed @@ -371,7 +380,7 @@ Every iteration MUST output a completed checklist with evidence per step. Free-f #### PR #<N> — <title> (<branch>) - [ ] **Branch:** checked out <branch> (mode: full/read-only) -- [ ] **Branch freshness:** <current/rebased/conflict-attempting/conflict-aborted> — evidence: `git merge-base` output +- [ ] **Branch freshness:** <current/rebased/conflict-aborted> — evidence: `git merge-base` output - [ ] **CI:** <pass/fail/pending> — evidence: `gh pr checks <N>` output - [ ] **Comments fetched:** <N> total from all 3 API surfaces (<M> self-replies filtered) - [ ] **Findings extracted:** <M> individual findings from <K> comments diff --git a/plugins/source-control/skills/pull-request/scripts/babysit-readiness-gate.sh b/plugins/source-control/skills/pull-request/scripts/babysit-readiness-gate.sh index 5d908af9f..bed3bfb6e 100755 --- a/plugins/source-control/skills/pull-request/scripts/babysit-readiness-gate.sh +++ b/plugins/source-control/skills/pull-request/scripts/babysit-readiness-gate.sh @@ -22,8 +22,9 @@ # comments — counted per match, not per line, so N findings on one # line each count (else a multi-finding line under-counts and the # gate false-passes) -# classified = OCCURRENCES of a classification token (VALID|INCORRECT| -# UNCERTAIN) across all SELF replies (the per-finding table rows). +# classified = TABLE ROWS (`|`-prefixed lines) carrying a classification +# token (VALID|INCORRECT|UNCERTAIN) across all SELF replies — +# one per line, so prose repetition never inflates the count. # Word-boundary matched so "INVALID" does not count as "VALID" # BLOCK when findings > 0 AND classified < findings (under-decomposed / # unaddressed — R1+R5), OR when a --checklist file has any "- [ ]" (R6). @@ -185,14 +186,21 @@ self_bodies="$(printf '%s' "$COMMENTS" | jq -r --argjson self "$SELF_JSON" ' .[] | select((.author as $a | $self | index($a))) | .body // ""' 2>/dev/null)" -# grep -o ... | grep -c . counts OCCURRENCES (one match per output line), not -# input lines — a single line with two markers must count as two findings, or -# the gate under-counts and false-passes (the very R1 decomposition gap it gates). -# `-w` = POSIX whole-word (portable to BSD grep); the badge URL is counted with a -# plain `-o` grep (no `-w`) and summed in. +# FINDINGS: grep -o ... | grep -c . counts OCCURRENCES (one match per output +# line), not input lines — a single line with two markers must count as two +# findings, or the gate under-counts and false-passes (the very R1 +# decomposition gap it gates). `-w` = POSIX whole-word (portable to BSD grep); +# the badge URL is counted with a plain `-o` grep (no `-w`) and summed in. +# +# CLASSIFICATIONS: counted as TABLE ROWS (markdown `|`-prefixed lines carrying +# a token), one per line — NOT free occurrences. A prose reply repeating +# "VALID" in its evidence sentence must not count twice, or the gate +# false-passes while findings lack per-finding rows (codex r3564093178). The +# per-finding classification TABLE is the mandated reply format (babysit.md +# §5.0.4), so non-table prose classifications intentionally do not count. sev_words=$(printf '%s\n' "$all_bodies" | grep -owE "$SEVERITY_WORDS_RE" | grep -c . || true) sev_badges=$(printf '%s\n' "$all_bodies" | grep -oE "$SEVERITY_BADGE_RE" | grep -c . || true) -classified=$(printf '%s\n' "$self_bodies" | grep -owE "$CLASSIFY_RE" | grep -c . || true) +classified=$(printf '%s\n' "$self_bodies" | grep -E '^[[:space:]]*\|' | grep -cwE "$CLASSIFY_RE" || true) sev_words=${sev_words//[^0-9]/} sev_badges=${sev_badges//[^0-9]/} classified=${classified//[^0-9]/} diff --git a/plugins/source-control/skills/pull-request/scripts/babysit-readiness-gate.test.sh b/plugins/source-control/skills/pull-request/scripts/babysit-readiness-gate.test.sh index 6ca167822..b8ba871f7 100755 --- a/plugins/source-control/skills/pull-request/scripts/babysit-readiness-gate.test.sh +++ b/plugins/source-control/skills/pull-request/scripts/babysit-readiness-gate.test.sh @@ -213,4 +213,18 @@ F=$(mkjson adjacent '[ r=$(run_gate "$F") assert_contains "adjacent words -> findings=2" "$r" "findings=2" +# --- Case: prose classification repetition must NOT inflate the count -------- +# A single self reply that repeats a token in prose ("VALID — ... is valid ... +# VALID") is NOT per-finding table rows; only `|`-prefixed table lines count, +# one per line (codex r3564093178). Two findings + one table row + prose +# repetition => classified=1, BLOCKED. +F=$(mkjson prose-repeat '[ + {author:"claude[bot]", body:"CRITICAL one. IMPORTANT two."}, + {author:"me[bot]", body:"| 1 | a | VALID | fixed |\nThe claim is VALID because the code confirms it. VALID indeed."} +]') +r=$(run_gate "$F") +assert_contains "prose repetition -> classified=1" "$r" "classified=1" +assert_contains "prose repetition under-decomposed -> blocked" "$r" "READINESS_BLOCKED reason=under-decomposed" +assert_contains "prose repetition -> exit 1" "$r" "EXIT:1" + [[ $FAILED -eq 0 ]] || exit 1 From 3c97c81453437667443e66d9e136c4b6bcbf24af Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sat, 11 Jul 2026 08:10:37 -0400 Subject: [PATCH 05/12] fix: match real gh pr checks bucket values in Monitor watch filter Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com> --- .../source-control/skills/pull-request/reference/monitor.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/plugins/source-control/skills/pull-request/reference/monitor.md b/plugins/source-control/skills/pull-request/reference/monitor.md index f5a2175ce..26b3049fb 100644 --- a/plugins/source-control/skills/pull-request/reference/monitor.md +++ b/plugins/source-control/skills/pull-request/reference/monitor.md @@ -77,8 +77,10 @@ Establish a baseline poll: `gh pr checks <N>` + the three comment-surface fetche --jq '.[] | select(.bucket != "pending" and .bucket != "in_progress") | "\(.name): \(.bucket)"' \ 2>/dev/null | tr -d '\r' | sort || true) if [ "$cur_checks" != "$prev_checks" ]; then + # gh pr checks --json bucket values are: pass|fail|pending|skipping|cancel + # (per the gh manual) — match those, not check-run conclusion strings. comm -13 <(echo "$prev_checks") <(echo "$cur_checks") | \ - grep --line-buffered -E 'failure|cancelled|timed_out|startup_failure|action_required|success|skipped' \ + grep --line-buffered -E ': (pass|fail|skipping|cancel)$' \ || true prev_checks="$cur_checks" fi From 6f0ed752462cf62435267a2c647f8a4c909929fa Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sat, 11 Jul 2026 08:28:11 -0400 Subject: [PATCH 06/12] ci: retrigger checks for head 3c97c81 From e0fa75f950eadc347cbd9126c8f6764a0b1142d6 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sat, 11 Jul 2026 09:33:10 -0400 Subject: [PATCH 07/12] fix: address round-4 review findings (own-branch babysit no-op, fork-safe gh pr checkout, safe rebase-state fallback, inline follow-up verify surface, watermark on fetch success, phantom-finding exclusion, no-PR smart-default routing) Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com> --- .../skills/pull-request/SKILL.md | 5 +-- .../skills/pull-request/reference/babysit.md | 32 +++++++++++++------ .../skills/pull-request/reference/monitor.md | 29 ++++++++++------- .../scripts/babysit-readiness-gate.sh | 16 ++++++++-- .../scripts/babysit-readiness-gate.test.sh | 12 +++++++ 5 files changed, 70 insertions(+), 24 deletions(-) diff --git a/plugins/source-control/skills/pull-request/SKILL.md b/plugins/source-control/skills/pull-request/SKILL.md index 8adf50338..a9150d1f9 100644 --- a/plugins/source-control/skills/pull-request/SKILL.md +++ b/plugins/source-control/skills/pull-request/SKILL.md @@ -90,10 +90,11 @@ Parse `$ARGUMENTS` to extract the action (first token) and any sub-arguments. 1. Check git branch — on the default branch? → "Create a worktree or branch first" 2. Resolve PR for current branch: gh pr view --json state,number 2>/dev/null - a. exit non-zero → no PR → continue to step 3 (Phase 1 prep) + a. exit non-zero → no PR yet → START AT PHASE 1 (prep); skip steps 3-6 + entirely (they all need a PR number that does not exist yet) b. state = MERGED → skip to Phase 4.3 (cleanup only — pull default branch, delete branch, prune) c. state = CLOSED → report "PR was closed without merging" and stop - d. state = OPEN → capture pr_number, continue to step 4 + d. state = OPEN → capture pr_number, continue to step 3 3. Check CI status (gh pr checks <pr_number>) — still running? → start at monitor 5. Check for unaddressed comments → start at monitor (comments sub-phase) 6. CI green + comments addressed → suggest merge diff --git a/plugins/source-control/skills/pull-request/reference/babysit.md b/plugins/source-control/skills/pull-request/reference/babysit.md index 7d792cc8a..2995d2fb4 100644 --- a/plugins/source-control/skills/pull-request/reference/babysit.md +++ b/plugins/source-control/skills/pull-request/reference/babysit.md @@ -180,22 +180,35 @@ A push channel arms for ONE PR at a time. Re-arm for each new PR in the loop. (`main` below — substitute the repo's default branch.) ```bash -# Pre-check 1: is branch checked out in another worktree? +# Pre-check 0: already on the PR branch? This session owns it — no checkout +# needed (the current worktree also shows up in `git worktree list`, so the +# other-worktree grep below would otherwise false-trip to read-only). +# Pre-check 1: is the branch checked out in ANOTHER worktree? # Pre-check 2: does THIS worktree have uncommitted changes? They may be # another session's WIP — never reset/clean work this loop did not create. BRANCH="<headRefName>" -if git worktree list | grep -q "\[$BRANCH\]"; then +CUR_BRANCH=$(git branch --show-current) +CUR_WT=$(git rev-parse --show-toplevel) +if [ "$CUR_BRANCH" = "$BRANCH" ]; then + # Already own the branch — no checkout; freshness check below still runs. + git fetch origin main + CHECKOUT_MODE="full" +elif git worktree list | grep -vF "$CUR_WT " | grep -q "\[$BRANCH\]"; then echo "Branch $BRANCH checked out in another worktree — processing read-only" CHECKOUT_MODE="read-only" elif [ -n "$(git status --porcelain)" ]; then echo "Working tree has uncommitted changes (possibly another session's WIP) — no checkout, processing read-only" CHECKOUT_MODE="read-only" else - git fetch origin "$BRANCH" git fetch origin main - git checkout "$BRANCH" + # gh pr checkout handles fork-sourced PRs (head branch not fetchable from + # origin) and same-repo branches alike — never bare fetch/checkout by name. + gh pr checkout "$PR_NUMBER" + CHECKOUT_MODE="full" +fi - # Branch freshness — rebase if behind main +# Branch freshness — rebase if behind main (full mode only) +if [ "$CHECKOUT_MODE" = "full" ]; then if ! git merge-base --is-ancestor origin/main HEAD; then echo "Branch $BRANCH is behind origin/main — rebasing" if git rebase origin/main; then @@ -226,10 +239,11 @@ else # `git rebase --continue` + `git push --force-with-lease origin "$BRANCH"`, # then set REBASE_STATUS="rebased". Only terminal states pass this point. - if [ "$REBASE_STATUS" = "conflict-aborted" ]; then + # Safe fallback: ONLY the terminal success states keep full mode. A + # lingering conflict-attempting (resolution skipped) degrades to read-only + # rather than granting write access mid-rebase. + if [ "$REBASE_STATUS" != "rebased" ] && [ "$REBASE_STATUS" != "current" ]; then CHECKOUT_MODE="read-only" - else - CHECKOUT_MODE="full" fi fi ``` @@ -270,7 +284,7 @@ D steps operate **per-finding**, not per-comment. One comment with 5 findings = - [ ] D6 — Fix if VALID → edit, `git add <files>`, commit, push - [ ] **verify commit pushed:** `gh api "repos/{owner}/{repo}/commits?sha=<branch>&per_page=1" --jq '.[0].sha'` — confirm the fix commit SHA on the remote - [ ] D7 — Post a follow-up reply citing the fix commit SHA - - [ ] **verify follow-up reply posted:** `gh api repos/{owner}/{repo}/issues/<N>/comments --jq '.[-1].body'` — confirm the follow-up with SHA on GitHub + - [ ] **verify follow-up reply posted — same surface routing as D5:** inline thread → `gh api repos/{owner}/{repo}/pulls/<N>/comments --jq '.[] | select(.in_reply_to_id == <original-id>)'`; issue-level → `gh api repos/{owner}/{repo}/issues/<N>/comments --jq '.[-1].body'` — confirm the follow-up with SHA on GitHub - [ ] D7.5 — Resolve review thread — **author-conditional** (canonical policy: SKILL.md D7.5), inline review comments only. Resolve ONLY threads whose OPENING comment is authored by a BOT reviewer that you addressed. NEVER resolve HUMAN-authored threads — the human resolves their own after verifying the fix. NEVER resolve your OWN threads (any of your posting identities — same self set as §5.0.3 step 4). Skip issue-level comments (no thread). **Thread author = login of the THREAD-OPENING comment** (replying into it does not change the author). **Bot detection is API-surface-specific:** resolution runs via GraphQL (the threadId fetch), where bot authors have `author.__typename == "Bot"` and `login` omits the `[bot]` suffix; REST surfaces show the suffix. When fetching the threadId, also select `author{__typename login}` to apply the conditional in one query - [ ] **verify thread resolved:** query the thread node via `gh api graphql` — `isResolved` must be `true` - [ ] **E** — Readiness gate. Run `bash "${CLAUDE_PLUGIN_ROOT}/skills/pull-request/scripts/babysit-readiness-gate.sh" <N>` — exit 0 `READINESS_OK` is REQUIRED to proceed. Exit 1 `READINESS_BLOCKED reason=under-decomposed` means classification rows < source findings → decompose + classify the missing findings, then re-run. THEN confirm: all checks terminal + 2-min cooldown diff --git a/plugins/source-control/skills/pull-request/reference/monitor.md b/plugins/source-control/skills/pull-request/reference/monitor.md index 26b3049fb..bd6af3bbb 100644 --- a/plugins/source-control/skills/pull-request/reference/monitor.md +++ b/plugins/source-control/skills/pull-request/reference/monitor.md @@ -74,7 +74,7 @@ Establish a baseline poll: `gh pr checks <N>` + the three comment-surface fetche # CI check-run changes (emit on any new terminal bucket) cur_checks=$(gh pr checks "$PR_NUMBER" --json name,bucket \ - --jq '.[] | select(.bucket != "pending" and .bucket != "in_progress") | "\(.name): \(.bucket)"' \ + --jq '.[] | select(.bucket != "pending") | "\(.name): \(.bucket)"' \ 2>/dev/null | tr -d '\r' | sort || true) if [ "$cur_checks" != "$prev_checks" ]; then # gh pr checks --json bucket values are: pass|fail|pending|skipping|cancel @@ -88,18 +88,25 @@ Establish a baseline poll: `gh pr checks <N>` + the three comment-surface fetche # New comments — ALL THREE review surfaces (issue-level, inline # review comments, review bodies). Watching only issues/comments # misses inline findings posted with no CI state change. + # Advance the watermark ONLY when every fetch succeeded — a transient + # gh failure would otherwise skip past comments that arrived during + # the failed poll window and never emit them. now=$(date -u +%Y-%m-%dT%H:%M:%SZ) - gh api "repos/$OWNER/$REPO/issues/$PR_NUMBER/comments?since=$last_comment_ts" \ - --jq '.[] | "COMMENT \(.user.login): \(.body[:80])"' \ - 2>/dev/null | tr -d '\r' | grep --line-buffered . || true - gh api "repos/$OWNER/$REPO/pulls/$PR_NUMBER/comments?since=$last_comment_ts" \ - --jq '.[] | "INLINE-COMMENT \(.user.login): \(.body[:80])"' \ - 2>/dev/null | tr -d '\r' | grep --line-buffered . || true + fetch_ok=1 + if out=$(gh api "repos/$OWNER/$REPO/issues/$PR_NUMBER/comments?since=$last_comment_ts" \ + --jq '.[] | "COMMENT \(.user.login): \(.body[:80])"' 2>/dev/null); then + printf '%s\n' "$out" | tr -d '\r' | grep --line-buffered . || true + else fetch_ok=0; fi + if out=$(gh api "repos/$OWNER/$REPO/pulls/$PR_NUMBER/comments?since=$last_comment_ts" \ + --jq '.[] | "INLINE-COMMENT \(.user.login): \(.body[:80])"' 2>/dev/null); then + printf '%s\n' "$out" | tr -d '\r' | grep --line-buffered . || true + else fetch_ok=0; fi # Reviews API has no `since` param — filter client-side on submitted_at - gh api "repos/$OWNER/$REPO/pulls/$PR_NUMBER/reviews" \ - --jq ".[] | select(.submitted_at > \"$last_comment_ts\") | \"REVIEW \(.user.login) [\(.state)]: \(.body[:80])\"" \ - 2>/dev/null | tr -d '\r' | grep --line-buffered . || true - last_comment_ts="$now" + if out=$(gh api "repos/$OWNER/$REPO/pulls/$PR_NUMBER/reviews" \ + --jq ".[] | select(.submitted_at > \"$last_comment_ts\") | \"REVIEW \(.user.login) [\(.state)]: \(.body[:80])\"" 2>/dev/null); then + printf '%s\n' "$out" | tr -d '\r' | grep --line-buffered . || true + else fetch_ok=0; fi + [ "$fetch_ok" -eq 1 ] && last_comment_ts="$now" sleep 30 done diff --git a/plugins/source-control/skills/pull-request/scripts/babysit-readiness-gate.sh b/plugins/source-control/skills/pull-request/scripts/babysit-readiness-gate.sh index bed3bfb6e..d24c65b42 100755 --- a/plugins/source-control/skills/pull-request/scripts/babysit-readiness-gate.sh +++ b/plugins/source-control/skills/pull-request/scripts/babysit-readiness-gate.sh @@ -180,12 +180,24 @@ CLASSIFY_RE='VALID|INCORRECT|UNCERTAIN' # findings visible (codex r3327878326). A classification reply carries a # VALID/INCORRECT/UNCERTAIN token, NOT a severity/badge, so it never inflates the # finding count; classifications are still counted only from self bodies. -all_bodies="$(printf '%s' "$COMMENTS" | - jq -r '.[] | .body // ""' 2>/dev/null)" +non_self_bodies="$(printf '%s' "$COMMENTS" | + jq -r --argjson self "$SELF_JSON" ' + .[] | select((.author as $a | $self | index($a)) | not) | .body // ""' 2>/dev/null)" self_bodies="$(printf '%s' "$COMMENTS" | jq -r --argjson self "$SELF_JSON" ' .[] | select((.author as $a | $self | index($a))) | .body // ""' 2>/dev/null)" +# Self classification-table rows are EXCLUDED from the finding corpus: a +# reply row like `| 1 | CRITICAL: null deref | VALID | ... |` repeats the +# source severity word, which would otherwise mint a phantom finding +# (findings=2 classified=1 → permanently blocked) — codex r3564159124. +# Non-table self content (a maintainer authoring a genuine source finding) +# still counts. The [^A-Za-z] guards keep e.g. "INVALID" rows countable. +self_source_bodies="$(printf '%s\n' "$self_bodies" | + grep -vE '^[[:space:]]*\|.*[^A-Za-z](VALID|INCORRECT|UNCERTAIN)([^A-Za-z]|$)' || true)" +all_bodies="$non_self_bodies +$self_source_bodies" + # FINDINGS: grep -o ... | grep -c . counts OCCURRENCES (one match per output # line), not input lines — a single line with two markers must count as two # findings, or the gate under-counts and false-passes (the very R1 diff --git a/plugins/source-control/skills/pull-request/scripts/babysit-readiness-gate.test.sh b/plugins/source-control/skills/pull-request/scripts/babysit-readiness-gate.test.sh index b8ba871f7..2e0496512 100755 --- a/plugins/source-control/skills/pull-request/scripts/babysit-readiness-gate.test.sh +++ b/plugins/source-control/skills/pull-request/scripts/babysit-readiness-gate.test.sh @@ -227,4 +227,16 @@ assert_contains "prose repetition -> classified=1" "$r" "classified=1" assert_contains "prose repetition under-decomposed -> blocked" "$r" "READINESS_BLOCKED reason=under-decomposed" assert_contains "prose repetition -> exit 1" "$r" "EXIT:1" +# --- Case: self classification row repeating the severity word is NOT a finding +# `| 1 | CRITICAL: null deref | VALID | ... |` must not mint a phantom source +# finding (would yield findings=2 classified=1 -> permanently blocked) — +# codex r3564159124. One source finding + one classification row => OK. +F=$(mkjson self-row-severity '[ + {author:"claude[bot]", body:"CRITICAL null deref in handler"}, + {author:"me[bot]", body:"| 1 | CRITICAL: null deref | VALID | fixed abc123 |"} +]') +r=$(run_gate "$F") +assert_contains "self classification row repeating severity -> findings=1" "$r" "findings=1" +assert_contains "self classification row repeating severity -> OK" "$r" "READINESS_OK" + [[ $FAILED -eq 0 ]] || exit 1 From 1b09b589d4354bad80efc0ff21854cfcdb133b4b Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sat, 11 Jul 2026 10:07:27 -0400 Subject: [PATCH 08/12] fix: fork-safe monitor checkout and rebase push targets Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com> --- plugins/source-control/skills/pull-request/SKILL.md | 2 +- .../skills/pull-request/reference/babysit.md | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/plugins/source-control/skills/pull-request/SKILL.md b/plugins/source-control/skills/pull-request/SKILL.md index a9150d1f9..36e451f96 100644 --- a/plugins/source-control/skills/pull-request/SKILL.md +++ b/plugins/source-control/skills/pull-request/SKILL.md @@ -124,7 +124,7 @@ Execute in order. Each phase is self-contained — read the relevant file for de When entering Phase 3 (`monitor`, `comments`, or `full` reaching monitor), complete EVERY step below. Do NOT skip to CI polling or comment evaluation. -- [ ] **Step 0 — Checkout the PR source branch (DEFAULT):** monitoring a PR means working ON its head branch — exploration, research, and any fix must run against the PR's actual code, not whatever branch you happen to be on. Resolve the head branch (`gh pr view <N> --json headRefName -q .headRefName`) and check it out. This is the default, not an exception. +- [ ] **Step 0 — Checkout the PR source branch (DEFAULT):** monitoring a PR means working ON its head branch — exploration, research, and any fix must run against the PR's actual code, not whatever branch you happen to be on. Check it out with `gh pr checkout <N>` (fork-safe — a fork's head branch is not fetchable from `origin` by name, and a bare `git checkout <headRefName>` can select a stale same-named local branch). This is the default, not an exception. - **Pre-check `git worktree list`:** if the branch is already checked out in another worktree, work there (or process read-only — no fix — if you can't). If you're already on the PR branch, no-op. - **Dirty tree with unrelated WIP** (staged/unstaged/untracked from other work): do NOT switch — surface the WIP to the user and proceed read-only. Never `git stash` another session's WIP. - **Interactive session** (human present): changing branches re-points the working tree, so confirm the target branch with the user FIRST — UNLESS the invoking message already named the checkout (invoking `/pull-request monitor <N>` against a specific PR is intent, but the target-branch confirmation gate still governs the mechanical switch). diff --git a/plugins/source-control/skills/pull-request/reference/babysit.md b/plugins/source-control/skills/pull-request/reference/babysit.md index 2995d2fb4..30cb4b819 100644 --- a/plugins/source-control/skills/pull-request/reference/babysit.md +++ b/plugins/source-control/skills/pull-request/reference/babysit.md @@ -213,7 +213,9 @@ if [ "$CHECKOUT_MODE" = "full" ]; then echo "Branch $BRANCH is behind origin/main — rebasing" if git rebase origin/main; then REBASE_STATUS="rebased" - git push --force-with-lease origin "$BRANCH" + # Bare push — the branch's upstream was configured by `gh pr checkout` + # (fork PRs push to the HEAD repository, not the base repo's origin). + git push --force-with-lease else # Graduated conflict handling — attempt simple, abort complex. # conflict-attempting is a TRANSIENT state: resolve it (rebase @@ -236,8 +238,8 @@ if [ "$CHECKOUT_MODE" = "full" ]; then # conflict-attempting: resolve NOW — per file, take the mechanical # resolution; if ANY file needs intent judgment, `git rebase --abort` and # set REBASE_STATUS="conflict-aborted". On success: `git add <files>` + - # `git rebase --continue` + `git push --force-with-lease origin "$BRANCH"`, - # then set REBASE_STATUS="rebased". Only terminal states pass this point. + # `git rebase --continue` + a bare `git push --force-with-lease` (upstream + # set by gh pr checkout), then REBASE_STATUS="rebased". Only terminal states pass this point. # Safe fallback: ONLY the terminal success states keep full mode. A # lingering conflict-attempting (resolution skipped) degrades to read-only @@ -251,7 +253,7 @@ fi **Rebase conflict handling (graduated).** Check for merge commits first (`git log --merges origin/main..HEAD`) — a branch that previously merged main integrates via `git merge origin/main`, not rebase. Then: - **Zero conflicts** (`REBASE_STATUS=rebased`) — rebase succeeded, force-push with lease, continue normally -- **Simple conflicts** (≤3 files, `REBASE_STATUS=conflict-attempting`) — TRANSIENT: attempt resolution immediately; on success `git rebase --continue` + force-push with lease → `rebased`; if ANY file requires intent judgment, `git rebase --abort` → `conflict-aborted`. Never proceed to comment processing, parking, or the next PR with a rebase in progress +- **Simple conflicts** (≤3 files, `REBASE_STATUS=conflict-attempting`) — TRANSIENT: attempt resolution immediately; on success `git rebase --continue` + bare `git push --force-with-lease` → `rebased`; if ANY file requires intent judgment, `git rebase --abort` → `conflict-aborted`. Never proceed to comment processing, parking, or the next PR with a rebase in progress - **Complex conflicts** (>3 files, `REBASE_STATUS=conflict-aborted`) — abort the rebase, post a PR comment: `"⚠️ Branch is behind main with merge conflicts ({N} files). Manual rebase required before CI will trigger."`. If an interactive terminal, also surface to the user directly. Process comments read-only (classification + reply, no fixes — the code may be stale) - **Already current** (`REBASE_STATUS=current`) — no action needed From ab429007e611ef68b98497789f5e5dcd71e90f67 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sat, 11 Jul 2026 10:45:20 -0400 Subject: [PATCH 09/12] fix: round-6 review findings (repo-agnostic default branch in merge phase, fork-safe babysit checklist checkout, untruncated log fetcher in readiness gate) Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com> --- .../skills/pull-request/SKILL.md | 2 +- .../skills/pull-request/reference/merge.md | 19 +++++++++++-------- .../pull-request/reference/readiness.md | 2 +- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/plugins/source-control/skills/pull-request/SKILL.md b/plugins/source-control/skills/pull-request/SKILL.md index 36e451f96..4db920595 100644 --- a/plugins/source-control/skills/pull-request/SKILL.md +++ b/plugins/source-control/skills/pull-request/SKILL.md @@ -172,7 +172,7 @@ When running the `babysit` action, execute these steps for EACH PR discovered. T - [ ] **Step 0 — PR discovery:** `gh pr list` filtered (skip draft, oldest-first). See [reference/babysit.md](reference/babysit.md) §5.0.2 - [ ] **Step 0.1 — Evidence-based fresh rescan:** fetch ALL comments via the bundled `fetch-all-pr-comments.sh`, classify each as addressed/unaddressed by checking GitHub for substantive replies with classification + evidence. GitHub is the source of truth, not model memory. See §5.0.3 -- [ ] **Step 0.2 — Branch checkout:** `git fetch origin <branch>` then `git checkout <branch>`. MANDATORY before any comment investigation — exploration and research must run against PR branch code. Pre-check `git worktree list` — if the branch is checked out elsewhere, process read-only (no fix). See §5.1.2 +- [ ] **Step 0.2 — Branch checkout:** `gh pr checkout <N>` (fork-safe — a fork's head branch is not fetchable from `origin` by name). MANDATORY before any comment investigation — exploration and research must run against PR branch code. Pre-checks first: already on the branch → no-op; branch checked out in ANOTHER worktree, or this worktree has foreign WIP → process read-only (no fix). See §5.1.2 - [ ] **Step 0.3 — Branch freshness:** `git fetch origin <default-branch>` then `git merge-base --is-ancestor origin/<default-branch> HEAD`. If behind: integrate (merge vs rebase per the project's convention and the branch's own history — see §5.1.2), resolving conflicts conservatively. Report status: current/rebased/conflict-aborted - [ ] **Steps 1-4 — Monitor entry checklist** (above) — run per-PR. A push channel re-arms its PR filter for each PR - [ ] **Steps A-F — Per-iteration monitoring checklist** (above) — run per-PR. Extract individual findings from each comment per §5.0.4 (one comment with N findings = N work items). **For any comment with ≥3 findings, MANDATORY subagent dispatch** per §5.0.4. Run D1-D7 per-finding, not per-comment. Verify each action landed on GitHub (D4.5/D5/D6/D7/D7.5 verification gates) diff --git a/plugins/source-control/skills/pull-request/reference/merge.md b/plugins/source-control/skills/pull-request/reference/merge.md index 9781354c2..d33f4b1c8 100644 --- a/plugins/source-control/skills/pull-request/reference/merge.md +++ b/plugins/source-control/skills/pull-request/reference/merge.md @@ -48,14 +48,17 @@ Detect if currently in a worktree (`git worktree list`). **If in a worktree (primary pattern — worktree reuse):** -Reuse the worktree for next task by creating a new branch from latest main. Faster than remove+recreate and preserves gitignored files. +Reuse the worktree for next task by creating a new branch from the latest default branch. Faster than remove+recreate and preserves gitignored files. ```bash -# 1. Get latest main -git fetch origin main +# 0. Resolve the repo's default branch (repo-agnostic — not every repo uses main) +DEFAULT_BRANCH=$(gh repo view --json defaultBranchRef -q .defaultBranchRef.name) -# 2. Create new branch from latest main (NOT checkout main — that's blocked) -git checkout -b <new-type>/<new-desc> origin/main +# 1. Get the latest default branch +git fetch origin "$DEFAULT_BRANCH" + +# 2. Create new branch from it (NOT checkout of the default branch — that's blocked in a worktree) +git checkout -b <new-type>/<new-desc> "origin/$DEFAULT_BRANCH" # 3. Delete old merged branch (squash merge needs -D not -d) git branch -D <old-branch> @@ -67,8 +70,8 @@ Worktree reuse (new branch from latest default branch in the same directory) is **If on a regular branch (not in worktree):** -1. **Check for uncommitted changes BEFORE checkout** — `git status --porcelain`. If uncommitted changes exist, they will be lost on `git checkout main` (conflicting changes fail, non-conflicting changes silently carry over to main's working tree — neither desirable). Stash first: `git stash push -u -m "pre-merge-cleanup: <branch-name>"` (`-u` includes untracked files — without it, new files are silently skipped). Stashes survive branch deletion (stored in `.git/refs/stash`, not tied to branches) -2. `git checkout main` +1. **Check for uncommitted changes BEFORE checkout** — `git status --porcelain`. If uncommitted changes exist, they will be lost on the default-branch checkout (conflicting changes fail, non-conflicting changes silently carry over — neither desirable). Stash first: `git stash push -u -m "pre-merge-cleanup: <branch-name>"` (`-u` includes untracked files — without it, new files are silently skipped). Stashes survive branch deletion (stored in `.git/refs/stash`, not tied to branches) +2. `git checkout "$DEFAULT_BRANCH"` (resolve via `gh repo view --json defaultBranchRef -q .defaultBranchRef.name`) 3. `git pull --ff-only` 4. `git branch -D <merged-branch>` 5. If a stash was created in step 1, inform user: "Stashed N uncommitted changes. Run `git stash list` to see them, `git stash pop` to restore on a new branch." @@ -92,7 +95,7 @@ git branch # merged branch should be gone, new branch active **Post-merge CI health check** — verify CI on main is green after merge commit lands: ```bash -gh run list --branch main --limit 1 --json conclusion,displayTitle \ +gh run list --branch "$DEFAULT_BRANCH" --limit 1 --json conclusion,displayTitle \ --jq '.[0] | "\(.conclusion): \(.displayTitle)"' ``` diff --git a/plugins/source-control/skills/pull-request/reference/readiness.md b/plugins/source-control/skills/pull-request/reference/readiness.md index 7474562f8..6d09db65b 100644 --- a/plugins/source-control/skills/pull-request/reference/readiness.md +++ b/plugins/source-control/skills/pull-request/reference/readiness.md @@ -77,7 +77,7 @@ For every check run with `bucket == "fail"`: gh pr checks <pr_number> --json name,state,bucket --jq '.[] | select(.bucket == "fail")' ``` -- [ ] Each failure has been **investigated** (logs read via `gh run view <run-id> --log-failed`) +- [ ] Each failure has been **investigated** — logs read via the monitor §3.1 tiered fetch chain (bundled `fetch-annotations.sh` → `fetch-failed-logs.sh` full untruncated ZIP; `gh run view <run-id> --log-failed` only as a last-resort eyeball — it truncates at the CLI display layer) - [ ] Each failure is **classified**: real failure (fix required) OR informational (document why safe to proceed) - [ ] Informational failures explicitly documented in monitoring report with exact error message - [ ] **No unclassified failures** — every `FAILURE` state must have an explicit disposition From d544bafaf5dc49e0188bd8555329c5ee5d2562cf Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:01:40 -0400 Subject: [PATCH 10/12] fix: round-7 review findings (review-body reaction exemption, default-branch resolution in create + babysit rebases) Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com> --- .../skills/pull-request/SKILL.md | 2 +- .../skills/pull-request/reference/babysit.md | 15 ++++++------ .../skills/pull-request/reference/create.md | 23 ++++++++++--------- 3 files changed, 21 insertions(+), 19 deletions(-) diff --git a/plugins/source-control/skills/pull-request/SKILL.md b/plugins/source-control/skills/pull-request/SKILL.md index 4db920595..7f495e362 100644 --- a/plugins/source-control/skills/pull-request/SKILL.md +++ b/plugins/source-control/skills/pull-request/SKILL.md @@ -153,7 +153,7 @@ When a channel event, Monitor notification, or poll iteration fires, complete AL - [ ] D2 — Explore referenced code (must be on the PR branch for accurate results) - [ ] D3 — **Validate the claim** before trusting: verify the assertion against actual code, run the command, check the file. Research non-trivial claims against official docs. Never implement a fix based solely on a bot's assertion — confirm it is correct first - [ ] D4 — Classify: VALID (fix now) / VALID (defer) / INCORRECT / UNCERTAIN. Classification MUST cite evidence from D2-D3 - - [ ] D4.5 — React to the parent comment: `+1` VALID, `-1` INCORRECT, `eyes` UNCERTAIN (via `gh api .../reactions`). One reaction per comment. Mixed findings: `+1` if any VALID. Verify the reaction posted via a GET on the same endpoint — non-zero confirms + - [ ] D4.5 — React to the parent comment: `+1` VALID, `-1` INCORRECT, `eyes` UNCERTAIN (via `gh api .../reactions`). One reaction per comment. Mixed findings: `+1` if any VALID. Verify the reaction posted via a GET on the same endpoint — non-zero confirms. **Exemption:** PR review BODIES (C3 surface) have no reactions endpoint in the REST API — skip the reaction for review-body findings; the D5 reply is the audit signal there - [ ] D5 — Reply with a per-finding classification table + evidence (before fixing). **Route by comment type — REQUIRED, not interchangeable:** inline review comments MUST reply THREADED via `gh api repos/<owner>/<repo>/pulls/<N>/comments/<id>/replies`; issue-level / review-level → `gh pr comment <N>`. Answering an inline finding with a detached `pr comment` is a routing error, not a style choice. Use the project's bot-identity wrapper for these writes when it has one; plain `gh` otherwise - [ ] **Verify reply exists — on the surface it was posted to:** inline threaded replies land on the review-comment surface — `gh api repos/<owner>/<repo>/pulls/<N>/comments --jq '.[] | select(.in_reply_to_id == <original-id>)'`; issue-level replies — `gh api repos/<owner>/<repo>/issues/<N>/comments --jq '.[].body'`. Querying only issues/comments false-fails a correctly posted inline reply - [ ] D6 — Fix if VALID (fix now) — edit, `git add <files>`, commit, push diff --git a/plugins/source-control/skills/pull-request/reference/babysit.md b/plugins/source-control/skills/pull-request/reference/babysit.md index 30cb4b819..d5249e547 100644 --- a/plugins/source-control/skills/pull-request/reference/babysit.md +++ b/plugins/source-control/skills/pull-request/reference/babysit.md @@ -189,9 +189,10 @@ A push channel arms for ONE PR at a time. Re-arm for each new PR in the loop. BRANCH="<headRefName>" CUR_BRANCH=$(git branch --show-current) CUR_WT=$(git rev-parse --show-toplevel) +DEFAULT_BRANCH=$(gh repo view --json defaultBranchRef -q .defaultBranchRef.name) if [ "$CUR_BRANCH" = "$BRANCH" ]; then # Already own the branch — no checkout; freshness check below still runs. - git fetch origin main + git fetch origin "$DEFAULT_BRANCH" CHECKOUT_MODE="full" elif git worktree list | grep -vF "$CUR_WT " | grep -q "\[$BRANCH\]"; then echo "Branch $BRANCH checked out in another worktree — processing read-only" @@ -200,18 +201,18 @@ elif [ -n "$(git status --porcelain)" ]; then echo "Working tree has uncommitted changes (possibly another session's WIP) — no checkout, processing read-only" CHECKOUT_MODE="read-only" else - git fetch origin main + git fetch origin "$DEFAULT_BRANCH" # gh pr checkout handles fork-sourced PRs (head branch not fetchable from # origin) and same-repo branches alike — never bare fetch/checkout by name. gh pr checkout "$PR_NUMBER" CHECKOUT_MODE="full" fi -# Branch freshness — rebase if behind main (full mode only) +# Branch freshness — rebase if behind the default branch (full mode only) if [ "$CHECKOUT_MODE" = "full" ]; then - if ! git merge-base --is-ancestor origin/main HEAD; then - echo "Branch $BRANCH is behind origin/main — rebasing" - if git rebase origin/main; then + if ! git merge-base --is-ancestor "origin/$DEFAULT_BRANCH" HEAD; then + echo "Branch $BRANCH is behind origin/$DEFAULT_BRANCH — rebasing" + if git rebase "origin/$DEFAULT_BRANCH"; then REBASE_STATUS="rebased" # Bare push — the branch's upstream was configured by `gh pr checkout` # (fork PRs push to the HEAD repository, not the base repo's origin). @@ -250,7 +251,7 @@ if [ "$CHECKOUT_MODE" = "full" ]; then fi ``` -**Rebase conflict handling (graduated).** Check for merge commits first (`git log --merges origin/main..HEAD`) — a branch that previously merged main integrates via `git merge origin/main`, not rebase. Then: +**Rebase conflict handling (graduated).** Check for merge commits first (`git log --merges origin/$DEFAULT_BRANCH..HEAD`) — a branch that previously merged the default branch integrates via `git merge origin/$DEFAULT_BRANCH`, not rebase. Then: - **Zero conflicts** (`REBASE_STATUS=rebased`) — rebase succeeded, force-push with lease, continue normally - **Simple conflicts** (≤3 files, `REBASE_STATUS=conflict-attempting`) — TRANSIENT: attempt resolution immediately; on success `git rebase --continue` + bare `git push --force-with-lease` → `rebased`; if ANY file requires intent judgment, `git rebase --abort` → `conflict-aborted`. Never proceed to comment processing, parking, or the next PR with a rebase in progress diff --git a/plugins/source-control/skills/pull-request/reference/create.md b/plugins/source-control/skills/pull-request/reference/create.md index ccd180622..1d8c4fa3a 100644 --- a/plugins/source-control/skills/pull-request/reference/create.md +++ b/plugins/source-control/skills/pull-request/reference/create.md @@ -42,27 +42,28 @@ ## 2.2 Rebase onto the latest default branch -Ensure the branch is current with the default branch before pushing. Prevents merge conflicts and stale-branch CI failures. (`main` below — substitute the repo's default branch.) +Ensure the branch is current with the default branch before pushing. Prevents merge conflicts and stale-branch CI failures. **Ordering — rebase needs a clean tree.** `git rebase` refuses to run with unstaged changes (`error: cannot rebase: You have unstaged changes.`). On the normal `create` path the PR changes are still uncommitted when this phase starts — in that case run 2.3 (classify unrelated changes + stage + commit) FIRST, then return here and integrate before the 2.4 push. Run 2.2 in the listed order only when the tree is already clean (all work committed). ```bash -git fetch origin main -MERGE_BASE=$(git merge-base HEAD origin/main) -ORIGIN_MAIN=$(git rev-parse origin/main) - -if [ "$MERGE_BASE" != "$ORIGIN_MAIN" ]; then - BEHIND=$(git rev-list --count HEAD..origin/main) - echo "Branch is $BEHIND commit(s) behind origin/main. Rebasing..." - git rebase origin/main +DEFAULT_BRANCH=$(gh repo view --json defaultBranchRef -q .defaultBranchRef.name) +git fetch origin "$DEFAULT_BRANCH" +MERGE_BASE=$(git merge-base HEAD "origin/$DEFAULT_BRANCH") +ORIGIN_DEFAULT=$(git rev-parse "origin/$DEFAULT_BRANCH") + +if [ "$MERGE_BASE" != "$ORIGIN_DEFAULT" ]; then + BEHIND=$(git rev-list --count HEAD.."origin/$DEFAULT_BRANCH") + echo "Branch is $BEHIND commit(s) behind origin/$DEFAULT_BRANCH. Rebasing..." + git rebase "origin/$DEFAULT_BRANCH" fi ``` -**Prefer `git merge origin/main` over rebase when the branch already contains a merge commit** (`git log --merges origin/main..HEAD` non-empty) — replaying pre-merge commits produces avoidable conflict slogs, and under squash-merge linear branch history buys nothing. +**Prefer `git merge origin/$DEFAULT_BRANCH` over rebase when the branch already contains a merge commit** (`git log --merges origin/$DEFAULT_BRANCH..HEAD` non-empty) — replaying pre-merge commits produces avoidable conflict slogs, and under squash-merge linear branch history buys nothing. **If conflicts occur:** resolve conservatively — take both sides where independent, pause and present to the user whenever intent is unclear. `git rebase --abort` / `git merge --abort` when resolution needs judgment you don't have. -**Skip conditions:** branch has zero commits ahead (nothing to rebase), or merge-base already equals `origin/main` (branch is current). +**Skip conditions:** branch has zero commits ahead (nothing to rebase), or merge-base already equals `origin/$DEFAULT_BRANCH` (branch is current). ## 2.3 Stage and commit From cf90cfb332fd7cb178c14d67725d274ead8aad73 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:17:12 -0400 Subject: [PATCH 11/12] fix: round-8 review findings (plain [P-num] severity counting with regression test, dirty-tree guard on own-branch babysit path) Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com> --- .../skills/pull-request/reference/babysit.md | 9 ++++++++- .../pull-request/scripts/babysit-readiness-gate.sh | 9 ++++++++- .../scripts/babysit-readiness-gate.test.sh | 12 ++++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/plugins/source-control/skills/pull-request/reference/babysit.md b/plugins/source-control/skills/pull-request/reference/babysit.md index d5249e547..f1d01231f 100644 --- a/plugins/source-control/skills/pull-request/reference/babysit.md +++ b/plugins/source-control/skills/pull-request/reference/babysit.md @@ -192,8 +192,15 @@ CUR_WT=$(git rev-parse --show-toplevel) DEFAULT_BRANCH=$(gh repo view --json defaultBranchRef -q .defaultBranchRef.name) if [ "$CUR_BRANCH" = "$BRANCH" ]; then # Already own the branch — no checkout; freshness check below still runs. + # The dirty-tree guard still applies: uncommitted changes may be another + # session's WIP even on this branch — full mode only on a clean tree. git fetch origin "$DEFAULT_BRANCH" - CHECKOUT_MODE="full" + if [ -n "$(git status --porcelain)" ]; then + echo "Working tree has uncommitted changes — processing read-only" + CHECKOUT_MODE="read-only" + else + CHECKOUT_MODE="full" + fi elif git worktree list | grep -vF "$CUR_WT " | grep -q "\[$BRANCH\]"; then echo "Branch $BRANCH checked out in another worktree — processing read-only" CHECKOUT_MODE="read-only" diff --git a/plugins/source-control/skills/pull-request/scripts/babysit-readiness-gate.sh b/plugins/source-control/skills/pull-request/scripts/babysit-readiness-gate.sh index d24c65b42..f1f76f901 100755 --- a/plugins/source-control/skills/pull-request/scripts/babysit-readiness-gate.sh +++ b/plugins/source-control/skills/pull-request/scripts/babysit-readiness-gate.sh @@ -171,6 +171,11 @@ SELF_JSON="$(printf '%s\n' "${SELF_LOGINS[@]}" | jq -R . | jq -s .)" # word char in `shields.io/badge/...`). SEVERITY_WORDS_RE='CRITICAL|IMPORTANT|SUGGESTION' SEVERITY_BADGE_RE='/badge/P[0-3]-' +# Plain bracketed P-severity markers ([P1] .. [P3]) — a common reviewer format +# with neither a severity word nor a shields badge. The badge alt text is +# `![PN Badge]` (space before the closing bracket), so this pattern cannot # spellchecker:disable-line +# double-count a badge finding. +SEVERITY_PLAIN_RE='\[P[0-9]\]' CLASSIFY_RE='VALID|INCORRECT|UNCERTAIN' # Findings are counted across ALL comment bodies, not just non-self ones: in an @@ -212,11 +217,13 @@ $self_source_bodies" # §5.0.4), so non-table prose classifications intentionally do not count. sev_words=$(printf '%s\n' "$all_bodies" | grep -owE "$SEVERITY_WORDS_RE" | grep -c . || true) sev_badges=$(printf '%s\n' "$all_bodies" | grep -oE "$SEVERITY_BADGE_RE" | grep -c . || true) +sev_plain=$(printf '%s\n' "$all_bodies" | grep -oE "$SEVERITY_PLAIN_RE" | grep -c . || true) classified=$(printf '%s\n' "$self_bodies" | grep -E '^[[:space:]]*\|' | grep -cwE "$CLASSIFY_RE" || true) sev_words=${sev_words//[^0-9]/} sev_badges=${sev_badges//[^0-9]/} +sev_plain=${sev_plain//[^0-9]/} classified=${classified//[^0-9]/} -findings=$((${sev_words:-0} + ${sev_badges:-0})) +findings=$((${sev_words:-0} + ${sev_badges:-0} + ${sev_plain:-0})) classified=${classified:-0} # --- R6: checklist completeness ---------------------------------------------- diff --git a/plugins/source-control/skills/pull-request/scripts/babysit-readiness-gate.test.sh b/plugins/source-control/skills/pull-request/scripts/babysit-readiness-gate.test.sh index 2e0496512..2794fa893 100755 --- a/plugins/source-control/skills/pull-request/scripts/babysit-readiness-gate.test.sh +++ b/plugins/source-control/skills/pull-request/scripts/babysit-readiness-gate.test.sh @@ -239,4 +239,16 @@ r=$(run_gate "$F") assert_contains "self classification row repeating severity -> findings=1" "$r" "findings=1" assert_contains "self classification row repeating severity -> OK" "$r" "READINESS_OK" +# --- Case: plain bracketed [P1]/[P2] severity markers count as findings ------ +# Reviewers that emit neither a severity word nor a shields badge use bare +# `[P1]` markers — the gate must not report findings=0 for them +# (codex r3564558962). Two plain markers, one classification row => BLOCKED. +F=$(mkjson plain-pseverity '[ + {author:"some-reviewer[bot]", body:"[P1] null deref in handler\n[P2] missing timeout"}, + {author:"me[bot]", body:"| 1 | null deref | VALID | fixed abc123 |"} +]') +r=$(run_gate "$F") +assert_contains "plain [P-num] markers -> findings=2" "$r" "findings=2" +assert_contains "plain [P-num] under-decomposed -> blocked" "$r" "READINESS_BLOCKED reason=under-decomposed" + [[ $FAILED -eq 0 ]] || exit 1 From 8aa7ac91cd17f890a33108b40600fcde21e0bbad Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:32:17 -0400 Subject: [PATCH 12/12] fix: guard dirty worktrees before force removal (round-9 P1) Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com> --- .../skills/worktree/context/cleanup.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/plugins/source-control/skills/worktree/context/cleanup.md b/plugins/source-control/skills/worktree/context/cleanup.md index eebae6dee..457868b39 100644 --- a/plugins/source-control/skills/worktree/context/cleanup.md +++ b/plugins/source-control/skills/worktree/context/cleanup.md @@ -59,9 +59,15 @@ Skipping 4a is the usual reason a previous `/worktree cleanup` left husks behind # Orphaned directory (on disk, not in `git worktree list`): remove the husk rm -rf <path> -# Git-tracked worktree — escalate only as far as needed: -git worktree remove <path> \ - || git worktree remove --force <path> \ +# Git-tracked worktree — plain removal first. It FAILS on a dirty worktree +# specifically to prevent data loss; never blind-escalate past that. +git worktree remove <path> +``` + +**Escalation guard (before any `--force`):** when the plain removal fails, inspect why — `git -C <path> status --porcelain` (uncommitted edits) and `git -C <path> log --branches --not --remotes --oneline | head` (unpushed commits). If either is non-empty, present the summary to the user and get explicit per-worktree confirmation BEFORE forcing — forced removal permanently discards those changes. Only after confirmation (or when the failure is a lock/metadata issue with a verifiably clean tree): + +```bash +git worktree remove --force <path> \ || git worktree remove --force --force <path> # second --force required for LOCKED worktrees (git-scm) ```