AI triage v2: plan-then-approve, manual precedence, ready PRs

Redesign the AI issue-triage pipeline so it can fix confidently on its own,
ask for approval only when unsure, and always yield to manual actions — while
staying cheap and fast.

- Tier 2 (daily_ai_fix) becomes graded: high-confidence small fixes open a
  READY-for-review PR; anything medium/large gets a full Opus-written plan
  comment (ai:plan-pending) instead of a PR.
- New Tier 3 (on_issue_approved): maintainer adds ai:approved and the posted
  plan is executed on Opus into a ready PR — immediate, zero cost until asked.
- New @claude interactive workflow (on_claude_mention): maintainer-only,
  Sonnet-low, full precedence over the automated tiers.
- New CodeRabbit follow-up (on_pr_coderabbit): one-shot Sonnet pass that fixes
  or replies to CodeRabbit's review of an ai-fix/* PR.
- Tier 1 self-healing: a reporter's reply to a needs-info request re-runs
  classification exactly once (ai:needs-info); a daily catch-up re-dispatches
  any issue that never got triaged.
- Extras: global kill switch (repo var AI_DISABLED), stale-bot exemption for
  AI labels, shared ai_guard_paths.sh, track_progress on the Opus tiers,
  CLAUDE.md documentation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
alexbelgium
2026-07-24 07:08:01 +02:00
parent 141f7a9790
commit ddf06a7647
11 changed files with 777 additions and 87 deletions

57
.github/prompts/issue-execute-plan.md vendored Normal file
View File

@@ -0,0 +1,57 @@
# Execute an approved plan — tier 3
@alexbelgium reviewed an AI-written plan and approved it. Your job is to carry
that plan out and open a pull request. The plan was already accepted, so do not
re-litigate it — execute it. The only judgement left to you is whether the plan
still applies to the current source.
Read:
- `/tmp/ai-exec/plan.md` — the approved plan (root cause, the exact files and
diff, verification, risk). This is your spec.
- `/tmp/ai-exec/issue.json` — the issue it fixes (for `Closes #<n>` and context).
## Hard limits (identical to the fix sweep — a workflow step enforces them)
1. **Never modify `.github/` or `.templates/`.** Repo-wide infrastructure.
2. **Never touch the `version` or `upstream` fields in `config.yaml`.**
3. **One add-on, one branch:** `ai-fix/<addon>-<issue-number>`.
4. **Never merge, never close the issue, never enable auto-merge.** Open the
pull request **ready for review** — CI (`onpr_check-pr.yaml`) validates it,
a human ships it.
## Do this in order
1. **Apply the plan.** Make exactly the edits it describes. Match surrounding
style (bash / Dockerfile; conventions vary per add-on). Run `shellcheck` on
any shell you change. Add a `CHANGELOG.md` entry in the add-on's format.
2. **If the plan is stale** — the source moved since it was written and the diff
no longer applies cleanly:
- Small drift (a line shifted, a nearby rename): adapt minimally to achieve
the plan's stated intent, and note the deviation in the PR body.
- Large drift (the root cause or the target code is gone or now different):
stop. Do **not** guess a new fix. Comment on the issue explaining why the
plan no longer applies, relabel `ai:needs-human` (see step 5), open no PR.
3. **Open the pull request, ready for review.** Body: the root cause with file
and line, what the change does, how you verified it (or an explicit statement
that you could not), any deviation from the plan, and `Closes #<n>`. Note that
it was executed from an approved plan.
4. **Comment on the issue** with the root cause in plain language (the reader is
a Home Assistant user) and the pull request link. Close with a note that this
is automated analysis pending Alex's review.
5. **Relabel, as your last action:**
```
gh issue edit <n> --remove-label ai:approved --remove-label ai:plan-pending --add-label <result>
```
`<result>` is `ai:fixed` if you opened a PR, or `ai:needs-human` if the plan
was too stale to apply (step 2). A workflow step also strips the approval
labels afterwards and flags `ai:needs-human` if no PR resulted — treat that
as a bug in your run, not a safety net.
Keep the change within the spirit of the approved plan. If carrying it out
honestly requires substantially more than the plan described, that is a sign the
plan was wrong — stop and relabel `ai:needs-human` rather than expanding scope.

View File

