@bradygaster/squad-sdk 0.8.20 → 0.8.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/README.md +296 -296
  2. package/dist/adapter/client.js +1 -1
  3. package/dist/adapter/client.js.map +1 -1
  4. package/dist/agents/charter-compiler.d.ts +4 -0
  5. package/dist/agents/charter-compiler.d.ts.map +1 -1
  6. package/dist/agents/charter-compiler.js +8 -0
  7. package/dist/agents/charter-compiler.js.map +1 -1
  8. package/dist/agents/history-shadow.js +30 -30
  9. package/dist/agents/index.js +1 -1
  10. package/dist/agents/index.js.map +1 -1
  11. package/dist/agents/lifecycle.js +1 -1
  12. package/dist/agents/lifecycle.js.map +1 -1
  13. package/dist/build/github-dist.js +42 -42
  14. package/dist/builders/index.d.ts +156 -0
  15. package/dist/builders/index.d.ts.map +1 -0
  16. package/dist/builders/index.js +404 -0
  17. package/dist/builders/index.js.map +1 -0
  18. package/dist/builders/types.d.ts +187 -0
  19. package/dist/builders/types.d.ts.map +1 -0
  20. package/dist/builders/types.js +12 -0
  21. package/dist/builders/types.js.map +1 -0
  22. package/dist/config/init.d.ts +5 -21
  23. package/dist/config/init.d.ts.map +1 -1
  24. package/dist/config/init.js +270 -182
  25. package/dist/config/init.js.map +1 -1
  26. package/dist/coordinator/coordinator.js +1 -1
  27. package/dist/coordinator/coordinator.js.map +1 -1
  28. package/dist/coordinator/index.js +1 -1
  29. package/dist/coordinator/index.js.map +1 -1
  30. package/dist/index.d.ts +3 -0
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +3 -0
  33. package/dist/index.js.map +1 -1
  34. package/dist/runtime/otel-api.d.ts +38 -0
  35. package/dist/runtime/otel-api.d.ts.map +1 -0
  36. package/dist/runtime/otel-api.js +94 -0
  37. package/dist/runtime/otel-api.js.map +1 -0
  38. package/dist/runtime/otel-bridge.js +1 -1
  39. package/dist/runtime/otel-bridge.js.map +1 -1
  40. package/dist/runtime/otel.d.ts +1 -1
  41. package/dist/runtime/otel.d.ts.map +1 -1
  42. package/dist/runtime/otel.js +28 -12
  43. package/dist/runtime/otel.js.map +1 -1
  44. package/dist/runtime/squad-observer.js +1 -1
  45. package/dist/runtime/squad-observer.js.map +1 -1
  46. package/dist/sharing/consult.js +78 -78
  47. package/dist/streams/filter.d.ts +33 -0
  48. package/dist/streams/filter.d.ts.map +1 -0
  49. package/dist/streams/filter.js +29 -0
  50. package/dist/streams/filter.js.map +1 -0
  51. package/dist/streams/index.d.ts +9 -0
  52. package/dist/streams/index.d.ts.map +1 -0
  53. package/dist/streams/index.js +9 -0
  54. package/dist/streams/index.js.map +1 -0
  55. package/dist/streams/resolver.d.ts +40 -0
  56. package/dist/streams/resolver.d.ts.map +1 -0
  57. package/dist/streams/resolver.js +162 -0
  58. package/dist/streams/resolver.js.map +1 -0
  59. package/dist/streams/types.d.ts +44 -0
  60. package/dist/streams/types.d.ts.map +1 -0
  61. package/dist/streams/types.js +10 -0
  62. package/dist/streams/types.js.map +1 -0
  63. package/dist/tools/index.js +1 -1
  64. package/dist/tools/index.js.map +1 -1
  65. package/dist/types.d.ts +20 -0
  66. package/dist/types.d.ts.map +1 -1
  67. package/package.json +12 -11
  68. package/templates/casting-history.json +4 -4
  69. package/templates/casting-policy.json +35 -35
  70. package/templates/casting-registry.json +3 -3
  71. package/templates/ceremonies.md +41 -41
  72. package/templates/charter.md +53 -53
  73. package/templates/constraint-tracking.md +38 -38
  74. package/templates/copilot-instructions.md +46 -46
  75. package/templates/history.md +10 -10
  76. package/templates/identity/now.md +9 -9
  77. package/templates/identity/wisdom.md +15 -15
  78. package/templates/mcp-config.md +98 -98
  79. package/templates/multi-agent-format.md +28 -28
  80. package/templates/orchestration-log.md +27 -27
  81. package/templates/plugin-marketplace.md +49 -49
  82. package/templates/raw-agent-output.md +37 -37
  83. package/templates/roster.md +60 -60
  84. package/templates/routing.md +54 -54
  85. package/templates/run-output.md +50 -50
  86. package/templates/scribe-charter.md +119 -119
  87. package/templates/skill.md +24 -24
  88. package/templates/skills/project-conventions/SKILL.md +56 -56
  89. package/templates/squad.agent.md +1146 -1146
  90. package/templates/workflows/squad-ci.yml +24 -24
  91. package/templates/workflows/squad-docs.yml +50 -50
  92. package/templates/workflows/squad-heartbeat.yml +316 -316
  93. package/templates/workflows/squad-insider-release.yml +61 -61
  94. package/templates/workflows/squad-issue-assign.yml +161 -161
  95. package/templates/workflows/squad-label-enforce.yml +181 -181
  96. package/templates/workflows/squad-preview.yml +55 -55
  97. package/templates/workflows/squad-promote.yml +120 -120
  98. package/templates/workflows/squad-release.yml +77 -77
  99. package/templates/workflows/squad-triage.yml +260 -260
  100. package/templates/workflows/sync-squad-labels.yml +169 -169
  101. package/dist/runtime/event-bus-otel-bridge.d.ts +0 -19
  102. package/dist/runtime/event-bus-otel-bridge.d.ts.map +0 -1
  103. package/dist/runtime/event-bus-otel-bridge.js +0 -61
  104. package/dist/runtime/event-bus-otel-bridge.js.map +0 -1
  105. package/templates/workflows/squad-main-guard.yml +0 -129
@@ -1,316 +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
- }
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
+ }