Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@
tests/test_readme_refresh_push.bats \
tests/test_validate_personas.bats \
tests/verify_persona_teams.bats \
tests/persona_runner.bats \
tests/test_dev_lead_concurrency_guard.bats \
tests/test_validate_workflow_schedules.bats \
tests/caller_stub_freeze.bats \
Expand Down Expand Up @@ -190,7 +191,7 @@
timeout-minutes: 5
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
# NOTE: persist-credentials is intentionally left at the default (true) —

Check warning on line 194 in .github/workflows/lint.yml

View workflow job for this annotation

GitHub Actions / Lint

194:9 [comments-indentation] comment not indented like content
# vci_resolve_reusable needs `git fetch origin` to authenticate against this
# private repo when resolving same-repo channel tags. Setting
# persist-credentials: false would silently push every such caller into the
Expand Down
154 changes: 154 additions & 0 deletions .github/workflows/persona-runner-reusable.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
# Persona Runtime — Reusable (dispatch side of the mention framework).
# This file is the source of truth. Caller: .github/workflows/persona-runner.yml.
# Standard: https://github.com/petry-projects/.github/blob/main/standards/persona-standards.md (§4.1)
#
# The router (petry-projects/.github persona-mention-reusable.yml) resolves an
# `@petry-projects/<role>` mention and fires a `persona-mention`
# repository_dispatch at this repo. This workflow receives it and runs the
# addressed persona's advisory prompt (prompts/<id>/advisory.md), posting exactly
# one advisory comment that carries the '<!-- persona:<id> -->' recursion marker.
#
# ONE runner for EVERY persona — manifest/convention driven, not one workflow per
# persona (§4.1). Advisory only: this runtime is granted no write beyond posting
# a comment, and the prompt forbids anything else.
#
# Requires:
# CLAUDE_CODE_OAUTH_TOKEN — org secret, the engine credential
# GH_PAT_WORKFLOWS — org PAT: read the target item cross-repo, post the
# comment. A PAT (not github.token) because the item
# lives in a DIFFERENT repo than this workflow.
name: Persona Runtime — Reusable

on:
workflow_call:
inputs:
persona:
required: true
type: string
source_repo:
required: true
type: string
item_number:
required: false
type: string
default: ''
comment_url:
required: false
type: string
default: ''
requested_by:
required: false
type: string
default: ''
mode:
required: false
type: string
default: 'advisory'
secrets:
CLAUDE_CODE_OAUTH_TOKEN:
required: true
GH_PAT_WORKFLOWS:
required: true

permissions: {} # nothing from github.token; all cross-repo work uses the PAT

concurrency:
# One run per (persona, item). A second mention while one is in flight is
# dropped rather than stacked — the advisory would be redundant.
group: persona-runner-${{ inputs.persona }}-${{ inputs.source_repo }}-${{ inputs.item_number }}
cancel-in-progress: false
Comment thread
don-petry marked this conversation as resolved.
Resolved

jobs:
run:
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read # checkout of THIS repo's tooling + prompts; all cross-repo
# reads and the comment-post use GH_PAT_WORKFLOWS, not this.

Check warning on line 67 in .github/workflows/persona-runner-reusable.yml

View workflow job for this annotation

GitHub Actions / Lint

67:24 [comments-indentation] comment not indented like content
steps:
- name: Checkout persona tooling + prompts
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 1
persist-credentials: false

- name: Resolve the persona's advisory prompt
id: resolve
env:
PERSONA: ${{ inputs.persona }}
run: |
set -euo pipefail
# shellcheck source=scripts/lib/persona-runner.sh
source scripts/lib/persona-runner.sh

# This runner is a SEPARATE trust boundary from the router: a
# repository_dispatch can be sent by anything holding a token, so the
# payload is re-validated here rather than trusted. A malformed id must
# never reach a path interpolation.
if ! prompt_path="$(pr_require_advisory "$PERSONA" .)"; then
echo "::notice::persona '${PERSONA}' has no advisory runtime — nothing to do"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
Comment thread
don-petry marked this conversation as resolved.
Resolved
echo "skip=false" >> "$GITHUB_OUTPUT"
echo "prompt_path=${prompt_path}" >> "$GITHUB_OUTPUT"
echo "marker=$(pr_agent_marker "$PERSONA")" >> "$GITHUB_OUTPUT"

