From 5179e37535f0eab8d1c1061d8cc6e92c442c3958 Mon Sep 17 00:00:00 2001 From: Stefan Broenner Date: Tue, 14 Jul 2026 06:52:15 +0200 Subject: [PATCH] Remove squad workflows; add cache-read cost, strict-markers, pyright test coverage, report resiliency --- .github/workflows/ci.yml | 2 +- .github/workflows/squad-ci.yml | 28 -- .github/workflows/squad-docs.yml | 27 -- .github/workflows/squad-heartbeat.yml | 171 ---------- .github/workflows/squad-insider-release.yml | 34 -- .github/workflows/squad-issue-assign.yml | 161 --------- .github/workflows/squad-label-enforce.yml | 181 ---------- .github/workflows/squad-preview.yml | 30 -- .github/workflows/squad-promote.yml | 120 ------- .github/workflows/squad-release.yml | 34 -- .github/workflows/squad-triage.yml | 260 -------------- .github/workflows/sync-squad-labels.yml | 169 ---------- .squad/templates/workflows/squad-ci.yml | 24 -- .squad/templates/workflows/squad-docs.yml | 50 --- .../templates/workflows/squad-heartbeat.yml | 316 ------------------ .../workflows/squad-insider-release.yml | 61 ---- .../workflows/squad-issue-assign.yml | 161 --------- .../workflows/squad-label-enforce.yml | 181 ---------- .squad/templates/workflows/squad-preview.yml | 55 --- .squad/templates/workflows/squad-promote.yml | 120 ------- .squad/templates/workflows/squad-release.yml | 77 ----- .squad/templates/workflows/squad-triage.yml | 260 -------------- .../templates/workflows/sync-squad-labels.yml | 169 ---------- CHANGELOG.md | 17 + pricing.toml | 11 +- pyproject.toml | 6 +- .../copilot/fixtures.py | 1 + .../execution/cost.py | 31 +- src/pytest_skill_engineering/plugin.py | 74 ++-- tests/integration/copilot/conftest.py | 2 +- tests/integration/copilot/test_08_scoring.py | 10 +- tests/unit/test_copilot_event_mapper.py | 9 +- tests/unit/test_cost.py | 56 ++++ 33 files changed, 170 insertions(+), 2738 deletions(-) delete mode 100644 .github/workflows/squad-ci.yml delete mode 100644 .github/workflows/squad-docs.yml delete mode 100644 .github/workflows/squad-heartbeat.yml delete mode 100644 .github/workflows/squad-insider-release.yml delete mode 100644 .github/workflows/squad-issue-assign.yml delete mode 100644 .github/workflows/squad-label-enforce.yml delete mode 100644 .github/workflows/squad-preview.yml delete mode 100644 .github/workflows/squad-promote.yml delete mode 100644 .github/workflows/squad-release.yml delete mode 100644 .github/workflows/squad-triage.yml delete mode 100644 .github/workflows/sync-squad-labels.yml delete mode 100644 .squad/templates/workflows/squad-ci.yml delete mode 100644 .squad/templates/workflows/squad-docs.yml delete mode 100644 .squad/templates/workflows/squad-heartbeat.yml delete mode 100644 .squad/templates/workflows/squad-insider-release.yml delete mode 100644 .squad/templates/workflows/squad-issue-assign.yml delete mode 100644 .squad/templates/workflows/squad-label-enforce.yml delete mode 100644 .squad/templates/workflows/squad-preview.yml delete mode 100644 .squad/templates/workflows/squad-promote.yml delete mode 100644 .squad/templates/workflows/squad-release.yml delete mode 100644 .squad/templates/workflows/squad-triage.yml delete mode 100644 .squad/templates/workflows/sync-squad-labels.yml create mode 100644 tests/unit/test_cost.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e9cfcdc..12ebf0a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,7 +56,7 @@ jobs: run: uv sync --all-extras - name: Run pyright - run: uv run pyright src/pytest_skill_engineering/ + run: uv run pyright unit-tests: name: Unit Tests diff --git a/.github/workflows/squad-ci.yml b/.github/workflows/squad-ci.yml deleted file mode 100644 index b1a1832..0000000 --- a/.github/workflows/squad-ci.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: Squad CI -# python project — configure build/test commands below - -on: - pull_request: - branches: [dev, preview, main, insider] - types: [opened, synchronize, reopened] - push: - branches: [dev, insider] - -permissions: - contents: read - -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - - name: Build and test - run: | - # TODO: Add your python build/test commands here - # Go: go test ./... - # Python: pip install -r requirements.txt && pytest - # .NET: dotnet test - # Java (Maven): mvn test - # Java (Gradle): ./gradlew test - echo "No build commands configured — update squad-ci.yml" diff --git a/.github/workflows/squad-docs.yml b/.github/workflows/squad-docs.yml deleted file mode 100644 index 0931fa4..0000000 --- a/.github/workflows/squad-docs.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: Squad Docs — Build & Deploy -# python project — configure documentation build commands below - -on: - workflow_dispatch: - push: - branches: [preview] - paths: - - 'docs/**' - - '.github/workflows/squad-docs.yml' - -permissions: - contents: read - pages: write - id-token: write - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - - name: Build docs - run: | - # TODO: Add your documentation build commands here - # This workflow is optional — remove or customize it for your project - echo "No docs build commands configured — update or remove squad-docs.yml" diff --git a/.github/workflows/squad-heartbeat.yml b/.github/workflows/squad-heartbeat.yml deleted file mode 100644 index 7b9c9e6..0000000 --- a/.github/workflows/squad-heartbeat.yml +++ /dev/null @@ -1,171 +0,0 @@ -name: Squad Heartbeat (Ralph) -# ⚠️ SYNC: This workflow is maintained in 4 locations. Changes must be applied to all: -# - templates/workflows/squad-heartbeat.yml (source template) -# - packages/squad-cli/templates/workflows/squad-heartbeat.yml (CLI package) -# - .squad/templates/workflows/squad-heartbeat.yml (installed template) -# - .github/workflows/squad-heartbeat.yml (active workflow) -# Run 'squad upgrade' to sync installed copies from source templates. - -on: - schedule: - # Every 30 minutes — adjust via cron expression as needed - - cron: '*/30 * * * *' - - # React to completed work or new squad work - issues: - types: [closed, labeled] - pull_request: - types: [closed] - - # Manual trigger - workflow_dispatch: - -permissions: - issues: write - contents: read - pull-requests: read - -jobs: - heartbeat: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - - name: Check triage script - id: check-script - run: | - if [ -f ".squad/templates/ralph-triage.js" ]; then - echo "has_script=true" >> $GITHUB_OUTPUT - else - echo "has_script=false" >> $GITHUB_OUTPUT - echo "⚠️ ralph-triage.js not found — run 'squad upgrade' to install" - fi - - - name: Ralph — Smart triage - if: steps.check-script.outputs.has_script == 'true' - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - node .squad/templates/ralph-triage.js \ - --squad-dir .squad \ - --output triage-results.json - - - name: Ralph — Apply triage decisions - if: steps.check-script.outputs.has_script == 'true' && hashFiles('triage-results.json') != '' - uses: actions/github-script@v9 - with: - script: | - const fs = require('fs'); - const path = 'triage-results.json'; - if (!fs.existsSync(path)) { - core.info('No triage results — board is clear'); - return; - } - - const results = JSON.parse(fs.readFileSync(path, 'utf8')); - if (results.length === 0) { - core.info('📋 Board is clear — Ralph found no untriaged issues'); - return; - } - - for (const decision of results) { - try { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: decision.issueNumber, - labels: [decision.label] - }); - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: decision.issueNumber, - body: [ - '### 🔄 Ralph — Auto-Triage', - '', - `**Assigned to:** ${decision.assignTo}`, - `**Reason:** ${decision.reason}`, - `**Source:** ${decision.source}`, - '', - '> Ralph auto-triaged this issue using routing rules.', - '> To reassign, swap the `squad:*` label.' - ].join('\n') - }); - - core.info(`Triaged #${decision.issueNumber} → ${decision.assignTo} (${decision.source})`); - } catch (e) { - core.warning(`Failed to triage #${decision.issueNumber}: ${e.message}`); - } - } - - core.info(`🔄 Ralph triaged ${results.length} issue(s)`); - - # Copilot auto-assign step (uses PAT if available) - - name: Ralph — Assign @copilot issues - if: success() - uses: actions/github-script@v9 - with: - github-token: ${{ secrets.COPILOT_ASSIGN_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const fs = require('fs'); - - let teamFile = '.squad/team.md'; - if (!fs.existsSync(teamFile)) { - teamFile = '.ai-team/team.md'; - } - if (!fs.existsSync(teamFile)) return; - - const content = fs.readFileSync(teamFile, 'utf8'); - - // Check if @copilot is on the team with auto-assign - const hasCopilot = content.includes('🤖 Coding Agent') || content.includes('@copilot'); - const autoAssign = content.includes(''); - if (!hasCopilot || !autoAssign) return; - - // Find issues labeled squad:copilot with no assignee - try { - const { data: copilotIssues } = await github.rest.issues.listForRepo({ - owner: context.repo.owner, - repo: context.repo.repo, - labels: 'squad:copilot', - state: 'open', - per_page: 5 - }); - - const unassigned = copilotIssues.filter(i => - !i.assignees || i.assignees.length === 0 - ); - - if (unassigned.length === 0) { - core.info('No unassigned squad:copilot issues'); - return; - } - - // Get repo default branch - const { data: repoData } = await github.rest.repos.get({ - owner: context.repo.owner, - repo: context.repo.repo - }); - - for (const issue of unassigned) { - try { - await github.request('POST /repos/{owner}/{repo}/issues/{issue_number}/assignees', { - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - assignees: ['copilot-swe-agent[bot]'], - agent_assignment: { - target_repo: `${context.repo.owner}/${context.repo.repo}`, - base_branch: repoData.default_branch, - custom_instructions: `Read .squad/team.md (or .ai-team/team.md) for team context and .squad/routing.md (or .ai-team/routing.md) for routing rules.` - } - }); - core.info(`Assigned copilot-swe-agent[bot] to #${issue.number}`); - } catch (e) { - core.warning(`Failed to assign @copilot to #${issue.number}: ${e.message}`); - } - } - } catch (e) { - core.info(`No squad:copilot label found or error: ${e.message}`); - } diff --git a/.github/workflows/squad-insider-release.yml b/.github/workflows/squad-insider-release.yml deleted file mode 100644 index 9bce1fa..0000000 --- a/.github/workflows/squad-insider-release.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Squad Insider Release -# python project — configure build, test, and insider release commands below - -on: - push: - branches: [insider] - -permissions: - contents: write - -jobs: - release: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - with: - fetch-depth: 0 - - - name: Build and test - run: | - # TODO: Add your python build/test commands here - # Go: go test ./... - # Python: pip install -r requirements.txt && pytest - # .NET: dotnet test - # Java (Maven): mvn test - # Java (Gradle): ./gradlew test - echo "No build commands configured — update squad-insider-release.yml" - - - name: Create insider release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - # TODO: Add your insider/pre-release commands here - echo "No release commands configured — update squad-insider-release.yml" diff --git a/.github/workflows/squad-issue-assign.yml b/.github/workflows/squad-issue-assign.yml deleted file mode 100644 index e697b35..0000000 --- a/.github/workflows/squad-issue-assign.yml +++ /dev/null @@ -1,161 +0,0 @@ -name: Squad Issue Assign - -on: - issues: - types: [labeled] - -permissions: - issues: write - contents: read - -jobs: - assign-work: - # Only trigger on squad:{member} labels (not the base "squad" label) - if: startsWith(github.event.label.name, 'squad:') - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - - name: Identify assigned member and trigger work - uses: actions/github-script@v9 - with: - script: | - const fs = require('fs'); - const issue = context.payload.issue; - const label = context.payload.label.name; - - // Extract member name from label (e.g., "squad:ripley" → "ripley") - const memberName = label.replace('squad:', '').toLowerCase(); - - // Read team roster — check .squad/ first, fall back to .ai-team/ - let teamFile = '.squad/team.md'; - if (!fs.existsSync(teamFile)) { - teamFile = '.ai-team/team.md'; - } - if (!fs.existsSync(teamFile)) { - core.warning('No .squad/team.md or .ai-team/team.md found — cannot assign work'); - return; - } - - const content = fs.readFileSync(teamFile, 'utf8'); - const lines = content.split('\n'); - - // Check if this is a coding agent assignment - const isCopilotAssignment = memberName === 'copilot'; - - let assignedMember = null; - if (isCopilotAssignment) { - assignedMember = { name: '@copilot', role: 'Coding Agent' }; - } else { - let inMembersTable = false; - for (const line of lines) { - if (line.match(/^##\s+(Members|Team Roster)/i)) { - inMembersTable = true; - continue; - } - if (inMembersTable && line.startsWith('## ')) { - break; - } - if (inMembersTable && line.startsWith('|') && !line.includes('---') && !line.includes('Name')) { - const cells = line.split('|').map(c => c.trim()).filter(Boolean); - if (cells.length >= 2 && cells[0].toLowerCase() === memberName) { - assignedMember = { name: cells[0], role: cells[1] }; - break; - } - } - } - } - - if (!assignedMember) { - core.warning(`No member found matching label "${label}"`); - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - body: `⚠️ No squad member found matching label \`${label}\`. Check \`.squad/team.md\` (or \`.ai-team/team.md\`) for valid member names.` - }); - return; - } - - // Post assignment acknowledgment - let comment; - if (isCopilotAssignment) { - comment = [ - `### 🤖 Routed to @copilot (Coding Agent)`, - '', - `**Issue:** #${issue.number} — ${issue.title}`, - '', - `@copilot has been assigned and will pick this up automatically.`, - '', - `> The coding agent will create a \`copilot/*\` branch and open a draft PR.`, - `> Review the PR as you would any team member's work.`, - ].join('\n'); - } else { - comment = [ - `### 📋 Assigned to ${assignedMember.name} (${assignedMember.role})`, - '', - `**Issue:** #${issue.number} — ${issue.title}`, - '', - `${assignedMember.name} will pick this up in the next Copilot session.`, - '', - `> **For Copilot coding agent:** If enabled, this issue will be worked automatically.`, - `> Otherwise, start a Copilot session and say:`, - `> \`${assignedMember.name}, work on issue #${issue.number}\``, - ].join('\n'); - } - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - body: comment - }); - - core.info(`Issue #${issue.number} assigned to ${assignedMember.name} (${assignedMember.role})`); - - # Separate step: assign @copilot using PAT (required for coding agent) - - name: Assign @copilot coding agent - if: github.event.label.name == 'squad:copilot' - uses: actions/github-script@v9 - with: - github-token: ${{ secrets.COPILOT_ASSIGN_TOKEN }} - script: | - const owner = context.repo.owner; - const repo = context.repo.repo; - const issue_number = context.payload.issue.number; - - // Get the default branch name (main, master, etc.) - const { data: repoData } = await github.rest.repos.get({ owner, repo }); - const baseBranch = repoData.default_branch; - - try { - await github.request('POST /repos/{owner}/{repo}/issues/{issue_number}/assignees', { - owner, - repo, - issue_number, - assignees: ['copilot-swe-agent[bot]'], - agent_assignment: { - target_repo: `${owner}/${repo}`, - base_branch: baseBranch, - custom_instructions: '', - custom_agent: '', - model: '' - }, - headers: { - 'X-GitHub-Api-Version': '2022-11-28' - } - }); - core.info(`Assigned copilot-swe-agent to issue #${issue_number} (base: ${baseBranch})`); - } catch (err) { - core.warning(`Assignment with agent_assignment failed: ${err.message}`); - // Fallback: try without agent_assignment - try { - await github.rest.issues.addAssignees({ - owner, repo, issue_number, - assignees: ['copilot-swe-agent'] - }); - core.info(`Fallback assigned copilot-swe-agent to issue #${issue_number}`); - } catch (err2) { - core.warning(`Fallback also failed: ${err2.message}`); - } - } diff --git a/.github/workflows/squad-label-enforce.yml b/.github/workflows/squad-label-enforce.yml deleted file mode 100644 index 9c0fd6c..0000000 --- a/.github/workflows/squad-label-enforce.yml +++ /dev/null @@ -1,181 +0,0 @@ -name: Squad Label Enforce - -on: - issues: - types: [labeled] - -permissions: - issues: write - contents: read - -jobs: - enforce: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - - name: Enforce mutual exclusivity - uses: actions/github-script@v9 - with: - script: | - const issue = context.payload.issue; - const appliedLabel = context.payload.label.name; - - // Namespaces with mutual exclusivity rules - const EXCLUSIVE_PREFIXES = ['go:', 'release:', 'type:', 'priority:']; - - // Skip if not a managed namespace label - if (!EXCLUSIVE_PREFIXES.some(p => appliedLabel.startsWith(p))) { - core.info(`Label ${appliedLabel} is not in a managed namespace — skipping`); - return; - } - - const allLabels = issue.labels.map(l => l.name); - - // Handle go: namespace (mutual exclusivity) - if (appliedLabel.startsWith('go:')) { - const otherGoLabels = allLabels.filter(l => - l.startsWith('go:') && l !== appliedLabel - ); - - if (otherGoLabels.length > 0) { - // Remove conflicting go: labels - for (const label of otherGoLabels) { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - name: label - }); - core.info(`Removed conflicting label: ${label}`); - } - - // Post update comment - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - body: `🏷️ Triage verdict updated → \`${appliedLabel}\`` - }); - } - - // Auto-apply release:backlog if go:yes and no release target - if (appliedLabel === 'go:yes') { - const hasReleaseLabel = allLabels.some(l => l.startsWith('release:')); - if (!hasReleaseLabel) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - labels: ['release:backlog'] - }); - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - body: `📋 Marked as \`release:backlog\` — assign a release target when ready.` - }); - - core.info('Applied release:backlog for go:yes issue'); - } - } - - // Remove release: labels if go:no - if (appliedLabel === 'go:no') { - const releaseLabels = allLabels.filter(l => l.startsWith('release:')); - if (releaseLabels.length > 0) { - for (const label of releaseLabels) { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - name: label - }); - core.info(`Removed release label from go:no issue: ${label}`); - } - } - } - } - - // Handle release: namespace (mutual exclusivity) - if (appliedLabel.startsWith('release:')) { - const otherReleaseLabels = allLabels.filter(l => - l.startsWith('release:') && l !== appliedLabel - ); - - if (otherReleaseLabels.length > 0) { - // Remove conflicting release: labels - for (const label of otherReleaseLabels) { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - name: label - }); - core.info(`Removed conflicting label: ${label}`); - } - - // Post update comment - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - body: `🏷️ Release target updated → \`${appliedLabel}\`` - }); - } - } - - // Handle type: namespace (mutual exclusivity) - if (appliedLabel.startsWith('type:')) { - const otherTypeLabels = allLabels.filter(l => - l.startsWith('type:') && l !== appliedLabel - ); - - if (otherTypeLabels.length > 0) { - for (const label of otherTypeLabels) { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - name: label - }); - core.info(`Removed conflicting label: ${label}`); - } - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - body: `🏷️ Issue type updated → \`${appliedLabel}\`` - }); - } - } - - // Handle priority: namespace (mutual exclusivity) - if (appliedLabel.startsWith('priority:')) { - const otherPriorityLabels = allLabels.filter(l => - l.startsWith('priority:') && l !== appliedLabel - ); - - if (otherPriorityLabels.length > 0) { - for (const label of otherPriorityLabels) { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - name: label - }); - core.info(`Removed conflicting label: ${label}`); - } - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - body: `🏷️ Priority updated → \`${appliedLabel}\`` - }); - } - } - - core.info(`Label enforcement complete for ${appliedLabel}`); diff --git a/.github/workflows/squad-preview.yml b/.github/workflows/squad-preview.yml deleted file mode 100644 index 33e0536..0000000 --- a/.github/workflows/squad-preview.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: Squad Preview Validation -# python project — configure build, test, and validation commands below - -on: - push: - branches: [preview] - -permissions: - contents: read - -jobs: - validate: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - - name: Build and test - run: | - # TODO: Add your python build/test commands here - # Go: go test ./... - # Python: pip install -r requirements.txt && pytest - # .NET: dotnet test - # Java (Maven): mvn test - # Java (Gradle): ./gradlew test - echo "No build commands configured — update squad-preview.yml" - - - name: Validate - run: | - # TODO: Add pre-release validation commands here - echo "No validation commands configured — update squad-preview.yml" diff --git a/.github/workflows/squad-promote.yml b/.github/workflows/squad-promote.yml deleted file mode 100644 index b5fb8ba..0000000 --- a/.github/workflows/squad-promote.yml +++ /dev/null @@ -1,120 +0,0 @@ -name: Squad Promote - -on: - workflow_dispatch: - inputs: - dry_run: - description: 'Dry run — show what would happen without pushing' - required: false - default: 'false' - type: choice - options: ['false', 'true'] - -permissions: - contents: write - -jobs: - dev-to-preview: - name: Promote dev → preview - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - with: - fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Configure git - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - - name: Fetch all branches - run: git fetch --all - - - name: Show current state (dry run info) - run: | - echo "=== dev HEAD ===" && git log origin/dev -1 --oneline - echo "=== preview HEAD ===" && git log origin/preview -1 --oneline - echo "=== Files that would be stripped ===" - git diff origin/preview..origin/dev --name-only | grep -E "^(\.(ai-team|squad|ai-team-templates)|team-docs/|docs/proposals/)" || echo "(none)" - - - name: Merge dev → preview (strip forbidden paths) - if: ${{ inputs.dry_run == 'false' }} - run: | - git checkout preview - git merge origin/dev --no-commit --no-ff -X theirs || true - - # Strip forbidden paths from merge commit - git rm -rf --cached --ignore-unmatch \ - .ai-team/ \ - .squad/ \ - .ai-team-templates/ \ - team-docs/ \ - "docs/proposals/" || true - - # Commit if there are staged changes - if ! git diff --cached --quiet; then - git commit -m "chore: promote dev → preview (v$(node -e "console.log(require('./package.json').version)"))" - git push origin preview - echo "✅ Pushed preview branch" - else - echo "ℹ️ Nothing to commit — preview is already up to date" - fi - - - name: Dry run complete - if: ${{ inputs.dry_run == 'true' }} - run: echo "🔍 Dry run complete — no changes pushed." - - preview-to-main: - name: Promote preview → main (release) - needs: dev-to-preview - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - with: - fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Configure git - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - - name: Fetch all branches - run: git fetch --all - - - name: Show current state - run: | - echo "=== preview HEAD ===" && git log origin/preview -1 --oneline - echo "=== main HEAD ===" && git log origin/main -1 --oneline - echo "=== Version ===" && node -e "console.log('v' + require('./package.json').version)" - - - name: Validate preview is release-ready - run: | - git checkout preview - VERSION=$(node -e "console.log(require('./package.json').version)") - if ! grep -q "## \[$VERSION\]" CHANGELOG.md 2>/dev/null; then - echo "::error::Version $VERSION not found in CHANGELOG.md — update before releasing" - exit 1 - fi - echo "✅ Version $VERSION has CHANGELOG entry" - - # Verify no forbidden files on preview - FORBIDDEN=$(git ls-files | grep -E "^(\.(ai-team|squad|ai-team-templates)/|team-docs/|docs/proposals/)" || true) - if [ -n "$FORBIDDEN" ]; then - echo "::error::Forbidden files found on preview: $FORBIDDEN" - exit 1 - fi - echo "✅ No forbidden files on preview" - - - name: Merge preview → main - if: ${{ inputs.dry_run == 'false' }} - run: | - git checkout main - git merge origin/preview --no-ff -m "chore: promote preview → main (v$(node -e "console.log(require('./package.json').version)"))" - git push origin main - echo "✅ Pushed main — squad-release.yml will tag and publish the release" - - - name: Dry run complete - if: ${{ inputs.dry_run == 'true' }} - run: echo "🔍 Dry run complete — no changes pushed." diff --git a/.github/workflows/squad-release.yml b/.github/workflows/squad-release.yml deleted file mode 100644 index cd7c600..0000000 --- a/.github/workflows/squad-release.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Squad Release -# python project — configure build, test, and release commands below - -on: - push: - branches: [main] - -permissions: - contents: write - -jobs: - release: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - with: - fetch-depth: 0 - - - name: Build and test - run: | - # TODO: Add your python build/test commands here - # Go: go test ./... - # Python: pip install -r requirements.txt && pytest - # .NET: dotnet test - # Java (Maven): mvn test - # Java (Gradle): ./gradlew test - echo "No build commands configured — update squad-release.yml" - - - name: Create release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - # TODO: Add your release commands here (e.g., git tag, gh release create) - echo "No release commands configured — update squad-release.yml" diff --git a/.github/workflows/squad-triage.yml b/.github/workflows/squad-triage.yml deleted file mode 100644 index 744200d..0000000 --- a/.github/workflows/squad-triage.yml +++ /dev/null @@ -1,260 +0,0 @@ -name: Squad Triage - -on: - issues: - types: [labeled] - -permissions: - issues: write - contents: read - -jobs: - triage: - if: github.event.label.name == 'squad' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - - name: Triage issue via Lead agent - uses: actions/github-script@v9 - with: - script: | - const fs = require('fs'); - const issue = context.payload.issue; - - // Read team roster — check .squad/ first, fall back to .ai-team/ - let teamFile = '.squad/team.md'; - if (!fs.existsSync(teamFile)) { - teamFile = '.ai-team/team.md'; - } - if (!fs.existsSync(teamFile)) { - core.warning('No .squad/team.md or .ai-team/team.md found — cannot triage'); - return; - } - - const content = fs.readFileSync(teamFile, 'utf8'); - const lines = content.split('\n'); - - // Check if @copilot is on the team - const hasCopilot = content.includes('🤖 Coding Agent'); - const copilotAutoAssign = content.includes(''); - - // Parse @copilot capability profile - let goodFitKeywords = []; - let needsReviewKeywords = []; - let notSuitableKeywords = []; - - if (hasCopilot) { - // Extract capability tiers from team.md - const goodFitMatch = content.match(/🟢\s*Good fit[^:]*:\s*(.+)/i); - const needsReviewMatch = content.match(/🟡\s*Needs review[^:]*:\s*(.+)/i); - const notSuitableMatch = content.match(/🔴\s*Not suitable[^:]*:\s*(.+)/i); - - if (goodFitMatch) { - goodFitKeywords = goodFitMatch[1].toLowerCase().split(',').map(s => s.trim()); - } else { - goodFitKeywords = ['bug fix', 'test coverage', 'lint', 'format', 'dependency update', 'small feature', 'scaffolding', 'doc fix', 'documentation']; - } - if (needsReviewMatch) { - needsReviewKeywords = needsReviewMatch[1].toLowerCase().split(',').map(s => s.trim()); - } else { - needsReviewKeywords = ['medium feature', 'refactoring', 'api endpoint', 'migration']; - } - if (notSuitableMatch) { - notSuitableKeywords = notSuitableMatch[1].toLowerCase().split(',').map(s => s.trim()); - } else { - notSuitableKeywords = ['architecture', 'system design', 'security', 'auth', 'encryption', 'performance']; - } - } - - const members = []; - let inMembersTable = false; - for (const line of lines) { - if (line.match(/^##\s+(Members|Team Roster)/i)) { - inMembersTable = true; - continue; - } - if (inMembersTable && line.startsWith('## ')) { - break; - } - if (inMembersTable && line.startsWith('|') && !line.includes('---') && !line.includes('Name')) { - const cells = line.split('|').map(c => c.trim()).filter(Boolean); - if (cells.length >= 2 && cells[0] !== 'Scribe') { - members.push({ - name: cells[0], - role: cells[1] - }); - } - } - } - - // Read routing rules — check .squad/ first, fall back to .ai-team/ - let routingFile = '.squad/routing.md'; - if (!fs.existsSync(routingFile)) { - routingFile = '.ai-team/routing.md'; - } - let routingContent = ''; - if (fs.existsSync(routingFile)) { - routingContent = fs.readFileSync(routingFile, 'utf8'); - } - - // Find the Lead - const lead = members.find(m => - m.role.toLowerCase().includes('lead') || - m.role.toLowerCase().includes('architect') || - m.role.toLowerCase().includes('coordinator') - ); - - if (!lead) { - core.warning('No Lead role found in team roster — cannot triage'); - return; - } - - // Build triage context - const memberList = members.map(m => - `- **${m.name}** (${m.role}) → label: \`squad:${m.name.toLowerCase()}\`` - ).join('\n'); - - // Determine best assignee based on issue content and routing - const issueText = `${issue.title}\n${issue.body || ''}`.toLowerCase(); - - let assignedMember = null; - let triageReason = ''; - let copilotTier = null; - - // First, evaluate @copilot fit if enabled - if (hasCopilot) { - const isNotSuitable = notSuitableKeywords.some(kw => issueText.includes(kw)); - const isGoodFit = !isNotSuitable && goodFitKeywords.some(kw => issueText.includes(kw)); - const isNeedsReview = !isNotSuitable && !isGoodFit && needsReviewKeywords.some(kw => issueText.includes(kw)); - - if (isGoodFit) { - copilotTier = 'good-fit'; - assignedMember = { name: '@copilot', role: 'Coding Agent' }; - triageReason = '🟢 Good fit for @copilot — matches capability profile'; - } else if (isNeedsReview) { - copilotTier = 'needs-review'; - assignedMember = { name: '@copilot', role: 'Coding Agent' }; - triageReason = '🟡 Routing to @copilot (needs review) — a squad member should review the PR'; - } else if (isNotSuitable) { - copilotTier = 'not-suitable'; - // Fall through to normal routing - } - } - - // If not routed to @copilot, use keyword-based routing - if (!assignedMember) { - for (const member of members) { - const role = member.role.toLowerCase(); - if ((role.includes('frontend') || role.includes('ui')) && - (issueText.includes('ui') || issueText.includes('frontend') || - issueText.includes('css') || issueText.includes('component') || - issueText.includes('button') || issueText.includes('page') || - issueText.includes('layout') || issueText.includes('design'))) { - assignedMember = member; - triageReason = 'Issue relates to frontend/UI work'; - break; - } - if ((role.includes('backend') || role.includes('api') || role.includes('server')) && - (issueText.includes('api') || issueText.includes('backend') || - issueText.includes('database') || issueText.includes('endpoint') || - issueText.includes('server') || issueText.includes('auth'))) { - assignedMember = member; - triageReason = 'Issue relates to backend/API work'; - break; - } - if ((role.includes('test') || role.includes('qa') || role.includes('quality')) && - (issueText.includes('test') || issueText.includes('bug') || - issueText.includes('fix') || issueText.includes('regression') || - issueText.includes('coverage'))) { - assignedMember = member; - triageReason = 'Issue relates to testing/quality work'; - break; - } - if ((role.includes('devops') || role.includes('infra') || role.includes('ops')) && - (issueText.includes('deploy') || issueText.includes('ci') || - issueText.includes('pipeline') || issueText.includes('docker') || - issueText.includes('infrastructure'))) { - assignedMember = member; - triageReason = 'Issue relates to DevOps/infrastructure work'; - break; - } - } - } - - // Default to Lead if no routing match - if (!assignedMember) { - assignedMember = lead; - triageReason = 'No specific domain match — assigned to Lead for further analysis'; - } - - const isCopilot = assignedMember.name === '@copilot'; - const assignLabel = isCopilot ? 'squad:copilot' : `squad:${assignedMember.name.toLowerCase()}`; - - // Add the member-specific label - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - labels: [assignLabel] - }); - - // Apply default triage verdict - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - labels: ['go:needs-research'] - }); - - // Auto-assign @copilot if enabled - if (isCopilot && copilotAutoAssign) { - try { - await github.rest.issues.addAssignees({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - assignees: ['copilot'] - }); - } catch (err) { - core.warning(`Could not auto-assign @copilot: ${err.message}`); - } - } - - // Build copilot evaluation note - let copilotNote = ''; - if (hasCopilot && !isCopilot) { - if (copilotTier === 'not-suitable') { - copilotNote = `\n\n**@copilot evaluation:** 🔴 Not suitable — issue involves work outside the coding agent's capability profile.`; - } else { - copilotNote = `\n\n**@copilot evaluation:** No strong capability match — routed to squad member.`; - } - } - - // Post triage comment - const comment = [ - `### 🏗️ Squad Triage — ${lead.name} (${lead.role})`, - '', - `**Issue:** #${issue.number} — ${issue.title}`, - `**Assigned to:** ${assignedMember.name} (${assignedMember.role})`, - `**Reason:** ${triageReason}`, - copilotTier === 'needs-review' ? `\n⚠️ **PR review recommended** — a squad member should review @copilot's work on this one.` : '', - copilotNote, - '', - `---`, - '', - `**Team roster:**`, - memberList, - hasCopilot ? `- **@copilot** (Coding Agent) → label: \`squad:copilot\`` : '', - '', - `> To reassign, remove the current \`squad:*\` label and add the correct one.`, - ].filter(Boolean).join('\n'); - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - body: comment - }); - - core.info(`Triaged issue #${issue.number} → ${assignedMember.name} (${assignLabel})`); diff --git a/.github/workflows/sync-squad-labels.yml b/.github/workflows/sync-squad-labels.yml deleted file mode 100644 index 079eed5..0000000 --- a/.github/workflows/sync-squad-labels.yml +++ /dev/null @@ -1,169 +0,0 @@ -name: Sync Squad Labels - -on: - push: - paths: - - '.squad/team.md' - - '.ai-team/team.md' - workflow_dispatch: - -permissions: - issues: write - contents: read - -jobs: - sync-labels: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - - name: Parse roster and sync labels - uses: actions/github-script@v9 - with: - script: | - const fs = require('fs'); - let teamFile = '.squad/team.md'; - if (!fs.existsSync(teamFile)) { - teamFile = '.ai-team/team.md'; - } - - if (!fs.existsSync(teamFile)) { - core.info('No .squad/team.md or .ai-team/team.md found — skipping label sync'); - return; - } - - const content = fs.readFileSync(teamFile, 'utf8'); - const lines = content.split('\n'); - - // Parse the Members table for agent names - const members = []; - let inMembersTable = false; - for (const line of lines) { - if (line.match(/^##\s+(Members|Team Roster)/i)) { - inMembersTable = true; - continue; - } - if (inMembersTable && line.startsWith('## ')) { - break; - } - if (inMembersTable && line.startsWith('|') && !line.includes('---') && !line.includes('Name')) { - const cells = line.split('|').map(c => c.trim()).filter(Boolean); - if (cells.length >= 2 && cells[0] !== 'Scribe') { - members.push({ - name: cells[0], - role: cells[1] - }); - } - } - } - - core.info(`Found ${members.length} squad members: ${members.map(m => m.name).join(', ')}`); - - // Check if @copilot is on the team - const hasCopilot = content.includes('🤖 Coding Agent'); - - // Define label color palette for squad labels - const SQUAD_COLOR = '9B8FCC'; - const MEMBER_COLOR = '9B8FCC'; - const COPILOT_COLOR = '10b981'; - - // Define go: and release: labels (static) - const GO_LABELS = [ - { name: 'go:yes', color: '0E8A16', description: 'Ready to implement' }, - { name: 'go:no', color: 'B60205', description: 'Not pursuing' }, - { name: 'go:needs-research', color: 'FBCA04', description: 'Needs investigation' } - ]; - - const RELEASE_LABELS = [ - { name: 'release:v0.4.0', color: '6B8EB5', description: 'Targeted for v0.4.0' }, - { name: 'release:v0.5.0', color: '6B8EB5', description: 'Targeted for v0.5.0' }, - { name: 'release:v0.6.0', color: '8B7DB5', description: 'Targeted for v0.6.0' }, - { name: 'release:v1.0.0', color: '8B7DB5', description: 'Targeted for v1.0.0' }, - { name: 'release:backlog', color: 'D4E5F7', description: 'Not yet targeted' } - ]; - - const TYPE_LABELS = [ - { name: 'type:feature', color: 'DDD1F2', description: 'New capability' }, - { name: 'type:bug', color: 'FF0422', description: 'Something broken' }, - { name: 'type:spike', color: 'F2DDD4', description: 'Research/investigation — produces a plan, not code' }, - { name: 'type:docs', color: 'D4E5F7', description: 'Documentation work' }, - { name: 'type:chore', color: 'D4E5F7', description: 'Maintenance, refactoring, cleanup' }, - { name: 'type:epic', color: 'CC4455', description: 'Parent issue that decomposes into sub-issues' } - ]; - - // High-signal labels — these MUST visually dominate all others - const SIGNAL_LABELS = [ - { name: 'bug', color: 'FF0422', description: 'Something isn\'t working' }, - { name: 'feedback', color: '00E5FF', description: 'User feedback — high signal, needs attention' } - ]; - - const PRIORITY_LABELS = [ - { name: 'priority:p0', color: 'B60205', description: 'Blocking release' }, - { name: 'priority:p1', color: 'D93F0B', description: 'This sprint' }, - { name: 'priority:p2', color: 'FBCA04', description: 'Next sprint' } - ]; - - // Ensure the base "squad" triage label exists - const labels = [ - { name: 'squad', color: SQUAD_COLOR, description: 'Squad triage inbox — Lead will assign to a member' } - ]; - - for (const member of members) { - labels.push({ - name: `squad:${member.name.toLowerCase()}`, - color: MEMBER_COLOR, - description: `Assigned to ${member.name} (${member.role})` - }); - } - - // Add @copilot label if coding agent is on the team - if (hasCopilot) { - labels.push({ - name: 'squad:copilot', - color: COPILOT_COLOR, - description: 'Assigned to @copilot (Coding Agent) for autonomous work' - }); - } - - // Add go:, release:, type:, priority:, and high-signal labels - labels.push(...GO_LABELS); - labels.push(...RELEASE_LABELS); - labels.push(...TYPE_LABELS); - labels.push(...PRIORITY_LABELS); - labels.push(...SIGNAL_LABELS); - - // Sync labels (create or update) - for (const label of labels) { - try { - await github.rest.issues.getLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: label.name - }); - // Label exists — update it - await github.rest.issues.updateLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: label.name, - color: label.color, - description: label.description - }); - core.info(`Updated label: ${label.name}`); - } catch (err) { - if (err.status === 404) { - // Label doesn't exist — create it - await github.rest.issues.createLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: label.name, - color: label.color, - description: label.description - }); - core.info(`Created label: ${label.name}`); - } else { - throw err; - } - } - } - - core.info(`Label sync complete: ${labels.length} labels synced`); diff --git a/.squad/templates/workflows/squad-ci.yml b/.squad/templates/workflows/squad-ci.yml deleted file mode 100644 index 2f809d7..0000000 --- a/.squad/templates/workflows/squad-ci.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: Squad CI - -on: - pull_request: - branches: [dev, preview, main, insider] - types: [opened, synchronize, reopened] - push: - branches: [dev, insider] - -permissions: - contents: read - -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: 22 - - - name: Run tests - run: node --test test/*.test.js diff --git a/.squad/templates/workflows/squad-docs.yml b/.squad/templates/workflows/squad-docs.yml deleted file mode 100644 index 307d502..0000000 --- a/.squad/templates/workflows/squad-docs.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: Squad Docs — Build & Deploy - -on: - workflow_dispatch: - push: - branches: [preview] - paths: - - 'docs/**' - - '.github/workflows/squad-docs.yml' - -permissions: - contents: read - pages: write - id-token: write - -concurrency: - group: pages - cancel-in-progress: true - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: '22' - - - name: Install build dependencies - run: npm install --no-save markdown-it markdown-it-anchor - - - name: Build docs site - run: node docs/build.js --out _site --base /squad - - - name: Upload Pages artifact - uses: actions/upload-pages-artifact@v3 - with: - path: _site - - deploy: - needs: build - runs-on: ubuntu-latest - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - steps: - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v4 diff --git a/.squad/templates/workflows/squad-heartbeat.yml b/.squad/templates/workflows/squad-heartbeat.yml deleted file mode 100644 index ad32caa..0000000 --- a/.squad/templates/workflows/squad-heartbeat.yml +++ /dev/null @@ -1,316 +0,0 @@ -name: Squad Heartbeat (Ralph) - -on: - # DISABLED: Cron heartbeat commented out pre-migration — re-enable when ready - # schedule: - # # Every 30 minutes — adjust or remove if not needed - # - cron: '*/30 * * * *' - - # React to completed work or new squad work - issues: - types: [closed, labeled] - pull_request: - types: [closed] - - # Manual trigger - workflow_dispatch: - -permissions: - issues: write - contents: read - pull-requests: read - -jobs: - heartbeat: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Ralph — Check for squad work - uses: actions/github-script@v7 - with: - script: | - const fs = require('fs'); - - // Read team roster — check .squad/ first, fall back to .ai-team/ - let teamFile = '.squad/team.md'; - if (!fs.existsSync(teamFile)) { - teamFile = '.ai-team/team.md'; - } - if (!fs.existsSync(teamFile)) { - core.info('No .squad/team.md or .ai-team/team.md found — Ralph has nothing to monitor'); - return; - } - - const content = fs.readFileSync(teamFile, 'utf8'); - - // Check if Ralph is on the roster - if (!content.includes('Ralph') || !content.includes('🔄')) { - core.info('Ralph not on roster — heartbeat disabled'); - return; - } - - // Parse members from roster - const lines = content.split('\n'); - const members = []; - let inMembersTable = false; - for (const line of lines) { - if (line.match(/^##\s+(Members|Team Roster)/i)) { - inMembersTable = true; - continue; - } - if (inMembersTable && line.startsWith('## ')) break; - if (inMembersTable && line.startsWith('|') && !line.includes('---') && !line.includes('Name')) { - const cells = line.split('|').map(c => c.trim()).filter(Boolean); - if (cells.length >= 2 && !['Scribe', 'Ralph'].includes(cells[0])) { - members.push({ - name: cells[0], - role: cells[1], - label: `squad:${cells[0].toLowerCase()}` - }); - } - } - } - - if (members.length === 0) { - core.info('No squad members found — nothing to monitor'); - return; - } - - // 1. Find untriaged issues (labeled "squad" but no "squad:{member}" label) - const { data: squadIssues } = await github.rest.issues.listForRepo({ - owner: context.repo.owner, - repo: context.repo.repo, - labels: 'squad', - state: 'open', - per_page: 20 - }); - - const memberLabels = members.map(m => m.label); - const untriaged = squadIssues.filter(issue => { - const issueLabels = issue.labels.map(l => l.name); - return !memberLabels.some(ml => issueLabels.includes(ml)); - }); - - // 2. Find assigned but unstarted issues (has squad:{member} label, no assignee) - const unstarted = []; - for (const member of members) { - try { - const { data: memberIssues } = await github.rest.issues.listForRepo({ - owner: context.repo.owner, - repo: context.repo.repo, - labels: member.label, - state: 'open', - per_page: 10 - }); - for (const issue of memberIssues) { - if (!issue.assignees || issue.assignees.length === 0) { - unstarted.push({ issue, member }); - } - } - } catch (e) { - // Label may not exist yet - } - } - - // 3. Find squad issues missing triage verdict (no go:* label) - const missingVerdict = squadIssues.filter(issue => { - const labels = issue.labels.map(l => l.name); - return !labels.some(l => l.startsWith('go:')); - }); - - // 4. Find go:yes issues missing release target - const goYesIssues = squadIssues.filter(issue => { - const labels = issue.labels.map(l => l.name); - return labels.includes('go:yes') && !labels.some(l => l.startsWith('release:')); - }); - - // 4b. Find issues missing type: label - const missingType = squadIssues.filter(issue => { - const labels = issue.labels.map(l => l.name); - return !labels.some(l => l.startsWith('type:')); - }); - - // 5. Find open PRs that need attention - const { data: openPRs } = await github.rest.pulls.list({ - owner: context.repo.owner, - repo: context.repo.repo, - state: 'open', - per_page: 20 - }); - - const squadPRs = openPRs.filter(pr => - pr.labels.some(l => l.name.startsWith('squad')) - ); - - // Build status summary - const summary = []; - if (untriaged.length > 0) { - summary.push(`🔴 **${untriaged.length} untriaged issue(s)** need triage`); - } - if (unstarted.length > 0) { - summary.push(`🟡 **${unstarted.length} assigned issue(s)** have no assignee`); - } - if (missingVerdict.length > 0) { - summary.push(`⚪ **${missingVerdict.length} issue(s)** missing triage verdict (no \`go:\` label)`); - } - if (goYesIssues.length > 0) { - summary.push(`⚪ **${goYesIssues.length} approved issue(s)** missing release target (no \`release:\` label)`); - } - if (missingType.length > 0) { - summary.push(`⚪ **${missingType.length} issue(s)** missing \`type:\` label`); - } - if (squadPRs.length > 0) { - const drafts = squadPRs.filter(pr => pr.draft).length; - const ready = squadPRs.length - drafts; - if (drafts > 0) summary.push(`🟡 **${drafts} draft PR(s)** in progress`); - if (ready > 0) summary.push(`🟢 **${ready} PR(s)** open for review/merge`); - } - - if (summary.length === 0) { - core.info('📋 Board is clear — Ralph found no pending work'); - return; - } - - core.info(`🔄 Ralph found work:\n${summary.join('\n')}`); - - // Auto-triage untriaged issues - for (const issue of untriaged) { - const issueText = `${issue.title}\n${issue.body || ''}`.toLowerCase(); - let assignedMember = null; - let reason = ''; - - // Simple keyword-based routing - for (const member of members) { - const role = member.role.toLowerCase(); - if ((role.includes('frontend') || role.includes('ui')) && - (issueText.includes('ui') || issueText.includes('frontend') || - issueText.includes('css') || issueText.includes('component'))) { - assignedMember = member; - reason = 'Matches frontend/UI domain'; - break; - } - if ((role.includes('backend') || role.includes('api') || role.includes('server')) && - (issueText.includes('api') || issueText.includes('backend') || - issueText.includes('database') || issueText.includes('endpoint'))) { - assignedMember = member; - reason = 'Matches backend/API domain'; - break; - } - if ((role.includes('test') || role.includes('qa')) && - (issueText.includes('test') || issueText.includes('bug') || - issueText.includes('fix') || issueText.includes('regression'))) { - assignedMember = member; - reason = 'Matches testing/QA domain'; - break; - } - } - - // Default to Lead - if (!assignedMember) { - const lead = members.find(m => - m.role.toLowerCase().includes('lead') || - m.role.toLowerCase().includes('architect') - ); - if (lead) { - assignedMember = lead; - reason = 'No domain match — routed to Lead'; - } - } - - if (assignedMember) { - // Add member label - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - labels: [assignedMember.label] - }); - - // Post triage comment - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - body: [ - `### 🔄 Ralph — Auto-Triage`, - '', - `**Assigned to:** ${assignedMember.name} (${assignedMember.role})`, - `**Reason:** ${reason}`, - '', - `> Ralph auto-triaged this issue via the squad heartbeat. To reassign, swap the \`squad:*\` label.` - ].join('\n') - }); - - core.info(`Auto-triaged #${issue.number} → ${assignedMember.name}`); - } - } - - # Copilot auto-assign step (uses PAT if available) - - name: Ralph — Assign @copilot issues - if: success() - uses: actions/github-script@v7 - with: - github-token: ${{ secrets.COPILOT_ASSIGN_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const fs = require('fs'); - - let teamFile = '.squad/team.md'; - if (!fs.existsSync(teamFile)) { - teamFile = '.ai-team/team.md'; - } - if (!fs.existsSync(teamFile)) return; - - const content = fs.readFileSync(teamFile, 'utf8'); - - // Check if @copilot is on the team with auto-assign - const hasCopilot = content.includes('🤖 Coding Agent') || content.includes('@copilot'); - const autoAssign = content.includes(''); - if (!hasCopilot || !autoAssign) return; - - // Find issues labeled squad:copilot with no assignee - try { - const { data: copilotIssues } = await github.rest.issues.listForRepo({ - owner: context.repo.owner, - repo: context.repo.repo, - labels: 'squad:copilot', - state: 'open', - per_page: 5 - }); - - const unassigned = copilotIssues.filter(i => - !i.assignees || i.assignees.length === 0 - ); - - if (unassigned.length === 0) { - core.info('No unassigned squad:copilot issues'); - return; - } - - // Get repo default branch - const { data: repoData } = await github.rest.repos.get({ - owner: context.repo.owner, - repo: context.repo.repo - }); - - for (const issue of unassigned) { - try { - await github.request('POST /repos/{owner}/{repo}/issues/{issue_number}/assignees', { - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - assignees: ['copilot-swe-agent[bot]'], - agent_assignment: { - target_repo: `${context.repo.owner}/${context.repo.repo}`, - base_branch: repoData.default_branch, - custom_instructions: `Read .squad/team.md (or .ai-team/team.md) for team context and .squad/routing.md (or .ai-team/routing.md) for routing rules.` - } - }); - core.info(`Assigned copilot-swe-agent[bot] to #${issue.number}`); - } catch (e) { - core.warning(`Failed to assign @copilot to #${issue.number}: ${e.message}`); - } - } - } catch (e) { - core.info(`No squad:copilot label found or error: ${e.message}`); - } diff --git a/.squad/templates/workflows/squad-insider-release.yml b/.squad/templates/workflows/squad-insider-release.yml deleted file mode 100644 index a3124d1..0000000 --- a/.squad/templates/workflows/squad-insider-release.yml +++ /dev/null @@ -1,61 +0,0 @@ -name: Squad Insider Release - -on: - push: - branches: [insider] - -permissions: - contents: write - -jobs: - release: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - uses: actions/setup-node@v4 - with: - node-version: 22 - - - name: Run tests - run: node --test test/*.test.js - - - name: Read version from package.json - id: version - run: | - VERSION=$(node -e "console.log(require('./package.json').version)") - SHORT_SHA=$(git rev-parse --short HEAD) - INSIDER_VERSION="${VERSION}-insider+${SHORT_SHA}" - INSIDER_TAG="v${INSIDER_VERSION}" - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - echo "short_sha=$SHORT_SHA" >> "$GITHUB_OUTPUT" - echo "insider_version=$INSIDER_VERSION" >> "$GITHUB_OUTPUT" - echo "insider_tag=$INSIDER_TAG" >> "$GITHUB_OUTPUT" - echo "📦 Base Version: $VERSION (Short SHA: $SHORT_SHA)" - echo "🏷️ Insider Version: $INSIDER_VERSION" - echo "🔖 Insider Tag: $INSIDER_TAG" - - - name: Create git tag - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git tag -a "${{ steps.version.outputs.insider_tag }}" -m "Insider Release ${{ steps.version.outputs.insider_tag }}" - git push origin "${{ steps.version.outputs.insider_tag }}" - - - name: Create GitHub Release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh release create "${{ steps.version.outputs.insider_tag }}" \ - --title "${{ steps.version.outputs.insider_tag }}" \ - --notes "This is an insider/development build of Squad. Install with:\`\`\`bash\nnpx github:bradygaster/squad#${{ steps.version.outputs.insider_tag }}\n\`\`\`\n\n**Note:** Insider builds may be unstable and are intended for early adopters and testing only." \ - --prerelease - - - name: Verify release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh release view "${{ steps.version.outputs.insider_tag }}" - echo "✅ Insider Release ${{ steps.version.outputs.insider_tag }} created and verified." diff --git a/.squad/templates/workflows/squad-issue-assign.yml b/.squad/templates/workflows/squad-issue-assign.yml deleted file mode 100644 index ad140f4..0000000 --- a/.squad/templates/workflows/squad-issue-assign.yml +++ /dev/null @@ -1,161 +0,0 @@ -name: Squad Issue Assign - -on: - issues: - types: [labeled] - -permissions: - issues: write - contents: read - -jobs: - assign-work: - # Only trigger on squad:{member} labels (not the base "squad" label) - if: startsWith(github.event.label.name, 'squad:') - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Identify assigned member and trigger work - uses: actions/github-script@v7 - with: - script: | - const fs = require('fs'); - const issue = context.payload.issue; - const label = context.payload.label.name; - - // Extract member name from label (e.g., "squad:ripley" → "ripley") - const memberName = label.replace('squad:', '').toLowerCase(); - - // Read team roster — check .squad/ first, fall back to .ai-team/ - let teamFile = '.squad/team.md'; - if (!fs.existsSync(teamFile)) { - teamFile = '.ai-team/team.md'; - } - if (!fs.existsSync(teamFile)) { - core.warning('No .squad/team.md or .ai-team/team.md found — cannot assign work'); - return; - } - - const content = fs.readFileSync(teamFile, 'utf8'); - const lines = content.split('\n'); - - // Check if this is a coding agent assignment - const isCopilotAssignment = memberName === 'copilot'; - - let assignedMember = null; - if (isCopilotAssignment) { - assignedMember = { name: '@copilot', role: 'Coding Agent' }; - } else { - let inMembersTable = false; - for (const line of lines) { - if (line.match(/^##\s+(Members|Team Roster)/i)) { - inMembersTable = true; - continue; - } - if (inMembersTable && line.startsWith('## ')) { - break; - } - if (inMembersTable && line.startsWith('|') && !line.includes('---') && !line.includes('Name')) { - const cells = line.split('|').map(c => c.trim()).filter(Boolean); - if (cells.length >= 2 && cells[0].toLowerCase() === memberName) { - assignedMember = { name: cells[0], role: cells[1] }; - break; - } - } - } - } - - if (!assignedMember) { - core.warning(`No member found matching label "${label}"`); - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - body: `⚠️ No squad member found matching label \`${label}\`. Check \`.squad/team.md\` (or \`.ai-team/team.md\`) for valid member names.` - }); - return; - } - - // Post assignment acknowledgment - let comment; - if (isCopilotAssignment) { - comment = [ - `### 🤖 Routed to @copilot (Coding Agent)`, - '', - `**Issue:** #${issue.number} — ${issue.title}`, - '', - `@copilot has been assigned and will pick this up automatically.`, - '', - `> The coding agent will create a \`copilot/*\` branch and open a draft PR.`, - `> Review the PR as you would any team member's work.`, - ].join('\n'); - } else { - comment = [ - `### 📋 Assigned to ${assignedMember.name} (${assignedMember.role})`, - '', - `**Issue:** #${issue.number} — ${issue.title}`, - '', - `${assignedMember.name} will pick this up in the next Copilot session.`, - '', - `> **For Copilot coding agent:** If enabled, this issue will be worked automatically.`, - `> Otherwise, start a Copilot session and say:`, - `> \`${assignedMember.name}, work on issue #${issue.number}\``, - ].join('\n'); - } - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - body: comment - }); - - core.info(`Issue #${issue.number} assigned to ${assignedMember.name} (${assignedMember.role})`); - - # Separate step: assign @copilot using PAT (required for coding agent) - - name: Assign @copilot coding agent - if: github.event.label.name == 'squad:copilot' - uses: actions/github-script@v7 - with: - github-token: ${{ secrets.COPILOT_ASSIGN_TOKEN }} - script: | - const owner = context.repo.owner; - const repo = context.repo.repo; - const issue_number = context.payload.issue.number; - - // Get the default branch name (main, master, etc.) - const { data: repoData } = await github.rest.repos.get({ owner, repo }); - const baseBranch = repoData.default_branch; - - try { - await github.request('POST /repos/{owner}/{repo}/issues/{issue_number}/assignees', { - owner, - repo, - issue_number, - assignees: ['copilot-swe-agent[bot]'], - agent_assignment: { - target_repo: `${owner}/${repo}`, - base_branch: baseBranch, - custom_instructions: '', - custom_agent: '', - model: '' - }, - headers: { - 'X-GitHub-Api-Version': '2022-11-28' - } - }); - core.info(`Assigned copilot-swe-agent to issue #${issue_number} (base: ${baseBranch})`); - } catch (err) { - core.warning(`Assignment with agent_assignment failed: ${err.message}`); - // Fallback: try without agent_assignment - try { - await github.rest.issues.addAssignees({ - owner, repo, issue_number, - assignees: ['copilot-swe-agent'] - }); - core.info(`Fallback assigned copilot-swe-agent to issue #${issue_number}`); - } catch (err2) { - core.warning(`Fallback also failed: ${err2.message}`); - } - } diff --git a/.squad/templates/workflows/squad-label-enforce.yml b/.squad/templates/workflows/squad-label-enforce.yml deleted file mode 100644 index 633d220..0000000 --- a/.squad/templates/workflows/squad-label-enforce.yml +++ /dev/null @@ -1,181 +0,0 @@ -name: Squad Label Enforce - -on: - issues: - types: [labeled] - -permissions: - issues: write - contents: read - -jobs: - enforce: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Enforce mutual exclusivity - uses: actions/github-script@v7 - with: - script: | - const issue = context.payload.issue; - const appliedLabel = context.payload.label.name; - - // Namespaces with mutual exclusivity rules - const EXCLUSIVE_PREFIXES = ['go:', 'release:', 'type:', 'priority:']; - - // Skip if not a managed namespace label - if (!EXCLUSIVE_PREFIXES.some(p => appliedLabel.startsWith(p))) { - core.info(`Label ${appliedLabel} is not in a managed namespace — skipping`); - return; - } - - const allLabels = issue.labels.map(l => l.name); - - // Handle go: namespace (mutual exclusivity) - if (appliedLabel.startsWith('go:')) { - const otherGoLabels = allLabels.filter(l => - l.startsWith('go:') && l !== appliedLabel - ); - - if (otherGoLabels.length > 0) { - // Remove conflicting go: labels - for (const label of otherGoLabels) { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - name: label - }); - core.info(`Removed conflicting label: ${label}`); - } - - // Post update comment - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - body: `🏷️ Triage verdict updated → \`${appliedLabel}\`` - }); - } - - // Auto-apply release:backlog if go:yes and no release target - if (appliedLabel === 'go:yes') { - const hasReleaseLabel = allLabels.some(l => l.startsWith('release:')); - if (!hasReleaseLabel) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - labels: ['release:backlog'] - }); - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - body: `📋 Marked as \`release:backlog\` — assign a release target when ready.` - }); - - core.info('Applied release:backlog for go:yes issue'); - } - } - - // Remove release: labels if go:no - if (appliedLabel === 'go:no') { - const releaseLabels = allLabels.filter(l => l.startsWith('release:')); - if (releaseLabels.length > 0) { - for (const label of releaseLabels) { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - name: label - }); - core.info(`Removed release label from go:no issue: ${label}`); - } - } - } - } - - // Handle release: namespace (mutual exclusivity) - if (appliedLabel.startsWith('release:')) { - const otherReleaseLabels = allLabels.filter(l => - l.startsWith('release:') && l !== appliedLabel - ); - - if (otherReleaseLabels.length > 0) { - // Remove conflicting release: labels - for (const label of otherReleaseLabels) { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - name: label - }); - core.info(`Removed conflicting label: ${label}`); - } - - // Post update comment - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - body: `🏷️ Release target updated → \`${appliedLabel}\`` - }); - } - } - - // Handle type: namespace (mutual exclusivity) - if (appliedLabel.startsWith('type:')) { - const otherTypeLabels = allLabels.filter(l => - l.startsWith('type:') && l !== appliedLabel - ); - - if (otherTypeLabels.length > 0) { - for (const label of otherTypeLabels) { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - name: label - }); - core.info(`Removed conflicting label: ${label}`); - } - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - body: `🏷️ Issue type updated → \`${appliedLabel}\`` - }); - } - } - - // Handle priority: namespace (mutual exclusivity) - if (appliedLabel.startsWith('priority:')) { - const otherPriorityLabels = allLabels.filter(l => - l.startsWith('priority:') && l !== appliedLabel - ); - - if (otherPriorityLabels.length > 0) { - for (const label of otherPriorityLabels) { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - name: label - }); - core.info(`Removed conflicting label: ${label}`); - } - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - body: `🏷️ Priority updated → \`${appliedLabel}\`` - }); - } - } - - core.info(`Label enforcement complete for ${appliedLabel}`); diff --git a/.squad/templates/workflows/squad-preview.yml b/.squad/templates/workflows/squad-preview.yml deleted file mode 100644 index 9298c36..0000000 --- a/.squad/templates/workflows/squad-preview.yml +++ /dev/null @@ -1,55 +0,0 @@ -name: Squad Preview Validation - -on: - push: - branches: [preview] - -permissions: - contents: read - -jobs: - validate: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: 22 - - - name: Validate version consistency - run: | - VERSION=$(node -e "console.log(require('./package.json').version)") - if ! grep -q "## \[$VERSION\]" CHANGELOG.md 2>/dev/null; then - echo "::error::Version $VERSION not found in CHANGELOG.md — update CHANGELOG.md before release" - exit 1 - fi - echo "✅ Version $VERSION validated in CHANGELOG.md" - - - name: Run tests - run: node --test test/*.test.js - - - name: Check no .ai-team/ or .squad/ files are tracked - run: | - FOUND_FORBIDDEN=0 - if git ls-files --error-unmatch .ai-team/ 2>/dev/null; then - echo "::error::❌ .ai-team/ files are tracked on preview — this must not ship." - FOUND_FORBIDDEN=1 - fi - if git ls-files --error-unmatch .squad/ 2>/dev/null; then - echo "::error::❌ .squad/ files are tracked on preview — this must not ship." - FOUND_FORBIDDEN=1 - fi - if [ $FOUND_FORBIDDEN -eq 1 ]; then - exit 1 - fi - echo "✅ No .ai-team/ or .squad/ files tracked — clean for release." - - - name: Validate package.json version - run: | - VERSION=$(node -e "console.log(require('./package.json').version)") - if [ -z "$VERSION" ]; then - echo "::error::❌ No version field found in package.json." - exit 1 - fi - echo "✅ package.json version: $VERSION" diff --git a/.squad/templates/workflows/squad-promote.yml b/.squad/templates/workflows/squad-promote.yml deleted file mode 100644 index 9d315b1..0000000 --- a/.squad/templates/workflows/squad-promote.yml +++ /dev/null @@ -1,120 +0,0 @@ -name: Squad Promote - -on: - workflow_dispatch: - inputs: - dry_run: - description: 'Dry run — show what would happen without pushing' - required: false - default: 'false' - type: choice - options: ['false', 'true'] - -permissions: - contents: write - -jobs: - dev-to-preview: - name: Promote dev → preview - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Configure git - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - - name: Fetch all branches - run: git fetch --all - - - name: Show current state (dry run info) - run: | - echo "=== dev HEAD ===" && git log origin/dev -1 --oneline - echo "=== preview HEAD ===" && git log origin/preview -1 --oneline - echo "=== Files that would be stripped ===" - git diff origin/preview..origin/dev --name-only | grep -E "^(\.(ai-team|squad|ai-team-templates)|team-docs/|docs/proposals/)" || echo "(none)" - - - name: Merge dev → preview (strip forbidden paths) - if: ${{ inputs.dry_run == 'false' }} - run: | - git checkout preview - git merge origin/dev --no-commit --no-ff -X theirs || true - - # Strip forbidden paths from merge commit - git rm -rf --cached --ignore-unmatch \ - .ai-team/ \ - .squad/ \ - .ai-team-templates/ \ - team-docs/ \ - "docs/proposals/" || true - - # Commit if there are staged changes - if ! git diff --cached --quiet; then - git commit -m "chore: promote dev → preview (v$(node -e "console.log(require('./package.json').version)"))" - git push origin preview - echo "✅ Pushed preview branch" - else - echo "ℹ️ Nothing to commit — preview is already up to date" - fi - - - name: Dry run complete - if: ${{ inputs.dry_run == 'true' }} - run: echo "🔍 Dry run complete — no changes pushed." - - preview-to-main: - name: Promote preview → main (release) - needs: dev-to-preview - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Configure git - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - - name: Fetch all branches - run: git fetch --all - - - name: Show current state - run: | - echo "=== preview HEAD ===" && git log origin/preview -1 --oneline - echo "=== main HEAD ===" && git log origin/main -1 --oneline - echo "=== Version ===" && node -e "console.log('v' + require('./package.json').version)" - - - name: Validate preview is release-ready - run: | - git checkout preview - VERSION=$(node -e "console.log(require('./package.json').version)") - if ! grep -q "## \[$VERSION\]" CHANGELOG.md 2>/dev/null; then - echo "::error::Version $VERSION not found in CHANGELOG.md — update before releasing" - exit 1 - fi - echo "✅ Version $VERSION has CHANGELOG entry" - - # Verify no forbidden files on preview - FORBIDDEN=$(git ls-files | grep -E "^(\.(ai-team|squad|ai-team-templates)/|team-docs/|docs/proposals/)" || true) - if [ -n "$FORBIDDEN" ]; then - echo "::error::Forbidden files found on preview: $FORBIDDEN" - exit 1 - fi - echo "✅ No forbidden files on preview" - - - name: Merge preview → main - if: ${{ inputs.dry_run == 'false' }} - run: | - git checkout main - git merge origin/preview --no-ff -m "chore: promote preview → main (v$(node -e "console.log(require('./package.json').version)"))" - git push origin main - echo "✅ Pushed main — squad-release.yml will tag and publish the release" - - - name: Dry run complete - if: ${{ inputs.dry_run == 'true' }} - run: echo "🔍 Dry run complete — no changes pushed." diff --git a/.squad/templates/workflows/squad-release.yml b/.squad/templates/workflows/squad-release.yml deleted file mode 100644 index bbd5de7..0000000 --- a/.squad/templates/workflows/squad-release.yml +++ /dev/null @@ -1,77 +0,0 @@ -name: Squad Release - -on: - push: - branches: [main] - -permissions: - contents: write - -jobs: - release: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - uses: actions/setup-node@v4 - with: - node-version: 22 - - - name: Run tests - run: node --test test/*.test.js - - - name: Validate version consistency - run: | - VERSION=$(node -e "console.log(require('./package.json').version)") - if ! grep -q "## \[$VERSION\]" CHANGELOG.md 2>/dev/null; then - echo "::error::Version $VERSION not found in CHANGELOG.md — update CHANGELOG.md before release" - exit 1 - fi - echo "✅ Version $VERSION validated in CHANGELOG.md" - - - name: Read version from package.json - id: version - run: | - VERSION=$(node -e "console.log(require('./package.json').version)") - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - echo "tag=v$VERSION" >> "$GITHUB_OUTPUT" - echo "📦 Version: $VERSION (tag: v$VERSION)" - - - name: Check if tag already exists - id: check_tag - run: | - if git rev-parse "refs/tags/${{ steps.version.outputs.tag }}" >/dev/null 2>&1; then - echo "exists=true" >> "$GITHUB_OUTPUT" - echo "⏭️ Tag ${{ steps.version.outputs.tag }} already exists — skipping release." - else - echo "exists=false" >> "$GITHUB_OUTPUT" - echo "🆕 Tag ${{ steps.version.outputs.tag }} does not exist — creating release." - fi - - - name: Create git tag - if: steps.check_tag.outputs.exists == 'false' - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git tag -a "${{ steps.version.outputs.tag }}" -m "Release ${{ steps.version.outputs.tag }}" - git push origin "${{ steps.version.outputs.tag }}" - - - name: Create GitHub Release - if: steps.check_tag.outputs.exists == 'false' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh release create "${{ steps.version.outputs.tag }}" \ - --title "${{ steps.version.outputs.tag }}" \ - --generate-notes \ - --latest - - - name: Verify release - if: steps.check_tag.outputs.exists == 'false' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh release view "${{ steps.version.outputs.tag }}" - echo "✅ Release ${{ steps.version.outputs.tag }} created and verified." diff --git a/.squad/templates/workflows/squad-triage.yml b/.squad/templates/workflows/squad-triage.yml deleted file mode 100644 index a58be9b..0000000 --- a/.squad/templates/workflows/squad-triage.yml +++ /dev/null @@ -1,260 +0,0 @@ -name: Squad Triage - -on: - issues: - types: [labeled] - -permissions: - issues: write - contents: read - -jobs: - triage: - if: github.event.label.name == 'squad' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Triage issue via Lead agent - uses: actions/github-script@v7 - with: - script: | - const fs = require('fs'); - const issue = context.payload.issue; - - // Read team roster — check .squad/ first, fall back to .ai-team/ - let teamFile = '.squad/team.md'; - if (!fs.existsSync(teamFile)) { - teamFile = '.ai-team/team.md'; - } - if (!fs.existsSync(teamFile)) { - core.warning('No .squad/team.md or .ai-team/team.md found — cannot triage'); - return; - } - - const content = fs.readFileSync(teamFile, 'utf8'); - const lines = content.split('\n'); - - // Check if @copilot is on the team - const hasCopilot = content.includes('🤖 Coding Agent'); - const copilotAutoAssign = content.includes(''); - - // Parse @copilot capability profile - let goodFitKeywords = []; - let needsReviewKeywords = []; - let notSuitableKeywords = []; - - if (hasCopilot) { - // Extract capability tiers from team.md - const goodFitMatch = content.match(/🟢\s*Good fit[^:]*:\s*(.+)/i); - const needsReviewMatch = content.match(/🟡\s*Needs review[^:]*:\s*(.+)/i); - const notSuitableMatch = content.match(/🔴\s*Not suitable[^:]*:\s*(.+)/i); - - if (goodFitMatch) { - goodFitKeywords = goodFitMatch[1].toLowerCase().split(',').map(s => s.trim()); - } else { - goodFitKeywords = ['bug fix', 'test coverage', 'lint', 'format', 'dependency update', 'small feature', 'scaffolding', 'doc fix', 'documentation']; - } - if (needsReviewMatch) { - needsReviewKeywords = needsReviewMatch[1].toLowerCase().split(',').map(s => s.trim()); - } else { - needsReviewKeywords = ['medium feature', 'refactoring', 'api endpoint', 'migration']; - } - if (notSuitableMatch) { - notSuitableKeywords = notSuitableMatch[1].toLowerCase().split(',').map(s => s.trim()); - } else { - notSuitableKeywords = ['architecture', 'system design', 'security', 'auth', 'encryption', 'performance']; - } - } - - const members = []; - let inMembersTable = false; - for (const line of lines) { - if (line.match(/^##\s+(Members|Team Roster)/i)) { - inMembersTable = true; - continue; - } - if (inMembersTable && line.startsWith('## ')) { - break; - } - if (inMembersTable && line.startsWith('|') && !line.includes('---') && !line.includes('Name')) { - const cells = line.split('|').map(c => c.trim()).filter(Boolean); - if (cells.length >= 2 && cells[0] !== 'Scribe') { - members.push({ - name: cells[0], - role: cells[1] - }); - } - } - } - - // Read routing rules — check .squad/ first, fall back to .ai-team/ - let routingFile = '.squad/routing.md'; - if (!fs.existsSync(routingFile)) { - routingFile = '.ai-team/routing.md'; - } - let routingContent = ''; - if (fs.existsSync(routingFile)) { - routingContent = fs.readFileSync(routingFile, 'utf8'); - } - - // Find the Lead - const lead = members.find(m => - m.role.toLowerCase().includes('lead') || - m.role.toLowerCase().includes('architect') || - m.role.toLowerCase().includes('coordinator') - ); - - if (!lead) { - core.warning('No Lead role found in team roster — cannot triage'); - return; - } - - // Build triage context - const memberList = members.map(m => - `- **${m.name}** (${m.role}) → label: \`squad:${m.name.toLowerCase()}\`` - ).join('\n'); - - // Determine best assignee based on issue content and routing - const issueText = `${issue.title}\n${issue.body || ''}`.toLowerCase(); - - let assignedMember = null; - let triageReason = ''; - let copilotTier = null; - - // First, evaluate @copilot fit if enabled - if (hasCopilot) { - const isNotSuitable = notSuitableKeywords.some(kw => issueText.includes(kw)); - const isGoodFit = !isNotSuitable && goodFitKeywords.some(kw => issueText.includes(kw)); - const isNeedsReview = !isNotSuitable && !isGoodFit && needsReviewKeywords.some(kw => issueText.includes(kw)); - - if (isGoodFit) { - copilotTier = 'good-fit'; - assignedMember = { name: '@copilot', role: 'Coding Agent' }; - triageReason = '🟢 Good fit for @copilot — matches capability profile'; - } else if (isNeedsReview) { - copilotTier = 'needs-review'; - assignedMember = { name: '@copilot', role: 'Coding Agent' }; - triageReason = '🟡 Routing to @copilot (needs review) — a squad member should review the PR'; - } else if (isNotSuitable) { - copilotTier = 'not-suitable'; - // Fall through to normal routing - } - } - - // If not routed to @copilot, use keyword-based routing - if (!assignedMember) { - for (const member of members) { - const role = member.role.toLowerCase(); - if ((role.includes('frontend') || role.includes('ui')) && - (issueText.includes('ui') || issueText.includes('frontend') || - issueText.includes('css') || issueText.includes('component') || - issueText.includes('button') || issueText.includes('page') || - issueText.includes('layout') || issueText.includes('design'))) { - assignedMember = member; - triageReason = 'Issue relates to frontend/UI work'; - break; - } - if ((role.includes('backend') || role.includes('api') || role.includes('server')) && - (issueText.includes('api') || issueText.includes('backend') || - issueText.includes('database') || issueText.includes('endpoint') || - issueText.includes('server') || issueText.includes('auth'))) { - assignedMember = member; - triageReason = 'Issue relates to backend/API work'; - break; - } - if ((role.includes('test') || role.includes('qa') || role.includes('quality')) && - (issueText.includes('test') || issueText.includes('bug') || - issueText.includes('fix') || issueText.includes('regression') || - issueText.includes('coverage'))) { - assignedMember = member; - triageReason = 'Issue relates to testing/quality work'; - break; - } - if ((role.includes('devops') || role.includes('infra') || role.includes('ops')) && - (issueText.includes('deploy') || issueText.includes('ci') || - issueText.includes('pipeline') || issueText.includes('docker') || - issueText.includes('infrastructure'))) { - assignedMember = member; - triageReason = 'Issue relates to DevOps/infrastructure work'; - break; - } - } - } - - // Default to Lead if no routing match - if (!assignedMember) { - assignedMember = lead; - triageReason = 'No specific domain match — assigned to Lead for further analysis'; - } - - const isCopilot = assignedMember.name === '@copilot'; - const assignLabel = isCopilot ? 'squad:copilot' : `squad:${assignedMember.name.toLowerCase()}`; - - // Add the member-specific label - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - labels: [assignLabel] - }); - - // Apply default triage verdict - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - labels: ['go:needs-research'] - }); - - // Auto-assign @copilot if enabled - if (isCopilot && copilotAutoAssign) { - try { - await github.rest.issues.addAssignees({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - assignees: ['copilot'] - }); - } catch (err) { - core.warning(`Could not auto-assign @copilot: ${err.message}`); - } - } - - // Build copilot evaluation note - let copilotNote = ''; - if (hasCopilot && !isCopilot) { - if (copilotTier === 'not-suitable') { - copilotNote = `\n\n**@copilot evaluation:** 🔴 Not suitable — issue involves work outside the coding agent's capability profile.`; - } else { - copilotNote = `\n\n**@copilot evaluation:** No strong capability match — routed to squad member.`; - } - } - - // Post triage comment - const comment = [ - `### 🏗️ Squad Triage — ${lead.name} (${lead.role})`, - '', - `**Issue:** #${issue.number} — ${issue.title}`, - `**Assigned to:** ${assignedMember.name} (${assignedMember.role})`, - `**Reason:** ${triageReason}`, - copilotTier === 'needs-review' ? `\n⚠️ **PR review recommended** — a squad member should review @copilot's work on this one.` : '', - copilotNote, - '', - `---`, - '', - `**Team roster:**`, - memberList, - hasCopilot ? `- **@copilot** (Coding Agent) → label: \`squad:copilot\`` : '', - '', - `> To reassign, remove the current \`squad:*\` label and add the correct one.`, - ].filter(Boolean).join('\n'); - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - body: comment - }); - - core.info(`Triaged issue #${issue.number} → ${assignedMember.name} (${assignLabel})`); diff --git a/.squad/templates/workflows/sync-squad-labels.yml b/.squad/templates/workflows/sync-squad-labels.yml deleted file mode 100644 index fbcfd9c..0000000 --- a/.squad/templates/workflows/sync-squad-labels.yml +++ /dev/null @@ -1,169 +0,0 @@ -name: Sync Squad Labels - -on: - push: - paths: - - '.squad/team.md' - - '.ai-team/team.md' - workflow_dispatch: - -permissions: - issues: write - contents: read - -jobs: - sync-labels: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Parse roster and sync labels - uses: actions/github-script@v7 - with: - script: | - const fs = require('fs'); - let teamFile = '.squad/team.md'; - if (!fs.existsSync(teamFile)) { - teamFile = '.ai-team/team.md'; - } - - if (!fs.existsSync(teamFile)) { - core.info('No .squad/team.md or .ai-team/team.md found — skipping label sync'); - return; - } - - const content = fs.readFileSync(teamFile, 'utf8'); - const lines = content.split('\n'); - - // Parse the Members table for agent names - const members = []; - let inMembersTable = false; - for (const line of lines) { - if (line.match(/^##\s+(Members|Team Roster)/i)) { - inMembersTable = true; - continue; - } - if (inMembersTable && line.startsWith('## ')) { - break; - } - if (inMembersTable && line.startsWith('|') && !line.includes('---') && !line.includes('Name')) { - const cells = line.split('|').map(c => c.trim()).filter(Boolean); - if (cells.length >= 2 && cells[0] !== 'Scribe') { - members.push({ - name: cells[0], - role: cells[1] - }); - } - } - } - - core.info(`Found ${members.length} squad members: ${members.map(m => m.name).join(', ')}`); - - // Check if @copilot is on the team - const hasCopilot = content.includes('🤖 Coding Agent'); - - // Define label color palette for squad labels - const SQUAD_COLOR = '9B8FCC'; - const MEMBER_COLOR = '9B8FCC'; - const COPILOT_COLOR = '10b981'; - - // Define go: and release: labels (static) - const GO_LABELS = [ - { name: 'go:yes', color: '0E8A16', description: 'Ready to implement' }, - { name: 'go:no', color: 'B60205', description: 'Not pursuing' }, - { name: 'go:needs-research', color: 'FBCA04', description: 'Needs investigation' } - ]; - - const RELEASE_LABELS = [ - { name: 'release:v0.4.0', color: '6B8EB5', description: 'Targeted for v0.4.0' }, - { name: 'release:v0.5.0', color: '6B8EB5', description: 'Targeted for v0.5.0' }, - { name: 'release:v0.6.0', color: '8B7DB5', description: 'Targeted for v0.6.0' }, - { name: 'release:v1.0.0', color: '8B7DB5', description: 'Targeted for v1.0.0' }, - { name: 'release:backlog', color: 'D4E5F7', description: 'Not yet targeted' } - ]; - - const TYPE_LABELS = [ - { name: 'type:feature', color: 'DDD1F2', description: 'New capability' }, - { name: 'type:bug', color: 'FF0422', description: 'Something broken' }, - { name: 'type:spike', color: 'F2DDD4', description: 'Research/investigation — produces a plan, not code' }, - { name: 'type:docs', color: 'D4E5F7', description: 'Documentation work' }, - { name: 'type:chore', color: 'D4E5F7', description: 'Maintenance, refactoring, cleanup' }, - { name: 'type:epic', color: 'CC4455', description: 'Parent issue that decomposes into sub-issues' } - ]; - - // High-signal labels — these MUST visually dominate all others - const SIGNAL_LABELS = [ - { name: 'bug', color: 'FF0422', description: 'Something isn\'t working' }, - { name: 'feedback', color: '00E5FF', description: 'User feedback — high signal, needs attention' } - ]; - - const PRIORITY_LABELS = [ - { name: 'priority:p0', color: 'B60205', description: 'Blocking release' }, - { name: 'priority:p1', color: 'D93F0B', description: 'This sprint' }, - { name: 'priority:p2', color: 'FBCA04', description: 'Next sprint' } - ]; - - // Ensure the base "squad" triage label exists - const labels = [ - { name: 'squad', color: SQUAD_COLOR, description: 'Squad triage inbox — Lead will assign to a member' } - ]; - - for (const member of members) { - labels.push({ - name: `squad:${member.name.toLowerCase()}`, - color: MEMBER_COLOR, - description: `Assigned to ${member.name} (${member.role})` - }); - } - - // Add @copilot label if coding agent is on the team - if (hasCopilot) { - labels.push({ - name: 'squad:copilot', - color: COPILOT_COLOR, - description: 'Assigned to @copilot (Coding Agent) for autonomous work' - }); - } - - // Add go:, release:, type:, priority:, and high-signal labels - labels.push(...GO_LABELS); - labels.push(...RELEASE_LABELS); - labels.push(...TYPE_LABELS); - labels.push(...PRIORITY_LABELS); - labels.push(...SIGNAL_LABELS); - - // Sync labels (create or update) - for (const label of labels) { - try { - await github.rest.issues.getLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: label.name - }); - // Label exists — update it - await github.rest.issues.updateLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: label.name, - color: label.color, - description: label.description - }); - core.info(`Updated label: ${label.name}`); - } catch (err) { - if (err.status === 404) { - // Label doesn't exist — create it - await github.rest.issues.createLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: label.name, - color: label.color, - description: label.description - }); - core.info(`Created label: ${label.name}`); - } else { - throw err; - } - } - } - - core.info(`Label sync complete: ${labels.length} labels synced`); diff --git a/CHANGELOG.md b/CHANGELOG.md index 751fd8e..64810aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] +### Added + +- **Cache-read tokens now count toward cost estimates** — `estimate_cost()` accepts a `cache_read_tokens` argument priced via an optional per-model `cache_read` rate in `pricing.toml` (defaults to `0.0`, so cost is unchanged unless a rate is configured). `pricing.toml` documents the new field and sets `cache_read` for the two frontier models. +- **Type checking now covers `tests/`** — pyright's `include` was widened from `src` to `["src", "tests"]` (excluding `tests/visual`, which needs the optional `playwright` extra), and CI runs `uv run pyright` (config-driven) instead of a hard-coded `src/` path. Fixed the type errors this surfaced in `test_08_scoring.py`, `test_13_plugins.py`, and the event-mapper test helper. +- **Unit tests for cost estimation** — `tests/unit/test_cost.py` covers the input/output/cache-read arithmetic, unknown-model handling, and the zero-token short-circuit. + +### Changed + +- **`--strict-markers` is now enforced** — unregistered pytest markers now fail collection instead of silently passing, preventing typo'd markers. +- **Report generation is more resilient** — in `pytest_sessionfinish`, AI-insight and HTML/Markdown rendering are wrapped so a rendering failure no longer discards the already-written JSON, skips the `--aitest-min-pass-rate` gate, or bypasses session cleanup. + +### Removed + +- **Squad CI workflows** — removed the 11 `squad-*.yml` / `sync-squad-labels.yml` GitHub Actions workflows and the `.squad/templates/workflows/` copies that could regenerate them. + +## [0.6.14] - 2026-07-14 + ### Changed - **Docs now match the shipped behaviour of `max_turns`** — corrected `CopilotEval` docstrings that claimed the runner "enforces turn limits externally". The runner enforces `timeout_s` as the hard wall-clock limit; `max_turns` is advisory for the top-level session (not hard-enforced mid-run) and is used only to cap subagent turns. diff --git a/pricing.toml b/pricing.toml index 946e56b..3e62851 100644 --- a/pricing.toml +++ b/pricing.toml @@ -8,7 +8,10 @@ # - Keep cost estimates accurate as vendor pricing changes # # Format: -# "model-key" = { input = , output = } +# "model-key" = { input = , output = , cache_read = } +# +# "cache_read" is optional (defaults to 0.0) and prices cached-prompt input +# tokens, which vendors typically bill at ~10% of the normal input rate. # # The model key must match the string passed to CopilotEval(model=...). @@ -16,9 +19,9 @@ # Frontier flagship models used by tests/integration/copilot (as of 2026-07, # vendor list pricing per Anthropic/OpenAI via GitHub Copilot billing docs — # verify against your plan before relying on these for real cost decisions). -"claude-opus-4.8" = { input = 5.00, output = 25.00 } -"gpt-5.6-sol" = { input = 5.00, output = 30.00 } +"claude-opus-4.8" = { input = 5.00, output = 25.00, cache_read = 0.50 } +"gpt-5.6-sol" = { input = 5.00, output = 30.00, cache_read = 0.50 } # Examples (uncomment and adjust as needed): # "azure/my-custom-deploy" = { input = 2.00, output = 8.00 } -# "claude-sonnet-4" = { input = 3.00, output = 15.00 } +# "claude-sonnet-4" = { input = 3.00, output = 15.00, cache_read = 0.30 } diff --git a/pyproject.toml b/pyproject.toml index b115529..8172727 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -102,7 +102,10 @@ quote-style = "double" indent-style = "space" [tool.pyright] -include = ["src"] +include = ["src", "tests"] +# tests/visual requires the optional 'playwright' dependency, which isn't part +# of the default dev environment. +exclude = ["tests/visual"] pythonVersion = "3.11" typeCheckingMode = "basic" @@ -120,6 +123,7 @@ filterwarnings = [ # This demonstrates the recommended setup - configure once in pyproject.toml. # LLM auth is handled by the GitHub Copilot SDK (gh auth login or GITHUB_TOKEN) addopts = """ +--strict-markers --aitest-summary-model=copilot/gpt-5.5 --aitest-html=aitest-reports/report.html """ diff --git a/src/pytest_skill_engineering/copilot/fixtures.py b/src/pytest_skill_engineering/copilot/fixtures.py index c157c04..92311d5 100644 --- a/src/pytest_skill_engineering/copilot/fixtures.py +++ b/src/pytest_skill_engineering/copilot/fixtures.py @@ -93,6 +93,7 @@ def _convert_to_aitest( usage.model or result.model_used or "", usage.input_tokens, usage.output_tokens, + usage.cache_read_tokens, ) for usage in result.usage ) diff --git a/src/pytest_skill_engineering/execution/cost.py b/src/pytest_skill_engineering/execution/cost.py index ffeaf58..a5da8bd 100644 --- a/src/pytest_skill_engineering/execution/cost.py +++ b/src/pytest_skill_engineering/execution/cost.py @@ -5,9 +5,11 @@ ``pricing.toml`` format:: - # Per-million-token pricing. + # Per-million-token pricing. ``cache_read`` is optional (defaults to 0.0) + # and prices cached-prompt input tokens, which most vendors bill at a + # fraction of the normal input rate. [models] - "claude-sonnet-4" = { input = 3.00, output = 15.00 } + "claude-sonnet-4" = { input = 3.00, output = 15.00, cache_read = 0.30 } "azure/my-custom-deploy" = { input = 2.00, output = 8.00 } """ @@ -25,10 +27,11 @@ # ── User overrides (pricing.toml) ──────────────────────────────────────────── -_user_overrides: dict[str, tuple[float, float]] | None = None +# Each entry is (input_per_million, output_per_million, cache_read_per_million). +_user_overrides: dict[str, tuple[float, float, float]] | None = None -def _load_user_overrides() -> dict[str, tuple[float, float]]: +def _load_user_overrides() -> dict[str, tuple[float, float, float]]: """Load per-million-token overrides from ``pricing.toml``. Searches upward from cwd for the first ``pricing.toml`` found. @@ -55,7 +58,8 @@ def _load_user_overrides() -> dict[str, tuple[float, float]]: if isinstance(value, dict): input_pm = float(value.get("input", 0)) output_pm = float(value.get("output", 0)) - _user_overrides[key] = (input_pm, output_pm) + cache_read_pm = float(value.get("cache_read", 0)) + _user_overrides[key] = (input_pm, output_pm, cache_read_pm) if _user_overrides: _logger.info( "Loaded %d pricing override(s) from %s", @@ -81,20 +85,29 @@ def _find_pricing_toml() -> Path | None: # ── Public API ─────────────────────────────────────────────────────────────── -def estimate_cost(model: str, input_tokens: int, output_tokens: int) -> float: +def estimate_cost( + model: str, + input_tokens: int, + output_tokens: int, + cache_read_tokens: int = 0, +) -> float: """Return estimated USD cost for a single LLM call. - Checks user overrides (``pricing.toml``). + Checks user overrides (``pricing.toml``). ``cache_read_tokens`` are priced + at the model's optional ``cache_read`` rate (``0.0`` when unset, so they add + nothing unless a rate is configured). Returns ``0.0`` and records the model in :data:`models_without_pricing` when no pricing is found. """ - if input_tokens == 0 and output_tokens == 0: + if input_tokens == 0 and output_tokens == 0 and cache_read_tokens == 0: return 0.0 overrides = _load_user_overrides() pricing = overrides.get(model) if pricing is not None: - return (input_tokens * pricing[0] + output_tokens * pricing[1]) / 1_000_000 + return ( + input_tokens * pricing[0] + output_tokens * pricing[1] + cache_read_tokens * pricing[2] + ) / 1_000_000 # No pricing found models_without_pricing.add(model) diff --git a/src/pytest_skill_engineering/plugin.py b/src/pytest_skill_engineering/plugin.py index c59488b..b37655b 100644 --- a/src/pytest_skill_engineering/plugin.py +++ b/src/pytest_skill_engineering/plugin.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from datetime import datetime from pathlib import Path from typing import TYPE_CHECKING, Any @@ -40,6 +41,9 @@ from pytest_skill_engineering.core.result import EvalResult +_logger = logging.getLogger(__name__) + + # Key for storing test reports in config COLLECTOR_KEY = pytest.StashKey[list[TestReport]]() # Key for storing session messages for @pytest.mark.session @@ -484,43 +488,55 @@ def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None: generate_json(suite_report, json_output_path) log_report_path(config, "JSON", json_output_path) - # Generate AI insights if HTML/MD report requested OR summary model specified + # Generate AI insights + HTML/MD reports. JSON is already safely written + # above, so any failure here is best-effort: log and continue so the + # min-pass-rate gate and cleanup below always run. summary_model = config.getoption("--aitest-summary-model") - insights = None - if html_path or md_path or summary_model: - insights = generate_structured_insights( - config, suite_report, required=bool(html_path or md_path) - ) + try: + insights = None + if html_path or md_path or summary_model: + insights = generate_structured_insights( + config, suite_report, required=bool(html_path or md_path) + ) - # Update JSON with insights if analysis succeeded - if insights is not None: - generate_json(suite_report, json_output_path, insights=insights) + # Update JSON with insights if analysis succeeded + if insights is not None: + generate_json(suite_report, json_output_path, insights=insights) - # Generate HTML report only when explicitly requested - if html_path: - html_output_path = Path(html_path) - html_output_path.parent.mkdir(parents=True, exist_ok=True) + # Generate HTML report only when explicitly requested + if html_path: + html_output_path = Path(html_path) + html_output_path.parent.mkdir(parents=True, exist_ok=True) - if insights is None: - insights = generate_structured_insights(config, suite_report, required=True) + if insights is None: + insights = generate_structured_insights(config, suite_report, required=True) - assert insights is not None # guaranteed by required=True above - generate_html( - suite_report, html_output_path, insights=insights, min_pass_rate=min_pass_rate - ) - log_report_path(config, "HTML", html_output_path) + assert insights is not None # guaranteed by required=True above + generate_html( + suite_report, html_output_path, insights=insights, min_pass_rate=min_pass_rate + ) + log_report_path(config, "HTML", html_output_path) - # Generate Markdown report if requested - if md_path: - md_output_path = Path(md_path) - md_output_path.parent.mkdir(parents=True, exist_ok=True) + # Generate Markdown report if requested + if md_path: + md_output_path = Path(md_path) + md_output_path.parent.mkdir(parents=True, exist_ok=True) - if insights is None: - insights = generate_structured_insights(config, suite_report, required=True) + if insights is None: + insights = generate_structured_insights(config, suite_report, required=True) - assert insights is not None # noqa: S101 - generate_md(suite_report, md_output_path, insights=insights, min_pass_rate=min_pass_rate) - log_report_path(config, "Markdown", md_output_path) + assert insights is not None # noqa: S101 + generate_md( + suite_report, md_output_path, insights=insights, min_pass_rate=min_pass_rate + ) + log_report_path(config, "Markdown", md_output_path) + except Exception: + _logger.warning( + "Report rendering failed after JSON was written to %s; " + "continuing with pass-rate enforcement and cleanup", + json_output_path, + exc_info=True, + ) # Enforce minimum pass rate threshold if min_pass_rate is not None: diff --git a/tests/integration/copilot/conftest.py b/tests/integration/copilot/conftest.py index fd91c4c..6f06daf 100644 --- a/tests/integration/copilot/conftest.py +++ b/tests/integration/copilot/conftest.py @@ -8,7 +8,7 @@ import pytest # Default model for integration tests -DEFAULT_MODEL: str | None = "claude-opus-4.8" +DEFAULT_MODEL: str = "claude-opus-4.8" # Frontier flagship models for parametrized tests MODELS: list[str] = ["claude-opus-4.8", "gpt-5.6-sol"] diff --git a/tests/integration/copilot/test_08_scoring.py b/tests/integration/copilot/test_08_scoring.py index 1bdc47a..21bf765 100644 --- a/tests/integration/copilot/test_08_scoring.py +++ b/tests/integration/copilot/test_08_scoring.py @@ -14,7 +14,13 @@ import pytest from pytest_skill_engineering.copilot.eval import CopilotEval -from pytest_skill_engineering.fixtures.llm_score import ScoringDimension, assert_score +from pytest_skill_engineering.copilot.result import CopilotResult +from pytest_skill_engineering.fixtures.llm_score import ( + LLMScore, + ScoreResult, + ScoringDimension, + assert_score, +) pytestmark = [pytest.mark.copilot] @@ -43,7 +49,7 @@ ] -def _score_artifact(result: object, file_path: str, llm_score: object) -> object: +def _score_artifact(result: CopilotResult, file_path: str, llm_score: LLMScore) -> ScoreResult: """Score the delivered artifact, not just the terminal confirmation message.""" content = result.file(file_path) response = result.final_response or "" diff --git a/tests/unit/test_copilot_event_mapper.py b/tests/unit/test_copilot_event_mapper.py index 0c84b1a..8087000 100644 --- a/tests/unit/test_copilot_event_mapper.py +++ b/tests/unit/test_copilot_event_mapper.py @@ -7,12 +7,17 @@ from __future__ import annotations from types import SimpleNamespace +from typing import Any from pytest_skill_engineering.copilot.events import EventMapper -def _make_event(event_type: str, **data_fields) -> SimpleNamespace: - """Create a mock SessionEvent with the given type and data fields.""" +def _make_event(event_type: str, **data_fields) -> Any: + """Create a mock SessionEvent with the given type and data fields. + + Returns ``Any`` because the mapper only relies on structural attribute + access (duck typing) — a real ``SessionEvent`` is intentionally not built. + """ # Use SimpleNamespace so getattr(data, field, default) returns default # for missing fields (unlike MagicMock which auto-creates them). data = SimpleNamespace(**data_fields) diff --git a/tests/unit/test_cost.py b/tests/unit/test_cost.py new file mode 100644 index 0000000..71d81eb --- /dev/null +++ b/tests/unit/test_cost.py @@ -0,0 +1,56 @@ +"""Pure-logic tests for token-based cost estimation. + +No LLM calls — these exercise the arithmetic and pricing lookup in +``execution.cost`` directly. +""" + +from __future__ import annotations + +import pytest + +from pytest_skill_engineering.execution import cost + + +@pytest.fixture +def pricing(monkeypatch: pytest.MonkeyPatch) -> None: + """Install a deterministic pricing table and reset module globals.""" + overrides = { + # (input, output, cache_read) per million tokens + "priced-model": (10.0, 30.0, 1.0), + "no-cache-model": (10.0, 30.0, 0.0), + } + monkeypatch.setattr(cost, "_user_overrides", overrides) + monkeypatch.setattr(cost, "models_without_pricing", set()) + + +def test_basic_input_output_cost(pricing: None) -> None: + # 1M input @ $10 + 1M output @ $30 = $40 + assert cost.estimate_cost("priced-model", 1_000_000, 1_000_000) == pytest.approx(40.0) + + +def test_cache_read_tokens_are_priced(pricing: None) -> None: + # 1M cache-read tokens @ $1 add exactly $1 on top of input/output. + without = cost.estimate_cost("priced-model", 1_000_000, 0, 0) + with_cache = cost.estimate_cost("priced-model", 1_000_000, 0, 1_000_000) + assert with_cache - without == pytest.approx(1.0) + + +def test_cache_read_default_is_zero(pricing: None) -> None: + # Omitting cache_read_tokens must not change the estimate. + explicit = cost.estimate_cost("priced-model", 500_000, 500_000, 0) + implicit = cost.estimate_cost("priced-model", 500_000, 500_000) + assert explicit == implicit + + +def test_cache_read_ignored_when_rate_unset(pricing: None) -> None: + # A model with cache_read rate 0.0 prices cache tokens at nothing. + assert cost.estimate_cost("no-cache-model", 0, 0, 1_000_000) == pytest.approx(0.0) + + +def test_unknown_model_returns_zero_and_is_recorded(pricing: None) -> None: + assert cost.estimate_cost("mystery-model", 1_000, 1_000) == 0.0 + assert "mystery-model" in cost.models_without_pricing + + +def test_all_zero_tokens_short_circuits(pricing: None) -> None: + assert cost.estimate_cost("priced-model", 0, 0, 0) == 0.0