name: Issue Triage v2 run-name: | Triage v2: #${{ github.event.issue.number || inputs.issue_number }} # Production — Stages 1, 2, 3, 4, 5, 6, 7, 8a, 8b, 8c, 9. # Bug / duplicate / enhancement classifications run through # investigate → mechanical-validate → adversarial-review → decision # gate. Decision gate selects between 8a (bug findings), 8c # (enhancement-design), and 8b (deferral with drift-bridge block or # triage: duplicate routing). The investigate and review prompts # have enhancement-variant siblings that shift the rubric from # "defect claim" to "existing surface the enhancement would touch". # Reviewer verdicts: approve keeps a finding; downgrade-confidence # keeps it at reduced weight in the avg-confidence gate; reject # drops it. Confirmed-duplicate routing fires only when the reviewer # rated the duplicate_of target exact or related. # # Triggers: fires automatically on `issues: [opened]`; also accepts # `workflow_dispatch` for re-runs, dry-run testing, and manual # triage on backfilled issues. v1 (issue-triage.yml) is kept as a # workflow_dispatch-only fallback — its `issues` trigger was # disabled when v2 took over production routing. # See docs/issue-triage/README.md. on: issues: types: [opened] workflow_dispatch: inputs: issue_number: description: "Issue number to triage" required: true type: number dry_run: description: "Run the pipeline but skip posting the comment and applying labels (artifacts still uploaded)" required: false type: boolean default: false permissions: issues: write contents: read concurrency: group: issue-triage-v2-${{ github.event.issue.number || inputs.issue_number }} cancel-in-progress: true jobs: # ────────────────────────────────────────────────────────────────────── # Stage 1 — Gate. # ────────────────────────────────────────────────────────────────────── gate: name: Gate runs-on: ubuntu-latest outputs: should_triage: ${{ steps.check.outputs.should_triage }} issue_number: ${{ steps.check.outputs.issue_number }} steps: - name: Evaluate gate id: check env: GH_TOKEN: ${{ github.token }} ISSUE_NUMBER: ${{ github.event.issue.number || inputs.issue_number }} run: | echo "issue_number=${ISSUE_NUMBER}" >> "$GITHUB_OUTPUT" author=$(gh issue view "${ISSUE_NUMBER}" \ --repo "${GITHUB_REPOSITORY}" \ --json author --jq '.author.login') if [[ "${author}" == "github-actions[bot]" ]]; then echo "should_triage=false" >> "$GITHUB_OUTPUT" echo "::notice::Skipping bot-authored issue" exit 0 fi echo "should_triage=true" >> "$GITHUB_OUTPUT" # ────────────────────────────────────────────────────────────────────── # Main pipeline. Stages 1-snapshot / 2 / 3 / 4 / 5 / 7 / 8a|8b / 9. # ────────────────────────────────────────────────────────────────────── triage: name: Triage runs-on: ubuntu-latest needs: gate if: needs.gate.outputs.should_triage == 'true' env: ISSUE_NUMBER: ${{ needs.gate.outputs.issue_number }} steps: - name: Checkout uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 0 - name: Set up Node.js uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: "20" - name: Install Claude CLI run: npm install -g @anthropic-ai/claude-code # Stage 1 — input snapshot. - name: Capture input snapshot env: GH_TOKEN: ${{ github.token }} run: | mkdir -p /tmp/triage gh issue view "${ISSUE_NUMBER}" \ --repo "${GITHUB_REPOSITORY}" \ --json number,title,body,author,updatedAt,createdAt \ > /tmp/triage/issue.json body=$(jq -r '.body // ""' /tmp/triage/issue.json) updated_at=$(jq -r '.updatedAt' /tmp/triage/issue.json) body_sha=$(printf '%s' "${body}" | sha256sum | awk '{print $1}') jq -n \ --argjson n "${ISSUE_NUMBER}" \ --arg body "${body}" \ --arg updated_at "${updated_at}" \ --arg sha "${body_sha}" \ '{ issue_number: $n, issue_body: $body, updated_at: $updated_at, body_sha256: $sha }' \ > /tmp/triage/input_snapshot.json - name: Cache repo label set env: GH_TOKEN: ${{ github.token }} run: | gh label list \ --repo "${GITHUB_REPOSITORY}" \ --limit 200 \ --json name --jq '[.[].name]' \ > /tmp/triage/repo-labels.json # Stage 2a — suspicious-input scan. Runs before the classifier # Sonnet call so a matched tell short-circuits to 8b without # sending the issue body through an LLM. The scan is a # conservative tripwire; the actual injection mitigations (wrap- # as-data, fresh-context reviewer, schema-constrained output) # remain in place downstream. Any match routes to 8b with reason # `suspicious-input — manual review` via the Decide route step. - name: Suspicious-input scan id: suspicious run: | bash .claude/scripts/triage/suspicious-input-scan.sh \ /tmp/triage/issue.json \ .claude/scripts/taxonomies/suspicious-input-tells.json \ /tmp/triage/suspicious-input.json hit=$(jq -r '.suspicious' /tmp/triage/suspicious-input.json) echo "suspicious=${hit}" >> "$GITHUB_OUTPUT" if [[ "${hit}" == "true" ]]; then matched=$(jq -r '.matched_tells | join(",")' \ /tmp/triage/suspicious-input.json) echo "::warning::suspicious-input tells matched: ${matched}" fi # Stage 2 — classify. - name: Classify issue id: classify if: steps.suspicious.outputs.suspicious != 'true' env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} run: | schema=$(cat .claude/scripts/schemas/classify.json) title=$(jq -r '.title' /tmp/triage/issue.json) body=$(jq -r '.body // ""' /tmp/triage/issue.json) { cat .claude/scripts/prompts/classify.txt echo "" echo "${title}" echo "" echo "" printf '%s\n' "${body}" echo "" } > /tmp/triage/classify-prompt.txt result=$(claude -p "$(cat /tmp/triage/classify-prompt.txt)" \ --output-format json \ --json-schema "${schema}" \ --model claude-sonnet-4-6 \ --max-budget-usd 1.00 \ 2>/dev/null) || { echo "::error::classify call failed" exit 1 } structured=$(printf '%s' "${result}" \ | jq -c '.structured_output // empty') if [[ -z "${structured}" ]]; then echo "::error::no structured_output from classify" exit 1 fi printf '%s' "${structured}" > /tmp/triage/classification.json classification=$(jq -r '.classification' \ /tmp/triage/classification.json) confidence=$(jq -r '.confidence' /tmp/triage/classification.json) { echo "classification=${classification}" echo "confidence=${confidence}" } >> "$GITHUB_OUTPUT" # Stage 2 — bug/enhancement doublecheck. - name: Classify double-check (bug/enhancement) id: doublecheck if: steps.classify.outputs.classification == 'bug' || steps.classify.outputs.classification == 'enhancement' env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} FIRST_PASS: ${{ steps.classify.outputs.classification }} run: | schema=$(cat .claude/scripts/schemas/classify-doublecheck-bug-vs-enhancement.json) title=$(jq -r '.title' /tmp/triage/issue.json) body=$(jq -r '.body // ""' /tmp/triage/issue.json) { cat .claude/scripts/prompts/classify-doublecheck-bug-vs-enhancement.txt echo "" echo "${title}" echo "" echo "" printf '%s\n' "${body}" echo "" } > /tmp/triage/doublecheck-prompt.txt result=$(claude -p "$(cat /tmp/triage/doublecheck-prompt.txt)" \ --output-format json \ --json-schema "${schema}" \ --model claude-sonnet-4-6 \ --max-budget-usd 1.00 \ 2>/dev/null) || { echo "::error::doublecheck call failed" exit 1 } structured=$(printf '%s' "${result}" \ | jq -c '.structured_output // empty') if [[ -z "${structured}" ]]; then echo "::error::no structured_output from doublecheck" exit 1 fi printf '%s' "${structured}" \ > /tmp/triage/classification-doublecheck.json first_pass="${FIRST_PASS}" verdict=$(jq -r '.verdict' \ /tmp/triage/classification-doublecheck.json) if [[ "${verdict}" == "ambiguous" || "${verdict}" != "${first_pass}" ]]; then echo "disagreed=true" >> "$GITHUB_OUTPUT" else echo "disagreed=false" >> "$GITHUB_OUTPUT" fi # Route decision — 'investigate' enters Stages 3-7; 'deferral' # skips straight to 8b. Phase 3 routed `duplicate` alongside # `bug` so Stage 5 + Stage 6 can rate the `duplicate_of` target. # Phase 4 adds `enhancement` to the investigate route — needed # for the 8c variant, which renders existing-surface citations # from reviewer-kept findings. Phase 4 also adds a # suspicious-input short-circuit: when Stage 2a matched any # prompt-injection tell, route straight to 8b with reason # `suspicious-input — manual review`, bypassing the LLM # classifier entirely. - name: Decide route id: route env: SUSPICIOUS: ${{ steps.suspicious.outputs.suspicious }} CLASSIFICATION: ${{ steps.classify.outputs.classification }} DISAGREED: ${{ steps.doublecheck.outputs.disagreed }} run: | suspicious="${SUSPICIOUS}" classification="${CLASSIFICATION}" disagreed="${DISAGREED}" if [[ "${suspicious}" == "true" ]]; then echo "route=deferral" >> "$GITHUB_OUTPUT" echo "deferral_reason_id=suspicious-input" \ >> "$GITHUB_OUTPUT" elif [[ "${disagreed}" == "true" ]]; then echo "route=deferral" >> "$GITHUB_OUTPUT" echo "deferral_reason_id=ambiguous" >> "$GITHUB_OUTPUT" elif [[ "${classification}" == "bug" \ || "${classification}" == "enhancement" \ || "${classification}" == "duplicate" ]]; then echo "route=investigate" >> "$GITHUB_OUTPUT" else echo "route=deferral" >> "$GITHUB_OUTPUT" echo "deferral_reason_id=no-findings" >> "$GITHUB_OUTPUT" fi # Stage 3a — version drift check. Compares classify's # claimed_version against the repo variable CLAUDE_DESKTOP_VERSION. # Investigation still runs regardless; the drift flag steers the # final decision gate. # # Both sides are normalized before compare: strip a leading `v`, # then strip anything from the first `-` or space onward. # Handles the common case where a reporter pastes the deb # package version (`claude-desktop 1.3561.0-2.0.0` — upstream + # REPO_VERSION suffix) or the AppImage filename (which can # carry the same suffix). A bare upstream version still # round-trips unchanged. - name: Check version drift id: drift if: steps.route.outputs.route == 'investigate' env: CURRENT_VERSION: ${{ vars.CLAUDE_DESKTOP_VERSION }} run: | claimed=$(jq -r '.claimed_version // ""' \ /tmp/triage/classification.json) normalize() { local v="${1#v}" v="${v%%-*}" v="${v%% *}" printf '%s' "${v}" } claimed_norm=$(normalize "${claimed}") current_norm=$(normalize "${CURRENT_VERSION}") if [[ -n "${claimed_norm}" && "${claimed_norm}" != "null" \ && -n "${current_norm}" \ && "${claimed_norm}" != "${current_norm}" ]]; then echo "drift_detected=true" >> "$GITHUB_OUTPUT" echo "::notice::version drift: claimed=${claimed} (normalized ${claimed_norm}) current=${CURRENT_VERSION} (normalized ${current_norm})" else echo "drift_detected=false" >> "$GITHUB_OUTPUT" fi # Stage 3b — validate regression_of and fetch its diff. The # classifier sets `regression_of` when the reporter explicitly # names a culprit PR (e.g. "broken since #305"). We verify: # 1. PR exists in this repo # 2. PR is merged (an unmerged PR can't have caused a regression) # 3. PR's mergedAt precedes issue's createdAt (a PR merged after # the issue was filed can't be what the reporter is citing) # Valid regression_of → diff fetched as a primary investigation # input; the defect site is almost always inside the named PR's # changed files. Invalid → cleared to null with a logged note # (e.g. reporter named an upstream Electron PR that isn't in # this repo) and the issue proceeds as a regular bug. - name: Validate regression_of id: regression if: steps.route.outputs.route == 'investigate' env: GH_TOKEN: ${{ github.token }} run: | reg=$(jq -r '.regression_of // empty' \ /tmp/triage/classification.json) if [[ -z "${reg}" || "${reg}" == "null" ]]; then echo 'null' > /tmp/triage/regression-of.json echo "has_regression=false" >> "$GITHUB_OUTPUT" exit 0 fi issue_created=$(jq -r '.createdAt' /tmp/triage/issue.json) # Separate exit-code capture — gh pr view returns nonzero on # 404 and under errexit that'd abort the step before we can # classify the failure. if pr_json=$(gh pr view "${reg}" \ --repo "${GITHUB_REPOSITORY}" \ --json state,mergedAt,url,title 2>/dev/null); then gh_ok=0 else gh_ok=$? fi if [[ "${gh_ok}" != "0" ]]; then jq -n --argjson n "${reg}" \ '{valid: false, pr_number: $n, reason: "PR not found in this repo"}' \ > /tmp/triage/regression-of.json echo "::notice::regression_of #${reg} not found — cleared" echo "has_regression=false" >> "$GITHUB_OUTPUT" exit 0 fi pr_state=$(jq -r '.state' <<<"${pr_json}") pr_merged_at=$(jq -r '.mergedAt // empty' <<<"${pr_json}") if [[ "${pr_state}" != "MERGED" || -z "${pr_merged_at}" ]]; then jq -n --argjson n "${reg}" --arg s "${pr_state}" \ '{valid: false, pr_number: $n, reason: ("PR not merged (state=" + $s + ")")}' \ > /tmp/triage/regression-of.json echo "::notice::regression_of #${reg} not merged — cleared" echo "has_regression=false" >> "$GITHUB_OUTPUT" exit 0 fi # Date comparison — if mergedAt is AFTER createdAt, the PR # can't be what the reporter is citing. ISO 8601 timestamps # compare lexicographically in chronological order, so `[[ > # ]]` on the raw strings is correct. if [[ "${pr_merged_at}" > "${issue_created}" ]]; then jq -n --argjson n "${reg}" \ --arg m "${pr_merged_at}" --arg c "${issue_created}" \ '{valid: false, pr_number: $n, reason: ("PR merged " + $m + " is after issue created " + $c)}' \ > /tmp/triage/regression-of.json echo "::notice::regression_of #${reg} merged after issue — cleared" echo "has_regression=false" >> "$GITHUB_OUTPUT" exit 0 fi # All three checks passed — fetch the diff so Stage 4 can # use it as a primary input. Capped at 4000 lines to bound # prompt length on large PRs; the investigator still has # tool access to read the full diff if it needs to. gh pr diff "${reg}" --repo "${GITHUB_REPOSITORY}" \ 2>/dev/null | head -4000 \ > /tmp/triage/regression-of-diff.txt || true pr_title=$(jq -r '.title' <<<"${pr_json}") pr_url=$(jq -r '.url' <<<"${pr_json}") diff_lines=$(wc -l < /tmp/triage/regression-of-diff.txt) jq -n --argjson n "${reg}" \ --arg m "${pr_merged_at}" --arg t "${pr_title}" \ --arg u "${pr_url}" --argjson lines "${diff_lines}" \ '{valid: true, pr_number: $n, merged_at: $m, title: $t, url: $u, diff_lines: $lines}' \ > /tmp/triage/regression-of.json echo "has_regression=true" >> "$GITHUB_OUTPUT" echo "::notice::regression_of #${reg} validated — ${diff_lines} diff lines" # Stage 3 — fetch reference. 3× retry with exponential backoff # per spec §Reference tarball failure mode (2s, 8s, 32s). - name: Fetch reference source id: fetch if: steps.route.outputs.route == 'investigate' env: GH_TOKEN: ${{ github.token }} run: | mkdir -p /tmp/ref-source fetched=false for backoff in 2 8 32; do if gh release download \ --repo "${GITHUB_REPOSITORY}" \ --pattern 'reference-source.tar.gz' \ --dir /tmp/ref-source \ --clobber 2>/dev/null; then fetched=true break fi echo "::notice::fetch failed, sleeping ${backoff}s" sleep "${backoff}" done if [[ "${fetched}" != "true" \ || ! -s /tmp/ref-source/reference-source.tar.gz ]]; then echo "fetch_ok=false" >> "$GITHUB_OUTPUT" echo "::warning::reference-source.tar.gz fetch exhausted retries" exit 0 fi tar -xzf /tmp/ref-source/reference-source.tar.gz \ -C /tmp/ref-source echo "fetch_ok=true" >> "$GITHUB_OUTPUT" # Stage 4 — investigate. Claude reads the repo + reference source # via tool access and emits structured findings. Schema validation # runs post-call (jq required-fields check); hard schema bans are # enforced by Stage 5 (validate.sh) per spec §4. # # Phase 4 adds the enhancement variant: for enhancement # classifications the prompt shifts to surfacing existing code # the ask would touch rather than defect sites. Same schema — # findings with identifier/behavior/flow claim_types, existing- # code only. The enhancement variant bans `claim_type: absence` # (the whole point of an enhancement is that some capability # isn't there; restating it as an absence finding adds nothing). - name: Investigate id: investigate if: steps.route.outputs.route == 'investigate' && steps.fetch.outputs.fetch_ok == 'true' env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} CLASSIFICATION_NAME: ${{ steps.classify.outputs.classification }} HAS_REGRESSION: ${{ steps.regression.outputs.has_regression }} run: | schema=$(cat .claude/scripts/schemas/investigate.json) title=$(jq -r '.title' /tmp/triage/issue.json) body=$(jq -r '.body // ""' /tmp/triage/issue.json) classification=$(cat /tmp/triage/classification.json) if [[ "${CLASSIFICATION_NAME}" == "enhancement" ]]; then investigate_prompt=.claude/scripts/prompts/investigate-enhancement.txt else investigate_prompt=.claude/scripts/prompts/investigate.txt fi { cat "${investigate_prompt}" echo "" echo "## Reference source" echo "" echo "Beautified upstream app.asar is extracted at:" echo " /tmp/ref-source/app-extracted/" echo "" echo "Key files:" echo " - /tmp/ref-source/app-extracted/.vite/build/index.js (main-process entry stub; require()s the code-split main chunk since 1.19367.0)" echo " - /tmp/ref-source/app-extracted/.vite/build/index.chunk-.js (main process — the real code)" echo " - /tmp/ref-source/app-extracted/.vite/build/mainWindow.js" echo " - /tmp/ref-source/app-extracted/.vite/build/mainView.js" echo "" echo "When citing reference-source paths in findings, prefix" echo "with 'reference-source/' (strip the /tmp/ref-source/" echo "portion) so Stage 5 can resolve them." echo "" echo "## This repo" echo "" echo "Working directory is $(pwd). Patches live in" echo "scripts/patches/*.sh; build orchestrator is build.sh;" echo "wrapper pattern is in frame-fix-wrapper.js /" echo "frame-fix-entry.js." echo "" echo "## Classification" echo "" echo '```json' printf '%s\n' "${classification}" echo '```' echo "" # regression_of diff block — only when Stage 3b validated # the PR. The reporter named a culprit; the diff is a # primary input for Stage 4 because the defect site is # almost always inside the named PR's changed files. if [[ "${HAS_REGRESSION}" == "true" ]]; then echo "## Regression context (PR named by reporter)" echo "" reg_title=$(jq -r '.title' /tmp/triage/regression-of.json) reg_num=$(jq -r '.pr_number' /tmp/triage/regression-of.json) reg_url=$(jq -r '.url' /tmp/triage/regression-of.json) echo "PR #${reg_num}: ${reg_title}" echo "${reg_url}" echo "" echo "The reporter named this PR as the regression culprit" echo "(\"broken since #${reg_num}\"). Start the search in" echo "files this PR changed." echo "" echo '```diff' cat /tmp/triage/regression-of-diff.txt echo '```' echo "" fi echo "${title}" echo "" echo "" printf '%s\n' "${body}" echo "" } > /tmp/triage/investigate-prompt.txt # The investigation call runs with tool access (read/grep) so # Claude can verify claims against actual source. `--json-schema` # constrains the FINAL message; tool-call intermediates flow # freely. Per CLI docs, validation is post-hoc — conforming # output lands in `.structured_output`, prose in `.result`. # We prefer `.structured_output` and fall back to the # extract-json.py helper on the `.result` field in case the # CLI hands back prose for any reason. # # Step hardening from earlier PRs: # * `timeout 1200s` bounds the step at 20 min. Bumped from # 10m after repeated timeouts on complex issues (e.g. #311 # on two consecutive dispatches) where the investigator # needed more tool-call budget to verify claims. # * `if cmd; then; else` form — GHA's `bash -e` treats a # failing command substitution as a fatal error and aborts # before `claude_exit=$?` can run; the if-form is the only # reliable way to capture the exit code and branch on it. # Matters because a timeout (exit 124) must fall through to # 8b gracefully, not fail the whole step. # * Stderr to an archived log file so a silent hang is # post-mortem debuggable. # * Raw response + extracted payload archived before schema # checks so a reject still leaves artifacts to inspect. if raw=$(timeout 1200s claude -p "$(cat /tmp/triage/investigate-prompt.txt)" \ --dangerously-skip-permissions \ --output-format json \ --json-schema "${schema}" \ --model claude-sonnet-4-6 \ --max-budget-usd 3.00 \ 2>/tmp/triage/investigate-stderr.log); then claude_exit=0 else claude_exit=$? fi printf '%s' "${raw}" > /tmp/triage/investigate-raw.json if [[ "${claude_exit}" == "124" ]]; then echo "::warning::investigate call timed out at 600s" echo "investigate_ok=false" >> "$GITHUB_OUTPUT" exit 0 elif [[ "${claude_exit}" != "0" ]]; then echo "::warning::investigate call failed (exit ${claude_exit})" echo "investigate_ok=false" >> "$GITHUB_OUTPUT" exit 0 fi # Prefer the CLI-validated structured_output when the schema # fit cleanly. extracted=$(printf '%s' "${raw}" | jq -c '.structured_output // empty') if [[ -z "${extracted}" ]]; then # Fallback: pull .result and try to rescue JSON from prose. # Handles the case where the CLI's schema enforcement # didn't land a structured_output (e.g. tool-use loop # drifted and the CLI returned prose in .result). payload=$(printf '%s' "${raw}" | jq -r '.result // empty') printf '%s' "${payload}" > /tmp/triage/investigate-payload.txt if [[ -z "${payload}" ]]; then echo "investigate_ok=false" >> "$GITHUB_OUTPUT" echo "::warning::empty investigation result" exit 0 fi extracted=$(python3 .claude/scripts/triage/extract-json.py \ < /tmp/triage/investigate-payload.txt) || { echo "investigate_ok=false" >> "$GITHUB_OUTPUT" echo "::warning::no valid JSON object in investigation payload" exit 0 } fi # Type-check the four required top-level arrays — presence # alone would pass `{"findings": "oops"}` which then explodes # in validate.sh. When `--json-schema` bit, this is a no-op. if ! printf '%s' "${extracted}" | jq -e ' (.findings | type == "array") and (.pattern_sweep | type == "array") and (.proposed_anchors | type == "array") and (.related_issues | type == "array") ' >/dev/null 2>&1; then echo "investigate_ok=false" >> "$GITHUB_OUTPUT" echo "::warning::investigation output failed shape check" exit 0 fi printf '%s' "${extracted}" > /tmp/triage/investigation.json echo "investigate_ok=true" >> "$GITHUB_OUTPUT" # Stage 5 — mechanical validation. Pure bash via validate.sh. - name: Validate findings id: validate if: steps.investigate.outputs.investigate_ok == 'true' env: GH_TOKEN: ${{ github.token }} run: | bash .claude/scripts/triage/validate.sh \ /tmp/triage/investigation.json \ "${GITHUB_WORKSPACE}" \ /tmp/ref-source/app-extracted \ "${GITHUB_REPOSITORY}" \ /tmp/triage/validation.json findings_passed=$(jq -r '.summary.findings_passed' \ /tmp/triage/validation.json) findings_total=$(jq -r '.summary.findings_total' \ /tmp/triage/validation.json) # Average confidence over surviving findings. high=3, medium=2, # low=1. 2.0 is the "at least medium" threshold per spec §7. avg=$(jq -r ' [.findings[] | select(.passed==true) | .finding.confidence | {high:3, medium:2, low:1}[.]] as $c | if ($c | length) == 0 then 0 else ($c | add / length) end ' /tmp/triage/validation.json) { echo "findings_passed=${findings_passed}" echo "findings_total=${findings_total}" echo "avg_confidence=${avg}" } >> "$GITHUB_OUTPUT" # Stage 3 sub-sweep — drift-bridge candidates. Runs only when # drift was detected AND investigation produced findings (we need # the file list to seed the sweep). - name: Drift-bridge sweep id: drift_bridge if: | steps.drift.outputs.drift_detected == 'true' && steps.investigate.outputs.investigate_ok == 'true' env: GH_TOKEN: ${{ github.token }} run: | claimed=$(jq -r '.claimed_version // ""' \ /tmp/triage/classification.json) bash .claude/scripts/triage/drift-bridge.sh \ /tmp/triage/investigation.json \ "${claimed}" \ "${GITHUB_REPOSITORY}" \ /tmp/triage/drift-bridge-candidates.json candidate_count=$(jq \ '(.commits | length) + (.prs | length)' \ /tmp/triage/drift-bridge-candidates.json) echo "candidate_count=${candidate_count}" >> "$GITHUB_OUTPUT" # Stage 5 extension — fetch the `duplicate_of` target body so # Stage 6 can rate it (exact/related/unrelated) against the # current issue. Runs only when classification is `duplicate` and # the classifier supplied a non-null `duplicate_of`. validate.sh # already fetches bodies for investigation-cited related_issues, # but the duplicate_of target comes from classify.json, not # investigation.json — that's this step's job. - name: Fetch duplicate_of body id: dup_fetch if: steps.route.outputs.route == 'investigate' && steps.classify.outputs.classification == 'duplicate' env: GH_TOKEN: ${{ github.token }} run: | dup_num=$(jq -r '.duplicate_of // empty' \ /tmp/triage/classification.json) if [[ -z "${dup_num}" || "${dup_num}" == "null" ]]; then echo "::notice::classification=duplicate but duplicate_of is null" echo 'null' > /tmp/triage/duplicate-of.json echo "dup_fetched=false" >> "$GITHUB_OUTPUT" exit 0 fi fetched=$(gh issue view "${dup_num}" \ --repo "${GITHUB_REPOSITORY}" \ --json number,title,state,stateReason,body,labels 2>/dev/null \ || echo '{}') title=$(jq -r '.title // ""' <<<"${fetched}") if [[ -z "${title}" ]]; then echo "::warning::duplicate_of #${dup_num} fetch failed" echo 'null' > /tmp/triage/duplicate-of.json echo "dup_fetched=false" >> "$GITHUB_OUTPUT" exit 0 fi printf '%s' "${fetched}" > /tmp/triage/duplicate-of.json echo "dup_fetched=true" >> "$GITHUB_OUTPUT" # Stage 6 — adversarial review. Fresh-context Sonnet call, no # tool access: the input set is pre-assembled (surviving findings # + source excerpts + closed_world_options + cited-issue bodies + # duplicate_of body when present + issue body/title as untrusted # data). Reviewer does NOT see the 8a draft, investigation's # free-form scratch reasoning, voice instructions, or drafter # prompt — that exclusion is structural per spec §6. # # Runs whenever Stage 5 produced a validation.json, including # zero surviving findings — the duplicate_of rating is still # needed when classification=duplicate. If there are neither # findings nor a duplicate_of to rate, we skip the call entirely # and let Stage 7 handle the empty case via the no-findings gate. - name: Adversarial review (Stage 6) id: review if: | steps.validate.outputs.findings_total != '' && (steps.validate.outputs.findings_passed != '0' || steps.dup_fetch.outputs.dup_fetched == 'true') env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} HAS_REGRESSION: ${{ steps.regression.outputs.has_regression }} CLASSIFICATION_NAME: ${{ steps.classify.outputs.classification }} run: | schema=$(cat .claude/scripts/schemas/review.json) title=$(jq -r '.title' /tmp/triage/issue.json) body=$(jq -r '.body // ""' /tmp/triage/issue.json) classification=$(cat /tmp/triage/classification.json) # Extract surviving findings with source excerpts + the # closed_world_options list Stage 5 computed. The reviewer # gets an index-stable view so verdicts reference findings by # finding_index (0..N-1 over surviving order). surviving='[]' idx=0 while IFS= read -r v; do f=$(jq -r '.finding.file' <<<"${v}") ls=$(jq -r '.finding.line_start' <<<"${v}") le=$(jq -r '.finding.line_end' <<<"${v}") if [[ "${f}" == reference-source/* ]]; then resolved="/tmp/ref-source/app-extracted/${f#reference-source/}" else resolved="${GITHUB_WORKSPACE}/${f}" fi es=$((ls - 5)) (( es < 1 )) && es=1 ee=$((le + 5)) excerpt=$(sed -n "${es},${ee}p" "${resolved}" \ 2>/dev/null || echo "") entry=$(jq -n \ --argjson idx "${idx}" \ --argjson v "${v}" \ --arg excerpt "${excerpt}" \ '{ finding_index: $idx, finding: $v.finding, closed_world_options: $v.closed_world_options, source_excerpt: $excerpt }') surviving=$(jq --argjson e "${entry}" '. + [$e]' \ <<<"${surviving}") idx=$((idx + 1)) done < <(jq -c '.findings[] | select(.passed==true)' \ /tmp/triage/validation.json) related=$(jq '.related_issues' /tmp/triage/validation.json) # duplicate_of payload — the fetched target body when the # classify step set duplicate_of and the fetch succeeded; # otherwise null. Reviewer emits `duplicate_of_rating: null` # for the null case. if [[ "${{ steps.dup_fetch.outputs.dup_fetched }}" == "true" ]]; then dup_payload=$(cat /tmp/triage/duplicate-of.json) else dup_payload='null' fi # Phase 4 prompt variant: enhancement classifications get the # reframed rubric ("is this an existing surface the # enhancement would touch?" rather than "is this defect claim # correct?"). Schema is identical. if [[ "${CLASSIFICATION_NAME}" == "enhancement" ]]; then review_prompt=.claude/scripts/prompts/review-enhancement.txt else review_prompt=.claude/scripts/prompts/review.txt fi { cat "${review_prompt}" echo "" echo "## Classification" echo "" echo "" echo '```json' printf '%s\n' "${classification}" echo '```' echo "" echo "" echo "## Surviving findings (with source excerpts and closed-world options)" echo "" echo "" echo '```json' printf '%s\n' "${surviving}" echo '```' echo "" echo "" echo "## Cited related issues (fetched bodies)" echo "" echo "" echo '```json' printf '%s\n' "${related}" echo '```' echo "" echo "" echo "## duplicate_of target (fetched body, null when absent)" echo "" echo "" echo '```json' printf '%s\n' "${dup_payload}" echo '```' echo "" echo "" # regression_of diff block — only when Stage 3b validated. # Lets the reviewer check whether a finding's citation # actually lands inside the named PR's changed files. if [[ "${HAS_REGRESSION}" == "true" ]]; then echo "## regression_of PR diff (reporter-named culprit)" echo "" reg_num=$(jq -r '.pr_number' /tmp/triage/regression-of.json) reg_title=$(jq -r '.title' /tmp/triage/regression-of.json) echo "" echo "PR #${reg_num}: ${reg_title}" echo "" echo '```diff' cat /tmp/triage/regression-of-diff.txt echo '```' echo "" echo "" fi echo "${title}" echo "" echo "" printf '%s\n' "${body}" echo "" } > /tmp/triage/review-prompt.txt # Errexit guard per Phase 2 pattern: naked command # substitution abort before we can branch. if raw=$(timeout 600s claude -p \ "$(cat /tmp/triage/review-prompt.txt)" \ --output-format json \ --json-schema "${schema}" \ --model claude-sonnet-4-6 \ --max-budget-usd 1.50 \ 2>/tmp/triage/review-stderr.log); then claude_exit=0 else claude_exit=$? fi printf '%s' "${raw}" > /tmp/triage/review-raw.json if [[ "${claude_exit}" == "124" ]]; then echo "::warning::review call timed out at 600s" echo "review_ok=false" >> "$GITHUB_OUTPUT" exit 0 elif [[ "${claude_exit}" != "0" ]]; then echo "::warning::review call failed (exit ${claude_exit})" echo "review_ok=false" >> "$GITHUB_OUTPUT" exit 0 fi extracted=$(printf '%s' "${raw}" \ | jq -c '.structured_output // empty') if [[ -z "${extracted}" ]]; then payload=$(printf '%s' "${raw}" | jq -r '.result // empty') printf '%s' "${payload}" > /tmp/triage/review-payload.txt if [[ -z "${payload}" ]]; then echo "review_ok=false" >> "$GITHUB_OUTPUT" echo "::warning::empty review result" exit 0 fi extracted=$(python3 .claude/scripts/triage/extract-json.py \ < /tmp/triage/review-payload.txt) || { echo "review_ok=false" >> "$GITHUB_OUTPUT" echo "::warning::no valid JSON object in review payload" exit 0 } fi if ! printf '%s' "${extracted}" | jq -e ' (.findings | type == "array") and (.related_issues_ratings | type == "array") and (has("duplicate_of_rating")) ' >/dev/null 2>&1; then echo "review_ok=false" >> "$GITHUB_OUTPUT" echo "::warning::review output failed shape check" exit 0 fi printf '%s' "${extracted}" > /tmp/triage/review.json echo "review_ok=true" >> "$GITHUB_OUTPUT" # Reviewer-aware filter. Combines Stage 5 survivors with Stage 6 # verdicts: approve keeps the finding at full confidence; # downgrade-confidence keeps it but contributes 1 less to the # average (floor 0.5); reject drops it. The duplicate_of rating is # summarized here too so the decision gate can read a single # scalar. - name: Apply reviewer verdicts id: filter if: steps.review.outputs.review_ok == 'true' run: | review=/tmp/triage/review.json validation=/tmp/triage/validation.json # findings_kept: approve + downgrade-confidence only. kept=$(jq '[.findings[] | select(.verdict != "reject")] | length' "${review}") rejected=$(jq '[.findings[] | select(.verdict == "reject")] | length' "${review}") downgraded=$(jq '[.findings[] | select(.verdict == "downgrade-confidence")] | length' \ "${review}") # Compute reviewer-aware average confidence across kept # findings. confidence points: high=3, medium=2, low=1. A # downgrade-confidence verdict subtracts 1 (floor 0.5 so it # still contributes something). Threshold 2.0 = "at least # medium on average" per spec §7. # # Cross-joins review[].finding_index with validation's # passed-findings list in survivor order. avg=$(jq -n \ --slurpfile r "${review}" \ --slurpfile v "${validation}" ' ($v[0].findings | map(select(.passed==true))) as $survivors | ($r[0].findings | map(select(.verdict != "reject"))) as $kept_reviews | [ $kept_reviews[] | . as $rv | ($survivors[$rv.finding_index].finding.confidence) as $c | (if $c == "high" then 3 elif $c == "medium" then 2 elif $c == "low" then 1 else 0 end) as $score | (if $rv.verdict == "downgrade-confidence" then ([$score - 1, 0.5] | max) else $score end) ] as $scores | if ($scores | length) == 0 then 0 else ($scores | add / ($scores | length)) end ') dup_rating=$(jq -r '.duplicate_of_rating.rating // "none"' \ "${review}") { echo "review_findings_kept=${kept}" echo "review_findings_rejected=${rejected}" echo "review_findings_downgraded=${downgraded}" echo "review_avg_confidence=${avg}" echo "duplicate_of_rating=${dup_rating}" } >> "$GITHUB_OUTPUT" # Stage 7 — decision gate. Selects the final comment variant and # reason. Priority per spec §7 with Phase 3's duplicate gate, # Phase 4's enhancement gate, and drift demoted from top-of-gate # veto to a banner modifier: # fetch-failure → duplicate → invest-failure → review-failure # → enhancement (8c) → no-findings → low-confidence → 8a # Drift no longer vetoes the comment. When drift is detected # AND 8a or 8c would render, the renderer prepends a drift # banner and appends the drift-bridge candidates block; the # finding citations stand but the reader is warned they're on # current code, not the reporter's version. When drift is # detected AND any other gate routes to 8b, the reason is # overridden to `version-drift` (drift is the more actionable # signal for the maintainer than the underlying no-findings / # low-confidence cause). - name: Decide comment variant id: decide env: ROUTE: ${{ steps.route.outputs.route }} DEFERRAL_REASON_ID: ${{ steps.route.outputs.deferral_reason_id }} CLASSIFICATION: ${{ steps.classify.outputs.classification }} FETCH_OK: ${{ steps.fetch.outputs.fetch_ok }} INVEST_OK: ${{ steps.investigate.outputs.investigate_ok }} DRIFT: ${{ steps.drift.outputs.drift_detected }} REVIEW_OK: ${{ steps.review.outputs.review_ok }} FINDINGS_PASSED: ${{ steps.validate.outputs.findings_passed }} KEPT: ${{ steps.filter.outputs.review_findings_kept }} AVG: ${{ steps.filter.outputs.review_avg_confidence }} DUP_RATING: ${{ steps.filter.outputs.duplicate_of_rating }} run: | route="${ROUTE}" if [[ "${route}" == "deferral" ]]; then echo "variant=8b" >> "$GITHUB_OUTPUT" echo "reason_id=${DEFERRAL_REASON_ID}" \ >> "$GITHUB_OUTPUT" exit 0 fi classification="${CLASSIFICATION}" fetch_ok="${FETCH_OK}" invest_ok="${INVEST_OK}" drift="${DRIFT}" review_ok="${REVIEW_OK}" findings_passed="${FINDINGS_PASSED}" kept="${KEPT}" avg="${AVG}" dup_rating="${DUP_RATING}" # Shared gates that apply to every investigate route. if [[ "${fetch_ok}" != "true" ]]; then variant=8b reason_id=reference-source-unavailable elif [[ "${classification}" == "duplicate" \ && ( "${dup_rating}" == "exact" \ || "${dup_rating}" == "related" ) ]]; then variant=8b reason_id=duplicate elif [[ "${invest_ok}" != "true" ]]; then variant=8b reason_id=no-findings # Enhancement path — 8c renders with 0..3 reviewer-kept # existing-surface findings plus design questions from the # taxonomy. An empty surface list is still useful output # because the design questions carry the core value. # Review must have succeeded when findings existed; when # findings_passed was 0 the review step was skipped by # design (nothing to rate), and that's fine. elif [[ "${classification}" == "enhancement" ]]; then if [[ "${findings_passed:-0}" == "0" ]] \ || [[ "${review_ok}" == "true" ]]; then variant=8c reason_id= else # Findings exist but review didn't succeed — fail closed # to human review rather than posting unreviewed surfaces. variant=8b reason_id=no-findings fi # Bug / duplicate (post-unrelated-rating) path. elif [[ "${review_ok}" != "true" ]]; then variant=8b reason_id=no-findings elif [[ -z "${kept}" || "${kept}" == "0" ]]; then variant=8b reason_id=no-findings elif awk -v a="${avg:-0}" \ 'BEGIN{exit !(a+0 < 2.0)}'; then variant=8b reason_id=low-confidence else variant=8a reason_id= fi # Drift override — when drift is detected AND the pipeline # would fall back to 8b for any other reason, report drift # as the deferral reason. Drift-bridge candidates give the # maintainer a more actionable signal than "no findings" / # "low confidence" on their own. When the pipeline reaches # 8a or 8c cleanly, drift stays as a banner flag — handled # by the renderer, not this gate. if [[ "${drift}" == "true" \ && "${variant}" == "8b" \ && "${reason_id}" != "duplicate" ]]; then reason_id=version-drift fi { echo "variant=${variant}" echo "reason_id=${reason_id}" } >> "$GITHUB_OUTPUT" - name: Resolve reason text id: reason if: steps.decide.outputs.reason_id != '' env: REASON_ID: ${{ steps.decide.outputs.reason_id }} run: | reason_text=$(jq -r --arg id "${REASON_ID}" \ '.reasons[] | select(.id==$id) | .text' \ .claude/scripts/reasons.json) # `duplicate` template carries a `#{duplicate_of}` placeholder; # substitute it now so the rendered comment reads # `likely-duplicate-of-#123`. The 8b post-processor normalizes # the rendered `#N` back to `#{duplicate_of}` before checking # the enum, so both sides stay in sync. if [[ "${REASON_ID}" == "duplicate" ]]; then dup_num=$(jq -r '.duplicate_of // empty' \ /tmp/triage/classification.json) if [[ -n "${dup_num}" ]]; then reason_text="${reason_text/\#\{duplicate_of\}/#${dup_num}}" fi fi echo "reason_text=${reason_text}" >> "$GITHUB_OUTPUT" # Stage 8a — findings variant. Sonnet call that emits structured # comment object; bash renders the markdown. Phase 3 filters # surviving findings by reviewer verdict: only `approve` and # `downgrade-confidence` reach the drafter; `reject` verdicts # drop the finding before Stage 8. - name: Draft 8a comment (findings variant) id: draft_8a if: steps.decide.outputs.variant == '8a' env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} run: | schema=$(cat .claude/scripts/schemas/comment-findings.json) # Reviewer-kept surviving-finding indices (approve + # downgrade-confidence). Used to cross-join validation's # passed findings with review verdicts — Stage 6's # `finding_index` is zero-based over Stage 5's survivor # order, matching how the review step assembled its input. kept_indices=$(jq '[.findings[] | select(.verdict != "reject") | .finding_index]' \ /tmp/triage/review.json) # Per-finding reviewer rating for the drafter's related-issue # copy-through — 8a's schema requires `relation` per cited # issue, and PR #459 item 3 wants the drafter reading # reviewer verdicts rather than inventing its own. related_ratings=$(jq '.related_issues_ratings // []' \ /tmp/triage/review.json) # Filter passed findings to the reviewer-kept set. surviving=$(jq --argjson keep "${kept_indices}" ' [.findings | map(select(.passed==true)) | to_entries[] | select(.key as $i | $keep | index($i)) | .value] ' /tmp/triage/validation.json) related=$(jq '.related_issues' /tmp/triage/validation.json) # Source excerpts for each reviewer-kept finding (±5 lines). excerpts='[]' while IFS= read -r v; do f=$(jq -r '.finding.file' <<<"${v}") ls=$(jq -r '.finding.line_start' <<<"${v}") le=$(jq -r '.finding.line_end' <<<"${v}") if [[ "${f}" == reference-source/* ]]; then resolved="/tmp/ref-source/app-extracted/${f#reference-source/}" else resolved="${GITHUB_WORKSPACE}/${f}" fi es=$((ls - 5)) (( es < 1 )) && es=1 ee=$((le + 5)) excerpt=$(sed -n "${es},${ee}p" "${resolved}" \ 2>/dev/null || echo "") entry=$(jq -n \ --arg f "${f}" --argjson ls "${ls}" --argjson le "${le}" \ --arg excerpt "${excerpt}" \ '{file: $f, line_start: $ls, line_end: $le, excerpt: $excerpt}') excerpts=$(jq --argjson e "${entry}" '. + [$e]' \ <<<"${excerpts}") done < <(jq -c '.[]' <<<"${surviving}") # JSON inputs are wrapped as — they trace # back to reporter-controlled text (evidence_quote, # quoted_excerpt, related-issue bodies) and carry the same # injection risk as the issue body itself, now that a second # reviewer stage makes prompt leakage more consequential # (PR #459 item 3). { cat .claude/scripts/prompts/comment-findings.txt echo "" echo "## Surviving findings (reviewer-kept)" echo "" echo "" echo '```json' printf '%s\n' "${surviving}" echo '```' echo "" echo "" echo "## Source excerpts at claim sites" echo "" echo "" echo '```json' printf '%s\n' "${excerpts}" echo '```' echo "" echo "" echo "## Related issues (fetched bodies)" echo "" echo "" echo '```json' printf '%s\n' "${related}" echo '```' echo "" echo "" echo "## Reviewer ratings for related issues (copy these verbatim)" echo "" echo "" echo '```json' printf '%s\n' "${related_ratings}" echo '```' echo "" } > /tmp/triage/render-8a-prompt.txt result=$(claude -p "$(cat /tmp/triage/render-8a-prompt.txt)" \ --output-format json \ --json-schema "${schema}" \ --model claude-sonnet-4-6 \ --max-budget-usd 2.00 \ 2>/dev/null) || { echo "::error::8a draft call failed" exit 1 } structured=$(printf '%s' "${result}" \ | jq -c '.structured_output // empty') if [[ -z "${structured}" ]]; then echo "::error::no structured_output from 8a draft" exit 1 fi printf '%s' "${structured}" > /tmp/triage/comment-findings.json - name: Render 8a comment markdown if: steps.decide.outputs.variant == '8a' env: RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} DRIFT_DETECTED: ${{ steps.drift.outputs.drift_detected }} CURRENT_VERSION: ${{ vars.CLAUDE_DESKTOP_VERSION }} run: | c=/tmp/triage/comment-findings.json hypothesis=$(jq -r '.hypothesis_line' "${c}") # Drift banner — prepended when the classifier picked up a # claimed_version that differs from CLAUDE_DESKTOP_VERSION. # The finding citations still stand (they describe current # code), but the reader needs to know the gap. drift_banner="" if [[ "${DRIFT_DETECTED}" == "true" ]]; then claimed=$(jq -r '.claimed_version // "unknown"' \ /tmp/triage/classification.json) drift_banner="⚠ You reported this on \`${claimed}\`; the bot investigated against the current release \`${CURRENT_VERSION}\`. Findings below are from current code — if the drift-bridge candidates at the bottom already address your case, you can probably close. Otherwise the file:line citations may still apply." fi { echo "**Automated draft — AI analysis, not maintainer judgment.** This bot won't close issues, apply labels beyond triage routing, or claim fixes are shipped. Findings below are starting points; the code citations are what to verify first." if [[ -n "${drift_banner}" ]]; then echo "" echo "${drift_banner}" fi echo "" echo "${hypothesis}" echo "" jq -r '.findings[] | "- \(.text) (\(.citation.file):\(.citation.line_start)-\(.citation.line_end))"' \ "${c}" # Patch sketch rendered only when body is non-null. if [[ "$(jq -r '.patch_sketch.body // "null"' "${c}")" != "null" ]]; then echo "" echo "
" echo "Unverified patch sketch (draft, not applied)" echo "" lang=$(jq -r '.patch_sketch.language // ""' "${c}") echo '```'"${lang}" jq -r '.patch_sketch.body' "${c}" echo '```' echo "" echo "
" fi # Related issues line — only non-unrelated relations. related_line=$(jq -r ' [.related_issues[] | select(.relation != "unrelated") | "#\(.number) — \(.relation)"] | join(", ") ' "${c}") if [[ -n "${related_line}" && "${related_line}" != "" ]]; then echo "" echo "Related: ${related_line}" fi # Drift-bridge candidates block — same shape as in 8b, # appended here when drift detected + sweep returned ≥1. if [[ "${DRIFT_DETECTED}" == "true" \ && -f /tmp/triage/drift-bridge-candidates.json ]]; then candidate_count=$(jq \ '(.commits | length) + (.prs | length)' \ /tmp/triage/drift-bridge-candidates.json) if [[ "${candidate_count}" -gt 0 ]]; then echo "" echo "Drift-bridge candidates — commits or PRs in the drift window that touched the relevant surface and may already address this:" jq -r ' (.commits[]? | "- \(.sha[0:8]) — \(.subject) (\(.date))"), (.prs[]? | "- #\(.number) — \(.title) (\(.mergedAt))") ' /tmp/triage/drift-bridge-candidates.json fi fi echo "" echo "Full investigation artifacts (\`investigation.json\`, \`validation.json\`) are attached to the [triage workflow run](${RUN_URL})." } > /tmp/triage/comment.md # Stage 8c — enhancement-design variant. Sonnet call emits a # structured comment object; bash renders the markdown. Parallel # to 8a but shaped for enhancement requests: acknowledgment + # existing-surface citations from reviewer-kept findings (0-3) + # design-review questions from a fixed taxonomy (1-3). # # When findings_passed was 0 the review step was skipped by # design and review.json doesn't exist — the drafter gets an # empty kept_indices array and renders 0 existing_surfaces; the # design questions carry the comment alone. When review_ok, the # reviewer-kept findings (approve + downgrade-confidence) flow # into existing_surfaces. - name: Draft 8c comment (enhancement-design variant) id: draft_8c if: steps.decide.outputs.variant == '8c' env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} run: | schema=$(cat .claude/scripts/schemas/comment-enhancement.json) title=$(jq -r '.title' /tmp/triage/issue.json) body=$(jq -r '.body // ""' /tmp/triage/issue.json) classification=$(cat /tmp/triage/classification.json) # Reviewer-kept indices when review ran, empty array when # findings_passed was 0 (review step skipped by design). if [[ -f /tmp/triage/review.json ]]; then kept_indices=$(jq '[.findings[] | select(.verdict != "reject") | .finding_index]' \ /tmp/triage/review.json) else kept_indices='[]' fi # Surviving findings filtered to the reviewer-kept set. if [[ -f /tmp/triage/validation.json ]]; then surviving=$(jq --argjson keep "${kept_indices}" ' [.findings | map(select(.passed==true)) | to_entries[] | select(.key as $i | $keep | index($i)) | .value] ' /tmp/triage/validation.json) else surviving='[]' fi # Source excerpts for each reviewer-kept finding (±5 lines). excerpts='[]' while IFS= read -r v; do f=$(jq -r '.finding.file' <<<"${v}") ls=$(jq -r '.finding.line_start' <<<"${v}") le=$(jq -r '.finding.line_end' <<<"${v}") if [[ "${f}" == reference-source/* ]]; then resolved="/tmp/ref-source/app-extracted/${f#reference-source/}" else resolved="${GITHUB_WORKSPACE}/${f}" fi es=$((ls - 5)) (( es < 1 )) && es=1 ee=$((le + 5)) excerpt=$(sed -n "${es},${ee}p" "${resolved}" \ 2>/dev/null || echo "") entry=$(jq -n \ --arg f "${f}" --argjson ls "${ls}" --argjson le "${le}" \ --arg excerpt "${excerpt}" \ '{file: $f, line_start: $ls, line_end: $le, excerpt: $excerpt}') excerpts=$(jq --argjson e "${entry}" '. + [$e]' \ <<<"${excerpts}") done < <(jq -c '.[]' <<<"${surviving}") # JSON inputs wrapped as pipeline_data for the same # injection-resistance reasons as 8a (reporter-derived # content flows through evidence_quote + excerpts). { cat .claude/scripts/prompts/comment-enhancement.txt echo "" echo "## Classification" echo "" echo "" echo '```json' printf '%s\n' "${classification}" echo '```' echo "" echo "" echo "## Reviewer-kept existing-surface findings" echo "" echo "" echo '```json' printf '%s\n' "${surviving}" echo '```' echo "" echo "" echo "## Source excerpts at surface sites" echo "" echo "" echo '```json' printf '%s\n' "${excerpts}" echo '```' echo "" echo "" echo "${title}" echo "" echo "" printf '%s\n' "${body}" echo "" } > /tmp/triage/render-8c-prompt.txt result=$(claude -p "$(cat /tmp/triage/render-8c-prompt.txt)" \ --output-format json \ --json-schema "${schema}" \ --model claude-sonnet-4-6 \ --max-budget-usd 2.00 \ 2>/dev/null) || { echo "::error::8c draft call failed" exit 1 } structured=$(printf '%s' "${result}" \ | jq -c '.structured_output // empty') if [[ -z "${structured}" ]]; then echo "::error::no structured_output from 8c draft" exit 1 fi printf '%s' "${structured}" \ > /tmp/triage/comment-enhancement.json - name: Render 8c comment markdown if: steps.decide.outputs.variant == '8c' env: RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} DRIFT_DETECTED: ${{ steps.drift.outputs.drift_detected }} CURRENT_VERSION: ${{ vars.CLAUDE_DESKTOP_VERSION }} run: | c=/tmp/triage/comment-enhancement.json tax=.claude/scripts/taxonomies/enhancement-design-questions.json ack=$(jq -r '.acknowledgment_line' "${c}") surface_count=$(jq '.existing_surfaces | length' "${c}") # Drift banner — same shape as 8a; reframed for enhancements # (surfaces point at current code, which may have moved # since the reporter's version). drift_banner="" if [[ "${DRIFT_DETECTED}" == "true" ]]; then claimed=$(jq -r '.claimed_version // "unknown"' \ /tmp/triage/classification.json) drift_banner="⚠ You reported this on \`${claimed}\`; the bot surfaced existing code on the current release \`${CURRENT_VERSION}\`. If the drift-bridge candidates at the bottom already address the enhancement, those may be a better starting point than the surfaces below." fi { echo "**Automated draft — AI analysis, not maintainer judgment.** This bot won't approve enhancements, prioritize roadmap, or commit timelines. The notes below flag existing surfaces and design questions that may be worth considering before implementation." if [[ -n "${drift_banner}" ]]; then echo "" echo "${drift_banner}" fi echo "" echo "${ack}" if [[ "${surface_count}" -gt 0 ]]; then echo "" echo "**Existing surfaces worth knowing about:**" jq -r '.existing_surfaces[] | "- \(.text) (\(.citation.file):\(.citation.line_start)-\(.citation.line_end))"' \ "${c}" fi echo "" echo "**Design-review questions:**" # Cross-join design_question_ids against the taxonomy to # resolve each ID to its human-readable text. Schema # enum-matched the IDs at draft time, so a missing lookup # here is a taxonomy-desync bug (loud failure preferred). jq -r --slurpfile tax "${tax}" ' .design_question_ids[] as $id | ($tax[0].questions[] | select(.id == $id) | .text) as $text | if $text == null then error("taxonomy missing id: \($id)") else "- \($text)" end ' "${c}" # Drift-bridge candidates block — appended when drift # detected + sweep returned ≥1 (spec §7). Helps the # maintainer spot PRs that may already address the ask. if [[ "${DRIFT_DETECTED}" == "true" \ && -f /tmp/triage/drift-bridge-candidates.json ]]; then candidate_count=$(jq \ '(.commits | length) + (.prs | length)' \ /tmp/triage/drift-bridge-candidates.json) if [[ "${candidate_count}" -gt 0 ]]; then echo "" echo "Drift-bridge candidates — commits or PRs in the drift window that touched the relevant surface and may already address this:" jq -r ' (.commits[]? | "- \(.sha[0:8]) — \(.subject) (\(.date))"), (.prs[]? | "- #\(.number) — \(.title) (\(.mergedAt))") ' /tmp/triage/drift-bridge-candidates.json fi fi echo "" echo "Full investigation artifacts attached to the [triage workflow run](${RUN_URL})." } > /tmp/triage/comment.md # 8c post-processor — schema already enforces maxItems:3 on both # existing_surfaces and design_question_ids, and enum-matches IDs # against the taxonomy, so rendering is bounded. Length cap per # spec §8c is 350 words; over that, drop the last # existing_surfaces entry (a helper the spec calls out) and # re-check. - name: Post-processor check (8c) if: steps.decide.outputs.variant == '8c' run: | words=$(wc -w < /tmp/triage/comment.md) if [[ "${words}" -le 350 ]]; then exit 0 fi # Drop last existing_surfaces entry by truncating the # "Existing surfaces worth knowing about:" block's final # bullet. When no surfaces are present, there's nothing to # trim and we let the cap notice fall through. if grep -q '^\*\*Existing surfaces worth knowing about:\*\*' \ /tmp/triage/comment.md; then # Find the last bullet under the surfaces heading and # remove it. Captures "- ..." lines between the heading # and the next "**Design-review questions:**" section. awk ' /^\*\*Existing surfaces worth knowing about:\*\*/ { in_surf=1; print; next } /^\*\*Design-review questions:\*\*/ { in_surf=0 } in_surf && /^- / { buf[++n]=$0; next } in_surf && !/^- / { for (i=1; i /tmp/triage/comment.md.trimmed mv /tmp/triage/comment.md.trimmed /tmp/triage/comment.md words=$(wc -w < /tmp/triage/comment.md) echo "::notice::Trimmed trailing existing_surfaces entry to meet 350-word cap (${words} words after)" else echo "::warning::8c comment ${words} words > 350 cap but no trimmable surfaces" fi # Stage 8b render — reason-based deferral. Includes the optional # drift-bridge-candidates block when drift was detected and the # sweep returned ≥1 candidate. - name: Render 8b comment if: steps.decide.outputs.variant == '8b' env: GH_TOKEN: ${{ github.token }} REASON_TEXT: ${{ steps.reason.outputs.reason_text }} REASON_ID: ${{ steps.decide.outputs.reason_id }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | author=$(jq -r '.author.login' /tmp/triage/issue.json) prior_count=$(gh issue list \ --repo "${GITHUB_REPOSITORY}" \ --author "${author}" \ --state all \ --limit 2 \ --json number --jq 'length') privacy_note="" if [[ "${prior_count}" -le 1 ]]; then privacy_note=$'\n\n(This bot processes issue text via Anthropic'"'"'s API. See [README §Privacy](https://github.com/aaddrick/claude-desktop-debian/blob/main/README.md#privacy) for what that means.)' fi # Drift-bridge block (only for version-drift reason with # non-empty candidate list). drift_block="" if [[ "${REASON_ID}" == "version-drift" \ && -f /tmp/triage/drift-bridge-candidates.json ]]; then candidate_count=$(jq \ '(.commits | length) + (.prs | length)' \ /tmp/triage/drift-bridge-candidates.json) if [[ "${candidate_count}" -gt 0 ]]; then drift_block=$'\n\n'"Drift-bridge candidates — commits or PRs in the drift window that touched the relevant surface and may already address this:"$'\n' drift_block+=$(jq -r ' (.commits[]? | "- \(.sha[0:8]) — \(.subject) (\(.date))"), (.prs[]? | "- #\(.number) — \(.title) (\(.mergedAt))") ' /tmp/triage/drift-bridge-candidates.json) fi fi { echo "**Automated draft — AI analysis, not maintainer judgment.** This bot looked at the issue but couldn't reach a confident read. Routing to a human for review." echo "" echo "Reason: ${REASON_TEXT}" if [[ -n "${drift_block}" ]]; then printf '%s' "${drift_block}" echo "" fi echo "" echo "${RUN_URL} has the raw classification artifact if helpful for context.${privacy_note}" } > /tmp/triage/comment.md # 8b post-processor — runs on the 8b variant only. 8a is schema- # constrained, no prose-stripping needed per spec. - name: Post-processor check (8b) if: steps.decide.outputs.variant == '8b' run: | reason_line=$(grep -oP '^Reason: \K.*$' /tmp/triage/comment.md \ || true) if [[ -z "${reason_line}" ]]; then echo "::error::No 'Reason: ...' line in 8b comment" exit 1 fi reason_check=$(printf '%s' "${reason_line}" \ | sed -E 's/#[0-9]+/#\{duplicate_of\}/') if ! jq -e --arg r "${reason_check}" \ '.reasons | map(.text) | any(. == $r)' \ .claude/scripts/reasons.json >/dev/null; then echo "::error::Reason '${reason_line}' not in reasons.json enum" exit 1 fi # 300-word cap accommodates the optional drift-bridge- # candidates block, which alone can add ~130 words for 10 # bullets. Spec §8b flagged "account for optional # drift-bridge-candidates block"; the original 150 was the # base-comment budget. words=$(wc -w < /tmp/triage/comment.md) if [[ "${words}" -gt 300 ]]; then echo "::error::8b comment exceeds 300 words (got ${words})" exit 1 fi # 8a post-processor — truncate
block if over 400 words. - name: Post-processor check (8a) if: steps.decide.outputs.variant == '8a' run: | words=$(wc -w < /tmp/triage/comment.md) if [[ "${words}" -gt 400 ]]; then # Strip the
...
block and re-check. sed -i '/
/,/<\/details>/d' /tmp/triage/comment.md words=$(wc -w < /tmp/triage/comment.md) echo "::notice::Truncated 8a patch-sketch block to meet 400-word cap (${words} words after)" fi # Edit-during-triage detection. Input snapshot captured the # reporter's body + updated_at at Stage 1; now re-fetch live # updated_at and append a disclaimer to the rendered comment # when they differ. Catches inject-then-delete attacks (inject # instructions, wait for bot, delete before human reads) and # honest mid-triage edits that would make the comment stale. # Runs for every variant. - name: Edit-during-triage check env: GH_TOKEN: ${{ github.token }} run: | snapshot_updated=$(jq -r '.updated_at' \ /tmp/triage/input_snapshot.json) live_updated=$(gh issue view "${ISSUE_NUMBER}" \ --repo "${GITHUB_REPOSITORY}" \ --json updatedAt --jq '.updatedAt' 2>/dev/null \ || echo "${snapshot_updated}") if [[ "${snapshot_updated}" != "${live_updated}" ]]; then { echo "" echo "---" echo "⚠ This issue was edited after triage began. The" echo "draft above reflects the body as of" echo "\`${snapshot_updated}\`; the current body may differ." echo "See \`input_snapshot.json\` in the workflow run for" echo "what the bot actually read." } >> /tmp/triage/comment.md echo "::notice::Issue edited during triage (snapshot=${snapshot_updated}, live=${live_updated}) — disclaimer appended" fi # Stage 9 — labels. Phase 3 adds the confirmed-duplicate path: # when the decision gate fired on reason_id=duplicate, apply # `triage: duplicate` and inherit the class label from the target # issue where resolvable (spec §Stage 9 table). Phase 2 held this # back because it needed Stage 6's exact/related rating. # # The 8a path assumes class=bug because only bug classifications # can reach 8a — duplicate-classified issues either land on the # duplicate gate (8b) or, when the reviewer rated `unrelated`, # fall through the remaining gates as a regular bug (spec §7), # at which point class=bug is the right inherited read. # # Skipped under dry_run — the step-summary table still shows # what would have been applied. - name: Apply labels if: inputs.dry_run != true env: GH_TOKEN: ${{ github.token }} REASON_ID: ${{ steps.decide.outputs.reason_id }} CLASSIFICATION: ${{ steps.classify.outputs.classification }} VARIANT: ${{ steps.decide.outputs.variant }} run: | classification="${CLASSIFICATION}" variant="${VARIANT}" if [[ "${variant}" == "8a" ]]; then triage_label="triage: investigated" class_label="bug" elif [[ "${variant}" == "8c" ]]; then triage_label="triage: investigated" class_label="enhancement" elif [[ "${REASON_ID}" == "duplicate" ]]; then triage_label="triage: duplicate" # Inherit class from the duplicate target's labels where # one of the four valid classes is present. Falls back to # empty when the target has none (rather than guessing). class_label="" if [[ -f /tmp/triage/duplicate-of.json \ && "$(cat /tmp/triage/duplicate-of.json)" != "null" ]]; then for c in bug enhancement documentation question; do if jq -e --arg c "${c}" \ '.labels[]? | select(.name == $c)' \ /tmp/triage/duplicate-of.json >/dev/null 2>&1; then class_label="${c}" break fi done fi else case "${classification}" in bug|enhancement|duplicate) triage_label="triage: needs-human" ;; question|needs-info) triage_label="triage: needs-info" ;; not-actionable) triage_label="triage: not-actionable" ;; *) triage_label="triage: needs-human" ;; esac case "${classification}" in bug|enhancement|question) class_label="${classification}" ;; *) class_label="" ;; esac fi priority_label=$(jq -r \ '.suggested_labels[]? | select(startswith("priority:"))' \ /tmp/triage/classification.json | head -1) if [[ -z "${priority_label}" ]]; then priority_label="priority: medium" fi if [[ "${priority_label}" == "priority: critical" ]]; then priority_label="priority: medium" fi apply_if_valid() { local candidate="$1" [[ -z "${candidate}" ]] && return 0 if jq -e --arg l "${candidate}" \ '.blocked_labels | any(. == $l)' \ .claude/scripts/taxonomies/label-blocklist.json \ >/dev/null; then echo "::notice::Label '${candidate}' blocked by blocklist" return 0 fi if ! jq -e --arg l "${candidate}" 'any(. == $l)' \ /tmp/triage/repo-labels.json >/dev/null; then echo "::notice::Label '${candidate}' not in repo label set" return 0 fi gh issue edit "${ISSUE_NUMBER}" \ --repo "${GITHUB_REPOSITORY}" \ --add-label "${candidate}" 2>/dev/null || true } gh issue edit "${ISSUE_NUMBER}" \ --repo "${GITHUB_REPOSITORY}" \ --add-label "${triage_label}" apply_if_valid "${class_label}" apply_if_valid "${priority_label}" mapfile -t categories < <(jq -r \ '.suggested_labels[]? | select(startswith("priority:") | not)' \ /tmp/triage/classification.json) for cat in "${categories[@]}"; do case "${cat}" in bug|enhancement|documentation|question) continue ;; triage:*) continue ;; esac apply_if_valid "${cat}" done - name: Post comment if: inputs.dry_run != true env: GH_TOKEN: ${{ github.token }} run: | gh issue comment "${ISSUE_NUMBER}" \ --repo "${GITHUB_REPOSITORY}" \ --body-file /tmp/triage/comment.md - name: Write step summary env: SUSPICIOUS: ${{ steps.suspicious.outputs.suspicious }} HAS_REGRESSION: ${{ steps.regression.outputs.has_regression }} CLASSIFICATION: ${{ steps.classify.outputs.classification }} CONFIDENCE: ${{ steps.classify.outputs.confidence }} DISAGREED: ${{ steps.doublecheck.outputs.disagreed }} VARIANT: ${{ steps.decide.outputs.variant }} REASON_TEXT: ${{ steps.reason.outputs.reason_text }} FINDINGS_TOTAL: ${{ steps.validate.outputs.findings_total }} FINDINGS_PASSED: ${{ steps.validate.outputs.findings_passed }} REVIEW_OK: ${{ steps.review.outputs.review_ok }} REVIEW_KEPT: ${{ steps.filter.outputs.review_findings_kept }} REVIEW_REJECTED: ${{ steps.filter.outputs.review_findings_rejected }} REVIEW_DOWNGRADED: ${{ steps.filter.outputs.review_findings_downgraded }} REVIEW_AVG: ${{ steps.filter.outputs.review_avg_confidence }} DUP_RATING: ${{ steps.filter.outputs.duplicate_of_rating }} DRIFT_DETECTED: ${{ steps.drift.outputs.drift_detected }} DRY_RUN: ${{ inputs.dry_run }} run: | { echo "## Triage v2 — Phase 4" echo "" if [[ "${DRY_RUN}" == "true" ]]; then echo "> ⚠ **Dry run** — no comment posted, no labels applied." echo "> Inspect the rendered comment under the uploaded \`comment.md\` artifact." echo "" fi echo "| Metric | Value |" echo "|---|---|" echo "| Issue | #${ISSUE_NUMBER} |" echo "| Dry run | ${DRY_RUN:-false} |" echo "| Suspicious-input tripped | ${SUSPICIOUS:-false} |" echo "| Classification | ${CLASSIFICATION:-n/a (scan blocked)} |" echo "| Confidence | ${CONFIDENCE:-n/a} |" echo "| Doublecheck disagreed | ${DISAGREED:-n/a} |" echo "| Version drift | ${DRIFT_DETECTED:-n/a} |" echo "| regression_of validated | ${HAS_REGRESSION:-n/a} |" echo "| Findings proposed | ${FINDINGS_TOTAL:-0} |" echo "| Findings passed mechanical | ${FINDINGS_PASSED:-0} |" echo "| Review call succeeded | ${REVIEW_OK:-n/a} |" echo "| Findings approve+downgrade (kept) | ${REVIEW_KEPT:-n/a} |" echo "| Findings rejected by reviewer | ${REVIEW_REJECTED:-n/a} |" echo "| Findings downgraded | ${REVIEW_DOWNGRADED:-n/a} |" echo "| Avg confidence after review | ${REVIEW_AVG:-n/a} |" echo "| Duplicate_of rating | ${DUP_RATING:-n/a} |" echo "| Comment variant rendered | ${VARIANT} |" echo "| Deferral reason (if applicable) | ${REASON_TEXT:-n/a} |" } >> "$GITHUB_STEP_SUMMARY" - name: Upload artifacts if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: triage-v2-phase-4-issue-${{ needs.gate.outputs.issue_number }} path: /tmp/triage/ retention-days: 14