# AI-powered PR conflict resolution.
#
# Supersedes .github/workflows/pr-conflict-resolver.yml (PR #81), which gave
# the model unrestricted Bash(git:*)/Bash(gh:*); exactly one push-triggered
# resolver may exist or they race each other's pushes.
#
# When any branch is pushed, every open PR (drafts included) that TARGETS that
# branch — and the open PR FROM it, whose conflict may have been created by
# that very head push during a quiet stretch on the base — is checked for
# merge conflicts.
#
# TRIGGER SHAPE (load-bearing): claude-code-action@v1 rejects the `push` event
# ("Unsupported event type: push"), so a push-triggered run can only DETECT and
# then hand off — the `handoff` job fires a repository_dispatch, and that run
# (a supported event) performs the resolution. Do not move the resolve job back
# onto `push`; the AI step fails there every time, after the merge has already
# been computed. pull_request_target runs take the same handoff hop — the
# action rejects that event too, and PR code must never run in target
# context anyway. Conflicting same-repo PRs get the base
# branch merged into their head, with Claude resolving the conflicted files,
# and the merge commit pushed back to the PR branch.
#
# Secrets:
# - ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN (one required): powers the
#   resolution agent (OAuth token via `/install-github-app` in the claude CLI),
#   and, when present, LLM semantic extraction in the post-merge graphify
#   refresh (ANTHROPIC_API_KEY selects graphify's claude API backend;
#   CLAUDE_CODE_OAUTH_TOKEN its claude-cli backend).
# - CONFLICT_RESOLVER_PAT (optional): a PAT with repo + workflow scope. Without
#   it, pushes use GITHUB_TOKEN, which GitHub rejects whenever the merge brings
#   ANY change under .github/workflows/ (workflow runs are then skipped early,
#   before AI spend, with a comment explaining the manual step). With it, those
#   merges push fine AND the pushed merge commit retriggers CI + this workflow
#   (natural stacked-PR cascade).
#
# Security model (the AI is treated as untrusted — conflicted file content can
# prompt-inject it):
# - The model has NO shell and NO git access — it ONLY edits files in the
#   working tree (Read/Grep/Glob/Edit/Write). All git operations (staging,
#   commit, push) are deterministic workflow steps. This removes the entire
#   git-flag exec surface (e.g. `git grep -O<cmd>`, `git diff --output`,
#   inline-env prefixes) that a git-subcommand allowlist could never fully
#   close for untrusted input.
# - The scope invariant is ENFORCED from git's object store alone: the verify
#   step recomputes the auto-merge with `git merge-tree --write-tree` (HEAD is
#   still the pre-merge commit mid-merge), stages ONLY the recomputed
#   conflicted paths, and diffs the staged index tree against the auto-merge
#   tree — every changed path must be a recomputed conflicted path (or
#   graphify-out/**, reset deterministically before the model runs, with its
#   staged subtree OID checked to equal the base side). Nothing the verify
#   step trusts lives anywhere the model could write; RUNNER_TEMP files are
#   informational only.
# - No network (WebFetch/WebSearch disallowed). .git/**, /proc/**, RUNNER_TEMP,
#   and $HOME git config are denied to the file tools as defense-in-depth, and
#   user/system git config is pinned to /dev/null (GIT_CONFIG_GLOBAL/SYSTEM) so
#   a stray write can't reconfigure git; enforcement does not rely on these
#   holding.
# - persist-credentials: false — no token in .git/config for the model to
#   read; the push step injects auth via GIT_CONFIG_* env at push time only,
#   and pushes to an explicit https URL (immune to remote-URL tampering).
# - All deterministic git steps run hook-proof (core.hooksPath=/dev/null,
#   core.fsmonitor=false via job-level GIT_CONFIG_* env), so a planted
#   .git/hooks script can never execute.
# - Staged conflicted files are scanned for the raw/base64 ANTHROPIC_API_KEY
#   before commit. This scan is BEST-EFFORT, not a boundary: a determined
#   injection could smuggle an exotic encoding past it. The actual boundary
#   for same-repo collaborators (the only principals who can reach this code
#   path) is that the commit lands on a reviewable PR branch, plus
#   GitGuardian's push protection. ANTHROPIC_API_KEY necessarily sits in the
#   model's own process env; treat it as exposed to same-repo collaborators
#   who can engineer a conflict, and scope/rotate it accordingly.
# - Fork PRs are skipped: we can't push to them, and running the model over
#   untrusted content with secrets in the environment is not acceptable.
# - GITHUB_TOKEN pushes don't retrigger `push` workflows (no self-loops);
#   stacked PRs are cascaded via repository_dispatch (always creates runs,
#   needs only contents:write), with a depth cap as a loop guard.
#
# graphify-out/** is never given to the AI. The repo's merge driver for
# graph.json is not configured in CI, so git would silently text-merge it into
# a mixed base+head union (the poisoned-pair state CLAUDE.md forbids). Instead,
# when BOTH sides touched graphify-out since the merge base, the whole
# directory is deterministically reset to the base side before anything else.
# AFTER the resolution is verified and committed, the graph is refreshed on a
# pristine reset tree and committed separately, so the pushed graph reflects
# the merged code. That ordering is required: the verify step asserts the
# staged graphify-out subtree still equals the base side, so refreshing
# earlier would fail its own check.
#
# The refresh includes LLM SEMANTIC extraction when a Claude credential
# exists: `graphify extract` + `graphify cluster-only` with whichever backend
# the repo's secret supports (ANTHROPIC_API_KEY → claude API backend;
# CLAUDE_CODE_OAUTH_TOKEN → claude-cli backend, the same credential the
# resolver itself runs on). extract is manifest-incremental, and unchanged
# content is served from the tracked content-addressed semantic cache
# (graphify-out/cache/semantic/), so only content genuinely new to the merge
# reaches the LLM — a typical conflict merge costs a handful of calls, often
# zero. `extract --no-cluster` must NEVER be used here: it writes only the
# newly-extracted nodes (verified: it reduces a 24k-node graph to 1 node),
# and as a mechanical backstop for that whole failure class, a semantic
# result whose node count halved is refused before staging.
# With no credential, or when extraction fails, the step falls back to the
# old AST-only `graphify update`. Handing this step a credential exposes it
# to the pinned graphifyy package and the claude CLI (no NEW principals —
# the resolve step already runs on the same secret); in exchange, the staged
# refresh outputs get the same best-effort secret scan as resolved files
# before committing, and the step still runs only after verification, on a
# tree the model never influenced.

name: Resolve PR conflicts (AI)

run-name: "Resolve PR conflicts under ${{ github.event.client_payload.branch || inputs.branch || github.event.pull_request.base.ref || github.ref_name }}"

