Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/2079-team-roster-table-scope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bradygaster/squad-sdk": patch
---

Limit team markdown table parsing to supported roster sections, preserving the legacy Team Roster heading while excluding auxiliary tables.
6 changes: 6 additions & 0 deletions .changeset/assignment-safety-attribution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@bradygaster/squad-cli": patch
"@bradygaster/squad-sdk": patch
---

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.
6 changes: 6 additions & 0 deletions .changeset/tidy-ralph-guidance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@bradygaster/squad-cli": patch
"@bradygaster/squad-sdk": patch
---

Clarify Ralph's monitor-only role and reserve pull request merging for humans in the distributed reference templates.
95 changes: 21 additions & 74 deletions .github/workflows/squad-heartbeat.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
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.
# Canonical source: .squad-templates/workflows/squad-heartbeat.yml.
# Run node scripts/sync-templates.mjs --sync for root, CLI and SDK mirrors;
# update the active .github/workflows copy while preserving repo-specific steps.
#
# CI Hardening: Cron schedule audit (item 8, refs diberry/squad#120)
# ─────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -35,6 +32,7 @@ permissions:
issues: write
contents: read
pull-requests: read
actions: write

# CI Hardening Phase 3 item A1: Prevent parallel runs from competing for resources
concurrency:
Expand Down Expand Up @@ -136,6 +134,8 @@ jobs:
core.info('📋 Board is clear — Ralph found no untriaged issues');
return;
}
const autoAssign = /<!--\s*copilot-auto-assign:\s*true\s*-->/i
.test(fs.readFileSync('.squad/team.md', 'utf8'));

for (const decision of results) {
try {
Expand All @@ -161,80 +161,27 @@ jobs:
'> To reassign, swap the `squad:*` label.'
].join('\n')
});

if (decision.label === 'squad:copilot' && autoAssign) {
await github.rest.actions.createWorkflowDispatch({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'squad-issue-assign.yml',
ref: context.payload.repository.default_branch,
inputs: { issue_number: String(decision.issueNumber) }
});
core.info(`Dispatched squad-issue-assign.yml for auto-assigned issue #${decision.issueNumber}`);
}

core.info(`Triaged #${decision.issueNumber} → ${decision.assignTo} (${decision.source})`);
} catch (e) {
core.warning(`Failed to triage #${decision.issueNumber}: ${e.message}`);
if (decision.label === 'squad:copilot' && autoAssign) {
core.setFailed(`Failed to dispatch Copilot assignment for #${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@3a2844b7e9c422d3c10d287c895573f7108da1b3 #v9
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}`);
}
# Copilot assignment belongs exclusively to squad-issue-assign.yml.
144 changes: 87 additions & 57 deletions .github/workflows/squad-issue-assign.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,39 @@ name: Squad Issue Assign
on:
issues:
types: [labeled]
workflow_dispatch:
inputs:
issue_number:
description: Issue number to assign to Copilot
required: true
type: string

permissions:
issues: write
contents: read

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

jobs:
assign-work:
# Only trigger on squad:{member} labels (not the base "squad" label)
if: startsWith(github.event.label.name, 'squad:')
if: github.event_name == 'workflow_dispatch' || startsWith(github.event.label.name, 'squad:')
runs-on: ubuntu-latest
steps:
- name: Require Copilot assignment token
if: github.event_name == 'workflow_dispatch' || github.event.label.name == 'squad:copilot'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 #v9
env:
COPILOT_ASSIGN_TOKEN: ${{ secrets.COPILOT_ASSIGN_TOKEN }}
with:
script: |
if (!process.env.COPILOT_ASSIGN_TOKEN?.trim()) {
throw new Error('COPILOT_ASSIGN_TOKEN is required for squad:copilot assignment; no routing or assignment attempted.');
}

- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7

# CI Hardening: GitHub API rate limit monitoring (item 9)
Expand Down Expand Up @@ -46,8 +63,12 @@ jobs:
with:
script: |
const fs = require('fs');
const issue = context.payload.issue;
const label = context.payload.label.name;
const label = context.payload.label?.name || 'squad:copilot';
const issue = context.payload.issue || (await github.rest.issues.get({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: Number(context.payload.inputs.issue_number)
})).data;
const slugify = value => value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
Expand Down Expand Up @@ -76,6 +97,8 @@ jobs:

// Extract member name from label (e.g., "squad:ripley" → "ripley")
const memberName = label.replace('squad:', '').toLowerCase();
// Copilot is acknowledged only after the assignment API accepts it.
if (memberName === 'copilot') return;

// Read team roster — check .squad/ first, fall back to .ai-team/
let teamFile = '.squad/team.md';
Expand All @@ -90,25 +113,17 @@ jobs:
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 {
const match = findMemberBySlug(lines, memberName);
assignedMember = match.member;
if (match.ambiguous) {
core.warning(`Multiple squad members map to label "${label}" — refusing ambiguous assignment`);
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
body: `⚠️ Multiple squad members map to label \`${label}\`. Rename the colliding members in \`.squad/team.md\` before assigning work.`
});
return;
}
const match = findMemberBySlug(lines, memberName);
const assignedMember = match.member;
if (match.ambiguous) {
core.warning(`Multiple squad members map to label "${label}" — refusing ambiguous assignment`);
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
body: `⚠️ Multiple squad members map to label \`${label}\`. Rename the colliding members in \`.squad/team.md\` before assigning work.`
});
return;
}

