| name | ship-it |
| description | Ship one verified PR on the configured target repo — the authorized merge step the rest of the pipeline defers to. Given a PR number, assert the matching gate has signalled PASS (review-code for code, review-doc for docs, review-skill for skills), confirm CI is already green plus the SHA-bound run-evidence bundle, then enqueue for a squash merge with `gh pr merge --auto` (no method flag — the queue owns the SQUASH method) — the merge queue owns the final, async merge, so success is "enqueued + green" (QUEUED → auto-merges on green) and the linked issue auto-closes async when the merge lands (ADR 0132) — then a bounded post-enqueue reconcile watches a batch window to catch a merge-queue ejection (a dropped PR — still open, no longer queued, not merged), routing an ejected PR back to repair/re-queue instead of reporting a silent false success. When the ship was a dark feature ship it surfaces a release queue for the humans (deploy is the agent's boundary, release is human; ADR 0083). For a control-plane PR (.claude/.github + the gate-critical skills) it is APPROVAL-AWARE (ADR 0135, amending 0053) — it enqueues the §CP PR only once a @kamp-us/control-plane team member has APPROVED it at the current head (all machine gates still green), else STOPS at "awaiting control-plane approval" — human judgment via the approval, pipeline mechanics via the enqueue. Every path that does NOT enqueue clears the `--auto` merge intent (`pipeline-cli merge-intent disarm`, ADR 0198) so a later bare approval can never enqueue ahead of these gates. Trigger on "ship |
ship-it
You are the merge actor — the one stage authorized to merge a PR and close the loop.
A gate (review-code for product code, review-doc for docs, review-skill for skills)
verified the PR against its issue's acceptance criteria (code/skills) or doc-quality bar
(docs) and signalled merge-ready, then stopped, because conflating
"verified" with "merged" is the self-grading collapse the gate exists to prevent. You are the
separate, deliberate act it defers to. See ADR 0048
for the why — note that gate is now one of three (review-code/review-doc/review-skill)
under ADRs 0053 and
0073, so 0048's prose, which
predates the split, only discusses review-code.
You ship exactly one PR per invocation. You do not sweep all open PRs — that fan-out
belongs to whatever loop drives the pipeline; keeping this stage atomic keeps it
composable and idempotent (re-running it on an already-merged PR is a clean no-op).
The control-plane boundary — what you may auto-merge
A PR is in one of two classes by the files it touches (ADR
0053, which supersedes
0049):
-
CONTROL PLANE — enqueue only on a control-plane-team approval. Any PR touching .claude/**,
.github/**, or one of the gate-critical skills is the agent control plane: agent
instructions/tools/hooks (.claude), CI enforcement (.github), and the verification/merge
machinery + marker contract (the gate-critical skills). A bad merge here is a serious security
concern — self-modification of the guardrails, or CI/secret exfiltration. Under ADR
0135
(amending 0053's merge model) the pipeline never self-merges a §CP PR on its own machine
gates alone — but it does enqueue one once a @kamp-us/control-plane team member has
APPROVED it at the current head. Human judgment enters via the approval; the pipeline owns the
mechanics. If the diff touches even one such file, you check for a current-head team approval
(see Step 0): present → enqueue like any PR; absent → STOP at awaiting control-plane approval, never enqueue.
The gate-critical skills are claude-plugins/kampus-pipeline/skills/ship-it/**, claude-plugins/kampus-pipeline/skills/review-code/**,
claude-plugins/kampus-pipeline/skills/review-doc/**, claude-plugins/kampus-pipeline/skills/review-skill/**, claude-plugins/kampus-pipeline/skills/review-plan/**, and
claude-plugins/kampus-pipeline/skills/gh-issue-intake-formats.md — the verification/merge gates plus the shared
marker-namespace/regex contract they all depend on. The single canonical definition of this
set lives in ../gh-issue-intake-formats.md §CP; cite it,
don't re-hard-code the path list (the three independent copies are exactly the #375 drift
class §CP closes — ADR 0073 §6). They are control plane regardless of directory, because
the one catastrophic case the AC-gates can't catch is a gate auto-merging a weakening of
itself; ADR
0065
makes exactly this subset blocking. This is a merge-authority concern only and is
independent of routing: every gate-critical skill is still verified — now by
review-skill (ADR 0073,
superseding 0063's review-code routing) — and a @kamp-us/control-plane team member reads that
verdict, then approves the PR, after which ship-it enqueues it (ADR 0135; the approval is
the human-judgment gate, the enqueue is the pipeline's). Every OTHER claude-plugins/kampus-pipeline/skills/** (triage, plan-epic, write-code, heal-ci, report, …) stays
non-blocking — review-skill-routed and auto-merged on a PASS, because those skills
neither merge nor verify, so a bad edit still has to clear the gate that does. ADR 0065's
blocking rule is unchanged by 0073: review-skill is the verdict gate; merge-authority
(blocking) is the separate axis 0065 owns, and 0065 stands verbatim until a later decision
retires it against review-skill's evidence (ADR 0073 §4).
-
NON-BLOCKING — autonomous. Everything else — apps/** (every app worker), packages/**,
.decisions/** (except a guard-touching ADR — see next paragraph), .patterns/**, and
other prose docs. These are product or knowledge
artifacts; they are gated for quality, but a human at the merge adds no security value, so
you ship them once the matching gate PASSes.
Note .decisions/** and .patterns/** are non-blocking under 0053 — they auto-merge
through review-doc (the boundary moved off "harness vs not" to "control plane vs not"). The
one exception (ADR 0164,
#2191): a guard-touching .decisions/** ADR is §CP. An ADR that relaxes/amends a documented
guard is control-plane by nature, so Step 0 classifies a .decisions/** file §CP by its
content (a conservative, fail-closed guard-vocabulary probe — not an author-declared tag) and
holds it for a founder/control-plane approval rather than auto-shipping it on a review-doc PASS.
All GitHub ops via gh api REST — never GraphQL
The kamp-us org runs a legacy Projects-classic integration that breaks GraphQL issue
and PR queries. Every read and write goes through gh api REST or the gh pr/gh run
porcelain. This is not a style preference — GraphQL calls error out on this org.
Resolve the target repo once, up front. This skill is repo-agnostic — every gh api
call targets $REPO, not a hardcoded repo. Resolve it at the top of your run per the shared
contract's Target repo resolution
(../gh-issue-intake-formats.md): $CLAUDE_PIPELINE_REPO
if set, else the current repository. In phoenix this defaults to kamp-us/phoenix, so the
behavior is unchanged with no config (ADR 0062 §1).
REPO="${CLAUDE_PIPELINE_REPO:-$(gh repo view --json nameWithOwner -q .nameWithOwner)}"
The hard guards
These are the rules that make shipping safe; violate any one and the gate above you was
pointless.
- Merge only on a PASS that is the current verdict. You merge on the latest verdict
being a PASS, never on the mere presence of a historical PASS nor the absence of a
failure — a newer FAIL vetoes an older PASS (Step 2 resolves latest-wins per gate
namespace). No PASS marker and no approving review → you stop and report the PR as
unverified. A red or pending check is not a "fail you can override" — it is a "not yet."
- Merge only on a commit-bound run-evidence bundle whose every check passed — when the
repo produces one. Beyond the marker, the run-evidence bundle (Step 3.5) is the SHA-bound
proof behind the green: a missing bundle, an unreadable schema, a
commit that isn't the
head SHA (stale), or any checks[] entry that isn't pass → you refuse. This is
additive to the PASS-marker and CI-green reads, not a replacement (ADR 0054 §3 / 0056).
In a foreign repo that ships no run-evidence producer, guard 2 is N/A and the gate falls
back to checks-green (Step 3) — a producer-presence degradation, not a per-PR override
(ADR 0086).
- You are the only skill that merges. If you find yourself wanting to merge a PR a gate
hasn't passed, the answer is to route it back through that gate (
review-code /
review-doc), not to merge it here.
- Read-only on git working state — the single canonical rule lives in
../gh-issue-intake-formats.md §RO; cite it, don't restate
the prohibition (the five verbatim copies were the #375-class drift §RO closes). You ship
entirely over gh api / gh pr merge (the merge happens server-side), so you have no
reason to touch the local working tree at all — read PR state read-only over gh api; you
never need a checkout to ship.
- An unresolved inline review thread (human or bot) blocks the merge — and the default is
route-back, never auto-dismiss. Before you enqueue, read the PR's unresolved inline threads
(Step 3.6): a substantive one refuses the ship like a FAIL (routed back to
write-code); a
genuine nit may be resolved only with an explicit written rationale; in doubt, treat
it as substantive and route back (ADR
0158).
A shipper that "resolves" a real objection just re-creates the throw-away one layer down —
never blanket-resolve threads to clear the gate.
- You never leave a merge intent armed.
gh pr merge --auto is a durable request: an arm
that outlives the run that made it enqueues the PR the moment the last requirement lands — on a
§CP PR, the instant a human approval arrives — with no ship-it run asserting guards 1–5 in
between. So an armed intent may exist only between a fully-gated Step-4 enqueue and the
queue accepting the PR; every other path clears it (see
the no-parked-merge-intent invariant below, ADR
0198).
The no-parked-merge-intent invariant — --auto never outlives its run (guard 6)
When --auto does not take effect at the head it was made against — a requirement unmet, or the
run interrupted mid-ship — GitHub keeps the request armed and fires it later, unattended. On
PR #3700 that armed leftover enqueued the PR one second after the approving review, before the
ship-it run that was supposed to gate the enqueue had started; the run then found it already queued to merge and merely confirmed the guards after the fact (#3723).
The approval requirement held throughout — the defect is purely one of ordering: the
assertions this skill makes at enqueue time can be skipped at the decisive instant. The bad case
the arm permits is concrete: a §CP PR enqueued → ejected → rebased → re-approved re-enqueues on
the re-approval alone, even with a missing run-evidence bundle at the new head.
The enqueue primitive is unchanged (gh pr merge --auto, Step 4). What is added is a lifecycle
rule with four mandated sites — run start, every stop/refusal, an ejection, and after the
bounded reconcile:
PCLI="${CLAUDE_PLUGIN_ROOT:-$(git rev-parse --show-toplevel 2>/dev/null)/claude-plugins/kampus-pipeline}/bin/pipeline-cli"
INTENT_UNCLEARED=0
disarm_intent() {
"$PCLI" merge-intent disarm --pr "$PR" --repo "$REPO" --site "$1" || {
echo "ship-it: FAILED to clear the merge intent on #$PR (site $1) — a later approval could enqueue it ungated (ADR 0198). Disable auto-merge by hand before this PR is approved again." >&2
return 1
}
}
Site 1 is Step 0's disarm_intent preflight || exit 1, and it is the one that catches the
interrupted run — #3700's actual mechanism, which by definition reaches no exit path of its own,
so no refuse site can ever fire for it. An intent armed by an earlier or interrupted run is
backed by no gate pass at this head. Abort the run if it cannot be cleared: a ship that cannot
establish the intent state cannot honor the invariant at any later site.
Site 2 is every path that stops or refuses, without exception — each runs disarm_intent refuse before it reports. A run that declines to enqueue must not leave behind the means to
enqueue without it. The refusal strings enumerated in Running it are that set; in
step order they are Step 0's awaiting control-plane approval, Step 1's draft /
closed (unmerged) / no linked issue, Step 2 guard 1's latest verdict is FAIL (<gate>), Step
2/2b's unverified …, Step 3's gating red, the CI-settle refusals, Step 3z's dropped-trigger
nudge, Step 3.5's run-evidence refusals, Step 3.6's substantive thread, and Step 3.7's leak. Read
the rule, not the list: a stop path this list forgot is still a Site-2 path, and Site 1 is the
backstop that clears whatever any exit missed on the next run. Sites 3 and 4 (ejected,
post-enqueue) live in Step 5.5, where the terminal state is known.
A failed disarm never rewrites a stop path's own control flow — each site records
INTENT_UNCLEARED=1 and continues to its existing disposition (so the durable PR-visible outcome
of #1928 and the heal-ci routing are untouched) — but it does change what the run reports:
an outcome line for a run with INTENT_UNCLEARED=1 must name it, because "ship-it declined" and
"ship-it declined and left the merge armed" are different facts.
The merge-ready signals
The pipeline runs three artifact-class gates (one per class), each landing its verdict as
a first-line marker comment — plus review-design, an additive UI-quality gate that a
UI-affecting PR requires alongside its class gate (a UI PR under apps/web/src is also code,
so it needs both review-code and review-design; review-design never replaces a class
gate, it layers on):
Every verdict is SHA-bound — its first line carries the head it reviewed (@ <sha>), and
you refuse any verdict not bound to the PR's current head (Step 2b, ADR
0058):
- product code (
apps/** — every app worker, not just apps/web — packages, other code) → review-code, whose marker is
review-code: PASS @ <sha> — merge-ready or review-code: FAIL @ <sha> — not merge-ready
(canonical shape: ../gh-issue-intake-formats.md §5).
review-code can also land a native approving review (event=APPROVE), whose
commit_id is its bound SHA.
- docs (
.decisions, .patterns, prose *.md outside .claude/.github, outside
claude-plugins/kampus-pipeline/skills/**, and outside the code roots apps/**/packages/** — a package/app-internal README
is review-code's scope, not this class; see Step 0) → review-doc, whose marker is
review-doc: PASS @ <sha> — merge-ready or
review-doc: FAIL @ <sha> — changes-requested (canonical shape: §6). review-doc is
comment-only — it never lands a native review (ADR 0058), so the doc lane is a single
comparable record type, not a review-vs-comment mix.
- skills (
claude-plugins/kampus-pipeline/skills/**) → review-skill, whose marker is
review-skill: PASS @ <sha> — merge-ready or review-skill: FAIL @ <sha> — changes-requested
(canonical shape: §6.5). review-skill is comment-only like review-doc (ADR 0058). This
supersedes ADR 0063's claude-plugins/kampus-pipeline/skills/** → review-code routing (ADR 0073 §4): a skill is a
behavioral artifact, gated by the gate built for it.
- UI-affecting (a changed path under
apps/web/src, a *.tsx file, or a style surface —
the same UI-affecting-PR detection the reviewer agent uses to dispatch review-design;
see the UI-affecting detection note
below) → review-design, whose marker is review-design: PASS @ <sha> — merge-ready or
review-design: FAIL @ <sha> — changes-requested. review-design is comment-only like
review-doc/review-skill (ADR 0058) — a single comparable record type, no native review.
Unlike the three class gates, review-design is additive, not a class: it is required
alongside the PR's artifact-class gate(s) whenever the diff is UI-affecting, never
instead of one (a UI PR under apps/web/src is has-code, so it needs review-code's PASS
and review-design's).
The marker-comment path is the default to expect: the single operator on this repo
(usirin) cannot post an approving review on their own PR under org branch rules, so on
the common path the gate falls back to a marker comment. You are the consumer the markers
were written for — without you, they are inert verdicts nobody acts on. Recognize a marker
tolerantly by shape (review-code: PASS @ <sha> … merge-ready, review-code: FAIL @ <sha>
… not merge-ready, review-doc: PASS @ <sha> … merge-ready, review-doc: FAIL @ <sha> …
changes-requested, review-skill: PASS @ <sha> … merge-ready, review-skill: FAIL @ <sha>
… changes-requested, review-design: PASS @ <sha> … merge-ready, review-design: FAIL @ <sha> … changes-requested), not by exact dashes — but the @ <sha> is required, and a
SHA-less legacy marker resolves to unverified, not PASS.
Each gate is stateless and re-runs, so a PR can flip PASS → (new commits) → FAIL or
FAIL → PASS, and (for code) the marker and the native-review forms interleave. So you never
act on the presence of a PASS; you act only on the latest verdict per gate. A FAIL
marker (or a CHANGES_REQUESTED review) that is the latest verdict for an artifact class
present in the diff is the mirror signal: the PR has unaddressed failures → you do not ship
it. The fix round-trip is write-code's (code) / the doc author's job, not yours.
Step 0 — Classify the diff against the control-plane boundary (guard 0)
Before anything else, read the PR's changed files and split them by class. This is one read:
PR=<pr number>
iso_preflight ship-it || exit 1
disarm_intent preflight || exit 1
gh api --paginate "repos/$REPO/pulls/$PR/files?per_page=100" --jq '.[].filename'
Classify each path. The control-plane / blocking set is defined once in
../gh-issue-intake-formats.md §CP — cite that regex, don't
re-hard-code the path list (the three independent copies are the #375 drift class §CP closes,
ADR 0073 §6). And resolve it from origin/main at run time, not from the copy embedded in
this skill body — that embedded copy travels in the injected snapshot, which can lag
origin/main even when the on-disk file is current, so a pre-amendment snapshot once
auto-merged a now-control-plane PR (#981). The bash below reads §CP freshly from origin/main
and fails closed (treats every path as control-plane → refuses) if that read can't be made:
- control plane (blocking): matches the §CP set —
.claude/**, .github/**, or a
gate-critical skill (claude-plugins/kampus-pipeline/skills/ship-it/**, claude-plugins/kampus-pipeline/skills/review-code/**,
claude-plugins/kampus-pipeline/skills/review-doc/**, claude-plugins/kampus-pipeline/skills/review-skill/**, claude-plugins/kampus-pipeline/skills/review-plan/**,
claude-plugins/kampus-pipeline/skills/gh-issue-intake-formats.md). A gate-critical skill is blocking for merge
authority (ship-it refuses → manual human merge, ADR
0065,
unchanged by 0073) AND is routed to review-skill for its verdict (ADR
0073, superseding 0063's
review-code routing) — the two axes are independent. The blocking refusal short-circuits in
the Routing step below, before the namespace check, so the review-skill routing stays
correct for the human-read verdict: gate-critical skills are skill-class for ROUTING,
blocking for MERGE. Every OTHER claude-plugins/kampus-pipeline/skills/** is non-blocking — skill-class for routing
and auto-merged on a review-skill PASS.
- skills: under
claude-plugins/kampus-pipeline/skills/** (the ^claude-plugins/kampus-pipeline/skills/ probe) → skill-class, requiring a
review-skill PASS. A skill is a behavioral artifact, gated by review-skill, not the code
AC-gate nor the doc hygiene-gate (ADR 0073 §4, superseding ADR
0063).
- code: under any app worker, a package, a standalone stack, or the glossary (
apps/**,
packages/**, infra/**, or .glossary/** — the ^(apps|packages|\.glossary|infra)/ probe,
covering every apps/<app> worker, not just apps/web, and every infra/** standalone stack
(ADR 0057)); a source path matching none
of the four probes still defaults to code, requiring a review-code PASS, so nothing under-gates.
The probe spans apps/** (not apps/web/**) so a future second worker like apps/<other>/** — code
or README — is review-code-gated like apps/web; infra/** is review-code-gated so a
package README under a standalone stack rides its code artifact (ADR 0057; #1987); and .glossary/**
is review-code-gated because Step 3c reads + enforces it (#912/#919); it agrees exactly with the
docs probe's apps/**/packages/**/infra/**/.glossary/** exclusion below (the two must name the
same code roots, or such a path would class as neither code nor docs and slip through ungated — #663).
- docs:
.decisions/**, .patterns/**, or a prose *.md outside .claude/.github,
outside claude-plugins/kampus-pipeline/skills/**, outside the code roots apps/**/packages/**/infra/**,
and outside .glossary/** — exactly
review-doc's verification scope. claude-plugins/kampus-pipeline/skills/** is the skill class, an *.md under
apps/**/packages/**/infra/** (a package/app-internal README, CHANGELOG, etc.) ships with its code
artifact and is review-code's scope, and .glossary/** is owned by review-code Step 3c —
so all four are carved out of docs before the .md$
match. The docs class is thus the surface a review-doc PASS can actually gate — see the
scope-consistency note after the routing.
if ! cp_changed_files "$REPO" "$PR"; then
echo "BLOCKING (changed-file list unreadable ⇒ §CP UNKNOWN, held — ADR 0092 §ZS, #4216)"
echo "STOP: classification unresolved — refuse to enqueue and report the failed read; do NOT read this as 'no gates required'."
exit 1
fi
FILES="$CP_FILES"
CONTROL_PLANE_RE='.'
CP_LIVE="$(gh api "repos/$REPO/contents/claude-plugins/kampus-pipeline/skills/gh-issue-intake-formats.md?ref=main" -H 'Accept: application/vnd.github.raw' 2>/dev/null | grep '^CONTROL_PLANE_RE=' | head -n1 || true)"
if [ -n "$CP_LIVE" ]; then
CONTROL_PLANE_RE="$(printf '%s' "$CP_LIVE" | sed "s/^CONTROL_PLANE_RE='//; s/'$//")"
else
CONTROL_PLANE_RE='.'
fi
echo "$FILES" | grep -Eq "$CONTROL_PLANE_RE" && echo "BLOCKING"
PCLI="${CLAUDE_PLUGIN_ROOT:-$(git rev-parse --show-toplevel 2>/dev/null)/claude-plugins/kampus-pipeline}/bin/pipeline-cli"
cp_head_sha "$REPO" "$PR"; HEAD_SHA="$CP_HEAD_SHA"
if [ -z "$HEAD_SHA" ]; then
echo "BLOCKING (head SHA unreadable ⇒ no ref to probe ADR content at ⇒ §CP UNKNOWN, held)"
else
echo "$FILES" | grep -E '^\.decisions/.*\.md$' | while IFS= read -r adr; do
[ -z "$adr" ] && continue
adr_body="$(gh api "repos/$REPO/contents/$adr?ref=$HEAD_SHA" -H 'Accept: application/vnd.github.raw' 2>/dev/null)" || adr_body=""
if [ -z "$adr_body" ]; then
echo "BLOCKING ($adr — ADR body unreadable ⇒ §CP UNKNOWN, held)"
else
GC_STATE="$(printf '%s' "$adr_body" | "$PCLI" guard-content-probe classify --path "$adr" 2>/dev/null)"
case "$GC_STATE" in
not-guard-touching) : ;;
guard-touching) echo "BLOCKING ($adr — guard-touching ADR ⇒ §CP, ADR 0164)" ;;
*) echo "BLOCKING ($adr — probe UNDETERMINED (state '$GC_STATE') ⇒ §CP, fail-closed)" ;;
esac
fi
done
fi
HAS_CODE_RE='^(apps|packages|\.glossary|infra)/'
HAS_SKILLS_RE='^claude-plugins/[^/]+/(skills|agents)/|^\.claude-plugin/'
HAS_DOCS_EXCLUDE_RE='^(claude-plugins|apps|packages|\.glossary|infra)/'
HAS_DOCS_RE='^(\.decisions|\.patterns)/|\.md$'
CLASS_RAW="$(gh api "repos/$REPO/contents/claude-plugins/kampus-pipeline/skills/gh-issue-intake-formats.md?ref=main" -H 'Accept: application/vnd.github.raw' 2>/dev/null || true)"
reresolve_re() { live="$(printf '%s\n' "$CLASS_RAW" | grep "^$1=" | head -n1 || true)"; if [ -n "$live" ]; then printf '%s' "$live" | sed "s/^$1='//; s/'\$//"; else printf '%s' "$2"; fi; }
HAS_CODE_RE="$(reresolve_re HAS_CODE_RE '.')"
HAS_SKILLS_RE="$(reresolve_re HAS_SKILLS_RE '.')"
HAS_DOCS_EXCLUDE_RE="$(reresolve_re HAS_DOCS_EXCLUDE_RE '\$^')"
HAS_DOCS_RE="$(reresolve_re HAS_DOCS_RE '.')"
echo "$FILES" | grep -Eq "$HAS_SKILLS_RE" && echo "has-skills"
echo "$FILES" | grep -Eq "$HAS_CODE_RE" && echo "has-code"
echo "$FILES" | grep -Ev "$HAS_DOCS_EXCLUDE_RE" | grep -Eq "$HAS_DOCS_RE" && echo "has-docs"
UI_RE='^apps/web/src/'
UI_EXCLUDE_RE='\.(test|spec)\.tsx?$'
UI_RAW="$(gh api "repos/$REPO/contents/claude-plugins/kampus-pipeline/skills/ship-it/SKILL.md?ref=main" -H 'Accept: application/vnd.github.raw' 2>/dev/null || true)"
UI_LIVE="$(printf '%s\n' "$UI_RAW" | grep '^UI_RE=' | head -n1 || true)"
UX_LIVE="$(printf '%s\n' "$UI_RAW" | grep '^UI_EXCLUDE_RE=' | head -n1 || true)"
if [ -n "$UI_LIVE" ]; then UI_RE="$(printf '%s' "$UI_LIVE" | sed "s/^UI_RE='//; s/'$//")"; else UI_RE='.'; fi
if [ -n "$UX_LIVE" ]; then UI_EXCLUDE_RE="$(printf '%s' "$UX_LIVE" | sed "s/^UI_EXCLUDE_RE='//; s/'$//")"; else UI_EXCLUDE_RE='$^'; fi
echo "$FILES" | grep -Ev "$UI_EXCLUDE_RE" | grep -Eq "$UI_RE" && echo "has-ui"
Routing:
-
If any file is control plane (the §CP set — .claude/**, .github/**, or a
gate-critical skill) OR any touched .decisions/** ADR matched the guard-touching content
probe above (a guard-relaxing/amending ADR is control-plane by nature — ADR
0164,
#2191; its review-doc verdict routing is unchanged, only merge-authority moves) → the PR is
§CP: APPROVAL-GATED (not a blanket refuse — ADR
0135,
amending 0053's merge model). Run the deterministic §CP cardinality check (the
§CP approval gate below, ADR 0175):
- discharge → the human-judgment gate is satisfied; carry on into Step 2's normal machine
gates (matching-gate SHA-bound PASS, CI green, run-evidence). Once those pass, ENQUEUE exactly
like a non-§CP PR (
gh pr merge --auto, no method flag — the queue owns the SQUASH method;
QUEUED → auto-merges on green; §CP PRs now
enter the ADR 0132 queue too). §CP carries one extra gate — the team approval — layered on
the same machine gates every PR clears.
- stop (the cardinality branch's required current-head signal is absent, or the team is
empty) → STOP. Run
disarm_intent refuse || INTENT_UNCLEARED=1 (guard 6 — this is the
stop the parked-intent defect fires on: the approval this PR is waiting for would otherwise
enqueue it the instant it lands), then report awaiting control-plane approval and stop; do
not enqueue. This
replaces the old blanket refuse.
This holds even if the rest of the diff is clean code/docs/skills — a mixed PR that touches the
control plane needs the team approval for the whole PR, and should be split so the non-§CP half
can flow without it. The §CP gate short-circuits before the namespace check below, so it never
conflicts with the fact that a gate-critical claude-plugins/kampus-pipeline/skills/** PR is still
review-skill-routed (ADR 0073): the routing decides which gate's verdict the human reads, the
§CP approval gate decides whether the enqueue is unblocked. A claude-plugins/kampus-pipeline/skills/**
PR that touches no gate-critical skill is not §CP — it flows through review-skill and
auto-merges on a PASS with no team approval.
The §CP approval gate — a deterministic team-cardinality check, resolved over gh api REST
(ADR 0175).
The discharge is a function of @kamp-us/control-plane team shape, never agent judgment — the
same §CP conditions produce the same verdict across agents (killing the #2435 non-determinism where
identical single-owner PRs merged in one run and were refused in another). The branch keys on N,
the count of present, active, human control-plane members, exactly as ADR 0175's case "$N"
reference specifies:
N == 0 (empty team) → STOP, fail closed — no accountable human to discharge the boundary.
N == 1, sole owner is the PR author → a current-head self-approval marker by the sole
owner discharges (the single-owner degenerate case; GitHub blocks native self-approval, so the
signal is a marker comment — ADR 0135/0175).
N == 1, sole member is not the author → that member's current-head approval discharges.
N >= 2 (ADR 0135's two-person control, unchanged) → a current-head APPROVED review by a
control-plane member who is NOT the author discharges; a self-approval never does.
Every discharge signal is bound to the PR's current head — a review's commit_id (the commit
it was submitted against, per the GitHub REST reviews resource)
or the self-approval marker's @ <sha> equals the PR head SHA. A stale signal on a superseded head
does not count — this retains ADR
0058's
SHA-staleness rule (and the dismiss_stale_reviews_on_push the Phase-3 ruleset sets). The branch
itself lives in the pure, unit-tested cp-cardinality core (packages/pipeline-cli) — the single
source ship-it runs, so the verdict cannot drift across shippers (the class-probe/control-plane-paths
precedent). Resolve the roster + the two signals over REST, never GraphQL, then decide:
PCLI="${CLAUDE_PLUGIN_ROOT:-$(git rev-parse --show-toplevel 2>/dev/null)/claude-plugins/kampus-pipeline}/bin/pipeline-cli"
ORG="${REPO%%/*}"
MEMBERS="$(gh api --paginate "orgs/$ORG/teams/control-plane/members?per_page=100" --jq '.[].login')"
AUTHOR="$(gh api "repos/$REPO/pulls/$PR" --jq '.user.login')"
HEAD="$(gh api "repos/$REPO/pulls/$PR" --jq '.head.sha')"
sha_binds_head() { [ -n "$1" ] || return 1; case "$HEAD" in "$1"*) return 0;; esac; case "$1" in "$HEAD"*) return 0;; esac; return 1; }
NON_AUTHOR_APPROVAL_AT_HEAD=false
CURRENT_APPROVERS="$(gh api --paginate "repos/$REPO/pulls/$PR/reviews?per_page=100" \
--jq "group_by(.user.login) | map(max_by(.submitted_at))
| map(select(.state == \"APPROVED\" and .commit_id == \"$HEAD\") | .user.login) | .[]")"
while IFS= read -r u; do
[ -z "$u" ] && continue
[ "$u" = "$AUTHOR" ] && continue
st="$(gh api "orgs/$ORG/teams/control-plane/memberships/$u" --jq '.state' 2>/dev/null)"
[ "$st" = "active" ] && { NON_AUTHOR_APPROVAL_AT_HEAD=true; break; }
done <<<"$CURRENT_APPROVERS"
SELF_APPROVAL_AT_HEAD=false
SELF_SHA="$(gh api --paginate "repos/$REPO/issues/$PR/comments?per_page=100" \
--jq "[.[] | select(.user.login == \"$AUTHOR\")
| select(.body | test(\"(?i)^\\\\s*\\\\**\\\\s*control-plane-self-approval\\\\b\"))]
| last | .body // \"\"" \
| grep -ioE 'control-plane-self-approval[[:space:]]*@?[[:space:]]*[0-9a-f]{7,40}' \
| grep -ioE '[0-9a-f]{7,40}' | head -n1)"
sha_binds_head "$SELF_SHA" && SELF_APPROVAL_AT_HEAD=true
[ -x "$PCLI" ] || { echo "ship-it: cp-cardinality is UNRESOLVED at '$PCLI' — the §CP discharge is UNKNOWN, NOT discharged. STOP." >&2; exit 1; }
if printf '%s\n' "$MEMBERS" | "$PCLI" cp-cardinality decide \
--author "$AUTHOR" \
$([ "$NON_AUTHOR_APPROVAL_AT_HEAD" = true ] && printf -- '--non-author-approval-at-head') \
$([ "$SELF_APPROVAL_AT_HEAD" = true ] && printf -- '--self-approval-at-head'); then
echo "§CP approval: discharged deterministically (ADR 0175) → carry on to machine gates"
else
echo "§CP approval: STOP (awaiting control-plane approval) — cardinality branch not satisfied (ADR 0175)"
fi
This is only the §CP unblock — it does not weaken any other guard. The SHA-bound gate verdict
(Step 2/2b), CI-green (Step 3), the run-evidence bundle (Step 3.5), and single-merge-authority
(ADR 0048) all still apply to a §CP PR exactly as to a non-§CP one; the cardinality discharge is an
additional requirement, never a substitute. The two-person control is preserved exactly where it
exists (N >= 2): GitHub blocks a member approving their own §CP PR, so a §CP change needs the
OTHER team member (ADR 0135). Adding a second control-plane member automatically re-tightens the
gate to that two-person control with no further edit — the branch keys on live cardinality (ADR 0175).
-
Otherwise, note which artifact classes are present (skills, code, docs, or a mix) and
whether the diff is UI-affecting (has-ui). Step 2 requires the matching gate's latest
verdict = PASS for each class present: skills → review-skill PASS; code → review-code
PASS; docs → review-doc PASS; a mixed PR needs a current-head PASS in each namespace
present. If the diff is UI-affecting (has-ui), a current-head review-design PASS is
required in addition — additive, alongside the class gate(s), never instead of one. Carry
the class set and the has-ui flag into Step 2.
The UI-affecting detection must AGREE with the reviewer, or the gate is unroutable. ship-it
requires a review-design PASS because the reviewer dispatched review-design on the same
diff, AND because the dispatched review-design run can actually reach a rendered surface to
verdict — so the UI_RE probe above (^apps/web/src/ — a changed path under the rendered
frontend) must be the same rule the reviewer agent uses to decide whether to run
review-design and the rule review-design's own Step 0 off-ramp uses to decide it has a
surface to gate. This is the same required-gate == dispatched-gate == satisfiable-gate
invariant that binds the has-code probe to review-code's scope: if ship-it required review-design
on a diff the reviewer never routed to it — or on one the dispatched review-design run then
off-ramps as non-UI without emitting a marker — that PR would demand a review-design: PASS no
gate ever produces → every such PR deadlocks (unverified — no review-design PASS). That
second gap is exactly #2470: the earlier UI_RE='^apps/web/src/|\.tsx$|\.css$' was a superset
of review-design's ^apps/web/src/ off-ramp, so a .tsx/.css outside apps/web/src was
required-but-unroutable — now the one live UI_RE is ^apps/web/src/ and all three sides
(require, dispatch, off-ramp) resolve it. Lockstep here is not two hand-synced copies that drift as
each side's checkout ages — that staleness was the enforcement hole (#2341: a shipper/reviewer on
a snapshot predating the review-design merge silently omitted the gate; PR #2333 merged
un-design-reviewed). Both sides instead resolve UI_RE from one live source — UI_RE= in
ship-it/SKILL.md on origin/main, read via ?ref=main at run time (the #981 idiom the §CP
CONTROL_PLANE_RE/GUARD_ADR_RE already use): ship-it re-resolves it in the probe above,
reviewer.md re-resolves the same line from the same ref before deciding to dispatch
review-design, and review-design's Step 0 off-ramp re-resolves the same line before
deciding it has a rendered surface to gate (#2470). All three fail closed to
require/dispatch/proceed on the gate if that line is unreadable — never to skip it. So they
agree by construction, not by manual sync: change the one live UI_RE (e.g. a new app worker →
apps/**/src) and every side tracks it on its next run.
The docs class must equal review-doc's verification scope, or the gate it demands is
unreachable. ship-it requires a class's gate PASS because that gate runs on that class —
so the docs probe may only class as docs a path a review-doc PASS can actually gate. The
.md$ match is therefore scoped, not over-matching: it runs only after grep -Ev '^(claude-plugins|apps|packages|\.glossary|infra)/' carves out the path-classes whose .md is not review-doc's
(this is the §DOC contract — cite it, don't re-derive the carve-out here):
claude-plugins/kampus-pipeline/skills/** — a skill .md is review-skill-gated (ADR 0073). Classing it docs would
demand a review-doc PASS that never comes (the original #358 deadlock, closed by the
dedicated gate).
apps/** / packages/** / infra/** — a package/app-internal *.md (a README, CHANGELOG) ships
with its code artifact and is review-code's scope: review-code reviews the whole
apps/**/packages/**/infra/** tree, README included, and review-doc explicitly disclaims that tree
(its Step 0 routes the apps/** workers — apps/web, … — packages/**, and the infra/**
standalone stacks (ADR 0057) to review-code). Classing such a .md docs demanded a review-doc PASS no gate ever produces —
review-code gates and PASSes the tree, but no doc gate runs on it — so a clean, fully-gated
product PR that merely includes a package README deadlocked (unverified — no review-doc PASS), the exact defect on PR #644 (#542/#650), reachable again for infra/** standalone stacks
(ADR 0057) until infra was added to the carve-out (#1987). Carving the code roots out makes the
present class always have a reachable gate.
.glossary/** — the domain-vocabulary surface (.glossary/TERMS.md) is gated by
review-code Step 3c, which reads + enforces the glossary contract (a new code surface MUST
touch TERMS.md — the #912 freshness gate). The gate that owns the glossary is therefore
review-code, so a .glossary/** touch rides the review-code PASS — the same precedent as
the apps/packages README. Were it left in the doc class, #912's mandatory .glossary/TERMS.md
touch on every new-surface code PR would make that PR mixed code+doc and demand a review-doc
PASS the pipeline never routes — the exact #919 deadlock (review-code PASSed, ship-it refused
unverified — no review-doc PASS). It is non-blocking — a knowledge surface like
.decisions/.patterns, not §CP — so a new-surface code PR still autoships with no
human-merge tax.
The has-code probe and this docs-exclusion name the same roots — they MUST agree. The
docs probe carves out ^(claude-plugins|apps|packages|\.glossary|infra)/ and the has-code probe is ^(apps|packages|\.glossary|infra)/:
both span the full apps/** tree (every app worker — apps/web, and any future app), not
just apps/web, and both name .glossary/** and infra/**. That agreement is the invariant — if the two
diverged (e.g. has-code stayed apps/web while docs excluded all apps/**, or the docs-exclusion
named infra/** while has-code did not), an apps/<other>/** or infra/<stack>/**
path — code .ts or README.md — would class as neither has-code (the narrow probe misses
it) nor has-docs (the docs exclusion drops it), and ship-it would demand no gate at all and
merge it ungated. Widening has-code to apps/** closes that hole for app workers (#663), and
adding infra/** in lockstep closes it for standalone stacks (ADR 0057; #1987): every apps/<app> and
infra/<stack> path now classes has-code and rides its review-code PASS, exactly as apps/web always has.
.glossary/** is carved out of the docs probe (it is review-code's scope, not review-doc's —
Step 3c reads + enforces it) and named by the has-code probe, in lockstep — so a .glossary/**
touch classes has-code and rides the review-code PASS, never falling into the #663 neither-class
hole. This holds for both shapes the glossary PR takes: the #912-mandated touch riding a new-surface
code change (already has-code from the code files), and a pathological glossary-only PR (now
has-code from .glossary/** alone) — both demand exactly the review-code PASS that the gate owning
the glossary produces (review-code's Step 0 verifies a glossary-only PR; it never off-ramps it). The
class label moves docs→code; no path goes ungated, and .glossary/** is never §CP/blocking, so a
new-surface code PR still autoships with no human-merge tax.
So .decisions/**/.patterns/** always class docs, and a prose *.md classes docs only when
it lives outside the code roots, claude-plugins/kampus-pipeline/skills/**, and the control plane — i.e. exactly the surface
review-doc verifies. This keeps the docs class and the doc gate consistent: a present docs class
implies a review-doc PASS is obtainable, never a phantom requirement. The control-plane check
remains the only exact probe and is unchanged; this carve-out narrows only the docs class,
weakening no other guard — the §CP approval gate, SHA-binding, and the green-CI requirement all
still hold, and a packages/**-internal .md simply rides the review-code PASS its tree already
needs.
Step 1 — Resolve the PR and its linked issue
gh api repos/$REPO/pulls/$PR \
--jq '{number, state, draft, merged, mergeable, head: .head.ref, base: .base.ref, body}'
If the PR is already merged → nothing to do, report it shipped and stop (idempotent).
If it's draft or state=closed (unmerged) → run disarm_intent refuse || INTENT_UNCLEARED=1
(guard 6 — a Site-2 stop like any other), then stop and report why.
Find the linked issue from the PR body's Fixes #N / Closes #N (the seam write-code
writes and review-code relies on) and pin it as a shell var Step 5 reads back:
ISSUE=<N>
If there is a linked issue (a closing keyword + #N), honor it as today regardless of
class — resolve it; the queue's async squash-merge (Step 4's --auto enqueue) auto-closes it
via Fixes #<ISSUE> when the merge lands (ADR 0132).
Intentional partial-split — an explicit non-closing Part of #N. A closing keyword is not
the only legitimate way a code/skills PR references its issue. When the body carries no
closing keyword but does carry an explicit non-closing Part of #N reference
(case-insensitive, naming a real open issue number), this is an intentional non-closing
state, the opposite of a forgotten seam: a backend-then-frontend partial split
deliberately advances one half while a sibling lane finishes the other, so the linked issue is
kept open on purpose until that sibling closes it. Recognize it as a valid linked
reference that merges without auto-closing: pin it for the report and leave ISSUE
unset, so Step 4's squash neither expects nor performs an auto-close and Step 5's
explicit-close fallback never fires — #N stays open for the sibling lane.
PART_OF=<N>
Do not refuse this as no linked issue: every actual merge-safety guard is unaffected
(Step 0's control-plane class, Step 2/2b's current-head PASS, Step 3's green CI, Step 3.5's
run-evidence all still hold) — only the missing-closing-keyword check is relaxed, and only
for this one explicit marker. This is a parallel allowance to the doc/vocab-surface-only
carve-out below
(ADR 0075),
not the same one: the doc/vocab-surface-only path is issueless (nothing to close); a partial
split names an issue it intentionally keeps open. The Part of #N marker is a non-closing mention by construction —
GitHub never populates closingIssuesReferences from it (only a closing keyword does;
gh-issue-intake-formats.md §9) — which is exactly why the merge
leaves #N open.
If there is no linked issue, the rule is class-aware — reuse the artifact classes
Step 0 already computed (do not re-derive them; ADR
0075):
The carve-out turns on doc/vocab-surface-only, a wider set than Step 0's docs class.
The doc/vocab surfaces are .decisions/**, .patterns/**, .glossary/**, and prose
*.md — all of which legitimately have no tracked issue. .glossary/** is a doc/vocab
surface here even though Step 0 classes it has-code (the #919 reclassification: the glossary
is owned by review-code Step 3c, not review-doc). That has-code label is about which gate
verifies the glossary, not about whether a glossary touch needs a Fixes #N — so the issueless
allowance keys on the surface, not the gate class. A PR is doc/vocab-surface-only when
every changed path is one of those four surfaces (no apps/**/packages/**/infra/** code,
no claude-plugins/** skills source).
- A real code or skills class is present — a changed path under
apps/**, packages/**,
infra/**, or claude-plugins/** (skills source), i.e. the PR is not
doc/vocab-surface-only — with no issue reference at all — neither a closing keyword nor
the explicit Part of #N partial-split marker above → run disarm_intent refuse || INTENT_UNCLEARED=1 (guard 6), then stop and report no linked issue. In
this pipeline write-code always writes Fixes #N (or, for a deliberate partial split,
Part of #N), so a code PR that names no issue at all is a broken seam, not a normal
state — it has nothing to auto-close on merge and would leave dangling work. (Distinct from the
linked-but-didn't-auto-close case Step 5 handles: there the seam fired but GitHub didn't,
which is recoverable; here no issue is named on a code PR, an anomaly worth stopping on. Also
distinct from the partial-split above, where Part of #N names the issue on purpose to
keep it open — that merges, this refuses.)
- Doc/vocab-surface-only (every changed path is
.decisions/**, .patterns/**,
.glossary/**, or prose *.md — no apps/**/packages/**/infra/** code and no
claude-plugins/** skills source) → a missing Fixes #N is a legitimate state, not a broken
seam. A conversation-authored ADR/doc records a settled choice that was never tracked work, so
there is nothing for a Fixes #N to close (ADR 0075). The canonical shape is a
conversation-authored ADR that co-locates its own .glossary/** term rename in the same PR
(the adr skill's vocabulary-impact step directs this) — a PR touching .decisions/** and
.glossary/** is doc/vocab-surface-only and ships issueless, even though .glossary/** makes
it has-code per Step 0/#919. Skip the auto-close expectation, leave ISSUE unset, and
proceed to the gate check — the PR ships on its gate PASS(es) alone (Step 2). Emit no
no linked issue refusal; it is not an anomaly. This relaxes only the missing-link guard:
Step 0's §CP approval gate and Step 2's required current-head PASS in each class present
are untouched — the glossary-riding class still requires its review-code: PASS and
.decisions/**/.patterns/**/prose its review-doc: PASS.
Step 2 — Resolve the latest current-head verdict per gate namespace, then branch on polarity (guard 1)
Step 2 gate — verdict gate is the enqueue precondition: run it FIRST, refuse on non-zero
Run this before any other Step-2 read, and treat a non-zero exit as a hard stop. It resolves,
in one fail-closed decision, the thing the rest of Step 2 describes: does every namespace this
PR's diff requires carry a verdict that was affirmatively read, bound to the PR's live head, and a
pass? A green exit is the only sanctioned path past guard 1 — no bypass, and no "I read the
namespaces and they looked fine."
The required set is derived, never eyeballed — the same class-probe output the reviewer fan
dispatches off, so dispatched-gate == required-gate holds by construction:
PCLI="${CLAUDE_PLUGIN_ROOT:-$(git rev-parse --show-toplevel 2>/dev/null)/claude-plugins/kampus-pipeline}/bin/pipeline-cli"
REQUIRED_NS="$(gh api --paginate "repos/$REPO/pulls/$PR/files" --jq '.[].filename' \
| "$PCLI" class-probe classify --namespaces)"; PROBE_RC=$?
if [ "$PROBE_RC" -ne 0 ]; then
disarm_intent refuse || INTENT_UNCLEARED=1
echo "ship-it: refused at guard 1 — class-probe REFUSED (exit $PROBE_RC); the required-namespace set is UNKNOWN, not empty. NOT enqueued." >&2; exit 1
fi
REQUIRED="$(printf '%s\n' "$REQUIRED_NS" | grep '[^[:space:]]' | paste -sd, - || true)"
if [ -z "$REQUIRED" ]; then
disarm_intent refuse || INTENT_UNCLEARED=1
echo "ship-it: refused at guard 1 (zero scope) — class-probe exited 0 but named ZERO namespaces; a zero-length required set would make the per-namespace conjunction vacuously true (ADR 0092). NOT enqueued." >&2; exit 1
fi
echo "ship-it guard 1: PR $PR requires $(printf '%s\n' "$REQUIRED_NS" | grep -c '[^[:space:]]' || true) namespace(s) → $REQUIRED"
CP_FLAG=""
"$PCLI" verdict gate --pr "$PR" --require "$REQUIRED" $CP_FLAG || {
disarm_intent refuse || INTENT_UNCLEARED=1
echo "ship-it: refused at guard 1 — see the named reason above; NOT enqueued."; exit 1; }
Why a verb and not a careful read. The rule below was already correct in prose ("docs present
but the review-doc namespace is empty → unverified (no review-doc PASS)"), but nothing computed
it: the shipper resolved each namespace with a separate verdict read and then decided by
eyeball whether the union covered every required namespace. That leaves the absence branch to
an agent's attention, and PR #3944 enqueued with no verdict bound to its live head at all — the
one FAIL on it was bound to a superseded head, so at enqueue time nothing attested the tree being
merged (#3982). "No verdict found" must be a refusal, not a pass-through, and absence is a
property of the required set — so it can only be decided where the set is known, which is
exactly what a per-namespace read cannot do. The verb makes the invalid state unrepresentable;
decideGate's unit tests are the contract, including the #3944 stale-head reproduction.
The verb refuses, with the named reason, on every non-pass state: a namespace with no verdict at
all, a verdict bound to a stale head, a SHA-less pre-0058 marker, a §CP advisory whose
Reviewed-head is stale or whose body carries a [FAIL] checkbox, a current-head FAIL, an empty
required set (zero required gates would make the conjunction vacuously true — an un-gated merge
dressed as a pass, ADR 0092), and an unresolvable head.
--cp is load-bearing in both directions. A §CP PR's only verdict is the SHA-less advisory, so
verdict read --gate code legitimately resolves _tag: none on it — that none is the expected
§CP shape, not an absent verdict, and a §CP PR must never be required to carry (nor be satisfied
by) a bindable first-line PASS @ <sha> (that drops the §CP verdict into the auto-merge namespace,
the ADR 0111 hazard). Pass --cp only for a PR Step 0 classified §CP whose control-plane
approval gate already passed; without it the advisory is correctly not a pass.
The one signal the verb does not read — apply it on top. verdict gate reads marker/advisory
comments, not GitHub's native reviews. So a green verdict gate is necessary, not sufficient:
the native-review fold below still applies to the review-code namespace, and a newer
CHANGES_REQUESTED review than the marker still flips that namespace to FAIL and refuses. Run the
verb first, then the fold; never let a green verb suppress a newer decisive review.
The rest of Step 2 is the explanation of what that verb decides, plus the native-review fold it
cannot see. Read it to understand a refusal — do not re-implement the conjunction by hand.
You do not ship on the presence of any PASS that ever existed. Each gate is stateless and
re-runs, so a PR can go PASS → FAIL or FAIL → PASS. Resolve review-code, review-doc, and
review-skill in separate namespaces — three anchored regexes that never cross-match — and
require a latest PASS in each namespace whose artifact class is present (from Step 0). A
scan in one namespace must never match another's marker.
The three anchors (case-insensitive, anchored at the start of the comment body so a comment
that merely quotes a marker mid-body doesn't match, emphasis-tolerant — the leading
\** absorbs an optional bolding **, since review-code emits its marker bolded — and
SHA-capturing — the trailing @\s*([0-9a-f]{7,40}) captures the bound head SHA so Step 2b
can apply the staleness refusal; see the matcher contract in
gh-issue-intake-formats.md §5/§6/§6.5 and ADR
0058):
- code:
^\s*\**\s*review-code:\s*(PASS|FAIL)\s*@\s*([0-9a-f]{7,40})
- doc:
^\s*\**\s*review-doc:\s*(PASS|FAIL)\s*@\s*([0-9a-f]{7,40})
- skill:
^\s*\**\s*review-skill:\s*(PASS|FAIL)\s*@\s*([0-9a-f]{7,40})
- design:
^\s*\**\s*review-design:\s*(PASS|FAIL)\s*@\s*([0-9a-f]{7,40})
A marker matching the looser …:\s*(PASS|FAIL) prefix but not the @ <sha> tail is a
pre-0058 legacy verdict → Step 2b resolves it to unverified (verdict not bound to current head), never a PASS.
A marker comment counts as a verdict only if its author holds write-or-higher permission
on the repo — authorization is resolved from GitHub's ACL at merge time, not from a list
in this file, so a forged review-code: PASS / review-doc: PASS from any commenter without
repo write (the write-code agent, a stranger) is invisible to the resolution, treated
exactly as ordinary PR chatter, never a verdict and never a FAIL (ADR
0055, superseding 0051). GitHub's
repo-collaborator permission is the single source of truth for whose PASS counts — a PR
author cannot widen it via a file in their own diff. The solo operator usirin (who can't
APPROVE their own PR under org branch rules, so their marker is the load-bearing default —
ADR 0048) holds admin and passes; any future operator or review-bot earns standing by being
a write+ collaborator, with no edit to this skill.
For the marker namespaces that author-gate is applied inside pipeline-cli verdict read
(Step 2) — it is the verb's own ADR-0055 trust root, not re-derived here. The set below is still
resolved explicitly because the §CP advisory resolution (Step 2.§CP) needs it: an advisory is
SHA-less in its first line by design (ADR 0111), so verdict read resolves that namespace to
none and cannot author-gate it — the advisory's own latest-wins/author-gated pick is ship-it's.
Resolve the authorized-author set from the ACL — every distinct marker author whose repo
permission is write / maintain / admin. This fails closed: a lookup error or a
read/triage author never enters the set, so their marker is ignored exactly as an
off-list author was under 0051. When no author clears the bar, authorized stays []
and no advisory resolves — unverified → refuse — so the empty set is the safe terminal
state, not an open door (the same fail-closed terminal verdict read applies to markers).
comments_file=$(mktemp)
gh api "repos/$REPO/issues/$PR/comments?per_page=100" > "$comments_file"
markerAuthors=$(jq -r '[.[]
| select(.body | test("^\\s*\\**\\s*review-(code|doc|skill|design):\\s*(PASS|FAIL)"; "i"))
| .user.login] | unique | .[]' "$comments_file")
authorized='[]'
while IFS= read -r a; do
[ -z "$a" ] && continue
perm=$(gh api "repos/$REPO/collaborators/$a/permission" --jq .permission 2>/dev/null)
case "$perm" in
admin|maintain|write) authorized=$(jq -c --arg a "$a" '. + [$a]' <<<"$authorized") ;;
esac
done <<<"$markerAuthors"
Resolve the in-force verdict per namespace through pipeline-cli verdict read: the verb folds
the ADR-0055 write+ author-gate (a forged newer marker from an unauthorized author can't shadow a real
verdict), the in-force pick, and the ADR-0058 SHA-staleness refusal (Step 2b) into one exit code — its
unit tests are the contract (#2102), the same resolution write-code reads.
The in-force rule, stated once and used by every namespace below: filter candidates to the ones
bound to the LIVE head first, then take the most recently WRITTEN of those. Two things follow from
verdict post upserting a verdict in place (ADR 0213/#4016/#4050), and created_at — when the comment
SLOT was opened, not when the verdict it now carries was written — gets both wrong:
- Never "the newest comment in the namespace, then check its head": a stale-but-freshly-created verdict
then outranks an at-head one that was rewritten in place. That is a false refusal of a green PR,
and it cost PR #3955 an hour (#4189).
- Never order two LIVE-HEAD verdicts by
created_at either: an in-place correction keeps its slot's
creation time, so it loses to a sibling run's merely-newer comment. Here the head filter admits both
candidates and there is no staleness test left to catch the mis-pick, so it also runs fail-open —
a FAIL upserted after a PASS is silently cleared (#4200). The ordering key is the Verdict-written:
stamp verdict post writes into every body (floored at created_at, so an unstamped legacy comment
is unaffected); verdict read reports it as writtenAt, which is the ONLY timestamp a fold below
may compare against.
The same trap catches a human skimming the PR, since the GitHub UI orders by creation time too — read
the @ <sha> / Reviewed-head: binding and the Verdict-written: stamp, never the position.
The native decisive review folds into the code namespace separately (the verb reads marker comments,
not reviews); Step 2b keeps is_current for it and for the §CP advisory body-SHA:
CURRENT_HEAD="$(gh api repos/$REPO/pulls/$PR --jq .head.sha)"
REVIEW=$(gh api "repos/$REPO/pulls/$PR/reviews?per_page=100" \
--jq '[.[] | select(.state=="APPROVED" or .state=="CHANGES_REQUESTED")]
| sort_by(.submitted_at) | last | {state, sha: .commit_id, at: .submitted_at}')
VERDICT="${CLAUDE_PLUGIN_ROOT:-claude-plugins/kampus-pipeline}/bin/pipeline-cli verdict"
CODE_JSON="$($VERDICT read --pr "$PR" --gate code --expect PASS 2>/dev/null)" && CODE_PASS=1 || CODE_PASS=0
$VERDICT read --pr "$PR" --gate code --expect FAIL >/dev/null 2>&1 && CODE_FAIL=1 || CODE_FAIL=0
$VERDICT read --pr "$PR" --gate doc --expect PASS >/dev/null 2>&1 && DOC_PASS=1 || DOC_PASS=0
$VERDICT read --pr "$PR" --gate doc --expect FAIL >/dev/null 2>&1 && DOC_FAIL=1 || DOC_FAIL=0
$VERDICT read --pr "$PR" --gate skill --expect PASS >/dev/null 2>&1 && SKILL_PASS=1 || SKILL_PASS=0
$VERDICT read --pr "$PR" --gate skill --expect FAIL >/dev/null 2>&1 && SKILL_FAIL=1 || SKILL_FAIL=0
$VERDICT read --pr "$PR" --gate design --expect PASS >/dev/null 2>&1 && DESIGN_PASS=1 || DESIGN_PASS=0
$VERDICT read --pr "$PR" --gate design --expect FAIL >/dev/null 2>&1 && DESIGN_FAIL=1 || DESIGN_FAIL=0
RSTATE=$(jq -r '.state // ""' <<<"$REVIEW"); RSHA=$(jq -r '.sha // empty' <<<"$REVIEW")
RAT=$(jq -r '.at // ""' <<<"$REVIEW")
if [ -n "$RSHA" ]; then case "$CURRENT_HEAD" in "$RSHA"*)
MARKER_AT=""
[ "$((CODE_PASS + CODE_FAIL))" -gt 0 ] &&
MARKER_AT=$(jq -r '.writtenAt // empty' <<<"$CODE_JSON" 2>/dev/null)
if [ -z "$MARKER_AT" ] || [ "$RAT" \> "$MARKER_AT" ]; then
case "$RSTATE" in
CHANGES_REQUESTED) CODE_FAIL=1; CODE_PASS=0 ;;
APPROVED) CODE_PASS=1; CODE_FAIL=0 ;;
esac
fi
;; esac; fi
Now resolve per namespace (the marker verdict from the verb above, the native review + §CP
advisory folded in):
- review-code namespace — the verdict is the newest of {latest decisive review, in-force
review-code marker comment}, compared by WRITE time (review
submitted_at vs the verdict's
writtenAt) only once both are current-head-bound — the head-first rule above, which is
what the case "$CURRENT_HEAD" in "$RSHA"* guard and the _tag == "current" marker test encode.
An APPROVED review or a review-code: PASS … merge-ready marker is PASS; a
CHANGES_REQUESTED review or a review-code: FAIL marker is FAIL. The verdict's bound SHA
is the marker's @ <sha> (or, for a native review, its commit_id). (The native
approving-review path stays; it interleaves only with the review-code markers, never with