/abc:review-epic — epic-context PR/MR reviewer (Linear)
Watch a Linear parent issue with sub-issues and review each child's PR/MR against the full epic context — the parent spec, the design decisions already taken in merged sibling PRs, and the acceptance criteria of children still pending. This is the reviewer half of the two-session epic-shipping pattern: /abc:ship-epic (or /abc:ship-issue on the parent) implements in one session; this skill reviews in another, holding the top-down spec context the per-PR view can't see.
Review-only. This skill never merges, never pushes, never closes issues, never transitions Linear states. Its only writes are PR/MR review comments and its own dedup markers — and those live on the PR/MR, never on the Linear issue (see ./linear-conventions.md), so the dedup convention is byte-identical with ../review-epic-gh/SKILL.md.
The per-PR review logic is shared with the GitHub variant via the abc:reviewer subagent; what differs here is plumbing — Linear MCP resolution, per-child platform routing, and the GitLab posting path. Linear↔VCS mapping lives in ./linear-conventions.md.
Hard rules
Never merge, approve-with-merge, push, close, label, or transition Linear state. Posting PR/MR review comments and <!-- review-epic:* --> markers is the entire write surface. No Linear write tools are granted — by construction.
Never review a PR/MR twice at the same HEAD SHA. The dedup marker (Phase 2) is load-bearing — without it every tick re-reviews everything.
Never post markers on the Linear issue. Markers live on the PR/MR so dedup is platform-agnostic and shared with review-epic-gh — a future migration of an epic between trackers keeps its review history.
Do not run this skill in the same Claude Code session as ship-issue / ship-epic workers. The dual-context perspective is the whole point: the implementer session holds per-PR context, this session holds the epic-wide spec. One session holding both collapses the benefit (and bloats context twice as fast).
Always self-cancel the cron on termination (Phase 5), mirroring the ship-* family contract.
Per-tick post gate (conservative by design). The repo convention gates posting reviewer comments behind AskUserQuestion. Each tick re-derives state from Linear + the VCS alone and no consent marker is stored anywhere, so consent cannot outlive a tick: the gate fires on the first review pass of each tick that has a review to post (Phase 3 step 3), and approval covers every post in that tick. Declining halts the loop and self-cancels the cron. No-op ticks never ask. Stated trade-off: a tick holding a pending review blocks until a human answers — the reviewer session is walk-away between reviews, not during them. (The alternative — dropping the gate and posting unattended with an explicit Hard-Rule exception, mirroring how ship-* post status comments unattended — was considered and deferred; see the two-session workflow section of the top-level README.)
Concurrency while a gate is open. A blocked AskUserQuestion holds the Claude Code turn, and /loop runs one turn per session serially — the next 12m cron fire does not spawn a second concurrent reviewer process against the same targets. The pending prompt holds the turn until answered; the cron's interim fire is absorbed by the single-session model rather than racing it. So "walk away during a gate" is non-destructive but stalling: no double-review, no marker race — the loop simply makes no further progress until the human answers, then resumes from the next tick's fresh Phase 2 derivation (any HEAD that advanced meanwhile is naturally re-targeted). This is why the dedup marker (Phase 2) only needs to guard posted reviews, not in-flight ones — there is never more than one in-flight review per session.
Phase 0: Parse input and self-arm
Normalize the arg
Flag extraction (before shape detection): detect and strip a trailing --no-compact flag from $ARGUMENTS. When present, set no-compact mode for this invocation — the compact-between-reviews prompt (Phase 4) is skipped. The flag stays in the raw arg string used for cron arming/matching, so the opt-out survives every subsequent tick. Contract: ../_shared/compact-on-merge.md.
$ARGUMENTS is one of:
- Bare Linear ID (e.g.
PROJ-100) — the parent issue.
- Linear issue URL (
https://linear.app/<org>/issue/<id>) → strip prefix, extract TEAM-N.
Anything else (GitHub <owner>/<repo>#<n> refs, comma-lists, milestone: refs, project URLs) → reject with the two supported shapes. This skill requires an explicit Linear parent issue; GitHub parents go through /abc:review-epic-gh.
Fetch parent and validate
mcp__claude_ai_Linear__get_issue with id: PARENT-ID, includeRelations: true. Read description, labels, statusType.
- If
statusType is completed (Done) or canceled → the epic is done, but how to exit depends on whether a cron is armed. Run the cron-entry match rule (below): if a matching entry exists (this is a loop tick), terminate via Phase 5 — emit the reviewed-PRs summary and CronDelete the entry — so the loop self-cancels instead of zombie-firing "epic done" every 12 minutes. Only when no matching cron exists (a fresh invocation against an already-done epic) print a one-line "epic done — nothing to review" and exit without arming.
- Resolve sub-issues:
mcp__claude_ai_Linear__list_issues with parentId: PARENT-ID, no status filter — Linear's native parent/child relation replaces the -gh family's managed task-list fence. If the list is empty → reject: "PARENT-ID has no sub-issues. Run /abc:scaffold-sub-issues first."
If any of these reads fails, classify the failure (an unknown epic state is never treated as "nothing to review"):
- Permanent (Linear
entity not found / 404 — the parent was deleted or moved out of scope) → run Phase 5 termination (emit the reviewed-PRs summary and CronDelete the cron) and exit, so a deleted parent doesn't zombie-fire "epic done" every tick.
- Transient (MCP error, timeout, 5xx, auth blip) → halt this tick without
CronDelete and retry next tick. If the same error repeats across consecutive ticks, surface stalled on same error; run /loop cancel once so the human can intervene.
Self-arm the loop
Mirror ship-epic's cron-entry match rule with this skill's name:
A CronList entry matches when its command string contains <command-name> <raw-arg> followed by a word boundary — the next character (if any) must NOT be alphanumeric, -, or ,. <command-name> is the literal slash-command name Claude Code injects (e.g. /abc:review-epic via plugin namespace) — read it from the <command-name> tag, never hardcode. Fallback regex: (?:^|[^A-Za-z0-9])(?:[A-Za-z][A-Za-z0-9_-]*:)?review-epic <raw-arg>(?![A-Za-z0-9_,-]).
Note this skill's name is a prefix of review-epic-gh — the required space between <command-name> and <raw-arg> is what disambiguates: an entry for /abc:review-epic-gh <owner>/<repo>#<n> never contains review-epic followed by a Linear ID, so cross-matching is impossible.
- Match found → no-op (the common loop-tick path), proceed to Phase 1.
- No match →
Skill(skill: "loop", args: "12m <command-name> <raw-arg>"), then proceed to Phase 1 — the first tick also does the first iteration's work.
12-minute cadence sits in the ~10–15 min target: slower than the 6m workers (a review is only actionable once a PR exists or gains commits), fast enough that a worker's pr-open window usually gets its review before the human merges.
Phase 0.7: Resolve platform per child (repo routing)
For each open sub-issue, resolve where its PR/MR lives — same repo: label convention as ship-issue Phase 1:
- Collect label names starting with
repo:. Exactly one expected; 0 → skip the child this tick with a [no-repo-label] line in the output; 2+ → same skip, noting the ambiguity.
- Extract
<name> from repo:<name> → workdir is the <cwd>/<name>/ subdirectory. Missing subdir → skip with a [no-workdir] line.
- Detect the platform from
git -C <workdir> remote get-url origin: contains github.com → gh; contains gitlab.<host> → glab; anything else → skip with [unknown-platform]. Capture the repo identity from the same remote URL: GitHub → <owner>/<repo>; GitLab → the project path (e.g. group/subgroup/project).
- Confirm CLI auth (
gh auth status / glab auth status --hostname <host>) once per platform per tick. Derive the GitLab <host> from the ABC_GITLAB_HOST env var if set, else parse it from git remote get-url origin; fall back to bare glab auth status if no GitLab remote resolves (never hardcode a GitLab hostname). Not authed → halt with the auth command — a reviewer that can read but not post would burn the dedup-free review work every tick.
Skipped children are not errors — this skill is review-only, so it degrades by narrowing coverage and saying so, rather than halting the whole loop the way a worker must. Cache the {workdir, platform, cli, repo} tuple per child for this tick; re-resolve fresh next tick.
Repo routing on every VCS call (load-bearing in multi-repo epics). The loop's cwd is not the child's workdir, so neither CLI may rely on cwd-based repo resolution: every gh call passes --repo <owner>/<repo> explicitly, every glab mr call passes -R <project-path>, and every glab api call substitutes the URL-encoded project path for the project segment (projects/<group%2Fproject>/merge_requests/...) instead of the cwd-resolved :id placeholder — :id would silently resolve against the wrong project (or none) from the loop's cwd.
Phase 1: Bootstrap context (per tick, ≤30KB)
Load fresh each tick (the tick interval keeps the prompt cache warm; re-fetching also picks up mid-epic spec edits):
Parent issue description verbatim — the source of truth.
Per child (from the parentId listing): title, statusType, dependency relations, and the acceptance-criteria section of its description. Relations come from a per-child get_issue with includeRelations: true — the parentId listing doesn't carry them (same pattern as ship-epic Phase 1). They're bootstrap context, not load-bearing; skip the per-child fetches when the tick is already over budget. The acceptance criteria live under a ## Acceptance criteria heading or an - **acceptance:** block, whichever convention the scaffold used. Skip scope / out-of-scope prose unless a review needs it.
Dedup against the parent (load-bearing for the budget). Scaffolded child descriptions are usually verbatim ST-sections of the parent PLAN, so naive parent+children assembly roughly doubles the spec bytes. When a child's spec text already appears in the parent description, do not re-include it — cite its location ("ST-4 section of the parent"). Include a child's own description only where it diverges from the parent's section (edited mid-epic). Same rule and reference measurement as review-epic-gh Phase 1.
Merged sibling PRs/MRs: per PR/MR, the summary review comment this skill previously posted (if any) plus a per-file change summary: GitHub — gh api /repos/<owner>/<repo>/pulls/<n>/files --jq '.[] | "\(.filename) +\(.additions) -\(.deletions)"' (gh pr diff has no --stat); GitLab — glab api "projects/<encoded-project-path>/merge_requests/<iid>/diffs" --paginate for the file list (new_path per entry; the API doesn't expose per-file +/− counts — fall back to glab mr diff <iid> -R <project-path> only when a review needs a specific decision).
Pending children's acceptance criteria — the forward-compat lens: what will later sub-issues exercise? (Subject to the same parent-dedup rule.)
Budget: keep the assembled context under ~30KB. When over, trim in this order: (1) merged-sibling full diffs → per-file change summary only, (2) per-file summaries → PR title + summary-comment only, (3) pending children's criteria → titles only. Never trim the parent description or the under-review child's acceptance criteria.
Phase 2: Enumerate review targets (dedup)
List candidate PRs/MRs: for each open child that survived Phase 0.7, take the child's gitBranchName (Linear provides this on the issue object) and the issue's attachments/links, then: GitHub — gh pr list --repo <owner>/<repo> --state open --head <gitBranchName> --json number,headRefOid,url; GitLab — glab mr list --source-branch <gitBranchName> -R <project-path> --output json.
0 matches — gitBranchName is Linear's suggested slug; the developer may have named the real branch differently. Fall back to the child's Linear attachments/links: extract a PR/MR URL and resolve it directly (gh pr view <url> --json number,state,headRefOid,url / glab mr view <url> --output json). Only when both the branch lookup and the attachments miss is the child [no-pr-yet].
2+ matches (branch reuse, closed-and-reopened) — take the first open PR/MR and flag the ambiguity in the tick output (e.g. [reviewed, 2 PRs on branch — picked #N]). The same 0/2+ rule applies to URLs from the attachments fallback.
For each candidate, read its HEAD SHA (GitHub: headRefOid; GitLab: diff_refs.head_sha from glab mr view <iid> -R <project-path> --output json) and fetch its top-level comments (GitHub: gh api /repos/<owner>/<repo>/issues/<pr>/comments; GitLab: glab api "projects/<encoded-project-path>/merge_requests/<iid>/notes" --paginate). If a <!-- review-epic:reviewed-at:<sha> --> marker matching the current HEAD SHA exists → skip this PR/MR with no further API calls. This dedup check is the only cost for unchanged PRs.
A PR/MR whose markers all reference older SHAs has new commits → it's a review target (the stale marker stays; history is the audit trail).
No targets this tick → print the one-line no-op summary (Phase 6) and return.
Phase 3: Review each target
For each target, in sub-issue order:
Fetch the diff and pin the reviewed SHA. GitHub — gh pr diff <n> --repo <owner>/<repo>, then re-read headRefOid (gh pr view <n> --repo <owner>/<repo> --json headRefOid). GitLab — glab mr diff <iid> -R <project-path>, capturing the diff_refs (base_sha / start_sha / head_sha) from the same glab mr view <iid> -R <project-path> --output json the diff was produced against; these refs build the inline position in step 4, so reading them once here guarantees the position matches the bytes reviewed. If the freshly-read HEAD (headRefOid / diff_refs.head_sha) differs from the SHA Phase 2 selected this target on, the branch advanced mid-tick → abort this target's pass with [head-moved], drop no marker, and let the next tick re-derive. Otherwise pin <reviewed-sha> to that value — it flows through the GitHub commit_id / the GitLab position, and is the SHA written in the step-5 marker, so the marker can never claim a SHA the review wasn't produced against.
Spawn the existing abc:reviewer subagent (Agent tool, subagent_type: abc:reviewer — plugin agents register namespaced; the bare reviewer does not resolve in a live session) — do not edit agents/reviewer.md; extend its input via the prompt. Pass:
The unified diff (its standard input contract), plus
Platform + PR/MR ref — github/gitlab, the repo/project identity, PR #<n> / MR !<iid>, and the reviewed SHA — so findings cite the concrete target.
The repo's review rules — the contents of <workdir>/.claude/review-rules.md when that file exists; omit silently when absent.
Full files for every touched path — Linear children resolve to a local checkout at the Phase 0.7-cached <workdir>. Before offering it for local reads, assert the checkout is on the reviewed commit: git -C <workdir> rev-parse HEAD must equal <reviewed-sha> from step 1.
- On match — state the absolute path in the prompt ("repo root for all paths in this diff:
<abs-path>") and tell the reviewer it may read full files there for surrounding context, rather than working from the hunk alone.
- On mismatch (the ship worker checked out a different commit, the branch advanced locally, etc.) — do not let the reviewer read local bytes that don't match the reviewed diff. Either abort the pass with
[head-moved] (consistent with step 1, drop no marker) or fall back to fetching each file via gh api /repos/<owner>/<repo>/contents/<path>?ref=<reviewed-sha> like the -gh sibling. Otherwise step 5 would write a marker claiming <reviewed-sha> against bytes the review never saw — silently defeating the SHA-pinning guarantee on the Linear path.
(This local-read path is the GitHub variant's one divergence: there every file is fetched via gh api ?ref=<reviewed-sha> unconditionally, because a cross-repo child may have no checkout — so the -gh side reads pinned bytes by construction and needs no HEAD assertion.)
Cross-cutting epic context from Phase 1: the parent spec, this child's acceptance criteria verbatim with their sub-issue ID, merged-sibling decisions, and pending children's criteria — with the instruction to additionally evaluate (a) which acceptance bullets this diff satisfies/misses, citing them by sub-issue ID and bullet, and (b) forward-looking flags where a pending sub-issue will exercise this code differently.
Per-tick post gate (first review pass of this tick only): show the assembled review — inline comments plus summary — via AskUserQuestion for a single go/no-go. Approval covers this and every subsequent post in this tick (see Hard Rules — consent can't persist across ticks because no consent marker is stored); decline → halt the loop and CronDelete via the Phase 0 match rule. Later passes in the same tick skip this step entirely.
Re-check PR/MR state, then post. After gate approval and immediately before posting, re-read the target's state (GitHub: gh pr view <n> --repo <owner>/<repo> --json state,mergedAt; GitLab: glab mr view <iid> -R <project-path> --output json → state); if it is merged/closed → skip this target with [merged-before-post], drop no marker — a review on a merged target is noise and would burn the dedup marker. Otherwise post the review:
- GitHub — one call:
POST /repos/<owner>/<repo>/pulls/<pr>/reviews with event: COMMENT, commit_id: <reviewed-sha> (pins the review to the exact SHA reviewed — without it GitHub attaches to the latest HEAD and inline comments mis-anchor when the branch moved), the reviewer's inline comments as the comments array, and the summary as the review body.
- GitLab — no batch review API: post each inline comment as a positioned discussion via
glab api "projects/<encoded-project-path>/merge_requests/<iid>/discussions" -f body=<text> with the full six-field position object (API doc): position[base_sha] / position[start_sha] / position[head_sha] built from the diff_refs captured in step 1 (the same response the diff came from — never a fresh read, which could have advanced past the reviewed bytes), position[position_type]=text (literal), and position[new_path] + position[new_line] for the new side of the diff (use old_path / old_line for deletion and context-only lines). Omitting any of the last three returns a generic 400 "the position is invalid" that's easy to mis-attribute to diff_refs. Then the summary as one glab mr note <iid> -R <project-path> --message <body>.
Post-failure guard (both platforms): if any posting call returns 4xx, halt this PR/MR's pass without dropping the step-5 dedup marker and surface the response body in the tick output. The marker is only written after every post for that target succeeds — otherwise a malformed position would burn the review and mark the HEAD as reviewed, and the loop would never retry it.
The summary body has explicit structure either way:
- (a) Inline comments — one-line index of what was flagged.
- (b) Spec cross-reference — "satisfies ST-N bullet X … misses ST-N bullet Y", citing specific acceptance bullets by sub-issue ID, never free-text paraphrase.
- (c) Forward-looking flags — "ST-N+1 will exercise this path differently; current shape will need rework", citing the pending child.
Drop the dedup marker as a marker-only top-level comment on the PR/MR (the marker is the entire body, matching the <!-- ship-issue:* --> marker-only convention): gh pr comment <n> --repo <owner>/<repo> --body '<!-- review-epic:reviewed-at:<reviewed-sha> -->' or glab mr note <iid> -R <project-path> --message '<!-- review-epic:reviewed-at:<reviewed-sha> -->' where <reviewed-sha> is the pinned value from step 1 (the SHA the review was actually produced against), not a freshly re-read HEAD. Never on the Linear issue.
Compact-between-reviews boundary — see Phase 4 before starting the next target.
Phase 4: Compact between reviews
Consumer of ../_shared/compact-on-merge.md at the "between two PR reviews" boundary: after a review pass completes (marker dropped) and one or more un-reviewed targets remain in this tick's queue, print — as the last output of the tick —
🗜 Review of <pr-or-mr-url> posted. Run /compact now to free context before reviewing <next-url>.
then end the tick (same end-the-wake rule as the workers — the dedup markers persist on the PRs/MRs, so the next tick's Phase 2 picks up exactly the remaining targets). Skip in no-compact mode, and when the just-reviewed PR/MR was the only/last target. At most once per tick.
Phase 5: Termination
On every tick, before Phase 1, re-check the parent:
- Parent
statusType is completed (Done) or canceled → terminal. To build the reviewed-PRs/MRs summary, re-run Phase 2 enumeration with the state filters dropped — every sub-issue regardless of statusType, and gh pr list ... --state all / glab mr list ... --state all — because by termination every reviewed child PR/MR is merged/closed and the default open-only enumeration would find nothing to scan for <!-- review-epic:reviewed-at:* --> markers. Print the summary with thread links, CronDelete the loop's own cron entry via the Phase 0 match rule, and exit cleanly. This lands within one tick of the parent transitioning to Done.
- User-invoked
Ctrl-C / loop cancellation needs no cleanup — every tick re-derives from Linear + the VCS; markers already posted keep dedup correct on any future re-arm.
If CronDelete fails, print a note ("couldn't auto-cancel; run /loop cancel") and continue — the summary is the authoritative surface.
Phase 6: Output contract (every tick)
/review-epic tick <timestamp>
Parent: PROJ-100 "<title>" (open, 3 of 6 children merged)
[reviewed] PR #43 (PROJ-103) 5 inline, 2 spec-refs, 1 forward flag
[skipped] MR !12 (PROJ-104) marker matches HEAD abc1234
[head-moved] PR #44 (PROJ-105) HEAD advanced mid-tick; re-review next tick
[merged-before-post] PR #45 (PROJ-106) merged after gate; no review posted
[no-pr-yet] PROJ-107
[no-repo-label] PROJ-108
[no-workdir] PROJ-109 repo:foo → no <cwd>/foo/ subdir
[unknown-platform] PROJ-110 remote is neither github.com nor gitlab.*
Next tick: /loop 12m /abc:review-epic <raw-arg>
One line on no-op ticks: no-op tick — no new commits on any child PR/MR.
Notes on persistence
Stateless across sessions — Linear and the VCS are the sources of truth. The <!-- review-epic:reviewed-at:<sha> --> markers on the PRs/MRs are the entire dedup store; closing the terminal mid-loop is safe, and a force-push that discards a marker simply triggers a benign re-review. Append markers, never edit them. Because the markers live on the PR/MR rather than the tracker, the dedup state is identical regardless of which review-epic variant posted it.
1---2name: review-epic3description: Linear · Review-only counterpart to /abc:ship-epic. Self-arming /loop that watches a Linear parent issue's sub-issues, reviews each child's PR/MR (GitHub or GitLab, routed via the `repo:` label) as it surfaces against the FULL epic context (parent spec + merged-sibling decisions + pending children's criteria), posts inline + spec-cross-referenced summary comments via the abc:reviewer subagent, and exits when the parent reaches Done. Never merges. TRIGGER when the user says "/abc:review-epic PARENT-ID", asks to "review this epic as it ships" against a Linear parent, or wants a standing reviewer session running parallel to /abc:ship-epic.4---56# /abc:review-epic — epic-context PR/MR reviewer (Linear)78Watch a Linear **parent issue** with sub-issues and review each child's PR/MR **against the full epic context** — the parent spec, the design decisions already taken in merged sibling PRs, and the acceptance criteria of children still pending. This is the reviewer half of the two-session epic-shipping pattern: `/abc:ship-epic` (or `/abc:ship-issue` on the parent) implements in one session; this skill reviews in another, holding the *top-down* spec context the per-PR view can't see.910**Review-only.** This skill never merges, never pushes, never closes issues, never transitions Linear states. Its only writes are PR/MR review comments and its own dedup markers — and those live **on the PR/MR, never on the Linear issue** (see [`./linear-conventions.md`](./linear-conventions.md)), so the dedup convention is byte-identical with [`../review-epic-gh/SKILL.md`](../review-epic-gh/SKILL.md).1112The per-PR review logic is shared with the GitHub variant via the `abc:reviewer` subagent; what differs here is plumbing — Linear MCP resolution, per-child platform routing, and the GitLab posting path. Linear↔VCS mapping lives in [`./linear-conventions.md`](./linear-conventions.md).1314## Hard rules1516- **Never merge, approve-with-merge, push, close, label, or transition Linear state.** Posting PR/MR review comments and `<!-- review-epic:* -->` markers is the entire write surface. No Linear write tools are granted — by construction.17- **Never review a PR/MR twice at the same HEAD SHA.** The dedup marker (Phase 2) is load-bearing — without it every tick re-reviews everything.18- **Never post markers on the Linear issue.** Markers live on the PR/MR so dedup is platform-agnostic and shared with `review-epic-gh` — a future migration of an epic between trackers keeps its review history.19- **Do not run this skill in the same Claude Code session as `ship-issue` / `ship-epic` workers.** The dual-context perspective is the whole point: the implementer session holds per-PR context, this session holds the epic-wide spec. One session holding both collapses the benefit (and bloats context twice as fast).20- **Always self-cancel the cron on termination** (Phase 5), mirroring the `ship-*` family contract.21- **Per-tick post gate (conservative by design).** The repo convention gates posting reviewer comments behind `AskUserQuestion`. Each tick re-derives state from Linear + the VCS alone and **no consent marker is stored anywhere**, so consent cannot outlive a tick: the gate fires on the **first review pass of each tick that has a review to post** (Phase 3 step 3), and approval covers every post in that tick. Declining halts the loop and self-cancels the cron. No-op ticks never ask. Stated trade-off: a tick holding a pending review **blocks until a human answers** — the reviewer session is walk-away *between* reviews, not *during* them. (The alternative — dropping the gate and posting unattended with an explicit Hard-Rule exception, mirroring how `ship-*` post status comments unattended — was considered and deferred; see the two-session workflow section of the top-level README.)2223 **Concurrency while a gate is open.** A blocked `AskUserQuestion` holds the Claude Code turn, and `/loop` runs one turn per session serially — the next 12m cron fire does **not** spawn a second concurrent reviewer process against the same targets. The pending prompt holds the turn until answered; the cron's interim fire is absorbed by the single-session model rather than racing it. So "walk away during a gate" is **non-destructive but stalling**: no double-review, no marker race — the loop simply makes no further progress until the human answers, then resumes from the next tick's fresh Phase 2 derivation (any HEAD that advanced meanwhile is naturally re-targeted). This is why the dedup marker (Phase 2) only needs to guard *posted* reviews, not in-flight ones — there is never more than one in-flight review per session.2425## Phase 0: Parse input and self-arm2627### Normalize the arg2829**Flag extraction (before shape detection):** detect and strip a trailing `--no-compact` flag from `$ARGUMENTS`. When present, set no-compact mode for this invocation — the compact-between-reviews prompt (Phase 4) is skipped. The flag stays in the **raw arg string** used for cron arming/matching, so the opt-out survives every subsequent tick. Contract: [`../_shared/compact-on-merge.md`](../_shared/compact-on-merge.md).3031`$ARGUMENTS` is one of:32331. **Bare Linear ID** (e.g. `PROJ-100`) — the parent issue.342. **Linear issue URL** (`https://linear.app/<org>/issue/<id>`) → strip prefix, extract `TEAM-N`.3536Anything else (GitHub `<owner>/<repo>#<n>` refs, comma-lists, `milestone:` refs, project URLs) → reject with the two supported shapes. This skill requires an explicit Linear parent issue; GitHub parents go through `/abc:review-epic-gh`.3738### Fetch parent and validate39401. `mcp__claude_ai_Linear__get_issue` with `id: PARENT-ID`, `includeRelations: true`. Read description, labels, `statusType`.412. If `statusType` is `completed` (Done) or `canceled` → the epic is done, but **how to exit depends on whether a cron is armed**. Run the cron-entry match rule (below): if a matching entry **exists** (this is a loop tick), terminate via Phase 5 — emit the reviewed-PRs summary and `CronDelete` the entry — so the loop self-cancels instead of zombie-firing "epic done" every 12 minutes. Only when **no** matching cron exists (a fresh invocation against an already-done epic) print a one-line "epic done — nothing to review" and exit without arming.423. Resolve sub-issues: `mcp__claude_ai_Linear__list_issues` with `parentId: PARENT-ID`, no status filter — Linear's native parent/child relation replaces the `-gh` family's managed task-list fence. If the list is empty → reject: "PARENT-ID has no sub-issues. Run `/abc:scaffold-sub-issues` first."4344**If any of these reads fails, classify the failure** (an unknown epic state is never treated as "nothing to review"):4546- **Permanent** (Linear `entity not found` / 404 — the parent was deleted or moved out of scope) → run Phase 5 termination (emit the reviewed-PRs summary and `CronDelete` the cron) and exit, so a deleted parent doesn't zombie-fire "epic done" every tick.47- **Transient** (MCP error, timeout, 5xx, auth blip) → halt this tick **without** `CronDelete` and retry next tick. If the same error repeats across consecutive ticks, surface `stalled on same error; run /loop cancel` once so the human can intervene.4849### Self-arm the loop5051Mirror `ship-epic`'s cron-entry match rule with this skill's name:5253> A `CronList` entry **matches** when its command string contains `<command-name> <raw-arg>` followed by a word boundary — the next character (if any) must NOT be alphanumeric, `-`, or `,`. `<command-name>` is the literal slash-command name Claude Code injects (e.g. `/abc:review-epic` via plugin namespace) — read it from the `<command-name>` tag, never hardcode. Fallback regex: `(?:^|[^A-Za-z0-9])(?:[A-Za-z][A-Za-z0-9_-]*:)?review-epic <raw-arg>(?![A-Za-z0-9_,-])`.54>55> Note this skill's name is a **prefix of `review-epic-gh`** — the required space between `<command-name>` and `<raw-arg>` is what disambiguates: an entry for `/abc:review-epic-gh <owner>/<repo>#<n>` never contains `review-epic ` followed by a Linear ID, so cross-matching is impossible.5657- Match found → no-op (the common loop-tick path), proceed to Phase 1.58- No match → `Skill(skill: "loop", args: "12m <command-name> <raw-arg>")`, then proceed to Phase 1 — the first tick also does the first iteration's work.596012-minute cadence sits in the ~10–15 min target: slower than the 6m workers (a review is only actionable once a PR exists or gains commits), fast enough that a worker's `pr-open` window usually gets its review before the human merges.6162## Phase 0.7: Resolve platform per child (repo routing)6364For each **open** sub-issue, resolve where its PR/MR lives — same `repo:` label convention as `ship-issue` Phase 1:65661. Collect label names starting with `repo:`. Exactly one expected; `0` → skip the child this tick with a `[no-repo-label]` line in the output; `2+` → same skip, noting the ambiguity.672. Extract `<name>` from `repo:<name>` → workdir is the `<cwd>/<name>/` subdirectory. Missing subdir → skip with a `[no-workdir]` line.683. Detect the platform from `git -C <workdir> remote get-url origin`: contains `github.com` → `gh`; contains `gitlab.<host>` → `glab`; anything else → skip with `[unknown-platform]`. **Capture the repo identity from the same remote URL**: GitHub → `<owner>/<repo>`; GitLab → the project path (e.g. `group/subgroup/project`).694. Confirm CLI auth (`gh auth status` / `glab auth status --hostname <host>`) once per platform per tick. Derive the GitLab `<host>` from the `ABC_GITLAB_HOST` env var if set, else parse it from `git remote get-url origin`; fall back to bare `glab auth status` if no GitLab remote resolves (never hardcode a GitLab hostname). Not authed → halt with the auth command — a reviewer that can read but not post would burn the dedup-free review work every tick.7071Skipped children are **not** errors — this skill is review-only, so it degrades by narrowing coverage and saying so, rather than halting the whole loop the way a worker must. Cache the `{workdir, platform, cli, repo}` tuple per child for this tick; re-resolve fresh next tick.7273**Repo routing on every VCS call (load-bearing in multi-repo epics).** The loop's cwd is not the child's workdir, so neither CLI may rely on cwd-based repo resolution: every `gh` call passes `--repo <owner>/<repo>` explicitly, every `glab mr` call passes `-R <project-path>`, and every `glab api` call substitutes the **URL-encoded project path** for the project segment (`projects/<group%2Fproject>/merge_requests/...`) instead of the cwd-resolved `:id` placeholder — `:id` would silently resolve against the wrong project (or none) from the loop's cwd.7475## Phase 1: Bootstrap context (per tick, ≤30KB)7677Load fresh each tick (the tick interval keeps the prompt cache warm; re-fetching also picks up mid-epic spec edits):78791. **Parent issue description verbatim** — the source of truth.802. **Per child** (from the `parentId` listing): title, `statusType`, dependency relations, and the **acceptance-criteria section** of its description. Relations come from a **per-child `get_issue` with `includeRelations: true`** — the `parentId` listing doesn't carry them (same pattern as `ship-epic` Phase 1). They're bootstrap context, not load-bearing; skip the per-child fetches when the tick is already over budget. The acceptance criteria live under a `## Acceptance criteria` heading or an `- **acceptance:**` block, whichever convention the scaffold used. Skip scope / out-of-scope prose unless a review needs it.8182 **Dedup against the parent (load-bearing for the budget).** Scaffolded child descriptions are usually verbatim ST-sections of the parent PLAN, so naive parent+children assembly roughly **doubles** the spec bytes. When a child's spec text already appears in the parent description, do **not** re-include it — cite its location ("ST-4 section of the parent"). Include a child's own description only where it diverges from the parent's section (edited mid-epic). Same rule and reference measurement as `review-epic-gh` Phase 1.833. **Merged sibling PRs/MRs**: per PR/MR, the summary review comment this skill previously posted (if any) plus a **per-file change summary**: GitHub — `gh api /repos/<owner>/<repo>/pulls/<n>/files --jq '.[] | "\(.filename) +\(.additions) -\(.deletions)"'` (`gh pr diff` has no `--stat`); GitLab — `glab api "projects/<encoded-project-path>/merge_requests/<iid>/diffs" --paginate` for the file list (`new_path` per entry; the API doesn't expose per-file +/− counts — fall back to `glab mr diff <iid> -R <project-path>` only when a review needs a specific decision).844. **Pending children's acceptance criteria** — the forward-compat lens: what will later sub-issues exercise? (Subject to the same parent-dedup rule.)8586**Budget: keep the assembled context under ~30KB.** When over, trim in this order: (1) merged-sibling full diffs → per-file change summary only, (2) per-file summaries → PR title + summary-comment only, (3) pending children's criteria → titles only. Never trim the parent description or the under-review child's acceptance criteria.8788## Phase 2: Enumerate review targets (dedup)89901. List candidate PRs/MRs: for each **open** child that survived Phase 0.7, take the child's `gitBranchName` (Linear provides this on the issue object) and the issue's attachments/links, then: GitHub — `gh pr list --repo <owner>/<repo> --state open --head <gitBranchName> --json number,headRefOid,url`; GitLab — `glab mr list --source-branch <gitBranchName> -R <project-path> --output json`.9192 **0 matches** — `gitBranchName` is Linear's *suggested* slug; the developer may have named the real branch differently. Fall back to the child's Linear attachments/links: extract a PR/MR URL and resolve it directly (`gh pr view <url> --json number,state,headRefOid,url` / `glab mr view <url> --output json`). Only when both the branch lookup and the attachments miss is the child `[no-pr-yet]`.93 **2+ matches** (branch reuse, closed-and-reopened) — take the first **open** PR/MR and flag the ambiguity in the tick output (e.g. `[reviewed, 2 PRs on branch — picked #N]`). The same 0/2+ rule applies to URLs from the attachments fallback.942. For each candidate, read its HEAD SHA (GitHub: `headRefOid`; GitLab: `diff_refs.head_sha` from `glab mr view <iid> -R <project-path> --output json`) and fetch its top-level comments (GitHub: `gh api /repos/<owner>/<repo>/issues/<pr>/comments`; GitLab: `glab api "projects/<encoded-project-path>/merge_requests/<iid>/notes" --paginate`). If a `<!-- review-epic:reviewed-at:<sha> -->` marker matching the **current** HEAD SHA exists → **skip this PR/MR with no further API calls**. This dedup check is the only cost for unchanged PRs.953. A PR/MR whose markers all reference older SHAs has new commits → it's a review target (the stale marker stays; history is the audit trail).9697No targets this tick → print the one-line no-op summary (Phase 6) and return.9899## Phase 3: Review each target100101For each target, in sub-issue order:1021031. **Fetch the diff and pin the reviewed SHA.** GitHub — `gh pr diff <n> --repo <owner>/<repo>`, then re-read `headRefOid` (`gh pr view <n> --repo <owner>/<repo> --json headRefOid`). GitLab — `glab mr diff <iid> -R <project-path>`, capturing the `diff_refs` (`base_sha` / `start_sha` / `head_sha`) from the same `glab mr view <iid> -R <project-path> --output json` the diff was produced against; these refs build the inline `position` in step 4, so reading them once here guarantees the position matches the bytes reviewed. If the freshly-read HEAD (`headRefOid` / `diff_refs.head_sha`) differs from the SHA Phase 2 selected this target on, the branch advanced mid-tick → **abort this target's pass with `[head-moved]`, drop no marker**, and let the next tick re-derive. Otherwise pin `<reviewed-sha>` to that value — it flows through the GitHub `commit_id` / the GitLab `position`, and is the SHA written in the step-5 marker, so the marker can never claim a SHA the review wasn't produced against.1042. Spawn the existing **`abc:reviewer`** subagent (`Agent` tool, `subagent_type: abc:reviewer` — plugin agents register namespaced; the bare `reviewer` does not resolve in a live session) — do **not** edit `agents/reviewer.md`; extend its input via the prompt. Pass:105 - The unified diff (its standard input contract), plus106 - **Platform + PR/MR ref** — `github`/`gitlab`, the repo/project identity, PR `#<n>` / MR `!<iid>`, and the reviewed SHA — so findings cite the concrete target.107 - **The repo's review rules** — the contents of `<workdir>/.claude/review-rules.md` when that file exists; omit silently when absent.108 - **Full files for every touched path** — Linear children resolve to a **local checkout** at the Phase 0.7-cached `<workdir>`. **Before** offering it for local reads, assert the checkout is on the reviewed commit: `git -C <workdir> rev-parse HEAD` must equal `<reviewed-sha>` from step 1.109 - **On match** — state the absolute path in the prompt ("repo root for all paths in this diff: `<abs-path>`") and tell the reviewer it may read full files there for surrounding context, rather than working from the hunk alone.110 - **On mismatch** (the ship worker checked out a different commit, the branch advanced locally, etc.) — do **not** let the reviewer read local bytes that don't match the reviewed diff. Either abort the pass with `[head-moved]` (consistent with step 1, drop no marker) or fall back to fetching each file via `gh api /repos/<owner>/<repo>/contents/<path>?ref=<reviewed-sha>` like the `-gh` sibling. Otherwise step 5 would write a marker claiming `<reviewed-sha>` against bytes the review never saw — silently defeating the SHA-pinning guarantee on the Linear path.111112 (This local-read path is the GitHub variant's one divergence: there every file is fetched via `gh api ?ref=<reviewed-sha>` unconditionally, because a cross-repo child may have no checkout — so the `-gh` side reads pinned bytes by construction and needs no HEAD assertion.)113 - **Cross-cutting epic context** from Phase 1: the parent spec, this child's acceptance criteria **verbatim with their sub-issue ID**, merged-sibling decisions, and pending children's criteria — with the instruction to additionally evaluate (a) which acceptance bullets this diff satisfies/misses, citing them **by sub-issue ID and bullet**, and (b) forward-looking flags where a pending sub-issue will exercise this code differently.1143. **Per-tick post gate** (first review pass of this tick only): show the assembled review — inline comments plus summary — via `AskUserQuestion` for a single go/no-go. Approval covers this and **every subsequent post in this tick** (see Hard Rules — consent can't persist across ticks because no consent marker is stored); decline → halt the loop and `CronDelete` via the Phase 0 match rule. Later passes in the same tick skip this step entirely.1154. **Re-check PR/MR state, then post.** After gate approval and immediately before posting, re-read the target's state (GitHub: `gh pr view <n> --repo <owner>/<repo> --json state,mergedAt`; GitLab: `glab mr view <iid> -R <project-path> --output json` → `state`); if it is merged/closed → **skip this target with `[merged-before-post]`, drop no marker** — a review on a merged target is noise and would burn the dedup marker. Otherwise post the review:116 - **GitHub** — one call: `POST /repos/<owner>/<repo>/pulls/<pr>/reviews` with `event: COMMENT`, `commit_id: <reviewed-sha>` (pins the review to the exact SHA reviewed — without it GitHub attaches to the latest HEAD and inline comments mis-anchor when the branch moved), the reviewer's inline comments as the `comments` array, and the summary as the review body.117 - **GitLab** — no batch review API: post each inline comment as a positioned discussion via `glab api "projects/<encoded-project-path>/merge_requests/<iid>/discussions" -f body=<text>` with the **full six-field `position` object** ([API doc](https://docs.gitlab.com/api/discussions/#create-a-new-thread-in-the-merge-request-diff)): `position[base_sha]` / `position[start_sha]` / `position[head_sha]` built from the **`diff_refs` captured in step 1** (the same response the diff came from — never a fresh read, which could have advanced past the reviewed bytes), `position[position_type]=text` (literal), and `position[new_path]` + `position[new_line]` for the new side of the diff (use `old_path` / `old_line` for deletion and context-only lines). Omitting any of the last three returns a generic 400 "the position is invalid" that's easy to mis-attribute to `diff_refs`. Then the summary as one `glab mr note <iid> -R <project-path> --message <body>`.118119 **Post-failure guard (both platforms):** if any posting call returns 4xx, halt this PR/MR's pass **without dropping the step-5 dedup marker** and surface the response body in the tick output. The marker is only written after every post for that target succeeds — otherwise a malformed `position` would burn the review *and* mark the HEAD as reviewed, and the loop would never retry it.120121 The **summary body** has explicit structure either way:122 - **(a) Inline comments** — one-line index of what was flagged.123 - **(b) Spec cross-reference** — "satisfies ST-N bullet X … misses ST-N bullet Y", citing specific acceptance bullets by sub-issue ID, never free-text paraphrase.124 - **(c) Forward-looking flags** — "ST-N+1 will exercise this path differently; current shape will need rework", citing the pending child.1255. Drop the dedup marker as a **marker-only** top-level comment on the PR/MR (the marker is the entire body, matching the `<!-- ship-issue:* -->` marker-only convention): `gh pr comment <n> --repo <owner>/<repo> --body '<!-- review-epic:reviewed-at:<reviewed-sha> -->'` or `glab mr note <iid> -R <project-path> --message '<!-- review-epic:reviewed-at:<reviewed-sha> -->'` where `<reviewed-sha>` is the pinned value from step 1 (the SHA the review was actually produced against), **not** a freshly re-read HEAD. **Never on the Linear issue.**1266. **Compact-between-reviews boundary** — see Phase 4 before starting the next target.127128## Phase 4: Compact between reviews129130Consumer of [`../_shared/compact-on-merge.md`](../_shared/compact-on-merge.md) at the **"between two PR reviews"** boundary: after a review pass completes (marker dropped) and **one or more un-reviewed targets remain in this tick's queue**, print — as the last output of the tick —131132```133🗜 Review of <pr-or-mr-url> posted. Run /compact now to free context before reviewing <next-url>.134```135136then **end the tick** (same end-the-wake rule as the workers — the dedup markers persist on the PRs/MRs, so the next tick's Phase 2 picks up exactly the remaining targets). Skip in no-compact mode, and when the just-reviewed PR/MR was the only/last target. At most once per tick.137138## Phase 5: Termination139140On every tick, before Phase 1, re-check the parent:141142- **Parent `statusType` is `completed` (Done) or `canceled`** → terminal. To build the reviewed-PRs/MRs summary, **re-run Phase 2 enumeration with the state filters dropped** — every sub-issue regardless of `statusType`, and `gh pr list ... --state all` / `glab mr list ... --state all` — because by termination every reviewed child PR/MR is merged/closed and the default open-only enumeration would find nothing to scan for `<!-- review-epic:reviewed-at:* -->` markers. Print the summary with thread links, `CronDelete` the loop's own cron entry via the Phase 0 match rule, and exit cleanly. This lands within one tick of the parent transitioning to Done.143- User-invoked `Ctrl-C` / loop cancellation needs no cleanup — every tick re-derives from Linear + the VCS; markers already posted keep dedup correct on any future re-arm.144145If `CronDelete` fails, print a note ("couldn't auto-cancel; run /loop cancel") and continue — the summary is the authoritative surface.146147## Phase 6: Output contract (every tick)148149```150/review-epic tick <timestamp>151Parent: PROJ-100 "<title>" (open, 3 of 6 children merged)152153 [reviewed] PR #43 (PROJ-103) 5 inline, 2 spec-refs, 1 forward flag154 [skipped] MR !12 (PROJ-104) marker matches HEAD abc1234155 [head-moved] PR #44 (PROJ-105) HEAD advanced mid-tick; re-review next tick156 [merged-before-post] PR #45 (PROJ-106) merged after gate; no review posted157 [no-pr-yet] PROJ-107158 [no-repo-label] PROJ-108159 [no-workdir] PROJ-109 repo:foo → no <cwd>/foo/ subdir160 [unknown-platform] PROJ-110 remote is neither github.com nor gitlab.*161162Next tick: /loop 12m /abc:review-epic <raw-arg>163```164165One line on no-op ticks: `no-op tick — no new commits on any child PR/MR`.166167## Notes on persistence168169Stateless across sessions — Linear and the VCS are the sources of truth. The `<!-- review-epic:reviewed-at:<sha> -->` markers on the PRs/MRs are the entire dedup store; closing the terminal mid-loop is safe, and a force-push that discards a marker simply triggers a benign re-review. Append markers, never edit them. Because the markers live on the PR/MR rather than the tracker, the dedup state is identical regardless of which review-epic variant posted it.