Skip to content

Commit cd29323

Browse files
fix: enable Development mode and add provider integration tests
Register IStorage (MemoryStorage) and AgentApplicationOptions in DI before AddAgent, fixing the DI validation error that prevented the host from starting in Development mode. Add integration tests for both Mastodon and Bluesky providers that validate real API connectivity (TestCategory=Integration, skipped by default). Fix agent card endpoint URLs in docs — the SDK serves the card at /.well-known/agent.json (A2A spec) and /a2a/.well-known/ agent-card.json, not /.well-known/agent-card.json. Also adds .copilot, .github workflows, .squad, and .gitattributes configuration files. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent b234e23 commit cd29323

56 files changed

Lines changed: 5977 additions & 2 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.copilot/mcp-config.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"mcpServers": {
3+
"EXAMPLE-github": {
4+
"command": "npx",
5+
"args": [
6+
"-y",
7+
"@anthropic/github-mcp-server"
8+
],
9+
"env": {
10+
"GITHUB_TOKEN": "${GITHUB_TOKEN}"
11+
}
12+
}
13+
}
14+
}

.gitattributes

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Squad: union merge for append-only team state files
2+
.squad/decisions.md merge=union
3+
.squad/agents/*/history.md merge=union
4+
.squad/log/** merge=union
5+
.squad/orchestration-log/** merge=union

.github/agents/squad.agent.md

Lines changed: 1146 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
1+
name: Squad Heartbeat (Ralph)
2+
3+
on:
4+
# DISABLED: Cron heartbeat commented out pre-migration — re-enable when ready
5+
# schedule:
6+
# # Every 30 minutes — adjust or remove if not needed
7+
# - cron: '*/30 * * * *'
8+
9+
# React to completed work or new squad work
10+
issues:
11+
types: [closed, labeled]
12+
pull_request:
13+
types: [closed]
14+
15+
# Manual trigger
16+
workflow_dispatch:
17+
18+
permissions:
19+
issues: write
20+
contents: read
21+
pull-requests: read
22+
23+
jobs:
24+
heartbeat:
25+
runs-on: ubuntu-latest
26+
steps:
27+
- uses: actions/checkout@v4
28+
29+
- name: Ralph — Check for squad work
30+
uses: actions/github-script@v7
31+
with:
32+
script: |
33+
const fs = require('fs');
34+
35+
// Read team roster — check .squad/ first, fall back to .ai-team/
36+
let teamFile = '.squad/team.md';
37+
if (!fs.existsSync(teamFile)) {
38+
teamFile = '.ai-team/team.md';
39+
}
40+
if (!fs.existsSync(teamFile)) {
41+
core.info('No .squad/team.md or .ai-team/team.md found — Ralph has nothing to monitor');
42+
return;
43+
}
44+
45+
const content = fs.readFileSync(teamFile, 'utf8');
46+
47+
// Check if Ralph is on the roster
48+
if (!content.includes('Ralph') || !content.includes('🔄')) {
49+
core.info('Ralph not on roster — heartbeat disabled');
50+
return;
51+
}
52+
53+
// Parse members from roster
54+
const lines = content.split('\n');
55+
const members = [];
56+
let inMembersTable = false;
57+
for (const line of lines) {
58+
if (line.match(/^##\s+(Members|Team Roster)/i)) {
59+
inMembersTable = true;
60+
continue;
61+
}
62+
if (inMembersTable && line.startsWith('## ')) break;
63+
if (inMembersTable && line.startsWith('|') && !line.includes('---') && !line.includes('Name')) {
64+
const cells = line.split('|').map(c => c.trim()).filter(Boolean);
65+
if (cells.length >= 2 && !['Scribe', 'Ralph'].includes(cells[0])) {
66+
members.push({
67+
name: cells[0],
68+
role: cells[1],
69+
label: `squad:${cells[0].toLowerCase()}`
70+
});
71+
}
72+
}
73+
}
74+
75+
if (members.length === 0) {
76+
core.info('No squad members found — nothing to monitor');
77+
return;
78+
}
79+
80+
// 1. Find untriaged issues (labeled "squad" but no "squad:{member}" label)
81+
const { data: squadIssues } = await github.rest.issues.listForRepo({
82+
owner: context.repo.owner,
83+
repo: context.repo.repo,
84+
labels: 'squad',
85+
state: 'open',
86+
per_page: 20
87+
});
88+
89+
const memberLabels = members.map(m => m.label);
90+
const untriaged = squadIssues.filter(issue => {
91+
const issueLabels = issue.labels.map(l => l.name);
92+
return !memberLabels.some(ml => issueLabels.includes(ml));
93+
});
94+
95+
// 2. Find assigned but unstarted issues (has squad:{member} label, no assignee)
96+
const unstarted = [];
97+
for (const member of members) {
98+
try {
99+
const { data: memberIssues } = await github.rest.issues.listForRepo({
100+
owner: context.repo.owner,
101+
repo: context.repo.repo,
102+
labels: member.label,
103+
state: 'open',
104+
per_page: 10
105+
});
106+
for (const issue of memberIssues) {
107+
if (!issue.assignees || issue.assignees.length === 0) {
108+
unstarted.push({ issue, member });
109+
}
110+
}
111+
} catch (e) {
112+
// Label may not exist yet
113+
}
114+
}
115+
116+
// 3. Find squad issues missing triage verdict (no go:* label)
117+
const missingVerdict = squadIssues.filter(issue => {
118+
const labels = issue.labels.map(l => l.name);
119+
return !labels.some(l => l.startsWith('go:'));
120+
});
121+
122+
// 4. Find go:yes issues missing release target
123+
const goYesIssues = squadIssues.filter(issue => {
124+
const labels = issue.labels.map(l => l.name);
125+
return labels.includes('go:yes') && !labels.some(l => l.startsWith('release:'));
126+
});
127+
128+
// 4b. Find issues missing type: label
129+
const missingType = squadIssues.filter(issue => {
130+
const labels = issue.labels.map(l => l.name);
131+
return !labels.some(l => l.startsWith('type:'));
132+
});
133+
134+
// 5. Find open PRs that need attention
135+
const { data: openPRs } = await github.rest.pulls.list({
136+
owner: context.repo.owner,
137+
repo: context.repo.repo,
138+
state: 'open',
139+
per_page: 20
140+
});
141+
142+
const squadPRs = openPRs.filter(pr =>
143+
pr.labels.some(l => l.name.startsWith('squad'))
144+
);
145+
146+
// Build status summary
147+
const summary = [];
148+
if (untriaged.length > 0) {
149+
summary.push(`🔴 **${untriaged.length} untriaged issue(s)** need triage`);
150+
}
151+
if (unstarted.length > 0) {
152+
summary.push(`🟡 **${unstarted.length} assigned issue(s)** have no assignee`);
153+
}
154+
if (missingVerdict.length > 0) {
155+
summary.push(`⚪ **${missingVerdict.length} issue(s)** missing triage verdict (no \`go:\` label)`);
156+
}
157+
if (goYesIssues.length > 0) {
158+
summary.push(`⚪ **${goYesIssues.length} approved issue(s)** missing release target (no \`release:\` label)`);
159+
}
160+
if (missingType.length > 0) {
161+
summary.push(`⚪ **${missingType.length} issue(s)** missing \`type:\` label`);
162+
}
163+
if (squadPRs.length > 0) {
164+
const drafts = squadPRs.filter(pr => pr.draft).length;
165+
const ready = squadPRs.length - drafts;
166+
if (drafts > 0) summary.push(`🟡 **${drafts} draft PR(s)** in progress`);
167+
if (ready > 0) summary.push(`🟢 **${ready} PR(s)** open for review/merge`);
168+
}
169+
170+
if (summary.length === 0) {
171+
core.info('📋 Board is clear — Ralph found no pending work');
172+
return;
173+
}
174+
175+
core.info(`🔄 Ralph found work:\n${summary.join('\n')}`);
176+
177+
// Auto-triage untriaged issues
178+
for (const issue of untriaged) {
179+
const issueText = `${issue.title}\n${issue.body || ''}`.toLowerCase();
180+
let assignedMember = null;
181+
let reason = '';
182+
183+
// Simple keyword-based routing
184+
for (const member of members) {
185+
const role = member.role.toLowerCase();
186+
if ((role.includes('frontend') || role.includes('ui')) &&
187+
(issueText.includes('ui') || issueText.includes('frontend') ||
188+
issueText.includes('css') || issueText.includes('component'))) {
189+
assignedMember = member;
190+
reason = 'Matches frontend/UI domain';
191+
break;
192+
}
193+
if ((role.includes('backend') || role.includes('api') || role.includes('server')) &&
194+
(issueText.includes('api') || issueText.includes('backend') ||
195+
issueText.includes('database') || issueText.includes('endpoint'))) {
196+
assignedMember = member;
197+
reason = 'Matches backend/API domain';
198+
break;
199+
}
200+
if ((role.includes('test') || role.includes('qa')) &&
201+
(issueText.includes('test') || issueText.includes('bug') ||
202+
issueText.includes('fix') || issueText.includes('regression'))) {
203+
assignedMember = member;
204+
reason = 'Matches testing/QA domain';
205+
break;
206+
}
207+
}
208+
209+
// Default to Lead
210+
if (!assignedMember) {
211+
const lead = members.find(m =>
212+
m.role.toLowerCase().includes('lead') ||
213+
m.role.toLowerCase().includes('architect')
214+
);
215+
if (lead) {
216+
assignedMember = lead;
217+
reason = 'No domain match — routed to Lead';
218+
}
219+
}
220+
221+
if (assignedMember) {
222+
// Add member label
223+
await github.rest.issues.addLabels({
224+
owner: context.repo.owner,
225+
repo: context.repo.repo,
226+
issue_number: issue.number,
227+
labels: [assignedMember.label]
228+
});
229+
230+
// Post triage comment
231+
await github.rest.issues.createComment({
232+
owner: context.repo.owner,
233+
repo: context.repo.repo,
234+
issue_number: issue.number,
235+
body: [
236+
`### 🔄 Ralph — Auto-Triage`,
237+
'',
238+
`**Assigned to:** ${assignedMember.name} (${assignedMember.role})`,
239+
`**Reason:** ${reason}`,
240+
'',
241+
`> Ralph auto-triaged this issue via the squad heartbeat. To reassign, swap the \`squad:*\` label.`
242+
].join('\n')
243+
});
244+
245+
core.info(`Auto-triaged #${issue.number} → ${assignedMember.name}`);
246+
}
247+
}
248+
249+
# Copilot auto-assign step (uses PAT if available)
250+
- name: Ralph — Assign @copilot issues
251+
if: success()
252+
uses: actions/github-script@v7
253+
with:
254+
github-token: ${{ secrets.COPILOT_ASSIGN_TOKEN || secrets.GITHUB_TOKEN }}
255+
script: |
256+
const fs = require('fs');
257+
258+
let teamFile = '.squad/team.md';
259+
if (!fs.existsSync(teamFile)) {
260+
teamFile = '.ai-team/team.md';
261+
}
262+
if (!fs.existsSync(teamFile)) return;
263+
264+
const content = fs.readFileSync(teamFile, 'utf8');
265+
266+
// Check if @copilot is on the team with auto-assign
267+
const hasCopilot = content.includes('🤖 Coding Agent') || content.includes('@copilot');
268+
const autoAssign = content.includes('<!-- copilot-auto-assign: true -->');
269+
if (!hasCopilot || !autoAssign) return;
270+
271+
// Find issues labeled squad:copilot with no assignee
272+
try {
273+
const { data: copilotIssues } = await github.rest.issues.listForRepo({
274+
owner: context.repo.owner,
275+
repo: context.repo.repo,
276+
labels: 'squad:copilot',
277+
state: 'open',
278+
per_page: 5
279+
});
280+
281+
const unassigned = copilotIssues.filter(i =>
282+
!i.assignees || i.assignees.length === 0
283+
);
284+
285+
if (unassigned.length === 0) {
286+
core.info('No unassigned squad:copilot issues');
287+
return;
288+
}
289+
290+
// Get repo default branch
291+
const { data: repoData } = await github.rest.repos.get({
292+
owner: context.repo.owner,
293+
repo: context.repo.repo
294+
});
295+
296+
for (const issue of unassigned) {
297+
try {
298+
await github.request('POST /repos/{owner}/{repo}/issues/{issue_number}/assignees', {
299+
owner: context.repo.owner,
300+
repo: context.repo.repo,
301+
issue_number: issue.number,
302+
assignees: ['copilot-swe-agent[bot]'],
303+
agent_assignment: {
304+
target_repo: `${context.repo.owner}/${context.repo.repo}`,
305+
base_branch: repoData.default_branch,
306+
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.`
307+
}
308+
});
309+
core.info(`Assigned copilot-swe-agent[bot] to #${issue.number}`);
310+
} catch (e) {
311+
core.warning(`Failed to assign @copilot to #${issue.number}: ${e.message}`);
312+
}
313+
}
314+
} catch (e) {
315+
core.info(`No squad:copilot label found or error: ${e.message}`);
316+
}

0 commit comments

Comments
 (0)