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.

  1. Claim before touching
  2. One isolated worktree per PR
  3. Rebase onto current upstream/main first
  4. Classify every red check
  5. Folds: remediation commits on the contributor branch
  6. Red-first verification for bugfix PRs
  7. Review focus areas beyond CI
  8. Adversarial squad for architectural or API-surface PRs
  9. Push discipline
  10. Post the remediation summary
  11. Hand-off — the operator merges
  12. 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 -S and 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 current main? 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 --ci to see which one is stale, and regenerate it with its own script, for example uv run python -m scripts.docs.docs_index --write (docs retrieval index) and uv 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 add Closes #<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 passed on main against 1 failed, 583 passed on 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.py deselects them unless SPEC_KITTY_RUN_P0_REPRO=1 is set, and only the nightly p0-repro job 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 regression marker is a different thing: it pins a bug that is already fixed, and it runs per PR. A red regression test 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 pytestmark may 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 forms
    
  • A 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 nightly p0-repro lane, so the PR still lands green. Do not mark it regression, 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 — if upstream/main has moved on, this includes both "what the PR changed" and "what upstream/main changed since," conflated into one diff.
  • Three-dot (...) diffs from git merge-base upstream/main HEAD to HEAD — only the PR branch's own commits, regardless of how far upstream/main has 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 revert or bisect ambiguous.

  • Label the commit subject <type>(landing): ... — a Conventional Commits type, with landing as 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 both type-empty and subject-empty. It went unnoticed for several landing passes because commitlint only lints commits in the PR range — main's own landing fold: commits were never checked, so the violation only ever surfaces on the PR that carries the folds. commitlint is not a devDependency, so mirror CI's own invocation (ci-quality.yml, the commitlint step) 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")"
    done
    

    Allowed types come from type-enum in commitlint.config.cjs (build chore ci docs feat fix lint perf plan refactor revert spec style test). There is no scope-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 under packs/built-in/ and src/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 ## Deferred section 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.py and 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.
  • cd into 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 on main — and a cherry-pick there silently commits landing folds onto local main. This happened on the 2026-08-04 pass: three commits went onto main before the branch name in git's output gave it away. It is fully recoverable (nothing was pushed; git reset --hard upstream/main restored 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:
    cd <landing-worktree> && test "$(git branch --show-current)" = "<landing-branch>" \
      && git cherry-pick <sha>...
    
    Re-check the fold count after each cherry-pick (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 X and Revert "X" plus a re-landed X'. 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 pending with a reason is worth more than one marked pass without 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:

  1. A bare --force-with-lease fails 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.
  2. 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.
  3. rev-parse FETCH_HEAD is only valid immediately after fetching the ref you mean. FETCH_HEAD is a single global file that every fetch overwrites, so a git fetch upstream main between 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 explicit refs/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 / mypy results;
  • 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.md is the canonical changelog; the root CHANGELOG.md is 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-lease on never-fetched fork branches. See step 9: use the explicit <branch>:<sha> lease form, and take the sha from git rev-parse — never retype it from an abbreviated display.

  • Pre-existing main breakage surfaces mid-pass. One broken contract on main (#2339: dotted migration_id vs 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 (a p0_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_*.py modules under src/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 in tasks_move_task.py (for a move-task seam) and in agent/tasks.py, and a patch must target the module whose globals the call reads (see the rule in the tasks_move_task_gates.py module 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 on tasks_move_task) and test_tasks_patch_targets_live.py (a patch that intercepts nothing). A new tasks_move_task_*.py module must also join the two scan lists in tests/architectural/test_exemption_registry_ratchet.py and tests/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 two architectural battery (heavy, code-scoped) legs (1/2 and 2/2). A PR can pass every module shard and still fail in a heavy leg later. Run the specific tests/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 changes filter skips shards such as module-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 under cli/commands/agent/ un-skipped fast-tests-agent (the then-current job name; the agent tests now run in the agent module row of ci-modules.yml) and exposed a golden-contract drift that reproduced cleanly on abca7ec96. Re-classify after each batch of folds, not only at the start.

  • File-scoped linters lint the whole file, not your diff. markdownlint runs over changed files, so editing one line of a long-lived document subjects its entire pre-existing violation set to the gate. Touching docs/changelog/CHANGELOG.md surfaced ~21 pre-existing MD049 findings 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 -q
    
  • A 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.replace deleting 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; run uv run python -m scripts.docs.<x>, never the bare scripts/docs/<x>.py path, which crashes with ModuleNotFoundError: scripts:

    uv run python -m scripts.docs.check_docs_freshness --ci
    
  • Regenerating 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.md
    
  • Per-worktree venv rebuild. The first uv run in 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.json no longer match live collection, so the exact-count checks in tests/architectural/test_module_length_agreement.py (for example test_charter_agrees_with_live_collection and test_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-blocking ShardTimingsDriftWarning, not a failure. The strict exact-count gate lives in ci-shard-recapture.yml (schedule + workflow_dispatch, no PR/push trigger, so it cannot run on a PR or block merge). Its recapture-shard-timings job 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 on ci/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 a kitty-specs/** mission dossier reds archive freeze. Archived mission dossiers are frozen byte-identical (tests/architectural/test_archive_root_byte_identical.py, the always-on archive freeze job; 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 touches kitty-specs/**.

  • A script that only a post-merge workflow runs is not exercised by PR CI. ci-aggregate.yml is workflow_run-triggered. It runs for pull requests too, but from the default-branch checkout, so a PR's change to that workflow, or to a scripts/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>.py from the repository root with PYTHONPATH unset, and confirm it starts. A direct-run script must insert the repository root on sys.path before importing scripts.* or kernel.* (see scripts/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].exclude in pyproject.toml is a shrink-only list of files with formatter debt. Format only with uv run --frozen ruff format --force-exclude <files> (or check with make format-check-files FILES=<paths>). If you deliberately format a listed file clean, its entry is stale: remove it in the same commit, and run tests/architectural/test_ruff_format_exclude_ratchet.py after the reformat, not only before. A whole-repo ruff format --check . skips excluded files, so it does not catch the stale entry.

  • Docs checks that fail only in CI. The frontmatter description must be 50 to 180 characters (uv run python -m scripts.docs.description_length_check), and a changelog entry over 1,200 characters fails tests/docs/test_changelog_style.py. Re-run docs_index --write after you edit a description again, since the retrieval index stores it. A new ADR for a governance or process decision goes in docs/adr/4.x/YYYY-MM-DD-N-<slug>.md with status Accepted; check the day's highest -N- on main first. 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 a closes #<n> link. If no tracking issue exists, file/claim one first (a docs-gap issue counts), name the branch issue-<n>-<slug> (not a bare docs/<slug>), and align the PR title to the [#<n>] … form.
  • ## Change — what changed and why.
  • ## Tests run — must contain a nested Self-review: block, and every recorded command must be runnable verbatim (full tests/… / docs/… paths; invoke doc scripts as uv run python -m scripts.docs.<x>, never the bare scripts/docs/<x>.py path, which fails ModuleNotFoundError: scripts).
  • ## Blast radius — a Discovery: line (the git-grep command) plus Files: (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