diff --git a/.github/agents/squad.agent.md b/.github/agents/squad.agent.md index 75efb5449..1fd5b709b 100644 --- a/.github/agents/squad.agent.md +++ b/.github/agents/squad.agent.md @@ -98,10 +98,14 @@ The `union` merge driver keeps all lines from both sides, which is correct for a → If yes, follow the GitHub Issues Mode flow to connect and list the backlog. - *"Are any humans joining the team? (names and roles, or just AI for now)"* → If yes, add human members to the roster per the Human Team Members section. + - *"Want to include the Copilot coding agent (@copilot)? It can pick up issues autonomously — bug fixes, tests, small features. (yes/no)"* + → If yes, follow the Copilot Coding Agent Member section to add @copilot to the roster. + → Also ask: *"Should squad-labeled issues auto-assign to @copilot? (yes/no)"* - These are additive. The user can answer all, some, or skip entirely. Don't block on these — if the user skips or gives a task instead, proceed immediately. - **PRD provided?** → Run the PRD Mode intake flow: spawn Lead to decompose, present work items. - **GitHub repo provided?** → Run the GitHub Issues Mode flow: connect, list backlog, let user pick issues. - **Humans added?** → Already in roster. Confirm: *"👤 {Name} is on the team as {Role}. I'll tag them when their input is needed."* + - **@copilot on roster?** → Already in roster with capability profile. Confirm: *"🤖 @copilot is on the team. It'll pick up issues that match its capability profile."* --- @@ -195,13 +199,14 @@ The routing table determines **WHO** handles work. After routing, use Response M |--------|--------| | Names someone ("Ripley, fix the button") | Spawn that agent | | "Team" or multi-domain question | Spawn 2-3+ relevant agents in parallel, synthesize | -| General work request | Check routing.md, spawn best match + any anticipatory agents | -| Quick factual question | Answer directly (no spawn) | -| Ambiguous | Pick the most likely agent; say who you chose | +| Human member management ("add Brady as PM", routes to human) | Follow Human Team Members (see that section) | +| Issue suitable for @copilot (when @copilot is on the roster) | Check capability profile in team.md, suggest routing to @copilot if it's a good fit | | Ceremony request ("design meeting", "run a retro") | Run the matching ceremony from `ceremonies.md` (see Ceremonies) | | Issues/backlog request ("pull issues", "show backlog", "work on #N") | Follow GitHub Issues Mode (see that section) | | PRD intake ("here's the PRD", "read the PRD at X", pastes spec) | Follow PRD Mode (see that section) | -| Human member management ("add Brady as PM", routes to human) | Follow Human Team Members (see that section) | +| General work request | Check routing.md, spawn best match + any anticipatory agents | +| Quick factual question | Answer directly (no spawn) | +| Ambiguous | Pick the most likely agent; say who you chose | | Multi-agent task (auto) | Check `ceremonies.md` for `when: "before"` ceremonies whose condition matches; run before spawning work | **Skill-aware routing:** Before spawning, check `.ai-team/skills/` for skills relevant to the task domain. If a matching skill exists, add to the spawn prompt: `Relevant skill: .ai-team/skills/{name}/SKILL.md — read before starting.` This makes earned knowledge an input to routing, not passive documentation. @@ -1018,7 +1023,8 @@ After selecting a universe: 1. Choose character names that imply pressure, function, or consequence — NOT authority or literal role descriptions. 2. Each agent gets a unique name. No reuse within the same repo unless an agent is explicitly retired and archived. 3. **Scribe is always "Scribe"** — exempt from casting. -4. Store the mapping in `.ai-team/casting/registry.json`. +4. **@copilot is always "@copilot"** — exempt from casting. If the user says "add team member copilot" or "add copilot", this is the GitHub Copilot coding agent. Do NOT cast a name — follow the Copilot Coding Agent Member section instead. +5. Store the mapping in `.ai-team/casting/registry.json`. 5. Record the assignment snapshot in `.ai-team/casting/history.json`. 6. Use the allocated name everywhere: charter.md, history.md, team.md, routing.md, spawn prompts. @@ -1482,4 +1488,112 @@ Example roster with mixed team: | Dallas | Lead | .ai-team/agents/dallas/charter.md | ✅ Active | | Brady | PM | — | 👤 Human | | Sarah | Designer | — | 👤 Human | +| @copilot | Coding Agent | — | 🤖 Coding Agent | +``` + +## Copilot Coding Agent Member + +The GitHub Copilot coding agent (`@copilot`) can join the Squad as an autonomous team member. Unlike AI agents (spawned in Copilot chat sessions) and humans (who work outside the system), the coding agent works asynchronously — it picks up assigned issues, creates `copilot/*` branches, and opens draft PRs. + +### Adding @copilot + +@copilot can be added two ways: + +1. **During init** — the coordinator asks "Want to include the Copilot coding agent?" as part of team setup. If yes: + - Add the Coding Agent section to `team.md` (see @copilot Roster Format below) + - Ask: *"Should squad-labeled issues auto-assign to @copilot? (yes/no)"* + - Set `` based on the answer + - Announce: *"🤖 @copilot joined the team as Coding Agent. I'll route suitable issues to it based on the capability profile."* + +2. **Post-init via CLI** — `npx github:bradygaster/squad copilot` (or `copilot --auto-assign`) + +Once @copilot is on the roster, the coordinator includes it in triage and routing decisions. + +### How the Coding Agent Differs + +| Aspect | AI Agent | Human Member | Coding Agent (@copilot) | +|--------|----------|-------------|------------------------| +| **Badge** | ✅ Active | 👤 Human | 🤖 Coding Agent | +| **Casting** | Named from universe | Real name | Always "@copilot" | +| **Charter** | Full charter.md | No charter | No charter — uses `copilot-instructions.md` | +| **Spawnable** | Yes (via `task` tool) | No — coordinator pauses | No — works via issue assignment | +| **History** | Writes to history.md | No history file | No history file | +| **Routing** | Auto-routed by coordinator | Coordinator presents, waits | Routed via issue labels + GitHub assignment | +| **Work style** | Synchronous in session | Asynchronous (human pace) | Asynchronous (creates branch + PR) | +| **Scope** | Full domain per charter | Role-based | Capability profile (three tiers) | + +### @copilot Roster Format + +When `npx github:bradygaster/squad copilot` is run, the CLI adds this to `team.md`: + +```markdown + + +| Name | Role | Charter | Status | +|------|------|---------|--------| +| @copilot | Coding Agent | — | 🤖 Coding Agent | + +### Capabilities + +🟢 Good fit: Bug fixes, test coverage, lint fixes, dependency updates, small features, scaffolding, doc fixes +🟡 Needs review: Medium features with clear specs, refactoring with tests, API additions +🔴 Not suitable: Architecture decisions, multi-system design, ambiguous requirements, security-critical changes ``` + +The CLI also adds routing entries to `.ai-team/routing.md` and copies `.github/copilot-instructions.md`. + +### Capability Profile + +The capability profile lives in `team.md` under the @copilot entry. It defines three tiers: + +- **🟢 Good fit** — The coding agent can handle these autonomously. If auto-assign is enabled, these issues get assigned to `@copilot` automatically. +- **🟡 Needs review** — The coding agent can do the work, but a squad member should review the PR before merging. The triage comment and PR description flag this. +- **🔴 Not suitable** — These should go to a squad member. If @copilot is accidentally assigned one, it should comment on the issue requesting reassignment. + +The profile is a living document. The Lead can suggest updates based on what @copilot handles well or poorly: +- *"@copilot nailed that refactoring — I'm bumping refactoring to 🟢 good fit."* +- *"That API change needed too much context — keeping multi-endpoint work at 🔴."* + +### Auto-Assign Behavior + +When `` is set in `team.md`: + +1. The `squad-issue-assign` workflow checks if the issue matches @copilot's capability profile. +2. If it's a 🟢 good fit, `@copilot` is added as the issue assignee — the coding agent picks it up automatically. +3. If it's a 🟡 needs review, `@copilot` is assigned but the comment flags that PR review is needed. +4. If it's a 🔴 not suitable or no match, the issue is NOT assigned to @copilot — it follows normal squad routing. + +When auto-assign is disabled, the workflow still comments with instructions but doesn't assign @copilot. Users can manually assign @copilot on any issue. + +### Lead Triage and @copilot + +During triage (in-session or via the `squad-triage` workflow), the Lead evaluates each issue against @copilot's capability profile: + +1. **Good fit?** → Suggest routing to @copilot: *"🤖 This looks like a good @copilot task — it's a straightforward bug fix with clear repro steps."* +2. **Needs review?** → Route to @copilot with a flag: *"🤖 Routing to @copilot, but this is a medium-complexity feature — {ReviewerName} should review the PR."* +3. **Not suitable?** → Route to squad member as normal, but note why: *"This needs architectural thinking — routing to {LeadName} instead of @copilot."* + +The Lead can also **reassign**: +- If a squad member has an issue that looks more suitable for @copilot: *"This test coverage task could go to @copilot — want me to reassign?"* +- If @copilot has an issue that's more complex than expected: *"@copilot might struggle with this — suggesting we reassign to {MemberName}."* + +### Routing to @copilot + +When work routes to @copilot, the coordinator does NOT spawn an agent. Instead: + +1. **Present the routing decision:** + ``` + 🤖 Routing to @copilot — {description of what's needed}. + Capability match: {🟢 Good fit / 🟡 Needs review} + + The coding agent will pick this up when the issue is assigned. + ``` + +2. **If auto-assign is enabled**, the workflow handles assignment automatically. + +3. **If auto-assign is disabled**, tell the user: + ``` + Assign @copilot on the issue to start autonomous work, or say "assign it" and I'll note it for you. + ``` + +4. **Non-dependent work continues immediately.** Like human blocks, @copilot routing does not serialize the rest of the team. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 000000000..7bfa98a32 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,46 @@ +# Copilot Coding Agent — Squad Instructions + +You are working on a project that uses **Squad**, an AI team framework. When picking up issues autonomously, follow these guidelines. + +## Team Context + +Before starting work on any issue: + +1. Read `.ai-team/team.md` for the team roster, member roles, and your capability profile. +2. Read `.ai-team/routing.md` for work routing rules. +3. If the issue has a `squad:{member}` label, read that member's charter at `.ai-team/agents/{member}/charter.md` to understand their domain expertise and coding style — work in their voice. + +## Capability Self-Check + +Before starting work, check your capability profile in `.ai-team/team.md` under the **Coding Agent → Capabilities** section. + +- **🟢 Good fit** — proceed autonomously. +- **🟡 Needs review** — proceed, but note in the PR description that a squad member should review. +- **🔴 Not suitable** — do NOT start work. Instead, comment on the issue: + ``` + 🤖 This issue doesn't match my capability profile (reason: {why}). Suggesting reassignment to a squad member. + ``` + +## Branch Naming + +Use the squad branch convention: +``` +squad/{issue-number}-{kebab-case-slug} +``` +Example: `squad/42-fix-login-validation` + +## PR Guidelines + +When opening a PR: +- Reference the issue: `Closes #{issue-number}` +- If the issue had a `squad:{member}` label, mention the member: `Working as {member} ({role})` +- If this is a 🟡 needs-review task, add to the PR description: `⚠️ This task was flagged as "needs review" — please have a squad member review before merging.` +- Follow any project conventions in `.ai-team/decisions.md` + +## Decisions + +If you make a decision that affects other team members, write it to: +``` +.ai-team/decisions/inbox/copilot-{brief-slug}.md +``` +The Scribe will merge it into the shared decisions file. diff --git a/.github/workflows/squad-issue-assign.yml b/.github/workflows/squad-issue-assign.yml new file mode 100644 index 000000000..01632a5d8 --- /dev/null +++ b/.github/workflows/squad-issue-assign.yml @@ -0,0 +1,133 @@ +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 @copilot auto-assign is enabled + const copilotAutoAssign = content.includes(''); + + // 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; + } + + // Auto-assign @copilot if enabled and this is a copilot assignment + if (isCopilotAssignment && copilotAutoAssign) { + try { + await github.rest.issues.addAssignees({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + assignees: ['copilot'] + }); + core.info(`Auto-assigned @copilot to issue #${issue.number}`); + } catch (err) { + core.warning(`Could not auto-assign @copilot: ${err.message}`); + } + } + + // Post assignment acknowledgment + let comment; + if (isCopilotAssignment) { + const autoAssignNote = copilotAutoAssign + ? `@copilot has been assigned and will pick this up automatically.` + : `Assign @copilot on this issue to start autonomous work.`; + + comment = [ + `### 🤖 Routed to @copilot (Coding Agent)`, + '', + `**Issue:** #${issue.number} — ${issue.title}`, + '', + autoAssignNote, + '', + `> 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})`); diff --git a/.github/workflows/squad-triage.yml b/.github/workflows/squad-triage.yml new file mode 100644 index 000000000..fb1c3e19c --- /dev/null +++ b/.github/workflows/squad-triage.yml @@ -0,0 +1,246 @@ +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] + }); + + // 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..67b730732 --- /dev/null +++ b/.github/workflows/sync-squad-labels.yml @@ -0,0 +1,122 @@ +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'; + + // 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' + }); + } + + // 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/README.md b/README.md index e58422c01..ab55c36b0 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,7 @@ The Coordinator enforces this. No self-review of rejected work. - [**GitHub Issues Mode**](docs/features/github-issues.md) — Issue-driven development with `gh` CLI integration - [**PRD Mode**](docs/features/prd-mode.md) — Product requirements decomposition into work items - [**Human Team Members**](docs/features/human-team-members.md) — Mixed AI/human teams with routing +- [**Copilot Coding Agent**](docs/features/copilot-coding-agent.md) — Add @copilot for autonomous issue work with capability-based routing - [**Skills System**](docs/features/skills.md) — Earned knowledge with confidence lifecycle - [**Tiered Response Modes**](docs/features/response-modes.md) — Direct/Lightweight/Standard/Full response depth - [**Smart Upgrade**](docs/scenarios/upgrading.md) — Version-aware upgrades with migrations @@ -256,6 +257,7 @@ Labels are auto-created from your team roster via the `sync-squad-labels` workfl |-------|---------| | `squad` | Triage inbox — Lead reviews and assigns | | `squad:{name}` | Assigned to a specific squad member | +| `squad:copilot` | Assigned to @copilot for autonomous coding agent work | Labels sync automatically when `.ai-team/team.md` changes, or you can trigger the workflow manually. @@ -273,6 +275,7 @@ Squad installs three GitHub Actions workflows: - GitHub Actions must be enabled on the repository - The `GITHUB_TOKEN` needs `issues: write` and `contents: read` permissions +- For @copilot auto-assign: a classic PAT with `repo` scope stored as `COPILOT_ASSIGN_TOKEN` repo secret (see [setup guide](docs/features/copilot-coding-agent.md#copilot_assign_token-required-for-auto-assign)) - For automated issue work: [Copilot coding agent](https://docs.github.com/en/copilot) must be enabled on the repo ### Session Awareness diff --git a/docs/features/copilot-coding-agent.md b/docs/features/copilot-coding-agent.md new file mode 100644 index 000000000..6c97fd1a8 --- /dev/null +++ b/docs/features/copilot-coding-agent.md @@ -0,0 +1,183 @@ +# Copilot Coding Agent (@copilot) + +Add the GitHub Copilot coding agent to your Squad as an autonomous team member. It picks up issues, creates branches, and opens PRs — all without a Copilot chat session. + +--- + +## Prerequisites + +Before enabling @copilot on your Squad, ensure: + +1. **Copilot coding agent is enabled** on the repository (Settings → Copilot → Coding agent) +2. **`copilot-setup-steps.yml`** exists in `.github/` (defines the agent's environment) +3. **GitHub Actions** are enabled on the repository + +--- + +## Quick Start + +```bash +# 1. Add @copilot to your squad with auto-assign +npx github:bradygaster/squad copilot --auto-assign + +# 2. Create a classic PAT for auto-assignment (see below) +# https://github.com/settings/tokens/new → check "repo" scope + +# 3. Add the PAT as a repo secret +gh secret set COPILOT_ASSIGN_TOKEN + +# 4. Commit and push +git add .github/ .ai-team/ && git commit -m "feat: add copilot to squad" && git push + +# 5. Test — label any issue with squad:copilot +``` + +--- + +## Enabling @copilot + +### In conversation (recommended) + +Say something like: +- **"I want to add copilot to the squad"** +- **"hire copilot to the squad"** +- **"add team member copilot"** + +The coordinator will add @copilot to the roster and ask about auto-assign. + +> **Note:** If your project has features named "copilot" (e.g., a Copilot extension), the coordinator may misinterpret the phrase as project work. Use the CLI fallback in that case. + +### During team setup (new projects) + +Squad asks if you want to include the coding agent during `init`. Say **yes** and it's added to the roster with a default capability profile. + +### Via CLI (fallback) + +```bash +# Add @copilot to the team +npx github:bradygaster/squad copilot + +# Add with auto-assign enabled +npx github:bradygaster/squad copilot --auto-assign + +# Remove from the team +npx github:bradygaster/squad copilot --off +``` + +--- + +## COPILOT_ASSIGN_TOKEN (required for auto-assign) + +The `squad-issue-assign` workflow needs a **classic Personal Access Token** to assign `copilot-swe-agent[bot]` to issues. The default `GITHUB_TOKEN` cannot do this. + +### Create the token + +1. Go to https://github.com/settings/tokens/new +2. **Note:** `squad-copilot-assign` +3. **Expiration:** 90 days (or your preference) +4. **Scopes:** check **`repo`** (full control of private repositories) +5. Click **Generate token** + +### Add as repo secret + +```bash +gh secret set COPILOT_ASSIGN_TOKEN --repo owner/repo +``` + +> **Why a classic PAT?** Fine-grained PATs return `403 Resource not accessible` for this endpoint. The REST API for assigning `copilot-swe-agent[bot]` requires a classic PAT with `repo` scope. The `GITHUB_TOKEN` silently ignores the assignment. + +--- + +## How @copilot Differs from Other Members + +| | AI Agent | Human Member | @copilot | +|---|----------|-------------|----------| +| Badge | ✅ Active | 👤 Human | 🤖 Coding Agent | +| Name | Cast from universe | Real name | Always "@copilot" | +| Charter | ✅ | ❌ | ❌ (uses `copilot-instructions.md`) | +| Works in session | ✅ | ❌ | ❌ (asynchronous via issue assignment) | +| Spawned by coordinator | ✅ | ❌ | ❌ | +| Creates PRs | Via session commands | Outside Squad | Autonomously | + +--- + +## Capability Profile + +The capability profile in `team.md` defines what @copilot should and shouldn't handle: + +| Tier | Meaning | Examples | +|------|---------|----------| +| 🟢 **Good fit** | Route automatically | Bug fixes, test coverage, lint fixes, dependency updates, small features, docs | +| 🟡 **Needs review** | Route to @copilot but flag for PR review | Medium features with specs, refactoring with tests, API additions | +| 🔴 **Not suitable** | Route to a squad member instead | Architecture, multi-system design, security-critical, ambiguous requirements | + +The profile is editable. The Lead can suggest updates based on experience: + +``` +> @copilot nailed that refactoring — bump refactoring to good fit +> That API change needed too much context — keep multi-endpoint work at not suitable +``` + +--- + +## Auto-Assign Flow + +When the `squad:copilot` label is added to an issue: + +1. **Step 1** — Workflow posts a routing comment (uses `GITHUB_TOKEN`) +2. **Step 2** — Workflow assigns `copilot-swe-agent[bot]` to the issue (uses `COPILOT_ASSIGN_TOKEN`) +3. **Step 3** — Coding agent picks up the issue, creates a `copilot/*` branch, and opens a draft PR + +The workflow automatically detects the repo's default branch (`main`, `master`, etc.). + +--- + +## Lead Triage + +The Lead evaluates every issue against @copilot's capability profile during triage: + +1. **Good fit?** → Routes to @copilot with reasoning +2. **Needs review?** → Routes to @copilot, flags for squad member PR review +3. **Not suitable?** → Routes to the right squad member, explains why not @copilot + +The Lead can also suggest reassignment in either direction: + +``` +> This test coverage task could go to @copilot — want me to reassign? +> @copilot might struggle with this — suggesting we reassign to Ripley. +``` + +--- + +## Labels + +When @copilot is on the team, the `sync-squad-labels` workflow creates: + +| Label | Color | Purpose | +|-------|-------|---------| +| `squad:copilot` | 🟢 Green | Assigned to @copilot for autonomous work | + +This works alongside the existing `squad` (triage) and `squad:{member}` labels. + +--- + +## copilot-instructions.md + +The `.github/copilot-instructions.md` file gives the coding agent context about your Squad when it works autonomously. It tells @copilot to: + +- Read `team.md` for roster and capability profile +- Read `routing.md` for work routing rules +- Check its capability profile before starting (and request reassignment if the issue doesn't match) +- Follow the `squad/{issue}-{slug}` branch naming convention +- Write decisions to the inbox for the Scribe to merge + +This file is **upgraded automatically** when you run `squad upgrade` and `@copilot` is on your team — even if Squad is already up to date. If @copilot is not enabled, the file is left untouched. + +--- + +## Tips + +- Start conservative with the capability profile and expand as you see what @copilot handles well. +- Use auto-assign for repos where you want fully autonomous issue processing. +- The coding agent works great alongside [issue-driven development](../scenarios/issue-driven-dev.md) — label issues `squad` and the Lead + @copilot handle the rest. +- @copilot's PRs go through normal review — treat them like any team member's work. diff --git a/docs/scenarios/upgrading.md b/docs/scenarios/upgrading.md index 2b4946155..2a8a41aab 100644 --- a/docs/scenarios/upgrading.md +++ b/docs/scenarios/upgrading.md @@ -33,6 +33,8 @@ That's it. |------|----------|-------| | `.github/agents/squad.agent.md` | ✅ Yes | Overwritten with latest coordinator logic | | `.ai-team-templates/` | ✅ Yes | Overwritten with latest templates | +| `.github/workflows/squad-*.yml` | ✅ Yes | Overwritten with latest squad workflows | +| `.github/copilot-instructions.md` | ⚡ Conditional | Updated only if @copilot is enabled on the team | | `.ai-team/` | ❌ Never | Your team's knowledge, decisions, casting state, skills | Squad-owned files (`squad.agent.md` and `.ai-team-templates/`) are replaced entirely. Don't put custom changes in them — they'll be lost on upgrade. diff --git a/index.js b/index.js index 89aff5386..325050a64 100644 --- a/index.js +++ b/index.js @@ -37,6 +37,8 @@ if (cmd === '--help' || cmd === '-h' || cmd === 'help') { console.log(` ${BOLD}upgrade${RESET} Update Squad-owned files to latest version`); console.log(` Overwrites: squad.agent.md, .ai-team-templates/`); console.log(` Never touches: .ai-team/ (your team state)`); + console.log(` ${BOLD}copilot${RESET} Add/remove the Copilot coding agent (@copilot)`); + console.log(` Usage: copilot [--off] [--auto-assign]`); console.log(` ${BOLD}export${RESET} Export squad to a portable JSON snapshot`); console.log(` Default: squad-export.json (use --out to override)`); console.log(` ${BOLD}import${RESET} Import squad from an export file`); @@ -64,6 +66,124 @@ function copyRecursive(src, target) { } } +// --- Copilot subcommand --- +if (cmd === 'copilot') { + const teamMd = path.join(dest, '.ai-team', 'team.md'); + if (!fs.existsSync(teamMd)) { + fatal('No squad found — run init first, then add the copilot agent.'); + } + + const isOff = process.argv.includes('--off'); + const autoAssign = process.argv.includes('--auto-assign'); + let content = fs.readFileSync(teamMd, 'utf8'); + const hasCopilot = content.includes('🤖 Coding Agent'); + + if (isOff) { + if (!hasCopilot) { + console.log(`${DIM}Copilot coding agent is not on the team — nothing to remove${RESET}`); + process.exit(0); + } + // Remove the Coding Agent section + content = content.replace(/\n## Coding Agent\n[\s\S]*?(?=\n## |\n*$)/, ''); + fs.writeFileSync(teamMd, content); + console.log(`${GREEN}✓${RESET} Removed @copilot from the team roster`); + + // Remove copilot-instructions.md + const instructionsDest = path.join(dest, '.github', 'copilot-instructions.md'); + if (fs.existsSync(instructionsDest)) { + fs.unlinkSync(instructionsDest); + console.log(`${GREEN}✓${RESET} Removed .github/copilot-instructions.md`); + } + process.exit(0); + } + + // Adding copilot + if (hasCopilot) { + // Update auto-assign if requested + if (autoAssign) { + content = content.replace('', ''); + fs.writeFileSync(teamMd, content); + console.log(`${GREEN}✓${RESET} Enabled @copilot auto-assign`); + } else { + console.log(`${DIM}@copilot is already on the team${RESET}`); + } + process.exit(0); + } + + // Add Coding Agent section before Project Context + const autoAssignValue = autoAssign ? 'true' : 'false'; + const copilotSection = ` +## Coding Agent + + + +| Name | Role | Charter | Status | +|------|------|---------|--------| +| @copilot | Coding Agent | — | 🤖 Coding Agent | + +### Capabilities + +**🟢 Good fit — auto-route when enabled:** +- Bug fixes with clear reproduction steps +- Test coverage (adding missing tests, fixing flaky tests) +- Lint/format fixes and code style cleanup +- Dependency updates and version bumps +- Small isolated features with clear specs +- Boilerplate/scaffolding generation +- Documentation fixes and README updates + +**🟡 Needs review — route to @copilot but flag for squad member PR review:** +- Medium features with clear specs and acceptance criteria +- Refactoring with existing test coverage +- API endpoint additions following established patterns +- Migration scripts with well-defined schemas + +**🔴 Not suitable — route to squad member instead:** +- Architecture decisions and system design +- Multi-system integration requiring coordination +- Ambiguous requirements needing clarification +- Security-critical changes (auth, encryption, access control) +- Performance-critical paths requiring benchmarking +- Changes requiring cross-team discussion + +`; + + // Insert before "## Project Context" if it exists, otherwise append + if (content.includes('## Project Context')) { + content = content.replace('## Project Context', copilotSection + '## Project Context'); + } else { + content = content.trimEnd() + '\n' + copilotSection; + } + + fs.writeFileSync(teamMd, content); + console.log(`${GREEN}✓${RESET} Added @copilot (Coding Agent) to team roster`); + if (autoAssign) { + console.log(`${GREEN}✓${RESET} Auto-assign enabled — squad-labeled issues will be assigned to @copilot`); + } + + // Copy copilot-instructions.md + const instructionsSrc = path.join(root, 'templates', 'copilot-instructions.md'); + const instructionsDest = path.join(dest, '.github', 'copilot-instructions.md'); + if (fs.existsSync(instructionsSrc)) { + fs.mkdirSync(path.dirname(instructionsDest), { recursive: true }); + fs.copyFileSync(instructionsSrc, instructionsDest); + console.log(`${GREEN}✓${RESET} .github/copilot-instructions.md`); + } + + console.log(); + console.log(`${BOLD}@copilot is on the team.${RESET}`); + console.log(`The coding agent will pick up issues matching its capability profile.`); + if (!autoAssign) { + console.log(`Run with ${BOLD}--auto-assign${RESET} to auto-assign @copilot on squad-labeled issues.`); + } + console.log(); + console.log(`${BOLD}Required:${RESET} Add a classic PAT (repo scope) as a repo secret for auto-assignment:`); + console.log(` 1. Create token: ${DIM}https://github.com/settings/tokens/new${RESET}`); + console.log(` 2. Set secret: ${DIM}gh secret set COPILOT_ASSIGN_TOKEN${RESET}`); + console.log(); + process.exit(0); +} + // --- Export subcommand --- if (cmd === 'export') { const teamMd = path.join(dest, '.ai-team', 'team.md'); @@ -415,6 +535,40 @@ if (isUpgrade) { if (isAlreadyCurrent) { // Still run missing migrations in case a prior upgrade was interrupted runMigrations(dest, oldVersion); + + // Even if already current, update copilot-instructions.md if @copilot is enabled + const copilotInstructionsSrc = path.join(root, 'templates', 'copilot-instructions.md'); + const copilotInstructionsDest = path.join(dest, '.github', 'copilot-instructions.md'); + const teamMd = path.join(dest, '.ai-team', 'team.md'); + const copilotEnabled = fs.existsSync(teamMd) + && fs.readFileSync(teamMd, 'utf8').includes('🤖 Coding Agent'); + if (copilotEnabled && fs.existsSync(copilotInstructionsSrc)) { + fs.mkdirSync(path.dirname(copilotInstructionsDest), { recursive: true }); + fs.copyFileSync(copilotInstructionsSrc, copilotInstructionsDest); + console.log(`${GREEN}✓${RESET} ${BOLD}upgraded${RESET} .github/copilot-instructions.md`); + } + + // Always update squad-owned workflows even when version matches + const workflowsSrcEarly = path.join(root, 'templates', 'workflows'); + const workflowsDestEarly = path.join(dest, '.github', 'workflows'); + if (fs.existsSync(workflowsSrcEarly) && fs.statSync(workflowsSrcEarly).isDirectory()) { + const wfFiles = fs.readdirSync(workflowsSrcEarly).filter(f => f.endsWith('.yml')); + fs.mkdirSync(workflowsDestEarly, { recursive: true }); + for (const file of wfFiles) { + fs.copyFileSync(path.join(workflowsSrcEarly, file), path.join(workflowsDestEarly, file)); + } + console.log(`${GREEN}✓${RESET} ${BOLD}upgraded${RESET} squad workflows (${wfFiles.length} files)`); + } + + // Always refresh squad.agent.md (may have changed on same version via branch) + try { + fs.mkdirSync(path.dirname(agentDest), { recursive: true }); + fs.copyFileSync(agentSrc, agentDest); + stampVersion(agentDest); + } catch (err) { + // Non-fatal in early-exit path + } + console.log(`${GREEN}✓${RESET} Already up to date (v${pkg.version})`); process.exit(0); } @@ -475,6 +629,21 @@ if (!fs.existsSync(ceremoniesDest)) { console.log(`${DIM}ceremonies.md already exists — skipping${RESET}`); } +// copilot-instructions.md — managed by `squad copilot` subcommand +// On upgrade, update if @copilot is enabled on the team +const copilotInstructionsSrc = path.join(root, 'templates', 'copilot-instructions.md'); +const copilotInstructionsDest = path.join(dest, '.github', 'copilot-instructions.md'); +if (isUpgrade) { + const teamMd = path.join(dest, '.ai-team', 'team.md'); + const copilotEnabled = fs.existsSync(teamMd) + && fs.readFileSync(teamMd, 'utf8').includes('🤖 Coding Agent'); + if (copilotEnabled && fs.existsSync(copilotInstructionsSrc)) { + fs.mkdirSync(path.dirname(copilotInstructionsDest), { recursive: true }); + fs.copyFileSync(copilotInstructionsSrc, copilotInstructionsDest); + console.log(`${GREEN}✓${RESET} ${BOLD}upgraded${RESET} .github/copilot-instructions.md`); + } +} + // Append merge=union rules for append-only .ai-team/ files const gitattributes = path.join(dest, '.gitattributes'); const unionRules = [ @@ -546,6 +715,15 @@ if (fs.existsSync(workflowsSrc) && fs.statSync(workflowsSrc).isDirectory()) { if (isUpgrade) { console.log(`\n${DIM}.ai-team/ untouched — your team state is safe${RESET}`); + + // Hint about new features available after upgrade + const teamMd = path.join(dest, '.ai-team', 'team.md'); + const copilotEnabled = fs.existsSync(teamMd) + && fs.readFileSync(teamMd, 'utf8').includes('🤖 Coding Agent'); + if (!copilotEnabled) { + console.log(`\n${BOLD}New:${RESET} @copilot coding agent support is now available.`); + console.log(` Run ${BOLD}npx squad copilot${RESET} to add it to your team.`); + } } console.log(); diff --git a/templates/copilot-instructions.md b/templates/copilot-instructions.md new file mode 100644 index 000000000..7bfa98a32 --- /dev/null +++ b/templates/copilot-instructions.md @@ -0,0 +1,46 @@ +# Copilot Coding Agent — Squad Instructions + +You are working on a project that uses **Squad**, an AI team framework. When picking up issues autonomously, follow these guidelines. + +## Team Context + +Before starting work on any issue: + +1. Read `.ai-team/team.md` for the team roster, member roles, and your capability profile. +2. Read `.ai-team/routing.md` for work routing rules. +3. If the issue has a `squad:{member}` label, read that member's charter at `.ai-team/agents/{member}/charter.md` to understand their domain expertise and coding style — work in their voice. + +## Capability Self-Check + +Before starting work, check your capability profile in `.ai-team/team.md` under the **Coding Agent → Capabilities** section. + +- **🟢 Good fit** — proceed autonomously. +- **🟡 Needs review** — proceed, but note in the PR description that a squad member should review. +- **🔴 Not suitable** — do NOT start work. Instead, comment on the issue: + ``` + 🤖 This issue doesn't match my capability profile (reason: {why}). Suggesting reassignment to a squad member. + ``` + +## Branch Naming + +Use the squad branch convention: +``` +squad/{issue-number}-{kebab-case-slug} +``` +Example: `squad/42-fix-login-validation` + +## PR Guidelines + +When opening a PR: +- Reference the issue: `Closes #{issue-number}` +- If the issue had a `squad:{member}` label, mention the member: `Working as {member} ({role})` +- If this is a 🟡 needs-review task, add to the PR description: `⚠️ This task was flagged as "needs review" — please have a squad member review before merging.` +- Follow any project conventions in `.ai-team/decisions.md` + +## Decisions + +If you make a decision that affects other team members, write it to: +``` +.ai-team/decisions/inbox/copilot-{brief-slug}.md +``` +The Scribe will merge it into the shared decisions file. diff --git a/templates/roster.md b/templates/roster.md index d2c0e340c..9e2a8d5cd 100644 --- a/templates/roster.md +++ b/templates/roster.md @@ -18,6 +18,39 @@ | {Name} | {Role} | `.ai-team/agents/{name}/charter.md` | ✅ Active | | Scribe | Session Logger | `.ai-team/agents/scribe/charter.md` | 📋 Silent | +## Coding Agent + + + +| Name | Role | Charter | Status | +|------|------|---------|--------| +| @copilot | Coding Agent | — | 🤖 Coding Agent | + +### Capabilities + +**🟢 Good fit — auto-route when enabled:** +- Bug fixes with clear reproduction steps +- Test coverage (adding missing tests, fixing flaky tests) +- Lint/format fixes and code style cleanup +- Dependency updates and version bumps +- Small isolated features with clear specs +- Boilerplate/scaffolding generation +- Documentation fixes and README updates + +**🟡 Needs review — route to @copilot but flag for squad member PR review:** +- Medium features with clear specs and acceptance criteria +- Refactoring with existing test coverage +- API endpoint additions following established patterns +- Migration scripts with well-defined schemas + +**🔴 Not suitable — route to squad member instead:** +- Architecture decisions and system design +- Multi-system integration requiring coordination +- Ambiguous requirements needing clarification +- Security-critical changes (auth, encryption, access control) +- Performance-critical paths requiring benchmarking +- Changes requiring cross-team discussion + ## Project Context - **Owner:** {user name} ({user email}) diff --git a/templates/routing.md b/templates/routing.md index 65e0e9f45..490b128e1 100644 --- a/templates/routing.md +++ b/templates/routing.md @@ -12,21 +12,35 @@ How to decide who handles what. | Code review | {Name} | Review PRs, check quality, suggest improvements | | Testing | {Name} | Write tests, find edge cases, verify fixes | | Scope & priorities | {Name} | What to build next, trade-offs, decisions | +| Async issue work (bugs, tests, small features) | @copilot 🤖 | Well-defined tasks matching capability profile | | Session logging | Scribe | Automatic — never needs routing | ## Issue Routing | Label | Action | Who | |-------|--------|-----| -| `squad` | Triage: analyze issue, assign `squad:{member}` label | Lead | +| `squad` | Triage: analyze issue, evaluate @copilot fit, assign `squad:{member}` label | Lead | | `squad:{name}` | Pick up issue and complete the work | Named member | +| `squad:copilot` | Assign to @copilot for autonomous work (if enabled) | @copilot 🤖 | ### How Issue Assignment Works -1. When a GitHub issue gets the `squad` label, the **Lead** triages it — analyzing content, assigning the right `squad:{member}` label, and commenting with triage notes. -2. When a `squad:{member}` label is applied, that member picks up the issue in their next session. -3. Members can reassign by removing their label and adding another member's label. -4. The `squad` label is the "inbox" — untriaged issues waiting for Lead review. +1. When a GitHub issue gets the `squad` label, the **Lead** triages it — analyzing content, evaluating @copilot's capability profile, assigning the right `squad:{member}` label, and commenting with triage notes. +2. **@copilot evaluation:** The Lead checks if the issue matches @copilot's capability profile (🟢 good fit / 🟡 needs review / 🔴 not suitable). If it's a good fit, the Lead may route to `squad:copilot` instead of a squad member. +3. When a `squad:{member}` label is applied, that member picks up the issue in their next session. +4. When `squad:copilot` is applied and auto-assign is enabled, `@copilot` is assigned on the issue and picks it up autonomously. +5. Members can reassign by removing their label and adding another member's label. +6. The `squad` label is the "inbox" — untriaged issues waiting for Lead review. + +### Lead Triage Guidance for @copilot + +When triaging, the Lead should ask: + +1. **Is this well-defined?** Clear title, reproduction steps or acceptance criteria, bounded scope → likely 🟢 +2. **Does it follow existing patterns?** Adding a test, fixing a known bug, updating a dependency → likely 🟢 +3. **Does it need design judgment?** Architecture, API design, UX decisions → likely 🔴 +4. **Is it security-sensitive?** Auth, encryption, access control → always 🔴 +5. **Is it medium complexity with specs?** Feature with clear requirements, refactoring with tests → likely 🟡 ## Rules @@ -37,3 +51,4 @@ How to decide who handles what. 5. **"Team, ..." → fan-out.** Spawn all relevant agents in parallel as `mode: "background"`. 6. **Anticipate downstream work.** If a feature is being built, spawn the tester to write test cases from requirements simultaneously. 7. **Issue-labeled work** — when a `squad:{member}` label is applied to an issue, route to that member. The Lead handles all `squad` (base label) triage. +8. **@copilot routing** — when evaluating issues, check @copilot's capability profile in `team.md`. Route 🟢 good-fit tasks to `squad:copilot`. Flag 🟡 needs-review tasks for PR review. Keep 🔴 not-suitable tasks with squad members. diff --git a/templates/workflows/squad-issue-assign.yml b/templates/workflows/squad-issue-assign.yml index cb2ed31be..b4970c512 100644 --- a/templates/workflows/squad-issue-assign.yml +++ b/templates/workflows/squad-issue-assign.yml @@ -37,22 +37,29 @@ jobs: 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; - 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] }; + 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; + } + } } } @@ -68,17 +75,31 @@ jobs: } // Post assignment acknowledgment - const 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'); + 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, @@ -88,3 +109,50 @@ jobs: }); 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/templates/workflows/squad-triage.yml b/templates/workflows/squad-triage.yml index c06f48fd9..fb1c3e19c 100644 --- a/templates/workflows/squad-triage.yml +++ b/templates/workflows/squad-triage.yml @@ -32,6 +32,38 @@ jobs: 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) { @@ -82,42 +114,65 @@ jobs: let assignedMember = null; let triageReason = ''; - - // Simple keyword-based routing as a baseline - 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; + 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 ((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; + } + + // 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; + } } } @@ -127,7 +182,8 @@ jobs: triageReason = 'No specific domain match — assigned to Lead for further analysis'; } - const assignLabel = `squad:${assignedMember.name.toLowerCase()}`; + const isCopilot = assignedMember.name === '@copilot'; + const assignLabel = isCopilot ? 'squad:copilot' : `squad:${assignedMember.name.toLowerCase()}`; // Add the member-specific label await github.rest.issues.addLabels({ @@ -137,6 +193,30 @@ jobs: labels: [assignLabel] }); + // 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})`, @@ -144,14 +224,17 @@ jobs: `**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.`, - ].join('\n'); + ].filter(Boolean).join('\n'); await github.rest.issues.createComment({ owner: context.repo.owner, diff --git a/templates/workflows/sync-squad-labels.yml b/templates/workflows/sync-squad-labels.yml index 58fe5c584..67b730732 100644 --- a/templates/workflows/sync-squad-labels.yml +++ b/templates/workflows/sync-squad-labels.yml @@ -55,9 +55,13 @@ jobs: 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'; // Ensure the base "squad" triage label exists const labels = [ @@ -72,6 +76,15 @@ jobs: }); } + // 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' + }); + } + // Sync labels (create or update) for (const label of labels) { try { diff --git a/test/index.test.js b/test/index.test.js index 5e918dca4..48812a8a6 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -543,3 +543,118 @@ describe('edge cases', () => { assert.equal(result.exitCode, 0, 're-init should exit 0'); }); }); + +describe('copilot subcommand', () => { + let tmpDir; + + function initWithTeam(dir) { + runInit(dir); + // Init creates directories but not team.md (coordinator does that) + // Create a minimal team.md so the copilot subcommand can proceed + const teamDir = path.join(dir, '.ai-team'); + fs.mkdirSync(teamDir, { recursive: true }); + fs.writeFileSync(path.join(teamDir, 'team.md'), '# The Squad\n\n## Project Context\n\nNo context yet.\n'); + } + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'squad-copilot-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('fails when no squad exists', () => { + const { status } = runCmdStatus(tmpDir, 'copilot'); + assert.notEqual(status, 0, 'should fail without a squad'); + }); + + it('adds @copilot to team.md with capability profile', () => { + initWithTeam(tmpDir); + runCmd(tmpDir, 'copilot'); + const teamMd = fs.readFileSync(path.join(tmpDir, '.ai-team', 'team.md'), 'utf8'); + assert.ok(teamMd.includes('🤖 Coding Agent'), 'should have coding agent badge'); + assert.ok(teamMd.includes('@copilot'), 'should have @copilot name'); + assert.ok(teamMd.includes('🟢 Good fit'), 'should have capability profile'); + assert.ok(teamMd.includes('🟡 Needs review'), 'should have needs-review tier'); + assert.ok(teamMd.includes('🔴 Not suitable'), 'should have not-suitable tier'); + }); + + it('creates .github/copilot-instructions.md', () => { + initWithTeam(tmpDir); + runCmd(tmpDir, 'copilot'); + const dest = path.join(tmpDir, '.github', 'copilot-instructions.md'); + assert.ok(fs.existsSync(dest), 'copilot-instructions.md should be created'); + const content = fs.readFileSync(dest, 'utf8'); + assert.ok(content.includes('Squad'), 'should reference Squad'); + assert.ok(content.includes('.ai-team/team.md'), 'should reference team.md'); + }); + + it('sets auto-assign when --auto-assign flag is used', () => { + initWithTeam(tmpDir); + runCmd(tmpDir, 'copilot --auto-assign'); + const teamMd = fs.readFileSync(path.join(tmpDir, '.ai-team', 'team.md'), 'utf8'); + assert.ok(teamMd.includes(''), 'should have auto-assign enabled'); + }); + + it('defaults auto-assign to false without flag', () => { + initWithTeam(tmpDir); + runCmd(tmpDir, 'copilot'); + const teamMd = fs.readFileSync(path.join(tmpDir, '.ai-team', 'team.md'), 'utf8'); + assert.ok(teamMd.includes(''), 'should have auto-assign disabled'); + }); + + it('reports already on team if run twice', () => { + initWithTeam(tmpDir); + runCmd(tmpDir, 'copilot'); + const out = runCmd(tmpDir, 'copilot'); + assert.ok(out.includes('already on the team'), 'should note already added'); + }); + + it('enables auto-assign on existing copilot member', () => { + initWithTeam(tmpDir); + runCmd(tmpDir, 'copilot'); + runCmd(tmpDir, 'copilot --auto-assign'); + const teamMd = fs.readFileSync(path.join(tmpDir, '.ai-team', 'team.md'), 'utf8'); + assert.ok(teamMd.includes(''), 'should update to auto-assign'); + }); + + it('removes @copilot with --off flag', () => { + initWithTeam(tmpDir); + runCmd(tmpDir, 'copilot'); + runCmd(tmpDir, 'copilot --off'); + const teamMd = fs.readFileSync(path.join(tmpDir, '.ai-team', 'team.md'), 'utf8'); + assert.ok(!teamMd.includes('🤖 Coding Agent'), 'should remove coding agent'); + }); + + it('does not overwrite copilot-instructions.md on upgrade when @copilot is not enabled', () => { + initWithTeam(tmpDir); + const dest = path.join(tmpDir, '.github', 'copilot-instructions.md'); + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.writeFileSync(dest, 'user customized'); + runCmd(tmpDir, 'upgrade'); + const content = fs.readFileSync(dest, 'utf8'); + assert.equal(content, 'user customized', 'upgrade should not touch copilot-instructions.md when @copilot is not enabled'); + }); + + it('upgrades copilot-instructions.md when @copilot is enabled on the team', () => { + initWithTeam(tmpDir); + runCmd(tmpDir, 'copilot'); + const dest = path.join(tmpDir, '.github', 'copilot-instructions.md'); + fs.writeFileSync(dest, 'old version'); + runCmd(tmpDir, 'upgrade'); + const content = fs.readFileSync(dest, 'utf8'); + assert.notEqual(content, 'old version', 'upgrade should overwrite copilot-instructions.md when @copilot is enabled'); + assert.ok(content.includes('Squad'), 'should contain latest template content'); + }); + + it('copilot-instructions.md content matches source template', () => { + initWithTeam(tmpDir); + runCmd(tmpDir, 'copilot'); + const dest = path.join(tmpDir, '.github', 'copilot-instructions.md'); + const src = path.join(ROOT, 'templates', 'copilot-instructions.md'); + const destContent = fs.readFileSync(dest, 'utf8'); + const srcContent = fs.readFileSync(src, 'utf8'); + assert.equal(destContent, srcContent, 'should copy template exactly'); + }); +});