@@ -7,6 +7,10 @@ application. You own the wrapper. You do not own the upstream app.
Read `/tmp/ai-fix/batch.json`. Work add-on by add-on, not issue by issue —
grouping is the point of the batch.
You are the Opus step of the pipeline: the diagnosis and, when a fix is not a
sure thing, the written plan. Getting the diagnosis right and being honest
about confidence matters more than the number of pull requests you open.
## Hard limits
These are not guidelines. A workflow step enforces them after you finish, and
@@ -19,27 +23,18 @@ anything that violates them gets blocked and flagged.
will not be around to resolve.
3. **One add-on per branch, one branch per pull request.** Branch name
`ai-fix/<addon>-<issue-number>`.
4. **Draft pull requests only.** Never merge, never mark ready for review,
never close an issue.
5. If the fix requires changing more than roughly 60 lines, or touching more
than three files, stop. Post the analysis, open no pull request, and say
plainly that the change is too large for an unattended fix.
6. **Relabel every issue before moving to the next one.** This sweep runs
daily over the same `ai-triage`-labelled backlog. An issue you have
already finished with must drop that label immediately — otherwise
tomorrow's sweep re-selects it, opens a second competing branch, and
burns another full read-the-source-and-fix pass on work that is already
done. Do this as your last action on the issue, right after commenting,
not batched at the end: `gh issue edit <n> --remove-label ai-triage
--add-label <replacement>`, where `<replacement>` is exactly one of:
- `ai:fixed` — you opened a draft pull request for it.
- `ai:upstream` — you reversed tier 1's call and the fault is upstream,
so no pull request.
- `ai:needs-human` — too large for an unattended fix (rule 5), or you
found no fix and are reporting what you ruled out.
A workflow step checks this after you finish and force-corrects to
`ai:needs-human` for anything still carrying `ai-triage` — treat that as
a bug in your run, not a safety net to lean on.
4. **Never merge, never close an issue, never enable auto-merge.** Opening a
pull request for review is as far as you go — a human ships it. CI
(`onpr_check-pr.yaml`: lint + Docker build) runs on every pull request you
open and is what actually validates the change.
5. **The small-fix ceiling is ~60 changed lines / 3 files.** A change under it,
*and* with a root cause you are confident of, may go out as a ready pull
request (Outcome A). Anything over it does not become a pull request — it
becomes a plan (Outcome B), however confident you are.
6. **Relabel every issue before moving to the next one** (see "Relabel", below).
This sweep runs daily over the same `ai-triage` backlog; an issue you have
finished with must drop that label immediately or tomorrow's sweep re-selects
it and burns another full pass on work already done.
## Per add-on, do this in order
@@ -50,41 +45,101 @@ suddenly usually has a commit behind it, and finding that commit is worth more
than reading the whole tree.
**2. Establish the root cause, and be honest about confidence.** Name the exact
file and line. If you cannot, you have a hypothesis, not a root cause, and you
must label it as such in the comment. Do not dress a guess up as a diagnosis.
Alex has to trust these comments without re-deriving them.
file and line. If you cannot, you have a hypothesis, not a root cause. Do not
dress a guess up as a diagnosis — Alex has to trust these without re-deriving
them. Your confidence in the root cause is what selects the outcome below.
**3. Re-check the upstream/wrapper split.** Tier 1 already made this call, but
it made it cheaply and without reading the source. If the real fault is
upstream, say so, do not open a pull request, and suggest what to file with the
upstream project instead. Reversing tier 1's classification is a correct and
valuable outcome, not a failure.
**3. Re-check the upstream/wrapper split.** Tier 1 made this call cheaply,
without reading the source. If the real fault is upstream, say so, open no pull
request, and suggest what to file upstream instead. Reversing tier 1 is a
correct and valuable outcome, not a failure.
**4. Fix it.** Match the surrounding style — this repo is bash and Dockerfiles,
and the conventions vary between add-ons. Run `shellcheck` on any shell you
change. Add a `CHANGELOG.md` entry in the add-on's existing format.
## Decide the outcome
**5. Open the draft pull request.** Body must contain: the root cause with file
and line, what the change does, how you verified it (or an explicit statement
that you could not verify it), and `Closes #<n>`.
Pick exactly one per issue. When you are between two, pick the more cautious
(A→B→D): a plan a human approves in one click costs far less trust than a wrong
pull request.
**6. Comment on the issue, then relabel it (hard limit 6).** Root cause, the
fix in one or two sentences, and the pull request link. Plain language — the
reader is a Home Assistant user, not a Go developer. If you found no fix, say
what you ruled out and what you would need to go further. Close with a note
that this is automated analysis pending Alex's review. Then remove
`ai-triage` and add the one replacement label that matches the outcome,
before starting the next issue.
### Outcome A — ready pull request → `ai:fixed`
**Only when both hold:** you named the root cause to an exact file and line and
are genuinely confident of it, **and** the fix is within the small-fix ceiling
(rule 5).
- Fix it. Match the surrounding style — this repo is bash and Dockerfiles, and
conventions vary between add-ons. Run `shellcheck` on any shell you change.
Add a `CHANGELOG.md` entry in the add-on's existing format.
- Open the pull request **ready for review** (not draft). Body: root cause with
file and line, what the change does, how you verified it (or an explicit
statement that you could not), and `Closes #<n>`.
### Outcome B — plan for approval → `ai:plan-pending`
**When you have a real diagnosis but** either your confidence is only moderate,
**or** the change is larger than the small-fix ceiling. Do **not** open a pull
request and do **not** commit anything.
Post one comment that begins with this exact marker on its own first line:
```
<!-- ai-plan -->
```
followed by a complete, executable plan:
- **Root cause** — the exact file and line, and why.
- **The change** — every file to edit and a diff sketch (before/after or a
fenced patch) precise enough that executing it needs no re-investigation.
- **Verification** — how a run should confirm the fix (build, shellcheck, the
behaviour to check).
- **Risk / why not automatic** — one line on what makes this uncertain or large.
End the comment with exactly:
> Add the `ai:approved` label to have this plan executed automatically, or reply
> with changes first. This is automated analysis pending @alexbelgium's review.
(Applying `ai:approved` triggers tier 3, `on_issue_approved.yaml`, which opens a
ready pull request from this plan on Opus. Nothing runs until Alex approves.)
### Outcome C — upstream → `ai:upstream`
The fault is in the upstream app or its base image, not the wrapper. Comment
the diagnosis and what to file upstream. No pull request, no plan.
### Outcome D — needs a human → `ai:needs-human`
You could not establish a root cause, or the issue is out of scope for an
unattended fix. Comment what you ruled out and what you would need to go
further. No pull request, no plan.
## Comment, then relabel (do this before the next issue)
Every issue gets a comment: root cause, and for A the fix in a sentence or two
plus the pull request link; for B the plan above; for C/D the analysis. Plain
language — the reader is a Home Assistant user, not a Go developer. Close A/C/D
comments with a note that this is automated analysis pending Alex's review.
Then, as your **last action on the issue**, relabel it:
```
gh issue edit <n> --remove-label ai-triage --add-label <replacement>
```
where `<replacement>` is exactly one of `ai:fixed`, `ai:plan-pending`,
`ai:upstream`, `ai:needs-human`, matching the outcome. A workflow step checks
this afterwards and force-corrects anything still carrying `ai-triage` to
`ai:needs-human` — treat that as a bug in your run, not a safety net.
## Meta-findings
This is the part a per-issue run cannot do, so do not skip it.
After the batch, look across everything you read. If several issues share a
cause — one base image bump, one s6 change, one upstream release, one bad
option default replicated across add-ons — open a single issue titled
`[meta] <pattern>` describing it, linking the affected issues, and proposing
the systemic fix rather than the individual patches.
cause — one base image bump, one s6 change, one upstream release, one bad option
default replicated across add-ons — open a single issue titled `[meta] <pattern>`
describing it, linking the affected issues, and proposing the systemic fix
rather than the individual patches.
Report honestly if the batch produced nothing. A sweep that fixes zero issues
and says so clearly is more useful than one that manufactures three plausible

