Skip to content

Prioritize Agent

Prioritize agent icon

Scores a GitHub issue using the RICE framework (Reach, Impact, Confidence, Effort) and produces structured scores with reasoning for project board ranking when a project board is configured.

How the agent works

Triggered on a schedule (the prioritize scheduler polls the project board for unscored issues) or on-demand via /fs-prioritize.

The prioritize agent fetches the issue and all its context, then evaluates it across the four RICE dimensions. It can invoke customer-research skills to gather additional signal about reach and impact. The output is a structured JSON result with per-dimension scores and written reasoning. The post-script always publishes the RICE reasoning comment; when a project number is supplied, it also updates the project board.

How it helps

  • Issues are ranked consistently using the same framework, reducing bias from whoever happens to see them first.
  • Scoring reasoning is transparent and auditable — anyone can read why an issue was ranked the way it was.
  • When configured, project boards stay sorted by value, so humans can focus on the highest-impact work first.

Commands

CommandWhereEffect
/fs-prioritizeIssue commentRuns RICE scoring on the issue

Requires write-level repository permission (admin, maintain, or write).

The /fs-prioritize command does not accept arguments. It scores the issue using the current content, comments, and any available customer-research skill data.

Project board configuration is optional for on-demand runs. Without a project number, the agent still posts the RICE score and reasoning comment but skips the project board update.

Control labels

The prioritize agent does not apply or consume control labels. It reads the issue content and produces a structured score. The post-script publishes the score and reasoning comment, and updates the project board when configured.

Configuration and extension

Skill: customer-research

The prioritize agent looks for a customer-research skill and, when available, uses it to inform Reach and Impact scores. To provide it, create a skill directory in your target repository at .agents/skills/customer-research/ with a SKILL.md and any helper scripts organized in a scripts/ subdirectory. Then symlink .claude/skills to .agents/skills so the skill is discoverable by both Fullsend and any local agent tooling:

your-repo/
  .agents/skills/customer-research/
    SKILL.md
    scripts/
  .claude/skills -> ../.agents/skills

This gives the prioritize agent concrete data to distinguish between "one user wants this" (Reach 0.25) and "three strategic accounts have filed support cases about it" (Reach 2.0), instead of guessing from the issue text alone.

Scheduled scoring

The prioritize agent can be triggered automatically by a scheduler workflow that polls a GitHub Project board for unscored issues. This scheduler is not managed by fullsend — it is bespoke org-level automation that you create manually in your {org}/.fullsend repo.

Prerequisites

  • A GitHub Projects (v2) board with a numeric RICE Score field. Run scripts/setup-prioritize.sh to create the field if it does not exist. The script is installed to the .fullsend repo by org-mode scaffold; per-repo orgs can find it in the fullsend source tree at internal/scaffold/fullsend-repo/scripts/setup-prioritize.sh.
  • prioritize.yml installed in every target repo (handled by repos install).
  • The scheduler passes project_number to each target repo's prioritize.yml thin caller via workflow_dispatch input. If the input is not provided, the thin caller falls back to vars.FULLSEND_PROJECT_NUMBER (repo or org-level variable).
  • The following variables and secrets set on the repo that hosts the scheduler:
Variable / SecretTypeRequiredDescription
FULLSEND_PRIORITIZE_CLIENT_IDVariableYesClient ID of the GitHub App with project board access (organization_projects: write)
FULLSEND_PRIORITIZE_APP_PRIVATE_KEYSecretYesPrivate key for the prioritize GitHub App
FULLSEND_FULLSEND_CLIENT_IDVariableYesClient ID of the GitHub App with cross-repo dispatch access (actions: write)
FULLSEND_FULLSEND_APP_PRIVATE_KEYSecretYesPrivate key for the fullsend GitHub App

Example workflow

Create this as .github/workflows/prioritize-scheduler.yml in your {org}/.fullsend repo. Replace <your-project-number>, <your-repos>, and <your-runner> with your org-specific values.

The scheduler uses two GitHub App tokens, kept separate for least-privilege: the prioritize app has only project board access (organization_projects: write) and the fullsend app has cross-repo workflow dispatch (actions: write). Combining them into a single app would give every per-repo prioritize run more permissions than it needs. See the Role Permissions Matrix for the full permission breakdown.

All operational configuration (project number, target repos, score field, thresholds) is self-contained in the workflow file via || fallback expressions — the only repository variables and secrets needed are the GitHub App credentials listed in Prerequisites above. Input descriptions document the defaults; the || fallbacks in the env block and the repositories field are the single source of truth (input default values only apply to workflow_dispatch, not schedule triggers).

yaml
---
name: Prioritize Scheduler

