Squad Test

This commit is contained in:
Stef Heyenrath
2026-04-16 16:29:28 +02:00
parent 6e2a4d7e04
commit facd2b66ca
127 changed files with 14965 additions and 1 deletions

171
.github/workflows/squad-heartbeat.yml vendored Normal file
View File

@@ -0,0 +1,171 @@
name: Squad Heartbeat (Ralph)
# ⚠️ SYNC: This workflow is maintained in 4 locations. Changes must be applied to all:
# - templates/workflows/squad-heartbeat.yml (source template)
# - packages/squad-cli/templates/workflows/squad-heartbeat.yml (CLI package)
# - .squad/templates/workflows/squad-heartbeat.yml (installed template)
# - .github/workflows/squad-heartbeat.yml (active workflow)
# Run 'squad upgrade' to sync installed copies from source templates.
on:
schedule:
# Every 30 minutes — adjust via cron expression as needed
- cron: '*/30 * * * *'
# React to completed work or new squad work
issues:
types: [closed, labeled]
pull_request:
types: [closed]
# Manual trigger
workflow_dispatch:
permissions:
issues: write
contents: read
pull-requests: read
jobs:
heartbeat:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Check triage script
id: check-script
run: |
if [ -f ".squad/templates/ralph-triage.js" ]; then
echo "has_script=true" >> $GITHUB_OUTPUT
else
echo "has_script=false" >> $GITHUB_OUTPUT
echo "⚠️ ralph-triage.js not found — run 'squad upgrade' to install"
fi
- name: Ralph — Smart triage
if: steps.check-script.outputs.has_script == 'true'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
node .squad/templates/ralph-triage.js \
--squad-dir .squad \
--output triage-results.json
- name: Ralph — Apply triage decisions
if: steps.check-script.outputs.has_script == 'true' && hashFiles('triage-results.json') != ''
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const path = 'triage-results.json';
if (!fs.existsSync(path)) {
core.info('No triage results — board is clear');
return;
}
const results = JSON.parse(fs.readFileSync(path, 'utf8'));
if (results.length === 0) {
core.info('📋 Board is clear — Ralph found no untriaged issues');
return;
}
for (const decision of results) {
try {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: decision.issueNumber,
labels: [decision.label]
});
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: decision.issueNumber,
body: [
'### 🔄 Ralph — Auto-Triage',
'',
`**Assigned to:** ${decision.assignTo}`,
`**Reason:** ${decision.reason}`,
`**Source:** ${decision.source}`,
'',
'> Ralph auto-triaged this issue using routing rules.',
'> To reassign, swap the `squad:*` label.'
].join('\n')
});
core.info(`Triaged #${decision.issueNumber} → ${decision.assignTo} (${decision.source})`);
} catch (e) {
core.warning(`Failed to triage #${decision.issueNumber}: ${e.message}`);
}
}
core.info(`🔄 Ralph triaged ${results.length} issue(s)`);
# Copilot auto-assign step (uses PAT if available)
- name: Ralph — Assign @copilot issues
if: success()
uses: actions/github-script@v7
with:
github-token: ${{ secrets.COPILOT_ASSIGN_TOKEN || secrets.GITHUB_TOKEN }}
script: |
const fs = require('fs');
let teamFile = '.squad/team.md';
if (!fs.existsSync(teamFile)) {
teamFile = '.ai-team/team.md';
}
if (!fs.existsSync(teamFile)) return;
const content = fs.readFileSync(teamFile, 'utf8');
// Check if @copilot is on the team with auto-assign
const hasCopilot = content.includes('🤖 Coding Agent') || content.includes('@copilot');
const autoAssign = content.includes('<!-- copilot-auto-assign: true -->');
if (!hasCopilot || !autoAssign) return;
// Find issues labeled squad:copilot with no assignee
try {
const { data: copilotIssues } = await github.rest.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
labels: 'squad:copilot',
state: 'open',
per_page: 5
});
const unassigned = copilotIssues.filter(i =>
!i.assignees || i.assignees.length === 0
);
if (unassigned.length === 0) {
core.info('No unassigned squad:copilot issues');
return;
}
// Get repo default branch
const { data: repoData } = await github.rest.repos.get({
owner: context.repo.owner,
repo: context.repo.repo
});
for (const issue of unassigned) {
try {
await github.request('POST /repos/{owner}/{repo}/issues/{issue_number}/assignees', {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
assignees: ['copilot-swe-agent[bot]'],
agent_assignment: {
target_repo: `${context.repo.owner}/${context.repo.repo}`,
base_branch: repoData.default_branch,
custom_instructions: `Read .squad/team.md (or .ai-team/team.md) for team context and .squad/routing.md (or .ai-team/routing.md) for routing rules.`
}
});
core.info(`Assigned copilot-swe-agent[bot] to #${issue.number}`);
} catch (e) {
core.warning(`Failed to assign @copilot to #${issue.number}: ${e.message}`);
}
}
} catch (e) {
core.info(`No squad:copilot label found or error: ${e.message}`);
}

