From e72c4aad98d42199df5a9e0820a93ae1e3673d3f Mon Sep 17 00:00:00 2001 From: Pedro Camara Junior Date: Mon, 24 Aug 2026 16:30:32 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(autopilot):=20init=20skill=20=E2=80=94?= =?UTF-8?q?=20scaffold=20labels,=20issue=20templates,=20config,=20and=20lo?= =?UTF-8?q?op=20workflows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Milestone 1: /autopilot:init scaffolds a target site repo with the full control-plane surface (labels, intention/task issue forms, .autopilot/config.yml, executor/gates/metrics workflows). Adversarially reviewed: executor authenticates with AUTOPILOT_PAT so its PRs trigger gates, path-guard scoped to autopilot/task-*, branch-protection and auto-merge setup included. Co-Authored-By: Claude Fable 5 --- autopilot/docs/architecture.md | 6 + .../autopilot-executor.yml.template | 317 ++++++++++++++++++ .../autopilot-gates.yml.template | 173 ++++++++++ .../autopilot-metrics.yml.template | 94 ++++++ .../docs/init-templates/config.yml.template | 44 +++ .../init-templates/intention.yml.template | 52 +++ .../docs/init-templates/task.yml.template | 67 ++++ autopilot/skills/init/SKILL.md | 307 +++++++++++++++++ 8 files changed, 1060 insertions(+) create mode 100644 autopilot/docs/init-templates/autopilot-executor.yml.template create mode 100644 autopilot/docs/init-templates/autopilot-gates.yml.template create mode 100644 autopilot/docs/init-templates/autopilot-metrics.yml.template create mode 100644 autopilot/docs/init-templates/config.yml.template create mode 100644 autopilot/docs/init-templates/intention.yml.template create mode 100644 autopilot/docs/init-templates/task.yml.template create mode 100644 autopilot/skills/init/SKILL.md diff --git a/autopilot/docs/architecture.md b/autopilot/docs/architecture.md index 0b427f0..5271089 100644 --- a/autopilot/docs/architecture.md +++ b/autopilot/docs/architecture.md @@ -142,3 +142,9 @@ work. 2. **Owner prerequisite (blocks milestone 3):** GSC service account for listadeleitura.com.br — Google Cloud → JSON key → added as a GSC property user → stored in the site repo's Actions secrets. Verified still missing on 2026-08-24. +3. **Textual parent-intention linkage (accepted v1 gap).** The executor guard parses + `Parent intention: #` from the task issue body as plain text, not a GitHub + sub-issue relationship — any open, `intention`-labeled, `intention:approved` + intention number the task body names passes the check, whether or not that + intention is genuinely linked to the task. Tightening this to real sub-issue + verification is deferred, not abandoned. diff --git a/autopilot/docs/init-templates/autopilot-executor.yml.template b/autopilot/docs/init-templates/autopilot-executor.yml.template new file mode 100644 index 0000000..b03b1a0 --- /dev/null +++ b/autopilot/docs/init-templates/autopilot-executor.yml.template @@ -0,0 +1,317 @@ +name: autopilot-executor + +# Fires on every label change; the guard job below narrows this to a task +# issue that just received autopilot:run. +on: + issues: + types: [labeled] + +# Only one executor run at a time, repo-wide — queue rather than cancel, so a +# run that's mid-task is never interrupted by the next labeled task. +concurrency: + group: autopilot-executor + cancel-in-progress: false + +permissions: {} + +jobs: + # Guard job: the control plane enforces, the brain (or a human) only + # proposes. Everything here runs BEFORE any Claude call. + guard: + if: > + github.event.label.name == 'autopilot:run' && + contains(github.event.issue.labels.*.name, 'task') + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + outputs: + proceed: ${{ steps.decide.outputs.proceed }} + parent_number: ${{ steps.decide.outputs.parent_number }} + change_type_label: ${{ steps.decide.outputs.change_type_label }} + max_turns: ${{ steps.decide.outputs.max_turns }} + timeout_minutes: ${{ steps.decide.outputs.timeout_minutes }} + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + ref: "{{DEFAULT_BRANCH}}" + + - name: Parse task issue body + id: parse + env: + ISSUE_BODY: ${{ github.event.issue.body }} + run: | + python3 - <<'PYEOF' + import os + import re + + body = os.environ.get("ISSUE_BODY") or "" + + def section(label): + m = re.search( + rf"### {re.escape(label)}\s*\n+(.*?)(?=\n### |\Z)", body, re.S + ) + return m.group(1).strip() if m else "" + + parent_raw = section("Parent intention") + skill_raw = section("Skill to run") + skill_other = section('Skill (if "other" above)') + + parent_match = re.search(r"#?(\d+)", parent_raw) + parent_number = parent_match.group(1) if parent_match else "" + + skill = skill_raw + if skill_raw.strip().lower().startswith("other") and skill_other.strip(): + skill = skill_other.strip() + + with open(os.environ["GITHUB_OUTPUT"], "a") as f: + f.write(f"parent_number={parent_number}\n") + f.write(f"skill={skill}\n") + PYEOF + + # NOTE (accepted v1 gap, see docs/architecture.md §5): the parent link + # verified below is a plain-text reference parsed from the task issue + # body ("Parent intention: #"), not a GitHub sub-issue relationship. + # Any open, intention-labeled, intention:approved intention number the + # task body names passes this check, whether or not that intention + # actually has a real sub-issue link to this task. Tightening this to + # true sub-issue verification is deferred, not forgotten. + - name: Verify parent intention is approved + id: verify_parent + if: steps.parse.outputs.parent_number != '' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PARENT: ${{ steps.parse.outputs.parent_number }} + run: | + if ! DATA=$(gh issue view "$PARENT" --json state,labels 2>/dev/null); then + { + echo "ok=false" + echo "reason=Parent intention #$PARENT does not exist." + } >> "$GITHUB_OUTPUT" + exit 0 + fi + + STATE=$(echo "$DATA" | python3 -c 'import json,sys; print(json.load(sys.stdin)["state"])') + LABELS_OK=$(echo "$DATA" | python3 -c ' + import json, sys + d = json.load(sys.stdin) + names = {l["name"] for l in d["labels"]} + print("true" if "intention" in names and "intention:approved" in names else "false") + ') + + if [ "$STATE" != "OPEN" ] || [ "$LABELS_OK" != "true" ]; then + { + echo "ok=false" + echo "reason=Parent intention #$PARENT is not open with both the intention and intention:approved labels (state=$STATE, labels_ok=$LABELS_OK)." + } >> "$GITHUB_OUTPUT" + else + echo "ok=true" >> "$GITHUB_OUTPUT" + fi + + - name: Check max open tasks + id: check_limit + if: steps.parse.outputs.parent_number != '' && steps.verify_parent.outputs.ok == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + MAX=$(yq '.limits.max_open_tasks' .autopilot/config.yml) + OPEN=$(gh issue list --label autopilot:run --state open --json number --jq 'length') + + if [ "$OPEN" -gt "$MAX" ]; then + { + echo "ok=false" + echo "reason=Open autopilot:run tasks ($OPEN) exceed limits.max_open_tasks ($MAX)." + } >> "$GITHUB_OUTPUT" + else + echo "ok=true" >> "$GITHUB_OUTPUT" + fi + + - name: Decide whether to proceed + id: decide + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ISSUE: ${{ github.event.issue.number }} + PARENT_NUMBER: ${{ steps.parse.outputs.parent_number }} + SKILL: ${{ steps.parse.outputs.skill }} + PARENT_OK: ${{ steps.verify_parent.outputs.ok }} + PARENT_REASON: ${{ steps.verify_parent.outputs.reason }} + LIMIT_OK: ${{ steps.check_limit.outputs.ok }} + LIMIT_REASON: ${{ steps.check_limit.outputs.reason }} + run: | + FAIL_REASON="" + if [ -z "$PARENT_NUMBER" ]; then + FAIL_REASON="Could not find a 'Parent intention: #' reference in the issue body." + elif [ "$PARENT_OK" != "true" ]; then + FAIL_REASON="$PARENT_REASON" + elif [ "$LIMIT_OK" != "true" ]; then + FAIL_REASON="$LIMIT_REASON" + fi + + if [ -n "$FAIL_REASON" ]; then + gh issue comment "$ISSUE" --body "Autopilot executor guard blocked this run: $FAIL_REASON" + gh issue edit "$ISSUE" --remove-label "autopilot:run" + echo "proceed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + case "$SKILL" in + content-ops:translate*|astro-builder:translate*) LABEL="autopilot:translate" ;; + content-ops:*|content-seo:*) LABEL="autopilot:content" ;; + astro-builder:*) LABEL="autopilot:code" ;; + *) LABEL="autopilot:code" ;; + esac + + { + echo "proceed=true" + echo "parent_number=$PARENT_NUMBER" + echo "change_type_label=$LABEL" + echo "max_turns=$(yq '.limits.max_turns' .autopilot/config.yml)" + echo "timeout_minutes=$(yq '.limits.timeout_minutes' .autopilot/config.yml)" + } >> "$GITHUB_OUTPUT" + + # Execute job: the only place a Claude call happens. Everything the guard + # job decided is handed in as plain strings — no config re-read here. + execute: + needs: guard + if: needs.guard.outputs.proceed == 'true' + runs-on: ubuntu-latest + timeout-minutes: ${{ fromJSON(needs.guard.outputs.timeout_minutes) }} + permissions: + contents: write + pull-requests: write + issues: write + steps: + # Executor-opened PRs must trigger the gates workflow, and the default + # GITHUB_TOKEN never starts a workflow run from its own pushes/PRs — a + # fine-grained PAT is required (architecture G2: "fine-grained PAT + # now"). Fail fast and clearly if it's missing rather than letting the + # checkout below silently fall back to github.token. + - name: Verify AUTOPILOT_PAT is configured + env: + AUTOPILOT_PAT: ${{ secrets.AUTOPILOT_PAT }} + run: | + if [ -z "$AUTOPILOT_PAT" ]; then + echo "::error::secrets.AUTOPILOT_PAT is not set. The executor needs a fine-grained PAT (contents: read/write, pull requests: read/write, issues: read/write on this repo, no admin) so its branch pushes and PR creation trigger the gates workflow — the default GITHUB_TOKEN cannot do this. Add the secret, then re-run." + exit 1 + fi + + - name: Checkout + uses: actions/checkout@v5 + with: + ref: "{{DEFAULT_BRANCH}}" + token: ${{ secrets.AUTOPILOT_PAT }} + + - name: Configure git identity + run: | + git config user.name "autopilot[bot]" + git config user.email "autopilot[bot]@users.noreply.github.com" + + - name: Run autopilot executor + uses: anthropics/claude-code-action@v1 + env: + GITHUB_TOKEN: ${{ secrets.AUTOPILOT_PAT }} + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + claude_args: | + --max-turns ${{ needs.guard.outputs.max_turns }} + --allowedTools "Bash,Read,Write,Edit,Glob,Grep,Task,WebSearch,WebFetch" + prompt: | + You are the autopilot executor. You implement exactly one task issue + on this repo, open a PR for it, and stop — you never merge, and you + never touch the autopilot control plane. + + Run non-interactively: never wait for user input; resolve open + choices yourself from the task brief and acceptance criteria. + + ## Task brief (issue #${{ github.event.issue.number }}) + + ${{ github.event.issue.body }} + + ## What to do + + 1. Read the "Skill to run" field above and invoke that plugin skill + (or the equivalent Task-based agent) with the "Inputs" field as + its brief. If a "Skill (if other)" value is present, use that + instead of the dropdown value. + 2. Create and switch to a branch named exactly + `autopilot/task-${{ github.event.issue.number }}` before making + any change. + 3. Do the work the skill produces, then commit it. + 4. Push the branch and open a pull request against + `{{DEFAULT_BRANCH}}` that: + - has the title `autopilot: `, + - includes `Refs #${{ github.event.issue.number }}` in the body + (do NOT use a closing keyword like "Closes" or "Fixes" — the + gates workflow closes the task issue explicitly after merge), + - copies the "Acceptance criteria" field above verbatim as a + markdown checklist in the PR description, + - is labeled `${{ needs.guard.outputs.change_type_label }}` + (apply this label with `gh pr edit --add-label` + right after creating the PR). + + ## Hard constraints + + - Never edit, create, or delete anything under `.autopilot/`, + `.github/` (including workflow files and issue templates), or any + file that defines a gate. If the task genuinely requires such a + change, stop and leave a comment on the PR explaining why instead + of making the change — that class of change is `manual` and needs + a human. + - Never merge the PR yourself and never apply `autopilot:done`. + - Touch only the files the task actually requires. + + # If the Claude run itself fails (crash, timeout, hit max-turns without + # finishing), retry once per limits.retries, then hand off to a human. + handle-failure: + needs: [guard, execute] + if: always() && needs.guard.outputs.proceed == 'true' && needs.execute.result == 'failure' + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + ref: "{{DEFAULT_BRANCH}}" + + - name: Count previous failed attempts + id: attempts + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ISSUE: ${{ github.event.issue.number }} + run: | + MARKER="" + COUNT=$(gh issue view "$ISSUE" --json comments \ + --jq "[.comments[].body | select(startswith(\"$MARKER\"))] | length") + MAX_RETRIES=$(yq '.limits.retries' .autopilot/config.yml) + { + echo "count=$COUNT" + echo "max_retries=$MAX_RETRIES" + } >> "$GITHUB_OUTPUT" + + - name: Retry + if: fromJSON(steps.attempts.outputs.count) < fromJSON(steps.attempts.outputs.max_retries) + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ISSUE: ${{ github.event.issue.number }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + gh issue comment "$ISSUE" --body " + Executor run failed: $RUN_URL + Retrying automatically." + gh issue edit "$ISSUE" --remove-label "autopilot:run" + gh issue edit "$ISSUE" --add-label "autopilot:run" + + - name: Block after exhausting retries + if: fromJSON(steps.attempts.outputs.count) >= fromJSON(steps.attempts.outputs.max_retries) + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ISSUE: ${{ github.event.issue.number }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + gh issue comment "$ISSUE" --body " + Executor failed again after exhausting retries: $RUN_URL + Needs triage — applying autopilot:blocked." + gh issue edit "$ISSUE" --remove-label "autopilot:run" --add-label "autopilot:blocked" diff --git a/autopilot/docs/init-templates/autopilot-gates.yml.template b/autopilot/docs/init-templates/autopilot-gates.yml.template new file mode 100644 index 0000000..b0141a9 --- /dev/null +++ b/autopilot/docs/init-templates/autopilot-gates.yml.template @@ -0,0 +1,173 @@ +name: autopilot-gates + +on: + pull_request: + branches: ["{{DEFAULT_BRANCH}}"] + types: [opened, synchronize, reopened] + +permissions: {} + +jobs: + # pull_request.branches (above) filters the PR's BASE branch, not its head — + # every job below re-checks the head branch itself so this workflow only + # ever gates autopilot task PRs (branches named `autopilot/task-`). The + # `autopilot/init` scaffold PR intentionally falls outside this prefix so + # it never has to pass its own path-guard. + + path-guard: + if: startsWith(github.head_ref, 'autopilot/task-') + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Reject changes to the autopilot control plane + run: | + git fetch origin "${{ github.event.pull_request.base.ref }}" --depth=1 + CHANGED=$(git diff --name-only "origin/${{ github.event.pull_request.base.ref }}...HEAD") + PROTECTED=$(echo "$CHANGED" | grep -E '^(\.autopilot/|\.github/)' || true) + + if [ -n "$PROTECTED" ]; then + echo "::error::This PR (from an autopilot/** branch) touches protected control-plane paths:" + echo "$PROTECTED" + exit 1 + fi + + build: + if: startsWith(github.head_ref, 'autopilot/task-') + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Install dependencies + run: "{{PACKAGE_MANAGER_INSTALL}}" + + - name: Build + run: "{{BUILD_COMMAND}}" + + audit: + if: startsWith(github.head_ref, 'autopilot/task-') + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Run astro-builder audit + uses: anthropics/claude-code-action@v1 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + claude_args: | + --max-turns 20 + --allowedTools "Bash,Read,Grep,Glob,Task" + prompt: | + Run /astro-builder:audit against this checkout. Never auto-fix + anything — you are a read-only reviewer here, the repo is not + pushed anywhere from this job. + + When the audit is done, run exactly one shell command as your + last action: + + echo "FAIL" > /tmp/audit-result.txt (if any P0 issue was found) + echo "PASS" > /tmp/audit-result.txt (otherwise) + + Then, on the line above that command in your final message, list + every P0 issue found (or write "none"). + + - name: Enforce audit result + run: | + if [ ! -f /tmp/audit-result.txt ] || ! grep -qx 'PASS' /tmp/audit-result.txt; then + echo "::error::astro-builder audit did not pass (or produced no result)." + cat /tmp/audit-result.txt 2>/dev/null || true + exit 1 + fi + + anti-slop: + if: startsWith(github.head_ref, 'autopilot/task-') + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Run content-ops review on changed content + uses: anthropics/claude-code-action@v1 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + claude_args: | + --max-turns 20 + --allowedTools "Bash,Read,Grep,Glob,Task" + prompt: | + Diff this PR's head against `origin/${{ github.event.pull_request.base.ref }}` + to find the changed content files, then run + `/content-ops:review-content ` on each one. Never auto-fix + anything — you are a read-only reviewer here, the repo is not + pushed anywhere from this job. + + When every changed content file has been reviewed, run exactly + one shell command as your last action: + + echo "FAIL" > /tmp/anti-slop-result.txt (if any Must Fix was found) + echo "PASS" > /tmp/anti-slop-result.txt (otherwise) + + Then, on the line above that command in your final message, list + every Must Fix found (or write "none"). + + - name: Enforce anti-slop result + run: | + if [ ! -f /tmp/anti-slop-result.txt ] || ! grep -qx 'PASS' /tmp/anti-slop-result.txt; then + echo "::error::content-ops review found unresolved Must Fix items (or produced no result)." + cat /tmp/anti-slop-result.txt 2>/dev/null || true + exit 1 + fi + + auto-merge: + needs: [path-guard, build, audit, anti-slop] + if: success() && startsWith(github.head_ref, 'autopilot/task-') + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + issues: write + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Decide merge policy and act + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR: ${{ github.event.pull_request.number }} + HEAD_REF: ${{ github.head_ref }} + run: | + CHANGE_LABEL=$(gh pr view "$PR" --json labels \ + --jq '[.labels[].name] | map(select(test("^autopilot:(content|translate|code|strategy)$"))) | .[0] // ""') + + if [ -z "$CHANGE_LABEL" ]; then + gh pr comment "$PR" --body "Gates are green, but this PR carries no autopilot change-type label (autopilot:content/translate/code/strategy) — treating it as manual. Awaiting human review." + exit 0 + fi + + CHANGE_TYPE=${CHANGE_LABEL#autopilot:} + POLICY=$(yq ".merge_policy.$CHANGE_TYPE" .autopilot/config.yml) + TASK_NUM=$(echo "$HEAD_REF" | grep -oP 'autopilot/task-\K[0-9]+' || true) + + if [ "$POLICY" = "auto" ]; then + gh pr merge --squash --auto "$PR" + if [ -n "$TASK_NUM" ]; then + gh issue edit "$TASK_NUM" --add-label "autopilot:done" + gh issue close "$TASK_NUM" --reason completed + fi + else + gh pr comment "$PR" --body "Gates are green — merge_policy.$CHANGE_TYPE is manual, awaiting human review." + fi diff --git a/autopilot/docs/init-templates/autopilot-metrics.yml.template b/autopilot/docs/init-templates/autopilot-metrics.yml.template new file mode 100644 index 0000000..f28df78 --- /dev/null +++ b/autopilot/docs/init-templates/autopilot-metrics.yml.template @@ -0,0 +1,94 @@ +name: autopilot-metrics + +on: + schedule: + - cron: "0 3 * * *" # nightly; GSC data itself lags ~3 days + workflow_dispatch: {} + +concurrency: + group: autopilot-metrics + cancel-in-progress: false + +permissions: {} + +jobs: + metrics: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + ref: "{{DEFAULT_BRANCH}}" + + - name: Check for GSC service account secret + id: check + env: + GSC_SERVICE_ACCOUNT: ${{ secrets.GSC_SERVICE_ACCOUNT }} + run: | + if [ -z "$GSC_SERVICE_ACCOUNT" ]; then + echo "::warning::GSC_SERVICE_ACCOUNT secret is not set — skipping tonight's metrics run." + echo "has_secret=false" >> "$GITHUB_OUTPUT" + else + echo "has_secret=true" >> "$GITHUB_OUTPUT" + fi + + - name: Write service account credentials + if: steps.check.outputs.has_secret == 'true' + env: + GSC_SERVICE_ACCOUNT: ${{ secrets.GSC_SERVICE_ACCOUNT }} + run: | + umask 077 + printf '%s' "$GSC_SERVICE_ACCOUNT" > "$RUNNER_TEMP/gsc-service-account.json" + + - name: Compute report date + if: steps.check.outputs.has_secret == 'true' + id: date + run: echo "today=$(date -u +%F)" >> "$GITHUB_OUTPUT" + + - name: Configure git identity + if: steps.check.outputs.has_secret == 'true' + run: | + git config user.name "autopilot[bot]" + git config user.email "autopilot[bot]@users.noreply.github.com" + + - name: Run GSC metrics report + if: steps.check.outputs.has_secret == 'true' + uses: anthropics/claude-code-action@v1 + env: + GOOGLE_APPLICATION_CREDENTIALS: ${{ runner.temp }}/gsc-service-account.json + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + claude_args: | + --max-turns 15 + --allowedTools "Bash,Read,Write,Task" + prompt: | + Use the content-seo plugin's gsc-reporter agent (via the Task + tool) to fetch Search Console data, then write and commit the + result yourself — no PR, this is a scheduled data snapshot. + + Brief for the gsc-reporter agent: + gsc_property: {{GSC_PROPERTY}} + credentials_path: (unset — resolve GOOGLE_APPLICATION_CREDENTIALS from the environment) + query_type: site-wide + date_range: last_28_days + dimensions: both + + Steps: + 1. Invoke the gsc-reporter agent with the brief above. + 2. Take the GSC_DATA and SUMMARY it returns and write them as a + single JSON file to + `.autopilot/metrics/gsc-${{ steps.date.outputs.today }}.json` + (create the `.autopilot/metrics/` directory if it does not + exist yet). + 3. Commit that one file to `{{DEFAULT_BRANCH}}` with the message + `chore(autopilot): gsc metrics for ${{ steps.date.outputs.today }}` + and push it directly. + 4. Do not modify, stage, or commit any other file. Do not touch + `.autopilot/config.yml`, `.github/`, or any workflow or gate + definition. + + - name: Clean up credentials + if: always() && steps.check.outputs.has_secret == 'true' + run: rm -f "$RUNNER_TEMP/gsc-service-account.json" diff --git a/autopilot/docs/init-templates/config.yml.template b/autopilot/docs/init-templates/config.yml.template new file mode 100644 index 0000000..3227af0 --- /dev/null +++ b/autopilot/docs/init-templates/config.yml.template @@ -0,0 +1,44 @@ +# .autopilot/config.yml +# +# Autonomy boundary and operating limits for the autopilot loop. +# Ratcheting autonomy is an edit to merge_policy — nothing else changes. +# The executor and gates workflows re-read this file at run time (except the +# two values baked in at init time below, which also live here for reference). +merge_policy: + # auto = merge automatically when all gates pass + # manual = always wait for human review + content: auto # new/edited content (articles, pages) + translate: auto # translations of existing content + code: manual # anything touching site code/config + strategy: manual # changes to .autopilot/, workflows, gates + +limits: + max_open_tasks: 5 # executor refuses new tasks past this + max_turns: 50 # per executor run + timeout_minutes: 30 # per executor run + retries: 1 # then autopilot:blocked + +gates: # Documentation only — this list names the gate set. + # The gate jobs themselves (path-guard, build, + # audit, anti-slop) are defined in + # .github/workflows/autopilot-gates.yml. Editing + # this list does not add, remove, or reconfigure a + # gate; that requires editing the workflow file. + - build + - audit + - anti-slop + +build: + # Informational only. {{BUILD_COMMAND}} (and the install command) are + # baked directly into .github/workflows/autopilot-gates.yml at init time — + # a workflow-file placeholder substitution, not a runtime read. Editing + # this value here does NOT change what CI runs; to actually change the + # build/install command, edit the workflow file (or re-run init) and + # update this value to match, so it stays accurate for anything else that + # reads it later (a local dry-run, the strategist, etc). + command: "{{BUILD_COMMAND}}" + +metrics: + provider: gsc + property: "{{GSC_PROPERTY}}" # e.g. sc-domain:example.com + schedule: nightly diff --git a/autopilot/docs/init-templates/intention.yml.template b/autopilot/docs/init-templates/intention.yml.template new file mode 100644 index 0000000..2ca60ee --- /dev/null +++ b/autopilot/docs/init-templates/intention.yml.template @@ -0,0 +1,52 @@ +name: Intention +description: Propose a goal for the autopilot loop to work toward. Inert until a maintainer approves it. +title: "[Intention]: " +labels: + - intention +body: + - type: markdown + attributes: + value: | + An intention is a goal, not a task — the autopilot strategist and executor + only act on task issues filed as sub-issues of an **approved** intention. + + **After filing:** pin this issue. A maintainer must apply the + `intention:approved` label before any task under it can run — that label + is the entire autonomy boundary, so only apply it once you agree the loop + should pursue the goal, metric, and constraints below. + - type: input + id: goal + attributes: + label: Goal + description: What outcome should the autopilot loop work toward? + placeholder: e.g. Grow organic sessions to the recipes section + validations: + required: true + - type: input + id: metric + attributes: + label: Metric + description: How will success be measured? Name the number and its source. + placeholder: e.g. GSC clicks on /recipes/** pages, tracked nightly + validations: + required: true + - type: input + id: horizon + attributes: + label: Horizon + description: By when? A date or a duration. + placeholder: e.g. 2026-12-31, or "90 days" + validations: + required: true + - type: textarea + id: constraints + attributes: + label: Constraints + description: > + Anything the loop must respect while pursuing this goal — tone, scope, + pages or paths that are off-limits, budget, anything else out of bounds. + placeholder: | + - Do not touch the pricing pages + - English content only for now + validations: + required: false diff --git a/autopilot/docs/init-templates/task.yml.template b/autopilot/docs/init-templates/task.yml.template new file mode 100644 index 0000000..2123c97 --- /dev/null +++ b/autopilot/docs/init-templates/task.yml.template @@ -0,0 +1,67 @@ +name: Task +description: A unit of work for the autopilot executor. Must belong to an approved intention. +title: "[Task]: " +labels: + - task +body: + - type: markdown + attributes: + value: | + A task only runs once labeled `autopilot:run`, and only if its parent + intention is open and carries `intention:approved` — the executor + re-checks that at run time no matter who applied the label. + - type: input + id: parent_intention + attributes: + label: Parent intention + description: > + Issue number of the approved parent intention, e.g. 12 or #12. + placeholder: "12 or #12" + validations: + required: true + - type: dropdown + id: skill + attributes: + label: Skill to run + description: Which plugin skill should the executor invoke for this task? + options: + - content-ops:write-content + - content-ops:review-content + - astro-builder:translate + - content-seo:seo + - astro-builder:new-page + - astro-builder:new-content-type + - other (specify below) + validations: + required: true + - type: input + id: skill_other + attributes: + label: Skill (if "other" above) + description: Only fill this in if you picked "other (specify below)" above. + placeholder: e.g. content-seo:opportunities + validations: + required: false + - type: textarea + id: inputs + attributes: + label: Inputs + description: Structured inputs the skill needs (paths, topics, target locale, word count, etc). + placeholder: | + topic: ... + target_path: ... + validations: + required: true + - type: textarea + id: acceptance_criteria + attributes: + label: Acceptance criteria + description: > + A checklist the work must satisfy. This doubles as the PR's validation + checklist — write it as checkboxes. + placeholder: | + - [ ] Article is 800-1200 words + - [ ] Passes /astro-builder:audit with no P0s + - [ ] Internal links added per content-inventory + validations: + required: true diff --git a/autopilot/skills/init/SKILL.md b/autopilot/skills/init/SKILL.md new file mode 100644 index 0000000..e0e40fb --- /dev/null +++ b/autopilot/skills/init/SKILL.md @@ -0,0 +1,307 @@ +--- +name: init +description: Run /autopilot:init to scaffold a target site repo for the autopilot loop — labels, intention/task issue templates, .autopilot/config.yml, and the executor/gates/metrics GitHub Actions workflows. +user-invocable: true +allowed-tools: Read, Write, Edit, Glob, Grep, Bash, AskUserQuestion +--- + +Scaffolds the current repo so the autopilot loop can run on it: labels, `.github/ISSUE_TEMPLATE/` forms, `.autopilot/config.yml`, and three GitHub Actions workflows (executor, gates, metrics). Run this **inside the target site repo**, not inside content-stack. + +Everything created here is infrastructure. This skill never writes an intention, never applies `intention:approved`, and never pushes to the default branch — it opens a PR like any other change. + +--- + +## Step 0 — Preflight & detection + +Run these checks before touching anything. Stop and explain if any hard requirement fails. + +1. **Inside a git repo:** + ```bash + git rev-parse --is-inside-work-tree + ``` + If this errors, stop: "Not a git repository. Run `git init` and add a GitHub remote first." + +2. **Has a GitHub remote:** + ```bash + git remote get-url origin + ``` + Must succeed and contain `github.com`. If it fails or points elsewhere, stop: "No GitHub remote named `origin` found. Add one (`git remote add origin `) before running init." + +3. **`gh` is installed and authenticated:** + ```bash + command -v gh && gh auth status + ``` + If `gh` is missing, stop: "Install the GitHub CLI: https://cli.github.com/". If installed but not authenticated, stop: "Run `gh auth login` first." + +4. **Clean working tree** (init is about to create a branch and commit on it): + ```bash + git status --porcelain + ``` + If non-empty, stop: "Working tree has uncommitted changes. Commit or stash them before running init." + +5. **Detect the default branch** — this fills `{{DEFAULT_BRANCH}}` everywhere below: + ```bash + gh repo view --json defaultBranchRef --jq .defaultBranchRef.name + ``` + Fall back to `git remote show origin | grep 'HEAD branch' | awk '{print $NF}'` if the `gh` call fails (e.g. rate limit). + +6. **Detect existing labels:** + ```bash + gh label list --json name --jq '.[].name' + ``` + Diff against the required set (see Step 2). Record which already exist. + +7. **Detect existing files** — for every target path in the Step 3 table, run: + ```bash + test -f && echo "exists" || echo "missing" + ``` + Record which already exist. + +8. **Report the detection summary** before moving on, e.g.: + ```text + Preflight — pcamarajr/lista-de-leitura, default branch: main + + Labels: 7/10 already present — will create: autopilot:content, autopilot:code, autopilot:strategy + Files: 0/6 already present — will create all 6 + + Continuing to the interview. + ``` + This report is what makes re-runs safe to reason about: nothing here is destructive, and anything already present is skipped later, not recreated. + +--- + +## Step 1 — Interview + +Ask one question at a time with `AskUserQuestion`. Use Step 0 detections to skip questions whose answer is already implied (e.g. a config file that already sets `metrics.property`). + +### Q1 — GSC property + +``` +AskUserQuestion: + question: "What is your Google Search Console property? It fills .autopilot/config.yml's metrics.property, used by the nightly metrics workflow (e.g. sc-domain:example.com)." + header: "GSC property" + options: + - "Enter it now" → free text; store as GSC_PROPERTY + - "Skip for now" → leave metrics.property empty in the config; add a line to the Step 5 follow-up checklist noting metrics won't run until it's set +``` + +### Q2 — Build & install commands + +``` +AskUserQuestion: + question: "How does this site install dependencies and build?" + header: "Build & install" + options: + - "npm — npm ci / npm run build (default)" → PACKAGE_MANAGER_INSTALL=npm ci, BUILD_COMMAND=npm run build + - "pnpm — pnpm install --frozen-lockfile / pnpm build" → set accordingly + - "yarn — yarn install --frozen-lockfile / yarn build" → set accordingly + - "Other — I'll specify both" → free text for PACKAGE_MANAGER_INSTALL and BUILD_COMMAND +``` + +### Q3 — Merge policy defaults + +Show the v1 default table, then ask: + +``` +AskUserQuestion: + question: "Autopilot's merge policy: content and translation PRs auto-merge once gates (build, audit, anti-slop) are green. Code and strategy PRs always wait for a human, even if gates pass. Keep these defaults?" + header: "Merge policy" + options: + - "Keep v1 defaults (recommended)" → merge_policy unchanged: content=auto, translate=auto, code=manual, strategy=manual + - "Customize — I understand the risk" → ask which of code/strategy to flip to auto; before applying, restate explicitly: "Loosening code or strategy to auto-merge is not recommended for v1 — these PRs can touch site behavior or the autopilot boundary itself with no human in the loop. Proceed anyway?" and require an explicit yes +``` + +Record the three answers (`GSC_PROPERTY`, `BUILD_COMMAND` + `PACKAGE_MANAGER_INSTALL`, `merge_policy`) — they feed the placeholders in Step 3. + +--- + +## Step 2 — Labels + +Create only the labels Step 0 found missing. Colors and descriptions below are fixed — reuse them verbatim so re-runs stay idempotent (a label that already exists with a different color is left alone; this skill never edits an existing label). + +| Label | Color | Description | +|---|---|---| +| `intention` | `5319E7` | Pinned goal issue for the autopilot loop | +| `intention:approved` | `0E8A16` | Human-approved — activates task execution under this intention. Only a maintainer applies this. | +| `task` | `1D76DB` | A task sub-issue under an approved intention | +| `autopilot:run` | `FBCA04` | Trigger label — applying it fires the executor | +| `autopilot:blocked` | `B60205` | Executor failed twice (one retry) — needs strategist/human triage | +| `autopilot:done` | `2EA043` | Task completed and its PR merged | +| `autopilot:content` | `C2E0C6` | PR change type: content — eligible for auto-merge under the default policy | +| `autopilot:translate` | `BFD4F2` | PR change type: translation — eligible for auto-merge under the default policy | +| `autopilot:code` | `F9D0C4` | PR change type: code — manual merge under the default policy | +| `autopilot:strategy` | `E99695` | PR change type: strategy — touches `.autopilot/`, workflows, or gate definitions; manual merge under the default policy | + +For each missing label: +```bash +gh label create "" --color "" --description "" +``` +For each label Step 0 already found, print `skipped (already exists): ` instead of calling `gh label create`. + +### Enable repo auto-merge + +The gates workflow's `gh pr merge --squash --auto` requires the repo setting +`allow_auto_merge`. Enable it — this call is idempotent, safe to run every time: +```bash +gh api -X PATCH repos/{owner}/{repo} -f allow_auto_merge=true +``` +This requires admin on the repo. If it 403s, don't stop the run — print it as a manual +follow-up instead (add it to the Step 5 checklist): +```text +[ ] Enable "Allow auto-merge" in repo Settings → General (requires admin) — needed for + the gates workflow's `gh pr merge --squash --auto` to succeed. +``` + +--- + +## Step 3 — Scaffold files from templates + +Templates live in the plugin at `${CLAUDE_PLUGIN_ROOT}/docs/init-templates/`. For each row: if Step 0 found the target file missing, read the template, substitute its placeholders, and write the result. If the target already exists, diff it against what you'd generate — identical means skip silently; different means stop and ask the user (`AskUserQuestion`: keep existing / overwrite / show diff) before writing. + +| # | Target file | Template | Placeholders | +|---|---|---|---| +| 1 | `.autopilot/config.yml` | `config.yml.template` | `{{GSC_PROPERTY}}`, `{{BUILD_COMMAND}}`, plus the `merge_policy` block written directly from the Q3 answer (not a placeholder — the four values are substituted literally) | +| 2 | `.github/ISSUE_TEMPLATE/intention.yml` | `intention.yml.template` | none | +| 3 | `.github/ISSUE_TEMPLATE/task.yml` | `task.yml.template` | none | +| 4 | `.github/workflows/autopilot-executor.yml` | `autopilot-executor.yml.template` | `{{DEFAULT_BRANCH}}` | +| 5 | `.github/workflows/autopilot-gates.yml` | `autopilot-gates.yml.template` | `{{BUILD_COMMAND}}`, `{{PACKAGE_MANAGER_INSTALL}}`, `{{DEFAULT_BRANCH}}` | +| 6 | `.github/workflows/autopilot-metrics.yml` | `autopilot-metrics.yml.template` | `{{GSC_PROPERTY}}`, `{{DEFAULT_BRANCH}}` | + +After writing (or skipping) all six, verify no placeholder survived in what you actually wrote this run: +```bash +grep -rn "{{" .autopilot .github/ISSUE_TEMPLATE .github/workflows 2>/dev/null +``` +This must return nothing. If it finds a match, fix the offending file before continuing — do not open a PR with an unfilled template. + +--- + +## Step 4 — Commit and open a PR + +Init never pushes to the default branch. Everything lands on `autopilot/init`. + +1. Sync and branch — check the REMOTE first, not just the local repo, so a prior run's + pushed branch is picked up instead of recreated (which would conflict on push): + ```bash + git fetch origin "$DEFAULT_BRANCH" + git checkout "$DEFAULT_BRANCH" + git pull --ff-only origin "$DEFAULT_BRANCH" + + if git ls-remote --exit-code origin autopilot/init >/dev/null 2>&1; then + echo "autopilot/init already exists on origin — a prior run got this far." + EXISTING_PR=$(gh pr list --head autopilot/init --json number,url --jq '.[0].url') + if [ -n "$EXISTING_PR" ]; then + echo "Existing PR: $EXISTING_PR" + fi + git fetch origin autopilot/init + git checkout -B autopilot/init origin/autopilot/init + elif git rev-parse --verify autopilot/init >/dev/null 2>&1; then + git checkout autopilot/init + else + git checkout -b autopilot/init + fi + ``` +2. Stage only the files this run actually created or updated (never `git add -A`): + ```bash + git add .autopilot/config.yml \ + .github/ISSUE_TEMPLATE/intention.yml .github/ISSUE_TEMPLATE/task.yml \ + .github/workflows/autopilot-executor.yml .github/workflows/autopilot-gates.yml .github/workflows/autopilot-metrics.yml + ``` + (Drop any path that was skipped because it already existed and was left untouched.) +3. Commit: + ```bash + git commit -m "chore(autopilot): scaffold autopilot loop" + ``` +4. Push: + ```bash + git push -u origin autopilot/init + ``` +5. Open the PR — check first whether one already exists from a prior run: + ```bash + gh pr list --head autopilot/init --json number --jq '.[0].number' + ``` + If a number comes back, the push above already updated it — report the PR URL and stop. Otherwise write the body to a file and create it: + ```bash + cat > /tmp/autopilot-init-pr-body.md <<'EOF' + ## Autopilot scaffold + + This PR sets up the autopilot loop on this repo: + - Labels: intention, intention:approved, task, autopilot:run, autopilot:blocked, autopilot:done, autopilot:content, autopilot:translate, autopilot:code, autopilot:strategy + - Issue templates: `.github/ISSUE_TEMPLATE/intention.yml`, `.github/ISSUE_TEMPLATE/task.yml` + - Config: `.autopilot/config.yml` + - Workflows: `.github/workflows/autopilot-executor.yml`, `autopilot-gates.yml`, `autopilot-metrics.yml` + + ## Manual follow-up required before the loop can run + - [ ] Add repo secret `ANTHROPIC_API_KEY` (dedicated, capped workspace recommended — ~$50/mo) + - [ ] Add repo secret `AUTOPILOT_PAT` — fine-grained PAT scoped to this repo only, with Contents (read/write), Pull requests (read/write), and Issues (read/write) permissions. No admin. Required so executor-opened branches/PRs trigger this workflow (the default `GITHUB_TOKEN` never does). If its owner is a machine account, that account must NOT be able to apply `intention:approved`. + - [ ] Add repo secret `GSC_SERVICE_ACCOUNT` (optional — only blocks the nightly metrics job, not the executor/gates) + - [ ] Enable branch protection on the default branch: require a PR before merging, require status checks `path-guard`, `build`, `audit`, `anti-slop`, disallow force pushes: + ```bash + gh api --method PUT repos/{owner}/{repo}/branches//protection \ + -F required_status_checks.strict=true \ + -f 'required_status_checks.contexts[]=path-guard' \ + -f 'required_status_checks.contexts[]=build' \ + -f 'required_status_checks.contexts[]=audit' \ + -f 'required_status_checks.contexts[]=anti-slop' \ + -F enforce_admins=true \ + -F required_pull_request_reviews.required_approving_review_count=0 \ + -F restrictions=null \ + -F allow_force_pushes=false \ + -F allow_deletions=false + ``` + (replace `` with this repo's default branch) + - [ ] Write and pin the first intention issue (goal / metric / horizon / constraints) + - [ ] Apply `intention:approved` to that issue to activate it — a maintainer must do this by hand + EOF + gh pr create --base "$DEFAULT_BRANCH" --head autopilot/init \ + --title "chore(autopilot): scaffold autopilot loop" \ + --body-file /tmp/autopilot-init-pr-body.md + ``` + +--- + +## Step 5 — Report + +Print the same follow-up checklist that went into the PR body, plus the enforcement reminder: + +```text +✅ Autopilot scaffold PR opened: + +Before the loop can run: + [ ] Add repo secret ANTHROPIC_API_KEY — dedicated, capped workspace recommended (~$50/mo) + [ ] Add repo secret AUTOPILOT_PAT — fine-grained PAT scoped to this repo only, with + Contents (read/write), Pull requests (read/write), Issues (read/write). No admin. + Required so executor-opened branches/PRs trigger the gates workflow (the default + GITHUB_TOKEN never does). If its owner is a machine account, that account must NOT + be able to apply intention:approved. + [ ] Add repo secret GSC_SERVICE_ACCOUNT — optional; only the nightly metrics job needs it + [ ] Enable branch protection on $DEFAULT_BRANCH — require a PR before merging, require + status checks path-guard/build/audit/anti-slop, disallow force pushes: + gh api --method PUT repos/{owner}/{repo}/branches/$DEFAULT_BRANCH/protection \ + -F required_status_checks.strict=true \ + -f 'required_status_checks.contexts[]=path-guard' \ + -f 'required_status_checks.contexts[]=build' \ + -f 'required_status_checks.contexts[]=audit' \ + -f 'required_status_checks.contexts[]=anti-slop' \ + -F enforce_admins=true \ + -F required_pull_request_reviews.required_approving_review_count=0 \ + -F restrictions=null \ + -F allow_force_pushes=false \ + -F allow_deletions=false + [ ] Write and pin the first intention issue (goal / metric / horizon / constraints) + [ ] Apply intention:approved to that issue — only a maintainer applies this label + +Remember: the brain proposes, the control plane enforces. A task never runs without an +approved parent intention, no matter who or what filed it — applying intention:approved +is the one action that turns proposals into work, and this skill never does it for you. +``` + +--- + +## Constraints + +- Never push to the default branch. Every change from this skill lands on `autopilot/init` and goes through a PR. +- Never overwrite an existing file without asking first. Identical content is skipped silently; different content stops and asks (keep / overwrite / show diff). +- Never create or approve an intention issue. This skill scaffolds infrastructure only — it does not author intentions and it never applies `intention:approved`. +- The executor's machine identity (PAT or GitHub App) must never be granted permission to apply `intention:approved`. That label activates work and is reserved for a human maintainer — do not add it to any token's scope, workflow `permissions:` block, or automation this skill writes. +- Gate enforcement depends on branch protection; without it (see the Step 5 checklist) the path-guard/build/audit/anti-slop boundary is advisory, not enforced — anyone with push access can bypass it. +- Re-running this skill must be safe: always detect what already exists (Step 0) before creating anything, and always report what was skipped, not just what was created. From 9e540491ae32de5f09e3ab65480ac7dba59a0757 Mon Sep 17 00:00:00 2001 From: Pedro Camara Junior Date: Mon, 24 Aug 2026 16:40:18 +0200 Subject: [PATCH 2/2] fix(autopilot): harden init skill from sandbox dogfood findings Co-Authored-By: Claude Fable 5 --- autopilot/skills/init/SKILL.md | 38 ++++++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/autopilot/skills/init/SKILL.md b/autopilot/skills/init/SKILL.md index e0e40fb..e0b6ac7 100644 --- a/autopilot/skills/init/SKILL.md +++ b/autopilot/skills/init/SKILL.md @@ -51,11 +51,22 @@ Run these checks before touching anything. Stop and explain if any hard requirem ``` Diff against the required set (see Step 2). Record which already exist. -7. **Detect existing files** — for every target path in the Step 3 table, run: +7. **Detect existing files** — file existence depends on which branch you look at: a + prior run may have committed everything to `autopilot/init` without the PR being + merged yet, so checking only the current checkout gives false "missing" reports. + Check the remote scaffold branch first: ```bash - test -f && echo "exists" || echo "missing" + if git ls-remote --exit-code origin autopilot/init >/dev/null 2>&1; then + git fetch origin autopilot/init + # detect against the prior run's branch + git cat-file -e origin/autopilot/init: 2>/dev/null && echo "exists" || echo "missing" + else + test -f && echo "exists" || echo "missing" + fi ``` - Record which already exist. + Run the detection for every target path in the Step 3 table and record which already + exist. If `origin/autopilot/init` exists, say so in the summary — Step 4 will reuse + that branch instead of creating a new one. 8. **Report the detection summary** before moving on, e.g.: ```text @@ -130,22 +141,26 @@ Create only the labels Step 0 found missing. Colors and descriptions below are f | `autopilot:content` | `C2E0C6` | PR change type: content — eligible for auto-merge under the default policy | | `autopilot:translate` | `BFD4F2` | PR change type: translation — eligible for auto-merge under the default policy | | `autopilot:code` | `F9D0C4` | PR change type: code — manual merge under the default policy | -| `autopilot:strategy` | `E99695` | PR change type: strategy — touches `.autopilot/`, workflows, or gate definitions; manual merge under the default policy | +| `autopilot:strategy` | `E99695` | PR change type: strategy — touches .autopilot/, workflows, or gates; manual merge | For each missing label: ```bash gh label create "" --color "" --description "" ``` +Descriptions must stay ≤100 characters — GitHub's label API rejects longer ones with a 422. For each label Step 0 already found, print `skipped (already exists): ` instead of calling `gh label create`. ### Enable repo auto-merge The gates workflow's `gh pr merge --squash --auto` requires the repo setting -`allow_auto_merge`. Enable it — this call is idempotent, safe to run every time: +`allow_auto_merge`. Enable it, then verify — on some plans/repo types the PATCH +returns 200 without actually flipping the setting, so never trust the status code alone: ```bash gh api -X PATCH repos/{owner}/{repo} -f allow_auto_merge=true +gh api repos/{owner}/{repo} --jq .allow_auto_merge ``` -This requires admin on the repo. If it 403s, don't stop the run — print it as a manual +The second command must print `true`. This requires admin on the repo. If it 403s OR +still reads `false` after the PATCH, don't stop the run — print it as a manual follow-up instead (add it to the Step 5 checklist): ```text [ ] Enable "Allow auto-merge" in repo Settings → General (requires admin) — needed for @@ -167,9 +182,9 @@ Templates live in the plugin at `${CLAUDE_PLUGIN_ROOT}/docs/init-templates/`. Fo | 5 | `.github/workflows/autopilot-gates.yml` | `autopilot-gates.yml.template` | `{{BUILD_COMMAND}}`, `{{PACKAGE_MANAGER_INSTALL}}`, `{{DEFAULT_BRANCH}}` | | 6 | `.github/workflows/autopilot-metrics.yml` | `autopilot-metrics.yml.template` | `{{GSC_PROPERTY}}`, `{{DEFAULT_BRANCH}}` | -After writing (or skipping) all six, verify no placeholder survived in what you actually wrote this run: +After writing (or skipping) all six, verify no init placeholder survived in what you actually wrote this run. Init placeholders are `{{UPPER_SNAKE}}` tokens — GitHub Actions' own `${{ ... }}` expressions legitimately contain `{{` and must NOT be flagged: ```bash -grep -rn "{{" .autopilot .github/ISSUE_TEMPLATE .github/workflows 2>/dev/null +grep -rEn '\{\{[A-Z_]+\}\}' .autopilot .github/ISSUE_TEMPLATE .github/workflows 2>/dev/null ``` This must return nothing. If it finds a match, fix the offending file before continuing — do not open a PR with an unfilled template. @@ -219,9 +234,10 @@ Init never pushes to the default branch. Everything lands on `autopilot/init`. ```bash gh pr list --head autopilot/init --json number --jq '.[0].number' ``` - If a number comes back, the push above already updated it — report the PR URL and stop. Otherwise write the body to a file and create it: + If a number comes back, the push above already updated it — report the PR URL and stop. Otherwise write the body to a temp file and create it: ```bash - cat > /tmp/autopilot-init-pr-body.md <<'EOF' + PR_BODY=$(mktemp) + cat > "$PR_BODY" <<'EOF' ## Autopilot scaffold This PR sets up the autopilot loop on this repo: @@ -254,7 +270,7 @@ Init never pushes to the default branch. Everything lands on `autopilot/init`. EOF gh pr create --base "$DEFAULT_BRANCH" --head autopilot/init \ --title "chore(autopilot): scaffold autopilot loop" \ - --body-file /tmp/autopilot-init-pr-body.md + --body-file "$PR_BODY" ``` ---