From ef01c44aed78e430dbc614ee59e89eab076ec44e Mon Sep 17 00:00:00 2001 From: Shayne Boyer Date: Mon, 9 Feb 2026 21:36:17 -0500 Subject: [PATCH] feat: add issue assignment and auto-triage for squad members - Add 3 GitHub Actions workflow templates (sync-squad-labels, squad-triage, squad-issue-assign) - Update index.js to copy workflow files during init/upgrade - Update coordinator prompt with issue awareness at session start - Update routing template with issue routing rules - Update README with issue assignment documentation - Add 5 new tests for workflow copy functionality Closes #4 --- .github/agents/squad.agent.md | 20 +++ README.md | 47 +++++- index.js | 32 ++++ templates/routing.md | 15 ++ templates/workflows/squad-issue-assign.yml | 90 ++++++++++++ templates/workflows/squad-triage.yml | 163 +++++++++++++++++++++ templates/workflows/sync-squad-labels.yml | 109 ++++++++++++++ test/index.test.js | 88 ++++++++++- 8 files changed, 556 insertions(+), 8 deletions(-) create mode 100644 templates/workflows/squad-issue-assign.yml create mode 100644 templates/workflows/squad-triage.yml create mode 100644 templates/workflows/sync-squad-labels.yml diff --git a/.github/agents/squad.agent.md b/.github/agents/squad.agent.md index 226c6c433..7bf843b9b 100644 --- a/.github/agents/squad.agent.md +++ b/.github/agents/squad.agent.md @@ -109,6 +109,26 @@ When triggered: **Casting migration check:** If `.ai-team/team.md` exists but `.ai-team/casting/` does not, perform the migration described in "Casting & Persistent Naming → Migration — Already-Squadified Repos" before proceeding. +### Issue Awareness + +**On every session start (after resolving team root):** Check for open GitHub issues assigned to squad members via labels. Use the GitHub CLI or API to list issues with `squad:*` labels: + +``` +gh issue list --label "squad:{member-name}" --state open --json number,title,labels,body --limit 10 +``` + +For each squad member with assigned issues, note them in the session context. When presenting a catch-up or when the user asks for status, include pending issues: + +``` +📋 Open issues assigned to squad members: + 🔧 {Backend} — #42: Fix auth endpoint timeout (squad:ripley) + ⚛️ {Frontend} — #38: Add dark mode toggle (squad:dallas) +``` + +**Proactive issue pickup:** If a user starts a session and there are open `squad:{member}` issues, mention them: *"Hey {user}, {AgentName} has an open issue — #42: Fix auth endpoint timeout. Want them to pick it up?"* + +**Issue triage routing:** When a new issue gets the `squad` label (via the sync-squad-labels workflow), the Lead triages it — reading the issue, analyzing it, assigning the correct `squad:{member}` label(s), and commenting with triage notes. The Lead can also reassign by swapping labels. + **⚡ Read `.ai-team/team.md` (roster), `.ai-team/routing.md` (routing), and `.ai-team/casting/registry.json` (persistent names) as parallel tool calls in a single turn. Do NOT read these sequentially.** ### Acknowledge Immediately — "Feels Heard" diff --git a/README.md b/README.md index da6a19030..1195f6cfb 100644 --- a/README.md +++ b/README.md @@ -217,6 +217,51 @@ The Coordinator enforces this. No self-review of rejected work. --- +## Issue Assignment & Triage + +Squad integrates with GitHub Issues. Label an issue with `squad` to trigger triage, or assign directly to a member with `squad:{name}`. + +### How It Works + +1. **Label an issue `squad`** — the Lead auto-triages it: reads the issue, determines who should handle it, applies the right `squad:{member}` label, and comments with triage notes. + +2. **`squad:{member}` label applied** — the assigned member picks up the issue in their next Copilot session (or automatically if Copilot coding agent is enabled). + +3. **Reassign** — remove the current `squad:*` label and add a different member's label. + +### Labels + +Labels are auto-created from your team roster via the `sync-squad-labels` workflow: + +| Label | Purpose | +|-------|---------| +| `squad` | Triage inbox — Lead reviews and assigns | +| `squad:{name}` | Assigned to a specific squad member | + +Labels sync automatically when `.ai-team/team.md` changes, or you can trigger the workflow manually. + +### Workflows + +Squad installs three GitHub Actions workflows: + +| Workflow | Trigger | What it does | +|----------|---------|--------------| +| `sync-squad-labels.yml` | Push to `.ai-team/team.md`, manual | Creates/updates `squad:*` labels from roster | +| `squad-triage.yml` | `squad` label added to issue | Lead triages and assigns `squad:{member}` label | +| `squad-issue-assign.yml` | `squad:{member}` label added | Acknowledges assignment, queues for member | + +### Prerequisites + +- GitHub Actions must be enabled on the repository +- The `GITHUB_TOKEN` needs `issues: write` and `contents: read` permissions +- For automated issue work: [Copilot coding agent](https://docs.github.com/en/copilot) must be enabled on the repo + +### Session Awareness + +The coordinator checks for open `squad:{member}` issues at session start and will mention them: *"Hey {user}, {AgentName} has an open issue — #42: Fix auth endpoint timeout. Want them to pick it up?"* + +--- + ## Install ```bash @@ -233,7 +278,7 @@ Already have Squad? Update Squad-owned files to the latest version without touch npx github:bradygaster/squad upgrade ``` -This overwrites `squad.agent.md` and `.ai-team-templates/`. It never touches `.ai-team/` — your team's knowledge, decisions, and casting are safe. +This overwrites `squad.agent.md`, `.ai-team-templates/`, and squad workflow files in `.github/workflows/`. It never touches `.ai-team/` — your team's knowledge, decisions, and casting are safe. --- diff --git a/index.js b/index.js index 9637be275..1211431d9 100644 --- a/index.js +++ b/index.js @@ -167,6 +167,38 @@ if (isUpgrade) { console.log(`${GREEN}✓${RESET} .ai-team-templates/`); } +// Copy workflow templates (Squad-owned — overwrite on upgrade) +const workflowsSrc = path.join(root, 'templates', 'workflows'); +const workflowsDest = path.join(dest, '.github', 'workflows'); + +if (fs.existsSync(workflowsSrc) && fs.statSync(workflowsSrc).isDirectory()) { + const workflowFiles = fs.readdirSync(workflowsSrc).filter(f => f.endsWith('.yml')); + + if (isUpgrade) { + fs.mkdirSync(workflowsDest, { recursive: true }); + for (const file of workflowFiles) { + fs.copyFileSync(path.join(workflowsSrc, file), path.join(workflowsDest, file)); + } + console.log(`${GREEN}✓${RESET} ${BOLD}upgraded${RESET} squad workflow files (${workflowFiles.length} workflows)`); + } else { + fs.mkdirSync(workflowsDest, { recursive: true }); + let copied = 0; + for (const file of workflowFiles) { + const destFile = path.join(workflowsDest, file); + if (fs.existsSync(destFile)) { + console.log(`${DIM}${file} already exists — skipping (run 'upgrade' to update)${RESET}`); + } else { + fs.copyFileSync(path.join(workflowsSrc, file), destFile); + console.log(`${GREEN}✓${RESET} .github/workflows/${file}`); + copied++; + } + } + if (copied === 0 && workflowFiles.length > 0) { + console.log(`${DIM}all squad workflows already exist — skipping${RESET}`); + } + } +} + if (isUpgrade) { console.log(`\n${DIM}.ai-team/ untouched — your team state is safe${RESET}`); } diff --git a/templates/routing.md b/templates/routing.md index cfa4d9828..65e0e9f45 100644 --- a/templates/routing.md +++ b/templates/routing.md @@ -14,6 +14,20 @@ How to decide who handles what. | Scope & priorities | {Name} | What to build next, trade-offs, decisions | | Session logging | Scribe | Automatic — never needs routing | +## Issue Routing + +| Label | Action | Who | +|-------|--------|-----| +| `squad` | Triage: analyze issue, assign `squad:{member}` label | Lead | +| `squad:{name}` | Pick up issue and complete the work | Named member | + +### 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. + ## Rules 1. **Eager by default** — spawn all agents who could usefully start work, including anticipatory downstream work. @@ -22,3 +36,4 @@ How to decide who handles what. 4. **When two agents could handle it**, pick the one whose domain is the primary concern. 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. diff --git a/templates/workflows/squad-issue-assign.yml b/templates/workflows/squad-issue-assign.yml new file mode 100644 index 000000000..cb2ed31be --- /dev/null +++ b/templates/workflows/squad-issue-assign.yml @@ -0,0 +1,90 @@ +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'); + + 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] }; + 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 + 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'); + + 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/templates/workflows/squad-triage.yml b/templates/workflows/squad-triage.yml new file mode 100644 index 000000000..c06f48fd9 --- /dev/null +++ b/templates/workflows/squad-triage.yml @@ -0,0 +1,163 @@ +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'); + + 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 = ''; + + // 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; + } + 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 assignLabel = `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] + }); + + // Post triage comment + const comment = [ + `### 🏗️ Squad Triage — ${lead.name} (${lead.role})`, + '', + `**Issue:** #${issue.number} — ${issue.title}`, + `**Assigned to:** ${assignedMember.name} (${assignedMember.role})`, + `**Reason:** ${triageReason}`, + '', + `---`, + '', + `**Team roster:**`, + memberList, + '', + `> To reassign, remove the current \`squad:*\` label and add the correct one.`, + ].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/templates/workflows/sync-squad-labels.yml b/templates/workflows/sync-squad-labels.yml new file mode 100644 index 000000000..58fe5c584 --- /dev/null +++ b/templates/workflows/sync-squad-labels.yml @@ -0,0 +1,109 @@ +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(', ')}`); + + // Define label color palette for squad labels + const SQUAD_COLOR = '6366f1'; + const MEMBER_COLOR = '3b82f6'; + + // 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})` + }); + } + + // 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/test/index.test.js b/test/index.test.js index 39fc152cf..a651c512c 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -144,14 +144,16 @@ describe('init into empty directory', () => { const templatesDir = path.join(tmpDir, '.ai-team-templates'); assert.ok(fs.existsSync(templatesDir), '.ai-team-templates/ should exist'); - // Every file in templates/ should be copied + // Every file/dir in templates/ should be copied const sourceFiles = fs.readdirSync(path.join(ROOT, 'templates')); const destFiles = fs.readdirSync(templatesDir); assert.deepEqual(destFiles.sort(), sourceFiles.sort()); - // Spot-check: content matches for first template + // Spot-check: content matches for files (skip directories) for (const file of sourceFiles) { - const expected = fs.readFileSync(path.join(ROOT, 'templates', file), 'utf8'); + const srcPath = path.join(ROOT, 'templates', file); + if (fs.statSync(srcPath).isDirectory()) continue; + const expected = fs.readFileSync(srcPath, 'utf8'); const actual = fs.readFileSync(path.join(templatesDir, file), 'utf8'); assert.equal(actual, expected, `template ${file} content should match`); } @@ -311,17 +313,21 @@ describe('upgrade subcommand', () => { it('overwrites .ai-team-templates/ with latest versions', () => { const templatesDir = path.join(tmpDir, '.ai-team-templates'); - // Modify a template to simulate old version - const templateFiles = fs.readdirSync(templatesDir); + // Modify a template file (not a directory) to simulate old version + const templateFiles = fs.readdirSync(templatesDir).filter(f => + fs.statSync(path.join(templatesDir, f)).isFile() + ); assert.ok(templateFiles.length > 0, 'should have template files'); fs.writeFileSync(path.join(templatesDir, templateFiles[0]), 'old template content'); runCmd(tmpDir, 'upgrade'); - // All templates should match source + // All template files should match source (skip directories) const sourceFiles = fs.readdirSync(path.join(ROOT, 'templates')); for (const file of sourceFiles) { - const expected = fs.readFileSync(path.join(ROOT, 'templates', file), 'utf8'); + const srcPath = path.join(ROOT, 'templates', file); + if (fs.statSync(srcPath).isDirectory()) continue; + const expected = fs.readFileSync(srcPath, 'utf8'); const actual = fs.readFileSync(path.join(templatesDir, file), 'utf8'); assert.equal(actual, expected, `template ${file} should match source after upgrade`); } @@ -422,6 +428,74 @@ describe('error handling', () => { }); }); +// --- Workflow copy tests --- + +describe('squad workflow files', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'squad-workflows-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('creates squad workflow files in .github/workflows/ on init', () => { + runInit(tmpDir); + const workflowsDir = path.join(tmpDir, '.github', 'workflows'); + assert.ok(fs.existsSync(workflowsDir), '.github/workflows/ should exist'); + + const expectedWorkflows = ['sync-squad-labels.yml', 'squad-triage.yml', 'squad-issue-assign.yml']; + for (const file of expectedWorkflows) { + assert.ok(fs.existsSync(path.join(workflowsDir, file)), `${file} should exist`); + } + }); + + it('workflow file contents match source templates', () => { + runInit(tmpDir); + const workflowsDir = path.join(tmpDir, '.github', 'workflows'); + const sourceDir = path.join(ROOT, 'templates', 'workflows'); + + const sourceFiles = fs.readdirSync(sourceDir).filter(f => f.endsWith('.yml')); + for (const file of sourceFiles) { + const expected = fs.readFileSync(path.join(sourceDir, file), 'utf8'); + const actual = fs.readFileSync(path.join(workflowsDir, file), 'utf8'); + assert.equal(actual, expected, `workflow ${file} content should match source`); + } + }); + + it('skips existing workflow files on re-init', () => { + runInit(tmpDir); + const workflowFile = path.join(tmpDir, '.github', 'workflows', 'sync-squad-labels.yml'); + fs.writeFileSync(workflowFile, 'user-customized workflow'); + + const output = runInit(tmpDir); + assert.ok(output.includes('already exists'), 'should report skipping'); + + const content = fs.readFileSync(workflowFile, 'utf8'); + assert.equal(content, 'user-customized workflow', 'should NOT overwrite user workflow'); + }); + + it('overwrites workflow files on upgrade', () => { + runInit(tmpDir); + const workflowFile = path.join(tmpDir, '.github', 'workflows', 'sync-squad-labels.yml'); + fs.writeFileSync(workflowFile, 'old workflow content'); + + runCmd(tmpDir, 'upgrade'); + + const expected = fs.readFileSync(path.join(ROOT, 'templates', 'workflows', 'sync-squad-labels.yml'), 'utf8'); + const actual = fs.readFileSync(workflowFile, 'utf8'); + assert.equal(actual, expected, 'workflow should match source after upgrade'); + }); + + it('upgrade output mentions workflow files', () => { + runInit(tmpDir); + const output = runCmd(tmpDir, 'upgrade'); + assert.ok(output.includes('workflow'), 'should mention workflows in upgrade output'); + }); +}); + // --- Edge case tests --- describe('edge cases', () => {