# Triage Pr

> Drive a pull request from draft with failing CI to merge-ready. While the PR is a draft, inspect and fix in-scope CI failures (lint, manifest-lint, build, tests) using the gh CLI and GitHub Actions logs — never weakening CI config to greenwash, and never editing lint config or adding an ignore directive unprompted (those are gated for the developer's sign-off and reported, not applied). After the PR is marked ready-for-review, wait for AI reviewers, verify each finding, then — by default — halt for a human envelope before applying dispositions (accept / decline / create a follow-up issue); with --auto-apply, fix high-impact findings and file the rest as follow-ups as before. Use when asked to triage a PR, fix failing CI or red checks on a PR, address or respond to PR review comments, action Bugbot or Claude review feedback, get a PR green, or take a draft PR to merge-ready. Handles base-branch drift and in-scope merge conflicts; escalates ambiguous ones.

- Skill: `rheged-studio/triage-pr` (Agent Skill, multi-file: 7 files)
- Install (CLI): `npx skillmds@latest add rheged-studio/triage-pr`
- Raw SKILL.md: https://api.skillmd.com/api/skills/rheged-studio/triage-pr/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Integrations & APIs
- License: MIT
- Author: rheged-studio (https://skillmd.com/u/rheged-studio)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/rheged-studio/triage-pr

---


# triage-pr

Take a pull request from **draft + failing CI** to **merge-ready**, in two
phases, choosing the phase from the PR's draft state:

- **Phase A — while the PR is a draft:** inspect failing checks, pull GitHub
  Actions logs, and fix failures **in PR scope only**. Loop until CI is green or
  report blockers. Phase A runs unattended — no human gate except hard blockers.
- **Phase B — after the PR is ready-for-review:** AI review is gated on
  `draft == false`, so once the PR is flipped to ready — by `promoteOnGreen` or a
  human — wait for configured reviewers (Claude Code Review, Bugbot, CodeRabbit)
  to post feedback, **verify-then-propose** dispositions for every finding, then
  — when `humanEnvelope` is on (the default) — **halt** for one batch approval
  (structured Questions UI when available — A-1647) before applying accepts,
  declines, or Linear follow-ups. With `--auto-apply` / `humanEnvelope: false`,
  restore the legacy auto path (fix high-impact now, file the rest as follow-ups
  for a Linear-only gate). After apply, re-watch CI and **re-envelope** if new bot
  findings appear — the same Questions contract as the first envelope.

Since send-it 0.8.0, `/send-it` **opens or updates** the pull request and then
**invokes this skill as its final step** (Step 11, A-1151): it waits for at least
one check to register, then hands off here — forwarding `--dry-run`, `--ci-only`,
`--no-promote`, and `--auto-apply` verbatim. A default `/send-it` run is incomplete
until that hand-off (A-1645). A send-it chain that reaches Phase B ends on **this**
skill's envelope (same structured Questions UX — A-1647). Running `/triage-pr`
directly stays fully supported for mid-flight re-entry when send-it already opened
the PR, or when send-it was deliberately skipped — it is not the normal end of a
send-it run.

The draft→ready
flip is governed by a single control — `promoteOnGreen` in [`config.json`](config.json)
— and **an enabled config _is_ the authorisation** for it: when `promoteOnGreen` is
`true` (the default), human authorisation for the flip is **already acquired via the
repo config**, so after a cleanly-green Phase A the skill flips the PR to ready and
continues into Phase B without stopping to seek a separate sign-off (the ready-flip is
the gate that turns AI review on; see Step 6). The flip stays **guarded** — gated on
proven-green CI, **no unresolved human review threads**, and no unresolved base drift.
Set `promoteOnGreen: false` (or pass `--no-promote`) to opt out and stop at green; an
explicit user prompt — or the `--promote` / `--no-promote` flags — overrides the config
for that run. Merge to `main` is never automated; that stays a human action. See
[`references/review-discipline.md`](references/review-discipline.md) for the full
review-reception and verification rules folded into Phase B.

## Configuration

The knobs live in [`config.json`](config.json) beside this file. Read it at the
start of a run and use its values throughout. Edit your copied `config.json` to
match the consuming repo's review bots and (optionally) its Linear workspace.

The first eight govern the **CI + review** loop:

| Key | Meaning | Default |
| --- | --- | --- |
| `reviewBots` | GitHub login names whose comments and threads are treated as first-class AI review feedback. Matched against `author.login`; the `[bot]` suffix is normalised, so `claude` and `claude[bot]` both match (the GraphQL API returns the bare form). Edit to match your install — review-bot logins vary per repo. `github-actions` is deliberately excluded by default: it posts CI status and release-PR comments, not code review, so Phase B would otherwise action them as findings; add it only if your install genuinely posts review-type comments via the Actions bot. | `["claude", "cursor", "coderabbitai"]` |
| `maxCiRounds` | Maximum Phase-A re-watch iterations before stopping and reporting blockers. Bounds the fix-and-watch loop so it can't spin forever. | `5` |
| `replyOnAccept` | Whether an **accepted** finding gets a factual thread reply referencing the fixing commit before the thread is resolved (the audit trail). `false` resolves accepted threads silently for maintainers who dislike bot-reply noise — declines always reply with reasoning regardless. | `true` |
| `promoteOnGreen` | The single control for the draft→ready flip. When `true`, after Phase A finishes with **every** required check genuinely green on a **draft** PR, run `gh pr ready <pr>` to flip it to ready-for-review (the gate that turns AI review on), then continue into Phase B — instead of stopping at green. **Default-on**, and an enabled config _is_ the human authorisation for the flip: proceed on proven green without seeking a separate sign-off. Set `false` (or pass `--no-promote`) to opt out and stop at green. Promotion is suppressed unless the green is _proven_ (Step 6's watched rollup, never "no failures yet"), there are **no unresolved human review threads**, and `mergeStateStatus` shows no unresolved base drift (`BEHIND` / `DIRTY`). An explicit user prompt — or `--promote` / `--no-promote` — overrides this per run; `--ci-only` and `--dry-run` never promote. | `true` |
| `deferNonBlocking` | When `true` (the default), a valid **in-scope** finding is proposed as **accept** only if it is **high-impact**; otherwise it is proposed as **follow-up** (same path as out-of-scope). High-impact means any of: it **blocks later work** on this PR or stacked work; it touches **Claude Code / agent-skill logic / CI or release infrastructure**; or it is **critical/high severity** (correctness, security, data-loss). You classify each finding yourself against those criteria — do **not** trust bot severity labels (CodeRabbit ⚠️/🧹, Bugbot grades). Set `false` to restore scope-only behaviour (every valid in-scope finding is proposed as accept; only out-of-scope findings become follow-ups). | `true` |
| `humanEnvelope` | When `true` (the default), Phase B **halts** after verify-then-propose with a full disposition plan (accept / decline / create a follow-up issue) and waits for one batch **Yes / No / Other** approval (**default yes**) before code changes, Linear create, or resolving replies — via Cursor `AskQuestion` or Claude Code `AskUserQuestion` when available, else prose `[Y/n]` (Step 10). Proposed follow-up threads get a non-resolving `follow-up-pending` mark when the plan is presented so restarts do not re-emit them. Same gate covers findings from later AI re-reviews on this PR (including `/send-it` → triage chains). Set `false` (or pass `--auto-apply`) to restore legacy auto Phase B (impact-gated fix-now; mark `follow-up-pending` on classify; Linear-only gate for follow-ups — see A-1654). Legacy CLI aliases `defer` / `defer-pending` still work. An explicit user prompt overrides the config per run. | `true` |
| `reviewIdleMinutes` | Hybrid review-settle idle window: after at least one configured bot has reported, treat reviews as settled when there is **no new** bot headline / unresolved-thread activity for this many minutes. | `5` |
| `reviewWaitMaxMinutes` | Hard cap on the hybrid wait after the ready flip (or Phase B entry on an already-ready PR). If bots are still missing when this expires, run the **slow-bot micro-gate** (proceed / wait longer / abort) before the disposition envelope. | `20` |

The remaining five configure the **follow-up capture** path — turning a follow-up
finding into a tracked Linear issue. Under `humanEnvelope`, capture is part of
the same envelope approval (not a second prompt). Under `--auto-apply` /
`humanEnvelope: false`, capture keeps its own Step 12 batch gate. Capture is
**opt-in**: when `linearTeamName` is empty, it is disabled (no Linear MCP calls);
skip silently when the Linear MCP server is unavailable.

| Key | Meaning | Default |
| --- | --- | --- |
| `linearTeamName` | Linear team **name** (not the key — the key is renamed over time, the name is stable) the follow-up issues are created under. Empty disables capture entirely. | `""` |
| `issueKeys` | Team-key prefixes that may appear in branch names, used to recognise issue ids the same way `linear-sync` does. Mirrors the established `issueKeys` convention. | `[]` |
| `followUpLabel` | Optional label applied to each created follow-up issue (e.g. `follow-up`). Empty = no label. | `""` |
| `followUpProject` | Linear project (name, id, or slug) used as the **catch-all** when a follow-up cannot inherit a live project from the PR's Linear issue. **Required when `linearTeamName` is set** — empty or unresolved must refuse create (never file with no project). Rheged estate value: `Follow-up issues`. | `""` |
| `followUpState` | Optional initial workflow state (type, name, or id — e.g. `Backlog`) for created issues. Empty = the team's default state. | `"Backlog"` |

Only the configured `reviewBots` are actioned in Phase B. Human review comments
are surfaced in the final report but never auto-actioned, replied to, or
resolved — leave those for the human.

## Usage modes

**Auto** — detect the current branch's PR and its phase, then run:

```bash
triage-pr
```

**Explicit PR** — operate on a specific PR by number or URL:

```bash
triage-pr 123
```

**CI only** — run Phase A and stop, even if the PR is ready:

```bash
triage-pr --ci-only
```

**Dry run** — report failing checks and unresolved findings and propose fixes,
but change nothing (no commits, no pushes, no thread replies):

```bash
triage-pr --dry-run
```

**Promote on green** — opt in to flipping the draft to ready once Phase A is cleanly
green (then continue into Phase B). Overrides `promoteOnGreen` for this run;
`--no-promote` forces the default stop-at-green:

```bash
triage-pr --promote
```

**Auto-apply Phase B** — skip the human envelope and restore legacy auto Phase B
for this run (impact-gated fix-now; Linear-only gate for follow-ups). Overrides
`humanEnvelope: true`:

```bash
triage-pr --auto-apply
```

## Process

### Step 1 — Locate the PR and detect the phase

```bash
gh pr view <pr> --json number,isDraft,state,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup
```

- Resolve the PR from the argument, or from the current branch when none is
  given. If `gh pr view` finds no PR, stop and tell the user to open one with
  `/send-it` first.
- `isDraft == true` → **Phase A**. When CI is green, promotion (`promoteOnGreen`,
  default on) flips the cleanly-green draft to ready at Step 6 and the run continues
  into Phase B. With promotion disabled (`--no-promote` / `promoteOnGreen: false`),
  report and stop instead — AI review has not run yet, and the skill leaves the flip
  to the human.
- `isDraft == false` → **Phase A** (confirm/clear CI), then **Phase B**.
- Record `baseRefName` for the drift checks and `mergeStateStatus` for conflict
  detection.

### Step 2 — Phase A: inspect failing checks

```bash
gh pr checks <pr>
```

For each failed Actions check, resolve its run ID from the check's `detailsUrl`
(in `statusCheckRollup`) and read the failing step's logs:

```bash
gh run view <run-id> --log-failed
```

Capture the **actual failing command and error lines**, not just the check name.
You are diagnosing a root cause, not pattern-matching a label.

### Step 3 — Phase A: classify each failure (in-scope vs upstream vs gated)

```bash
git fetch origin <base>
git diff --name-only origin/<base>...HEAD   # files this PR actually touches
```

- **In-scope** — the failure names files in this PR's diff, or is a lint / test /
  build failure reproducible on the branch head. Fix it (Step 4) — **unless** its
  only remedy would change lint / format / static-analysis config or add an ignore
  or disable directive, in which case **Lint-surface gated** (below) takes
  precedence, regardless of how clearly the failure belongs to this PR.
- **Upstream / base drift** — the job also fails on `origin/<base>` independent of
  this diff, **or** `mergeStateStatus == BEHIND`, **or** the error names files the
  PR never touched. Remedy is to rebase/merge the base (Step 5), **not** to edit
  the failing code.
- **Lint-surface gated** — the only remedy available is a change to lint / format /
  static-analysis **config**, or a new **ignore / disable directive**. That is a
  **developer decision**, so it is not in-scope and you do not make it. Record a
  **gated item** — the file, the change you would have made, why no code fix was
  available, and the preferred alternative (fix the offending code, or raise the rule
  change in the shared config package) — then carry on with the rest of the round and
  report it at the next natural stopping point (Step 6 / Step 13). The full surface
  list is in
  [`references/review-discipline.md`](references/review-discipline.md#lint-surfaces-are-a-developer-decision).
- A failure that can only be "fixed" by weakening a gate is never in-scope — that is
  the hard ban in **Important rules**; the gated bucket above is its human-decided
  grey zone.

### Step 4 — Phase A: fix in-scope failures, one at a time

- Apply the smallest fix that addresses the **root cause** within the PR's scope.
- **Fix the code, never the lint surface.** For a lint / format / static-analysis
  failure the preference order is: (1) fix the offending **code**; (2) if the rule
  itself is genuinely wrong, the remedy is a change to the **shared config package**
  (`@rheged-studio/eslint-config`, `@rheged-studio/markdownlint-config`, …),
  proposed to the developer — not landed here; (3) a local config override or an
  ignore / disable directive only with the developer's sign-off. You never take (2)
  or (3) on your own initiative — classify it as gated (Step 3) and keep going.
  _Carve-out:_ when the PR's own diff already contains a developer-authored lint
  config or ignore change, you may repair a genuine error in it (e.g. a syntax or
  schema error breaking the lint job), but never loosen a rule or widen an ignore.
- Re-run the **specific** failing command locally and read its exit code before
  claiming it fixed (e.g. `pnpm lint`,
  `npx --yes skills-ref@0.1.5 validate ./skills/<name>`, the failing test). Pin the
  version so the local check matches CI exactly and can't be rug-pulled. Evidence
  before claims — never assert a fix on "should" or "probably".
- Commit with a Conventional Commit subject, then push. One fix → one
  verification → next fix.

### Step 5 — Phase A: handle base-branch drift

Only when Step 3 classified the failure as upstream/behind:

```bash
git fetch origin <base>
git merge origin/<base>      # or rebase, per the repo's convention
```

- Clean merge → push and re-watch (Step 6).
- Conflict → go to **Merge conflicts** below.

### Step 6 — Phase A: re-watch CI until green or budget exhausted

```bash
gh pr checks <pr> --watch
```

- After each push, watch the rollup to completion. Still red → loop back to
  Step 2 — **unless** every remaining red check is a Step 3 **gated** item, which
  ends Phase A immediately (see the gated-exit rule below).
- **No early "done".** Do not tell the user the run is complete, green, or ready
  for attention until `gh pr checks --watch` (or an equivalent fresh rollup) has
  **exited** and every required check is **terminal** (success or failure). Queued,
  pending, or in-progress checks are non-terminal — "no failures yet", an empty
  rollup, or mixed pending+pass is **not** proven green and **not** a completion
  signal (same discipline as the promotion gate below, applied to every end-of-run
  claim).
- **Stay quiet while watching.** Prefer a silent wait / background watch over
  interim "still waiting" pings that interrupt other work. Surface the human only
  at a **natural stopping point**: the **human envelope** (Step 10 — actionable
  dispositions to approve), the Step 13 report (when the loop converged), a
  documented Phase-A early stop in this step (promotion disabled / promotion gate
  failed / gated lint-surface items outstanding / `--ci-only` / `--dry-run`), the
  slow-bot micro-gate (Step 7), or a hard blocker / `maxCiRounds` exhaustion that
  needs a decision. The envelope _is_ actionable — do not treat A-1178's "don't
  pull attention" rule as a reason to skip it.
- **Gated lint-surface items end the loop.** When every remaining red check is a
  Step 3 **gated** item, stop **immediately** — do not spend `maxCiRounds` re-watching
  a failure you have decided not to fix. Report each gated item (file, the change you
  would have made, the preferred alternative) as a Phase-A early stop and hand the
  decision to the developer. CI is not green, so the promotion gate below cannot pass
  either; name the gated items as the specific reason it wasn't promoted.
- **Bound the loop** by `maxCiRounds`. When exhausted, stop and report the
  remaining failures as blockers rather than looping forever.
- Green **and ready** → continue to Phase B.
- Green **and draft**, promotion **disabled** (`--no-promote` / `promoteOnGreen: false`)
  → report green and **stop**.
- Green **and draft**, promotion **enabled** (default, or `--promote`) → run
  the **promotion gate** before flipping. All three must hold:
  1. **Proven green** — the green is _this step's_ watched-rollup green (not pending /
     "no failures yet"); apply the same exit-code discipline Phase A already enforces,
     never greenwash to reach the flip.
  2. **No unresolved human threads** — run
     `node scripts/review-threads.mjs <pr> --bots "<config.reviewBots joined by commas>"`
     and require `humanThreads` empty. (On a draft, `unresolvedThreads` is empty
     anyway — AI review hasn't run — so this gate is specifically about humans who
     reviewed the draft.)
  3. **No unresolved base drift** — re-fetch `mergeStateStatus` **fresh** right before
     the flip (`gh pr view <pr> --json mergeStateStatus`), not the Step 1 snapshot: an
     intervening Phase A push can have changed it. Require it not `BEHIND` / `DIRTY`
     (Phase A's Step 5 resolves in-scope drift; if it persists, do **not** promote —
     report it as a blocker).

  All three pass → `gh pr ready <pr>`, report the flip, then **continue to Phase B**
  (Step 7). The ready-flip and Phase B's pushes re-fire CI + AI review, and the whole
  loop stays bounded by `maxCiRounds`. Any gate fails → **do not flip**; report green
  plus the specific reason it wasn't promoted, and stop. Under `--dry-run`, report
  that it _would_ promote (or why not) and flip nothing. Under `--ci-only`, never
  promote — stop at green regardless of the knob.

### Step 7 — Phase B: hybrid wait for AI reviewers

Stay in the same session. After the ready flip (or when entering Phase B on an
already-ready PR), poll the fetcher until reviews settle — or until the hard cap
forces the slow-bot micro-gate. Resolve the skill directory as in Step 8.

```bash
node scripts/review-threads.mjs <pr> --bots "claude,cursor,coderabbitai"
```

Use the JSON settle fields:

- `botsReported` — configured bots that have a **sticky-marker** headline in
  `aiSummaryComments` (e.g. `use_sticky_comment` / `BUGBOT_REVIEW`) **and/or** at
  least one unresolved or follow-up-pending thread. A bare ack kept only as
  `selectSummaryComments`' first-candidate fallback does **not** count.
- `botsMissing` — configured bots still without a sticky headline or thread.

**Settled when any of:**

1. **All reported** — `botsMissing` is empty (every `reviewBots` entry has
   reported), **or**
2. **Idle** — at least one bot has reported, and there has been **no new** bot
   sticky-headline / unresolved-thread activity for `reviewIdleMinutes`, **or**
3. **Max wait** — `reviewWaitMaxMinutes` since Phase B wait started.

Poll quietly (e.g. every 60s); do **not** ping the human while waiting unless the
slow-bot gate fires. Record a fingerprint of `{botsReported, unresolvedThread
ids, aiSummary comment ids}` each poll to detect "new activity" for the idle
window.

**Slow-bot micro-gate** — only when max wait expires with `botsMissing`
non-empty. List arrived vs outstanding bots and ask **once** in this session
using the same dual-host structured-question pattern as Step 10:

1. Put the timeout context in the chat message (reported vs outstanding bots).
2. Prefer a structured question tool when available:
   - **Cursor `AskQuestion`:** options **Proceed** / **Wait** / **Abort**.
   - **Claude Code `AskUserQuestion`:** `header` e.g. `Review wait` (≤12 chars);
     options **Proceed** / **Wait** / **Abort** (descriptions: continue with
     findings so far / extend the wait / stop without an envelope).
3. **Fallback** (neither tool): prose

```text
Review wait timed out after <N> minutes.
  Reported: claude, coderabbitai
  Still outstanding: cursor
Proceed with what we have / wait longer / abort? [proceed|wait|abort]
```

- **Proceed** → continue to Step 8 with the findings so far (note missing bots in
  the envelope report).
- **Wait** → reset the max-wait clock (or extend by another `reviewWaitMaxMinutes`)
  and return to the poll loop.
- **Abort** → stop; leave threads untouched; no disposition envelope.

Do **not** open the disposition envelope until this gate is answered (or reviews
settled via headlines/idle).

### Step 8 — Phase B: fetch unresolved review feedback

Run the bundled fetcher (path **relative to this skill's own directory**):

```bash
node scripts/review-threads.mjs <pr> --bots "claude,cursor,coderabbitai"
```

This fetcher is **read-only**. The write side is `respond-threads.mjs` (Step 11).

It prints minimal JSON including:

- `unresolvedThreads` — inline review threads (`isResolved == false`) raised by a
  configured `reviewBot`, trimmed to `{threadId, path, line, isOutdated, author,
  url, comments}`. `url` is the first review-comment permalink when GitHub
  provides one (use it in the Step 10 detail block). This is the actionable set.
- `deferredThreads` — bot threads already carrying our **non-resolving follow-up-pending
  marker** (from a prior pass). Do not re-triage these in Step 9; include them in
  the envelope's follow-up set when rediscovering pending capture.
- `humanThreads` — unresolved threads **not** raised by a review bot. Surface in
  the report; do not auto-action.
- `aiSummaryComments` — headline summary per review bot (sticky issue comment
  and/or Bugbot `<!-- BUGBOT_REVIEW -->` review body). At most **one per bot**.
- `botsReported` / `botsMissing` — settle helpers (Step 7).

Resolved threads are filtered out. Empty `unresolvedThreads`, no AI summary,
**and** no `deferredThreads` → report "no actionable AI review feedback" and skip
to Step 13. If only `deferredThreads` remain under `humanEnvelope`, fold them into
the envelope plan; under `--auto-apply`, go to Step 12's Linear path.

### Step 9 — Phase B: verify-then-propose dispositions

Apply READ → UNDERSTAND → VERIFY → EVALUATE for every actionable finding (full
rules in [`references/review-discipline.md`](references/review-discipline.md)).
**Do not** IMPLEMENT, create Linear issues, or resolve threads yet — build a
numbered **disposition plan** only:

For each finding record:

- source bot + path:line (or issue-level summary item)
- thread / comment **permalink** when available (`url` on the shaped thread, or
  the issue-comment / review URL for summary items) — omit rather than invent a
  broken link
- short finding paraphrase (1–3 sentences — enough to decide without opening GitHub)
- verification sketch (real? in-scope? high-impact?)
- proposed disposition: `accept` | `decline` | `follow-up` | `outdated` | `gated`
- for `accept`: concrete fix sketch
- for `decline`: technical reasoning
- for `follow-up`: draft Linear title + rationale, **and** the destination line
  (`→ file under …`) from the routing step below
- for `gated`: surface touched + preferred alternative

Impact classification still follows `deferNonBlocking` (propose accept only when
high-impact when that knob is on).

**Resolve follow-up destination before the envelope.** When the plan includes
any `follow-up` and capture is enabled (`linearTeamName` set), run the Step 11
inherit-then-fallback algorithm **read-only in this step** — before Step 10 —
so each follow-up item can show `→ file under …`. Use only `get_issue`,
`list_projects`, and `list_milestones`; do **not** call `save_milestone` or
`save_issue` until Step 11 after approval (or the auto-apply Linear gate).
Reuse that destination on mint in Step 11; do not re-decide it after approval.
If routing fail-closes (empty or unresolved catch-all), keep the item as a
follow-up candidate but say on the envelope line that capture will decline /
`Follow-up not tracked`. On the catch-all, when the repo milestone does not yet
exist, still show `file under <catch-all> / <repo> (no parent project)` on the
envelope line — Step 11 creates the milestone on mint. Skip this resolve when
capture is disabled (`linearTeamName` empty). Under `--dry-run`, this resolve
stays read-only too (no Linear writes).

**Lint-surface findings are gated, whatever their impact.** When a finding's fix
would edit lint / format / static-analysis config or add an ignore / disable
directive, mark the plan item `[gated]` — naming the surface it would touch and the
preferred alternative (code fix, or a change to the shared config package). `[gated]`
**displaces every other disposition**, not just `accept`: classify the surface
_before_ applying `deferNonBlocking`, so a valid but low-impact lint-surface finding
is gated rather than routed to `follow-up` and the Linear follow-up flow. Under
`humanEnvelope` it rides the **same** envelope so the developer
sees it in one batch — never a second prompt, and never applied without their explicit
go-ahead. Under `--auto-apply` / `humanEnvelope: false` there is no envelope, so a
gated item is simply reported at Step 13 and left unapplied.

**When `humanEnvelope` is true** (default) → continue to Step 10. As soon as the
plan includes any per-thread `follow-up`, **immediately** mark those threads with the
non-resolving `follow-up-pending` decision (below) so a restart or overlapping run
does not re-emit them as fresh findings while the human decides. Do **not**
resolve them yet — Step 11 finalises after approval.
**When `humanEnvelope` is false / `--auto-apply`** → skip Step 10; proceed to
Steps 11–12 using today's auto behaviour (fix accepts now; mark `follow-up-pending`
as soon as you classify a follow-up, then the Linear-only gate at Step 12).

### Step 10 — Phase B: human envelope (same-session gate)

Present the disposition plan as **one batch** and ask **once** (**default yes** —
plans are generally accurate). Nothing is applied until the human answers.

#### 1. Detail block (chat message, above the gate)

Use **Option A — grouped by disposition**. Omit empty disposition sections.
Full cards for `accept` / `follow-up` / `gated`. Routine `decline` / `outdated`
items go in one compressed list unless the reason is non-obvious (then promote
to a full card). Include a GitHub **thread permalink** on every item when the
fetcher (or comment) provides `url`.

```markdown
## Disposition details

### [accept] Null guard in src/api.ts:42
- **Source:** CodeRabbit · [thread](https://github.com/org/repo/pull/12#discussion_r1)
- **Finding:** Optional `user` used without a check before `.id`.
- **Verify:** Real, in-scope, high-impact (would throw on anonymous).
- **Fix:** Early return when `!user`.

### [follow-up] Extract retry helper
- **Source:** Claude · [thread](…)
- **Finding:** Duplicated fetch-retry in three callers.
- **Verify:** Real but non-blocking for this PR.
- **Linear:** "Add retry backoff to fetch layer" → file under Triage PR upgrades

### [gated] eslint.config.mjs rule change
- **Source:** … · [thread](…)
- **Finding:** …
- **Verify:** …
- **Why gated:** Would edit lint config; prefer shared package change unless signed off.

### [decline] / [outdated]
1. CodeRabbit · `README.md:14` — pinning wording already correct · [thread](…)
2. Claude · exact-pinned astro — YAGNI for this PR · [thread](…)
```

#### 2. Compact plan + structured Yes / No / Other

Keep a short numbered plan (for Other overrides by number) plus any bot-wait
footer. Prefer a structured question tool when available — **at most one** such
call per turn. Do **not** dump the full detail block into the question prompt.

**Compact plan shape** (also used in the prose fallback):

```text
Phase B disposition plan (nothing applied yet):
  1. [accept] Fix null guard in src/api.ts:42 — …
  2. [decline] Suggested rewrite is YAGNI — …
  3. [follow-up] Extract retry helper — draft: "Add retry backoff to fetch layer"
     → file under Triage PR upgrades (inherited from A-1541)
  4. [gated] Would need `eslint.config.mjs` rule change — your call; prefer a
     change to @rheged-studio/eslint-config
  Bots still outstanding at wait end: cursor (if any)
```

**Cursor — `AskQuestion` when available:**

- Prompt: apply this Phase B disposition plan? (default yes; nothing applied yet).
- Options: **Yes** | **No** | **Other (type overrides)**.

**Claude Code — `AskUserQuestion` when available** (listed in `allowed-tools`):

- `header`: `Apply plan` (≤12 chars).
- `question`: apply this Phase B disposition plan? (default yes; nothing applied yet).
- Options: **Yes (Recommended)** (apply as proposed) | **No** (apply nothing).
- Rely on Claude Code’s automatic **Other** free-text path for typed overrides;
  if a host build does not append Other, list an explicit third **Other** option.

**Fallback** (neither `AskQuestion` nor `AskUserQuestion`):

```text
Apply this plan? [Y/n]
  (optional overrides: "yes except decline #1, follow-up #2 as …")
```

#### 3. Interpret the answer

Keep the session open — this gate **is** the actionable interrupt.
Proposed follow-up threads should already carry the `follow-up-pending` marker from
Step 9 (durable, still open).

- **Yes** (default / Recommended / empty Enter on `[Y/n]`) → apply the plan in
  Step 11.
- **No** / Skip / dismiss-as-cancel → leave remaining threads untouched (optional:
  decline `follow-up-pending` threads with `Follow-up not tracked` if you want them
  closed); stop; no Step 13 "all done" claim beyond "envelope declined; nothing
  applied".
- **Other** / typed overrides → interpret freeform changes (`yes except decline
  #1, follow-up #2 as …`). If unclear, **one** clarifying turn — still no apply
  until resolved.
- Under `--dry-run`, print the detail block + plan that _would_ be proposed and
  create nothing (do not call the question tools).

The same envelope covers findings from **later** AI re-reviews on this PR
(Step 12 re-envelope) — one gate for all dispositions, including new Linear
follow-ups, using this same Questions contract (including when `/send-it` chained
into this run).

Fleet rollout of this Questions pattern to other skills is tracked under A-1655;
the auto-apply Linear-only gate is intentionally unchanged here (A-1654).

### Step 11 — Phase B: apply approved dispositions

Execute the approved plan (or the auto-apply path) one finding at a time:

- **Accept** → IMPLEMENT, prove locally, commit/push, re-watch CI (Step 6), then
  reply+resolve via `respond-threads.mjs` only once that fix's CI round is green.
- **Decline** / **outdated** → reply+resolve immediately (no code).
- **Follow-up (auto-apply path)** → as soon as you classify the finding as follow-up,
  mark it with `follow-up-pending` (non-resolving) so it is not re-triaged on the next
  pass; after the Linear-only `[y/N]` at Step 12 (or on capture disabled / no),
  post the final `follow-up` reply+resolve (or decline `Follow-up not tracked`).
- **Follow-up (envelope path)** → thread should already be `follow-up-pending` from
  Step 9; on approval create the Linear issue (when capture enabled) then final
  `follow-up` reply+resolve; when capture is disabled or the human excluded a follow-up,
  fall back to decline (`Follow-up not tracked`).
- **Gated (lint surface)** → apply **only** when the developer explicitly approved
  that item in the envelope. Their sign-off is what turns it into an accept, so from
  there it runs the **Accept** path exactly: IMPLEMENT the signed-off config or ignore
  change, prove locally, commit/push, re-watch CI (Step 6), then reply+resolve once
  that fix's CI round is green — with `--decision accept`, referencing the fixing
  commit. `gated` is a **plan label only**; `respond-threads.mjs` has no such decision
  (its set is `accept` / `decline` / `follow-up` / `follow-up-pending` / `outdated`;
  legacy aliases `defer` / `defer-pending` still accepted) and
  passing one would throw. **Except `.github/workflows/*`** — approval never
  authorises a workflow edit. **Never greenwash** bans those outright, and this gate
  does not relax it: when a signed-off item would touch a workflow (e.g. a CI
  lint-step severity knob), report that the developer must make the change
  themselves, and reply+resolve without a code change. Without sign-off, leave the
  thread untouched and carry the
  item into the Step 13 report. If it only becomes clear **mid-apply** that an approved
  accept needs a lint-config edit or an ignore directive, stop that item, apply
  nothing, and re-present it as `[gated]` in the Step 12 re-envelope. The gate holds
  under `--auto-apply` / `humanEnvelope: false` too — there it is reported, never
  auto-applied.

```bash
node scripts/respond-threads.mjs thread --thread <PRRT_id> --decision accept --sha <sha> --bots "claude,cursor,coderabbitai"
node scripts/respond-threads.mjs thread --thread <PRRT_id> --decision decline --reason "<technical reasoning>" --bots "claude,cursor,coderabbitai"
# mark a follow-up candidate without resolving (envelope Step 9, or auto-apply as you classify):
node scripts/respond-threads.mjs thread --thread <PRRT_id> --decision follow-up-pending --bots "claude,cursor,coderabbitai"
# after Linear mint (or envelope/auto-apply capture approval):
node scripts/respond-threads.mjs thread --thread <PRRT_id> --decision follow-up --reference <issue-id> --bots "claude,cursor,coderabbitai"
```

Linear create details (team by **name**, state by **type**, links, labels,
**project**, optional **milestone**) — resolve via `list_issue_statuses` /
`list_projects` / `list_milestones` and fail loudly on typos.

**Fail closed on project.** When capture is enabled (`linearTeamName` set),
every minted issue **must** have a resolved `project`. Never omit `project`;
never call `save_issue` if routing fails. Resolve the destination **read-only**
in **Step 9** before the envelope (Step 10) so the plan can show it, then reuse
that destination on mint (creating the catch-all milestone with
`save_milestone` if needed).

1. **Inherit from the PR's Linear issue when it has a live project.** Extract
   issue ids using the same `issueKeys` regex as `linear-sync`
   (`lib/issue-keys.mjs` / `buildIssueRe`: `\bA-\d+\b` for a single key;
   grouped alternation when there are several). Skip lookup if there are no
   configured keys. Match in this order — **stop at the first source that
   yields a match**:
   1. the **upper-cased** branch name — if it has at least one match, the
      **first** match is the primary parent (later matches on the branch, and
      every match on the PR title, are ignored);
   2. else the PR title — if it has at least one match, the **first** match is
      the parent;
   3. else there is **no** parent id — skip inherit and go to step 2.
   `get_issue` on that id. If it has a `project`, resolve that project with
   `list_projects` and inspect its status **type**. Types `completed` and
   `canceled` are **not live** — treat as no inherit. On a live project: pass
   that `project` on `save_issue`, set `relatedTo` to the parent id, and **do
   not** nest as a sub-issue (`parentId`). **Do not** attach a Follow-up
   issues milestone. Envelope line:
   `file under <project> (inherited from A-NNNN)`.
2. **Otherwise fall back to `followUpProject` (the catch-all).** Typical
   reasons: no issue id, parent has no project, or the parent project is
   completed/canceled. If `followUpProject` is empty → **do not** call
   `save_issue`. Abort capture loudly (tell the human to set `followUpProject`
   in `config.json`), and fall back to decline / `Follow-up not tracked` for
   each follow-up candidate. If set → resolve it with `list_projects` (name,
   id, or slug). On a miss → **do not** call `save_issue`; fail loudly with the
   unresolved value (same decline fallback).
3. **On the catch-all, bucket by repo milestone.** GitHub repo **short name**
   from `gh repo view --json name --jq .name` (not the worktree directory).
   In Step 9, `list_milestones` only — never `save_milestone`. If a milestone
   with that exact name exists, use it on mint. If not, still show the envelope
   line below; **on mint in this step** `save_milestone` to create it
   (`project` + `name`), then use the created milestone. Pass both `project`
   and `milestone` on `save_issue`. Envelope line:
   `file under <catch-all> / <repo> (no parent project)`. Do **not**
   `relatedTo` a parent that was skipped for inherit.
4. On a successful inherit **or** fallback, always pass the resolved `project`
   on every `save_issue` create. Never omit `project`.

### Step 12 — Phase B: issue-level ack + re-envelope

Acknowledge headline summary findings with one consolidated comment once the
approved plan has been applied:

```bash
node scripts/respond-threads.mjs summary --pr <pr> --findings '[{"title":"…","status":"accepted","reference":"<sha>"}]'
```

Then re-fetch (Step 8). If **new** unresolved bot findings appear (fresh review
after the apply push), return to Step 7 → Step 9 → **Step 10 envelope again**
(same Option A detail + structured Yes/No/Other contract, including any new
Linear candidates). Bound by `maxCiRounds`. If CI is terminal
green and there are no new bot findings, continue to Step 13.

Under auto-apply, if only `deferredThreads` / follow-up candidates remain without an
envelope, present the legacy Linear-only batch `[y/N]` here (default no), then
ack summaries. Structured Questions for that Linear-only gate are out of scope
here (A-1654).

### Step 13 — Report

This is the completion alert for a full run. Emit it only after every required
check is **terminal**, or after a hard blocker / budget exhaustion / envelope
decline / abort — never while checks are still pending. Phase-A early stops
report at their Step 6 exit instead.

Summarise:

- Checks fixed, each with the failing command it addressed.
- Envelope outcome (approved / declined / auto-apply) and any slow-bot decisions.
- Findings accepted and fixed (with the resolving commit).
- Findings declined, each with the technical reasoning given.
- **Gated lint-surface items** awaiting the developer's decision — each with the
  file, the change that would have been made, why no code fix was available, and the
  preferred alternative (fix the code, or raise it in the shared config package).
  Nothing on this list was applied.
- Follow-up issues created (each with its Linear id/URL and destination —
  inherited project, or catch-all project plus repo milestone), or follow-up
  candidates that were not tracked.
- Issue-level findings acknowledged in the consolidated comment.
- Base merges/rebases performed.
- Remaining blockers (if `maxCiRounds` was exhausted).
- Final CI state, with the proving command's **fresh** exit evidence — check names
  and their terminal states.
- Any **human** review comments, surfaced for the human to handle.
- The PR's draft/ready state: when promotion fired, report the flip (draft → ready)
  and that Phase B then ran; otherwise a reminder that the state is unchanged —
  and, if promotion was enabled but a gate blocked it, the specific reason.

## Merge conflicts

- Resolve **only** when the resolution is unambiguous and within the PR's scope
  (e.g. both sides touched disjoint hunks, or this branch's intent clearly
  supersedes).
- **Abort and ask the human** when inten

…(truncated)