- name: Install Claude Code
if: steps.resolve.outputs.skip == 'false'
env:
CLAUDE_CODE_VERSION: ${{ vars.CLAUDE_CODE_VERSION || 'latest' }}
run: |
set -euo pipefail
mkdir -p "$HOME/.npm-global"
npm config set prefix "$HOME/.npm-global"
echo "$HOME/.npm-global/bin" >> "$GITHUB_PATH"
npm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}"

- name: Run the persona advisory
if: steps.resolve.outputs.skip == 'false'
env:
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# The PAT posts the comment and reads the cross-repo item. Exported as
# GH_TOKEN for the gh calls the agent makes from inside the prompt.
GH_TOKEN: ${{ secrets.GH_PAT_WORKFLOWS }}
PERSONA: ${{ inputs.persona }}
SOURCE_REPO: ${{ inputs.source_repo }}
ITEM_NUMBER: ${{ inputs.item_number }}
COMMENT_URL: ${{ inputs.comment_url }}
REQUESTED_BY: ${{ inputs.requested_by }}
AGENT_MARKER: ${{ steps.resolve.outputs.marker }}
PROMPT_PATH: ${{ steps.resolve.outputs.prompt_path }}
run: |
set -euo pipefail
export PATH="$HOME/.npm-global/bin:$PATH"

# The advisory prompt is the persona's instructions; the header pins the
# concrete item so the agent does not have to be told twice. Fields go in
# via env (already exported) — never interpolated into the prompt text,
# which the agent could be steered by.
{
echo "You are running headless for persona '${PERSONA}'. The work item is"
echo "${SOURCE_REPO}#${ITEM_NUMBER} (${COMMENT_URL}); ${REQUESTED_BY} summoned you."
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
echo "Follow your instructions exactly. Post ONE comment; its first line"
echo "MUST be exactly: ${AGENT_MARKER}"
echo
cat "$PROMPT_PATH"
} > /tmp/persona-prompt.txt

_out=$(mktemp); _rc=0
claude --print \
--permission-mode bypassPermissions \
--allowedTools Bash \
--fallback-model opus \
Comment thread
don-petry marked this conversation as resolved.
Resolved
-p "$(cat /tmp/persona-prompt.txt)" 2>&1 | tee "$_out" || _rc=${PIPESTATUS[0]}

# Rate-limit is a soft skip, matching ci-failure-analyst: do not fail the
# run (and do not retry-storm), just leave the mention un-answered.
if [ "$_rc" -ne 0 ] && \
grep -qiE "hit your.*(session )?limit|rate.?limit|usage limit|quota exceeded|too many requests" "$_out"; then
echo "::notice::engine rate-limited — leaving ${SOURCE_REPO}#${ITEM_NUMBER} un-answered (a future mention retries)"
rm -f "$_out"; exit 0
fi
rm -f "$_out"
exit "$_rc"
33 changes: 33 additions & 0 deletions .github/workflows/persona-runner.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Persona Runtime — caller (receives the router's dispatch).
# SOURCE OF TRUTH: petry-projects/.github-private/.github/workflows/persona-runner-reusable.yml
# Standard: https://github.com/petry-projects/.github/blob/main/standards/persona-standards.md (§4.1)
#
# The mention router (in petry-projects/.github) fires `persona-mention` at this
# repo with client_payload {persona, mode, source_repo, item_number, comment_url,
# requested_by}. This stub hands that payload to the reusable, which runs the
# addressed persona's advisory prompt.
#
# Thin caller: no logic here beyond mapping the payload to inputs. All behaviour
# lives in the reusable. This repo is the `next` ring — the first, smallest
# blast-radius soak for the persona runtime.
name: Persona Runtime

on:
repository_dispatch:
types: [persona-mention]

permissions: {}

jobs:
run:
permissions:
contents: read
uses: ./.github/workflows/persona-runner-reusable.yml
with:
persona: ${{ github.event.client_payload.persona }}
source_repo: ${{ github.event.client_payload.source_repo }}
item_number: ${{ github.event.client_payload.item_number }}
comment_url: ${{ github.event.client_payload.comment_url }}
requested_by: ${{ github.event.client_payload.requested_by }}
mode: ${{ github.event.client_payload.mode }}
secrets: inherit
100 changes: 100 additions & 0 deletions prompts/qa-lead/advisory.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# QA Lead — advisory (headless)

You are **QA Lead**, the org's Master Test Architect & Quality Advisor persona
(`personas/qa-lead/persona.yml`). You have been `@`-mentioned on a GitHub work
item and must post **exactly one advisory comment** — you advise, you never
write code, open PRs, or mutate the repo.

Your expertise is the vendored **BMAD Test Architecture** agent (`bmad-tea`,
pinned `v1.19.0`). Read its skill by path — it is checked out in this repo — and
apply it non-interactively to the item at hand:

```bash
cat frameworks/bmad-test-architecture/src/agents/bmad-tea/SKILL.md
ls frameworks/bmad-test-architecture/src/workflows/testarch/
```

Do **not** run the skill's interactive "On Activation" greeting or ask the user
anything — this is headless. Take the skill's *substance* (risk-based test
strategy, fixture architecture, ATDD, API/UI automation, CI/CD quality gates,
flakiness-as-critical-tech-debt) and produce a written advisory.

