diff --git a/.github/workflows/squad-heartbeat.yml b/.github/workflows/squad-heartbeat.yml new file mode 100644 index 000000000..a0b1b3ba1 --- /dev/null +++ b/.github/workflows/squad-heartbeat.yml @@ -0,0 +1,313 @@ +name: Squad Heartbeat (Ralph) + +on: + schedule: + # Every 30 minutes β€” adjust or remove if not needed + - cron: '*/30 * * * *' + + # React to completed work + issues: + types: [closed] + pull_request: + types: [closed] + + # React to new squad work + issues: + types: [labeled] + + # 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 + const teamFile = '.ai-team/team.md'; + if (!fs.existsSync(teamFile)) { + core.info('No .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.startsWith('## Members')) { + 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'); + + const 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 .ai-team/team.md for team context and .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-issue-assign.yml b/.github/workflows/squad-issue-assign.yml new file mode 100644 index 000000000..b4970c512 --- /dev/null +++ b/.github/workflows/squad-issue-assign.yml @@ -0,0 +1,158 @@ +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 to find the member + const teamFile = '.ai-team/team.md'; + if (!fs.existsSync(teamFile)) { + core.warning('No .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.startsWith('## Members')) { + 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 \`.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/.github/workflows/squad-label-enforce.yml b/.github/workflows/squad-label-enforce.yml new file mode 100644 index 000000000..633d220df --- /dev/null +++ b/.github/workflows/squad-label-enforce.yml @@ -0,0 +1,181 @@ +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/.github/workflows/squad-main-guard.yml b/.github/workflows/squad-main-guard.yml index 9399f9ec0..9f7f65117 100644 --- a/.github/workflows/squad-main-guard.yml +++ b/.github/workflows/squad-main-guard.yml @@ -1,8 +1,8 @@ -name: Squad Main Guard +name: Squad Protected Branch Guard on: pull_request: - branches: [main] + branches: [main, preview] types: [opened, synchronize, reopened] permissions: diff --git a/.github/workflows/squad-triage.yml b/.github/workflows/squad-triage.yml new file mode 100644 index 000000000..0aa53c9aa --- /dev/null +++ b/.github/workflows/squad-triage.yml @@ -0,0 +1,254 @@ +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 to find the Lead and all members + const teamFile = '.ai-team/team.md'; + if (!fs.existsSync(teamFile)) { + core.warning('No .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.startsWith('## Members')) { + 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 + const 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 new file mode 100644 index 000000000..3ae1ff5a4 --- /dev/null +++ b/.github/workflows/sync-squad-labels.yml @@ -0,0 +1,158 @@ +name: Sync Squad Labels + +on: + push: + paths: + - '.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'); + const teamFile = '.ai-team/team.md'; + + if (!fs.existsSync(teamFile)) { + core.info('No .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.startsWith('## Members')) { + 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 = '6366f1'; + const MEMBER_COLOR = '3b82f6'; + 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: '0052CC', description: 'Targeted for v0.4.0' }, + { name: 'release:v0.5.0', color: '1D76DB', description: 'Targeted for v0.5.0' }, + { name: 'release:v0.6.0', color: '5319E7', description: 'Targeted for v0.6.0' }, + { name: 'release:v1.0.0', color: '6F42C1', description: 'Targeted for v1.0.0' }, + { name: 'release:backlog', color: 'C5DEF5', description: 'Not yet targeted' } + ]; + + const TYPE_LABELS = [ + { name: 'type:feature', color: 'D4C5F9', description: 'New capability' }, + { name: 'type:bug', color: 'D73A4A', description: 'Something broken' }, + { name: 'type:spike', color: 'F9D0C4', description: 'Research/investigation β€” produces a plan, not code' }, + { name: 'type:docs', color: 'C5DEF5', description: 'Documentation work' }, + { name: 'type:chore', color: 'BFD4F2', description: 'Maintenance, refactoring, cleanup' }, + { name: 'type:epic', color: 'B60205', description: 'Parent issue that decomposes into sub-issues' } + ]; + + 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:, and priority: labels + labels.push(...GO_LABELS); + labels.push(...RELEASE_LABELS); + labels.push(...TYPE_LABELS); + labels.push(...PRIORITY_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 f7ee9b430..2680f10a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,36 @@ # Changelog +## [0.4.0] β€” 2026-02-15 + +### Added + +- **MCP tool discovery and use** (PR #11 by @csharpfritz) β€” Auto-discover available MCP tools, graceful degradation if service unavailable, tool usage in agent workflows +- **User documentation improvements** (PR #16 by @csharpfritz) β€” Expanded guides, sample prompts, troubleshooting sections, release process documentation +- **VS Code client compatibility** (PR #17 by @spboyer) β€” Full support for VS Code Copilot without code changes; runSubagent parallel execution, zero-change deployment +- **Plugin marketplace concept** β€” Community skills from GitHub repos, auto-discover plugins, sandbox isolation +- **Agent notifications system** β€” Trello cards, Teams webhooks, GitHub Discussions posts; agents can emit lifecycle events +- **MCP integration for external services** β€” Auto-discover and integrate Trello, Azure, Notion, GitHub, Slack MCP tools; graceful degradation +- **Progress signals for long-running work** β€” `[MILESTONE]` markers in agent output, coordinator relays progress to user +- **Notification channels** β€” Trello cards for work items, Teams webhooks for milestones, GitHub Discussions for team updates +- **Earned skills improvements** β€” Better confidence scoring, export/import polish, skill discovery from real work +- **Universe expansion** β€” 11 new universes (Futurama, Seinfeld, The Office, Cowboy Bebop, FMA, Stranger Things, The Expanse, Arcane, Ted Lasso, Dune, Adventure Time); casting universe now 31 total (up from 20) +- **Branch protection guard** (squad-main-guard.yml) β€” Prevents `.ai-team/` and internal `team-docs/` from shipping on main and preview branches +- **Release process documentation** (docs/scenarios/release-process.md) β€” Complete step-by-step guide for maintainers: preview builds, PR workflows, full release lifecycle, guard testing, troubleshooting + +### Changed + +- VS Code is now fully compatible β€” zero code changes required; agents run identically on CLI and VS Code +- Agent progress signals use `[MILESTONE]` markers for coordinator relay +- 6 new GitHub Actions workflows added: squad-main-guard.yml, squad-heartbeat.yml, squad-issue-assign.yml, squad-label-enforce.yml, squad-triage.yml, sync-squad-labels.yml +- Universe count: 20 β†’ 31 (added 11 new universes) + +### Community + +- @csharpfritz: MCP tool discovery (#11), user documentation improvements (#16) +- @spboyer: VS Code client compatibility (#17) +- @essenbee2, @miketsui3a, @londospark: Issue contributions and feedback +- External universe contributions: Futurama, Seinfeld, The Office, Cowboy Bebop, Full Metal Alchemist, Stranger Things, The Expanse, Arcane, Ted Lasso, Dune, Adventure Time casting universes + ## [0.3.0] β€” 2026-02-11 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..e047ffdb1 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,397 @@ +# Contributing to Squad + +Squad is built by contributors who believe in democratizing multi-agent development. We're excited to have you join us β€” and we want to make contributing as smooth as possible. + +This guide covers everything you need to know: how to set up your environment, the branch model that keeps us sane, what files go where, and how to submit your work. **Pay special attention to the branch protection rules** β€” we protect `main` and `preview` aggressively, and it's easier to get it right the first time. + +--- + +## Getting Started + +### Prerequisites + +- **Node.js 22.0.0 or later** β€” required by the `engines` field in package.json +- **Git** β€” for cloning and branching +- **GitHub CLI (`gh`)** β€” for interactions with Issues, PRs, and (optionally) Project Boards + +### 1. Fork and Clone + +```bash +# Fork on GitHub, then clone your fork +git clone https://github.com/{your-username}/squad.git +cd squad +``` + +### 2. Install Dependencies + +```bash +npm install +``` + +### 3. Run Tests + +```bash +npm test +# Or explicitly: +node --test test/*.test.js +``` + +All tests should pass. If anything fails, [open an issue](https://github.com/bradygaster/squad/issues). + +--- + +## Branch Model + +Squad uses a three-tier branch structure to protect production and staging while keeping development flexible. + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ β”‚ +β”‚ dev ── Feature branches ──→ dev ── (merge/rebase) ──→ preview β”‚ +β”‚ (squad/{issue}-{slug}) β•² β”‚ β”‚ +β”‚ └──→ Release tagged ───→ main β”‚ +β”‚ β”‚ β”‚ +β”‚ βœ… ALL files allowed 🚫 .ai-team/ BLOCKED 🚫 BLOCKED β”‚ +β”‚ (dev branch = safe sandbox) team-docs/ BLOCKED (except β”‚ +β”‚ (except blog/) tagged β”‚ +β”‚ releases) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### Branch Purposes + +| Branch | Purpose | Protection | Files Allowed | +|--------|---------|------------|---------------| +| **`dev`** | Development & integration | None | βœ… Everything | +| **`feature/squad/{issue}-{slug}`** | Feature work | None β€” merge to dev | βœ… Everything | +| **`preview`** | Staging & release candidate | Guard checks for `.ai-team/`, `team-docs/` (except blog/) | βœ… Most files β€” see [Protected Files](#whats-protected) | +| **`main`** | Production & releases | Guard checks for `.ai-team/`, `team-docs/` (except blog/) | βœ… Most files β€” see [Protected Files](#whats-protected) | + +### Creating a Feature Branch + +Create branches from `dev` using the naming convention `squad/{issue-number}-{slug}`: + +```bash +# Check out dev and get latest +git checkout dev +git pull origin dev + +# Create feature branch +git checkout -b squad/42-auth-refresh +# Or for fixes without an issue: +git checkout -b squad/fix-silent-success-on-sync +``` + +### Merging to dev + +When your work is ready, create a PR **targeting `dev`** (not `main` or `preview`). No guard checks apply to `dev` β€” it's a safe sandbox for any changes. + +```bash +git push origin squad/42-auth-refresh +# Then open PR on GitHub, targeting dev +``` + +--- + +## What's Protected + +### 🚫 CRITICAL: Files Blocked from `main` and `preview` + +These files are **runtime team state** and belong on development branches only: + +| Path | Reason | Merged to main? | +|------|--------|-----------------| +| **`.ai-team/**`** | Agent charters, routing, decisions, history, casting registry | ❌ NEVER | +| **`team-docs/` (except `team-docs/blog/`** | Internal team documentation, sprint plans, notes | ❌ NEVER | +| **`team-docs/blog/**`** | Public blog content | βœ… YES β€” blog posts are public | + +**Why?** `.ai-team/` contains persistent agent knowledge, routing rules, and decision history. It's internal infrastructure. If it leaks to `main`, you're shipping developer metadata as product. `team-docs/` is working notes β€” only blog content is publication-ready. + +### βœ… Files That Flow Freely + +These files move between `dev` β†’ `preview` β†’ `main` with no restrictions: + +- `index.js` β€” CLI entry point +- `squad.agent.md` β€” Squad coordinator +- `templates/` β€” Agent templates +- `docs/` β€” Public documentation +- `test/` β€” Test suite +- `.github/workflows/` β€” GitHub Actions workflows +- `team-docs/blog/` β€” Blog posts +- `package.json` β€” Dependencies +- `README.md`, `LICENSE` β€” Project metadata +- `CHANGELOG.md` β€” Release history +- `.gitignore`, `.gitattributes`, `.npmignore` β€” Git configuration + +--- + +## PR Process + +### Step 1: Create Feature Branch from `dev` + +```bash +git checkout dev +git pull origin dev +git checkout -b squad/123-your-feature +``` + +### Step 2: Make Changes, Commit, Push + +```bash +# Edit files... +git add . +git commit -m "feat: add feature description" +git push origin squad/123-your-feature +``` + +Follow [commit message conventions](#commit-message-convention) (below). + +### Step 3: Open PR Targeting `dev` + +On GitHub, create a PR with: +- **Base branch:** `dev` ← **Always target dev first** +- **Title:** Follows conventional commits (e.g., `feat: add auth refresh`, `fix: silent success bug`) +- **Description:** What changed, why, and any testing notes + +### Step 4: Guard Checks (if targeting `preview` or `main`) + +If you accidentally (or intentionally) target `preview` or `main`, the **guard workflow** (`squad-main-guard.yml`) runs: + +```yaml +βœ… If no forbidden files detected: + PR checks pass, you can merge. + +❌ If forbidden files detected (.ai-team/, team-docs/ except blog/): + Workflow fails with actionable error message. + You must remove the files before merging. +``` + +### Step 5: Fixing a Blocked PR + +If the guard blocks your PR because it contains `.ai-team/` or `team-docs/` files: + +```bash +# Remove .ai-team/ from git (keeps local copies safe) +git rm --cached -r .ai-team/ + +# Remove team-docs/ except blog/ +git rm --cached -r team-docs/ +git checkout HEAD -- team-docs/blog/ + +# Commit and push +git commit -m "chore: remove internal team files from PR" +git push +``` + +The workflow will re-run and pass. Your local `.ai-team/` and `team-docs/` files remain untouched. + +--- + +## Running Tests + +Squad uses Node's built-in test runner. No external dependencies. + +```bash +# Run all tests +npm test + +# Or explicitly: +node --test test/*.test.js +``` + +Tests should pass before you open a PR. If a test fails, check if it's related to your changes. If you're fixing a known failing test as part of your work, that's fine β€” but don't introduce new failures. + +--- + +## Commit Message Convention + +Squad follows [Conventional Commits](https://www.conventionalcommits.org/): + +``` +(): + + + +