diff --git a/assets/scripts/pr-stack-resolve.sh b/assets/scripts/pr-stack-resolve.sh
new file mode 100644
index 000000000..358ef7f95
--- /dev/null
+++ b/assets/scripts/pr-stack-resolve.sh
@@ -0,0 +1,300 @@
+#!/usr/bin/env bash
+# pr-stack-resolve.sh — deterministic PR stack resolution checks
+#
+# Discovers all open PRs, orders them by stack position, and reports the
+# resolution state of each. Does NOT make changes — only reports.
+# The agent (dispatched via the formula) does the actual /act work.
+#
+# Output: JSON report to stdout, human-readable log to stderr.
+# Exit: 0 if all PRs resolved, 1 if any need work.
+set -euo pipefail
+
+REPO="sverka-dev/sverka"
+cd "$(git rev-parse --show-toplevel)"
+
+TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
+log() { echo "[pr-resolve $TIMESTAMP] $*" >&2; }
+
+TMPDIR=$(mktemp -d)
+trap 'rm -rf "$TMPDIR"' EXIT
+
+# --- 1. Fetch latest ---
+log "Fetching latest from origin..."
+git fetch origin --prune 2>&1 | grep -v "^From " || true
+
+# --- 2. Discover all open PRs ---
+log "Discovering open PRs..."
+gh pr list --repo "$REPO" --state open --json number,headRefName,baseRefName,title > "$TMPDIR/prs.json" 2>/dev/null
+
+PR_COUNT=$(python3 -c "import json; print(len(json.load(open('$TMPDIR/prs.json'))))" 2>/dev/null || echo "0")
+
+if [ "$PR_COUNT" -eq 0 ]; then
+ log "No open PRs. All resolved."
+ echo '{"prs":[],"all_resolved":true,"prs_resolved":0,"prs_needs_work":0}'
+ exit 0
+fi
+
+log "Found $PR_COUNT open PR(s)."
+
+# --- 3. Order PRs by stack position (base → top) ---
+python3 -c "
+import json
+prs = json.load(open('$TMPDIR/prs.json'))
+by_base = {}
+for p in prs:
+ by_base.setdefault(p['baseRefName'], []).append(p)
+
+ordered = []
+visited = set()
+
+def visit(base):
+ for p in by_base.get(base, []):
+ if p['number'] in visited:
+ continue
+ visited.add(p['number'])
+ ordered.append(p)
+ visit(p['headRefName'])
+
+visit('main')
+for p in prs:
+ if p['number'] not in visited:
+ ordered.append(p)
+
+with open('$TMPDIR/ordered.json', 'w') as f:
+ json.dump(ordered, f)
+
+print(' '.join(str(p['number']) for p in ordered))
+" > "$TMPDIR/order.txt"
+
+ORDERED_NUMBERS=$(cat "$TMPDIR/order.txt")
+log "Stack order (base → top): $ORDERED_NUMBERS"
+
+# --- 4. Check each PR ---
+ALL_RESOLVED=true
+NEEDS_WORK_COUNT=0
+RESOLVED_COUNT=0
+ENTRIES=""
+
+for PR_NUM in $ORDERED_NUMBERS; do
+ PR_DATA=$(python3 -c "
+import json
+for p in json.load(open('$TMPDIR/ordered.json')):
+ if p['number'] == $PR_NUM:
+ print(json.dumps(p))
+ break
+")
+
+ HEAD_REF=$(echo "$PR_DATA" | python3 -c "import json,sys; print(json.load(sys.stdin)['headRefName'])")
+ BASE_REF=$(echo "$PR_DATA" | python3 -c "import json,sys; print(json.load(sys.stdin)['baseRefName'])")
+ TITLE=$(echo "$PR_DATA" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d['title'].replace('\"','\\'\"'))")
+
+ log "PR #$PR_NUM: $TITLE (head=$HEAD_REF base=$BASE_REF)"
+
+ # Check 1: Merge state
+ MERGE_STATE=$(gh pr view "$PR_NUM" --repo "$REPO" --json mergeStateStatus -q '.mergeStateStatus' 2>/dev/null || echo "unknown")
+ HAS_CONFLICT=false
+ if [ "$MERGE_STATE" = "DIRTY" ] || [ "$MERGE_STATE" = "BLOCKED" ]; then
+ HAS_CONFLICT=true
+ fi
+
+ # Check 2: Unresolved review threads (via GraphQL — REST API can't filter by resolved state)
+ # GraphQL variables ($owner, $repo, $pr) are not shell expansion
+ # shellcheck disable=SC2016
+ OPEN_THREADS=$(gh api graphql -f query='
+query($owner: String!, $repo: String!, $pr: Int!) {
+ repository(owner: $owner, name: $repo) {
+ pullRequest(number: $pr) {
+ reviewThreads(first: 100) {
+ nodes { isResolved }
+ }
+ }
+ }
+}' -F owner=sverka-dev -F repo=sverka -F pr="$PR_NUM" 2>/dev/null | python3 -c "
+import json, sys
+try:
+ data = json.load(sys.stdin)
+ threads = data['data']['repository']['pullRequest']['reviewThreads']['nodes']
+ print(sum(1 for t in threads if not t['isResolved']))
+except:
+ print(0)
+" 2>/dev/null || echo "0")
+
+ # Check 3: CI status
+ CI_STATE="unknown"
+ CI_CHECKS=$(gh pr checks "$PR_NUM" --repo "$REPO" --json name,bucket --required 2>/dev/null || echo "[]")
+ CI_STATE=$(echo "$CI_CHECKS" | python3 -c "
+import json, sys
+try:
+ checks = json.load(sys.stdin)
+ failing = [c['name'] for c in checks if c.get('bucket') == 'fail']
+ pending = [c for c in checks if c.get('bucket') == 'pending']
+ if failing:
+ print('FAIL:' + ','.join(failing))
+ elif pending:
+ print('PENDING')
+ else:
+ print('PASS')
+except:
+ print('UNKNOWN')
+")
+
+ # Check 4: Is branch behind base?
+ BEHIND=false
+ BEHIND_COUNT=0
+ git fetch origin "$HEAD_REF" 2>/dev/null || true
+ git fetch origin "$BASE_REF" 2>/dev/null || true
+ DIVERGENCE=$(git rev-list --left-right --count "origin/$BASE_REF...origin/$HEAD_REF" 2>/dev/null || echo "0 0")
+ BEHIND_COUNT=$(echo "$DIVERGENCE" | awk '{print $1}')
+ if [ "$BEHIND_COUNT" -gt 0 ] 2>/dev/null; then
+ BEHIND=true
+ fi
+
+ # Determine resolution state
+ RESOLVED=true
+ REASONS=""
+
+ if [ "$HAS_CONFLICT" = "true" ]; then
+ RESOLVED=false
+ REASONS="${REASONS}merge_conflict "
+ fi
+
+ if [ "$OPEN_THREADS" -gt 0 ] 2>/dev/null; then
+ RESOLVED=false
+ REASONS="${REASONS}open_threads($OPEN_THREADS) "
+ fi
+
+ if [[ "$CI_STATE" == FAIL* ]]; then
+ RESOLVED=false
+ REASONS="${REASONS}ci_failing(${CI_STATE#FAIL:}) "
+ fi
+
+ if [[ "$CI_STATE" == "PENDING" ]]; then
+ RESOLVED=false
+ REASONS="${REASONS}ci_pending "
+ fi
+
+ if [ "$BEHIND" = "true" ]; then
+ RESOLVED=false
+ REASONS="${REASONS}behind_base($BEHIND_COUNT) "
+ fi
+
+ if [ "$RESOLVED" = "true" ]; then
+ log " ✓ RESOLVED (CI=$CI_STATE, threads=0, no conflicts, rebased)"
+ RESOLVED_COUNT=$((RESOLVED_COUNT + 1))
+ else
+ log " ✗ NEEDS WORK: ${REASONS:-unknown}"
+ ALL_RESOLVED=false
+ NEEDS_WORK_COUNT=$((NEEDS_WORK_COUNT + 1))
+ fi
+
+ # Build JSON entry
+ PY_RESOLVED=$( [ "$RESOLVED" = "true" ] && echo "True" || echo "False" )
+ PY_BEHIND=$( [ "$BEHIND" = "true" ] && echo "True" || echo "False" )
+ ENTRY=$(python3 -c "
+import json
+entry = {
+ 'number': $PR_NUM,
+ 'head': '$HEAD_REF',
+ 'base': '$BASE_REF',
+ 'resolved': $PY_RESOLVED,
+ 'merge_state': '$MERGE_STATE',
+ 'open_threads': $OPEN_THREADS,
+ 'ci_state': '$CI_STATE',
+ 'behind': $PY_BEHIND,
+ 'behind_count': $BEHIND_COUNT,
+ 'reasons': '${REASONS:-}'.strip()
+}
+print(json.dumps(entry))
+")
+ if [ -n "$ENTRIES" ]; then
+ ENTRIES="$ENTRIES,$ENTRY"
+ else
+ ENTRIES="$ENTRY"
+ fi
+done
+
+ALL_RESOLVED_JSON=$( [ "$ALL_RESOLVED" = "true" ] && echo "true" || echo "false" )
+ENTRIES_JSON="$ENTRIES"
+echo "{\"timestamp\":\"$TIMESTAMP\",\"prs\":[$ENTRIES_JSON],\"all_resolved\":$ALL_RESOLVED_JSON,\"prs_resolved\":$RESOLVED_COUNT,\"prs_needs_work\":$NEEDS_WORK_COUNT}"
+
+# --- 5. Nudge mayor if action is needed ---
+if [ "$ALL_RESOLVED" = "true" ]; then
+ log "All $RESOLVED_COUNT PR(s) resolved."
+ exit 0
+fi
+
+log "$NEEDS_WORK_COUNT PR(s) need work, $RESOLVED_COUNT resolved. Building nudge for mayor..."
+
+# Build a prioritized action message for the mayor
+NUDGE_MSG="PR stack resolve: $NEEDS_WORK_COUNT of $PR_COUNT PR(s) need work.\n\n"
+
+# Priority 1: Merge conflicts (must fix first — blocks everything above)
+CONFLICT_PRS=$(echo "$ENTRIES_JSON" | python3 -c "
+import json, sys
+entries = json.loads('[' + sys.stdin.read() + ']')
+conflicts = [e for e in entries if e.get('merge_state') in ('DIRTY', 'BLOCKED')]
+for e in conflicts:
+ print(f\"PR #{e['number']} ({e['head']}): {e['reasons']}\")
+" 2>/dev/null || true)
+
+if [ -n "$CONFLICT_PRS" ]; then
+ NUDGE_MSG="${NUDGE_MSG}PRIORITY 1 — MERGE CONFLICTS (fix first, cascade rebase up):\n$CONFLICT_PRS\n\n"
+ NUDGE_MSG="${NUDGE_MSG}For each: git fetch origin, checkout branch, rebase onto origin/, resolve conflicts, git push --force-with-lease. Then rebase all PRs above onto the new base.\n\n"
+fi
+
+# Priority 2: Branches behind base (rebase needed)
+BEHIND_PRS=$(echo "$ENTRIES_JSON" | python3 -c "
+import json, sys
+entries = json.loads('[' + sys.stdin.read() + ']')
+behind = [e for e in entries if e.get('behind') and e.get('merge_state') not in ('DIRTY', 'BLOCKED')]
+for e in behind:
+ print(f\"PR #{e['number']} ({e['head']}): {e['behind_count']} commits behind {e['base']}\")
+" 2>/dev/null || true)
+
+if [ -n "$BEHIND_PRS" ]; then
+ NUDGE_MSG="${NUDGE_MSG}PRIORITY 2 — BEHIND BASE (rebase):\n$BEHIND_PRS\n\n"
+fi
+
+# Priority 3: CI failures
+CI_FAIL_PRS=$(echo "$ENTRIES_JSON" | python3 -c "
+import json, sys
+entries = json.loads('[' + sys.stdin.read() + ']')
+fails = [e for e in entries if e.get('ci_state', '').startswith('FAIL')]
+for e in fails:
+ print(f\"PR #{e['number']} ({e['head']}): {e['ci_state']}\")
+" 2>/dev/null || true)
+
+if [ -n "$CI_FAIL_PRS" ]; then
+ NUDGE_MSG="${NUDGE_MSG}PRIORITY 3 — CI FAILING:\n$CI_FAIL_PRS\n\n"
+ NUDGE_MSG="${NUDGE_MSG}Read logs with: gh pr checks --watch. /act to fix, push, wait for re-run.\n\n"
+fi
+
+# Priority 4: Open review threads
+THREAD_PRS=$(echo "$ENTRIES_JSON" | python3 -c "
+import json, sys
+entries = json.loads('[' + sys.stdin.read() + ']')
+threads = [e for e in entries if e.get('open_threads', 0) > 0]
+for e in threads:
+ print(f\"PR #{e['number']} ({e['head']}): {e['open_threads']} open thread(s)\")
+" 2>/dev/null || true)
+
+if [ -n "$THREAD_PRS" ]; then
+ NUDGE_MSG="${NUDGE_MSG}PRIORITY 4 — OPEN REVIEW THREADS:\n$THREAD_PRS\n\n"
+ NUDGE_MSG="${NUDGE_MSG}For each PR: fetch comments with gh api repos/sverka-dev/sverka/pulls//comments. For each thread: read comment, /act to fix or reply, push fix, resolve thread. AFTER each /act, RE-FETCH comments to check for NEW replies. Loop until no new comments.\n\n"
+fi
+
+NUDGE_MSG="${NUDGE_MSG}After all fixes: re-run this check. Goal: all_resolved=true."
+
+# Send nudge to mayor
+if gc session nudge mayor "$(echo -e "$NUDGE_MSG")" 2>/dev/null; then
+ log "Nudged mayor with $NEEDS_WORK_COUNT PR action items"
+else
+ log "Failed to nudge mayor"
+fi
+
+# Also mail human if there are conflicts or CI failures
+if [ -n "$CONFLICT_PRS" ] || [ -n "$CI_FAIL_PRS" ]; then
+ gc mail send human "PR stack: critical issues" "$(echo -e "$NUDGE_MSG")" 2>/dev/null || true
+fi
+
+exit 1
diff --git a/formulas/pr-stack-resolve.toml b/formulas/pr-stack-resolve.toml
new file mode 100644
index 000000000..7b3bbbb65
--- /dev/null
+++ b/formulas/pr-stack-resolve.toml
@@ -0,0 +1,160 @@
+formula = "pr-stack-resolve"
+description = "Resolve all open PRs in the stack: rebase, /act on review comments, follow CI, loop until green"
+
+[requires]
+formula_compiler = ">=2.0.0"
+
+[[steps]]
+id = "discover"
+title = "Discover and assess PR stack"
+description = """
+Run the deterministic check script to discover all open PRs and their
+resolution state:
+
+ bash assets/scripts/pr-stack-resolve.sh
+
+This outputs a JSON report showing each PR's: merge state, open review
+threads, CI status, and whether it's behind its base.
+
+If ALL PRs are resolved (exit 0), close the formula — nothing to do.
+
+For each PR that NEEDS WORK, note the reasons:
+- merge_conflict → needs rebase
+- open_threads(N) → needs /act on N review comments
+- ci_failing(checks) → needs CI fix
+- ci_pending → wait for CI to complete
+- behind_base(N) → needs rebase on base
+
+Order work by stack position (base → top) — fixing a lower PR may
+cascade to upper PRs.
+"""
+agent = "builder"
+
+[[steps]]
+id = "rebase"
+title = "Rebase PRs on their base branches"
+description = """
+For each PR that needs rebase (behind_base or merge_conflict), working
+from BOTTOM of stack to TOP:
+
+1. Fetch latest:
+ git fetch origin
+
+2. Checkout the PR's head branch:
+ git checkout
+
+3. Rebase on the PR's base branch:
+ git rebase origin/
+
+4. If rebase fails with conflicts:
+ - STOP. Do NOT force anything.
+ - Report the conflict to the mayor.
+ - Do NOT continue to upper PRs.
+
+5. If rebase succeeds, force-push with lease:
+ git push --force-with-lease origin
+
+6. After rebasing a PR, ALL PRs above it in the stack MUST also be
+ rebased on their (now-updated) bases. Cascade upward.
+
+NEVER use `git push --force` without `--force-with-lease`.
+NEVER skip the fetch step.
+NEVER work on a stale branch.
+"""
+needs = ["discover"]
+agent = "builder"
+
+[[steps]]
+id = "act-comments"
+title = "/act on all open review comments"
+description = """
+For each PR with open review threads, resolve them:
+
+1. Fetch all review comments:
+ gh api repos/sverka-dev/sverka/pulls//comments
+
+2. For each unresolved thread:
+ a. Read the comment carefully.
+ b. Decide: fix the code, or reply with reasoning.
+ c. If fixing: make the change, commit, push.
+ d. If replying: post a substantive reply explaining the decision.
+ e. After pushing a fix, wait for CI to start.
+
+3. CRITICAL — AFTER each /act, re-check for NEW review comments:
+ - Fetch comments again: gh api repos/sverka-dev/sverka/pulls//comments
+ - Compare with previous fetch — are there new comments?
+ - If yes → /act on those too.
+ - Repeat until no new comments appear.
+
+This prevents the known bug where agents reply but don't check for
+follow-up comments. You MUST loop until stable.
+
+4. After all comments are addressed and no new ones appear, verify:
+ - All threads are resolved (0 open)
+ - The fix commits exist on the branch
+"""
+needs = ["rebase"]
+agent = "builder"
+
+[[steps]]
+id = "ci-followup"
+title = "Follow CI to green"
+description = """
+After all pushes (rebase + /act fixes), follow CI to completion:
+
+1. For each PR that was pushed to:
+ gh pr checks --watch
+
+2. If CI fails:
+ a. Read the failing check logs:
+ gh run view --log-failed
+ b. Identify the root cause.
+ c. Fix the issue (this is another /act).
+ d. Push the fix.
+ e. Wait for CI again.
+ f. Re-check for new review comments (CI may trigger bot reviews).
+
+3. If CI passes:
+ a. Check for new quality findings (SonarCloud, CodeQL, etc.).
+ b. If new findings → /act to fix them.
+ c. If no findings → PR is CI-green.
+
+4. Core pipeline MUST pass:
+ bun run build && bun run lint && bun run typecheck && bun run test
+
+ If any of these fail locally, fix before pushing.
+"""
+needs = ["act-comments"]
+agent = "builder"
+
+[[steps]]
+id = "verify"
+title = "Verify all PRs are resolved"
+description = """
+Run the deterministic check script one final time:
+
+ bash assets/scripts/pr-stack-resolve.sh
+
+A PR is RESOLVED when ALL of:
+✓ CI green (build, lint, typecheck, test, quality gates)
+✓ All review threads resolved (0 open)
+✓ No merge conflicts with base
+✓ Branch is rebased on latest base
+✓ No new quality findings
+✓ At least one follow-up check after last /act (no new comments appeared)
+
+If any PR is still not resolved, loop back to the appropriate step:
+- merge_conflict → rebase step
+- open_threads → act-comments step
+- ci_failing → ci-followup step
+- behind_base → rebase step
+
+If ALL PRs are resolved, report to the mayor:
+- Total PRs resolved
+- Any PRs that were skipped and why
+- Summary of changes made
+
+The mayor will then send a mail to the human with the resolution report.
+"""
+needs = ["ci-followup"]
+agent = "builder"
diff --git a/orders/pr-stack-resolve.toml b/orders/pr-stack-resolve.toml
new file mode 100644
index 000000000..906952ca0
--- /dev/null
+++ b/orders/pr-stack-resolve.toml
@@ -0,0 +1,10 @@
+# pr-stack-resolve — permanent PR stack resolution watchdog.
+# Runs every 20m via cooldown trigger. Monitors all open PRs in the stack:
+# CI status, review threads, merge conflicts, branch divergence.
+# Nudges mayor with specific /act instructions when action is needed.
+# Does NOT self-modify city config. Agents do the work; this script detects.
+[order]
+description = "PR stack watchdog: CI, review threads, conflicts, rebase needs"
+exec = "/home/pepl/projects/sverka/assets/scripts/pr-stack-resolve.sh"
+trigger = "cooldown"
+interval = "20m"