on:
  schedule:
    - cron: '*/10 * * * *'
  workflow_dispatch:
    inputs:
      project_number:
        description: "GitHub Project number to poll (default: <your-project-number>)"
        required: false
        type: string
      repos:
        description: "Comma-separated repo names to dispatch to (default: <your-repos>)"
        required: false
        type: string
      score_field:
        description: "Project board field name for the RICE score (default: RICE Score)"
        required: false
        type: string
      workflow:
        description: "Workflow filename to dispatch on target repos (default: prioritize.yml)"
        required: false
        type: string
      stale_threshold:
        description: "Re-score issues whose RICE score is older than this (default: 7d)"
        required: false
        type: string
      wip_limit:
        description: "Max number of prioritize jobs to dispatch (default: 5)"
        required: false
        type: number

concurrency:
  group: fullsend-prioritize-scheduler
  cancel-in-progress: false

jobs:
  dispatch:
    name: Find and dispatch issues for RICE scoring
    runs-on: <your-runner>
    timeout-minutes: 5
    permissions:
      actions: write
      contents: read

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4  # pin to SHA per your org policy

      - name: Generate prioritize token (project board access)
        id: prioritize-token
        uses: actions/create-github-app-token@v3
        with:
          client-id: ${{ vars.FULLSEND_PRIORITIZE_CLIENT_ID }}
          private-key: ${{ secrets.FULLSEND_PRIORITIZE_APP_PRIVATE_KEY }}
          owner: ${{ github.repository_owner }}

      - name: Generate fullsend token (cross-repo dispatch)
        id: dispatch-token
        uses: actions/create-github-app-token@v3
        with:
          client-id: ${{ vars.FULLSEND_FULLSEND_CLIENT_ID }}
          private-key: ${{ secrets.FULLSEND_FULLSEND_APP_PRIVATE_KEY }}
          owner: ${{ github.repository_owner }}
          repositories: ${{ inputs.repos || '<your-repos>' }}

      - name: Find issues and dispatch prioritize runs
        env:
          GH_TOKEN: ${{ steps.prioritize-token.outputs.token }}
          DISPATCH_TOKEN: ${{ steps.dispatch-token.outputs.token }}
          ORG: ${{ github.repository_owner }}
          PROJECT_NUMBER: ${{ inputs.project_number || '<your-project-number>' }}
          SCORE_FIELD: ${{ inputs.score_field || 'RICE Score' }}
          STALE_THRESHOLD: ${{ inputs.stale_threshold || '7d' }}
          WORKFLOW: ${{ inputs.workflow || 'prioritize.yml' }}
          WIP_LIMIT: ${{ inputs.wip_limit || '5' }}
        run: |
          set -euo pipefail

          # Parse stale threshold into seconds
          parse_threshold() {
            local val="${1%[dh]}"
            local unit="${1: -1}"
            if [[ -z "${val}" || ! "${val}" =~ ^[1-9][0-9]*$ ]]; then
              echo "ERROR: invalid threshold '${1}' (use Nd or Nh, e.g. 7d)" >&2; exit 1
            fi
            case "${unit}" in
              d) echo $(( val * 86400 )) ;;
              h) echo $(( val * 3600 )) ;;
              *) echo "ERROR: unsupported threshold unit '${unit}' (use Nd or Nh)" >&2; exit 1 ;;
            esac
          }
          THRESHOLD_SECONDS=$(parse_threshold "${STALE_THRESHOLD}")

          # Fetch project metadata
          PROJECT_ID=$(gh project view "${PROJECT_NUMBER}" \
            --owner "${ORG}" --format json | jq -r '.id')

          if [[ -z "${PROJECT_ID}" || "${PROJECT_ID}" == "null" ]]; then
            echo "ERROR: Failed to fetch project ${PROJECT_NUMBER}."
            exit 1
          fi

          SCORE_FIELD_ID=$(gh project field-list "${PROJECT_NUMBER}" \
            --owner "${ORG}" --format json \
            | jq -r --arg sf "${SCORE_FIELD}" '.fields[] | select(.name == $sf) | .id')

          if [[ -z "${SCORE_FIELD_ID}" ]]; then
            echo "ERROR: '${SCORE_FIELD}' field not found on project."
            echo "Run scripts/setup-prioritize.sh to create it."
            exit 1
          fi

          # Paginate through all project items (100 per page).
          ALL_ITEMS="[]"
          CURSOR=""
          while true; do
            CURSOR_ARG=""
            if [[ -n "${CURSOR}" ]]; then
              CURSOR_ARG="-f cursor=${CURSOR}"
            fi
            PAGE=$(gh api graphql -f query='
              query($projectId: ID!, $cursor: String) {
                node(id: $projectId) {
                  ... on ProjectV2 {
                    items(first: 100, after: $cursor) {
                      pageInfo { hasNextPage endCursor }
                      nodes {
                        fieldValues(first: 20) { nodes {
                          ... on ProjectV2ItemFieldNumberValue {
                            field { ... on ProjectV2Field { id } }
                            updatedAt
                          }
                        } }
                        content { ... on Issue { url number state } }
                      }
                    }
                  }
                }
              }' -f projectId="${PROJECT_ID}" ${CURSOR_ARG})

            ALL_ITEMS=$(echo "${ALL_ITEMS}" | jq \
              --argjson page "$(echo "${PAGE}" \
                | jq '[.data.node.items.nodes[]]')" \
              '. + $page')

            HAS_NEXT=$(echo "${PAGE}" \
              | jq -r '.data.node.items.pageInfo.hasNextPage')
            if [[ "${HAS_NEXT}" != "true" ]]; then
              break
            fi
            CURSOR=$(echo "${PAGE}" \
              | jq -r '.data.node.items.pageInfo.endCursor')
          done

          # Find unscored open issues (up to WIP_LIMIT)
          UNSCORED=$(echo "${ALL_ITEMS}" | jq -r \
            --arg fid "${SCORE_FIELD_ID}" --argjson limit "${WIP_LIMIT}" '
            [.[]
             | select(.content.state == "OPEN")
             | select(.content.url != null)
             | select([.fieldValues.nodes[]
                       | select(.field.id == $fid)] | length == 0)
             | {url: .content.url, number: .content.number}
            ] | .[:$limit]')

          COUNT=$(echo "${UNSCORED}" | jq 'length')
          if [[ "${COUNT}" -gt 0 ]]; then
            echo "Found ${COUNT} unscored issue(s) to dispatch."
          else
            echo "All issues scored. Checking for stale scores..."

            NOW_EPOCH=$(date +%s)

            UNSCORED=$(echo "${ALL_ITEMS}" | jq -r \
              --arg fid "${SCORE_FIELD_ID}" \
              --argjson limit "${WIP_LIMIT}" \
              --argjson threshold "${THRESHOLD_SECONDS}" \
              --argjson now "${NOW_EPOCH}" '
              [.[]
               | select(.content.state == "OPEN")
               | select(.content.url != null)
               | {
                   url: .content.url,
                   number: .content.number,
                   updatedAt: ([.fieldValues.nodes[]
                                | select(.field.id == $fid)
                                | .updatedAt] | first)
                 }
               | select(.updatedAt != null)
               | select(($now - (.updatedAt | fromdateiso8601)) > $threshold)
              ]
              | sort_by(.updatedAt)
              | .[:$limit]')

            STALE_COUNT=$(echo "${UNSCORED}" | jq 'length')

            if [[ "${STALE_COUNT}" -eq 0 ]]; then
              echo "No stale scores found. Nothing to do."
              exit 0
            fi

            echo "Found ${STALE_COUNT} stale issue(s) to re-score."
          fi

          DISPATCHED=0
          FAILED=0

          for row in $(echo "${UNSCORED}" | jq -c '.[]'); do
            ISSUE_URL=$(echo "${row}" | jq -r '.url')
            ISSUE_NUMBER=$(echo "${row}" | jq -r '.number')
            SOURCE_REPO="${ISSUE_URL#https://github.com/}"
            SOURCE_REPO="${SOURCE_REPO%%/issues/*}"

            # Skip issues from outside this org
            if [[ "${SOURCE_REPO}" != "${ORG}/"* ]]; then
              echo "::warning::Skipping issue from unexpected org: ${SOURCE_REPO}"
              continue
            fi

            EVENT_PAYLOAD=$(jq -n \
              --arg url "${ISSUE_URL}" \
              --argjson number "${ISSUE_NUMBER}" \
              '{issue: {html_url: $url, number: $number}}')

            echo "Dispatching prioritize for ${SOURCE_REPO}#${ISSUE_NUMBER}..."

            if GH_TOKEN="${DISPATCH_TOKEN}" gh workflow run "${WORKFLOW}" \
              --repo "${SOURCE_REPO}" \
              -f event_type="schedule" \
              -f source_repo="${SOURCE_REPO}" \
              -f event_payload="${EVENT_PAYLOAD}" \
              -f project_number="${PROJECT_NUMBER}"; then
              DISPATCHED=$((DISPATCHED + 1))
            else
              echo "::warning::Failed to dispatch for ${ISSUE_URL}"
              FAILED=$((FAILED + 1))
            fi
          done

          echo "Dispatched ${DISPATCHED} prioritize run(s), ${FAILED} failed."

The scheduler dispatches prioritize.yml to ${SOURCE_REPO} (the repo that owns the issue), not to the scheduler's own repo. The repos input (passed to create-github-app-token via the repositories field) must list every target repo so the generated token has sufficient scope for cross-repo dispatch.

When all issues are already scored, the scheduler checks for stale scores — issues whose RICE Score field was last updated longer ago than stale_threshold (default: 7 days). Stale issues are re-dispatched for re-scoring, sorted oldest-first, up to wip_limit.

Source

fullsend-ai/agentsharness/prioritize.yaml