49
.github/prompts/pr-coderabbit.md vendored Normal file
View File

@@ -0,0 +1,49 @@
# Address CodeRabbit's review — one-shot
CodeRabbit reviewed an AI-authored pull request. You are checked out on that
PR's branch (`ai-fix/<addon>-<issue>`). Your job, in a **single pass**: read
CodeRabbit's feedback and either fix each actionable point or reply saying why
it doesn't apply. This runs once — CodeRabbit re-reviewing your pushed fix will
not trigger you again.
The PR number is in your prompt. Set `PR` to it.
## Gather the feedback
- Review summaries and verdicts:
`gh pr view "$PR" --json reviews,title,body,files`
- Inline (line-level) comments — this is where CodeRabbit's concrete suggestions
live:
`gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR}/comments" --paginate`
Each has `path`, `line`, `body`, and an `id` (needed to reply).
Consider only comments authored by `coderabbitai[bot]`. Ignore its collapsed
"nitpick"/"outside diff" noise unless the point is real.
## Hard limits (a workflow step enforces the first)
1. **Never modify `.github/` or `.templates/`.** Repo-wide infrastructure.
2. **Never touch the `version` or `upstream` fields in `config.yaml`.**
3. **Stay within this PR's scope and branch.** Do not open a new PR, do not
touch other add-ons, do not merge, do not mark ready/draft.
## For each actionable comment
- **Fix it** when it's a real correctness, safety, or clarity improvement within
scope: make the minimal edit, run `shellcheck` on any shell you change, and if
behaviour changed update the add-on's `CHANGELOG.md` entry.
- **Decline it** when it's wrong, out of scope, or a style nit that fights the
add-on's conventions: reply to that specific comment with one sentence of
reasoning:
`gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR}/comments/<id>/replies" -f body='...'`
## Finish
- If you changed anything: stage, commit with a short message
(`fix: address CodeRabbit review`), and push to the PR branch
(`git push origin HEAD`).
- Post one summary comment on the PR (`gh pr comment "$PR" --body '...'`) listing
what you fixed and what you deliberately left, in plain language. End with a
note that this is automated and pending @alexbelgium's review.
- If nothing was actionable, post a one-line comment saying so and stop. Do not
invent changes to look busy.

53
.github/scripts/ai_guard_paths.sh vendored Executable file
View File

@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# Destination: .github/scripts/ai_guard_paths.sh
#
# Belt-and-braces enforcement of the one rule every AI fix prompt is told
# never to break: no add-on fix may touch `.github/` or `.templates/`, because
# those are inherited by all 100+ add-ons — a change there is a repo-wide
# incident, not a per-add-on fix. The prompts forbid it; this script is what
# actually enforces it after the model finishes, on the PRs it opened.
#
# Any AI PR that touches a protected path is pulled out of review (converted
# back to draft), labelled `ai:blocked`, and commented on. This is shared by
# every tier that can open or push to an `ai-fix/` PR (tiers 2 and 3, and the
# CodeRabbit follow-up) so the rule is defined and fixed in exactly one place.
#
# Env:
# GH_TOKEN (required) — token with pull-requests:write on REPO
# REPO (required) — owner/name
# PR_NUMBER (optional) — check only this PR; if unset, scan every open
# `ai-fix/` PR in the repo.
set -euo pipefail
: "${REPO:?REPO must be set}"
: "${GH_TOKEN:?GH_TOKEN must be set}"
PROTECTED='^(\.github/|\.templates/)'
if [ -n "${PR_NUMBER:-}" ]; then
PRS="$PR_NUMBER"
else
# gh pr list applies --limit before the headRefName filter, so a low cap
# could silently drop older ai-fix/ PRs once total open PRs (of any kind)
# grow past it. 300 is far above anything this repo runs; gh paginates.
PRS=$(gh pr list --repo "$REPO" --state open --limit 300 \
--json number,headRefName \
--jq '.[] | select(.headRefName|startswith("ai-fix/")) | .number')
fi
for pr in $PRS; do
[ -n "$pr" ] || continue
BAD=$(gh pr diff "$pr" --repo "$REPO" --name-only | grep -E "$PROTECTED" || true)
if [ -n "$BAD" ]; then
echo "::error::PR #$pr touches protected paths:"
echo "$BAD"
# Ensure the label exists before adding it — with set -e a missing
# label would abort the whole loop and skip any PRs behind this one.
gh label create "ai:blocked" --repo "$REPO" --color ededed >/dev/null 2>&1 || true
gh pr ready "$pr" --repo "$REPO" --undo || true
gh pr edit "$pr" --repo "$REPO" --add-label "ai:blocked"
gh pr comment "$pr" --repo "$REPO" --body \
"Blocked automatically: this PR modifies shared infrastructure (\`.github/\` or \`.templates/\`), which is inherited by every add-on in the repo. Needs manual review before it goes anywhere."
fi
done

View File

