/sdlc:pr-check
Generated from solutions/ontological/skills/pr-check/SKILL.md.
Description
Section titled “Description”Classify the state of a single open or closed pull request into one of
a fixed verdict enum — CLEAN, NEEDS-RESPONSE, CONFLICTS, CI-FAILED,
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.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).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 must mirror the shape of the gh pr view --json
selection above — same keys, same value types — so the classifier
(Section 3) is fed an indistinguishable payload. 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→ emitCI-FAILED reason="<failing check name>". If multiple checks failed, the first failure in the array wins. -
reviewDecision == "CHANGES_REQUESTED"OR new comments since the cursor (see Section 4) → emitNEEDS-RESPONSE reason="<who, comment count>". -
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.
Fixture schema
Section titled “Fixture schema”The fixture is a JSON object whose keys match the
gh pr view --json selection from Step 2:
{ "state": "OPEN | CLOSED | MERGED", "mergeable": "MERGEABLE | CONFLICTING | UNKNOWN", "mergeStateStatus": "CLEAN | DIRTY | BLOCKED | UNSTABLE | BEHIND | HAS_HOOKS | UNKNOWN", "reviewDecision": "APPROVED | CHANGES_REQUESTED | REVIEW_REQUIRED | \"\"", "headRefName": "task/example", "baseRefName": "main", "statusCheckRollup": [ {"name": "ci/build", "conclusion": "SUCCESS | FAILURE | TIMED_OUT | CANCELLED | STARTUP_FAILURE | NEUTRAL | SKIPPED"} ], "mergedAt": "<ISO 8601 UTC | null>", "comments": [ {"author": {"login": "user"}, "createdAt": "<ISO 8601 UTC>", "body": "..."} ], "reviews": [ {"author": {"login": "user"}, "submittedAt": "<ISO 8601 UTC>", "state": "COMMENTED | APPROVED | CHANGES_REQUESTED"} ]}Missing arrays are treated as []; missing scalars as the
appropriate empty value. The fixture intentionally mirrors the live
gh shape so real and mock payloads are interchangeable.
Bundled fixtures
Section titled “Bundled fixtures”One fixture per documented verdict lives at
${CLAUDE_PLUGIN_ROOT}skills/pr-check/fixtures/:
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.merged.json—state: MERGEDwith amergedAttimestamp →MERGED.closed.json—state: CLOSEDwith no merge →CLOSED.
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/fixtures/.