From e00f026f92a6a14e38459fb0c2ff03b98cb673d4 Mon Sep 17 00:00:00 2001 From: bradygaster Date: Fri, 13 Feb 2026 08:45:20 -0800 Subject: [PATCH 1/2] feat: v0.4.0 release prep - Add 5 new workflow files (heartbeat, issue-assign, label-enforce, triage, sync-labels) - Update guard workflow: protect both main and preview branches - Sync template guard to match .github/workflows/ copy - Bump version to 0.4.0 - Add v0.4.0 blog post --- .github/workflows/squad-heartbeat.yml | 313 ++++++++++++++++++++++ .github/workflows/squad-issue-assign.yml | 158 +++++++++++ .github/workflows/squad-label-enforce.yml | 181 +++++++++++++ .github/workflows/squad-main-guard.yml | 4 +- .github/workflows/squad-triage.yml | 254 ++++++++++++++++++ .github/workflows/sync-squad-labels.yml | 158 +++++++++++ package.json | 2 +- team-docs/blog/008-v040-release.md | 71 +++++ templates/workflows/squad-main-guard.yml | 4 +- 9 files changed, 1140 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/squad-heartbeat.yml create mode 100644 .github/workflows/squad-issue-assign.yml create mode 100644 .github/workflows/squad-label-enforce.yml create mode 100644 .github/workflows/squad-triage.yml create mode 100644 .github/workflows/sync-squad-labels.yml create mode 100644 team-docs/blog/008-v040-release.md 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/package.json b/package.json index c2b2c88b8..1c3289287 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bradygaster/create-squad", - "version": "0.3.0", + "version": "0.4.0", "description": "Add an AI agent team to any project", "bin": { "create-squad": "./index.js" diff --git a/team-docs/blog/008-v040-release.md b/team-docs/blog/008-v040-release.md new file mode 100644 index 000000000..01e63b55d --- /dev/null +++ b/team-docs/blog/008-v040-release.md @@ -0,0 +1,71 @@ +--- +title: "v0.4.0: Squad Works Everywhere, Talks to You, and Brings Friends" +date: 2026-02-13 +author: "McManus (DevRel)" +wave: 6 +tags: [squad, release, v0.4.0, multi-client, mcp, notifications, plugins, github-projects] +status: published +hero: "v0.4.0 ships VS Code support, GitHub Projects integration, real-time agent progress updates, MCP integrations, a plugin marketplace, and a 70% context reduction. Squad is no longer CLI-only." +--- + +# v0.4.0: Squad Works Everywhere, Talks to You, and Brings Friends + +> _Squad now runs inside VS Code. Agents post progress updates as they work. MCP tools unlock GitHub, Trello, Azure, and your own infrastructure. When adding teammates, Squad finds the right plugins. And we dropped token costs by 70%._ + +## What Shipped + +- **VS Code Support** β€” Agents run inside VS Code Copilot, not just the CLI. Full feature parity: spawn mechanism via `runSubagent`, file discovery and `.ai-team/` access, background execution, parallel sub-agents. Feature compatibility matrix published at `docs/scenarios/client-compatibility.md`. _(Verbal + Fenster)_ +- **GitHub Projects Integration** β€” Agents create GitHub Projects V2 boards to visualize workflow. Work items move through Todo β†’ In Progress β†’ Done. Agents track their own status without manual board updates. _(Built by @londospark)_ +- **MCP (Model Context Protocol) Tools** β€” Agents discover and invoke MCP tools for GitHub (beyond API), Trello, Aspire dashboards, Azure, and custom tools you bring. Discovery is automatic. Setup guides for CLI and VS Code included; graceful degradation if MCP not configured. _(Built by @csharpfritz)_ +- **Agent Progress Updates** β€” Long-running tasks emit `[MILESTONE]` markers. The coordinator polls every 30 seconds and relays updates to you as πŸ“ status messages. No more wondering if anything is happening. _(Built by Fenster)_ +- **Squad Pings You (Notifications)** β€” Agents can notify you on Teams, iMessage, Discord, or via webhook when they need input. Zero infrastructure in Squad core β€” bring your own MCP notification server. Teams is the primary path with copy-paste config. _(Built by @csharpfritz)_ +- **Plugin Marketplace** β€” When onboarding new team members, Squad browses configured plugin marketplaces (e.g., `github/awesome-copilot`, `anthropics/skills`) and auto-recommends relevant plugins. React frontend? It finds React patterns. Azure DevOps? It finds the Azure plugin. Full CLI: `squad plugin marketplace add/remove/list/browse`. _(Built by @GreenCee)_ +- **Context Window Optimization** β€” `decisions.md` pruned from 298KB (80K tokens) to 50KB. Spawn templates collapsed from 3 to 1. Per-agent spawn cost dropped from 82–93K tokens (41–46%) to 19–28K tokens (10–14%). _(Built by Fenster)_ +- **SSH Agent Hang Fix** β€” `npx github:bradygaster/squad` no longer appears to hang when no SSH agent is running. Root cause was npm spinner burying the passphrase prompt. Documented workaround: `--progress=false` or start SSH agent first. _(Built by @dnoriegagoodwin)_ + +## The Story + +Three releases in, Squad proved itself: agents work in parallel, they remember you and your code, they learn and adapt. But Squad was locked to one environment β€” the CLI. Copy the `.ai-team/` folder to VS Code? Agents couldn't see it. Run on a laptop without SSH agent configured? The spinner hid the passphrase prompt. + +v0.4.0 is about breaking those walls. + +The biggest story is VS Code support. Brady identified early that Squad's value isn't in the CLI β€” it's in agents working alongside you. The CLI was just the first place agents could do that. VS Code is where developers live. v0.4.0 makes Squad a first-class citizen there. Not a degraded version of CLI Squad β€” full feature parity. Same agents. Same decisions. Same backlog. Same persistent knowledge. Just integrated into Copilot instead of a terminal window. + +The multi-client story unlocked a bigger conversation: how do agents talk to developers? In v0.3.0, agents reported status in history files. v0.4.0 goes further. Long tasks emit progress markers. The coordinator reads them every 30 seconds and tells you "πŸ”§ Fenster is 60% done with the refactor." And when agents need a decision from you β€” a configuration choice, a design call, a code review approval β€” they don't wait in history files. They ping you on Teams, Discord, or any webhook endpoint you wire up. That's MCP notifications, a feature @csharpfritz saw was missing and built into the core. + +MCP (Model Context Protocol) is the other big unlock. MCP lets agents talk to tools β€” GitHub API, Trello boards, Azure infrastructure, your own dashboards. In v0.3.0, agents were read-only against external systems. v0.4.0 agents are active participants. Create a PR? GitHub MCP tool. Schedule work on a Trello board? Trello MCP tool. MCP discovery is automatic; graceful degradation if you don't set it up. This is the foundation for agent workflows that span from code to deployment to team communication. + +GitHub Projects integration completes the circle. Agents already knew how to create GitHub Issues (v0.3.0). v0.4.0 agents create GitHub Projects V2 boards to visualize workflow. Every agent instance gets its own board β€” Todo, In Progress, Done. As agents work, they move cards. No manual process. No sync drift. The board is a live view of what your agents are actually doing. + +The plugin marketplace is where community energy meets developer experience. When you onboard a new agent, Squad browses configured plugin marketplaces and recommends relevant plugins. It's not magic β€” it's just really useful defaults. New frontend agent? Here's the React plugin. New DevOps agent? Here's the Azure plugin. Developers don't need to know what plugins exist. Squad finds them. + +On the implementation side, Fenster did context optimization work that's invisible to users but changes the economics of running Squad at scale. `decisions.md` went from 298KB to 50KB. Spawn templates collapsed from 3 separate patterns to 1 unified one. The result: per-agent spawn cost dropped by 70%. That compounds across teams and teams across organizations. + +And @dnoriegagoodwin caught a UX death cut in the SSH hang scenario: developers with no SSH agent see the passphrase prompt get buried under an npm spinner. Documented workaround, and we're watching for the cleaner fix. + +## By the Numbers + +| Metric | Value | +|--------|-------| +| Issues closed | 12 | +| Community contributors | 5 (@londospark, @csharpfritz, @GreenCee, @dnoriegagoodwin, @essenbee2) | +| New major features | 3 (VS Code, MCP, notifications + marketplace) | +| Context reduction | 70% (spawn costs from 82–93K tokens β†’ 19–28K tokens) | +| Client compatibility matrix | Complete (βœ…/❌/⚠️ across CLI vs VS Code) | +| MCP integrations | 5+ (GitHub, Trello, Aspire, Azure, custom) | + +## What We Learned + +- **Multi-client is the game changer, not the nice-to-have.** Agents in VS Code aren't a convenience feature β€” they're where developers need them. The CLI was the start. The real product happens where developers work. +- **Agent-to-developer communication scales differently than agent-to-agent.** Agents talking to each other (via decisions and drop-box patterns) works in-process. Agents talking to developers (notifications, progress pings) require external infrastructure β€” Teams, Discord, webhooks. This is the bridge from internal agent coordination to external developer experience. +- **Community contributors see the sharp edges first.** @dnoriegagoodwin's SSH hang fix, @csharpfritz's notification needs, @GreenCee's plugin marketplace idea β€” these came from real projects using Squad. The core team builds architecture; the community builds the polish. + +## What's Next + +v0.4.0 is the inflection point where Squad stops being a CLI tool and starts being an agent framework. VS Code support means agents can be embedded. MCP integration means agents can reach out. Notifications mean developers can be in the loop. The next wave is about scaling β€” how do you run Squad at team scale, across projects, with agent instances that spawn and scale independently? + +We're also watching GitHub Projects integration closely. Kanban boards are how teams visualize work. If agents can own a board and move items autonomously, the feedback loop between developer intent and agent execution becomes visible and instantaneous. + +--- + +_This post was written by McManus, the DevRel on Squad's own team. Squad is an open source project by [@bradygaster](https://github.com/bradygaster). [Try it β†’](https://github.com/bradygaster/squad)_ diff --git a/templates/workflows/squad-main-guard.yml b/templates/workflows/squad-main-guard.yml index 9399f9ec0..9f7f65117 100644 --- a/templates/workflows/squad-main-guard.yml +++ b/templates/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: From d88ae63791d6ca17fecb8f2c3a0a8c7a9523ed79 Mon Sep 17 00:00:00 2001 From: bradygaster Date: Fri, 13 Feb 2026 08:50:25 -0800 Subject: [PATCH 2/2] docs: add release runbook, CONTRIBUTING.md, and v0.4.0 changelog - Create docs/scenarios/release-process.md (maintainer release playbook) - Create CONTRIBUTING.md (contributor guide with branch protection rules) - Update CHANGELOG.md with v0.4.0 entry - Update docs/community.md to reference CONTRIBUTING.md --- CHANGELOG.md | 31 ++ CONTRIBUTING.md | 397 +++++++++++++++ docs/community.md | 4 +- docs/scenarios/release-process.md | 811 ++++++++++++++++++++++++++++++ 4 files changed, 1242 insertions(+), 1 deletion(-) create mode 100644 CONTRIBUTING.md create mode 100644 docs/scenarios/release-process.md 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/): + +``` +(): + + + +