@@ -1,11 +1,28 @@
---
# Destination: .github/workflows/daily_ai_fix.yaml
#
# Tier 2. One Opus 4.8 run at xhigh over the whole batch of `ai-triage` issues,
# grouped by add-on, so it can spot the cross-issue patterns a per-issue run
# never sees ("these four reports are all the same base image bump"). Runs
# daily rather than weekly, so batches (default limit 8) stay small and any
# one day's failure doesn't block a week's worth of issues.
# Tier 2 of the AI triage system. One Opus 4.8 run at xhigh over the whole
# batch of `ai-triage` issues, grouped by add-on, so it can spot the cross-issue
# patterns a per-issue run never sees ("these four reports are all the same base
# image bump"). Runs daily rather than weekly, so batches (default limit 8) stay
# small and any one day's failure doesn't block a week's worth of issues.
#
# This is the Opus step — root-cause diagnosis and, for anything not clearly a
# small confident fix, the written plan. Per issue it produces one of:
# * high certainty + small change -> a READY-for-review PR (ai:fixed)
# * medium certainty, or too large -> a full plan comment, no PR (ai:plan-pending)
# Alex then adds `ai:approved` and on_issue_approved.yaml (tier 3) executes
# the plan. This is the "create the full plan, ask for approval only when
# not sure" path — cheap to gate, immediate once approved.
# * fault is upstream / no fix -> analysis only (ai:upstream / ai:needs-human)
#
# Full tier map:
# Tier 1 on_issues_ai_triage.yaml Sonnet-low classify on issue open
# Tier 2 daily_ai_fix.yaml (this) Opus-xhigh daily fix/plan sweep
# Tier 3 on_issue_approved.yaml Opus-high execute an approved plan
# @claude on_claude_mention.yml Sonnet-low maintainer-only interactive
# PR on_pr_coderabbit.yml Sonnet-low one-shot CodeRabbit follow-up
# Kill switch: set repo variable AI_DISABLED=true to pause every AI workflow.
#
# Auth:
# Claude Pro/Max subscription via the CR_PAT GitHub Environment, which
@@ -41,6 +58,9 @@ concurrency:
jobs:
sweep:
# Global kill switch: set repo variable AI_DISABLED=true to pause the sweep
# (and every other AI workflow) without editing any file.
if: ${{ vars.AI_DISABLED != 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 180
environment: CR_PAT
@@ -100,7 +120,7 @@ jobs:
REPO: ${{ github.repository }}
run: |
set -euo pipefail
for l in ai:fixed ai:upstream ai:needs-human ai:blocked; do
for l in ai:fixed ai:plan-pending ai:approved ai:upstream ai:needs-human ai:blocked; do
gh label create "$l" --repo "$REPO" --color ededed --force >/dev/null 2>&1 || true
done
@@ -109,6 +129,9 @@ jobs:
uses: anthropics/claude-code-action@44423bdec74b97d67543eb16c110546762c110b2 # v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# One sticky, auto-updating status comment per run instead of the
# model narrating its own progress in scattered comments.
track_progress: true
prompt: |
The batch of issues to work through is /tmp/ai-fix/batch.json.
Follow .github/prompts/issue-fix.md exactly. Do not deviate from
@@ -147,29 +170,12 @@ jobs:
fi
done
# Belt and braces. The prompt forbids these paths; this enforces it.
# Belt and braces. The prompt forbids .github/ and .templates/; this
# enforces it over every open ai-fix/ PR (shared by tiers 2, 3 and the
# CodeRabbit follow-up, so the rule lives in exactly one place).
- name: Guard forbidden paths
if: always() && steps.batch.outputs.count != '0'
env:
GH_TOKEN: ${{ secrets.AI_PR_TOKEN }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
# gh pr list applies --limit before the headRefName filter below, so
# a low cap could silently drop older ai-fix/ PRs from the check
# once total open PRs (of any kind) grow past it. 300 is far above
# anything this repo runs today; gh paginates to satisfy it.
PRS=$(gh pr list --repo "$REPO" --state open --limit 300 \
--json number,headRefName \
--jq '.[] | select(.headRefName|startswith("ai-fix/")) | .number')
for pr in $PRS; do
BAD=$(gh pr diff "$pr" --repo "$REPO" --name-only \
| grep -E '^(\.github/|\.templates/)' || true)
if [ -n "$BAD" ]; then
echo "::error::PR #$pr touches protected paths:"; echo "$BAD"
gh pr ready "$pr" --repo "$REPO" --undo || true
gh pr edit "$pr" --repo "$REPO" --add-label "ai:blocked"
gh pr comment "$pr" --repo "$REPO" --body \
"Blocked automatically: this PR modifies shared infrastructure (\`.github/\` or \`.templates/\`), which is inherited by every add-on in the repo. Needs manual review before it goes anywhere."
fi
done
run: bash .github/scripts/ai_guard_paths.sh

View File

@@ -29,5 +29,8 @@ jobs:
stale-pr-label: 'stale'
days-before-stale: '5'
days-before-close: '2'
exempt-issue-labels: 'prevent stale'
# AI triage labels are exempt: an issue waiting on @alexbelgium's
# `ai:approved`, or queued for the tier-2 sweep, must not be auto-closed
# out from under the pipeline before it is acted on.
exempt-issue-labels: 'prevent stale,ai-triage,ai:plan-pending,ai:needs-info,ai:needs-human'
remove-stale-when-updated: true

77
.github/workflows/on_claude_mention.yml vendored Normal file
View File

@@ -0,0 +1,77 @@
---
# Destination: .github/workflows/on_claude_mention.yml
#
# Interactive @claude — the maintainer's manual override, always taking
# precedence over the automated tiers. Mention @claude in an issue, an issue
# comment, or a PR review/comment and Claude acts on that thread: answer a
# question, investigate, make a small change and open a PR. Runs on Sonnet-low
# to stay cheap; for anything large or uncertain it hands off to the ai:approved
# / tier-2 path rather than grinding.
#
# Hard-gated to @alexbelgium: only the maintainer's mentions trigger it. This is
# the official claude-code-action "tag mode" (no `prompt:` input).
#
# Auth: CR_PAT environment (CLAUDE_CODE_OAUTH_TOKEN) + AI_PR_TOKEN as github_token
# so any PR/push it makes triggers onpr_check-pr.yaml.
# Kill switch: repo variable AI_DISABLED=true pauses this (and every AI workflow).
name: Claude interactive
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
pull_request_review:
types: [submitted]
issues:
types: [opened, assigned]
jobs:
claude:
# Fire only on a @claude mention authored by the maintainer. The author
# field differs per event type, hence the four branches.
if: >-
vars.AI_DISABLED != 'true' &&
(
(github.event_name == 'issue_comment' &&
contains(github.event.comment.body, '@claude') &&
github.event.comment.user.login == 'alexbelgium') ||
(github.event_name == 'pull_request_review_comment' &&
contains(github.event.comment.body, '@claude') &&
github.event.comment.user.login == 'alexbelgium') ||
(github.event_name == 'pull_request_review' &&
contains(github.event.review.body, '@claude') &&
github.event.review.user.login == 'alexbelgium') ||
(github.event_name == 'issues' &&
(contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')) &&
github.event.issue.user.login == 'alexbelgium')
)
runs-on: ubuntu-latest
timeout-minutes: 30
environment: CR_PAT
permissions:
contents: write
pull-requests: write
issues: write
id-token: write
actions: read # let Claude read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
with:
fetch-depth: 1
- name: Run Claude Code
uses: anthropics/claude-code-action@44423bdec74b97d67543eb16c110546762c110b2 # v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# AI_PR_TOKEN, not GITHUB_TOKEN, so a PR Claude opens triggers CI.
github_token: ${{ secrets.AI_PR_TOKEN }}
claude_args: |
--model claude-sonnet-5
--effort low
--max-turns 30
--allowedTools "Read,Write,Edit,Glob,Grep,Bash(git:*),Bash(gh:*),Bash(shellcheck:*),Bash(yamllint:*),Bash(docker build:*)"
--system-prompt "You are @claude on alexbelgium/hassio-addons, invoked manually by the maintainer, so you take precedence over the automated triage tiers. Each add-on is a thin wrapper around an upstream app. For a small, confident change: make the edit on a branch ai-fix/<addon>-<issue>, run shellcheck on any shell, add a CHANGELOG.md entry, and open a ready PR that Closes the issue. For anything large or uncertain: do NOT grind on it here on Sonnet — post your analysis and recommend applying the ai:approved label (if a tier-2 plan exists) or dispatching the AI fix sweep. Never modify .github/ or .templates/. Never touch the version or upstream fields in config.yaml. Never merge or enable auto-merge."

164
.github/workflows/on_issue_approved.yaml vendored Normal file
View File

@@ -0,0 +1,164 @@
---
# Destination: .github/workflows/on_issue_approved.yaml
#
# Tier 3 of the AI triage system — the approval executor.
#
# Tier 2 (daily_ai_fix.yaml) posts a full `<!-- ai-plan -->` plan on any issue
# it is not confident enough to fix unattended, and labels it `ai:plan-pending`.
# When @alexbelgium reviews that plan and applies the `ai:approved` label, this
# workflow fires within a minute and executes the plan on Opus, opening a
# ready-for-review pull request — no waiting for the next daily sweep.
#
# Manual precedence: the label is the maintainer's explicit go-ahead, so this
# runs regardless of `no-ai`. Only @alexbelgium may approve; a label applied by
# anyone else is stripped and ignored (revoke job below).
#
# Auth: CR_PAT environment (CLAUDE_CODE_OAUTH_TOKEN) + AI_PR_TOKEN (so the PR
# triggers onpr_check-pr.yaml, which GITHUB_TOKEN-authored PRs would not).
# Kill switch: repo variable AI_DISABLED=true pauses this (and every AI workflow).
name: AI approved-plan executor
on:
issues:
types: [labeled]
workflow_dispatch:
inputs:
issue:
description: "Issue number whose approved plan to execute"
required: true
permissions:
contents: write
issues: write
pull-requests: write
id-token: write # claude-code-action fetches a GitHub OIDC token to auth the OAuth flow
concurrency:
group: ai-approve-${{ github.event.issue.number || inputs.issue }}
cancel-in-progress: false
jobs:
# A non-maintainer applied ai:approved: strip it and do nothing else. Only
# users with triage rights can label at all, so this is belt-and-braces.
revoke:
if: >-
github.event_name == 'issues' &&
github.event.label.name == 'ai:approved' &&
github.event.sender.login != 'alexbelgium'
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: Remove unauthorised approval
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
ISSUE: ${{ github.event.issue.number }}
run: |
set -euo pipefail
gh issue edit "$ISSUE" --repo "$REPO" --remove-label ai:approved || true
gh issue comment "$ISSUE" --repo "$REPO" --body \
"The \`ai:approved\` label only takes effect when applied by @alexbelgium; removing it."
execute:
if: >-
vars.AI_DISABLED != 'true' &&
( github.event_name == 'workflow_dispatch' ||
( github.event.label.name == 'ai:approved' &&
github.event.sender.login == 'alexbelgium' ) )
runs-on: ubuntu-latest
timeout-minutes: 60
environment: CR_PAT
steps:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
with:
fetch-depth: 0
token: ${{ secrets.AI_PR_TOKEN }}
- name: Configure git
run: |
git config user.name "claude-ai-fix[bot]"
git config user.email "claude-ai-fix[bot]@users.noreply.github.com"
- name: Ensure control labels exist
env:
GH_TOKEN: ${{ secrets.AI_PR_TOKEN }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
for l in ai:fixed ai:plan-pending ai:approved ai:needs-human ai:blocked; do
gh label create "$l" --repo "$REPO" --color ededed --force >/dev/null 2>&1 || true
done
- name: Bundle the approved plan
id: bundle
env:
GH_TOKEN: ${{ secrets.AI_PR_TOKEN }}
REPO: ${{ github.repository }}
ISSUE: ${{ github.event.issue.number || inputs.issue }}
run: |
set -euo pipefail
mkdir -p /tmp/ai-exec
gh issue view "$ISSUE" --repo "$REPO" \
--json number,title,body,author,labels,comments > /tmp/ai-exec/issue.json
# The plan is the most recent comment carrying the ai-plan marker.
jq -r '[.comments[] | select(.body | contains("<!-- ai-plan -->"))]
| last | .body // ""' /tmp/ai-exec/issue.json > /tmp/ai-exec/plan.md
if [ ! -s /tmp/ai-exec/plan.md ]; then
echo "has_plan=false" >> "$GITHUB_OUTPUT"
echo "::warning::no <!-- ai-plan --> comment on issue #$ISSUE; nothing to execute"
gh issue comment "$ISSUE" --repo "$REPO" --body \
"No AI plan (\`<!-- ai-plan -->\`) was found on this issue, so \`ai:approved\` has nothing to execute. Run the tier-2 sweep on it first (\`AI fix sweep\` → issue $ISSUE)."
else
echo "has_plan=true" >> "$GITHUB_OUTPUT"
echo "plan: $(wc -c < /tmp/ai-exec/plan.md) bytes"
fi
- name: Execute the plan
if: steps.bundle.outputs.has_plan == 'true'
uses: anthropics/claude-code-action@44423bdec74b97d67543eb16c110546762c110b2 # v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
track_progress: true
prompt: |
The approved plan is /tmp/ai-exec/plan.md and the issue it belongs
to is /tmp/ai-exec/issue.json. Follow .github/prompts/issue-execute-plan.md
exactly. Do not deviate from the path restrictions under any
circumstances.
claude_args: |
--model claude-opus-4-8
--effort high
--max-turns 200
--allowedTools "Read,Write,Edit,Glob,Grep,Bash(git:*),Bash(gh:*),Bash(shellcheck:*),Bash(yamllint:*),Bash(docker build:*)"
env:
GH_TOKEN: ${{ secrets.AI_PR_TOKEN }}
# Consume the approval no matter how the run ended, so a stuck label can
# never re-fire this workflow, and flag a human if nothing shipped.
- name: Guard against repeat processing
if: always() && steps.bundle.outputs.has_plan == 'true'
env:
GH_TOKEN: ${{ secrets.AI_PR_TOKEN }}
REPO: ${{ github.repository }}
ISSUE: ${{ github.event.issue.number || inputs.issue }}
run: |
set -euo pipefail
gh issue edit "$ISSUE" --repo "$REPO" \
--remove-label ai:approved --remove-label ai:plan-pending >/dev/null 2>&1 || true
NOW=$(gh issue view "$ISSUE" --repo "$REPO" --json labels --jq '[.labels[].name]')
if ! echo "$NOW" | jq -e 'index("ai:fixed")!=null' >/dev/null \
&& ! echo "$NOW" | jq -e 'index("ai:upstream")!=null' >/dev/null; then
echo "::warning::issue #$ISSUE produced no PR/upstream outcome, flagging for a human"
gh issue edit "$ISSUE" --repo "$REPO" --add-label ai:needs-human >/dev/null 2>&1 || true
fi
# The plan should never touch shared infra, but enforce it on the PR just
# as the tier-2 sweep does — same script, single source of truth.
- name: Guard forbidden paths
if: always() && steps.bundle.outputs.has_plan == 'true'
env:
GH_TOKEN: ${{ secrets.AI_PR_TOKEN }}
REPO: ${{ github.repository }}
run: bash .github/scripts/ai_guard_paths.sh

View File

@@ -1,9 +1,23 @@
---
# Destination: .github/workflows/on_issues_ai_triage.yaml
#
# Tier 1. Fires on every new issue, costs cents, finishes in ~2 minutes.
# Classifies, de-duplicates, asks for missing info, answers simple questions,
# and applies the `ai-triage` label that tier 2 (daily_ai_fix.yaml) picks up.
# Tier 1 of the AI triage system. Fires on every new issue, costs cents,
# finishes in ~2 minutes on Sonnet-low. Classifies, de-duplicates, asks for
# missing info, answers simple questions, and applies the `ai-triage` label
# that tier 2 (daily_ai_fix.yaml) picks up.
#
# Runs once per issue, with exactly one automatic re-run: when it asks the
# reporter for info (`ai:needs-info`), the reporter's reply re-triggers a single
# fresh classification (issue_comment path below). A daily 03:30 catch-up job
# also re-dispatches any issue that never got triaged (e.g. a failed run).
#
# Full tier map:
# Tier 1 on_issues_ai_triage.yaml (this) Sonnet-low classify on issue open
# Tier 2 daily_ai_fix.yaml Opus-xhigh daily fix/plan sweep
# Tier 3 on_issue_approved.yaml Opus-high execute an approved plan
# @claude on_claude_mention.yml Sonnet-low maintainer-only interactive
# PR on_pr_coderabbit.yml Sonnet-low one-shot CodeRabbit follow-up
# Kill switch: set repo variable AI_DISABLED=true to pause every AI workflow.
#
# Auth: Claude Pro/Max subscription via the CR_PAT GitHub Environment, which
# holds the CLAUDE_CODE_OAUTH_TOKEN secret (generate with `claude setup-token`).
@@ -13,6 +27,11 @@ name: AI issue triage
on:
issues:
types: [opened]
issue_comment:
types: [created]
schedule:
# 03:30 — half an hour after the tier-2 sweep, so its relabels have settled.
- cron: "30 3 * * *"
workflow_dispatch:
inputs:
issue:
@@ -25,7 +44,7 @@ permissions:
id-token: write # claude-code-action fetches a GitHub OIDC token to auth the OAuth flow
concurrency:
group: ai-triage-${{ github.event.issue.number || inputs.issue }}
group: ai-triage-${{ github.event.issue.number || inputs.issue || github.run_id }}
cancel-in-progress: false
env:
@@ -33,18 +52,45 @@ env:
jobs:
classify:
# Manual dispatch is a deliberate override: skip the auto-trigger guards
# (don't self-triage the maintainer's own issues; honour the no-ai
# opt-out) that only make sense for the fire-on-every-open path.
# Three entry paths:
# * issues.opened — the normal fire-on-every-open path, with the guards
# that keep it from self-triaging the maintainer's own issues or issues
# that opted out with `no-ai`.
# * issue_comment — the ONE automatic re-run: the reporter replied to a
# needs-info request (issue carries `ai:needs-info`, commenter is the
# issue author, not the maintainer). Re-classifies with the new info.
# * workflow_dispatch — a deliberate manual/catch-up override that skips
# the open-path guards.
# The 03:30 schedule does NOT run this job; it runs `catchup` below.
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event.issue.user.login != 'alexbelgium' &&
!contains(github.event.issue.labels.*.name, 'no-ai'))
vars.AI_DISABLED != 'true' &&
(
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'issues' &&
github.event.issue.user.login != 'alexbelgium' &&
!contains(github.event.issue.labels.*.name, 'no-ai')) ||
(github.event_name == 'issue_comment' &&
github.event.comment.user.login == github.event.issue.user.login &&
github.event.comment.user.login != 'alexbelgium' &&
contains(github.event.issue.labels.*.name, 'ai:needs-info') &&
!contains(github.event.issue.labels.*.name, 'no-ai'))
)
runs-on: ubuntu-latest
timeout-minutes: 15
environment: CR_PAT
steps:
# The reply that re-triggered this run consumes the needs-info flag, so a
# later comment on the same thread cannot re-run tier 1 again. apply-verdict
# re-adds it only if the issue still lacks the info (one more round).
- name: Consume needs-info flag
if: github.event_name == 'issue_comment'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ISSUE: ${{ github.event.issue.number }}
REPO: ${{ github.repository }}
run: gh issue edit "$ISSUE" --repo "$REPO" --remove-label ai:needs-info || true
# on_issues_ping_submitter.yml has to land first: the classifier reads
# the existing comments and bails out if someone already owns the issue.
# Both workflows fire on the same issues.opened event and race. The
@@ -76,7 +122,7 @@ jobs:
- name: Classify
id: classify
continue-on-error: true
uses: anthropics/claude-code-action@v1
uses: anthropics/claude-code-action@44423bdec74b97d67543eb16c110546762c110b2 # v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
show_full_output: true
@@ -126,7 +172,8 @@ jobs:
if [ "$VERDICT" = "owned" ]; then
echo "issue already has an owner, nothing to do"
gh issue edit "$ISSUE" --repo "$REPO" \
--remove-label=ai-triage --remove-label=ai:classified --remove-label=ai:needs-human \
--remove-label=ai-triage --remove-label=ai:classified \
--remove-label=ai:needs-human --remove-label=ai:needs-info \
>/dev/null 2>&1 || true
exit 0
fi
@@ -145,6 +192,14 @@ jobs:
if [ "$VERDICT" = "addon-bug" ] && [ "$CONF" != "low" ]; then
LABELS+=("ai-triage")
fi
# needs-info flags the thread so the reporter's reply re-triggers one
# more classification (see the issue_comment path). Only on a
# confident needs-info — a low-confidence verdict already became
# ai:needs-human above, which is a human hand-off, not an info wait.
if [ "$VERDICT" = "needs-info" ] && [ "$CONF" != "low" ]; then
LABELS+=("ai:needs-info")
fi
LABELS+=("ai:classified")
# No --force: an existing label (e.g. a model-supplied cosmetic
@@ -166,7 +221,7 @@ jobs:
declare -A FRESH=()
for l in "${LABELS[@]}"; do FRESH["$l"]=1; done
STALE=()
for l in ai-triage ai:classified ai:needs-human; do
for l in ai-triage ai:classified ai:needs-human ai:needs-info; do
[ -z "${FRESH[$l]:-}" ] && STALE+=("$l")
done
if [ "${#STALE[@]}" -gt 0 ]; then
@@ -183,3 +238,44 @@ jobs:
} > /tmp/ai-triage/comment.md
gh issue comment "$ISSUE" --repo "$REPO" --body-file /tmp/ai-triage/comment.md
fi
# Self-healing catch-up. Tier 1 fires on issue-open, but a run can fail
# (Claude overload, a transient error) and leave the issue untriaged forever.
# Once a day, re-dispatch classification for any open issue that never got an
# ai:* label — cheap pure shell, no Claude in this job.
catchup:
if: ${{ github.event_name == 'schedule' && vars.AI_DISABLED != 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 10
environment: CR_PAT
steps:
- name: Re-dispatch untriaged issues
env:
# AI_PR_TOKEN (repo scope) can dispatch workflows; GITHUB_TOKEN would
# need actions:write added to the whole workflow.
GH_TOKEN: ${{ secrets.AI_PR_TOKEN }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
NOW=$(date -u +%s)
gh issue list --repo "$REPO" --state open --limit 100 \
--json number,createdAt,author,labels > /tmp/catchup.json
# Untriaged = no ai:* / ai-triage / no-ai label, not the maintainer's
# own issue, and older than 2h (so a just-opened issue whose tier-1
# run is still in flight is not double-dispatched). Cap 5 per day.
jq -r --argjson now "$NOW" '
.[]
| select(.author.login != "alexbelgium")
| select((.createdAt | fromdateiso8601) < ($now - 7200))
| select([.labels[].name]
| any(. == "no-ai" or . == "ai-triage" or startswith("ai:")) | not)
| .number' /tmp/catchup.json | head -n 5 > /tmp/todo.txt
COUNT=$(grep -c . /tmp/todo.txt || true)
echo "untriaged issues to re-dispatch: $COUNT"
while IFS= read -r n; do
[ -n "$n" ] || continue
echo "re-dispatching tier 1 for #$n"
gh workflow run "AI issue triage" --repo "$REPO" -f issue="$n" || \
echo "::warning::could not dispatch classify for #$n"
done < /tmp/todo.txt

106
.github/workflows/on_pr_coderabbit.yml vendored Normal file
View File

@@ -0,0 +1,106 @@
---
# Destination: .github/workflows/on_pr_coderabbit.yml
#
# One-shot CodeRabbit follow-up. CodeRabbit already reviews every PR, so instead
# of a second AI self-review, this reacts to CodeRabbit's review of an AI-opened
# PR (branch `ai-fix/*`): once, on Sonnet-low, Claude reads the review and either
# fixes each actionable comment (commit + push to the PR branch) or replies
# saying why it doesn't apply.
#
# Fires on the `pull_request_review` submitted event from coderabbitai[bot] —
# i.e. exactly when the review lands, which is inherently "after the PR is in
# review". Runs a single time per PR: the `ai:cr-addressed` label is claimed
# before any work, so CodeRabbit's re-review of the pushed fix does not loop.
#
# Auth: CR_PAT environment (CLAUDE_CODE_OAUTH_TOKEN) + AI_PR_TOKEN so pushes
# re-trigger CI. Kill switch: repo variable AI_DISABLED=true pauses it.
name: AI CodeRabbit follow-up
on:
pull_request_review:
types: [submitted]
permissions:
contents: write
pull-requests: write
issues: write
id-token: write # claude-code-action fetches a GitHub OIDC token to auth the OAuth flow
concurrency:
group: ai-coderabbit-${{ github.event.pull_request.number }}
cancel-in-progress: false
jobs:
address:
if: >-
vars.AI_DISABLED != 'true' &&
github.event.review.user.login == 'coderabbitai[bot]' &&
startsWith(github.event.pull_request.head.ref, 'ai-fix/') &&
!contains(github.event.pull_request.labels.*.name, 'ai:cr-addressed')
runs-on: ubuntu-latest
timeout-minutes: 30
environment: CR_PAT
steps:
# Claim the run against the LIVE label set, not the (possibly stale) event
# payload: concurrency serializes duplicate review events, so a queued
# second run sees the label the first one set and bails here.
- name: Claim once
id: claim
env:
GH_TOKEN: ${{ secrets.AI_PR_TOKEN }}
REPO: ${{ github.repository }}
PR: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail
LABELS=$(gh pr view "$PR" --repo "$REPO" --json labels --jq '[.labels[].name]')
if echo "$LABELS" | jq -e 'index("ai:cr-addressed")!=null' >/dev/null; then
echo "PR #$PR already has ai:cr-addressed, skipping"
echo "go=false" >> "$GITHUB_OUTPUT"
exit 0
fi
gh label create "ai:cr-addressed" --repo "$REPO" --color ededed >/dev/null 2>&1 || true
gh pr edit "$PR" --repo "$REPO" --add-label "ai:cr-addressed"
echo "go=true" >> "$GITHUB_OUTPUT"
- name: Checkout PR branch
if: steps.claim.outputs.go == 'true'
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
with:
ref: ${{ github.event.pull_request.head.ref }}
fetch-depth: 0
token: ${{ secrets.AI_PR_TOKEN }}
- name: Configure git
if: steps.claim.outputs.go == 'true'
run: |
git config user.name "claude-ai-fix[bot]"
git config user.email "claude-ai-fix[bot]@users.noreply.github.com"
- name: Address CodeRabbit comments
if: steps.claim.outputs.go == 'true'
uses: anthropics/claude-code-action@44423bdec74b97d67543eb16c110546762c110b2 # v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
prompt: |
CodeRabbit has reviewed pull request #${{ github.event.pull_request.number }}
on ${{ github.repository }}. You are on that PR's branch. Follow
.github/prompts/pr-coderabbit.md exactly. Do not deviate from the
path restrictions under any circumstances.
claude_args: |
--model claude-sonnet-5
--effort low
--max-turns 40
--allowedTools "Read,Edit,Write,Glob,Grep,Bash(git:*),Bash(gh:*),Bash(shellcheck:*)"
env:
GH_TOKEN: ${{ secrets.AI_PR_TOKEN }}
# A CodeRabbit "fix" must not smuggle in a protected-path edit either.
- name: Guard forbidden paths
if: always() && steps.claim.outputs.go == 'true'
env:
GH_TOKEN: ${{ secrets.AI_PR_TOKEN }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: bash .github/scripts/ai_guard_paths.sh

View File

@@ -139,6 +139,30 @@ Other automation workflows:
Adding `[nobuild]` anywhere in a commit message skips the builder workflow.
### AI issue triage
A tiered, Claude-powered pipeline triages and fixes add-on issues. It escalates
from cheap classification to a maintainer-approved automated fix, always leaving
manual actions with precedence. Prompts live in `.github/prompts/`, shared shell
in `.github/scripts/`.
| Workflow | Model | Trigger | Role |
|---|---|---|---|
| `on_issues_ai_triage.yaml` | Sonnet-low | issue opened (+ author reply, daily catch-up) | Tier 1: classify, dedupe, answer, ask for info; label `ai-triage` for real add-on bugs |
| `daily_ai_fix.yaml` | Opus-xhigh | daily 03:00 | Tier 2: diagnose the `ai-triage` batch; small+confident → ready PR (`ai:fixed`); else write a plan (`ai:plan-pending`) |
| `on_issue_approved.yaml` | Opus-high | maintainer adds `ai:approved` | Tier 3: execute the approved plan → ready PR |
| `on_claude_mention.yml` | Sonnet-low | `@claude` by @alexbelgium | Manual interactive override on any issue/PR |
| `on_pr_coderabbit.yml` | Sonnet-low | CodeRabbit reviews an `ai-fix/*` PR | Once: fix or reply to review comments |
Control labels (`ai:*`) are workflow-owned. Key ones: `ai-triage` (queued for
the sweep), `ai:plan-pending` (plan posted, awaiting `ai:approved`), `ai:fixed`,
`ai:upstream`, `ai:needs-info` (a reporter reply re-runs tier 1 once),
`ai:needs-human`, `ai:blocked` (touched protected paths). `no-ai` opts an issue
out of the automated tiers but not the manual ones. **Kill switch:** set the
repo variable `AI_DISABLED=true` to pause every AI workflow with no file edits.
AI fixes must never touch `.github/` or `.templates/` (enforced by
`ai_guard_paths.sh`) or the `version`/`upstream` fields in `config.yaml`.
## Linting Rules
| Tool | Config | Key ignores |