on:
  push:
    branches:
      - "**"
  # A PR branched off a STALE base can be born conflicting, and no push to the
  # base follows it — so without this it would sit unresolved until the next
  # unrelated base push. This MUST be pull_request_target, not pull_request:
  # GitHub creates NO pull_request run for a PR that opens CONFLICTING (there
  # is no merge ref to build one from — verified empirically on canary PR
  # #176, where zero runs appeared while mergeable PRs did get runs), so a
  # plain pull_request trigger is a no-op for exactly the case it exists to
  # catch. pull_request_target fires from the base-branch context regardless
  # of mergeability. SECURITY: target-context runs never check out or execute
  # PR code here — the detect job is API-only, the resolve job is excluded
  # for this event, and resolution happens in the repository_dispatch
  # re-entry, the same hop every push takes.
  # Deliberately NOT `synchronize`: that fires on every push to a PR head,
  # including this workflow's own resolution pushes. Head pushes are covered
  # by the plain `push` trigger instead — its detect run also scans the PR
  # FROM the pushed branch (see the scan step), which catches conflicts a
  # head push itself creates, without adding a per-PR target-context event.
  pull_request_target:
    types: [opened, reopened]
  workflow_dispatch:
    inputs:
      branch:
        description: "Base branch to scan for conflicting PRs"
        required: true
        type: string
      depth:
        description: "Internal: stacked-PR cascade depth (leave at 0)"
        required: false
        default: "0"
        type: string
  repository_dispatch:
    types: [resolve-conflicts-cascade]

# One run per base branch; a newer push supersedes an in-flight resolution
# (the push is atomic, so cancelling mid-run never half-publishes anything).
concurrency:
  # Three distinct namespaces, because they must NOT cancel each other:
  #   detect-<branch>  a push run (detect + handoff only)
  #   pr<N>            a pull_request_target run (detect + handoff), per PR
  #   base-<branch>    an actual resolution sweep (dispatch / workflow_dispatch)
  # Critically, a push run and the dispatch it spawns previously shared a group,
  # so the child cancelled its own parent mid-handoff — observed as
  # "handoff: cancelled". It happened to work only because the dispatch API call
  # had already returned; had cancellation landed first the handoff would have
  # been lost with no error, i.e. a silent, timing-dependent no-op.
  group: >-
    ${{ github.event_name == 'push'
        && format('resolve-detect-{0}', github.ref_name)
        || (github.event.pull_request.number && format('resolve-pr{0}', github.event.pull_request.number))
        || format('resolve-base-{0}', github.event.client_payload.branch || inputs.branch || github.ref_name) }}
  cancel-in-progress: true

permissions:
  contents: read

