From bc1fe613599ecec22e0b9686ea2ddc74c34740a1 Mon Sep 17 00:00:00 2001 From: Copilot Date: Mon, 7 Sep 2026 13:51:13 +0300 Subject: [PATCH 1/4] feat: add Squad retrospective workflow Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/agents.md | 3 +- .github/workflows/squad-ci.yml | 10 +- .squad/skills/gh-aw-enlistment/SKILL.md | 23 +- README.md | 4 +- docs/src/content/docs/guide/gh-aw.md | 72 +- .../skills/gh-aw-enlistment/SKILL.md | 23 +- .../skills/gh-aw-enlistment/SKILL.md | 23 +- test/gh-aw-enlistment-skill.test.ts | 4 +- test/gh-aw-implement-workflow.test.ts | 14 +- test/gh-aw-retro-workflow.test.ts | 1532 +++++++++++++++++ test/gh-aw-review-workflow.test.ts | 21 +- test/gh-aw-security-scan.test.ts | 5 +- workflows/shared/builtins/ralph-charter.md | 11 + workflows/shared/squad-retro-evidence.mjs | 1034 +++++++++++ workflows/shared/squad.md | 3 +- workflows/squad-implement-worker.md | 23 +- workflows/squad-retro.md | 432 +++++ workflows/squad-review.md | 27 +- workflows/squad.md | 31 +- 19 files changed, 3224 insertions(+), 71 deletions(-) create mode 100644 test/gh-aw-retro-workflow.test.ts create mode 100644 workflows/shared/squad-retro-evidence.mjs create mode 100644 workflows/squad-retro.md diff --git a/.github/agents.md b/.github/agents.md index c6fed736f..7f1544670 100644 --- a/.github/agents.md +++ b/.github/agents.md @@ -24,7 +24,8 @@ gh aw add \ bradygaster/squad/workflows/squad.md@dev \ bradygaster/squad/workflows/squad-implement-worker.md@dev \ bradygaster/squad/workflows/squad-deps-worker.md@dev \ - bradygaster/squad/workflows/squad-review.md@dev + bradygaster/squad/workflows/squad-review.md@dev \ + bradygaster/squad/workflows/squad-retro.md@dev ``` This command: diff --git a/.github/workflows/squad-ci.yml b/.github/workflows/squad-ci.yml index 19de1f7cb..5d45f6497 100644 --- a/.github/workflows/squad-ci.yml +++ b/.github/workflows/squad-ci.yml @@ -705,7 +705,7 @@ jobs: fi # ── gh-aw strict compile gate ──────────────────────────────────────────── - # Compiles all four Squad-authored workflow sources with --strict against a + # Compiles all five Squad-authored workflow sources with --strict against a # pinned compiler so the delivered stack is always proven compilable. # Runs unconditionally on every PR / push so it can be used as a required # status check (check name: "Squad CI / gh-aw strict compile"). @@ -749,7 +749,7 @@ jobs: cp -r workflows/. "$COMPILE_DIR/.github/workflows/" git -C "$COMPILE_DIR" init --quiet - - name: Compile all four workflows (--strict) and verify lock files + - name: Compile all five workflows (--strict) and verify lock files # Failures accumulate rather than short-circuit so every result is # visible in one run. A compiler that exits 0 without emitting a lock # file is treated as failure — silent-success is a gate gap. @@ -757,7 +757,7 @@ jobs: set -euo pipefail FAILED=0 echo "### Compile results" >> "$GITHUB_STEP_SUMMARY" - for WF in squad squad-implement-worker squad-review squad-deps-worker; do + for WF in squad squad-implement-worker squad-review squad-deps-worker squad-retro; do printf -- '--- Compiling %s ---\n' "$WF" if (cd "$COMPILE_DIR" && gh aw compile "$WF" --strict --approve --no-check-update 2>&1); then printf ' => OK\n' @@ -770,7 +770,7 @@ jobs: done echo "" >> "$GITHUB_STEP_SUMMARY" echo "### Lock files" >> "$GITHUB_STEP_SUMMARY" - for LOCK in squad.lock.yml squad-implement-worker.lock.yml squad-review.lock.yml squad-deps-worker.lock.yml; do + for LOCK in squad.lock.yml squad-implement-worker.lock.yml squad-review.lock.yml squad-deps-worker.lock.yml squad-retro.lock.yml; do if [ ! -f "$COMPILE_DIR/.github/workflows/$LOCK" ]; then echo "::error::$LOCK not emitted — compiler exited 0 without writing output" echo "❌ missing: \`$LOCK\`" >> "$GITHUB_STEP_SUMMARY" @@ -786,4 +786,4 @@ jobs: exit 1 fi echo "" >> "$GITHUB_STEP_SUMMARY" - echo "**✅ All four workflows compiled, all four lock files emitted**" >> "$GITHUB_STEP_SUMMARY" + echo "**✅ All five workflows compiled, all five lock files emitted**" >> "$GITHUB_STEP_SUMMARY" diff --git a/.squad/skills/gh-aw-enlistment/SKILL.md b/.squad/skills/gh-aw-enlistment/SKILL.md index 4feb92277..e10db32ee 100644 --- a/.squad/skills/gh-aw-enlistment/SKILL.md +++ b/.squad/skills/gh-aw-enlistment/SKILL.md @@ -11,7 +11,7 @@ tools: when: "Every step: preflight identity/auth, enabling Actions-created PRs, opening and watching the bootstrap PR." - name: "gh aw" description: "GitHub Agentic Workflows extension (github/gh-aw) — installs and strictly compiles the Squad workflow set." - when: "Installing the four @dev workflows and compiling them into deterministic .lock.yml files." + when: "Installing the five @dev workflows and compiling them into deterministic .lock.yml files." --- ## Context @@ -99,14 +99,15 @@ git switch -c chore/squad-gh-aw-bootstrap not part of the Squad set. `gh aw add` is additive; if you see it about to replace an unrelated workflow, **STOP**. -### 3. Install the four supported workflows — in order, dispatcher first +### 3. Install the five supported workflows — in order, dispatcher first ```bash gh aw add \ bradygaster/squad/workflows/squad.md@dev \ bradygaster/squad/workflows/squad-implement-worker.md@dev \ bradygaster/squad/workflows/squad-deps-worker.md@dev \ - bradygaster/squad/workflows/squad-review.md@dev + bradygaster/squad/workflows/squad-review.md@dev \ + bradygaster/squad/workflows/squad-retro.md@dev ``` Keep `squad.md` first: `gh aw add` discovers its worker/reviewer dependencies @@ -117,6 +118,7 @@ creating duplicates. The installed top-level set is exactly: - `squad-implement-worker.md` + `squad-implement-worker.lock.yml` - `squad-deps-worker.md` + `squad-deps-worker.lock.yml` - `squad-review.md` + `squad-review.lock.yml` +- `squad-retro.md` + `squad-retro.lock.yml` `@dev` is intentional — it tracks the branch where new modes and fixes land first. @@ -158,29 +160,29 @@ gh aw compile --strict This must run after any first-install approval and before committing. Success criteria: -- All four workflows compile successfully. +- All five workflows compile successfully. - The **only** permitted warning is the known `squad.md` bot-trigger warning: it configures both slash-command and `github-actions[bot]` triggers, and the bot trigger is required for controlled worker-continuation dispatches. - **STOP** on any error, or on **any additional warning** beyond that single documented one. -### 6. Require all four source/lock pairs to exist +### 6. Require all five source/lock pairs to exist ```bash -for workflow in squad squad-implement-worker squad-deps-worker squad-review; do +for workflow in squad squad-implement-worker squad-deps-worker squad-review squad-retro; do test -f ".github/workflows/${workflow}.md" || { echo "MISSING ${workflow}.md"; exit 1; } test -f ".github/workflows/${workflow}.lock.yml" || { echo "MISSING ${workflow}.lock.yml"; exit 1; } done ``` - **STOP** and rerun `gh aw compile --strict` if any `.lock.yml` is missing. Do not - open or merge the bootstrap PR until all **eight** files exist and strict + open or merge the bootstrap PR until all **ten** files exist and strict compilation passes. > On Windows PowerShell, the `for`/`test -f` loop above is POSIX. Use an > equivalent guard (e.g. `Test-Path`) or run it under Git Bash; the *logic* — all -> eight files must exist — is what matters. +> ten files must exist — is what matters. ### 7. Inspect generated files, then stage only the documented surfaces @@ -257,7 +259,8 @@ gh aw add \ bradygaster/squad/workflows/squad.md@dev \ bradygaster/squad/workflows/squad-implement-worker.md@dev \ bradygaster/squad/workflows/squad-deps-worker.md@dev \ - bradygaster/squad/workflows/squad-review.md@dev + bradygaster/squad/workflows/squad-review.md@dev \ + bradygaster/squad/workflows/squad-retro.md@dev # Safe-update report shows ONLY the two documented secrets + squad-init → approve once gh aw compile --strict --approve @@ -321,4 +324,4 @@ gh pr merge --squash # auto-merge before human review. NEVER. - ❌ **Widening the default token.** Keep `default_workflow_permissions=read`. - ❌ **Auto-merging.** The bootstrap PR and the later Cast PR are both human-reviewed. `/squad cast` runs only after the bootstrap PR merges. -- ❌ **Opening the PR before all eight files exist and strict compile passes.** +- ❌ **Opening the PR before all ten files exist and strict compile passes.** diff --git a/README.md b/README.md index 01a47d0a8..f84bec09a 100644 --- a/README.md +++ b/README.md @@ -566,7 +566,8 @@ gh aw add \ bradygaster/squad/workflows/squad.md@dev \ bradygaster/squad/workflows/squad-implement-worker.md@dev \ bradygaster/squad/workflows/squad-deps-worker.md@dev \ - bradygaster/squad/workflows/squad-review.md@dev + bradygaster/squad/workflows/squad-review.md@dev \ + bradygaster/squad/workflows/squad-retro.md@dev git add -- \ .github/aw/ \ .github/skills/ \ @@ -604,6 +605,7 @@ Review the complete generated diff before you commit: | `/squad retire ` | Remove a team member | | `/squad status` | Check current team | | `/squad implement` | Implement an issue or dispatch ready tasks from an epic | +| `/squad retro` | Run the shared retrospective now; scheduled and evidence-driven wakeups use the same worker | ### Casting brief tip diff --git a/docs/src/content/docs/guide/gh-aw.md b/docs/src/content/docs/guide/gh-aw.md index 08b5cfa11..22aa21b5a 100644 --- a/docs/src/content/docs/guide/gh-aw.md +++ b/docs/src/content/docs/guide/gh-aw.md @@ -35,7 +35,8 @@ gh aw add \ bradygaster/squad/workflows/squad.md@dev \ bradygaster/squad/workflows/squad-implement-worker.md@dev \ bradygaster/squad/workflows/squad-deps-worker.md@dev \ - bradygaster/squad/workflows/squad-review.md@dev + bradygaster/squad/workflows/squad-review.md@dev \ + bradygaster/squad/workflows/squad-retro.md@dev # 5. On first install, review the safe-update report. # If it contains only the documented Squad secrets and init action, approve it: @@ -45,7 +46,7 @@ gh aw compile --strict --approve gh aw compile --strict # Verify every supported workflow has a source and generated lockfile -for workflow in squad squad-implement-worker squad-deps-worker squad-review; do +for workflow in squad squad-implement-worker squad-deps-worker squad-review squad-retro; do test -f ".github/workflows/${workflow}.md" test -f ".github/workflows/${workflow}.lock.yml" done @@ -125,19 +126,20 @@ gh aw add \ bradygaster/squad/workflows/squad.md@dev \ bradygaster/squad/workflows/squad-implement-worker.md@dev \ bradygaster/squad/workflows/squad-deps-worker.md@dev \ - bradygaster/squad/workflows/squad-review.md@dev + bradygaster/squad/workflows/squad-review.md@dev \ + bradygaster/squad/workflows/squad-retro.md@dev ``` Keep the dispatcher first. `gh aw add` discovers its general worker, dependency -worker, and reviewer dependencies while compiling it; the explicit worker and -reviewer entries then confirm the complete install surface without creating -duplicates. +worker, reviewer, and retrospective dependencies while compiling it; the explicit +entries then confirm the complete install surface without creating duplicates. The installed top-level workflow set is: - `squad.md` and `squad.lock.yml` - `squad-implement-worker.md` and `squad-implement-worker.lock.yml` - `squad-deps-worker.md` and `squad-deps-worker.lock.yml` - `squad-review.md` and `squad-review.lock.yml` +- `squad-retro.md` and `squad-retro.lock.yml` `gh aw add` also installs the Squad skills under `.github/skills/`, which is why the bootstrap commit stages that path alongside the workflows. @@ -185,7 +187,7 @@ gh aw compile --strict ``` Run this exact command after any required first-install approval and before -committing. It must report all four workflows succeeded. `squad.md` currently +committing. It must report all five workflows succeeded. `squad.md` currently emits one known warning because both slash-command and `github-actions[bot]` triggers are configured; the bot trigger is required for controlled worker continuation dispatches. Any error or any additional warning is a stop condition. @@ -193,7 +195,7 @@ continuation dispatches. Any error or any additional warning is a stop condition Verify the complete source/lock surface: ```bash -for workflow in squad squad-implement-worker squad-deps-worker squad-review; do +for workflow in squad squad-implement-worker squad-deps-worker squad-review squad-retro; do test -f ".github/workflows/${workflow}.md" test -f ".github/workflows/${workflow}.lock.yml" done @@ -226,7 +228,7 @@ editor setting untracked. Delete it if you do not want the local setting, or stage it explicitly if your team wants to share it. > **Troubleshooting:** If the lock files are missing, rerun `gh aw compile -> --strict`. Do not open or merge the bootstrap PR until all four source/lock +> --strict`. Do not open or merge the bootstrap PR until all five source/lock > pairs exist and strict compilation succeeds. Downloaded workflow audit data is local diagnostic output and should not be @@ -287,8 +289,8 @@ Use this checklist for the initial bootstrap and after any workflow update: | Stage | Action | Expected evidence | |-------|--------|-------------------| -| Install | Run the four-workflow `gh aw add` command on a bootstrap branch | All four `.md`/`.lock.yml` pairs exist, with shared imports, `.github/aw/`, installed skills, and `.gitattributes` included in the diff | -| Compile | Review any first-install safe-update report, approve only the documented entries, then run `gh aw compile --strict` without approval | All four workflows succeed, only the documented bot-trigger warning remains, and all eight source/lock files exist | +| Install | Run the five-workflow `gh aw add` command on a bootstrap branch | All five `.md`/`.lock.yml` pairs exist, with shared imports, `.github/aw/`, installed skills, and `.gitattributes` included in the diff | +| Compile | Review any first-install safe-update report, approve only the documented entries, then run `gh aw compile --strict` without approval | All five workflows succeed, only documented warnings remain, and all ten source/lock files exist | | Bootstrap review | Open the PR, request `@copilot`, wait for checks, and merge only after human approval | The default branch receives the complete generated install as one human-reviewable change | | Activation | Run `/squad cast` after the bootstrap PR merges | The run resolves `v0.13.1` by default, installs the standalone bundle, initializes only when no committed team exists, runs health, and uploads `squad-state` | | Cast persistence | Review the Cast PR before merging | The PR contains `.squad/casting/policy.json`, `registry.json`, and `history.json`, plus the team, routing, charters, Copilot agent, and `meet-the-squad.md` | @@ -343,6 +345,7 @@ wins: `/squad plan accept scope` is not treated as `/squad plan`. | Activation | `/squad plan activate phase {N}` | Create GitHub issues for only Phase N | Use when accept didn't auto-activate | | Implementation | `/squad implement` | Implement an issue, or start the next ready wave of an epic | Dispatches an isolated implementation worker | | Review | `/squad review` | Independently review the current pull request | Advisory `COMMENT` or `REQUEST_CHANGES`; human approval remains mandatory | +| Retrospective | `/squad retro` | Run the shared retrospective immediately | Authorized manual run; weekly and evidence-driven wakeups use the same durable gate | ### Where you can use slash commands @@ -356,6 +359,48 @@ wins: `/squad plan accept scope` is not treated as `/squad plan`. For workflow dispatch, go to **Actions → Squad → Run workflow** and enter the command (for example `cast` or `connect myorg/my-squad`). +### Retrospective lifecycle + +`squad-retro` runs weekly, wakes every six hours to drain pending requests, and +accepts authorized `/squad retro` relays. Review and implementation workers also +send bounded early-evidence wakeups. A deterministic pre-agent gate, not the +reasoning model, paginates durable workflow/review evidence, excludes the retro's +own runs and issues, deduplicates retries, and counts distinct run IDs or review +revision SHAs. + +A failure fingerprint combines the workflow name, the failing job name, and the +first error line taken from a bounded excerpt of that job's log. The head SHA is +deliberately excluded so an identical failure matches across revisions. When no +error line can be read, the gate records low-confidence evidence that can never +qualify on its own, and a transient GitHub API failure produces a visible no-op +with a diagnostic instead of a crash, leaving pending requests untouched. +Malformed `.squad/config.json` values still fail the run visibly. + +The defaults are two independent attempts within 168 hours and a 72-hour +cooldown. Repositories may set `squadRetroEarlyThreshold`, +`squadRetroWindowHours`, and `squadRetroCooldownHours` in `.squad/config.json`, +or disable the workflow with `"squadRetro": "deny"`. The full retrospective +remains due every fixed 168 hours regardless of the configured evidence window; +the six-hour schedule only drains evidence and pending requests between weekly +runs. Requests suppressed by cooldown remain as structured comments on the +bot-authored `squad-retro-state` issue; the periodic drain re-evaluates them and +expires requests whose evidence aged out only when that kind's collection is +complete. Truncated failed-run collection preserves pending `fail:` requests; +truncated pull-request collection preserves pending `review:` requests. Reports +list these preserved requests separately from expired requests. + +State discovery prefers the lowest-numbered open `github-actions[bot]`-authored +issue carrying `squad-retro-state`. If none exists, a bounded fallback looks for +an open issue from that same bot titled exactly `Squad retrospective state`. +The explicit `repair_state_label` gate adds the missing label and no-ops instead +of creating a duplicate; same-title human-authored issues are never trusted. +An incomplete state-discovery scan also no-ops rather than risking a duplicate. + +Reports and at most five owned action issues are automatic. Changes to +`.squad/**`, `.github/**`, prompts, charters, routing, or governance remain +human-reviewed proposals. The worker cannot edit files, create pull requests, +merge, change permissions or secrets, or upgrade Squad. + --- ## Casting a team @@ -1099,7 +1144,7 @@ exactly one pull request review. It returns: or required-but-missing changeset - `COMMENT` when findings are advisory or no merge blocker is established -The reviewer has no file-editing, workflow-dispatch, issue-creation, +The reviewer has no file-editing, issue-creation, pull-request-creation, remediation, merge, or `APPROVE` authority. Its verdict does not replace branch protection, required status checks, or a human reviewer's approval. Human approval remains mandatory. @@ -1293,6 +1338,7 @@ gh aw add \ bradygaster/squad/workflows/squad-implement-worker.md@${SQUAD_SHA} \ bradygaster/squad/workflows/squad-deps-worker.md@${SQUAD_SHA} \ bradygaster/squad/workflows/squad-review.md@${SQUAD_SHA} \ + bradygaster/squad/workflows/squad-retro.md@${SQUAD_SHA} \ --force ``` @@ -1322,7 +1368,7 @@ curl --fail --silent --show-error --location \ gh aw compile --strict ``` -Confirm all four source files and generated locks reference `SQUAD_SHA`, review +Confirm all five source files and generated locks reference `SQUAD_SHA`, review the workflow diff, then commit them together. With gh-aw v0.87.10, do not use `gh aw update` for this immutable-pin flow: its stored source branch and cooldown can leave the installed sources at a different revision than the SHA you intend. diff --git a/packages/squad-cli/templates/skills/gh-aw-enlistment/SKILL.md b/packages/squad-cli/templates/skills/gh-aw-enlistment/SKILL.md index 4feb92277..e10db32ee 100644 --- a/packages/squad-cli/templates/skills/gh-aw-enlistment/SKILL.md +++ b/packages/squad-cli/templates/skills/gh-aw-enlistment/SKILL.md @@ -11,7 +11,7 @@ tools: when: "Every step: preflight identity/auth, enabling Actions-created PRs, opening and watching the bootstrap PR." - name: "gh aw" description: "GitHub Agentic Workflows extension (github/gh-aw) — installs and strictly compiles the Squad workflow set." - when: "Installing the four @dev workflows and compiling them into deterministic .lock.yml files." + when: "Installing the five @dev workflows and compiling them into deterministic .lock.yml files." --- ## Context @@ -99,14 +99,15 @@ git switch -c chore/squad-gh-aw-bootstrap not part of the Squad set. `gh aw add` is additive; if you see it about to replace an unrelated workflow, **STOP**. -### 3. Install the four supported workflows — in order, dispatcher first +### 3. Install the five supported workflows — in order, dispatcher first ```bash gh aw add \ bradygaster/squad/workflows/squad.md@dev \ bradygaster/squad/workflows/squad-implement-worker.md@dev \ bradygaster/squad/workflows/squad-deps-worker.md@dev \ - bradygaster/squad/workflows/squad-review.md@dev + bradygaster/squad/workflows/squad-review.md@dev \ + bradygaster/squad/workflows/squad-retro.md@dev ``` Keep `squad.md` first: `gh aw add` discovers its worker/reviewer dependencies @@ -117,6 +118,7 @@ creating duplicates. The installed top-level set is exactly: - `squad-implement-worker.md` + `squad-implement-worker.lock.yml` - `squad-deps-worker.md` + `squad-deps-worker.lock.yml` - `squad-review.md` + `squad-review.lock.yml` +- `squad-retro.md` + `squad-retro.lock.yml` `@dev` is intentional — it tracks the branch where new modes and fixes land first. @@ -158,29 +160,29 @@ gh aw compile --strict This must run after any first-install approval and before committing. Success criteria: -- All four workflows compile successfully. +- All five workflows compile successfully. - The **only** permitted warning is the known `squad.md` bot-trigger warning: it configures both slash-command and `github-actions[bot]` triggers, and the bot trigger is required for controlled worker-continuation dispatches. - **STOP** on any error, or on **any additional warning** beyond that single documented one. -### 6. Require all four source/lock pairs to exist +### 6. Require all five source/lock pairs to exist ```bash -for workflow in squad squad-implement-worker squad-deps-worker squad-review; do +for workflow in squad squad-implement-worker squad-deps-worker squad-review squad-retro; do test -f ".github/workflows/${workflow}.md" || { echo "MISSING ${workflow}.md"; exit 1; } test -f ".github/workflows/${workflow}.lock.yml" || { echo "MISSING ${workflow}.lock.yml"; exit 1; } done ``` - **STOP** and rerun `gh aw compile --strict` if any `.lock.yml` is missing. Do not - open or merge the bootstrap PR until all **eight** files exist and strict + open or merge the bootstrap PR until all **ten** files exist and strict compilation passes. > On Windows PowerShell, the `for`/`test -f` loop above is POSIX. Use an > equivalent guard (e.g. `Test-Path`) or run it under Git Bash; the *logic* — all -> eight files must exist — is what matters. +> ten files must exist — is what matters. ### 7. Inspect generated files, then stage only the documented surfaces @@ -257,7 +259,8 @@ gh aw add \ bradygaster/squad/workflows/squad.md@dev \ bradygaster/squad/workflows/squad-implement-worker.md@dev \ bradygaster/squad/workflows/squad-deps-worker.md@dev \ - bradygaster/squad/workflows/squad-review.md@dev + bradygaster/squad/workflows/squad-review.md@dev \ + bradygaster/squad/workflows/squad-retro.md@dev # Safe-update report shows ONLY the two documented secrets + squad-init → approve once gh aw compile --strict --approve @@ -321,4 +324,4 @@ gh pr merge --squash # auto-merge before human review. NEVER. - ❌ **Widening the default token.** Keep `default_workflow_permissions=read`. - ❌ **Auto-merging.** The bootstrap PR and the later Cast PR are both human-reviewed. `/squad cast` runs only after the bootstrap PR merges. -- ❌ **Opening the PR before all eight files exist and strict compile passes.** +- ❌ **Opening the PR before all ten files exist and strict compile passes.** diff --git a/packages/squad-sdk/templates/skills/gh-aw-enlistment/SKILL.md b/packages/squad-sdk/templates/skills/gh-aw-enlistment/SKILL.md index 4feb92277..e10db32ee 100644 --- a/packages/squad-sdk/templates/skills/gh-aw-enlistment/SKILL.md +++ b/packages/squad-sdk/templates/skills/gh-aw-enlistment/SKILL.md @@ -11,7 +11,7 @@ tools: when: "Every step: preflight identity/auth, enabling Actions-created PRs, opening and watching the bootstrap PR." - name: "gh aw" description: "GitHub Agentic Workflows extension (github/gh-aw) — installs and strictly compiles the Squad workflow set." - when: "Installing the four @dev workflows and compiling them into deterministic .lock.yml files." + when: "Installing the five @dev workflows and compiling them into deterministic .lock.yml files." --- ## Context @@ -99,14 +99,15 @@ git switch -c chore/squad-gh-aw-bootstrap not part of the Squad set. `gh aw add` is additive; if you see it about to replace an unrelated workflow, **STOP**. -### 3. Install the four supported workflows — in order, dispatcher first +### 3. Install the five supported workflows — in order, dispatcher first ```bash gh aw add \ bradygaster/squad/workflows/squad.md@dev \ bradygaster/squad/workflows/squad-implement-worker.md@dev \ bradygaster/squad/workflows/squad-deps-worker.md@dev \ - bradygaster/squad/workflows/squad-review.md@dev + bradygaster/squad/workflows/squad-review.md@dev \ + bradygaster/squad/workflows/squad-retro.md@dev ``` Keep `squad.md` first: `gh aw add` discovers its worker/reviewer dependencies @@ -117,6 +118,7 @@ creating duplicates. The installed top-level set is exactly: - `squad-implement-worker.md` + `squad-implement-worker.lock.yml` - `squad-deps-worker.md` + `squad-deps-worker.lock.yml` - `squad-review.md` + `squad-review.lock.yml` +- `squad-retro.md` + `squad-retro.lock.yml` `@dev` is intentional — it tracks the branch where new modes and fixes land first. @@ -158,29 +160,29 @@ gh aw compile --strict This must run after any first-install approval and before committing. Success criteria: -- All four workflows compile successfully. +- All five workflows compile successfully. - The **only** permitted warning is the known `squad.md` bot-trigger warning: it configures both slash-command and `github-actions[bot]` triggers, and the bot trigger is required for controlled worker-continuation dispatches. - **STOP** on any error, or on **any additional warning** beyond that single documented one. -### 6. Require all four source/lock pairs to exist +### 6. Require all five source/lock pairs to exist ```bash -for workflow in squad squad-implement-worker squad-deps-worker squad-review; do +for workflow in squad squad-implement-worker squad-deps-worker squad-review squad-retro; do test -f ".github/workflows/${workflow}.md" || { echo "MISSING ${workflow}.md"; exit 1; } test -f ".github/workflows/${workflow}.lock.yml" || { echo "MISSING ${workflow}.lock.yml"; exit 1; } done ``` - **STOP** and rerun `gh aw compile --strict` if any `.lock.yml` is missing. Do not - open or merge the bootstrap PR until all **eight** files exist and strict + open or merge the bootstrap PR until all **ten** files exist and strict compilation passes. > On Windows PowerShell, the `for`/`test -f` loop above is POSIX. Use an > equivalent guard (e.g. `Test-Path`) or run it under Git Bash; the *logic* — all -> eight files must exist — is what matters. +> ten files must exist — is what matters. ### 7. Inspect generated files, then stage only the documented surfaces @@ -257,7 +259,8 @@ gh aw add \ bradygaster/squad/workflows/squad.md@dev \ bradygaster/squad/workflows/squad-implement-worker.md@dev \ bradygaster/squad/workflows/squad-deps-worker.md@dev \ - bradygaster/squad/workflows/squad-review.md@dev + bradygaster/squad/workflows/squad-review.md@dev \ + bradygaster/squad/workflows/squad-retro.md@dev # Safe-update report shows ONLY the two documented secrets + squad-init → approve once gh aw compile --strict --approve @@ -321,4 +324,4 @@ gh pr merge --squash # auto-merge before human review. NEVER. - ❌ **Widening the default token.** Keep `default_workflow_permissions=read`. - ❌ **Auto-merging.** The bootstrap PR and the later Cast PR are both human-reviewed. `/squad cast` runs only after the bootstrap PR merges. -- ❌ **Opening the PR before all eight files exist and strict compile passes.** +- ❌ **Opening the PR before all ten files exist and strict compile passes.** diff --git a/test/gh-aw-enlistment-skill.test.ts b/test/gh-aw-enlistment-skill.test.ts index 56163fd75..728316488 100644 --- a/test/gh-aw-enlistment-skill.test.ts +++ b/test/gh-aw-enlistment-skill.test.ts @@ -125,15 +125,17 @@ describe('gh-aw-enlistment skill', () => { expect(bulletLines.length, 'allowlist region must contain exactly 2 bullet lines').toBe(2); }); - it('installs all four @dev workflows', () => { + it('installs all five @dev workflows and requires all ten generated files', () => { for (const wf of [ 'squad.md@dev', 'squad-implement-worker.md@dev', 'squad-deps-worker.md@dev', 'squad-review.md@dev', + 'squad-retro.md@dev', ]) { expect(content, `should install ${wf}`).toContain(wf); } + expect(content).toContain('all **ten** files'); }); it('requires a final strict compile without --approve', () => { diff --git a/test/gh-aw-implement-workflow.test.ts b/test/gh-aw-implement-workflow.test.ts index c0a226497..7d48d7a6b 100644 --- a/test/gh-aw-implement-workflow.test.ts +++ b/test/gh-aw-implement-workflow.test.ts @@ -148,6 +148,8 @@ describe('gh-aw implement workflows', () => { 'group: "squad-implement-${{ github.event.inputs.issue_number || github.event.pull_request.number }}"', ); expect(worker).toContain('cancel-in-progress: false'); + expect(listInBlock(yamlBlock(workerFrontmatter, 'dispatch-workflow'), 'workflows')) + .toEqual(['squad', 'squad-retro']); }); it('continues epic execution after implementation PRs merge', () => { @@ -213,7 +215,7 @@ describe('gh-aw implement workflows', () => { const dispatch = safeOutputs.dispatch_workflow; expect(dispatch, 'compiled worker must retain dispatch-workflow configuration').toBeDefined(); - expect(dispatch.aw_context_workflows).toEqual(['squad']); + expect(dispatch.aw_context_workflows).toEqual(['squad', 'squad-retro']); expect(dispatch['target-ref']).toBe('${{ github.event.repository.default_branch }}'); }, 20000); @@ -231,6 +233,7 @@ describe('gh-aw implement workflows', () => { 'bradygaster/squad/workflows/squad-implement-worker.md@dev', 'bradygaster/squad/workflows/squad-deps-worker.md@dev', 'bradygaster/squad/workflows/squad-review.md@dev', + 'bradygaster/squad/workflows/squad-retro.md@dev', ]; const orderedInstallCommand = [ 'gh aw add \\', @@ -252,7 +255,7 @@ describe('gh-aw implement workflows', () => { ); expect(hasOrderedInstallCommand(reorderedGuide)).toBe(false); expect(guide).toMatch( - /Keep the dispatcher first\. `gh aw add` discovers its general worker, dependency\s+worker, and reviewer dependencies while compiling it; the explicit worker and\s+reviewer entries then confirm the complete install surface without creating\s+duplicates\./, + /Keep the dispatcher first\. `gh aw add` discovers its general worker, dependency\s+worker, reviewer, and retrospective dependencies while compiling it; the explicit\s+entries then confirm the complete install surface without creating duplicates\./, ); }); }); @@ -476,8 +479,11 @@ describe('gh-aw implement worker: cross-sibling refill traversal (#1779)', () => const workerDispatch = yamlBlock(frontmatter(worker), 'dispatch-workflow'); const dispatchMax = Number(scalarInBlock(workerDispatch, 'max')); - expect(dispatchMax, 'refill scope is the fix; do not paper over it by raising max').toBe(2); - expect(dispatchMax).toBeLessThanOrEqual(slotCap); + const retroWakeupBudget = 1; + expect(listInBlock(workerDispatch, 'workflows')).toEqual(['squad', 'squad-retro']); + expect(worker).toContain('emit one complete typed retrospective'); + expect(dispatchMax - retroWakeupBudget, 'retro wakeup must not widen the refill budget').toBe(2); + expect(dispatchMax - retroWakeupBudget).toBeLessThanOrEqual(slotCap); }); // Negative control: documents the pre-fix scope and proves the model above is diff --git a/test/gh-aw-retro-workflow.test.ts b/test/gh-aw-retro-workflow.test.ts new file mode 100644 index 000000000..5a0b49d54 --- /dev/null +++ b/test/gh-aw-retro-workflow.test.ts @@ -0,0 +1,1532 @@ +import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, describe, expect, it } from 'vitest'; +import { + collectRecentPages, + collectLiveInput, + collectStateIssues, + collectionFailureContext, + deriveRequestGroup, + evaluateRetro, + evidenceFingerprint, + extractErrorLine, + FAILED_RUN_LIMIT, + normalizeSignature, + PULL_REQUEST_LIMIT, + resolveRetroConfig, +} from '../workflows/shared/squad-retro-evidence.mjs'; +import { extractSafeOutputsConfigJson } from './helpers/gh-aw-lock.js'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const GATE = resolve(ROOT, 'workflows/shared/squad-retro-evidence.mjs'); +const RETRO = read('workflows/squad-retro.md'); +const ROUTER = read('workflows/squad.md'); +const REVIEWER = read('workflows/squad-review.md'); +const IMPLEMENTER = read('workflows/squad-implement-worker.md'); +const CI = read('.github/workflows/squad-ci.yml'); +const workspaces: string[] = []; + +function read(path: string): string { + return readFileSync(resolve(ROOT, path), 'utf8').replace(/\r\n/g, '\n'); +} + +function scratch(config?: unknown): string { + const workspace = mkdtempSync(resolve(ROOT, '.squad-retro-gate-')); + workspaces.push(workspace); + if (config !== undefined) { + mkdirSync(resolve(workspace, '.squad'), { recursive: true }); + writeFileSync( + resolve(workspace, '.squad', 'config.json'), + typeof config === 'string' ? config : JSON.stringify(config), + ); + } + return workspace; +} + +function stateIssue(author = 'github-actions[bot]') { + return [{ + number: 41, + author, + state: 'open', + labels: ['squad', 'squad-retro-state'], + }]; +} + +function stateComment(data: Record, createdAt = '2026-09-01T00:00:00Z') { + return [{ + author: 'github-actions[bot]', + created_at: createdAt, + body: `## Squad retrospective state\n\nStructured data:\n\n\`\`\`json\n${JSON.stringify({ + squad_artifact: 'retro-state', + schema_version: '1', + retro_id: '1', + ...data, + })}\n\`\`\``, + }]; +} + +function failure(overrides: Record = {}) { + return { + id: 101, + run_attempt: 1, + workflow_name: 'CI', + job_name: 'test', + error_line: 'Expected 2 but got 1', + head_sha: 'a'.repeat(40), + html_url: 'https://github.example/runs/101', + created_at: '2026-09-06T00:00:00Z', + conclusion: 'failure', + ...overrides, + }; +} + +function review(overrides: Record = {}) { + return { + pr_number: 12, + head_sha: 'b'.repeat(40), + state: 'CHANGES_REQUESTED', + submitted_at: '2026-09-06T00:00:00Z', + html_url: 'https://github.example/pull/12#review-1', + theme: 'Missing empty-input coverage', + path_prefix: 'src', + labels: [], + ...overrides, + }; +} + +function baseInput(overrides: Record = {}) { + return { + now: '2026-09-07T00:00:00Z', + eventName: 'schedule', + config: {}, + stateIssues: stateIssue(), + stateComments: stateComment({ + status: 'IDLE', + completed_at: '2026-09-03T00:00:00Z', + resolved_fingerprints: [], + pending_fingerprints: [], + }), + workflowRuns: [], + reviews: [], + incoming: {}, + ...overrides, + }; +} + +function collectFixture({ + runs = [], + pulls = [], + comments = baseInput().stateComments, + nested = (route: string, _fields: Record): unknown => { + throw new Error(`Unexpected API route: ${route}`); + }, + log = () => Buffer.from('##[error]Expected 2 but got 1'), +}: { + runs?: Record[]; + pulls?: Record[]; + comments?: ReturnType; + nested?: (route: string, fields: Record) => unknown; + log?: () => Buffer; +} = {}) { + const calls: { route: string; page: number }[] = []; + let logCalls = 0; + const input = collectLiveInput({ + GITHUB_REPOSITORY: 'squad-test/example', + GITHUB_WORKSPACE: scratch({}), + SQUAD_RETRO_EVENT_NAME: 'schedule', + }, { + now: new Date('2026-09-07T00:00:00Z'), + fetchJson: (route, fields) => { + calls.push({ route, page: fields.page }); + if (route.endsWith('/issues')) { + return stateIssue().map(issue => ({ ...issue, user: { login: issue.author } })); + } + if (route.endsWith('/comments')) { + return comments.map(comment => ({ ...comment, user: { login: comment.author } })); + } + if (route.endsWith('/actions/runs')) return { workflow_runs: runs }; + if (route.endsWith('/pulls')) return pulls; + return nested(route, fields); + }, + fetchLog: () => { + logCalls++; + return log(); + }, + }); + return { input, result: evaluateRetro(input), calls, logCalls }; +} + +afterAll(() => { + for (const workspace of workspaces) rmSync(workspace, { recursive: true, force: true }); +}); + +describe('Squad retrospective deterministic gate', () => { + it('normalizes unstable values into a stable fingerprint', () => { + const left = normalizeSignature( + 'CI job 123 failed at C:\\Temp\\run-55\\out on 2026-09-06T01:02:03Z https://example/1 abcdef1234567', + ); + const right = normalizeSignature( + 'ci job 999 failed at C:\\Temp\\run-88\\out on 2026-09-07T04:05:06Z https://example/2 fedcba7654321', + ); + expect(left).toBe(right); + expect(evidenceFingerprint('fail', left)).toBe(evidenceFingerprint('fail', right)); + }); + + it('uses bounded configurable defaults and rejects malformed configuration', () => { + expect(resolveRetroConfig({})).toEqual({ + enabled: true, + threshold: 2, + windowHours: 168, + cooldownHours: 72, + }); + expect(() => resolveRetroConfig({ squadRetroEarlyThreshold: 1 })).toThrow(); + expect(() => resolveRetroConfig({ squadRetro: true })).toThrow(); + }); + + it('keeps the full retrospective weekly while the evidence window remains configurable', () => { + const notWeeklyDue = evaluateRetro(baseInput({ + config: { squadRetroWindowHours: 24 }, + stateComments: stateComment({ + retro_id: '20', + status: 'IDLE', + completed_at: '2026-09-03T00:00:00Z', + resolved_fingerprints: [], + pending_fingerprints: [], + }), + workflowRuns: [failure()], + })); + expect(notWeeklyDue.weekly_interval_hours).toBe(168); + expect(notWeeklyDue.trigger_path).toBe('drain'); + expect(notWeeklyDue.action).toBe('noop'); + + const weeklyDue = evaluateRetro(baseInput({ + config: { squadRetroWindowHours: 24 }, + stateComments: stateComment({ + retro_id: '21', + status: 'IDLE', + completed_at: '2026-08-30T00:00:00Z', + resolved_fingerprints: [], + pending_fingerprints: [], + }), + workflowRuns: [failure()], + })); + expect(weeklyDue.trigger_path).toBe('scheduled'); + expect(weeklyDue.action).toBe('run'); + }); + + it('does not let an early retrospective postpone the next full weekly report', () => { + const result = evaluateRetro(baseInput({ + stateComments: stateComment({ + retro_id: '22', + status: 'IDLE', + completed_at: '2026-09-03T00:00:00Z', + last_full_completed_at: '2026-08-30T00:00:00Z', + resolved_fingerprints: [], + pending_fingerprints: [], + }), + workflowRuns: [failure()], + })); + expect(result.state_completed_at).toBe('2026-09-03T00:00:00.000Z'); + expect(result.state_last_full_completed_at).toBe('2026-08-30T00:00:00.000Z'); + expect(result.trigger_path).toBe('scheduled'); + expect(result.action).toBe('run'); + }); + + it('keeps an explicit null full-report timestamp due after an early-only completion', () => { + const result = evaluateRetro(baseInput({ + stateComments: stateComment({ + retro_id: '23', + status: 'IDLE', + completed_at: '2026-09-03T00:00:00Z', + last_full_completed_at: null, + resolved_fingerprints: [], + pending_fingerprints: [], + }), + workflowRuns: [failure()], + })); + expect(result.state_last_full_completed_at).toBeNull(); + expect(result.trigger_path).toBe('scheduled'); + expect(result.action).toBe('run'); + }); + + it('requires the exact threshold across independent workflow attempts', () => { + const one = evaluateRetro(baseInput({ workflowRuns: [failure()] })); + expect(one.action).toBe('noop'); + expect(one.reason).toBe('early-threshold-not-met'); + + const retries = evaluateRetro(baseInput({ + workflowRuns: [failure(), failure({ run_attempt: 2 })], + })); + expect(retries.action).toBe('noop'); + expect(retries.evidence_groups[0].attempt_count).toBe(1); + + const independent = evaluateRetro(baseInput({ + workflowRuns: [failure(), failure({ id: 102, html_url: 'https://github.example/runs/102' })], + })); + expect(independent.action).toBe('run'); + expect(independent.qualifying_groups[0].attempt_count).toBe(2); + }); + + it('counts repeated review rejection only across distinct revisions', () => { + const sameRevision = evaluateRetro(baseInput({ + reviews: [review(), review({ html_url: 'https://github.example/pull/12#review-2' })], + })); + expect(sameRevision.action).toBe('noop'); + expect(sameRevision.evidence_groups[0].attempt_count).toBe(1); + + const distinctRevision = evaluateRetro(baseInput({ + reviews: [review(), review({ head_sha: 'c'.repeat(40) })], + })); + expect(distinctRevision.action).toBe('run'); + expect(distinctRevision.qualifying_groups[0].kind).toBe('review'); + }); + + it('treats heading-only review rejections as low-confidence evidence', () => { + const { result } = collectFixture({ + pulls: [{ number: 12, updated_at: '2026-09-06T00:00:00Z', labels: [] }], + nested: route => { + if (route.endsWith('/reviews')) { + return [ + review({ body: '## Review', commit_id: 'b'.repeat(40) }), + review({ body: '## Review', commit_id: 'c'.repeat(40) }), + ]; + } + if (route.endsWith('/files')) return [{ filename: 'src/example.ts' }]; + throw new Error(`Unexpected API route: ${route}`); + }, + }); + expect(result.action).toBe('noop'); + expect(result.evidence_groups[0]).toMatchObject({ + attempt_count: 2, + low_confidence: true, + qualifies: false, + }); + }); + + it('ignores review provenance and mandatory footer boilerplate as rejection themes', () => { + const { result } = collectFixture({ + pulls: [{ number: 12, updated_at: '2026-09-06T00:00:00Z', labels: [] }], + nested: route => { + if (route.endsWith('/reviews')) { + return [ + review({ + body: '## Review\nProvenance: Squad implementation worker.\nHuman approval remains mandatory.\nSquad-Review-Head: abc', + commit_id: 'b'.repeat(40), + }), + review({ + body: '## Review\nProvenance: Squad implementation worker.\nHuman approval remains mandatory.\nSquad-Review-Head: def', + commit_id: 'c'.repeat(40), + }), + ]; + } + if (route.endsWith('/files')) return [{ filename: 'src/example.ts' }]; + throw new Error(`Unexpected API route: ${route}`); + }, + }); + expect(result.action).toBe('noop'); + expect(result.evidence_groups[0]).toMatchObject({ + attempt_count: 2, + low_confidence: true, + qualifies: false, + }); + }); + + it.each(['fail', 'review'])('only drains unresolved or recurring qualifying %s evidence', kind => { + const fingerprint = kind === 'fail' + ? evidenceFingerprint('fail', 'CI|test|Expected 2 but got 1') + : evidenceFingerprint('review', 'Missing empty-input coverage|src'); + const evidence = (latest: string) => kind === 'fail' ? { + workflowRuns: [ + failure({ created_at: '2026-09-02T00:00:00Z' }), + failure({ id: 102, created_at: latest }), + ], + } : { + reviews: [ + review({ submitted_at: '2026-09-02T00:00:00Z' }), + review({ head_sha: 'c'.repeat(40), submitted_at: latest }), + ], + }; + const stateComments = stateComment({ + status: 'IDLE', + completed_at: '2026-09-03T00:00:00Z', + resolved_fingerprints: [fingerprint], + pending_fingerprints: [], + }); + for (const incoming of [{}, { reason: 'drain' }, { reason: 'early-evidence', fingerprint }]) { + const old = evaluateRetro(baseInput({ + stateComments, incoming, ...evidence('2026-09-03T00:00:00Z'), + })); + expect(old.action).toBe('noop'); + expect(old.evidence_groups[0].qualifies).toBe(true); + expect(old.qualifying_groups).toEqual([]); + expect(old.primary_fingerprint).toBeNull(); + + const recurred = evaluateRetro(baseInput({ + stateComments, incoming, ...evidence('2026-09-03T00:00:01Z'), + })); + expect(recurred.action).toBe('run'); + expect(recurred.qualifying_groups.map(group => group.fingerprint)).toEqual([fingerprint]); + expect(recurred.primary_fingerprint).toBe(fingerprint); + } + const unresolved = evaluateRetro(baseInput({ + ...evidence('2026-09-02T00:00:00Z'), + })); + expect(unresolved.action).toBe('run'); + }); + + it('does not resolve evidence until the final state checkpoint is committed', () => { + const fingerprint = evidenceFingerprint('fail', 'CI|test|Expected 2 but got 1'); + const comments = [ + ...baseInput().stateComments, + ...['2026-09-04T00:00:00Z', '2026-09-05T00:00:00Z'].map(createdAt => + stateComment({ squad_artifact: 'retro-report', fingerprint }, createdAt)[0]), + ]; + const old = evaluateRetro(baseInput({ + stateComments: comments, + workflowRuns: [ + failure({ created_at: '2026-09-04T12:00:00Z' }), + failure({ id: 102, created_at: '2026-09-05T00:00:00Z' }), + ], + })); + expect(old.action).toBe('run'); + expect(old.qualifying_groups.map(group => group.fingerprint)).toEqual([fingerprint]); + const recurring = evaluateRetro(baseInput({ + stateComments: comments, + workflowRuns: [failure(), failure({ id: 102 })], + })); + expect(recurring.action).toBe('run'); + }); + + it('keeps resolved old groups out of actionable output when another group triggers a drain', () => { + const resolved = evidenceFingerprint('fail', 'CI|test|Expected 2 but got 1'); + const actionable = evidenceFingerprint('fail', 'CI|test|new regression'); + const result = evaluateRetro(baseInput({ + stateComments: stateComment({ + status: 'IDLE', + completed_at: '2026-09-03T00:00:00Z', + resolved_fingerprints: [resolved], + pending_fingerprints: [], + }), + workflowRuns: [ + ...[101, 102, 103].map(id => failure({ id, created_at: '2026-09-02T00:00:00Z' })), + ...[201, 202].map(id => failure({ id, error_line: 'new regression' })), + ], + })); + expect(result.action).toBe('run'); + expect(result.evidence_groups).toHaveLength(2); + expect(result.qualifying_groups.map(group => group.fingerprint)).toEqual([actionable]); + expect(result.primary_fingerprint).toBe(actionable); + }); + + it.each(['manual', 'scheduled'])('still reports resolved evidence on a %s retrospective', path => { + const fingerprint = evidenceFingerprint('fail', 'CI|test|Expected 2 but got 1'); + const result = evaluateRetro(baseInput({ + config: { squadRetroWindowHours: 336 }, + eventName: path === 'manual' ? 'workflow_dispatch' : 'schedule', + incoming: path === 'manual' ? { reason: 'manual', origin: 'manual' } : {}, + stateComments: stateComment({ + status: 'IDLE', + completed_at: '2026-08-30T00:00:00Z', + resolved_fingerprints: [fingerprint], + pending_fingerprints: [], + }), + workflowRuns: [ + failure({ created_at: '2026-08-29T00:00:00Z' }), + failure({ id: 102, created_at: '2026-08-29T00:00:00Z' }), + ], + })); + expect(result.trigger_path).toBe(path); + expect(result.action).toBe('run'); + expect(result.qualifying_groups.map(group => group.fingerprint)).toEqual([fingerprint]); + }); + + it.each([ + { latest: '2026-09-02T00:00:00Z', count: 2, resolved: true, action: 'noop' }, + { latest: '2026-09-06T00:00:00Z', count: 1, resolved: true, action: 'noop' }, + { latest: '2026-09-06T00:00:00Z', count: 2, resolved: true, action: 'run' }, + { latest: '2026-09-02T00:00:00Z', count: 2, resolved: false, action: 'run' }, + ])('requires current valid evidence for a pending drain: %j', ({ latest, count, resolved, action }) => { + const fingerprint = evidenceFingerprint('fail', 'CI|test|Expected 2 but got 1'); + const result = evaluateRetro(baseInput({ + stateComments: stateComment({ + status: 'IDLE', + completed_at: '2026-09-03T00:00:00Z', + resolved_fingerprints: resolved ? [fingerprint] : [], + pending_fingerprints: [fingerprint], + }), + workflowRuns: Array.from({ length: count }, (_, index) => + failure({ id: 101 + index, created_at: latest })), + })); + expect(result.trigger_path).toBe('drain'); + expect(result.action).toBe(action); + expect(result.pending_fingerprints).toEqual([fingerprint]); + expect(result.qualifying_groups).toHaveLength(action === 'run' ? 1 : 0); + }); + + it('keeps incoming requests pending during cooldown and drains them after expiry', () => { + const fingerprint = evidenceFingerprint('fail', 'CI|test|Expected 2 but got 1'); + const cooling = evaluateRetro(baseInput({ + stateComments: stateComment({ + status: 'IDLE', + completed_at: '2026-09-06T00:00:00Z', + resolved_fingerprints: [], + pending_fingerprints: [], + }), + workflowRuns: [failure(), failure({ id: 102 })], + incoming: { reason: 'early-evidence', origin: 'squad-implement', fingerprint }, + })); + expect(cooling.action).toBe('suppress'); + expect(cooling.reason).toBe('cooldown-active'); + expect(cooling.pending_fingerprints).toContain(fingerprint); + + const drained = evaluateRetro(baseInput({ + workflowRuns: [failure(), failure({ id: 102 })], + stateComments: [ + ...stateComment({ + status: 'IDLE', + completed_at: '2026-09-03T00:00:00Z', + resolved_fingerprints: [], + pending_fingerprints: [], + }), + { + author: 'github-actions[bot]', + created_at: '2026-09-06T00:00:00Z', + body: `Request\n\nStructured data:\n\n\`\`\`json\n${JSON.stringify({ + squad_artifact: 'retro-request', + schema_version: '1', + retro_id: '2', + fingerprint, + })}\n\`\`\``, + }, + ], + })); + expect(drained.action).toBe('run'); + expect(drained.pending_fingerprints).toContain(fingerprint); + }); + + it('expires a pending request after its evidence ages out', () => { + const fingerprint = evidenceFingerprint('fail', 'CI|test|Expected 2 but got 1'); + const result = evaluateRetro(baseInput({ + stateComments: [ + ...stateComment({ + status: 'IDLE', + completed_at: '2026-09-03T00:00:00Z', + resolved_fingerprints: [], + pending_fingerprints: [], + }), + { + author: 'github-actions[bot]', + created_at: '2026-09-04T00:00:00Z', + body: `Request\n\nStructured data:\n\n\`\`\`json\n${JSON.stringify({ + squad_artifact: 'retro-request', + schema_version: '1', + retro_id: '2', + fingerprint, + })}\n\`\`\``, + }, + ], + })); + expect(result.action).toBe('housekeep'); + expect(result.expired_pending_fingerprints).toEqual([fingerprint]); + expect(result.pending_fingerprints).toEqual([]); + }); + + it.each([ + { failed: true, pulls: true, preservedKinds: ['fail', 'review'], action: 'noop' }, + { failed: true, pulls: false, preservedKinds: ['fail'], action: 'housekeep' }, + { failed: false, pulls: true, preservedKinds: ['review'], action: 'housekeep' }, + { failed: false, pulls: false, preservedKinds: [], action: 'housekeep' }, + ])('expires only complete evidence kinds: $failed/$pulls', ({ + failed, pulls, preservedKinds, action, + }) => { + const pending = [ + evidenceFingerprint('fail', 'CI|test|aged-out failure'), + evidenceFingerprint('review', 'aged-out rejection|src'), + ]; + const preserved = pending.filter(value => preservedKinds.includes(value.split(':')[0])); + const expired = pending.filter(value => !preserved.includes(value)); + const result = evaluateRetro(baseInput({ + collection: { failed_runs_truncated: failed, pull_requests_truncated: pulls }, + stateComments: stateComment({ + status: 'IDLE', + completed_at: '2026-09-03T00:00:00Z', + resolved_fingerprints: [], + pending_fingerprints: pending, + }), + })); + expect(result.action).toBe(action); + expect(result.pending_fingerprints).toEqual(preserved); + expect(result.expired_pending_fingerprints).toEqual(expired); + expect(result.truncation_preserved_fingerprints).toEqual(preserved); + expect(result.resolved_fingerprints).toEqual([]); + }); + + it('preserves unobserved requests during a bounded report without blocking qualifying evidence', () => { + const missing = evidenceFingerprint('fail', 'CI|test|uncollected failure'); + const observed = evidenceFingerprint('fail', 'CI|test|Expected 2 but got 1'); + const result = evaluateRetro(baseInput({ + collection: { failed_runs_truncated: true, pull_requests_truncated: false }, + stateComments: stateComment({ + status: 'IDLE', + completed_at: '2026-09-03T00:00:00Z', + resolved_fingerprints: [], + pending_fingerprints: [missing, observed], + }), + workflowRuns: [failure(), failure({ id: 102 })], + })); + expect(result.action).toBe('run'); + expect(result.pending_fingerprints).toEqual([missing, observed].sort()); + expect(result.truncation_preserved_fingerprints).toEqual([missing]); + expect(result.expired_pending_fingerprints).toEqual([]); + expect(result.qualifying_groups[0].fingerprint).toBe(observed); + }); + + it('recovers stale legacy in-flight state but suppresses an active record', () => { + const active = evaluateRetro(baseInput({ + stateComments: stateComment({ + status: 'IN_FLIGHT', + started_at: '2026-09-06T22:00:00Z', + resolved_fingerprints: [], + pending_fingerprints: [], + }), + workflowRuns: [failure(), failure({ id: 102 })], + })); + expect(active.action).toBe('suppress'); + expect(active.reason).toBe('trusted-state-is-in-flight'); + + const stale = evaluateRetro(baseInput({ + stateComments: stateComment({ + status: 'IN_FLIGHT', + started_at: '2026-09-05T00:00:00Z', + resolved_fingerprints: [], + pending_fingerprints: [], + }), + workflowRuns: [failure(), failure({ id: 102 })], + })); + expect(stale.action).toBe('run'); + expect(stale.stale_in_flight_recovered).toBe(true); + }); + + it('noops for no evidence and excludes retrospective self-evidence', () => { + expect(evaluateRetro(baseInput()).reason).toBe('no-evidence-in-window'); + const selfOnly = evaluateRetro(baseInput({ + workflowRuns: [ + failure({ id: 201, workflow_name: 'Squad Retro', self_retro: true }), + failure({ id: 202, workflow_name: 'Squad Retro', self_retro: true }), + ], + reviews: [ + review({ self_retro: true }), + review({ self_retro: true, head_sha: 'd'.repeat(40) }), + ], + })); + expect(selfOnly.action).toBe('noop'); + expect(selfOnly.evidence_count).toBe(0); + }); + + it('refuses user-authored prose as trusted state', () => { + const result = evaluateRetro(baseInput({ stateIssues: stateIssue('octocat') })); + expect(result.action).toBe('initialize_state'); + }); + + it('repairs the lowest-numbered trusted unlabeled state issue instead of initializing', () => { + const unlabeled = { + ...stateIssue()[0], + title: 'Squad retrospective state', + labels: ['squad', 'squad-retro'], + }; + const result = evaluateRetro(baseInput({ + stateIssues: [{ ...unlabeled, number: 99 }, unlabeled], + })); + expect(result).toMatchObject({ + action: 'repair_state_label', + reason: 'trusted-state-issue-unlabeled', + state_issue: 41, + missing_label: 'squad-retro-state', + duplicate_state_issues: 1, + }); + const repaired = evaluateRetro(baseInput({ + stateIssues: [{ ...unlabeled, labels: [...unlabeled.labels, result.missing_label] }], + })); + expect(repaired.action).toBe('noop'); + expect(repaired.state_issue).toBe(41); + }); + + it('prefers the lowest-numbered labeled trusted state over a lower-numbered fallback', () => { + const result = evaluateRetro(baseInput({ + stateIssues: [ + { ...stateIssue()[0], number: 1, title: 'Squad retrospective state', labels: [] }, + { ...stateIssue()[0], number: 99 }, + ...stateIssue(), + { ...stateIssue('octocat')[0], number: 2 }, + ], + })); + expect(result.action).toBe('noop'); + expect(result.state_issue).toBe(41); + expect(result.duplicate_state_issues).toBe(2); + }); + + it.each([ + { author: 'octocat' }, + { author: 'another-app[bot]' }, + { title: 'Squad retrospective state ' }, + { title: 'Squad retrospective state proposal' }, + { state: 'closed' }, + { pull_request: { url: 'https://github.example/pull/41' } }, + ])('refuses an untrusted or inexact title fallback: %j', overrides => { + const result = evaluateRetro(baseInput({ + stateIssues: [{ + ...stateIssue()[0], + title: 'Squad retrospective state', + labels: ['squad-retro'], + ...overrides, + }], + })); + expect(result.action).toBe('initialize_state'); + expect(result).not.toHaveProperty('state_issue'); + }); + + it('discovers an unlabeled bot state through a bounded creator-filtered fallback', () => { + const calls: Record[] = []; + const stateIssues = collectStateIssues('squad-test/example', fields => { + calls.push(fields); + if (fields.labels) return []; + return [ + { number: 1, user: { login: 'octocat' }, state: 'open', title: 'Squad retrospective state', labels: [] }, + { number: 3, user: { login: 'github-actions[bot]' }, state: 'open', title: 'Other issue', labels: [] }, + { number: 41, user: { login: 'github-actions[bot]' }, state: 'open', title: 'Squad retrospective state', labels: [] }, + ]; + }); + expect(calls).toHaveLength(2); + expect(calls[0]).toMatchObject({ labels: 'squad-retro-state' }); + expect(calls[1]).toEqual({ + state: 'open', creator: 'github-actions[bot]', sort: 'created', direction: 'asc', + per_page: 100, page: 1, + }); + expect(evaluateRetro(baseInput({ stateIssues }))).toMatchObject({ + action: 'repair_state_label', state_issue: 41, + }); + }); + + it('ignores a human-authored same-title issue returned by fallback discovery', () => { + const stateIssues = collectStateIssues('squad-test/example', fields => fields.labels ? [] : [{ + number: 41, user: { login: 'octocat' }, state: 'open', + title: 'Squad retrospective state', labels: [], + }]); + expect(evaluateRetro(baseInput({ stateIssues })).action).toBe('initialize_state'); + }); + + it('does not scan unlabeled issues when a trusted labeled state exists', () => { + let calls = 0; + const stateIssues = collectStateIssues('squad-test/example', fields => { + calls++; + expect(fields.labels).toBe('squad-retro-state'); + return [99, 41].map(number => ({ + number, user: { login: 'github-actions[bot]' }, state: 'open', + labels: [{ name: 'squad-retro-state' }], + })); + }); + expect(calls).toBe(1); + expect(evaluateRetro(baseInput({ stateIssues })).state_issue).toBe(41); + }); + + it('paginates fallback discovery and fails closed if its scan is incomplete', () => { + const unrelated = Array.from({ length: 100 }, (_, index) => ({ + number: index + 1, user: { login: 'github-actions[bot]' }, state: 'open', + title: 'Unrelated bot issue', labels: [], + })); + const stateIssues = collectStateIssues('squad-test/example', fields => { + if (fields.labels) return []; + if (fields.page === 1) return unrelated; + return [{ + ...unrelated[0], number: 141, title: 'Squad retrospective state', + }]; + }); + expect(evaluateRetro(baseInput({ stateIssues }))).toMatchObject({ + action: 'repair_state_label', state_issue: 141, + }); + let calls = 0; + expect(() => collectStateIssues('squad-test/example', fields => { + calls++; + return fields.labels ? [] : unrelated; + })).toThrow('pagination limit'); + expect(calls).toBe(21); + }); + + it('allows an authorized manual request to override cooldown but not evidence', () => { + const stateComments = stateComment({ + status: 'IDLE', + completed_at: '2026-09-06T00:00:00Z', + resolved_fingerprints: [], + pending_fingerprints: [], + }); + expect(evaluateRetro(baseInput({ + eventName: 'workflow_dispatch', + stateComments, + incoming: { reason: 'manual', origin: 'manual' }, + })).action).toBe('noop'); + expect(evaluateRetro(baseInput({ + eventName: 'workflow_dispatch', + stateComments, + workflowRuns: [failure()], + incoming: { reason: 'manual', origin: 'manual' }, + })).action).toBe('run'); + }); + + it('derives a fingerprint for a wakeup that carries none and never for manual', () => { + const failFingerprint = evidenceFingerprint('fail', 'CI|test|Expected 2 but got 1'); + const cooling = stateComment({ + status: 'IDLE', + completed_at: '2026-09-06T00:00:00Z', + resolved_fingerprints: [], + pending_fingerprints: [], + }); + + const derived = evaluateRetro(baseInput({ + eventName: 'workflow_dispatch', + stateComments: cooling, + workflowRuns: [failure(), failure({ id: 102 })], + incoming: { reason: 'early-evidence', origin: 'squad-implement' }, + })); + expect(derived.action).toBe('suppress'); + expect(derived.reason).toBe('cooldown-active'); + expect(derived.incoming_request).toMatchObject({ + fingerprint: failFingerprint, + derived: true, + already_pending: false, + }); + expect(derived.pending_fingerprints).toContain(failFingerprint); + + const reviewOrigin = evaluateRetro(baseInput({ + eventName: 'workflow_dispatch', + stateComments: cooling, + workflowRuns: [failure(), failure({ id: 102 })], + reviews: [review(), review({ head_sha: 'c'.repeat(40) })], + incoming: { reason: 'early-evidence', origin: 'squad-review' }, + })); + expect(reviewOrigin.incoming_request.fingerprint) + .toBe(evidenceFingerprint('review', 'Missing empty-input coverage|src')); + + const manual = evaluateRetro(baseInput({ + eventName: 'workflow_dispatch', + stateComments: cooling, + workflowRuns: [failure(), failure({ id: 102 })], + incoming: { reason: 'manual', origin: 'manual' }, + })); + expect(manual.incoming_request).toBeNull(); + expect(manual.pending_fingerprints).toEqual([]); + }); + + it('records a newly incoming request exactly once via already_pending', () => { + const fingerprint = evidenceFingerprint('fail', 'CI|test|Expected 2 but got 1'); + const cooling = { + eventName: 'workflow_dispatch', + stateComments: [ + ...stateComment({ + status: 'IDLE', + completed_at: '2026-09-06T00:00:00Z', + resolved_fingerprints: [], + pending_fingerprints: [], + }), + { + author: 'github-actions[bot]', + created_at: '2026-09-06T12:00:00Z', + body: `Request\n\nStructured data:\n\n\`\`\`json\n${JSON.stringify({ + squad_artifact: 'retro-request', + schema_version: '1', + retro_id: '2', + fingerprint, + })}\n\`\`\``, + }, + ], + workflowRuns: [failure(), failure({ id: 102 })], + incoming: { reason: 'early-evidence', origin: 'squad-implement', fingerprint }, + }; + const repeated = evaluateRetro(baseInput(cooling)); + expect(repeated.action).toBe('suppress'); + expect(repeated.incoming_request.already_pending).toBe(true); + expect(repeated.pending_fingerprints.filter(value => value === fingerprint)).toHaveLength(1); + }); + + it('durably requeues each post-resolution recurrence exactly once', () => { + const fingerprint = evidenceFingerprint('fail', 'CI|test|Expected 2 but got 1'); + const report = (retroId: string, createdAt: string) => ({ + author: 'github-actions[bot]', + created_at: createdAt, + body: `Report\n\nStructured data:\n\n\`\`\`json\n${JSON.stringify({ + squad_artifact: 'retro-report', + schema_version: '1', + retro_id: retroId, + fingerprint, + })}\n\`\`\``, + }); + const request = (retroId: string, createdAt: string) => ({ + author: 'github-actions[bot]', + created_at: createdAt, + body: `Request\n\nStructured data:\n\n\`\`\`json\n${JSON.stringify({ + squad_artifact: 'retro-request', + schema_version: '1', + retro_id: retroId, + fingerprint, + })}\n\`\`\``, + }); + const resolvedState = (retroId: string, completedAt: string) => stateComment({ + retro_id: retroId, + status: 'IDLE', + completed_at: completedAt, + resolved_fingerprints: [fingerprint], + pending_fingerprints: [], + }, completedAt); + const recurrence = failure({ + id: 201, + created_at: '2026-09-06T12:00:00Z', + }); + + const first = evaluateRetro(baseInput({ + eventName: 'workflow_dispatch', + stateComments: [ + ...resolvedState('200', '2026-09-06T00:00:00Z'), + report('200', '2026-09-06T00:00:00Z'), + ], + workflowRuns: [recurrence], + incoming: { reason: 'early-evidence', origin: 'squad-implement', fingerprint }, + })); + expect(first.action).toBe('suppress'); + expect(first.incoming_request).toMatchObject({ + fingerprint, + recurrence: true, + already_pending: false, + }); + expect(first.pending_fingerprints).toEqual([fingerprint]); + + const repeated = evaluateRetro(baseInput({ + eventName: 'workflow_dispatch', + stateComments: [ + ...resolvedState('200', '2026-09-06T00:00:00Z'), + report('200', '2026-09-06T00:00:00Z'), + request('201', '2026-09-06T13:00:00Z'), + ], + workflowRuns: [recurrence], + incoming: { reason: 'early-evidence', origin: 'squad-implement', fingerprint }, + })); + expect(repeated.incoming_request.already_pending).toBe(true); + expect(repeated.pending_fingerprints).toEqual([fingerprint]); + + const later = evaluateRetro(baseInput({ + eventName: 'workflow_dispatch', + stateComments: [ + ...resolvedState('300', '2026-09-06T18:00:00Z'), + report('300', '2026-09-06T18:00:00Z'), + request('201', '2026-09-06T13:00:00Z'), + ], + workflowRuns: [failure({ id: 301, created_at: '2026-09-06T23:00:00Z' })], + incoming: { reason: 'early-evidence', origin: 'squad-implement', fingerprint }, + })); + expect(later.incoming_request).toMatchObject({ + recurrence: true, + already_pending: false, + }); + expect(later.pending_fingerprints).toEqual([fingerprint]); + }); + + it('never lets a low-confidence failure signature qualify on its own', () => { + const weak = { + error_line: '|step:Run tests', + error_confidence: 'low', + }; + const result = evaluateRetro(baseInput({ + workflowRuns: [failure(weak), failure({ id: 102, ...weak })], + })); + expect(result.action).toBe('noop'); + expect(result.reason).toBe('early-threshold-not-met'); + expect(result.evidence_groups[0]).toMatchObject({ + attempt_count: 2, + low_confidence: true, + qualifies: false, + }); + expect(result.qualifying_groups).toEqual([]); + + const wakeup = evaluateRetro(baseInput({ + eventName: 'workflow_dispatch', + workflowRuns: [failure(weak), failure({ id: 102, ...weak })], + incoming: { reason: 'early-evidence', origin: 'squad-implement' }, + })); + expect(wakeup.action).toBe('noop'); + expect(deriveRequestGroup(wakeup.evidence_groups, 'squad-implement')?.qualifies).toBe(false); + }); + + it('keeps a failure fingerprint stable across head SHAs', () => { + const first = evaluateRetro(baseInput({ + workflowRuns: [failure(), failure({ id: 102, head_sha: 'e'.repeat(40) })], + })); + expect(first.action).toBe('run'); + expect(first.qualifying_groups).toHaveLength(1); + expect(first.qualifying_groups[0].attempt_count).toBe(2); + }); + + it('extracts a bounded stable error line and rejects unusable log output', () => { + const log = [ + '2026-09-06T00:00:00.1234567Z Run npm test', + '2026-09-06T00:00:01.1234567Z \u001b[31mnot ok 3 - adds numbers\u001b[0m', + '2026-09-06T00:00:02.1234567Z ##[error]Process completed with exit code 1.', + ].join('\n'); + expect(extractErrorLine(log)).toBe('not ok 3 - adds numbers'); + expect(extractErrorLine('##[error]Process completed with exit code 1.')).toBeNull(); + expect(extractErrorLine('2026-09-06T00:00:00Z AssertionError: expected 2 to equal 1')) + .toBe('AssertionError: expected 2 to equal 1'); + expect(extractErrorLine('0 Error(s)\nAssertionError: expected 2 to equal 1')) + .toBe('AssertionError: expected 2 to equal 1'); + expect(extractErrorLine('Build succeeded. 0 Error(s)')).toBeNull(); + for (const summary of [ + 'Passed! - Failed: 0, Passed: 12, Skipped: 0, Total: 12', + '# fail 0', + 'Found 0 errors.', + 'Tests: 12 passed, 0 failed', + '[INFO] Tests run: 12, Failures: 0, Errors: 0, Skipped: 0', + '0 errors, 0 warnings', + ]) { + expect(extractErrorLine(`${summary}\nAssertionError: expected 2 to equal 1`)) + .toBe('AssertionError: expected 2 to equal 1'); + } + expect(extractErrorLine('all good\nnothing to see')).toBeNull(); + expect(extractErrorLine('PK\u0000\u0000binary')).toBeNull(); + expect(extractErrorLine('')).toBeNull(); + expect(extractErrorLine(`##[error]${'x'.repeat(500)}`)).toHaveLength(200); + }); + + it('bounds recent failed runs and stops pagination at the evidence cutoff', () => { + const recent = Array.from({ length: FAILED_RUN_LIMIT }, (_, index) => ({ + id: index + 1, + created_at: '2026-09-06T00:00:00Z', + conclusion: 'failure', + })); + let calls = 0; + const bounded = collectRecentPages( + () => { + calls++; + return recent; + }, + { + cutoff: '2026-09-01T00:00:00Z', + maxPages: 10, + maxItems: FAILED_RUN_LIMIT, + occurredAt: run => run.created_at, + accept: run => run.conclusion === 'failure', + }, + ); + expect(calls).toBe(1); + expect(bounded.values).toHaveLength(FAILED_RUN_LIMIT); + expect(bounded.truncated).toBe(true); + + calls = 0; + const cutoffBounded = collectRecentPages( + () => { + calls++; + return [ + { id: 1, updated_at: '2026-09-06T00:00:00Z' }, + { id: 2, updated_at: '2026-08-31T23:59:59Z' }, + { id: 3, updated_at: '2026-08-30T00:00:00Z' }, + ]; + }, + { + cutoff: '2026-09-01T00:00:00Z', + maxPages: 10, + maxItems: PULL_REQUEST_LIMIT, + occurredAt: pull => pull.updated_at, + }, + ); + expect(calls).toBe(1); + expect(cutoffBounded.values.map(item => item.id)).toEqual([1]); + expect(cutoffBounded.reached_cutoff).toBe(true); + expect(cutoffBounded.truncated).toBe(false); + }); + + it('reports collection truncation in deterministic context', () => { + const result = evaluateRetro(baseInput({ + collection: { + api_request_ceiling: 700, + failed_run_limit: FAILED_RUN_LIMIT, + failed_runs_truncated: true, + pull_request_limit: PULL_REQUEST_LIMIT, + pull_requests_truncated: false, + }, + })); + expect(result.evidence_truncated).toBe(true); + expect(result.collection.api_request_ceiling).toBeLessThan(1000); + expect(result.collection.failed_run_limit).toBe(100); + }); + + it('recovers the unprocessed state tail from the latest updated checkpoint', () => { + const fingerprint = evidenceFingerprint('fail', 'CI|test|Expected 2 but got 1'); + const checkpoint = { + id: 1, + user: { login: 'github-actions[bot]' }, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-09-05T00:00:00Z', + body: stateComment({ + status: 'IDLE', + completed_at: '2026-09-05T00:00:00Z', + resolved_fingerprints: [], + pending_fingerprints: [], + })[0].body, + }; + const request = { + id: 2001, + user: { login: 'github-actions[bot]' }, + created_at: '2026-09-06T00:00:00Z', + updated_at: '2026-09-06T00:00:00Z', + body: stateComment({ + squad_artifact: 'retro-request', + retro_id: '2', + fingerprint, + })[0].body, + }; + const input = collectLiveInput({ + GITHUB_REPOSITORY: 'squad-test/example', + GITHUB_WORKSPACE: scratch({}), + SQUAD_RETRO_EVENT_NAME: 'schedule', + }, { + now: new Date('2026-09-07T00:00:00Z'), + fetchJson: (route, fields) => { + if (route.endsWith('/issues')) { + return stateIssue().map(issue => ({ ...issue, user: { login: issue.author } })); + } + if (route.endsWith('/comments')) { + if (fields.since) return fields.page === 1 ? [checkpoint, request] : []; + const page = Number(fields.page); + const values = Array.from({ length: 100 }, (_, index) => ({ + id: page * 100 + index, + user: { login: 'octocat' }, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + body: 'unrelated', + })); + if (page === 1) values[0] = checkpoint; + return values; + } + if (route.endsWith('/actions/runs')) return { workflow_runs: [] }; + if (route.endsWith('/pulls')) return []; + throw new Error(`Unexpected API route: ${route}`); + }, + }); + expect(input.collection).toMatchObject({ + state_comments_truncated: false, + state_checkpoint_tail_recovered: true, + }); + expect(evaluateRetro({ ...input, workflowRuns: [failure()] }).pending_fingerprints) + .toEqual([fingerprint]); + }); + + it('fails closed before mutations when the checkpoint tail is incomplete', () => { + const result = evaluateRetro(baseInput({ + workflowRuns: [failure(), failure({ id: 102 })], + collection: { + state_comments_truncated: true, + failed_runs_truncated: false, + pull_requests_truncated: false, + }, + })); + expect(result.action).toBe('noop'); + expect(result.reason).toBe('state-ledger-incomplete'); + expect(result.state_ledger_incomplete).toBe(true); + expect(result.qualifying_groups).toHaveLength(1); + }); + + it.each(['reviews', 'files', 'jobs'])('preserves evidence beyond inner %s page limits', endpoint => { + const failFingerprint = evidenceFingerprint('fail', 'CI|test|Expected 2 but got 1'); + const reviewFingerprint = evidenceFingerprint('review', 'Missing empty-input coverage|src'); + const preserved = endpoint === 'jobs' ? failFingerprint : reviewFingerprint; + const expired = endpoint === 'jobs' ? reviewFingerprint : failFingerprint; + const limit = endpoint === 'jobs' ? 2 : 3; + const { input, result, calls, logCalls } = collectFixture({ + runs: endpoint === 'jobs' ? [failure({ name: 'CI' })] : [], + pulls: endpoint === 'jobs' ? [] : [{ + number: 12, updated_at: '2026-09-06T00:00:00Z', labels: [], + }], + comments: stateComment({ + status: 'IDLE', + completed_at: '2026-09-03T00:00:00Z', + resolved_fingerprints: [], + pending_fingerprints: [failFingerprint, reviewFingerprint], + }), + nested: (route, fields) => { + const page = Number(fields.page); + if (route.endsWith('/jobs')) { + return { jobs: page <= limit + ? Array.from({ length: 100 }, (_, index) => ({ + id: page * 100 + index, name: 'successful job', conclusion: 'success', + })) + : [{ id: 999, name: 'test', conclusion: 'failure' }], + }; + } + if (route.endsWith('/reviews')) { + return endpoint === 'reviews' && page <= limit + ? Array.from({ length: 100 }, () => review({ state: 'APPROVED' })) + : [review({ body: 'Missing empty-input coverage', commit_id: 'b'.repeat(40) })]; + } + if (route.endsWith('/files')) { + return page <= limit + ? Array.from({ length: 100 }, (_, index) => ({ + filename: `docs/page-${page}-${index}.md`, + })) + : [{ filename: 'src/missing-coverage.ts' }]; + } + throw new Error(`Unexpected API route: ${route}`); + }, + }); + const nestedCalls = calls.filter(call => call.route.endsWith(`/${endpoint}`)); + expect(nestedCalls.map(call => call.page)).toEqual( + Array.from({ length: limit }, (_, index) => index + 1), + ); + expect(input.collection.failed_runs_truncated).toBe(endpoint === 'jobs'); + expect(input.collection.pull_requests_truncated).toBe(endpoint !== 'jobs'); + expect(input.collection.run_jobs_truncated).toBe(endpoint === 'jobs'); + expect(input.collection.reviews_truncated).toBe(endpoint === 'reviews'); + expect(input.collection.files_truncated).toBe(endpoint === 'files'); + expect(result.evidence_truncated).toBe(true); + expect(result.action).toBe('housekeep'); + expect(result.truncation_preserved_fingerprints).toEqual([preserved]); + expect(result.pending_fingerprints).toEqual([preserved]); + expect(result.expired_pending_fingerprints).toEqual([expired]); + expect(calls.length + logCalls).toBeLessThanOrEqual(input.collection.api_request_ceiling); + }); + + it('preserves pending failures from additional failed jobs not inspected within the log budget', () => { + const skipped = evidenceFingerprint('fail', 'CI|integration|second regression'); + const { input, result, logCalls } = collectFixture({ + runs: [failure({ name: 'CI' })], + comments: stateComment({ + status: 'IDLE', + completed_at: '2026-09-03T00:00:00Z', + resolved_fingerprints: [], + pending_fingerprints: [skipped], + }), + nested: route => { + if (route.endsWith('/jobs')) { + return { + jobs: [ + { id: 1, name: 'test', conclusion: 'failure' }, + { id: 2, name: 'integration', conclusion: 'failure' }, + ], + }; + } + throw new Error(`Unexpected API route: ${route}`); + }, + }); + expect(logCalls).toBe(1); + expect(input.collection.job_logs_truncated).toBe(true); + expect(input.collection.failed_runs_truncated).toBe(true); + expect(result.truncation_preserved_fingerprints).toEqual([skipped]); + expect(result.pending_fingerprints).toEqual([skipped]); + expect(result.expired_pending_fingerprints).toEqual([]); + }); + + it.each(['unavailable', 'bytes', 'lines', 'binary', 'partial'])( + 'preserves pending failures when job logs are incomplete: %s', scenario => { + const fingerprint = evidenceFingerprint('fail', 'CI|test|Expected 2 but got 1'); + const { input, result, logCalls } = collectFixture({ + runs: [failure({ name: 'CI' })], + comments: stateComment({ + status: 'IDLE', completed_at: '2026-09-03T00:00:00Z', + resolved_fingerprints: [], pending_fingerprints: [fingerprint], + }), + nested: () => ({ jobs: [{ id: 1, name: 'test', conclusion: 'failure' }] }), + log: () => { + if (scenario === 'unavailable') throw new Error('logs unavailable'); + if (scenario === 'partial') { + throw Object.assign(new Error('maxBuffer exceeded'), { + stdout: Buffer.from('##[error]another failure'), + }); + } + const prefix = scenario === 'bytes' ? 'x'.repeat(262144) : + scenario === 'lines' ? 'passed\n'.repeat(2000) : 'PK\u0000'; + return Buffer.from(`${prefix}\n##[error]Expected 2 but got 1`); + }, + }); + expect(logCalls).toBe(1); + expect(input.collection.run_jobs_truncated).toBe(false); + expect(input.collection.job_logs_truncated).toBe(true); + expect(input.collection.failed_runs_truncated).toBe(true); + expect(input.collection.pull_requests_truncated).toBe(false); + expect(result.evidence_truncated).toBe(true); + expect(result.action).toBe('noop'); + expect(result.pending_fingerprints).toEqual([fingerprint]); + expect(result.truncation_preserved_fingerprints).toEqual([fingerprint]); + expect(result.expired_pending_fingerprints).toEqual([]); + }, + ); + + it('expires missing fingerprints only after complete nested evidence collection', () => { + const missing = [ + evidenceFingerprint('fail', 'CI|test|missing failure'), + evidenceFingerprint('review', 'missing rejection|src'), + ]; + const { input, result } = collectFixture({ + runs: [failure({ name: 'CI' })], + pulls: [{ number: 12, updated_at: '2026-09-06T00:00:00Z', labels: [] }], + comments: stateComment({ + status: 'IDLE', completed_at: '2026-09-03T00:00:00Z', + resolved_fingerprints: [], pending_fingerprints: missing, + }), + nested: route => { + if (route.endsWith('/jobs')) { + return { jobs: [{ id: 1, name: 'test', conclusion: 'failure' }] }; + } + if (route.endsWith('/reviews')) { + return [review({ body: 'Missing empty-input coverage', commit_id: 'b'.repeat(40) })]; + } + if (route.endsWith('/files')) return [{ filename: 'src/example.ts' }]; + throw new Error(`Unexpected API route: ${route}`); + }, + }); + expect(input.collection).toMatchObject({ + failed_runs_truncated: false, run_jobs_truncated: false, job_logs_truncated: false, + pull_requests_truncated: false, reviews_truncated: false, files_truncated: false, + }); + expect(result.evidence_truncated).toBe(false); + expect(result.action).toBe('housekeep'); + expect(result.expired_pending_fingerprints).toEqual(missing); + expect(result.truncation_preserved_fingerprints).toEqual([]); + }); + + it('retains the exact bounded API ceiling including all nested pages and log requests', () => { + let calls = 0; + const full = (value: Record) => Array.from({ length: 100 }, () => value); + const input = collectLiveInput({ + GITHUB_REPOSITORY: 'squad-test/example', + GITHUB_WORKSPACE: scratch({}), + SQUAD_RETRO_EVENT_NAME: 'schedule', + }, { + now: new Date('2026-09-07T00:00:00Z'), + fetchJson: (route, fields) => { + calls++; + const page = fields.page; + if (route.endsWith('/issues')) { + if (page < 20) return full({ title: 'Unrelated' }); + return fields.labels ? [] : [{ + ...stateIssue()[0], user: { login: 'github-actions[bot]' }, + }]; + } + if (route.endsWith('/comments')) { + if (fields.since) { + return full({ + id: 2000 + Number(page), + user: { login: 'octocat' }, + created_at: '2026-09-06T00:00:00Z', + updated_at: '2026-09-06T00:00:00Z', + body: 'request', + }); + } + const values = full({ + id: Number(page) * 100, + user: { login: 'octocat' }, + created_at: '2026-09-01T00:00:00Z', + updated_at: '2026-09-01T00:00:00Z', + body: 'unrelated', + }); + if (page === 1) { + values[0] = { + id: 1, + user: { login: 'github-actions[bot]' }, + created_at: '2026-09-01T00:00:00Z', + updated_at: '2026-09-05T00:00:00Z', + body: stateComment({ + status: 'IDLE', + completed_at: '2026-09-05T00:00:00Z', + resolved_fingerprints: [], + pending_fingerprints: [], + })[0].body, + }; + } + return values; + } + if (route.endsWith('/actions/runs')) { + return { workflow_runs: full(failure({ + name: 'CI', conclusion: page < 10 ? 'success' : 'failure', + })) }; + } + if (route.endsWith('/jobs')) { + return { jobs: full({ id: 1, name: 'test', conclusion: 'failure' }) }; + } + if (route.endsWith('/pulls')) { + return full({ + number: 12, updated_at: '2026-09-06T00:00:00Z', + labels: page < 10 ? ['squad-retro'] : [], + }); + } + if (route.endsWith('/reviews')) { + return full(review({ body: 'Missing empty-input coverage', commit_id: 'b'.repeat(40) })); + } + if (route.endsWith('/files')) return full({ filename: 'src/example.ts' }); + throw new Error(`Unexpected API route: ${route}`); + }, + fetchLog: () => { + calls++; + return Buffer.from('##[error]Expected 2 but got 1'); + }, + }); + expect(input.collection.log_requests_per_run).toBe(1); + expect(calls).toBe(4 * 20 + 10 + 100 * (2 + 1) + 10 + 50 * (3 + 3)); + expect(calls).toBe(input.collection.api_request_ceiling); + expect(calls).toBeLessThan(1000); + }); + + it('short-circuits collection when the retrospective is disabled by config', () => { + const workspace = scratch({ squadRetro: 'deny' }); + const input = collectLiveInput({ + GITHUB_REPOSITORY: 'squad-test/does-not-exist', + GITHUB_WORKSPACE: workspace, + SQUAD_RETRO_EVENT_NAME: 'schedule', + }); + expect(input.stateIssues).toEqual([]); + expect(input.workflowRuns).toEqual([]); + expect(input.reviews).toEqual([]); + expect(input.collection.api_request_ceiling).toBe(700); + expect(evaluateRetro(input as never).action).toBe('noop'); + expect(evaluateRetro(input as never).reason).toBe('disabled-by-config'); + }); + + it('fails closed on malformed config and noops safely on collection failure', () => { + const invalid = scratch({ squadRetroWindowHours: 0 }); + const invalidRun = spawnSync( + process.execPath, + [GATE, '--output', resolve(invalid, 'context.json')], + { + cwd: invalid, + encoding: 'utf8', + env: { + ...process.env, + GITHUB_WORKSPACE: invalid, + GITHUB_REPOSITORY: 'squad-test/does-not-exist', + }, + }, + ); + expect(invalidRun.status).toBe(1); + expect(invalidRun.stderr).toContain('squadRetroWindowHours'); + + const transient = scratch({}); + const output = resolve(transient, 'context.json'); + const transientRun = spawnSync( + process.execPath, + [GATE, '--output', output], + { + cwd: transient, + encoding: 'utf8', + env: { + GITHUB_WORKSPACE: transient, + GITHUB_REPOSITORY: 'squad-test/does-not-exist', + SystemRoot: process.env.SystemRoot ?? '', + PATH: '', + Path: '', + }, + }, + ); + expect(transientRun.status).toBe(0); + const context = JSON.parse(readFileSync(output, 'utf8')); + expect(context.action).toBe('noop'); + expect(context.reason).toBe('evidence-collection-unavailable'); + expect(String(context.diagnostic).length).toBeGreaterThan(0); + expect(context.pending_fingerprints).toEqual([]); + expect(context.expired_pending_fingerprints).toEqual([]); + expect(context.truncation_preserved_fingerprints).toEqual([]); + expect(collectionFailureContext(new Error('boom'), {}).reason) + .toBe('evidence-collection-unavailable'); + }); +}); + +describe('Squad retrospective workflow integration', () => { + it('wires schedule, manual dispatch, worker wakeups, and one non-cancelling slot', () => { + expect(RETRO).toContain('- cron: "weekly on monday"'); + expect(RETRO).toContain('- cron: "every 6h"'); + expect(RETRO).toContain('group: squad-retro'); + expect(RETRO).toContain('cancel-in-progress: false'); + expect(RETRO).toContain('queue: max'); + expect(RETRO).toContain('shared/squad-retro-evidence.mjs'); + expect(ROUTER).toContain('| `/squad retro` | Retrospective Relay |'); + expect(ROUTER).toContain('"workflow_name": "squad-retro"'); + expect(REVIEWER).toContain('"request_origin": "squad-review"'); + expect(IMPLEMENTER).toContain('"request_origin": "squad-implement"'); + expect(CI).toContain( + 'for WF in squad squad-implement-worker squad-review squad-deps-worker squad-retro; do', + ); + expect(CI).toContain( + 'squad-deps-worker.lock.yml squad-retro.lock.yml; do', + ); + }); + + it('keeps governance changes human-reviewed and partial retries idempotent', () => { + expect(RETRO).not.toMatch(/^tools:\n\s+edit:/m); + expect(RETRO).not.toMatch(/^\s+create-pull-request:/m); + expect(RETRO).not.toMatch(/^\s+dispatch-workflow:/m); + expect(RETRO).toContain('Action-Key:'); + expect(RETRO).toContain('open and closed issues labeled `squad-retro-action`'); + expect(RETRO).toContain('human review'); + expect(RETRO).toContain('Do not propose a new coordinator'); + }); + + it('gives the deterministic gate a read-only token and a safe failure path', () => { + expect(RETRO).toContain('GH_TOKEN: ${{ github.token }}'); + expect(RETRO).toMatch(/permissions:\n contents: read\n/); + expect(RETRO).not.toMatch(/^permissions:[\s\S]*?^\s{2}(issues|contents|pull-requests): write$/m); + expect(RETRO).toContain('evidence-collection-unavailable'); + expect(RETRO).toContain('Squad retro diagnostic'); + }); + + it('states the durable request and nullable fingerprint contracts', () => { + expect(RETRO).toContain('required: [squad_artifact, schema_version, retro_id, fingerprint]'); + expect(RETRO).toContain('type: ["string", "null"]'); + expect(RETRO).toContain('`already_pending`'); + expect(RETRO).toContain('fingerprint: null'); + expect(RETRO).toContain('low_confidence'); + expect(RETRO).toContain('the head SHA is\ndeliberately excluded'); + expect(RETRO).toContain('bounded and non-exhaustive'); + expect(RETRO).toContain('truncation_preserved_fingerprints'); + expect(RETRO).toContain('never resolve it merely because evidence is absent'); + expect(RETRO).toContain('`failed_runs_truncated` flag includes incomplete job pagination'); + expect(RETRO).toContain('includes incomplete review and file pagination'); + expect(RETRO).toContain('collector ceiling remains 700 API'); + expect(RETRO).toContain('state-ledger-incomplete'); + expect(RETRO).toContain('`state_last_full_completed_at`'); + }); + + it('limits drain actions to new or recurring qualifying evidence, not merely pending requests', () => { + expect(RETRO).toContain('`qualifying_groups` excludes resolved evidence'); + expect(RETRO).toContain('is later than its resolution timestamp; pending requests do not bypass this'); + expect(RETRO).toContain('Manual and weekly reports may include resolved evidence'); + expect(RETRO).toContain('remain idempotent by `Action-Key:`'); + }); + + it('requires explicit label repair followed by noop without creating another issue', () => { + const repair = RETRO.match(/- `repair_state_label`:([\s\S]*?)(?=\n- `suppress`)/)?.[1] ?? ''; + expect(repair).toContain('`missing_label`'); + expect(repair).toContain('`state_issue` using `add-labels`, then `noop`'); + expect(repair).toContain('Do not create another'); + expect(repair).toContain('same-title human-authored issue'); + }); + + it('strict-compiles the worker with bounded safe outputs', () => { + const workspace = scratch(); + const workflowDir = resolve(workspace, '.github', 'workflows'); + mkdirSync(workflowDir, { recursive: true }); + cpSync(resolve(ROOT, 'workflows'), workflowDir, { recursive: true }); + execFileSync('git', ['init', '--quiet'], { cwd: workspace }); + execFileSync( + 'gh', + ['aw', 'compile', 'squad-retro', '--strict', '--no-check-update'], + { cwd: workspace, encoding: 'utf8', stdio: 'pipe' }, + ); + const lock = readFileSync(resolve(workflowDir, 'squad-retro.lock.yml'), 'utf8'); + const config = JSON.parse(extractSafeOutputsConfigJson(lock)!) as Record; + expect(config.create_issue.max).toBe(6); + expect(config.add_comment.max).toBe(8); + expect(config.add_comment.data_schema?.required).toContain('fingerprint'); + expect(config).not.toHaveProperty('create_pull_request'); + expect(config).not.toHaveProperty('dispatch_workflow'); + const agent = lock.match(/^ agent:\n([\s\S]*?)(?=^ [\w-]+:\n)/m)?.[1] ?? ''; + expect(agent).not.toMatch(/^\s+(issues|pull-requests|contents): write$/m); + }, 30000); +}); diff --git a/test/gh-aw-review-workflow.test.ts b/test/gh-aw-review-workflow.test.ts index 8bce2a555..b706b3baf 100644 --- a/test/gh-aw-review-workflow.test.ts +++ b/test/gh-aw-review-workflow.test.ts @@ -92,7 +92,8 @@ function assertReviewerContract(workflow: string): void { const rows = provenanceRows(workflow); expect(tools).not.toMatch(/^\s+edit:/m); - expect(outputs).not.toMatch(/^\s+(dispatch-workflow|create-issue|create-pull-request|update-pull-request):/m); + expect(outputs).not.toMatch(/^\s+(create-issue|create-pull-request|update-pull-request):/m); + expect(listInBlock(yamlBlock(outputs, 'dispatch-workflow'), 'workflows')).toEqual(['squad-retro']); expect(listInBlock(submitReview, 'allowed-events')).toEqual(['COMMENT', 'REQUEST_CHANGES']); expect(submitReview).not.toContain('APPROVE'); expect(concurrency).toContain('cancel-in-progress: true'); @@ -197,6 +198,7 @@ describe('gh-aw advisory Squad reviewer', () => { 'squad-implement-worker.md', 'squad-deps-worker.md', 'squad-review.md', + 'squad-retro.md', ]); const workspace = mkdtempSync(resolve(tmpdir(), 'squad-review-install-')); @@ -232,12 +234,14 @@ describe('gh-aw advisory Squad reviewer', () => { 'squad-deps-worker.md', 'squad-implement-worker.lock.yml', 'squad-implement-worker.md', + 'squad-retro.lock.yml', + 'squad-retro.md', 'squad-review.lock.yml', 'squad-review.md', 'squad.lock.yml', 'squad.md', ]); - }, 30000); + }, 60000); it('detects a missing workflow_dispatch job discriminator during strict compilation', () => { const workspace = mkdtempSync(resolve(tmpdir(), 'squad-review-discriminator-mutation-')); @@ -265,7 +269,7 @@ describe('gh-aw advisory Squad reviewer', () => { ); }, 20000); - it('keeps all consumer install surfaces on the coherent four-workflow order', () => { + it('keeps all consumer install surfaces on the coherent five-workflow order', () => { for (const surface of [GUIDE, README, AGENT_GUIDE, SHARED_BOOTSTRAP]) { const orders = installOrders(surface); expect(orders.length).toBeGreaterThan(0); @@ -275,6 +279,7 @@ describe('gh-aw advisory Squad reviewer', () => { 'squad-implement-worker.md', 'squad-deps-worker.md', 'squad-review.md', + 'squad-retro.md', ]); } } @@ -291,7 +296,7 @@ describe('gh-aw advisory Squad reviewer', () => { expect(GUIDE).toContain('`Squad-Review-Head: `'); expect(GUIDE).toContain('`COMMENT`'); expect(GUIDE).toContain('`REQUEST_CHANGES`'); - expect(GUIDE).toContain('no file-editing, workflow-dispatch, issue-creation,'); + expect(GUIDE).toContain('no file-editing, issue-creation,'); expect(GUIDE).toContain('Human approval remains mandatory.'); expect(GUIDE).toContain('Because review is advisory, it is possible to merge without waiting'); expect(GUIDE).toContain('This follow-up is only needed when the safe-update warning appears.'); @@ -316,6 +321,7 @@ describe('gh-aw advisory Squad reviewer', () => { 'add-comment', 'create-pull-request-review-comment', 'submit-pull-request-review', + 'dispatch-workflow', ]); expect(REVIEWER).toContain('Never use `APPROVE`'); assertReviewerContract(REVIEWER); @@ -364,7 +370,10 @@ describe('gh-aw advisory Squad reviewer', () => { max: 1, allowed_events: ['COMMENT', 'REQUEST_CHANGES'], }); - expect(safeOutputs).not.toHaveProperty('dispatch_workflow'); + expect(safeOutputs.dispatch_workflow).toMatchObject({ + max: 1, + workflows: ['squad-retro'], + }); expect(safeOutputs).not.toHaveProperty('create_issue'); expect(safeOutputs).not.toHaveProperty('create_pull_request'); expect(lock).toContain('GH_AW_HEAD_SHA: ${{ github.event.pull_request.head.sha }}'); @@ -373,7 +382,7 @@ describe('gh-aw advisory Squad reviewer', () => { it('kills mutations of every important authority and provenance gate', () => { const mutations = [ REVIEWER.replace('tools:\n bash:', 'tools:\n edit:\n bash:'), - REVIEWER.replace('safe-outputs:\n add-comment:', 'safe-outputs:\n dispatch-workflow:\n max: 1\n add-comment:'), + REVIEWER.replace('workflows: [squad-retro]', 'workflows: [squad-retro, arbitrary-worker]'), REVIEWER.replace('allowed-events: [COMMENT, REQUEST_CHANGES]', 'allowed-events: [COMMENT, APPROVE]'), REVIEWER.replace('cancel-in-progress: true', 'cancel-in-progress: false'), REVIEWER.replace( diff --git a/test/gh-aw-security-scan.test.ts b/test/gh-aw-security-scan.test.ts index 655ce5bd3..01649eea6 100644 --- a/test/gh-aw-security-scan.test.ts +++ b/test/gh-aw-security-scan.test.ts @@ -144,13 +144,14 @@ describe('gh-aw: distributed workflows survive the public `gh aw add` security s expect(versionProbe.status, `gh aw --version failed. ${GH_AW_INSTALL_HINT}`).toBe(0); }); - it('enumerates the four public entrypoints plus their shared imports', () => { + it('enumerates the five public entrypoints plus their shared imports', () => { const names = distributedWorkflowFiles().map((p) => relative(WORKFLOWS_DIR, p)); expect(names).toContain('squad.md'); expect(names).toContain('squad-implement-worker.md'); expect(names).toContain('squad-deps-worker.md'); expect(names).toContain('squad-review.md'); - expect(names.some((n) => n.startsWith('shared/'))).toBe(true); + expect(names).toContain('squad-retro.md'); + expect(names.some((n) => n.split(/[\\/]/)[0] === 'shared')).toBe(true); }); it.each(distributedWorkflowFiles().map((p, i) => [relative(REPO_ROOT, p), p, i] as const))( diff --git a/workflows/shared/builtins/ralph-charter.md b/workflows/shared/builtins/ralph-charter.md index 9a1959832..05689e7d0 100644 --- a/workflows/shared/builtins/ralph-charter.md +++ b/workflows/shared/builtins/ralph-charter.md @@ -29,6 +29,17 @@ shutdown. Use the `gh` CLI when GitHub MCP tooling is unavailable. +## Retrospective Evidence + +When a scan finds the same normalized failure across two independent attempts +within seven days, or the same review rejection across distinct revision SHAs, +return the evidence URLs and ask the coordinator to emit one typed +`squad-retro` workflow dispatch with `retro_reason: early-evidence` and +`request_origin: ralph`. Never run the retrospective or decide eligibility +yourself. One-off failures and retries of the same run are evidence only; the +shared worker's deterministic gate owns threshold, deduplication, cooldown, and +pending-request recovery. + ## Board Status Format ``` diff --git a/workflows/shared/squad-retro-evidence.mjs b/workflows/shared/squad-retro-evidence.mjs new file mode 100644 index 000000000..45d1c5e36 --- /dev/null +++ b/workflows/shared/squad-retro-evidence.mjs @@ -0,0 +1,1034 @@ +import { createHash } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const BOT = 'github-actions[bot]'; +const STATE_LABEL = 'squad-retro-state'; +const STATE_TITLE = 'Squad retrospective state'; +const FINGERPRINT = /^(?:fail|review):[0-9a-f]{16}$/; +const DEFAULTS = Object.freeze({ + enabled: true, + threshold: 2, + windowHours: 168, + cooldownHours: 72, +}); +const LOG_MAX_BYTES = 262144; +const LOG_MAX_LINES = 2000; +const ERROR_LINE_MAX = 200; +const WEEKLY_INTERVAL_HOURS = 168; +export const FAILED_RUN_LIMIT = 100; +export const PULL_REQUEST_LIMIT = 50; +const ACTION_RUN_MAX_PAGES = 10; +const RUN_JOB_MAX_PAGES = 2; +const LOG_REQUESTS_PER_RUN = 1; +const STATE_MAX_PAGES = 20; +const PULL_MAX_PAGES = 10; +const REVIEW_MAX_PAGES = 3; +const FILE_MAX_PAGES = 3; +const API_REQUEST_CEILING = + 4 * STATE_MAX_PAGES + + ACTION_RUN_MAX_PAGES + + FAILED_RUN_LIMIT * (RUN_JOB_MAX_PAGES + LOG_REQUESTS_PER_RUN) + + PULL_MAX_PAGES + + PULL_REQUEST_LIMIT * (REVIEW_MAX_PAGES + FILE_MAX_PAGES); +const ORIGIN_KIND = Object.freeze({ + 'squad-review': 'review', + 'squad-implement': 'fail', +}); + +function boundedInteger(value, fallback, minimum, maximum, name) { + if (value === undefined) return fallback; + if (!Number.isInteger(value) || value < minimum || value > maximum) { + throw new Error(`${name} must be an integer from ${minimum} to ${maximum}.`); + } + return value; +} + +export function resolveRetroConfig(raw = {}) { + if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) { + throw new Error('.squad/config.json must contain a JSON object.'); + } + const mode = raw.squadRetro; + if (mode !== undefined && mode !== 'allow' && mode !== 'deny') { + throw new Error('squadRetro must be "allow" or "deny".'); + } + return { + enabled: mode !== 'deny', + threshold: boundedInteger( + raw.squadRetroEarlyThreshold, + DEFAULTS.threshold, + 2, + 20, + 'squadRetroEarlyThreshold', + ), + windowHours: boundedInteger( + raw.squadRetroWindowHours, + DEFAULTS.windowHours, + 1, + 8760, + 'squadRetroWindowHours', + ), + cooldownHours: boundedInteger( + raw.squadRetroCooldownHours, + DEFAULTS.cooldownHours, + 0, + 8760, + 'squadRetroCooldownHours', + ), + }; +} + +export function normalizeSignature(value) { + return String(value || '') + .toLowerCase() + .replace(/https?:\/\/\S+/g, '') + .replace(/\b\d{4}-\d{2}-\d{2}t\d{2}:\d{2}:\d{2}(?:\.\d+)?z?\b/g, '