gatekeeper-loop · git:20261026.c6cc7df · 2026-10-26 · sha256 dfe84b95d8fc7dbe
gatekeeper-loop git:20261026.c6cc7dfA
Immutable. This exact content never changes and is served at /api/v1/blob/dfe84b95d8fc7dbe.
---
name: gatekeeper-loop
description: Infinite-loop single gatekeeper agent that owns main and services the complete independently accepted receipt population ready-first through `landing_merge_verdict.py --service-ready`. Valid unchanged receipts are reused; only named invalidated inputs or dependencies receive bounded revalidation when no accepted candidate is deliverable. Genuine holds, dependencies, shared conflicts, and failed gates remain blocking. Only the READY-bound sole final writer under the short `gatekeeper-final.lock` may squash-merge after exact remote id, head, and base binding. Machine-red or a reproducible HIGH requests changes; never force, bypass, or treat mergeability as acceptance. Healthy idle is not termination.
---
<!-- WAVE_76_CHIP_AGNOSTIC_BANNER -->
> **Case-study notation.** This skill uses `PR-A` / `branch-A` as the
> canonical example PR — substitute the real PR number and branch. The
> rules themselves are chip-AGNOSTIC and apply to any PR against `main`,
> independent of which IC / vendor / SKU the PR touches. When you adopt
> this skill, swap `PR-A` → `#<your PR number>` and `branch-A` →
> `<your head ref>`; the structural gates and the merge-queue protocol
> do not depend on those values.
# Gatekeeper Loop — Single-Owner PR-Merge Loop for `main`
## User-facing step numbering (owner directive, 2026-10-23)
All progress reports to the user MUST use the new step ID: the 1-based
position of the row in the current canonical `flow/phase1_phase2_phase3.yaml`
`steps` list, consistent with `flow-order.json`'s `position` for that source.
Read the mapping from the source used by the run; never infer order from the
legacy row `id`. Do not renumber after filtering out inapplicable rows.
Report `Step <new ID> — <step name>: <measured status>`. Legacy IDs belong only
in internal CLI arguments, receipts, paths and historical cross-references;
do not use them as progress step numbers in replies, summaries or dashboards.
Only explain an explicitly labeled old-to-new mapping when the user asks for it.
This is a reporting rule: preserve executable IDs, historical outputs and
checkpoint identity. Changing the displayed number does not trigger a rerun.
If the mapping is unavailable, report the step name and say numbering is not
yet verified rather than guessing. Applicability/PASS counts are separate
from the new step ID; never use a position as a completed-step count.
## Purpose
> **DOCTRINE NOTE (2026-06-26 owner directive — STANDING preference; supersedes
> the 2026-06-17 PR-method).** The maintainer now ships its OWN fixes by **direct
> push** to `main` (direct commit + `git push origin main`, NO `gh pr create` —
> see `vibe-ic:core-agent-loop` §Step 3). The gatekeeper's **gate SEQUENCE is
> unchanged and still authoritative** — it is just applied PRE-PUSH on the main
> checkout (drive `gatekeeper_review.py` to MERGE_OK → Step-2.7 on any
> guard/transform diff → `gatekeeper_assign_version.py --write` → `git push`)
> instead of on a PR branch. The **PR-merge-queue machinery in this skill is
> RETAINED** for any PR filed from ELSEWHERE (an external contributor, or a legacy
> in-flight branch): such a PR is still gated + version-assigned + squash-merged
> exactly as below. Doctrine history (so the flip-flop is legible): direct-push
> through v1.1.5 → PR-method 2026-06-17 → direct-push again 2026-06-26; the gates
> are retained in every era, only the landing ceremony changed.
> **Two-layer framing.** The maintainer's own direct-push fixes are **Layer 2**
> (the internal improvement-phase shortcut). The PR-merge-queue machinery in this
> skill is **Layer 1** — the public contribution model: an external contributor
> files a **backlog** (a report) or a **PR** (a fix), and you are the sole merger
> of those PRs. Both layers must carry accepted evidence from the identical gate
> sequence; an unchanged accepted receipt is not rerun. Only Layer-1 adds the PR
> ceremony. External contributors never push to `main`.
The gatekeeper is the **review-gate-and-merge** half of the Vibe-IC
contribution model. Where `core-agent-loop` is an issue-**fix** loop
(poll open issues → ship a fix → close), the gatekeeper is a PR-**merge**
loop (poll open PRs → gate them → squash-merge the green ones) for any
EXTERNALLY-filed PR, AND the pre-push gate authority for the maintainer's
own direct-push fixes. It is the **single agent that owns `main`** and the
**sole party allowed to merge** an external PR.
Centralising the merge authority in one looped identity is what makes the
contribution model trustworthy: every PR — including one the gatekeeper
authored itself — must produce the SAME accepted-receipt evidence from the
machine gates and the one agent gate (Step-2.7). Completed receipts are served
ready-first; only named invalidated evidence is boundedly revalidated, and only
the READY-bound sole writer may land the selected id/head/base. The gate is the
quality guarantee, not a separation of author from approver.
The loop is **infinite** and **stateless across ticks** (all state lives
in git + GitHub — the PR's open/merged/closed state, its review state,
its required-check statuses — plus the root-owned receipt manifest and the
short `gatekeeper-final.lock`). Every cron wake-up is a fresh, independent tick:
1. **poll and classify** the complete open, non-draft population against `main`
(`poll_prs.py`) together with root-owned independently accepted receipts.
2. **first invoke `--service-ready`** over the complete accepted population.
A deliverable READY candidate reaches the bound sole writer immediately;
genuine holds, dependencies, shared conflicts, or failed gates block only
their own candidate, not an unrelated READY candidate.
3. Only `BLOCKED_NO_ACCEPTED_CANDIDATE`, meaning there is **no immediately
deliverable READY candidate**, permits bounded preparation of one candidate
without accepted evidence: run its **machine gates** and then the one agent
gate. A red or reproducible HIGH requests changes and ends that preparation.
4. A green preparation publishes its accepted receipt bytes and independently
supplied outer SHA256 pin, then returns to the canonical `--service-ready`
consumer; it never performs an inline final write.
The loop is **chip-AGNOSTIC**: every gate and queue rule reads generic PR
metadata + program verdicts; nothing references `PR-A`, any IC name,
`Vendor`, or any SKU as control logic.
## The machine-vs-agent-judgment split (the central doctrine)
This is the load-bearing idea, and it mirrors the rest of the plugin's
"program-first, agent-only-on-failure" doctrine:
| Layer | What it is | How it runs | Who/what decides |
|-------|------------|-------------|------------------|
| **Machine gates** | The **required status checks** — the full deterministic gate set (`gatekeeper_review.py`: the plugin test suite the CI way, `source_chip_agnostic_check.py`, `marketplace_version_sync_check.py`, `version_bump_monotonic_check.py`, `git_prohibition_guard.py`, `full_suite_run_check.py`, the per-step compliance checkers, …) | DETERMINISTICALLY, cadence-aware (see §Cadence) | a PROGRAM exit code — no LLM |
| **The one agent gate** | **Step-2.7 adversarial review** — the *irreducible* judgement: General-not-overfit / §4.05 no-leak / root-cause-not-bypass | the gatekeeper agent reads the diff and tries to BREAK it | an LLM, but ONLY blocks on a **reproducible HIGH** |
Everything that **can** reduce to a program **is** a machine gate; the
agent gate is the single residue that cannot. The poll itself
(`poll_prs.py`) is deterministic too — it enumerates candidates and makes
**no** merge decision. Judgement enters the loop in exactly one place:
Step-2.7. Anywhere else, "the agent decided" is a smell.
## The four-step loop
### Step 1 — poll
Run **before** any other action, deterministically:
```bash
python3 plugins/vibe-ic/skills/gatekeeper-loop/programs/poll_prs.py
```
The program lists every open, **non-draft** PR against `main`,
**ready-first**. A CLEAN/MERGEABLE signal is an advisory service-order hint,
never acceptance; unknown candidates follow, then known DIRTY/CONFLICTING
candidates. PR number is only a tie-breaker. Exit codes are the cron
driver's signal:
| rc | Meaning | Gatekeeper action |
|----|---------|-------------------|
| 0 | No actionable PRs | Output `(no actionable PRs)` and idle this tick (healthy idle — see §STOP CONDITION) |
| 1 | ≥1 actionable | Classify the complete population, service accepted receipts first, and prepare an unaccepted candidate only if none is deliverable |
| 2 | I/O / auth error | Log + exit; retry next tick (do NOT treat as actionable) |
One rule: **actionable = ANY open, non-draft PR targeting `main`.** A
draft PR is the author still declaring "not ready" — excluded (the author
has not asked for the gate). No label gating, no comment classifier. The
poll surfaces `mergeable` / `mergeStateStatus` / `labels` as **advisory
context only** — it never filters on them. A PR GitHub currently reports
`CONFLICTING` is **still** actionable, because the gatekeeper must eject
it back to the author; silently dropping it from the poll would **wedge**
the PR with no path forward (a §4.05 leak at the poll layer — the poll
must not mask a PR that needs the gate to say "fix your branch").
**Ready-first is binding:** before any candidate preparation, invoke the actual
`--service-ready` consumer over the complete independently accepted population.
An accepted candidate proceeds to the next available short final lock promptly.
An unrelated earlier PR, lower/higher PR number, handoff, queue position, or
slow preparation is not a dependency and must not block it. Genuine declared
dependencies, overlapping source ownership, an explicit owner hold, or a failed
required gate still block their own candidate. Exactly one writer may hold the
final lock.
### Step 2 — bounded preparation only: machine gates
Only after the complete-population service call returns
`BLOCKED_NO_ACCEPTED_CANDIDATE`, select one candidate without accepted evidence
for bounded preparation. Run the deterministic gate aggregator against
`base=origin/main`, `head=<the PR branch>`:
```bash
git fetch origin
python3 plugins/vibe-ic/skills/gatekeeper-loop/programs/gatekeeper_review.py \
--repo <owner/repo> --pr <num> --base origin/main --head <headRef>
```
`gatekeeper_review.py` is the **machine-gate aggregator** — it checks out
the PR head against current `origin/main`, runs the required status checks
**cadence-aware** (§Cadence), and returns:
- **exit 0** — all required checks green → proceed to Step 2.7.
- **exit 1** — ≥1 required check RED → the gatekeeper **request-changes**.
On a RED, request changes with the **verbatim failing program output**,
in a 繁體中文 5-section comment (see §Comment shape), then **continue**
to the next PR — do NOT block the queue on a red PR:
```bash
gh pr review <num> --request-changes --body-file <comment_file.md>
```
> **why this is a program, not judgement:** "did the suite pass", "is the
> version monotonically bumped", "is the source chip-agnostic" are all
> exit-code questions. No LLM reads them. The gatekeeper only *relays*
> the failing output into the review.
### Step 2.7 — the one agent gate within bounded preparation
Within that bounded preparation, when **and only when** the machine gates are
green, run the irreducible agent-judgment gate on the PR diff. This is the
Step-2.7 adversarial
review doctrine carried over from `core-agent-loop` — **try to BREAK the
change, do not validate it** — with three concrete attack lenses:
1. **General-not-overfit** — does the change encode a chip / vendor / SKU
/ benchmark-name literal as *logic* (vs the chip-agnostic deny-list /
structural form)? A green `source_chip_agnostic_check.py` catches the
*known* deny-list tokens; the agent gate catches a *novel* overfit the
deny-list hasn't learned yet (e.g. a magic constant tuned to one
golden file, a regex that only matches the motivating example).
2. **§4.05 no-leak** — does a relaxation / new SKIP / widened guard make a
gate that was BLOCKING a real defect now PASS it? A relaxation must
never mask a real defect. Re-derive: what shape did this gate block
before, and does the diff let that shape through now?
3. **Root-cause-not-bypass** — does the change fix the cause, or
`--no-verify` / defer / partial / silently-swallow it? A bypass that
makes the symptom disappear without removing the cause is a HIGH.
Spawn independent adversarial reviewers (e.g. `codex-adversarial-review`)
against the PR diff with these lenses. **Only a finding the reviewer can
REPRODUCE blocks.** A speculative "this might…" without a reproduction is
NOT a block — it is at most a review comment. On a reproducible HIGH,
`request-changes` with the reproduction in the 繁中 comment, then
continue. No reproducible HIGH publishes accepted evidence and returns to
the complete-population `--service-ready` path in Step 2.9.
> **why_not_bucket_a (the judgment residual):** whether a regex is
> "general" or "tuned to one example", whether a widened guard "masks a
> real defect", and whether a diff "fixes the cause or hides the symptom"
> all require reading the change's INTENT against the spec — open-ended
> reading no regex settles. The *programmable* half (the known
> deny-list, the version invariants, the full-suite gate) is already in
> the machine gates; Step-2.7 is the single LLM residue.
### Completed V2 receipts: executable ready-first delivery
Use `programs/landing_merge_verdict.py --service-ready <root-admission.json>
--repo <checkout> --base origin/main --writer repo-gatekeeper --receipt-root
<git-common-dir>/gatekeeper-ready-receipts --candidates-json
<root-owned-accepted-candidate-array> --final-command
python3 programs/landing_merge_verdict.py --service-final-write --repo <checkout>
--base origin/main`
for independently accepted work. The input is root-owned, not a PR payload:
`writer`, `candidates[]` with `id`, `head`, `receipt`, independently accepted
`receipt_sha256`, optional `holds`, `shared_conflicts`, `failed_gates`, landed
commit `dependencies`, and `checks[path]=[{"id": ..., "argv": [...]}]`.
Pins must come from the independent accepted packet, never the receipt itself.
This actual consumer reasserts evidence, passes unrelated blocked work, refuses
missing/tampered/mismatched pins, and requests only named impacted checks.
Checks run outside its short common-repository final-write lock. Delivery argv
is supplied only by the designated sole writer; the consumer neither grants a
source worker landing authority nor treats mergeable status as acceptance.
Do not repeat acceptance due to waiting, handoff, identity, unrelated base
movement, or version assignment. Genuine holds/dependencies/shared ownership
and failed gates remain blocking. This binding path is the current normative
procedure; the explicitly marked incident record below is non-executable.
The canonical cron stores exact accepted receipt bytes below
`<git-common-dir>/gatekeeper-ready-receipts/`, verifies their independently
supplied outer pins, and publishes `root-admission.json` as a mode-0600 sibling
temporary followed by flush, `os.fsync`, and `os.replace`. It never regenerates
an already accepted receipt. The bound final writer rechecks
`GATEKEEPER_READY_ID`, `GATEKEEPER_READY_HEAD`, and `GATEKEEPER_READY_BASE` and
uses `--match-head-commit`; it never merges a PR number remembered by the caller.
### Step 2.9 — serialized merge queue (READY-bound final writer)
A green PR does **not** merge directly. An accepted receipt enters the
root-owned atomic manifest and returns to `--service-ready`; only its selected
READY_ID/HEAD/BASE reaches `--service-final-write` under the short
`gatekeeper-final.lock`. If no accepted receipt is deliverable, the bounded
preparation branch may produce one, but it must return here instead of merging
or repeating acceptance inline. This is the only executable landing route.
The **squash-merge** is load-bearing: **one PR = one squash commit = one
gatekeeper-assigned version bump**, honoring the **one-version-per-push**
rule. **The gatekeeper assigns ALL versions** (owner directive 2026-06-17:
*"all versions are given by gatekeeper"*) — the authoring PR is VERSION-LESS
(it does NOT touch `plugin.json` / `marketplace.json`), because two PRs in
flight that each self-bumped would COLLIDE (both pick `x.y.(z+1)`). When no
accepted candidate is deliverable, bounded preparation assigns the next
version from the admitted base and runs the named version/cadence checks once;
the resulting exact bytes and receipt then enter the atomic manifest. An
unchanged accepted receipt is reused. The READY-bound squash carries the one
assigned bump. Never merge-commit or rebase-merge a multi-commit PR.
<!-- HISTORICAL_NON_EXECUTABLE_START -->
### Historical merge-path incident record — non-executable
Everything through the matching end marker records the pre-service-ready
incident and old command semantics. It is evidence for why the current bound
path exists, not an operator instruction; do not execute commands in this
section. The current normative path is the `--service-ready` procedure above
and the canonical cron below.
```bash
tools/gatekeeper-verify-merge.sh <num> --json /tmp/verify-<num>.json # MUST exit 0
# Root atomically admits these exact bytes to root-admission.json; the canonical
# cron below invokes --service-ready and only its READY-bound final writer merges.
```
**This is not a reminder, it is the only gate on this path.** Measured
2026-08-12 (vibe-ic#1019):
| what was assumed | what is true |
|---|---|
| required status checks gate the merge | Actions is disabled at the **account** level — `actions/permissions` → `{"enabled": false}`; the appeal was rejected. `gh workflow run` → `HTTP 422`. A self-hosted runner does not help: *scheduling* is the blocked layer. |
| branch protection backs it up | `gh api repos/vibeic/vibe-ic/branches/main/protection` → **`404 Branch not protected`**. No required check exists. |
| `gatekeeper-land.sh` runs at the merge | It runs on **`git push`**, via the stamp `tools/git-hooks/pre-push` demands. **`gh pr merge --squash` creates the commit SERVER-SIDE — nothing is pushed from a local clone, so `pre-push` never fires and `gatekeeper-land.sh` never runs.** |
Consequence, measured rather than feared: `test_matrix_d2_falsifiable.py` was
RED on `main` across five merges (#1006 #1007 #1008 #1009 #1013) and the
targeted selector had picked that exact file for **all seven** merges examined.
The suite was right, the selection was right, and the thing that runs them was
never invoked. In the same window the **version assignment of Step 3.5 also
stopped happening** — the last twelve first-parent landings all carry the same
plugin version, because Step 3.5 commits it on the *rebased local branch* and a
server-side squash of the PR's *remote head* never sees it.
`gatekeeper-verify-merge.sh` fetches the PR head, rebases it onto the current
base **in a throwaway worktree**, proves that tree is the tree the merge would
produce (`git merge-tree --write-tree`, cross-checked against the forge's own
`refs/pull/<n>/merge`), and runs `gatekeeper-land.sh` on it. It refuses — loudly,
non-zero — on a rebase conflict, on any non-test gate failure, on a stamp naming
another commit, and on a tree that is not the one that would land. **Land only
on rc 0.**
It judges the suite DIFFERENTIALLY (the base's failed set vs the candidate's)
rather than demanding green, because `main` carries pre-existing red and a gate
that refuses every landing is a ban that teaches the operator to bypass it. A
test going **failed → skipped/absent is a refusal, never an improvement**.
Two things it does NOT do, on purpose: it does not push, and it does not merge.
The version assignment of Step 3.5 still has to reach the PR branch before
`gh pr merge` can carry it — the verdict says so in its notes rather than
letting the deferral pass for a bump.
#### Read the TIER on the verdict. This host is probably running the weaker one.
`git merge-tree --write-tree` needs **git >= 2.38**, and four of the six hosts
in this fleet run 2.34.1 — **including `.102`, the orchestrator, where every
`gh pr merge` is actually run.** On those the strong path cannot start, so the
script probes the capability and declares a tier:
| `verification_tier` | tree under test | squash-vs-rebase cross-check |
|---|---|---|
| `merge-tree` | the 3-way merge | **performed** — the rebase replay is an independent second opinion and a disagreement is a refusal |
| `rebase-replay` | the rebase replay | **NOT performed** — there is only one answer, so nothing is left to disagree with it |
Everything else is identical in both tiers: the same squash commit, the same
`gatekeeper-land.sh`, the same test and gate differentials, and the same
fail-closed refusal when the replay conflicts. **A `rebase-replay` pass is a
real pass, not a waiver** — the negative control (an innocuous diff that leaves
a test red) is asserted under the fallback precisely so it cannot become one.
Read it off the record rather than off the prose — it is machine-readable:
```bash
jq -r '.verification_tier, .tier_degraded, .squash_vs_rebase_cross_check' /tmp/verify-<num>.json
# rebase-replay
# true
# NOT_PERFORMED
```
The printed verdict carries the same codes as ` DISCLOSE ` lines. If a PR is
one whose risk is exactly the replay-vs-merge divergence (a revert of something
that also moved on `main` — the phantom-revert shape), verify it on `.112` or
`.114`, which have the capability, or wait for the forge's own
`refs/pull/<n>/merge` to exist: when the forge merged this same base it
cross-checks the replayed tree even under the fallback.
The historical direct `gh pr merge` command is not an executable instruction:
the canonical cron must return to `--service-ready`, whose READY-bound final
writer supplies the selected id/head/base and `--match-head-commit`.
The merge uses `--squash` and NEVER `--admin` / `--force` (see §Hard
rules). After release, the loop moves to the next ready PR. Landing remains
serialized, but acceptance work is not repeated merely because another agent
landed unrelated bytes. `--reassert <receipt> --reassert-head <headRef>` binds
the exact candidate head and compares the advanced base with the receipt's
candidate/evidence/dependency manifest: disjoint movement reuses the receipt;
overlap names the affected paths and requests only their impacted checks.
> **why affected checks on changed inputs are not optional:** the PR's own CI ran
> against the base it was opened on. Between then and now, the queue has
> merged other PRs. A clean 3-way merge proves the *text* doesn't conflict;
> it does NOT prove behaviour still holds when candidate-owned or named
> evidence/dependency bytes changed. Bounded revalidation catches that class
> without discarding independent completed evidence.
<!-- HISTORICAL_NON_EXECUTABLE_END -->
## Hard rules (non-negotiable)
> **The gatekeeper MAY self-merge.** A single identity may both author and
> merge its own PRs — there is NO author≠approver requirement. The gates
> (machine required checks + Step-2.7), independently pinned receipt, genuine
> blocker checks, and READY-bound final writer — not identity separation — are
> what make a merge trustworthy.
| # | Rule | Reason | Enforced by |
|---|------|--------|-------------|
| 1 | NEVER `gh pr merge --admin` / `--force` / bypass branch protection | Bypasses the required status checks (the machine gates) | `git_prohibition_guard.py` |
| 2 | NEVER `git push --force` / `git commit --no-verify` / `git reset --hard` on `main` | Loss of history; bypasses pre-commit gates | `git_prohibition_guard.py` |
| 3 | NEVER merge a multi-commit PR with merge-commit / rebase-merge — squash only | one PR = one squash commit = one version bump (one-version-per-push) | `--squash` only; version gates |
| 4 | NEVER use chip/vendor/SKU string literals as detection logic in any gate | gates must be general | `source_chip_agnostic_check.py` |
| 5 | Keep `gatekeeper-final.lock` short and NEVER force-steal it | only the selected READY id/head/base may occupy the sole final writer | `landing_merge_verdict.py` |
### Self-merge is allowed (no author≠approver requirement)
There is **no** identity-separation rule: the gatekeeper MAY merge a PR it
authored itself. The PR author login is irrelevant to the merge decision —
a PR lands iff its accepted receipt survives the machine and agent gates,
genuine blocker checks, exact remote binding, and the READY-bound final writer,
regardless of who wrote it. The quality guarantee is the GATE, not a second
person.
### Break-glass override (so a wedged gate can't deadlock the queue)
A failure mode the loop MUST survive: **a required gate is itself broken**
(e.g. `gatekeeper_review.py` crashes, or a machine gate has a bug that
red-flags every PR). If the *only* fix is a PR that repairs that gate, the
queue deadlocks — the broken gate blocks the very PR that would fix it.
**Break-glass protocol** (documented, auditable, narrow):
1. **Eligibility.** Break-glass applies ONLY to a PR whose diff is scoped
to **fixing the wedged gate itself** (the gate program + its tests) —
never to a feature/IC PR. The gatekeeper confirms the PR touches only
the gate machinery and carries a `break-glass: <gate>` declaration in
its body naming the wedged gate and a reproduction of the wedge.
2. **Explicit recorded approval.** Break-glass is never silent: the
gatekeeper records an explicit `break-glass: <gate>` approval on the PR
(a `gh pr review --approve` + a comment naming the wedged gate and the
reproduction of the wedge) BEFORE merging. Skipping a safety gate is the
one high-risk action the loop can take, so it must always leave an
auditable trail — even though self-merge of an ordinary gated PR is
allowed, a gate-SKIP must be declared, not hidden.
3. **Reduced gate set, never zero gates.** The wedged gate is the ONLY
check skipped; **every other** required check still runs during bounded
preparation. The exact accepted bytes return to the atomic manifest, and
only the READY-bound writer may use `--squash`, never `--admin`/`--force`.
4. **Self-healing follow-up.** Immediately after the gate-fix lands, the
gatekeeper re-runs the full machine gates (now including the repaired
gate) on `origin/main` to confirm the wedge is gone, and the normal
loop resumes — the next tick re-validates every still-open PR against
the repaired gate. The break-glass event is recorded in the PR (the
declaration + the human co-sign + the post-merge re-run output) so it
is fully auditable.
Break-glass is deliberately painful (scoped diff + human co-sign +
reduced-not-zero gates + post-merge re-validation) so it can only ever be
used for its one legitimate purpose: un-wedging a broken gate. It can
never be used to fast-path a normal PR.
## Cadence (machine-gate test cost)
The machine gates honor the SAME test cadence as the rest of the plugin so
gatekeeping a routine patch-bump PR doesn't pay the full-suite cost:
- **PATCH-bump PR** (`x.y.Z`, Z>0) → `gatekeeper_review.py` runs the
TARGETED regression (the PR's new `test_v*` + the touched module's
tests + an affected-family `-k` sweep + `source_chip_agnostic_check.py`).
- **MINOR-milestone PR** (`x.y.0`) → the FULL both-tree suite the CI way,
green required before merge — the periodic cross-module safety net.
The patch rollover (`x.y.99 → x.(y+1).0`) makes the milestone full-test
land automatically. Bounded preparation uses the same cadence (targeted for a
patch, full for a milestone); an unchanged accepted receipt is not repeated.
## Comment shape (繁體中文, 5 sections)
Every `request-changes` (machine-red or reproducible-HIGH) posts a
繁體中文 comment in this exact 5-section shape:
```
Gatekeeper 已 request-changes:PR #<num>(head=<branch>)
**問題**:<重述 PR 的目標 + 觸發 request-changes 的 gate / 發現>
**根因**:<machine gate 的失敗類別,或 Step-2.7 對抗發現的根因>
**證據**:
<失敗 gate 的逐字輸出,或可重現的 HIGH 對抗重現步驟與輸出>
**要求**:<請作者修正什麼,才能重新進入 gate>
**機制說明**:本 PR 由單一 gatekeeper 把關 main;machine gates 與 Step-2.7 產生精確 accepted receipt,完整人口由 `--service-ready` ready-first 消費。僅具名失效證據會 bounded revalidation;真實 hold/dependency/shared conflict/failed gate 仍阻擋。短 final lock 內只有 READY-bound id/head/base 可由 sole writer 以 squash 合併(一 PR=一 squash commit=一次版本 bump)。品質由 gate 保證(非作者≠審核者之身分分離);本機從不 --admin / --force / --no-verify / 繞過 branch protection。
```
A successful merge is recorded by the squash-merge itself (and the deleted
branch); no comment is required on the happy path, though the gatekeeper
MAY post a 繁中 `已 squash-merge:<sha>` note.
## Compliance gate (mandatory)
After producing the 繁體中文 request-changes comment text (before posting),
save it to a file and run the deterministic publish-layer gate:
```bash
python3 plugins/vibe-ic/_shared/skill_compliance_check.py \
--requirements plugins/vibe-ic/skills/gatekeeper-loop/compliance.yaml \
<comment_file.md>
```
Exit 0 = PASS (post the comment), exit 1 = FAIL with the missing sections
listed. The gate enforces the canonical 5-section shape (`問題` / `根因` /
`證據` / `要求` / `機制說明`), the `Gatekeeper 已 request-changes:PR #`
header, the squash one-version-per-push mention, and a chip-AGNOSTIC
publish-layer scan. Patch and re-run until PASS, THEN post the comment.
## STOP CONDITION
The cron continues **indefinitely**. The gatekeeper does **not**
self-terminate — it stays available to gate any future PR. A tick that
produces `(no actionable PRs)` is a **healthy idle** state, not a stop
signal. There is no "done": the gatekeeper is the standing owner of
`main`. (This is the PR-loop analogue of the core-agent's "no actionable
issues" healthy idle.)
## State
The gatekeeper is **stateless across cron ticks**. All state lives in:
- **git** — `main`'s commit history, each PR branch's state.
- **GitHub** — PR open/merged/closed, review state (`request-changes` /
`approved`), the required-check statuses.
- **the filesystem** — root-owned accepted receipt bytes and atomic manifest;
`gatekeeper-final.lock` is held only for the selected final write and is
released when the `landing_merge_verdict.py` service call exits.
Every tick is independent — no `state.json`. The gatekeeper classifies the
complete population first, then selects at most one READY candidate for the
short final write; only when none is deliverable may it prepare one unaccepted
candidate. The result is captured by the PR's review/merge transition, accepted
receipt bytes, and the transient final lock.
## Cron-invocation template
```
Run /gatekeeper-loop against <owner/repo> (base=main).
Each tick must:
1. python3 plugins/vibe-ic/skills/gatekeeper-loop/programs/poll_prs.py --repo <owner/repo> --base main
2. If rc=0 → output "(no actionable PRs)" and exit (healthy idle).
3. If rc=2 → log + exit. Retry next tick.
4. If rc=1 → classify the complete `actionable[]` population ready-first. The
PR author is irrelevant — the gatekeeper MAY merge its OWN authored PR; the
gates are the quality guarantee, not identity separation:
a. Inspect the complete population and FIRST invoke the actual consumer
over every already accepted packet:
`python3 programs/landing_merge_verdict.py --service-ready
<git-common-dir>/gatekeeper-ready-receipts/root-admission.json --repo
<checkout> --base origin/main --writer repo-gatekeeper --final-command
python3 programs/landing_merge_verdict.py --service-final-write --repo
<checkout> --base origin/main`, including `--receipt-root
<git-common-dir>/gatekeeper-ready-receipts --candidates-json
<root-owned-accepted-candidate-array>` before `--final-command`.
Each accepted candidate pins decimal PR id, exact head, receipt path,
independently supplied outer SHA256, blockers/dependencies, and named
impacted-check argv. Do not infer authority from PR metadata or a
receipt self-digest.
It admits the complete queue ready-first, reuses unchanged receipts,
runs only named impacted checks outside `gatekeeper-final.lock`, and
refuses missing/tampered/mismatched receipts and genuine blockers.
Under the short lock the final writer binds READY_ID/HEAD/BASE, then
invokes the existing squash writer with `--match-head-commit`.
b. `DELIVERED` or any possibly-partial final-write status ends the tick.
Only `BLOCKED_NO_ACCEPTED_CANDIDATE` permits bounded preparation of a
candidate without a receipt: run its existing machine gates, Step-2.7,
version assignment and `gatekeeper-verify-merge.sh --json` once. Red
requests changes; green exact bytes enter the next atomic manifest.
Never make an accepted later candidate wait behind this preparation,
and never rerun acceptance due to waiting, handoff, identity or
unrelated base movement.
Hard rules: never --admin/--force/--no-verify, squash-only, and keep the sole
final lock short. Self-merge of an ordinary gated PR is ALLOWED. A PR that fixes a
wedged gate uses the documented BREAK-GLASS path (scoped diff + recorded
break-glass approval + reduced-not-zero gates + post-merge re-validation).
See SKILL.md §Hard rules and §Break-glass override.
End of tick. The cron runs indefinitely — never self-terminate.
```
Save as the CronCreate `prompt` field; pick a short interval (e.g. 3-4
minutes) so freshly-pushed PRs are gated promptly.
## Reference
Deterministic gates + programs backing this skill (the loop SCAFFOLD is
fully programmable; only Step 2.7 is genuine LLM judgment):
- Poll / actionability (every open, non-draft PR against `main`,
ready-first): `skills/gatekeeper-loop/programs/poll_prs.py`
- Machine-gate aggregator (required status checks, cadence-aware):
`gatekeeper_review.py` (the required-check runner this loop invokes)
- Ready-first receipt consumer and short sole-final-writer lock:
`programs/landing_merge_verdict.py --service-ready`
- Forbidden git/gh ops (rules 1-2): `programs/git_prohibition_guard.py`
- Chip-AGNOSTIC source scan (rule 4): `programs/source_chip_agnostic_check.py`
- Version equality (one-version-per-push): `programs/marketplace_version_sync_check.py`
- Version strict-monotonic bump: `programs/version_bump_monotonic_check.py`
- Gatekeeper version ASSIGNMENT at merge (the gatekeeper's sole right —
next monotonic version → plugin.json + marketplace.json):
`programs/gatekeeper_assign_version.py`
- PR machine gate used by bounded preparation; exact accepted bytes then enter
the service manifest: `programs/gatekeeper_review.py`
- Full-suite (not subset) test run: `programs/full_suite_run_check.py`. It
classifies an invocation by the POPULATION IT COVERS (git-derived), not by
its shape or its name: `./run_tests.sh` is FULL because the tiers it prints
cover every tracked test file; a bare `pytest` and an explicit
`pytest programs/tests` are SUBSETS, because `pytest.ini` names one tree of
five (owner ruling, 2026-08-31). At an x.y.0 milestone the cadence gate
requires the first and refuses the others.
- Issue-fix counterpart (the other half of the contribution model):
`vibe-ic:core-agent-loop`
- Adversarial-review skill used at Step 2.7: `vibe-ic:codex-adversarial-review`
### Which PR owns a non-atomic write
`atomic_artifact_write_check` answers "does THIS TREE contain a new offender".
It cannot answer "WHOSE PR put it there", and a batch of open PRs needs the
second question — each site has to be converted on the branch that carries it,
because none is on main yet.
programs/atomic_write_pr_attribution.py --owner-repo <org/repo> --pr <n> [--pr <n> ...]
Not a gate and it cannot become one: `--pr` names live GitHub state, so no flow
clause and no `run` line can supply it. It is a triage tool for the person
holding the batch, which is this loop.