jobs:
  detect:
    name: Find conflicting PRs for this branch
    # Branch deletions also fire `push`; there is nothing to scan then.
    if: github.event_name != 'push' || github.event.deleted == false
    runs-on: ubuntu-latest
    timeout-minutes: 10
    permissions:
      pull-requests: read
    outputs:
      prs: ${{ steps.scan.outputs.prs }}
      any: ${{ steps.scan.outputs.any }}
    steps:
      - name: Scan open PRs targeting (and from) this branch
        id: scan
        env:
          GH_TOKEN: ${{ github.token }}
          REPO: ${{ github.repository }}
          TARGET: ${{ github.event.client_payload.branch || inputs.branch || github.event.pull_request.base.ref || github.ref_name }}
          # Set only on pull_request_target events: narrows the scan to the
          # triggering PR instead of every PR sharing its base. TARGET reads
          # base.ref from the PR record rather than event ref context, which
          # stays correct regardless of which ref the event ran from.
          ONLY_PR: ${{ github.event.pull_request.number }}
          # Push runs only: also scan the open PR FROM the pushed branch. A
          # head push can CREATE a conflict (`synchronize` is deliberately
          # not a trigger, and the next base push may be days away — observed
          # on PR #173, which made itself conflicted and sat unresolved).
          # Costs nothing: this run exists for every push anyway. Cannot
          # loop: the resolver's own resolution push makes its PR mergeable,
          # so that push's detect run finds nothing to do.
          SCAN_HEAD: ${{ github.event_name == 'push' }}
        run: |
          set -euo pipefail

          query() {
            {
              gh pr list --repo "$REPO" --base "$TARGET" --state open \
                --json number,headRefName,baseRefName,isCrossRepository,isDraft,mergeable \
                --limit 100
              if [ "$SCAN_HEAD" = "true" ]; then
                gh pr list --repo "$REPO" --head "$TARGET" --state open \
                  --json number,headRefName,baseRefName,isCrossRepository,isDraft,mergeable \
                  --limit 100
              fi
            } | jq -s 'add | unique_by(.number)'
          }

          # GitHub computes mergeability lazily after a base push; poll until
          # every PR has a verdict (querying is itself what triggers compute).
          prs="$(query)"
          for attempt in 1 2 3 4 5 6 7 8; do
            unknown="$(jq '[.[] | select(.mergeable == "UNKNOWN")] | length' <<<"$prs")"
            [ "$unknown" -eq 0 ] && break
            echo "attempt $attempt: $unknown PR(s) still computing mergeability; retrying..."
            sleep 10
            prs="$(query)"
          done

          echo "Open PRs based on $TARGET:"
          jq -r '.[] | "  #\(.number) \(.headRefName) draft=\(.isDraft) mergeable=\(.mergeable) fork=\(.isCrossRepository)"' <<<"$prs"

          skipped="$(jq -r '[.[] | select(.isCrossRepository) | select(.mergeable == "CONFLICTING") | .number] | join(", ")' <<<"$prs")"
          [ -n "$skipped" ] && echo "::warning::Skipping conflicting fork PR(s) #$skipped — cannot push to fork branches."
          still_unknown="$(jq -r '[.[] | select(.mergeable == "UNKNOWN") | .number] | join(", ")' <<<"$prs")"
          [ -n "$still_unknown" ] && echo "::warning::PR(s) #$still_unknown still UNKNOWN after polling; they will be re-checked on the next push."

          # On a pull_request_target event, scan ONLY the triggering PR — the
          # other PRs sharing this base are not this event's business and are
          # already covered by base pushes.
          # `base` comes from the API record of the PR itself, so the resolve
          # job never re-derives it from event context — deriving branch names
          # from event refs is how "<N>/merge"-style ref spoofing and wrong-ref
          # merges happen; the PR record is the single source of truth.
          conflicting="$(jq -c --arg only "${ONLY_PR:-}" '[.[] | select(.isCrossRepository | not) | select(.mergeable == "CONFLICTING") | select($only == "" or (.number|tostring) == $only) | {number: .number, head: .headRefName, base: .baseRefName}]' <<<"$prs")"
          echo "prs=$conflicting" >> "$GITHUB_OUTPUT"
          echo "any=$(jq 'length > 0' <<<"$conflicting")" >> "$GITHUB_OUTPUT"

  # anthropics/claude-code-action@v1 REJECTS the `push` event outright
  # ("Unsupported event type: push"). Its supported set is issues,
  # issue_comment, pull_request, pull_request_review,
  # pull_request_review_comment, workflow_dispatch, repository_dispatch,
  # schedule, workflow_run. So a push-triggered run cannot resolve anything
  # itself — it detects the work and hands off to repository_dispatch, which
  # IS supported and re-enters this same workflow to do the real work.
  handoff:
    name: Hand off to a dispatch run (push/PR-target cannot run the AI step)
    needs: detect
    if: >-
      (github.event_name == 'push' || github.event_name == 'pull_request_target')
      && needs.detect.outputs.any == 'true'
    runs-on: ubuntu-latest
    timeout-minutes: 5
    permissions:
      contents: write
    steps:
      - name: Dispatch a resolvable run per conflicted base branch
        env:
          GH_TOKEN: ${{ github.token }}
          REPO: ${{ github.repository }}
          # Bases come from the conflicting PRs' own records, NOT the event
          # ref: a push run may have found the PR *from* the pushed branch
          # (head-push-created conflict), whose resolution sweep must run
          # under that PR's BASE branch. For PRs found by the base scan the
          # derived base equals the event ref anyway.
          PRS: ${{ needs.detect.outputs.prs }}
        run: |
          set -euo pipefail
          jq -r '[.[].base] | unique | .[]' <<<"$PRS" | while IFS= read -r base; do
            echo "Conflicting PR(s) under $base; re-entering via repository_dispatch."
            gh api "repos/$REPO/dispatches" \
              -f event_type=resolve-conflicts-cascade \
              -f "client_payload[branch]=$base" \
              -f "client_payload[depth]=0"
          done

  model_config:
    name: Load the conflict-resolver model waterfall
    needs: detect
    # Fetch once for the whole resolution sweep, never once per matrix PR. The
    # push/PR-target detect runs only hand off, so they do not need this config.
    if: >-
      needs.detect.outputs.any == 'true'
      && github.event_name != 'push'
      && github.event_name != 'pull_request_target'
    runs-on: ubuntu-latest
    timeout-minutes: 2
    # This job deliberately has no checkout and no repository permissions. It
    # reads one public, non-secret setting and turns it into a closed set of
    # Claude CLI values; repository or PR code can never execute here.
    permissions: {}
    outputs:
      model_args: ${{ steps.waterfall.outputs.model_args }}
    steps:
      - name: Fetch and validate the configured waterfall
        id: waterfall
        shell: bash
        run: |
          set -euo pipefail

          endpoint="https://thingtime.com/api/v1/settings/pr-conflict-auto-resolver-model-waterfall"
          response_file="$RUNNER_TEMP/pr-conflict-model-waterfall.json"

          # The resolver must remain usable when Thingtime is unavailable or a
          # deployment predates this endpoint. Its hard fail-safe is exactly the
          # Claude Code `default` model, with no configured fallback.
          waterfall='["default"]'
          source="built-in default"

          if curl --fail --silent --show-error \
            --proto '=https' \
            --tlsv1.2 \
            --connect-timeout 5 \
            --max-time 12 \
            --retry 2 \
            --retry-delay 1 \
            --retry-max-time 30 \
            --max-filesize 65536 \
            --output "$response_file" \
            "$endpoint"; then
            # Slurp first so a response containing multiple top-level JSON
            # values is malformed too. Only the exact public setting key and a
            # unique 1..3 item array from the closed model allowlist is valid.
            if configured="$(jq -cse '
              select(length == 1)
              | .[0]
              | select(type == "object")
              | select(.ok == true)
              | select(.key == "Thingtime.PRConflictAutoResolverModelWaterfall")
              | .waterfall
              | select(type == "array")
              | select(length >= 1 and length <= 3)
              | select(all(.[]; type == "string"))
              | select(all(.[];
                  . == "default"
                  or . == "claude-fable-5"
                  or . == "claude-opus-5"))
              | select(length == (unique | length))
            ' "$response_file" 2>/dev/null)"; then
              waterfall="$configured"
              source="Thingtime admin setting"
              # The API normalizes this already; keep the workflow safe if an
              # older valid deployment omitted the final default entry.
              if ! jq -e 'index("default") != null' >/dev/null <<<"$waterfall"; then
                waterfall="$(jq -c '. + ["default"]' <<<"$waterfall")"
              fi
            else
              echo "::warning::Thingtime returned a malformed conflict-resolver model waterfall; using [default]."
            fi
          else
            echo "::warning::Thingtime conflict-resolver model waterfall is unavailable; using [default]."
          fi

          # Never interpolate API strings into claude_args. Rebuild the entire
          # ordered chain from fixed literals after validation, so even a
          # compromised response cannot inject another CLI flag or model name.
          mapfile -t configured_ids < <(jq -r '.[]' <<<"$waterfall")
          models=()
          for id in "${configured_ids[@]}"; do
            case "$id" in
              default) models+=("default") ;;
              claude-fable-5) models+=("claude-fable-5") ;;
              claude-opus-5) models+=("claude-opus-5") ;;
              *)
                echo "::warning::Validated waterfall mapping failed closed; using [default]."
                models=("default")
                source="built-in default"
                break
                ;;
            esac
          done

          primary="${models[0]}"
          model_args="--model $primary"
          if [ "${#models[@]}" -gt 1 ]; then
            fallbacks="$(IFS=,; echo "${models[*]:1}")"
            model_args="$model_args --fallback-model $fallbacks"
          fi

          echo "Model waterfall source: $source"
          echo "Claude Code model waterfall: $(IFS=,; echo "${models[*]}")"
          echo "Fallbacks apply only to model overload, unavailability, or eligible server errors."
          echo "A model that completes but leaves conflict markers does not mechanically trigger the next model."
          echo "model_args=$model_args" >> "$GITHUB_OUTPUT"

  resolve:
    name: "Resolve PR #${{ matrix.pr.number }}"
    needs: [detect, model_config]
    # Never on `push` or `pull_request_target` — the AI step rejects both
    # ("Unsupported event type"), and resolution must not run in the target
    # context regardless. The handoff job converts both into a
    # repository_dispatch run.
    if: >-
      needs.detect.outputs.any == 'true'
      && github.event_name != 'push'
      && github.event_name != 'pull_request_target'
    runs-on: ubuntu-latest
    timeout-minutes: 30
    permissions:
      contents: write
      pull-requests: write
    strategy:
      fail-fast: false
      matrix:
        pr: ${{ fromJSON(needs.detect.outputs.prs) }}
    env:
      REPO: ${{ github.repository }}
      # From the PR's own API record (see the scan step) — never re-derived from
      # event context, which is wrong on pull_request events.
      BASE_REF: ${{ matrix.pr.base }}
      HEAD_REF: ${{ matrix.pr.head }}
      PR_NUMBER: ${{ matrix.pr.number }}
      RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
      # True when CONFLICT_RESOLVER_PAT exists (a boolean, never the secret
      # itself — the secret is scoped to the push step only, so it is not in
      # the environment while the model runs).
      HAS_WORKFLOW_PUSH: ${{ secrets.CONFLICT_RESOLVER_PAT != '' }}
      # Hook-proof every git invocation in this job (including the model's):
      # a planted .git/hooks script or fsmonitor command must never execute.
      # GIT_CONFIG_* env has higher precedence than anything in .git/config.
      GIT_CONFIG_COUNT: "2"
      GIT_CONFIG_KEY_0: core.hooksPath
      GIT_CONFIG_VALUE_0: /dev/null
      GIT_CONFIG_KEY_1: core.fsmonitor
      GIT_CONFIG_VALUE_1: "false"
      # Never read user/system git config: a model-written ~/.gitconfig could
      # otherwise define external diff/merge drivers (arbitrary exec via
      # allowlisted git commands), url.insteadOf push redirects, or custom
      # merge drivers that skew the verify step's merge-tree re-derivation.
      GIT_CONFIG_GLOBAL: /dev/null
      GIT_CONFIG_SYSTEM: /dev/null
    steps:
      - name: Check out PR head
        uses: actions/checkout@v4
        with:
          ref: ${{ matrix.pr.head }}
          # fetch-depth 0 brings all branches, so origin/$BASE_REF is already
          # local — no authenticated fetch needed later.
          fetch-depth: 0
          # Keep the token out of .git/config: the model has Read access to
          # the workspace, and the push step supplies its own auth.
          persist-credentials: false

      - name: Merge base into head
        id: merge
        run: |
          set -euo pipefail
          git config user.name "github-actions[bot]"
          git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
          # zdiff3 markers show ours + BASE + theirs, so the model can infer
          # each side's intent from the file alone (it has no git access).
          git config merge.conflictStyle zdiff3

          base="origin/$BASE_REF"
          git rev-parse --verify --quiet "$base" >/dev/null
          pre_merge="$(git rev-parse HEAD)"
          echo "$pre_merge" > "$RUNNER_TEMP/pre_merge_sha.txt"

          clean=true
          git merge --no-edit "$base" || clean=false

          # graphify-out determinism: the repo's graph.json merge driver is not
          # configured in CI, so git text-merges it — producing a mixed
          # base+head graph/manifest pair that CLAUDE.md explicitly forbids.
          # When BOTH sides touched graphify-out since the merge base, reset
          # the WHOLE directory to the base side (one side, never mixed).
          # One-sided changes are left alone: git already took that side
          # uniformly.
          graphify_reset=false
          mb="$(git merge-base "$pre_merge" "$base")"
          if ! git diff --quiet "$mb" "$pre_merge" -- graphify-out/ 2>/dev/null \
             && ! git diff --quiet "$mb" "$base" -- graphify-out/ 2>/dev/null; then
            graphify_reset=true
            git rm -rfq --ignore-unmatch -- graphify-out/
            # rev-parse on <rev>:<path> is the reliable existence test for a
            # tree path (ls-tree -d with a trailing-slash pathspec lists the
            # directory's children, not the directory, and can be empty).
            if git rev-parse --verify --quiet "$base:graphify-out" >/dev/null; then
              git checkout "$base" -- graphify-out/
            fi
            if [ "$clean" = true ]; then
              # merge auto-committed; fold the reset in (amend keeps both parents)
              git commit --amend --no-edit
            fi
          fi
          echo "graphify_reset=$graphify_reset" >> "$GITHUB_OUTPUT"

          if [ "$clean" = false ]; then
            git diff --name-only --diff-filter=U > "$RUNNER_TEMP/conflicted.txt"
            if [ ! -s "$RUNNER_TEMP/conflicted.txt" ]; then
              # the graphify reset was the only conflict — finish the merge
              # deterministically, zero AI spend
              git commit --no-edit
              clean=true
            fi
          fi

          if [ "$clean" = false ]; then
            echo "Conflicted paths:"
            cat "$RUNNER_TEMP/conflicted.txt"
          fi
          echo "conflicted=$([ "$clean" = true ] && echo false || echo true)" >> "$GITHUB_OUTPUT"

          # Workflow-file guard: GITHUB_TOKEN pushes are rejected if the merge
          # introduces ANY change under .github/workflows/ relative to the head
          # we push to — including cleanly-merged ones (e.g. a workflow newly
          # added on the base). Detect that here, BEFORE any AI spend. A PAT
          # with workflow scope lifts this.
          # (`git diff --cached $pre_merge` compares the index — which equals
          # HEAD after a clean merge — against the pre-merge head, so it works
          # for both the clean and the mid-merge state.)
          if [ "$HAS_WORKFLOW_PUSH" != "true" ] \
             && git diff --cached --name-only "$pre_merge" -- .github/workflows/ | grep -q .; then
            echo "workflows_blocked=true" >> "$GITHUB_OUTPUT"
            echo "::warning::This merge changes .github/workflows/**, which GITHUB_TOKEN cannot push. Merge the base manually, or add a CONFLICT_RESOLVER_PAT secret with workflow scope."
            exit 1
          fi

          # No pre-AI snapshot is taken: the verify step re-derives both the
          # conflicted set and the auto-merge baseline from git's object store
          # (`git merge-tree --write-tree`), so there is nothing here for the
          # model to tamper with. conflicted.txt above is informational only.

      - name: Require an AI credential
        if: steps.merge.outputs.conflicted == 'true'
        env:
          HAS_AI_CREDENTIAL: ${{ secrets.ANTHROPIC_API_KEY != '' || secrets.CLAUDE_CODE_OAUTH_TOKEN != '' }}
        run: |
          set -euo pipefail
          if [ "$HAS_AI_CREDENTIAL" != "true" ]; then
            echo "::error::Add the ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN repo secret to enable the AI conflict resolver (claude CLI: /install-github-app)."
            exit 1
          fi

      - name: Resolve conflicts with Claude
        if: steps.merge.outputs.conflicted == 'true'
        uses: anthropics/claude-code-action@v1
        with:
          anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
          claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
          github_token: ${{ github.token }}
          # The handoff fires repository_dispatch with GITHUB_TOKEN, so those
          # runs are attributed to github-actions[bot]. The action refuses
          # non-human actors by default ("Workflow initiated by non-human
          # actor: github-actions (type: Bot)"), which silently broke the whole
          # push -> handoff -> dispatch -> resolve chain: only runs a human
          # dispatched ever reached the model. Allow exactly this bot, not '*'.
          allowed_bots: "github-actions"
          prompt: |
            You are resolving git merge conflicts inside a GitHub Actions runner by
            EDITING FILES ONLY. You have NO shell and NO git access — that is
            intentional. A later deterministic step stages, verifies, commits, and
            pushes what you leave in the working tree. Do not attempt to run git or
            any command; just edit the conflicted files in place.

            Situation: base branch "${{ matrix.pr.base }}"
            was merged into "${{ matrix.pr.head }}" (the head of PR #${{ matrix.pr.number }}),
            and the merge stopped on conflicts. Conflicted files currently contain
            zdiff3 conflict markers:

                <<<<<<< (ours = the PR head branch)
                ...head side...
                ||||||| (the common ancestor / base of the two sides)
                ...original...
                =======
                ...base-branch side...
                >>>>>>> (theirs = the branch being merged in)

            Method:
            1. Use the Grep tool to find every file containing a line that starts with
               "<<<<<<<" — those are your conflicts (search the whole workspace).
            2. For each conflicted file, read it in full. The three-way markers show
               both sides AND their common ancestor, so you can tell what each side
               changed and why. Rewrite the conflicted region to the semantic UNION —
               preserve BOTH sides' intent unless they are genuinely mutually
               exclusive; when they are, prefer the base-branch (theirs) side and
               re-apply the head (ours) intent adapted on top of it. Remove ALL
               conflict markers (the <<<<<<<, |||||||, =======, and >>>>>>> lines).
            3. Rules:
               - graphify-out/** has ALREADY been handled deterministically before you
                 ran. Never touch anything under graphify-out/.
               - pnpm-lock.yaml / package-lock.json: only take one side wholesale, and
                 only if the corresponding package.json resolved fully to that SAME
                 side. If package.json needed a true union of dependency changes,
                 LEAVE THE LOCKFILE'S MARKERS IN PLACE and say why — a human must
                 regenerate it (the run will then stop for manual resolution).
               - Edit ONLY files that contain conflict markers. Do not create, rename,
                 or delete files, and do not edit non-conflicted files. (Only the
                 conflicted files are staged afterward; anything else you change is
                 ignored, and out-of-scope edits are rejected mechanically.)
            4. If a conflict cannot be resolved with high confidence, LEAVE its markers
               in place rather than guessing — the workflow then stops and asks a human.

            When you are done, every file you could confidently resolve should have no
            conflict markers left, and you should have touched nothing else.
          # The first admin-ordered model is primary; the rest are Claude
          # Code's native availability fallbacks. This does not retry a
          # completed-but-unresolved edit with another model.
          claude_args: |
            ${{ needs.model_config.outputs.model_args }}
            --effort max
            --max-turns 80
            --allowedTools "Read,Grep,Glob,Edit,Write"
            --disallowedTools "WebFetch,WebSearch,Bash,Edit(.gitattributes),Write(.gitattributes),Edit(**/.gitattributes),Write(**/.gitattributes),Read(./.git/**),Edit(./.git/**),Write(./.git/**),Read(**/.git/**),Edit(**/.git/**),Write(**/.git/**),Read(/proc/**),Edit(/proc/**),Write(/proc/**),Read(/home/runner/work/_temp/**),Edit(/home/runner/work/_temp/**),Write(/home/runner/work/_temp/**),Read(/home/runner/.gitconfig*),Edit(/home/runner/.gitconfig*),Write(/home/runner/.gitconfig*),Edit(/home/runner/.config/git/**),Write(/home/runner/.config/git/**)"

      - name: Verify resolution and commit
        if: steps.merge.outputs.conflicted == 'true'
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
          CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
          GRAPHIFY_RESET: ${{ steps.merge.outputs.graphify_reset }}
        run: |
          set -euo pipefail

          # The merge must still be in progress (the model must not have
          # committed or aborted it).
          if [ ! -f .git/MERGE_HEAD ]; then
            echo "::error::Merge is no longer in progress — refusing to continue."
            exit 1
          fi

          # Belt to the .git/** write-deny: a merge/clean/smudge/textconv driver
          # defined in local .git/config would be EXECUTED by the git add /
          # merge-tree below if an attribute referenced it (arbitrary code exec).
          # A normal CI checkout configures none; if one appears, .git was
          # tampered with — abort rather than rely solely on the file-tool deny.
          if git config --local --list --name-only 2>/dev/null \
             | grep -qiE '^(merge|filter|diff)\.[^.]+\.(driver|clean|smudge|process|textconv|command)$'; then
            echo "::error::Unexpected local git driver configured — refusing (possible .git tampering):"
            git config --local --list --name-only | grep -iE '^(merge|filter|diff)\.[^.]+\.(driver|clean|smudge|process|textconv|command)$' || true
            exit 1
          fi

          # Everything this step trusts is RE-DERIVED from git's object store.
          # The model has NO git access at all — it only edited files in the
          # working tree — so nothing it can do influences this recomputation.
          # RUNNER_TEMP files from earlier steps are NOT trusted here.
          #
          # HEAD is still the pre-merge head commit during a merge, so
          # merge-tree recomputes the exact auto-merge baseline and the exact
          # conflicted set the merge step saw.
          #
          # CRITICAL: run merge-tree from an EMPTY cwd with GIT_DIR set, so no
          # in-tree .gitattributes is consulted. A model-planted working-tree
          # .gitattributes (e.g. "clean.txt merge=binary") would otherwise make
          # merge-tree reclassify a cleanly-auto-merged file as conflicted,
          # smuggling it into the allowed set. Attributes here come only from
          # .git/info/attributes (model can't write it) + builtin; the sole
          # merge= attribute the repo actually uses targets graphify-out/,
          # which is validated separately (subtree OID), so ignoring in-tree
          # attributes is safe. SHAs are resolved before the cd.
          merge_head="$(git rev-parse MERGE_HEAD)"
          head_sha="$(git rev-parse HEAD)"
          git_dir="$(git rev-parse --absolute-git-dir)"
          # --no-messages: output is exactly <tree-oid>\n<conflicted names...>
          # (verified empirically; without it an informational section follows
          # after a blank line). The sed stop-at-blank is belt-and-braces.
          attic="$(mktemp -d)"
          merge_out="$(cd "$attic" && GIT_DIR="$git_dir" GIT_ATTR_NOSYSTEM=1 \
            git -c core.attributesFile=/dev/null merge-tree --write-tree --no-messages --name-only "$head_sha" "$merge_head")" || true
          rmdir "$attic" 2>/dev/null || true
          automerge_tree="$(printf '%s\n' "$merge_out" | head -1)"
          git rev-parse --verify --quiet "$automerge_tree^{tree}" >/dev/null || {
            echo "::error::Could not recompute the auto-merge tree."; exit 1;
          }
          printf '%s\n' "$merge_out" | tail -n +2 | sed -n '/^$/q;p' | sort -u > "$RUNNER_TEMP/conflicted-derived.txt"

          # DETERMINISTIC STAGING: the model only edited the working tree; we
          # stage its resolutions ourselves, and ONLY for the recomputed
          # conflicted paths (never a path the model might have invented). A
          # file that still carries markers is left UNMERGED on purpose so the
          # ls-files -u guard below catches it. A file the model deleted while
          # resolving a modify/delete is staged as a deletion.
          while IFS= read -r path; do
            [ -n "$path" ] || continue
            if [ ! -e "$path" ]; then
              git rm -q --ignore-unmatch -- "$path" || true
            elif grep -qE '^(<{7}|\|{7}|>{7})( |$)' "$path"; then
              echo "::warning::$path still has conflict markers — leaving it unmerged."
            else
              git add -- "$path"
            fi
          done < "$RUNNER_TEMP/conflicted-derived.txt"

          # Any unmerged path left now is an unresolved conflict.
          if [ -n "$(git ls-files -u)" ]; then
            echo "::error::Unresolved paths remain:"
            git ls-files -u | awk -F'\t' '{print $2}' | sort -u
            exit 1
          fi

          # ENFORCE the scope invariant: diff the staged index tree against the
          # recomputed auto-merge tree; every changed path must be a recomputed
          # conflicted path. graphify-out/** is NOT blanket-allowed — when the
          # merge step reset it, the staged subtree must equal the base side
          # EXACTLY (subtree OID compare), so the model cannot smuggle content
          # under the reset's cover; when no reset ran, graphify-out paths are
          # held to the same conflicted-set rule as everything else.
          index_tree="$(git write-tree)"
          changed="$(git diff-tree -r --name-only "$automerge_tree" "$index_tree")"
          graphify_changed=false
          if [ -n "$changed" ]; then
            while IFS= read -r path; do
              case "$path" in graphify-out/*) graphify_changed=true; continue ;; esac
              if ! grep -qxF "$path" "$RUNNER_TEMP/conflicted-derived.txt"; then
                echo "::error::Staged change outside the recomputed conflicted set: $path — refusing to commit."
                exit 1
              fi
            done <<<"$changed"
          fi
          if [ "$graphify_changed" = true ]; then
            if [ "$GRAPHIFY_RESET" = "true" ]; then
              staged_sub="$(git rev-parse --verify --quiet "$index_tree:graphify-out" || echo missing)"
              base_sub="$(git rev-parse --verify --quiet "origin/$BASE_REF:graphify-out" || echo missing)"
              if [ "$staged_sub" != "$base_sub" ]; then
                echo "::error::graphify-out was reset to the base side, but the staged subtree no longer matches it — refusing to commit."
                exit 1
              fi
            else
              # no reset ran, so graphify-out paths must individually be
              # recomputed conflicts (same rule as everything else)
              while IFS= read -r path; do
                case "$path" in
                  graphify-out/*)
                    if ! grep -qxF "$path" "$RUNNER_TEMP/conflicted-derived.txt"; then
                      echo "::error::Staged graphify-out change outside the recomputed conflicted set: $path — refusing to commit."
                      exit 1
                    fi
                    ;;
                esac
              done <<<"$changed"
            fi
          fi

          # Leftover-marker + secret scan, against STAGED content (what ships),
          # scoped to the recomputed conflicted set. '=======' alone is
          # skipped: legit in markdown setext underlines. Best-effort backstop,
          # not a boundary (see header).
          keyfile="$RUNNER_TEMP/scan-needles.txt"
          : > "$keyfile"
          for secret in "${ANTHROPIC_API_KEY:-}" "${CLAUDE_CODE_OAUTH_TOKEN:-}"; do
            [ -n "$secret" ] || continue
            printf '%s\n' "$secret" >> "$keyfile"
            printf '%s' "$secret" | base64 -w0 >> "$keyfile"
            printf '\n' >> "$keyfile"
          done
          # Materialize each staged blob to a temp file and grep the FILE (not
          # a pipe): a pipe would let grep -q's early exit kill `git show` with
          # SIGPIPE, which under pipefail surfaces as exit 141 that the `if`
          # reads as "no match" — a secret near the top of a large staged file
          # would then slip past. (Process substitution avoids the pipe but is
          # unreliable on older bash; a temp file is unambiguous everywhere.)
          blob="$RUNNER_TEMP/staged-blob"
          while IFS= read -r path; do
            git show ":$path" > "$blob" 2>/dev/null || true
            if grep -qE '^(<{7}|>{7})( |$)' "$blob"; then
              echo "::error::Conflict markers left in staged $path"
              rm -f "$blob"; exit 1
            fi
            if [ -s "$keyfile" ] && grep -qFf "$keyfile" "$blob"; then
              echo "::error::Staged $path contains secret material — refusing to commit."
              rm -f "$blob"; exit 1
            fi
          done < "$RUNNER_TEMP/conflicted-derived.txt"
          rm -f "$keyfile" "$blob"

          if [ -n "$(git diff --name-only)" ]; then
            echo "::warning::Unstaged edits left behind (not committed):"
            git diff --name-only
          fi

          # ', '-joined list (paste -sd would CYCLE the two delimiter chars)
          conflicted_list="$(sed '$!s/$/,/' "$RUNNER_TEMP/conflicted-derived.txt" | tr '\n' ' ' | sed 's/ $//')"
          git commit \
            -m "Merge $BASE_REF into $HEAD_REF (AI-resolved conflicts)" \
            -m "Conflicted paths: $conflicted_list" \
            -m "Resolved by the resolve-pr-conflicts workflow: $RUN_URL" \
            -m "Co-Authored-By: Claude <noreply@anthropic.com>"

      # Runs ONLY after the resolution has been verified and committed, so the
      # verify step's "staged graphify-out subtree == base side" assertion is
      # evaluated against the untouched base tree (refreshing before it would
      # make that check fail by design). The tree is reset to the commit first,
      # so a stray model edit left unstaged can never end up indexed here, and
      # the model has no influence over this step at all.
      - name: Refresh graphify outputs (LLM semantic when a credential exists)
        id: graphify
        if: steps.merge.outputs.graphify_reset == 'true'
        # This refresh is a nicety, never worth losing a verified resolution:
        # any failure here must not skip the push step below, which is what
        # actually publishes the merge commit.
        continue-on-error: true
        timeout-minutes: 25
        env:
          # Pinned: the AST cache is version-namespaced and untracked, so CI
          # always re-extracts. An unpinned upgrade whose output format drifted
          # would rewrite graph.json/GRAPH_REPORT.md wholesale (~37k lines) and
          # guarantee a graphify-out conflict on the NEXT merge — i.e. the step
          # would manufacture the condition it exists to resolve.
          GRAPHIFY_VERSION: "0.9.4"
          # Semantic-extraction credentials (either may be absent; see the
          # header). Same secrets the resolve step runs on, so no NEW
          # principals see them beyond the pinned graphifyy package and the
          # claude CLI it shells out to.
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
          CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
          # graphify's claude-cli backend defaults to the CLI's default model
          # (Opus) — structured-JSON extraction doesn't need it; sonnet is the
          # right cost/quality point.
          GRAPHIFY_CLAUDE_CLI_MODEL: sonnet
        run: |
          set -euo pipefail
          # An empty env var and an unset one look the same to `[ -n ]` but
          # not necessarily to child processes (the claude CLI); normalize.
          [ -n "${ANTHROPIC_API_KEY:-}" ] || unset ANTHROPIC_API_KEY
          [ -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ] || unset CLAUDE_CODE_OAUTH_TOKEN

          # Index exactly what was committed, and nothing the model left behind.
          # -x is REQUIRED: without it, ignored paths survive, and the model can
          # write ignored files that graphify reads and folds into TRACKED output
          # (graphify-out/.graphify_labels.json becomes community labels in
          # GRAPH_REPORT.md; graphify-out/cache/ast/** is keyed by a hash the
          # model can enumerate and would inject nodes/edges into graph.json).
          # That would smuggle model-authored content past the verify step.
          git reset -q --hard HEAD
          git clean -qffdx
          # Belt: restore graphify-out from the commit outright.
          rm -rf graphify-out
          git checkout -q HEAD -- graphify-out/ 2>/dev/null || true

          if ! (pipx install "graphifyy==$GRAPHIFY_VERSION" >/dev/null 2>&1 \
                || pip install --user "graphifyy==$GRAPHIFY_VERSION" >/dev/null 2>&1); then
            echo "::warning::graphify $GRAPHIFY_VERSION could not be installed in CI — keeping the base branch's graph as-is."
            echo "refreshed=failed" >> "$GITHUB_OUTPUT"
            exit 0
          fi
          export PATH="$HOME/.local/bin:$PATH"

          # Pick a semantic backend from whichever Claude credential exists.
          backend=""
          if [ -n "${ANTHROPIC_API_KEY:-}" ]; then
            backend="claude"
          elif [ -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ]; then
            # graphify's claude-cli backend shells out to `claude -p`, which
            # authenticates from CLAUDE_CODE_OAUTH_TOKEN — the same mechanism
            # claude-code-action uses. The resolve step may have left the CLI
            # installed already; install it if not.
            if command -v claude >/dev/null 2>&1; then
              backend="claude-cli"
            elif npm install -g @anthropic-ai/claude-code >/dev/null 2>&1 \
                 && command -v claude >/dev/null 2>&1; then
              backend="claude-cli"
            else
              echo "::warning::claude CLI unavailable and could not be installed — semantic extraction skipped (AST-only refresh)."
            fi
          fi

          # Semantic path: `extract` is manifest-incremental (only files the
          # merge changed are re-extracted; unchanged docs are served from the
          # tracked cache — zero LLM calls when nothing semantic changed), then
          # `cluster-only` regenerates GRAPH_REPORT.md. --no-label matches the
          # repo's current placeholder community names without spending LLM
          # calls on naming; --api-timeout bounds each request so one
          # pathological doc can't eat the step timeout. NEVER add
          # --no-cluster to the extract: it writes ONLY the newly-extracted
          # nodes and destroys the rest of the graph.

          # Mechanical guard for that whole failure class: refuse a semantic
          # result whose node count halved. `graphify update` has a built-in
          # fewer-nodes guard, `graphify extract` does not — without this, a
          # collapsed graph would be committed to the PR branch. A legitimate
          # >50% shrink (mass code deletion) lands on the AST fallback, whose
          # own guard then decides; resolve that rare case locally with
          # `graphify update --force`.
          graph_not_collapsed() {
            old_n="$(git show HEAD:graphify-out/graph.json 2>/dev/null | jq '.nodes | length' 2>/dev/null || echo 0)"
            new_n="$(jq '.nodes | length' graphify-out/graph.json 2>/dev/null || echo 0)"
            case "$old_n" in ''|*[!0-9]*) old_n=0 ;; esac
            case "$new_n" in ''|*[!0-9]*) new_n=0 ;; esac
            if [ "$old_n" -gt 0 ] && [ "$new_n" -lt $(( old_n / 2 )) ]; then
              echo "::warning::Semantic refresh shrank the graph ($old_n -> $new_n nodes) — refusing it."
              return 1
            fi
            return 0
          }

          semantic="none"
          if [ -n "$backend" ]; then
            conc=4
            if [ "$backend" = "claude-cli" ]; then conc=1; fi
            if graphify extract . --backend "$backend" --max-concurrency "$conc" --api-timeout 240 \
               && graphify cluster-only . --no-viz --no-label \
               && graph_not_collapsed; then
              semantic="$backend"
            else
              semantic="failed"
              echo "::warning::Semantic \`graphify extract\` via $backend failed — falling back to the AST-only refresh."
              # The failed run may have half-written outputs (including a
              # dated backup dir); restore the committed state before the
              # fallback so it starts from the same tree the extract did.
              git checkout -q HEAD -- graphify-out/ 2>/dev/null || true
              git clean -qffdx -- graphify-out/
            fi
          fi
          echo "semantic=$semantic" >> "$GITHUB_OUTPUT"

          # AST/text-only fallback (and the no-credential path): no LLM.
          if [ "$semantic" = "none" ] || [ "$semantic" = "failed" ]; then
            if ! graphify update .; then
              echo "::warning::\`graphify update\` failed in CI — keeping the base branch's graph as-is."
              echo "refreshed=failed" >> "$GITHUB_OUTPUT"
              git reset -q --hard HEAD
              exit 0
            fi
          fi

          # Stage ONLY the portable outputs the repo tracks — never the whole
          # subtree, which would sweep in graphify's leftovers (.graph.tmp.json,
          # needs_update, dated backup dirs) and the untracked derived viz.
          for f in graph.json GRAPH_REPORT.md manifest.json cost.json; do
            p="graphify-out/$f"
            if [ -e "$p" ] || git ls-files --error-unmatch -- "$p" >/dev/null 2>&1; then
              git add -A -- "$p"
            fi
          done
          # The content-addressed semantic cache is tracked by repo policy:
          # blobs paid for in CI must be committed so no local run ever
          # re-pays the same extraction.
          if [ -d graphify-out/cache/semantic ] \
             || [ -n "$(git ls-files -- 'graphify-out/cache/semantic/')" ]; then
            git add -A -- graphify-out/cache/semantic/
          fi

          # Anything changed outside graphify-out/ means the CLI touched source.
          if [ -n "$(git status --porcelain --untracked-files=no -- . ':(exclude)graphify-out/')" ]; then
            echo "::error::graphify update modified files outside graphify-out/ — refusing to commit the refresh."
            git reset -q --hard HEAD
            echo "refreshed=failed" >> "$GITHUB_OUTPUT"
            exit 0
          fi

          # Same best-effort secret scan the verify step runs on resolved
          # files, here for the refresh commit: this step holds live
          # credentials and commits LLM-influenced output (a prompt-injected
          # doc could steer extraction), so staged content must never carry
          # them. Best-effort backstop, not a boundary (see header).
          keyfile="$RUNNER_TEMP/graphify-needles.txt"
          : > "$keyfile"
          for secret in "${ANTHROPIC_API_KEY:-}" "${CLAUDE_CODE_OAUTH_TOKEN:-}"; do
            [ -n "$secret" ] || continue
            printf '%s\n' "$secret" >> "$keyfile"
            printf '%s' "$secret" | base64 -w0 >> "$keyfile"
            printf '\n' >> "$keyfile"
          done
          if [ -s "$keyfile" ]; then
            git diff --cached --name-only > "$RUNNER_TEMP/graphify-staged.txt"
            blob="$RUNNER_TEMP/graphify-staged-blob"
            while IFS= read -r path; do
              [ -n "$path" ] || continue
              git show ":$path" > "$blob" 2>/dev/null || true
              if grep -qFf "$keyfile" "$blob"; then
                echo "::error::Staged $path contains secret material — dropping the graphify refresh."
                rm -f "$blob" "$keyfile"
                git reset -q --hard HEAD
                echo "refreshed=failed" >> "$GITHUB_OUTPUT"
                exit 0
              fi
            done < "$RUNNER_TEMP/graphify-staged.txt"
            rm -f "$blob"
          fi
          rm -f "$keyfile"

          if git diff --cached --quiet; then
            echo "Graph already current; nothing to refresh."
            echo "refreshed=nochange" >> "$GITHUB_OUTPUT"
          else
            case "$semantic" in
              claude|claude-cli)
                detail="\`graphify extract\` with LLM semantic extraction (graphify $GRAPHIFY_VERSION, $semantic backend); unchanged content served from the tracked semantic cache." ;;
              *)
                detail="AST/text-only \`graphify update\` (graphify $GRAPHIFY_VERSION, no semantic extraction); run graphify locally with an LLM backend if semantic data is needed." ;;
            esac
            git commit -q \
              -m "chore: refresh graphify outputs after conflict merge" \
              -m "$detail" \
              -m "Refreshed by the resolve-pr-conflicts workflow: $RUN_URL"
            echo "refreshed=true" >> "$GITHUB_OUTPUT"
          fi

      - name: Push merge commit
        id: push
        env:
          PUSH_PAT: ${{ secrets.CONFLICT_RESOLVER_PAT }}
          DEFAULT_TOKEN: ${{ github.token }}
        run: |
          set -euo pipefail
          token="${PUSH_PAT:-$DEFAULT_TOKEN}"
          # Auth via GIT_CONFIG_* env (never argv, never .git/config), pushing
          # to the explicit canonical URL so a tampered remote can't redirect.
          auth="$(printf 'x-access-token:%s' "$token" | base64 -w0)"
          export GIT_CONFIG_COUNT=3
          export GIT_CONFIG_KEY_2="http.https://github.com/.extraheader"
          export GIT_CONFIG_VALUE_2="AUTHORIZATION: basic $auth"
          git push "https://github.com/$REPO.git" "HEAD:refs/heads/$HEAD_REF"

      - name: Comment on PR (resolved)
        env:
          GH_TOKEN: ${{ github.token }}
          GRAPHIFY_RESET: ${{ steps.merge.outputs.graphify_reset }}
          GRAPHIFY_REFRESHED: ${{ steps.graphify.outputs.refreshed }}
          GRAPHIFY_SEMANTIC: ${{ steps.graphify.outputs.semantic }}
        run: |
          set -euo pipefail
          body="$RUNNER_TEMP/comment.md"
          {
            echo "🤝 Merged \`$BASE_REF\` into \`$HEAD_REF\` — conflicts auto-resolved by the [resolve-pr-conflicts workflow]($RUN_URL)."
            echo
            # conflicted-derived.txt is written by the verify step AFTER the
            # model finished (recomputed from git), so it is the trustworthy
            # list; plain conflicted.txt only exists on non-AI paths.
            if [ -s "$RUNNER_TEMP/conflicted-derived.txt" ]; then
              echo "Conflicted files:"
              sed 's/^/- `/; s/$/`/' "$RUNNER_TEMP/conflicted-derived.txt"
            else
              echo "No AI resolution was needed by merge time; the branch was updated with a plain merge commit."
            fi
            if [ "$GRAPHIFY_RESET" = "true" ]; then
              echo
              echo "> graphify-out/ was reset wholesale to the \`$BASE_REF\` side (repo rule: one side, never mixed — the graph merge driver is unavailable in CI)."
              case "$GRAPHIFY_REFRESHED" in
                true)
                  case "$GRAPHIFY_SEMANTIC" in
                    claude|claude-cli)
                      echo "> Then re-ran graphify on the merged code WITH LLM semantic extraction (\`graphify extract\`, $GRAPHIFY_SEMANTIC backend) and committed the result — content new to this merge is semantically indexed; unchanged content came from the tracked cache." ;;
                    failed)
                      echo "> Then re-ran \`graphify update\` (AST/text-only — the semantic extraction attempt failed this run; see the workflow log) and committed the result. Run graphify locally with an LLM backend if fresh semantic data is needed." ;;
                    *)
                      echo "> Then re-ran \`graphify update\` (AST/text-only — no Claude credential available to the refresh step for semantic extraction) and committed the result." ;;
                  esac ;;
                nochange) echo "> Re-ran the graphify refresh; the graph was already current." ;;
                *)        echo "> ⚠️ The in-CI graphify refresh did not run successfully, so the graph is the base branch's — please run \`graphify update .\` locally." ;;
              esac
            fi
            if [ "$HAS_WORKFLOW_PUSH" != "true" ]; then
              echo
              echo "> Note: this push used \`GITHUB_TOKEN\`, so GitHub Actions checks did not re-run on the merge commit (Vercel and other app-based checks did)."
            fi
            echo
            echo "Please review the merge commit before relying on it."
          } > "$body"
          gh pr comment "$PR_NUMBER" --repo "$REPO" --body-file "$body"

      - name: Cascade to PRs stacked on this head
        # With a PAT, the push itself retriggers this workflow for the head
        # branch (natural cascade) — dispatching too would double-run it.
        if: env.HAS_WORKFLOW_PUSH != 'true'
        env:
          GH_TOKEN: ${{ github.token }}
          DEPTH: ${{ github.event.client_payload.depth || inputs.depth || '0' }}
        run: |
          set -euo pipefail
          case "$DEPTH" in (*[!0-9]*) DEPTH=99 ;; esac
          if [ "$DEPTH" -ge 3 ]; then
            echo "Cascade depth cap reached ($DEPTH) — not dispatching further."
            exit 0
          fi
          stacked="$(gh pr list --repo "$REPO" --base "$HEAD_REF" --state open --json number --jq 'length')"
          if [ "$stacked" -gt 0 ]; then
            echo "$stacked open PR(s) are based on $HEAD_REF — dispatching a follow-up scan."
            # repository_dispatch always creates a run (from the default
            # branch's workflow file) and needs only contents:write. Non-fatal:
            # a missed cascade is re-covered by the next real push.
            if ! gh api "repos/$REPO/dispatches" \
                 -f event_type=resolve-conflicts-cascade \
                 -f "client_payload[branch]=$HEAD_REF" \
                 -f "client_payload[depth]=$((DEPTH + 1))"; then
              echo "::warning::Cascade dispatch failed (transient API error?) — the next real push re-covers it."
            fi
          else
            echo "No PRs are stacked on $HEAD_REF."
          fi

      - name: Comment on PR (needs attention)
        if: failure()
        env:
          GH_TOKEN: ${{ github.token }}
          PUSH_OUTCOME: ${{ steps.push.outcome }}
          WF_BLOCKED: ${{ steps.merge.outputs.workflows_blocked }}
        run: |
          set -euo pipefail
          body="$RUNNER_TEMP/comment.md"
          {
            if [ "$PUSH_OUTCOME" = "success" ]; then
              echo "⚠️ Conflicts with \`$BASE_REF\` were resolved and pushed, but a follow-up step failed — see the [workflow run]($RUN_URL). The merge commit itself is on the branch."
            elif [ "$WF_BLOCKED" = "true" ]; then
              echo "⚠️ Merging \`$BASE_REF\` into this branch changes \`.github/workflows/\`, which the default token cannot push. Merge the base branch manually (\`git merge $BASE_REF\`), or add a \`CONFLICT_RESOLVER_PAT\` secret (repo + workflow scope) so this workflow can handle it. [Workflow run]($RUN_URL)"
            else
              echo "⚠️ Could not auto-resolve conflicts with \`$BASE_REF\` — manual resolution needed. See the [workflow run]($RUN_URL)."
              # prefer the verify step's recomputed list when it exists; the
              # merge step's list is informational (pre-AI)
              list="$RUNNER_TEMP/conflicted-derived.txt"
              [ -s "$list" ] || list="$RUNNER_TEMP/conflicted.txt"
              if [ -s "$list" ]; then
                echo
                echo "Conflicted files (as recorded by the merge step):"
                sed 's/^/- `/; s/$/`/' "$list"
              fi
            fi
          } > "$body"
          gh pr comment "$PR_NUMBER" --repo "$REPO" --body-file "$body" || true