161
.github/workflows/squad-issue-assign.yml vendored Normal file
View File

@@ -0,0 +1,161 @@
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 — check .squad/ first, fall back to .ai-team/
let teamFile = '.squad/team.md';
if (!fs.existsSync(teamFile)) {
teamFile = '.ai-team/team.md';
}
if (!fs.existsSync(teamFile)) {
core.warning('No .squad/team.md or .ai-team/team.md found — cannot assign work');
return;
}
const content = fs.readFileSync(teamFile, 'utf8');
const lines = content.split('\n');
// Check if this is a coding agent assignment
const isCopilotAssignment = memberName === 'copilot';
let assignedMember = null;
if (isCopilotAssignment) {
assignedMember = { name: '@copilot', role: 'Coding Agent' };
} else {
let inMembersTable = false;
for (const line of lines) {
if (line.match(/^##\s+(Members|Team Roster)/i)) {
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 \`.squad/team.md\` (or \`.ai-team/team.md\`) for valid member names.`
});
return;
}
// Post assignment acknowledgment
let comment;
if (isCopilotAssignment) {
comment = [
`### 🤖 Routed to @copilot (Coding Agent)`,
'',
`**Issue:** #${issue.number} — ${issue.title}`,
'',
`@copilot has been assigned and will pick this up automatically.`,
'',
`> The coding agent will create a \`copilot/*\` branch and open a draft PR.`,
`> Review the PR as you would any team member's work.`,
].join('\n');
} else {
comment = [
`### 📋 Assigned to ${assignedMember.name} (${assignedMember.role})`,
'',
`**Issue:** #${issue.number} — ${issue.title}`,
'',
`${assignedMember.name} will pick this up in the next Copilot session.`,
'',
`> **For Copilot coding agent:** If enabled, this issue will be worked automatically.`,
`> Otherwise, start a Copilot session and say:`,
`> \`${assignedMember.name}, work on issue #${issue.number}\``,
].join('\n');
}
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
body: comment
});
core.info(`Issue #${issue.number} assigned to ${assignedMember.name} (${assignedMember.role})`);
# Separate step: assign @copilot using PAT (required for coding agent)
- name: Assign @copilot coding agent
if: github.event.label.name == 'squad:copilot'
uses: actions/github-script@v7
with:
github-token: ${{ secrets.COPILOT_ASSIGN_TOKEN }}
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const issue_number = context.payload.issue.number;
// Get the default branch name (main, master, etc.)
const { data: repoData } = await github.rest.repos.get({ owner, repo });
const baseBranch = repoData.default_branch;
try {
await github.request('POST /repos/{owner}/{repo}/issues/{issue_number}/assignees', {
owner,
repo,
issue_number,
assignees: ['copilot-swe-agent[bot]'],
agent_assignment: {
target_repo: `${owner}/${repo}`,
base_branch: baseBranch,
custom_instructions: '',
custom_agent: '',
model: ''
},
headers: {
'X-GitHub-Api-Version': '2022-11-28'
}
});
core.info(`Assigned copilot-swe-agent to issue #${issue_number} (base: ${baseBranch})`);
} catch (err) {
core.warning(`Assignment with agent_assignment failed: ${err.message}`);
// Fallback: try without agent_assignment
try {
await github.rest.issues.addAssignees({
owner, repo, issue_number,
assignees: ['copilot-swe-agent']
});
core.info(`Fallback assigned copilot-swe-agent to issue #${issue_number}`);
} catch (err2) {
core.warning(`Fallback also failed: ${err2.message}`);
}
}

260
.github/workflows/squad-triage.yml vendored Normal file
View File

