Skip to content

Commit b127c7a

Browse files
bradygasterbrady gasterCopilot
authored
Fix assignment safety, roster parsing, and Ralph authority (#2156)
* fix: make Copilot assignment fail closed and disclose triage provenance Closes #2080, Refs #2085 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(sdk): scope team markdown parsing Closes #2079 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * docs: clarify Ralph merge authority (Closes #2077) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix: address assignment and roster review feedback Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: brady gaster <bradygaster@github.com> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent deee440 commit b127c7a

29 files changed

Lines changed: 1406 additions & 870 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@bradygaster/squad-sdk": patch
3+
---
4+
5+
Limit team markdown table parsing to supported roster sections, preserving the legacy Team Roster heading while excluding auxiliary tables.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@bradygaster/squad-cli": patch
3+
"@bradygaster/squad-sdk": patch
4+
---
5+
6+
Make issue-assign the sole Copilot assignment workflow, fail visibly on missing tokens or unconfirmed API responses, dispatch automated auto-assign handoffs explicitly, and disclose deterministic keyword triage provenance without attributing it to Lead-agent analysis.

‎.changeset/tidy-ralph-guidance.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@bradygaster/squad-cli": patch
3+
"@bradygaster/squad-sdk": patch
4+
---
5+
6+
Clarify Ralph's monitor-only role and reserve pull request merging for humans in the distributed reference templates.

‎.github/workflows/squad-heartbeat.yml‎

Lines changed: 21 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
11
name: Squad Heartbeat (Ralph)
2-
# ⚠️ SYNC: This workflow is maintained in 4 locations. Changes must be applied to all:
3-
# - templates/workflows/squad-heartbeat.yml (source template)
4-
# - packages/squad-cli/templates/workflows/squad-heartbeat.yml (CLI package)
5-
# - .squad/templates/workflows/squad-heartbeat.yml (installed template)
6-
# - .github/workflows/squad-heartbeat.yml (active workflow)
7-
# Run 'squad upgrade' to sync installed copies from source templates.
2+
# Canonical source: .squad-templates/workflows/squad-heartbeat.yml.
3+
# Run node scripts/sync-templates.mjs --sync for root, CLI and SDK mirrors;
4+
# update the active .github/workflows copy while preserving repo-specific steps.
85
#
96
# CI Hardening: Cron schedule audit (item 8, refs diberry/squad#120)
107
# ─────────────────────────────────────────────────────────────────
@@ -35,6 +32,7 @@ permissions:
3532
issues: write
3633
contents: read
3734
pull-requests: read
35+
actions: write
3836

3937
# CI Hardening Phase 3 item A1: Prevent parallel runs from competing for resources
4038
concurrency:
@@ -136,6 +134,8 @@ jobs:
136134
core.info('📋 Board is clear — Ralph found no untriaged issues');
137135
return;
138136
}
137+
const autoAssign = /<!--\s*copilot-auto-assign:\s*true\s*-->/i
138+
.test(fs.readFileSync('.squad/team.md', 'utf8'));
139139
140140
for (const decision of results) {
141141
try {
@@ -161,80 +161,27 @@ jobs:
161161
'> To reassign, swap the `squad:*` label.'
162162
].join('\n')
163163
});
164+
165+
if (decision.label === 'squad:copilot' && autoAssign) {
166+
await github.rest.actions.createWorkflowDispatch({
167+
owner: context.repo.owner,
168+
repo: context.repo.repo,
169+
workflow_id: 'squad-issue-assign.yml',
170+
ref: context.payload.repository.default_branch,
171+
inputs: { issue_number: String(decision.issueNumber) }
172+
});
173+
core.info(`Dispatched squad-issue-assign.yml for auto-assigned issue #${decision.issueNumber}`);
174+
}
164175
165176
core.info(`Triaged #${decision.issueNumber} → ${decision.assignTo} (${decision.source})`);
166177
} catch (e) {
167178
core.warning(`Failed to triage #${decision.issueNumber}: ${e.message}`);
179+
if (decision.label === 'squad:copilot' && autoAssign) {
180+
core.setFailed(`Failed to dispatch Copilot assignment for #${decision.issueNumber}: ${e.message}`);
181+
}
168182
}
169183
}
170184
171185
core.info(`🔄 Ralph triaged ${results.length} issue(s)`);
172186
173-
# Copilot auto-assign step (uses PAT if available)
174-
- name: Ralph — Assign @copilot issues
175-
if: success()
176-
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 #v9
177-
with:
178-
github-token: ${{ secrets.COPILOT_ASSIGN_TOKEN || secrets.GITHUB_TOKEN }}
179-
script: |
180-
const fs = require('fs');
181-
182-
let teamFile = '.squad/team.md';
183-
if (!fs.existsSync(teamFile)) {
184-
teamFile = '.ai-team/team.md';
185-
}
186-
if (!fs.existsSync(teamFile)) return;
187-
188-
const content = fs.readFileSync(teamFile, 'utf8');
189-
190-
// Check if @copilot is on the team with auto-assign
191-
const hasCopilot = content.includes('🤖 Coding Agent') || content.includes('@copilot');
192-
const autoAssign = content.includes('<!-- copilot-auto-assign: true -->');
193-
if (!hasCopilot || !autoAssign) return;
194-
195-
// Find issues labeled squad:copilot with no assignee
196-
try {
197-
const { data: copilotIssues } = await github.rest.issues.listForRepo({
198-
owner: context.repo.owner,
199-
repo: context.repo.repo,
200-
labels: 'squad:copilot',
201-
state: 'open',
202-
per_page: 5
203-
});
204-
205-
const unassigned = copilotIssues.filter(i =>
206-
!i.assignees || i.assignees.length === 0
207-
);
208-
209-
if (unassigned.length === 0) {
210-
core.info('No unassigned squad:copilot issues');
211-
return;
212-
}
213-
214-
// Get repo default branch
215-
const { data: repoData } = await github.rest.repos.get({
216-
owner: context.repo.owner,
217-
repo: context.repo.repo
218-
});
219-
220-
for (const issue of unassigned) {
221-
try {
222-
await github.request('POST /repos/{owner}/{repo}/issues/{issue_number}/assignees', {
223-
owner: context.repo.owner,
224-
repo: context.repo.repo,
225-
issue_number: issue.number,
226-
assignees: ['copilot-swe-agent[bot]'],
227-
agent_assignment: {
228-
target_repo: `${context.repo.owner}/${context.repo.repo}`,
229-
base_branch: repoData.default_branch,
230-
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.`
231-
}
232-
});
233-
core.info(`Assigned copilot-swe-agent[bot] to #${issue.number}`);
234-
} catch (e) {
235-
core.warning(`Failed to assign @copilot to #${issue.number}: ${e.message}`);
236-
}
237-
}
238-
} catch (e) {
239-
core.info(`No squad:copilot label found or error: ${e.message}`);
240-
}
187+
# Copilot assignment belongs exclusively to squad-issue-assign.yml.

‎.github/workflows/squad-issue-assign.yml‎

Lines changed: 87 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,39 @@ name: Squad Issue Assign
33
on:
44
issues:
55
types: [labeled]
6+
workflow_dispatch:
7+
inputs:
8+
issue_number:
9+
description: Issue number to assign to Copilot
10+
required: true
11+
type: string
612

713
permissions:
814
issues: write
915
contents: read
1016

1117
# CI Hardening Phase 3 item A1: Prevent parallel runs from competing for resources
1218
concurrency:
13-
group: ${{ github.workflow }}-${{ github.event.issue.number || github.ref }}
19+
group: ${{ github.workflow }}-${{ github.event.issue.number || github.event.inputs.issue_number || github.ref }}
1420
cancel-in-progress: true
1521

1622
jobs:
1723
assign-work:
1824
# Only trigger on squad:{member} labels (not the base "squad" label)
19-
if: startsWith(github.event.label.name, 'squad:')
25+
if: github.event_name == 'workflow_dispatch' || startsWith(github.event.label.name, 'squad:')
2026
runs-on: ubuntu-latest
2127
steps:
28+
- name: Require Copilot assignment token
29+
if: github.event_name == 'workflow_dispatch' || github.event.label.name == 'squad:copilot'
30+
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 #v9
31+
env:
32+
COPILOT_ASSIGN_TOKEN: ${{ secrets.COPILOT_ASSIGN_TOKEN }}
33+
with:
34+
script: |
35+
if (!process.env.COPILOT_ASSIGN_TOKEN?.trim()) {
36+
throw new Error('COPILOT_ASSIGN_TOKEN is required for squad:copilot assignment; no routing or assignment attempted.');
37+
}
38+
2239
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7
2340

2441
# CI Hardening: GitHub API rate limit monitoring (item 9)
@@ -46,8 +63,12 @@ jobs:
4663
with:
4764
script: |
4865
const fs = require('fs');
49-
const issue = context.payload.issue;
50-
const label = context.payload.label.name;
66+
const label = context.payload.label?.name || 'squad:copilot';
67+
const issue = context.payload.issue || (await github.rest.issues.get({
68+
owner: context.repo.owner,
69+
repo: context.repo.repo,
70+
issue_number: Number(context.payload.inputs.issue_number)
71+
})).data;
5172
const slugify = value => value
5273
.toLowerCase()
5374
.replace(/[^a-z0-9]+/g, '-')
@@ -76,6 +97,8 @@ jobs:
7697
7798
// Extract member name from label (e.g., "squad:ripley" → "ripley")
7899
const memberName = label.replace('squad:', '').toLowerCase();
100+
// Copilot is acknowledged only after the assignment API accepts it.
101+
if (memberName === 'copilot') return;
79102
80103
// Read team roster — check .squad/ first, fall back to .ai-team/
81104
let teamFile = '.squad/team.md';
@@ -90,25 +113,17 @@ jobs:
90113
const content = fs.readFileSync(teamFile, 'utf8');
91114
const lines = content.split('\n');
92115
93-
// Check if this is a coding agent assignment
94-
const isCopilotAssignment = memberName === 'copilot';
95-
96-
let assignedMember = null;
97-
if (isCopilotAssignment) {
98-
assignedMember = { name: '@copilot', role: 'Coding Agent' };
99-
} else {
100-
const match = findMemberBySlug(lines, memberName);
101-
assignedMember = match.member;
102-
if (match.ambiguous) {
103-
core.warning(`Multiple squad members map to label "${label}" — refusing ambiguous assignment`);
104-
await github.rest.issues.createComment({
105-
owner: context.repo.owner,
106-
repo: context.repo.repo,
107-
issue_number: issue.number,
108-
body: `⚠️ Multiple squad members map to label \`${label}\`. Rename the colliding members in \`.squad/team.md\` before assigning work.`
109-
});
110-
return;
111-
}
116+
const match = findMemberBySlug(lines, memberName);
117+
const assignedMember = match.member;
118+
if (match.ambiguous) {
119+
core.warning(`Multiple squad members map to label "${label}" — refusing ambiguous assignment`);
120+
await github.rest.issues.createComment({
121+
owner: context.repo.owner,
122+
repo: context.repo.repo,
123+
issue_number: issue.number,
124+
body: `⚠️ Multiple squad members map to label \`${label}\`. Rename the colliding members in \`.squad/team.md\` before assigning work.`
125+
});
126+
return;
112127
}
113128
114129
if (!assignedMember) {
@@ -135,20 +150,7 @@ jobs:
135150
}
136151
137152
// Post assignment acknowledgment
138-
let comment;
139-
if (isCopilotAssignment) {
140-
comment = [
141-
`### 🤖 Routed to @copilot (Coding Agent)`,
142-
'',
143-
`**Issue:** #${issue.number} — ${issue.title}`,
144-
'',
145-
`@copilot has been assigned and will pick this up automatically.`,
146-
'',
147-
`> The coding agent will create a \`copilot/*\` branch and open a draft PR.`,
148-
`> Review the PR as you would any team member's work.`,
149-
].join('\n');
150-
} else {
151-
comment = [
153+
const comment = [
152154
`### 📋 Assigned to ${assignedMember.name} (${assignedMember.role})`,
153155
'',
154156
`**Issue:** #${issue.number} — ${issue.title}`,
@@ -158,8 +160,7 @@ jobs:
158160
`> **For Copilot coding agent:** If enabled, this issue will be worked automatically.`,
159161
`> Otherwise, start a Copilot session and say:`,
160162
`> \`${assignedMember.name}, work on issue #${issue.number}\``,
161-
].join('\n');
162-
}
163+
].join('\n');
163164
164165
await github.rest.issues.createComment({
165166
owner: context.repo.owner,
@@ -172,21 +173,28 @@ jobs:
172173
173174
# Separate step: assign @copilot using PAT (required for coding agent)
174175
- name: Assign @copilot coding agent
175-
if: github.event.label.name == 'squad:copilot'
176+
if: github.event_name == 'workflow_dispatch' || github.event.label.name == 'squad:copilot'
176177
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 #v9
177178
with:
178179
github-token: ${{ secrets.COPILOT_ASSIGN_TOKEN }}
179180
script: |
180181
const owner = context.repo.owner;
181182
const repo = context.repo.repo;
182-
const issue_number = context.payload.issue.number;
183+
const issue_number = Number(context.payload.issue?.number || context.payload.inputs.issue_number);
184+
if (!Number.isSafeInteger(issue_number) || issue_number < 1) {
185+
throw new Error('A valid issue_number is required for Copilot assignment.');
186+
}
187+
const label = context.payload.label?.name || 'squad:copilot';
188+
const { data: issue } = await github.rest.issues.get({ owner, repo, issue_number });
189+
if (!issue.labels.some(existing => existing.name === label)) {
190+
throw new Error(`Issue #${issue_number} no longer has ${label}; refusing stale Copilot handoff.`);
191+
}
183192
184193
// Get the default branch name (main, master, etc.)
185194
const { data: repoData } = await github.rest.repos.get({ owner, repo });
186195
const baseBranch = repoData.default_branch;
187196
188-
try {
189-
await github.request('POST /repos/{owner}/{repo}/issues/{issue_number}/assignees', {
197+
const response = await github.request('POST /repos/{owner}/{repo}/issues/{issue_number}/assignees', {
190198
owner,
191199
repo,
192200
issue_number,
@@ -201,18 +209,40 @@ jobs:
201209
headers: {
202210
'X-GitHub-Api-Version': '2022-11-28'
203211
}
204-
});
205-
core.info(`Assigned copilot-swe-agent to issue #${issue_number} (base: ${baseBranch})`);
206-
} catch (err) {
207-
core.warning(`Assignment with agent_assignment failed: ${err.message}`);
208-
// Fallback: try without agent_assignment
209-
try {
210-
await github.rest.issues.addAssignees({
211-
owner, repo, issue_number,
212-
assignees: ['copilot-swe-agent']
213-
});
214-
core.info(`Fallback assigned copilot-swe-agent to issue #${issue_number}`);
215-
} catch (err2) {
216-
core.warning(`Fallback also failed: ${err2.message}`);
217-
}
212+
});
213+
if (![200, 201].includes(response?.status) ||
214+
!Array.isArray(response?.data?.assignees) ||
215+
!response.data.assignees.some(assignee => assignee?.login === 'copilot-swe-agent[bot]')) {
216+
throw new Error(`Copilot assignment API did not confirm copilot-swe-agent[bot] for issue #${issue_number}; handoff is unverified.`);
217+
}
218+
core.info(`Copilot assignment API accepted issue #${issue_number} (base: ${baseBranch}); session creation is not verified.`);
219+
await github.rest.issues.createComment({
220+
owner, repo, issue_number,
221+
body: [
222+
`### 🤖 Copilot assignment API accepted`,
223+
'',
224+
`**Issue:** #${issue_number} — ${issue.title}`,
225+
`The API accepted the coding-agent request and returned \`copilot-swe-agent[bot]\` as an assignee (base: \`${baseBranch}\`).`,
226+
'',
227+
`This is not proof of a running session. Verify a Copilot session linked to this issue or a linked \`copilot/*\` draft PR before treating the handoff as started.`,
228+
].join('\n')
229+
});
230+
231+
# Repair the parent label only after the PAT-backed assignment and its acknowledgment succeed.
232+
# The default GITHUB_TOKEN does not create another issues:labeled workflow run.
233+
- name: Ensure parent squad label
234+
if: github.event_name == 'workflow_dispatch' || github.event.label.name == 'squad:copilot'
235+
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 #v9
236+
with:
237+
script: |
238+
const owner = context.repo.owner;
239+
const repo = context.repo.repo;
240+
const issue_number = Number(context.payload.issue?.number || context.payload.inputs.issue_number);
241+
if (!Number.isSafeInteger(issue_number) || issue_number < 1) {
242+
throw new Error('A valid issue_number is required to repair the parent squad label.');
243+
}
244+
const { data: issue } = await github.rest.issues.get({ owner, repo, issue_number });
245+
if (!issue.labels.some(label => label.name === 'squad')) {
246+
await github.rest.issues.addLabels({ owner, repo, issue_number, labels: ['squad'] });
247+
core.info(`Added parent "squad" label to #${issue_number}`);
218248
}

0 commit comments

Comments
 (0)