name: PR Visual Recap

# Visual code review: a coding agent runs the repo's visual-recap skill over the
# PR diff, publishes a plan, and upserts one sticky comment with a screenshot.
# Plain `pull_request` (NOT `pull_request_target`) so fork code never sees secrets.

on:
  pull_request:
    types: [opened, synchronize, reopened, ready_for_review, labeled, closed]

permissions:
  contents: read

concurrency:
  group: pr-visual-recap-${{ github.event.pull_request.number }}
  cancel-in-progress: true

env:
  VISUAL_RECAP_AGENT: ${{ vars.VISUAL_RECAP_AGENT || 'claude' }}
  VISUAL_RECAP_BASE_URL: ${{ vars.VISUAL_RECAP_BASE_URL || '' }}
  VISUAL_RECAP_REQUIRED_LABELS: ${{ vars.VISUAL_RECAP_REQUIRED_LABELS || '' }}
  VISUAL_RECAP_SKILL_SOURCE: ${{ vars.VISUAL_RECAP_SKILL_SOURCE || 'auto' }}
  VISUAL_RECAP_SECRET_SCAN: ${{ vars.VISUAL_RECAP_SECRET_SCAN || 'high-confidence' }}

jobs:
  gate:
    name: Gate
    if: github.event.action != 'labeled' || vars.VISUAL_RECAP_REQUIRED_LABELS != ''
    # A custom plain-label runner is allowed only for trusted same-repo authors.
    # Fork and untrusted PRs are forced onto GitHub-hosted ubuntu-latest before
    # any step starts. The only fromJSON input is this static association list.
    runs-on: ${{ github.event.pull_request.head.repo.full_name == github.repository && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association) && (vars.VISUAL_RECAP_GATE_RUNS_ON || 'ubuntu-latest') || 'ubuntu-latest' }}
    timeout-minutes: 10
    permissions:
      contents: read
      pull-requests: read
    outputs:
      run: ${{ steps.decide.outputs.run }}
      agent: ${{ steps.decide.outputs.agent }}
      runs_on: ${{ steps.decide.outputs.runs_on }}
    steps:
      - id: decide
        uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
        env:
          # Presence-only signals — never expose secret VALUES to the gate.
          HAS_PLAN: ${{ secrets.PLAN_RECAP_TOKEN != '' }}
          HAS_ANTHROPIC: ${{ secrets.ANTHROPIC_API_KEY != '' }}
          HAS_OPENAI: ${{ secrets.OPENAI_API_KEY != '' }}
          HAS_COMPATIBLE: ${{ secrets.VISUAL_RECAP_API_KEY != '' }}
          AGENT: ${{ env.VISUAL_RECAP_AGENT }}
          VISUAL_RECAP_BASE_URL: ${{ env.VISUAL_RECAP_BASE_URL }}
          VISUAL_RECAP_MODEL: ${{ vars.VISUAL_RECAP_MODEL }}
          VISUAL_RECAP_RUNS_ON: ${{ vars.VISUAL_RECAP_RUNS_ON || '"ubuntu-latest"' }}
          VISUAL_RECAP_REQUIRED_LABELS: ${{ env.VISUAL_RECAP_REQUIRED_LABELS }}
          VISUAL_RECAP_SKILL_SOURCE: ${{ env.VISUAL_RECAP_SKILL_SOURCE }}
          HEAD_SHA: ${{ github.event.pull_request.head.sha }}
        with:
          script: |
            const pr = context.payload.pull_request;
            const reasons = [];

            if (!pr) reasons.push('no pull_request payload');
            if (pr && pr.draft) reasons.push('draft PR');
            if (pr && context.payload.action === 'closed' && !pr.merged) {
              reasons.push('closed without merge');
            }
            const requiredLabels = (process.env.VISUAL_RECAP_REQUIRED_LABELS || '')
              .split(',')
              .map((label) => label.trim().toLowerCase())
              .filter(Boolean);
            if (pr && requiredLabels.length > 0) {
              const prLabels = new Set((Array.isArray(pr.labels) ? pr.labels : [])
                .map((label) => typeof label === 'string' ? label : label && label.name)
                .filter(Boolean)
                .map((label) => String(label).toLowerCase()));
              if (!requiredLabels.some((label) => prLabels.has(label))) {
                reasons.push(`missing required recap label (${requiredLabels.join(', ')})`);
              }
            }

            // Fork PRs only receive repo secrets when the org/repo opts into
            // GitHub's "Send secrets to workflows from pull requests" setting
            // (common in private orgs that use forks heavily). Gate on secret
            // availability, not fork-ness: run on forks that have the token,
            // and skip — with an actionable hint — those that don't.
            const headRepo = pr && pr.head && pr.head.repo && pr.head.repo.full_name;
            const isFork = !!(pr && headRepo && headRepo !== process.env.GITHUB_REPOSITORY);
            const isPrivate = !!(context.payload.repository && context.payload.repository.private);
            const association = (pr && pr.author_association || '').toUpperCase();
            const trustedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR'];
            const isTrustedAuthor = trustedAssociations.includes(association);
            let configuredRunner = 'ubuntu-latest';
            let usesSelfHostedRunner = false;
            try {
              const candidate = JSON.parse(process.env.VISUAL_RECAP_RUNS_ON || '"ubuntu-latest"');
              const hosted = typeof candidate === 'string' && /^(?:ubuntu|windows|macos)-[A-Za-z0-9.-]+$/.test(candidate);
              const selfHosted = Array.isArray(candidate) && candidate.length >= 1 && candidate.length <= 20 && candidate.includes('self-hosted') && candidate.every((label) => typeof label === 'string' && label.length >= 1 && label.length <= 100 && !/[\u0000-\u001f\u007f]/.test(label)) && new Set(candidate).size === candidate.length;
              if (!hosted && !selfHosted) throw new Error('unsupported runner value');
              configuredRunner = candidate;
              usesSelfHostedRunner = selfHosted;
            } catch {
              reasons.push('invalid VISUAL_RECAP_RUNS_ON JSON');
            }
            if (usesSelfHostedRunner && (isFork || !isTrustedAuthor)) {
              reasons.push('self-hosted runner mode requires a trusted same-repository PR author');
            }
            if (isFork && process.env.HAS_PLAN !== 'true') {
              reasons.push(`fork PR (${headRepo}) without secret access — enable "Send secrets to workflows from pull requests" (and write tokens) in the repo/org Actions settings to run recaps on forks`);
            }

            const login = (pr && pr.user && pr.user.login || '').toLowerCase();
            const botAuthors = ['dependabot[bot]', 'dependabot', 'renovate[bot]', 'renovate'];
            if (botAuthors.includes(login)) reasons.push(`bot author (${login})`);
            if (pr && pr.user && pr.user.type === 'Bot') reasons.push('bot author (type=Bot)');

            if (!isFork && process.env.HAS_PLAN !== 'true') reasons.push('PLAN_RECAP_TOKEN not configured');

            // Normalize + validate the agent so a mis-cased value can't pass the
            // gate and then match neither agent step below.
            const rawAgent = (process.env.AGENT || 'claude').toLowerCase();
            const agent = ['deepseek', 'kimi', 'moonshot', 'custom'].includes(rawAgent) ? 'openai-compatible' : rawAgent;
            if (!['claude', 'codex', 'openai-compatible'].includes(agent)) {
              reasons.push(`unsupported VISUAL_RECAP_AGENT "${process.env.AGENT}" (expected "claude", "codex", or "openai-compatible")`);
            } else if (agent === 'codex') {
              if (process.env.HAS_OPENAI !== 'true') reasons.push('OPENAI_API_KEY not configured (codex backend)');
            } else if (agent === 'claude') {
              if (process.env.HAS_ANTHROPIC !== 'true') reasons.push('ANTHROPIC_API_KEY not configured (claude backend)');
            } else {
              if (process.env.HAS_COMPATIBLE !== 'true') reasons.push('VISUAL_RECAP_API_KEY not configured (openai-compatible backend)');
              if (!(process.env.VISUAL_RECAP_MODEL || '').trim()) reasons.push('VISUAL_RECAP_MODEL is required (openai-compatible backend)');
              const baseUrl = process.env.VISUAL_RECAP_BASE_URL || '';
              try {
                const parsed = new URL(baseUrl);
                if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password) {
                  reasons.push('VISUAL_RECAP_BASE_URL must be an http(s) URL without credentials');
                }
              } catch {
                reasons.push('VISUAL_RECAP_BASE_URL must be a valid http(s) URL');
              }
            }

            // Validate the model before it reaches the agent CLI.
            const model = process.env.VISUAL_RECAP_MODEL || '';
            if (model && !/^[a-zA-Z0-9._-]{1,80}$/.test(model)) {
              reasons.push(`invalid VISUAL_RECAP_MODEL value (must match [a-zA-Z0-9._-]{1,80})`);
            }

            const skillSource = (process.env.VISUAL_RECAP_SKILL_SOURCE || 'auto').toLowerCase();
            if (!['auto', 'latest', 'repo'].includes(skillSource)) {
              reasons.push('invalid VISUAL_RECAP_SKILL_SOURCE value (expected "auto", "latest", or "repo")');
            }
            const usesRepoSkill = skillSource === 'repo';

            // Self-modifying guard, evaluated in the trusted gate (runs NO
            // PR-checked-out code): skip the ENTIRE job if the PR touches the
            // repo-pinned skill instructions or any agent config the runner
            // loads, so a PR can't rewrite what the agent loads and exfiltrate
            // secrets. With the default bundled skill source, visual skill and
            // recap workflow files are reviewed content, not instructions loaded
            // by the runner.
            // Keep this guard for untrusted forks and untrusted public-repo PRs.
            // Trusted write actors may edit recap-control files as normal
            // reviewable content; running the recap is useful signal for those
            // changes.
            if (pr && !isTrustedAuthor && (isFork || !isPrivate)) {
              try {
                const files = await github.paginate(github.rest.pulls.listFiles, {
                  owner: context.repo.owner,
                  repo: context.repo.repo,
                  pull_number: pr.number,
                  per_page: 100,
                });
                const isSensitive = (p) =>
                  (usesRepoSkill && /(^|\/)skills\/visual-(recap|plan|plans)\//.test(p)) ||
                  p.startsWith('.claude/') ||
                  p === 'CLAUDE.md' ||
                  p === 'AGENTS.md' ||
                  p === '.mcp.json';
                const hits = files.map((f) => f.filename).filter(isSensitive);
                if (hits.length) {
                  reasons.push(`PR modifies recap-control files (${hits.slice(0, 3).join(', ')}${hits.length > 3 ? ', …' : ''}) — skipping so untrusted PR code never runs with secrets`);
                }
              } catch (e) {
                // Fail closed: if the file list can't be read, skip.
                reasons.push(`could not list PR files for the self-modifying guard (${e.message}); skipping to be safe`);
              }
            }

            const run = reasons.length === 0;
            core.setOutput('run', run ? 'true' : 'false');
            core.setOutput('agent', agent);
            core.setOutput('runs_on', JSON.stringify(configuredRunner));
            if (run) {
              core.info(`Visual recap will run (${agent}).`);
            } else {
              // Surface the skip reason as a run-summary annotation, not just a
              // buried info log, so it's clear in the Actions UI why we skipped.
              core.notice(`Visual recap skipped: ${reasons.join('; ')}`);
            }

  recap:
    name: Generate visual recap
    needs: gate
    if: needs.gate.outputs.run == 'true'
    runs-on: ${{ fromJSON(needs.gate.outputs.runs_on) }}
    timeout-minutes: 30
    defaults:
      run:
        shell: bash
    permissions:
      actions: write
      checks: write
      contents: read
      issues: write
      pull-requests: write
    env:
      PLAN_RECAP_APP_URL: ${{ secrets.PLAN_RECAP_APP_URL || 'https://plan.agent-native.com' }}
      PLAN_RECAP_TOKEN: ${{ secrets.PLAN_RECAP_TOKEN }}
      GH_TOKEN: ${{ github.token }}
      PR_NUMBER: ${{ github.event.pull_request.number }}
      PR_STATE: ${{ github.event.pull_request.state }}
      PR_MERGED: ${{ github.event.pull_request.merged }}
      PR_MERGED_AT: ${{ github.event.pull_request.merged_at }}
      HEAD_SHA: ${{ github.event.pull_request.head.sha }}
      VISUAL_RECAP_MODEL: ${{ vars.VISUAL_RECAP_MODEL }}
      VISUAL_RECAP_BASE_URL: ${{ vars.VISUAL_RECAP_BASE_URL || '' }}
      VISUAL_RECAP_REASONING: ${{ vars.VISUAL_RECAP_REASONING }}
      VISUAL_RECAP_SKILL_SOURCE: ${{ vars.VISUAL_RECAP_SKILL_SOURCE || 'auto' }}
      VISUAL_RECAP_SECRET_SCAN: ${{ vars.VISUAL_RECAP_SECRET_SCAN || 'high-confidence' }}
    steps:
      - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
        with:
          fetch-depth: 0
          # This job runs an agent over untrusted PR diff; don't leave the token
          # in .git/config (it uses GH_TOKEN for gh API calls, never git push).
          persist-credentials: false

      # Dogfood trusted base-branch source inside this monorepo, else install the
      # published package once. Never execute PR-head recap CLI code.
      - name: Resolve recap CLI
        id: cli
        env:
          # Optional: pin the consumer CLI version (e.g. "1.2.3"). Defaults to
          # "latest" when unset. Set via repository variable RECAP_CLI_VERSION.
          RECAP_CLI_VERSION: ${{ vars.RECAP_CLI_VERSION || 'latest' }}
        run: |
          if [ "$GITHUB_REPOSITORY" = "BuilderIO/agent-native" ] && [ -f packages/core/src/cli/index.ts ]; then
            echo "local=true" >> "$GITHUB_OUTPUT"
          else
            echo "local=false" >> "$GITHUB_OUTPUT"
          fi

      - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
        if: steps.cli.outputs.local == 'true'
        with:
          ref: ${{ github.event.pull_request.base.sha }}
          path: .recap-cli-source
          fetch-depth: 1
          persist-credentials: false

      - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
        if: steps.cli.outputs.local == 'true'

      - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
        with:
          node-version: "22"
          cache: ${{ steps.cli.outputs.local == 'true' && 'pnpm' || '' }}

      - name: Install trusted workspace recap CLI
        if: steps.cli.outputs.local == 'true'
        working-directory: .recap-cli-source
        run: |
          set -euo pipefail
          pnpm install --frozen-lockfile --ignore-scripts
          pnpm --filter '@agent-native/core...' build
          echo "RECAP_CLI=$PWD/node_modules/.bin/tsx $PWD/packages/core/src/cli/index.ts" >> "$GITHUB_ENV"
          echo "CODE_CLI=$PWD/node_modules/.bin/tsx $PWD/packages/core/src/cli/index.ts" >> "$GITHUB_ENV"
          echo "RECAP_PLAYWRIGHT=$PWD/node_modules/.bin/playwright" >> "$GITHUB_ENV"

      - name: Install published recap CLI
        if: steps.cli.outputs.local != 'true'
        env:
          RECAP_CLI_VERSION: ${{ vars.RECAP_CLI_VERSION || 'latest' }}
        run: |
          set -euo pipefail
          VERSION="$RECAP_CLI_VERSION"
          if [ "$VERSION" = "latest" ]; then
            VERSION="$(npm view @agent-native/recap-cli@latest version)"
          fi
          for attempt in 1 2 3; do
            if npm install --prefix "$RUNNER_TEMP/recap-cli" --no-audit --no-fund --ignore-scripts "@agent-native/recap-cli@$VERSION"; then
              break
            fi
            if [ "$attempt" = "3" ]; then exit 1; fi
            sleep $((attempt * 10))
          done
          echo "RECAP_CLI_VERSION=$VERSION" >> "$GITHUB_ENV"
          echo "RECAP_CLI=$RUNNER_TEMP/recap-cli/node_modules/.bin/agent-native" >> "$GITHUB_ENV"
          echo "RECAP_PLAYWRIGHT=$RUNNER_TEMP/recap-cli/node_modules/.bin/playwright" >> "$GITHUB_ENV"

      - name: Install OpenAI-compatible provider runtime
        if: needs.gate.outputs.agent == 'openai-compatible' && steps.cli.outputs.local != 'true'
        env:
          CORE_CLI_VERSION: ${{ vars.CORE_CLI_VERSION || 'latest' }}
        run: |
          set -euo pipefail
          CORE_VERSION="$CORE_CLI_VERSION"
          if [ "$CORE_VERSION" = "latest" ]; then
            CORE_VERSION="$(npm view @agent-native/core@latest version)"
          fi
          npm install --prefix "$RUNNER_TEMP/recap-code" --no-audit --no-fund --ignore-scripts ai @ai-sdk/openai "@agent-native/core@$CORE_VERSION"
          echo "CORE_CLI_VERSION=$CORE_VERSION" >> "$GITHUB_ENV"
          echo "CODE_CLI=$RUNNER_TEMP/recap-code/node_modules/.bin/agent-native" >> "$GITHUB_ENV"

      - name: Start visual recap check
        id: recap_check
        continue-on-error: true
        run: |
          set -uo pipefail
          $RECAP_CLI recap check start --sha "$HEAD_SHA" --workflow-url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"

      - name: Fetch pull request head
        env:
          PR_NUMBER_ENV: ${{ github.event.pull_request.number }}
        run: |
          set -euo pipefail
          if git cat-file -e "${HEAD_SHA}^{commit}" 2>/dev/null; then
            git update-ref refs/recap/pr-head "$HEAD_SHA"
          else
            AUTH_B64="$(printf 'x-access-token:%s' "$GH_TOKEN" | base64 | tr -d '\n')"
            git -c "http.https://github.com/.extraheader=AUTHORIZATION: basic $AUTH_B64" fetch origin "pull/${PR_NUMBER_ENV}/head:refs/recap/pr-head"
          fi
          FETCHED_SHA="$(git rev-parse refs/recap/pr-head)"
          if [ "$FETCHED_SHA" != "$HEAD_SHA" ]; then
            echo "FATAL: fetched PR head $FETCHED_SHA != event HEAD_SHA $HEAD_SHA — aborting to avoid recapping the wrong commit"
            exit 1
          fi

      - name: Collect bounded diff
        id: diff
        env:
          BASE_SHA: ${{ github.event.pull_request.base.sha }}
        run: |
          set -euo pipefail
          $RECAP_CLI recap collect-diff --base "$BASE_SHA" --head refs/recap/pr-head --out recap.diff --stat recap.stat

      - name: Probe plan-app auth
        id: auth_probe
        if: steps.diff.outputs.tiny != 'true'
        continue-on-error: true
        run: |
          set -uo pipefail
          # Hit the plan app's action surface with the publish token. A 401 means
          # the token is expired/revoked; surface it in the sticky comment so the
          # repo owner knows to re-mint it instead of seeing a generic failure.
          HTTP_STATUS=$(node -e '
            const https = require("https");
            const url = new URL("/_agent-native/actions/record-recap-usage", process.env.PLAN_RECAP_APP_URL || "https://plan.agent-native.com");
            const req = https.request(url, { method: "POST", headers: { "authorization": "Bearer " + process.env.PLAN_RECAP_TOKEN, "content-type": "application/json" }, timeout: 8000 }, (res) => { process.stdout.write(String(res.statusCode)); req.destroy(); });
            req.on("error", () => process.stdout.write("0"));
            req.end(JSON.stringify({ planId: "__probe__" }));
          ' 2>/dev/null || echo "0")
          if [ "$HTTP_STATUS" = "401" ]; then
            echo "auth_failed=true" >> "$GITHUB_OUTPUT"
          else
            echo "auth_failed=false" >> "$GITHUB_OUTPUT"
          fi

      - name: Probe plan-app route health
        id: route_health
        if: steps.diff.outputs.tiny != 'true'
        continue-on-error: true
        run: |
          set -uo pipefail
          # Pre-publish health gate: confirm the plan app's recap action routes
          # are actually deployed BEFORE the agent runs. A 404 from
          # create-visual-recap (POST) or get-plan-blocks (GET) means the
          # plan-app deploy has not propagated yet (the client is ahead of the
          # deployed server). Say that plainly here instead of letting the agent
          # run and then fail confusingly at publish time. A 401 or 200 is
          # healthy — the route exists, it just rejected/accepted the probe.
          probe_status() {
            ROUTE="$1" METHOD="$2" node -e '
              const https = require("https");
              const base = process.env.PLAN_RECAP_APP_URL || "https://plan.agent-native.com";
              const url = new URL(process.env.ROUTE, base);
              if (process.env.METHOD === "GET") url.searchParams.set("format", "reference");
              const req = https.request(url, { method: process.env.METHOD, headers: { "authorization": "Bearer " + (process.env.PLAN_RECAP_TOKEN || ""), "content-type": "application/json" }, timeout: 8000 }, (res) => { process.stdout.write(String(res.statusCode)); req.destroy(); });
              req.on("error", () => process.stdout.write("0"));
              req.on("timeout", () => { process.stdout.write("0"); req.destroy(); });
              if (process.env.METHOD === "POST") { req.end(JSON.stringify({ __probe__: true })); } else { req.end(); }
            ' 2>/dev/null || echo "0"
          }
          CREATE_STATUS="$(probe_status /_agent-native/actions/create-visual-recap POST)"
          BLOCKS_STATUS="$(probe_status /_agent-native/actions/get-plan-blocks GET)"
          REASON=""
          if [ "$CREATE_STATUS" = "404" ] || [ "$BLOCKS_STATUS" = "404" ]; then
            REASON="Plan app routes return 404 — deploy not yet propagated (create-visual-recap: $CREATE_STATUS, get-plan-blocks: $BLOCKS_STATUS). The plan-app client is ahead of the deployed server; re-run once the deploy finishes propagating."
            echo "::error::$REASON"
            echo "unhealthy=true" >> "$GITHUB_OUTPUT"
          else
            echo "unhealthy=false" >> "$GITHUB_OUTPUT"
          fi
          {
            echo 'reason<<__RECAP_ROUTE_HEALTH_EOF__'
            echo "$REASON"
            echo '__RECAP_ROUTE_HEALTH_EOF__'
          } >> "$GITHUB_OUTPUT"

      - name: Secret scan
        id: scan
        if: steps.diff.outputs.tiny != 'true' && steps.route_health.outputs.unhealthy != 'true'
        run: |
          set -uo pipefail
          # Fail CLOSED: a scanner error or invalid JSON suppresses the diff so a
          # credential-bearing diff is never handed to the agent / plan service.
          if ! SCAN_JSON="$($RECAP_CLI recap scan --diff recap.diff --mode "$VISUAL_RECAP_SECRET_SCAN")"; then
            SCAN_JSON='{"suppressed":true,"reason":"secret scan failed to run; failing closed"}'
          fi
          {
            echo 'json<<__RECAP_SCAN_EOF__'
            echo "$SCAN_JSON"
            echo '__RECAP_SCAN_EOF__'
          } >> "$GITHUB_OUTPUT"
          SUPPRESSED=$(node -e 'try{process.stdout.write(JSON.parse(process.argv[1]).suppressed?"true":"false")}catch{process.stdout.write("true")}' "$SCAN_JSON")
          echo "suppressed=$SUPPRESSED" >> "$GITHUB_OUTPUT"

      - name: Read previous plan id
        id: prev
        if: steps.diff.outputs.tiny != 'true' && steps.route_health.outputs.unhealthy != 'true'
        continue-on-error: true
        run: |
          set -euo pipefail
          PLAN_ID="$($RECAP_CLI recap comment find-plan-id --repo "$GITHUB_REPOSITORY" --issue "$PR_NUMBER" --token "$GH_TOKEN")"
          echo "plan_id=$PLAN_ID" >> "$GITHUB_OUTPUT"

      - name: Fetch plan block reference
        id: block_reference
        if: steps.diff.outputs.tiny != 'true' && steps.route_health.outputs.unhealthy != 'true' && steps.scan.outputs.suppressed != 'true'
        continue-on-error: true
        run: |
          set -uo pipefail
          if $RECAP_CLI recap block-reference --app-url "$PLAN_RECAP_APP_URL" --out recap-blocks.md; then
            echo "ok=true" >> "$GITHUB_OUTPUT"
          else
            echo "ok=false" >> "$GITHUB_OUTPUT"
            {
              echo 'summary<<__RECAP_BLOCK_REFERENCE_EOF__'
              echo "Could not fetch the live plan block reference; the agent will fall back to bundled visual-recap instructions and the hosted Plan action will validate the final MDX."
              echo '__RECAP_BLOCK_REFERENCE_EOF__'
            } >> "$GITHUB_OUTPUT"
            cat > recap-blocks.md <<'EOF'
          Live plan block reference unavailable. Follow the bundled visual-recap skill and author conservative MDX; the deterministic publisher will validate the source before posting.
          EOF
          fi

      - name: Build recap prompt
        id: prompt
        if: steps.diff.outputs.tiny != 'true' && steps.route_health.outputs.unhealthy != 'true' && steps.scan.outputs.suppressed != 'true'
        env:
          # Pass step outputs via env, NOT ${{ }} interpolation into the run body:
          # the prev plan id is parsed from a PR comment and could inject shell.
          PREV_PLAN_ID: ${{ steps.prev.outputs.plan_id }}
          DIFF_HUGE: ${{ steps.diff.outputs.huge }}
          IS_FORK: ${{ github.event.pull_request.head.repo.full_name != github.repository }}
        run: |
          set -euo pipefail
          ARGS=(--diff recap.diff --stat recap.stat --block-reference recap-blocks.md --pr "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --head "$HEAD_SHA" --app-url "$PLAN_RECAP_APP_URL" --skill-source "$VISUAL_RECAP_SKILL_SOURCE" --out recap-prompt.md)
          if [ "${DIFF_HUGE:-}" = "true" ]; then ARGS+=(--huge); fi
          if [ "${IS_FORK:-}" = "true" ]; then ARGS+=(--fork-pr true); fi
          if [ -n "${PREV_PLAN_ID:-}" ]; then ARGS+=(--prev-plan-id "$PREV_PLAN_ID"); fi
          $RECAP_CLI recap build-prompt "${ARGS[@]}"

      - name: Run agent (Claude Code)
        id: claude
        if: needs.gate.outputs.agent == 'claude' && steps.diff.outputs.tiny != 'true' && steps.route_health.outputs.unhealthy != 'true' && steps.scan.outputs.suppressed != 'true'
        continue-on-error: true
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
        run: |
          set -uo pipefail
          CLAUDE_ALLOWED_TOOLS="Read,Write,Bash(git diff:*)"
          CLAUDE_ARGS=(-p "$(cat recap-prompt.md)" --allowedTools "$CLAUDE_ALLOWED_TOOLS" --permission-mode dontAsk --output-format json)
          CLAUDE_ARGS+=(--model "${VISUAL_RECAP_MODEL:-claude-sonnet-5}")
          rm -f recap-source.json recap-url.txt recap-url-reason.txt claude-result.json claude-stderr.log
          run_claude() {
            set +e
            npx -y @anthropic-ai/claude-code@2 "${CLAUDE_ARGS[@]}" > claude-result.json 2> claude-stderr.log
            CLAUDE_STATUS="$?"
            set -e
            echo "$CLAUDE_STATUS" > claude-exit-code.txt
          }
          run_claude
          # A clean agent exit WITHOUT recap-source.json is the strongest
          # "retry me" signal — the deterministic publisher needs that file, and
          # the agent occasionally finishes a turn without writing it. Retry once.
          if [ ! -s recap-source.json ]; then
            if grep -Eiq -- 'quota exceeded|billing details|insufficient (credits|quota)|rate[-_ ]limit|invalid (api )?key|authentication (failed|error)|unauthorized|forbidden' claude-result.json claude-stderr.log 2>/dev/null; then
              echo "::error::Visual recap agent failed with a non-retryable provider error; skipping the duplicate retry."
            else
              echo "::warning::recap-source.json missing after the agent run; retrying the agent once."
              sleep 5
              run_claude
            fi
          fi

      - name: Run agent (Codex)
        id: codex
        if: needs.gate.outputs.agent == 'codex' && steps.diff.outputs.tiny != 'true' && steps.route_health.outputs.unhealthy != 'true' && steps.scan.outputs.suppressed != 'true'
        continue-on-error: true
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
        run: |
          set -uo pipefail
          # `codex login` writes ~/.codex/auth.json (the bare env var is dropped on
          # the gpt-5.5 wss transport); stdin keeps the key out of process args.
          printenv OPENAI_API_KEY | npx -y @openai/codex@0 login --with-api-key || true
          # The runner is itself an ephemeral sandbox; bypass Codex's own sandbox
          # (bubblewrap can't init here) and approval gate (cancels the MCP write).
          CODEX_ARGS=(exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check)
          if [ -n "${VISUAL_RECAP_MODEL:-}" ]; then CODEX_ARGS+=(--model "$VISUAL_RECAP_MODEL"); fi
          # Validate reasoning against the enum before embedding it in the TOML override.
          case "${VISUAL_RECAP_REASONING:-}" in
            none|minimal|low|medium|high|xhigh)
              CODEX_ARGS+=(-c "model_reasoning_effort=\"$VISUAL_RECAP_REASONING\"") ;;
            "") ;;
            *) echo "Ignoring invalid VISUAL_RECAP_REASONING: $VISUAL_RECAP_REASONING" ;;
          esac
          rm -f recap-source.json recap-url.txt recap-url-reason.txt codex-events.jsonl codex-stderr.log
          run_codex() {
            set +e
            npx -y @openai/codex@0 "${CODEX_ARGS[@]}" --json "$(cat recap-prompt.md)" 2> codex-stderr.log | tee codex-events.jsonl
            CODEX_STATUS="${PIPESTATUS[0]}"
            set -e
            echo "$CODEX_STATUS" > codex-exit-code.txt
          }
          run_codex
          # Retry once if the agent exited without writing recap-source.json
          # (see the Claude step) — the publisher needs that file.
          if [ ! -s recap-source.json ]; then
            if grep -Eiq -- 'quota exceeded|billing details|insufficient (credits|quota)|rate[-_ ]limit|invalid (api )?key|authentication (failed|error)|unauthorized|forbidden' codex-events.jsonl codex-stderr.log 2>/dev/null; then
              echo "::error::Visual recap agent failed with a non-retryable provider error; skipping the duplicate retry."
            else
              echo "::warning::recap-source.json missing after the agent run; retrying the agent once."
              sleep 5
              run_codex
            fi
          fi

      - name: Run agent (OpenAI-compatible)
        id: openai_compatible
        if: needs.gate.outputs.agent == 'openai-compatible' && steps.diff.outputs.tiny != 'true' && steps.route_health.outputs.unhealthy != 'true' && steps.scan.outputs.suppressed != 'true'
        continue-on-error: true
        env:
          OPENAI_API_KEY: ${{ secrets.VISUAL_RECAP_API_KEY }}
          OPENAI_BASE_URL: ${{ vars.VISUAL_RECAP_BASE_URL }}
          AGENT_ENGINE: ai-sdk:openai
          AGENT_MODEL: ${{ vars.VISUAL_RECAP_MODEL }}
          AGENT_NATIVE_CODE_USAGE_FILE: openai-compatible-usage.json
          AGENT_NATIVE_CODE_TOOL_PROFILE: recap-source
        run: |
          set -uo pipefail
          rm -f recap-source.json recap-url.txt recap-url-reason.txt openai-compatible-result.txt openai-compatible-usage.json openai-compatible-stderr.log
          run_openai_compatible() {
            set +e
            $CODE_CLI code exec --permission-mode auto-edit "$(cat recap-prompt.md)" > openai-compatible-result.txt 2> openai-compatible-stderr.log
            OPENAI_COMPATIBLE_STATUS="$?"
            set -e
            echo "$OPENAI_COMPATIBLE_STATUS" > openai-compatible-exit-code.txt
          }
          run_openai_compatible
          if [ ! -s recap-source.json ]; then
            if grep -Eiq -- 'quota exceeded|billing details|insufficient (credits|quota)|rate[-_ ]limit|invalid (api )?key|authentication (failed|error)|unauthorized|forbidden' openai-compatible-result.txt openai-compatible-stderr.log 2>/dev/null; then
              echo "::error::Visual recap agent failed with a non-retryable provider error; skipping the duplicate retry."
            else
              echo "::warning::recap-source.json missing after the agent run; retrying the agent once."
              sleep 5
              run_openai_compatible
            fi
          fi

      - name: Check recap source
        id: source_status
        if: steps.diff.outputs.tiny != 'true' && steps.route_health.outputs.unhealthy != 'true' && steps.scan.outputs.suppressed != 'true'
        env:
          RECAP_AGENT: ${{ needs.gate.outputs.agent }}
        run: |
          set -uo pipefail
          if [ -s recap-source.json ]; then
            echo "ready=true" >> "$GITHUB_OUTPUT"
            exit 0
          fi
          case "$RECAP_AGENT" in
            codex) AGENT_LABEL="Codex" ;;
            claude) AGENT_LABEL="Claude" ;;
            openai-compatible) AGENT_LABEL="OpenAI-compatible agent" ;;
            *) AGENT_LABEL="Recap agent" ;;
          esac
          if grep -Eiq -- 'quota exceeded|billing details|insufficient (credits|quota)|rate[-_ ]limit' codex-events.jsonl codex-stderr.log claude-result.json claude-stderr.log openai-compatible-result.txt openai-compatible-stderr.log 2>/dev/null; then
            case "$RECAP_AGENT" in
              codex) REASON="Codex could not author recap-source.json because the OpenAI API project's quota/budget is exhausted. Add API credits or raise that project's monthly budget, then rerun the workflow." ;;
              claude) REASON="Claude could not author recap-source.json because the Anthropic provider quota is exhausted. Restore its quota or billing, then rerun the workflow." ;;
              *) REASON="$AGENT_LABEL could not author recap-source.json because its provider quota was exceeded." ;;
            esac
          elif grep -Eiq -- 'invalid (api )?key|authentication (failed|error)|unauthorized|forbidden' codex-events.jsonl codex-stderr.log claude-result.json claude-stderr.log openai-compatible-result.txt openai-compatible-stderr.log 2>/dev/null; then
            REASON="$AGENT_LABEL could not author recap-source.json because provider authentication failed."
          else
            REASON="$AGENT_LABEL did not produce recap-source.json before source authoring completed."
          fi
          printf '%s\n' "$REASON" > recap-url-reason.txt
          echo "ready=false" >> "$GITHUB_OUTPUT"
          {
            echo 'reason<<__RECAP_SOURCE_REASON_EOF__'
            echo "$REASON"
            echo '__RECAP_SOURCE_REASON_EOF__'
          } >> "$GITHUB_OUTPUT"
          echo "::warning::$REASON Skipping deterministic publish."

      - name: Publish recap source
        id: publish
        if: steps.diff.outputs.tiny != 'true' && steps.route_health.outputs.unhealthy != 'true' && steps.scan.outputs.suppressed != 'true' && steps.source_status.outputs.ready == 'true'
        continue-on-error: true
        env:
          PREV_PLAN_ID: ${{ steps.prev.outputs.plan_id }}
        run: |
          set -uo pipefail
          ARGS=(--source recap-source.json --out recap-url.txt --repo "$GITHUB_REPOSITORY" --pr "$PR_NUMBER" --app-url "$PLAN_RECAP_APP_URL" --token "$PLAN_RECAP_TOKEN")
          if [ -n "${PREV_PLAN_ID:-}" ]; then ARGS+=(--prev-plan-id "$PREV_PLAN_ID"); fi
          ARGS+=(--source-type pull-request --source-repo "$GITHUB_REPOSITORY" --source-pr-number "$PR_NUMBER")
          if [ "${PR_MERGED:-false}" = "true" ] || [ -n "${PR_MERGED_AT:-}" ]; then
            ARGS+=(--source-pr-state merged)
          elif [ -n "${PR_STATE:-}" ]; then
            ARGS+=(--source-pr-state "$PR_STATE")
          fi
          if [ -n "${PR_MERGED_AT:-}" ]; then ARGS+=(--source-pr-merged-at "$PR_MERGED_AT"); fi
          $RECAP_CLI recap publish "${ARGS[@]}"

      - name: Build one-shot recap repair prompt
        id: repair_prompt
        if: steps.publish.outputs.repairable == 'true'
        run: |
          set -euo pipefail
          cp recap-source.json recap-source.initial.json
          $RECAP_CLI recap repair-prompt --source recap-source.json --reason-file recap-url-reason.txt --out recap-repair-prompt.md

      - name: Repair recap source (Claude Code)
        id: claude_repair
        if: steps.publish.outputs.repairable == 'true' && needs.gate.outputs.agent == 'claude'
        continue-on-error: true
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
        run: |
          set -uo pipefail
          CLAUDE_ALLOWED_TOOLS="Read,Write"
          CLAUDE_ARGS=(-p "$(cat recap-repair-prompt.md)" --allowedTools "$CLAUDE_ALLOWED_TOOLS" --permission-mode dontAsk --output-format json)
          CLAUDE_ARGS+=(--model "${VISUAL_RECAP_MODEL:-claude-sonnet-5}")
          rm -f claude-repair-result.json claude-repair-stderr.log
          set +e
          npx -y @anthropic-ai/claude-code@2 "${CLAUDE_ARGS[@]}" > claude-repair-result.json 2> claude-repair-stderr.log
          CLAUDE_REPAIR_STATUS="$?"
          set -e
          echo "$CLAUDE_REPAIR_STATUS" > claude-repair-exit-code.txt
          if [ "$CLAUDE_REPAIR_STATUS" -eq 0 ]; then echo "ok=true" >> "$GITHUB_OUTPUT"; else echo "ok=false" >> "$GITHUB_OUTPUT"; fi

      - name: Repair recap source (Codex)
        id: codex_repair
        if: steps.publish.outputs.repairable == 'true' && needs.gate.outputs.agent == 'codex'
        continue-on-error: true
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
        run: |
          set -uo pipefail
          printenv OPENAI_API_KEY | npx -y @openai/codex@0 login --with-api-key || true
          CODEX_ARGS=(exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check)
          if [ -n "${VISUAL_RECAP_MODEL:-}" ]; then CODEX_ARGS+=(--model "$VISUAL_RECAP_MODEL"); fi
          case "${VISUAL_RECAP_REASONING:-}" in
            none|minimal|low|medium|high|xhigh)
              CODEX_ARGS+=(-c "model_reasoning_effort=\"$VISUAL_RECAP_REASONING\"") ;;
            "") ;;
            *) echo "Ignoring invalid VISUAL_RECAP_REASONING: $VISUAL_RECAP_REASONING" ;;
          esac
          rm -f codex-repair-events.jsonl codex-repair-stderr.log
          set +e
          npx -y @openai/codex@0 "${CODEX_ARGS[@]}" --json "$(cat recap-repair-prompt.md)" 2> codex-repair-stderr.log | tee codex-repair-events.jsonl
          CODEX_REPAIR_STATUS="${PIPESTATUS[0]}"
          set -e
          echo "$CODEX_REPAIR_STATUS" > codex-repair-exit-code.txt
          if [ "$CODEX_REPAIR_STATUS" -eq 0 ]; then echo "ok=true" >> "$GITHUB_OUTPUT"; else echo "ok=false" >> "$GITHUB_OUTPUT"; fi

      - name: Repair recap source (OpenAI-compatible)
        id: openai_compatible_repair
        if: steps.publish.outputs.repairable == 'true' && needs.gate.outputs.agent == 'openai-compatible'
        continue-on-error: true
        env:
          OPENAI_API_KEY: ${{ secrets.VISUAL_RECAP_API_KEY }}
          OPENAI_BASE_URL: ${{ vars.VISUAL_RECAP_BASE_URL }}
          AGENT_ENGINE: ai-sdk:openai
          AGENT_MODEL: ${{ vars.VISUAL_RECAP_MODEL }}
          AGENT_NATIVE_CODE_USAGE_FILE: openai-compatible-repair-usage.json
          AGENT_NATIVE_CODE_TOOL_PROFILE: recap-source
        run: |
          set -uo pipefail
          rm -f openai-compatible-repair-result.txt openai-compatible-repair-usage.json openai-compatible-repair-stderr.log
          set +e
          $CODE_CLI code exec --permission-mode auto-edit "$(cat recap-repair-prompt.md)" > openai-compatible-repair-result.txt 2> openai-compatible-repair-stderr.log
          OPENAI_COMPATIBLE_REPAIR_STATUS="$?"
          set -e
          echo "$OPENAI_COMPATIBLE_REPAIR_STATUS" > openai-compatible-repair-exit-code.txt
          if [ "$OPENAI_COMPATIBLE_REPAIR_STATUS" -eq 0 ]; then echo "ok=true" >> "$GITHUB_OUTPUT"; else echo "ok=false" >> "$GITHUB_OUTPUT"; fi

      - name: Validate repaired recap source
        id: repaired_source
        if: steps.publish.outputs.repairable == 'true'
        env:
          REPAIR_AGENT_OK: ${{ steps.claude_repair.outputs.ok || steps.codex_repair.outputs.ok || steps.openai_compatible_repair.outputs.ok }}
        run: |
          set -uo pipefail
          REPAIR_REASON=""
          if [ "${REPAIR_AGENT_OK:-false}" != "true" ]; then
            echo "ok=false" >> "$GITHUB_OUTPUT"
            REPAIR_REASON="Repair agent exited unsuccessfully; repaired source was not published."
          else
            VALIDATION_JSON="$(GITHUB_OUTPUT=/dev/null $RECAP_CLI recap validate-repair --original recap-source.initial.json --source recap-source.json --reason-file recap-url-reason.txt || true)"
            REPAIR_OK="$(node -e 'try{process.stdout.write(JSON.parse(process.argv[1]).ok===true?"true":"false")}catch{process.stdout.write("false")}' "$VALIDATION_JSON")"
            REPAIR_REASON="$(node -e 'try{process.stdout.write(JSON.parse(process.argv[1]).reason||"")}catch{process.stdout.write("Repair validation returned invalid output.")}' "$VALIDATION_JSON")"
            echo "ok=$REPAIR_OK" >> "$GITHUB_OUTPUT"
          fi
          if [ -n "$REPAIR_REASON" ]; then
            echo "$REPAIR_REASON" > recap-url-reason.txt
          fi
          {
            echo 'reason<<__RECAP_REPAIR_REASON_EOF__'
            echo "$REPAIR_REASON"
            echo '__RECAP_REPAIR_REASON_EOF__'
          } >> "$GITHUB_OUTPUT"

      - name: Publish repaired recap source
        id: publish_repair
        if: steps.repaired_source.outputs.ok == 'true'
        continue-on-error: true
        env:
          PREV_PLAN_ID: ${{ steps.prev.outputs.plan_id }}
        run: |
          set -uo pipefail
          ARGS=(--source recap-source.json --out recap-url.txt --repo "$GITHUB_REPOSITORY" --pr "$PR_NUMBER" --app-url "$PLAN_RECAP_APP_URL" --token "$PLAN_RECAP_TOKEN")
          if [ -n "${PREV_PLAN_ID:-}" ]; then ARGS+=(--prev-plan-id "$PREV_PLAN_ID"); fi
          ARGS+=(--source-type pull-request --source-repo "$GITHUB_REPOSITORY" --source-pr-number "$PR_NUMBER")
          if [ "${PR_MERGED:-false}" = "true" ] || [ -n "${PR_MERGED_AT:-}" ]; then
            ARGS+=(--source-pr-state merged)
          elif [ -n "${PR_STATE:-}" ]; then
            ARGS+=(--source-pr-state "$PR_STATE")
          fi
          if [ -n "${PR_MERGED_AT:-}" ]; then ARGS+=(--source-pr-merged-at "$PR_MERGED_AT"); fi
          $RECAP_CLI recap publish "${ARGS[@]}"

      - name: Read plan URL
        id: url
        if: steps.diff.outputs.tiny != 'true' && steps.route_health.outputs.unhealthy != 'true' && steps.scan.outputs.suppressed != 'true'
        run: |
          set -uo pipefail
          PLAN_URL=""
          URL_REASON=""
          if [ -f recap-url.txt ]; then
            PLAN_URL="$(tr -d '\r\n' < recap-url.txt | tr -d ' ')"
          elif [ -f recap-url-reason.txt ]; then
            URL_REASON="$(cat recap-url-reason.txt)"
          else
            URL_REASON="recap-url.txt was not created."
          fi
          # recap-url.txt is agent-written -> untrusted. Rebuild a canonical
          # recap URL from the trusted app base and a strictly validated plan id,
          # preserving path-prefixed self-hosted mounts.
          if [ -z "$URL_REASON" ]; then
            URL_RESULT=$(PLAN_URL="$PLAN_URL" node <<'NODE'
          const emit = (value) => process.stdout.write(JSON.stringify(value));
          try {
            const raw = process.env.PLAN_URL || "";
            if (!raw) {
              emit({ url: "", reason: "recap-url.txt was empty" });
              process.exit(0);
            }
            const trusted = new URL(process.env.PLAN_RECAP_APP_URL || "https://plan.agent-native.com");
            const parsed = /^https?:\/\//i.test(raw)
              ? new URL(raw)
              : new URL(raw, trusted);
            if (parsed.origin !== trusted.origin) {
              emit({ url: "", reason: `recap-url.txt points at ${parsed.origin}, expected ${trusted.origin}` });
              process.exit(0);
            }

            const base = trusted.pathname.replace(/\/$/, "");
            const paths = [parsed.pathname];
            if (base && parsed.pathname.startsWith(`${base}/`)) {
              paths.push(parsed.pathname.slice(base.length) || "/");
            }

            for (const path of paths) {
              const match = path.match(/^\/(?:plans|recaps)\/([A-Za-z0-9_-]+)\/?$/);
              if (match) {
                emit({ url: `${trusted.origin}${base}/recaps/${match[1]}`, reason: "" });
                process.exit(0);
              }
            }
            emit({ url: "", reason: "recap-url.txt did not contain a valid /plans/<id> or /recaps/<id> URL for the configured plan app" });
          } catch {
            emit({ url: "", reason: "recap-url.txt was not a valid URL or recap path" });
          }
          NODE
            )
            CANONICAL_URL=$(node -e 'try{process.stdout.write(JSON.parse(process.argv[1]).url||"")}catch{process.stdout.write("")}' "$URL_RESULT")
            URL_REASON=$(node -e 'try{process.stdout.write(JSON.parse(process.argv[1]).reason||"")}catch{process.stdout.write("recap-url.txt URL validation failed")}' "$URL_RESULT")
          else
            CANONICAL_URL=""
          fi
          if [ -n "$CANONICAL_URL" ]; then
            echo "plan_url=$CANONICAL_URL" >> "$GITHUB_OUTPUT"; echo "ok=true" >> "$GITHUB_OUTPUT"
          else
            echo "plan_url=" >> "$GITHUB_OUTPUT"; echo "ok=false" >> "$GITHUB_OUTPUT"
          fi
          {
            echo 'reason<<__RECAP_URL_REASON_EOF__'
            echo "$URL_REASON"
            echo '__RECAP_URL_REASON_EOF__'
          } >> "$GITHUB_OUTPUT"

      - name: Summarize agent failure
        id: agent_summary
        if: steps.url.outputs.ok != 'true' && steps.diff.outputs.tiny != 'true' && steps.route_health.outputs.unhealthy != 'true' && steps.scan.outputs.suppressed != 'true'
        continue-on-error: true
        env:
          RECAP_AGENT: ${{ needs.gate.outputs.agent }}
          RECAP_REPAIR_ATTEMPTED: ${{ steps.repair_prompt.outcome == 'success' }}
          RECAP_BLOCK_REFERENCE_SUMMARY: ${{ steps.block_reference.outputs.summary }}
          RECAP_PUBLISH_REASON: ${{ steps.repaired_source.outputs.reason || steps.publish_repair.outputs.reason || steps.publish.outputs.reason }}
        run: |
          set -uo pipefail
          RESULT=claude-result.json
          STDERR=claude-stderr.log
          EXIT_CODE=claude-exit-code.txt
          if [ "$RECAP_AGENT" = "codex" ]; then
            RESULT=codex-events.jsonl
            STDERR=codex-stderr.log
            EXIT_CODE=codex-exit-code.txt
          elif [ "$RECAP_AGENT" = "openai-compatible" ]; then
            RESULT=openai-compatible-result.txt
            STDERR=openai-compatible-stderr.log
            EXIT_CODE=openai-compatible-exit-code.txt
          fi
          if [ "$RECAP_REPAIR_ATTEMPTED" = "true" ]; then
            RESULT=claude-repair-result.json
            STDERR=claude-repair-stderr.log
            EXIT_CODE=claude-repair-exit-code.txt
            if [ "$RECAP_AGENT" = "codex" ]; then
              RESULT=codex-repair-events.jsonl
              STDERR=codex-repair-stderr.log
              EXIT_CODE=codex-repair-exit-code.txt
            elif [ "$RECAP_AGENT" = "openai-compatible" ]; then
              RESULT=openai-compatible-repair-result.txt
              STDERR=openai-compatible-repair-stderr.log
              EXIT_CODE=openai-compatible-repair-exit-code.txt
            fi
          fi
          SUMMARY_JSON="$(GITHUB_OUTPUT=/dev/null $RECAP_CLI recap agent-summary --agent "$RECAP_AGENT" --result-file "$RESULT" --stderr-file "$STDERR" --exit-code-file "$EXIT_CODE" || echo '{}')"
          SUMMARY="$(node -e 'try { const value = JSON.parse(process.argv[1]).summary; process.stdout.write(typeof value === "string" ? value : ""); } catch {}' "$SUMMARY_JSON")"
          if [ -n "$SUMMARY" ]; then
            {
              echo 'summary<<__RECAP_AGENT_SUMMARY_EOF__'
              echo "$SUMMARY"
              echo '__RECAP_AGENT_SUMMARY_EOF__'
            } >> "$GITHUB_OUTPUT"
          elif [ -n "${RECAP_BLOCK_REFERENCE_SUMMARY:-}" ]; then
            {
              echo 'summary<<__RECAP_BLOCK_REFERENCE_SUMMARY_EOF__'
              echo "$RECAP_BLOCK_REFERENCE_SUMMARY"
              echo '__RECAP_BLOCK_REFERENCE_SUMMARY_EOF__'
            } >> "$GITHUB_OUTPUT"
          elif [ -n "${RECAP_PUBLISH_REASON:-}" ]; then
            {
              echo 'summary<<__RECAP_PUBLISH_SUMMARY_EOF__'
              echo "$RECAP_PUBLISH_REASON"
              echo '__RECAP_PUBLISH_SUMMARY_EOF__'
            } >> "$GITHUB_OUTPUT"
          fi

      - name: Attach usage
        if: steps.url.outputs.ok == 'true'
        continue-on-error: true
        env:
          PLAN_URL: ${{ steps.url.outputs.plan_url }}
          # Use the gate-normalized agent so "Codex" still selects the right file.
          RECAP_AGENT: ${{ needs.gate.outputs.agent }}
          RECAP_REPAIR_SUCCEEDED: ${{ steps.publish_repair.outcome == 'success' }}
        run: |
          set -uo pipefail
          RESULT=claude-result.json
          if [ "$RECAP_AGENT" = "codex" ]; then RESULT=codex-events.jsonl; fi
          if [ "$RECAP_AGENT" = "openai-compatible" ]; then RESULT=openai-compatible-usage.json; fi
          if [ "$RECAP_REPAIR_SUCCEEDED" = "true" ]; then
            RESULT=claude-repair-result.json
            if [ "$RECAP_AGENT" = "codex" ]; then RESULT=codex-repair-events.jsonl; fi
            if [ "$RECAP_AGENT" = "openai-compatible" ]; then RESULT=openai-compatible-repair-usage.json; fi
          fi
          if [ -f "$RESULT" ]; then $RECAP_CLI recap usage --plan-url "$PLAN_URL" --agent "$RECAP_AGENT" --result-file "$RESULT" --model "${VISUAL_RECAP_MODEL:-}" --app-url "$PLAN_RECAP_APP_URL" --token "$PLAN_RECAP_TOKEN" || true; fi

      - name: Cache Playwright browsers
        if: steps.url.outputs.ok == 'true'
        uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
        with:
          path: ~/.cache/ms-playwright
          key: playwright-1-${{ runner.os }}

      - name: Screenshot + upload
        id: shot
        if: steps.url.outputs.ok == 'true'
        continue-on-error: true
        env:
          # recap-url.txt is untrusted agent output; pass via env, never ${{ }}.
          PLAN_URL: ${{ steps.url.outputs.plan_url }}
        run: |
          set -uo pipefail
          if [ -n "${RECAP_PLAYWRIGHT:-}" ] && [ -x "$RECAP_PLAYWRIGHT" ]; then
            "$RECAP_PLAYWRIGHT" install --with-deps chromium || true
          elif command -v pnpm >/dev/null 2>&1; then
            pnpm exec playwright install --with-deps chromium 2>/dev/null || npx -y playwright@1 install --with-deps chromium || true
          else
            npx -y playwright@1 install --with-deps chromium || true
          fi
          IMAGE_CACHE_KEY="$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT"
          LIGHT_SHOT_JSON="$($RECAP_CLI recap shot --url "$PLAN_URL" --token "$PLAN_RECAP_TOKEN" --app-url "$PLAN_RECAP_APP_URL" --out recap.png --theme light --image-cache-key "$IMAGE_CACHE_KEY" || echo '{}')"
          DARK_SHOT_JSON="$($RECAP_CLI recap shot --url "$PLAN_URL" --token "$PLAN_RECAP_TOKEN" --app-url "$PLAN_RECAP_APP_URL" --out recap-dark.png --theme dark --image-cache-key "$IMAGE_CACHE_KEY" || echo '{}')"
          for SHOT_LABEL in light dark; do
            if [ "$SHOT_LABEL" = "light" ]; then SHOT_JSON="$LIGHT_SHOT_JSON"; else SHOT_JSON="$DARK_SHOT_JSON"; fi
            SHOT_LABEL="$SHOT_LABEL" SHOT_JSON="$SHOT_JSON" node -e 'const label = process.env.SHOT_LABEL || "shot"; let parsed = {}; try { parsed = JSON.parse(process.env.SHOT_JSON || "{}"); } catch { parsed = { ok: false, reason: "invalid shot JSON" }; } const summary = { ok: parsed.ok === true, imageUrl: parsed.imageUrl ? "[present]" : "", out: typeof parsed.out === "string" ? parsed.out : "", reason: typeof parsed.reason === "string" ? parsed.reason.slice(0, 500) : "" }; console.log(`[recap shot] ${label}: ${JSON.stringify(summary)}`);'
          done
          IMAGE_URL=$(node -e 'try{process.stdout.write(JSON.parse(process.argv[1]).imageUrl||"")}catch{process.stdout.write("")}' "$LIGHT_SHOT_JSON")
          DARK_IMAGE_URL=$(node -e 'try{process.stdout.write(JSON.parse(process.argv[1]).imageUrl||"")}catch{process.stdout.write("")}' "$DARK_SHOT_JSON")
          SHOT_STATUS=$(LIGHT_SHOT_JSON="$LIGHT_SHOT_JSON" DARK_SHOT_JSON="$DARK_SHOT_JSON" node <<'NODE'
          const parse = (raw) => { try { return JSON.parse(raw || "{}"); } catch { return { ok: false, reason: "invalid shot JSON" }; } };
          const shots = [["light", parse(process.env.LIGHT_SHOT_JSON)], ["dark", parse(process.env.DARK_SHOT_JSON)]];
          const hasAllImages = shots.every(([, shot]) => typeof shot.imageUrl === "string" && shot.imageUrl.trim());
          const reasons = shots.flatMap(([label, shot]) => {
            if (typeof shot.reason === "string" && shot.reason.trim()) return [`${label}: ${shot.reason.trim()}`];
            if (!(typeof shot.imageUrl === "string" && shot.imageUrl.trim())) return [`${label}: no imageUrl returned`];
            return [];
          });
          process.stdout.write(JSON.stringify({ ok: hasAllImages, reason: hasAllImages ? "" : reasons.join("; ").slice(0, 1000) }));
          NODE
          )
          SHOT_OK=$(node -e 'try{process.stdout.write(JSON.parse(process.argv[1]).ok===true?"true":"false")}catch{process.stdout.write("false")}' "$SHOT_STATUS")
          SHOT_REASON=$(node -e 'try{process.stdout.write(JSON.parse(process.argv[1]).reason||"")}catch{process.stdout.write("invalid shot status JSON")}' "$SHOT_STATUS")
          if [ "$SHOT_OK" != "true" ]; then
            echo "::warning::Visual recap screenshot unavailable; posting screenshot-failed recap comment. $SHOT_REASON"
          fi
          echo "image_url=$IMAGE_URL" >> "$GITHUB_OUTPUT"
          echo "light_image_url=$IMAGE_URL" >> "$GITHUB_OUTPUT"
          echo "dark_image_url=$DARK_IMAGE_URL" >> "$GITHUB_OUTPUT"
          echo "shot_ok=$SHOT_OK" >> "$GITHUB_OUTPUT"
          {
            echo 'shot_reason<<__RECAP_SHOT_REASON_EOF__'
            echo "$SHOT_REASON"
            echo '__RECAP_SHOT_REASON_EOF__'
          } >> "$GITHUB_OUTPUT"
          if [ -f recap.png ] || [ -f recap-dark.png ]; then echo "captured=true" >> "$GITHUB_OUTPUT"; else echo "captured=false" >> "$GITHUB_OUTPUT"; fi

      - name: Upload recap screenshot artifact
        if: steps.shot.outputs.captured == 'true'
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: pr-visual-recap-${{ github.event.pull_request.number }}
          path: |
            recap.png
            recap-dark.png
          if-no-files-found: ignore
          retention-days: 14

      - name: Upload recap source artifact
        if: always() && !cancelled()
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          # recap-source.json + the agent transcript (claude-result.json /
          # codex-events.jsonl + stderr) are the only window into WHAT the agent
          # did when a publish fails (no plan URL) — INCLUDING the case where it
          # finished without writing recap-source.json at all. The sticky comment
          # only shows the screenshot, so without these a failed recap is
          # undebuggable. Uploaded on success + failure; tolerant when absent.
          name: pr-visual-recap-source-${{ github.event.pull_request.number }}
          path: |
            recap-source.json
            recap-source.initial.json
            recap-url-reason.txt
            recap-repair-prompt.md
            claude-result.json
            claude-stderr.log
            claude-repair-result.json
            claude-repair-stderr.log
            claude-repair-exit-code.txt
            codex-events.jsonl
            codex-stderr.log
            codex-repair-events.jsonl
            codex-repair-stderr.log
            codex-repair-exit-code.txt
            openai-compatible-result.txt
            openai-compatible-usage.json
            openai-compatible-stderr.log
            openai-compatible-repair-result.txt
            openai-compatible-repair-usage.json
            openai-compatible-repair-stderr.log
            openai-compatible-repair-exit-code.txt
          if-no-files-found: ignore
          retention-days: 14

      - name: Upsert sticky comment
        if: always() && !cancelled() && steps.diff.outputs.tiny != 'true'
        continue-on-error: true
        env:
          PLAN_URL: ${{ steps.url.outputs.plan_url }}
          RECAP_IMAGE_URL: ${{ steps.shot.outputs.image_url }}
          RECAP_LIGHT_IMAGE_URL: ${{ steps.shot.outputs.light_image_url }}
          RECAP_DARK_IMAGE_URL: ${{ steps.shot.outputs.dark_image_url }}
          RECAP_SHOT_OK: ${{ steps.shot.outputs.shot_ok }}
          RECAP_SHOT_REASON: ${{ steps.shot.outputs.shot_reason }}
          SUPPRESSED: ${{ steps.scan.outputs.suppressed }}
          SUPPRESSED_JSON: ${{ steps.scan.outputs.json }}
          DIFF_HUGE: ${{ steps.diff.outputs.huge }}
          DIFF_TINY: ${{ steps.diff.outputs.tiny }}
          PREV_PLAN_ID: ${{ steps.prev.outputs.plan_id }}
          RECAP_AUTH_FAILED: ${{ steps.auth_probe.outputs.auth_failed }}
          RECAP_AGENT_SUMMARY: ${{ steps.agent_summary.outputs.summary }}
          # Prefer the route-health diagnostic when the plan app routes are not
          # yet deployed so the comment explains the 404 instead of a generic
          # "recap-url.txt was not created" message.
          RECAP_URL_REASON: ${{ steps.route_health.outputs.reason || steps.source_status.outputs.reason || steps.url.outputs.reason }}
        run: |
          set -euo pipefail
          $RECAP_CLI recap comment upsert --repo "$GITHUB_REPOSITORY" --issue "$PR_NUMBER" --token "$GH_TOKEN" --head-sha "$HEAD_SHA"

      - name: Complete visual recap check
        if: always() && !cancelled() && steps.recap_check.outputs.check_run_id != ''
        continue-on-error: true
        env:
          # Untrusted/step values via env (NOT ${{ }}-interpolated into the run
          # body): the agent-written plan URL and the scan JSON could inject shell.
          CHECK_RUN_ID: ${{ steps.recap_check.outputs.check_run_id }}
          PLAN_OK: ${{ steps.url.outputs.ok }}
          PLAN_URL: ${{ steps.url.outputs.plan_url }}
          SUPPRESSED: ${{ steps.scan.outputs.suppressed }}
          SUPPRESSED_JSON: ${{ steps.scan.outputs.json }}
          DIFF_HUGE: ${{ steps.diff.outputs.huge }}
          DIFF_TINY: ${{ steps.diff.outputs.tiny }}
          RECAP_AGENT_SUMMARY: ${{ steps.agent_summary.outputs.summary }}
          RECAP_URL_REASON: ${{ steps.route_health.outputs.reason || steps.source_status.outputs.reason || steps.url.outputs.reason }}
        run: |
          set -uo pipefail
          $RECAP_CLI recap check complete \
            --check-run-id "$CHECK_RUN_ID" \
            --plan-ok "$PLAN_OK" \
            --plan-url "$PLAN_URL" \
            --suppressed "$SUPPRESSED" \
            --suppressed-json "$SUPPRESSED_JSON" \
            --huge "$DIFF_HUGE" \
            --tiny "$DIFF_TINY" \
            --failure-summary "$RECAP_AGENT_SUMMARY" \
            --url-reason "$RECAP_URL_REASON" \
            --workflow-url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
