Landing Contributor PRs: The Maintainer Runbook
Audience: Maintainers taking contributor PRs from "open with red CI" to "merge-ready, evidence posted, operator merges". Issue: #2341 Origin: The 2026-07-04 landing pass (#2332, #2336, #2338, #2239, #2238), where this workflow was run end-to-end and its friction points were logged.
The deliverable of a landing pass is never a merge. It is a PR that is green, carries a full evidence trail in its comment thread, and states any landing-order constraints — so the operator can merge it without re-deriving the adjudication. The maintainer never merges (see step 11).
The workflow at a glance
Before step 1, check the PR is handed off and worth landing.
- Claim before touching
- One isolated worktree per PR
- Rebase onto current upstream/main first
- Classify every red check
- Folds: remediation commits on the contributor branch
- Red-first verification for bugfix PRs
- Review focus areas beyond CI
- Adversarial squad for architectural or API-surface PRs
- Push discipline
- Post the remediation summary
- Hand-off — the operator merges
- Follow-up hygiene
Before you claim: check the PR is handed off and worth landing
Start with two read-only checks.
Is it handed off? A new PR is opened as a draft, followed, fixed to green, and
then flipped to "ready for review". The flip is the hand-off to the maintainers: it
signals that a landing pass is safe to start. A draft PR is not handed off. Run
gh pr view <N> --repo spec-kitty/spec-kitty --json isDraft,author,statusCheckRollup;
if isDraft is true, stop before you touch the branch. The first thing in your reply
to the operator is a prominent warning that the PR is STILL A DRAFT AND NOT HANDED
OFF FOR LANDING, with its author and CI state, and the question whether to land it
anyway. Rebase, push or comment only after an explicit go-ahead.
Is it worth landing? Size the PR on two axes: old (its base is far behind
main, an aging contributor branch) and substantive (a capability, a fix or a
contract change, as opposed to a version bump, a dependency pin, a regenerated file
or a typo). When unsure, treat it as old and substantive.
- Old or substantive: run the full check. Was the change already landed by
another PR or Mission (search the changelog,
git log -Sand the referenced issues)? Does its approach still fit the current design, or did a seam it depends on move? Does the problem still reproduce on currentmain? Post the result as a PR comment, with evidence: LAND as is, RESCOPE (keep the durable part) or CLOSE (superseded or moot). Continue only on LAND; otherwise report the recommendation to the operator and wait for the answer. - Recent and mechanical: run the short check. Is the change accurate (correct and coherent), desirable (actually wanted) and aligned with current conventions? If so, record the LAND call in your first comment and continue.
What you update at the end depends on the outcome. A RESCOPE gets a changelog entry for the kept part only; a CLOSE gets a closing rationale in the PR body instead of a changelog entry.
PRs prepared by the SkyKitty fleet
A squad:passed label (see the agent fleet; the fleet is now
switched off and the label was deleted, so this applies to PRs it prepared earlier) meant the fleet's
review passed against the base the PR had at that time. It does not mean the PR is
ready now. After the rebase:
- Regenerate stale generated surfaces. Run
uv run python -m scripts.docs.check_docs_freshness --cito see which one is stale, and regenerate it with its own script, for exampleuv run python -m scripts.docs.docs_index --write(docs retrieval index) anduv run python -m scripts.docs.freshen_adr_inventory(ADR and page inventory). A command that another PR added after this branch's base is missing from them and reds the freshness gates although nothing in the diff is wrong. This is part of landing, not scope creep. - Fix the fleet's follow-up issues. The fleet files its smaller findings as
separate issues that reference the PR. Search for them
(
gh issue list --repo spec-kitty/spec-kitty --search "#<N>", plus the PR's own comments), fix each one in this landing (or at least make the PR-body correction it asks for), and addCloses #<n>to the PR body next to the PR's own issue line.
1. Claim before touching
Post a claim comment on the PR before any rebase or review work: what you are picking up, in which landing queue, and what you plan to do. One claim per PR in the pass, posted first.
unset GITHUB_TOKEN # keyring auth has full repo scope; a limited env token may not
gh pr comment <N> --repo spec-kitty/spec-kitty \
--body "Claiming this PR for today's landing pass: rebase onto upstream/main, adjudicate red checks, fold fixes as needed. Evidence to follow."
Why: it prevents duplicated maintainer effort when several PRs are being landed in parallel, and it means the contributor is never surprised by maintainer commits appearing on their branch.
2. One isolated worktree per PR
Never touch the primary checkout — a mission session may own it. Give every PR its own worktree:
git fetch upstream pull/<N>/head:pr-<N>-local
git worktree add .worktrees/pr-<N>-landing pr-<N>-local
cd .worktrees/pr-<N>-landing
Each worktree builds its own uv virtualenv on the first uv run — expect
roughly 40 seconds and some disk on that first command. That is normal, not a
hang.
3. Rebase onto current upstream/main first
Contributor branches are routinely 100+ commits behind. Every adjudication — tests, gates, review — happens on the rebased tip, not the stale base:
git fetch upstream main
git rebase upstream/main
Changelog conflicts resolve in docs/changelog/CHANGELOG.md, which is the
canonical changelog. The root CHANGELOG.md is a symlink to it (since the
symlink cutover that rode #2338), so both paths reach the same file — resolve
the conflict once, in the canonical location.
4. Classify every red check
This is the core reviewer decision point. Diagnose each red check on the rebased tip and classify it into exactly one of four bins:
| Classification | What it looks like | Action |
|---|---|---|
| PR defect | The PR's own change breaks a test or gate | Fix it on the branch (a "fold", step 5) |
| Contract the PR legitimately crosses | A seam move-set completeness gate, a census tolerance band | Re-pin the contract in the same PR, with a dated rationale in the pin |
| Pre-existing main breakage | The same red reproduces on an unrelated main-based branch | Fold the fix in by default (step 5) — keep main green even for a red the PR did not cause — unless one of the two carve-outs below applies |
| Perf-budget flake | A budget gate trips without a correctness signal | Note it, watch for recurrence, tune the budget at the root if it repeats — never retry-to-green |
Start with main's own CI run at the merge-base — it is cheaper and more decisive than any local rerun. CI already ran the same shards against the commit your branch is based on, so the comparison is a fetch, not a test run:
unset GITHUB_TOKEN
gh run list --repo spec-kitty/spec-kitty --branch main --limit 5 \
--json databaseId,name,conclusion,headSha
# then, per failing job, pull the logs and diff the failing-test sets:
gh api --allow-escape-sequences "/repos/spec-kitty/spec-kitty/actions/jobs/<job-id>/logs" \
| sed 's/\x1b\[[0-9;]*m//g' | grep -E "^FAILED|short test summary| failed,"
Two things make this decisive rather than merely suggestive:
- Match on test id and assertion text, not just job status. A job can be red on both sides for different reasons.
- Compare the pass counts too.
1 failed, 572 passedon main against1 failed, 583 passedon the PR proves the PR added 11 tests and no failures. That single line refutes "the PR broke something" faster than any argument.
Decompose composite jobs. lint is not one check — it runs schema
generation, ruff, mypy, commitlint, markdownlint, pip-audit and
bandit as separate steps. Never classify lint as a unit: extract the
failing steps on both sides and compare those. On the 2026-08-04 pass,
pip-audit and bandit were red on main (pre-existing) while commitlint
and markdownlint were introduced by the folds — a distinction invisible at
job level:
grep -nE "##\[group\]Run |##\[error\]Process completed" <log> \
| awk '/error/{print prev} {prev=$0}'
Then reproduce locally only for the reds you still need to settle:
git worktree add /tmp/repro-main upstream/main
cd /tmp/repro-main && PWHEADLESS=1 uv run pytest <failing test> -q
A red there too confirms the failure is pre-existing, not the PR's. The
default is then to fold the fix in anyway (boy-scout / campsite-clean,
DIRECTIVE_025) — a stale generated artifact, a forgotten regen, or a small
unwired reference are all fixable inline, and a landing pass that leaves an
easy pre-existing red on the board just defers the cost and keeps main red for
longer. Two carve-outs override that default:
A
p0_repro-marked red-first test. An intentionally-failing, issue-pinned reproduction of an open P0 bug (per ADR 2026-07-17-1, Amendment 2026-10-04) carries@pytest.mark.p0_repro(issue=N). It is a deliberate red signal that holds the nightly, and so the release, open until the P0 is fixed — leave it red. Do not fold it to green; the product fix that closes its tracking issue is separate, dedicated work, never a landing-pass fold. Greening it here would erase the honest release-blocker signal the ADR exists to preserve.A pull request never runs these tests:
tests/_support/p0_repro.pydeselects them unlessSPEC_KITTY_RUN_P0_REPRO=1is set, and only the nightlyp0-reprojob sets it. So a landing pass meets one only in a nightly run or an explicit opt-in run (SPEC_KITTY_RUN_P0_REPRO=1 uv run --frozen pytest -m p0_repro -q). Each failure starts with an[OPEN P0 #N]banner naming the issue. Details: Red Main and Release Readiness.The
regressionmarker is a different thing: it pins a bug that is already fixed, and it runs per PR. A redregressiontest is a real failure in the PR, not a deliberate signal. Fold it or fix the code.Check for the marker per test, not per file. It is applied as a decorator on the individual test while the module's
pytestmarkmay list only category markers, so a file-level grep reports "unmarked" for a properly pinned reproduction:git grep -n "pytest.mark.p0_repro" -- tests/ # catches both formsA fix that is mission scope and cannot reasonably be folded. If the real fix belongs to a distinct mission — wide blast radius, its own spec/design, an in-flight mission's own reconciliation — do not cram it into the contributor PR. Instead accept the failure as an honest P0 red: file or find the P0 issue, pin the failing test to it with
@pytest.mark.p0_repro(issue=N), and leave it red per Red Main and Release Readiness. The marker takes the test out of per-PR CI and into the nightlyp0-reprolane, so the PR still lands green. Do not mark itregression, which would keep it red in every PR. Never retry-to-green.
See manage-issue-tracker.md for the type/severity triage this leans on, and testing-flakiness.md for the never-retry-to-green rule behind the fourth bin.
Multi-WP lanes: classify against the true base, not the lane tip
A mission with dependency-merged lanes (lanes.json, execution-lanes.md)
gives a downstream WP's lane tip a branch that already contains every
upstream WP it depends on — the tip is not close to the mission's own base,
it is ahead of it by however many WPs merged in first. Diffing or classifying
reds against that lane tip mixes in work this PR did not write.
Always classify against the true base, computed fresh, not against the lane tip and not against a remembered branch point:
git merge-base <mission-branch> upstream/main
Reds that reproduce between that merge-base and the lane tip are pre-existing or upstream-WP fallout, not this WP's defect — apply the same four-bin classification above, but with the merge-base as the comparison point.
One concrete trap this produces: a mock.patch.object(...) raising
AttributeError inside a red you have classified as "pre-existing" is very
often migration fallout — an earlier WP in the same dependency chain
renamed or removed the attribute the mock patches — not a defect in the WP
under review. Confirm by checking whether the patched attribute still exists
on the target at the merge-base; if it does not, the fix belongs to whichever
WP renamed it, not to a fold on this PR.
Stale-stack diagnostic: two-dot vs three-dot diff
A "small fix" PR whose diff shows charter, doctrine, or other governance files it has no business touching is a symptom of a stale stack: the branch was cut before a since-merged doctrine change, and rebasing (or a naive two-dot diff) is smuggling that governance change back in as if it were part of this PR.
Tell the two apart with git's own diff-base semantics:
git diff upstream/main..HEAD # two-dot: literal tip-to-tip diff
git diff upstream/main...HEAD # three-dot: diff since the merge-base
- Two-dot (
..) diffs the two commits directly — ifupstream/mainhas moved on, this includes both "what the PR changed" and "what upstream/main changed since," conflated into one diff. - Three-dot (
...) diffs fromgit merge-base upstream/main HEADtoHEAD— only the PR branch's own commits, regardless of how farupstream/mainhas since moved.
If the three-dot diff is clean (only the PR's intended files) but the two-dot
diff shows charter/governance files, the branch is stale, not defective: fetch
and rebase onto current upstream/main (step 3)
before re-classifying. If charter/governance files still show up in the
three-dot diff, that is a real defect — the PR is genuinely carrying
governance changes it should not — and belongs in the "PR defect" bin, not the
"stale stack" bin.
5. Folds: remediation commits on the contributor branch
Folds are maintainer commits pushed directly to the contributor branch. This
relies on maintainerCanModify, which is true by default on PRs from forks.
One commit per fold. Each landing-pass remediation is its own single-purpose commit — never bundle two unrelated fixes into one commit, and never split one logical fix across several. This is a hard rule, not a preference: a mixed-purpose fold commit is what makes a later
git revertor bisect ambiguous.Label the commit subject
<type>(landing): ...— a Conventional Commits type, withlandingas the scope. Pick the type from what the commit actually touches:fix(product behaviour),test(tests/gates/baselines),docs(prose, changelog, mission artifacts),chore(config, CI, tooling). The scope keeps every fold greppable (git log --grep '(landing)').Do not write
landing fold: .... This runbook prescribed that label until 2026-08-04 and it is rejected by the repo's own commitlint gate: "landing fold" contains a space, so the Conventional Commits parser extracts no type at all and every such commit fails bothtype-emptyandsubject-empty. It went unnoticed for several landing passes because commitlint only lints commits in the PR range — main's ownlanding fold:commits were never checked, so the violation only ever surfaces on the PR that carries the folds. commitlint is not adevDependency, so mirror CI's own invocation (ci-quality.yml, thecommitlintstep) and verify the whole batch before pushing:for c in $(git rev-list upstream/main..HEAD); do npx --yes @commitlint/[email protected] --config commitlint.config.cjs \ --from "$c~1" --to "$c" --verbose \ || echo "FAILED: $(git log --format=%s -n1 "$c")" doneAllowed types come from
type-enumincommitlint.config.cjs(build chore ci docs feat fix lint perf plan refactor revert spec style test). There is noscope-enum, so(landing)needs no registration.Explain every fold in the remediation summary comment (step 10).
Typical folds: canonical-source fixes (the changelog lives in
docs/changelog/), seam re-pins with dated rationale, retired-shim API
migrations, doc/contract artifact sync, and — by default — fixes for
pre-existing main breakage the PR happens to surface (step 4).
The one red you do not fold to green is a p0_repro-marked red-first
test: it is a deliberate open-P0 signal in the nightly and stays red until its
own product fix lands (ADR 2026-07-17-1).
Clean history: compress bookkeeping, keep code separate
Recent practice over-corrected on this point: several contributor PRs were landed fully squashed, erasing the reviewable seam between distinct code changes. That is not the target. When landing a contributor PR:
- Compress bookkeeping/admin commits — planning notes, fixups, "wip", formatting-only, mission-scaffolding, revert-of-own-typo — into the related work commit they belong to.
- Keep genuinely separate code/feature work as separate, reviewable commits. If the contributor made two distinct logical changes, land two commits, not one.
- The goal is a readable history where each commit is one coherent change — neither a single opaque squash nor 200 noise commits.
This governs the contributor's commits. Landing folds (above) are always
their own single-purpose <type>(landing): ... commits regardless.
Regroup a very long history
For a very long history of status churn, tracer files and review artifacts, rebuild a
clean few-commit shape instead of replaying every commit. Branch off the current
main, run git merge --squash <pr-branch> (it integrates the branch and resolves
conflicts once, all staged), commit a snapshot, then git reset --mixed <main> and
re-commit logical path groups: the code as reviewable commits plus one consolidated
chore(mission) commit for everything under kitty-specs/**. The one guarantee
before you force-push is that the rebuilt tree is byte-identical to the old head
(git rev-parse HEAD^{tree} equal, git diff <old> <new> empty). Keep the
contributor's authorship on their commits; maintainer landing fixes are separate,
maintainer-authored commits on top.
The merge bar is the PR head going green, not each commit: an intermediate commit may fail a gate that only reconciles at the tip (a regenerated docs index, say). Do not rewrite history only to make every commit green.
6. Red-first verification for bugfix PRs
A fix whose test is green before and after the fix captures nothing. Prove the PR's test actually witnesses the bug by swapping the pre-fix product file back in:
git checkout upstream/main -- <product-file>
PWHEADLESS=1 uv run pytest <the PR's test> -q # MUST FAIL
git checkout HEAD -- <product-file>
PWHEADLESS=1 uv run pytest <the PR's test> -q # must pass again
Post the result on the PR. If the test never goes red, the fold is a better test — not a green checkmark.
7. Review focus areas beyond CI
What the maintainer reads the diff for, beyond the checks:
- Canonical sources — does the change edit the source of truth, or a
generated mirror/agent copy? (Agent directories under
.claude/,.amazonq/, etc. are generated; sources live underpacks/built-in/andsrc/charter/offering/.) - SSOT / duplication — does new code near-copy an existing canonical seam or resolver? Justified divergence must be adjudicated explicitly (name the contract difference), never assumed.
- Contract artifacts — a new command or field on a versioned surface must
land in the machine contract (
upstream_contract.json), the version ledger, and the human docs together, in the same PR. - Scope-vs-spec — an apparent scope surprise may be required by the mission spec; check the FRs and constraints before flagging creep.
- Error-handling nets — best-effort helpers must catch the actual exception types their callees raise, not a guessed superset.
- Terminology canon — on any prose or doctrine touch, run the guard
locally:
PWHEADLESS=1 uv run pytest tests/architectural/test_no_legacy_terminology.py -q. - PR body style — does the PR body lead with impact (what changes for a user or operator), before architecture or test-strategy detail? See Review gates: PR body style.
- Changelog update — does a user-facing change carry a consumer-focused
entry in
docs/changelog/CHANGELOG.md? See Review gates: Changelog update and style.
8. Adversarial squad for architectural or API-surface PRs
For changes to versioned contracts or shared seams, dispatch profile-loaded
review lenses in parallel — for example architect-alphonso for design and
contract adherence, paula-patterns for SSOT and duplication — with
read-only access to the landing worktree.
- Fold their findings — MAJOR, MINOR, and NOTE alike (step 5). The default is to fix everything the squad surfaces, in this PR, while the branch is open and the context is loaded. A MINOR or NOTE is cheap to fold now and expensive to rediscover later; deferring it to a "someday" issue is how easy fixes rot on a backlog and how the same finding gets re-raised on the next pass. Do not triage-by-severity into fold-vs-defer — fold by default.
- File a follow-up issue only as the exception, when folding is genuinely the
wrong call for one of three reasons: (a) the finding is highly impactful
(it changes a contract, a shared seam or behaviour the PR does not own);
(b) it takes significant effort (wide blast radius, many files, a refactor
that would swamp the review); or (c) it needs a significant operator
decision — a spec, an ADR, or a ruling before any code moves. In those cases
file the issue the same day, parent it under the relevant functional epic, and
name it in the PR's
## Deferredsection with the reason. Every other finding, whatever its severity, is folded.
Delegate remediation to subagents
When a landing pass needs remediation beyond a one-line fold, delegate the
implementation to a profile-loaded subagent rather than hand-authoring it
inline — load the agent profile through the charter
(spec-kitty charter context --action implement --include agent-profile:<id>,
or spec-kitty agent profile show <id>), and apply model discipline:
implementation work routes to sonnet, review work routes to opus. The
maintainer's job is to classify each red (step 4)
and adjudicate the result, not to write every fold by hand. This complements
the adversarial squad above — the squad finds and classifies, subagents
implement the fold, the maintainer adjudicates and lands it as one commit
(step 5).
Mechanics that make a parallel pass work, learned on 2026-08-04 (24+ folds, 14 delegated agents):
- Give every agent its own worktree and an explicit no-touch list. Name
the files other in-flight agents hold. Without it, two agents edit
resolver.pyand both cherry-picks conflict. Sequence anything that shares a file; parallelise everything that does not. - Agents commit in their own worktree and never push. The maintainer
cherry-picks onto the landing branch. Because the worktrees share one object
store,
git cherry-pick <sha>works with no remote round-trip. cdinto the landing worktree on every cherry-pick, and check the branch before applying. A shell whose working directory resets between commands lands you in the primary checkout — which is onmain— and a cherry-pick there silently commits landing folds onto localmain. This happened on the 2026-08-04 pass: three commits went ontomainbefore the branch name in git's output gave it away. It is fully recoverable (nothing was pushed;git reset --hard upstream/mainrestored it, and the commits re-applied cleanly to the landing branch from the agent worktree), but only because it was noticed immediately. Make it structural rather than vigilant:
Re-check the fold count after each cherry-pick (cd <landing-worktree> && test "$(git branch --show-current)" = "<landing-branch>" \ && git cherry-pick <sha>...git log --oneline <base>..HEAD | wc -l) — a count that resets to a small number means you are on the wrong branch.- A revert pair cancels out — skip both and prove tree parity. An agent
that self-corrects may leave
XandRevert "X"plus a re-landedX'. Cherry-pick the net set, then prove nothing was lost:git diff <agent-branch> HEAD -- <the files that agent owned> # expect empty - Convergence is the signal worth acting on. When independent lenses reproduce the same defect with live before/after output, treat it as confirmed and fix it — do not re-derive. Conversely, a single lens's proposed fix still needs checking: on 2026-08-04 two lenses proposed the same one-line change and the implementer correctly rejected it, because that line would have destroyed a legitimately-empty configured value. Brief agents to verify the diagnosis before applying the prescription, and to stop and report if the code disagrees with the brief.
- Instruct agents never to fabricate evidence. A measurement they could
not take must be reported as not taken. A matrix row marked
pendingwith a reason is worth more than one markedpasswithout proof.
9. Push discipline
SSH is the preferred transport to GitHub, including for a force-push with lease:
use an SSH remote by default. Never push to main. If a permission prompt or safety
classifier refuses a push that follows these rules, stop and hand the push to the
operator.
Before any force-push to a fork branch, check for commits you have not seen — Copilot-review commits and parallel-session commits get cherry-picked, never clobbered:
# Fetch into an explicit remote-tracking ref — never rely on FETCH_HEAD.
git fetch <fork-remote> <branch>:refs/remotes/<fork-remote>/<branch> --force
git log <old-head>..refs/remotes/<fork-remote>/<branch> --oneline # anything here? cherry-pick it first
LEASE_SHA=$(git rev-parse refs/remotes/<fork-remote>/<branch>)
git push <fork-remote> HEAD:refs/heads/<branch> --force-with-lease=<branch>:"$LEASE_SHA"
Two lease lessons from the 2026-07-04 pass, both worth internalizing:
- A bare
--force-with-leasefails with(stale info)on fork branches you have never fetched — the lease has no remote-tracking ref to compare against locally. The explicit<branch>:<sha>form above is the standard flow, not a workaround. - The lease sha must come from
git rev-parse, never retyped from a display. Two pushes in the pass were rejected because a lease sha was retyped from a 9-character abbreviated prefix. rev-parse FETCH_HEADis only valid immediately after fetching the ref you mean.FETCH_HEADis a single global file that every fetch overwrites, so agit fetch upstream mainbetween the fork fetch and the push silently substitutes main's sha into the lease — producing a(stale info)rejection that looks like "someone else pushed" when nothing moved. This cost a rejected push on the 2026-08-04 pass. Fetch into an explicitrefs/remotes/<fork-remote>/<branch>and rev-parse that, as above; it survives any number of intervening fetches.
10. Post the remediation summary
After pushing folds, post one structured comment on the PR:
- the review verdict;
- each fold, with its why;
- squad verdicts, if a squad ran;
- local test evidence — counts, plus
ruff/mypyresults; - pre-existing failures called out with the filed issue number;
- the state: e.g. "watching CI; merge-ready on green".
Contributor-education notes (for example, which file is the canonical changelog) go in this comment too, addressed to the author.
11. Hand-off — the operator merges
The operator merges. A landing pass asks once at its start whether to arm
auto-merge (gh pr merge <N> --rebase --auto) when the PR is merge-ready; the
default is not to, and without an explicit yes for that run nobody but the
operator runs gh pr merge. The hand-off deliverable is:
- green CI;
- the PR ready for review (the author flipped it, or the operator told you to land it while it was still a draft);
- the evidence trail on the PR;
- landing-order constraints stated explicitly — for example, a structural cutover riding one PR forces an order on the rest of the pass.
12. Follow-up hygiene
Most of what a landing pass discovers should be folded, not filed — see the fold-first default in step 8. What genuinely cannot be folded — a finding that is highly impactful, takes significant effort, or needs a significant operator decision — gets a tracked home the same day: filed, labeled, and parented under a functional epic (never a meta rollup). New issues get processed by a triage pass immediately, so the next landing pass starts from a clean queue.
Gotchas
Field notes from the 2026-07-04 landing pass. Where the friction has since been fixed, the end-state is stated instead of the trap.
The changelog has one canonical home.
docs/changelog/CHANGELOG.mdis the canonical changelog; the rootCHANGELOG.mdis a symlink to it. Edits and conflict resolutions land in the canonical file either way — there is no longer a generated root mirror to trip docs-freshness.--force-with-leaseon never-fetched fork branches. See step 9: use the explicit<branch>:<sha>lease form, and take the sha fromgit rev-parse— never retype it from an abbreviated display.Pre-existing main breakage surfaces mid-pass. One broken contract on main (#2339: dotted
migration_idvs the dry-run JSON contract) turned local runs red on every rebased branch in the pass. The default is to fold the fix (step 4) — that clears main and every rebased branch inherits the green. When you can't fold (ap0_repro-marked red-first test, or a mission-scope fix), file early; the filed issue is what lets subsequent PRs skip re-reproducing it.Saturated tolerance bands trip on the next legitimate change. The CLI visible-count census sat at the top of its band, so the next legitimate command (#2338) tripped it. A saturated band needs a re-pin with a dated rationale — e.g. the 2026-07-04 re-pin at 236 visible (tolerance 212–259) in
tests/docs/test_check_cli_reference_freshness.py; the current values live in that test, not here. That re-pin-in-the-same-PR pattern is the model for the second bin of step 4.Seam completeness gates are invisible to contributors. The
tasks_*.pymodules undersrc/specify_cli/cli/commands/agent/are found on disk, so no test file keeps a hand list of them. A moved or new symbol still needs its re-export block intasks_move_task.py(for a move-task seam) and inagent/tasks.py, and a patch must target the module whose globals the call reads (see the rule in thetasks_move_task_gates.pymodule docstring). Three tests catch a miss:test_tasks_compat_surface.py(a re-export that is a copy),test_tasks_move_task_seams.py(a missing re-export ontasks_move_task) andtest_tasks_patch_targets_live.py(a patch that intercepts nothing). A newtasks_move_task_*.pymodule must also join the two scan lists intests/architectural/test_exemption_registry_ratchet.pyandtests/architectural/test_no_write_side_rederivation.py. Expect this as a fold on PRs that touch decomposed command modules.CI-only architectural gates land late. Repo-wide gates (terminology, shim retirement, seam boundaries) run in the architectural battery: the always-on
architectural fast gates (ratchet/census, always-on)job and the twoarchitectural battery (heavy, code-scoped)legs (1/2and2/2). A PR can pass every module shard and still fail in a heavy leg later. Run the specifictests/architectural/gate files your diff implicates on the rebased tip before declaring a branch green; do not sweep the whole directory (NO_FULL_HEAVY_SUITES_IN_MISSION, see known friction points).Shard path-filters mask pre-existing failures. The
changesfilter skips shards such asmodule-tests (cli shard 1/2)on PRs that do not touch those paths, so a pre-existing red only surfaces on the first PR that does — the innocent PR wears the failure. Classify it as pre-existing (bin three of step 4), not as the PR's defect. Your own folds trigger this too: a fold that touches a new path un-skips that path's shard, so the pass surfaces reds the PR never caused. On 2026-08-04 a fold undercli/commands/agent/un-skippedfast-tests-agent(the then-current job name; the agent tests now run in theagentmodule row ofci-modules.yml) and exposed a golden-contract drift that reproduced cleanly onabca7ec96. Re-classify after each batch of folds, not only at the start.File-scoped linters lint the whole file, not your diff.
markdownlintruns over changed files, so editing one line of a long-lived document subjects its entire pre-existing violation set to the gate. Touchingdocs/changelog/CHANGELOG.mdsurfaced ~21 pre-existingMD049findings spread across the file. Expect this on any changelog or large-doc fold, and budget for fixing the file rather than only your lines. Run the repo's own markdownlint invocation (see the step in.github/workflows/ci-quality.yml) rather than a default config.Moving or adding a test file trips completeness baselines. New or relocated test files must join their registries in the same commit as the move, each with a dated rationale. Search the repo for the new filename before committing. Never key an allowlist by line number or whole file (banned by #2077 /
DIRECTIVE_041) — use content descriptors.Verify architectural gates from a non-dot path. Gates that walk the tree can silently skip dot-prefixed path segments, so a run from
.worktrees/…or.claude/worktrees/…can report a false green. Confirm from a plain checkout before declaring a gate satisfied:git worktree add /tmp/verify-<N> <sha> cd /tmp/verify-<N> && PWHEADLESS=1 uv run pytest tests/architectural/<gate>.py -qA frozen-contract drift with
missing: []means flags were added, not removed. Read the direction before acting: extras with nothing missing is a pin that was never joined when a feature shipped, so the fix is to re-pin with a dated rationale. Never remove a shipped flag to satisfy a stale pin — that smuggles a breaking change into a landing pass. Trace each addition (git log -S'--flag-name' -- src/) and confirm it predates the merge-base before re-pinning.Never green-wash a red by "updating the test". Twice on the 2026-08-04 pass the obvious read — new gating narrowed a payload, so the assertion is stale — was wrong: the rendered output was byte-identical on both sides and the real defect was a product bug (a
str.replacedeleting a section header along with its body). Prove the assertion is wrong before changing it, by showing the expected behaviour genuinely changed. If the test is right, fix the product even when that is the larger job.Run doc scripts as modules. The docs scripts import
scripts.docs.*as a package; runuv run python -m scripts.docs.<x>, never the barescripts/docs/<x>.pypath, which crashes withModuleNotFoundError: scripts:uv run python -m scripts.docs.check_docs_freshness --ciRegenerating the CLI reference. The canonical reference is
docs/api/cli-commands.md; pass the outputs explicitly:uv run python -m scripts.docs.build_cli_reference \ --output docs/api/cli-commands.md \ --agent-output docs/api/agent-subcommands.mdPer-worktree venv rebuild. The first
uv runin a fresh landing worktree rebuilds the virtualenv (~40 s + disk). Budget for it; do not debug it.Do NOT recapture shard-timings during a landing (#5189, #5536). When a PR adds or removes tests in a registry module, that module's committed count and durations in
.github/ci-shard-timings.jsonno longer match live collection, so the exact-count checks intests/architectural/test_module_length_agreement.py(for exampletest_charter_agrees_with_live_collectionandtest_every_registry_module_agrees_with_live_collection) drift. This is the one count-pinning anchor the general "recapture a drifted count anchor" advice does not apply to, for any module. Per PR the drift is a non-blockingShardTimingsDriftWarning, not a failure. The strict exact-count gate lives inci-shard-recapture.yml(schedule+workflow_dispatch, no PR/push trigger, so it cannot run on a PR or block merge). Itsrecapture-shard-timingsjob counts every module's tests on each scheduled run on the primary branch (main), recaptures the drifted ones within a 70-minute budget (the rest are deferred to the next run) and opens or refreshes one pull request onci/recapture-shard-timings. Until that proposal merges, the strict check is red on the scheduled run; that is the accepted window. So note the drift as automation-owned in the hand-off and move on: a local recapture of a large module takes 18-30 minutes of machine-specific measurement for a gate that cannot block the PR. If the scheduled job captured but could not push, the publish step names the token secret to replace (see #5624). See Scheduled shard-timings recapture.A
docs(landing)fold that edits akitty-specs/**mission dossier redsarchive freeze. Archived mission dossiers are frozen byte-identical (tests/architectural/test_archive_root_byte_identical.py, the always-onarchive freezejob; this is the #4260 "rewriting archived dossiers unjudged" class). If a squad flags that a cited archived contract now contradicts shipped behavior, do not edit the frozen dossier — record the supersession in the live code that cites it (the resolver docstring), which is the correct place to overturn a frozen snapshot's stale claims. Reproduce the gate locally before pushing any fold that toucheskitty-specs/**.A script that only a post-merge workflow runs is not exercised by PR CI.
ci-aggregate.ymlisworkflow_run-triggered. It runs for pull requests too, but from the default-branch checkout, so a PR's change to that workflow, or to ascripts/ci/*file it calls, is first exercised after the merge, and its check does not attach to the PR head. When a PR edits such a script, run it the way the workflow does before merge:python3 scripts/ci/<name>.pyfrom the repository root withPYTHONPATHunset, and confirm it starts. A direct-run script must insert the repository root onsys.pathbefore importingscripts.*orkernel.*(seescripts/ci/select_source_artifacts.py). Add a test that runs it as a subprocess.Formatting an excluded file is a companion change.
[tool.ruff.format].excludeinpyproject.tomlis a shrink-only list of files with formatter debt. Format only withuv run --frozen ruff format --force-exclude <files>(or check withmake format-check-files FILES=<paths>). If you deliberately format a listed file clean, its entry is stale: remove it in the same commit, and runtests/architectural/test_ruff_format_exclude_ratchet.pyafter the reformat, not only before. A whole-reporuff format --check .skips excluded files, so it does not catch the stale entry.Docs checks that fail only in CI. The frontmatter
descriptionmust be 50 to 180 characters (uv run python -m scripts.docs.description_length_check), and a changelog entry over 1,200 characters failstests/docs/test_changelog_style.py. Re-rundocs_index --writeafter you edit a description again, since the retrieval index stores it. A new ADR for a governance or process decision goes indocs/adr/4.x/YYYY-MM-DD-N-<slug>.mdwith status Accepted; check the day's highest-N-onmainfirst. Markdownlint is advisory in CI, so do not spend landing time on a file's pre-existing findings.
PR-body contract
Spec Kitty PRs use a fixed body shape; an ad-hoc body (Summary/Changes/Why/…) draws a
squad MAJOR and blocks merge even when the diff is perfect. The shape is the one in
.github/PULL_REQUEST_TEMPLATE.md; start from
that template. Exactly five sections, in order:
## Issue— must carry acloses #<n>link. If no tracking issue exists, file/claim one first (a docs-gap issue counts), name the branchissue-<n>-<slug>(not a baredocs/<slug>), and align the PR title to the[#<n>] …form.## Change— what changed and why.## Tests run— must contain a nestedSelf-review:block, and every recorded command must be runnable verbatim (fulltests/…/docs/…paths; invoke doc scripts asuv run python -m scripts.docs.<x>, never the barescripts/docs/<x>.pypath, which failsModuleNotFoundError: scripts).## Blast radius— aDiscovery:line (the git-grep command) plusFiles:(paths re-derived from a fresh run of it).## Deferred— anything intentionally left for follow-up.
The shape is defined by the template above; the internal validator
packs/internal/assets/validate-pr-body.py (stdlib only; internal pack, never shipped to
consumers) enforces it, and the fleet squad runs it. It checks the five sections in order,
a closes #<n> line, a Self-review: block inside Tests run, runnable test commands, and
Discovery: plus Files: in Blast radius, and it rejects a body that still carries the
template's placeholders. Run it before opening, from a file or stdin:
python packs/internal/assets/validate-pr-body.py body.md
gh pr view <n> --json body -q .body | python packs/internal/assets/validate-pr-body.py -
If you do not have a Spec Kitty source checkout with this internal asset, check the body
against the template manually: keep exactly the five headings above in order; put an
own-line Closes #<n> in Issue; list runnable commands and their results plus a
Self-review: block in Tests run; record the actual git grep discovery command and
its matching paths under Files: in Blast radius; and remove all template placeholders.
After the last rebase, prove the Files: list against a fresh run of the Discovery:
git grep at the head you are landing (only a git grep command is ever executed):
python packs/internal/assets/validate-pr-body.py body.md --verify-discovery --expected-head <sha>
Exit 0 means valid; exit 1 prints one section: problem line per violation; exit 2 is a
usage error. Note the description-length SEO gate
(50–180 chars) is CI-only — run scripts/docs/description_length_check.py (or the
docs SEO tests) as part of any docs blast radius.
See also
- Review gates: pre-PR / pre-review checklist — the contributor-side hygiene this runbook assumes.
- Test-flakiness handling policy — the never-retry-to-green rule and budget-gate tuning.
- Guides index