@@ -0,0 +1,260 @@
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 — check .squad/ first, fall back to .ai-team/
let teamFile = '.squad/team.md';
if (!fs.existsSync(teamFile)) {
teamFile = '.ai-team/team.md';
}
if (!fs.existsSync(teamFile)) {
core.warning('No .squad/team.md or .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('<!-- copilot-auto-assign: true -->');
// 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.match(/^##\s+(Members|Team Roster)/i)) {
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 — check .squad/ first, fall back to .ai-team/
let routingFile = '.squad/routing.md';
if (!fs.existsSync(routingFile)) {
routingFile = '.ai-team/routing.md';
}
let routingContent = '';
if (fs.existsSync(routingFile)) {
routingContent = fs.readFileSync(routingFile, 'utf8');
}
// Find the Lead
const lead = members.find(m =>
m.role.toLowerCase().includes('lead') ||
m.role.toLowerCase().includes('architect') ||
m.role.toLowerCase().includes('coordinator')
);
if (!lead) {
core.warning('No Lead role found in team roster — cannot triage');
return;
}
// Build triage context
const memberList = members.map(m =>
`- **${m.name}** (${m.role}) → label: \`squad:${m.name.toLowerCase()}\``
).join('\n');
// Determine best assignee based on issue content and routing
const issueText = `${issue.title}\n${issue.body || ''}`.toLowerCase();
let assignedMember = null;
let triageReason = '';
let copilotTier = null;
// First, evaluate @copilot fit if enabled
if (hasCopilot) {
const isNotSuitable = notSuitableKeywords.some(kw => issueText.includes(kw));
const isGoodFit = !isNotSuitable && goodFitKeywords.some(kw => issueText.includes(kw));
const isNeedsReview = !isNotSuitable && !isGoodFit && needsReviewKeywords.some(kw => issueText.includes(kw));
if (isGoodFit) {
copilotTier = 'good-fit';
assignedMember = { name: '@copilot', role: 'Coding Agent' };
triageReason = '🟢 Good fit for @copilot — matches capability profile';
} else if (isNeedsReview) {
copilotTier = 'needs-review';
assignedMember = { name: '@copilot', role: 'Coding Agent' };
triageReason = '🟡 Routing to @copilot (needs review) — a squad member should review the PR';
} else if (isNotSuitable) {
copilotTier = 'not-suitable';
// Fall through to normal routing
}
}
// If not routed to @copilot, use keyword-based routing
if (!assignedMember) {
for (const member of members) {
const role = member.role.toLowerCase();
if ((role.includes('frontend') || role.includes('ui')) &&
(issueText.includes('ui') || issueText.includes('frontend') ||
issueText.includes('css') || issueText.includes('component') ||
issueText.includes('button') || issueText.includes('page') ||
issueText.includes('layout') || issueText.includes('design'))) {
assignedMember = member;
triageReason = 'Issue relates to frontend/UI work';
break;
}
if ((role.includes('backend') || role.includes('api') || role.includes('server')) &&
(issueText.includes('api') || issueText.includes('backend') ||
issueText.includes('database') || issueText.includes('endpoint') ||
issueText.includes('server') || issueText.includes('auth'))) {
assignedMember = member;
triageReason = 'Issue relates to backend/API work';
break;
}
if ((role.includes('test') || role.includes('qa') || role.includes('quality')) &&
(issueText.includes('test') || issueText.includes('bug') ||
issueText.includes('fix') || issueText.includes('regression') ||
issueText.includes('coverage'))) {
assignedMember = member;
triageReason = 'Issue relates to testing/quality work';
break;
}
if ((role.includes('devops') || role.includes('infra') || role.includes('ops')) &&
(issueText.includes('deploy') || issueText.includes('ci') ||
issueText.includes('pipeline') || issueText.includes('docker') ||
issueText.includes('infrastructure'))) {
assignedMember = member;
triageReason = 'Issue relates to DevOps/infrastructure work';
break;
}
}
}
// Default to Lead if no routing match
if (!assignedMember) {
assignedMember = lead;
triageReason = 'No specific domain match — assigned to Lead for further analysis';
}
const isCopilot = assignedMember.name === '@copilot';
const assignLabel = isCopilot ? 'squad:copilot' : `squad:${assignedMember.name.toLowerCase()}`;
// Add the member-specific label
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: [assignLabel]
});
// Apply default triage verdict
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: ['go:needs-research']
});
// Auto-assign @copilot if enabled
if (isCopilot && copilotAutoAssign) {
try {
await github.rest.issues.addAssignees({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
assignees: ['copilot']
});
} catch (err) {
core.warning(`Could not auto-assign @copilot: ${err.message}`);
}
}
// Build copilot evaluation note
let copilotNote = '';
if (hasCopilot && !isCopilot) {
if (copilotTier === 'not-suitable') {
copilotNote = `\n\n**@copilot evaluation:** 🔴 Not suitable — issue involves work outside the coding agent's capability profile.`;
} else {
copilotNote = `\n\n**@copilot evaluation:** No strong capability match — routed to squad member.`;
}
}
// Post triage comment
const comment = [
`### 🏗️ Squad Triage — ${lead.name} (${lead.role})`,
'',
`**Issue:** #${issue.number} — ${issue.title}`,
`**Assigned to:** ${assignedMember.name} (${assignedMember.role})`,
`**Reason:** ${triageReason}`,
copilotTier === 'needs-review' ? `\n⚠ **PR review recommended** — a squad member should review @copilot's work on this one.` : '',
copilotNote,
'',
`---`,
'',
`**Team roster:**`,
memberList,
hasCopilot ? `- **@copilot** (Coding Agent) → label: \`squad:copilot\`` : '',
'',
`> To reassign, remove the current \`squad:*\` label and add the correct one.`,
].filter(Boolean).join('\n');
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
body: comment
});
core.info(`Triaged issue #${issue.number} → ${assignedMember.name} (${assignLabel})`);

