Compare commits

...

3 Commits

Author SHA1 Message Date
Miguel Palhas 5608c61b20 feat(land): detect and resolve base conflicts 2026-08-19 16:34:13 +01:00
Miguel Palhas 435478e7ab fix(land): re-request copilot only when its review is stale 2026-08-19 16:31:04 +01:00
Miguel Palhas f486d0bd1f feat(land): watch a PR until it merges
The loop used to stop the watcher once CI was green and the PR approved,
so comments arriving hours or days later were never seen. It now runs
through a phase machine (draft -> review -> ready -> done) and only stops
when the PR is merged or closed.

Also adds a draft-until-green-light policy (inferred for subvisual repos,
configurable via prDraft), Copilot re-requests after every code push, poll
backoff while quiet, watcher liveness checks on wake, and an on-disk state
file so a compacted or restarted session can resume.
2026-08-19 16:27:32 +01:00
4 changed files with 127 additions and 37 deletions
+1 -1
View File
@@ -110,7 +110,7 @@ Drop a new `skills/<name>/SKILL.md` (+ optional `scripts/`, `references/`, `asse
|-------|------|
| `work` | tracker issue → worktree → PR → hands off to `land` |
| `yolo` | quick ship; optional `land` handoff |
| `land` | drive an open PR to green + ready-to-merge; user clicks merge (canonical CI/review loop) |
| `land` | drive an open PR to green + ready-to-merge, then keep watching until it merges; user clicks merge (canonical CI/review loop) |
| `blitz` | drive a whole milestone to done |
| `nightshift` | hours-long unattended build; architect delegating to subagents, backs off before the 5h limit |
| `linear-common` | shared config/setup/worktree conventions + local verification budget (dependency of work/yolo/blitz/nightshift) |
+114 -34
View File
@@ -1,6 +1,6 @@
---
name: land
description: "Drive an existing PR to ready-to-merge: wait for CI + reviews, fix failures, resolve every comment, push, iterate until green + approved, then hand the merge click to the user. Never merges. Forge-agnostic (GitHub or Gitea)."
description: "Drive an existing PR to ready-to-merge and stay on it until it merges: wait for CI + reviews, fix failures, resolve every comment, push, iterate. Handles draft-until-green-light repos and requests Copilot review on GitHub. Never merges. Forge-agnostic (GitHub or Gitea)."
user-invocable: true
args:
- name: target
@@ -12,11 +12,13 @@ args:
Takes an **already-open PR** and shepherds it to the merge button: green CI, all review threads resolved, approved, branch up to date. Spends **zero model tokens idling** — waits by arming background watchers that wake on real events, never by polling on a timer.
**The loop ends when the PR is merged or closed, not when it goes green.** Green + approved is a milestone: notify the user, then keep the watcher armed. Review comments arrive hours or days later, and a PR that sat for two days still needs its next comment answered. The only other way out is the user saying stop, or the session ending (see "Resuming" — re-entry picks up from the state file).
**Never merge.** The final merge click is always the user's — on every repo, every forge. Public repos with other contributors and client repos need a human gate, and a single click on private repos is cheap. No `gh pr merge`, no merge API call, no `--auto`.
This is the canonical review/CI-iteration loop. `/work` opens a PR then hands off here; `/yolo` can hand off here when a PR flow is wanted. It also stands alone: `/land 47`, `/land <url>`, or `/land` on a branch that already has a PR.
**Config:** reads `.claude/tracker.json` (or legacy `.claude/linear.json`) at the repo root if present — see `linear-common/COMMON.md` (sibling skill, same skills root). No config needed to just land a PR; config only adds tracker-issue closing and `remoteHost` selection.
**Config:** reads `.claude/tracker.json` (or legacy `.claude/linear.json`) at the repo root if present — see `linear-common/COMMON.md` (sibling skill, same skills root). No config needed to just land a PR; config adds tracker-issue closing, `remoteHost` selection, `prReviewers`, and the `prDraft` policy.
## 1. Resolve the target
@@ -35,18 +37,40 @@ This is the canonical review/CI-iteration loop. `/work` opens a PR then hands of
- github: `OWNER`/`REPO` from origin. Set with `gh`.
- gitea: `BASE=$remoteBaseUrl` (or the origin host), `REPO=<owner>/<repo>` from origin, `$GITEA_TOKEN` in env (`source ~/.env.claude` if missing). Never put the token in a URL.
- **Tracking issue `REF`** (optional): parse `Closes <REF>` / `Closes #<n>` from the PR body. Used only for the Linear follow-up note in close-out; skip silently if absent.
- **Draft policy** — see §2.
Then run the variant for your forge below. Both share these **terminal conditions** (all must hold before handing off):
- CI checks pass
- All review threads resolved
- Approved, no pending review requests
## 2. Phases
## 2. Efficiency rules (both variants)
A PR moves through these phases. The phase decides who gets asked for what; the watcher stays armed across all of them.
**Draft policy.** `prDraft` in config: `never` (default) or `until-green-light`. If unset, infer `until-green-light` when origin is a **client repo**`github.com[:/]subvisual/` — and `never` otherwise. A PR that is already published is never pushed back to draft, whatever the policy says.
| Phase | Entered when | What happens |
|---|---|---|
| **draft** | policy is `until-green-light` and the PR is a draft | CI + bot review only. Do not request human reviewers. On CI green with every bot thread addressed, tell the user it's ready to publish and wait. |
| **review** | policy is `never`, or the user gave the green light | Human + bot reviewers requested. Fix failures, answer every thread. |
| **ready** | CI green, approved, no pending review requests, all threads resolved | Update the branch, notify the user, keep watching. |
| **done** | PR merged or closed | Close out (§6) and stop the watcher. |
**Publishing a draft needs an explicit yes.** Silence, a timeout, or "user may be away" is not a green light — keep waiting. On a yes:
```bash
gh pr ready <N>
gh pr edit <N> --add-reviewer <r1>,<r2> # from prReviewers, if configured
```
Gitea: `PATCH $BASE/api/v1/repos/$REPO/pulls/$N` with `{"body": ...}` does not toggle draft — drop the `WIP:` title prefix instead (`{"title": "<title without WIP:>"}`), then request reviewers.
## 3. Efficiency rules (both variants)
- **Baseline once.** Right after resolving `N`, snapshot existing review-comment IDs to `<git-dir>/pr-<N>-seen`. Every later pass processes only IDs not in that file — handled feedback is never re-read. Guard it so a re-entry after a wake never truncates + reseeds (that would reprocess everything).
- **Wake on events, not a clock.** CI is minutes; human review is hours. Block a background watcher (Bash `run_in_background` for one-shot "CI done"; `Monitor persistent` for the whole review window) and stay idle until something actually happens. Handle exactly what the watcher reports, then re-arm.
- **Stall guard.** A watcher must never hang forever on a stuck pipeline. Bound every CI wait: if no check appears within ~3 min of a push, or a run sits in-progress past a sane ceiling (default ~20 min, or the repo's known CI duration ×2), **stop waiting and surface it to the user** — don't keep idling. Silence is not success.
- **Fix everything.** Every unresolved thread gets an action — a code fix or a reply. Bot reviewers (crit, CodeRabbit, Copilot, etc.) count. Don't declare ready over an unaddressed thread.
- **Keep state on disk.** Maintain `<git-dir>/pr-<N>-state.md`: phase, draft policy, head SHA at last push, what each round of feedback asked for and what you changed, and anything you're waiting on from the user. Update it whenever one of those changes — one short line per event, not a transcript. This file, plus `pr-<N>-seen`, is the whole loop's memory.
- **Survive compaction.** A review window can span days, so the conversation will be summarized out from under you. After any wake following a long gap, re-read `pr-<N>-state.md` and the PR's own thread list before acting — trust those over anything you seem to remember. Don't restate old context in chat to keep it alive; that's what the file is for. Keeping the working set small is also what keeps the prompt cache useful across a long window.
- **Wake on events, not a clock.** CI is minutes; human review is hours or days. Block a background watcher (Bash `run_in_background` for one-shot "CI done"; `Monitor persistent` for the whole review window) and stay idle until something actually happens. Handle exactly what the watcher reports, then re-arm.
- **Back off while quiet.** The review watcher polls every 30s right after activity, every 5 min once an hour has passed with nothing, every 15 min after a day. Any event resets it to 30s. This is what makes a multi-day window cheap.
- **Check the watcher is alive on every wake.** `TaskList`; if the review Monitor is gone (crashed, rate-limited out, killed with the last session), re-arm it before doing anything else. A dead watcher looks exactly like a quiet PR.
- **Stall guard — for CI only.** Bound every CI wait: if no check appears within ~3 min of a push, or a run sits in-progress past a sane ceiling (default ~20 min, or the repo's known CI duration ×2), stop waiting and surface it. Silence is not success. This guard does **not** apply to the review watcher — a quiet PR is normal and is not a stall.
- **Fix everything.** Every unresolved thread gets an action — a code fix or a reply. Bot reviewers (Copilot, crit, CodeRabbit, etc.) count. Don't declare ready over an unaddressed thread.
---
@@ -61,13 +85,19 @@ if [ ! -f "$seen" ]; then
fi
```
**Copilot review** (once, right after baselining): if Copilot has neither reviewed nor been requested, request it. Its comments then flow through the normal comment watcher like any other bot reviewer.
**Copilot review.** On GitHub, Copilot is a reviewer you always ask for — including on drafts, where it's the only review happening. Request it right after baselining. Its comments then flow through the normal comment watcher like any other bot reviewer.
**Do not re-request it after every push** — that's a review round per commit for no new signal. Re-request only when its last review has gone stale (**2 days or older**) and code has changed since, or when it has never reviewed at all:
```bash
if ! gh pr view <N> --json reviews,reviewRequests --jq '.. | .login? // empty' | grep -qi copilot; then
last=$(gh api repos/<OWNER>/<REPO>/pulls/<N>/reviews \
--jq '[.[] | select(.user.login | test("copilot";"i")) | .submitted_at] | max // ""' 2>/dev/null)
if [ -z "$last" ] || [ $(( ($(date +%s) - $(date -d "$last" +%s)) / 86400 )) -ge 2 ]; then
gh api -X POST repos/<OWNER>/<REPO>/pulls/<N>/requested_reviewers \
-f 'reviewers[]=copilot-pull-request-reviewer[bot]' >/dev/null 2>&1 || true # repo may not have Copilot review enabled
-f 'reviewers[]=copilot-pull-request-reviewer[bot]' >/dev/null 2>&1 \
|| echo "copilot review unavailable on this repo" # not enabled, or already pending
fi
```
Before declaring the draft phase done, wait for Copilot's first review to actually land — it takes a few minutes, and shipping "ready" before it arrives just means another round.
**Wait for CI** (after every push): block in the background — one wake when checks reach a terminal state. Do NOT poll `statusCheckRollup` in a loop.
```
@@ -78,15 +108,27 @@ gh pr checks <N> --watch --fail-fast
- Non-zero → CI failed. Read only the failing job: `gh run view <run-id> --log-failed`. Fix, commit, push, re-arm this watcher.
- No checks appear within the stall window → surface to user (see stall guard).
**Wait for review comments** (whole review window): arm one persistent Monitor that emits a line per *new* comment on any unresolved thread, with its thread id.
**Watch the PR** (whole review window, all phases): one persistent Monitor that emits a line per *new* comment on any unresolved thread, plus a line whenever the PR's state, draft flag, or review decision changes. It exits only when the PR is merged or closed.
```bash
# Monitor persistent: true
seen="$(git rev-parse --git-dir)/pr-<N>-seen"; touch "$seen"
q='{repository(owner:"<OWNER>",name:"<REPO>"){pullRequest(number:<N>){state isDraft mergeable reviewDecision reviewThreads(first:100){nodes{id isResolved comments(first:50){nodes{id author{login} body}}}}}}}'
prev=""; iv=30; quiet=0
while true; do
gh api graphql -f query='{repository(owner:"<OWNER>",name:"<REPO>"){pullRequest(number:<N>){reviewThreads(first:100){nodes{id isResolved comments(first:50){nodes{id author{login} body}}}}}}}' \
--jq '.data.repository.pullRequest.reviewThreads.nodes[]|select(.isResolved==false)|.id as $tid|.comments.nodes[]|"\(.id)\t\($tid)\t\(.author.login): \(.body)"' 2>/dev/null \
| while IFS=$'\t' read -r cid tid rest; do grep -qxF "$cid" "$seen" || { echo "NEW COMMENT $cid (thread $tid) — $rest"; echo "$cid" >> "$seen"; }; done
sleep 30
p=$(gh api graphql -f query="$q" 2>/dev/null) || { sleep "$iv"; quiet=$((quiet+iv)); continue; }
# mergeable is UNKNOWN for a while after each push — only report the settled CONFLICTING state
st=$(jq -r '.data.repository.pullRequest|"\(.state) draft=\(.isDraft) review=\(.reviewDecision)" + (if .mergeable=="CONFLICTING" then " CONFLICTS" else "" end)' <<<"$p")
new=$(jq -r '.data.repository.pullRequest.reviewThreads.nodes[]|select(.isResolved==false)|.id as $t|.comments.nodes[]|"\(.id)\t\($t)\t\(.author.login): \(.body)"' <<<"$p" \
| while IFS=$'\t' read -r cid tid rest; do
grep -qxF "$cid" "$seen" || { echo "NEW COMMENT $cid (thread $tid) — $rest"; echo "$cid" >> "$seen"; }
done)
[ "$st" != "$prev" ] && { echo "PR STATE $st"; prev=$st; quiet=0; }
[ -n "$new" ] && { echo "$new"; quiet=0; }
case "$st" in MERGED*|CLOSED*) echo "PR FINAL — $st"; break;; esac
if [ "$quiet" -gt 86400 ]; then iv=900
elif [ "$quiet" -gt 3600 ]; then iv=300
else iv=30; fi
sleep "$iv"; quiet=$((quiet+iv))
done
```
@@ -102,18 +144,18 @@ On each `NEW COMMENT` event (`<cid>` = comment id, `<tid>` = thread):
gh api graphql -f query='mutation { resolveReviewThread(input: {threadId: "<tid>"}) { thread { isResolved } } }'
```
**Stop** (`TaskStop` the Monitor) when the terminal condition holds: CI green, approved, no pending review requests (`gh pr view <N> --json reviewDecision,reviewRequests,reviews`), all threads resolved.
On each `PR STATE` event: if `draft=false` after you asked for a green light, move to the review phase and request `prReviewers`. If `MERGED` or `CLOSED`, go to close-out.
**Ready.** A long review loop moves the base, so update the branch and let CI re-run the user's click should be the only step left:
**Reaching ready.** When CI is green, `reviewDecision` is `APPROVED`, there are no pending review requests, and every thread is resolved: update the branch (a long review window moves the base) and let CI re-run, so the user's click is the only step left.
```bash
gh pr update-branch <N> 2>/dev/null || true # rebase/merge default into the PR branch if behind
# if it updated, the CI watcher re-arms on the new head; wait for green again
```
Then go to step 3 (close out). Do **not** run `gh pr merge` in any form.
Then notify the user (§6) — and **leave the Monitor armed**. Ready is not done. Do **not** run `gh pr merge` in any form.
## Gitea variant
Requires `$GITEA_TOKEN` and `remoteBaseUrl` (or origin host). Set `BASE`, `REPO`, `$GITEA_TOKEN`, `N` in the environment first. Gitea has no `--watch`, no GraphQL, no per-thread resolve — same principle, plain REST.
Requires `$GITEA_TOKEN` and `remoteBaseUrl` (or origin host). Set `BASE`, `REPO`, `$GITEA_TOKEN`, `N` in the environment first. Gitea has no `--watch`, no GraphQL, no per-thread resolve, and no Copilot — same principle, plain REST.
**Baseline** (once — guard against re-entry): seed with existing review/issue comment ids.
```bash
@@ -138,16 +180,28 @@ done
```
On `CI failure`/`CI error`: read the failing job's log, fix, commit, push, re-arm. Apply the stall guard — bound the wait.
**Wait for review comments** (whole window): persistent Monitor emitting each new review/issue comment since the last check.
**Watch the PR** (whole window): persistent Monitor emitting each new review/issue comment, plus PR state changes. Exits when the PR is merged or closed.
```bash
# Monitor persistent: true
seen="$(git rev-parse --git-dir)/pr-$N-seen"; touch "$seen"
prev=""; iv=30; quiet=0
while true; do
{ curl -sS -H "Authorization: token $GITEA_TOKEN" "$BASE/api/v1/repos/$REPO/issues/$N/comments"; \
curl -sS -H "Authorization: token $GITEA_TOKEN" "$BASE/api/v1/repos/$REPO/pulls/$N/reviews"; } 2>/dev/null \
| jq -r '.[]? | "\(.id)\t\(.user.login): \(.body // .content // "")"' \
| while IFS=$'\t' read -r id rest; do grep -qxF "$id" "$seen" || { echo "NEW $id — $rest"; echo "$id" >> "$seen"; }; done
sleep 30
pr=$(curl -sS -H "Authorization: token $GITEA_TOKEN" "$BASE/api/v1/repos/$REPO/pulls/$N" 2>/dev/null) \
|| { sleep "$iv"; quiet=$((quiet+iv)); continue; }
st=$(jq -r '"\(.state) merged=\(.merged) draft=\(.draft // false)" + (if .mergeable==false then " CONFLICTS" else "" end)' <<<"$pr")
new=$({ curl -sS -H "Authorization: token $GITEA_TOKEN" "$BASE/api/v1/repos/$REPO/issues/$N/comments"; \
curl -sS -H "Authorization: token $GITEA_TOKEN" "$BASE/api/v1/repos/$REPO/pulls/$N/reviews"; } 2>/dev/null \
| jq -r '.[]? | "\(.id)\t\(.user.login): \(.body // .content // "")"' \
| while IFS=$'\t' read -r id rest; do
grep -qxF "$id" "$seen" || { echo "NEW $id — $rest"; echo "$id" >> "$seen"; }
done)
[ "$st" != "$prev" ] && { echo "PR STATE $st"; prev=$st; quiet=0; }
[ -n "$new" ] && { echo "$new"; quiet=0; }
case "$st" in *merged=true*|closed*) echo "PR FINAL — $st"; break;; esac
if [ "$quiet" -gt 86400 ]; then iv=900
elif [ "$quiet" -gt 3600 ]; then iv=300
else iv=30; fi
sleep "$iv"; quiet=$((quiet+iv))
done
```
@@ -161,22 +215,48 @@ On each `NEW` event:
```
- Gitea has no per-thread resolve — signal addressed by replying with a short confirmation and pushing the fix.
**After approval + green CI** (`TaskStop` the review Monitor first): if the base moved, update the branch and wait for CI green again — the user's click should be the only step left.
**Reaching ready** (approved + green CI): if the base moved, update the branch and wait for CI green again.
```bash
curl -sS -X POST -H "Authorization: token $GITEA_TOKEN" \
"$BASE/api/v1/repos/$REPO/pulls/$N/update" >/dev/null 2>&1 || true
# if it updated, re-arm the CI watcher and wait for green
```
Then go to step 3 (close out). Do **not** call the merge API.
Then notify the user (§6) and leave the Monitor armed. Do **not** call the merge API.
Never embed `$GITEA_TOKEN` in URLs or commit messages — `Authorization` header only.
---
## 3. Close out
## 4. Conflicts with the base
Report to the user: PR ready to merge (link it), CI green, approved, all threads resolved, and a one-line summary of what feedback was addressed. The merge — and the branch delete + tracking-issue close that follow it — is theirs.
A PR sitting through a long review window will eventually conflict. The watcher reports it as `CONFLICTS` in a `PR STATE` line; also check before declaring ready — `gh pr view <N> --json mergeable` (github) or `.mergeable` on the pull object (gitea). GitHub reports `UNKNOWN` for a minute or two after each push while it recomputes; that is not a conflict, just poll again.
The review window is often hours; the user may be away when the PR goes green. Push a notification so the one click can happen from their phone: `mcp__ha-mcp__ha_call_service` with `domain: "notify"`, service `mobile_app_pixel_7_naps` (or `blitz.notifyService` from config), message "PR #<N> ready to merge" + the PR URL.
Resolve by merging the base into the PR branch, not by rebasing — a force-push mid-review detaches existing review comments from their lines and makes reviewers re-read the whole diff.
`Closes <REF>` in the PR body closes the tracking issue automatically on merge (GitHub/Gitea). Only Linear needs follow-up: if config points at Linear, tell the user the issue must be moved to Done after they merge, or move it yourself if you're still around post-merge.
```bash
git fetch origin <defaultBranch>
git merge origin/<defaultBranch> # conflicts stop here
# resolve, then:
git add -A && git commit --no-edit
git push
```
Rules for resolving:
- Read both sides before touching either. The base side is someone else's landed work — keep its intent, don't flatten it back to your version because your version is the one you remember.
- Resolve only what you actually understand. If the conflict is semantic (the two sides changed the same behaviour in incompatible ways, or a rename on one side collides with new callers on the other), stop and ask the user rather than guessing.
- After resolving, run the `buildCommand` before pushing. A clean textual merge that doesn't compile is the common failure here.
- The push re-triggers the CI watcher. Note the merge in `pr-<N>-state.md`.
If the branch is merely **behind** with no conflicts, don't do this by hand — `gh pr update-branch <N>` (github) or the `/update` endpoint (gitea) covers it, as in the ready step.
## 5. Resuming
Watchers die with the session. Re-running `/land <N>` on the same PR resumes rather than restarting: `pr-<N>-seen` means already-handled feedback is not reprocessed, and `pr-<N>-state.md` says which phase you were in and what you were waiting on. Read both, re-arm the watchers, and continue. Say in one line what you're picking up from.
## 6. Notifying and closing out
**At ready** (CI green, approved, threads resolved, branch up to date): tell the user the PR is ready to merge (link it) with a one-line summary of what feedback was addressed. The review window is often hours and the user may be away, so push a notification so the one click can happen from their phone: `mcp__ha-mcp__ha_call_service` with `domain: "notify"`, service `mobile_app_pixel_7_naps` (or `blitz.notifyService` from config), message "PR #<N> ready to merge" + the PR URL. Then keep watching — a comment can still land after approval.
**When waiting on a green light** to publish a draft: same notification, message "PR #<N> ready to publish".
**At done** (merged or closed): stop the watcher (`TaskStop`), report the outcome in one line, and delete the state files. `Closes <REF>` in the PR body closes the tracking issue automatically on merge (GitHub/Gitea). Only Linear needs follow-up: move the issue to Done, or tell the user to if you can't. The merge itself, and the branch delete, stay the user's.
+1
View File
@@ -74,6 +74,7 @@ GitHub-issues example (`tracker: github`):
| `setupCommands` | no | Commands to run inside a new worktree (install deps, etc.) |
| `contextFiles` | no | Files to read before coding (specs, architecture docs) |
| `prReviewers` | no | Reviewer usernames to request reviews from (`/work` only) |
| `prDraft` | no | `never` (default) or `until-green-light`. Under `until-green-light`, `/work` opens the PR as a draft with no reviewers, and `/land` publishes it only after the user explicitly says to. When unset, `until-green-light` is inferred for client repos (origin matches `github.com[:/]subvisual/`). |
| `labels` | no | Default issue labels for ad-hoc issues |
| `remoteHost` | no | `github` (default) or `gitea`. Selects which API `/work` uses for PR creation and review polling. `/yolo` is unaffected. Implied `gitea` when `tracker: gitea`, `github` when `tracker: github`. |
| `remoteBaseUrl` | no | Required when `remoteHost: gitea` or `tracker: gitea`. Base URL of the Gitea instance (e.g. `https://git.naps.pt`). |
+11 -2
View File
@@ -64,7 +64,8 @@ Advisory, not a hard gate: for a trivial diff (typo, one-liner, config bump) ski
<what was tested and how>
```
where `<REF>` is the Linear issue ID (`ERN-347`) for `tracker: linear`, or `#<N>` for `tracker: gitea` / `tracker: github` (both auto-close the issue when the PR merges to the default branch).
- Request reviewers from `prReviewers` if configured.
- Request reviewers from `prReviewers` if configured — **unless the PR opens as a draft** (see below), in which case reviewers are requested later, when the user publishes it.
- **Draft or not**: `prDraft` in config — `never` (default) or `until-green-light`. If unset, infer `until-green-light` when origin is a client repo (`github.com[:/]subvisual/`), `never` otherwise. Under `until-green-light` the PR opens as a draft and only `/land` publishes it, after the user says so.
4. Move the tracking issue to "In Review":
- **linear**: set the issue status to "In Review" (or equivalent).
- **gitea**: no review state exists — leave the issue open (the PR's `Closes #N` closes it on merge); optionally add an `in-review` label if one already exists in the repo.
@@ -72,7 +73,7 @@ Advisory, not a hard gate: for a trivial diff (typo, one-liner, config bump) ski
### 5. Hand off to `/land`
The PR is open — now drive it to ready-to-merge. **Invoke `/land <N>`** (the `land` skill). It owns the whole review/CI iteration loop: waits for CI + reviews without idling, fixes failures, resolves every comment (including bot reviewers), pushes, re-arms, and once green + approved it updates the branch and hands the merge click to the user — it never merges.
The PR is open — now drive it to ready-to-merge. **Invoke `/land <N>`** (the `land` skill). It owns the whole review/CI iteration loop: waits for CI + reviews without idling, fixes failures, resolves every comment (including bot reviewers), pushes, re-arms. Once green + approved it updates the branch and hands the merge click to the user, then keeps watching until the PR actually merges or closes — it never merges. It also owns publishing a draft PR, on the user's green light.
Do not re-implement that loop here — `/land` is the single source of truth for it, and it reads the same `remoteHost` / tracker config. `/land` derives the tracking issue from the PR body's `Closes <REF>`, so no extra hand-off state is needed.
@@ -86,6 +87,12 @@ The success bar `/land` enforces (all must hold before it declares ready): CI gr
gh pr create --title "<title>" --body "<body>" --reviewer <r1>,<r2>
```
Draft policy `until-green-light` — no reviewers yet, `/land` requests them on publish:
```
gh pr create --draft --title "<title>" --body "<body>"
```
Then go to step 5 (`/land <N>`).
## Gitea variant (open PR)
@@ -103,6 +110,8 @@ curl -sS -X POST \
The response includes `number` and `html_url`. Save the number — it's the PR index `/land` uses.
Gitea has no draft flag: under `prDraft: until-green-light`, prefix the title with `WIP: `. `/land` drops the prefix when the user gives the green light.
To request reviewers (if `prReviewers` is set):
```bash