# ----------------------------------------------------------------------------
# Example workflow distributed with the jira-sdlc plugin
# (https://github.com/kantorv/jira-sdlc-tools). It ships as an example to
# copy into your own repo's .github/workflows/ and adapt — it is not
# installed with the plugin.
#
#   Guide:         https://kantorv.github.io/jira-sdlc-tools/docs/applications/ci-review-pr-demo
#   All examples:  https://kantorv.github.io/jira-sdlc-tools/docs/applications
#   Source:        https://github.com/kantorv/jira-sdlc-tools/blob/main/docs/github-automations/example-workflows/demo-fcc-nvidia-nim-reviewer.yml
# ----------------------------------------------------------------------------

name: Reviewer Skill on PR (FCC + NVIDIA-NIM + CLAUDE demo)

# on:
#   workflow_dispatch:
#     inputs:
#       model:
#         description: 'Nvidia-NIM model to use'
#         required: false
#         default: 'nvidia/nemotron-3-ultra-550b-a55b'
#         type: choice
#         options:
#           - "z-ai/glm-5.2"
#           - "nvidia/nemotron-3-ultra-550b-a55b"
#           - "deepseek-ai/deepseek-v4-pro"
#           - "minimaxai/minimax-m3"


# Trigger: a `/fcc-review` comment on a PR (issue_comment), restricted to OWNER
# or MEMBER commenters — issue_comment fires for any commenter on a public repo,
# so that author check is the security boundary.
#
# The command may be bare or followed by a space or newline and free-form
# prose — `/fcc-review the auth changes only`. A parse step strips the command
# token and passes the remainder to the skill as its argument, which
# jira-task-reviewer documents as free-form notes about the run (focus areas,
# constraints, context). A bare `/fcc-review` gets the bare invocation this demo
# has always used. The guard is an exact match plus three `startsWith` forms,
# not the bare `startsWith(body, '/fcc-review')` it used to be — that one also
# fired on `/fcc-reviewer-anything`.
on:
  issue_comment:
    types: [created]


env:
  # Pinned deliberately, same as validator.yml: a float would let an upstream
  # CLI change break this demo on an unrelated day. Workflow-level env can't
  # cross files, so validator.yml hardcodes the same pin — bump both together.
  CLAUDE_CLI_VERSION: 2.1.263
  DEFAULT_REVIEWER_MODEL: "nvidia_nim/z-ai/glm-5.3-flash"