169
.github/workflows/sync-squad-labels.yml vendored Normal file
View File

@@ -0,0 +1,169 @@
name: Sync Squad Labels
on:
push:
paths:
- '.squad/team.md'
- '.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');
let teamFile = '.squad/team.md';
if (!fs.existsSync(teamFile)) {
teamFile = '.ai-team/team.md';
}
if (!fs.existsSync(teamFile)) {
core.info('No .squad/team.md or .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.match(/^##\s+(Members|Team Roster)/i)) {
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 = '9B8FCC';
const MEMBER_COLOR = '9B8FCC';
const COPILOT_COLOR = '10b981';
// Define go: and release: labels (static)
const GO_LABELS = [
{ name: 'go:yes', color: '0E8A16', description: 'Ready to implement' },
{ name: 'go:no', color: 'B60205', description: 'Not pursuing' },
{ name: 'go:needs-research', color: 'FBCA04', description: 'Needs investigation' }
];
const RELEASE_LABELS = [
{ name: 'release:v0.4.0', color: '6B8EB5', description: 'Targeted for v0.4.0' },
{ name: 'release:v0.5.0', color: '6B8EB5', description: 'Targeted for v0.5.0' },
{ name: 'release:v0.6.0', color: '8B7DB5', description: 'Targeted for v0.6.0' },
{ name: 'release:v1.0.0', color: '8B7DB5', description: 'Targeted for v1.0.0' },
{ name: 'release:backlog', color: 'D4E5F7', description: 'Not yet targeted' }
];
const TYPE_LABELS = [
{ name: 'type:feature', color: 'DDD1F2', description: 'New capability' },
{ name: 'type:bug', color: 'FF0422', description: 'Something broken' },
{ name: 'type:spike', color: 'F2DDD4', description: 'Research/investigation — produces a plan, not code' },
{ name: 'type:docs', color: 'D4E5F7', description: 'Documentation work' },
{ name: 'type:chore', color: 'D4E5F7', description: 'Maintenance, refactoring, cleanup' },
{ name: 'type:epic', color: 'CC4455', description: 'Parent issue that decomposes into sub-issues' }
];
// High-signal labels — these MUST visually dominate all others
const SIGNAL_LABELS = [
{ name: 'bug', color: 'FF0422', description: 'Something isn\'t working' },
{ name: 'feedback', color: '00E5FF', description: 'User feedback — high signal, needs attention' }
];
const PRIORITY_LABELS = [
{ name: 'priority:p0', color: 'B60205', description: 'Blocking release' },
{ name: 'priority:p1', color: 'D93F0B', description: 'This sprint' },
{ name: 'priority:p2', color: 'FBCA04', description: 'Next sprint' }
];
// Ensure the base "squad" triage label exists
const labels = [
{ name: 'squad', color: SQUAD_COLOR, description: 'Squad triage inbox — Lead will assign to a member' }
];
for (const member of members) {
labels.push({
name: `squad:${member.name.toLowerCase()}`,
color: MEMBER_COLOR,
description: `Assigned to ${member.name} (${member.role})`
});
}
// Add @copilot label if coding agent is on the team
if (hasCopilot) {
labels.push({
name: 'squad:copilot',
color: COPILOT_COLOR,
description: 'Assigned to @copilot (Coding Agent) for autonomous work'
});
}
// Add go:, release:, type:, priority:, and high-signal labels
labels.push(...GO_LABELS);
labels.push(...RELEASE_LABELS);
labels.push(...TYPE_LABELS);
labels.push(...PRIORITY_LABELS);
labels.push(...SIGNAL_LABELS);
// Sync labels (create or update)
for (const label of labels) {
try {
await github.rest.issues.getLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: label.name
});
// Label exists — update it
await github.rest.issues.updateLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: label.name,
color: label.color,
description: label.description
});
core.info(`Updated label: ${label.name}`);
} catch (err) {
if (err.status === 404) {
// Label doesn't exist — create it
await github.rest.issues.createLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: label.name,
color: label.color,
description: label.description
});
core.info(`Created label: ${label.name}`);
} else {
throw err;
}
}
}
core.info(`Label sync complete: ${labels.length} labels synced`);