diff --git a/.github/workflows/reusable-create-children.yml b/.github/workflows/reusable-create-children.yml
new file mode 100644
index 000000000..e59518f5a
--- /dev/null
+++ b/.github/workflows/reusable-create-children.yml
@@ -0,0 +1,175 @@
+# Reusable create-children workflow. Called after critique approves a refinement.
+# Creates child issues in GitHub and/or Jira from the refine agent's output.
+name: Create Children
+
+on:
+ workflow_call:
+ inputs:
+ event_type:
+ required: true
+ type: string
+ source_repo:
+ required: true
+ type: string
+ event_payload:
+ required: true
+ type: string
+ mint_url:
+ required: true
+ type: string
+ gcp_region:
+ required: true
+ type: string
+ fullsend_version:
+ required: false
+ type: string
+ default: "latest"
+ install_mode:
+ required: false
+ type: string
+ default: "per-org"
+ fullsend_ai_ref:
+ description: Ref of fullsend-ai/fullsend to load actions from. Must match the ref used in the `uses:` line that calls this workflow.
+ type: string
+ required: false
+ default: v0
+ issue_key:
+ required: true
+ type: string
+ issue_source:
+ required: false
+ type: string
+ default: "github"
+ refine_run_id:
+ required: true
+ type: string
+ secrets:
+ FULLSEND_GCP_WIF_PROVIDER:
+ required: true
+ FULLSEND_GCP_PROJECT_ID:
+ required: true
+ JIRA_HOST:
+ required: false
+ JIRA_EMAIL:
+ required: false
+ JIRA_API_TOKEN:
+ required: false
+
+jobs:
+ create-children:
+ name: Create Children
+ runs-on: ubuntu-latest
+ permissions:
+ actions: read
+ contents: read
+ id-token: write
+ issues: write
+
+ steps:
+ - name: Checkout config repository
+ uses: actions/checkout@v6
+
+ - name: Checkout upstream defaults
+ if: hashFiles('.defaults/action.yml', '.fullsend/.defaults/action.yml') == ''
+ uses: actions/checkout@v6
+ with:
+ repository: fullsend-ai/fullsend
+ ref: ${{ inputs.fullsend_ai_ref }}
+ path: .defaults
+ fetch-depth: 1
+ sparse-checkout: |
+ .github/actions/
+ .github/scripts/
+ internal/scaffold/fullsend-repo/
+ action.yml
+
+ - name: Prepare workspace (upstream defaults + org/repo overrides)
+ env:
+ INSTALL_MODE: ${{ inputs.install_mode }}
+ run: |
+ set -euo pipefail
+ if [[ "${INSTALL_MODE}" != "per-org" && "${INSTALL_MODE}" != "per-repo" ]]; then
+ echo "::error::Invalid install_mode '${INSTALL_MODE}': must be 'per-org' or 'per-repo'"
+ exit 1
+ fi
+ SRC=".defaults/internal/scaffold/fullsend-repo"
+ LAYERED_DIRS="agents skills schemas harness plugins policies scripts env"
+ for dir in ${LAYERED_DIRS}; do
+ if [[ -d "${SRC}/${dir}" ]]; then
+ mkdir -p "${dir}"
+ cp -r "${SRC}/${dir}/." "${dir}/"
+ fi
+ done
+ CUSTOM_BASE="customized"
+ if [[ "${INSTALL_MODE}" == "per-repo" ]]; then
+ CUSTOM_BASE=".fullsend/customized"
+ fi
+ for dir in ${LAYERED_DIRS}; do
+ if [[ -d "${CUSTOM_BASE}/${dir}" ]]; then
+ find "${CUSTOM_BASE}/${dir}" -type f ! -name '.gitkeep' -print0 \
+ | while IFS= read -r -d '' f; do
+ rel="${f#"${CUSTOM_BASE}"/}"
+ mkdir -p "$(dirname "${rel}")"
+ cp "${f}" "${rel}"
+ done
+ fi
+ done
+ mkdir -p .github/scripts
+ cp "${SRC}/.github/scripts/setup-agent-env.sh" .github/scripts/setup-agent-env.sh
+
+
+ - name: Validate enrollment and extract repo metadata
+ id: repo-parts
+ uses: ./.defaults/.github/actions/validate-enrollment
+ with:
+ source_repo: ${{ inputs.source_repo }}
+ install_mode: ${{ inputs.install_mode }}
+
+ - name: Mint create-children token
+ id: app-token
+ uses: ./.defaults/.github/actions/mint-token
+ with:
+ role: refine
+ repos: ${{ steps.repo-parts.outputs.name }}
+ mint_url: ${{ inputs.mint_url }}
+
+ - name: Checkout target repository
+ uses: actions/checkout@v6
+ with:
+ repository: ${{ inputs.source_repo }}
+ token: ${{ steps.app-token.outputs.token }}
+ path: target-repo
+ fetch-depth: 1
+ persist-credentials: false
+
+ - name: Setup GCP and prepare credentials
+ uses: ./.defaults/.github/actions/setup-gcp
+ with:
+ gcp_wif_provider: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }}
+ gcp_project_id: ${{ secrets.FULLSEND_GCP_PROJECT_ID }}
+
+ - name: Setup agent environment
+ env:
+ AGENT_PREFIX: CREATE_CHILDREN_
+ CREATE_CHILDREN_GH_TOKEN: ${{ steps.app-token.outputs.token }}
+ CREATE_CHILDREN_TARGET_REPO_DIR: target-repo
+ CREATE_CHILDREN_ISSUE_KEY: ${{ inputs.issue_key }}
+ CREATE_CHILDREN_ISSUE_SOURCE: ${{ inputs.issue_source }}
+ CREATE_CHILDREN_REFINE_RUN_ID: ${{ inputs.refine_run_id }}
+ CREATE_CHILDREN_ANTHROPIC_VERTEX_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }}
+ CREATE_CHILDREN_CLOUD_ML_REGION: ${{ inputs.gcp_region }}
+ JIRA_HOST: ${{ secrets.JIRA_HOST }}
+ JIRA_EMAIL: ${{ secrets.JIRA_EMAIL }}
+ JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }}
+ run: bash .github/scripts/setup-agent-env.sh
+
+ - name: Run create-children script
+ env:
+ ISSUE_KEY: ${{ inputs.issue_key }}
+ ISSUE_SOURCE: ${{ inputs.issue_source }}
+ REFINE_RUN_ID: ${{ inputs.refine_run_id }}
+ GH_TOKEN: ${{ steps.app-token.outputs.token }}
+ JIRA_HOST: ${{ secrets.JIRA_HOST }}
+ JIRA_EMAIL: ${{ secrets.JIRA_EMAIL }}
+ JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }}
+ run: bash scripts/create-children.sh
diff --git a/.github/workflows/reusable-critique.yml b/.github/workflows/reusable-critique.yml
new file mode 100644
index 000000000..68e21351b
--- /dev/null
+++ b/.github/workflows/reusable-critique.yml
@@ -0,0 +1,193 @@
+# Reusable critique agent workflow. Called by thin callers in .fullsend repos
+# via workflow_call. Runs in the caller's repo context (secrets, checkout).
+name: Critique Agent
+
+on:
+ workflow_call:
+ inputs:
+ event_type:
+ required: true
+ type: string
+ source_repo:
+ required: true
+ type: string
+ event_payload:
+ required: true
+ type: string
+ mint_url:
+ required: true
+ type: string
+ gcp_region:
+ required: true
+ type: string
+ fullsend_version:
+ required: false
+ type: string
+ default: "latest"
+ install_mode:
+ required: false
+ type: string
+ default: "per-org"
+ fullsend_ai_ref:
+ description: Ref of fullsend-ai/fullsend to load actions from. Must match the ref used in the `uses:` line that calls this workflow.
+ type: string
+ required: false
+ default: v0
+ issue_key:
+ required: true
+ type: string
+ issue_source:
+ required: false
+ type: string
+ default: "github"
+ refine_run_id:
+ required: true
+ type: string
+ review_round:
+ required: false
+ type: string
+ default: "1"
+ max_review_rounds:
+ required: false
+ type: string
+ default: "3"
+ auto_create:
+ required: false
+ type: string
+ default: "false"
+ jira_project_visibility:
+ required: false
+ type: string
+ default: "private"
+ secrets:
+ FULLSEND_GCP_WIF_PROVIDER:
+ required: true
+ FULLSEND_GCP_PROJECT_ID:
+ required: true
+ JIRA_HOST:
+ required: false
+ JIRA_EMAIL:
+ required: false
+ JIRA_API_TOKEN:
+ required: false
+
+jobs:
+ critique:
+ name: Critique
+ runs-on: ubuntu-latest
+ permissions:
+ actions: write
+ contents: read
+ id-token: write
+ issues: write
+
+ steps:
+ - name: Checkout config repository
+ uses: actions/checkout@v6
+
+ - name: Checkout upstream defaults
+ if: hashFiles('.defaults/action.yml', '.fullsend/.defaults/action.yml') == ''
+ uses: actions/checkout@v6
+ with:
+ repository: fullsend-ai/fullsend
+ ref: ${{ inputs.fullsend_ai_ref }}
+ path: .defaults
+ fetch-depth: 1
+ sparse-checkout: |
+ .github/actions/
+ .github/scripts/
+ internal/scaffold/fullsend-repo/
+ action.yml
+
+ - name: Prepare workspace (upstream defaults + org/repo overrides)
+ env:
+ INSTALL_MODE: ${{ inputs.install_mode }}
+ run: |
+ set -euo pipefail
+ if [[ "${INSTALL_MODE}" != "per-org" && "${INSTALL_MODE}" != "per-repo" ]]; then
+ echo "::error::Invalid install_mode '${INSTALL_MODE}': must be 'per-org' or 'per-repo'"
+ exit 1
+ fi
+ SRC=".defaults/internal/scaffold/fullsend-repo"
+ LAYERED_DIRS="agents skills schemas harness plugins policies scripts env"
+ for dir in ${LAYERED_DIRS}; do
+ if [[ -d "${SRC}/${dir}" ]]; then
+ mkdir -p "${dir}"
+ cp -r "${SRC}/${dir}/." "${dir}/"
+ fi
+ done
+ CUSTOM_BASE="customized"
+ if [[ "${INSTALL_MODE}" == "per-repo" ]]; then
+ CUSTOM_BASE=".fullsend/customized"
+ fi
+ for dir in ${LAYERED_DIRS}; do
+ if [[ -d "${CUSTOM_BASE}/${dir}" ]]; then
+ find "${CUSTOM_BASE}/${dir}" -type f ! -name '.gitkeep' -print0 \
+ | while IFS= read -r -d '' f; do
+ rel="${f#"${CUSTOM_BASE}"/}"
+ mkdir -p "$(dirname "${rel}")"
+ cp "${f}" "${rel}"
+ done
+ fi
+ done
+ mkdir -p .github/scripts
+ cp "${SRC}/.github/scripts/setup-agent-env.sh" .github/scripts/setup-agent-env.sh
+
+
+ - name: Validate enrollment and extract repo metadata
+ id: repo-parts
+ uses: ./.defaults/.github/actions/validate-enrollment
+ with:
+ source_repo: ${{ inputs.source_repo }}
+ install_mode: ${{ inputs.install_mode }}
+
+ - name: Mint critique token
+ id: app-token
+ uses: ./.defaults/.github/actions/mint-token
+ with:
+ role: critique
+ repos: ${{ steps.repo-parts.outputs.name }}
+ mint_url: ${{ inputs.mint_url }}
+
+ - name: Checkout target repository
+ uses: actions/checkout@v6
+ with:
+ repository: ${{ inputs.source_repo }}
+ token: ${{ steps.app-token.outputs.token }}
+ path: target-repo
+ fetch-depth: 1
+ persist-credentials: false
+
+ - name: Setup GCP and prepare credentials
+ uses: ./.defaults/.github/actions/setup-gcp
+ with:
+ gcp_wif_provider: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }}
+ gcp_project_id: ${{ secrets.FULLSEND_GCP_PROJECT_ID }}
+
+ - name: Setup agent environment
+ env:
+ AGENT_PREFIX: CRITIQUE_
+ CRITIQUE_GH_TOKEN: ${{ steps.app-token.outputs.token }}
+ CRITIQUE_TARGET_REPO_DIR: target-repo
+ CRITIQUE_ISSUE_KEY: ${{ inputs.issue_key }}
+ CRITIQUE_ISSUE_SOURCE: ${{ inputs.issue_source }}
+ CRITIQUE_REFINE_RUN_ID: ${{ inputs.refine_run_id }}
+ CRITIQUE_REVIEW_ROUND: ${{ inputs.review_round }}
+ CRITIQUE_MAX_REVIEW_ROUNDS: ${{ inputs.max_review_rounds }}
+ CRITIQUE_AUTO_CREATE: ${{ inputs.auto_create }}
+ CRITIQUE_ANTHROPIC_VERTEX_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }}
+ CRITIQUE_CLOUD_ML_REGION: ${{ inputs.gcp_region }}
+ JIRA_HOST: ${{ secrets.JIRA_HOST }}
+ JIRA_EMAIL: ${{ secrets.JIRA_EMAIL }}
+ JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }}
+ JIRA_PROJECT_VISIBILITY: ${{ inputs.jira_project_visibility }}
+ run: bash .github/scripts/setup-agent-env.sh
+
+ - name: Run critique agent
+ uses: ./.defaults/
+ with:
+ agent: critique
+ version: ${{ inputs.fullsend_version }}
+ run-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+ status-repo: ${{ inputs.source_repo }}
+ mint-url: ${{ inputs.mint_url }}
diff --git a/.github/workflows/reusable-explore.yml b/.github/workflows/reusable-explore.yml
new file mode 100644
index 000000000..20f17430e
--- /dev/null
+++ b/.github/workflows/reusable-explore.yml
@@ -0,0 +1,174 @@
+# Reusable explore agent workflow. Called by thin callers in .fullsend repos
+# via workflow_call. Runs in the caller's repo context (secrets, checkout).
+name: Explore Agent
+
+on:
+ workflow_call:
+ inputs:
+ event_type:
+ required: true
+ type: string
+ source_repo:
+ required: true
+ type: string
+ event_payload:
+ required: true
+ type: string
+ mint_url:
+ required: true
+ type: string
+ gcp_region:
+ required: true
+ type: string
+ fullsend_version:
+ required: false
+ type: string
+ default: "latest"
+ install_mode:
+ required: false
+ type: string
+ default: "per-org"
+ fullsend_ai_ref:
+ description: Ref of fullsend-ai/fullsend to load actions from. Must match the ref used in the `uses:` line that calls this workflow.
+ type: string
+ required: false
+ default: v0
+ issue_key:
+ required: true
+ type: string
+ issue_source:
+ required: false
+ type: string
+ default: "github"
+ jira_project_visibility:
+ required: false
+ type: string
+ default: "private"
+ secrets:
+ FULLSEND_GCP_WIF_PROVIDER:
+ required: true
+ FULLSEND_GCP_PROJECT_ID:
+ required: true
+ JIRA_HOST:
+ required: false
+ JIRA_EMAIL:
+ required: false
+ JIRA_API_TOKEN:
+ required: false
+
+jobs:
+ explore:
+ name: Explore
+ runs-on: ubuntu-latest
+ permissions:
+ actions: write
+ contents: read
+ id-token: write
+ issues: write
+
+ steps:
+ - name: Checkout config repository
+ uses: actions/checkout@v6
+
+ - name: Checkout upstream defaults
+ if: hashFiles('.defaults/action.yml', '.fullsend/.defaults/action.yml') == ''
+ uses: actions/checkout@v6
+ with:
+ repository: fullsend-ai/fullsend
+ ref: ${{ inputs.fullsend_ai_ref }}
+ path: .defaults
+ fetch-depth: 1
+ sparse-checkout: |
+ .github/actions/
+ .github/scripts/
+ internal/scaffold/fullsend-repo/
+ action.yml
+
+ - name: Prepare workspace (upstream defaults + org/repo overrides)
+ env:
+ INSTALL_MODE: ${{ inputs.install_mode }}
+ run: |
+ set -euo pipefail
+ if [[ "${INSTALL_MODE}" != "per-org" && "${INSTALL_MODE}" != "per-repo" ]]; then
+ echo "::error::Invalid install_mode '${INSTALL_MODE}': must be 'per-org' or 'per-repo'"
+ exit 1
+ fi
+ SRC=".defaults/internal/scaffold/fullsend-repo"
+ LAYERED_DIRS="agents skills schemas harness plugins policies scripts env"
+ for dir in ${LAYERED_DIRS}; do
+ if [[ -d "${SRC}/${dir}" ]]; then
+ mkdir -p "${dir}"
+ cp -r "${SRC}/${dir}/." "${dir}/"
+ fi
+ done
+ CUSTOM_BASE="customized"
+ if [[ "${INSTALL_MODE}" == "per-repo" ]]; then
+ CUSTOM_BASE=".fullsend/customized"
+ fi
+ for dir in ${LAYERED_DIRS}; do
+ if [[ -d "${CUSTOM_BASE}/${dir}" ]]; then
+ find "${CUSTOM_BASE}/${dir}" -type f ! -name '.gitkeep' -print0 \
+ | while IFS= read -r -d '' f; do
+ rel="${f#"${CUSTOM_BASE}"/}"
+ mkdir -p "$(dirname "${rel}")"
+ cp "${f}" "${rel}"
+ done
+ fi
+ done
+ mkdir -p .github/scripts
+ cp "${SRC}/.github/scripts/setup-agent-env.sh" .github/scripts/setup-agent-env.sh
+
+
+ - name: Validate enrollment and extract repo metadata
+ id: repo-parts
+ uses: ./.defaults/.github/actions/validate-enrollment
+ with:
+ source_repo: ${{ inputs.source_repo }}
+ install_mode: ${{ inputs.install_mode }}
+
+ - name: Mint explore token
+ id: app-token
+ uses: ./.defaults/.github/actions/mint-token
+ with:
+ role: explore
+ repos: ${{ steps.repo-parts.outputs.name }}
+ mint_url: ${{ inputs.mint_url }}
+
+ - name: Checkout target repository
+ uses: actions/checkout@v6
+ with:
+ repository: ${{ inputs.source_repo }}
+ token: ${{ steps.app-token.outputs.token }}
+ path: target-repo
+ fetch-depth: 1
+ persist-credentials: false
+
+ - name: Setup GCP and prepare credentials
+ uses: ./.defaults/.github/actions/setup-gcp
+ with:
+ gcp_wif_provider: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }}
+ gcp_project_id: ${{ secrets.FULLSEND_GCP_PROJECT_ID }}
+
+ - name: Setup agent environment
+ env:
+ AGENT_PREFIX: EXPLORE_
+ EXPLORE_GH_TOKEN: ${{ steps.app-token.outputs.token }}
+ EXPLORE_TARGET_REPO_DIR: target-repo
+ EXPLORE_ISSUE_KEY: ${{ inputs.issue_key }}
+ EXPLORE_ISSUE_SOURCE: ${{ inputs.issue_source }}
+ EXPLORE_ANTHROPIC_VERTEX_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }}
+ EXPLORE_CLOUD_ML_REGION: ${{ inputs.gcp_region }}
+ JIRA_HOST: ${{ secrets.JIRA_HOST }}
+ JIRA_EMAIL: ${{ secrets.JIRA_EMAIL }}
+ JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }}
+ JIRA_PROJECT_VISIBILITY: ${{ inputs.jira_project_visibility }}
+ run: bash .github/scripts/setup-agent-env.sh
+
+ - name: Run explore agent
+ uses: ./.defaults/
+ with:
+ agent: explore
+ version: ${{ inputs.fullsend_version }}
+ run-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+ status-repo: ${{ inputs.source_repo }}
+ mint-url: ${{ inputs.mint_url }}
diff --git a/.github/workflows/reusable-refine.yml b/.github/workflows/reusable-refine.yml
new file mode 100644
index 000000000..d242cee61
--- /dev/null
+++ b/.github/workflows/reusable-refine.yml
@@ -0,0 +1,204 @@
+# Reusable refine agent workflow. Called by thin callers in .fullsend repos
+# via workflow_call. Runs in the caller's repo context (secrets, checkout).
+name: Refine Agent
+
+on:
+ workflow_call:
+ inputs:
+ event_type:
+ required: true
+ type: string
+ source_repo:
+ required: true
+ type: string
+ event_payload:
+ required: true
+ type: string
+ mint_url:
+ required: true
+ type: string
+ gcp_region:
+ required: true
+ type: string
+ fullsend_version:
+ required: false
+ type: string
+ default: "latest"
+ install_mode:
+ required: false
+ type: string
+ default: "per-org"
+ fullsend_ai_ref:
+ description: Ref of fullsend-ai/fullsend to load actions from. Must match the ref used in the `uses:` line that calls this workflow.
+ type: string
+ required: false
+ default: v0
+ issue_key:
+ required: true
+ type: string
+ issue_source:
+ required: false
+ type: string
+ default: "github"
+ explore_run_id:
+ required: false
+ type: string
+ default: ""
+ explore_context_ref:
+ required: false
+ type: string
+ default: ""
+ review_round:
+ required: false
+ type: string
+ default: "1"
+ max_review_rounds:
+ required: false
+ type: string
+ default: "3"
+ auto_create:
+ required: false
+ type: string
+ default: "false"
+ critique_run_id:
+ required: false
+ type: string
+ default: ""
+ jira_project_visibility:
+ required: false
+ type: string
+ default: "private"
+ secrets:
+ FULLSEND_GCP_WIF_PROVIDER:
+ required: true
+ FULLSEND_GCP_PROJECT_ID:
+ required: true
+ JIRA_HOST:
+ required: false
+ JIRA_EMAIL:
+ required: false
+ JIRA_API_TOKEN:
+ required: false
+
+jobs:
+ refine:
+ name: Refine
+ runs-on: ubuntu-latest
+ permissions:
+ actions: write
+ contents: read
+ id-token: write
+ issues: write
+
+ steps:
+ - name: Checkout config repository
+ uses: actions/checkout@v6
+
+ - name: Checkout upstream defaults
+ if: hashFiles('.defaults/action.yml', '.fullsend/.defaults/action.yml') == ''
+ uses: actions/checkout@v6
+ with:
+ repository: fullsend-ai/fullsend
+ ref: ${{ inputs.fullsend_ai_ref }}
+ path: .defaults
+ fetch-depth: 1
+ sparse-checkout: |
+ .github/actions/
+ .github/scripts/
+ internal/scaffold/fullsend-repo/
+ action.yml
+
+ - name: Prepare workspace (upstream defaults + org/repo overrides)
+ env:
+ INSTALL_MODE: ${{ inputs.install_mode }}
+ run: |
+ set -euo pipefail
+ if [[ "${INSTALL_MODE}" != "per-org" && "${INSTALL_MODE}" != "per-repo" ]]; then
+ echo "::error::Invalid install_mode '${INSTALL_MODE}': must be 'per-org' or 'per-repo'"
+ exit 1
+ fi
+ SRC=".defaults/internal/scaffold/fullsend-repo"
+ LAYERED_DIRS="agents skills schemas harness plugins policies scripts env"
+ for dir in ${LAYERED_DIRS}; do
+ if [[ -d "${SRC}/${dir}" ]]; then
+ mkdir -p "${dir}"
+ cp -r "${SRC}/${dir}/." "${dir}/"
+ fi
+ done
+ CUSTOM_BASE="customized"
+ if [[ "${INSTALL_MODE}" == "per-repo" ]]; then
+ CUSTOM_BASE=".fullsend/customized"
+ fi
+ for dir in ${LAYERED_DIRS}; do
+ if [[ -d "${CUSTOM_BASE}/${dir}" ]]; then
+ find "${CUSTOM_BASE}/${dir}" -type f ! -name '.gitkeep' -print0 \
+ | while IFS= read -r -d '' f; do
+ rel="${f#"${CUSTOM_BASE}"/}"
+ mkdir -p "$(dirname "${rel}")"
+ cp "${f}" "${rel}"
+ done
+ fi
+ done
+ mkdir -p .github/scripts
+ cp "${SRC}/.github/scripts/setup-agent-env.sh" .github/scripts/setup-agent-env.sh
+
+
+ - name: Validate enrollment and extract repo metadata
+ id: repo-parts
+ uses: ./.defaults/.github/actions/validate-enrollment
+ with:
+ source_repo: ${{ inputs.source_repo }}
+ install_mode: ${{ inputs.install_mode }}
+
+ - name: Mint refine token
+ id: app-token
+ uses: ./.defaults/.github/actions/mint-token
+ with:
+ role: refine
+ repos: ${{ steps.repo-parts.outputs.name }}
+ mint_url: ${{ inputs.mint_url }}
+
+ - name: Checkout target repository
+ uses: actions/checkout@v6
+ with:
+ repository: ${{ inputs.source_repo }}
+ token: ${{ steps.app-token.outputs.token }}
+ path: target-repo
+ fetch-depth: 1
+ persist-credentials: false
+
+ - name: Setup GCP and prepare credentials
+ uses: ./.defaults/.github/actions/setup-gcp
+ with:
+ gcp_wif_provider: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }}
+ gcp_project_id: ${{ secrets.FULLSEND_GCP_PROJECT_ID }}
+
+ - name: Setup agent environment
+ env:
+ AGENT_PREFIX: REFINE_
+ REFINE_GH_TOKEN: ${{ steps.app-token.outputs.token }}
+ REFINE_TARGET_REPO_DIR: target-repo
+ REFINE_ISSUE_KEY: ${{ inputs.issue_key }}
+ REFINE_ISSUE_SOURCE: ${{ inputs.issue_source }}
+ REFINE_EXPLORE_RUN_ID: ${{ inputs.explore_run_id }}
+ REFINE_EXPLORE_CONTEXT_REF: ${{ inputs.explore_context_ref }}
+ REFINE_REVIEW_ROUND: ${{ inputs.review_round }}
+ REFINE_MAX_REVIEW_ROUNDS: ${{ inputs.max_review_rounds }}
+ REFINE_AUTO_CREATE: ${{ inputs.auto_create }}
+ REFINE_CRITIQUE_RUN_ID: ${{ inputs.critique_run_id }}
+ REFINE_ANTHROPIC_VERTEX_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }}
+ REFINE_CLOUD_ML_REGION: ${{ inputs.gcp_region }}
+ JIRA_HOST: ${{ secrets.JIRA_HOST }}
+ JIRA_EMAIL: ${{ secrets.JIRA_EMAIL }}
+ JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }}
+ JIRA_PROJECT_VISIBILITY: ${{ inputs.jira_project_visibility }}
+ run: bash .github/scripts/setup-agent-env.sh
+
+ - name: Run refine agent
+ uses: ./.defaults/
+ with:
+ agent: refine
+ version: ${{ inputs.fullsend_version }}
+ run-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+ status-repo: ${{ inputs.source_repo }}
+ mint-url: ${{ inputs.mint_url }}
diff --git a/Makefile b/Makefile
index 43d4f927d..bced3a2e2 100644
--- a/Makefile
+++ b/Makefile
@@ -115,6 +115,10 @@ script-test:
bash internal/scaffold/fullsend-repo/scripts/validate-output-schema-test.sh
bash internal/scaffold/fullsend-repo/scripts/pre-code-test.sh
bash internal/scaffold/fullsend-repo/scripts/pre-fetch-prior-review-test.sh
+ bash internal/scaffold/fullsend-repo/scripts/post-explore-test.sh
+ bash internal/scaffold/fullsend-repo/scripts/post-refine-test.sh
+ bash internal/scaffold/fullsend-repo/scripts/post-critique-test.sh
+ bash internal/scaffold/fullsend-repo/scripts/create-children-test.sh
python3 internal/scaffold/fullsend-repo/scripts/process-fix-result-test.py
python3 skills/topissues/scripts/topissues_test.py
diff --git a/docs/ADRs/0051-refinement-pipeline-architecture.md b/docs/ADRs/0051-refinement-pipeline-architecture.md
new file mode 100644
index 000000000..90cdb1e2c
--- /dev/null
+++ b/docs/ADRs/0051-refinement-pipeline-architecture.md
@@ -0,0 +1,111 @@
+---
+title: "51. Refinement Pipeline Architecture"
+status: Accepted
+relates_to: []
+topics:
+ - agents
+ - refinement
+ - pipeline
+ - multi-agent orchestration
+---
+
+# ADR 0051: Refinement Pipeline Architecture
+
+## Status
+
+Accepted
+
+## Context
+
+fullsend currently ships 6 standalone agents (triage, code, review, fix, retro, prioritize). Each is triggered by a single event and produces a single output. When agents relate to each other today, the coupling is loose: triage adds a `ready-to-code` label, which fires a webhook, and the code agent reads the issue fresh from GitHub. No data passes between them — each agent is self-contained.
+
+The Feature Refinement Working Group needs a pipeline that decomposes high-level issues into implementable work items through three stages: exploration, refinement, and quality-gated critique. This pipeline cannot work with fullsend's current standalone-agent model because each stage produces structured context that the next stage consumes:
+
+- **explore** produces `exploration_context.json` (~50-100KB of analyzed research, codebase findings, prior art)
+- **refine** consumes that context and produces `refine-result.json` (the full decomposition plan with children, acceptance criteria, etc.)
+- **critique** consumes both to evaluate the plan against the original issue
+
+Labels can signal "go/no-go" but cannot carry the structured data or the run ID needed to locate a previous stage's artifacts. This means the existing pattern of loose coupling via labels + webhooks is insufficient — we need a way for agents to explicitly chain to each other and pass data between runs.
+
+## Decision
+
+We will introduce **agent chaining** as a new orchestration pattern in fullsend: the ability for one agent's post-script to trigger a subsequent agent via `workflow_dispatch`, passing a run ID that the next agent uses to download the previous stage's artifacts.
+
+The first use of this pattern is the refinement pipeline — three new agent roles (**explore**, **refine**, **critique**) that chain together to decompose issues. But the pattern itself is general-purpose and available for future multi-stage workflows.
+
+### How chaining works
+
+A post-script chains to the next stage by invoking `gh workflow run` with the current run ID:
+
+```
+gh workflow run refine.yml \
+ --repo "$WORKFLOW_REPO" \
+ -f issue_key="PROJ-123" \
+ -f issue_source="jira" \
+ -f explore_run_id="$GITHUB_RUN_ID"
+```
+
+The downstream pre-script downloads the upstream artifact using that run ID:
+
+```
+gh run download "$EXPLORE_RUN_ID" \
+ --repo "$REPO" \
+ --name "fullsend-explore" \
+ --dir "$ARTIFACT_DIR"
+```
+
+This extends the existing review → fix iteration pattern (which uses `FIX_ITERATION` cap, default 5, with PR event triggers) to support `workflow_dispatch`-based loops where critique feedback artifacts must be passed back to refine.
+
+### Phase 1 decisions (this PR)
+
+This is a **Phase 1 implementation** focused on getting the chaining pattern and refinement pipeline functional. Later phases will harden, optimize, and extend based on production feedback.
+
+**Separate GitHub Apps**: Each agent (explore, refine, critique) gets its own GitHub App, consistent with every other fullsend agent. For the GitHub workflow, this provides a clear audit trail — each agent's comments and actions appear under a distinct bot identity. It also enables granular access control and avoids coupling permissions if the agents' needs diverge. Note: for the Jira workflow, all three agents share the same `JIRA_EMAIL` service account, so Jira comments are attributed to a single bot user regardless of which agent posted them.
+
+**Default enabled, command-gated**: The agents ship with every fullsend installation but only activate when a user explicitly invokes `/fs-refine`. There are no automatic triggers — the pipeline never runs unless a human requests it.
+
+**Revision loop with hard cap**: Critique can return a "revise" verdict, causing post-critique.sh to re-trigger refine.yml with critique feedback as additional context. MAX_REVIEW_ROUNDS (default 3) prevents infinite loops. If the cap is reached, the plan is presented to a human for final decision.
+
+**Separate dispatch workflows**: The pipeline uses separate dispatch files (refine-dispatch.yml, jira-dispatch.yml) rather than integrating into the existing dispatch.yml. We chose this because it matches the validated pattern from fullsend-ai/features and avoids modifying the shared dispatch routing logic in Phase 1.
+
+**No eval system**: The features repo has a full eval system (MLflow baselines, fixture evals, eval-gate.yml). This is intentionally excluded from Phase 1 to reduce PR scope and review burden.
+
+### Alternatives considered
+
+**Single-workflow pipeline**: Run all three stages as sequential jobs within one GitHub Actions workflow. Data passes via job outputs or workspace sharing — no cross-run artifact downloads, no run-ID correlation, and artifacts never leave the run (eliminating the data leakage risk for public repos entirely). This would also reduce the GitHub App count from 3 to 1, since all stages run under a single workflow identity. We deferred this because the harness currently expects one agent = one workflow run, and changing that is a significant architectural lift. It also means a failure in stage 3 requires re-running the entire pipeline, and individual agent actions are no longer attributable in the GitHub UI. This remains the strongest candidate for Phase 2 (see Deferred table below). Related existing work: #234 (design validation loop and multi-agent orchestration patterns for harness definitions) and #1817 (investigate deterministic orchestration for intra-stage multi-agent pipelines).
+
+**Issue comments as data store**: Write structured context into issue comments (e.g., a machine-readable JSON block) instead of artifacts, so downstream agents read the issue like every other fullsend agent. This is closest to fullsend's current model. We rejected it because GitHub comments have a 65,536-character limit and exploration context can reach 50-100KB, making data truncation likely. It also pollutes the issue with large machine-readable blobs that aren't useful to humans.
+
+**Repository dispatch with client payload**: Use `repository_dispatch` events with a structured `client_payload` instead of `workflow_dispatch` inputs. Similar to our chosen approach, but `client_payload` is limited to 10 top-level properties and has payload size constraints that would require compression or external storage for larger artifacts — adding complexity without clear benefit over `workflow_dispatch` + run-ID correlation.
+
+### Deferred to Phase 2+
+
+| Decision | Rationale for deferral |
+|----------|----------------------|
+| Consolidate dispatch into dispatch.yml | Blocked on #1985 (harness-driven dispatch). Once that lands, the separate dispatch files should be migrated. |
+| Eval system integration | Complex (MLflow, baselines, scorer configs). Ship agents first, add eval infrastructure once we have production data to calibrate against. |
+| Automatic triggers (e.g., trigger on issue creation with certain labels) | Phase 1 is deliberately manual-only to build confidence. Automatic triggers should be a separate ADR once we have usage data. |
+| Single-workflow pipeline mode | Running all 3 stages in one workflow (no cross-run artifacts) would eliminate data leakage risk for public repos. Requires significant harness changes. |
+| Dynamic role registration (#1985) | Currently adding roles to the hardcoded ValidRoles() list. Once #1985 lands, these should migrate to harness-declared roles. |
+
+## Consequences
+
+### Positive
+
+- Agent chaining is a general-purpose pattern — future multi-stage workflows (e.g., plan → implement → validate) can reuse the same `workflow_dispatch` + run-ID artifact correlation mechanism without inventing new infrastructure
+- Teams get automated issue decomposition from day one after installation
+- The pipeline is entirely opt-in (requires explicit `/fs-refine` command)
+- Revision loop ensures quality without human intervention for routine work
+
+### Negative
+
+- Agent chaining introduces coupling between agents that didn't exist before — downstream agents depend on upstream artifact structure, naming, and availability
+- Three new roles (each with its own GitHub App) increase the total agent count from 7 to 10 and add 3 apps to install
+- Artifact-based data passing has a 90-day retention limit (GitHub Actions constraint)
+- Separate dispatch files add workflow sprawl (mitigated by planned consolidation in Phase 2)
+
+### Risks
+
+- The revision loop could produce unnecessary iterations on simple issues (mitigated by confidence scoring and max rounds)
+- Artifact downloads between workflow runs add latency (~30s per stage transition)
+- Private Jira data could leak via public repo artifacts/logs (mitigated by a safety check that blocks Jira sources on public repos — see ADR 0052)
diff --git a/docs/ADRs/0052-jira-integration-model.md b/docs/ADRs/0052-jira-integration-model.md
new file mode 100644
index 000000000..0bf3d9016
--- /dev/null
+++ b/docs/ADRs/0052-jira-integration-model.md
@@ -0,0 +1,108 @@
+---
+title: "52. Jira Integration Model"
+status: Accepted
+relates_to: []
+topics:
+ - jira
+ - integration
+ - issue tracking
+ - credentials
+---
+
+# ADR 0052: Jira Integration Model
+
+## Status
+
+Accepted
+
+## Context
+
+fullsend agents currently interact exclusively with GitHub Issues via the GitHub API. The refinement pipeline needs to work with Jira issues as well, since many teams use Jira as their primary issue tracker while hosting code on GitHub.
+
+Prior work on Jira integration (PR #2162 by Manish Kumar) validated the pattern of static API tokens with credential isolation, where Jira credentials are available to pre/post scripts on the trusted GitHub Actions runner but never enter the agent sandbox. That PR was closed without merging (review feedback required an ADR first), but the technical approach was validated on staging.
+
+This ADR covers the **Phase 1 Jira integration** — the minimum viable model to get the refinement pipeline working against Jira production. It intentionally defers several improvements to later phases.
+
+## Decision
+
+We will support Jira as an issue source for the refinement pipeline using the following model.
+
+### Phase 1 decisions (this PR)
+
+#### Authentication: Static API tokens
+
+- Jira access uses a service account email + API token stored as GitHub Actions secrets (`JIRA_HOST`, `JIRA_EMAIL`, `JIRA_API_TOKEN`)
+- We chose static tokens because they work today, require zero infrastructure, and match what was validated in fullsend-ai/features
+- **Credential isolation**: Jira credentials are available ONLY to pre/post scripts running on the trusted GitHub Actions runner. They are explicitly excluded from the agent sandbox environment. The agent cannot make Jira API calls directly.
+- Pre-scripts fetch data and write it to `issue-context.json`; post-scripts read agent output and post results back to Jira
+
+#### Issue source routing
+
+Scripts use an `ISSUE_SOURCE` environment variable (`"github"` or `"jira"`) to determine:
+
+- Where to fetch issue data from (pre-scripts)
+- Where to post results back to (post-scripts)
+- What format to use (Markdown for GitHub, ADF for Jira via `markdown-to-adf.py`)
+
+#### Trigger mechanisms: Manual dispatch + comment poller
+
+For Phase 1, we ship two trigger mechanisms:
+
+1. **Manual dispatch**: Direct `gh workflow run jira-dispatch.yml -f jira_key=PROJ-123 -f command=/fs-refine`. This is the simplest path — zero Jira-side configuration needed.
+2. **Jira comment poller** (cron, every 5 min): Polls Jira projects for issues labeled "fullsend" that have `/fs-refine` comments. Zero Jira admin access needed. Trade-off: GitHub Actions scheduled workflows run on a best-effort basis — the 5-minute cron is not guaranteed and can be delayed by minutes or skipped entirely during periods of high GitHub Actions load. This is acceptable for Phase 1 but is a key reason to prioritize Jira Automation Rule webhooks in Phase 2.
+
+Both mechanisms route through `jira-dispatch.yml`, which validates the issue key and dispatches the appropriate pipeline workflow.
+
+#### Security: Private Jira on public repos
+
+The risk is specific: **private** Jira data leaking into **public** GitHub Actions artifacts and logs. Public Jira projects running on public repos are fine — the data is already publicly accessible.
+
+The pipeline uses `JIRA_PROJECT_VISIBILITY` to control this. Teams set it as a **GitHub Actions repository variable** on their `.fullsend` config repo (Settings → Secrets and variables → Actions → Variables tab → `JIRA_PROJECT_VISIBILITY`). Values:
+
+- `"public"` — Jira project is publicly accessible, skip the check
+- `"private"` or unset (default) — Jira project is private, require a private config repo
+
+When the value is `"private"` and the config repo is public, the pipeline **hard-fails** with a security error explaining the options.
+
+The variable flows through the full chain: scaffold shim (`vars.JIRA_PROJECT_VISIBILITY`) → reusable workflow (`jira_project_visibility` input) → agent environment → pre-script. The check is also enforced independently at `jira-dispatch.yml` and `jira-comment-poller.yml` before any agent is invoked.
+
+#### Data flow
+
+1. Pre-script fetches issue data from Jira REST API v3, normalizes to `issue-context.json`
+2. Agent reads `issue-context.json` in sandbox (read-only)
+3. Agent produces structured JSON output (explore/refine/critique result)
+4. Post-script reads agent output, posts formatted comment back to Jira using ADF
+
+### Deferred to Phase 2+
+
+| Decision | Why it's good | Why it's deferred |
+|----------|--------------|-------------------|
+| **Jira Automation Rule webhooks** | Instant trigger (no polling delay). A Jira Automation Rule fires a `repository_dispatch` event to GitHub when a comment containing `/fs-refine` is added. | Requires Jira admin access to create the rule per project. Phase 1 avoids any Jira-side setup requirements so teams can self-serve. The `jira-dispatch.yml` already handles `repository_dispatch` events — teams just need to create the Jira rule when ready. |
+| **OAuth 2.0 with WIF for short-lived tokens** | Eliminates token rotation, follows ercohen's token mint proposal, aligns with fullsend's existing OIDC mint pattern. | Requires new infrastructure (token mint service for Jira). Static tokens work for initial adoption. Migrate when the token mint supports Jira. |
+| **`fullsend jira enroll` CLI command** | Streamlines onboarding — one command sets up secrets, creates Jira Automation Rules, configures the poller. | Manish started this in PR #2162 but it wasn't merged. Needs design work to handle both per-org and per-repo modes. |
+| **Jira custom field mapping for child issues** | Map additional Jira fields (story points, sprints, components, fix versions) when creating child issues. | Phase 1 creates children with summary, description, type, and parent link. Custom field mapping requires per-project configuration that varies across teams. |
+| **Jira project discovery and enrollment** | Auto-detect which Jira projects to poll, manage project-to-repo mappings in config.yaml. | Phase 1 uses the "fullsend" label convention — poll all issues with that label. Explicit project enrollment adds config complexity that isn't needed for initial adoption. |
+| **Private Jira on public repos** | Some teams have private Jira but public GitHub repos. A single-workflow pipeline mode or encrypted artifacts could enable this safely. | Architecturally complex. Phase 1 blocks this combination with a safety check. Teams in this situation should use a private config repo. |
+
+## Consequences
+
+### Positive
+
+- Jira credentials never touch the agent sandbox (security)
+- Same agent prompts work for both GitHub and Jira issues
+- Two trigger mechanisms cover the main use cases without requiring Jira admin access
+- Public repo safety check prevents accidental data leakage
+- The `jira-dispatch.yml` webhook handler is already built — enabling Jira Automation Rules in Phase 2 is zero code change
+
+### Negative
+
+- Static API tokens require manual rotation
+- Cron poller delay is unpredictable — GitHub Actions scheduled workflows are best-effort and can be delayed or skipped under load (webhook trigger in Phase 2 eliminates this entirely)
+- ADF (Atlassian Document Format) conversion adds complexity
+- Child issues are created in both GitHub and Jira, with Jira hierarchy support (parent linking with retry-without-parent fallback) and automatic issue type resolution against the project's available types
+
+### Risks
+
+- Token rotation is a manual process — if a token expires, the pipeline silently fails until someone notices (mitigated: post-scripts log clear errors on auth failures)
+- The poller creates GitHub Actions runs even when no commands are found (mitigated: the workflow exits quickly when there's nothing to process)
+- GitHub's best-effort cron scheduling means the poller may not fire reliably, leading to user frustration if they expect timely responses (mitigated: manual dispatch is always available as a fallback, and Phase 2 Jira Automation webhooks provide instant, reliable triggering)
diff --git a/docs/agents/README.md b/docs/agents/README.md
index f8c074b56..8b091fb73 100644
--- a/docs/agents/README.md
+++ b/docs/agents/README.md
@@ -12,6 +12,9 @@ the YAML files in [`internal/scaffold/fullsend-repo/harness/`](../../internal/sc
| [Review](review.md) | Reviews pull requests for correctness, security, and intent alignment |
| [Fix](fix.md) | Addresses review feedback on open PRs |
| [Retro](retro.md) | Analyzes completed workflows and proposes system improvements |
+| [Explore](explore.md) | Investigates issues and gathers context for refinement |
+| [Refine](refine.md) | Decomposes issues into structured implementation plans |
+| [Critique](critique.md) | Reviews refinement plans and controls quality gates |
## Customization
diff --git a/docs/agents/critique.md b/docs/agents/critique.md
new file mode 100644
index 000000000..96aae3c81
--- /dev/null
+++ b/docs/agents/critique.md
@@ -0,0 +1,85 @@
+# Critique Agent
+
+
+
+Reviews the refinement plan for quality, completeness, and feasibility, then decides whether to approve it, request revisions, or escalate to a human.
+
+## How the agent works
+
+The critique agent receives the exploration context, the original issue, and the refinement plan produced by the [refine agent](refine.md). It evaluates the plan against six quality dimensions, cross-checks the refine agent's self-reported confidence, and scrutinizes assumptions that lack evidence from the exploration context.
+
+The agent runs in a read-only sandbox. It cannot modify issues, push code, or interact with external services. Its only output is a structured JSON verdict consumed by the post-script, which either creates child issues (on approval), re-triggers refine (on revise), or posts a question comment (on needs_input).
+
+Nothing gets created until this agent approves.
+
+## How it helps
+
+- Prevents over-decomposition (15 issues when 6 would suffice) from flooding the backlog.
+- Catches vague children that restate the parent without adding specificity.
+- Identifies missing coverage — entire dimensions of a feature forgotten by refine.
+- Detects dependency cycles and impossible ordering.
+- Guards against scope creep — children that exceed what the parent asked for.
+- Catches "assumption laundering" — plans that look implementable but are built on unverified guesses.
+
+## Control labels
+
+Labels are applied by `post-critique.sh` based on the verdict (GitHub flow only):
+
+| Label | When applied |
+|-------|-------------|
+| `refine-approved` | Plan approved (with `auto_create=false`), or max review rounds reached (escalation). |
+| `refine-needs-input` | Human input is required before the pipeline can proceed (`needs_input` verdict). |
+| `refine-needs-human` | Max review rounds reached — critique still has concerns, human must decide. |
+| `refine-stalled` | Max review rounds reached — added alongside `refine-needs-human` to signal the pipeline has stopped. |
+
+## Configuration and extension
+
+See [Customizing with AGENTS.md](../guides/user/customizing-with-agents-md.md) and
+[Customizing with Skills](../guides/user/customizing-with-skills.md).
+
+## Verdicts
+
+| Verdict | Meaning | What happens next |
+|---------|---------|-------------------|
+| `approved` | Plan is ready for child issue creation | Post-script creates issues (if `auto_create=true`) or posts approval comment |
+| `revise` | Plan has fixable problems | Post-script re-triggers refine with critique feedback |
+| `needs_input` | Human must answer a question before proceeding | Post-script posts a focused question as an issue comment |
+
+## Auto-create vs. human gate
+
+When the critique agent approves a plan:
+
+- **`auto_create=true`**: The post-script automatically creates child issues in the target tracker (GitHub or Jira).
+- **`auto_create=false`** (default): The post-script posts an approval comment with the proposed plan. A human must comment `/fs-create` to trigger issue creation.
+
+This gives teams control over whether refinement is fully automated or requires human sign-off before backlog changes.
+
+## Review rounds
+
+- The critique agent tracks its review round via `REVIEW_ROUND` and has access to prior feedback via `CRITIQUE_HISTORY`.
+- On re-reviews (round 2+), it focuses on whether specific requested revisions were addressed rather than re-evaluating the entire plan.
+- Maximum review rounds default to 3 (configurable via `MAX_REVIEW_ROUNDS`).
+- When close to the iteration limit, the threshold lowers — it approves with notes rather than forcing another round that would hit the cap.
+- If the limit is reached without approval, the pipeline escalates to a human regardless of verdict.
+
+## Assessment dimensions
+
+| Dimension | What it checks |
+|-----------|---------------|
+| Coverage | Every requirement dimension has corresponding children |
+| Granularity | Children are sized appropriately (epics = team-sized, stories = engineer-sized) |
+| Dependency coherence | No cycles, achievable ordering, cross-team deps called out |
+| Implementability | Engineers can read each child and know what to build |
+| Scope accuracy | Children collectively match parent scope — no more, no less |
+| Assumption grounding | Architectural decisions backed by evidence, not speculation |
+
+## Commands
+
+| Command | Where | Effect |
+|---------|-------|--------|
+| `/fs-refine` | Issue comment | Triggers the pipeline; critique runs automatically after refine |
+| `/fs-create` | Issue comment | Creates child issues from an approved plan (when `auto_create=false`) |
+
+## Source
+
+[`internal/scaffold/fullsend-repo/harness/critique.yaml`](../../internal/scaffold/fullsend-repo/harness/critique.yaml)
diff --git a/docs/agents/explore.md b/docs/agents/explore.md
new file mode 100644
index 000000000..c44761823
--- /dev/null
+++ b/docs/agents/explore.md
@@ -0,0 +1,67 @@
+# Explore Agent
+
+
+
+Investigates a GitHub or Jira issue to gather technical landscape, related work, architectural constraints, and competitive context before refinement begins.
+
+## How the agent works
+
+The explore agent is the first stage of the refinement pipeline, triggered when `/fs-refine` is invoked on an issue. It fetches the issue content — title, body, labels, comments, parent context — and then systematically researches the target codebase, related GitHub issues and PRs, Jira linked work, and the public web.
+
+The agent runs in a read-only sandbox. It cannot modify issues, push code, create PRs, or interact with external services beyond read access. Its only output is a structured JSON exploration context consumed by the downstream [refine agent](refine.md).
+
+The explore agent works with both GitHub Issues and Jira issues. When the issue source is Jira, the pre-script fetches issue data via the Jira REST API and normalizes it to `issue-context.json` before the agent starts.
+
+## How it helps
+
+- Gathers technical context that would take a human hours to assemble — project structure, dependencies, deployment targets, API surface, test infrastructure.
+- Identifies related work (prior issues, PRs, abandoned attempts) so the refine agent doesn't propose work that already exists or was previously rejected.
+- Surfaces architectural constraints and competitive approaches that inform decomposition decisions.
+- Assesses confidence across five dimensions, flagging specific gaps so the refine agent knows where it lacks grounding.
+
+## Commands
+
+| Command | Where | Effect |
+|---------|-------|--------|
+| `/fs-refine` | Issue comment | Triggers the refinement pipeline starting with explore |
+
+The `/fs-refine` command kicks off the full pipeline. Explore runs first, then automatically chains to [refine](refine.md). There is no separate command to run explore in isolation.
+
+For Jira issues, the pipeline can also be triggered by:
+- A Jira Automation webhook sending a `repository_dispatch` event
+- The Jira comment poller (cron job, every 5 minutes) detecting `/fs-refine` comments
+
+## Control labels
+
+The explore agent does not manage any labels. On completion, it chains directly to the [refine agent](refine.md) via `workflow_dispatch`.
+
+## Configuration and extension
+
+See [Customizing with AGENTS.md](../guides/user/customizing-with-agents-md.md) and
+[Customizing with Skills](../guides/user/customizing-with-skills.md).
+
+## Pipeline flow
+
+```
+/fs-refine → explore → refine → critique → [approved | revise | needs_input]
+```
+
+Explore produces `exploration_context.json` as a GitHub Actions artifact. The post-script chains to the refine workflow via `workflow_dispatch`, passing the run ID for artifact correlation.
+
+## Exploration dimensions
+
+The agent assesses confidence (0–100) across these dimensions:
+
+| Dimension | What it measures |
+|-----------|-----------------|
+| `technical_landscape` | Codebase, APIs, and patterns understood |
+| `related_work` | Prior issues, PRs, and discussions found |
+| `architectural_constraints` | Deployment targets, dependencies, and contracts |
+| `competitive_context` | How alternatives handle this problem |
+| `requirements_clarity` | Whether the work item is clear enough to decompose |
+
+Dimensions scoring below 60 are flagged with specific gap descriptions in the output.
+
+## Source
+
+[`internal/scaffold/fullsend-repo/harness/explore.yaml`](../../internal/scaffold/fullsend-repo/harness/explore.yaml)
diff --git a/docs/agents/icons/critique.png b/docs/agents/icons/critique.png
new file mode 100644
index 000000000..a385bb652
Binary files /dev/null and b/docs/agents/icons/critique.png differ
diff --git a/docs/agents/icons/explore.png b/docs/agents/icons/explore.png
new file mode 100644
index 000000000..e1ce009fe
Binary files /dev/null and b/docs/agents/icons/explore.png differ
diff --git a/docs/agents/icons/refine.png b/docs/agents/icons/refine.png
new file mode 100644
index 000000000..ca692e1eb
Binary files /dev/null and b/docs/agents/icons/refine.png differ
diff --git a/docs/agents/refine.md b/docs/agents/refine.md
new file mode 100644
index 000000000..d151d0dde
--- /dev/null
+++ b/docs/agents/refine.md
@@ -0,0 +1,69 @@
+# Refine Agent
+
+
+
+Takes exploration context and decomposes a work item into a structured plan of child issues — epics, stories, and tasks — with acceptance criteria, dependencies, and effort estimates.
+
+## How the agent works
+
+The refine agent receives the exploration context produced by the [explore agent](explore.md) and the original issue content. It reads the target codebase, assesses its confidence across multiple dimensions, and produces a complete hierarchical decomposition of the work item into implementable child issues.
+
+The agent runs in a read-only sandbox. It cannot modify issues, push code, or interact with external services. Its only output is a structured JSON refinement plan consumed by the downstream [critique agent](critique.md).
+
+The refine agent always produces a plan — it never halts to ask questions. When information is incomplete, it makes its best judgment, flags assumptions explicitly, and adds open questions for the critique agent to evaluate.
+
+## How it helps
+
+- Turns vague features into actionable work items with testable acceptance criteria.
+- Produces full decomposition trees (feature → epics → stories → tasks) in a single pass.
+- Names specific APIs, tools, libraries, and configuration patterns — not vague capability references.
+- Identifies cross-team dependencies and blocking relationships.
+- Reduces manual refinement time from hours of team discussion to minutes of automated analysis.
+
+## Commands
+
+| Command | Where | Effect |
+|---------|-------|--------|
+| `/fs-refine` | Issue comment | Triggers the pipeline; refine runs automatically after explore |
+
+There is no separate command to invoke refine alone. It runs as the second stage of the refinement pipeline after explore completes.
+
+## Control labels
+
+The refine agent does not add labels. On completion, it chains directly to the [critique agent](critique.md) via `workflow_dispatch`. The post-script removes `refine-needs-input` and `human-refinement` labels (if present) as cleanup from prior runs.
+
+## Configuration and extension
+
+See [Customizing with AGENTS.md](../guides/user/customizing-with-agents-md.md) and
+[Customizing with Skills](../guides/user/customizing-with-skills.md).
+
+## Pipeline flow
+
+```
+/fs-refine → explore → refine → critique → [approved | revise | needs_input]
+ ↑ |
+ └── revision feedback ─────┘
+```
+
+When the [critique agent](critique.md) returns a `revise` verdict, the pipeline re-triggers refine with the critique feedback as additional context. The refine agent must address each requested revision or explain why it chose a different approach.
+
+## Revision loop
+
+- Critique can send work back to refine with specific, actionable revisions.
+- Each revision has a type: `remove`, `merge`, `split`, `revise`, or `add`.
+- The refine agent incorporates feedback and produces an updated plan.
+- Maximum review rounds default to 3 (configurable via `MAX_REVIEW_ROUNDS`).
+- If the limit is reached without approval, the pipeline escalates to a human.
+
+## Output structure
+
+The refinement plan includes:
+- Hierarchical children using `parent_title` for tree structure
+- Confidence scores per child and per dimension
+- Open questions and uncited assumptions (for critique to evaluate)
+- A proposed enhanced feature description
+- A summary comment for the issue
+
+## Source
+
+[`internal/scaffold/fullsend-repo/harness/refine.yaml`](../../internal/scaffold/fullsend-repo/harness/refine.yaml)
diff --git a/hack/lint-agent-docs b/hack/lint-agent-docs
index 1a7e3b7a5..b88b49836 100755
--- a/hack/lint-agent-docs
+++ b/hack/lint-agent-docs
@@ -62,7 +62,9 @@ echo "Checking agent doc structure..."
echo "================================================"
REQUIRED_SECTIONS="How the agent works|How it helps|Commands|Control labels|Configuration and extension|Source"
+OPTIONAL_SECTIONS="Pipeline flow|Exploration dimensions|Revision loop|Output structure|Verdicts|Auto-create vs. human gate|Review rounds|Assessment dimensions"
IFS='|' read -ra SECTION_LIST <<< "$REQUIRED_SECTIONS"
+IFS='|' read -ra OPTIONAL_LIST <<< "$OPTIONAL_SECTIONS"
for yaml_file in "$HARNESS_DIR"/*.yaml; do
doc_value="$(grep -E '^doc:' "$yaml_file" | sed 's/^doc:[[:space:]]*//' || true)"
@@ -102,6 +104,14 @@ for yaml_file in "$HARNESS_DIR"/*.yaml; do
break
fi
done
+ if [[ "$found" == "false" ]]; then
+ for optional in "${OPTIONAL_LIST[@]}"; do
+ if [[ "$section" == "$optional" ]]; then
+ found=true
+ break
+ fi
+ done
+ fi
if [[ "$found" == "false" ]]; then
extra+=("$section")
fi
@@ -114,7 +124,7 @@ for yaml_file in "$HARNESS_DIR"/*.yaml; do
errors=$((errors + 1))
done
for s in "${extra[@]+"${extra[@]}"}"; do
- echo " unexpected: \"## $s\" — adding new sections is fine, but please add them to the REQUIRED_SECTIONS list in hack/lint-agent-docs so all agent docs stay consistent"
+ echo " unexpected: \"## $s\" — adding new sections is fine, but please add them to the REQUIRED_SECTIONS or OPTIONAL_SECTIONS list in hack/lint-agent-docs so all agent docs stay consistent"
errors=$((errors + 1))
done
else
diff --git a/internal/config/config.go b/internal/config/config.go
index 6dcf4897e..d8ba9c36a 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -91,7 +91,7 @@ type OrgConfig struct {
// ValidRoles returns the set of recognized agent roles.
func ValidRoles() []string {
- return []string{"fullsend", "triage", "coder", "review", "fix", "retro", "prioritize"}
+ return []string{"fullsend", "triage", "coder", "review", "fix", "retro", "prioritize", "explore", "refine", "critique"}
}
// ValidProviders returns the set of recognized inference providers.
@@ -103,14 +103,14 @@ func ValidProviders() []string {
// when no custom roles are specified. The fix stage reuses the coder
// app (role: coder) so it does not need a separate app or PEM.
func DefaultAgentRoles() []string {
- return []string{"fullsend", "triage", "coder", "review", "retro", "prioritize"}
+ return []string{"fullsend", "triage", "coder", "review", "retro", "prioritize", "explore", "refine", "critique"}
}
// PerRepoDefaultRoles returns agent roles for per-repo installation.
// The "fullsend" dispatch role is excluded because per-repo mode uses
// the target repo's shim workflow for dispatch instead of a separate app.
func PerRepoDefaultRoles() []string {
- return []string{"triage", "coder", "review", "fix", "retro", "prioritize"}
+ return []string{"triage", "coder", "review", "fix", "retro", "prioritize", "explore", "refine", "critique"}
}
// NewOrgConfig creates a new OrgConfig with sensible defaults.
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index a9ce98b57..fdbb4bcc6 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -10,7 +10,7 @@ import (
func TestValidRoles(t *testing.T) {
roles := ValidRoles()
- assert.Len(t, roles, 7)
+ assert.Len(t, roles, 10)
assert.Contains(t, roles, "fullsend")
assert.Contains(t, roles, "triage")
assert.Contains(t, roles, "coder")
@@ -18,17 +18,23 @@ func TestValidRoles(t *testing.T) {
assert.Contains(t, roles, "fix")
assert.Contains(t, roles, "retro")
assert.Contains(t, roles, "prioritize")
+ assert.Contains(t, roles, "explore")
+ assert.Contains(t, roles, "refine")
+ assert.Contains(t, roles, "critique")
}
func TestPerRepoDefaultRoles(t *testing.T) {
roles := PerRepoDefaultRoles()
- assert.Len(t, roles, 6)
+ assert.Len(t, roles, 9)
assert.Contains(t, roles, "triage")
assert.Contains(t, roles, "coder")
assert.Contains(t, roles, "review")
assert.Contains(t, roles, "fix")
assert.Contains(t, roles, "retro")
assert.Contains(t, roles, "prioritize")
+ assert.Contains(t, roles, "explore")
+ assert.Contains(t, roles, "refine")
+ assert.Contains(t, roles, "critique")
// "fullsend" dispatch role must be excluded in per-repo mode.
assert.NotContains(t, roles, "fullsend")
}
diff --git a/internal/forge/github/types.go b/internal/forge/github/types.go
index 6d0354935..46a3851e7 100644
--- a/internal/forge/github/types.go
+++ b/internal/forge/github/types.go
@@ -38,7 +38,7 @@ type AppConfig struct {
// DefaultAgentRoles returns the standard set of agent roles.
func DefaultAgentRoles() []string {
- return []string{"fullsend", "triage", "coder", "review", "retro", "prioritize"}
+ return []string{"fullsend", "triage", "coder", "review", "retro", "prioritize", "explore", "refine", "critique"}
}
// AgentAppConfig returns the GitHub App configuration for a given agent role.
@@ -139,6 +139,33 @@ func AgentAppConfig(org, role, appSet string) AppConfig {
// No webhook events — triggered via workflow_dispatch from other agents.
base.Events = []string{}
+ case "explore":
+ base.Description = fmt.Sprintf("Fullsend explore agent for %s", org)
+ base.Permissions = AppPermissions{
+ Actions: "write",
+ Contents: "read",
+ Issues: "write",
+ }
+ base.Events = []string{}
+
+ case "refine":
+ base.Description = fmt.Sprintf("Fullsend refine agent for %s", org)
+ base.Permissions = AppPermissions{
+ Actions: "write",
+ Contents: "read",
+ Issues: "write",
+ }
+ base.Events = []string{}
+
+ case "critique":
+ base.Description = fmt.Sprintf("Fullsend critique agent for %s", org)
+ base.Permissions = AppPermissions{
+ Actions: "write",
+ Contents: "read",
+ Issues: "write",
+ }
+ base.Events = []string{}
+
default:
base.Description = fmt.Sprintf("Fullsend %s agent for %s", role, org)
base.Permissions = AppPermissions{
diff --git a/internal/forge/github/types_test.go b/internal/forge/github/types_test.go
index 097191002..eceb370b4 100644
--- a/internal/forge/github/types_test.go
+++ b/internal/forge/github/types_test.go
@@ -10,8 +10,8 @@ import (
func TestDefaultAgentRoles(t *testing.T) {
roles := DefaultAgentRoles()
- require.Len(t, roles, 6)
- assert.Equal(t, []string{"fullsend", "triage", "coder", "review", "retro", "prioritize"}, roles)
+ require.Len(t, roles, 9)
+ assert.Equal(t, []string{"fullsend", "triage", "coder", "review", "retro", "prioritize", "explore", "refine", "critique"}, roles)
}
func TestAgentAppConfig_Fullsend(t *testing.T) {
@@ -102,6 +102,42 @@ func TestAgentAppConfig_Retro(t *testing.T) {
assert.Empty(t, cfg.Events)
}
+func TestAgentAppConfig_Explore(t *testing.T) {
+ cfg := AgentAppConfig("myorg", "explore", "fullsend")
+
+ assert.Equal(t, "fullsend-explore", cfg.Name)
+ assert.Equal(t, "write", cfg.Permissions.Actions)
+ assert.Equal(t, "read", cfg.Permissions.Contents)
+ assert.Equal(t, "write", cfg.Permissions.Issues)
+ assert.Empty(t, cfg.Permissions.PullRequests)
+
+ assert.Empty(t, cfg.Events)
+}
+
+func TestAgentAppConfig_Refine(t *testing.T) {
+ cfg := AgentAppConfig("myorg", "refine", "fullsend")
+
+ assert.Equal(t, "fullsend-refine", cfg.Name)
+ assert.Equal(t, "write", cfg.Permissions.Actions)
+ assert.Equal(t, "read", cfg.Permissions.Contents)
+ assert.Equal(t, "write", cfg.Permissions.Issues)
+ assert.Empty(t, cfg.Permissions.PullRequests)
+
+ assert.Empty(t, cfg.Events)
+}
+
+func TestAgentAppConfig_Critique(t *testing.T) {
+ cfg := AgentAppConfig("myorg", "critique", "fullsend")
+
+ assert.Equal(t, "fullsend-critique", cfg.Name)
+ assert.Equal(t, "write", cfg.Permissions.Actions)
+ assert.Equal(t, "read", cfg.Permissions.Contents)
+ assert.Equal(t, "write", cfg.Permissions.Issues)
+ assert.Empty(t, cfg.Permissions.PullRequests)
+
+ assert.Empty(t, cfg.Events)
+}
+
func TestAgentAppConfig_UnknownRole(t *testing.T) {
cfg := AgentAppConfig("myorg", "custom-bot", "fullsend")
diff --git a/internal/harness/scaffold_integration_test.go b/internal/harness/scaffold_integration_test.go
index 519355f03..4a8a5fa96 100644
--- a/internal/harness/scaffold_integration_test.go
+++ b/internal/harness/scaffold_integration_test.go
@@ -177,8 +177,8 @@ func TestDiscoverAgents_ScaffoldDirectory(t *testing.T) {
agents, err := DiscoverAgents(harnessDir)
require.NoError(t, err)
- // Expect all 6 scaffold harnesses discovered.
- require.Len(t, agents, 6, "should discover all 6 scaffold harnesses")
+ // Expect all 9 scaffold harnesses discovered.
+ require.Len(t, agents, 9, "should discover all 9 scaffold harnesses")
// Build a map of filename -> AgentInfo for easier assertion.
byFilename := make(map[string]AgentInfo, len(agents))
@@ -193,6 +193,9 @@ func TestDiscoverAgents_ScaffoldDirectory(t *testing.T) {
"retro.yaml": {"retro", "fullsend-ai-retro"},
"review.yaml": {"review", "fullsend-ai-review"},
"triage.yaml": {"triage", "fullsend-ai-triage"},
+ "explore.yaml": {"explore", "fullsend-ai-explore"},
+ "refine.yaml": {"refine", "fullsend-ai-refine"},
+ "critique.yaml": {"critique", "fullsend-ai-critique"},
}
for filename, want := range expected {
diff --git a/internal/scaffold/baseurl_test.go b/internal/scaffold/baseurl_test.go
index 7e348f929..c854e3353 100644
--- a/internal/scaffold/baseurl_test.go
+++ b/internal/scaffold/baseurl_test.go
@@ -157,7 +157,7 @@ func TestHarnessNames(t *testing.T) {
require.NoError(t, err)
t.Run("returns expected harnesses", func(t *testing.T) {
- expected := []string{"code", "fix", "prioritize", "retro", "review", "triage"}
+ expected := []string{"code", "critique", "explore", "fix", "prioritize", "refine", "retro", "review", "triage"}
assert.Equal(t, expected, names)
})
diff --git a/internal/scaffold/fullsend-repo/.github/workflows/create-children.yml b/internal/scaffold/fullsend-repo/.github/workflows/create-children.yml
new file mode 100644
index 000000000..9bf4c0880
--- /dev/null
+++ b/internal/scaffold/fullsend-repo/.github/workflows/create-children.yml
@@ -0,0 +1,60 @@
+---
+# fullsend-stage: create-children
+name: Create Children
+
+permissions:
+ actions: read
+ contents: read
+ id-token: write
+ issues: write
+
+on:
+ workflow_dispatch:
+ inputs:
+ issue_key:
+ required: true
+ type: string
+ issue_source:
+ required: false
+ type: string
+ default: "github"
+ refine_run_id:
+ required: true
+ type: string
+ event_type:
+ required: false
+ type: string
+ default: "workflow_dispatch"
+ source_repo:
+ required: false
+ type: string
+ default: ""
+ event_payload:
+ required: false
+ type: string
+ default: "{}"
+
+concurrency:
+ group: fullsend-create-children-${{ inputs.issue_key || 'unknown' }}
+ cancel-in-progress: false
+
+jobs:
+ create-children:
+ uses: __REUSABLE_WORKFLOW__
+ with:
+ event_type: ${{ inputs.event_type || 'workflow_dispatch' }}
+ source_repo: ${{ inputs.source_repo || github.repository }}
+ event_payload: ${{ inputs.event_payload || '{}' }}
+ mint_url: ${{ vars.FULLSEND_MINT_URL }}
+ gcp_region: ${{ vars.FULLSEND_GCP_REGION }}
+ install_mode: per-org
+ fullsend_ai_ref: v0
+ issue_key: ${{ inputs.issue_key }}
+ issue_source: ${{ inputs.issue_source }}
+ refine_run_id: ${{ inputs.refine_run_id }}
+ secrets:
+ FULLSEND_GCP_WIF_PROVIDER: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }}
+ FULLSEND_GCP_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }}
+ JIRA_HOST: ${{ secrets.JIRA_HOST }}
+ JIRA_EMAIL: ${{ secrets.JIRA_EMAIL }}
+ JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }}
diff --git a/internal/scaffold/fullsend-repo/.github/workflows/critique.yml b/internal/scaffold/fullsend-repo/.github/workflows/critique.yml
new file mode 100644
index 000000000..dd758183c
--- /dev/null
+++ b/internal/scaffold/fullsend-repo/.github/workflows/critique.yml
@@ -0,0 +1,64 @@
+---
+# fullsend-stage: critique
+name: Critique
+
+permissions:
+ actions: write
+ contents: read
+ id-token: write
+ issues: write
+
+on:
+ workflow_dispatch:
+ inputs:
+ issue_key:
+ required: true
+ type: string
+ issue_source:
+ required: false
+ type: string
+ default: "github"
+ refine_run_id:
+ required: true
+ type: string
+ review_round:
+ required: false
+ type: string
+ default: "1"
+ max_review_rounds:
+ required: false
+ type: string
+ default: "3"
+ auto_create:
+ required: false
+ type: string
+ default: "false"
+
+concurrency:
+ group: fullsend-critique-${{ inputs.issue_key || 'unknown' }}
+ cancel-in-progress: false
+
+jobs:
+ critique:
+ uses: __REUSABLE_WORKFLOW__
+ with:
+ event_type: workflow_dispatch
+ source_repo: ${{ github.repository }}
+ event_payload: "{}"
+ mint_url: ${{ vars.FULLSEND_MINT_URL }}
+ gcp_region: ${{ vars.FULLSEND_GCP_REGION }}
+ install_mode: per-org
+ fullsend_ai_ref: v0
+ issue_key: ${{ inputs.issue_key }}
+ issue_source: ${{ inputs.issue_source }}
+ refine_run_id: ${{ inputs.refine_run_id }}
+ review_round: ${{ inputs.review_round }}
+ max_review_rounds: ${{ inputs.max_review_rounds }}
+ auto_create: ${{ inputs.auto_create }}
+ jira_project_visibility: ${{ vars.JIRA_PROJECT_VISIBILITY || 'private' }}
+ secrets:
+ FULLSEND_GCP_WIF_PROVIDER: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }}
+ FULLSEND_GCP_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }}
+ JIRA_HOST: ${{ secrets.JIRA_HOST }}
+ JIRA_EMAIL: ${{ secrets.JIRA_EMAIL }}
+ JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }}
diff --git a/internal/scaffold/fullsend-repo/.github/workflows/explore.yml b/internal/scaffold/fullsend-repo/.github/workflows/explore.yml
new file mode 100644
index 000000000..cf26ed005
--- /dev/null
+++ b/internal/scaffold/fullsend-repo/.github/workflows/explore.yml
@@ -0,0 +1,65 @@
+---
+# fullsend-stage: explore
+name: Explore
+
+permissions:
+ actions: write
+ contents: read
+ id-token: write
+ issues: write
+
+on:
+ workflow_dispatch:
+ inputs:
+ issue_key:
+ required: true
+ type: string
+ issue_source:
+ required: false
+ type: string
+ default: "github"
+ event_type:
+ required: false
+ type: string
+ default: "workflow_dispatch"
+ source_repo:
+ required: false
+ type: string
+ default: ""
+ event_payload:
+ required: false
+ type: string
+ default: "{}"
+ auto_create:
+ required: false
+ type: string
+ default: "false"
+ repo_full_name:
+ required: false
+ type: string
+ default: ""
+
+concurrency:
+ group: fullsend-explore-${{ inputs.issue_key || 'unknown' }}
+ cancel-in-progress: true
+
+jobs:
+ explore:
+ uses: __REUSABLE_WORKFLOW__
+ with:
+ event_type: ${{ inputs.event_type || 'workflow_dispatch' }}
+ source_repo: ${{ inputs.source_repo || github.repository }}
+ event_payload: ${{ inputs.event_payload || '{}' }}
+ mint_url: ${{ vars.FULLSEND_MINT_URL }}
+ gcp_region: ${{ vars.FULLSEND_GCP_REGION }}
+ install_mode: per-org
+ fullsend_ai_ref: v0
+ issue_key: ${{ inputs.issue_key }}
+ issue_source: ${{ inputs.issue_source }}
+ jira_project_visibility: ${{ vars.JIRA_PROJECT_VISIBILITY || 'private' }}
+ secrets:
+ FULLSEND_GCP_WIF_PROVIDER: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }}
+ FULLSEND_GCP_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }}
+ JIRA_HOST: ${{ secrets.JIRA_HOST }}
+ JIRA_EMAIL: ${{ secrets.JIRA_EMAIL }}
+ JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }}
diff --git a/internal/scaffold/fullsend-repo/.github/workflows/jira-comment-poller.yml b/internal/scaffold/fullsend-repo/.github/workflows/jira-comment-poller.yml
new file mode 100644
index 000000000..40b1c752c
--- /dev/null
+++ b/internal/scaffold/fullsend-repo/.github/workflows/jira-comment-poller.yml
@@ -0,0 +1,149 @@
+---
+# fullsend-stage: jira-comment-poller
+name: jira-comment-poller
+
+permissions:
+ actions: write
+ contents: read
+
+on:
+ schedule:
+ - cron: '*/5 * * * *'
+ workflow_dispatch:
+
+concurrency:
+ group: jira-comment-poller
+ cancel-in-progress: true
+
+jobs:
+ poll:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Poll Jira for /fs-* commands
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ JIRA_HOST: ${{ secrets.JIRA_HOST }}
+ JIRA_EMAIL: ${{ secrets.JIRA_EMAIL }}
+ JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }}
+ REPO: ${{ github.repository }}
+ JIRA_PROJECT_VISIBILITY: ${{ vars.JIRA_PROJECT_VISIBILITY }}
+ run: |
+ set -euo pipefail
+
+ if [[ -z "${JIRA_HOST:-}" || -z "${JIRA_EMAIL:-}" || -z "${JIRA_API_TOKEN:-}" ]]; then
+ echo "::error::JIRA_HOST, JIRA_EMAIL, and JIRA_API_TOKEN secrets are required"
+ exit 1
+ fi
+
+ # Block private Jira on public repos to prevent data leakage.
+ # Public Jira projects on public repos are fine — set JIRA_PROJECT_VISIBILITY=public.
+ JIRA_VIS="${JIRA_PROJECT_VISIBILITY:-private}"
+ if [[ "$JIRA_VIS" != "public" ]]; then
+ REPO_VISIBILITY=$(gh api "repos/${REPO}" --jq '.visibility' 2>/dev/null || echo "public")
+ if [[ "$REPO_VISIBILITY" == "public" ]]; then
+ echo "::error::SECURITY: Private Jira poller blocked — repo ${REPO} is public."
+ echo "::error::Private Jira data would leak into public artifacts/logs."
+ echo "::error::Use a private config repo, or set JIRA_PROJECT_VISIBILITY=public for public Jira projects."
+ exit 1
+ fi
+ fi
+
+ AUTH=$(printf '%s:%s' "$JIRA_EMAIL" "$JIRA_API_TOKEN" | base64 -w0)
+ ACK_MARKER="⚡ fullsend"
+
+ jira_api() {
+ local method="$1" endpoint="$2"
+ shift 2
+ curl -sSf -X "$method" \
+ -H "Authorization: Basic $AUTH" \
+ -H "Content-Type: application/json" \
+ -H "Accept: application/json" \
+ "https://${JIRA_HOST}${endpoint}" "$@"
+ }
+
+ echo "Searching for fullsend-labeled issues..."
+ ISSUES=$(jira_api POST '/rest/api/3/search/jql' \
+ -d '{"jql":"labels = fullsend AND updated >= -1d","fields":["key","summary"],"maxResults":50}')
+ ISSUE_COUNT=$(echo "$ISSUES" | jq '.issues | length')
+ echo "Found $ISSUE_COUNT issue(s)"
+
+ if [[ "$ISSUE_COUNT" == "0" ]]; then
+ echo "No issues to process"
+ exit 0
+ fi
+
+ DISPATCHED=0
+
+ while read -r ISSUE_KEY; do
+ echo ""
+ echo "--- Checking $ISSUE_KEY ---"
+
+ COMMENTS=$(jira_api GET "/rest/api/3/issue/${ISSUE_KEY}/comment?orderBy=-created&maxResults=20")
+
+ while read -r COMMENT_B64; do
+ COMMENT_ID=$(echo "$COMMENT_B64" | base64 -d | jq -r '.id')
+ AUTHOR=$(echo "$COMMENT_B64" | base64 -d | jq -r '.author.displayName // .author.emailAddress // "unknown"')
+ CREATED=$(echo "$COMMENT_B64" | base64 -d | jq -r '.created')
+
+ BODY=$(echo "$COMMENT_B64" | base64 -d | jq -r '
+ [.body.content[]? | .. | .text? // empty] | join(" ")
+ ')
+
+ if printf '%s' "$BODY" | grep -qF "$ACK_MARKER"; then
+ continue
+ fi
+
+ if [[ "$AUTHOR" == "Fullsend Refinement Squad" ]]; then
+ continue
+ fi
+
+ if ! printf '%s' "$BODY" | grep -qP '(?:^|\s)/fs-\S+'; then
+ continue
+ fi
+
+ echo " Comment $COMMENT_ID by $AUTHOR at $CREATED"
+ echo " Body: $BODY"
+
+ ALREADY_ACKED=$(echo "$COMMENTS" | jq -r --arg cid "$COMMENT_ID" --arg marker "$ACK_MARKER" '
+ [.comments[] |
+ select(.id != $cid) |
+ select(
+ [.body.content[]? | .. | .text? // empty] | join(" ") |
+ contains($marker) and contains($cid)
+ )
+ ] | length
+ ')
+
+ if [[ "$ALREADY_ACKED" != "0" ]]; then
+ echo " → Already processed (ack found), skipping"
+ continue
+ fi
+
+ COMMAND_LINE=$(printf '%s' "$BODY" | grep -oP '(?:^|\s)\K/fs-\w+(?:\s+--\w[\w-]*(?:\s+\S+))*' | head -1)
+ echo " → Dispatching: $COMMAND_LINE"
+
+ gh workflow run jira-dispatch.yml \
+ --repo "$REPO" \
+ -f jira_key="$ISSUE_KEY" \
+ -f command="$COMMAND_LINE" || {
+ echo " → ERROR: dispatch failed"
+ continue
+ }
+
+ ACK_TEXT="${ACK_MARKER} dispatched ${COMMAND_LINE} → GitHub Actions (comment:${COMMENT_ID})"
+ ACK_BODY=$(jq -n --arg text "$ACK_TEXT" '{
+ body: {type:"doc",version:1,content:[{
+ type:"paragraph",
+ content:[{type:"text",text:$text,marks:[{type:"strong"}]}]
+ }]}
+ }')
+
+ jira_api POST "/rest/api/3/issue/${ISSUE_KEY}/comment" -d "$ACK_BODY" > /dev/null
+
+ echo " → Dispatched and acknowledged"
+ DISPATCHED=$((DISPATCHED + 1))
+ done < <(echo "$COMMENTS" | jq -r '.comments[] | @base64')
+ done < <(echo "$ISSUES" | jq -r '.issues[].key')
+
+ echo ""
+ echo "=== Polling complete ==="
diff --git a/internal/scaffold/fullsend-repo/.github/workflows/jira-dispatch.yml b/internal/scaffold/fullsend-repo/.github/workflows/jira-dispatch.yml
new file mode 100644
index 000000000..d2a4ac3cb
--- /dev/null
+++ b/internal/scaffold/fullsend-repo/.github/workflows/jira-dispatch.yml
@@ -0,0 +1,191 @@
+---
+# fullsend-stage: jira-dispatch
+name: jira-dispatch
+
+permissions:
+ actions: write
+ contents: read
+ id-token: write
+ issues: write
+
+on:
+ workflow_dispatch:
+ inputs:
+ jira_key:
+ description: 'Jira issue key (e.g., SECURESIGN-1251)'
+ required: true
+ type: string
+ command:
+ description: 'Slash command to execute (e.g., /fs-refine, /fs-create)'
+ required: true
+ type: string
+ default: '/fs-refine'
+ repository_dispatch:
+ types: [jira-command, jira-refine]
+
+concurrency:
+ group: jira-dispatch-${{ inputs.jira_key || github.event.client_payload.jira_key || 'unknown' }}
+ cancel-in-progress: false
+
+jobs:
+ dispatch:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Parse command and dispatch
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ JIRA_KEY: ${{ inputs.jira_key || github.event.client_payload.jira_key }}
+ RAW_COMMAND: ${{ inputs.command || github.event.client_payload.command || '' }}
+ EVENT_TYPE: ${{ github.event.action || 'workflow_dispatch' }}
+ REPO: ${{ github.repository }}
+ JIRA_PROJECT_VISIBILITY: ${{ vars.JIRA_PROJECT_VISIBILITY }}
+ run: |
+ set -euo pipefail
+
+ if [[ -z "$JIRA_KEY" ]]; then
+ echo "::error::jira_key is required"
+ exit 1
+ fi
+
+ if [[ ! "$JIRA_KEY" =~ ^[A-Z][A-Z0-9]+-[0-9]+$ ]]; then
+ echo "::error::jira_key must match PROJECT-123 pattern, got: $JIRA_KEY"
+ exit 1
+ fi
+
+ # Block private Jira on public repos to prevent data leakage.
+ # Public Jira projects on public repos are fine — set JIRA_PROJECT_VISIBILITY=public.
+ JIRA_VIS="${JIRA_PROJECT_VISIBILITY:-private}"
+ if [[ "$JIRA_VIS" != "public" ]]; then
+ REPO_VISIBILITY=$(gh api "repos/${REPO}" --jq '.visibility' 2>/dev/null || echo "public")
+ if [[ "$REPO_VISIBILITY" == "public" ]]; then
+ echo "::error::SECURITY: Private Jira dispatch blocked — repo ${REPO} is public."
+ echo "::error::Private Jira data would leak into public artifacts/logs."
+ echo "::error::Use a private config repo, or set JIRA_PROJECT_VISIBILITY=public for public Jira projects."
+ exit 1
+ fi
+ fi
+
+ if [[ "$EVENT_TYPE" == "jira-refine" && -z "$RAW_COMMAND" ]]; then
+ RAW_COMMAND="/fs-refine"
+ fi
+
+ COMMAND="$(printf '%s\n' "$RAW_COMMAND" | head -1 | awk '{print $1}')"
+
+ if [[ -z "$COMMAND" ]]; then
+ echo "::error::No command found in payload"
+ exit 1
+ fi
+
+ STOP_TOKEN=$(uuidgen)
+ echo "::stop-commands::${STOP_TOKEN}"
+ echo "Jira dispatch:"
+ echo " Jira key: ${JIRA_KEY}"
+ echo " Command: ${COMMAND}"
+ echo " Full command: ${RAW_COMMAND}"
+ echo " Event type: ${EVENT_TYPE}"
+ echo "::${STOP_TOKEN}::"
+
+ case "$COMMAND" in
+
+ /fs-refine)
+ SKIP_EXPLORE=false
+ CONTEXT_REF=""
+ AUTO_CREATE="false"
+ TARGET_REPO=""
+
+ if printf '%s\n' "$RAW_COMMAND" | grep -qw -- '--skip-explore'; then
+ SKIP_EXPLORE=true
+ fi
+ if printf '%s\n' "$RAW_COMMAND" | grep -qw -- '--auto-create'; then
+ AUTO_CREATE="true"
+ fi
+ CONTEXT_REF=$(printf '%s\n' "$RAW_COMMAND" | grep -oP '(?<=--context\s)\S+' || true)
+ TARGET_REPO=$(printf '%s\n' "$RAW_COMMAND" | grep -oP '(?<=--repo\s)\S+' || true)
+
+ STOP_TOKEN2=$(uuidgen)
+ echo "::stop-commands::${STOP_TOKEN2}"
+ echo " Skip explore: ${SKIP_EXPLORE}"
+ echo " Auto-create: ${AUTO_CREATE}"
+ echo " Context ref: ${CONTEXT_REF:-none}"
+ echo " Target repo: ${TARGET_REPO:-none (web research only)}"
+ echo "::${STOP_TOKEN2}::"
+
+ DISPATCH_ARGS=(
+ -f issue_key="$JIRA_KEY"
+ -f issue_source="jira"
+ -f auto_create="$AUTO_CREATE"
+ )
+
+ if [[ -n "$TARGET_REPO" ]]; then
+ DISPATCH_ARGS+=(-f repo_full_name="$TARGET_REPO")
+ fi
+
+ if [[ "$SKIP_EXPLORE" == "true" || -n "$CONTEXT_REF" ]]; then
+ if [[ -n "$CONTEXT_REF" ]]; then
+ DISPATCH_ARGS+=(-f explore_context_ref="$CONTEXT_REF")
+ fi
+ gh workflow run refine.yml \
+ --repo "$REPO" \
+ "${DISPATCH_ARGS[@]}"
+
+ echo "::notice::Refine dispatched directly for ${JIRA_KEY} (skip explore)"
+ else
+ gh workflow run explore.yml \
+ --repo "$REPO" \
+ "${DISPATCH_ARGS[@]}"
+
+ echo "::notice::Explore dispatched for ${JIRA_KEY}${TARGET_REPO:+ (target: $TARGET_REPO)}"
+ fi
+ ;;
+
+ /fs-create)
+ echo "Dispatching child issue creation for ${JIRA_KEY}"
+
+ EXISTING_CREATE=$(gh run list --repo "$REPO" --workflow "create-children" \
+ --limit 10 --json databaseId,status,conclusion,displayTitle \
+ --jq "[.[] | select(.status == \"completed\" and .conclusion == \"success\" and (.displayTitle | contains(\"$JIRA_KEY\")))] | .[0].databaseId // empty" \
+ 2>/dev/null || true)
+
+ if [[ -n "$EXISTING_CREATE" ]]; then
+ echo "::error::Child issues were already created for ${JIRA_KEY} (run: ${EXISTING_CREATE}). To recreate, run /fs-refine first."
+ exit 1
+ fi
+
+ REFINE_RUN_ID=$(gh run list --repo "$REPO" --workflow "fullsend-refine" \
+ --limit 20 --json databaseId,status,conclusion,displayTitle \
+ --jq "[.[] | select(.status == \"completed\" and .conclusion == \"success\" and (.displayTitle | contains(\"$JIRA_KEY\")))] | .[0].databaseId // empty" \
+ 2>/dev/null || true)
+
+ if [[ -z "$REFINE_RUN_ID" ]]; then
+ echo "::error::No successful refine run found to create issues from"
+ exit 1
+ fi
+
+ CRITIQUE_RUN_ID=$(gh run list --repo "$REPO" --workflow "fullsend-critique" \
+ --limit 10 --json databaseId,displayTitle \
+ --jq "[.[] | select(.displayTitle | contains(\"$JIRA_KEY\"))] | .[0].databaseId // empty" \
+ 2>/dev/null || true)
+
+ DISPATCH_ARGS=(
+ --repo "$REPO"
+ -f issue_key="$JIRA_KEY"
+ -f issue_source="jira"
+ -f repo_full_name="$REPO"
+ -f refine_run_id="$REFINE_RUN_ID"
+ )
+
+ if [[ -n "$CRITIQUE_RUN_ID" ]]; then
+ DISPATCH_ARGS+=(-f critique_run_id="$CRITIQUE_RUN_ID")
+ echo "Found critique run for ${JIRA_KEY}: $CRITIQUE_RUN_ID"
+ fi
+
+ gh workflow run create-children.yml "${DISPATCH_ARGS[@]}"
+
+ echo "::notice::Child issue creation dispatched for ${JIRA_KEY} (refine run: ${REFINE_RUN_ID}${CRITIQUE_RUN_ID:+, critique run: $CRITIQUE_RUN_ID})"
+ ;;
+
+ *)
+ echo "::error::Unknown command '${COMMAND}'. Supported: /fs-refine, /fs-create"
+ exit 1
+ ;;
+ esac
diff --git a/internal/scaffold/fullsend-repo/.github/workflows/refine-dispatch.yml b/internal/scaffold/fullsend-repo/.github/workflows/refine-dispatch.yml
new file mode 100644
index 000000000..b6d35a427
--- /dev/null
+++ b/internal/scaffold/fullsend-repo/.github/workflows/refine-dispatch.yml
@@ -0,0 +1,203 @@
+---
+# fullsend-stage: refine-dispatch
+name: refine-dispatch
+
+permissions:
+ actions: write
+ contents: read
+ issues: write
+
+on:
+ issue_comment:
+ types: [created]
+
+jobs:
+ dispatch:
+ if: >-
+ github.event.comment.user.type != 'Bot'
+ && (
+ github.event.comment.author_association == 'OWNER'
+ || github.event.comment.author_association == 'MEMBER'
+ || github.event.comment.author_association == 'COLLABORATOR'
+ || github.event.comment.user.login == github.event.issue.user.login
+ )
+ && (
+ startsWith(github.event.comment.body, '/fs-refine')
+ || startsWith(github.event.comment.body, '/fs-create')
+ || (
+ contains(join(github.event.issue.labels.*.name, ','), 'refine-needs-input')
+ )
+ || (
+ contains(join(github.event.issue.labels.*.name, ','), 'human-refinement')
+ )
+ )
+ runs-on: ubuntu-latest
+ steps:
+ - name: Parse command and dispatch
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ COMMENT_BODY: ${{ github.event.comment.body }}
+ ISSUE_NUMBER: ${{ github.event.issue.number }}
+ REPO: ${{ github.repository }}
+ LABELS: ${{ join(github.event.issue.labels.*.name, ',') }}
+ run: |
+ set -euo pipefail
+
+ COMMAND="$(printf '%s\n' "${COMMENT_BODY}" | head -1 | awk '{print $1}')"
+ IS_REFINE_CMD=false
+ IS_CREATE_CMD=false
+ IS_FOLLOWUP=false
+
+ if [[ "$COMMAND" == "/fs-refine" ]]; then
+ IS_REFINE_CMD=true
+ elif [[ "$COMMAND" == "/fs-create" ]]; then
+ IS_CREATE_CMD=true
+ elif echo "$LABELS" | grep -qF "refine-needs-input" || echo "$LABELS" | grep -qF "human-refinement"; then
+ IS_FOLLOWUP=true
+ fi
+
+ if [[ "$IS_REFINE_CMD" == "false" && "$IS_CREATE_CMD" == "false" && "$IS_FOLLOWUP" == "false" ]]; then
+ echo "Not a refine/create trigger — skipping"
+ exit 0
+ fi
+
+ if [[ "$IS_CREATE_CMD" == "true" ]]; then
+ echo "Dispatching child issue creation for #${ISSUE_NUMBER}"
+
+ if ! echo "$LABELS" | grep -qF "refine-approved"; then
+ gh api "repos/${REPO}/issues/comments/${{ github.event.comment.id }}/reactions" \
+ -f content="confused" --silent 2>/dev/null || true
+ echo "::error::Cannot create children — the plan has not been approved by the critique agent (missing 'refine-approved' label)"
+ exit 1
+ fi
+
+ REFINE_RUN_ID=$(gh run list --repo "$REPO" --workflow "fullsend-refine" \
+ --limit 20 --json databaseId,status,conclusion,displayTitle \
+ --jq "[.[] | select(.status == \"completed\" and .conclusion == \"success\" and (.displayTitle | endswith(\" · ${ISSUE_NUMBER}\")))] | .[0].databaseId // empty" \
+ 2>/dev/null || true)
+
+ if [[ -z "$REFINE_RUN_ID" ]]; then
+ gh api "repos/${REPO}/issues/comments/${{ github.event.comment.id }}/reactions" \
+ -f content="confused" --silent 2>/dev/null || true
+ echo "::error::No successful refine run found for issue #${ISSUE_NUMBER}"
+ exit 1
+ fi
+
+ CRITIQUE_RUN_ID=$(gh run list --repo "$REPO" --workflow "fullsend-critique" \
+ --limit 10 --json databaseId,displayTitle \
+ --jq "[.[] | select(.displayTitle | endswith(\" · ${ISSUE_NUMBER}\"))] | .[0].databaseId // empty" \
+ 2>/dev/null || true)
+
+ DISPATCH_ARGS=(
+ --repo "$REPO"
+ -f issue_key="$ISSUE_NUMBER"
+ -f issue_source="github"
+ -f repo_full_name="$REPO"
+ -f refine_run_id="$REFINE_RUN_ID"
+ -f github_issue_number="$ISSUE_NUMBER"
+ )
+
+ if [[ -n "$CRITIQUE_RUN_ID" ]]; then
+ DISPATCH_ARGS+=(-f critique_run_id="$CRITIQUE_RUN_ID")
+ echo "Found critique run for issue #${ISSUE_NUMBER}: $CRITIQUE_RUN_ID"
+ fi
+
+ gh workflow run create-children.yml "${DISPATCH_ARGS[@]}"
+
+ ENCODED=$(printf '%s' "refine-approved" | jq -sRr @uri)
+ gh api "repos/${REPO}/issues/${ISSUE_NUMBER}/labels/${ENCODED}" \
+ -X DELETE --silent 2>/dev/null || true
+
+ gh api "repos/${REPO}/issues/comments/${{ github.event.comment.id }}/reactions" \
+ -f content="rocket" --silent 2>/dev/null || true
+
+ echo "::notice::Child issue creation dispatched from approved plan"
+ exit 0
+ fi
+
+ SKIP_EXPLORE=false
+ CONTEXT_REF=""
+ AUTO_CREATE="false"
+
+ if [[ "$IS_REFINE_CMD" == "true" ]]; then
+ if printf '%s\n' "${COMMENT_BODY}" | grep -qw -- '--skip-explore'; then
+ SKIP_EXPLORE=true
+ fi
+ if printf '%s\n' "${COMMENT_BODY}" | grep -qw -- '--auto-create'; then
+ AUTO_CREATE="true"
+ fi
+ CONTEXT_REF=$(printf '%s\n' "${COMMENT_BODY}" | grep -oP '(?<=--context\s)\S+' || true)
+ fi
+
+ STOP_TOKEN=$(uuidgen)
+ echo "::stop-commands::${STOP_TOKEN}"
+ echo "Dispatching GitHub issue refinement:"
+ echo " Issue: #${ISSUE_NUMBER}"
+ echo " Skip explore: ${SKIP_EXPLORE}"
+ echo " Context ref: ${CONTEXT_REF:-none}"
+ echo " Auto-create: ${AUTO_CREATE}"
+ echo " Follow-up: ${IS_FOLLOWUP}"
+ echo "::${STOP_TOKEN}::"
+
+ if [[ "$IS_FOLLOWUP" == "true" ]]; then
+ EXPLORE_RUN_ID=$(gh run list --repo "$REPO" --workflow "fullsend-explore" \
+ --limit 10 --json databaseId,status,conclusion \
+ --jq '[.[] | select(.status == "completed" and .conclusion == "success")] | .[0].databaseId // empty' \
+ 2>/dev/null || true)
+
+ FOLLOWUP_ARGS=(
+ --repo "$REPO"
+ -f issue_key="$ISSUE_NUMBER"
+ -f issue_source="github"
+ -f repo_full_name="$REPO"
+ -f github_issue_number="$ISSUE_NUMBER"
+ )
+ if [[ -n "$EXPLORE_RUN_ID" ]]; then
+ FOLLOWUP_ARGS+=(-f explore_run_id="$EXPLORE_RUN_ID")
+ fi
+
+ gh workflow run refine.yml "${FOLLOWUP_ARGS[@]}"
+
+ gh api "repos/${REPO}/issues/${ISSUE_NUMBER}/labels/refine-needs-input" \
+ -X DELETE --silent 2>/dev/null || true
+ ENCODED_HR=$(printf '%s' "human-refinement" | jq -sRr @uri)
+ gh api "repos/${REPO}/issues/${ISSUE_NUMBER}/labels/${ENCODED_HR}" \
+ -X DELETE --silent 2>/dev/null || true
+
+ echo "::notice::Refine re-triggered (follow-up response, explore_run_id=${EXPLORE_RUN_ID:-none})"
+
+ elif [[ "$SKIP_EXPLORE" == "true" || -n "$CONTEXT_REF" ]]; then
+ REFINE_ARGS=(
+ --repo "$REPO"
+ -f issue_key="$ISSUE_NUMBER"
+ -f issue_source="github"
+ -f repo_full_name="$REPO"
+ -f github_issue_number="$ISSUE_NUMBER"
+ -f explore_context_ref="$CONTEXT_REF"
+ -f auto_create="$AUTO_CREATE"
+ )
+
+ gh workflow run refine.yml "${REFINE_ARGS[@]}"
+
+ echo "::notice::Refine dispatched directly (skip explore)"
+
+ gh api "repos/${REPO}/issues/comments/${{ github.event.comment.id }}/reactions" \
+ -f content="rocket" --silent 2>/dev/null || true
+
+ else
+ EXPLORE_ARGS=(
+ --repo "$REPO"
+ -f issue_key="$ISSUE_NUMBER"
+ -f issue_source="github"
+ -f repo_full_name="$REPO"
+ -f github_issue_number="$ISSUE_NUMBER"
+ -f auto_create="$AUTO_CREATE"
+ )
+
+ gh workflow run explore.yml "${EXPLORE_ARGS[@]}"
+
+ echo "::notice::Explore stage dispatched for GitHub issue #${ISSUE_NUMBER}"
+
+ gh api "repos/${REPO}/issues/comments/${{ github.event.comment.id }}/reactions" \
+ -f content="rocket" --silent 2>/dev/null || true
+ fi
diff --git a/internal/scaffold/fullsend-repo/.github/workflows/refine.yml b/internal/scaffold/fullsend-repo/.github/workflows/refine.yml
new file mode 100644
index 000000000..f912d0dcf
--- /dev/null
+++ b/internal/scaffold/fullsend-repo/.github/workflows/refine.yml
@@ -0,0 +1,91 @@
+---
+# fullsend-stage: refine
+name: Refine
+
+permissions:
+ actions: write
+ contents: read
+ id-token: write
+ issues: write
+
+on:
+ workflow_dispatch:
+ inputs:
+ issue_key:
+ required: true
+ type: string
+ issue_source:
+ required: false
+ type: string
+ default: "github"
+ explore_run_id:
+ required: false
+ type: string
+ default: ""
+ explore_context_ref:
+ required: false
+ type: string
+ default: ""
+ review_round:
+ required: false
+ type: string
+ default: "1"
+ max_review_rounds:
+ required: false
+ type: string
+ default: "3"
+ auto_create:
+ required: false
+ type: string
+ default: "false"
+ critique_run_id:
+ required: false
+ type: string
+ default: ""
+ repo_full_name:
+ required: false
+ type: string
+ default: ""
+ event_type:
+ required: false
+ type: string
+ default: "workflow_dispatch"
+ source_repo:
+ required: false
+ type: string
+ default: ""
+ event_payload:
+ required: false
+ type: string
+ default: "{}"
+
+concurrency:
+ group: fullsend-refine-${{ inputs.issue_key || 'unknown' }}
+ cancel-in-progress: false
+
+jobs:
+ refine:
+ uses: __REUSABLE_WORKFLOW__
+ with:
+ event_type: ${{ inputs.event_type || 'workflow_dispatch' }}
+ source_repo: ${{ inputs.source_repo || github.repository }}
+ event_payload: ${{ inputs.event_payload || '{}' }}
+ mint_url: ${{ vars.FULLSEND_MINT_URL }}
+ gcp_region: ${{ vars.FULLSEND_GCP_REGION }}
+ install_mode: per-org
+ fullsend_ai_ref: v0
+ issue_key: ${{ inputs.issue_key }}
+ issue_source: ${{ inputs.issue_source }}
+ explore_run_id: ${{ inputs.explore_run_id }}
+ explore_context_ref: ${{ inputs.explore_context_ref }}
+ review_round: ${{ inputs.review_round }}
+ max_review_rounds: ${{ inputs.max_review_rounds }}
+ auto_create: ${{ inputs.auto_create }}
+ critique_run_id: ${{ inputs.critique_run_id }}
+ jira_project_visibility: ${{ vars.JIRA_PROJECT_VISIBILITY || 'private' }}
+ secrets:
+ FULLSEND_GCP_WIF_PROVIDER: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }}
+ FULLSEND_GCP_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }}
+ JIRA_HOST: ${{ secrets.JIRA_HOST }}
+ JIRA_EMAIL: ${{ secrets.JIRA_EMAIL }}
+ JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }}
diff --git a/internal/scaffold/fullsend-repo/agents/critique.md b/internal/scaffold/fullsend-repo/agents/critique.md
new file mode 100644
index 000000000..c7769c924
--- /dev/null
+++ b/internal/scaffold/fullsend-repo/agents/critique.md
@@ -0,0 +1,404 @@
+---
+name: critique
+description: >-
+ Adversarial reviewer and quality gate for refinement plans. Evaluates a
+ proposed decomposition from the refine agent and decides: approve (ready
+ for issue creation), revise (send back to refine), or needs_input (escalate
+ to a human for clarification). Nothing gets created until this agent approves.
+tools: Bash(gh,jq,python3,find,ls,cat,head,grep,wc,tree)
+model: opus
+disallowedTools: >-
+ Bash(git push *), Bash(git push),
+ Bash(gh issue create *), Bash(gh issue edit *), Bash(gh issue comment *),
+ Bash(gh pr create *), Bash(gh pr edit *), Bash(gh pr merge *),
+ Bash(gh api *POST*), Bash(gh api *DELETE*), Bash(gh api *PATCH*)
+---
+
+# Critique Agent
+
+You are an adversarial reviewer for refinement plans. Your purpose is to
+evaluate a proposed decomposition from the refine agent and decide:
+
+1. **Approve** — the plan is ready for child issue creation
+2. **Revise** — the plan has fixable problems; send it back to refine
+3. **Needs Input** — the plan has gaps that only a human can resolve
+
+You are the quality gate. Nothing gets created until you approve it.
+
+## Why you exist
+
+Automated decomposition without review leads to:
+1. Over-decomposition — 15 issues when 6 would suffice
+2. Vague children that restate the parent without adding specificity
+3. Missing coverage — entire dimensions of the feature forgotten
+4. Dependency cycles or impossible ordering
+5. Scope creep — children that exceed what the parent asked for
+
+You catch these before issues flood the backlog.
+
+## Behavioral properties (HARD CONSTRAINTS)
+
+1. **Be specific** — "Epic 4 should be removed" not "consider reducing scope."
+ Every revision must name exact children and explain what to do.
+2. **Be constructive** — a rejection without actionable guidance wastes a cycle.
+ Every `revise` verdict must include concrete revisions.
+3. **Approve when good enough** — perfection is the enemy of progress. If the
+ plan covers the requirements and children are implementable, approve it.
+ Do not nitpick formatting or style preferences.
+4. **Respect the iteration budget** — you may be on round 2 or 3 of review.
+ Check `REVIEW_ROUND` and prior feedback. If this is a re-review after
+ revisions, focus on whether the specific revisions were addressed.
+5. **Never invent work** — you review what the refine agent proposed. You
+ may suggest adding a missing child, but you do not design it in detail.
+ That is the refine agent's job.
+
+## The three failure modes to avoid
+
+1. **Rubber Stamp** — approving without meaningful evaluation. If you approve,
+ your assessment scores and reasoning must demonstrate you actually checked
+ coverage, granularity, dependencies, and implementability.
+
+2. **Infinite Loop** — requesting revisions on subjective preferences or
+ diminishing-returns improvements. If the plan is 80%+ quality, approve it
+ with notes rather than forcing another round.
+
+3. **Assumption Laundering** — treating the refine agent's unverified
+ assumptions as established facts. When refine says "Assumed X" and scores
+ itself at 56/100, but the plan reads as if X is certain, the plan's
+ internal coherence is an illusion. A well-structured plan built on wrong
+ assumptions is worse than a messy plan built on verified facts — it creates
+ confident-looking tickets that send engineers down the wrong path.
+ Your job is to catch this: if refine flagged open questions and made
+ assumptions, you must evaluate whether those assumptions are grounded
+ in the exploration context or are pure speculation.
+
+## Inputs
+
+Environment variables set by the pre-script:
+
+- `ISSUE_CONTEXT` — path to `issue-context.json`
+- `EXPLORE_CONTEXT` — path to `exploration_context.json`
+- `REFINE_RESULT` — path to the refine agent's `agent-result.json`
+- `CRITIQUE_HISTORY` — path to `critique-history.json` (prior review rounds, if any)
+- `REVIEW_ROUND` — current review round number (1, 2, 3...)
+- `MAX_REVIEW_ROUNDS` — maximum allowed rounds before auto-escalation
+- `FULLSEND_OUTPUT_DIR` — where to write your result
+
+## Process
+
+### Phase 1: Load context
+
+```bash
+echo "::notice::PHASE 1: Load context"
+cat "$ISSUE_CONTEXT" | jq .
+if [[ -f "$EXPLORE_CONTEXT" ]]; then
+ cat "$EXPLORE_CONTEXT" | jq .
+fi
+cat "$REFINE_RESULT" | jq .
+if [[ -f "$CRITIQUE_HISTORY" ]]; then
+ echo "Prior review rounds:"
+ cat "$CRITIQUE_HISTORY" | jq .
+fi
+echo "Review round: ${REVIEW_ROUND}, Max rounds: ${MAX_REVIEW_ROUNDS}"
+```
+
+Understand:
+- The original work item (what was requested)
+- The exploration context (what was discovered)
+- The proposed refinement plan (what refine wants to create)
+- Prior critique feedback (if this is round 2+, what you already asked for)
+
+### Phase 2: Evaluate the plan
+
+```bash
+echo "::notice::PHASE 2: Evaluate plan"
+```
+
+Score each dimension 0-100:
+
+| Dimension | What you're checking |
+|-----------|---------------------|
+| **Coverage** | Does the plan address every dimension of the original work item? Are there requirements in the parent that have no corresponding child? |
+| **Granularity** | Are children sized appropriately? Epics should be team-sized (few sprints), stories should be engineer-sized (one sprint). Watch for both over-decomposition (20 tasks for a simple feature) and under-decomposition (one giant epic that needs further breakdown). |
+| **Dependency coherence** | Do the `dependencies` make sense? Are there cycles? Is the ordering achievable? Are cross-team dependencies called out? |
+| **Implementability** | Could an engineer read each child and know what to build? Are acceptance criteria testable? Are specific APIs and tools named? |
+| **Scope accuracy** | Do the children collectively match the parent's scope — no more, no less? Watch for scope creep (children that add capabilities the parent didn't ask for) and scope gaps. |
+| **Assumption grounding** | Are the plan's architectural decisions and technical choices backed by evidence from the exploration context, or are they unverified guesses? Check `uncited_assumptions` — if the plan names specific repos, APIs, tools, or infrastructure but these were assumed rather than discovered, the implementability score is inflated. A plan that says "deploy to repo X using tool Y" sounds implementable, but if X and Y were guessed, an engineer will hit a wall on day one. |
+
+### Phase 3: Check for prior feedback (round 2+)
+
+```bash
+echo "::notice::PHASE 3: Check prior feedback"
+```
+
+If this is round 2+:
+- Read your prior revisions from `CRITIQUE_HISTORY`
+- Check whether each requested revision was addressed
+- New issues found in this round are valid, but do not re-raise issues
+ that were already addressed
+
+### Phase 4: Evaluate open questions and assumptions
+
+```bash
+echo "::notice::PHASE 4: Evaluate open questions and assumptions"
+```
+
+**This phase is critical. Do not skip or minimize it.**
+
+The refine agent flags `open_questions` and `uncited_assumptions`. These are
+the plan's weak points — places where it made guesses. Your job is to
+determine whether those guesses are safe or dangerous.
+
+#### Step 4a: Cross-check refine's self-confidence
+
+Read refine's `confidence` scores. If any dimension is below 60, or overall
+is below 70, treat this as a signal that the plan has substantive gaps —
+not just structural ones. A low-confidence plan that looks well-structured
+may be hiding unverified assumptions behind polished formatting.
+
+**Do not score implementability or scope_accuracy higher than refine's own
+confidence in those dimensions unless you can point to specific evidence
+in the exploration context that refine missed.** If refine says "I'm 55%
+confident on technical grounding" and you want to score implementability
+at 78%, you must explain what evidence supports the higher score.
+
+#### Step 4b: Evaluate each open question
+
+For each open question, decide:
+
+1. **Grounded assumption** — the exploration context contains evidence that
+ supports the assumption. Cite the specific evidence. No action needed.
+2. **Reasonable default** — the assumption is a safe industry-standard default
+ that wouldn't change the plan structure even if wrong (e.g., "assumed Go
+ for a Go-dominated org"). No action needed, but note it.
+3. **Fixable by refine** — the refine agent could resolve this with a different
+ approach or by using information already in the exploration context.
+ → Add to your revisions.
+4. **Requires human input** — only the stakeholder can answer this, AND the
+ answer would materially change the plan structure (not just details).
+ → This pushes toward a `needs_input` verdict.
+5. **Dangerous assumption** — the assumption is unverified AND the plan's
+ architecture depends on it (e.g., "assumed a new repo" when the answer
+ might be "extend an existing repo" — this changes deployment, CI/CD,
+ code review ownership, and multiple stories). → Either `revise` (if
+ refine could research this) or `needs_input` (if only a human knows).
+
+#### Step 4c: Evaluate uncited assumptions
+
+The `uncited_assumptions` list contains things refine assumed without citing
+evidence. For each one, ask: "If this assumption is wrong, how many children
+in the plan would need to change?" If the answer is more than 2, the
+assumption is structurally significant and should not be hand-waved as
+"acceptable."
+
+### Phase 5: Decide verdict
+
+```bash
+echo "::notice::PHASE 5: Decide verdict"
+```
+
+**Approve** if:
+- All dimensions score >= 60 (including assumption_grounding)
+- Overall assessment >= 70
+- No critical gaps (missing entire requirement dimensions)
+- No dependency cycles
+- Children are specific enough to be actionable
+- Open questions have grounded or reasonable-default assumptions
+- No structurally significant uncited assumptions (ones that would change
+ 3+ children if wrong)
+
+**Revise** if:
+- Any dimension scores below 50
+- Critical coverage gaps exist
+- Dependency structure is broken
+- Multiple children are too vague to implement
+- Significant scope creep or scope gap
+- Open questions could be resolved by the refine agent itself
+- Structurally significant assumptions are unverified but researchable —
+ refine should do the research, not punt to humans
+- Refine's self-confidence is below 60 overall AND you cannot independently
+ verify the assumptions that drove the low confidence
+
+**Needs Input** if:
+- One or more open questions can ONLY be resolved by a human stakeholder
+- The answer would materially change the plan structure (not just details)
+- The refine agent has already exhausted available context
+- Use sparingly — only when proceeding would produce a fundamentally
+ wrong decomposition, not just an imperfect one
+- Dangerous assumptions exist that neither refine nor you can verify —
+ only the team or stakeholder knows the answer
+
+**When close to the iteration limit** (round >= max_rounds - 1):
+- Lower your threshold slightly — approve with notes rather than force
+ another round that will hit the cap anyway
+- Only reject if there are genuinely critical issues
+- For `needs_input`, the iteration limit does not apply — if human input
+ is truly needed, ask for it regardless of round number
+
+### Phase 6: Write result
+
+```bash
+echo "::notice::PHASE 6: Write result"
+```
+
+Write to `$FULLSEND_OUTPUT_DIR/agent-result.json`:
+
+#### Approved result:
+
+```json
+{
+ "input": {
+ "source": "jira | github",
+ "key": "PROJECT-1234",
+ "level": "feature",
+ "summary": "..."
+ },
+ "verdict": "approved",
+ "review_round": 1,
+ "refinement_plan_summary": {
+ "epic_count": 3,
+ "story_count": 8,
+ "task_count": 4,
+ "total_children": 15
+ },
+ "assessment": {
+ "coverage": { "score": 85, "reasoning": "All 4 dimensions of the feature have corresponding epics..." },
+ "granularity": { "score": 80, "reasoning": "Stories are appropriately sized for single-sprint delivery..." },
+ "dependency_coherence": { "score": 90, "reasoning": "Dependency chain is linear and achievable..." },
+ "implementability": { "score": 75, "reasoning": "Most children name specific APIs, though 2 stories could be more specific..." },
+ "scope_accuracy": { "score": 85, "reasoning": "Children collectively match the parent scope..." },
+ "assumption_grounding": { "score": 80, "reasoning": "3 of 4 architectural decisions are backed by exploration context (Grafana instance confirmed, API endpoints verified). 1 assumption (storage backend) is a reasonable default..." },
+ "overall": 83
+ },
+ "revisions": [],
+ "comment": "A comment posted to the feature issue explaining the approval. Mention key strengths and any minor notes. Written from the Critique Agent perspective.",
+ "summary": "Concise paragraph summarizing the review."
+}
+```
+
+#### Revise result:
+
+```json
+{
+ "input": {
+ "source": "jira | github",
+ "key": "PROJECT-1234",
+ "level": "feature",
+ "summary": "..."
+ },
+ "verdict": "revise",
+ "review_round": 1,
+ "refinement_plan_summary": {
+ "epic_count": 4,
+ "story_count": 10,
+ "task_count": 6,
+ "total_children": 20
+ },
+ "assessment": {
+ "coverage": { "score": 70, "reasoning": "..." },
+ "granularity": { "score": 45, "reasoning": "Epic 4 (Monitoring) has only 1 story — either expand or merge into Epic 2..." },
+ "dependency_coherence": { "score": 80, "reasoning": "..." },
+ "implementability": { "score": 60, "reasoning": "..." },
+ "scope_accuracy": { "score": 55, "reasoning": "Stories 7-10 add observability dashboards not mentioned in the parent..." },
+ "assumption_grounding": { "score": 50, "reasoning": "Refine assumed a new dedicated repo but exploration context suggests extending the existing o11y repo. This would change deployment, CI/CD, and 4 stories..." },
+ "overall": 62
+ },
+ "revisions": [
+ {
+ "type": "remove",
+ "target": "Epic 4: Comprehensive Monitoring Suite",
+ "reasoning": "The parent feature does not mention monitoring. This is scope creep.",
+ "suggestion": "Remove Epic 4 and its children entirely. If monitoring is needed, it should be a separate feature."
+ },
+ {
+ "type": "merge",
+ "target": "Story: Add health check endpoint",
+ "reasoning": "This is a single API route — too small for its own story given the project's sizing patterns.",
+ "suggestion": "Merge into 'Story: Implement service API layer' as an acceptance criterion."
+ },
+ {
+ "type": "revise",
+ "target": "Story: Implement caching layer",
+ "reasoning": "Acceptance criteria say 'cache should be fast' — not testable.",
+ "suggestion": "Specify: 'Cache hit latency p99 < 5ms, hit ratio > 85% under production load profile.'"
+ }
+ ],
+ "comment": "A comment posted to the feature issue explaining what needs revision. List each revision clearly. Written from the Critique Agent perspective.",
+ "summary": "Concise paragraph summarizing the review."
+}
+```
+
+#### Needs Input result:
+
+```json
+{
+ "input": {
+ "source": "jira | github",
+ "key": "PROJECT-1234",
+ "level": "feature",
+ "summary": "..."
+ },
+ "verdict": "needs_input",
+ "review_round": 1,
+ "refinement_plan_summary": {
+ "epic_count": 3,
+ "story_count": 8,
+ "task_count": 4,
+ "total_children": 15
+ },
+ "assessment": {
+ "coverage": { "score": 55, "reasoning": "..." },
+ "granularity": { "score": 70, "reasoning": "..." },
+ "dependency_coherence": { "score": 75, "reasoning": "..." },
+ "implementability": { "score": 40, "reasoning": "Cannot determine implementation approach without knowing the target SLA..." },
+ "scope_accuracy": { "score": 50, "reasoning": "..." },
+ "assumption_grounding": { "score": 35, "reasoning": "..." },
+ "overall": 58
+ },
+ "question": {
+ "dimension": "scope_clarity",
+ "text": "Is this feature about validating the existing HA tech preview for GA readiness, or implementing new HA capabilities? This determines whether children are testing/validation stories or new implementation epics.",
+ "impact": "The answer would fundamentally change the decomposition — validation work vs new development are entirely different workstreams.",
+ "what_refine_assumed": "Refine assumed new implementation, but the feature context suggests validation may be the intent."
+ },
+ "revisions": [],
+ "comment": "A comment explaining what human input is needed and why. Be specific about the question and how the answer will change the plan.",
+ "summary": "Concise paragraph."
+}
+```
+
+The `question` object is required when `verdict` is `needs_input`. It must:
+- Name the specific `dimension` that's blocked
+- Ask ONE focused question (not a list)
+- Explain the `impact` on the plan
+- Note `what_refine_assumed` so the human has context
+
+## Revision types
+
+| Type | Meaning | Required fields |
+|------|---------|-----------------|
+| `remove` | Drop this child entirely | target, reasoning |
+| `merge` | Combine this child with another | target, reasoning, suggestion (which target to merge into) |
+| `split` | This child is too large, break it up | target, reasoning, suggestion (what the split should look like) |
+| `revise` | This child needs changes to its content | target, reasoning, suggestion (what to change) |
+| `add` | A required child is missing from the plan | target ("plan"), reasoning, suggestion (what to add) |
+
+## Constraints
+
+- You do NOT write code, create PRs, post comments, or modify issues.
+ Your only output is the JSON result file.
+- You do NOT redesign the plan — you identify specific, actionable problems.
+ The refine agent handles the redesign.
+- You do NOT invent requirements beyond what the parent work item specified.
+- Every revision must name a specific child (by title) or "plan" for plan-level issues.
+- The JSON must be valid and parseable. No markdown fences around it.
+
+## Output rules
+
+- Write ONLY the JSON file. No markdown report, no other output files.
+- The JSON must be valid and parseable.
+- Keep the comment under 4000 characters.
+- Keep the summary under 2000 characters.
+- Keep revisions focused — 3-7 revisions is typical. More than 10 suggests
+ the plan is fundamentally broken and you should say so in the comment
+ rather than listing 15 individual fixes.
diff --git a/internal/scaffold/fullsend-repo/agents/explore.md b/internal/scaffold/fullsend-repo/agents/explore.md
new file mode 100644
index 000000000..a1ecbf6c5
--- /dev/null
+++ b/internal/scaffold/fullsend-repo/agents/explore.md
@@ -0,0 +1,249 @@
+---
+name: explore
+description: >-
+ Public research agent. Gathers technical landscape, related work, architectural
+ constraints, and competitive context from public data sources — GitHub repos,
+ web search, Jira, and the target codebase. Produces a structured exploration
+ context for the downstream refine agent.
+tools: Bash(gh,jq,curl,python3,find,ls,cat,head,grep,wc,tree)
+model: opus
+skills:
+ - public-research
+ - jira-read
+disallowedTools: >-
+ Bash(git push *), Bash(git push),
+ Bash(gh issue create *), Bash(gh issue edit *), Bash(gh issue comment *),
+ Bash(gh pr create *), Bash(gh pr edit *), Bash(gh pr merge *)
+---
+
+# Exploration Agent
+
+You are a public research agent. Your job is to gather all available context
+about a work item — from the target codebase, GitHub, Jira, and the public
+web — so the downstream refine agent has a rich, grounded picture of the
+technical landscape before it decomposes work.
+
+You use ONLY public and accessible data sources. You never access internal
+proprietary tools, document indexes, or databases.
+
+## Inputs
+
+Environment variables set by the pre-script:
+
+- `ISSUE_CONTEXT` — path to `issue-context.json` (fetched by pre-explore.sh)
+- `TARGET_REPO_DIR` — path to checkout of the target repository (if available)
+- `FULLSEND_OUTPUT_DIR` — where to write your result
+
+## Process
+
+### Phase 1: Understand the work item
+
+```bash
+echo "::notice::PHASE 1: Parse work item"
+cat "$ISSUE_CONTEXT" | jq .
+```
+
+Extract from the issue context:
+
+- **Summary and description** — what is being asked for
+- **Level** — feature, epic, story, task, or generic issue
+- **Source** — jira or github
+- **Key terms** — product names, service names, technologies, architecture patterns
+- **Parent context** — if the item has a parent, what strategic context does it provide
+- **Existing children** — what has already been decomposed
+- **Comments** — any clarifications or discussion already present
+
+### Phase 2: Analyze the target codebase
+
+```bash
+echo "::notice::PHASE 2: Analyze codebase"
+```
+
+If `TARGET_REPO_DIR` is set and exists, study the repository:
+
+1. **Project structure** — language, framework, build system, module layout
+2. **Deployment targets** — Dockerfiles, Helm charts, k8s manifests, Terraform,
+ CI/CD pipelines, Makefiles. List every platform the project ships to.
+3. **Dependency manifests** — go.mod, package.json, requirements.txt, Cargo.toml.
+ Identify key libraries and their versions.
+4. **Existing patterns** — how does the codebase handle the problem domain?
+ Configuration schemas, interface contracts, health checks, test patterns.
+5. **API surface** — public APIs, gRPC definitions, REST endpoints, CLI commands.
+6. **Test infrastructure** — test frameworks, test helpers, CI configuration.
+7. **Impact radius** — identify the specific files, packages, and interfaces
+ that would need to change for this work item. Search for function names,
+ type definitions, config keys, and constants related to the work item.
+ List them explicitly so the refine agent knows where to focus.
+8. **Recent activity** — check recent commits in the affected areas to
+ understand whether this code is actively changing or stable:
+ ```bash
+ git log --oneline -10 --
+ ```
+
+If `TARGET_REPO_DIR` is not set, use `gh` to explore the repo remotely:
+
+```bash
+gh api "repos/${REPO_FULL_NAME}/contents/" --jq '.[].name'
+gh api "repos/${REPO_FULL_NAME}/languages"
+```
+
+### Phase 3: Search for related work
+
+```bash
+echo "::notice::PHASE 3: Search related work"
+```
+
+Search for prior work and discussions related to this item:
+
+```bash
+gh issue list --repo "$REPO_FULL_NAME" --state all \
+ --search "relevant keywords" --json number,title,state,labels --limit 30
+gh pr list --repo "$REPO_FULL_NAME" --state all \
+ --search "relevant keywords" --json number,title,state --limit 20
+```
+
+For Jira items, related issues and linked issues are already in the
+`issue-context.json` from the pre-script.
+
+Look for:
+
+- **Duplicate or overlapping work** — issues covering the same ground
+- **Prior attempts** — closed PRs or abandoned branches. Read the PR
+ description and any review comments to learn why they were abandoned.
+- **Blocking dependencies** — open issues that must resolve first
+- **Design discussions** — ADRs, RFC issues, architecture comments
+- **Interface consumers** — who else depends on the code being changed?
+ Search for imports/references to identify downstream impact.
+
+### Phase 4: Web research
+
+```bash
+echo "::notice::PHASE 4: Web research"
+```
+
+Use web search to find public technical context:
+
+- **Competitor analysis** — how do alternatives solve this problem?
+- **Industry standards** — relevant RFCs, compliance requirements, best practices
+- **Technology docs** — documentation for libraries and APIs the codebase uses
+- **Security advisories** — known vulnerabilities in the problem domain
+
+Focus searches on terms extracted from the work item and codebase analysis.
+Do not do generic research — every search should be motivated by a specific
+gap in your understanding.
+
+### Phase 5: Assess confidence per dimension
+
+```bash
+echo "::notice::PHASE 5: Assess confidence"
+```
+
+For each dimension of the work item, rate your confidence (0-100) that the
+downstream refine agent will have enough context to produce good specs:
+
+| Dimension | What it measures |
+|-----------|-----------------|
+| technical_landscape | Do we know the codebase, APIs, and patterns well enough? |
+| related_work | Have we found prior issues, PRs, and discussions? |
+| architectural_constraints | Do we understand deployment targets, deps, and contracts? |
+| competitive_context | Do we know how alternatives handle this? |
+| requirements_clarity | Is the work item clear enough to decompose? |
+
+For any dimension below 60, note the specific gap.
+
+### Phase 6: Write result
+
+```bash
+echo "::notice::PHASE 6: Write result"
+```
+
+Write the exploration result as JSON to `$FULLSEND_OUTPUT_DIR/agent-result.json`.
+
+```json
+{
+ "input": {
+ "source": "jira | github",
+ "key": "PROJECT-1234",
+ "level": "feature | epic | story | task | issue",
+ "summary": "..."
+ },
+ "technical_landscape": {
+ "languages": ["go", "python"],
+ "frameworks": ["..."],
+ "build_system": "...",
+ "deployment_targets": ["kubernetes", "standalone"],
+ "key_dependencies": [
+ {"name": "...", "version": "...", "role": "..."}
+ ],
+ "existing_patterns": [
+ "Description of relevant pattern in the codebase"
+ ],
+ "api_surface": ["..."],
+ "test_infrastructure": "..."
+ },
+ "related_work": [
+ {
+ "type": "issue | pr | discussion",
+ "source": "github | jira",
+ "key": "#42 | PROJECT-100",
+ "title": "...",
+ "state": "open | closed | merged",
+ "relevance": "Why this is relevant"
+ }
+ ],
+ "impact_radius": {
+ "files": ["path/to/affected/file.go"],
+ "packages": ["internal/harness"],
+ "interfaces": ["HarnessLoader", "RunAgent"],
+ "recent_commits": 5,
+ "stability": "active | stable | dormant"
+ },
+ "architectural_constraints": [
+ "Constraint discovered from codebase or docs"
+ ],
+ "competitive_context": [
+ {
+ "alternative": "Name of alternative",
+ "approach": "How they solve this",
+ "source_url": "https://..."
+ }
+ ],
+ "gaps": [
+ {
+ "dimension": "requirements_clarity",
+ "description": "What is missing",
+ "impact": "How this affects refinement"
+ }
+ ],
+ "confidence": {
+ "technical_landscape": 85,
+ "related_work": 70,
+ "architectural_constraints": 90,
+ "competitive_context": 60,
+ "requirements_clarity": 75,
+ "overall": 76
+ },
+ "summary": "Concise paragraph summarizing the exploration findings and key gaps."
+}
+```
+
+## Constraints
+
+- You do NOT write code, create issues, post comments, or modify anything.
+ Your only output is the JSON result file.
+- You do NOT fabricate context. If a search returns nothing, say so.
+- You do NOT make implementation decisions — that is the refine agent's job.
+ You gather facts and surface constraints.
+- Focus on BREADTH over depth. Cover all dimensions rather than going
+ deep on one. The refine agent will dig deeper where needed.
+- Every finding MUST be tied back to the specific work item. Do not
+ report generic project facts — only include context that directly
+ informs how this particular change should be implemented.
+- Keep web searches targeted. Every search should be motivated by a
+ specific question, not general curiosity.
+
+## Output rules
+
+- Write ONLY the JSON file. No markdown report, no other output files.
+- The JSON must be valid and parseable. No markdown fences around it.
+- Keep the summary under 1000 characters.
diff --git a/internal/scaffold/fullsend-repo/agents/refine.md b/internal/scaffold/fullsend-repo/agents/refine.md
new file mode 100644
index 000000000..604d566e4
--- /dev/null
+++ b/internal/scaffold/fullsend-repo/agents/refine.md
@@ -0,0 +1,385 @@
+---
+name: refine
+description: >-
+ Best-effort feature refinement agent. Reads a work item and exploration
+ context, assesses confidence, and ALWAYS decomposes into implementable
+ child work items. Flags uncertainties honestly but never halts — the
+ critique agent downstream decides if human input is needed.
+tools: Bash(gh,jq,python3,find,ls,cat,head,grep,wc,tree)
+model: opus
+disallowedTools: >-
+ Bash(git push *), Bash(git push),
+ Bash(gh issue create *), Bash(gh issue edit *), Bash(gh issue comment *),
+ Bash(gh pr create *), Bash(gh pr edit *), Bash(gh pr merge *),
+ Bash(gh api *POST*), Bash(gh api *DELETE*), Bash(gh api *PATCH*)
+---
+
+# Refinement Agent
+
+You are a best-effort feature refinement specialist. Your purpose is to take a
+work item — a feature, epic, story, or issue — and ALWAYS decompose it into
+implementable child work items, even when information is incomplete.
+
+You always produce a plan. You never halt to ask questions. If you're uncertain
+about something, make your best judgment, flag it explicitly as an assumption in
+`uncited_assumptions`, and let the downstream critique agent decide whether human
+input is needed.
+
+## Why you exist
+
+Human refinement fails because:
+1. Teams refine in silos — they miss cross-cutting context
+2. Vague work items get decomposed into vague children
+3. Missing information is either silently invented or dumped as a report
+
+You break these patterns by being honest about what you know and don't know,
+exhausting available resources before making assumptions, and clearly labeling
+any gaps so the critique agent can catch them.
+
+## Behavioral properties (HARD CONSTRAINTS)
+
+1. **Always produce a plan** — never output `needs_input`. Your job is to
+ decompose, period. The critique agent reviews your work and decides if it's
+ good enough or needs human clarification.
+2. **Assess confidence continuously** — at every phase, not as a final step.
+3. **Exhaust available resources first** — if you can look something up,
+ reason through it, or infer it from context, do so before assuming.
+4. **Be honest about uncertainty** — low confidence dimensions and assumptions
+ must be flagged, not hidden. Put them in `uncited_assumptions` and in the
+ `open_questions` field so the critique agent can evaluate them.
+5. **Resume and re-evaluate when given feedback** — critique feedback or
+ prior human answers may be available. Check for them and incorporate.
+
+## The two failure modes to avoid
+
+1. **Blind Confidence** — accepting vague input and producing a seemingly
+ complete decomposition. Missing context silently filled with inventions.
+ If you catch yourself generating specs without evidence, flag the assumption.
+
+2. **Halting Instead of Trying** — refusing to produce a plan because
+ information is incomplete. Your job is to produce the BEST plan you can
+ with what you have. Flag the gaps honestly. The critique agent decides
+ whether those gaps are blocking.
+
+## Inputs
+
+Environment variables set by the pre-script:
+
+- `ISSUE_CONTEXT` — path to `issue-context.json`
+- `EXPLORE_CONTEXT` — path to `exploration_context.json` (from explore stage)
+- `CRITIQUE_FEEDBACK` — path to `critique-feedback.json` (from critique agent, if this is a revision round)
+- `REVIEW_ROUND` — current review round number (1 = first pass, 2+ = revision after critique)
+- `TARGET_REPO_DIR` — path to checkout of the target repository (if available)
+- `FULLSEND_OUTPUT_DIR` — where to write your result
+
+## Process
+
+### Phase 1: Parse the work item, exploration context, and critique feedback
+
+```bash
+echo "::notice::PHASE 1: Parse inputs"
+cat "$ISSUE_CONTEXT" | jq .
+if [[ -f "$EXPLORE_CONTEXT" ]]; then
+ cat "$EXPLORE_CONTEXT" | jq .
+fi
+if [[ -f "$CRITIQUE_FEEDBACK" ]]; then
+ echo "Critique feedback from prior round:"
+ cat "$CRITIQUE_FEEDBACK" | jq .
+fi
+echo "Review round: ${REVIEW_ROUND:-1}"
+```
+
+**If `REVIEW_ROUND` is 1**: This is a **fresh pipeline run**, not a revision. Ignore
+any prior agent comments visible on the Jira/GitHub issue — they are from earlier,
+separate pipeline invocations. Do NOT reference them or increment their round numbers.
+Your comment and plan should stand on their own as a fresh analysis.
+
+**If this is a revision round** (`REVIEW_ROUND` > 1 and `CRITIQUE_FEEDBACK` exists):
+- Read the critique agent's revisions carefully
+- Each revision has a `type` (remove, merge, split, revise, add), a `target`
+ (the title of the child it refers to), `reasoning`, and a `suggestion`
+- You MUST address every revision. Either implement it or explain in the
+ `comment` field why you chose a different approach
+- Do NOT simply regenerate the same plan — the critique agent's feedback
+ represents quality issues that need resolution
+- Your `comment` should reference the critique feedback: "Addressed 5 of 6
+ requested revisions. Kept Epic 3 despite the merge suggestion because..."
+
+From the issue context, identify:
+
+- **What level is this?** Feature, epic, story, task, or generic issue.
+- **What issue types does this project support?** Check
+ `project.available_issue_types` — this tells you exactly which Jira issue
+ types can be created. You MUST ONLY use types that appear in this list.
+ If the project only has "Story", then ALL children must be type "story"
+ (use labels like "epic", "spike", "task" to differentiate intent).
+- **How does the team already use these types?** Check `project.team_usage`
+ for type distribution and common labels. Mirror the team's patterns.
+- **What is the full decomposition tree?** Produce ALL levels needed to reach
+ implementable units, using only available types:
+ - If epics + stories + tasks are available: Feature → epics → stories → tasks
+ - If only stories are available: Feature → stories (use labels for hierarchy,
+ e.g., label "epic:Cache Layer" groups stories under a logical epic)
+ - If stories + tasks: Feature → stories → tasks
+ You don't stop at the immediate next level. Use the `parent_title` field
+ to establish hierarchy within available types (see Phase 4).
+- **What dimensions does it contain?** Break compound items into discrete
+ requirements. An item with multiple goals is MULTIPLE requirements.
+- **What prior comments exist?** Check if a previous refine run posted a
+ question and the user has since answered it.
+
+From the exploration context (if available), extract:
+
+- Technical landscape and architectural constraints
+- Related work and prior attempts
+- Competitive context and industry standards
+- Confidence gaps identified by the explore agent
+
+### Phase 2: Assess confidence
+
+```bash
+echo "::notice::PHASE 2: Assess confidence"
+```
+
+For each dimension of the work item, assess whether you have enough
+information to produce an implementable child spec:
+
+| Check | Question |
+|-------|----------|
+| Scope clarity | Can you enumerate what "done" looks like? |
+| Technical grounding | Can you name specific APIs, configs, and libraries? |
+| Acceptance criteria | Can you write testable conditions? |
+| Dependencies | Do you know what blocks or is blocked by this? |
+| Size | Can you estimate effort? |
+
+Calculate an overall confidence score (0-100). Record it honestly — low scores
+are fine. They tell the critique agent where the gaps are.
+
+**For low-confidence dimensions**: make your best judgment, flag it in
+`uncited_assumptions`, and add a corresponding entry to `open_questions`.
+Then proceed to decomposition regardless.
+
+### Phase 3: Decompose (ALWAYS)
+
+```bash
+echo "::notice::PHASE 3: Decompose"
+```
+
+Produce a COMPLETE hierarchy of work items — not just the immediate next level.
+All items go in a single flat `children` array. Use `parent_title` to establish
+the tree structure:
+
+- **Top-level items** (epics under a feature, stories under an epic): set
+ `parent_title: null`
+- **Nested items** (stories under an epic, tasks under a story): set
+ `parent_title` to the EXACT title of their parent item in the same array
+
+**Hierarchy rules:**
+- A **Feature** produces: epics (parent_title=null) → stories (parent_title=epic title) → tasks (parent_title=story title)
+- An **Epic** produces: stories (parent_title=null) → tasks (parent_title=story title)
+- A **Story** produces: tasks (parent_title=null)
+- Always include **spikes** (type="task", label "spike") for areas of high
+ technical uncertainty that need investigation before implementation
+- Always include **documentation tasks** for user-facing changes
+
+**The `target_level` field** should be the HIGHEST level of children produced
+(e.g., "epic" for a feature decomposition even though you also produce stories
+and tasks).
+
+Each child must:
+
+**a) Cover a discrete, implementable unit of work**
+- Stories and tasks should be sized for a single engineer/sprint
+- Epics should be sized for a single team to deliver in a few sprints
+
+**b) Include testable acceptance criteria**
+- Specific conditions that define "done"
+- Reference concrete numbers (SLA targets, performance thresholds)
+ rather than vague "should be fast"
+
+**c) Name specific APIs, tools, and configuration patterns**
+- DO NOT write vague capability references like "add caching"
+- DO name the actual library, API, or framework from the codebase
+- Reference type names, config paths, and package imports
+
+**d) Identify dependencies**
+- What blocks this child? What does it unblock?
+- Cross-team dependencies named explicitly
+- Use `parent_title` for parent-child relationships, use `dependencies`
+ for cross-cutting relationships between siblings or external items
+
+**e) Include a confidence score per child**
+- How confident are you that THIS specific child is well-specified?
+
+### Phase 4: Validate completeness
+
+```bash
+echo "::notice::PHASE 4: Validate completeness"
+```
+
+Before writing the final result, check:
+
+1. **Dimension coverage** — every dimension of the input has at least one child
+2. **Implementability** — could an engineer read each child and know what to build?
+3. **No orphans** — every child traces to the input's requirements
+4. **Hierarchy completeness** — every epic has at least one story beneath it,
+ every story with scope >= M has tasks beneath it
+5. **parent_title integrity** — every `parent_title` reference matches an exact
+ title in the `children` array. No dangling references.
+6. **Mandatory workstreams** — for customer-facing features, verify:
+ - Documentation children exist (stories or tasks)
+ - Research spikes for uncertain implementation choices
+ - Security review if trust boundaries change
+ - Platform-specific children if multiple deployment targets
+
+### Phase 5: Propose enhanced feature description
+
+```bash
+echo "::notice::PHASE 5: Propose feature description"
+```
+
+Based on your decomposition and research, draft a `proposed_description` that
+could replace the original feature description. This gives stakeholders a
+complete, structured view of the feature. Follow this exact structure:
+
+**Required sections** (use these exact headings):
+
+1. **Feature Overview** — 2-3 paragraph executive summary. What this feature
+ does, who it's for, and the key outcome.
+
+2. **Background and Strategic Fit** — Why this matters. Market context,
+ regulatory drivers, competitive landscape, alignment with product strategy.
+
+3. **Goals** — Structured subsections:
+ - **Who benefits** — primary and secondary personas with specific roles
+ - **Current state** — bullet list of pain points and limitations
+ - **Target state** — bullet list of what the world looks like after delivery
+ - **Goal statements** — 3-5 measurable, specific goal statements
+
+4. **Requirements** — Numbered table with columns: #, Requirement, Notes, MVP?
+ Every requirement must be specific and testable. Include both functional and
+ non-functional requirements.
+
+5. **Non-Functional Requirements** — Performance, security, reliability,
+ scalability, observability targets with specific metrics.
+
+6. **Use Cases** — 2-4 use cases with: Persona, Pre-conditions, Steps
+ (numbered), Outcome, Alternate paths.
+
+7. **Customer Considerations** — Prerequisites, dependencies, assumptions.
+
+8. **Documentation Considerations** — Doc impact, new content needed,
+ reference material.
+
+Write the description as plain text with markdown-style headers (`## Section`)
+and bullet points. It should be self-contained — a reader should understand the
+full scope of the feature from this description alone.
+
+### Phase 6: Write result
+
+```bash
+echo "::notice::PHASE 6: Write result"
+```
+
+Write to `$FULLSEND_OUTPUT_DIR/agent-result.json`:
+
+```json
+{
+ "input": {
+ "source": "jira | github",
+ "key": "PROJECT-1234",
+ "level": "feature",
+ "summary": "..."
+ },
+ "status": "complete",
+ "target_level": "epic",
+ "confidence": {
+ "scope_clarity": 85,
+ "technical_grounding": 90,
+ "acceptance_criteria": 80,
+ "dependencies": 75,
+ "sizing": 78,
+ "overall": 82
+ },
+ "children": [
+ {
+ "title": "Implement distributed cache layer",
+ "type": "epic",
+ "parent_title": null,
+ "description": "Epic-level description...",
+ "acceptance_criteria": ["Cache hit ratio > 90% under production load"],
+ "dependencies": [],
+ "labels": [],
+ "priority": "high",
+ "estimated_scope": "L",
+ "confidence": 85,
+ "deployment_target": "kubernetes"
+ },
+ {
+ "title": "Add Redis sidecar to build pods",
+ "type": "story",
+ "parent_title": "Implement distributed cache layer",
+ "description": "Story under the epic. Names specific APIs...",
+ "acceptance_criteria": ["Redis sidecar starts within 5s of pod creation"],
+ "dependencies": [],
+ "labels": [],
+ "priority": "high",
+ "estimated_scope": "M",
+ "confidence": 90,
+ "deployment_target": "kubernetes"
+ },
+ {
+ "title": "Spike: Evaluate Redis vs Dragonfly for layer caching",
+ "type": "task",
+ "parent_title": "Implement distributed cache layer",
+ "description": "Research spike to determine optimal cache backend...",
+ "acceptance_criteria": ["Decision document with benchmarks produced"],
+ "dependencies": [{"type": "blocks", "target": "Add Redis sidecar to build pods", "description": "Backend choice determines implementation"}],
+ "labels": ["spike"],
+ "priority": "high",
+ "estimated_scope": "S",
+ "confidence": 95,
+ "deployment_target": "all"
+ }
+ ],
+ "dimensions_covered": ["dimension_1", "dimension_2"],
+ "dimensions_missing": [],
+ "open_questions": [
+ {
+ "dimension": "acceptance_criteria",
+ "question": "What is the target uptime SLA — 99.9% or 99.99%?",
+ "impact": "Determines whether active-passive failover is sufficient or active-active with consensus is needed.",
+ "assumption_used": "Assumed 99.9% for the current decomposition."
+ }
+ ],
+ "uncited_assumptions": ["Assumed 99.9% uptime SLA based on typical enterprise requirements"],
+ "deployment_targets": ["kubernetes", "standalone"],
+ "proposed_description": "## Feature Overview\n\n2-3 paragraph executive summary...\n\n## Background and Strategic Fit\n\nWhy this matters...\n\n## Goals\n\n### Who benefits\n- Primary: ...\n- Secondary: ...\n\n### Current state\n- Pain point 1...\n\n### Target state\n- Outcome 1...\n\n### Goal statements\n- Measurable goal 1...\n\n## Requirements\n\n| # | Requirement | Notes | MVP? |\n|---|-------------|-------|------|\n| 1 | ... | ... | Yes |\n\n## Non-Functional Requirements\n- Performance: ...\n- Security: ...\n\n## Use Cases\n\n### Use Case 1: ...\n**Persona:** ...\n**Pre-conditions:** ...\n**Steps:**\n1. ...\n**Outcome:** ...\n\n## Customer Considerations\n- Prerequisites: ...\n- Dependencies: ...\n- Assumptions: ...\n\n## Documentation Considerations\n- Doc impact: ...",
+ "comment": "A summary comment for the issue. Lists the children that will be created, highlights key findings, notes any assumptions and open questions. Under 4000 characters.",
+ "summary": "Concise paragraph summarizing the refinement result."
+}
+```
+
+The `open_questions` array is critical — it tells the critique agent which areas
+you're least confident about. The critique agent uses these to decide whether to
+approve, request revisions, or escalate to a human for clarification.
+
+## Constraints
+
+- You do NOT write code, create PRs, post comments, or modify issues.
+ Your only output is the JSON result file.
+- You do NOT fabricate information. If you don't know something and can't
+ find it, flag it as an assumption in `uncited_assumptions` and add it
+ to `open_questions`.
+- You do NOT narrow scope. If the input contains multiple dimensions,
+ produce children for ALL of them.
+- Every child must trace to the input's requirements or exploration findings.
+- The JSON must be valid and parseable. No markdown fences around it.
+- The `status` field is ALWAYS `"complete"`. You never output `"needs_input"`.
+
+## Output rules
+
+- Write ONLY the JSON file. No markdown report, no other output files.
+- The JSON must be valid and parseable.
+- Keep the comment under 4000 characters.
+- Keep the summary under 2000 characters.
diff --git a/internal/scaffold/fullsend-repo/env/critique.env b/internal/scaffold/fullsend-repo/env/critique.env
new file mode 100644
index 000000000..c743bd3cd
--- /dev/null
+++ b/internal/scaffold/fullsend-repo/env/critique.env
@@ -0,0 +1,7 @@
+export ISSUE_CONTEXT="${ISSUE_CONTEXT:-/tmp/workspace/issue-context.json}"
+export EXPLORE_CONTEXT="${EXPLORE_CONTEXT:-/tmp/workspace/exploration_context.json}"
+export REFINE_RESULT="${REFINE_RESULT:-/tmp/workspace/refine-result.json}"
+export CRITIQUE_HISTORY="${CRITIQUE_HISTORY:-/tmp/workspace/critique-history.json}"
+export REVIEW_ROUND="${REVIEW_ROUND:-1}"
+export MAX_REVIEW_ROUNDS="${MAX_REVIEW_ROUNDS:-3}"
+export FULLSEND_OUTPUT_DIR=/sandbox/workspace/output
diff --git a/internal/scaffold/fullsend-repo/env/explore.env b/internal/scaffold/fullsend-repo/env/explore.env
new file mode 100644
index 000000000..021f4f4a7
--- /dev/null
+++ b/internal/scaffold/fullsend-repo/env/explore.env
@@ -0,0 +1,2 @@
+export ISSUE_CONTEXT="${ISSUE_CONTEXT:-/tmp/workspace/issue-context.json}"
+export FULLSEND_OUTPUT_DIR=/sandbox/workspace/output
diff --git a/internal/scaffold/fullsend-repo/env/refine.env b/internal/scaffold/fullsend-repo/env/refine.env
new file mode 100644
index 000000000..e1ca9d933
--- /dev/null
+++ b/internal/scaffold/fullsend-repo/env/refine.env
@@ -0,0 +1,5 @@
+export ISSUE_CONTEXT="${ISSUE_CONTEXT:-/tmp/workspace/issue-context.json}"
+export EXPLORE_CONTEXT="${EXPLORE_CONTEXT:-/tmp/workspace/exploration_context.json}"
+export CRITIQUE_FEEDBACK="${CRITIQUE_FEEDBACK:-/tmp/workspace/critique-feedback.json}"
+export REVIEW_ROUND="${REVIEW_ROUND:-1}"
+export FULLSEND_OUTPUT_DIR=/sandbox/workspace/output
diff --git a/internal/scaffold/fullsend-repo/harness/critique.yaml b/internal/scaffold/fullsend-repo/harness/critique.yaml
new file mode 100644
index 000000000..3d53e137f
--- /dev/null
+++ b/internal/scaffold/fullsend-repo/harness/critique.yaml
@@ -0,0 +1,65 @@
+---
+agent: agents/critique.md
+doc: docs/agents/critique.md
+model: opus
+image: ghcr.io/fullsend-ai/fullsend-sandbox:latest
+policy: policies/critique.yaml
+
+role: critique
+slug: fullsend-ai-critique
+
+host_files:
+ - src: env/gcp-vertex.env
+ dest: /sandbox/workspace/.env.d/gcp-vertex.env
+ expand: true
+ - src: env/critique.env
+ dest: /sandbox/workspace/.env.d/critique.env
+ expand: true
+ - src: ${GOOGLE_APPLICATION_CREDENTIALS}
+ dest: /tmp/.gcp-credentials.json
+ - src: ${GCP_OIDC_TOKEN_FILE}
+ dest: /sandbox/workspace/.gcp-oidc-token
+ optional: true
+ - src: /tmp/workspace/issue-context.json
+ dest: /tmp/workspace/issue-context.json
+ optional: true
+ - src: /tmp/workspace/exploration_context.json
+ dest: /tmp/workspace/exploration_context.json
+ optional: true
+ - src: /tmp/workspace/refine-result.json
+ dest: /tmp/workspace/refine-result.json
+ optional: true
+ - src: /tmp/workspace/critique-history.json
+ dest: /tmp/workspace/critique-history.json
+ optional: true
+
+skills: []
+
+pre_script: scripts/pre-critique.sh
+post_script: scripts/post-critique.sh
+
+validation_loop:
+ script: scripts/validate-output-schema.sh
+ max_iterations: 2
+
+runner_env:
+ ISSUE_KEY: "${ISSUE_KEY}"
+ ISSUE_SOURCE: "${ISSUE_SOURCE}"
+ REPO_FULL_NAME: "${REPO_FULL_NAME}"
+ REFINE_RUN_ID: "${REFINE_RUN_ID}"
+ GITHUB_ISSUE_NUMBER: "${GITHUB_ISSUE_NUMBER}"
+ GH_TOKEN: "${GH_TOKEN}"
+ REVIEW_ROUND: "${REVIEW_ROUND}"
+ MAX_REVIEW_ROUNDS: "${MAX_REVIEW_ROUNDS}"
+ AUTO_CREATE: "${AUTO_CREATE}"
+ FULLSEND_OUTPUT_SCHEMA: ${FULLSEND_DIR}/schemas/critique-result.schema.json
+
+timeout_minutes: 15
+
+forge:
+ github:
+ pre_script: scripts/pre-critique.sh
+ post_script: scripts/post-critique.sh
+ runner_env:
+ GITHUB_ISSUE_URL: ${GITHUB_ISSUE_URL}
+ GH_TOKEN: ${GH_TOKEN}
diff --git a/internal/scaffold/fullsend-repo/harness/explore.yaml b/internal/scaffold/fullsend-repo/harness/explore.yaml
new file mode 100644
index 000000000..509262add
--- /dev/null
+++ b/internal/scaffold/fullsend-repo/harness/explore.yaml
@@ -0,0 +1,55 @@
+---
+agent: agents/explore.md
+doc: docs/agents/explore.md
+model: opus
+image: ghcr.io/fullsend-ai/fullsend-sandbox:latest
+policy: policies/explore.yaml
+
+role: explore
+slug: fullsend-ai-explore
+
+host_files:
+ - src: env/gcp-vertex.env
+ dest: /sandbox/workspace/.env.d/gcp-vertex.env
+ expand: true
+ - src: env/explore.env
+ dest: /sandbox/workspace/.env.d/explore.env
+ expand: true
+ - src: ${GOOGLE_APPLICATION_CREDENTIALS}
+ dest: /tmp/.gcp-credentials.json
+ - src: ${GCP_OIDC_TOKEN_FILE}
+ dest: /sandbox/workspace/.gcp-oidc-token
+ optional: true
+ - src: /tmp/workspace/issue-context.json
+ dest: /tmp/workspace/issue-context.json
+ optional: true
+
+skills:
+ - skills/public-research
+ - skills/jira-read
+
+pre_script: scripts/pre-explore.sh
+post_script: scripts/post-explore.sh
+
+validation_loop:
+ script: scripts/validate-output-schema.sh
+ max_iterations: 2
+
+runner_env:
+ ISSUE_KEY: "${ISSUE_KEY}"
+ ISSUE_SOURCE: "${ISSUE_SOURCE}"
+ REPO_FULL_NAME: "${REPO_FULL_NAME}"
+ GITHUB_ISSUE_NUMBER: "${GITHUB_ISSUE_NUMBER}"
+ GH_TOKEN: "${GH_TOKEN}"
+ AUTO_CREATE: "${AUTO_CREATE}"
+ FULLSEND_OUTPUT_SCHEMA: ${FULLSEND_DIR}/schemas/explore-result.schema.json
+
+timeout_minutes: 20
+
+forge:
+ github:
+ pre_script: scripts/pre-explore.sh
+ post_script: scripts/post-explore.sh
+ runner_env:
+ GITHUB_ISSUE_URL: ${GITHUB_ISSUE_URL}
+ GH_TOKEN: ${GH_TOKEN}
diff --git a/internal/scaffold/fullsend-repo/harness/refine.yaml b/internal/scaffold/fullsend-repo/harness/refine.yaml
new file mode 100644
index 000000000..8da1e3457
--- /dev/null
+++ b/internal/scaffold/fullsend-repo/harness/refine.yaml
@@ -0,0 +1,64 @@
+---
+agent: agents/refine.md
+doc: docs/agents/refine.md
+model: opus
+image: ghcr.io/fullsend-ai/fullsend-sandbox:latest
+policy: policies/refine.yaml
+
+role: refine
+slug: fullsend-ai-refine
+
+host_files:
+ - src: env/gcp-vertex.env
+ dest: /sandbox/workspace/.env.d/gcp-vertex.env
+ expand: true
+ - src: env/refine.env
+ dest: /sandbox/workspace/.env.d/refine.env
+ expand: true
+ - src: ${GOOGLE_APPLICATION_CREDENTIALS}
+ dest: /tmp/.gcp-credentials.json
+ - src: ${GCP_OIDC_TOKEN_FILE}
+ dest: /sandbox/workspace/.gcp-oidc-token
+ optional: true
+ - src: /tmp/workspace/issue-context.json
+ dest: /tmp/workspace/issue-context.json
+ optional: true
+ - src: /tmp/workspace/exploration_context.json
+ dest: /tmp/workspace/exploration_context.json
+ optional: true
+ - src: /tmp/workspace/critique-feedback.json
+ dest: /tmp/workspace/critique-feedback.json
+ optional: true
+
+skills: []
+
+pre_script: scripts/pre-refine.sh
+post_script: scripts/post-refine.sh
+
+validation_loop:
+ script: scripts/validate-output-schema.sh
+ max_iterations: 2
+
+runner_env:
+ ISSUE_KEY: "${ISSUE_KEY}"
+ ISSUE_SOURCE: "${ISSUE_SOURCE}"
+ REPO_FULL_NAME: "${REPO_FULL_NAME}"
+ EXPLORE_RUN_ID: "${EXPLORE_RUN_ID}"
+ EXPLORE_CONTEXT_REF: "${EXPLORE_CONTEXT_REF}"
+ CRITIQUE_RUN_ID: "${CRITIQUE_RUN_ID}"
+ REVIEW_ROUND: "${REVIEW_ROUND}"
+ MAX_REVIEW_ROUNDS: "${MAX_REVIEW_ROUNDS}"
+ AUTO_CREATE: "${AUTO_CREATE}"
+ GITHUB_ISSUE_NUMBER: "${GITHUB_ISSUE_NUMBER}"
+ GH_TOKEN: "${GH_TOKEN}"
+ FULLSEND_OUTPUT_SCHEMA: ${FULLSEND_DIR}/schemas/refine-result.schema.json
+
+timeout_minutes: 25
+
+forge:
+ github:
+ pre_script: scripts/pre-refine.sh
+ post_script: scripts/post-refine.sh
+ runner_env:
+ GITHUB_ISSUE_URL: ${GITHUB_ISSUE_URL}
+ GH_TOKEN: ${GH_TOKEN}
diff --git a/internal/scaffold/fullsend-repo/policies/critique.yaml b/internal/scaffold/fullsend-repo/policies/critique.yaml
new file mode 100644
index 000000000..fbb12ce4a
--- /dev/null
+++ b/internal/scaffold/fullsend-repo/policies/critique.yaml
@@ -0,0 +1,53 @@
+---
+version: 1
+
+filesystem_policy:
+ include_workdir: true
+ read_only: [/usr, /lib, /proc, /dev/urandom, /app, /etc, /var/log]
+ read_write: [/sandbox, /tmp, /dev/null]
+landlock:
+ compatibility: best_effort
+process:
+ run_as_user: sandbox
+ run_as_group: sandbox
+
+network_policies:
+ vertex_ai:
+ name: vertex-ai
+ endpoints:
+ - host: "api.anthropic.com"
+ port: 443
+ protocol: rest
+ enforcement: enforce
+ access: read-write
+ - host: "*.googleapis.com"
+ port: 443
+ protocol: rest
+ enforcement: enforce
+ access: read-write
+ binaries:
+ - path: "**/claude"
+ - path: "**/node"
+
+ github_api:
+ name: github-api
+ endpoints:
+ - host: "api.github.com"
+ port: 443
+ protocol: rest
+ enforcement: enforce
+ access: read-write
+ - host: "github.com"
+ port: 443
+ protocol: rest
+ enforcement: enforce
+ access: read-write
+ - host: "raw.githubusercontent.com"
+ port: 443
+ protocol: rest
+ enforcement: enforce
+ access: read-only
+ binaries:
+ - path: "**/gh"
+ - path: "**/git"
+ - path: "**/node"
diff --git a/internal/scaffold/fullsend-repo/policies/explore.yaml b/internal/scaffold/fullsend-repo/policies/explore.yaml
new file mode 100644
index 000000000..c57014197
--- /dev/null
+++ b/internal/scaffold/fullsend-repo/policies/explore.yaml
@@ -0,0 +1,71 @@
+---
+version: 1
+
+filesystem_policy:
+ include_workdir: true
+ read_only: [/usr, /lib, /proc, /dev/urandom, /app, /etc, /var/log]
+ read_write: [/sandbox, /tmp, /dev/null]
+landlock:
+ compatibility: best_effort
+process:
+ run_as_user: sandbox
+ run_as_group: sandbox
+
+network_policies:
+ vertex_ai:
+ name: vertex-ai
+ endpoints:
+ - host: "api.anthropic.com"
+ port: 443
+ protocol: rest
+ enforcement: enforce
+ access: read-write
+ - host: "*.googleapis.com"
+ port: 443
+ protocol: rest
+ enforcement: enforce
+ access: read-write
+ binaries:
+ - path: "**/claude"
+ - path: "**/node"
+
+ github_api:
+ name: github-api
+ endpoints:
+ - host: "api.github.com"
+ port: 443
+ protocol: rest
+ enforcement: enforce
+ access: read-write
+ - host: "github.com"
+ port: 443
+ protocol: rest
+ enforcement: enforce
+ access: read-write
+ - host: "raw.githubusercontent.com"
+ port: 443
+ protocol: rest
+ enforcement: enforce
+ access: read-only
+ binaries:
+ - path: "**/gh"
+ - path: "**/git"
+ - path: "**/node"
+ - path: "**/curl"
+
+ web_search:
+ name: web-search
+ endpoints:
+ - host: "api.tavily.com"
+ port: 443
+ protocol: rest
+ enforcement: enforce
+ access: read-write
+ - host: "*.google.com"
+ port: 443
+ protocol: rest
+ enforcement: enforce
+ access: read-only
+ binaries:
+ - path: "**/curl"
+ - path: "**/node"
diff --git a/internal/scaffold/fullsend-repo/policies/refine.yaml b/internal/scaffold/fullsend-repo/policies/refine.yaml
new file mode 100644
index 000000000..fbb12ce4a
--- /dev/null
+++ b/internal/scaffold/fullsend-repo/policies/refine.yaml
@@ -0,0 +1,53 @@
+---
+version: 1
+
+filesystem_policy:
+ include_workdir: true
+ read_only: [/usr, /lib, /proc, /dev/urandom, /app, /etc, /var/log]
+ read_write: [/sandbox, /tmp, /dev/null]
+landlock:
+ compatibility: best_effort
+process:
+ run_as_user: sandbox
+ run_as_group: sandbox
+
+network_policies:
+ vertex_ai:
+ name: vertex-ai
+ endpoints:
+ - host: "api.anthropic.com"
+ port: 443
+ protocol: rest
+ enforcement: enforce
+ access: read-write
+ - host: "*.googleapis.com"
+ port: 443
+ protocol: rest
+ enforcement: enforce
+ access: read-write
+ binaries:
+ - path: "**/claude"
+ - path: "**/node"
+
+ github_api:
+ name: github-api
+ endpoints:
+ - host: "api.github.com"
+ port: 443
+ protocol: rest
+ enforcement: enforce
+ access: read-write
+ - host: "github.com"
+ port: 443
+ protocol: rest
+ enforcement: enforce
+ access: read-write
+ - host: "raw.githubusercontent.com"
+ port: 443
+ protocol: rest
+ enforcement: enforce
+ access: read-only
+ binaries:
+ - path: "**/gh"
+ - path: "**/git"
+ - path: "**/node"
diff --git a/internal/scaffold/fullsend-repo/schemas/critique-result.schema.json b/internal/scaffold/fullsend-repo/schemas/critique-result.schema.json
new file mode 100644
index 000000000..5a3594681
--- /dev/null
+++ b/internal/scaffold/fullsend-repo/schemas/critique-result.schema.json
@@ -0,0 +1,135 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "title": "Critique Result",
+ "description": "Output schema for the critique agent — either an approval or revision request for a refinement plan.",
+ "type": "object",
+ "required": ["input", "verdict", "refinement_plan_summary", "review_round"],
+ "properties": {
+ "input": {
+ "type": "object",
+ "required": ["source", "key", "level", "summary"],
+ "properties": {
+ "source": { "type": "string", "enum": ["jira", "github", "text"] },
+ "key": { "type": "string" },
+ "level": { "type": "string", "enum": ["outcome", "feature", "epic", "story", "task", "issue"] },
+ "summary": { "type": "string" }
+ }
+ },
+ "verdict": {
+ "type": "string",
+ "enum": ["approved", "revise", "needs_input"]
+ },
+ "question": {
+ "type": "object",
+ "description": "Required when verdict is needs_input. The ONE question that a human must answer.",
+ "properties": {
+ "dimension": { "type": "string" },
+ "text": { "type": "string" },
+ "impact": { "type": "string" },
+ "what_refine_assumed": { "type": "string" }
+ },
+ "required": ["dimension", "text", "impact"]
+ },
+ "review_round": {
+ "type": "integer",
+ "minimum": 1
+ },
+ "refinement_plan_summary": {
+ "type": "object",
+ "required": ["epic_count", "story_count", "task_count", "total_children"],
+ "properties": {
+ "epic_count": { "type": "integer", "minimum": 0 },
+ "story_count": { "type": "integer", "minimum": 0 },
+ "task_count": { "type": "integer", "minimum": 0 },
+ "total_children": { "type": "integer", "minimum": 0 }
+ }
+ },
+ "assessment": {
+ "type": "object",
+ "properties": {
+ "coverage": {
+ "type": "object",
+ "properties": {
+ "score": { "type": "number", "minimum": 0, "maximum": 100 },
+ "reasoning": { "type": "string" }
+ }
+ },
+ "granularity": {
+ "type": "object",
+ "properties": {
+ "score": { "type": "number", "minimum": 0, "maximum": 100 },
+ "reasoning": { "type": "string" }
+ }
+ },
+ "dependency_coherence": {
+ "type": "object",
+ "properties": {
+ "score": { "type": "number", "minimum": 0, "maximum": 100 },
+ "reasoning": { "type": "string" }
+ }
+ },
+ "implementability": {
+ "type": "object",
+ "properties": {
+ "score": { "type": "number", "minimum": 0, "maximum": 100 },
+ "reasoning": { "type": "string" }
+ }
+ },
+ "scope_accuracy": {
+ "type": "object",
+ "properties": {
+ "score": { "type": "number", "minimum": 0, "maximum": 100 },
+ "reasoning": { "type": "string" }
+ }
+ },
+ "assumption_grounding": {
+ "type": "object",
+ "description": "Are the plan's architectural decisions backed by exploration context or unverified guesses?",
+ "properties": {
+ "score": { "type": "number", "minimum": 0, "maximum": 100 },
+ "reasoning": { "type": "string" }
+ }
+ },
+ "overall": { "type": "number", "minimum": 0, "maximum": 100 }
+ }
+ },
+ "revisions": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": ["type", "target", "reasoning"],
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": ["remove", "merge", "split", "revise", "add"]
+ },
+ "target": {
+ "type": "string",
+ "description": "Title of the child item this revision targets, or 'plan' for plan-level feedback"
+ },
+ "reasoning": { "type": "string" },
+ "suggestion": { "type": "string" }
+ }
+ }
+ },
+ "comment": { "type": "string", "maxLength": 4000 },
+ "summary": { "type": "string", "maxLength": 2000 }
+ },
+ "allOf": [
+ {
+ "if": { "properties": { "verdict": { "const": "revise" } } },
+ "then": {
+ "required": ["input", "verdict", "refinement_plan_summary", "review_round", "revisions", "comment", "summary"],
+ "properties": { "revisions": { "minItems": 1 } }
+ }
+ },
+ {
+ "if": { "properties": { "verdict": { "const": "needs_input" } } },
+ "then": { "required": ["input", "verdict", "refinement_plan_summary", "review_round", "question", "comment", "summary"] }
+ },
+ {
+ "if": { "properties": { "verdict": { "const": "approved" } } },
+ "then": { "required": ["input", "verdict", "refinement_plan_summary", "review_round", "assessment", "comment", "summary"] }
+ }
+ ]
+}
diff --git a/internal/scaffold/fullsend-repo/schemas/explore-result.schema.json b/internal/scaffold/fullsend-repo/schemas/explore-result.schema.json
new file mode 100644
index 000000000..6e4662cea
--- /dev/null
+++ b/internal/scaffold/fullsend-repo/schemas/explore-result.schema.json
@@ -0,0 +1,97 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "title": "Exploration Result",
+ "description": "Output schema for the explore agent — public research context for a work item.",
+ "type": "object",
+ "required": ["input", "technical_landscape", "related_work", "confidence", "summary"],
+ "properties": {
+ "input": {
+ "type": "object",
+ "required": ["source", "key", "level", "summary"],
+ "properties": {
+ "source": { "type": "string", "enum": ["jira", "github", "text", "web"] },
+ "key": { "type": "string" },
+ "level": { "type": "string", "enum": ["outcome", "feature", "epic", "story", "task", "issue"] },
+ "summary": { "type": "string" }
+ }
+ },
+ "technical_landscape": {
+ "type": "object",
+ "properties": {
+ "languages": { "type": "array", "items": { "type": "string" } },
+ "frameworks": { "type": "array", "items": { "type": "string" } },
+ "build_system": { "type": "string" },
+ "deployment_targets": { "type": "array", "items": { "type": "string" } },
+ "key_dependencies": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "name": { "type": "string" },
+ "version": { "type": "string" },
+ "role": { "type": "string" }
+ }
+ }
+ },
+ "existing_patterns": { "type": "array", "items": { "type": "string" } },
+ "api_surface": { "type": "array", "items": { "type": "string" } },
+ "test_infrastructure": { "type": "string" }
+ }
+ },
+ "related_work": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": ["type", "source", "key", "title", "relevance"],
+ "properties": {
+ "type": { "type": "string", "enum": ["issue", "pr", "discussion", "adr", "documentation", "test", "rfc", "blog", "commit", "release", "other"] },
+ "source": { "type": "string" },
+ "key": { "type": "string" },
+ "title": { "type": "string" },
+ "state": { "type": "string" },
+ "relevance": { "type": "string" }
+ }
+ }
+ },
+ "architectural_constraints": {
+ "type": "array",
+ "items": { "type": "string" }
+ },
+ "competitive_context": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "alternative": { "type": "string" },
+ "approach": { "type": "string" },
+ "source_url": { "type": "string" }
+ }
+ }
+ },
+ "gaps": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": ["dimension", "description"],
+ "properties": {
+ "dimension": { "type": "string" },
+ "description": { "type": "string" },
+ "impact": { "type": "string" }
+ }
+ }
+ },
+ "confidence": {
+ "type": "object",
+ "required": ["overall"],
+ "properties": {
+ "technical_landscape": { "type": "number", "minimum": 0, "maximum": 100 },
+ "related_work": { "type": "number", "minimum": 0, "maximum": 100 },
+ "architectural_constraints": { "type": "number", "minimum": 0, "maximum": 100 },
+ "competitive_context": { "type": "number", "minimum": 0, "maximum": 100 },
+ "requirements_clarity": { "type": "number", "minimum": 0, "maximum": 100 },
+ "overall": { "type": "number", "minimum": 0, "maximum": 100 }
+ }
+ },
+ "summary": { "type": "string", "maxLength": 1000 }
+ }
+}
diff --git a/internal/scaffold/fullsend-repo/schemas/refine-result.schema.json b/internal/scaffold/fullsend-repo/schemas/refine-result.schema.json
new file mode 100644
index 000000000..b2483f2fa
--- /dev/null
+++ b/internal/scaffold/fullsend-repo/schemas/refine-result.schema.json
@@ -0,0 +1,94 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "title": "Refinement Result",
+ "description": "Output schema for the refine agent — always a complete decomposition plan.",
+ "type": "object",
+ "required": ["input", "status", "confidence", "target_level", "children", "comment", "summary"],
+ "properties": {
+ "input": {
+ "type": "object",
+ "required": ["source", "key", "level", "summary"],
+ "properties": {
+ "source": { "type": "string", "enum": ["jira", "github", "text"] },
+ "key": { "type": "string" },
+ "level": { "type": "string", "enum": ["outcome", "feature", "epic", "story", "task", "issue"] },
+ "summary": { "type": "string" }
+ }
+ },
+ "status": {
+ "type": "string",
+ "enum": ["complete"]
+ },
+ "target_level": {
+ "type": "string",
+ "enum": ["feature", "epic", "story", "task", "sub-issue"]
+ },
+ "confidence": {
+ "type": "object",
+ "required": ["overall"],
+ "properties": {
+ "scope_clarity": { "type": "number", "minimum": 0, "maximum": 100 },
+ "technical_grounding": { "type": "number", "minimum": 0, "maximum": 100 },
+ "acceptance_criteria": { "type": "number", "minimum": 0, "maximum": 100 },
+ "dependencies": { "type": "number", "minimum": 0, "maximum": 100 },
+ "sizing": { "type": "number", "minimum": 0, "maximum": 100 },
+ "overall": { "type": "number", "minimum": 0, "maximum": 100 }
+ }
+ },
+ "children": {
+ "type": "array",
+ "minItems": 1,
+ "items": {
+ "type": "object",
+ "required": ["title", "type", "description", "acceptance_criteria"],
+ "properties": {
+ "title": { "type": "string" },
+ "type": { "type": "string", "description": "Issue type matching the project's available types (lowercase). Common: feature, epic, story, task, sub-issue, bug, spike." },
+ "parent_title": { "type": ["string", "null"] },
+ "description": { "type": "string" },
+ "acceptance_criteria": {
+ "type": "array",
+ "items": { "type": "string" },
+ "minItems": 1
+ },
+ "dependencies": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "type": { "type": "string", "enum": ["blocks", "blocked_by", "related"] },
+ "target": { "type": "string" },
+ "description": { "type": "string" }
+ }
+ }
+ },
+ "labels": { "type": "array", "items": { "type": "string" } },
+ "priority": { "type": "string", "enum": ["highest", "critical", "high", "medium", "low"] },
+ "estimated_scope": { "type": "string", "enum": ["S", "M", "L", "XL"] },
+ "confidence": { "type": "number", "minimum": 0, "maximum": 100 },
+ "deployment_target": { "type": "string" }
+ }
+ }
+ },
+ "open_questions": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": ["dimension", "question", "impact"],
+ "properties": {
+ "dimension": { "type": "string" },
+ "question": { "type": "string" },
+ "impact": { "type": "string" },
+ "assumption_used": { "type": "string" }
+ }
+ }
+ },
+ "proposed_description": { "type": "string", "maxLength": 20000 },
+ "dimensions_covered": { "type": "array", "items": { "type": "string" } },
+ "dimensions_missing": { "type": "array", "items": { "type": "string" } },
+ "uncited_assumptions": { "type": "array", "items": { "type": "string" } },
+ "deployment_targets": { "type": "array", "items": { "type": "string" } },
+ "comment": { "type": "string", "maxLength": 4000 },
+ "summary": { "type": "string", "maxLength": 2000 }
+ }
+}
diff --git a/internal/scaffold/fullsend-repo/scripts/create-children-test.sh b/internal/scaffold/fullsend-repo/scripts/create-children-test.sh
new file mode 100755
index 000000000..8aebbb7a7
--- /dev/null
+++ b/internal/scaffold/fullsend-repo/scripts/create-children-test.sh
@@ -0,0 +1,207 @@
+#!/usr/bin/env bash
+# create-children-test.sh — Test create-children.sh issue creation logic.
+#
+# Run from the repo root: bash internal/scaffold/fullsend-repo/scripts/create-children-test.sh
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+CREATE_SCRIPT="${SCRIPT_DIR}/create-children.sh"
+FAILURES=0
+
+TEST_TMPDIR="$(mktemp -d)"
+trap 'rm -rf "${TEST_TMPDIR}"' EXIT
+
+GH_LOG="${TEST_TMPDIR}/gh-calls.log"
+MOCK_BIN="${TEST_TMPDIR}/bin"
+ISSUE_COUNTER_FILE="${TEST_TMPDIR}/issue-counter"
+mkdir -p "${MOCK_BIN}"
+
+echo "100" > "${ISSUE_COUNTER_FILE}"
+
+cat > "${MOCK_BIN}/gh" <> "${GH_LOG}"
+
+case "\$*" in
+ *"label create"*)
+ exit 0
+ ;;
+ *"issue create"*)
+ cat > /dev/null # consume --body-file stdin
+ counter=\$(cat "${ISSUE_COUNTER_FILE}")
+ counter=\$((counter + 1))
+ echo "\$counter" > "${ISSUE_COUNTER_FILE}"
+ echo "https://github.com/test-org/test-repo/issues/\${counter}"
+ exit 0
+ ;;
+ *"repos/"*"/issues/"*"--jq"*)
+ echo "I_node_id_123"
+ exit 0
+ ;;
+ *"repos/"*"/issues/"*)
+ echo '{"id": "I_node_id_123"}'
+ exit 0
+ ;;
+ *"sub_issues"*)
+ exit 0
+ ;;
+esac
+exit 0
+MOCKEOF
+chmod +x "${MOCK_BIN}/gh"
+
+cat > "${MOCK_BIN}/python3" <<'MOCKEOF'
+#!/usr/bin/env bash
+if [[ "${1:-}" == "-c" ]]; then
+ if [[ "${2:-}" == *"time.time"* ]]; then
+ echo "1000000"
+ exit 0
+ fi
+ echo ""
+ exit 0
+fi
+exec /usr/bin/python3 "$@"
+MOCKEOF
+chmod +x "${MOCK_BIN}/python3"
+
+export PATH="${MOCK_BIN}:${PATH}"
+export GH_LOG="${GH_LOG}"
+export GH_TOKEN="fake-token"
+export ISSUE_KEY="42"
+export ISSUE_SOURCE="github"
+export REPO_FULL_NAME="test-org/test-repo"
+export GITHUB_ISSUE_NUMBER="42"
+
+FLAT_CHILDREN_FIXTURE='{
+ "status": "complete",
+ "children": [
+ {"title": "Child A", "type": "story", "description": "First child", "acceptance_criteria": ["AC1"], "priority": "high", "estimated_scope": "S"},
+ {"title": "Child B", "type": "task", "description": "Second child", "acceptance_criteria": ["AC2"], "priority": "medium", "estimated_scope": "M"}
+ ]
+}'
+
+HIERARCHICAL_FIXTURE='{
+ "status": "complete",
+ "children": [
+ {"title": "Epic Parent", "type": "epic", "description": "Parent epic", "acceptance_criteria": ["AC-E1"]},
+ {"title": "Story under Epic", "type": "story", "parent_title": "Epic Parent", "description": "Child story", "acceptance_criteria": ["AC-S1"]},
+ {"title": "Task under Story", "type": "task", "parent_title": "Story under Epic", "description": "Grandchild task", "acceptance_criteria": ["AC-T1"]}
+ ]
+}'
+
+ORPHAN_FIXTURE='{
+ "status": "complete",
+ "children": [
+ {"title": "Good Child", "type": "story", "description": "Has no parent ref", "acceptance_criteria": ["AC1"]},
+ {"title": "Orphan", "type": "task", "parent_title": "Nonexistent Parent", "description": "Bad ref", "acceptance_criteria": ["AC2"]}
+ ]
+}'
+
+run_test() {
+ local test_name="$1"
+ local fixture="$2"
+ local expect_failure="${3:-false}"
+
+ local result_file="${TEST_TMPDIR}/result-${test_name}.json"
+ echo "${fixture}" > "${result_file}"
+
+ echo "100" > "${ISSUE_COUNTER_FILE}"
+ : > "${GH_LOG}"
+
+ local exit_code=0
+ RESULT_FILE="${result_file}" bash "${CREATE_SCRIPT}" > "${TEST_TMPDIR}/stdout-${test_name}.log" 2>&1 || exit_code=$?
+
+ if [[ "${expect_failure}" == "true" ]]; then
+ if [[ ${exit_code} -eq 0 ]]; then
+ echo "FAIL: ${test_name} — expected failure but got success"
+ FAILURES=$((FAILURES + 1))
+ return
+ fi
+ echo "PASS: ${test_name} (expected failure)"
+ return
+ fi
+
+ if [[ ${exit_code} -ne 0 ]]; then
+ echo "FAIL: ${test_name} — exit code ${exit_code}"
+ cat "${TEST_TMPDIR}/stdout-${test_name}.log"
+ FAILURES=$((FAILURES + 1))
+ return
+ fi
+
+ echo "PASS: ${test_name}"
+}
+
+assert_gh_called() {
+ local test_name="$1" pattern="$2"
+ if ! grep -qF "${pattern}" "${GH_LOG}"; then
+ echo "FAIL: ${test_name} — expected gh call matching '${pattern}'"
+ cat "${GH_LOG}"
+ FAILURES=$((FAILURES + 1))
+ fi
+}
+
+assert_stdout_contains() {
+ local test_name="$1" pattern="$2"
+ if ! grep -qF "${pattern}" "${TEST_TMPDIR}/stdout-${test_name}.log"; then
+ echo "FAIL: ${test_name} — expected stdout containing '${pattern}'"
+ FAILURES=$((FAILURES + 1))
+ fi
+}
+
+count_gh_calls() {
+ local pattern="$1"
+ grep -cF "${pattern}" "${GH_LOG}" 2>/dev/null || echo "0"
+}
+
+# --- Tests ---
+
+# 1. Flat children — 2 issues created under root parent
+run_test "flat-children" "$FLAT_CHILDREN_FIXTURE"
+ISSUE_CREATES=$(count_gh_calls "issue create")
+if [[ "$ISSUE_CREATES" == "2" ]]; then
+ echo "PASS: flat-children created 2 issues"
+else
+ echo "FAIL: flat-children — expected 2 issue creates, got ${ISSUE_CREATES}"
+ FAILURES=$((FAILURES + 1))
+fi
+
+# 2. Hierarchical children — topological ordering
+run_test "hierarchical" "$HIERARCHICAL_FIXTURE"
+assert_stdout_contains "hierarchical" "Created"
+ISSUE_CREATES=$(count_gh_calls "issue create")
+if [[ "$ISSUE_CREATES" == "3" ]]; then
+ echo "PASS: hierarchical created 3 issues"
+else
+ echo "FAIL: hierarchical — expected 3 issue creates, got ${ISSUE_CREATES}"
+ FAILURES=$((FAILURES + 1))
+fi
+
+# 3. Orphan fallback — unresolvable parent_title falls back to root
+run_test "orphan-fallback" "$ORPHAN_FIXTURE"
+assert_stdout_contains "orphan-fallback" "orphan"
+ISSUE_CREATES=$(count_gh_calls "issue create")
+if [[ "$ISSUE_CREATES" == "2" ]]; then
+ echo "PASS: orphan-fallback created 2 issues"
+else
+ echo "FAIL: orphan-fallback — expected 2 issue creates, got ${ISSUE_CREATES}"
+ FAILURES=$((FAILURES + 1))
+fi
+
+# 4. Missing RESULT_FILE
+unset RESULT_FILE
+run_test "missing-result-file" "unused" "true"
+export RESULT_FILE="/nonexistent"
+run_test "nonexistent-result-file" "unused" "true"
+
+# 5. Invalid JSON
+run_test "invalid-json" "not valid json" "true"
+
+if [[ ${FAILURES} -gt 0 ]]; then
+ echo ""
+ echo "${FAILURES} test(s) failed."
+ exit 1
+fi
+
+echo ""
+echo "All create-children tests passed."
diff --git a/internal/scaffold/fullsend-repo/scripts/create-children.sh b/internal/scaffold/fullsend-repo/scripts/create-children.sh
new file mode 100755
index 000000000..9f1c3e933
--- /dev/null
+++ b/internal/scaffold/fullsend-repo/scripts/create-children.sh
@@ -0,0 +1,421 @@
+#!/usr/bin/env bash
+# create-children.sh — Create child issues from an approved refinement plan.
+#
+# Reusable script that reads a refinement result JSON and creates child issues
+# in topological order using parent_title references for hierarchy.
+#
+# Can be called from:
+# - post-critique.sh (auto-approval path)
+# - create-children.yml workflow (human-approval path)
+#
+# Required env vars:
+# RESULT_FILE — Path to the approved agent-result.json
+# ISSUE_KEY — Parent issue identifier (Jira key or GH issue number)
+# ISSUE_SOURCE — "jira" or "github"
+# GH_TOKEN — GitHub token
+#
+# GitHub flow env vars:
+# GITHUB_ISSUE_NUMBER — GitHub issue number
+# REPO_FULL_NAME — owner/repo
+# PUSH_TOKEN — Token with write access
+#
+# Jira flow env vars:
+# JIRA_HOST, JIRA_EMAIL, JIRA_API_TOKEN
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+if [[ -f "${SCRIPT_DIR}/pipeline-events.sh" ]]; then
+ source "${SCRIPT_DIR}/pipeline-events.sh"
+ # shellcheck disable=SC2034 # HAS_PE used by callers that source this script
+ HAS_PE=true
+else
+ # shellcheck disable=SC2034
+ HAS_PE=false
+ pe_start() { :; }
+ pe_end() { :; }
+fi
+
+if [[ -z "${RESULT_FILE:-}" ]]; then
+ echo "ERROR: RESULT_FILE env var not set"
+ exit 1
+fi
+
+if [[ ! -f "${RESULT_FILE}" ]]; then
+ echo "ERROR: Result file not found: ${RESULT_FILE}"
+ exit 1
+fi
+
+if ! jq empty "${RESULT_FILE}" 2>/dev/null; then
+ echo "ERROR: ${RESULT_FILE} is not valid JSON"
+ exit 1
+fi
+
+# Dry-run validation: verify children structure before touching external APIs
+VALIDATION_ERRORS=$(jq -r '
+ if (.children | type) != "array" then "RESULT_FILE has no .children array"
+ elif (.children | length) == 0 then ".children array is empty — nothing to create"
+ else
+ [.children | to_entries[] |
+ (if (.value.title // "" | length) == 0 then "child[\(.key)]: missing title" else empty end),
+ (if (.value.type // "" | length) == 0 then "child[\(.key)]: missing type" else empty end),
+ (if (.value.description // "" | length) == 0 then "child[\(.key)]: missing description" else empty end)
+ ] |
+ if length > 0 then join("\n") else empty end
+ end // empty
+' "${RESULT_FILE}" 2>/dev/null)
+
+if [[ -n "${VALIDATION_ERRORS}" ]]; then
+ echo "::error::Dry-run validation failed — child issue structure is invalid:"
+ echo "${VALIDATION_ERRORS}" | while IFS= read -r line; do
+ echo "::error:: ${line}"
+ done
+ echo "::error::Fix the refine agent output and retry. No issues were created."
+ exit 1
+fi
+
+# Validate parent_title references resolve within the tree
+ORPHAN_REFS=$(jq -r '
+ [.children[].title] as $titles |
+ [.children[] | select(.parent_title != null and .parent_title != "") |
+ select([.parent_title] | inside($titles) | not) |
+ "parent_title \"\(.parent_title)\" (in child \"\(.title)\") not found in children list"
+ ] | if length > 0 then join("\n") else empty end
+' "${RESULT_FILE}" 2>/dev/null)
+
+if [[ -n "${ORPHAN_REFS}" ]]; then
+ echo "::warning::Some parent_title references don't match any child title (will fall back to root):"
+ echo "${ORPHAN_REFS}" | while IFS= read -r line; do
+ echo "::warning:: ${line}"
+ done
+fi
+
+echo "Dry-run validation passed: $(jq '.children | length' "${RESULT_FILE}") children, structure OK"
+
+USE_GITHUB=false
+if [[ -n "${GITHUB_ISSUE_NUMBER:-}" && "${GITHUB_ISSUE_NUMBER}" != "" && "${GITHUB_ISSUE_NUMBER}" != "N/A" ]]; then
+ USE_GITHUB=true
+elif [[ "${ISSUE_SOURCE:-}" == "github" ]]; then
+ USE_GITHUB=true
+ GITHUB_ISSUE_NUMBER="${ISSUE_KEY}"
+fi
+
+# --- Helper functions ---
+
+github_create_issue() {
+ local repo="$1" title="$2" body="$3" labels="$4" parent_number="${5:-}"
+ local args=(--repo "$repo" --title "$title")
+ if [[ -n "$labels" && "$labels" != "null" ]]; then
+ while IFS= read -r label; do
+ if [[ -n "$label" ]]; then
+ gh label create "$label" --repo "$repo" --force 2>/dev/null || true
+ args+=(--label "$label")
+ fi
+ done < <(echo "$labels" | jq -r '.[]')
+ fi
+ local result
+ result=$(printf '%s' "$body" | gh issue create "${args[@]}" --body-file - 2>&1) || {
+ echo "::warning::Failed to create issue '${title}': ${result}" >&2
+ echo "FAILED"
+ return 0
+ }
+
+ local issue_number
+ issue_number=$(echo "$result" | grep -oP '/issues/\K[0-9]+' || true)
+
+ if [[ -n "$parent_number" && -n "$issue_number" ]]; then
+ local child_id
+ child_id=$(gh api "repos/${repo}/issues/${issue_number}" --jq '.id' 2>/dev/null)
+ if [[ -n "$child_id" ]]; then
+ gh api "repos/${repo}/issues/${parent_number}/sub_issues" \
+ -F sub_issue_id="$child_id" \
+ --silent 2>/dev/null || \
+ echo "::warning::Could not link #${issue_number} as sub-issue of #${parent_number}" >&2
+ fi
+ fi
+
+ echo "$issue_number"
+}
+
+jira_create_issue() {
+ local project="$1" type="$2" summary="$3" description="$4" parent_key="${5:-}"
+ local auth
+ auth=$(printf '%s:%s' "$JIRA_EMAIL" "$JIRA_API_TOKEN" | base64 -w0)
+
+ # Build ADF description: split on double-newlines into paragraphs,
+ # single newlines become hardBreak nodes within a paragraph.
+ local adf_desc
+ adf_desc=$(python3 -c "
+import json, sys
+
+text = sys.argv[1]
+paragraphs = text.split('\n\n')
+content = []
+for para in paragraphs:
+ para = para.strip()
+ if not para:
+ continue
+ # Split single newlines into text + hardBreak sequences
+ lines = para.split('\n')
+ inline_content = []
+ for i, line in enumerate(lines):
+ if line.strip():
+ inline_content.append({'type': 'text', 'text': line})
+ if i < len(lines) - 1:
+ inline_content.append({'type': 'hardBreak'})
+ if inline_content:
+ content.append({'type': 'paragraph', 'content': inline_content})
+
+doc = {'type': 'doc', 'version': 1, 'content': content if content else [{'type': 'paragraph', 'content': [{'type': 'text', 'text': ' '}]}]}
+print(json.dumps(doc))
+" "$description")
+
+ local payload
+ payload=$(jq -n \
+ --arg proj "$project" \
+ --arg type "$type" \
+ --arg summary "$summary" \
+ --argjson desc "$adf_desc" \
+ --arg parent "$parent_key" \
+ '{
+ fields: ({
+ project: {key: $proj},
+ issuetype: {name: $type},
+ summary: $summary,
+ description: $desc
+ } + (if $parent != "" then {parent: {key: $parent}} else {} end))
+ }')
+
+ local response http_code
+ response=$(curl -sS -w "\n%{http_code}" -X POST \
+ -H "Authorization: Basic $auth" \
+ -H "Content-Type: application/json" \
+ -d "$payload" \
+ "https://${JIRA_HOST}/rest/api/3/issue")
+
+ http_code=$(echo "$response" | tail -1)
+ local body
+ body=$(echo "$response" | sed '$d')
+
+ if [[ "$http_code" -ge 400 ]]; then
+ echo "::warning::Jira API returned ${http_code} creating '${summary}' (type: ${type}, parent: ${parent_key}): ${body}" >&2
+ # If parent hierarchy fails, retry without parent
+ if [[ -n "$parent_key" && "$http_code" == "400" ]]; then
+ echo " Retrying without parent..." >&2
+ payload=$(jq -n \
+ --arg proj "$project" \
+ --arg type "$type" \
+ --arg summary "$summary" \
+ --argjson desc "$adf_desc" \
+ '{
+ fields: {
+ project: {key: $proj},
+ issuetype: {name: $type},
+ summary: $summary,
+ description: $desc
+ }
+ }')
+ response=$(curl -sS -w "\n%{http_code}" -X POST \
+ -H "Authorization: Basic $auth" \
+ -H "Content-Type: application/json" \
+ -d "$payload" \
+ "https://${JIRA_HOST}/rest/api/3/issue")
+ http_code=$(echo "$response" | tail -1)
+ body=$(echo "$response" | sed '$d')
+ if [[ "$http_code" -ge 400 ]]; then
+ echo "::warning::Retry without parent also failed (${http_code}): ${body}" >&2
+ echo ""
+ return 0
+ fi
+ else
+ echo ""
+ return 0
+ fi
+ fi
+
+ echo "$body" | jq -r '.key'
+}
+
+resolve_jira_type() {
+ local requested_type="$1"
+ local available_types="${2:-}"
+
+ if [[ -z "$available_types" || "$available_types" == "[]" ]]; then
+ case "${requested_type,,}" in
+ feature) echo "Feature" ;;
+ epic) echo "Epic" ;;
+ story) echo "Story" ;;
+ task) echo "Task" ;;
+ spike) echo "Task" ;;
+ bug) echo "Bug" ;;
+ *) echo "Story" ;;
+ esac
+ return
+ fi
+
+ local match
+ match=$(echo "$available_types" | jq -r --arg t "$requested_type" \
+ '[.[].name] | map(select(ascii_downcase == ($t | ascii_downcase))) | .[0] // empty')
+
+ if [[ -n "$match" ]]; then
+ echo "$match"
+ return
+ fi
+
+ local fallback
+ fallback=$(echo "$available_types" | jq -r '
+ [.[] | select(.subtask != true) | .name] |
+ if any(. == "Story") then "Story"
+ elif any(. == "Task") then "Task"
+ elif any(. == "Bug") then "Bug"
+ else .[0] // "Story"
+ end')
+
+ echo "$fallback"
+}
+
+# Load available issue types from issue context if present
+AVAILABLE_TYPES="[]"
+ISSUE_CONTEXT_FILE="/tmp/workspace/issue-context.json"
+if [[ -f "$ISSUE_CONTEXT_FILE" ]]; then
+ AVAILABLE_TYPES=$(jq -c '.project.available_issue_types // []' "$ISSUE_CONTEXT_FILE")
+fi
+
+# --- Create children in topological order ---
+
+pe_start "create-children" "create-children"
+
+CHILD_COUNT=$(jq '.children | length' "${RESULT_FILE}")
+echo "Creating ${CHILD_COUNT} child issue(s) with hierarchy..."
+
+declare -A TITLE_TO_KEY
+CREATED_KEYS=()
+CREATED_COUNT=0
+MAX_PASSES=5
+PASS=0
+
+declare -A CREATED_IDX
+
+while [[ $CREATED_COUNT -lt $CHILD_COUNT && $PASS -lt $MAX_PASSES ]]; do
+ PASS=$((PASS + 1))
+ PROGRESS=false
+
+ for i in $(seq 0 $((CHILD_COUNT - 1))); do
+ if [[ -n "${CREATED_IDX[$i]:-}" ]]; then continue; fi
+
+ CHILD_TITLE=$(jq -r ".children[${i}].title" "${RESULT_FILE}")
+ CHILD_PARENT_TITLE=$(jq -r ".children[${i}].parent_title // \"\"" "${RESULT_FILE}")
+ CHILD_TYPE=$(jq -r ".children[${i}].type" "${RESULT_FILE}")
+ CHILD_DESC=$(jq -r ".children[${i}].description" "${RESULT_FILE}")
+ CHILD_AC=$(jq -r ".children[${i}].acceptance_criteria | map(\"- [ ] \" + .) | join(\"\n\")" "${RESULT_FILE}")
+ CHILD_LABELS=$(jq -c ".children[${i}].labels // []" "${RESULT_FILE}")
+ CHILD_PRIORITY=$(jq -r ".children[${i}].priority // \"medium\"" "${RESULT_FILE}")
+ CHILD_SCOPE=$(jq -r ".children[${i}].estimated_scope // \"M\"" "${RESULT_FILE}")
+
+ PARENT_KEY_FOR_CHILD=""
+ if [[ -z "$CHILD_PARENT_TITLE" || "$CHILD_PARENT_TITLE" == "null" ]]; then
+ PARENT_KEY_FOR_CHILD="$ISSUE_KEY"
+ elif [[ -n "${TITLE_TO_KEY[$CHILD_PARENT_TITLE]:-}" ]]; then
+ PARENT_KEY_FOR_CHILD="${TITLE_TO_KEY[$CHILD_PARENT_TITLE]}"
+ else
+ continue
+ fi
+
+ FULL_BODY="${CHILD_DESC}
+
+## Acceptance Criteria
+
+${CHILD_AC}
+
+---
+*Priority: ${CHILD_PRIORITY} | Scope: ${CHILD_SCOPE} | Generated by fullsend refine agent*"
+
+ if $USE_GITHUB; then
+ TYPE_LABEL="$CHILD_TYPE"
+ COMBINED_LABELS=$(echo "$CHILD_LABELS" | jq --arg t "$TYPE_LABEL" '. + [$t]')
+ NEW_ISSUE=$(github_create_issue "${REPO_FULL_NAME}" "$CHILD_TITLE" "$FULL_BODY" "$COMBINED_LABELS" "$PARENT_KEY_FOR_CHILD")
+ if [[ -z "$NEW_ISSUE" || "$NEW_ISSUE" == "FAILED" ]]; then
+ echo " [pass ${PASS}] FAILED to create ${CHILD_TYPE}: ${CHILD_TITLE}"
+ continue
+ fi
+ echo " [pass ${PASS}] Created ${CHILD_TYPE} #${NEW_ISSUE} under #${PARENT_KEY_FOR_CHILD}"
+ TITLE_TO_KEY["$CHILD_TITLE"]="$NEW_ISSUE"
+ CREATED_KEYS+=("#$NEW_ISSUE")
+ else
+ PROJECT_KEY=$(echo "$ISSUE_KEY" | sed 's/-.*//')
+ JIRA_TYPE=$(resolve_jira_type "$CHILD_TYPE" "$AVAILABLE_TYPES")
+ NEW_KEY=$(jira_create_issue "$PROJECT_KEY" "$JIRA_TYPE" "$CHILD_TITLE" "$FULL_BODY" "$PARENT_KEY_FOR_CHILD")
+ if [[ -z "$NEW_KEY" ]]; then
+ echo " [pass ${PASS}] FAILED to create ${JIRA_TYPE}: ${CHILD_TITLE}"
+ continue
+ fi
+ echo " [pass ${PASS}] Created ${JIRA_TYPE} ${NEW_KEY} under ${PARENT_KEY_FOR_CHILD} (requested: ${CHILD_TYPE})"
+ TITLE_TO_KEY["$CHILD_TITLE"]="$NEW_KEY"
+ CREATED_KEYS+=("$NEW_KEY")
+ fi
+
+ CREATED_IDX[$i]=1
+ CREATED_COUNT=$((CREATED_COUNT + 1))
+ PROGRESS=true
+ done
+
+ if ! $PROGRESS; then
+ echo "::warning::Pass ${PASS} made no progress — $((CHILD_COUNT - CREATED_COUNT)) items have unresolvable parent_title references"
+ break
+ fi
+done
+
+# Orphans fall back to root parent
+if [[ $CREATED_COUNT -lt $CHILD_COUNT ]]; then
+ echo "::warning::Creating remaining orphaned items under root issue"
+ for i in $(seq 0 $((CHILD_COUNT - 1))); do
+ if [[ -n "${CREATED_IDX[$i]:-}" ]]; then continue; fi
+
+ CHILD_TITLE=$(jq -r ".children[${i}].title" "${RESULT_FILE}")
+ CHILD_TYPE=$(jq -r ".children[${i}].type" "${RESULT_FILE}")
+ CHILD_DESC=$(jq -r ".children[${i}].description" "${RESULT_FILE}")
+ CHILD_AC=$(jq -r ".children[${i}].acceptance_criteria | map(\"- [ ] \" + .) | join(\"\n\")" "${RESULT_FILE}")
+ CHILD_LABELS=$(jq -c ".children[${i}].labels // []" "${RESULT_FILE}")
+ CHILD_PRIORITY=$(jq -r ".children[${i}].priority // \"medium\"" "${RESULT_FILE}")
+ CHILD_SCOPE=$(jq -r ".children[${i}].estimated_scope // \"M\"" "${RESULT_FILE}")
+
+ FULL_BODY="${CHILD_DESC}
+
+## Acceptance Criteria
+
+${CHILD_AC}
+
+---
+*Priority: ${CHILD_PRIORITY} | Scope: ${CHILD_SCOPE} | Generated by fullsend refine agent*"
+
+ if $USE_GITHUB; then
+ TYPE_LABEL="$CHILD_TYPE"
+ COMBINED_LABELS=$(echo "$CHILD_LABELS" | jq --arg t "$TYPE_LABEL" '. + [$t]')
+ NEW_ISSUE=$(github_create_issue "${REPO_FULL_NAME}" "$CHILD_TITLE" "$FULL_BODY" "$COMBINED_LABELS" "$ISSUE_KEY")
+ if [[ -z "$NEW_ISSUE" || "$NEW_ISSUE" == "FAILED" ]]; then
+ echo " [orphan] FAILED to create: ${CHILD_TITLE}"
+ continue
+ fi
+ echo " [orphan] Created #${NEW_ISSUE} under #${ISSUE_KEY}"
+ CREATED_KEYS+=("#$NEW_ISSUE")
+ else
+ PROJECT_KEY=$(echo "$ISSUE_KEY" | sed 's/-.*//')
+ JIRA_TYPE=$(resolve_jira_type "$CHILD_TYPE" "$AVAILABLE_TYPES")
+ NEW_KEY=$(jira_create_issue "$PROJECT_KEY" "$JIRA_TYPE" "$CHILD_TITLE" "$FULL_BODY" "$ISSUE_KEY")
+ if [[ -z "$NEW_KEY" ]]; then
+ echo " [orphan] FAILED to create ${JIRA_TYPE}: ${CHILD_TITLE}"
+ continue
+ fi
+ echo " [orphan] Created ${JIRA_TYPE}: ${NEW_KEY} (requested: ${CHILD_TYPE})"
+ CREATED_KEYS+=("$NEW_KEY")
+ fi
+ done
+fi
+
+pe_end "create-children" "create-children" "$(jq -nc --argjson total "$CHILD_COUNT" --argjson created "${#CREATED_KEYS[@]}" --argjson orphaned "$((CHILD_COUNT - CREATED_COUNT))" '{total:$total, created:$created, orphaned:$orphaned}')"
+
+echo "::notice::Created ${#CREATED_KEYS[@]} child issue(s): ${CREATED_KEYS[*]}"
+
+# Export for callers that need the result
+export CREATED_CHILD_COUNT="${#CREATED_KEYS[@]}"
+export CREATED_CHILD_KEYS="${CREATED_KEYS[*]}"
diff --git a/internal/scaffold/fullsend-repo/scripts/markdown-to-adf.py b/internal/scaffold/fullsend-repo/scripts/markdown-to-adf.py
new file mode 100755
index 000000000..60e3dc4d7
--- /dev/null
+++ b/internal/scaffold/fullsend-repo/scripts/markdown-to-adf.py
@@ -0,0 +1,291 @@
+#!/usr/bin/env python3
+"""Convert markdown-style text to Jira Atlassian Document Format (ADF).
+
+Usage:
+ echo "## Heading\n\nSome **bold** text" | python3 markdown-to-adf.py
+ python3 markdown-to-adf.py < comment.md
+
+Outputs a JSON object suitable for the Jira REST API comment body field.
+Handles: headings, bold, code, links, bullet lists, horizontal rules, paragraphs.
+"""
+
+import json
+import re
+import sys
+from urllib.parse import urlparse
+
+MAX_INPUT_BYTES = 128 * 1024
+ALLOWED_SCHEMES = {"http", "https", "mailto", ""}
+
+
+def parse_inline(text: str) -> list:
+ """Parse inline markdown (bold, code, links) into ADF inline nodes.
+
+ The input must be a single line — newlines are not permitted in ADF text
+ nodes. Use parse_inline_multiline() for text that may span lines.
+ """
+ nodes = []
+ pos = 0
+ pattern = re.compile(
+ r"(?P\*\*(.+?)\*\*)"
+ r"|(?P`([^`]+)`)"
+ r"|(?P\[([^\]]+)\]\(([^)]+)\))"
+ )
+ for m in pattern.finditer(text):
+ if m.start() > pos:
+ plain = text[pos : m.start()]
+ if plain:
+ nodes.append({"type": "text", "text": plain})
+ if m.group("bold"):
+ nodes.append(
+ {
+ "type": "text",
+ "text": m.group(2),
+ "marks": [{"type": "strong"}],
+ }
+ )
+ elif m.group("code"):
+ nodes.append(
+ {
+ "type": "text",
+ "text": m.group(4),
+ "marks": [{"type": "code"}],
+ }
+ )
+ elif m.group("link"):
+ href = m.group(7)
+ scheme = urlparse(href).scheme.lower()
+ if scheme in ALLOWED_SCHEMES:
+ nodes.append(
+ {
+ "type": "text",
+ "text": m.group(6),
+ "marks": [{"type": "link", "attrs": {"href": href}}],
+ }
+ )
+ else:
+ nodes.append({"type": "text", "text": f"{m.group(6)} ({href})"})
+ pos = m.end()
+ if pos < len(text):
+ remainder = text[pos:]
+ if remainder:
+ nodes.append({"type": "text", "text": remainder})
+ if not nodes and text:
+ nodes.append({"type": "text", "text": text})
+ return nodes
+
+
+def parse_inline_multiline(text: str) -> list:
+ """Parse inline markdown, inserting hardBreak nodes for newlines.
+
+ Jira's ADF validator rejects literal newline characters inside text nodes.
+ This function splits on newlines and inserts {"type": "hardBreak"} between
+ line segments so the output is valid ADF.
+ """
+ lines = text.split("\n")
+ nodes: list = []
+ for i, line in enumerate(lines):
+ if line:
+ nodes.extend(parse_inline(line))
+ if i < len(lines) - 1:
+ nodes.append({"type": "hardBreak"})
+ # Strip trailing hardBreaks (from trailing empty lines)
+ while nodes and nodes[-1].get("type") == "hardBreak":
+ nodes.pop()
+ return nodes if nodes else [{"type": "text", "text": " "}]
+
+
+def text_to_adf(text: str) -> dict:
+ """Convert markdown-style text to an ADF document."""
+ doc = {"type": "doc", "version": 1, "content": []}
+ blocks = re.split(r"\n{2,}", text.strip())
+
+ for block in blocks:
+ block = block.strip()
+ if not block:
+ continue
+
+ if block == "---":
+ doc["content"].append({"type": "rule"})
+ continue
+
+ heading_match = re.match(r"^(#{1,6})\s+(.+)$", block)
+ if heading_match:
+ level = len(heading_match.group(1))
+ doc["content"].append(
+ {
+ "type": "heading",
+ "attrs": {"level": level},
+ "content": parse_inline(heading_match.group(2)),
+ }
+ )
+ continue
+
+ lines = block.split("\n")
+
+ if all(re.match(r"^\s*[-*]\s+", line) for line in lines if line.strip()):
+ list_node = {"type": "bulletList", "content": []}
+ for line in lines:
+ item_text = re.sub(r"^\s*[-*]\s+", "", line).strip()
+ if item_text:
+ list_node["content"].append(
+ {
+ "type": "listItem",
+ "content": [{"type": "paragraph", "content": parse_inline(item_text)}],
+ }
+ )
+ if list_node["content"]:
+ doc["content"].append(list_node)
+ continue
+
+ if all(re.match(r"^\s*\d+[.)]\s+", line) for line in lines if line.strip()):
+ list_node = {"type": "orderedList", "content": []}
+ for line in lines:
+ item_text = re.sub(r"^\s*\d+[.)]\s+", "", line).strip()
+ if item_text:
+ list_node["content"].append(
+ {
+ "type": "listItem",
+ "content": [{"type": "paragraph", "content": parse_inline(item_text)}],
+ }
+ )
+ if list_node["content"]:
+ doc["content"].append(list_node)
+ continue
+
+ table_match = re.match(r"^\|", block)
+ if table_match:
+ table_lines = [
+ row for row in lines if row.strip() and not re.match(r"^\|[-\s|]+\|$", row)
+ ]
+ if len(table_lines) >= 1:
+ table_node = {"type": "table", "attrs": {"layout": "default"}, "content": []}
+ for i, tl in enumerate(table_lines):
+ cells = [c.strip() for c in tl.strip("|").split("|")]
+ cell_type = "tableHeader" if i == 0 else "tableCell"
+ row = {"type": "tableRow", "content": []}
+ for cell in cells:
+ row["content"].append(
+ {
+ "type": cell_type,
+ "content": [{"type": "paragraph", "content": parse_inline(cell)}],
+ }
+ )
+ table_node["content"].append(row)
+ doc["content"].append(table_node)
+ continue
+
+ mixed_content = []
+ in_list = False
+ list_type = "bulletList"
+ list_items = []
+
+ def _flush_list():
+ nonlocal list_items, in_list, list_type
+ if list_items:
+ ln = {"type": list_type, "content": []}
+ for it in list_items:
+ ln["content"].append(
+ {
+ "type": "listItem",
+ "content": [{"type": "paragraph", "content": parse_inline(it)}],
+ }
+ )
+ doc["content"].append(ln)
+ list_items = []
+ in_list = False
+ list_type = "bulletList"
+
+ for line in lines:
+ is_bullet = bool(re.match(r"^\s*[-*]\s+", line))
+ is_ordered = bool(re.match(r"^\s*\d+[.)]\s+", line))
+ is_list_item = is_bullet or is_ordered
+ is_heading = bool(re.match(r"^#{1,6}\s+", line))
+
+ if is_list_item:
+ new_type = "orderedList" if is_ordered else "bulletList"
+ if in_list and new_type != list_type:
+ _flush_list()
+ if not in_list and mixed_content:
+ para_text = "\n".join(mixed_content).strip()
+ if para_text:
+ doc["content"].append(
+ {
+ "type": "paragraph",
+ "content": parse_inline_multiline(para_text),
+ }
+ )
+ mixed_content = []
+ in_list = True
+ list_type = new_type
+ if is_ordered:
+ item_text = re.sub(r"^\s*\d+[.)]\s+", "", line).strip()
+ else:
+ item_text = re.sub(r"^\s*[-*]\s+", "", line).strip()
+ list_items.append(item_text)
+ elif is_heading:
+ _flush_list()
+ if mixed_content:
+ para_text = "\n".join(mixed_content).strip()
+ if para_text:
+ doc["content"].append(
+ {
+ "type": "paragraph",
+ "content": parse_inline_multiline(para_text),
+ }
+ )
+ mixed_content = []
+ hm = re.match(r"^(#{1,6})\s+(.+)$", line)
+ doc["content"].append(
+ {
+ "type": "heading",
+ "attrs": {"level": len(hm.group(1))},
+ "content": parse_inline(hm.group(2)),
+ }
+ )
+ else:
+ _flush_list()
+ if line.strip() == "---":
+ if mixed_content:
+ para_text = "\n".join(mixed_content).strip()
+ if para_text:
+ doc["content"].append(
+ {
+ "type": "paragraph",
+ "content": parse_inline_multiline(para_text),
+ }
+ )
+ mixed_content = []
+ doc["content"].append({"type": "rule"})
+ else:
+ mixed_content.append(line)
+
+ _flush_list()
+ if mixed_content:
+ para_text = "\n".join(mixed_content).strip()
+ if para_text:
+ doc["content"].append(
+ {
+ "type": "paragraph",
+ "content": parse_inline_multiline(para_text),
+ }
+ )
+
+ if not doc["content"]:
+ doc["content"].append(
+ {
+ "type": "paragraph",
+ "content": [{"type": "text", "text": text or " "}],
+ }
+ )
+
+ return doc
+
+
+if __name__ == "__main__":
+ raw = sys.stdin.read(MAX_INPUT_BYTES + 1)
+ if len(raw) > MAX_INPUT_BYTES:
+ print(f"ERROR: input exceeds {MAX_INPUT_BYTES} bytes", file=sys.stderr)
+ sys.exit(1)
+ adf = text_to_adf(raw)
+ print(json.dumps({"body": adf}, ensure_ascii=False))
diff --git a/internal/scaffold/fullsend-repo/scripts/pipeline-events.sh b/internal/scaffold/fullsend-repo/scripts/pipeline-events.sh
new file mode 100755
index 000000000..5b56a191f
--- /dev/null
+++ b/internal/scaffold/fullsend-repo/scripts/pipeline-events.sh
@@ -0,0 +1,129 @@
+#!/usr/bin/env bash
+# pipeline-events.sh — Emit structured pipeline events for full-trace observability.
+#
+# Source this file in pre/post scripts to record timing and metadata for each
+# pipeline phase. Events are appended to a JSONL file that send-trace.py reads
+# to build a hierarchical trace spanning the entire workflow — not just the
+# Claude sandbox.
+#
+# When otel-trace-context.sh is also sourced and initialized, events are
+# enriched with trace_id, span_id, and parent_span_id fields for proper
+# distributed trace correlation.
+#
+# Usage:
+# source "$(dirname "${BASH_SOURCE[0]}")/pipeline-events.sh"
+# pe_start "pre-explore" "fetch-issue"
+# ... do work ...
+# pe_end "pre-explore" "fetch-issue" '{"children": 5, "comments": 3}'
+#
+# The events file path defaults to /tmp/workspace/pipeline-events.jsonl and
+# is also written to $GITHUB_WORKSPACE/output/pipeline-events.jsonl for
+# artifact upload.
+
+PIPELINE_EVENTS_DIR="/tmp/workspace"
+PIPELINE_EVENTS_FILE="${PIPELINE_EVENTS_DIR}/pipeline-events.jsonl"
+mkdir -p "$PIPELINE_EVENTS_DIR"
+
+# Auto-source OTEL trace context if available and not already loaded
+_PE_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+if [[ -z "${_OTEL_INITIALIZED:-}" && -f "${_PE_SCRIPT_DIR}/otel-trace-context.sh" ]]; then
+ source "${_PE_SCRIPT_DIR}/otel-trace-context.sh"
+fi
+
+_pe_now_ms() {
+ python3 -c "import time; print(int(time.time() * 1000))" 2>/dev/null \
+ || date +%s%3N 2>/dev/null \
+ || echo "$(date +%s)000"
+}
+
+_pe_now_iso() {
+ date -u +"%Y-%m-%dT%H:%M:%S.%3NZ" 2>/dev/null || date -u +"%Y-%m-%dT%H:%M:%SZ"
+}
+
+pe_start() {
+ local phase="$1" step="$2"
+ local ts_ms; ts_ms=$(_pe_now_ms)
+ local ts_iso; ts_iso=$(_pe_now_iso)
+
+ # Start an OTEL span if trace context is initialized
+ if [[ -n "${_OTEL_INITIALIZED:-}" ]]; then
+ otel_start_span "${phase}:${step}"
+ fi
+
+ jq -nc \
+ --arg phase "$phase" \
+ --arg step "$step" \
+ --arg event "start" \
+ --arg ts "$ts_iso" \
+ --argjson ts_ms "$ts_ms" \
+ --arg trace_id "${_OTEL_TRACE_ID:-}" \
+ '{phase: $phase, step: $step, event: $event, timestamp: $ts, timestamp_ms: $ts_ms}
+ + (if $trace_id != "" then {trace_id: $trace_id} else {} end)' \
+ >> "$PIPELINE_EVENTS_FILE"
+}
+
+pe_end() {
+ local phase="$1" step="$2"
+ local metadata="$3"
+ [[ -z "$metadata" ]] && metadata='{}'
+ local ts_ms; ts_ms=$(_pe_now_ms)
+ local ts_iso; ts_iso=$(_pe_now_iso)
+
+ if ! echo "$metadata" | jq empty 2>/dev/null; then
+ metadata="{}"
+ fi
+
+ # End the OTEL span if trace context is initialized
+ if [[ -n "${_OTEL_INITIALIZED:-}" ]]; then
+ otel_end_span "ok" "$metadata"
+ fi
+
+ jq -nc \
+ --arg phase "$phase" \
+ --arg step "$step" \
+ --arg event "end" \
+ --arg ts "$ts_iso" \
+ --argjson ts_ms "$ts_ms" \
+ --argjson meta "$metadata" \
+ --arg trace_id "${_OTEL_TRACE_ID:-}" \
+ '{phase: $phase, step: $step, event: $event, timestamp: $ts, timestamp_ms: $ts_ms, metadata: $meta}
+ + (if $trace_id != "" then {trace_id: $trace_id} else {} end)' \
+ >> "$PIPELINE_EVENTS_FILE"
+}
+
+pe_error() {
+ local phase="$1" step="$2" error_msg="$3"
+ local ts_ms; ts_ms=$(_pe_now_ms)
+ local ts_iso; ts_iso=$(_pe_now_iso)
+
+ # End the OTEL span with error status
+ if [[ -n "${_OTEL_INITIALIZED:-}" ]]; then
+ otel_end_span "error" "$(jq -nc --arg err "$error_msg" '{error: $err}')"
+ fi
+
+ jq -nc \
+ --arg phase "$phase" \
+ --arg step "$step" \
+ --arg event "error" \
+ --arg ts "$ts_iso" \
+ --argjson ts_ms "$ts_ms" \
+ --arg error "$error_msg" \
+ --arg trace_id "${_OTEL_TRACE_ID:-}" \
+ '{phase: $phase, step: $step, event: $event, timestamp: $ts, timestamp_ms: $ts_ms, error: $error}
+ + (if $trace_id != "" then {trace_id: $trace_id} else {} end)' \
+ >> "$PIPELINE_EVENTS_FILE"
+}
+
+pe_copy_to_output() {
+ local output_dir="${1:-${GITHUB_WORKSPACE:-$(pwd)}/output}"
+ if [[ -f "$PIPELINE_EVENTS_FILE" ]]; then
+ mkdir -p "$output_dir"
+ cp "$PIPELINE_EVENTS_FILE" "$output_dir/pipeline-events.jsonl"
+ fi
+ # Also copy OTEL trace files if they exist
+ for f in otel-spans.jsonl otel-trace-context.json; do
+ if [[ -f "${OTEL_SPANS_DIR:-/tmp/workspace}/$f" ]]; then
+ cp "${OTEL_SPANS_DIR:-/tmp/workspace}/$f" "$output_dir/"
+ fi
+ done
+}
diff --git a/internal/scaffold/fullsend-repo/scripts/pipeline-helpers.sh b/internal/scaffold/fullsend-repo/scripts/pipeline-helpers.sh
new file mode 100755
index 000000000..b51b93012
--- /dev/null
+++ b/internal/scaffold/fullsend-repo/scripts/pipeline-helpers.sh
@@ -0,0 +1,97 @@
+#!/usr/bin/env bash
+# pipeline-helpers.sh — Shared helpers for refinement pipeline post-scripts.
+#
+# Source this file from post-explore.sh, post-refine.sh, post-critique.sh.
+# Requires: GH_TOKEN, JIRA_HOST, JIRA_EMAIL, JIRA_API_TOKEN (Jira path only)
+
+# Prevent double-sourcing
+[[ -n "${_PIPELINE_HELPERS_LOADED:-}" ]] && return 0
+_PIPELINE_HELPERS_LOADED=1
+
+SCRIPT_DIR="${SCRIPT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}"
+
+add_label() {
+ local repo="$1" number="$2" label="$3"
+ gh api "repos/${repo}/issues/${number}/labels" -f "labels[]=${label}" --silent 2>/dev/null || true
+}
+
+remove_label() {
+ local repo="$1" number="$2" label="$3"
+ local encoded
+ encoded=$(printf '%s' "$label" | jq -sRr @uri)
+ gh api "repos/${repo}/issues/${number}/labels/${encoded}" -X DELETE --silent 2>/dev/null || true
+}
+
+github_comment() {
+ local repo="$1" number="$2" body="$3"
+ printf '%s' "$body" | gh issue comment "$number" --repo "$repo" --body-file -
+}
+
+jira_comment() {
+ local key="$1" body="$2"
+ local auth
+ auth=$(printf '%s:%s' "$JIRA_EMAIL" "$JIRA_API_TOKEN" | base64 -w0)
+ local adf_body
+ adf_body=$(printf '%s' "$body" | python3 "${SCRIPT_DIR}/markdown-to-adf.py")
+ curl -sSf -X POST \
+ -H "Authorization: Basic $auth" \
+ -H "Content-Type: application/json" \
+ -d "$adf_body" \
+ "https://${JIRA_HOST}/rest/api/3/issue/${key}/comment"
+}
+
+# post_comment dispatches to GitHub or Jira based on USE_GITHUB.
+# Callers must set USE_GITHUB, REPO_FULL_NAME, GITHUB_ISSUE_NUMBER, ISSUE_KEY.
+post_comment() {
+ local body="$1"
+ if ${USE_GITHUB:-false}; then
+ github_comment "${REPO_FULL_NAME}" "$GITHUB_ISSUE_NUMBER" "$body"
+ else
+ jira_comment "$ISSUE_KEY" "$body"
+ fi
+}
+
+# Determine reply target from environment. Sets USE_GITHUB and GITHUB_ISSUE_NUMBER.
+determine_reply_target() {
+ USE_GITHUB=false
+ if [[ -n "${GITHUB_ISSUE_NUMBER:-}" && "${GITHUB_ISSUE_NUMBER}" != "" && "${GITHUB_ISSUE_NUMBER}" != "N/A" ]]; then
+ USE_GITHUB=true
+ elif [[ "${ISSUE_SOURCE:-}" == "github" ]]; then
+ USE_GITHUB=true
+ GITHUB_ISSUE_NUMBER="${ISSUE_KEY}"
+ fi
+}
+
+# Build a run link from GITHUB_REPOSITORY and GITHUB_RUN_ID.
+build_run_link() {
+ local run_url="https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID:-}"
+ echo "[Run #${GITHUB_RUN_ID:-manual}](${run_url})"
+}
+
+# Find the last agent-result.json from iteration output directories.
+find_agent_result() {
+ local result_file=""
+ for dir in iteration-*/output; do
+ if [[ -f "${dir}/agent-result.json" ]]; then
+ result_file="${dir}/agent-result.json"
+ fi
+ done
+ if [[ -z "$result_file" ]]; then
+ echo "ERROR: agent-result.json not found in any iteration output directory" >&2
+ return 1
+ fi
+ if ! jq empty "$result_file" 2>/dev/null; then
+ echo "ERROR: ${result_file} is not valid JSON" >&2
+ return 1
+ fi
+ echo "$result_file"
+}
+
+# Read trace context for distributed tracing propagation.
+get_traceparent() {
+ local tp="${TRACEPARENT:-}"
+ if [[ -z "$tp" && -f "/tmp/workspace/otel-trace-context.json" ]]; then
+ tp=$(python3 -c "import json; print(json.load(open('/tmp/workspace/otel-trace-context.json'))['traceparent'])" 2>/dev/null || echo "")
+ fi
+ echo "$tp"
+}
diff --git a/internal/scaffold/fullsend-repo/scripts/post-critique-test.sh b/internal/scaffold/fullsend-repo/scripts/post-critique-test.sh
new file mode 100755
index 000000000..80370614f
--- /dev/null
+++ b/internal/scaffold/fullsend-repo/scripts/post-critique-test.sh
@@ -0,0 +1,216 @@
+#!/usr/bin/env bash
+# post-critique-test.sh — Test post-critique.sh verdict routing and iteration control.
+#
+# Run from the repo root: bash internal/scaffold/fullsend-repo/scripts/post-critique-test.sh
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+POST_SCRIPT="${SCRIPT_DIR}/post-critique.sh"
+FAILURES=0
+
+TEST_TMPDIR="$(mktemp -d)"
+trap 'rm -rf "${TEST_TMPDIR}" /tmp/workspace/critique-history.json /tmp/workspace/critique-feedback.json /tmp/workspace/refine-result.json' EXIT
+
+GH_LOG="${TEST_TMPDIR}/gh-calls.log"
+MOCK_BIN="${TEST_TMPDIR}/bin"
+mkdir -p "${MOCK_BIN}"
+
+cat > "${MOCK_BIN}/gh" <<'MOCKEOF'
+#!/usr/bin/env bash
+echo "gh $*" >> "$GH_LOG"
+
+case "$*" in
+ *"issue comment"*)
+ cat > /dev/null
+ exit 0
+ ;;
+ *"workflow run"*)
+ exit 0
+ ;;
+ *"api"*)
+ exit 0
+ ;;
+esac
+exit 0
+MOCKEOF
+chmod +x "${MOCK_BIN}/gh"
+
+cat > "${MOCK_BIN}/python3" <<'MOCKEOF'
+#!/usr/bin/env bash
+if [[ "${1:-}" == "-c" ]]; then
+ if [[ "${2:-}" == *"time.time"* ]]; then
+ echo "1000000"
+ exit 0
+ fi
+ echo ""
+ exit 0
+fi
+exec /usr/bin/python3 "$@"
+MOCKEOF
+chmod +x "${MOCK_BIN}/python3"
+
+export PATH="${MOCK_BIN}:${PATH}"
+export GH_LOG="${GH_LOG}"
+export GH_TOKEN="fake-token"
+export ISSUE_KEY="42"
+export ISSUE_SOURCE="github"
+export REPO_FULL_NAME="test-org/test-repo"
+export GITHUB_REPOSITORY="test-org/.fullsend"
+export GITHUB_RUN_ID="99999"
+export GITHUB_ISSUE_NUMBER="42"
+export GITHUB_WORKSPACE="${TEST_TMPDIR}"
+export REFINE_RUN_ID="88888"
+
+APPROVED_FIXTURE='{
+ "verdict": "approved",
+ "comment": "Plan looks good.",
+ "assessment": {"overall": 90},
+ "revisions": []
+}'
+
+REVISE_FIXTURE='{
+ "verdict": "revise",
+ "comment": "Needs adjustments.",
+ "assessment": {"overall": 55},
+ "revisions": [{"type": "revise", "target": "Child 1", "reason": "Missing AC"}]
+}'
+
+NEEDS_INPUT_FIXTURE='{
+ "verdict": "needs_input",
+ "comment": "Cannot evaluate without clarification.",
+ "assessment": {"overall": 40},
+ "question": {"dimension": "scope", "text": "What is the target platform?", "impact": "Determines child decomposition"}
+}'
+
+UNKNOWN_FIXTURE='{
+ "verdict": "banana",
+ "comment": "This should fail.",
+ "assessment": {"overall": 0}
+}'
+
+# Pre-populate refine result for approved path
+mkdir -p /tmp/workspace
+echo '{"children": [{"title": "c1"}, {"title": "c2"}]}' > /tmp/workspace/refine-result.json
+
+run_test() {
+ local test_name="$1"
+ local fixture="$2"
+ local extra_env="$3"
+ local expect_failure="${4:-false}"
+
+ local run_dir="${TEST_TMPDIR}/run-${test_name}"
+ mkdir -p "${run_dir}/iteration-1/output"
+ echo "${fixture}" > "${run_dir}/iteration-1/output/agent-result.json"
+
+ # Clean up workspace state between tests
+ rm -f /tmp/workspace/critique-history.json
+ rm -f /tmp/workspace/critique-feedback.json
+
+ : > "${GH_LOG}"
+
+ local exit_code=0
+ (cd "${run_dir}" && eval "${extra_env}" bash "${POST_SCRIPT}") > "${TEST_TMPDIR}/stdout-${test_name}.log" 2>&1 || exit_code=$?
+
+ if [[ "${expect_failure}" == "true" ]]; then
+ if [[ ${exit_code} -eq 0 ]]; then
+ echo "FAIL: ${test_name} — expected failure but got success"
+ FAILURES=$((FAILURES + 1))
+ return
+ fi
+ echo "PASS: ${test_name} (expected failure)"
+ return
+ fi
+
+ if [[ ${exit_code} -ne 0 ]]; then
+ echo "FAIL: ${test_name} — exit code ${exit_code}"
+ cat "${TEST_TMPDIR}/stdout-${test_name}.log"
+ FAILURES=$((FAILURES + 1))
+ return
+ fi
+
+ echo "PASS: ${test_name}"
+}
+
+assert_gh_called() {
+ local test_name="$1" pattern="$2"
+ if ! grep -qF "${pattern}" "${GH_LOG}"; then
+ echo "FAIL: ${test_name} — expected gh call matching '${pattern}'"
+ cat "${GH_LOG}"
+ FAILURES=$((FAILURES + 1))
+ fi
+}
+
+assert_gh_not_called() {
+ local test_name="$1" pattern="$2"
+ if grep -qF "${pattern}" "${GH_LOG}"; then
+ echo "FAIL: ${test_name} — unexpected gh call matching '${pattern}'"
+ cat "${GH_LOG}"
+ FAILURES=$((FAILURES + 1))
+ fi
+}
+
+assert_stdout_contains() {
+ local test_name="$1" pattern="$2"
+ if ! grep -qF "${pattern}" "${TEST_TMPDIR}/stdout-${test_name}.log"; then
+ echo "FAIL: ${test_name} — expected stdout containing '${pattern}'"
+ FAILURES=$((FAILURES + 1))
+ fi
+}
+
+# --- Tests ---
+
+# 1. Approved verdict with auto-create disabled (default) — adds label
+run_test "approved-no-auto-create" "$APPROVED_FIXTURE" "REVIEW_ROUND=1 MAX_REVIEW_ROUNDS=3 AUTO_CREATE=false"
+assert_gh_called "approved-no-auto-create" "issue comment"
+assert_gh_called "approved-no-auto-create" "refine-approved"
+assert_gh_not_called "approved-no-auto-create" "workflow run"
+assert_stdout_contains "approved-no-auto-create" "Post-critique complete"
+
+# 2. Revise verdict under limit — chains back to refine
+run_test "revise-under-limit" "$REVISE_FIXTURE" "REVIEW_ROUND=1 MAX_REVIEW_ROUNDS=3"
+assert_gh_called "revise-under-limit" "workflow run refine.yml"
+assert_gh_called "revise-under-limit" "review_round=2"
+assert_gh_called "revise-under-limit" "issue comment"
+assert_stdout_contains "revise-under-limit" "Post-critique complete"
+
+# 3. Revise at max rounds — escalates to human
+run_test "revise-max-rounds" "$REVISE_FIXTURE" "REVIEW_ROUND=3 MAX_REVIEW_ROUNDS=3"
+assert_gh_called "revise-max-rounds" "refine-needs-human"
+assert_gh_called "revise-max-rounds" "refine-approved"
+assert_gh_not_called "revise-max-rounds" "workflow run refine.yml"
+assert_stdout_contains "revise-max-rounds" "Post-critique complete"
+
+# 4. Needs input — posts question and adds label
+run_test "needs-input" "$NEEDS_INPUT_FIXTURE" "REVIEW_ROUND=1 MAX_REVIEW_ROUNDS=3"
+assert_gh_called "needs-input" "refine-needs-input"
+assert_gh_called "needs-input" "issue comment"
+assert_gh_not_called "needs-input" "workflow run"
+assert_stdout_contains "needs-input" "Post-critique complete"
+
+# 5. Unknown verdict — should fail
+run_test "unknown-verdict" "$UNKNOWN_FIXTURE" "REVIEW_ROUND=1 MAX_REVIEW_ROUNDS=3" "true"
+
+# 6. Critique history accumulates across rounds
+run_test "history-round-1" "$REVISE_FIXTURE" "REVIEW_ROUND=1 MAX_REVIEW_ROUNDS=3"
+if [[ -f /tmp/workspace/critique-history.json ]]; then
+ HISTORY_ROUNDS=$(jq '.rounds | length' /tmp/workspace/critique-history.json)
+ if [[ "$HISTORY_ROUNDS" == "1" ]]; then
+ echo "PASS: history-accumulation"
+ else
+ echo "FAIL: history-accumulation — expected 1 round in history, got ${HISTORY_ROUNDS}"
+ FAILURES=$((FAILURES + 1))
+ fi
+else
+ echo "FAIL: history-accumulation — critique-history.json not found"
+ FAILURES=$((FAILURES + 1))
+fi
+
+if [[ ${FAILURES} -gt 0 ]]; then
+ echo ""
+ echo "${FAILURES} test(s) failed."
+ exit 1
+fi
+
+echo ""
+echo "All post-critique tests passed."
diff --git a/internal/scaffold/fullsend-repo/scripts/post-critique.sh b/internal/scaffold/fullsend-repo/scripts/post-critique.sh
new file mode 100755
index 000000000..df0ce1ca4
--- /dev/null
+++ b/internal/scaffold/fullsend-repo/scripts/post-critique.sh
@@ -0,0 +1,290 @@
+#!/usr/bin/env bash
+# post-critique.sh — Process critique agent output.
+#
+# Reads the critique result and performs one of:
+# - verdict=approved + AUTO_CREATE=true: creates child issues immediately
+# - verdict=approved + AUTO_CREATE=false: posts approval, adds label for human gate
+# - verdict=revise + under iteration limit: posts feedback, chains back to refine
+# - verdict=revise + at iteration limit: posts final plan for human decision
+#
+# Required env vars:
+# ISSUE_KEY — Issue identifier (Jira key or GH issue number)
+# ISSUE_SOURCE — "jira" or "github"
+# GH_TOKEN — GitHub token
+#
+# GitHub flow env vars:
+# GITHUB_ISSUE_NUMBER — GitHub issue number
+# REPO_FULL_NAME — owner/repo
+# PUSH_TOKEN — Token with write access
+#
+# Jira flow env vars:
+# JIRA_HOST, JIRA_EMAIL, JIRA_API_TOKEN
+#
+# Critique flow env vars:
+# REVIEW_ROUND — Current review round (default: 1)
+# MAX_REVIEW_ROUNDS — Max rounds (default: 3)
+# AUTO_CREATE — "true" to auto-create on approval (default: "false")
+# REFINE_RUN_ID — Run ID of the refine stage
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+source "${SCRIPT_DIR}/pipeline-events.sh"
+source "${SCRIPT_DIR}/pipeline-helpers.sh"
+
+pe_start "post-critique" "post-critique"
+
+REVIEW_ROUND="${REVIEW_ROUND:-1}"
+MAX_REVIEW_ROUNDS="${MAX_REVIEW_ROUNDS:-3}"
+AUTO_CREATE="${AUTO_CREATE:-false}"
+
+RESULT_FILE=$(find_agent_result) || exit 1
+echo "Reading critique result from: ${RESULT_FILE}"
+
+VERDICT=$(jq -r '.verdict' "${RESULT_FILE}")
+COMMENT=$(jq -r '.comment // ""' "${RESULT_FILE}")
+OVERALL_SCORE=$(jq -r '.assessment.overall // 0' "${RESULT_FILE}")
+REVISION_COUNT=$(jq '.revisions // [] | length' "${RESULT_FILE}")
+
+echo "Verdict: ${VERDICT}, Overall score: ${OVERALL_SCORE}, Revisions: ${REVISION_COUNT}, Round: ${REVIEW_ROUND}/${MAX_REVIEW_ROUNDS}"
+
+determine_reply_target
+RUN_LINK=$(build_run_link)
+
+AGENT_HEADER="🔎 **Critique Agent** · ${RUN_LINK} · Review Round ${REVIEW_ROUND}"
+
+echo "Reply target: $(if $USE_GITHUB; then echo "GitHub #${GITHUB_ISSUE_NUMBER}"; else echo "Jira ${ISSUE_KEY}"; fi)"
+
+# --- Update critique history ---
+# Accumulate review rounds for the next iteration's context
+CRITIQUE_HISTORY_FILE="/tmp/workspace/critique-history.json"
+if [[ -f "$CRITIQUE_HISTORY_FILE" ]]; then
+ UPDATED_HISTORY=$(jq --argjson round "$REVIEW_ROUND" \
+ --arg verdict "$VERDICT" \
+ --argjson score "$OVERALL_SCORE" \
+ --argjson revisions "$(jq '.revisions // []' "$RESULT_FILE")" \
+ '.rounds += [{"round": $round, "verdict": $verdict, "overall_score": $score, "revisions": $revisions}]' \
+ "$CRITIQUE_HISTORY_FILE")
+ echo "$UPDATED_HISTORY" > "$CRITIQUE_HISTORY_FILE"
+else
+ jq -n --argjson round "$REVIEW_ROUND" \
+ --arg verdict "$VERDICT" \
+ --argjson score "$OVERALL_SCORE" \
+ --argjson revisions "$(jq '.revisions // []' "$RESULT_FILE")" \
+ '{rounds: [{"round": $round, "verdict": $verdict, "overall_score": $score, "revisions": $revisions}]}' \
+ > "$CRITIQUE_HISTORY_FILE"
+fi
+
+# --- Process based on verdict ---
+
+if [[ "${VERDICT}" == "approved" ]]; then
+ pe_start "post-critique" "handle-approval"
+ echo "::notice::Critique approved the refinement plan (round ${REVIEW_ROUND})"
+
+ FULL_COMMENT="${AGENT_HEADER}
+
+**Verdict: ✅ Approved** (score: ${OVERALL_SCORE}/100)
+
+${COMMENT}"
+
+ if [[ "${AUTO_CREATE}" == "true" ]]; then
+ echo "Auto-create enabled — creating child issues..."
+
+ # Post the approval comment first
+ post_comment "$FULL_COMMENT"
+
+ # Find the refine result to create children from
+ REFINE_RESULT_FILE="/tmp/workspace/refine-result.json"
+ if [[ ! -f "$REFINE_RESULT_FILE" ]]; then
+ echo "::error::Refine result not found at ${REFINE_RESULT_FILE}"
+ exit 1
+ fi
+
+ # Delegate to create-children.sh
+ export RESULT_FILE="$REFINE_RESULT_FILE"
+ bash "${SCRIPT_DIR}/create-children.sh"
+
+ CHILD_SUMMARY="Created ${CREATED_CHILD_COUNT:-0} child issue(s): ${CREATED_CHILD_KEYS:-none}"
+ echo "::notice::${CHILD_SUMMARY}"
+
+ CREATION_COMMENT="📦 **Issue Creator** · Run #${GITHUB_RUN_ID:-manual}
+
+**Child issues created** after critique approval.
+
+${CHILD_SUMMARY}"
+ post_comment "$CREATION_COMMENT"
+
+ else
+ echo "Auto-create disabled — posting approval for human review"
+
+ PLAN_CHILD_COUNT=$(jq '.children | length' "/tmp/workspace/refine-result.json" 2>/dev/null || echo "0")
+
+ APPROVAL_COMMENT="${FULL_COMMENT}
+
+---
+**Ready for human approval.** The plan proposes ${PLAN_CHILD_COUNT} child issue(s).
+
+To create the child issues, comment \`/fs-create\`.
+To request further changes, reply with your feedback."
+
+ post_comment "$APPROVAL_COMMENT"
+
+ if $USE_GITHUB; then
+ add_label "${REPO_FULL_NAME}" "$GITHUB_ISSUE_NUMBER" "refine-approved"
+ fi
+ fi
+
+ pe_end "post-critique" "handle-approval" "$(jq -nc --arg auto_create "$AUTO_CREATE" --argjson score "$OVERALL_SCORE" '{auto_create:$auto_create, overall_score:$score}')"
+
+elif [[ "${VERDICT}" == "revise" ]]; then
+ NEXT_ROUND=$((REVIEW_ROUND + 1))
+
+ if [[ $NEXT_ROUND -gt $MAX_REVIEW_ROUNDS ]]; then
+ pe_start "post-critique" "handle-max-iterations"
+ echo "::warning::Max review rounds (${MAX_REVIEW_ROUNDS}) reached — escalating to human"
+
+ PLAN_CHILD_COUNT=$(jq '.children | length' "/tmp/workspace/refine-result.json" 2>/dev/null || echo "0")
+
+ # Synthesize review history for human handoff
+ ROUND_SUMMARY=""
+ if [[ -f "$CRITIQUE_HISTORY_FILE" ]]; then
+ ROUND_SUMMARY=$(jq -r '
+ "| Round | Score | Verdict | Revisions |\n|-------|-------|---------|-----------|",
+ (.rounds[] |
+ "| \(.round) | \(.overall_score)/100 | \(.verdict) | \(.revisions | length) revision(s): \(.revisions | map(.type + ": " + (.target // .description // "—")[0:40]) | join(", ")) |"
+ )
+ ' "$CRITIQUE_HISTORY_FILE" 2>/dev/null || echo "")
+ fi
+
+ HISTORY_SECTION=""
+ if [[ -n "$ROUND_SUMMARY" ]]; then
+ HISTORY_SECTION="
+### Review History
+
+${ROUND_SUMMARY}
+"
+ fi
+
+ ESCALATION_COMMENT="${AGENT_HEADER}
+
+**Verdict: ⚠️ Max review rounds reached** (${MAX_REVIEW_ROUNDS} rounds, score: ${OVERALL_SCORE}/100)
+
+${COMMENT}
+${HISTORY_SECTION}
+---
+**Human decision needed.** The critique agent still has concerns after ${MAX_REVIEW_ROUNDS} rounds of review.
+
+The current plan proposes ${PLAN_CHILD_COUNT} child issue(s). Options:
+- Reply \`/fs-create\` to create the issues as-is
+- Reply \`/fs-refine\` to restart the refinement process
+- Reply with specific guidance for the refine agent"
+
+ post_comment "$ESCALATION_COMMENT"
+
+ if $USE_GITHUB; then
+ add_label "${REPO_FULL_NAME}" "$GITHUB_ISSUE_NUMBER" "refine-needs-human"
+ add_label "${REPO_FULL_NAME}" "$GITHUB_ISSUE_NUMBER" "refine-stalled"
+ add_label "${REPO_FULL_NAME}" "$GITHUB_ISSUE_NUMBER" "refine-approved"
+ fi
+
+ # Mark critique history as approved so create-children.yml can verify
+ if [[ -f "$CRITIQUE_HISTORY_FILE" ]]; then
+ UPDATED=$(jq '.rounds[-1].verdict = "approved" | .rounds[-1].escalated = true' "$CRITIQUE_HISTORY_FILE")
+ echo "$UPDATED" > "$CRITIQUE_HISTORY_FILE"
+ fi
+
+ pe_end "post-critique" "handle-max-iterations" "$(jq -nc --argjson round "$REVIEW_ROUND" --argjson score "$OVERALL_SCORE" '{round:$round, score:$score}')"
+
+ else
+ pe_start "post-critique" "handle-revision"
+ echo "::notice::Critique requests revisions — chaining back to refine (round ${NEXT_ROUND})"
+
+ REVISION_COMMENT="${AGENT_HEADER}
+
+**Verdict: 🔄 Revisions requested** (score: ${OVERALL_SCORE}/100, ${REVISION_COUNT} revision(s))
+
+${COMMENT}"
+
+ post_comment "$REVISION_COMMENT"
+
+ # Save critique result for refine to read
+ cp "$RESULT_FILE" "/tmp/workspace/critique-feedback.json"
+
+ # Chain back to refine with review context
+ WORKFLOW_REPO="${GITHUB_REPOSITORY}"
+ TARGET_REPO="${REPO_FULL_NAME:-}"
+ THIS_RUN_ID="${GITHUB_RUN_ID:-}"
+
+ if [[ -n "$THIS_RUN_ID" ]]; then
+ CURRENT_TRACEPARENT=$(get_traceparent)
+
+ CHAIN_ARGS=(
+ --repo "$WORKFLOW_REPO"
+ -f issue_key="${ISSUE_KEY}"
+ -f issue_source="${ISSUE_SOURCE}"
+ -f critique_run_id="${THIS_RUN_ID}"
+ -f review_round="${NEXT_ROUND}"
+ -f max_review_rounds="${MAX_REVIEW_ROUNDS}"
+ -f auto_create="${AUTO_CREATE}"
+ )
+
+ # Only pass repo_full_name if a target repo was specified
+ if [[ -n "$TARGET_REPO" ]]; then
+ CHAIN_ARGS+=(-f repo_full_name="${TARGET_REPO}")
+ fi
+
+ if [[ -n "$CURRENT_TRACEPARENT" ]]; then
+ CHAIN_ARGS+=(-f parent_traceparent="${CURRENT_TRACEPARENT}")
+ fi
+
+ if [[ -n "${GITHUB_ISSUE_NUMBER:-}" && "${GITHUB_ISSUE_NUMBER}" != "N/A" ]]; then
+ CHAIN_ARGS+=(-f github_issue_number="${GITHUB_ISSUE_NUMBER}")
+ fi
+
+ gh workflow run refine.yml "${CHAIN_ARGS[@]}" \
+ 2>/dev/null || echo "::warning::Failed to chain refine workflow — trigger manually"
+ else
+ echo "::warning::GITHUB_RUN_ID not available — refine must be triggered manually"
+ fi
+
+ pe_end "post-critique" "handle-revision" "$(jq -nc --argjson next_round "$NEXT_ROUND" --argjson revisions "$REVISION_COUNT" '{next_round:$next_round, revision_count:$revisions}')"
+ fi
+
+elif [[ "${VERDICT}" == "needs_input" ]]; then
+ pe_start "post-critique" "handle-needs-input"
+ echo "::notice::Critique needs human input — posting question"
+
+ QUESTION_DIM=$(jq -r '.question.dimension // "unknown"' "${RESULT_FILE}")
+ QUESTION_TEXT=$(jq -r '.question.text // ""' "${RESULT_FILE}")
+ QUESTION_IMPACT=$(jq -r '.question.impact // ""' "${RESULT_FILE}")
+
+ QUESTION_COMMENT="${AGENT_HEADER}
+
+**Verdict: ❓ Needs Human Input** (score: ${OVERALL_SCORE}/100)
+
+${COMMENT}
+
+---
+**Question** (${QUESTION_DIM}): ${QUESTION_TEXT}
+
+**Why this matters**: ${QUESTION_IMPACT}
+
+Reply with your answer, then comment \`/fs-refine\` to restart the pipeline with the new context."
+
+ post_comment "$QUESTION_COMMENT"
+
+ if $USE_GITHUB; then
+ add_label "${REPO_FULL_NAME}" "$GITHUB_ISSUE_NUMBER" "refine-needs-input"
+ fi
+
+ pe_end "post-critique" "handle-needs-input" "$(jq -nc --arg dim "$QUESTION_DIM" --argjson score "$OVERALL_SCORE" '{dimension:$dim, score:$score}')"
+
+else
+ echo "ERROR: Unknown verdict '${VERDICT}'"
+ exit 1
+fi
+
+pe_end "post-critique" "post-critique" "$(jq -nc --arg verdict "$VERDICT" --argjson score "$OVERALL_SCORE" --argjson round "$REVIEW_ROUND" '{verdict:$verdict, score:$score, round:$round}')"
+pe_copy_to_output
+
+echo "Post-critique complete."
diff --git a/internal/scaffold/fullsend-repo/scripts/post-explore-test.sh b/internal/scaffold/fullsend-repo/scripts/post-explore-test.sh
new file mode 100755
index 000000000..1c5fe17d4
--- /dev/null
+++ b/internal/scaffold/fullsend-repo/scripts/post-explore-test.sh
@@ -0,0 +1,171 @@
+#!/usr/bin/env bash
+# post-explore-test.sh — Test post-explore.sh with fixture JSON and mock gh.
+#
+# Run from the repo root: bash internal/scaffold/fullsend-repo/scripts/post-explore-test.sh
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+POST_SCRIPT="${SCRIPT_DIR}/post-explore.sh"
+FAILURES=0
+
+TEST_TMPDIR="$(mktemp -d)"
+trap 'rm -rf "${TEST_TMPDIR}"' EXIT
+
+GH_LOG="${TEST_TMPDIR}/gh-calls.log"
+MOCK_BIN="${TEST_TMPDIR}/bin"
+mkdir -p "${MOCK_BIN}"
+
+cat > "${MOCK_BIN}/gh" <<'MOCKEOF'
+#!/usr/bin/env bash
+echo "gh $*" >> "$GH_LOG"
+
+case "$*" in
+ *"workflow run"*)
+ exit 0
+ ;;
+ *"issue comment"*)
+ cat > /dev/null # consume stdin
+ exit 0
+ ;;
+ *"api"*)
+ exit 0
+ ;;
+esac
+exit 0
+MOCKEOF
+chmod +x "${MOCK_BIN}/gh"
+
+cat > "${MOCK_BIN}/python3" <<'MOCKEOF'
+#!/usr/bin/env bash
+if [[ "${1:-}" == "-c" ]]; then
+ # Handle _pe_now_ms calls from pipeline-events.sh
+ if [[ "${2:-}" == *"time.time"* ]]; then
+ echo "1000000"
+ exit 0
+ fi
+ # Handle get_traceparent json.load
+ echo ""
+ exit 0
+fi
+exec /usr/bin/python3 "$@"
+MOCKEOF
+chmod +x "${MOCK_BIN}/python3"
+
+export PATH="${MOCK_BIN}:${PATH}"
+export GH_LOG="${GH_LOG}"
+export GH_TOKEN="fake-token"
+export ISSUE_KEY="42"
+export ISSUE_SOURCE="github"
+export REPO_FULL_NAME="test-org/test-repo"
+export GITHUB_REPOSITORY="test-org/.fullsend"
+export GITHUB_RUN_ID="12345"
+export GITHUB_ISSUE_NUMBER="42"
+export GITHUB_WORKSPACE="${TEST_TMPDIR}"
+
+EXPLORE_FIXTURE='{
+ "confidence": {"overall": 85},
+ "gaps": ["gap1"],
+ "related_work": [{"title": "PR #1"}, {"title": "PR #2"}],
+ "summary": "Test exploration summary.",
+ "technical_landscape": {"languages": ["Go"]}
+}'
+
+run_test() {
+ local test_name="$1"
+ local fixture="${2:-$EXPLORE_FIXTURE}"
+ local expect_failure="${3:-false}"
+
+ local run_dir="${TEST_TMPDIR}/run-${test_name}"
+ mkdir -p "${run_dir}/iteration-1/output"
+ echo "${fixture}" > "${run_dir}/iteration-1/output/agent-result.json"
+
+ : > "${GH_LOG}"
+
+ local exit_code=0
+ (cd "${run_dir}" && bash "${POST_SCRIPT}") > "${TEST_TMPDIR}/stdout-${test_name}.log" 2>&1 || exit_code=$?
+
+ if [[ "${expect_failure}" == "true" ]]; then
+ if [[ ${exit_code} -eq 0 ]]; then
+ echo "FAIL: ${test_name} — expected failure but got success"
+ FAILURES=$((FAILURES + 1))
+ return
+ fi
+ echo "PASS: ${test_name} (expected failure)"
+ return
+ fi
+
+ if [[ ${exit_code} -ne 0 ]]; then
+ echo "FAIL: ${test_name} — exit code ${exit_code}"
+ cat "${TEST_TMPDIR}/stdout-${test_name}.log"
+ FAILURES=$((FAILURES + 1))
+ return
+ fi
+
+ echo "PASS: ${test_name}"
+}
+
+assert_gh_called() {
+ local test_name="$1" pattern="$2"
+ if ! grep -qF "${pattern}" "${GH_LOG}"; then
+ echo "FAIL: ${test_name} — expected gh call matching '${pattern}'"
+ cat "${GH_LOG}"
+ FAILURES=$((FAILURES + 1))
+ fi
+}
+
+assert_gh_not_called() {
+ local test_name="$1" pattern="$2"
+ if grep -qF "${pattern}" "${GH_LOG}"; then
+ echo "FAIL: ${test_name} — unexpected gh call matching '${pattern}'"
+ cat "${GH_LOG}"
+ FAILURES=$((FAILURES + 1))
+ fi
+}
+
+# --- Tests ---
+
+# Happy path: exploration completes and chains refine
+run_test "happy-path"
+assert_gh_called "happy-path" "workflow run refine.yml"
+assert_gh_called "happy-path" "issue_key=42"
+assert_gh_called "happy-path" "explore_run_id=12345"
+if [[ -f "/tmp/workspace/exploration_context.json" ]]; then
+ echo "PASS: happy-path exploration_context.json saved"
+ rm -f "/tmp/workspace/exploration_context.json"
+else
+ echo "FAIL: happy-path — exploration_context.json not saved to /tmp/workspace/"
+ FAILURES=$((FAILURES + 1))
+fi
+
+# Auto-create propagation
+export AUTO_CREATE="true"
+run_test "auto-create-propagation"
+assert_gh_called "auto-create-propagation" "auto_create=true"
+unset AUTO_CREATE
+
+# Missing agent result — run from empty dir with no iteration-*/output/
+test_name="missing-result"
+run_dir="${TEST_TMPDIR}/run-${test_name}"
+mkdir -p "${run_dir}"
+: > "${GH_LOG}"
+exit_code=0
+(cd "${run_dir}" && bash "${POST_SCRIPT}") > "${TEST_TMPDIR}/stdout-${test_name}.log" 2>&1 || exit_code=$?
+if [[ ${exit_code} -ne 0 ]]; then
+ echo "PASS: ${test_name} (expected failure)"
+else
+ echo "FAIL: ${test_name} — expected failure but got success"
+ FAILURES=$((FAILURES + 1))
+fi
+
+# Invalid JSON result
+run_test "invalid-json" "not valid json" "true"
+
+if [[ ${FAILURES} -gt 0 ]]; then
+ echo ""
+ echo "${FAILURES} test(s) failed."
+ exit 1
+fi
+
+echo ""
+echo "All post-explore tests passed."
diff --git a/internal/scaffold/fullsend-repo/scripts/post-explore.sh b/internal/scaffold/fullsend-repo/scripts/post-explore.sh
new file mode 100755
index 000000000..d6054239a
--- /dev/null
+++ b/internal/scaffold/fullsend-repo/scripts/post-explore.sh
@@ -0,0 +1,129 @@
+#!/usr/bin/env bash
+# post-explore.sh — Store exploration results and chain the refine stage.
+#
+# The explore agent writes its result to agent-result.json. This script
+# validates the output, stores it for artifact upload, and triggers the
+# refine workflow with this run's ID for artifact correlation.
+#
+# Required env vars:
+# ISSUE_KEY — Issue identifier
+# ISSUE_SOURCE — "jira" or "github"
+# REPO_FULL_NAME — owner/repo
+# GH_TOKEN — GitHub token
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+source "${SCRIPT_DIR}/pipeline-events.sh"
+source "${SCRIPT_DIR}/pipeline-helpers.sh"
+
+pe_start "post-explore" "post-explore"
+
+RESULT_FILE=$(find_agent_result) || exit 1
+echo "Reading exploration result from: ${RESULT_FILE}"
+
+pe_start "post-explore" "validate-result"
+
+OVERALL_CONFIDENCE=$(jq -r '.confidence.overall // 0' "${RESULT_FILE}")
+GAP_COUNT=$(jq '.gaps // [] | length' "${RESULT_FILE}")
+RELATED_COUNT=$(jq '.related_work | length' "${RESULT_FILE}")
+
+pe_end "post-explore" "validate-result" "$(jq -nc --argjson conf "$OVERALL_CONFIDENCE" --argjson gaps "$GAP_COUNT" --argjson related "$RELATED_COUNT" '{confidence:$conf, gap_count:$gaps, related_work_count:$related}')"
+
+echo "::notice::Exploration complete: confidence=${OVERALL_CONFIDENCE}, gaps=${GAP_COUNT}, related_work=${RELATED_COUNT}"
+
+# Copy to a well-known location for artifact upload
+WORKSPACE="/tmp/workspace"
+mkdir -p "$WORKSPACE"
+cp "${RESULT_FILE}" "${WORKSPACE}/exploration_context.json"
+
+echo "Exploration context saved to ${WORKSPACE}/exploration_context.json"
+
+# Chain the refine stage with this run's ID for artifact correlation.
+# GITHUB_RUN_ID is set by GitHub Actions automatically.
+WORKFLOW_REPO="${GITHUB_REPOSITORY}"
+TARGET_REPO="${REPO_FULL_NAME:-}"
+THIS_RUN_ID="${GITHUB_RUN_ID:-}"
+
+if [[ -n "$THIS_RUN_ID" ]]; then
+ pe_start "post-explore" "chain-refine"
+ echo "Chaining refine stage with explore run ID: ${THIS_RUN_ID}"
+
+ CURRENT_TRACEPARENT=$(get_traceparent)
+
+ CHAIN_ARGS=(
+ --repo "$WORKFLOW_REPO"
+ -f issue_key="${ISSUE_KEY}"
+ -f issue_source="${ISSUE_SOURCE}"
+ -f explore_run_id="${THIS_RUN_ID}"
+ )
+
+ # Only pass repo_full_name if a target repo was specified (not the config repo)
+ if [[ -n "$TARGET_REPO" ]]; then
+ CHAIN_ARGS+=(-f repo_full_name="${TARGET_REPO}")
+ echo "Propagating target repo: ${TARGET_REPO}"
+ fi
+
+ # Propagate trace context for distributed tracing
+ if [[ -n "$CURRENT_TRACEPARENT" ]]; then
+ CHAIN_ARGS+=(-f parent_traceparent="${CURRENT_TRACEPARENT}")
+ echo "Propagating trace context: ${CURRENT_TRACEPARENT}"
+ fi
+
+ # Pass through GitHub issue number for reply-back (GitHub flow)
+ if [[ -n "${GITHUB_ISSUE_NUMBER:-}" && "${GITHUB_ISSUE_NUMBER}" != "N/A" ]]; then
+ CHAIN_ARGS+=(-f github_issue_number="${GITHUB_ISSUE_NUMBER}")
+ fi
+
+ # Pass through auto-create preference to refine → critique chain
+ if [[ "${AUTO_CREATE:-false}" == "true" ]]; then
+ CHAIN_ARGS+=(-f auto_create="true")
+ fi
+
+ gh workflow run refine.yml "${CHAIN_ARGS[@]}" \
+ 2>/dev/null || echo "::warning::Failed to chain refine workflow — trigger manually"
+ pe_end "post-explore" "chain-refine" "$(jq -nc --arg run_id "$THIS_RUN_ID" --arg traceparent "$CURRENT_TRACEPARENT" '{explore_run_id:$run_id, traceparent:$traceparent}')"
+else
+ echo "::warning::GITHUB_RUN_ID not available — refine must be triggered manually"
+fi
+
+# --- Post exploration summary with agent identity ---
+EXPLORE_SUMMARY=$(jq -r '.summary // "Exploration complete."' "${RESULT_FILE}")
+
+RUN_LINK=$(build_run_link)
+
+EXPLORE_COMMENT="🔍 **Explore Agent** · ${RUN_LINK}
+
+**Status: ✅ Exploration Complete** (confidence: ${OVERALL_CONFIDENCE}/100, gaps: ${GAP_COUNT}, related work: ${RELATED_COUNT})
+
+${EXPLORE_SUMMARY}
+
+---
+*Chaining to the Refine Agent for decomposition.*"
+
+determine_reply_target
+post_comment "$EXPLORE_COMMENT" 2>/dev/null || true
+
+if $USE_GITHUB; then
+ EVAL_META=$(jq -nc \
+ --arg run_id "${GITHUB_RUN_ID:-manual}" \
+ --arg agent "explore" \
+ --arg issue_key "${ISSUE_KEY}" \
+ --arg issue_source "${ISSUE_SOURCE:-unknown}" \
+ --arg status "complete" \
+ --argjson confidence "$OVERALL_CONFIDENCE" \
+ --argjson dimensions "$(jq '.confidence // {}' "${RESULT_FILE}")" \
+ --argjson child_count 0 \
+ '{run_id:$run_id, agent:$agent, issue_key:$issue_key, issue_source:$issue_source, status:$status, confidence:$confidence, dimensions:$dimensions, child_count:$child_count}')
+
+ EVAL_PROMPT="---
+**Eval this run:** React with :+1: or :-1: on this comment, or reply \`/eval yes\` or \`/eval no \"reason\"\`.
+"
+
+ github_comment "${REPO_FULL_NAME:-${GITHUB_REPOSITORY}}" "$GITHUB_ISSUE_NUMBER" "$EVAL_PROMPT" 2>/dev/null || true
+fi
+
+pe_end "post-explore" "post-explore" "$(jq -nc --argjson conf "$OVERALL_CONFIDENCE" --argjson gaps "$GAP_COUNT" '{confidence:$conf, gaps:$gaps}')"
+pe_copy_to_output
+
+echo "Post-explore complete."
diff --git a/internal/scaffold/fullsend-repo/scripts/post-refine-test.sh b/internal/scaffold/fullsend-repo/scripts/post-refine-test.sh
new file mode 100755
index 000000000..2798e636b
--- /dev/null
+++ b/internal/scaffold/fullsend-repo/scripts/post-refine-test.sh
@@ -0,0 +1,184 @@
+#!/usr/bin/env bash
+# post-refine-test.sh — Test post-refine.sh with fixture JSON and mock gh.
+#
+# Run from the repo root: bash internal/scaffold/fullsend-repo/scripts/post-refine-test.sh
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+POST_SCRIPT="${SCRIPT_DIR}/post-refine.sh"
+FAILURES=0
+
+TEST_TMPDIR="$(mktemp -d)"
+trap 'rm -rf "${TEST_TMPDIR}"' EXIT
+
+GH_LOG="${TEST_TMPDIR}/gh-calls.log"
+MOCK_BIN="${TEST_TMPDIR}/bin"
+mkdir -p "${MOCK_BIN}"
+
+cat > "${MOCK_BIN}/gh" <<'MOCKEOF'
+#!/usr/bin/env bash
+echo "gh $*" >> "$GH_LOG"
+
+case "$*" in
+ *"issue comment"*)
+ cat > /dev/null
+ exit 0
+ ;;
+ *"workflow run"*)
+ exit 0
+ ;;
+ *"api"*)
+ exit 0
+ ;;
+esac
+exit 0
+MOCKEOF
+chmod +x "${MOCK_BIN}/gh"
+
+cat > "${MOCK_BIN}/python3" <<'MOCKEOF'
+#!/usr/bin/env bash
+if [[ "${1:-}" == "-c" ]]; then
+ if [[ "${2:-}" == *"time.time"* ]]; then
+ echo "1000000"
+ exit 0
+ fi
+ echo ""
+ exit 0
+fi
+exec /usr/bin/python3 "$@"
+MOCKEOF
+chmod +x "${MOCK_BIN}/python3"
+
+export PATH="${MOCK_BIN}:${PATH}"
+export GH_LOG="${GH_LOG}"
+export GH_TOKEN="fake-token"
+export ISSUE_KEY="42"
+export ISSUE_SOURCE="github"
+export REPO_FULL_NAME="test-org/test-repo"
+export GITHUB_REPOSITORY="test-org/.fullsend"
+export GITHUB_RUN_ID="12345"
+export GITHUB_ISSUE_NUMBER="42"
+export GITHUB_WORKSPACE="${TEST_TMPDIR}"
+
+REFINE_FIXTURE='{
+ "status": "complete",
+ "confidence": {"overall": 78},
+ "comment": "Proposed decomposition plan.",
+ "proposed_description": "Enhanced feature description.",
+ "children": [
+ {"title": "Child 1", "type": "story", "description": "First child", "acceptance_criteria": ["AC1"]},
+ {"title": "Child 2", "type": "task", "description": "Second child", "acceptance_criteria": ["AC2"]}
+ ],
+ "open_questions": [{"dimension": "scope", "question": "Is this in scope?", "impact": "High"}]
+}'
+
+run_test() {
+ local test_name="$1"
+ local fixture="${2:-$REFINE_FIXTURE}"
+ local expect_failure="${3:-false}"
+
+ local run_dir="${TEST_TMPDIR}/run-${test_name}"
+ mkdir -p "${run_dir}/iteration-1/output"
+ echo "${fixture}" > "${run_dir}/iteration-1/output/agent-result.json"
+
+ : > "${GH_LOG}"
+
+ local exit_code=0
+ (cd "${run_dir}" && bash "${POST_SCRIPT}") > "${TEST_TMPDIR}/stdout-${test_name}.log" 2>&1 || exit_code=$?
+
+ if [[ "${expect_failure}" == "true" ]]; then
+ if [[ ${exit_code} -eq 0 ]]; then
+ echo "FAIL: ${test_name} — expected failure but got success"
+ FAILURES=$((FAILURES + 1))
+ return
+ fi
+ echo "PASS: ${test_name} (expected failure)"
+ return
+ fi
+
+ if [[ ${exit_code} -ne 0 ]]; then
+ echo "FAIL: ${test_name} — exit code ${exit_code}"
+ cat "${TEST_TMPDIR}/stdout-${test_name}.log"
+ FAILURES=$((FAILURES + 1))
+ return
+ fi
+
+ echo "PASS: ${test_name}"
+}
+
+assert_gh_called() {
+ local test_name="$1" pattern="$2"
+ if ! grep -qF "${pattern}" "${GH_LOG}"; then
+ echo "FAIL: ${test_name} — expected gh call matching '${pattern}'"
+ cat "${GH_LOG}"
+ FAILURES=$((FAILURES + 1))
+ fi
+}
+
+assert_stdout_contains() {
+ local test_name="$1" pattern="$2"
+ if ! grep -qF "${pattern}" "${TEST_TMPDIR}/stdout-${test_name}.log"; then
+ echo "FAIL: ${test_name} — expected stdout containing '${pattern}'"
+ FAILURES=$((FAILURES + 1))
+ fi
+}
+
+assert_gh_not_called() {
+ local test_name="$1" pattern="$2"
+ if grep -qF "${pattern}" "${GH_LOG}"; then
+ echo "FAIL: ${test_name} — unexpected gh call matching '${pattern}'"
+ cat "${GH_LOG}"
+ FAILURES=$((FAILURES + 1))
+ fi
+}
+
+# --- Tests ---
+
+# Happy path: refine completes and chains critique
+run_test "happy-path"
+assert_gh_called "happy-path" "workflow run critique.yml"
+assert_gh_called "happy-path" "issue_key=42"
+assert_gh_called "happy-path" "refine_run_id=12345"
+assert_gh_called "happy-path" "review_round=1"
+assert_gh_called "happy-path" "issue comment"
+assert_stdout_contains "happy-path" "Post-refine complete"
+
+# Revision round propagation
+export REVIEW_ROUND="2"
+run_test "revision-round"
+assert_gh_called "revision-round" "review_round=2"
+assert_stdout_contains "revision-round" "Post-refine complete"
+unset REVIEW_ROUND
+
+# Auto-create pass-through
+export AUTO_CREATE="true"
+run_test "auto-create-passthrough"
+assert_gh_called "auto-create-passthrough" "auto_create=true"
+unset AUTO_CREATE
+
+# Missing agent result — run from empty dir with no iteration-*/output/
+test_name="missing-result"
+run_dir="${TEST_TMPDIR}/run-${test_name}"
+mkdir -p "${run_dir}"
+: > "${GH_LOG}"
+exit_code=0
+(cd "${run_dir}" && bash "${POST_SCRIPT}") > "${TEST_TMPDIR}/stdout-${test_name}.log" 2>&1 || exit_code=$?
+if [[ ${exit_code} -ne 0 ]]; then
+ echo "PASS: ${test_name} (expected failure)"
+else
+ echo "FAIL: ${test_name} — expected failure but got success"
+ FAILURES=$((FAILURES + 1))
+fi
+
+# Invalid JSON result
+run_test "invalid-json" "not valid json" "true"
+
+if [[ ${FAILURES} -gt 0 ]]; then
+ echo ""
+ echo "${FAILURES} test(s) failed."
+ exit 1
+fi
+
+echo ""
+echo "All post-refine tests passed."
diff --git a/internal/scaffold/fullsend-repo/scripts/post-refine.sh b/internal/scaffold/fullsend-repo/scripts/post-refine.sh
new file mode 100755
index 000000000..800366d0b
--- /dev/null
+++ b/internal/scaffold/fullsend-repo/scripts/post-refine.sh
@@ -0,0 +1,196 @@
+#!/usr/bin/env bash
+# post-refine.sh — Process refine agent output and chain to critique.
+#
+# The refine agent ALWAYS produces a plan (status=complete). This script
+# posts a summary comment and ALWAYS chains to the critique agent regardless
+# of confidence. The confidence score is passed to critique as context —
+# critique decides whether to approve, request revisions, or escalate.
+#
+# Issue creation is handled downstream by the critique agent's approval flow,
+# NOT by this script. See post-critique.sh and create-children.sh.
+#
+# Routing: results go back to the same system that owns the work item.
+# - GitHub flow: GITHUB_ISSUE_NUMBER is set → post to GitHub issue
+# - Jira flow: GITHUB_ISSUE_NUMBER is empty → post to Jira
+#
+# Required env vars:
+# ISSUE_KEY — Issue identifier (Jira key or GH issue number)
+# ISSUE_SOURCE — "jira" or "github"
+# GH_TOKEN — GitHub token
+#
+# GitHub flow env vars:
+# GITHUB_ISSUE_NUMBER — GitHub issue number to post results to
+# REPO_FULL_NAME — owner/repo
+# PUSH_TOKEN — Token with write access
+#
+# Jira flow env vars:
+# JIRA_HOST, JIRA_EMAIL, JIRA_API_TOKEN
+#
+# Critique flow env vars (passed through from critique → refine loop):
+# REVIEW_ROUND — Current review round (default: 1)
+# MAX_REVIEW_ROUNDS — Max rounds (default: 3)
+# AUTO_CREATE — "true" to auto-create on approval (default: "false")
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+source "${SCRIPT_DIR}/pipeline-events.sh"
+source "${SCRIPT_DIR}/pipeline-helpers.sh"
+
+pe_start "post-refine" "post-refine"
+
+RESULT_FILE=$(find_agent_result) || exit 1
+echo "Reading refine result from: ${RESULT_FILE}"
+
+STATUS=$(jq -r '.status' "${RESULT_FILE}")
+COMMENT=$(jq -r '.comment // ""' "${RESULT_FILE}")
+CONFIDENCE=$(jq -r '.confidence.overall // 0' "${RESULT_FILE}")
+
+echo "Status: ${STATUS}, Confidence: ${CONFIDENCE}"
+
+determine_reply_target
+echo "Reply target: $(if $USE_GITHUB; then echo "GitHub #${GITHUB_ISSUE_NUMBER}"; else echo "Jira ${ISSUE_KEY}"; fi)"
+
+REVIEW_ROUND="${REVIEW_ROUND:-1}"
+MAX_REVIEW_ROUNDS="${MAX_REVIEW_ROUNDS:-3}"
+AUTO_CREATE="${AUTO_CREATE:-false}"
+IS_REVISION=$([[ "$REVIEW_ROUND" -gt 1 ]] && echo "true" || echo "false")
+
+RUN_LINK=$(build_run_link)
+
+AGENT_HEADER="📋 **Refine Agent** · ${RUN_LINK}"
+if [[ "$IS_REVISION" == "true" ]]; then
+ AGENT_HEADER="${AGENT_HEADER} · Iteration ${REVIEW_ROUND} (revised)"
+fi
+
+# --- Post plan and always chain to critique ---
+
+CONFIDENCE_INT=$(printf '%.0f' "$CONFIDENCE" 2>/dev/null || echo "0")
+
+pe_start "post-refine" "post-plan"
+echo "::notice::Refine complete (confidence ${CONFIDENCE_INT}/100) — posting proposed plan and chaining critique"
+
+if $USE_GITHUB; then
+ remove_label "${REPO_FULL_NAME}" "$GITHUB_ISSUE_NUMBER" "refine-needs-input"
+ remove_label "${REPO_FULL_NAME}" "$GITHUB_ISSUE_NUMBER" "human-refinement"
+fi
+
+CHILD_COUNT=$(jq '.children | length' "${RESULT_FILE}" 2>/dev/null || echo "0")
+OPEN_QUESTION_COUNT=$(jq '.open_questions | length' "${RESULT_FILE}" 2>/dev/null || echo "0")
+
+EPIC_COUNT=$(jq '[.children[]? | select(.type == "epic")] | length' "${RESULT_FILE}" 2>/dev/null || echo "0")
+STORY_COUNT=$(jq '[.children[]? | select(.type == "story")] | length' "${RESULT_FILE}" 2>/dev/null || echo "0")
+TASK_COUNT=$(jq '[.children[]? | select(.type == "task")] | length' "${RESULT_FILE}" 2>/dev/null || echo "0")
+
+PLAN_SUMMARY="Proposed: ${CHILD_COUNT} work items"
+PLAN_PARTS=()
+[[ "$EPIC_COUNT" -gt 0 ]] && PLAN_PARTS+=("${EPIC_COUNT} epics")
+[[ "$STORY_COUNT" -gt 0 ]] && PLAN_PARTS+=("${STORY_COUNT} stories")
+[[ "$TASK_COUNT" -gt 0 ]] && PLAN_PARTS+=("${TASK_COUNT} tasks")
+if [[ ${#PLAN_PARTS[@]} -gt 0 ]]; then
+ PLAN_SUMMARY="${PLAN_SUMMARY} ($(IFS=', '; echo "${PLAN_PARTS[*]}"))"
+fi
+
+if [[ "$OPEN_QUESTION_COUNT" -gt 0 ]]; then
+ PLAN_SUMMARY="${PLAN_SUMMARY} · ${OPEN_QUESTION_COUNT} open question(s)"
+fi
+
+WORKFLOW_REPO="${GITHUB_REPOSITORY:-${REPO_FULL_NAME}}"
+ARTIFACT_URL="https://github.com/${WORKFLOW_REPO}/actions/runs/${GITHUB_RUN_ID:-}"
+
+QUESTIONS_SECTION=""
+if [[ "$OPEN_QUESTION_COUNT" -gt 0 ]]; then
+ QUESTIONS_LIST=$(jq -r '.open_questions[]? | if type == "object" then "- **\(.dimension // "general")**: \(.question // .text // .description // tostring)\n *Impact*: \(.impact // "Unknown")" else "- \(tostring)" end' "${RESULT_FILE}" 2>/dev/null || true)
+ if [[ -n "$QUESTIONS_LIST" ]]; then
+ QUESTIONS_SECTION="
+---
+
+## Open Questions
+
+${OPEN_QUESTION_COUNT} question(s) that may affect plan accuracy — reply with answers, then comment \`/fs-refine\` to re-run.
+
+${QUESTIONS_LIST}"
+ fi
+fi
+
+PLAN_COMMENT="${AGENT_HEADER}
+
+**Refinement Plan** (confidence: ${CONFIDENCE}/100)
+
+${PLAN_SUMMARY}
+
+${COMMENT}
+
+📎 [**Full plan details** (all epics, stories, tasks, acceptance criteria)](${ARTIFACT_URL}) — download the \`fullsend-refine\` artifact for the complete \`refine-result.json\`.
+${QUESTIONS_SECTION}
+
+---
+*This plan will be reviewed by the Critique Agent before any issues are created.*"
+
+post_comment "$PLAN_COMMENT"
+
+# Post proposed description as a separate comment (standalone document)
+PROPOSED_DESC=$(jq -r '.proposed_description // ""' "${RESULT_FILE}")
+if [[ -n "$PROPOSED_DESC" && "$PROPOSED_DESC" != "null" ]]; then
+ DESC_COMMENT="📝 **Refine Agent** · Proposed Feature Description
+
+The following is a proposed enhanced description for this feature based on exploration research and decomposition analysis. If the plan is approved, this description can replace the current one.
+
+---
+
+${PROPOSED_DESC}"
+
+ post_comment "$DESC_COMMENT"
+fi
+
+# Save the refine result for critique to pick up via artifact
+cp "${RESULT_FILE}" "/tmp/workspace/refine-result.json"
+
+# Chain to the critique agent
+WORKFLOW_REPO="${GITHUB_REPOSITORY}"
+TARGET_REPO="${REPO_FULL_NAME:-}"
+THIS_RUN_ID="${GITHUB_RUN_ID:-}"
+
+if [[ -n "$THIS_RUN_ID" ]]; then
+ pe_start "post-refine" "chain-critique"
+ echo "Chaining critique stage with refine run ID: ${THIS_RUN_ID}"
+
+ CURRENT_TRACEPARENT=$(get_traceparent)
+
+ CHAIN_ARGS=(
+ --repo "$WORKFLOW_REPO"
+ -f issue_key="${ISSUE_KEY}"
+ -f issue_source="${ISSUE_SOURCE}"
+ -f refine_run_id="${THIS_RUN_ID}"
+ -f review_round="${REVIEW_ROUND}"
+ -f max_review_rounds="${MAX_REVIEW_ROUNDS}"
+ -f auto_create="${AUTO_CREATE}"
+ )
+
+ if [[ -n "$TARGET_REPO" ]]; then
+ CHAIN_ARGS+=(-f repo_full_name="${TARGET_REPO}")
+ echo "Propagating target repo: ${TARGET_REPO}"
+ fi
+
+ if [[ -n "$CURRENT_TRACEPARENT" ]]; then
+ CHAIN_ARGS+=(-f parent_traceparent="${CURRENT_TRACEPARENT}")
+ echo "Propagating trace context: ${CURRENT_TRACEPARENT}"
+ fi
+
+ if [[ -n "${GITHUB_ISSUE_NUMBER:-}" && "${GITHUB_ISSUE_NUMBER}" != "N/A" ]]; then
+ CHAIN_ARGS+=(-f github_issue_number="${GITHUB_ISSUE_NUMBER}")
+ fi
+
+ gh workflow run critique.yml "${CHAIN_ARGS[@]}" \
+ 2>/dev/null || echo "::warning::Failed to chain critique workflow — trigger manually"
+ pe_end "post-refine" "chain-critique" "$(jq -nc --arg run_id "$THIS_RUN_ID" --arg traceparent "$CURRENT_TRACEPARENT" --argjson round "$REVIEW_ROUND" '{refine_run_id:$run_id, traceparent:$traceparent, review_round:$round}')"
+else
+ echo "::warning::GITHUB_RUN_ID not available — critique must be triggered manually"
+fi
+
+pe_end "post-refine" "post-plan" "$(jq -nc --argjson total "$CHILD_COUNT" --argjson epics "$EPIC_COUNT" --argjson stories "$STORY_COUNT" --argjson tasks "$TASK_COUNT" --argjson open_questions "$OPEN_QUESTION_COUNT" '{total:$total, epics:$epics, stories:$stories, tasks:$tasks, open_questions:$open_questions}')"
+
+pe_end "post-refine" "post-refine" "$(jq -nc --arg status "$STATUS" --argjson confidence "$CONFIDENCE_INT" --argjson round "$REVIEW_ROUND" '{status:$status, confidence:$confidence, review_round:$round}')"
+pe_copy_to_output
+
+echo "Post-refine complete."
diff --git a/internal/scaffold/fullsend-repo/scripts/pre-critique.sh b/internal/scaffold/fullsend-repo/scripts/pre-critique.sh
new file mode 100755
index 000000000..faaf5dee3
--- /dev/null
+++ b/internal/scaffold/fullsend-repo/scripts/pre-critique.sh
@@ -0,0 +1,145 @@
+#!/usr/bin/env bash
+# pre-critique.sh — Prepare context for the critique agent.
+#
+# Downloads the refine agent's result and assembles the full context
+# (issue, exploration, refinement plan, prior critique history) for review.
+#
+# Required env vars:
+# ISSUE_KEY — Issue identifier
+# ISSUE_SOURCE — "jira" or "github"
+# REFINE_RUN_ID — GitHub Actions run ID of the refine stage
+# GH_TOKEN — GitHub token
+#
+# Optional env vars:
+# REVIEW_ROUND — Current review round (default: 1)
+# MAX_REVIEW_ROUNDS — Max rounds before escalation (default: 3)
+# GITHUB_ISSUE_NUMBER — GitHub issue for reply-back
+# JIRA_HOST, JIRA_EMAIL, JIRA_API_TOKEN — for Jira sources
+# REPO_FULL_NAME — for GitHub sources
+
+set -euo pipefail
+
+WORKSPACE="/tmp/workspace"
+mkdir -p "$WORKSPACE"
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+source "${SCRIPT_DIR}/pipeline-events.sh"
+
+pe_start "pre-critique" "pre-critique"
+
+REVIEW_ROUND="${REVIEW_ROUND:-1}"
+MAX_REVIEW_ROUNDS="${MAX_REVIEW_ROUNDS:-3}"
+
+echo "::notice::Pre-critique: preparing context (source=${ISSUE_SOURCE}, key=${ISSUE_KEY}, round=${REVIEW_ROUND}/${MAX_REVIEW_ROUNDS})"
+
+# --- Step 1: Ensure issue context ---
+pe_start "pre-critique" "fetch-issue-context"
+if [[ ! -f "$WORKSPACE/issue-context.json" ]]; then
+ if [[ -f "${SCRIPT_DIR}/pre-explore.sh" ]]; then
+ echo "Fetching issue context via pre-explore.sh..."
+ bash "${SCRIPT_DIR}/pre-explore.sh"
+ else
+ echo "ERROR: No issue context available and pre-explore.sh not found"
+ exit 1
+ fi
+fi
+pe_end "pre-critique" "fetch-issue-context" '{}'
+
+# --- Step 2: Download refine result ---
+pe_start "pre-critique" "fetch-refine-result"
+
+# Check for refine-result.json already present (from post-refine.sh copy),
+# or extract from downloaded artifact iteration layout
+if [[ -f "$WORKSPACE/refine-result.json" ]]; then
+ echo "Refine result already present."
+elif ls "$WORKSPACE"/iteration-*/output/agent-result.json 1>/dev/null 2>&1; then
+ # Artifact was downloaded directly to workspace — extract the result
+ for dir in "$WORKSPACE"/iteration-*/output; do
+ if [[ -f "${dir}/agent-result.json" ]]; then
+ cp "${dir}/agent-result.json" "$WORKSPACE/refine-result.json"
+ fi
+ done
+ echo "Refine result extracted from downloaded artifact."
+elif [[ -n "${REFINE_RUN_ID:-}" && "${REFINE_RUN_ID}" != "N/A" ]]; then
+ REPO="${REPO_FULL_NAME:-$(gh api repos/:owner/:repo --jq .full_name 2>/dev/null || echo "")}"
+ if [[ -n "$REPO" ]]; then
+ echo "Downloading refine artifact from run ${REFINE_RUN_ID}..."
+ ARTIFACT_DIR=$(mktemp -d)
+ if gh run download "$REFINE_RUN_ID" --repo "$REPO" --name "fullsend-refine" --dir "$ARTIFACT_DIR" 2>/dev/null; then
+ # Find the agent-result.json in the artifact
+ REFINE_RESULT_IN_ARTIFACT=""
+ for dir in "$ARTIFACT_DIR"/iteration-*/output; do
+ if [[ -f "${dir}/agent-result.json" ]]; then
+ REFINE_RESULT_IN_ARTIFACT="${dir}/agent-result.json"
+ fi
+ done
+
+ if [[ -n "$REFINE_RESULT_IN_ARTIFACT" ]]; then
+ cp "$REFINE_RESULT_IN_ARTIFACT" "$WORKSPACE/refine-result.json"
+ echo "Refine result extracted from artifact."
+ else
+ echo "::error::Refine artifact downloaded but agent-result.json not found"
+ exit 1
+ fi
+
+ # Also grab exploration context and issue context if present
+ for f in exploration_context.json issue-context.json; do
+ if [[ -f "$ARTIFACT_DIR/$f" && ! -f "$WORKSPACE/$f" ]]; then
+ cp "$ARTIFACT_DIR/$f" "$WORKSPACE/$f"
+ fi
+ done
+ else
+ echo "::error::Could not download refine artifact from run ${REFINE_RUN_ID}"
+ exit 1
+ fi
+ rm -rf "$ARTIFACT_DIR"
+ fi
+else
+ echo "::error::No refine result available (REFINE_RUN_ID not set)"
+ exit 1
+fi
+
+if ! jq empty "$WORKSPACE/refine-result.json" 2>/dev/null; then
+ echo "::error::Refine result is not valid JSON"
+ exit 1
+fi
+
+pe_end "pre-critique" "fetch-refine-result" '{}'
+
+# --- Step 3: Build critique history for round 2+ ---
+pe_start "pre-critique" "build-critique-history"
+
+if [[ "$REVIEW_ROUND" -gt 1 && ! -f "$WORKSPACE/critique-history.json" ]]; then
+ echo "Round ${REVIEW_ROUND}: building critique history from prior rounds..."
+ # History is accumulated by post-critique.sh and passed as an artifact.
+ # If it's not already present from artifact download, create empty placeholder.
+ echo '{"rounds": [], "note": "History not available from artifact — critique agent should focus on current plan quality"}' \
+ > "$WORKSPACE/critique-history.json"
+fi
+
+if [[ ! -f "$WORKSPACE/critique-history.json" ]]; then
+ echo '{"rounds": []}' > "$WORKSPACE/critique-history.json"
+fi
+
+pe_end "pre-critique" "build-critique-history" "$(jq -nc --argjson round "$REVIEW_ROUND" '{review_round:$round}')"
+
+# --- Step 4: Ensure exploration context ---
+if [[ ! -f "$WORKSPACE/exploration_context.json" ]]; then
+ echo "::warning::No exploration context available — critique will rely on issue context and refine result"
+ echo '{"gaps": [{"dimension": "exploration", "description": "Explore stage context not available to critique"}], "confidence": {"overall": 50}}' \
+ > "$WORKSPACE/exploration_context.json"
+fi
+
+# --- Export paths ---
+{
+ echo "ISSUE_CONTEXT=$WORKSPACE/issue-context.json"
+ echo "EXPLORE_CONTEXT=$WORKSPACE/exploration_context.json"
+ echo "REFINE_RESULT=$WORKSPACE/refine-result.json"
+ echo "CRITIQUE_HISTORY=$WORKSPACE/critique-history.json"
+ echo "REVIEW_ROUND=$REVIEW_ROUND"
+ echo "MAX_REVIEW_ROUNDS=$MAX_REVIEW_ROUNDS"
+} >> "${GITHUB_ENV:-/dev/null}"
+
+pe_end "pre-critique" "pre-critique" "$(jq -nc --arg source "$ISSUE_SOURCE" --arg key "$ISSUE_KEY" --argjson round "$REVIEW_ROUND" '{source:$source, key:$key, review_round:$round}')"
+
+echo "Pre-critique complete."
diff --git a/internal/scaffold/fullsend-repo/scripts/pre-explore.sh b/internal/scaffold/fullsend-repo/scripts/pre-explore.sh
new file mode 100755
index 000000000..c13982636
--- /dev/null
+++ b/internal/scaffold/fullsend-repo/scripts/pre-explore.sh
@@ -0,0 +1,327 @@
+#!/usr/bin/env bash
+# pre-explore.sh — Fetch issue data and prepare context for the explore agent.
+#
+# Runs on the host before the sandbox is created. Fetches issue data from
+# Jira or GitHub using credentials that never enter the sandbox.
+#
+# Required env vars:
+# ISSUE_KEY — Jira key (e.g., SECURESIGN-1620) or GitHub issue number
+# ISSUE_SOURCE — "jira" or "github"
+# GH_TOKEN — GitHub token
+#
+# Jira-only env vars:
+# JIRA_HOST — Jira hostname (e.g., stage-redhat.atlassian.net)
+# JIRA_EMAIL — Jira user email
+# JIRA_API_TOKEN — Jira API token
+#
+# GitHub-only env vars:
+# REPO_FULL_NAME — owner/repo (e.g., fullsend-ai/features)
+
+set -euo pipefail
+
+WORKSPACE="/tmp/workspace"
+mkdir -p "$WORKSPACE"
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+source "${SCRIPT_DIR}/pipeline-events.sh"
+
+pe_start "pre-explore" "pre-explore"
+
+echo "::notice::Pre-explore: fetching issue data (source=${ISSUE_SOURCE}, key=${ISSUE_KEY})"
+
+# --- Safety check: block private Jira on public repos ---
+# The risk: private Jira data leaking into public GitHub Actions artifacts/logs.
+# This only matters when the Jira project is private. Public Jira on a public
+# repo is fine — the data is already public.
+#
+# JIRA_PROJECT_VISIBILITY controls this. Values:
+# "public" — Jira project is publicly accessible, skip the check
+# "private" — Jira project is private, require a private config repo (default)
+# unset — treated as "private" (safe default)
+if [[ "${ISSUE_SOURCE}" == "jira" ]]; then
+ JIRA_VIS="${JIRA_PROJECT_VISIBILITY:-private}"
+ if [[ "$JIRA_VIS" != "public" ]]; then
+ CONFIG_REPO="${GITHUB_REPOSITORY:-}"
+ if [[ -n "$CONFIG_REPO" ]]; then
+ REPO_VISIBILITY=$(gh api "repos/${CONFIG_REPO}" --jq '.visibility' 2>/dev/null || echo "public")
+ if [[ "$REPO_VISIBILITY" == "public" ]]; then
+ echo "::error::SECURITY: Private Jira source blocked — this repo (${CONFIG_REPO}) is public."
+ echo "::error::Private Jira data would leak into public workflow artifacts and logs."
+ echo "::error::Options:"
+ echo "::error:: 1. Run fullsend from a PRIVATE config repo"
+ echo "::error:: 2. Set JIRA_PROJECT_VISIBILITY=public if this Jira project is publicly accessible"
+ echo "::error:: 3. Use ISSUE_SOURCE=github instead"
+ exit 1
+ fi
+ fi
+ fi
+fi
+
+# Validate inputs to prevent injection
+if [[ "${ISSUE_SOURCE}" != "jira" && "${ISSUE_SOURCE}" != "github" ]]; then
+ echo "::error::ISSUE_SOURCE must be 'jira' or 'github', got: ${ISSUE_SOURCE}"
+ exit 1
+fi
+
+if [[ "${ISSUE_SOURCE}" == "jira" && ! "${ISSUE_KEY}" =~ ^[A-Z][A-Z0-9]+-[0-9]+$ ]]; then
+ echo "::error::ISSUE_KEY does not match Jira key pattern (e.g., PROJECT-123): ${ISSUE_KEY}"
+ exit 1
+fi
+
+if [[ "${ISSUE_SOURCE}" == "github" && ! "${ISSUE_KEY}" =~ ^[0-9]+$ ]]; then
+ echo "::error::ISSUE_KEY must be a numeric GitHub issue number, got: ${ISSUE_KEY}"
+ exit 1
+fi
+
+if [[ "${ISSUE_SOURCE}" == "jira" ]]; then
+ if [[ -z "${JIRA_HOST:-}" || -z "${JIRA_EMAIL:-}" || -z "${JIRA_API_TOKEN:-}" ]]; then
+ echo "::error::Jira credentials not set (JIRA_HOST, JIRA_EMAIL, JIRA_API_TOKEN)"
+ exit 1
+ fi
+
+ JIRA_BASE="https://${JIRA_HOST}/rest/api/3"
+ AUTH=$(printf '%s:%s' "$JIRA_EMAIL" "$JIRA_API_TOKEN" | base64 -w0)
+
+ # Preflight: verify Jira token is valid before doing any real work
+ PREFLIGHT_HTTP=$(curl -sS -o /dev/null -w "%{http_code}" \
+ -H "Authorization: Basic $AUTH" \
+ "${JIRA_BASE}/myself" 2>/dev/null || echo "000")
+
+ if [[ "$PREFLIGHT_HTTP" == "401" || "$PREFLIGHT_HTTP" == "403" ]]; then
+ echo "::error::Jira API token is invalid or expired (HTTP ${PREFLIGHT_HTTP})."
+ echo "::error::Update JIRA_API_TOKEN in your .fullsend config repo secrets."
+ echo "::error::Pipeline halted — no Jira API calls will be attempted."
+ exit 1
+ elif [[ "$PREFLIGHT_HTTP" == "000" ]]; then
+ echo "::warning::Could not reach Jira at ${JIRA_HOST} — continuing (may fail later)"
+ fi
+
+ jira_get() {
+ curl -sSf -H "Authorization: Basic $AUTH" \
+ -H "Accept: application/json" "$1"
+ }
+
+ pe_start "pre-explore" "fetch-issue"
+ ISSUE_JSON=$(jira_get "${JIRA_BASE}/issue/${ISSUE_KEY}?expand=names")
+
+ SUMMARY=$(echo "$ISSUE_JSON" | jq -r '.fields.summary // ""')
+ DESCRIPTION=$(echo "$ISSUE_JSON" | jq -r '.fields.description // "" | if type == "object" then (.content // [] | map(.content // [] | map(.text // "") | join("")) | join("\n")) else . end')
+ STATUS=$(echo "$ISSUE_JSON" | jq -r '.fields.status.name // ""')
+ PRIORITY=$(echo "$ISSUE_JSON" | jq -r '.fields.priority.name // ""')
+ ISSUE_TYPE=$(echo "$ISSUE_JSON" | jq -r '.fields.issuetype.name // ""')
+ REPORTER=$(echo "$ISSUE_JSON" | jq -r '.fields.reporter.emailAddress // ""')
+ LABELS=$(echo "$ISSUE_JSON" | jq -c '.fields.labels // []')
+ CREATED=$(echo "$ISSUE_JSON" | jq -r '.fields.created // ""')
+ UPDATED=$(echo "$ISSUE_JSON" | jq -r '.fields.updated // ""')
+
+ # Determine level from issue type
+ LEVEL="issue"
+ case "${ISSUE_TYPE,,}" in
+ outcome) LEVEL="outcome" ;;
+ feature) LEVEL="feature" ;;
+ epic) LEVEL="epic" ;;
+ story) LEVEL="story" ;;
+ task|sub-task) LEVEL="task" ;;
+ esac
+
+ pe_end "pre-explore" "fetch-issue" "$(jq -nc --arg key "$ISSUE_KEY" --arg type "$ISSUE_TYPE" --arg level "$LEVEL" --arg status "$STATUS" '{key:$key, type:$type, level:$level, status:$status}')"
+
+ pe_start "pre-explore" "fetch-parent"
+ PARENT_KEY=$(echo "$ISSUE_JSON" | jq -r '.fields.parent.key // ""')
+ PARENT_JSON="null"
+ if [[ -n "$PARENT_KEY" ]]; then
+ PARENT_ISSUE=$(jira_get "${JIRA_BASE}/issue/${PARENT_KEY}" 2>/dev/null || echo "{}")
+ PARENT_SUMMARY=$(echo "$PARENT_ISSUE" | jq -r '.fields.summary // ""')
+ PARENT_DESC=$(echo "$PARENT_ISSUE" | jq -r '.fields.description // "" | if type == "object" then (.content // [] | map(.content // [] | map(.text // "") | join("")) | join("\n")) else . end')
+ PARENT_JSON=$(jq -n --arg k "$PARENT_KEY" --arg s "$PARENT_SUMMARY" --arg d "$PARENT_DESC" \
+ '{"key": $k, "summary": $s, "description": $d}')
+ fi
+
+ pe_end "pre-explore" "fetch-parent" "$(jq -nc --arg parent_key "${PARENT_KEY:-none}" '{parent_key:$parent_key}')"
+
+ pe_start "pre-explore" "fetch-children"
+ CHILDREN_JSON=$(jira_get "${JIRA_BASE}/search?jql=parent=${ISSUE_KEY}&fields=summary,status,issuetype&maxResults=50" 2>/dev/null \
+ | jq '[.issues[] | {key: .key, summary: .fields.summary, status: .fields.status.name, type: .fields.issuetype.name}]' \
+ || echo "[]")
+
+ CHILD_COUNT=$(echo "$CHILDREN_JSON" | jq 'length')
+ pe_end "pre-explore" "fetch-children" "$(jq -nc --argjson count "$CHILD_COUNT" '{child_count:$count}')"
+
+ pe_start "pre-explore" "fetch-comments"
+ COMMENTS_JSON=$(jira_get "${JIRA_BASE}/issue/${ISSUE_KEY}/comment?maxResults=50" 2>/dev/null \
+ | jq '[.comments[] | {author: .author.emailAddress, created: .created, body: (.body | if type == "object" then (.content // [] | map(.content // [] | map(.text // "") | join("")) | join("\n")) else . end)}]' \
+ || echo "[]")
+
+ COMMENT_COUNT=$(echo "$COMMENTS_JSON" | jq 'length')
+ pe_end "pre-explore" "fetch-comments" "$(jq -nc --argjson count "$COMMENT_COUNT" '{comment_count:$count}')"
+
+ pe_start "pre-explore" "fetch-links-and-project"
+ LINKS_JSON=$(echo "$ISSUE_JSON" | jq '[.fields.issuelinks // [] | .[] | {
+ type: (.type.outward // .type.name),
+ key: (.outwardIssue.key // .inwardIssue.key),
+ summary: (.outwardIssue.fields.summary // .inwardIssue.fields.summary),
+ status: (.outwardIssue.fields.status.name // .inwardIssue.fields.status.name)
+ }]')
+
+ # Fetch project metadata
+ PROJECT_KEY=$(echo "$ISSUE_JSON" | jq -r '.fields.project.key')
+ PROJECT_NAME=$(echo "$ISSUE_JSON" | jq -r '.fields.project.name')
+
+ # Fetch available issue types for the project
+ PROJECT_ISSUE_TYPES=$(jira_get "${JIRA_BASE}/project/${PROJECT_KEY}" 2>/dev/null \
+ | jq '[.issueTypes[]? | {name: .name, subtask: .subtask, hierarchyLevel: .hierarchyLevel, description: .description}]' \
+ || echo "[]")
+
+ # If project endpoint didn't return issue types, try createmeta
+ if [[ "$PROJECT_ISSUE_TYPES" == "[]" || "$PROJECT_ISSUE_TYPES" == "null" ]]; then
+ PROJECT_ISSUE_TYPES=$(jira_get "${JIRA_BASE}/issue/createmeta?projectKeys=${PROJECT_KEY}&expand=projects.issuetypes" 2>/dev/null \
+ | jq '[.projects[0].issuetypes[]? | {name: .name, subtask: .subtask, hierarchyLevel: .hierarchyLevel, description: .description}]' \
+ || echo "[]")
+ fi
+
+ echo "Available issue types for ${PROJECT_KEY}: $(echo "$PROJECT_ISSUE_TYPES" | jq -r '[.[].name] | join(", ")')"
+
+ # Sample existing children to learn team conventions (type distribution)
+ TEAM_USAGE=$(jira_get "${JIRA_BASE}/search?jql=project=${PROJECT_KEY}+AND+issuetype+in+(Story,Task,Epic,Feature,Bug,Spike)+ORDER+BY+created+DESC&fields=issuetype,labels&maxResults=50" 2>/dev/null \
+ | jq '{
+ type_counts: [.issues[]? | .fields.issuetype.name] | group_by(.) | map({type: .[0], count: length}) | sort_by(-.count),
+ common_labels: [.issues[]? | .fields.labels[]?] | group_by(.) | map({label: .[0], count: length}) | sort_by(-.count) | .[0:10]
+ }' \
+ || echo '{"type_counts": [], "common_labels": []}')
+
+ LINK_COUNT=$(echo "$LINKS_JSON" | jq 'length')
+ TYPE_COUNT=$(echo "$PROJECT_ISSUE_TYPES" | jq 'length')
+ pe_end "pre-explore" "fetch-links-and-project" "$(jq -nc --argjson links "$LINK_COUNT" --argjson types "$TYPE_COUNT" --arg proj "$PROJECT_KEY" '{link_count:$links, issue_type_count:$types, project:$proj}')"
+
+ pe_start "pre-explore" "build-context"
+ jq -n \
+ --arg source "jira" \
+ --arg host "$JIRA_HOST" \
+ --arg key "$ISSUE_KEY" \
+ --arg level "$LEVEL" \
+ --arg summary "$SUMMARY" \
+ --arg description "$DESCRIPTION" \
+ --arg status "$STATUS" \
+ --arg priority "$PRIORITY" \
+ --argjson labels "$LABELS" \
+ --arg reporter "$REPORTER" \
+ --arg created "$CREATED" \
+ --arg updated "$UPDATED" \
+ --argjson parent "$PARENT_JSON" \
+ --argjson children "$CHILDREN_JSON" \
+ --argjson comments "$COMMENTS_JSON" \
+ --argjson linked_issues "$LINKS_JSON" \
+ --arg project_key "$PROJECT_KEY" \
+ --arg project_name "$PROJECT_NAME" \
+ --argjson available_issue_types "$PROJECT_ISSUE_TYPES" \
+ --argjson team_usage "$TEAM_USAGE" \
+ '{
+ source: $source,
+ host: $host,
+ key: $key,
+ level: $level,
+ summary: $summary,
+ description: $description,
+ status: $status,
+ priority: $priority,
+ labels: $labels,
+ reporter: $reporter,
+ created: $created,
+ updated: $updated,
+ parent: $parent,
+ children: $children,
+ comments: $comments,
+ linked_issues: $linked_issues,
+ project: {key: $project_key, name: $project_name, available_issue_types: $available_issue_types, team_usage: $team_usage}
+ }' > "$WORKSPACE/issue-context.json"
+
+elif [[ "${ISSUE_SOURCE}" == "github" ]]; then
+ if [[ -z "${REPO_FULL_NAME:-}" ]]; then
+ echo "::error::REPO_FULL_NAME not set for GitHub source"
+ exit 1
+ fi
+
+ pe_start "pre-explore" "fetch-issue"
+ ISSUE_JSON=$(gh issue view "$ISSUE_KEY" --repo "$REPO_FULL_NAME" \
+ --json number,title,body,labels,comments,state,milestone,assignees,createdAt,updatedAt,author)
+
+ TITLE=$(echo "$ISSUE_JSON" | jq -r '.title')
+ BODY=$(echo "$ISSUE_JSON" | jq -r '.body // ""')
+ STATE=$(echo "$ISSUE_JSON" | jq -r '.state')
+ LABELS=$(echo "$ISSUE_JSON" | jq -c '[.labels[].name]')
+ AUTHOR=$(echo "$ISSUE_JSON" | jq -r '.author.login')
+ CREATED=$(echo "$ISSUE_JSON" | jq -r '.createdAt')
+ UPDATED=$(echo "$ISSUE_JSON" | jq -r '.updatedAt')
+
+ # Determine level from labels
+ LEVEL="issue"
+ while IFS= read -r label; do
+ case "${label,,}" in
+ feature) LEVEL="feature" ;;
+ epic) LEVEL="epic" ;;
+ story) LEVEL="story" ;;
+ task) LEVEL="task" ;;
+ esac
+ done < <(echo "$LABELS" | jq -r '.[]')
+
+ COMMENT_COUNT=$(echo "$ISSUE_JSON" | jq '.comments | length')
+ pe_end "pre-explore" "fetch-issue" "$(jq -nc --arg key "#${ISSUE_KEY}" --arg level "$LEVEL" --arg status "$STATE" --argjson comments "$COMMENT_COUNT" '{key:$key, level:$level, status:$status, comment_count:$comments}')"
+
+ pe_start "pre-explore" "fetch-comments"
+ COMMENTS=$(echo "$ISSUE_JSON" | jq '[.comments[] | {author: .author.login, created: .createdAt, body: .body}]')
+ pe_end "pre-explore" "fetch-comments" "$(jq -nc --argjson count "$COMMENT_COUNT" '{comment_count:$count}')"
+
+ pe_start "pre-explore" "fetch-children"
+ SUB_ISSUES=$(gh issue list --repo "$REPO_FULL_NAME" --state all \
+ --search "parent:#${ISSUE_KEY}" --json number,title,state,labels --limit 30 2>/dev/null \
+ | jq '[.[] | {key: ("#" + (.number | tostring)), summary: .title, status: .state, type: "issue"}]' \
+ || echo "[]")
+ CHILD_COUNT=$(echo "$SUB_ISSUES" | jq 'length')
+ pe_end "pre-explore" "fetch-children" "$(jq -nc --argjson count "$CHILD_COUNT" '{child_count:$count}')"
+
+ jq -n \
+ --arg source "github" \
+ --arg key "#${ISSUE_KEY}" \
+ --arg level "$LEVEL" \
+ --arg summary "$TITLE" \
+ --arg description "$BODY" \
+ --arg status "$STATE" \
+ --argjson labels "$LABELS" \
+ --arg reporter "$AUTHOR" \
+ --arg created "$CREATED" \
+ --arg updated "$UPDATED" \
+ --argjson children "$SUB_ISSUES" \
+ --argjson comments "$COMMENTS" \
+ --arg repo "$REPO_FULL_NAME" \
+ '{
+ source: $source,
+ key: $key,
+ level: $level,
+ summary: $summary,
+ description: $description,
+ status: $status,
+ labels: $labels,
+ reporter: $reporter,
+ created: $created,
+ updated: $updated,
+ parent: null,
+ children: $children,
+ comments: $comments,
+ linked_issues: [],
+ project: {key: $repo, name: $repo}
+ }' > "$WORKSPACE/issue-context.json"
+
+else
+ echo "::error::Unknown ISSUE_SOURCE: ${ISSUE_SOURCE}"
+ exit 1
+fi
+
+pe_end "pre-explore" "build-context" '{}'
+
+echo "Issue context written to $WORKSPACE/issue-context.json"
+echo "::notice::Issue: ${ISSUE_KEY} (${ISSUE_SOURCE}, level=$(jq -r .level "$WORKSPACE/issue-context.json"))"
+
+pe_end "pre-explore" "pre-explore" "$(jq -nc --arg source "$ISSUE_SOURCE" --arg key "$ISSUE_KEY" --arg level "$(jq -r .level "$WORKSPACE/issue-context.json")" '{source:$source, key:$key, level:$level}')"
+
+# Export paths for the agent
+echo "ISSUE_CONTEXT=$WORKSPACE/issue-context.json" >> "${GITHUB_ENV:-/dev/null}"
diff --git a/internal/scaffold/fullsend-repo/scripts/pre-refine.sh b/internal/scaffold/fullsend-repo/scripts/pre-refine.sh
new file mode 100755
index 000000000..f7217f5e3
--- /dev/null
+++ b/internal/scaffold/fullsend-repo/scripts/pre-refine.sh
@@ -0,0 +1,190 @@
+#!/usr/bin/env bash
+# pre-refine.sh — Prepare context for the refine agent.
+#
+# Fetches issue data (if not already available) and downloads/locates
+# the exploration context from one of three sources:
+# 1. Explore workflow artifact (EXPLORE_RUN_ID)
+# 2. User-provided file from a repo (EXPLORE_CONTEXT_REF = owner/repo:path)
+# 3. User-provided file from a Jira attachment (EXPLORE_CONTEXT_REF = attachment name)
+#
+# Required env vars:
+# ISSUE_KEY — Issue identifier
+# ISSUE_SOURCE — "jira" or "github"
+# GH_TOKEN — GitHub token
+#
+# Optional env vars:
+# EXPLORE_RUN_ID — GitHub Actions run ID of the explore stage
+# EXPLORE_CONTEXT_REF — User-provided exploration context reference
+# CRITIQUE_RUN_ID — GitHub Actions run ID of the critique stage (revision rounds)
+# REVIEW_ROUND — Current review round (default: 1)
+# GITHUB_ISSUE_NUMBER — GitHub issue for reply-back (GitHub flow)
+# JIRA_HOST, JIRA_EMAIL, JIRA_API_TOKEN — for Jira sources
+# REPO_FULL_NAME — for GitHub sources
+
+set -euo pipefail
+
+WORKSPACE="/tmp/workspace"
+mkdir -p "$WORKSPACE"
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+source "${SCRIPT_DIR}/pipeline-events.sh"
+
+pe_start "pre-refine" "pre-refine"
+
+echo "::notice::Pre-refine: preparing context (source=${ISSUE_SOURCE}, key=${ISSUE_KEY})"
+
+pe_start "pre-refine" "fetch-issue-context"
+if [[ ! -f "$WORKSPACE/issue-context.json" ]]; then
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+ if [[ -f "${SCRIPT_DIR}/pre-explore.sh" ]]; then
+ echo "Fetching issue context via pre-explore.sh..."
+ bash "${SCRIPT_DIR}/pre-explore.sh"
+ else
+ echo "ERROR: No issue context available and pre-explore.sh not found"
+ exit 1
+ fi
+fi
+
+pe_end "pre-refine" "fetch-issue-context" '{}'
+
+pe_start "pre-refine" "obtain-exploration-context"
+
+if [[ -f "$WORKSPACE/exploration_context.json" ]]; then
+ echo "Exploration context already present."
+
+elif [[ -n "${EXPLORE_CONTEXT_REF:-}" && "${EXPLORE_CONTEXT_REF}" != "N/A" ]]; then
+ # User-provided exploration context (skip-explore flow)
+ echo "Fetching user-provided exploration context: ${EXPLORE_CONTEXT_REF}"
+
+ if [[ "$EXPLORE_CONTEXT_REF" =~ ^[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+:.+ ]]; then
+ # Format: owner/repo:path/to/file.json — fetch from a GitHub repo
+ CONTEXT_REPO="${EXPLORE_CONTEXT_REF%%:*}"
+ CONTEXT_PATH="${EXPLORE_CONTEXT_REF#*:}"
+
+ echo " Fetching from GitHub repo: ${CONTEXT_REPO} path: ${CONTEXT_PATH}"
+ gh api "repos/${CONTEXT_REPO}/contents/${CONTEXT_PATH}" \
+ --jq '.content' | base64 -d > "$WORKSPACE/exploration_context.json" \
+ || { echo "::error::Failed to fetch exploration context from ${CONTEXT_REPO}:${CONTEXT_PATH}"; exit 1; }
+
+ elif [[ "$EXPLORE_CONTEXT_REF" =~ ^https?:// ]]; then
+ # Direct URL
+ echo " Fetching from URL: ${EXPLORE_CONTEXT_REF}"
+ curl -sSfL "$EXPLORE_CONTEXT_REF" > "$WORKSPACE/exploration_context.json" \
+ || { echo "::error::Failed to fetch exploration context from URL"; exit 1; }
+
+ elif [[ "${ISSUE_SOURCE}" == "jira" && -n "${JIRA_HOST:-}" ]]; then
+ # Treat as Jira attachment name
+ ATTACHMENT_NAME="$EXPLORE_CONTEXT_REF"
+ echo " Fetching Jira attachment: ${ATTACHMENT_NAME}"
+
+ AUTH=$(printf '%s:%s' "$JIRA_EMAIL" "$JIRA_API_TOKEN" | base64 -w0)
+ ATTACHMENT_URL=$(curl -sSf \
+ -H "Authorization: Basic $AUTH" \
+ -H "Accept: application/json" \
+ "https://${JIRA_HOST}/rest/api/3/issue/${ISSUE_KEY}?fields=attachment" \
+ | jq -r --arg name "$ATTACHMENT_NAME" \
+ '.fields.attachment[] | select(.filename == $name) | .content' \
+ | head -1)
+
+ if [[ -z "$ATTACHMENT_URL" ]]; then
+ echo "::error::Jira attachment '${ATTACHMENT_NAME}' not found on ${ISSUE_KEY}"
+ exit 1
+ fi
+
+ curl -sSfL -H "Authorization: Basic $AUTH" \
+ "$ATTACHMENT_URL" > "$WORKSPACE/exploration_context.json" \
+ || { echo "::error::Failed to download Jira attachment"; exit 1; }
+
+ else
+ echo "::error::Cannot resolve EXPLORE_CONTEXT_REF: ${EXPLORE_CONTEXT_REF}"
+ exit 1
+ fi
+
+ # Validate the fetched context
+ if ! jq empty "$WORKSPACE/exploration_context.json" 2>/dev/null; then
+ echo "::error::Fetched exploration context is not valid JSON"
+ exit 1
+ fi
+
+ echo "User-provided exploration context loaded."
+
+elif [[ -n "${EXPLORE_RUN_ID:-}" && "${EXPLORE_RUN_ID}" != "N/A" ]]; then
+ # Download from explore workflow artifact
+ REPO="${REPO_FULL_NAME:-$(gh api repos/:owner/:repo --jq .full_name 2>/dev/null || echo "")}"
+ if [[ -n "$REPO" ]]; then
+ echo "Downloading exploration artifact from run ${EXPLORE_RUN_ID}..."
+ ARTIFACT_DIR=$(mktemp -d)
+ if gh run download "$EXPLORE_RUN_ID" --repo "$REPO" --name "fullsend-explore" --dir "$ARTIFACT_DIR" 2>/dev/null; then
+ if [[ -f "$ARTIFACT_DIR/exploration_context.json" ]]; then
+ cp "$ARTIFACT_DIR/exploration_context.json" "$WORKSPACE/exploration_context.json"
+ echo "Exploration context downloaded from explore run."
+ fi
+ else
+ echo "::warning::Could not download exploration artifact — refine will proceed without it"
+ fi
+ rm -rf "$ARTIFACT_DIR"
+ fi
+fi
+
+pe_end "pre-refine" "obtain-exploration-context" "$(jq -nc --argjson has_context "$(if [[ -f "$WORKSPACE/exploration_context.json" ]]; then echo true; else echo false; fi)" '{has_exploration_context:$has_context}')"
+
+# Step 3: If no exploration context, create a minimal placeholder
+if [[ ! -f "$WORKSPACE/exploration_context.json" ]]; then
+ echo "::warning::No exploration context available — refine agent will rely on issue context and codebase only"
+ echo '{"gaps": [{"dimension": "exploration", "description": "Explore stage did not run", "impact": "Refine agent has limited context"}], "confidence": {"overall": 50}}' \
+ > "$WORKSPACE/exploration_context.json"
+fi
+
+# --- Step 4: Load critique feedback for revision rounds ---
+REVIEW_ROUND="${REVIEW_ROUND:-1}"
+
+if [[ "$REVIEW_ROUND" -gt 1 && -n "${CRITIQUE_RUN_ID:-}" && "${CRITIQUE_RUN_ID}" != "N/A" ]]; then
+ pe_start "pre-refine" "fetch-critique-feedback"
+ echo "Revision round ${REVIEW_ROUND}: downloading critique feedback from run ${CRITIQUE_RUN_ID}..."
+
+ REPO="${REPO_FULL_NAME:-$(gh api repos/:owner/:repo --jq .full_name 2>/dev/null || echo "")}"
+ if [[ -n "$REPO" ]]; then
+ ARTIFACT_DIR=$(mktemp -d)
+ if gh run download "$CRITIQUE_RUN_ID" --repo "$REPO" --name "fullsend-critique" --dir "$ARTIFACT_DIR" 2>/dev/null; then
+ # Find the critique result
+ CRITIQUE_RESULT_IN_ARTIFACT=""
+ for dir in "$ARTIFACT_DIR"/iteration-*/output; do
+ if [[ -f "${dir}/agent-result.json" ]]; then
+ CRITIQUE_RESULT_IN_ARTIFACT="${dir}/agent-result.json"
+ fi
+ done
+
+ if [[ -n "$CRITIQUE_RESULT_IN_ARTIFACT" ]]; then
+ cp "$CRITIQUE_RESULT_IN_ARTIFACT" "$WORKSPACE/critique-feedback.json"
+ echo "Critique feedback loaded."
+ fi
+
+ # Also grab critique history and exploration context if present
+ for f in critique-history.json exploration_context.json issue-context.json; do
+ if [[ -f "$ARTIFACT_DIR/$f" && ! -f "$WORKSPACE/$f" ]]; then
+ cp "$ARTIFACT_DIR/$f" "$WORKSPACE/$f"
+ fi
+ done
+ else
+ echo "::warning::Could not download critique artifact — refine will proceed without feedback"
+ fi
+ rm -rf "$ARTIFACT_DIR"
+ fi
+ pe_end "pre-refine" "fetch-critique-feedback" "$(jq -nc --argjson round "$REVIEW_ROUND" '{review_round:$round}')"
+elif [[ -f "$WORKSPACE/critique-feedback.json" ]]; then
+ echo "Critique feedback already present from artifact download."
+fi
+
+{
+ echo "ISSUE_CONTEXT=$WORKSPACE/issue-context.json"
+ echo "EXPLORE_CONTEXT=$WORKSPACE/exploration_context.json"
+ echo "REVIEW_ROUND=$REVIEW_ROUND"
+} >> "${GITHUB_ENV:-/dev/null}"
+
+if [[ -f "$WORKSPACE/critique-feedback.json" ]]; then
+ echo "CRITIQUE_FEEDBACK=$WORKSPACE/critique-feedback.json" >> "${GITHUB_ENV:-/dev/null}"
+fi
+
+pe_end "pre-refine" "pre-refine" "$(jq -nc --arg source "$ISSUE_SOURCE" --arg key "$ISSUE_KEY" --argjson round "$REVIEW_ROUND" '{source:$source, key:$key, review_round:$round}')"
+
+echo "Pre-refine complete."
diff --git a/internal/scaffold/fullsend-repo/skills/jira-read/SKILL.md b/internal/scaffold/fullsend-repo/skills/jira-read/SKILL.md
new file mode 100644
index 000000000..b102ebd04
--- /dev/null
+++ b/internal/scaffold/fullsend-repo/skills/jira-read/SKILL.md
@@ -0,0 +1,106 @@
+---
+name: jira-read
+description: >-
+ Read-only Jira integration skill. Provides patterns for reading issue data,
+ comments, hierarchy, and linked issues from Jira Cloud instances.
+ Write operations are handled by post-scripts, not the agent.
+---
+
+# Jira Read Skill
+
+This skill provides patterns for reading data from Jira Cloud instances.
+All Jira API calls require authentication — the pre-script fetches data
+and writes it to files the agent can read. This skill documents what
+data is available and how to interpret it.
+
+## Data available to the agent
+
+The pre-script fetches issue data and writes it to `$ISSUE_CONTEXT`:
+
+```json
+{
+ "source": "jira",
+ "host": "stage-redhat.atlassian.net",
+ "key": "SECURESIGN-1620",
+ "level": "feature",
+ "summary": "Issue summary text",
+ "description": "Full issue description (Atlassian Document Format converted to text)",
+ "status": "In Progress",
+ "priority": "High",
+ "labels": ["label1", "label2"],
+ "reporter": "user@example.com",
+ "assignee": "user@example.com",
+ "created": "2026-01-15T10:00:00.000+0000",
+ "updated": "2026-05-18T14:30:00.000+0000",
+ "parent": {
+ "key": "HATSTRAT-259",
+ "summary": "Parent issue summary",
+ "description": "Parent description (provides strategic context)"
+ },
+ "children": [
+ {
+ "key": "SECURESIGN-1621",
+ "summary": "Child issue summary",
+ "status": "To Do",
+ "type": "Epic"
+ }
+ ],
+ "comments": [
+ {
+ "author": "user@example.com",
+ "created": "2026-05-18T15:00:00.000+0000",
+ "body": "Comment text"
+ }
+ ],
+ "linked_issues": [
+ {
+ "type": "blocks",
+ "key": "OTHER-123",
+ "summary": "Linked issue summary",
+ "status": "Open"
+ }
+ ],
+ "project": {
+ "key": "SECURESIGN",
+ "name": "Secure Sign",
+ "hierarchy": ["Outcome", "Feature", "Epic", "Story", "Task"]
+ }
+}
+```
+
+## Interpreting issue levels
+
+The `level` field is derived from the Jira issue type and project hierarchy:
+
+| Jira type | Level | Decomposes into |
+|-----------|-------|----------------|
+| Outcome | outcome | features |
+| Feature | feature | epics |
+| Epic | epic | stories |
+| Story | story | tasks |
+| Task | task | sub-tasks |
+| Bug/Spike | issue | sub-issues |
+
+## Parent context
+
+The `parent` field contains the parent issue's description. This often
+provides strategic context that the issue itself lacks. Always read the
+parent description when available — it may contain goals, constraints,
+or requirements not repeated in the child.
+
+## Comments as conversation history
+
+The `comments` array contains the full comment history. When the refine
+agent previously posted a clarification question and the user answered:
+
+1. The question will be in an older comment (posted by the bot)
+2. The answer will be in a newer comment (posted by a human)
+
+Check for this pattern to continue iteration without re-asking.
+
+## What the agent CANNOT do
+
+- The agent cannot call the Jira API directly (sandbox network policy blocks it)
+- The agent cannot create, update, or comment on Jira issues
+- All write operations happen in the post-script using credentials that
+ never enter the sandbox
diff --git a/internal/scaffold/fullsend-repo/skills/public-research/SKILL.md b/internal/scaffold/fullsend-repo/skills/public-research/SKILL.md
new file mode 100644
index 000000000..31a1b676d
--- /dev/null
+++ b/internal/scaffold/fullsend-repo/skills/public-research/SKILL.md
@@ -0,0 +1,97 @@
+---
+name: public-research
+description: >-
+ Public data research skill. Provides patterns for gathering context from
+ GitHub, web search, and public documentation. Replaces the internal
+ org-research skill with public-only data sources.
+---
+
+# Public Research Skill
+
+This skill provides techniques for gathering technical context from public
+data sources. It replaces the internal `analyze` tool with open, accessible
+alternatives.
+
+## Available data sources
+
+### 1. GitHub API
+
+Search issues, PRs, discussions, and code across repositories:
+
+```bash
+# Search issues by keyword
+gh issue list --repo OWNER/REPO --state all --search "keywords" \
+ --json number,title,state,labels,body --limit 30
+
+# Search PRs
+gh pr list --repo OWNER/REPO --state all --search "keywords" \
+ --json number,title,state,body --limit 20
+
+# Search code across GitHub
+gh search code "pattern" --repo OWNER/REPO --json path,repository
+
+# Read file contents
+gh api "repos/OWNER/REPO/contents/path/to/file" --jq '.content' | base64 -d
+
+# List repository topics/languages
+gh api "repos/OWNER/REPO" --jq '{topics: .topics, language: .language}'
+gh api "repos/OWNER/REPO/languages"
+```
+
+### 2. Repository analysis
+
+When the target repo is checked out locally:
+
+```bash
+# Project structure
+find . -maxdepth 3 -type f -name "*.go" -o -name "*.py" -o -name "*.ts" | head -50
+tree -L 2 --dirsfirst
+
+# Dependency manifests
+cat go.mod 2>/dev/null || cat package.json 2>/dev/null || cat requirements.txt 2>/dev/null
+
+# Deployment configs
+find . -name "Dockerfile*" -o -name "*.yaml" -path "*/deploy/*" -o -name "Makefile" | head -20
+
+# Test infrastructure
+find . -name "*_test.go" -o -name "*.test.ts" -o -name "test_*.py" | head -20
+```
+
+### 3. Web search
+
+For competitive analysis and industry standards. Use targeted searches:
+
+```bash
+# Technical documentation
+curl -s "https://api.tavily.com/search" \
+ -H "Content-Type: application/json" \
+ -d '{"query": "specific technical question", "max_results": 5}'
+```
+
+If Tavily is unavailable, use GitHub as a proxy for public knowledge:
+
+```bash
+gh search repos "topic keywords" --json fullName,description,stargazersCount --limit 10
+```
+
+### 4. Public documentation
+
+Read README files and docs from related repositories:
+
+```bash
+gh api "repos/OWNER/REPO/readme" --jq '.content' | base64 -d
+```
+
+## Research strategy
+
+1. **Start with the target repo** — understand what exists before searching externally
+2. **Search for related work** — prior issues, PRs, and discussions
+3. **Search related repos** — projects in the same org or ecosystem
+4. **Web search last** — only for gaps not covered by repo/GitHub analysis
+
+## What NOT to do
+
+- Do not access internal/proprietary tools or databases
+- Do not fabricate sources — if you can't find information, note the gap
+- Do not do unfocused research — every search should answer a specific question
+- Do not spend excessive tokens on broad web crawling
diff --git a/internal/scaffold/render.go b/internal/scaffold/render.go
index d22644dc1..402c9a3d7 100644
--- a/internal/scaffold/render.go
+++ b/internal/scaffold/render.go
@@ -31,6 +31,10 @@ var thinStageWorkflows = []struct {
{"fix", ".github/workflows/fix.yml"},
{"retro", ".github/workflows/retro.yml"},
{"prioritize", ".github/workflows/prioritize.yml"},
+ {"explore", ".github/workflows/explore.yml"},
+ {"refine", ".github/workflows/refine.yml"},
+ {"critique", ".github/workflows/critique.yml"},
+ {"create-children", ".github/workflows/create-children.yml"},
}
// RenderTemplate applies vendoring-aware substitutions to scaffold templates.
diff --git a/internal/scaffold/scaffold.go b/internal/scaffold/scaffold.go
index dbd44f643..21b4e5084 100644
--- a/internal/scaffold/scaffold.go
+++ b/internal/scaffold/scaffold.go
@@ -42,6 +42,20 @@ var executableFiles = map[string]struct{}{
"scripts/fullsend-check-output": {},
"scripts/validate-output-schema-test.sh": {},
"scripts/validate-source-repo.sh": {},
+ "scripts/pre-explore.sh": {},
+ "scripts/post-explore.sh": {},
+ "scripts/pre-refine.sh": {},
+ "scripts/post-refine.sh": {},
+ "scripts/pre-critique.sh": {},
+ "scripts/post-critique.sh": {},
+ "scripts/create-children.sh": {},
+ "scripts/pipeline-events.sh": {},
+ "scripts/markdown-to-adf.py": {},
+ "scripts/pipeline-helpers.sh": {},
+ "scripts/post-explore-test.sh": {},
+ "scripts/post-refine-test.sh": {},
+ "scripts/post-critique-test.sh": {},
+ "scripts/create-children-test.sh": {},
}
// FileMode returns the Git tree mode for a scaffold file.
diff --git a/internal/scaffold/scaffold_test.go b/internal/scaffold/scaffold_test.go
index 0ca8f6c0d..38eaa54fa 100644
--- a/internal/scaffold/scaffold_test.go
+++ b/internal/scaffold/scaffold_test.go
@@ -94,6 +94,38 @@ func TestFullsendRepoFilesExist(t *testing.T) {
"scripts/post-prioritize-test.sh",
".github/workflows/prioritize.yml",
".github/workflows/prioritize-scheduler.yml",
+ // Refinement pipeline
+ "agents/explore.md",
+ "agents/refine.md",
+ "agents/critique.md",
+ "harness/explore.yaml",
+ "harness/refine.yaml",
+ "harness/critique.yaml",
+ "policies/explore.yaml",
+ "policies/refine.yaml",
+ "policies/critique.yaml",
+ "schemas/explore-result.schema.json",
+ "schemas/refine-result.schema.json",
+ "schemas/critique-result.schema.json",
+ "scripts/pre-explore.sh",
+ "scripts/post-explore.sh",
+ "scripts/pre-refine.sh",
+ "scripts/post-refine.sh",
+ "scripts/pre-critique.sh",
+ "scripts/post-critique.sh",
+ "scripts/create-children.sh",
+ "scripts/pipeline-events.sh",
+ "scripts/pipeline-helpers.sh",
+ "scripts/markdown-to-adf.py",
+ "skills/jira-read/SKILL.md",
+ "skills/public-research/SKILL.md",
+ ".github/workflows/explore.yml",
+ ".github/workflows/refine.yml",
+ ".github/workflows/critique.yml",
+ ".github/workflows/create-children.yml",
+ ".github/workflows/refine-dispatch.yml",
+ ".github/workflows/jira-dispatch.yml",
+ ".github/workflows/jira-comment-poller.yml",
}
for _, path := range expected {
@@ -673,6 +705,21 @@ func TestHarnessForgeRunnerEnvMerge(t *testing.T) {
topLevelKeys: []string{"FULLSEND_OUTPUT_SCHEMA"},
forgeGithubKeys: []string{"GITHUB_ISSUE_URL", "GH_TOKEN", "ORG", "PROJECT_NUMBER"},
},
+ {
+ file: "explore.yaml",
+ topLevelKeys: []string{"FULLSEND_OUTPUT_SCHEMA", "ISSUE_KEY"},
+ forgeGithubKeys: []string{"GH_TOKEN"},
+ },
+ {
+ file: "refine.yaml",
+ topLevelKeys: []string{"FULLSEND_OUTPUT_SCHEMA", "ISSUE_KEY", "EXPLORE_RUN_ID"},
+ forgeGithubKeys: []string{"GH_TOKEN"},
+ },
+ {
+ file: "critique.yaml",
+ topLevelKeys: []string{"FULLSEND_OUTPUT_SCHEMA", "ISSUE_KEY", "REFINE_RUN_ID"},
+ forgeGithubKeys: []string{"GH_TOKEN"},
+ },
}
for _, tt := range tests {
@@ -882,6 +929,284 @@ func TestPrioritizeHarnessContent(t *testing.T) {
assert.Contains(t, s, "PROJECT_NUMBER")
}
+// --- Refinement pipeline content tests ---
+
+func TestExploreWorkflowContent(t *testing.T) {
+ content, err := FullsendRepoFile(".github/workflows/explore.yml")
+ require.NoError(t, err)
+ s := string(content)
+ assert.Contains(t, s, "# fullsend-stage: explore")
+ assert.Contains(t, s, "workflow_dispatch")
+ assert.Contains(t, s, "issue_key")
+ assert.Contains(t, s, "issue_source")
+ assert.Contains(t, s, "__REUSABLE_WORKFLOW__")
+ assert.Contains(t, s, "FULLSEND_MINT_URL")
+ assert.NotContains(t, s, "secrets: inherit")
+ assert.Contains(t, s, "FULLSEND_GCP_WIF_PROVIDER: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }}")
+ assert.Contains(t, s, "FULLSEND_GCP_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }}")
+ assert.Contains(t, s, "concurrency:")
+ assert.Contains(t, s, "fullsend-explore-")
+ assert.Contains(t, s, "cancel-in-progress: true")
+ assert.Contains(t, s, "permissions:")
+ assert.Contains(t, s, "actions: write")
+ assert.Contains(t, s, "id-token: write")
+ assert.Contains(t, s, "issues: write")
+ assert.Contains(t, s, "contents: read")
+ assert.Contains(t, s, "JIRA_HOST")
+ assert.Contains(t, s, "JIRA_EMAIL")
+ assert.Contains(t, s, "JIRA_API_TOKEN")
+ assert.Contains(t, s, "jira_project_visibility")
+}
+
+func TestRefineWorkflowContent(t *testing.T) {
+ content, err := FullsendRepoFile(".github/workflows/refine.yml")
+ require.NoError(t, err)
+ s := string(content)
+ assert.Contains(t, s, "# fullsend-stage: refine")
+ assert.Contains(t, s, "workflow_dispatch")
+ assert.Contains(t, s, "issue_key")
+ assert.Contains(t, s, "explore_run_id")
+ assert.Contains(t, s, "review_round")
+ assert.Contains(t, s, "max_review_rounds")
+ assert.Contains(t, s, "auto_create")
+ assert.Contains(t, s, "critique_run_id")
+ assert.Contains(t, s, "__REUSABLE_WORKFLOW__")
+ assert.Contains(t, s, "FULLSEND_MINT_URL")
+ assert.NotContains(t, s, "secrets: inherit")
+ assert.Contains(t, s, "FULLSEND_GCP_WIF_PROVIDER: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }}")
+ assert.Contains(t, s, "FULLSEND_GCP_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }}")
+ assert.Contains(t, s, "concurrency:")
+ assert.Contains(t, s, "fullsend-refine-")
+ assert.Contains(t, s, "cancel-in-progress: false")
+ assert.Contains(t, s, "permissions:")
+ assert.Contains(t, s, "actions: write")
+ assert.Contains(t, s, "id-token: write")
+ assert.Contains(t, s, "issues: write")
+ assert.Contains(t, s, "contents: read")
+ assert.Contains(t, s, "JIRA_HOST")
+ assert.Contains(t, s, "JIRA_EMAIL")
+ assert.Contains(t, s, "JIRA_API_TOKEN")
+ assert.Contains(t, s, "jira_project_visibility")
+}
+
+func TestCritiqueWorkflowContent(t *testing.T) {
+ content, err := FullsendRepoFile(".github/workflows/critique.yml")
+ require.NoError(t, err)
+ s := string(content)
+ assert.Contains(t, s, "# fullsend-stage: critique")
+ assert.Contains(t, s, "workflow_dispatch")
+ assert.Contains(t, s, "issue_key")
+ assert.Contains(t, s, "refine_run_id")
+ assert.Contains(t, s, "review_round")
+ assert.Contains(t, s, "max_review_rounds")
+ assert.Contains(t, s, "auto_create")
+ assert.Contains(t, s, "__REUSABLE_WORKFLOW__")
+ assert.Contains(t, s, "FULLSEND_MINT_URL")
+ assert.NotContains(t, s, "secrets: inherit")
+ assert.Contains(t, s, "FULLSEND_GCP_WIF_PROVIDER: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }}")
+ assert.Contains(t, s, "FULLSEND_GCP_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }}")
+ assert.Contains(t, s, "concurrency:")
+ assert.Contains(t, s, "fullsend-critique-")
+ assert.Contains(t, s, "cancel-in-progress: false")
+ assert.Contains(t, s, "permissions:")
+ assert.Contains(t, s, "actions: write")
+ assert.Contains(t, s, "id-token: write")
+ assert.Contains(t, s, "issues: write")
+ assert.Contains(t, s, "contents: read")
+ assert.Contains(t, s, "JIRA_HOST")
+ assert.Contains(t, s, "JIRA_EMAIL")
+ assert.Contains(t, s, "JIRA_API_TOKEN")
+ assert.Contains(t, s, "jira_project_visibility")
+}
+
+func TestCreateChildrenWorkflowContent(t *testing.T) {
+ content, err := FullsendRepoFile(".github/workflows/create-children.yml")
+ require.NoError(t, err)
+ s := string(content)
+ assert.Contains(t, s, "# fullsend-stage: create-children")
+ assert.Contains(t, s, "workflow_dispatch")
+ assert.Contains(t, s, "issue_key")
+ assert.Contains(t, s, "refine_run_id")
+ assert.Contains(t, s, "__REUSABLE_WORKFLOW__")
+ assert.Contains(t, s, "FULLSEND_MINT_URL")
+ assert.NotContains(t, s, "secrets: inherit")
+ assert.Contains(t, s, "concurrency:")
+ assert.Contains(t, s, "fullsend-create-children-")
+ assert.Contains(t, s, "cancel-in-progress: false")
+}
+
+func TestRefineDispatchWorkflowContent(t *testing.T) {
+ content, err := FullsendRepoFile(".github/workflows/refine-dispatch.yml")
+ require.NoError(t, err)
+ s := string(content)
+ assert.Contains(t, s, "/fs-refine")
+ assert.Contains(t, s, "/fs-create")
+ assert.Contains(t, s, "author_association == 'OWNER'")
+ assert.Contains(t, s, "author_association == 'MEMBER'")
+ assert.Contains(t, s, "author_association == 'COLLABORATOR'")
+ assert.Contains(t, s, "refine-approved")
+ assert.Contains(t, s, "explore.yml")
+ assert.Contains(t, s, "refine.yml")
+}
+
+func TestJiraDispatchWorkflowContent(t *testing.T) {
+ content, err := FullsendRepoFile(".github/workflows/jira-dispatch.yml")
+ require.NoError(t, err)
+ s := string(content)
+ assert.Contains(t, s, "/fs-refine")
+ assert.Contains(t, s, "jira_key")
+ assert.Contains(t, s, "JIRA_PROJECT_VISIBILITY")
+ assert.Contains(t, s, "explore.yml")
+}
+
+func TestJiraCommentPollerWorkflowContent(t *testing.T) {
+ content, err := FullsendRepoFile(".github/workflows/jira-comment-poller.yml")
+ require.NoError(t, err)
+ s := string(content)
+ assert.Contains(t, s, "schedule")
+ assert.Contains(t, s, "cron")
+ assert.Contains(t, s, "labels = fullsend")
+ assert.Contains(t, s, "jira-dispatch.yml")
+}
+
+func TestExploreAgentPromptContent(t *testing.T) {
+ content, err := FullsendRepoFile("agents/explore.md")
+ require.NoError(t, err)
+ s := string(content)
+ assert.Contains(t, s, "agent-result.json")
+ assert.Contains(t, s, "confidence")
+ assert.Contains(t, s, "technical_landscape")
+ assert.Contains(t, s, "related_work")
+ assert.Contains(t, s, "disallowedTools")
+}
+
+func TestRefineAgentPromptContent(t *testing.T) {
+ content, err := FullsendRepoFile("agents/refine.md")
+ require.NoError(t, err)
+ s := string(content)
+ assert.Contains(t, s, "agent-result.json")
+ assert.Contains(t, s, "children")
+ assert.Contains(t, s, "acceptance_criteria")
+ assert.Contains(t, s, "parent_title")
+ assert.Contains(t, s, "disallowedTools")
+}
+
+func TestCritiqueAgentPromptContent(t *testing.T) {
+ content, err := FullsendRepoFile("agents/critique.md")
+ require.NoError(t, err)
+ s := string(content)
+ assert.Contains(t, s, "agent-result.json")
+ assert.Contains(t, s, "verdict")
+ assert.Contains(t, s, "approved")
+ assert.Contains(t, s, "revise")
+ assert.Contains(t, s, "needs_input")
+ assert.Contains(t, s, "disallowedTools")
+}
+
+func TestExploreSchemaContent(t *testing.T) {
+ content, err := FullsendRepoFile("schemas/explore-result.schema.json")
+ require.NoError(t, err)
+ s := string(content)
+ assert.Contains(t, s, "$schema")
+ assert.Contains(t, s, "technical_landscape")
+ assert.Contains(t, s, "related_work")
+ assert.Contains(t, s, "confidence")
+ assert.Contains(t, s, "summary")
+}
+
+func TestRefineSchemaContent(t *testing.T) {
+ content, err := FullsendRepoFile("schemas/refine-result.schema.json")
+ require.NoError(t, err)
+ s := string(content)
+ assert.Contains(t, s, "$schema")
+ assert.Contains(t, s, "children")
+ assert.Contains(t, s, "acceptance_criteria")
+ assert.Contains(t, s, "parent_title")
+ assert.Contains(t, s, "complete")
+}
+
+func TestCritiqueSchemaContent(t *testing.T) {
+ content, err := FullsendRepoFile("schemas/critique-result.schema.json")
+ require.NoError(t, err)
+ s := string(content)
+ assert.Contains(t, s, "$schema")
+ assert.Contains(t, s, "verdict")
+ assert.Contains(t, s, "approved")
+ assert.Contains(t, s, "revise")
+ assert.Contains(t, s, "needs_input")
+ assert.Contains(t, s, "assessment")
+}
+
+func TestExploreHarnessContent(t *testing.T) {
+ content, err := FullsendRepoFile("harness/explore.yaml")
+ require.NoError(t, err)
+ s := string(content)
+ assert.Contains(t, s, "agents/explore.md")
+ assert.Contains(t, s, "pre_script")
+ assert.Contains(t, s, "post_script")
+ assert.Contains(t, s, "runner_env")
+ assert.Contains(t, s, "ISSUE_KEY")
+}
+
+func TestRefineHarnessContent(t *testing.T) {
+ content, err := FullsendRepoFile("harness/refine.yaml")
+ require.NoError(t, err)
+ s := string(content)
+ assert.Contains(t, s, "agents/refine.md")
+ assert.Contains(t, s, "pre_script")
+ assert.Contains(t, s, "post_script")
+ assert.Contains(t, s, "runner_env")
+ assert.Contains(t, s, "EXPLORE_RUN_ID")
+}
+
+func TestCritiqueHarnessContent(t *testing.T) {
+ content, err := FullsendRepoFile("harness/critique.yaml")
+ require.NoError(t, err)
+ s := string(content)
+ assert.Contains(t, s, "agents/critique.md")
+ assert.Contains(t, s, "pre_script")
+ assert.Contains(t, s, "post_script")
+ assert.Contains(t, s, "runner_env")
+ assert.Contains(t, s, "REFINE_RUN_ID")
+}
+
+func TestPipelineHelpersContent(t *testing.T) {
+ content, err := FullsendRepoFile("scripts/pipeline-helpers.sh")
+ require.NoError(t, err)
+ s := string(content)
+ assert.Contains(t, s, "find_agent_result")
+ assert.Contains(t, s, "determine_reply_target")
+ assert.Contains(t, s, "post_comment")
+ assert.Contains(t, s, "build_run_link")
+ assert.Contains(t, s, "add_label")
+ assert.Contains(t, s, "remove_label")
+ assert.Contains(t, s, "jira_comment")
+}
+
+func TestPipelineEventsContent(t *testing.T) {
+ content, err := FullsendRepoFile("scripts/pipeline-events.sh")
+ require.NoError(t, err)
+ s := string(content)
+ assert.Contains(t, s, "pe_start")
+ assert.Contains(t, s, "pe_end")
+ assert.Contains(t, s, "pe_error")
+ assert.Contains(t, s, "pe_copy_to_output")
+ assert.Contains(t, s, "pipeline-events.jsonl")
+}
+
+func TestCreateChildrenScriptContent(t *testing.T) {
+ content, err := FullsendRepoFile("scripts/create-children.sh")
+ require.NoError(t, err)
+ s := string(content)
+ assert.Contains(t, s, "RESULT_FILE")
+ assert.Contains(t, s, "github_create_issue")
+ assert.Contains(t, s, "jira_create_issue")
+ assert.Contains(t, s, "resolve_jira_type")
+ assert.Contains(t, s, "TITLE_TO_KEY")
+ assert.Contains(t, s, "MAX_PASSES=5")
+ assert.Contains(t, s, "CREATED_CHILD_COUNT")
+}
+
func TestAllScaffoldYAMLDocumentStartMarker(t *testing.T) {
// yamllint document-start rule requires --- at the top of every YAML file.
// Walk embedded scaffold YAML/YML files and verify each starts with "---\n".
diff --git a/internal/scaffold/vendormanifest.go b/internal/scaffold/vendormanifest.go
index ccc5f6c8c..93d6532c1 100644
--- a/internal/scaffold/vendormanifest.go
+++ b/internal/scaffold/vendormanifest.go
@@ -136,9 +136,13 @@ func (m *VendorManifest) CleanupPaths(workflowPrefix string) []string {
var vendoredReusableWorkflows = []string{
"reusable-code.yml",
+ "reusable-create-children.yml",
+ "reusable-critique.yml",
"reusable-dispatch.yml",
+ "reusable-explore.yml",
"reusable-fix.yml",
"reusable-prioritize.yml",
+ "reusable-refine.yml",
"reusable-retro.yml",
"reusable-review.yml",
"reusable-triage.yml",
diff --git a/internal/scaffold/workflow_call_alignment_test.go b/internal/scaffold/workflow_call_alignment_test.go
index 0379396e7..29d932504 100644
--- a/internal/scaffold/workflow_call_alignment_test.go
+++ b/internal/scaffold/workflow_call_alignment_test.go
@@ -47,7 +47,7 @@ type callerJob struct {
// reusableWorkflowRef extracts the reusable workflow filename from a uses: reference.
// Handles both "fullsend-ai/fullsend/.github/workflows/reusable-foo.yml@v0"
// and "./.github/workflows/reusable-foo.yml".
-var reusableWorkflowRef = regexp.MustCompile(`reusable-[a-z]+\.yml`)
+var reusableWorkflowRef = regexp.MustCompile(`reusable-[a-z][-a-z]*\.yml`)
// callerPair defines a caller → reusable workflow relationship to validate.
type callerPair struct {
@@ -97,6 +97,10 @@ func TestWorkflowCallInputAlignment(t *testing.T) {
{"scaffold/fix.yml", loadRenderedScaffoldCaller(".github/workflows/fix.yml"), "fix"},
{"scaffold/retro.yml", loadRenderedScaffoldCaller(".github/workflows/retro.yml"), "retro"},
{"scaffold/prioritize.yml", loadRenderedScaffoldCaller(".github/workflows/prioritize.yml"), "prioritize"},
+ {"scaffold/explore.yml", loadRenderedScaffoldCaller(".github/workflows/explore.yml"), "explore"},
+ {"scaffold/refine.yml", loadRenderedScaffoldCaller(".github/workflows/refine.yml"), "refine"},
+ {"scaffold/critique.yml", loadRenderedScaffoldCaller(".github/workflows/critique.yml"), "critique"},
+ {"scaffold/create-children.yml", loadRenderedScaffoldCaller(".github/workflows/create-children.yml"), "create-children"},
}
// Also validate reusable-dispatch.yml's stage jobs.
@@ -184,7 +188,7 @@ func TestReusableWorkflowsShareCommonInputs(t *testing.T) {
"FULLSEND_GCP_PROJECT_ID",
}
- stages := []string{"triage", "code", "review", "fix", "retro", "prioritize"}
+ stages := []string{"triage", "code", "review", "fix", "retro", "prioritize", "explore", "refine", "critique", "create-children"}
for _, stage := range stages {
t.Run(stage, func(t *testing.T) {