jobs:
  # ── Gating job — cheap, secret-free, and runs before the reviewer job ─────
  # Resolves the commented-on PR and its issue key. Proceeds only when that PR
  # is OPEN on a feature/*|hotfix/* branch, so an unqualified comment is
  # rejected before a reviewer runner starts or a model token is spent.
  check_pr_exists:
    #
    # The command clause accepts the bare command or the command plus a
    # separator, so prose after it still fires the run. `fromJSON('"\n"')` is
    # how you spell a newline in a GitHub expression — there is no escape
    # sequence in its string literals; `\r` is checked too because comment
    # bodies come back from the API with CRLF line endings.
    if: |
      github.event.issue.pull_request &&
      (github.event.comment.body == '/fcc-review' ||
       startsWith(github.event.comment.body, '/fcc-review ') ||
       startsWith(github.event.comment.body, format('/fcc-review{0}', fromJSON('"\n"'))) ||
       startsWith(github.event.comment.body, format('/fcc-review{0}', fromJSON('"\r"')))) &&
      github.event.comment.author_association == 'OWNER'

    runs-on: ubuntu-latest
    name: Check PR exists
    timeout-minutes: 5
    permissions:
      contents: read
      pull-requests: read
    outputs:
      pr_exists: ${{ steps.check_pr.outputs.pr_exists }}
      pr_number: ${{ steps.check_pr.outputs.pr_number }}
      issue_key: ${{ steps.check_pr.outputs.issue_key }}
    steps:
      - name: Resolve the PR and its issue key
        # The triggering comment is on a PR (guarded above), so the PR number IS
        # github.event.issue.number, and the branch must be read from the PR.
        #
        # Do NOT use github.ref_name here: on an issue_comment event the ref is
        # always the repository's DEFAULT branch, never the PR head. This step
        # used to read it — correct while the trigger was workflow_dispatch,
        # where ref_name IS the dispatch branch — and when JST-204 switched the
        # trigger to issue_comment without changing it, every /review run died
        # here with "Branch name 'development' does not match required format".
        id: check_pr
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          PR_NUMBER: ${{ github.event.issue.number }}
          REPO_NAME: ${{ github.repository }}
        run: |
          set -euo pipefail

          # One API call for both fields the gate needs.
          PR_JSON=$(gh pr view "$PR_NUMBER" --repo "$REPO_NAME" --json headRefName,state)
          BRANCH_NAME=$(printf '%s' "$PR_JSON" | jq -r '.headRefName')
          PR_STATE=$(printf '%s' "$PR_JSON" | jq -r '.state')

          # A closed or merged PR is nothing to review — skip the reviewer job
          # rather than failing the run, since commenting on one isn't an error.
          if [ "$PR_STATE" != "OPEN" ]; then
            echo "PR #$PR_NUMBER is $PR_STATE — nothing to review."
            echo "pr_exists=0" >> "$GITHUB_OUTPUT"
            echo "pr_number=0" >> "$GITHUB_OUTPUT"
            echo "issue_key=" >> "$GITHUB_OUTPUT"
            exit 0
          fi

          # Validate branch name format: must be feature/PROJ-NNN-slug or hotfix/PROJ-NNN-slug
          if [[ ! "$BRANCH_NAME" =~ ^(feature|hotfix)/[A-Z][A-Z0-9_]+-[0-9]+- ]]; then
            echo "::error::PR #$PR_NUMBER head branch '$BRANCH_NAME' does not match required format: feature/PROJ-NNN-slug or hotfix/PROJ-NNN-slug — the reviewer skill derives its issue key from the branch and hard-stops otherwise"
            echo "pr_exists=0" >> "$GITHUB_OUTPUT"
            echo "pr_number=0" >> "$GITHUB_OUTPUT"
            echo "issue_key=" >> "$GITHUB_OUTPUT"
            exit 1
          fi

          # Extract issue key from branch name (PROJ-NNN)
          ISSUE_KEY=$(printf '%s' "$BRANCH_NAME" | sed -nE 's#^(feature|hotfix)/([A-Z][A-Z0-9_]+-[0-9]+)-.*$#\2#p')
          if [ -z "$ISSUE_KEY" ]; then
            echo "::error::Could not extract issue key from branch name: $BRANCH_NAME"
            echo "pr_exists=0" >> "$GITHUB_OUTPUT"
            echo "pr_number=0" >> "$GITHUB_OUTPUT"
            echo "issue_key=" >> "$GITHUB_OUTPUT"
            exit 1
          fi

          echo "pr_exists=1" >> "$GITHUB_OUTPUT"
          echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT"
          echo "issue_key=$ISSUE_KEY" >> "$GITHUB_OUTPUT"
          echo "Found PR #$PR_NUMBER ($BRANCH_NAME) for issue $ISSUE_KEY"

  # ── Reviewer job — the Claude-powered rework of demo-kimi-openrouter-reviewer.yml's
  #    reviewer-smoke job ────────────────────────────────────────────────────
  reviewer-claude:
    name: Reviewer skill (Claude)
    needs: check_pr_exists
    if: needs.check_pr_exists.outputs.pr_exists == '1'
    runs-on: ubuntu-latest
    timeout-minutes: 60
    permissions:
      contents: read
      pull-requests: write # the reviewer posts its verdict as a PR comment

    steps:
      - name: Checkout
        # Full history + every remote ref: the linked worktree is built from a
        # fetched origin/<head>, and we stand on the PR's base branch, both of
        # which a shallow single-branch clone doesn't have.
        uses: actions/checkout@v6
        with:
          fetch-depth: 0

      - name: Resolve PR head, base, and issue key
        # The authoritative head/base come from the PR itself. Under an
        # issue_comment trigger github.ref_name is the repository's default
        # branch, not the PR head, so the ref is never a usable source here —
        # see the gating job. Re-deriving the key here lets us fail before
        # installing claude or spending tokens if the head isn't an issue branch.
        id: pr
        shell: bash
        env:
          GH_TOKEN: ${{  secrets.GITHUB_TOKEN }}
          PR_NUMBER: ${{ needs.check_pr_exists.outputs.pr_number }}
        run: |
          set -euo pipefail
          HEAD=$(gh pr view "$PR_NUMBER" --json headRefName --jq '.headRefName')
          BASE=$(gh pr view "$PR_NUMBER" --json baseRefName  --jq '.baseRefName')
          if [ -z "${HEAD:-}" ] || [ -z "${BASE:-}" ]; then
            echo "::error::could not resolve head/base refs for PR #$PR_NUMBER"
            exit 1
          fi
          # Generic <UPPER>-<digits> key (any project), so the worktree path
          # matches the worktree-<KEY> convention the skill expects without
          # needing PROJECT_KEY from the env file.
          KEY=$(printf '%s' "$HEAD" | sed -nE 's#^(feature|hotfix)/([A-Z][A-Z0-9_]*-[0-9]+)-.+$#\2#p')
          if [ -z "$KEY" ]; then
            echo "::error::PR #$PR_NUMBER head branch '$HEAD' is not a feature/* or hotfix/* issue branch — no <KEY>-<n> to derive, and the reviewer skill hard-stops otherwise. Dispatch this workflow from the PR's issue branch."
            exit 1
          fi
          echo "head=$HEAD"  >> "$GITHUB_OUTPUT"
          echo "base=$BASE"  >> "$GITHUB_OUTPUT"
          echo "key=$KEY"    >> "$GITHUB_OUTPUT"
          echo "### Reviewing \`$KEY\` — PR #$PR_NUMBER \`$HEAD\` → \`$BASE\`" >> "$GITHUB_STEP_SUMMARY"

      - name: Write .jst/jira-sdlc-tools.local.env (reviewer role only)
        shell: bash
        env:
          # Each secret is named exactly as the env-file key it becomes, so the
          # loop below is the whole secret->file mapping (env, not sed).
          KEYS: JIRA_ACCOUNT_URL JIRA_REVIEWER_EMAIL JIRA_REVIEWER_TOKEN GITHUB_PAT_TOKEN
          JIRA_ACCOUNT_URL: ${{ secrets.JIRA_ACCOUNT_URL }}
          JIRA_REVIEWER_EMAIL: ${{ secrets.JIRA_REVIEWER_EMAIL }}
          JIRA_REVIEWER_TOKEN: ${{ secrets.JIRA_REVIEWER_TOKEN }}
          GITHUB_PAT_TOKEN: ${{ secrets.GITHUB_TOKEN }}

        run: |
          set -euo pipefail
          # Written in the *main checkout*, before the worktree exists: the
          # skill's own ensure_local_env.sh copies it into the worktree from
          # here, which is exactly the path that script exists to cover.
          missing=()
          for k in $KEYS; do
            [ -n "${!k:-}" ] || missing+=("$k")
          done
          if [ ${#missing[@]} -gt 0 ]; then
            echo "::error::missing or empty repo secret(s): ${missing[*]} — this demo cannot run without them. Add them under Settings → Secrets and variables → Actions."
            exit 1
          fi

          # Create empty and lock it down BEFORE the secrets go in.
          : > .jst/jira-sdlc-tools.local.env
          chmod 600 .jst/jira-sdlc-tools.local.env
          # printf with the value as an argument: secret values are data, never
          # re-expanded by the shell and never part of the script text.
          for k in $KEYS; do
            printf '%s=%s\n' "$k" "${!k}" >> .jst/jira-sdlc-tools.local.env
          done
          # WORKTREES_DIR is deliberately NOT a secret: it's a path on *this*
          # runner, and a value carried over from a developer's machine would
          # point somewhere that doesn't exist here.
          mkdir -p "$RUNNER_TEMP/worktrees"
          printf 'WORKTREES_DIR=%s\n' "$RUNNER_TEMP/worktrees" >> .jst/jira-sdlc-tools.local.env
          echo "Wrote $(grep -c . .jst/jira-sdlc-tools.local.env) config lines (values not echoed)."

      - name: Stand on the base branch
        # The workspace stays a *main checkout* (`.git` is a directory) on the
        # PR's base branch; the skill itself runs in the linked worktree built
        # below. Standing on the base (not the head) is what lets that worktree
        # be created at all — git refuses to check the same branch out twice.
        # The skills are not read from either checkout: they come from the
        # marketplace install — see caveat 3 in the header.
        shell: bash
        env:
          BASE: ${{ steps.pr.outputs.base }}
        run: |
          set -euo pipefail
          git switch "$BASE"
          # The reviewer is read-only — it never commits — so unlike
          # demo-claude-hotfix-flow.yml's committing jobs, no git user identity
          # is needed here. git switch's DWIM creates a local tracking branch
          # from origin/<base> when one doesn't exist locally yet.

      - name: Install claude CLI (native, no npm)
        # Same install as validator.yml and demo-claude-hotfix-flow.yml: the
        # installer SHA256-verifies a prebuilt binary against Anthropic's
        # manifest and runs its own `claude install`. Pinned deliberately — a
        # float would let an upstream CLI change break this demo on an unrelated
        # day. validator.yml and demo-claude-hotfix-flow.yml pin the same
        # version — bump all three together.
        shell: bash
        run: |
          set -euo pipefail
          curl -fsSL https://claude.ai/install.sh | bash -s -- "$CLAUDE_CLI_VERSION"
          echo "$HOME/.local/bin" >> "$GITHUB_PATH"

      - name: Install the jira-sdlc plugin from its marketplace
        shell: bash
        run: |
          set -euo pipefail
          # The skills come from the plugin's marketplace, not from this
          # checkout — which is what makes this workflow copy-pasteable into a
          # repo that does not contain the plugin. External consumers: swap the
          # URL below for your own fork or clone.
          #
          # `marketplace add` clones the source repo's DEFAULT branch, so what
          # runs here is whatever has landed there — not the code on the branch
          # under test. Land a skill change on the default branch before
          # expecting a demo run to exercise it.
          #
          # Spelled as an https:// URL rather than the `owner/repo` shorthand:
          # the shorthand tries SSH first and only falls back to HTTPS once that
          # fails, and a runner has no SSH key for github.com. Same marketplace
          # name (`jira-sdlc-tools`, from its manifest) either way.
          claude plugin marketplace add https://github.com/kantorv/jira-sdlc-tools.git
          claude plugin install jira-sdlc@jira-sdlc-tools





      - name: Install uv
        run: curl -LsSf https://astral.sh/uv/install.sh | sh

      - name: Install FCC server only
        run: |
          export PATH="$HOME/.local/bin:$PATH"
          uv tool install --force --refresh-package free-claude-code \
            --python 3.14.7 \
            "free-claude-code @ git+https://github.com/Alishahryar1/free-claude-code.git@main"
          echo "$(uv tool dir --bin)" >> "$GITHUB_PATH"

      - name: Start fcc-server (background)
        env:
          NVIDIA_NIM_API_KEY: ${{ secrets.NVIDIA_NIM_API_KEY }}
          MODEL:  ${{ env.DEFAULT_REVIEWER_MODEL  }}
          ANTHROPIC_AUTH_TOKEN: freecc
          FCC_OPEN_BROWSER: "0"
          MESSAGING_PLATFORM: none
          LOG_LEVEL: INFO
        run: |
          fcc-server > /tmp/fcc-server.log 2>&1 &
          echo $! > /tmp/fcc-server.pid
          # wait until the proxy answers
          for i in $(seq 1 60); do
            if curl -sf -o /dev/null -H "Authorization: Bearer freecc" \
                 http://127.0.0.1:8082/v1/models 2>/dev/null \
               || curl -sf -o /dev/null http://127.0.0.1:8082/admin 2>/dev/null; then
              echo "fcc-server is up"
              exit 0
            fi
            sleep 1
          done
          echo "fcc-server failed to start"
          cat /tmp/fcc-server.log
          exit 1




      - name: Rebuild the issue worktree (linked, from the PR head)
        # The prerequisite the bare checkout can't satisfy: a LINKED worktree on
        # the PR's head branch, where the skill runs and derives its issue key.
        # Without this the skill's statuscheck FAILs on the `worktree` row.
        id: worktree
        shell: bash
        env:
          HEAD: ${{ steps.pr.outputs.head }}
          BASE: ${{ steps.pr.outputs.base }}
          KEY: ${{ steps.pr.outputs.key }}
        run: |
          set -euo pipefail
          WT="$RUNNER_TEMP/worktrees/worktree-$KEY"
          git fetch origin "+refs/heads/$HEAD:refs/remotes/origin/$HEAD"
          git worktree add --track -B "$HEAD" "$WT" "origin/$HEAD"
          # parentbranch is machine-local git config, so it never survives into
          # CI. Re-set it to the PR's real base: first source the skill's §13
          # PR-base resolver reads, ahead of the assigner's durable "PR target
          # branch:" Jira comment. Correct for sub-task PRs (base = parent
          # branch) and single-step PRs (base = development) alike.
          git config "branch.$HEAD.parentbranch" "$BASE"
          echo "path=$WT" >> "$GITHUB_OUTPUT"

      - name: Parse the prose after the trigger command
        # Free-form direction may follow the command in the triggering comment
        # (`/fcc-review the auth changes only`). Strip the leading token,
        # trim, and expose the remainder as a step output; an empty remainder is
        # the normal case and leaves the run behaving exactly as a bare command
        # always did.
        #
        # This step parses, it never gates: the security boundary is the
        # job-level `if:` above and stays there, so this step only ever sees a
        # comment that already passed it. The body is attacker-supplied text —
        # it enters as an env var and leaves through `printf`, never as part of
        # the script.
        #
        # Identical in all five comment-triggered demos apart from COMMAND —
        # keep it that way, so a diff between two of them shows only what
        # genuinely differs.
        id: prose
        shell: bash
        env:
          COMMENT_BODY: ${{ github.event.comment.body }}
          COMMAND: /fcc-review
        run: |
          set -euo pipefail
          shopt -s extglob
          PROSE=${COMMENT_BODY:-}
          PROSE=${PROSE//$'\r'/}          # comment bodies arrive CRLF
          PROSE=${PROSE#"$COMMAND"}       # drop the leading command token
          PROSE=${PROSE##+([[:space:]])}  # trim both ends, newlines included
          PROSE=${PROSE%%+([[:space:]])}
          # Multi-line step output needs a delimiter, and a fixed one would let
          # prose containing that marker close the block early and forge further
          # outputs — so randomize it.
          DELIM="PROSE_EOF_$(openssl rand -hex 8)"
          {
            printf 'text<<%s\n' "$DELIM"
            printf '%s\n' "$PROSE"
            printf '%s\n' "$DELIM"
          } >> "$GITHUB_OUTPUT"
          if [ -n "$PROSE" ]; then
            printf 'Extra direction from the triggering comment: %s characters.\n' "${#PROSE}"
          else
            printf 'Bare command — no extra direction.\n'
          fi

      - name: Run jira-task-reviewer
        # No key: the reviewer derives the issue from the worktree's
        # branch. Run in the worktree; the skills come from the marketplace
        # install, so nothing points back into the checkout. Anything typed after
        # `/fcc-review` becomes the skill's argument — the free-form run notes
        # it documents — and a bare command keeps the bare invocation.
        #
        # --dangerously-skip-permissions: headless runs have no interactive
        # turn to approve tool calls. Safe-ish here and nowhere else: a
        # throwaway runner VM, non-root (the flag refuses to run as root).

        timeout-minutes: 60
        shell: bash
        working-directory: ${{ steps.worktree.outputs.path }}
        env:
          ANTHROPIC_BASE_URL: http://127.0.0.1:8082
          ANTHROPIC_AUTH_TOKEN: freecc
          CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: "0"
          # Comment prose is user content: env var in, printf out, never
          # interpolated into the script.
          COMMENT_PROSE: ${{ steps.prose.outputs.text }}
        run: |
          set -euo pipefail
          # No prose → the exact bare invocation this demo has always used.
          PROMPT='/jira-sdlc:jira-task-reviewer'
          if [ -n "${COMMENT_PROSE:-}" ]; then
            PROMPT=$(printf -- '%s %s' "$PROMPT" "$COMMENT_PROSE")
          fi
          claude \
            --dangerously-skip-permissions \
            --model "$DEFAULT_REVIEWER_MODEL" \
            -p "$PROMPT" \
            2>&1 | tee "$RUNNER_TEMP/reviewer.log"

      - name: Comment the reviewer run metrics on the PR
        # This workflow posts no transcript comment of its own — the reviewer
        # skill owns the verdict on the PR — so this is the only comment this
        # job writes, and it carries what the run *cost* rather than what it
        # said. Worth more here than on the Claude demos: the model is a
        # workflow input, so the token and turn counts are how one NIM model's
        # run compares with another's. It goes on the PR (`gh pr comment`, the
        # number resolved by check_pr_exists) because that is where the review
        # lives; the job already declares `pull-requests: write`.
        #
        # `if: always()` — a failed run is exactly when the numbers are worth
        # most — plus `continue-on-error: true` following the GIF step's
        # precedent: a missing transcript or an absent jq must never redden a
        # green run. GH_TOKEN is set on the gh command in a step that runs AFTER
        # the skill; exporting it earlier would block statuscheck's gh login and
        # abort the review.
        if: always()
        continue-on-error: true
        shell: bash
        env:
          GITHUB_PAT_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          PR_NUMBER: ${{ needs.check_pr_exists.outputs.pr_number }}
          ROLE: reviewer
        run: |
          set -uo pipefail
          # The script exits 0 on every degrade path and never embeds the
          # transcript, so the body needs no truncation guard — a metrics table
          # is nowhere near gh's 65536-character limit.
          BODY="$RUNNER_TEMP/$ROLE-metrics.md"
          # The workspace stands on the PR's BASE branch, which on an older PR
          # can predate this script — hence the presence guard: a missing
          # script degrades to a line, like every other path here.
          SCRIPT="$GITHUB_WORKSPACE/.github/scripts/conversation_metrics.sh"
          {
            printf '### reviewer — conversation metrics\n\n'
            [ -f "$SCRIPT" ] && bash "$SCRIPT" \
              || printf '_%s is not on this checkout — nothing to measure._\n' "${SCRIPT##*/}"
          } > "$BODY"
          GH_TOKEN="$GITHUB_PAT_TOKEN" gh pr comment "$PR_NUMBER" --body-file "$BODY"

      - name: Upload reviewer log
        # The skill's full transcript — its verdict is echoed here as it posts
        # to GitHub and Jira, so a human inspecting the run sees the review.
        # The reviewer skill itself owns the report.md / Jira comment content;
        # this log is the reliable, path-independent artifact of what happened.
        if: always()
        uses: actions/upload-artifact@v7
        with:
          name: reviewer-log
          path: ${{ runner.temp }}/reviewer.log
          if-no-files-found: warn
          retention-days: 7





      - name: Render the reviewer transcript to a GIF
        if: always()
        continue-on-error: true
        timeout-minutes: 5
        shell: bash
        env:
          ROLE: reviewer
        run: |
          set -uo pipefail
          # A debug nicety, and it must never redden a green run — hence
          # `continue-on-error` + a step timeout, and deliberately NO `set -e`,
          # so each failure path below prints its own reason and the step ends
          # legibly instead of dying on an opaque non-zero. `if: always()` is
          # the point rather than a precaution: a *failed* skill run is exactly
          # the case a terminal replay beats 50k characters of piped stdout.
          # uv and uvx land in $HOME/.local/bin, which is already on PATH on
          # ubuntu-latest — prepended explicitly rather than assumed.
          export PATH="$HOME/.local/bin:$PATH"
          if ! pip install uv >/dev/null 2>&1; then
            echo "::warning::$ROLE GIF skipped — pip install uv failed"; exit 1
          fi
          if ! uv tool install agent-log-gif >/dev/null 2>&1; then
            echo "::warning::$ROLE GIF skipped — uv tool install agent-log-gif failed"; exit 1
          fi
          OUT="$RUNNER_TEMP/$ROLE-gif"
          # RENDER_LOG sits outside OUT: the artifact carries GIFs, not logs.
          RENDER_LOG="$RUNNER_TEMP/$ROLE-render.log"
          mkdir -p "$OUT"
          # Glob rather than computing the project slug: the slug differs per
          # job (job 1 runs claude from the workspace, jobs 2/3 from the
          # worktree under RUNNER_TEMP), and each job gets a fresh VM, so there
          # is normally exactly one file. Both other cases are handled rather
          # than assumed away.
          shopt -s nullglob
          FILES=("$HOME"/.claude/projects/*/*.jsonl)
          if [ ${#FILES[@]} -eq 0 ]; then
            echo "::notice::no Claude session JSONL found for $ROLE — nothing to render"
            exit 0
          fi
          rc=0
          for f in "${FILES[@]}"; do
            gif="$OUT/$(basename "$f" .jsonl).gif"
            # Renderer output goes to a file: its progress display would
            # otherwise be noise in the Actions log. Only the name, size and
            # final summary line are echoed.
            if uvx agent-log-gif json "$f" -o "$gif" \
                 --chrome linux --color-scheme Nord --show tools --turns 50 \
                 > "$RENDER_LOG" 2>&1; then
              echo "$(basename "$gif") — $(du -h "$gif" | cut -f1) — $(tail -1 "$RENDER_LOG")"
            else
              echo "::warning::$ROLE GIF render failed for $(basename "$f")"
              tail -5 "$RENDER_LOG"
              rc=1
            fi
          done
          exit $rc

      - name: Upload reviewer transcript GIF
        if: always()
        continue-on-error: true
        uses: actions/upload-artifact@v7
        with:
          name: reviewer-transcript-gif
          path: ${{ runner.temp }}/reviewer-gif/*.gif
          if-no-files-found: warn
          retention-days: 7
