/sdlc:pr-check
Generated from solutions/ontological/plugin/plugins/sdlc/skills/pr-check/pr-check.md.
Description
Section titled “Description”Classify the state of a single open or closed pull request into one of
a fixed verdict enum — CLEAN, BEHIND, NEEDS-RESPONSE, CONFLICTS,
CI-FAILED, CI-BLOCKED, MERGED, CLOSED, or ERROR — plus a one-line reason. Non-interactive
and read-only on both the repo and GitHub; the only file it writes
is a per-PR cursor under .sdlc/pr-cursors/ (gitignored) used to
detect new review comments between calls. Designed to be called per
open PR from /sdlc:orchestrate; the parent dispatches on the verdict
and never has to parse free-form PR state itself. Supports a
—mock-state flag that swaps the live gh payload for a JSON
fixture so the classifier and cursor-update path can be exercised
deterministically under the eval harness.
Allowed tools
Section titled “Allowed tools”BashReadWrite
Source
Section titled “Source”Usage:
/sdlc:pr-check <pr-number>— classify the given PR. The number is a bare integer (38), not the#38form./sdlc:pr-check <pr-number> --mock-state <path>— classify against a JSON fixture at<path>instead of calling GitHub. No network calls are made; the cursor file is still written. The reason string is suffixed with(mock)so harness assertions can distinguish mock runs from real ones. See “Mock mode” below for the fixture shape and use cases./sdlc:pr-check <pr-number> --cursor-dir <path>— override the cursor directory (default.sdlc/pr-cursors/). Useful in mock mode so harness runs don’t write to the real cursor location. Composes with--mock-state.
Read-only on the repo and on GitHub apart from this skill’s own
cursor file under .sdlc/pr-cursors/ (or the override passed via
--cursor-dir): no GitHub state mutation, no commits. Classify the
PR and emit the verdict; the orchestrator acts on it.
The classification is performed by the pr classify op (the gh
fetch, the priority-ordered classifier, the cursor read/write, and
the verdict emission). Invoke that op as shown below; do not
re-implement the classifier inline in a sub-agent. The prose below
documents the contract the op honours.
Invocation
Section titled “Invocation”Shell out to the op with the parsed arguments. The op writes the one-line verdict to stdout (see “Output contract” below) and exits 0 for every defined verdict, including ERROR:
${CLAUDE_PLUGIN_ROOT}/cli/sdlc pr classify <pr-number> \ [--mock-state <path>] [--cursor-dir <path>]Forward the op’s stdout verbatim to the caller. Non-zero exit is reserved for catastrophic failures (e.g. cursor write blocked by filesystem permissions); the stderr describes the cause in that case.
Output contract — verdict enum
Section titled “Output contract — verdict enum”Exactly one line on stdout, of the form:
<VERDICT> reason="<one-line description>"Where VERDICT is one of:
MERGED—state == "MERGED". The PR shipped.CLOSED—state == "CLOSED"with no merge. The PR was abandoned.CONFLICTS— the head branch can’t merge cleanly into base.CI-FAILED— at least one required check is failing or has failed, and that failure is not fully explained by a billing/spending-limit block (seeCI-BLOCKED, which outranks nothing below it).NEEDS-RESPONSE— review changes are requested, OR new review/issue comments have arrived since the last/sdlc:pr-checkinvocation for this PR (tracked via the cursor file). OutranksBEHIND: the person who would rebase the branch is the one who would respond to the feedback, so surface the feedback first — they can rebase in the same pass.BEHIND— the head branch trails its base. Measured locally fromorigin/<base>andorigin/<head>, never read offgh: GitHub only reportsmergeStateStatus: BEHINDwhen branch protection requires an up-to-date branch, so without it the field stays silent however far a branch has drifted. The count is only as fresh as the last fetch — this skill does not fetch. Reached only when there is no new feedback to report (seeNEEDS-RESPONSEabove), so it advances the comment cursor like every other verdict.CI-BLOCKED— every currently-failing check failed because GitHub never started the job (an account-level billing/spending-limit block, detected from the check run’s own annotations), not because of anything in the PR’s diff, AND nothing above this rule matched either (no conflicts, no real failure, no new feedback, not behind). A blocked-only CI state never hides real feedback: NEEDS-RESPONSE and BEHIND both outrank it. There is nothing for a review/respond round to fix, so the orchestrator does not dispatchpr-respondfor this verdict — except a first-seen PR still gets its initialpr-review, the same as CLEAN/BEHIND would.CLEAN— none of the above. The PR is waiting on review or merge.ERROR— the PR could not be resolved (number not found,ghcall failed, etc.). The reason carries the diagnostic.
The orchestrator keys off VERDICT only; the reason is for the
human reading the digest log.
References:
${CLAUDE_PLUGIN_ROOT}/conventions/commit-messages.md— not used by this skill (it never commits), kept for cross-reference only.
The remainder of this document defines the contract the pr classify
op honours. The tests under
tests/classify_pr.test.ts pin every documented
verdict against the bundled fixtures.
1. Argument contract
Section titled “1. Argument contract”The script accepts:
<pr-number>(required, bare integer —38, not#38).--mock-state <path>(optional). When present, the classifier reads PR state from the JSON file at<path>instead of callinggh. Noghcalls are made on the mock path — not even the existence probe.--cursor-dir <path>(optional). Overrides the cursor directory. Defaults to.sdlc/pr-cursors/. Composes with--mock-statebut is also valid in real mode (e.g. tests that want to assert cursor writes without polluting the real location).
User-facing failure modes the script emits as ERROR lines (still exit 0 — the verdict is the contract, not the exit code):
- Missing or non-numeric
<pr-number>→ERROR reason="usage: /sdlc:pr-check <pr-number> [--mock-state <path>] [--cursor-dir <path>]". --mock-state <path>not found or unparseable →ERROR reason="--mock-state <path>: <not found | malformed JSON>".gh pr view <N> --json ...exits non-zero (real mode only) →ERROR reason="gh pr view <N> --json: <captured stderr>".
2. State source
Section titled “2. State source”Real mode (no --mock-state). Exactly one JSON fetch so the rest
of the classifier is fed by a single snapshot of PR state:
gh pr view <N> --json state,mergeable,mergeStateStatus,reviewDecision,headRefName,baseRefName,statusCheckRollup,mergedAt,comments,reviews,authorThe author field carries {"login": "..."} and is used by the
author-comment filter (see Section 4) when self-notes mode is
configured.
Mock mode (--mock-state <path>). Read and parse <path> as
JSON. The blob is shaped like PrFacts (lib/services/pr/forge/types.ts)
— the SAME normalized, forge-agnostic shape every real-mode call is
assembled into internally before it ever reaches the classifier
(Section 3), not the raw gh pr view --json payload above. See “Mock
mode” below for the canonical schema and the bundled fixtures.
Mock mode does NOT make any network calls. Missing arrays are
treated as []; missing scalars are treated as the appropriate empty
value (null, ""); do not error.
3. Classification — priority-ordered, first match wins
Section titled “3. Classification — priority-ordered, first match wins”Apply these rules in order, stopping at the first match; lower-priority rules do not override an earlier match. A MERGED PR with a brand-new review comment is therefore still MERGED, not NEEDS-RESPONSE.
-
state == "MERGED"→ emitMERGED reason="merged at <mergedAt>; task=<headRefName>". -
state == "CLOSED"(unmerged) → emitCLOSED reason="closed without merge". -
mergeable == "CONFLICTING"ORmergeStateStatus == "DIRTY"→ emitCONFLICTS reason="head branch <headRefName> conflicts with base <baseRefName>". -
Any entry in
statusCheckRollupwhoseconclusionisFAILURE,TIMED_OUT,CANCELLED, orSTARTUP_FAILURE, AND is NOT explained by a GitHub Actions “job was not started” annotation (an account-level billing/spending-limit block, not anything in the diff) → emitCI-FAILED reason="<failing check name>"(first such REAL failure in the array wins). A blocked-only CI state (every failing check is explained by that annotation) does NOT match this rule — it falls through to Rule 7 below, so it can never hide a stale branch or new feedback. -
reviewDecision == "CHANGES_REQUESTED"OR new comments since the cursor (see Section 4) → emitNEEDS-RESPONSE reason="<who, comment count>". Also outranks a blocked-only CI state, and outranks Rule 6 (BEHIND) below: the person who would rebase the branch is the one who would respond to the feedback, so surface the feedback first — they can rebase in the same pass. -
The head branch trails its base —
origin/<headRefName>is behindorigin/<baseRefName>by one or more commits → emitBEHIND reason="<n> commit(s) behind <baseRefName>". The count comes fromgit rev-list --left-right --count, not fromgh; a count that cannot be measured (a ref this checkout has never fetched) is unknown and skips the rule rather than reading as zero. Outranks a blocked-only CI state. Reached only when Rule 5 above did not match (no new feedback), so it always advances the comment cursor. -
Otherwise:
-
If every failing check (there is at least one) is blocked → emit
CI-BLOCKED reason="<blocked check name(s)>: check(s) never started — account/billing issue, not code". Reached only when nothing above claimed the verdict — no conflicts, no real failure, no new feedback, not behind. -
Otherwise → emit
CLEANwith a reason composed of two clauses separated by;:- Rollup clause:
no checks configuredwhenstatusCheckRollupis empty (length 0);checks passingwhen it has at least one entry and none failed. - Review clause:
awaiting reviewwhenreviewDecision != "APPROVED";approved, awaiting mergewhenreviewDecision == "APPROVED".
For example:
CLEAN reason="no checks configured; awaiting review",CLEAN reason="checks passing; approved, awaiting merge", etc. The verdict token remainsCLEANin every case — distinguishing “no checks” from “checks passing” is a reason-string refinement, not an enum expansion. - Rollup clause:
-
In mock mode, the script appends (mock) to the reason string so
harness assertions can distinguish mock-generated verdicts from real
ones. For example:
NEEDS-RESPONSE reason="reviewer-one, 1 new comment (mock)"The verdict token itself is unchanged — only the trailing reason is suffixed.
4. Cursor file
Section titled “4. Cursor file”The script maintains a per-PR cursor at
<cursor-dir>/<pr-number>.json (where <cursor-dir> is
.sdlc/pr-cursors/ by default, or the path passed via
--cursor-dir) with this shape:
{ "last_seen_comment_at": "<ISO 8601 UTC>", "last_invoked_at": "<ISO 8601 UTC>", "self_posted_at": ["<ISO 8601 UTC>", "..."]}The default directory is auto-ignored by .sdlc/* in
.gitignore; the script creates the parent dir on first write.
self_posted_at carries the createdAt timestamps of comments the
orchestrator itself posted on this PR (via the wrapper documented in
“Self-posted comment filter” below). The classifier filters comments
and reviews whose timestamps match any entry in this list, so the
orchestrator’s own replies do not trigger NEEDS-RESPONSE on the next
tick. pr-check itself never appends to self_posted_at — it only
reads, filters with, and preserves the field across writes. Forward
compatibility: a pre-extension cursor file without self_posted_at
is read as self_posted_at: [] (no migration needed).
The cursor write is unconditional. Mock mode does NOT skip it —
exercising the cursor-update path is the whole point of having a
mock mode. A --cursor-dir override in tests lets the harness
isolate writes from the real .sdlc/pr-cursors/ location.
On every invocation, the script:
- Reads the cursor if present; treats absence as
last_seen_comment_at: null(any existing comment counts as “new” on the first call). - Computes the “new comments” set as every entry in
comments[]plusreviews[]whosecreatedAt(orsubmittedAtfor reviews) is later thanlast_seen_comment_at. Entries authored by an ignored automation bot are always excluded (see the normalized bot filter below). PR-author entries are excluded conditionally per the author-comment filter below. - When the verdict is
NEEDS-RESPONSEdriven by new entries, the reason cites the newest such author and the count. - Always updates
last_seen_comment_atandlast_invoked_atand writes the cursor back, regardless of the verdict.self_posted_atis preserved across the write unchanged — pr-check never extends it. The cursor’s job is to track what’s been seen, not what triggered a response.
Normalized bot filter
Section titled “Normalized bot filter”Comments and reviews authored by automation are always dropped before
the new-entries computation, independent of the author-comment filter
below. The matcher is shape-normalized: it strips a trailing
[bot] suffix off the author login before comparing, so it catches
both the suffix-less login gh pr view --json (GraphQL) returns
(github-actions) and the suffixed login gh api (REST) returns
(github-actions[bot]). The built-in ignored set is github-actions
and cloudflare-workers-and-pages; a project extends it via
pr_check.ignored_authors in sdlc.yaml (entries may be written in
either shape — both match).
Historical note: before T-7AR0 the filter compared the suffixed
literal github-actions[bot] against the suffix-less GraphQL login,
so it never matched in production and bot chatter (github-actions and
cloudflare deploy-preview noise) flipped PRs to NEEDS-RESPONSE. The
normalized matcher closes that gap and is shared verbatim with the
orchestrate watch pending-work gate so both surfaces filter
identically.
Author-comment filter
Section titled “Author-comment filter”The script reads pr_check.author_comments from
<project-root>/sdlc.yaml. Accepted values: actionable (default)
and self-notes.
actionable(default) — keep PR-author entries; they count as actionable feedback.self-notes— exclude entries whoseauthor.loginmatches the PR’sauthor.login(carried on the top-levelauthorfield from the gh fetch).
Missing file, missing block, or missing key all default to
actionable. Any other value: the script logs a one-line warning to
stderr and falls back to actionable rather than erroring — a
misconfigured project should still get a verdict.
See ${CLAUDE_PLUGIN_ROOT}/conventions/sdlc-yaml.md for the canonical
documentation of the pr_check: block.
Self-posted comment filter
Section titled “Self-posted comment filter”Orchestrator-authored replies (posted from /sdlc:orchestrate’s
NEEDS-RESPONSE dispatch sub-agents) authenticate as the user’s GitHub
identity, so they are indistinguishable from human comments at the
JSON layer. The cursor’s self_posted_at: list[str] field — see the
schema above — filters them out: comment / review entries whose
createdAt (or submittedAt for reviews) matches any timestamp in
this list are dropped before the new-entries computation. A
pre-extension cursor without the field reads as [], so the filter is
safe over existing cursor files without a migration step.
Orchestrator sub-agents MUST post via the co-located wrapper, not the raw GitHub-CLI comment command — a raw post records no timestamp, so the next tick re-fires NEEDS-RESPONSE on the orchestrator’s own reply:
${CLAUDE_PLUGIN_ROOT}/skills/pr-check/post_self_comment.sh <pr-number> <body>The wrapper posts the comment via gh, captures the resulting
createdAt, and appends that timestamp to the PR’s cursor’s
self_posted_at array (creating the cursor file with a null
last_seen_comment_at sentinel if absent). It uses jq for the
cursor manipulation and exits non-zero on any failure (missing
deps, post failure, timestamp-capture failure, cursor write
failure) so the dispatching sub-agent can surface the error rather
than silently posting without recording.
5. Emit and exit
Section titled “5. Emit and exit”The script prints the single verdict line on stdout (no leading
whitespace, no trailing newline beyond the natural one). Exit 0 for
every defined verdict — including ERROR. Non-zero exit is reserved
for catastrophic failures (e.g. cursor write blocked by filesystem
permissions), in which case stderr describes the cause.
Mock mode
Section titled “Mock mode”--mock-state <path> swaps the live gh payload for a JSON fixture
so the classifier and cursor-update path can be exercised
deterministically. Use it under the eval harness, in CI tests, and
anywhere a real PR isn’t available.
behind is the one key with no gh counterpart: a live run measures
drift with git, and a mock run must not shell out, so the fixture
carries the count itself. Omitting it means “unknown”, which skips the
BEHIND rule.
Fixture schema
Section titled “Fixture schema”The fixture is a JSON object shaped like PrFacts
(lib/services/pr/forge/types.ts) — the normalized shape assemblePrFacts
produces from EITHER forge adapter, plus behind/ahead:
{ "number": 1, "url": "https://github.com/example/example/pull/1", "title": "example", "state": "open | closed | merged", "isDraft": false, "author": {"login": "user", "isBot": false}, "baseRef": "main", "headRef": "task/example", "headSha": "<sha | null>", "mergedAt": "<ISO 8601 UTC | null>", "mergeable": "mergeable | conflicting | unknown", "checks": { "rollup": "none | pending | success | failure", "failing": ["ci/build"], "failingUrls": [{"name": "ci/build", "url": "<log URL>"}], "blocked": ["<subset of failing that never actually started — a billing/spending-limit block, not a real failure>"] }, "reviewDecision": "approved | changes_requested | review_required | null", "activity": [ {"id": "...", "kind": "comment | review | review-comment", "author": {"login": "user", "isBot": false}, "at": "<ISO 8601 UTC | null>", "editedAt": "<ISO 8601 UTC | null>", "body": "...", "reviewState": "<review state | null>", "location": null} ], "behind": "<integer | null>", "ahead": "<integer | null>"}Missing arrays are treated as []; missing scalars as the
appropriate empty value. The fixture is fed to the classifier exactly
as a real forge call’s assembled PrFacts would be — there is no
separate mock-vs-real code path past this point.
Bundled fixtures
Section titled “Bundled fixtures”One fixture per documented verdict lives at
${CLAUDE_PLUGIN_ROOT}/skills/pr-check/mock-states/:
clean.json— open, mergeable,statusCheckRolluphas one passing check →CLEAN reason="checks passing; awaiting review".clean-no-checks.json— open, mergeable,statusCheckRollupis empty →CLEAN reason="no checks configured; awaiting review". Distinguishes “no CI configured” from “CI passed” without a new verdict token.needs-response.json—reviewDecision: CHANGES_REQUESTEDplus a third-party comment →NEEDS-RESPONSE.conflicts.json—mergeable: CONFLICTING→CONFLICTS.ci-failed.json— astatusCheckRollupentry withconclusion: FAILURE→CI-FAILED.ci-blocked.json— one failing check (ci/test), also listed inchecks.blocked(every failing check is a billing/spending-limit block, not a real failure) →CI-BLOCKED.merged.json—state: MERGEDwith amergedAttimestamp →MERGED.closed.json—state: CLOSEDwith no merge →CLOSED.behind.json—clean.jsonplusbehind: 12. Everyghfield says the PR is fine; only the local count says otherwise →BEHIND reason="12 commits behind main".
The eval harness asserts each fixture produces its expected verdict
via tests/classify_pr.test.ts, which exercises both
the in-process classifier and the CLI surface for every bundled
fixture.
Failure modes
Section titled “Failure modes”If gh is unavailable, the user is unauthenticated, or the PR doesn’t
exist, the skill emits ERROR reason="<reason>" and exits 0. The
orchestrator treats ERROR like CLEAN (no action) and surfaces the
PR for human attention in its digest.
If the cursor file is malformed JSON, treat it as missing (the worst
case is one spurious NEEDS-RESPONSE while it’s repaired). Log to
stderr and continue.
- Read-only on the repo and on GitHub except for
.sdlc/pr-cursors/writes. No commits to the repo, no GitHub state mutation. - One PR per invocation. The orchestrator iterates; this skill does not fan out.
- The cursor lives outside version control, so a fresh clone has no
history of “what I already saw”. On a new machine the first tick
surfaces every PR as
NEEDS-RESPONSEonce if it has any third-party comments; subsequent ticks settle. - Flaky CI is not distinguished from real failures — any failing
check name in
statusCheckRollupflips the verdict toCI-FAILED. Retrying flakes is the orchestrator’s call. - Both
MERGEDandCLOSEDflow to/sdlc:task-close-outin the orchestrator; this skill does not collapse them, so the close-out skill can record the resolution kind correctly. --mock-stateswaps theghfetch for a JSON fixture; absent the flag, the defaultgh-backed path runs. Mock mode is detectable from the(mock)suffix on the emitted reason string. See “Mock mode” above for the fixture schema and the bundled fixtures under${CLAUDE_PLUGIN_ROOT}/skills/pr-check/mock-states/.