diff --git a/.claude/commands/pre-pr.md b/.claude/commands/pre-pr.md
new file mode 100644
index 000000000..42b769dc9
--- /dev/null
+++ b/.claude/commands/pre-pr.md
@@ -0,0 +1,24 @@
+---
+description: Run the full pre-PR gate (pnpm all) and self-review the diff against the component checklist
+allowed-tools: Read, Bash(pnpm all), Bash(pnpm run:*), Bash(pnpm exec:*), Bash(git status:*), Bash(git diff:*), Bash(git log:*)
+---
+
+# /pre-pr — gate + self-review before opening a PR
+
+1. Run `pnpm all` (build, typecheck, test+coverage, bundle:stats, lint,
+ format:check, storybook build). If it fails, report the failing task and
+ stop — fix before reviewing.
+2. Read the working diff (`git status`, `git diff main...HEAD` plus any
+ uncommitted changes). If the diff touches `bulma-ui/src` components,
+ stories, SCSS, or `skills/`, review it against the checklist in
+ `CONTRIBUTING-COMPONENTS.md` (repo root — Read it), focusing on what CI
+ can NOT catch:
+ - prop-level changes without matching story/docs/skills updates
+ - missing listing surfaces (guide page section, homepage card — step 2)
+ - skills sync for themeable or shared-helper changes (step 3)
+ For diffs that don't touch components (docs-only, tooling, workflows),
+ SKIP the component checklist and just review the diff generally.
+ Always check commit message type/scope (`feat|fix|perf|refactor|style`
+ need scope `bulma-ui|docs|create-bestax`).
+3. Report a short summary: gate result, then each checklist gap with the
+ file to touch. Do not fix anything — this command only reviews.
diff --git a/.claude/commands/triage-dedupe.md b/.claude/commands/triage-dedupe.md
new file mode 100644
index 000000000..47ffac7d4
--- /dev/null
+++ b/.claude/commands/triage-dedupe.md
@@ -0,0 +1,93 @@
+---
+description: Find likely duplicates of an issue with 5 parallel search agents and post one triage comment
+allowed-tools: Task, Bash(gh issue view:*), Bash(gh issue list:*), Bash(gh pr list:*), Bash(gh search:*), Bash(gh issue comment:*)
+---
+
+# /triage-dedupe — duplicate search for an issue
+
+Find likely duplicates of the target issue and post EXACTLY ONE comment (or
+nothing — see pre-checks). Context comes from the caller (the ai-triage
+workflow prompt, or the user when run locally): `REPO` (owner/repo), `NUMBER`
+(the target issue), `TRIGGER` (`opened` or `labeled`; assume `labeled` when
+run locally), and `AUTOCLOSE` (`active` or `off`; assume `off` locally). If
+`NUMBER` is missing, ask — never guess.
+
+## Pre-checks (in order; each exit is SILENT — post nothing)
+
+1. `gh issue view NUMBER --repo REPO --json state,title,body,comments` — if
+ the issue is not open, stop.
+2. Marker check — does any existing comment by a claude bot account contain
+ ``?
+ - `TRIGGER=opened` and marker present → stop (already triaged).
+ - `TRIGGER=labeled` and marker present → continue; at the end REFRESH
+ that comment instead of posting a new one: if it is your most recent
+ comment on the issue, use
+ `gh issue comment NUMBER --repo REPO --edit-last --body ...`;
+ otherwise post a fresh comment (the old one stays as history).
+3. If the issue is too vague to search meaningfully (no error text, no
+ component or file name, no concrete behavior), stop.
+
+## Search — 5 parallel agents
+
+Summarize the issue first: symptoms, component/file names, API/prop names,
+exact error strings, and the general area. Then fan out FIVE Task agents in
+a single message, each pursuing ONE distinct strategy:
+
+1. **Error strings** — exact quoted messages, stack-trace lines, warning text.
+2. **Component + file names** — `Button`, `Modal`, `useConfig`, source paths.
+3. **API + prop names** — prop names, exported symbols, CSS class names.
+4. **Title keywords** — the strongest 2–4 words of the title, plus synonyms.
+5. **Broad area terms** — the general feature area (theming, SSR, a11y, docs).
+
+Rules for every agent:
+
+- EVERY search is scoped to this repository: `repo:REPO` in the query (or
+ the `--repo REPO` flag). Never search outside it.
+- Use `gh search issues`, `gh issue list --search`; include closed issues
+ (a duplicate of a closed issue is still a duplicate).
+- Return candidate numbers with title and a one-line justification each.
+
+## Filter pass (you, not the agents)
+
+Judge every candidate against the target: same root cause or clearly the
+same feature request = duplicate; same area or a blocking relationship =
+related. DROP weak keyword-only matches — an empty result beats a wrong one.
+Read the top candidates with `gh issue view` when unsure.
+
+## Post ONE comment
+
+Via `gh issue comment NUMBER --repo REPO --body ...` (or the refresh path
+from pre-check 2), formatted exactly like this:
+
+```markdown
+### AI triage
+
+**Likely duplicates**
+
+- #N —
:
+- (at most 3 entries, best match first)
+
+Duplicate of #N
+
+**Related** (optional, at most 3)
+
+- #M — :
+
+
+```
+
+- The line `Duplicate of #N` — with N the single best duplicate — must use
+ THIS EXACT FORMAT on its own line: the auto-close cron machine-parses it.
+ Include it only when you found at least one credible duplicate.
+- If `AUTOCLOSE=active`, add this sentence directly after the
+ `Duplicate of #N` line: "This issue may be auto-closed in 14 days unless
+ someone objects (comment or 👎)." If `AUTOCLOSE=off`, omit it.
+- The marker `` goes on its own line at the end.
+- No credible duplicates → post "No duplicates found." under the heading,
+ plus the marker, WITHOUT any `Duplicate of #N` line or auto-close notice.
+
+## Hard rules
+
+Never apply or remove labels; never close, merge, push, or resolve
+anything; never write "@claude" or "@coderabbitai" in any text; post at
+most one comment and nothing else.
diff --git a/.claude/commands/triage-find-duplicate-prs.md b/.claude/commands/triage-find-duplicate-prs.md
new file mode 100644
index 000000000..5965beb5e
--- /dev/null
+++ b/.claude/commands/triage-find-duplicate-prs.md
@@ -0,0 +1,66 @@
+---
+description: Find open PRs that duplicate a PR with 3 parallel search agents; silent when none found
+allowed-tools: Task, Bash(gh pr view:*), Bash(gh pr diff:*), Bash(gh pr list:*), Bash(gh search:*), Bash(gh pr comment:*)
+---
+
+# /triage-find-duplicate-prs — overlapping-work check for a PR
+
+Find OPEN, unmerged PRs that duplicate or overlap the target PR. SILENT by
+default: when nothing credible turns up, post NOTHING. Context from the
+caller: `REPO`, `NUMBER` (the target PR), `TRIGGER` (`opened`/`labeled`;
+assume `labeled` locally). If `NUMBER` is missing, ask.
+
+## Pre-checks (each exit is SILENT)
+
+1. `gh pr view NUMBER --repo REPO --json state,title,body,files,comments` —
+ if the PR is not open, stop.
+2. Marker check — a claude-bot comment containing
+ ``:
+ - `TRIGGER=opened` and marker present → stop.
+ - `TRIGGER=labeled` and marker present → continue; at the end refresh
+ that comment (`gh pr comment NUMBER --repo REPO --edit-last --body ...`
+ if it is your most recent comment on the PR; otherwise post fresh).
+
+## Search — 3 parallel agents
+
+Read the PR title, body, and changed file list, then fan out THREE Task
+agents in a single message, one strategy each:
+
+1. **Changed files** — other PRs touching the same components or paths.
+2. **Title + body keywords** — the strongest words plus synonyms.
+3. **Feature/fix intent** — what the PR accomplishes, phrased differently.
+
+Rules for every agent: EVERY search scoped
+`repo:REPO is:pr is:open is:unmerged` (via `gh search prs` or
+`gh pr list --search`); exclude the target PR itself and draft PRs; return
+numbers + titles + a one-line justification.
+
+## Filter pass
+
+A duplicate solves the same problem or implements the same feature — same
+files alone is NOT enough. Compare diffs (`gh pr diff`) when unsure. Drop
+weak matches. Zero credible duplicates: on `TRIGGER=opened`, stop SILENTLY
+— no comment, no marker; on `TRIGGER=labeled` (an explicit human request),
+post/refresh the comment with the single line "No duplicate PRs found."
+plus the marker (matches the pre-check refresh path).
+
+## Post ONE comment (when duplicates exist, or on a labeled rerun)
+
+Via `gh pr comment NUMBER --repo REPO --body ...` (or the refresh path):
+
+```markdown
+### AI triage — possible duplicate PRs
+
+- #N — : (at most 3, best match first)
+
+
+```
+
+The marker `` goes on its own line at
+the end.
+
+## Hard rules
+
+Never apply or remove labels; never close, merge, push, or resolve
+anything; never write "@claude" or "@coderabbitai" in any text; post at
+most one comment, and none at all when nothing was found.
diff --git a/.claude/commands/triage-find-issues.md b/.claude/commands/triage-find-issues.md
new file mode 100644
index 000000000..2e8b2b4aa
--- /dev/null
+++ b/.claude/commands/triage-find-issues.md
@@ -0,0 +1,78 @@
+---
+description: Find open issues a PR likely resolves with 5 parallel search agents and post one triage comment
+allowed-tools: Task, Bash(gh pr view:*), Bash(gh pr diff:*), Bash(gh issue view:*), Bash(gh issue list:*), Bash(gh search:*), Bash(gh pr comment:*)
+---
+
+# /triage-find-issues — link a PR to the issues it resolves
+
+Find OPEN issues the target PR likely resolves or relates to, and post
+EXACTLY ONE comment (or nothing). Context from the caller: `REPO`, `NUMBER`
+(the target PR), `TRIGGER` (`opened`/`labeled`; assume `labeled` locally),
+`AUTOCLOSE` (unused here). If `NUMBER` is missing, ask.
+
+## Pre-checks (each exit is SILENT)
+
+1. `gh pr view NUMBER --repo REPO --json state,title,body,comments` — if the
+ PR is not open, stop.
+2. Marker check — a claude-bot comment containing
+ ``:
+ - `TRIGGER=opened` and marker present → stop.
+ - `TRIGGER=labeled` and marker present → continue; at the end refresh
+ that comment (`gh pr comment NUMBER --repo REPO --edit-last --body ...`
+ if it is your most recent comment on the PR; otherwise post fresh).
+3. Note every issue already referenced in the PR body (`#N`, `Fixes #N`,
+ `Closes #N`, full URLs) — those are EXCLUDED from the results.
+
+## Search — 5 parallel agents
+
+Read the PR title, body, and `gh pr diff NUMBER --repo REPO` (never check
+out or execute its code). Extract signals, then fan out FIVE Task agents in
+a single message, one strategy each:
+
+1. **Error strings** — messages or test names the diff fixes or touches.
+2. **Component + file names** — components and paths the diff changes.
+3. **API + prop names** — props, exports, and CSS classes the diff touches.
+4. **Title keywords** — the strongest words of the PR title, plus synonyms.
+5. **Broad area terms** — the feature area of the change.
+
+Rules for every agent: scope EVERY search to this repository
+(`repo:REPO` or `--repo REPO`); OPEN issues only (`is:open` /
+`--state open`); return numbers + titles + a one-line justification.
+
+## Filter pass
+
+Keep only issues this PR plausibly resolves (the diff addresses the issue's
+root cause) or directly relates to. Exclude everything already linked in
+the PR body (pre-check 3). Drop weak keyword-only matches. If nothing
+credible remains: on `TRIGGER=opened`, stop SILENTLY — no comment, no
+marker; on `TRIGGER=labeled` (an explicit human request — silence would
+read as a malfunction), post/refresh the comment with the single line
+"No open issues found that this PR resolves." plus the marker (matches
+pre-check 2's refresh path).
+
+## Post ONE comment
+
+Via `gh pr comment NUMBER --repo REPO --body ...` (or the refresh path):
+
+````markdown
+### AI triage — issues this PR may resolve
+
+- #N — : (best match first, at most 5)
+
+If this PR resolves one of these, add the line below to the PR description
+so the issue closes on merge:
+
+```
+Fixes #N
+```
+
+
+````
+
+The marker `` goes on its own line at the end.
+
+## Hard rules
+
+Never apply or remove labels; never close, merge, push, or resolve
+anything; never edit the PR body yourself — only suggest; never write
+"@claude" or "@coderabbitai" in any text; post at most one comment.
diff --git a/.github/workflows/ai-triage.yml b/.github/workflows/ai-triage.yml
index 619c6edde..e12ccc04e 100644
--- a/.github/workflows/ai-triage.yml
+++ b/.github/workflows/ai-triage.yml
@@ -1,156 +1,298 @@
name: AI Triage
-# Opt-in, label-triggered triage — an adaptation of oven-sh/bun's always-on
-# claude-find-issues-for-pr.yml + claude-dedupe-issues.yml. A maintainer
-# applies the `ai-triage` label to a PR or issue; one cheap sonnet session
-# searches for related issues / likely duplicates and posts a single summary
-# comment; the label is then auto-removed so it works like a re-pressable
-# button.
+# AI triage v2 — fan-out duplicate/related search on issues and PRs. An
+# adaptation of oven-sh/bun's claude-dedupe-issues.yml +
+# claude-find-issues-for-pr.yml, budget-capped for a metered Claude plan.
+# One sonnet session per run drives the repo's triage slash commands
+# (.claude/commands/triage-*.md): issues get /triage-dedupe; PRs get
+# /triage-find-issues + /triage-find-duplicate-prs. Each command fans out
+# parallel read-only search sub-agents and posts at most ONE marker-tagged
+# comment, so re-runs are idempotent.
#
-# SECURITY — two layers gate every run:
-# 1. Job `if:` requires the `ai-triage` label from a real user
-# (GitHub already restricts labeling to triage+ collaborators).
-# 2. A live permission re-check step requires admin/maintain/write/triage,
-# so an automated or stale-association label can never spend usage.
-# This never checks out PR head code — only the base repo's default branch —
-# so `pull_request_target` is safe here. The Claude tool allowlist is
-# deliberately GET-only gh commands (view/diff/list/search) plus the two
-# comment commands — no `gh api`, which could also POST/PATCH/DELETE —
-# because the session ingests untrusted issue/PR text.
+# MODES (repo variable AI_TRIAGE_MODE; string-compared, unset ⇒ `label`):
+# auto | `opened` events triage automatically (budget-capped below);
+# | the ai-triage label works too
+# label | opt-in only — the label is the sole trigger (today's behavior)
+# off | everything dead, label included
#
-# Usage posture: one sonnet session per label application, always human-gated.
-# Kill switch: repo variable AI_LOOP_ENABLED=false.
+# BUDGET (auto runs only; label runs are exempt — already human-metered by
+# the click): a repo-wide daily counter kept as ONE marker comment on
+# tracking issue #290 (``),
+# edited in place. Same fail-closed pattern as claude-pr-loop.yml's
+# iteration cap: the probe is author-filtered to github-actions[bot] so
+# commenters cannot forge or reset the count; a new UTC date resets it; a
+# present-but-unparsable marker counts as AT-CAP; the count is incremented
+# BEFORE the Claude session starts, so crashed runs still spend budget.
+# Limit from AI_TRIAGE_DAILY_LIMIT (unset ⇒ 10). At/over limit ⇒ silent
+# skip, zero usage. The counter is read-modify-write, so its correctness
+# DEPENDS on the global single-flight concurrency group below — do not
+# re-scope the group per item.
#
-# Label to create at rollout: `ai-triage`, color #0e8a16, description
+# AI_TRIAGE_AUTOCLOSE (`on` / `dry-run` / unset ⇒ `off`) is passed through
+# to the session: when active, dedupe comments that name a duplicate include
+# the 14-day auto-close notice consumed by the auto-close cron (PR B of
+# #289), which parses the `Duplicate of #N` line those comments carry.
+#
+# SECURITY:
+# - labeled runs re-verify the labeler's LIVE role (triage+) via the
+# collaborators API, so an automated or stale-association label can
+# never spend usage.
+# - auto runs skip bot authors (bestaxbot, *[bot]), claude/* heads, and
+# PRs whose body already links an issue — AI-loop PRs self-link by
+# construction, so auto-triage would only duplicate the loop.
+# - This never checks out PR head code — only the base repo's default
+# branch — so `pull_request_target` is safe here. The Claude tool
+# allowlist is GET-only gh commands + the two comment commands + Task
+# (the fan-out sub-agents) — no `gh api`, which could also
+# POST/PATCH/DELETE, and no file-editing tools, because the session
+# ingests untrusted issue/PR text.
+#
+# KILL SWITCHES: AI_TRIAGE_MODE=off (this workflow only) or
+# AI_LOOP_ENABLED=false (all Claude spend, repo-wide).
+#
+# Label (exists since v1): `ai-triage`, color #0e8a16, description
# "Run one-shot AI triage: related issues + duplicates (sonnet; triage+ only)".
on:
pull_request_target:
- types: [labeled]
+ types: [opened, labeled]
issues:
- types: [labeled]
+ types: [opened, labeled]
# No workflow-level grants; the single job takes exactly what it needs.
permissions: {}
+# GLOBAL single-flight — every run (any item, any event) shares ONE group,
+# so runs queue instead of overlapping. This is load-bearing for the daily
+# BUDGET above: the counter on the tracking issue is read-modify-write, and
+# a per-item group would let concurrent `opened` runs race it (two runs read
+# the same count and both charge it to the same value, or the first run of a
+# UTC day is duplicated into two marker comments). Queuing a burst is exactly
+# what a budget wants; label runs queue behind auto runs too — acceptable,
+# they're short. cancel-in-progress stays false so queued work isn't lost.
concurrency:
- group: ai-triage-${{ github.event.pull_request.number || github.event.issue.number }}
+ group: ai-triage
cancel-in-progress: false
+env:
+ # The pinned budget-counter issue. The gate step PATCHes one
+ # github-actions[bot] marker comment on it per UTC day.
+ TRACKING_ISSUE: '290'
+
jobs:
triage:
- # Layer 1 (cheap pre-gate): only the ai-triage label, applied by a real
- # user, with the system enabled. Anything else is a silent no-op that
- # spends no usage.
+ # Layer 1 (cheap pre-gate, event shapes only). GitHub renders an UNSET
+ # variable as the empty string, so the expression is designed around '':
+ # - unset AI_TRIAGE_MODE ⇒ '' != 'off' keeps the labeled path live,
+ # while '' == 'auto' is false and kills the opened path ⇒ the
+ # default is label-only.
+ # - 'off' fails the != 'off' term ⇒ both paths dead.
+ # - 'auto' ⇒ both paths live.
+ # labeled events additionally require the ai-triage label from a real
+ # user; anything else is a silent no-op that spends no usage.
if: |
- github.event.label.name == 'ai-triage' &&
- github.event.sender.type == 'User' &&
- vars.AI_LOOP_ENABLED == 'true'
+ vars.AI_LOOP_ENABLED == 'true' &&
+ vars.AI_TRIAGE_MODE != 'off' &&
+ (
+ (github.event.action == 'labeled' &&
+ github.event.label.name == 'ai-triage' &&
+ github.event.sender.type == 'User') ||
+ (github.event.action == 'opened' &&
+ vars.AI_TRIAGE_MODE == 'auto')
+ )
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read # checkout of the base default branch
pull-requests: write # post the triage comment on PRs; remove the label
- issues: write # post the triage comment on issues; remove the label
+ issues: write # post the triage comment on issues; budget marker; remove the label
id-token: write # OIDC exchange for the Claude OAuth token
steps:
- # Layer 2 (authoritative): GitHub already restricts labeling to triage+;
- # re-verify the labeler's LIVE role defensively so an automated or
- # template-applied label can never trigger a session. All later steps
- # gate on this output.
- - name: Check labeler permission
- id: perm
+ # Layer 2 (authoritative, zero Claude usage). Two shapes:
+ # labeled → GitHub already restricts labeling to triage+; re-verify
+ # the labeler's LIVE role defensively so an automated or
+ # template-applied label can never trigger a session.
+ # Budget is BYPASSED — each run is human-metered.
+ # opened → skip bot authors, AI-loop PRs (claude/* heads or bodies
+ # that already link an issue), then charge the daily
+ # budget BEFORE any Claude spend.
+ # All later steps gate on `run`.
+ - name: Gate (permissions / budget)
+ id: gate
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
+ ACTION: ${{ github.event.action }}
SENDER: ${{ github.event.sender.login }}
+ IS_PR: ${{ github.event.pull_request != null }}
+ NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
+ LIMIT: ${{ vars.AI_TRIAGE_DAILY_LIMIT }}
run: |
set -euo pipefail
- ROLE=$(gh api "repos/$REPO/collaborators/$SENDER/permission" \
- --jq '.role_name' || echo none)
- case "$ROLE" in
- admin|maintain|write|triage)
- echo "allowed=true" >> "$GITHUB_OUTPUT"
- echo "$SENDER has $ROLE access — running triage" ;;
- *)
- echo "allowed=false" >> "$GITHUB_OUTPUT"
- echo "$SENDER lacks triage access ($ROLE) — ignoring" ;;
+ out() { echo "$1=$2" >> "$GITHUB_OUTPUT"; }
+ out is_pr "$IS_PR"
+ out number "$NUMBER"
+
+ if [ "$ACTION" = "labeled" ]; then
+ ROLE=$(gh api "repos/$REPO/collaborators/$SENDER/permission" \
+ --jq '.role_name' || echo none)
+ case "$ROLE" in
+ admin|maintain|write|triage)
+ out run true
+ echo "$SENDER has $ROLE access — running triage (budget exempt)" ;;
+ *)
+ out run false
+ echo "$SENDER lacks triage access ($ROLE) — ignoring" ;;
+ esac
+ exit 0
+ fi
+
+ # ---- opened event ----
+ # Bot authors never trigger auto-triage: the AI loop's own PRs and
+ # any app-created items would otherwise burn budget on themselves.
+ case "$SENDER" in
+ bestaxbot|*\[bot\])
+ out run false
+ echo "bot author ($SENDER) — skipping auto-triage"
+ exit 0 ;;
esac
+ if [ "$IS_PR" = "true" ]; then
+ PR_JSON=$(gh pr view "$NUMBER" --repo "$REPO" --json headRefName,body)
+ HEAD_REF=$(jq -r .headRefName <<<"$PR_JSON")
+ case "$HEAD_REF" in
+ claude/*)
+ out run false
+ echo "AI-loop head branch ($HEAD_REF) — skipping"
+ exit 0 ;;
+ esac
+ if jq -r '.body // ""' <<<"$PR_JSON" | grep -qE '(Fixes|Closes) #'; then
+ out run false
+ echo "PR body already links an issue (Fixes/Closes) — skipping"
+ exit 0
+ fi
+ fi
+
+ # ---- Daily budget (single marker comment on the tracking issue,
+ # edited in place). Author-filtered probe over ALL comment
+ # pages (--paginate applies --jq per page, so a second
+ # `jq -s` slurp picks the overall LAST match) — the marker
+ # can never paginate off page 1 and silently reset the cap.
+ # A present-but-unparsable marker fails CLOSED (counts as
+ # at-cap) rather than resetting to 0. ----
+ TODAY=$(date -u +%F)
+ LIMIT="${LIMIT:-10}"
+ STATE=$(gh api --paginate "repos/$REPO/issues/$TRACKING_ISSUE/comments?per_page=100" --jq \
+ '.[] | select((.user.login == "github-actions[bot]")
+ and (.body | startswith(".*/\1/p')
+ MCOUNT=$(jq -r .body <<<"$STATE" \
+ | sed -n 's/^.*/\2/p')
+ if [ -z "$MDATE" ] || [ -z "$MCOUNT" ]; then
+ out run false
+ echo "budget marker present but unparsable — failing closed (treated as at-cap)"
+ exit 0
+ fi
+ # A marker from a previous UTC day resets the count to 0.
+ if [ "$MDATE" = "$TODAY" ]; then COUNT="$MCOUNT"; fi
+ fi
+ if [ "$COUNT" -ge "$LIMIT" ]; then
+ out run false
+ echo "daily auto-triage budget spent ($COUNT/$LIMIT for $TODAY) — skipping; label runs still work"
+ exit 0
+ fi
+
+ # ---- Charge the budget BEFORE Claude runs (crashes still count) ----
+ NEXT=$((COUNT + 1))
+ BODY="
+ **AI triage budget**: $NEXT/$LIMIT auto-triggered session(s) on $TODAY (UTC). Label-triggered runs are exempt.
+ _Managed by ai-triage.yml — never reformat the first line; a new UTC day resets the count._"
+ if [ -n "$COMMENT_ID" ]; then
+ gh api "repos/$REPO/issues/comments/$COMMENT_ID" -X PATCH -f body="$BODY" >/dev/null
+ else
+ gh api "repos/$REPO/issues/$TRACKING_ISSUE/comments" -f body="$BODY" >/dev/null
+ fi
+ echo "budget charged: $NEXT/$LIMIT for $TODAY — running triage"
+ out run true
+
# Check out ONLY the base repo's default branch — never the PR head.
# Under pull_request_target that keeps untrusted PR code out of the
- # workspace; the triage agent reads PR content via read-only gh commands.
- # No pnpm/node setup and no dependency install: this job never runs
- # project code, it only gives the Claude action a workspace.
+ # workspace; the triage agent reads PR content via read-only gh
+ # commands. The checkout also provides the .claude/commands/ triage
+ # commands the session runs. No pnpm/node setup and no dependency
+ # install: this job never runs project code.
- name: Checkout base default branch
- if: steps.perm.outputs.allowed == 'true'
+ if: steps.gate.outputs.run == 'true'
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false
- name: Run Claude (triage)
- if: steps.perm.outputs.allowed == 'true'
+ if: steps.gate.outputs.run == 'true'
uses: anthropics/claude-code-action@51c47629174b5dbca955cf93690fd39c8a41dadf # v1
with:
- # OAuth token only — posts as claude[bot]. Safe: this is a plain
- # top-level comment; the loop's state machine only classifies
+ # OAuth token only — posts as claude[bot]. Safe: these are plain
+ # top-level comments; the loop's state machine only classifies
# review threads.
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
+ # Task is required for the commands' fan-out search sub-agents
+ # (they inherit this same allowlist). Still no `gh api` and no
+ # Edit/Write — the session ingests untrusted issue/PR text.
claude_args: |
- --max-turns 30
+ --max-turns 50
--model claude-sonnet-5
- --allowedTools "Bash(gh pr view:*),Bash(gh pr diff:*),Bash(gh issue view:*),Bash(gh issue list:*),Bash(gh pr list:*),Bash(gh search:*),Bash(gh pr comment:*),Bash(gh issue comment:*)"
+ --allowedTools "Task,Bash(gh pr view:*),Bash(gh pr diff:*),Bash(gh issue view:*),Bash(gh issue list:*),Bash(gh pr list:*),Bash(gh search:*),Bash(gh pr comment:*),Bash(gh issue comment:*)"
prompt: |
REPO: ${{ github.repository }}
- NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
- IS_PR: ${{ github.event.pull_request != null }}
-
- You are a one-shot TRIAGE agent. A maintainer applied the
- `ai-triage` label to the PR or issue above; your entire job is to
- find related and duplicate items and post EXACTLY ONE comment.
-
- 1. Read the target:
- - If IS_PR is true: `gh pr view NUMBER --repo REPO` for the
- title/body, then `gh pr diff NUMBER --repo REPO` for a
- summary of what it changes. Do NOT check out or execute
- any of its code.
- - If IS_PR is false: `gh issue view NUMBER --repo REPO` for
- the title/body and existing comments.
- 2. Search THIS repo for connections, using several keyword
- variations from the title, body, and (for PRs) the changed
- file paths — `gh search issues`, `gh search prs`,
- `gh issue list`, and `gh pr list`. Look for:
- - If a PR: (a) existing issues this PR likely resolves or
- relates to (especially ones it should say "Closes #N" for),
- and (b) other PRs — open or recently closed — that look like
- duplicates or overlapping work.
- - If an issue: likely duplicate issues (open or closed) and
- closely related issues or PRs.
- 3. Judge each candidate before including it: same root cause or
- clearly the same feature = duplicate; same area or blocking
- relationship = related. Drop weak keyword-only matches.
- 4. Post EXACTLY ONE comment — `gh pr comment` if IS_PR is true,
- `gh issue comment` otherwise — formatted as scannable
- Markdown:
- - A short heading, e.g. `### AI triage`.
- - A `**Likely duplicates**` list and/or a `**Related**` list,
- each entry as `#N — ` plus a one-line reason why it
- qualifies.
- - If nothing credible turns up, post the single line
- "No related issues or duplicates found." under the heading
- instead.
- 5. HARD RULES: never apply or remove labels; never close, merge,
- push, or resolve anything; never write "@claude" or
- "@coderabbitai" in any text; post one comment and nothing
- else.
-
- # Always remove the trigger label — even when the Claude step failed or
- # the permission re-check errored — so the label stays a re-pressable
- # button and never wedges "on". Removal uses only GITHUB_TOKEN, so an
- # unauthorized labeling still spends zero Claude usage.
+ NUMBER: ${{ steps.gate.outputs.number }}
+ IS_PR: ${{ steps.gate.outputs.is_pr }}
+ TRIGGER: ${{ github.event.action }}
+ AUTOCLOSE: ${{ (vars.AI_TRIAGE_AUTOCLOSE == 'on' || vars.AI_TRIAGE_AUTOCLOSE == 'dry-run') && 'active' || 'off' }}
+
+ You are the TRIAGE agent for this repository. Your entire job is
+ to run this repo's triage slash commands against the item above:
+
+ - If IS_PR is false: run the /triage-dedupe command.
+ - If IS_PR is true: run /triage-find-issues, then
+ /triage-find-duplicate-prs.
+
+ The command definitions live in .claude/commands/ in this
+ checkout (triage-dedupe.md, triage-find-issues.md,
+ triage-find-duplicate-prs.md). Read the matching file(s) and
+ follow them exactly — they consume the REPO / NUMBER / TRIGGER /
+ AUTOCLOSE context lines above.
+
+ Idempotency (the commands restate this; it is binding):
+ - TRIGGER is opened: if a command's marker comment already
+ exists on the item, that command exits silently — post
+ nothing for it.
+ - TRIGGER is labeled: a maintainer explicitly re-ran triage —
+ refresh your existing marker comment in place
+ (`gh issue comment`/`gh pr comment` with `--edit-last`)
+ instead of posting a duplicate, per the command's rules.
+
+ AUTOCLOSE is active: a dedupe comment that names a duplicate
+ MUST include the 14-day auto-close notice (exact wording in the
+ command). AUTOCLOSE is off: omit that notice entirely.
+
+ HARD RULES: never apply or remove labels; never close, merge,
+ push, or resolve anything; never write "@claude" or
+ "@coderabbitai" in any text; keep every search scoped to REPO;
+ post nothing beyond what the commands specify.
+
+ # Always remove the trigger label on labeled runs — even when the
+ # Claude step failed or the gate said no — so the label stays a
+ # re-pressable button and never wedges "on". Removal uses only
+ # GITHUB_TOKEN, so an unauthorized labeling still spends zero Claude
+ # usage. Opened runs have no label to remove.
- name: Remove ai-triage label
- if: always()
+ if: always() && github.event.action == 'labeled'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
diff --git a/CLAUDE.md b/CLAUDE.md
index 2f86e4d56..f9d078d2f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -96,8 +96,11 @@ label. Kill switches: remove `ai-loop` (per PR) or set repo
variable `AI_LOOP_ENABLED=false` (whole system). The `` PR comment
is machine-managed — never reformat its first line. The loop refuses PRs that touch
`.github/**` or the jest/commitlint/release/pnpm-workspace configs.
-Separately, labeling any PR or issue `ai-triage` (triage+ only) runs a one-shot sonnet triage
-session that comments with related issues/duplicates and then removes the label.
+Separately, `ai-triage` runs a one-shot sonnet triage session that comments with related
+issues/duplicates: automatic on new issues/PRs when `AI_TRIAGE_MODE=auto` (capped at
+`AI_TRIAGE_DAILY_LIMIT`/day via a counter comment on issue #290), or on demand via the label
+(triage+ only, budget-exempt; auto-removed after the run). Flagged duplicates may be
+auto-closed after 14 days per `AI_TRIAGE_AUTOCLOSE` (see the ai-development docs guide).
Stale automation: PRs go `stale` at 30 days and close 14 days later — except Claude-assisted
PRs (`claude-assisted` label or bestaxbot author), which skip that sweep and instead close
after 90 days of inactivity; `neverstale` exempts a PR from both layers.
diff --git a/docs/docs/guides/getting-started/ai-development.md b/docs/docs/guides/getting-started/ai-development.md
index 67e8c0f89..e53f07d57 100644
--- a/docs/docs/guides/getting-started/ai-development.md
+++ b/docs/docs/guides/getting-started/ai-development.md
@@ -54,11 +54,26 @@ PRs authored by the loop move through a small label lifecycle:
| `needs-human-review` | PRs | The loop converged (or hit a disagreement) — awaiting maintainer review/merge |
| `ai-loop-paused` | PRs | The loop hit its iteration cap or a guardrail — a maintainer must intervene |
| `deep-review` | PRs | Opt-in: a triage+ user applies it to run the Claude deep review on any PR (re-apply to re-run) |
-| `ai-triage` | PRs & issues | Opt-in: a triage+ user applies it to run one-shot AI triage (related issues + duplicates); the label auto-removes after the run |
+| `ai-triage` | PRs & issues | Runs one-shot AI triage (related issues + duplicates): automatic on new issues/PRs in auto mode (daily budget), or applied by a triage+ user (budget-exempt; label auto-removes) |
| `claude-assisted` | PRs | Auto-applied provenance for AI-assisted PRs (bestaxbot or the Claude footer) |
| `stale` | PRs | Auto-applied after 30 days of inactivity; closes 14 days later unless activity resumes. Claude-assisted PRs skip this sweep — a separate closer sweeps them after 90 days instead |
| `neverstale` | PRs | Exempts a PR from all stale automation (both the 30/14-day sweep and the 90-day Claude-assisted closer) |
+### AI triage
+
+New issues and PRs can get an automatic triage comment: likely duplicates (issues), the open
+issues a PR probably resolves, and overlapping PRs. Three repository variables control it:
+
+- **`AI_TRIAGE_MODE`** — `auto` (new issues/PRs are triaged automatically and the label still
+ works), `label` (opt-in only; the default when unset), or `off` (disables both automatic
+ triage **and** the label).
+- **`AI_TRIAGE_DAILY_LIMIT`** — maximum auto-triggered triage sessions per UTC day (default
+ 10). Label-triggered runs are exempt — each one is already human-metered by the click.
+- **`AI_TRIAGE_AUTOCLOSE`** — `on`, `dry-run`, or `off` (the default). When active, an issue
+ whose triage comment names a `Duplicate of #N` is auto-closed after **14 days** — unless
+ anyone objects: a human comment after the triage comment, a 👎 reaction on it, or reopening
+ the issue each veto the close.
+
The loop itself: Claude implements the issue and opens the PR → CodeRabbit reviews it and a
second, independent Claude review (a stronger model than the implementer) does a deep pass →
Claude addresses every finding, fixing what's right and refuting what's wrong → both reviewers