diff --git a/.github/agents/squad.agent.md b/.github/agents/squad.agent.md index e27a581aa..75efb5449 100644 --- a/.github/agents/squad.agent.md +++ b/.github/agents/squad.agent.md @@ -124,6 +124,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 2de253dd0..e58422c01 100644 --- a/README.md +++ b/README.md @@ -236,6 +236,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 @@ -252,7 +297,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 98682262d..89aff5386 100644 --- a/index.js +++ b/index.js @@ -512,6 +512,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 new file mode 100644 index 000000000..5e918dca4 --- /dev/null +++ b/test/index.test.js @@ -0,0 +1,545 @@ +const { describe, it, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { execSync } = require('child_process'); + +const ROOT = path.resolve(__dirname, '..'); +const INDEX = path.join(ROOT, 'index.js'); +const PKG = require(path.join(ROOT, 'package.json')); + +function runInit(cwd) { + return execSync(`node "${INDEX}"`, { cwd, encoding: 'utf8', env: { ...process.env } }); +} + +function runCmd(cwd, args) { + return execSync(`node "${INDEX}" ${args}`, { cwd, encoding: 'utf8', env: { ...process.env } }); +} + +function runCmdStatus(cwd, args) { + try { + const stdout = execSync(`node "${INDEX}" ${args}`, { cwd, encoding: 'utf8', env: { ...process.env } }); + return { stdout, exitCode: 0 }; + } catch (err) { + return { stdout: err.stdout || '', stderr: err.stderr || '', exitCode: err.status }; + } +} + +// --- copyRecursive unit tests --- + +describe('copyRecursive', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'squad-copy-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + // We can't require copyRecursive directly (no module.exports), so we + // replicate it here for isolated unit testing of the algorithm. + function copyRecursive(src, target) { + if (fs.statSync(src).isDirectory()) { + fs.mkdirSync(target, { recursive: true }); + for (const entry of fs.readdirSync(src)) { + copyRecursive(path.join(src, entry), path.join(target, entry)); + } + } else { + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.copyFileSync(src, target); + } + } + + it('copies a single file', () => { + const src = path.join(tmpDir, 'src'); + const dest = path.join(tmpDir, 'dest'); + fs.mkdirSync(src); + fs.writeFileSync(path.join(src, 'file.txt'), 'hello'); + + copyRecursive(path.join(src, 'file.txt'), path.join(dest, 'file.txt')); + assert.equal(fs.readFileSync(path.join(dest, 'file.txt'), 'utf8'), 'hello'); + }); + + it('copies nested directories and preserves file contents', () => { + const src = path.join(tmpDir, 'src'); + const dest = path.join(tmpDir, 'dest'); + + // Create nested structure: src/a/b/deep.txt, src/root.md + fs.mkdirSync(path.join(src, 'a', 'b'), { recursive: true }); + fs.writeFileSync(path.join(src, 'root.md'), '# Root'); + fs.writeFileSync(path.join(src, 'a', 'mid.json'), '{"key":"value"}'); + fs.writeFileSync(path.join(src, 'a', 'b', 'deep.txt'), 'deep content'); + + copyRecursive(src, dest); + + assert.equal(fs.readFileSync(path.join(dest, 'root.md'), 'utf8'), '# Root'); + assert.equal(fs.readFileSync(path.join(dest, 'a', 'mid.json'), 'utf8'), '{"key":"value"}'); + assert.equal(fs.readFileSync(path.join(dest, 'a', 'b', 'deep.txt'), 'utf8'), 'deep content'); + }); + + it('copies an empty directory', () => { + const src = path.join(tmpDir, 'src'); + const dest = path.join(tmpDir, 'dest'); + fs.mkdirSync(src); + + copyRecursive(src, dest); + assert.ok(fs.existsSync(dest)); + assert.equal(fs.readdirSync(dest).length, 0); + }); + + it('preserves binary file contents', () => { + const src = path.join(tmpDir, 'src'); + const dest = path.join(tmpDir, 'dest'); + fs.mkdirSync(src); + + const buf = Buffer.from([0x00, 0x01, 0xFF, 0xFE, 0x89, 0x50]); + fs.writeFileSync(path.join(src, 'bin.dat'), buf); + + copyRecursive(src, dest); + const result = fs.readFileSync(path.join(dest, 'bin.dat')); + assert.deepEqual(result, buf); + }); +}); + +// --- Init happy path --- + +describe('init into empty directory', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'squad-init-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('creates .github/agents/squad.agent.md', () => { + runInit(tmpDir); + const agentFile = path.join(tmpDir, '.github', 'agents', 'squad.agent.md'); + assert.ok(fs.existsSync(agentFile), 'squad.agent.md should exist'); + + // Content should match the source but with version stamped + const source = fs.readFileSync(path.join(ROOT, '.github', 'agents', 'squad.agent.md'), 'utf8'); + const actual = fs.readFileSync(agentFile, 'utf8'); + const pkg = require(path.join(ROOT, 'package.json')); + const expected = source.replace('version: "0.0.0-source"', `version: "${pkg.version}"`); + assert.equal(actual, expected); + }); + + it('stamps version into squad.agent.md', () => { + runInit(tmpDir); + const agentFile = path.join(tmpDir, '.github', 'agents', 'squad.agent.md'); + const content = fs.readFileSync(agentFile, 'utf8'); + const pkg = require(path.join(ROOT, 'package.json')); + assert.ok(content.includes(`version: "${pkg.version}"`), 'should contain stamped version'); + assert.ok(!content.includes('0.0.0-source'), 'should not contain source placeholder'); + }); + + it('creates .ai-team-templates/ with all template files', () => { + runInit(tmpDir); + const templatesDir = path.join(tmpDir, '.ai-team-templates'); + assert.ok(fs.existsSync(templatesDir), '.ai-team-templates/ should exist'); + + // 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 files (skip directories) + for (const file of sourceFiles) { + 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`); + } + }); + + it('creates drop-box directories', () => { + runInit(tmpDir); + assert.ok(fs.existsSync(path.join(tmpDir, '.ai-team', 'decisions', 'inbox')), + 'decisions/inbox should exist'); + assert.ok(fs.existsSync(path.join(tmpDir, '.ai-team', 'orchestration-log')), + 'orchestration-log should exist'); + assert.ok(fs.existsSync(path.join(tmpDir, '.ai-team', 'casting')), + 'casting should exist'); + }); + + it('outputs success messages', () => { + const output = runInit(tmpDir); + assert.ok(output.includes('squad.agent.md'), 'should mention squad.agent.md'); + assert.ok(output.includes('.ai-team-templates'), 'should mention templates'); + assert.ok(output.includes('Squad is ready'), 'should print ready message'); + }); +}); + +// --- Re-init (idempotency) --- + +describe('re-init into existing directory', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'squad-reinit-')); + // First init + runInit(tmpDir); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('skips squad.agent.md when it already exists', () => { + // Modify the agent file so we can verify it's NOT overwritten + const agentFile = path.join(tmpDir, '.github', 'agents', 'squad.agent.md'); + fs.writeFileSync(agentFile, 'user-customized content'); + + const output = runInit(tmpDir); + assert.ok(output.includes('already exists'), 'should report skipping'); + + const content = fs.readFileSync(agentFile, 'utf8'); + assert.equal(content, 'user-customized content', 'should NOT overwrite user file'); + }); + + it('skips .ai-team-templates/ when it already exists', () => { + // Add a user file to templates dir + const userFile = path.join(tmpDir, '.ai-team-templates', 'user-custom.md'); + fs.writeFileSync(userFile, 'custom content'); + + const output = runInit(tmpDir); + assert.ok(output.includes('already exists'), 'should report skipping templates'); + + // User file should still be there + assert.ok(fs.existsSync(userFile), 'user custom file should survive re-init'); + }); + + it('drop-box directories still exist after re-init', () => { + runInit(tmpDir); + assert.ok(fs.existsSync(path.join(tmpDir, '.ai-team', 'decisions', 'inbox'))); + assert.ok(fs.existsSync(path.join(tmpDir, '.ai-team', 'orchestration-log'))); + assert.ok(fs.existsSync(path.join(tmpDir, '.ai-team', 'casting'))); + }); + + it('does not corrupt existing drop-box contents', () => { + // Put a file in inbox before re-init + const inboxFile = path.join(tmpDir, '.ai-team', 'decisions', 'inbox', 'test-decision.md'); + fs.writeFileSync(inboxFile, '# Test Decision'); + + runInit(tmpDir); + + assert.ok(fs.existsSync(inboxFile), 'inbox file should survive'); + assert.equal(fs.readFileSync(inboxFile, 'utf8'), '# Test Decision'); + }); +}); + +// --- Flag tests --- + +describe('flags and subcommands', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'squad-flags-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('--version prints version from package.json', () => { + const result = runCmdStatus(tmpDir, '--version'); + assert.equal(result.exitCode, 0, 'should exit 0'); + assert.equal(result.stdout.trim(), PKG.version); + }); + + it('-v works as alias for --version', () => { + const result = runCmdStatus(tmpDir, '-v'); + assert.equal(result.exitCode, 0, 'should exit 0'); + assert.equal(result.stdout.trim(), PKG.version); + }); + + it('--help prints usage information', () => { + const result = runCmdStatus(tmpDir, '--help'); + assert.equal(result.exitCode, 0, 'should exit 0'); + assert.ok(result.stdout.includes('squad'), 'should mention squad'); + assert.ok(result.stdout.includes('Usage'), 'should include Usage'); + assert.ok(result.stdout.includes('upgrade'), 'should mention upgrade command'); + }); + + it('-h works as alias for --help', () => { + const result = runCmdStatus(tmpDir, '-h'); + assert.equal(result.exitCode, 0, 'should exit 0'); + assert.ok(result.stdout.includes('Usage'), 'should include Usage'); + }); + + it('help subcommand prints usage information', () => { + const result = runCmdStatus(tmpDir, 'help'); + assert.equal(result.exitCode, 0, 'should exit 0'); + assert.ok(result.stdout.includes('Usage'), 'should include Usage'); + assert.ok(result.stdout.includes('Commands'), 'should list commands'); + }); +}); + +// --- Upgrade path tests --- + +describe('upgrade subcommand', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'squad-upgrade-')); + // Initial install first + runInit(tmpDir); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('overwrites squad.agent.md with latest version', () => { + const agentFile = path.join(tmpDir, '.github', 'agents', 'squad.agent.md'); + // Simulate user having an older version + fs.writeFileSync(agentFile, 'old version content'); + + runCmd(tmpDir, 'upgrade'); + + const source = fs.readFileSync(path.join(ROOT, '.github', 'agents', 'squad.agent.md'), 'utf8'); + const actual = fs.readFileSync(agentFile, 'utf8'); + const pkg = require(path.join(ROOT, 'package.json')); + const expected = source.replace('version: "0.0.0-source"', `version: "${pkg.version}"`); + assert.equal(actual, expected, 'squad.agent.md should match source with version stamped after upgrade'); + }); + + it('overwrites .ai-team-templates/ with latest versions', () => { + const templatesDir = path.join(tmpDir, '.ai-team-templates'); + // 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'); + + // Simulate an older installed version so upgrade doesn't short-circuit + const agentFile = path.join(tmpDir, '.github', 'agents', 'squad.agent.md'); + fs.writeFileSync(agentFile, 'version: "0.0.1"\nold agent content'); + + runCmd(tmpDir, 'upgrade'); + + // All template files should match source (skip directories) + const sourceFiles = fs.readdirSync(path.join(ROOT, 'templates')); + for (const file of sourceFiles) { + 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`); + } + }); + + it('does NOT touch .ai-team/ directory', () => { + // Add user state to .ai-team/ + const userFile = path.join(tmpDir, '.ai-team', 'decisions', 'inbox', 'user-decision.md'); + fs.writeFileSync(userFile, '# My Important Decision'); + const castingFile = path.join(tmpDir, '.ai-team', 'casting', 'my-cast.json'); + fs.writeFileSync(castingFile, '{"agent":"test"}'); + + runCmd(tmpDir, 'upgrade'); + + // User state must survive + assert.ok(fs.existsSync(userFile), 'inbox decision should survive upgrade'); + assert.equal(fs.readFileSync(userFile, 'utf8'), '# My Important Decision'); + assert.ok(fs.existsSync(castingFile), 'casting file should survive upgrade'); + assert.equal(fs.readFileSync(castingFile, 'utf8'), '{"agent":"test"}'); + }); + + it('outputs upgrade confirmation messages', () => { + // Simulate an older installed version so upgrade doesn't short-circuit + const agentFile = path.join(tmpDir, '.github', 'agents', 'squad.agent.md'); + fs.writeFileSync(agentFile, 'version: "0.0.1"\nold agent content'); + + const output = runCmd(tmpDir, 'upgrade'); + assert.ok(output.includes('upgraded'), 'should mention upgraded'); + assert.ok(output.includes('untouched') || output.includes('safe'), + 'should confirm .ai-team/ is safe'); + }); +}); + +// --- Error handling tests --- + +describe('error handling', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'squad-err-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('fatal() exits with code 1 on error', () => { + // Run index.js from a fake root missing source files — triggers source validation fatal() + const fakeRoot = path.join(tmpDir, 'fake-pkg'); + fs.mkdirSync(fakeRoot); + fs.copyFileSync(INDEX, path.join(fakeRoot, 'index.js')); + fs.copyFileSync(path.join(ROOT, 'package.json'), path.join(fakeRoot, 'package.json')); + + const target = path.join(tmpDir, 'target'); + fs.mkdirSync(target); + + try { + execSync(`node "${path.join(fakeRoot, 'index.js')}"`, { + cwd: target, encoding: 'utf8', stdio: 'pipe' + }); + assert.fail('should have thrown'); + } catch (err) { + assert.equal(err.status, 1, 'fatal() should exit with code 1'); + assert.ok(err.stderr.includes('missing') || err.stderr.includes('corrupted'), + 'should mention missing/corrupted source'); + } + }); + + it('missing source files produce clean error message', () => { + // Same approach: fake package root without .github/agents/squad.agent.md + const fakeRoot = path.join(tmpDir, 'fake-pkg2'); + fs.mkdirSync(fakeRoot); + fs.copyFileSync(INDEX, path.join(fakeRoot, 'index.js')); + fs.copyFileSync(path.join(ROOT, 'package.json'), path.join(fakeRoot, 'package.json')); + + const target = path.join(tmpDir, 'target2'); + fs.mkdirSync(target); + + try { + execSync(`node "${path.join(fakeRoot, 'index.js')}"`, { + cwd: target, encoding: 'utf8', stdio: 'pipe' + }); + assert.fail('should have thrown'); + } catch (err) { + // Error message should be human-readable, not a raw stack trace + assert.ok(err.stderr.includes('squad.agent.md') || err.stderr.includes('installation'), + 'error should reference the missing file or installation'); + // Should NOT contain raw stack trace + assert.ok(!err.stderr.includes(' at '), 'should not include stack trace'); + } + }); + + it('exits with code 0 on successful init', () => { + const result = runCmdStatus(tmpDir, ''); + assert.equal(result.exitCode, 0, 'should exit 0 on success'); + }); + + it('exits with code 0 on successful upgrade', () => { + runInit(tmpDir); + const result = runCmdStatus(tmpDir, 'upgrade'); + assert.equal(result.exitCode, 0, 'upgrade should exit 0 on success'); + }); +}); + +// --- 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'); + + // Simulate an older installed version so upgrade doesn't short-circuit + const agentFile = path.join(tmpDir, '.github', 'agents', 'squad.agent.md'); + fs.writeFileSync(agentFile, 'version: "0.0.1"\nold agent 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); + + // Simulate an older installed version so upgrade doesn't short-circuit + const agentFile = path.join(tmpDir, '.github', 'agents', 'squad.agent.md'); + fs.writeFileSync(agentFile, 'version: "0.0.1"\nold agent content'); + + const output = runCmd(tmpDir, 'upgrade'); + assert.ok(output.includes('workflow'), 'should mention workflows in upgrade output'); + }); +}); + +// --- Edge case tests --- + +describe('edge cases', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'squad-edge-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('re-init skips existing files and reports it', () => { + // First init + runInit(tmpDir); + // Second init should skip + const output = runInit(tmpDir); + assert.ok(output.includes('already exists'), 'should report files already exist'); + // Files should still be valid + assert.ok(fs.existsSync(path.join(tmpDir, '.github', 'agents', 'squad.agent.md'))); + assert.ok(fs.existsSync(path.join(tmpDir, '.ai-team-templates'))); + }); + + it('exit code is 0 on re-init', () => { + runInit(tmpDir); + const result = runCmdStatus(tmpDir, ''); + assert.equal(result.exitCode, 0, 're-init should exit 0'); + }); +});