## Inputs (provided as environment variables)

- `PERSONA` — always `qa-lead` here.
- `SOURCE_REPO` — `owner/name` of the repo the item lives in.
- `ITEM_NUMBER` — the issue or PR number (empty for a discussion).
- `COMMENT_URL` — the html_url of the comment that summoned you.
Comment thread
don-petry marked this conversation as resolved.
Resolved
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
- `REQUESTED_BY` — the login of the human who mentioned you.
- `AGENT_MARKER` — the exact HTML comment marker you MUST place on the first
line of your comment (see Rules).
Comment thread
don-petry marked this conversation as resolved.
Resolved

## Steps

1. **Read the skill** (commands above) so your advice reflects `bmad-tea`, not
generic testing folklore.

2. **Gather the item's context** with `gh`, read-only:
- PR: `gh pr view "$ITEM_NUMBER" --repo "$SOURCE_REPO" --json title,body,files`
and `gh pr diff "$ITEM_NUMBER" --repo "$SOURCE_REPO" | head -400`
Comment thread
don-petry marked this conversation as resolved.
Resolved
- Issue: `gh issue view "$ITEM_NUMBER" --repo "$SOURCE_REPO" --json title,body,labels`
- Read the summoning comment for the specific question:
`gh api "$COMMENT_URL"` (its `.body` is what `@`-you was asked).
Never fetch anything you were not asked about; never run a write command.
Comment thread
don-petry marked this conversation as resolved.
Resolved

3. **Assess** through the Test Architect lens. Calculate **risk vs value** — do
not manufacture risk on a well-covered change. Concretely:
- the risk tier of the change (LOW / MEDIUM / HIGH) and why;
- the highest-leverage test gaps (missing negative paths, wrong test level,
absent NFR/perf consideration, fixture/isolation problems, flakiness);
- whether anything is severe enough to **escalate** (block-worthy), stated
plainly — you cannot block, only advise.

4. **Post exactly one comment** on the item:

```bash
gh api "repos/$SOURCE_REPO/issues/$ITEM_NUMBER/comments" -f body="$BODY"
```
Comment thread
don-petry marked this conversation as resolved.
Resolved