if (!assignedMember) {
Expand All @@ -135,20 +150,7 @@ jobs:
}

// 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 = [
const comment = [
`### 📋 Assigned to ${assignedMember.name} (${assignedMember.role})`,
'',
`**Issue:** #${issue.number} — ${issue.title}`,
Expand All @@ -158,8 +160,7 @@ jobs:
`> **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');
}
].join('\n');

await github.rest.issues.createComment({
owner: context.repo.owner,
Expand All @@ -172,21 +173,28 @@ jobs:

# Separate step: assign @copilot using PAT (required for coding agent)
- name: Assign @copilot coding agent
if: github.event.label.name == 'squad:copilot'
if: github.event_name == 'workflow_dispatch' || github.event.label.name == 'squad:copilot'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 #v9
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;
const issue_number = Number(context.payload.issue?.number || context.payload.inputs.issue_number);
if (!Number.isSafeInteger(issue_number) || issue_number < 1) {
throw new Error('A valid issue_number is required for Copilot assignment.');
}
const label = context.payload.label?.name || 'squad:copilot';
const { data: issue } = await github.rest.issues.get({ owner, repo, issue_number });
if (!issue.labels.some(existing => existing.name === label)) {
throw new Error(`Issue #${issue_number} no longer has ${label}; refusing stale Copilot handoff.`);
}

// 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', {
const response = await github.request('POST /repos/{owner}/{repo}/issues/{issue_number}/assignees', {
owner,
repo,
issue_number,
Expand All @@ -201,18 +209,40 @@ jobs:
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}`);
}
});
if (![200, 201].includes(response?.status) ||
!Array.isArray(response?.data?.assignees) ||
!response.data.assignees.some(assignee => assignee?.login === 'copilot-swe-agent[bot]')) {
throw new Error(`Copilot assignment API did not confirm copilot-swe-agent[bot] for issue #${issue_number}; handoff is unverified.`);
}
core.info(`Copilot assignment API accepted issue #${issue_number} (base: ${baseBranch}); session creation is not verified.`);
await github.rest.issues.createComment({
owner, repo, issue_number,
body: [
`### 🤖 Copilot assignment API accepted`,
'',
`**Issue:** #${issue_number} — ${issue.title}`,
`The API accepted the coding-agent request and returned \`copilot-swe-agent[bot]\` as an assignee (base: \`${baseBranch}\`).`,
'',
`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.`,
].join('\n')
});

# Repair the parent label only after the PAT-backed assignment and its acknowledgment succeed.
# The default GITHUB_TOKEN does not create another issues:labeled workflow run.
- name: Ensure parent squad label
if: github.event_name == 'workflow_dispatch' || github.event.label.name == 'squad:copilot'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 #v9
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const issue_number = Number(context.payload.issue?.number || context.payload.inputs.issue_number);
if (!Number.isSafeInteger(issue_number) || issue_number < 1) {
throw new Error('A valid issue_number is required to repair the parent squad label.');
}
const { data: issue } = await github.rest.issues.get({ owner, repo, issue_number });
if (!issue.labels.some(label => label.name === 'squad')) {
await github.rest.issues.addLabels({ owner, repo, issue_number, labels: ['squad'] });
core.info(`Added parent "squad" label to #${issue_number}`);
}
Loading
Loading