diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 6c2a2224f..9c1a5011a 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -119,6 +119,7 @@ jobs: 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 \ diff --git a/.github/workflows/persona-runner-reusable.yml b/.github/workflows/persona-runner-reusable.yml new file mode 100644 index 000000000..f948dfdeb --- /dev/null +++ b/.github/workflows/persona-runner-reusable.yml @@ -0,0 +1,209 @@ +# 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/` mention and fires a `persona-mention` +# repository_dispatch at this repo. This workflow receives it and runs the +# addressed persona's advisory prompt (prompts//advisory.md), posting exactly +# one advisory comment that carries the '' recursion marker. +# +# ONE runner for EVERY persona — manifest/convention driven, not one workflow per +# persona (§4.1). +# +# Read/write split (the recursion guarantee): +# The AGENT gathers context and PRINTS the advisory between sentinels — it runs +# with github.token, which reads the (public) source repo but CANNOT post to +# it (that token is scoped to THIS repo). The WORKFLOW then enforces the marker +# and posts, using GH_PAT_WORKFLOWS. So an agent that forgets the marker cannot +# post an unmarked comment, because it cannot post at all. Prompt-only marker +# enforcement is how .github-private#860 happened (1,481 acks in 4.5h). +# +# Requires: +# CLAUDE_CODE_OAUTH_TOKEN — org secret, the engine credential +# GH_PAT_WORKFLOWS — org PAT: the WORKFLOW uses it to post the comment on +# the cross-repo item. The agent never sees it. +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: {} + +concurrency: + # One run per (persona, item). item_number is empty for discussions, so key on + # comment_url too — otherwise every discussion mention for one persona+repo + # would collapse into a single group and drop concurrent advisories. + group: persona-runner-${{ inputs.persona }}-${{ inputs.source_repo }}-${{ inputs.item_number }}-${{ inputs.comment_url }} + cancel-in-progress: false + +jobs: + run: + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read # checkout THIS repo's tooling + prompts. The agent reads + # the public source repo with github.token; the comment is + # posted by the workflow with GH_PAT_WORKFLOWS. + 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. Distinguish the two failure modes: + # rc 2 = malformed id (a bad/hostile payload) — FAIL loudly, do not + # mask it as a benign skip. + # rc 3 = no advisory prompt for a well-formed id — benign ("persona + # has no runtime wired yet"), skip quietly. + rc=0 + prompt_path="$(pr_require_advisory "$PERSONA" .)" || rc=$? + case "$rc" in + 0) + echo "skip=false" >> "$GITHUB_OUTPUT" + echo "prompt_path=${prompt_path}" >> "$GITHUB_OUTPUT" + echo "marker=$(pr_agent_marker "$PERSONA")" >> "$GITHUB_OUTPUT" + ;; + 3) + echo "::notice::persona '${PERSONA}' has no advisory runtime — nothing to do" + echo "skip=true" >> "$GITHUB_OUTPUT" + ;; + *) + echo "::error::rejecting persona-mention dispatch for malformed persona id '${PERSONA}'" + exit 1 + ;; + esac + + - 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: Produce the advisory (agent — read only, no post) + if: steps.resolve.outputs.skip == 'false' + id: advise + env: + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + # github.token: reads the PUBLIC source repo, cannot post to it (scoped + # to THIS repo). The agent deliberately never receives GH_PAT_WORKFLOWS. + GH_TOKEN: ${{ github.token }} + 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" + + { + echo "You are running headless for persona '${PERSONA}'. The work item details and summoner are provided in the environment variables SOURCE_REPO, ITEM_NUMBER, COMMENT_URL, and REQUESTED_BY." + echo "Follow your instructions exactly. PRINT the advisory between the" + echo "sentinels; do not attempt to post it (you cannot)." + echo + cat "$PROMPT_PATH" + } > /tmp/persona-prompt.txt + + _out=$(mktemp); _rc=0 + claude --print \ + --permission-mode bypassPermissions \ + --allowedTools Bash \ + --fallback-model opus \ + -p "$(cat /tmp/persona-prompt.txt)" > "$_out" 2>&1 || _rc=$? + + if [ "$_rc" -ne 0 ]; then + if 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)" + echo "skip_post=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + cat "$_out" + echo "::error::persona agent failed (rc=$_rc)" + exit "$_rc" + fi + + # Stash raw agent output for the post step to parse (multi-line, so a + # file, not a step output). + cp "$_out" "$RUNNER_TEMP/agent-out.txt" + echo "skip_post=false" >> "$GITHUB_OUTPUT" + + - name: Enforce marker and post the comment (workflow — the only writer) + if: steps.resolve.outputs.skip == 'false' && steps.advise.outputs.skip_post == 'false' + env: + # The PAT lives ONLY here. The agent step above never had it. + GH_TOKEN: ${{ secrets.GH_PAT_WORKFLOWS }} + PERSONA: ${{ inputs.persona }} + SOURCE_REPO: ${{ inputs.source_repo }} + ITEM_NUMBER: ${{ inputs.item_number }} + run: | + set -euo pipefail + # shellcheck source=scripts/lib/persona-runner.sh + source scripts/lib/persona-runner.sh + + raw="$(cat "$RUNNER_TEMP/agent-out.txt")" + body="$(pr_extract_advisory "$raw")" + if [ -z "${body//[[:space:]]/}" ]; then + echo "::warning::agent produced no advisory between the sentinels for ${SOURCE_REPO}#${ITEM_NUMBER} — nothing to post" + exit 0 + fi + + # Mechanically guarantee the recursion marker — the agent could not post + # unmarked (no write token), and now cannot post unmarked via us either. + body="$(pr_ensure_marker "$PERSONA" "$body")" + + if [ -z "${ITEM_NUMBER}" ]; then + echo "::notice::discussion advisory not yet supported by the runner post-path — skipping post for ${SOURCE_REPO}" + exit 0 + fi + + # Issue-comments endpoint serves PRs too. + gh api "repos/${SOURCE_REPO}/issues/${ITEM_NUMBER}/comments" -f body="$body" >/dev/null + echo "Posted ${PERSONA} advisory on ${SOURCE_REPO}#${ITEM_NUMBER}." diff --git a/.github/workflows/persona-runner.yml b/.github/workflows/persona-runner.yml new file mode 100644 index 000000000..3b943c5ae --- /dev/null +++ b/.github/workflows/persona-runner.yml @@ -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 diff --git a/prompts/qa-lead/advisory.md b/prompts/qa-lead/advisory.md new file mode 100644 index 000000000..b2659e615 --- /dev/null +++ b/prompts/qa-lead/advisory.md @@ -0,0 +1,107 @@ +# 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. Your job is to produce **one written test-risk advisory** — you advise, you +never write code, open PRs, or mutate anything. + +**You do NOT post the comment yourself. You PRINT it** (see "Output", below); the +workflow posts it for you. You have read-only access and no ability to write to +the target repo — this is deliberate. + +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 its *substance* non-interactively: + +```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 anyone +anything — this is headless. Take its substance (risk-based test strategy, +fixture architecture, ATDD, API/UI automation, CI/CD quality gates, +flakiness-as-critical-tech-debt) and write the advisory. + +## Inputs (environment variables) + +- `SOURCE_REPO` — `owner/name` the item lives in (a public repo). +- `ITEM_NUMBER` — the issue or PR number (empty for a discussion). +- `COMMENT_URL` — the **API** URL of the summoning comment (the router sends the + comment's `.url`, so `gh api "$COMMENT_URL"` returns it directly). It may be + empty; if so, work from the item's title/body/diff. +- `REQUESTED_BY` — the login of the human who mentioned you. +- `AGENT_MARKER` — the exact marker string; see "Output". + +## Steps + +1. **Read the skill** (above) so the advice reflects `bmad-tea`, not generic + testing folklore. + +2. **Gather the item's context**, 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 -n 400 || true` + - Issue: `gh issue view "$ITEM_NUMBER" --repo "$SOURCE_REPO" --json title,body,labels` + - Read the exact question you were asked: + ```bash + gh api "$COMMENT_URL" --jq '.body' 2>/dev/null || true + ``` + If that fails, proceed from the item title/body/diff alone. + Never fetch anything you were not asked about. Never run a write command; you + have no token that could post, so do not try. + +3. **Assess** through the Test Architect lens. Calculate **risk vs value** — do + not manufacture risk on a well-covered change. Cover: the risk tier and why; + the highest-leverage test gaps (missing negative paths, wrong test level, + absent NFR/perf, fixture/isolation problems, flakiness); and whether anything + is severe enough to **escalate** (you cannot block, only say so). + +## Output — how you deliver the advisory + +Print the comment body **between these exact sentinel lines**, each alone on its +own line, and print nothing else after the closing sentinel: + +```text +===PERSONA-ADVISORY-BEGIN=== + +===PERSONA-ADVISORY-END=== +``` + +The workflow reads what is between the sentinels, guarantees the recursion +marker, and posts it. Anything you print outside the sentinels is ignored. + +### Comment body shape + +The very first line of the body **must** be the exact value of `$AGENT_MARKER`, +alone on its line: + +```text + +## QA Lead — test-risk advisory + +**Risk tier:** LOW | MEDIUM | HIGH — one clause on why. + +**What I'd shore up** (highest leverage first, up to 4 bullets): +- … + +**Escalate?** yes/no — if yes, the single reason. + +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. +``` + +## Rules (non-negotiable) + +- **First body line = `$AGENT_MARKER` exactly.** The workflow will prepend it if + you forget, but write it yourself — it is the recursion guard the whole + framework depends on. +- **Never write a literal `@petry-projects/`** anywhere in the body. Naming + a live persona handle in your own output is a way to self-trigger the fleet. + Refer to roles in prose ("the dev-lead persona"), never as a handle. +- **Advisory only.** No writes of any kind. Every `gh` call is read-only. +- **One advisory.** If the item is out of scope for test strategy, print a short + body saying so between the sentinels rather than nothing. +- **Stay in your lane.** Test strategy and quality. If asked for something else, + say briefly in prose that it is outside the QA Lead role and name the role that + fits. +- Keep the body under ~250 words. Concrete over exhaustive. diff --git a/scripts/lib/persona-runner.sh b/scripts/lib/persona-runner.sh new file mode 100644 index 000000000..ec3ccbe31 --- /dev/null +++ b/scripts/lib/persona-runner.sh @@ -0,0 +1,124 @@ +# 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/` 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//advisory.md) exactly as the router resolves the manifest by +# convention (personas//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 ''. The +# router's pm_is_agent_comment skips any comment carrying '' "$PR_MARKER_PREFIX" "$1" +} + +# pr_valid_persona_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 — 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 — 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 — 0 if the body's FIRST line is the +# persona's marker. +# +# CRLF: GitHub API/webhook bodies often use '\r\n', and `read` keeps the '\r', +# so the raw first line would be '\r' and never match. +# Strip it. +pr_comment_has_marker() { + local id="$1" body="$2" first marker + marker="$(pr_agent_marker "$id")" + IFS= read -r first <<<"$body" + first="${first%$'\r'}" + [ "$first" = "$marker" ] +} + +# pr_extract_advisory — emit the advisory body the agent placed +# between the sentinels, or nothing. +# +# The agent does NOT post its own comment (it has no token that can write to the +# source repo — see the reusable). It PRINTS the body between two sentinels, and +# the workflow posts it. That is what makes the marker MECHANICALLY enforced +# rather than merely prompt-requested: an agent that forgets the marker cannot +# post an unmarked comment, because it cannot post at all. Prompt-only +# enforcement is how #860 happened (1,481 acks in 4.5h). +PR_ADVISORY_BEGIN='===PERSONA-ADVISORY-BEGIN===' +PR_ADVISORY_END='===PERSONA-ADVISORY-END===' +pr_extract_advisory() { + printf '%s' "$1" | awk -v b="$PR_ADVISORY_BEGIN" -v e="$PR_ADVISORY_END" ' + $0 == b { grab = 1; next } + $0 == e { grab = 0 } + grab { print } + ' +} + +# pr_ensure_marker — echo the body guaranteed to start with +# the persona marker: unchanged if it already leads with it, otherwise the +# marker is prepended. The workflow calls this on the extracted advisory before +# posting, so the recursion guard holds even if the agent omitted the marker. +pr_ensure_marker() { + local id="$1" body="$2" marker + marker="$(pr_agent_marker "$id")" + if pr_comment_has_marker "$id" "$body"; then + printf '%s' "$body" + else + printf '%s\n%s' "$marker" "$body" + fi +} diff --git a/tests/persona_runner.bats b/tests/persona_runner.bats new file mode 100644 index 000000000..98ed94614 --- /dev/null +++ b/tests/persona_runner.bats @@ -0,0 +1,158 @@ +#!/usr/bin/env bats +# Unit tests for the persona runtime core (scripts/lib/persona-runner.sh). +# Standard: petry-projects/.github standards/persona-standards.md §4.1. +# +# The runner is a SEPARATE trust boundary from the router: a repository_dispatch +# can be sent by anything with a token, so payload re-validation and the +# write-side recursion marker get the most coverage here. + +SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)" +LIB="$SCRIPT_DIR/scripts/lib/persona-runner.sh" + +setup() { + # shellcheck source=/dev/null + source "$LIB" + ROOT="$(mktemp -d "$BATS_TEST_TMPDIR/root.XXXXXX")" +} + +# --- the recursion marker (shared with the router) ------------------------- + +@test "pr_agent_marker matches the router's marker prefix exactly" { + run pr_agent_marker qa-lead + [ "$output" = "" ] +} + +@test "pr_comment_has_marker accepts a body whose first line is the marker" { + run pr_comment_has_marker qa-lead "$(printf '\n## advisory\nbody')" + [ "$status" -eq 0 ] +} + +@test "pr_comment_has_marker accepts a body with CRLF line endings" { + run pr_comment_has_marker qa-lead "$(printf '\r\n## advisory\r\nbody')" + [ "$status" -eq 0 ] +} + +@test "pr_comment_has_marker rejects a body missing the marker" { + run pr_comment_has_marker qa-lead "$(printf '## advisory\nno marker')" + [ "$status" -ne 0 ] +} + +@test "pr_comment_has_marker rejects the marker on a later line (must be first)" { + run pr_comment_has_marker qa-lead "$(printf 'preamble\n')" + [ "$status" -ne 0 ] +} + +@test "pr_comment_has_marker rejects the wrong persona's marker" { + run pr_comment_has_marker qa-lead "$(printf '\nbody')" + [ "$status" -ne 0 ] +} + +# --- payload re-validation (this runner is its own trust boundary) --------- + +@test "pr_valid_persona_id accepts a kebab-case slug" { + run pr_valid_persona_id qa-lead + [ "$status" -eq 0 ] +} + +@test "pr_valid_persona_id accepts a single-token slug" { + run pr_valid_persona_id murat + [ "$status" -eq 0 ] +} + +@test "pr_valid_persona_id rejects an empty id" { + run pr_valid_persona_id "" + [ "$status" -ne 0 ] +} + +@test "pr_valid_persona_id rejects a path-traversal id" { + run pr_valid_persona_id "../../etc/passwd" + [ "$status" -ne 0 ] +} + +@test "pr_valid_persona_id rejects uppercase, spaces, and slashes" { + run pr_valid_persona_id "QA Lead" + [ "$status" -ne 0 ] + run pr_valid_persona_id "qa/lead" + [ "$status" -ne 0 ] +} + +@test "pr_valid_persona_id rejects leading, trailing, and doubled hyphens" { + run pr_valid_persona_id "-qa" + [ "$status" -ne 0 ] + run pr_valid_persona_id "qa-" + [ "$status" -ne 0 ] + run pr_valid_persona_id "qa--lead" + [ "$status" -ne 0 ] +} + +# --- advisory prompt resolution (convention-driven) ------------------------ + +@test "pr_advisory_prompt_path follows the prompts//advisory.md convention" { + run pr_advisory_prompt_path qa-lead + [ "$output" = "prompts/qa-lead/advisory.md" ] +} + +@test "pr_require_advisory returns the path when the prompt exists" { + mkdir -p "$ROOT/prompts/qa-lead" + echo "prompt" > "$ROOT/prompts/qa-lead/advisory.md" + run pr_require_advisory qa-lead "$ROOT" + [ "$status" -eq 0 ] + [ "$output" = "prompts/qa-lead/advisory.md" ] +} + +@test "pr_require_advisory fails (rc 3) with a diagnostic when the prompt is absent" { + # A persona can declare an address before its runtime exists; the runner must + # say so, not crash or invoke an empty prompt. + run pr_require_advisory scrum-master "$ROOT" + [ "$status" -eq 3 ] + [[ "$output" == *"no runtime wired yet"* ]] +} + +@test "pr_require_advisory fails (rc 2) on a malformed id BEFORE touching the fs" { + # Guards the path interpolation: a bad id must never reach the filesystem read. + run pr_require_advisory "../../etc" "$ROOT" + [ "$status" -eq 2 ] + [[ "$output" == *"malformed persona id"* ]] +} + +# --- marker enforced MECHANICALLY, not just prompt-requested ---------------- +# The agent prints between sentinels and cannot post (no write token). The +# workflow extracts, guarantees the marker, and posts. These pin that path. + +@test "pr_comment_has_marker accepts a CRLF body (GitHub uses \\r\\n)" { + run pr_comment_has_marker qa-lead "$(printf '\r\n## advisory\r\nbody')" + [ "$status" -eq 0 ] +} + +@test "pr_extract_advisory pulls only the text between the sentinels" { + raw="$(printf 'chatter before\n===PERSONA-ADVISORY-BEGIN===\n\nrisk: low\n===PERSONA-ADVISORY-END===\ntrailing noise')" + run pr_extract_advisory "$raw" + [ "${lines[0]}" = "" ] + [ "${lines[1]}" = "risk: low" ] + [ "${#lines[@]}" -eq 2 ] +} + +@test "pr_extract_advisory yields nothing when the sentinels are absent" { + run pr_extract_advisory "the agent rambled but emitted no sentinels" + [ -z "$output" ] +} + +@test "pr_ensure_marker leaves an already-marked body unchanged" { + body="$(printf '\n## advisory')" + run pr_ensure_marker qa-lead "$body" + [ "$output" = "$body" ] +} + +@test "pr_ensure_marker PREPENDS the marker when the agent forgot it" { + run pr_ensure_marker qa-lead "$(printf '## advisory\nno marker here')" + [ "${lines[0]}" = "" ] + [ "${lines[1]}" = "## advisory" ] +} + +@test "pr_ensure_marker output always satisfies pr_comment_has_marker" { + # The invariant the workflow relies on: whatever the agent produced, the posted + # body starts with the marker. + ensured="$(pr_ensure_marker qa-lead 'agent forgot the marker entirely')" + run pr_comment_has_marker qa-lead "$ensured" + [ "$status" -eq 0 ] +}