diff --git a/.github/workflows/pr-labels-ci.yml b/.github/workflows/pr-labels-ci.yml
new file mode 100644
index 0000000..e1d2ee0
--- /dev/null
+++ b/.github/workflows/pr-labels-ci.yml
@@ -0,0 +1,136 @@
+# mcp-awareness — ambient system awareness for AI agents
+# Copyright (C) 2026 Chris Means
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+name: PR Label Automation (CI)
+
+# Handles label transitions when CI completes (pass or fail).
+# Split from pr-labels.yml because workflow_run fires on every CI
+# completion (including merges to main), which caused noisy failures
+# when there was no associated PR.
+#
+# NOTE: References the CI workflow by name ("CI").
+# If the CI workflow in ci.yml is ever renamed, update the name here too.
+#
+# LIMITATION: workflow_run triggers always run from the default branch
+# (main), not the PR branch. Changes to this file cannot be tested
+# on a PR — they take effect only after merge.
+on:
+ workflow_run:
+ workflows: [CI]
+ types: [completed]
+
+permissions:
+ pull-requests: write
+ checks: read
+
+jobs:
+ # When CI passes on a PR branch, promote from Awaiting CI to Ready for QA.
+ # Skips if Dev Active is present (dev isn't done yet).
+ on-ci-pass:
+ if: >-
+ github.event.workflow_run.conclusion == 'success'
+ && github.event.workflow_run.event == 'pull_request'
+ && github.event.workflow_run.head_branch != github.event.repository.default_branch
+ runs-on: ubuntu-latest
+ steps:
+ - name: Promote to Ready for QA
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ REPO=${{ github.repository }}
+
+ # Look up the associated PR.
+ # The API can 404 after a force-push (orphaned run).
+ # Capture output only on success so 404 body doesn't leak into $PR.
+ PR=""
+ API_OUT=$(gh api "repos/$REPO/actions/runs/${{ github.event.workflow_run.id }}/pull_requests" \
+ --jq '.[0].number // empty' 2>&1) && PR="$API_OUT" || true
+
+ # Fallback: pull_requests array is empty for dependabot PRs.
+ # Search by head branch instead.
+ if [ -z "$PR" ]; then
+ HEAD_BRANCH=${{ github.event.workflow_run.head_branch }}
+ PR=$(gh pr list --repo "$REPO" --head "$HEAD_BRANCH" --state open \
+ --json number --jq '.[0].number // empty' 2>/dev/null) || true
+ fi
+
+ if [ -z "$PR" ]; then
+ echo "No PR associated with this workflow run — exiting"
+ exit 0
+ fi
+
+ LABELS=$(gh pr view "$PR" --repo "$REPO" --json labels --jq '.labels[].name')
+
+ # Dev Active means dev isn't done — don't promote.
+ # on-unlabel will handle promotion when Dev Active is removed.
+ if echo "$LABELS" | grep -q "^Dev Active$"; then
+ echo "Dev Active present — skipping promotion"
+ exit 0
+ fi
+
+ # Promote if Awaiting CI is present
+ if echo "$LABELS" | grep -q "^Awaiting CI$"; then
+ REMOVE="Awaiting CI"
+ if echo "$LABELS" | grep -q "^CI Failed$"; then
+ REMOVE="Awaiting CI,CI Failed"
+ fi
+ gh pr edit "$PR" --repo "$REPO" --remove-label "$REMOVE"
+ gh pr edit "$PR" --repo "$REPO" --add-label "Ready for QA"
+ fi
+
+ # When CI fails on a PR branch, set CI Failed.
+ # Skips if Dev Active is present (dev is already working).
+ on-ci-fail:
+ if: >-
+ github.event.workflow_run.conclusion == 'failure'
+ && github.event.workflow_run.event == 'pull_request'
+ && github.event.workflow_run.head_branch != github.event.repository.default_branch
+ runs-on: ubuntu-latest
+ steps:
+ - name: Set CI Failed
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ REPO=${{ github.repository }}
+
+ PR=""
+ API_OUT=$(gh api "repos/$REPO/actions/runs/${{ github.event.workflow_run.id }}/pull_requests" \
+ --jq '.[0].number // empty' 2>&1) && PR="$API_OUT" || true
+
+ if [ -z "$PR" ]; then
+ HEAD_BRANCH=${{ github.event.workflow_run.head_branch }}
+ PR=$(gh pr list --repo "$REPO" --head "$HEAD_BRANCH" --state open \
+ --json number --jq '.[0].number // empty' 2>/dev/null) || true
+ fi
+
+ if [ -z "$PR" ]; then
+ echo "No PR associated with this workflow run — exiting"
+ exit 0
+ fi
+
+ LABELS=$(gh pr view "$PR" --repo "$REPO" --json labels --jq '.labels[].name')
+
+ # Dev Active means dev is already working — don't add CI Failed
+ if echo "$LABELS" | grep -q "^Dev Active$"; then
+ echo "Dev Active present — skipping CI Failed"
+ exit 0
+ fi
+
+ # Set CI Failed if Awaiting CI is present (normal flow)
+ if echo "$LABELS" | grep -q "^Awaiting CI$"; then
+ gh pr edit "$PR" --repo "$REPO" --remove-label "Awaiting CI"
+ gh pr edit "$PR" --repo "$REPO" --add-label "CI Failed"
+ fi
diff --git a/.github/workflows/pr-labels.yml b/.github/workflows/pr-labels.yml
index cca90c3..0109c18 100644
--- a/.github/workflows/pr-labels.yml
+++ b/.github/workflows/pr-labels.yml
@@ -16,25 +16,23 @@
name: PR Label Automation
-# NOTE: The workflow_run trigger references the CI workflow by name ("CI").
-# If the CI workflow in ci.yml is ever renamed, update the name here too.
+# Handles label transitions triggered by PR events (push, label, unlabel).
+# CI-completion promotion lives in pr-labels-ci.yml (separate workflow_run trigger).
on:
pull_request:
branches: [main]
- types: [synchronize, labeled, unlabeled]
- workflow_run:
- workflows: [CI]
- types: [completed]
+ types: [opened, synchronize, labeled, unlabeled]
permissions:
pull-requests: write
checks: read
jobs:
- # When new commits are pushed to a PR, reset to Awaiting CI.
- # Removes any QA/review labels that are now stale.
+ # When a PR is opened or new commits are pushed, reset to Awaiting CI.
+ # Removes stale QA/workflow labels. Keeps Dev Active if present (coexists
+ # with Awaiting CI — dev is still working, CI runs but won't promote).
on-push:
- if: github.event_name == 'pull_request' && github.event.action == 'synchronize'
+ if: github.event.action == 'opened' || github.event.action == 'synchronize'
runs-on: ubuntu-latest
steps:
- name: Reset labels on new push
@@ -50,9 +48,9 @@ jobs:
HAD_QA_ACTIVE=true
fi
- # Remove stale workflow labels (coalesced where possible)
+ # Remove stale workflow labels (coalesced into one API call)
REMOVE=""
- for LABEL in "Ready for QA" "Ready for QA Signoff" "QA Approved" "QA Active" "QA Failed" "QA Invalidated"; do
+ for LABEL in "Ready for QA" "Ready for QA Signoff" "QA Approved" "QA Active" "QA Failed" "CI Failed"; do
if echo "$PR_LABELS" | grep -q "\"$LABEL\""; then
if [ -n "$REMOVE" ]; then
REMOVE="$REMOVE,$LABEL"
@@ -65,83 +63,24 @@ jobs:
gh pr edit "$PR" --repo "$REPO" --remove-label "$REMOVE"
fi
- # Only add Awaiting CI if Dev Active is NOT present.
- # If Dev Active is on, the dev isn't done yet — on-unlabel will
- # handle the transition when they remove it.
- if echo "$PR_LABELS" | grep -q '"Dev Active"'; then
- echo "Dev Active present — skipping Awaiting CI (dev still working)"
- elif ! echo "$PR_LABELS" | grep -q '"Awaiting CI"'; then
+ # Add Awaiting CI (unless already present).
+ # Dev Active is NOT removed — it coexists with Awaiting CI.
+ if ! echo "$PR_LABELS" | grep -q '"Awaiting CI"'; then
gh pr edit "$PR" --repo "$REPO" --add-label "Awaiting CI"
fi
- # If QA was actively reviewing, flag the invalidation
+ # If QA was actively reviewing, notify via comment
if [ "$HAD_QA_ACTIVE" = true ]; then
- gh pr edit "$PR" --repo "$REPO" --add-label "QA Invalidated"
gh pr comment "$PR" --repo "$REPO" \
--body "New commits pushed while QA was active. QA review invalidated — resetting to Awaiting CI."
fi
- # When CI completes successfully, move from Awaiting CI to Ready for QA.
- # Only works for same-origin PRs — fork PRs must be promoted manually
- # because the workflow_run pull_requests array is empty for forks.
- on-ci-pass:
- if: >-
- github.event_name == 'workflow_run'
- && github.event.workflow_run.conclusion == 'success'
- && github.event.workflow_run.event == 'pull_request'
- runs-on: ubuntu-latest
- steps:
- - name: Promote to Ready for QA
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: |
- REPO=${{ github.repository }}
-
- # Get the PR number from the workflow run.
- # This can 404 after a force-push (the old commit's run is orphaned).
- PR=$(gh api repos/$REPO/actions/runs/${{ github.event.workflow_run.id }}/pull_requests \
- --jq '.[0].number // empty' 2>/dev/null) || true
-
- # Fallback: pull_requests array is empty for dependabot and other
- # non-default-branch PRs. Search by head branch instead.
- if [ -z "$PR" ]; then
- HEAD_BRANCH=${{ github.event.workflow_run.head_branch }}
- PR=$(gh pr list --repo "$REPO" --head "$HEAD_BRANCH" --state open \
- --json number --jq '.[0].number // empty' 2>/dev/null) || true
- fi
-
- if [ -z "$PR" ]; then
- echo "No PR associated with this workflow run (may be a fork PR or force-pushed away)"
- exit 0
- fi
-
- LABELS=$(gh pr view "$PR" --repo "$REPO" --json labels --jq '.labels[].name')
-
- # Don't promote if Dev Active is present — dev isn't done yet.
- # When they remove Dev Active, on-unlabel will check CI and promote.
- if echo "$LABELS" | grep -q "^Dev Active$"; then
- echo "Dev Active present — skipping promotion (dev still working)"
- exit 0
- fi
-
- # Only promote if Awaiting CI is present
- if echo "$LABELS" | grep -q "^Awaiting CI$"; then
- # Also clean up QA Invalidated if it lingered from a previous push
- REMOVE="Awaiting CI"
- if echo "$LABELS" | grep -q "^QA Invalidated$"; then
- REMOVE="Awaiting CI,QA Invalidated"
- fi
- gh pr edit "$PR" --repo "$REPO" --remove-label "$REMOVE"
- gh pr edit "$PR" --repo "$REPO" --add-label "Ready for QA"
- fi
-
- # When Dev Active is removed, transition to Awaiting CI — or straight
- # to Ready for QA if CI already passed (avoids stuck state when CI
- # finishes before the label is removed).
+ # When Dev Active is removed manually, check if CI already passed.
+ # If yes, promote straight to Ready for QA. If no, Awaiting CI is
+ # already present and on-ci-pass will promote when CI completes.
on-unlabel:
if: >-
- github.event_name == 'pull_request'
- && github.event.action == 'unlabeled'
+ github.event.action == 'unlabeled'
&& github.event.label.name == 'Dev Active'
runs-on: ubuntu-latest
steps:
@@ -153,39 +92,37 @@ jobs:
PR=${{ github.event.pull_request.number }}
REPO=${{ github.repository }}
- # Check if CI already passed for the head commit (job-name-agnostic).
+ # Check if CI already passed for the head commit
HEAD_SHA=${{ github.event.pull_request.head.sha }}
CI_CONCLUSION=$(gh api "repos/$REPO/actions/workflows/ci.yml/runs?head_sha=$HEAD_SHA" \
--jq '.workflow_runs[0].conclusion // empty' 2>/dev/null) || true
if [ "$CI_CONCLUSION" = "success" ]; then
- # CI passed — go straight to Ready for QA
+ # CI passed — promote to Ready for QA
REMOVE=""
if echo "$PR_LABELS" | grep -q '"Awaiting CI"'; then
REMOVE="Awaiting CI"
fi
- if echo "$PR_LABELS" | grep -q '"QA Invalidated"'; then
- REMOVE="${REMOVE:+$REMOVE,}QA Invalidated"
+ if echo "$PR_LABELS" | grep -q '"CI Failed"'; then
+ REMOVE="${REMOVE:+$REMOVE,}CI Failed"
fi
if [ -n "$REMOVE" ]; then
gh pr edit "$PR" --repo "$REPO" --remove-label "$REMOVE"
fi
gh pr edit "$PR" --repo "$REPO" --add-label "Ready for QA"
else
- # CI hasn't passed yet — ensure Awaiting CI is present
+ # CI hasn't passed — Awaiting CI should already be present
if ! echo "$PR_LABELS" | grep -q '"Awaiting CI"'; then
gh pr edit "$PR" --repo "$REPO" --add-label "Awaiting CI"
fi
fi
# When a workflow label is added, clean up labels that no longer apply.
- # Only runs for labels that have cleanup rules — skips irrelevant labels
- # to avoid wasted runner invocations.
+ # Only runs for labels that have cleanup rules.
on-label:
if: >-
- github.event_name == 'pull_request'
- && github.event.action == 'labeled'
- && contains(fromJSON('["QA Active","Dev Active","Ready for QA Signoff","QA Failed","QA Approved"]'), github.event.label.name)
+ github.event.action == 'labeled'
+ && contains(fromJSON('["Ready for QA","QA Active","Dev Active","Ready for QA Signoff","QA Failed","QA Approved"]'), github.event.label.name)
runs-on: ubuntu-latest
steps:
- name: Clean up stale labels
@@ -204,12 +141,16 @@ jobs:
}
case "$ADDED_LABEL" in
+ "Ready for QA")
+ remove_if_present "Awaiting CI"
+ remove_if_present "CI Failed"
+ ;;
"QA Active")
remove_if_present "Ready for QA"
;;
"Dev Active")
remove_if_present "QA Failed"
- remove_if_present "QA Invalidated"
+ remove_if_present "CI Failed"
remove_if_present "Awaiting CI"
remove_if_present "Ready for QA"
;;
@@ -218,6 +159,7 @@ jobs:
;;
"QA Failed")
remove_if_present "QA Active"
+ remove_if_present "Ready for QA Signoff"
;;
"QA Approved")
remove_if_present "Ready for QA Signoff"
diff --git a/docs/superpowers/plans/2026-03-30-pr-label-state-machine.md b/docs/superpowers/plans/2026-03-30-pr-label-state-machine.md
new file mode 100644
index 0000000..8639760
--- /dev/null
+++ b/docs/superpowers/plans/2026-03-30-pr-label-state-machine.md
@@ -0,0 +1,508 @@
+# PR Label State Machine Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Rewrite the PR label automation as a clean state machine with proper transitions, replacing the current buggy incremental code on the `fix/pr-labels-workflow-split` branch.
+
+**Architecture:** Two workflow files split by trigger type: `pr-labels.yml` (pull_request events) and `pr-labels-ci.yml` (workflow_run events). A shared PR-lookup pattern in the CI file handles 404s safely. Label cleanup rules in on-label provide defense-in-depth for every transition.
+
+**Tech Stack:** GitHub Actions YAML, bash shell scripts, `gh` CLI
+
+**Spec:** `docs/superpowers/specs/2026-03-30-pr-label-state-machine-design.md`
+
+**Branch:** `fix/pr-labels-workflow-split` (already exists, will be rewritten)
+
+---
+
+### Task 1: Rewrite pr-labels.yml (pull_request triggers)
+
+**Files:**
+- Rewrite: `.github/workflows/pr-labels.yml`
+
+This file handles three jobs triggered by `pull_request` events: `on-push`, `on-unlabel`, and `on-label`.
+
+- [ ] **Step 1: Write the complete pr-labels.yml file**
+
+Replace the entire contents of `.github/workflows/pr-labels.yml` with:
+
+```yaml
+# mcp-awareness — ambient system awareness for AI agents
+# Copyright (C) 2026 Chris Means
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+name: PR Label Automation
+
+# Handles label transitions triggered by PR events (push, label, unlabel).
+# CI-completion promotion lives in pr-labels-ci.yml (separate workflow_run trigger).
+on:
+ pull_request:
+ branches: [main]
+ types: [opened, synchronize, labeled, unlabeled]
+
+permissions:
+ pull-requests: write
+ checks: read
+
+jobs:
+ # When a PR is opened or new commits are pushed, reset to Awaiting CI.
+ # Removes stale QA/workflow labels. Keeps Dev Active if present (coexists
+ # with Awaiting CI — dev is still working, CI runs but won't promote).
+ on-push:
+ if: github.event.action == 'opened' || github.event.action == 'synchronize'
+ runs-on: ubuntu-latest
+ steps:
+ - name: Reset labels on new push
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ PR_LABELS: ${{ toJSON(github.event.pull_request.labels.*.name) }}
+ run: |
+ PR=${{ github.event.pull_request.number }}
+ REPO=${{ github.repository }}
+
+ HAD_QA_ACTIVE=false
+ if echo "$PR_LABELS" | grep -q '"QA Active"'; then
+ HAD_QA_ACTIVE=true
+ fi
+
+ # Remove stale workflow labels (coalesced into one API call)
+ REMOVE=""
+ for LABEL in "Ready for QA" "Ready for QA Signoff" "QA Approved" "QA Active" "QA Failed" "CI Failed"; do
+ if echo "$PR_LABELS" | grep -q "\"$LABEL\""; then
+ if [ -n "$REMOVE" ]; then
+ REMOVE="$REMOVE,$LABEL"
+ else
+ REMOVE="$LABEL"
+ fi
+ fi
+ done
+ if [ -n "$REMOVE" ]; then
+ gh pr edit "$PR" --repo "$REPO" --remove-label "$REMOVE"
+ fi
+
+ # Add Awaiting CI (unless already present).
+ # Dev Active is NOT removed — it coexists with Awaiting CI.
+ if ! echo "$PR_LABELS" | grep -q '"Awaiting CI"'; then
+ gh pr edit "$PR" --repo "$REPO" --add-label "Awaiting CI"
+ fi
+
+ # If QA was actively reviewing, notify via comment
+ if [ "$HAD_QA_ACTIVE" = true ]; then
+ gh pr comment "$PR" --repo "$REPO" \
+ --body "New commits pushed while QA was active. QA review invalidated — resetting to Awaiting CI."
+ fi
+
+ # When Dev Active is removed manually, check if CI already passed.
+ # If yes, promote straight to Ready for QA. If no, Awaiting CI is
+ # already present and on-ci-pass will promote when CI completes.
+ on-unlabel:
+ if: >-
+ github.event.action == 'unlabeled'
+ && github.event.label.name == 'Dev Active'
+ runs-on: ubuntu-latest
+ steps:
+ - name: Transition after Dev Active removed
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ PR_LABELS: ${{ toJSON(github.event.pull_request.labels.*.name) }}
+ run: |
+ PR=${{ github.event.pull_request.number }}
+ REPO=${{ github.repository }}
+
+ # Check if CI already passed for the head commit
+ HEAD_SHA=${{ github.event.pull_request.head.sha }}
+ CI_CONCLUSION=$(gh api "repos/$REPO/actions/workflows/ci.yml/runs?head_sha=$HEAD_SHA" \
+ --jq '.workflow_runs[0].conclusion // empty' 2>/dev/null) || true
+
+ if [ "$CI_CONCLUSION" = "success" ]; then
+ # CI passed — promote to Ready for QA
+ REMOVE=""
+ if echo "$PR_LABELS" | grep -q '"Awaiting CI"'; then
+ REMOVE="Awaiting CI"
+ fi
+ if echo "$PR_LABELS" | grep -q '"CI Failed"'; then
+ REMOVE="${REMOVE:+$REMOVE,}CI Failed"
+ fi
+ if [ -n "$REMOVE" ]; then
+ gh pr edit "$PR" --repo "$REPO" --remove-label "$REMOVE"
+ fi
+ gh pr edit "$PR" --repo "$REPO" --add-label "Ready for QA"
+ else
+ # CI hasn't passed — Awaiting CI should already be present
+ if ! echo "$PR_LABELS" | grep -q '"Awaiting CI"'; then
+ gh pr edit "$PR" --repo "$REPO" --add-label "Awaiting CI"
+ fi
+ fi
+
+ # When a workflow label is added, clean up labels that no longer apply.
+ # Only runs for labels that have cleanup rules.
+ on-label:
+ if: >-
+ github.event.action == 'labeled'
+ && contains(fromJSON('["Ready for QA","QA Active","Dev Active","Ready for QA Signoff","QA Failed","QA Approved"]'), github.event.label.name)
+ runs-on: ubuntu-latest
+ steps:
+ - name: Clean up stale labels
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ ADDED_LABEL: ${{ github.event.label.name }}
+ PR_LABELS: ${{ toJSON(github.event.pull_request.labels.*.name) }}
+ run: |
+ PR=${{ github.event.pull_request.number }}
+ REPO=${{ github.repository }}
+
+ remove_if_present() {
+ if echo "$PR_LABELS" | grep -q "\"$1\""; then
+ gh pr edit "$PR" --repo "$REPO" --remove-label "$1"
+ fi
+ }
+
+ case "$ADDED_LABEL" in
+ "Ready for QA")
+ remove_if_present "Awaiting CI"
+ remove_if_present "CI Failed"
+ ;;
+ "QA Active")
+ remove_if_present "Ready for QA"
+ ;;
+ "Dev Active")
+ remove_if_present "QA Failed"
+ remove_if_present "CI Failed"
+ remove_if_present "Awaiting CI"
+ remove_if_present "Ready for QA"
+ ;;
+ "Ready for QA Signoff")
+ remove_if_present "QA Active"
+ ;;
+ "QA Failed")
+ remove_if_present "QA Active"
+ ;;
+ "QA Approved")
+ remove_if_present "Ready for QA Signoff"
+ ;;
+ esac
+```
+
+- [ ] **Step 2: Verify YAML syntax**
+
+Run: `python -c "import yaml; yaml.safe_load(open('.github/workflows/pr-labels.yml'))"`
+Expected: No output (valid YAML)
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add .github/workflows/pr-labels.yml
+git commit -m "refactor: rewrite pr-labels.yml as clean state machine
+
+Complete rewrite based on the state machine spec. Key changes:
+- Dev Active coexists with Awaiting CI (no longer blocks it)
+- on-push keeps Dev Active, removes all other workflow labels
+- on-unlabel checks CI status and promotes if already passed
+- on-label cleanup includes Ready for QA removing Awaiting CI + CI Failed
+- QA Invalidated label removed entirely (comment is sufficient)
+- CI Failed added to cleanup rules for Dev Active and Ready for QA"
+```
+
+---
+
+### Task 2: Rewrite pr-labels-ci.yml (workflow_run triggers)
+
+**Files:**
+- Rewrite: `.github/workflows/pr-labels-ci.yml`
+
+This file handles two jobs triggered by CI completion: `on-ci-pass` and `on-ci-fail`.
+
+- [ ] **Step 1: Write the complete pr-labels-ci.yml file**
+
+Replace the entire contents of `.github/workflows/pr-labels-ci.yml` with:
+
+```yaml
+# mcp-awareness — ambient system awareness for AI agents
+# Copyright (C) 2026 Chris Means
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+name: PR Label Automation (CI)
+
+# Handles label transitions when CI completes (pass or fail).
+# Split from pr-labels.yml because workflow_run fires on every CI
+# completion (including merges to main), which caused noisy failures
+# when there was no associated PR.
+#
+# NOTE: References the CI workflow by name ("CI").
+# If the CI workflow in ci.yml is ever renamed, update the name here too.
+#
+# LIMITATION: workflow_run triggers always run from the default branch
+# (main), not the PR branch. Changes to this file cannot be tested
+# on a PR — they take effect only after merge.
+on:
+ workflow_run:
+ workflows: [CI]
+ types: [completed]
+
+permissions:
+ pull-requests: write
+ checks: read
+
+jobs:
+ # When CI passes on a PR branch, promote from Awaiting CI to Ready for QA.
+ # Skips if Dev Active is present (dev isn't done yet).
+ on-ci-pass:
+ if: >-
+ github.event.workflow_run.conclusion == 'success'
+ && github.event.workflow_run.event == 'pull_request'
+ && github.event.workflow_run.head_branch != github.event.repository.default_branch
+ runs-on: ubuntu-latest
+ steps:
+ - name: Promote to Ready for QA
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ REPO=${{ github.repository }}
+
+ # Look up the associated PR.
+ # The API can 404 after a force-push (orphaned run).
+ # Capture output only on success so 404 body doesn't leak into $PR.
+ PR=""
+ API_OUT=$(gh api "repos/$REPO/actions/runs/${{ github.event.workflow_run.id }}/pull_requests" \
+ --jq '.[0].number // empty' 2>&1) && PR="$API_OUT" || true
+
+ # Fallback: pull_requests array is empty for dependabot PRs.
+ # Search by head branch instead.
+ if [ -z "$PR" ]; then
+ HEAD_BRANCH=${{ github.event.workflow_run.head_branch }}
+ PR=$(gh pr list --repo "$REPO" --head "$HEAD_BRANCH" --state open \
+ --json number --jq '.[0].number // empty' 2>/dev/null) || true
+ fi
+
+ if [ -z "$PR" ]; then
+ echo "No PR associated with this workflow run — exiting"
+ exit 0
+ fi
+
+ LABELS=$(gh pr view "$PR" --repo "$REPO" --json labels --jq '.labels[].name')
+
+ # Dev Active means dev isn't done — don't promote.
+ # on-unlabel will handle promotion when Dev Active is removed.
+ if echo "$LABELS" | grep -q "^Dev Active$"; then
+ echo "Dev Active present — skipping promotion"
+ exit 0
+ fi
+
+ # Promote if Awaiting CI is present
+ if echo "$LABELS" | grep -q "^Awaiting CI$"; then
+ REMOVE="Awaiting CI"
+ if echo "$LABELS" | grep -q "^CI Failed$"; then
+ REMOVE="Awaiting CI,CI Failed"
+ fi
+ gh pr edit "$PR" --repo "$REPO" --remove-label "$REMOVE"
+ gh pr edit "$PR" --repo "$REPO" --add-label "Ready for QA"
+ fi
+
+ # When CI fails on a PR branch, set CI Failed.
+ # Skips if Dev Active is present (dev is already working).
+ on-ci-fail:
+ if: >-
+ github.event.workflow_run.conclusion == 'failure'
+ && github.event.workflow_run.event == 'pull_request'
+ && github.event.workflow_run.head_branch != github.event.repository.default_branch
+ runs-on: ubuntu-latest
+ steps:
+ - name: Set CI Failed
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ REPO=${{ github.repository }}
+
+ PR=""
+ API_OUT=$(gh api "repos/$REPO/actions/runs/${{ github.event.workflow_run.id }}/pull_requests" \
+ --jq '.[0].number // empty' 2>&1) && PR="$API_OUT" || true
+
+ if [ -z "$PR" ]; then
+ HEAD_BRANCH=${{ github.event.workflow_run.head_branch }}
+ PR=$(gh pr list --repo "$REPO" --head "$HEAD_BRANCH" --state open \
+ --json number --jq '.[0].number // empty' 2>/dev/null) || true
+ fi
+
+ if [ -z "$PR" ]; then
+ echo "No PR associated with this workflow run — exiting"
+ exit 0
+ fi
+
+ LABELS=$(gh pr view "$PR" --repo "$REPO" --json labels --jq '.labels[].name')
+
+ # Dev Active means dev is already working — don't add CI Failed
+ if echo "$LABELS" | grep -q "^Dev Active$"; then
+ echo "Dev Active present — skipping CI Failed"
+ exit 0
+ fi
+
+ # Set CI Failed if Awaiting CI is present (normal flow)
+ if echo "$LABELS" | grep -q "^Awaiting CI$"; then
+ gh pr edit "$PR" --repo "$REPO" --remove-label "Awaiting CI"
+ gh pr edit "$PR" --repo "$REPO" --add-label "CI Failed"
+ fi
+```
+
+- [ ] **Step 2: Verify YAML syntax**
+
+Run: `python -c "import yaml; yaml.safe_load(open('.github/workflows/pr-labels-ci.yml'))"`
+Expected: No output (valid YAML)
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add .github/workflows/pr-labels-ci.yml
+git commit -m "refactor: rewrite pr-labels-ci.yml with CI Failed state and safe PR lookup
+
+Complete rewrite based on the state machine spec. Key changes:
+- on-ci-fail adds CI Failed label (not Dev Active) when CI fails
+- Dev Active suppresses both pass and fail transitions
+- Safe 404 handling: API output captured only on success
+- head_branch filter skips CI runs on main (merge commits)
+- CI Failed cleaned up on next CI pass"
+```
+
+---
+
+### Task 3: Create CI Failed label and clean up QA Invalidated
+
+**Files:** None (GitHub API only)
+
+- [ ] **Step 1: Create the CI Failed label**
+
+```bash
+gh label create "CI Failed" --repo cmeans/mcp-awareness --color "B60205" --description "CI failed — dev needs to fix" --force
+```
+
+Expected: Label created (red color matches QA Failed)
+
+- [ ] **Step 2: Delete QA Invalidated label if it exists**
+
+```bash
+gh label delete "QA Invalidated" --repo cmeans/mcp-awareness --yes 2>/dev/null || echo "Label does not exist"
+```
+
+Expected: Label deleted or "does not exist"
+
+- [ ] **Step 3: Verify label set**
+
+```bash
+gh label list --repo cmeans/mcp-awareness | grep -E "Active|Awaiting|Failed|Ready|QA|CI"
+```
+
+Expected: Shows `Dev Active`, `Awaiting CI`, `CI Failed`, `Ready for QA`, `QA Active`, `Ready for QA Signoff`, `QA Failed`, `QA Approved`. No `QA Invalidated`.
+
+- [ ] **Step 4: Commit** (no file changes — label operations are API-only, but commit the spec)
+
+```bash
+git add docs/superpowers/specs/2026-03-30-pr-label-state-machine-design.md
+git commit -m "docs: add PR label state machine design spec"
+```
+
+---
+
+### Task 4: Update PR description and push
+
+- [ ] **Step 1: Update the PR #108 description to reflect the full redesign**
+
+```bash
+gh pr edit 108 --title "refactor: PR label automation as a proper state machine" --body "$(cat <<'PREOF'
+## Summary
+
+- Rewrites the PR label automation as a clean state machine with defined states, transitions, and cleanup rules
+- Splits workflow_run trigger into separate file to eliminate failure emails on merges to main
+- Adds `CI Failed` label for CI failures (covers both hard failures and Codecov)
+- Removes `QA Invalidated` label (push comment is sufficient)
+- `Dev Active` now coexists with `Awaiting CI` instead of blocking it
+
+## State Machine
+
+```mermaid
+stateDiagram-v2
+ [*] --> Awaiting_CI : PR opened / push
+ Dev_Active --> Awaiting_CI : push (coexist)
+ Awaiting_CI --> Ready_for_QA : CI pass (no Dev Active)
+ Awaiting_CI --> CI_Failed : CI fail (no Dev Active)
+ CI_Failed --> Awaiting_CI : push (fix)
+ Ready_for_QA --> QA_Active : QA starts
+ QA_Active --> Ready_for_QA_Signoff : signoff
+ QA_Active --> QA_Failed : fail
+ Ready_for_QA_Signoff --> QA_Approved : approved
+ QA_Approved --> [*] : merge
+ QA_Failed --> Dev_Active : dev picks up
+```
+
+## Design spec
+
+`docs/superpowers/specs/2026-03-30-pr-label-state-machine-design.md`
+
+## Changes
+
+| File | What |
+|------|------|
+| `pr-labels.yml` | Rewritten: on-push keeps Dev Active, removes all others, adds Awaiting CI. on-unlabel promotes if CI passed. on-label has full cleanup matrix. |
+| `pr-labels-ci.yml` | Rewritten: on-ci-pass promotes (skips if Dev Active). on-ci-fail adds CI Failed (skips if Dev Active). Safe 404 handling. |
+| Labels | Created `CI Failed`. Deleted `QA Invalidated`. |
+
+## QA
+
+### Prerequisites
+- Review both workflow files against the spec
+
+### Manual tests
+1. - [ ] **New PR without Dev Active** — create a test PR, verify it gets `Awaiting CI` on open, `Ready for QA` after CI passes (note: on-ci-pass runs from main, so this tests the OLD code until merge)
+2. - [ ] **Push with Dev Active** — add `Dev Active` to a PR, push a commit, verify both `Dev Active` and `Awaiting CI` are present
+3. - [ ] **Remove Dev Active after CI passed** — remove `Dev Active`, verify `Ready for QA` appears within 15s
+4. - [ ] **CI failure without Dev Active** — push a commit that breaks lint, verify `CI Failed` appears (post-merge test only — on-ci-fail runs from main)
+5. - [ ] **Label cleanup** — add `Ready for QA` manually, verify `Awaiting CI` is removed
+6. - [ ] **No failure emails on merge** — merge a PR, verify no "PR Label Automation (CI)" failure email
+
+Note: Tests 1, 4, and 6 depend on the workflow_run trigger which runs from main. These can only be fully verified after merge.
+PREOF
+)"
+```
+
+- [ ] **Step 2: Push all commits**
+
+```bash
+git push
+```
+
+- [ ] **Step 3: Verify labels on PR #108**
+
+```bash
+gh pr view 108 --json labels --jq '[.labels[].name]'
+```
+
+Expected: `["Ready for QA"]` (or the automation sets it after push)
+
+- [ ] **Step 4: If automation doesn't set Ready for QA within 30 seconds, set it manually**
+
+The on-push job will reset to `Awaiting CI`, but since this is a `pull_request`-triggered workflow it runs from the PR branch with the new code. After CI passes, the old `on-ci-pass` on main will try to promote. If it fails (chicken-and-egg), manually set:
+
+```bash
+gh pr edit 108 --remove-label "Awaiting CI" --add-label "Ready for QA"
+```
diff --git a/docs/superpowers/specs/2026-03-30-pr-label-state-machine-design.md b/docs/superpowers/specs/2026-03-30-pr-label-state-machine-design.md
new file mode 100644
index 0000000..18a73a4
--- /dev/null
+++ b/docs/superpowers/specs/2026-03-30-pr-label-state-machine-design.md
@@ -0,0 +1,176 @@
+# PR Label State Machine Design
+
+**Date:** 2026-03-30
+**Status:** Approved
+**Scope:** `.github/workflows/pr-labels.yml`, `.github/workflows/pr-labels-ci.yml`
+
+## Problem
+
+The PR label automation grew organically and had several bugs:
+- `workflow_run` trigger fired on merges to main (no associated PR), causing failure emails
+- `Dev Active` blocked `Awaiting CI` from being added, creating a dead end
+- `opened` event was missing, so new PRs never received `Awaiting CI`
+- `Ready for QA` didn't clean up `Awaiting CI`, allowing label coexistence
+- No CI failure label — GitHub's soft failures (Codecov) aren't always blocking, but the team applies stricter standards
+- `QA Invalidated` was added without user direction and adds complexity without value
+
+These were discovered reactively across 4 incremental commits instead of being caught upfront.
+
+## Design
+
+### States
+
+Exactly one workflow label is active at a time, except `Dev Active` which coexists with `Awaiting CI`.
+
+| Label | Meaning |
+|-------|---------|
+| `Dev Active` | Dev is working on this PR. Don't promote even if CI passes. Set manually by the dev. |
+| `Awaiting CI` | CI is running. Waiting for result. |
+| `CI Failed` | CI failed (hard failure or soft/Codecov). Dev needs to fix. |
+| `Ready for QA` | CI passed, QA can begin testing. |
+| `QA Active` | QA is actively reviewing. |
+| `Ready for QA Signoff` | QA finished review, waiting for sign-off decision. |
+| `QA Failed` | QA found issues. Back to dev. |
+| `QA Approved` | QA passed. Merge is unblocked (satisfies QA Gate check). |
+
+### Removed
+
+- `QA Invalidated` — removed entirely. Push resets labels and the PR comment is sufficient notification that QA's review was invalidated.
+
+### State Diagram
+
+```mermaid
+stateDiagram-v2
+ [*] --> Awaiting_CI : PR opened
+ Dev_Active --> Awaiting_CI : push (coexist)
+ Awaiting_CI --> Ready_for_QA : CI pass (no Dev Active)
+ Awaiting_CI --> CI_Failed : CI fail (no Dev Active)
+ CI_Failed --> Awaiting_CI : push (fix)
+ Ready_for_QA --> QA_Active : QA starts
+ QA_Active --> Ready_for_QA_Signoff : signoff
+ QA_Active --> QA_Failed : fail
+ Ready_for_QA_Signoff --> QA_Failed : fail at signoff
+ Ready_for_QA_Signoff --> QA_Approved : approved
+ QA_Approved --> [*] : merge
+ QA_Failed --> Dev_Active : dev picks up
+
+ state Dev_Active {
+ direction LR
+ [*] --> holding : manual set
+ holding --> holding : push (CI runs, no promotion)
+ }
+
+ note right of Dev_Active
+ Coexists with Awaiting CI.
+ CI runs but does not promote.
+ Removing Dev Active triggers
+ promotion if CI already passed.
+ end note
+
+ note right of CI_Failed
+ Covers hard failures (lint, tests)
+ and soft failures (Codecov).
+ Cleaned up on next push or CI pass.
+ end note
+```
+
+### Dev Active Semantics
+
+`Dev Active` is a **manual hold** that coexists with `Awaiting CI`:
+
+- Dev sets `Dev Active` before pushing to signal they're not done
+- Push adds `Awaiting CI` alongside `Dev Active` (both present)
+- CI runs normally. If CI passes, `on-ci-pass` sees `Dev Active` and **does not promote**
+- If CI fails with `Dev Active` present, **nothing happens** (dev is already working)
+- When dev removes `Dev Active`, `on-unlabel` checks CI status:
+ - CI already passed -> promote to `Ready for QA`
+ - CI not passed -> `Awaiting CI` is already present, `on-ci-pass` will promote when CI passes
+
+### Transition Rules
+
+#### on-push (PR opened or synchronized)
+
+Trigger: `pull_request: [opened, synchronize]`
+File: `pr-labels.yml`
+
+1. Remove all QA/workflow labels: `Ready for QA`, `Ready for QA Signoff`, `QA Approved`, `QA Active`, `QA Failed`, `CI Failed`
+2. **Keep `Dev Active` if present** — don't remove it
+3. Add `Awaiting CI` (unless already present)
+4. If `QA Active` was present, add comment: "New commits pushed while QA was active."
+
+#### on-ci-pass (CI completes successfully)
+
+Trigger: `workflow_run: completed` (success, PR branch, not default branch)
+File: `pr-labels-ci.yml`
+
+1. Look up associated PR (API with 404 safety, fallback to branch search)
+2. If no PR found, exit silently
+3. If `Dev Active` present, exit (don't promote)
+4. If `Awaiting CI` present: remove `Awaiting CI`, remove `CI Failed` if present, add `Ready for QA`
+
+#### on-ci-fail (CI fails)
+
+Trigger: `workflow_run: completed` (failure, PR branch, not default branch)
+File: `pr-labels-ci.yml`
+
+1. Look up associated PR (same pattern as on-ci-pass)
+2. If no PR found, exit silently
+3. If `Dev Active` present, exit (dev is already working)
+4. If `Awaiting CI` present: remove `Awaiting CI`, add `CI Failed`
+
+#### on-unlabel (Dev Active removed)
+
+Trigger: `pull_request: unlabeled` where label is `Dev Active`
+File: `pr-labels.yml`
+
+1. Check if CI already passed for head commit
+2. If CI passed: remove `Awaiting CI` if present, add `Ready for QA`
+3. If CI not passed: ensure `Awaiting CI` is present (should already be)
+
+#### on-label (cleanup rules)
+
+Trigger: `pull_request: labeled` for specific labels
+File: `pr-labels.yml`
+
+| Label Added | Remove |
+|-------------|--------|
+| `Ready for QA` | `Awaiting CI`, `CI Failed` |
+| `QA Active` | `Ready for QA` |
+| `Dev Active` | `QA Failed`, `CI Failed`, `Awaiting CI`, `Ready for QA` |
+| `Ready for QA Signoff` | `QA Active` |
+| `QA Failed` | `QA Active`, `Ready for QA Signoff` |
+| `QA Approved` | `Ready for QA Signoff` |
+
+### Edge Cases
+
+| Scenario | Behavior |
+|----------|----------|
+| Dev sets `Dev Active`, pushes 3 times | Each push resets `Awaiting CI`, `Dev Active` persists, CI runs but doesn't promote |
+| Dev removes `Dev Active` after CI passed | `on-unlabel` promotes to `Ready for QA` |
+| Dev removes `Dev Active` before CI passed | `Awaiting CI` already present, `on-ci-pass` promotes later |
+| CI fails without `Dev Active` | `CI Failed` added, dev pushes fix -> `Awaiting CI` |
+| CI fails with `Dev Active` | Nothing, dev is already working |
+| QA fails, dev adds `Dev Active`, pushes fix | `Dev Active` cleans up `QA Failed`, push adds `Awaiting CI`, flow restarts |
+| New PR opened without `Dev Active` | Straight to `Awaiting CI` |
+| Codecov fails but GitHub doesn't block | `CI Failed` label makes stricter standard visible |
+| QA fails at signoff stage | `QA Failed` removes `Ready for QA Signoff`, back to dev |
+
+### File Structure
+
+Two workflow files, split by trigger type:
+
+- **`pr-labels.yml`** — `on: pull_request [opened, synchronize, labeled, unlabeled]`
+ - Jobs: `on-push`, `on-unlabel`, `on-label`
+- **`pr-labels-ci.yml`** — `on: workflow_run [completed]`
+ - Jobs: `on-ci-pass`, `on-ci-fail`
+
+Rationale: `workflow_run` fires on every CI completion (including merges to main). Separating it prevents noisy failures when there's no associated PR. The `head_branch != default_branch` filter plus safe 404 handling provide defense in depth.
+
+### Known Limitation
+
+`workflow_run`-triggered workflows always run from the default branch (main). Changes to `pr-labels-ci.yml` cannot be tested on a PR branch — they take effect only after merge. The `pull_request`-triggered jobs in `pr-labels.yml` DO run from the PR branch and can be tested normally.
+
+### QA Gate
+
+`qa-gate.yml` is unchanged. It sets a GitHub status check based on the `QA Approved` label. This is the merge gate — independent of the label automation.
+