(GitHub's issue-comments endpoint serves PRs too.) For a discussion, reply on
the discussion thread referenced by `COMMENT_URL` instead.

## Comment shape

```text
<!-- persona:qa-lead --> (the exact value of $AGENT_MARKER)
## QA Lead — test-risk advisory
Comment thread
don-petry marked this conversation as resolved.
Resolved

**Risk tier:** LOW | MEDIUM | HIGH — one clause on why.

**What I'd shore up** (highest leverage first, ≤4 bullets):
- …

**Escalate?** yes/no — if yes, the single reason.

<sub>Advisory only — I comment, I do not change code. Ground: BMAD Test
Architecture (`bmad-tea` v1.19.0). Opt out on this item with the
`qa-lead:hands-off` label.</sub>
```

## Rules (non-negotiable)

- **The first line of your comment body MUST be the exact value of
`$AGENT_MARKER`** (`<!-- persona:qa-lead -->`). This is the recursion guard:
the mention router skips any comment carrying it, so without it your advisory
re-summons you and every other persona addressed in the thread — the failure
mode that produced 1,481 acks in 4.5h (`.github-private#860`). No marker, no
post.
- **Never emit a literal `@petry-projects/<role>`** anywhere in your comment.
Naming a persona handle in your own output is the *other* way to self-trigger.
Refer to roles in prose ("the dev-lead persona"), never as a live handle.
- **Advisory only.** No `gh pr create`, no pushes, no edits, no labels, no
closing/merging. Every `gh` call is either read-only or the single
comment-post above.
- **Exactly one comment.** If you cannot form a useful advisory (e.g. the item
is out of scope for test strategy), post one short comment saying so rather
than several, or nothing.
- **Stay in your lane.** You are advisory on test strategy and quality. If asked
to do something else, say briefly that it is outside the QA Lead role and name
the role that fits, in prose.
- Keep the comment under ~250 words. Concrete over exhaustive.
88 changes: 88 additions & 0 deletions scripts/lib/persona-runner.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# shellcheck shell=bash
# scripts/lib/persona-runner.sh — Persona runtime core (dispatch side)
#
# The other half of the mention framework. The router
# (petry-projects/.github scripts/lib/persona-mention.sh) resolves an
# `@petry-projects/<role>` mention and fires a `persona-mention`
# repository_dispatch at this repo; this library backs the workflow that
# receives it and runs the addressed persona's advisory prompt.
#
# ONE runner serves EVERY persona — it is manifest/convention driven, not one
# workflow per persona (§4.1: per-agent workflows are the drift the framework
# exists to prevent). It resolves the persona's advisory prompt by convention
# (prompts/<id>/advisory.md) exactly as the router resolves the manifest by
# convention (personas/<id>/persona.yml).
#
# ----------------------------------------------------------------------------
# Caller contract
# ----------------------------------------------------------------------------
# `set -euo pipefail`-safe, sourced by a parent (# shellcheck
# source=scripts/lib/persona-runner.sh). Does NOT call `set` itself and runs
# nothing at source time.
#
# ----------------------------------------------------------------------------
# The recursion marker is shared with the router, on purpose
# ----------------------------------------------------------------------------
# Every comment a persona posts MUST begin with '<!-- persona:<id> -->'. The
# router's pm_is_agent_comment skips any comment carrying '<!-- persona:', so a
# persona's own advisory cannot re-summon it (or any persona named in the
# thread). This is axis 2 of the router's guard, closed from the writing side.
# .github-private#860 burned 1,481 acks in 4.5h without it.
PR_MARKER_PREFIX='<!-- persona:'

# pr_agent_marker <persona-id> — the exact first line every advisory must carry.
pr_agent_marker() {
printf '%s%s -->' "$PR_MARKER_PREFIX" "$1"
}

# pr_valid_persona_id <id> — 0 if id is a well-formed persona slug (kebab-case).
# The dispatch payload is attacker-influenced only through the mention body,
# which the router already constrained to this shape — but this runner is a
# separate trust boundary (a repository_dispatch can be sent by anything with a
# token), so it re-validates rather than trusting the payload. A bad id must
# never reach a path interpolation.
pr_valid_persona_id() {
case "$1" in
"" ) return 1 ;;
*[!a-z0-9-]* ) return 1 ;; # only lower kebab
-* | *- ) return 1 ;; # no leading/trailing hyphen
*--* ) return 1 ;; # no doubled hyphen
* ) return 0 ;;
esac
}

# pr_advisory_prompt_path <persona-id> — repo-relative path to the persona's
# advisory prompt. Convention, mirroring the router's manifest-by-convention.
pr_advisory_prompt_path() {
printf 'prompts/%s/advisory.md' "$1"
}

# pr_require_advisory <persona-id> <repo-root> — echo the prompt path if it
# exists, else fail with a diagnostic. A missing prompt is a real answer
# ("this persona has no runtime wired yet"), not a crash: a persona can enable
# the mention surface and declare an address before its advisory prompt exists,
# and the runner must say so rather than invoke an empty prompt.
pr_require_advisory() {
local id="$1" root="${2:-.}" path
if ! pr_valid_persona_id "$id"; then
echo "persona-runner: refusing malformed persona id '$id'" >&2
return 2
fi
path="$(pr_advisory_prompt_path "$id")"
if [ ! -f "$root/$path" ]; then
echo "persona-runner: no advisory prompt at $path — persona '$id' has no runtime wired yet" >&2
return 3
fi
printf '%s\n' "$path"
}

# pr_comment_has_marker <persona-id> <body> — 0 if the body's FIRST line is the
# persona's marker. The workflow asserts this on the agent's output before
# posting: a comment without the marker would self-summon, so an unmarked
# advisory is dropped rather than posted. Belt to the prompt's suspenders.
Comment thread
don-petry marked this conversation as resolved.
Resolved
pr_comment_has_marker() {
local id="$1" body="$2" first marker
marker="$(pr_agent_marker "$id")"
IFS= read -r first <<<"$body"
[ "$first" = "$marker" ]
}
Comment thread
don-petry marked this conversation as resolved.
Resolved
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
Loading
Loading