From b0f54cae881b4b6904c1d003e1847a806a650a40 Mon Sep 17 00:00:00 2001 From: brady gaster Date: Fri, 2 Oct 2026 17:22:31 -0700 Subject: [PATCH 1/4] 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> --- .changeset/assignment-safety-attribution.md | 6 + .github/workflows/squad-heartbeat.yml | 78 +----- .github/workflows/squad-issue-assign.yml | 99 ++++--- .github/workflows/squad-triage.yml | 20 +- .../workflows/squad-heartbeat.yml | 75 +---- .../workflows/squad-issue-assign.yml | 95 +++---- .squad-templates/workflows/squad-triage.yml | 20 +- .../docs/features/copilot-coding-agent.md | 43 +-- .../templates/workflows/squad-heartbeat.yml | 75 +---- .../workflows/squad-issue-assign.yml | 95 +++---- .../templates/workflows/squad-triage.yml | 20 +- .../templates/workflows/squad-heartbeat.yml | 75 +---- .../workflows/squad-issue-assign.yml | 95 +++---- .../templates/workflows/squad-triage.yml | 20 +- templates/workflows/squad-heartbeat.yml | 75 +---- templates/workflows/squad-issue-assign.yml | 95 +++---- templates/workflows/squad-triage.yml | 20 +- test/assignment-safety-attribution.test.ts | 259 ++++++++++++++++++ 18 files changed, 555 insertions(+), 710 deletions(-) create mode 100644 .changeset/assignment-safety-attribution.md create mode 100644 test/assignment-safety-attribution.test.ts diff --git a/.changeset/assignment-safety-attribution.md b/.changeset/assignment-safety-attribution.md new file mode 100644 index 000000000..180f3a3f1 --- /dev/null +++ b/.changeset/assignment-safety-attribution.md @@ -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, and disclose deterministic keyword triage provenance without attributing it to Lead-agent analysis. diff --git a/.github/workflows/squad-heartbeat.yml b/.github/workflows/squad-heartbeat.yml index 0ebffd832..758db70d8 100644 --- a/.github/workflows/squad-heartbeat.yml +++ b/.github/workflows/squad-heartbeat.yml @@ -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) # ───────────────────────────────────────────────────────────────── @@ -170,71 +167,4 @@ jobs: 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(''); - 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. diff --git a/.github/workflows/squad-issue-assign.yml b/.github/workflows/squad-issue-assign.yml index 9526a081b..3e8c569be 100644 --- a/.github/workflows/squad-issue-assign.yml +++ b/.github/workflows/squad-issue-assign.yml @@ -19,6 +19,17 @@ jobs: if: startsWith(github.event.label.name, 'squad:') runs-on: ubuntu-latest steps: + - name: Require Copilot assignment token + if: 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) @@ -76,6 +87,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'; @@ -90,25 +103,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) { @@ -135,20 +140,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}`, @@ -158,8 +150,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, @@ -185,8 +176,7 @@ jobs: 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, @@ -201,18 +191,25 @@ 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.`); + // Preserve the active workflow's parent-label repair after acceptance. + if (!context.payload.issue.labels.some(label => label.name === 'squad')) { + await github.rest.issues.addLabels({ owner, repo, issue_number, labels: ['squad'] }); + } + await github.rest.issues.createComment({ + owner, repo, issue_number, + body: [ + `### 🤖 Copilot assignment API accepted`, + '', + `**Issue:** #${issue_number} — ${context.payload.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') + }); diff --git a/.github/workflows/squad-triage.yml b/.github/workflows/squad-triage.yml index beb97805c..bf9b7d986 100644 --- a/.github/workflows/squad-triage.yml +++ b/.github/workflows/squad-triage.yml @@ -40,7 +40,7 @@ jobs: core.info(`✅ Rate limit: OK — above safe threshold (resets ${resetDate})`); } - - name: Triage issue via Lead agent + - name: Triage issue via deterministic keyword routing uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 #v9 with: script: | @@ -62,7 +62,6 @@ jobs: // Check if @copilot is on the team const hasCopilot = content.includes('🤖 Coding Agent'); - const copilotAutoAssign = content.includes(''); // Parse @copilot capability profile let goodFitKeywords = []; @@ -312,19 +311,7 @@ jobs: 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}`); - } - } + // Labels route work; only squad-issue-assign.yml assigns Copilot. // Build copilot evaluation note let copilotNote = ''; @@ -338,8 +325,9 @@ jobs: // Post triage comment const comment = [ - `### 🏗️ Squad Triage — ${lead.name} (${lead.role})`, + `### 🏗️ Squad Triage — Automated keyword routing`, '', + `**Provenance:** Deterministic keyword matching against the capability profile in \`.squad/team.md\` and rules in \`.squad/routing.md\`; no Lead-agent analysis was performed.`, `**Issue:** #${issue.number} — ${issue.title}`, `**Assigned to:** ${assignedMember.name} (${assignedMember.role})`, `**Reason:** ${triageReason}`, diff --git a/.squad-templates/workflows/squad-heartbeat.yml b/.squad-templates/workflows/squad-heartbeat.yml index cc1cbe986..3f2172c75 100644 --- a/.squad-templates/workflows/squad-heartbeat.yml +++ b/.squad-templates/workflows/squad-heartbeat.yml @@ -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. on: # React to completed work or new squad work @@ -103,68 +100,4 @@ jobs: 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@f28e40c7f34bde8b3046d885e986cb6290c5673b #v7 - with: - github-token: ${{ secrets.COPILOT_ASSIGN_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const fs = require('fs'); - - const teamFile = '.squad/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(''); - 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 for team context and .squad/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. diff --git a/.squad-templates/workflows/squad-issue-assign.yml b/.squad-templates/workflows/squad-issue-assign.yml index 50df68310..d3bc8e305 100644 --- a/.squad-templates/workflows/squad-issue-assign.yml +++ b/.squad-templates/workflows/squad-issue-assign.yml @@ -14,6 +14,17 @@ jobs: if: startsWith(github.event.label.name, 'squad:') runs-on: ubuntu-latest steps: + - name: Require Copilot assignment token + if: github.event.label.name == 'squad:copilot' + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b #v7 + 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@34e114876b0b11c390a56381ad16ebd13914f8d5 #v4 - name: Identify assigned member and trigger work @@ -51,6 +62,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; const teamFile = '.squad/team.md'; if (!fs.existsSync(teamFile)) { @@ -61,25 +74,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) { @@ -94,20 +99,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}`, @@ -117,8 +109,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, @@ -144,8 +135,7 @@ jobs: 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, @@ -160,18 +150,21 @@ 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} — ${context.payload.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') + }); diff --git a/.squad-templates/workflows/squad-triage.yml b/.squad-templates/workflows/squad-triage.yml index 21e67f70c..19a3b146b 100644 --- a/.squad-templates/workflows/squad-triage.yml +++ b/.squad-templates/workflows/squad-triage.yml @@ -15,7 +15,7 @@ jobs: steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 #v4 - - name: Triage issue via Lead agent + - name: Triage issue via deterministic keyword routing uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b #v7 with: script: | @@ -33,7 +33,6 @@ jobs: // Check if @copilot is on the team const hasCopilot = content.includes('🤖 Coding Agent'); - const copilotAutoAssign = content.includes(''); // Parse @copilot capability profile let goodFitKeywords = []; @@ -279,19 +278,7 @@ jobs: 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}`); - } - } + // Labels route work; only squad-issue-assign.yml assigns Copilot. // Build copilot evaluation note let copilotNote = ''; @@ -305,8 +292,9 @@ jobs: // Post triage comment const comment = [ - `### 🏗️ Squad Triage — ${lead.name} (${lead.role})`, + `### 🏗️ Squad Triage — Automated keyword routing`, '', + `**Provenance:** Deterministic keyword matching against the capability profile in \`.squad/team.md\` and rules in \`.squad/routing.md\`; no Lead-agent analysis was performed.`, `**Issue:** #${issue.number} — ${issue.title}`, `**Assigned to:** ${assignedMember.name} (${assignedMember.role})`, `**Reason:** ${triageReason}`, diff --git a/docs/src/content/docs/features/copilot-coding-agent.md b/docs/src/content/docs/features/copilot-coding-agent.md index 7c9f28911..714c90135 100644 --- a/docs/src/content/docs/features/copilot-coding-agent.md +++ b/docs/src/content/docs/features/copilot-coding-agent.md @@ -127,28 +127,37 @@ The profile is editable. The Lead can suggest updates based on experience: When the `squad:copilot` label is added to an issue: -1. **Step 1** — Workflow posts a routing comment (uses `GITHUB_TOKEN`) -2. **Step 2** — Workflow assigns `copilot-swe-agent[bot]` to the issue (uses `COPILOT_ASSIGN_TOKEN`) -3. **Step 3** — Coding agent picks up the issue, creates a `copilot/*` branch, and opens a draft PR - -The workflow automatically detects the repo's default branch (`main`, `master`, etc.). +1. **Preflight** — `squad-issue-assign.yml` requires `COPILOT_ASSIGN_TOKEN`. If missing or blank, the job fails before routing comments or assignment attempts. Other member labels do not require this token. +2. **Request** — Using that token, the workflow requests `copilot-swe-agent[bot]` with `agent_assignment` targeting the repository's default branch. API errors fail the job; there is no ordinary-bot or CLI fallback. +3. **Acceptance** — Only an HTTP 200/201 response containing the exact `copilot-swe-agent[bot]` assignee permits an acknowledgment comment. Missing or malformed evidence fails visibly without a success comment. +4. **Handoff** — Verify a Copilot session linked to the issue, or a linked `copilot/*` draft PR with Copilot activity. API acceptance, a bot assignee, or a green Actions job alone does **not** prove that a coding session started or completed. + +`squad-issue-assign.yml` is the sole label-triggered Copilot assignment authority. +Heartbeat continues monitoring and applying triage labels, and triage continues +routing, but neither assigns Copilot directly. A failed request may have reached +GitHub before its response was lost; inspect the issue/session before retrying. + +Labels added with the default `GITHUB_TOKEN` do not trigger another Actions +workflow. If automated triage applies `squad:copilot` using that token, a human +or suitably authorized non-`GITHUB_TOKEN` integration must remove and reapply +the label to trigger the assignment workflow. Merely seeing the label is not +handoff evidence. --- -## Lead Triage - -The Lead evaluates every issue against @copilot's capability profile during triage: +## Automated Triage Provenance -1. **Good fit?** → Routes to @copilot with reasoning -2. **Needs review?** → Routes to @copilot, flags for squad member PR review -3. **Not suitable?** → Routes to the right squad member, explains why not @copilot +`squad-triage.yml` uses deterministic keyword matching, not a Lead-agent or LLM +analysis. It reads the capability profile from `.squad/team.md` and scores the +current `.squad/routing.md` table. Its comments disclose those sources: -The Lead can also suggest reassignment in either direction: +1. **Good fit?** → Routes to @copilot on a capability keyword match. +2. **Needs review?** → Routes to @copilot, flags for squad member PR review. +3. **Not suitable?** → Uses normal routing rules instead of @copilot. -``` -> This test coverage task could go to @copilot — want me to reassign? -> @copilot might struggle with this — suggesting we reassign to Ripley. -``` +No-match and tied-owner results still route to the Lead for **future** analysis; +the Lead has not analyzed the issue simply because this workflow ran. A Lead +can separately review or suggest reassignment in a Copilot session. --- @@ -182,7 +191,7 @@ This file is **upgraded automatically** when you run `squad upgrade` and `@copil - Start conservative with the capability profile and expand as you see what @copilot handles well. - Use auto-assign for repos where you want fully autonomous issue processing. -- The coding agent works great alongside [issue-driven development](../scenarios/issue-driven-dev.md) — label issues `squad` and the Lead + @copilot handle the rest. +- The coding agent works alongside [issue-driven development](../scenarios/issue-driven-dev.md) — label issues `squad` for keyword routing, then verify the assignment trigger and Copilot handoff. - @copilot's PRs go through normal review — treat them like any team member's work. ## Sample Prompts diff --git a/packages/squad-cli/templates/workflows/squad-heartbeat.yml b/packages/squad-cli/templates/workflows/squad-heartbeat.yml index cc1cbe986..3f2172c75 100644 --- a/packages/squad-cli/templates/workflows/squad-heartbeat.yml +++ b/packages/squad-cli/templates/workflows/squad-heartbeat.yml @@ -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. on: # React to completed work or new squad work @@ -103,68 +100,4 @@ jobs: 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@f28e40c7f34bde8b3046d885e986cb6290c5673b #v7 - with: - github-token: ${{ secrets.COPILOT_ASSIGN_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const fs = require('fs'); - - const teamFile = '.squad/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(''); - 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 for team context and .squad/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. diff --git a/packages/squad-cli/templates/workflows/squad-issue-assign.yml b/packages/squad-cli/templates/workflows/squad-issue-assign.yml index 50df68310..d3bc8e305 100644 --- a/packages/squad-cli/templates/workflows/squad-issue-assign.yml +++ b/packages/squad-cli/templates/workflows/squad-issue-assign.yml @@ -14,6 +14,17 @@ jobs: if: startsWith(github.event.label.name, 'squad:') runs-on: ubuntu-latest steps: + - name: Require Copilot assignment token + if: github.event.label.name == 'squad:copilot' + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b #v7 + 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@34e114876b0b11c390a56381ad16ebd13914f8d5 #v4 - name: Identify assigned member and trigger work @@ -51,6 +62,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; const teamFile = '.squad/team.md'; if (!fs.existsSync(teamFile)) { @@ -61,25 +74,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) { @@ -94,20 +99,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}`, @@ -117,8 +109,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, @@ -144,8 +135,7 @@ jobs: 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, @@ -160,18 +150,21 @@ 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} — ${context.payload.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') + }); diff --git a/packages/squad-cli/templates/workflows/squad-triage.yml b/packages/squad-cli/templates/workflows/squad-triage.yml index 21e67f70c..19a3b146b 100644 --- a/packages/squad-cli/templates/workflows/squad-triage.yml +++ b/packages/squad-cli/templates/workflows/squad-triage.yml @@ -15,7 +15,7 @@ jobs: steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 #v4 - - name: Triage issue via Lead agent + - name: Triage issue via deterministic keyword routing uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b #v7 with: script: | @@ -33,7 +33,6 @@ jobs: // Check if @copilot is on the team const hasCopilot = content.includes('🤖 Coding Agent'); - const copilotAutoAssign = content.includes(''); // Parse @copilot capability profile let goodFitKeywords = []; @@ -279,19 +278,7 @@ jobs: 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}`); - } - } + // Labels route work; only squad-issue-assign.yml assigns Copilot. // Build copilot evaluation note let copilotNote = ''; @@ -305,8 +292,9 @@ jobs: // Post triage comment const comment = [ - `### 🏗️ Squad Triage — ${lead.name} (${lead.role})`, + `### 🏗️ Squad Triage — Automated keyword routing`, '', + `**Provenance:** Deterministic keyword matching against the capability profile in \`.squad/team.md\` and rules in \`.squad/routing.md\`; no Lead-agent analysis was performed.`, `**Issue:** #${issue.number} — ${issue.title}`, `**Assigned to:** ${assignedMember.name} (${assignedMember.role})`, `**Reason:** ${triageReason}`, diff --git a/packages/squad-sdk/templates/workflows/squad-heartbeat.yml b/packages/squad-sdk/templates/workflows/squad-heartbeat.yml index cc1cbe986..3f2172c75 100644 --- a/packages/squad-sdk/templates/workflows/squad-heartbeat.yml +++ b/packages/squad-sdk/templates/workflows/squad-heartbeat.yml @@ -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. on: # React to completed work or new squad work @@ -103,68 +100,4 @@ jobs: 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@f28e40c7f34bde8b3046d885e986cb6290c5673b #v7 - with: - github-token: ${{ secrets.COPILOT_ASSIGN_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const fs = require('fs'); - - const teamFile = '.squad/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(''); - 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 for team context and .squad/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. diff --git a/packages/squad-sdk/templates/workflows/squad-issue-assign.yml b/packages/squad-sdk/templates/workflows/squad-issue-assign.yml index 50df68310..d3bc8e305 100644 --- a/packages/squad-sdk/templates/workflows/squad-issue-assign.yml +++ b/packages/squad-sdk/templates/workflows/squad-issue-assign.yml @@ -14,6 +14,17 @@ jobs: if: startsWith(github.event.label.name, 'squad:') runs-on: ubuntu-latest steps: + - name: Require Copilot assignment token + if: github.event.label.name == 'squad:copilot' + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b #v7 + 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@34e114876b0b11c390a56381ad16ebd13914f8d5 #v4 - name: Identify assigned member and trigger work @@ -51,6 +62,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; const teamFile = '.squad/team.md'; if (!fs.existsSync(teamFile)) { @@ -61,25 +74,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) { @@ -94,20 +99,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}`, @@ -117,8 +109,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, @@ -144,8 +135,7 @@ jobs: 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, @@ -160,18 +150,21 @@ 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} — ${context.payload.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') + }); diff --git a/packages/squad-sdk/templates/workflows/squad-triage.yml b/packages/squad-sdk/templates/workflows/squad-triage.yml index 21e67f70c..19a3b146b 100644 --- a/packages/squad-sdk/templates/workflows/squad-triage.yml +++ b/packages/squad-sdk/templates/workflows/squad-triage.yml @@ -15,7 +15,7 @@ jobs: steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 #v4 - - name: Triage issue via Lead agent + - name: Triage issue via deterministic keyword routing uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b #v7 with: script: | @@ -33,7 +33,6 @@ jobs: // Check if @copilot is on the team const hasCopilot = content.includes('🤖 Coding Agent'); - const copilotAutoAssign = content.includes(''); // Parse @copilot capability profile let goodFitKeywords = []; @@ -279,19 +278,7 @@ jobs: 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}`); - } - } + // Labels route work; only squad-issue-assign.yml assigns Copilot. // Build copilot evaluation note let copilotNote = ''; @@ -305,8 +292,9 @@ jobs: // Post triage comment const comment = [ - `### 🏗️ Squad Triage — ${lead.name} (${lead.role})`, + `### 🏗️ Squad Triage — Automated keyword routing`, '', + `**Provenance:** Deterministic keyword matching against the capability profile in \`.squad/team.md\` and rules in \`.squad/routing.md\`; no Lead-agent analysis was performed.`, `**Issue:** #${issue.number} — ${issue.title}`, `**Assigned to:** ${assignedMember.name} (${assignedMember.role})`, `**Reason:** ${triageReason}`, diff --git a/templates/workflows/squad-heartbeat.yml b/templates/workflows/squad-heartbeat.yml index cc1cbe986..3f2172c75 100644 --- a/templates/workflows/squad-heartbeat.yml +++ b/templates/workflows/squad-heartbeat.yml @@ -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. on: # React to completed work or new squad work @@ -103,68 +100,4 @@ jobs: 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@f28e40c7f34bde8b3046d885e986cb6290c5673b #v7 - with: - github-token: ${{ secrets.COPILOT_ASSIGN_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const fs = require('fs'); - - const teamFile = '.squad/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(''); - 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 for team context and .squad/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. diff --git a/templates/workflows/squad-issue-assign.yml b/templates/workflows/squad-issue-assign.yml index 50df68310..d3bc8e305 100644 --- a/templates/workflows/squad-issue-assign.yml +++ b/templates/workflows/squad-issue-assign.yml @@ -14,6 +14,17 @@ jobs: if: startsWith(github.event.label.name, 'squad:') runs-on: ubuntu-latest steps: + - name: Require Copilot assignment token + if: github.event.label.name == 'squad:copilot' + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b #v7 + 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@34e114876b0b11c390a56381ad16ebd13914f8d5 #v4 - name: Identify assigned member and trigger work @@ -51,6 +62,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; const teamFile = '.squad/team.md'; if (!fs.existsSync(teamFile)) { @@ -61,25 +74,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) { @@ -94,20 +99,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}`, @@ -117,8 +109,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, @@ -144,8 +135,7 @@ jobs: 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, @@ -160,18 +150,21 @@ 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} — ${context.payload.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') + }); diff --git a/templates/workflows/squad-triage.yml b/templates/workflows/squad-triage.yml index 21e67f70c..19a3b146b 100644 --- a/templates/workflows/squad-triage.yml +++ b/templates/workflows/squad-triage.yml @@ -15,7 +15,7 @@ jobs: steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 #v4 - - name: Triage issue via Lead agent + - name: Triage issue via deterministic keyword routing uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b #v7 with: script: | @@ -33,7 +33,6 @@ jobs: // Check if @copilot is on the team const hasCopilot = content.includes('🤖 Coding Agent'); - const copilotAutoAssign = content.includes(''); // Parse @copilot capability profile let goodFitKeywords = []; @@ -279,19 +278,7 @@ jobs: 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}`); - } - } + // Labels route work; only squad-issue-assign.yml assigns Copilot. // Build copilot evaluation note let copilotNote = ''; @@ -305,8 +292,9 @@ jobs: // Post triage comment const comment = [ - `### 🏗️ Squad Triage — ${lead.name} (${lead.role})`, + `### 🏗️ Squad Triage — Automated keyword routing`, '', + `**Provenance:** Deterministic keyword matching against the capability profile in \`.squad/team.md\` and rules in \`.squad/routing.md\`; no Lead-agent analysis was performed.`, `**Issue:** #${issue.number} — ${issue.title}`, `**Assigned to:** ${assignedMember.name} (${assignedMember.role})`, `**Reason:** ${triageReason}`, diff --git a/test/assignment-safety-attribution.test.ts b/test/assignment-safety-attribution.test.ts new file mode 100644 index 000000000..d25280cb4 --- /dev/null +++ b/test/assignment-safety-attribution.test.ts @@ -0,0 +1,259 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { runInNewContext } from 'node:vm'; +import { parse } from 'yaml'; +import { describe, expect, it, vi } from 'vitest'; + +type Step = { + name?: string; + if?: string; + env?: Record; + with?: { script?: string; 'github-token'?: string }; +}; +type Workflow = { + on: Record; + jobs: Record; +}; + +const roots = [ + '.squad-templates', + 'templates', + join('packages', 'squad-cli', 'templates'), + join('packages', 'squad-sdk', 'templates'), + '.github', +]; +const team = `## Members +| Name | Role | +| --- | --- | +| River | Lead | +| Moss | Runtime Engineer | +| Vale | Quality Engineer | +## Coding Agent +🤖 Coding Agent + +🟢 Good fit: bug fix, test coverage, documentation +🟡 Needs review: refactoring +🔴 Not suitable: security +`; +const routing = `| Work Type | Route To | Examples | +| --- | --- | --- | +| Runtime | Moss | session lifecycle | +| Quality | Vale | regression gate | +`; +const accepted = { status: 201, data: { assignees: [{ login: 'copilot-swe-agent[bot]' }] } }; + +function load(root: string, name: string): Workflow { + return parse(readFileSync(join(root, 'workflows', name), 'utf8')) as Workflow; +} + +async function execute(workflow: Workflow, { + label = 'squad:copilot', + token = 'test-pat', + title = 'bug fix', + response = accepted, + requestError = '', +}: { + label?: string; token?: string; title?: string; + response?: unknown; requestError?: string; +} = {}) { + const calls: string[] = []; + const comments: string[] = []; + const labels: string[][] = []; + const failures: string[] = []; + const infos: string[] = []; + const issue = { number: 42, title, body: '', labels: [{ name: 'squad' }], assignees: [] }; + const context = { + repo: { owner: 'example', repo: 'project' }, + payload: { issue, label: { name: label } }, + }; + const core = { + info: (message: string) => infos.push(message), + warning: vi.fn(), + setSecret: vi.fn(), + setFailed: (message: string) => failures.push(message), + }; + const github = { + request: vi.fn(async (...args: unknown[]) => { + calls.push('agent-assignment'); + if (requestError) throw new Error(requestError); + return response; + }), + rest: { + rateLimit: { get: async () => ({ data: { rate: { remaining: 5000, limit: 5000, reset: 0 } } }) }, + repos: { get: async () => { + calls.push('repo-read'); + return { data: { default_branch: 'dev' } }; + } }, + issues: { + listForRepo: async () => ({ data: [issue] }), + get: async () => { + calls.push('issue-read'); + return response; + }, + addAssignees: async () => { + calls.push('ordinary-assignment'); + return accepted; + }, + addLabels: async (input: { labels: string[] }) => { + calls.push('label'); + labels.push(input.labels); + }, + createComment: async (input: { body: string }) => { + calls.push('comment'); + comments.push(input.body); + }, + }, + }, + }; + const files: Record = { + '.squad/team.md': team, + '.squad/routing.md': routing, + 'triage-results.json': JSON.stringify([ + { issueNumber: 42, label: 'squad:copilot', assignTo: '@copilot', reason: 'keyword', source: 'routing' }, + ]), + }; + const fs = { + existsSync: (file: string) => file in files, + readFileSync: (file: string) => { + if (!(file in files)) throw new Error(`Unexpected file read: ${file}`); + return files[file]; + }, + }; + function condition(expression?: string) { + if (!expression) return true; + return Boolean(runInNewContext( + expression + .replace(/github\.event\.label\.name/g, 'label') + .replace(/steps\.check-script\.outputs\.has_script/g, "'true'"), + { + label, startsWith: (value: string, prefix: string) => value.startsWith(prefix), + success: () => failures.length === 0, hashFiles: () => 'fixture', + }, + )); + } + for (const job of Object.values(workflow.jobs)) { + if (!condition(job.if)) continue; + for (const step of job.steps) { + if (failures.length || !condition(step.if) || !step.with?.script) continue; + const env = Object.fromEntries(Object.entries(step.env ?? {}).map(([key, value]) => { + if (value !== '${{ secrets.COPILOT_ASSIGN_TOKEN }}') { + throw new Error(`Unsupported env expression in ${step.name}: ${value}`); + } + return [key, token]; + })); + try { + await runInNewContext(`(async () => { ${step.with.script}\n })()`, { + context, github, core, process: { env }, + require: (name: string) => { + if (name !== 'fs') throw new Error(`Unexpected require: ${name}`); + return fs; + }, + }, { timeout: 1000 }); + } catch (error) { + failures.push(`${step.name}: ${String(error)}`); + } + } + } + return { calls, comments, labels, failures, infos, github }; +} + +for (const root of roots) { + describe(`${root}: executable assignment and attribution contract`, () => { + const assignment = () => load(root, 'squad-issue-assign.yml'); + + it('missing COPILOT_ASSIGN_TOKEN fails before any routing comment or API mutation', async () => { + const result = await execute(assignment(), { token: '' }); + expect(result.failures.join('\n'), `${root}: missing token must visibly fail`).toContain('COPILOT_ASSIGN_TOKEN'); + expect(result.calls, `${root}: missing token must not route or attempt assignment`).toEqual([]); + }); + + it.each([ + ['API 403', accepted, '403 Resource not accessible'], + ['missing assignee', { status: 201, data: { assignees: [] } }, ''], + ['missing data', { status: 201 }, ''], + ['null response', null, ''], + ['malformed assignees', { status: 201, data: { assignees: 'copilot-swe-agent[bot]' } }, ''], + ['ordinary bot login', { status: 201, data: { assignees: [{ login: 'copilot-swe-agent' }] } }, ''], + ['failed status', { status: 403, data: accepted.data }, ''], + ])('%s cannot claim handoff or fall back to ordinary assignment', async (input, response, requestError) => { + const result = await execute(assignment(), { response, requestError }); + expect(result.failures.length, `${root}: ${input} must fail visibly`).toBeGreaterThan(0); + expect(result.calls, `${root}: ${input} must never use ordinary bot assignment`).not.toContain('ordinary-assignment'); + expect(result.comments, `${root}: ${input} must not announce successful routing`).toEqual([]); + }); + + it('acknowledges only the verified API acceptance, after assignment, without promising a session', async () => { + const result = await execute(assignment()); + expect(result.failures, root).toEqual([]); + expect(result.github.request).toHaveBeenCalledExactlyOnceWith( + 'POST /repos/{owner}/{repo}/issues/{issue_number}/assignees', + expect.objectContaining({ + assignees: ['copilot-swe-agent[bot]'], + agent_assignment: expect.objectContaining({ target_repo: 'example/project', base_branch: 'dev' }), + }), + ); + expect(result.calls.indexOf('comment'), `${root}: acknowledgment must follow verified assignment`) + .toBeGreaterThan(result.calls.indexOf('agent-assignment')); + expect(result.comments.join('\n'), root).toContain('API accepted'); + expect(result.comments.join('\n'), root).toContain('session'); + expect(result.comments.join('\n'), root).not.toContain('will pick this up automatically'); + const step = Object.values(assignment().jobs).flatMap(job => job.steps) + .find(step => step.name === 'Assign @copilot coding agent'); + expect(step?.with?.['github-token'], `${root}: assignment must use only the explicit PAT`) + .toBe('${{ secrets.COPILOT_ASSIGN_TOKEN }}'); + }); + + it('preserves named member routing without requiring a Copilot token', async () => { + const result = await execute(assignment(), { label: 'squad:moss', token: '' }); + expect(result.failures, root).toEqual([]); + expect(result.comments.join('\n'), root).toContain('Assigned to Moss (Runtime Engineer)'); + expect(result.calls, root).not.toContain('agent-assignment'); + }); + + it('heartbeat monitors and labels but never assigns Copilot', async () => { + const workflow = load(root, 'squad-heartbeat.yml'); + expect(workflow.on, `${root}: monitoring triggers must remain`).toMatchObject({ + issues: { types: ['closed', 'labeled'] }, + pull_request_target: { types: ['closed'] }, + workflow_dispatch: null, + }); + const result = await execute(workflow); + expect(result.failures, root).toEqual([]); + expect(result.labels, `${root}: heartbeat must still apply triage decisions`).toContainEqual(['squad:copilot']); + expect(result.calls, `${root}: heartbeat cannot compete with issue-assign`).not.toContain('agent-assignment'); + expect(result.calls, root).not.toContain('ordinary-assignment'); + }); + + it.each([ + ['bug fix', 'squad:copilot', 'matches capability profile'], + ['refactoring', 'squad:copilot', 'needs review'], + ['runtime session lifecycle', 'squad:moss', 'Matched routing rule'], + ['security regression gate', 'squad:vale', 'Matched routing rule'], + ['unknown subject', 'squad:river', 'No specific domain match'], + ['runtime quality', 'squad:river', 'No specific domain match'], + ])('triage "%s" preserves routing and reports deterministic provenance, not Lead analysis', async (title, label, reason) => { + const result = await execute(load(root, 'squad-triage.yml'), { label: 'squad', title }); + expect(result.failures, `${root}: ${title}`).toEqual([]); + expect(result.labels, `${root}: ${title} routing changed`).toEqual([[label], ['go:needs-research']]); + expect(result.comments.join('\n'), `${root}: ${title}`).toContain(reason); + expect(result.comments.join('\n'), `${root}: ${title} must disclose keyword provenance`) + .toContain('Deterministic keyword matching'); + expect(result.comments.join('\n'), `${root}: ${title}`).toContain('.squad/routing.md'); + expect(result.comments.join('\n'), `${root}: ${title} must not impersonate Lead analysis`) + .not.toContain('Squad Triage — River'); + expect(result.calls, `${root}: ${title} labels delegate to sole assignment authority`) + .not.toContain('ordinary-assignment'); + expect(result.calls, root).not.toContain('agent-assignment'); + }); + }); +} + +it('root, CLI and SDK workflow mirrors exactly match canonical source', () => { + for (const name of ['squad-issue-assign.yml', 'squad-heartbeat.yml', 'squad-triage.yml']) { + const canonical = readFileSync(join(roots[0], 'workflows', name), 'utf8').replace(/\r\n/g, '\n'); + for (const root of roots.slice(1, 4)) { + expect(readFileSync(join(root, 'workflows', name), 'utf8').replace(/\r\n/g, '\n'), + `${root}: ${name} differs from canonical source`).toBe(canonical); + } + } +}); From 95b28d0d59b2d10923fb3788999210db34cd7196 Mon Sep 17 00:00:00 2001 From: brady gaster Date: Fri, 2 Oct 2026 17:30:18 -0700 Subject: [PATCH 2/4] fix(sdk): scope team markdown parsing Closes #2079 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .changeset/2079-team-roster-table-scope.md | 5 ++ .../src/config/markdown-migration.ts | 20 +++++- test/cli/health-command.test.ts | 66 +++++++++++++++++-- test/migration.test.ts | 31 +++++++++ 4 files changed, 113 insertions(+), 9 deletions(-) create mode 100644 .changeset/2079-team-roster-table-scope.md diff --git a/.changeset/2079-team-roster-table-scope.md b/.changeset/2079-team-roster-table-scope.md new file mode 100644 index 000000000..76903c150 --- /dev/null +++ b/.changeset/2079-team-roster-table-scope.md @@ -0,0 +1,5 @@ +--- +"@bradygaster/squad-sdk": patch +--- + +Limit team markdown table parsing to supported roster sections so auxiliary tables do not inflate agent counts. diff --git a/packages/squad-sdk/src/config/markdown-migration.ts b/packages/squad-sdk/src/config/markdown-migration.ts index 4024d91c4..a29b9b4ad 100644 --- a/packages/squad-sdk/src/config/markdown-migration.ts +++ b/packages/squad-sdk/src/config/markdown-migration.ts @@ -122,7 +122,7 @@ export interface MarkdownMigrationResult { * Parses team.md content into agent configurations. * * Supports two formats: - * 1. Table format: | Name | Role | Skills | Model | + * 1. Table format within a roster section: | Name | Role | Skills | Model | * 2. Section format: ## Agent Name \n Role: ... \n Skills: ... * * @param content - Raw team.md content @@ -232,14 +232,28 @@ export function parseTeamMarkdown(content: string): { agents: ParsedAgent[]; war function parseTeamTable(lines: string[]): ParsedAgent[] { const agents: ParsedAgent[] = []; let headerCols: string[] | null = null; + let inRosterSection = false; for (const line of lines) { const trimmed = line.trim(); - if (!trimmed.startsWith('|')) continue; + const headingMatch = trimmed.match(/^(#{1,6})\s+(.+?)\s*#*$/); + if (headingMatch) { + const heading = headingMatch[2]!.trim(); + inRosterSection = + headingMatch[1] === '##' && + /^(?:roster|members?|team\s+members?|agents?)$/i.test(heading); + headerCols = null; + continue; + } + + if (!trimmed.startsWith('|')) { + headerCols = null; + continue; + } + if (!inRosterSection) continue; const cells = trimmed.split('|').map((c) => c.trim()).filter(Boolean); - // Reset the column mapping for each roster table in the document. if (cells.some((c) => /^name$/i.test(c.replace(/[*_`]/g, '').trim()))) { headerCols = cells.map((c) => c.toLowerCase()); continue; diff --git a/test/cli/health-command.test.ts b/test/cli/health-command.test.ts index 8ed67bc51..8dc76bed1 100644 --- a/test/cli/health-command.test.ts +++ b/test/cli/health-command.test.ts @@ -23,11 +23,34 @@ const { mockResolveStateBackend, mockVerifyStateBackend } = vi.hoisted(() => ({ mockVerifyStateBackend: vi.fn(), })); -vi.mock('@bradygaster/squad-sdk', async (importOriginal) => { - const actual = - await importOriginal(); +vi.mock('@bradygaster/squad-sdk', async () => { + const fs = await import('node:fs'); + const pathModule = await import('node:path'); return { - ...actual, + FSStorageProvider: class { + existsSync(filePath: string): boolean { + return fs.existsSync(filePath); + } + + readSync(filePath: string): string { + return fs.readFileSync(filePath, 'utf8'); + } + }, + loadDirConfig(squadDir: string): Record | undefined { + try { + return JSON.parse( + fs.readFileSync(pathModule.join(squadDir, 'config.json'), 'utf8'), + ) as Record; + } catch { + return undefined; + } + }, + resolveExternalStateDir(projectKey: string): string { + if (!projectKey || projectKey.split(/[\\/]/).includes('..')) { + throw new Error('invalid project key'); + } + return pathModule.resolve(projectKey); + }, resolveStateBackend: mockResolveStateBackend, verifyStateBackend: mockVerifyStateBackend, }; @@ -246,13 +269,44 @@ describe('team readiness', () => { expect(result.diagnostics).toEqual(['duplicate: alpha']); }); - it('parses multiple roster tables with their own column contracts', () => { + it('counts only the Members table, not the Coordinator table', () => { writeSquad('team.md', MULTI_TABLE_TEAM); const result = check(runSquadHealth(squadDir, repoRoot), 'team'); expect(result.status).toBe('pass'); - expect(result.message).toContain('2 members'); + expect(result.message).toBe('team.md is valid (1 members)'); + }); + + it('reports only roster members when team.md contains auxiliary tables', () => { + writeSquad( + 'team.md', + `${TEAM} + +## Human Members + +| Name | Role | Skills | +|------|------|--------| +| Casey | Product Owner | Planning | + +## Existing Project Agents Reused + +| Name | Role | Skills | +|------|------|--------| +| Existing | Engineer | Reuse | + +## Project Notes + +| Description | Owner | Status | +|-------------|-------|--------| +| A project note | Alpha | Current | +`, + ); + + const result = check(runSquadHealth(squadDir, repoRoot), 'team'); + + expect(result.status).toBe('pass'); + expect(result.message).toBe('team.md is valid (1 members)'); }); }); diff --git a/test/migration.test.ts b/test/migration.test.ts index 479236019..1ec2d3995 100644 --- a/test/migration.test.ts +++ b/test/migration.test.ts @@ -309,6 +309,37 @@ describe('MarkdownMigration', () => { expect(warnings).toHaveLength(0); }); + it('should ignore tables outside supported roster sections', () => { + const md = ` +## Members + +| Name | Role | Skills | +|------|------|--------| +| Alpha | Developer | TypeScript | + +## Human Members + +| Name | Role | Skills | +|------|------|--------| +| Casey | Product Owner | Planning | + +## Existing Project Agents Reused + +| Name | Role | Skills | +|------|------|--------| +| Existing | Engineer | Reuse | + +## Project Notes + +| Description | Owner | Status | +|-------------|-------|--------| +| A project note | Alpha | Current | +`; + const { agents } = parseTeamMarkdown(md); + + expect(agents.map((agent) => agent.name)).toEqual(['alpha']); + }); + it('should parse section format', () => { const md = ` ## Team Members From 020173fa51636b12ed43c94371cd0a473edb7bf0 Mon Sep 17 00:00:00 2001 From: brady gaster Date: Fri, 2 Oct 2026 17:34:29 -0700 Subject: [PATCH 3/4] docs: clarify Ralph merge authority (Closes #2077) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .changeset/tidy-ralph-guidance.md | 6 ++ .squad-templates/ralph-reference.md | 56 ++++++++++--------- .squad/templates/ralph-reference.md | 56 ++++++++++--------- .../squad-cli/templates/ralph-reference.md | 56 ++++++++++--------- .../squad-sdk/templates/ralph-reference.md | 56 ++++++++++--------- templates/ralph-reference.md | 56 ++++++++++--------- test/ralph-reference-authority.test.ts | 55 ++++++++++++++++++ 7 files changed, 216 insertions(+), 125 deletions(-) create mode 100644 .changeset/tidy-ralph-guidance.md create mode 100644 test/ralph-reference-authority.test.ts diff --git a/.changeset/tidy-ralph-guidance.md b/.changeset/tidy-ralph-guidance.md new file mode 100644 index 000000000..ff3378dbf --- /dev/null +++ b/.changeset/tidy-ralph-guidance.md @@ -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. diff --git a/.squad-templates/ralph-reference.md b/.squad-templates/ralph-reference.md index 3d8b2b440..b1b13d877 100644 --- a/.squad-templates/ralph-reference.md +++ b/.squad-templates/ralph-reference.md @@ -2,9 +2,9 @@ ## Ralph — Work Monitor -Ralph is a built-in squad member whose job is keeping tabs on work. **Ralph tracks and drives the work queue.** Always on the roster, one job: make sure the team never sits idle. +Ralph is a built-in squad member whose job is keeping tabs on work. **Ralph is monitor/triage-only:** it scans, categorizes, and reports work so the coordinator can keep the queue moving. Ralph does not implement changes, dispatch agents, approve or merge pull requests, or close issues. -**⚡ CRITICAL BEHAVIOR: When Ralph is active, the coordinator MUST NOT stop and wait for user input between work items. Ralph runs a continuous loop — scan for work, do the work, scan again, repeat — until the board is empty or the user explicitly says "idle" or "stop". This is not optional. If work exists, keep going. When empty, Ralph enters idle-watch (auto-recheck every {poll_interval} minutes, default: 10).** +**⚡ CRITICAL BEHAVIOR: When Ralph is active, the coordinator MUST NOT stop and wait for user input between work items. Ralph runs a continuous monitoring loop — scan for work, report findings to the coordinator, scan again, repeat — until the board is empty or the user explicitly says "idle" or "stop". This is not optional. If work exists, keep coordinating it. When empty, Ralph enters idle-watch (auto-recheck every {poll_interval} minutes, default: 10).** **Between checks:** Ralph's in-session loop runs while work exists. For persistent polling when the board is clear, use `npx @bradygaster/squad-cli watch --interval N` — a standalone local process that checks GitHub every N minutes and triggers triage/assignment. See [Watch Mode](#watch-mode-squad-watch). @@ -23,12 +23,12 @@ Ralph always appears in `team.md`: `| Ralph | Work Monitor | — | 🔄 Monitor | "Ralph, check every N minutes" | Set idle-watch polling interval | | "Ralph, idle" / "Take a break" / "Stop monitoring" | Fully deactivate (stop loop + idle-watch) | | "Ralph, scope: just issues" / "Ralph, skip CI" | Adjust what Ralph monitors this session | -| References PR feedback or changes requested | Spawn agent to address PR review feedback | -| "merge PR #N" / "merge it" (recent context) | Merge via `gh pr merge` | +| References PR feedback or changes requested | Report it to the coordinator for routing to the PR author agent | +| "merge PR #N" / "merge it" (recent context) | Report the PR status to the coordinator; only a human may merge | These are intent signals, not exact strings — match meaning, not words. -When Ralph is active, run this check cycle after every batch of agent work completes (or immediately on activation): +When Ralph is active, run this check cycle after every batch of coordinator-managed agent work completes (or immediately on activation): **Step 1 — Scan for work** (run these in parallel): @@ -50,19 +50,25 @@ gh pr list --state open --draft --json number,title,author,labels,checks --limit | Category | Signal | Action | |----------|--------|--------| -| **Untriaged issues** | `squad` label, no `squad:{member}` label | Lead triages: reads issue, assigns `squad:{member}` label | -| **Assigned but unstarted** | `squad:{member}` label, no assignee or no PR | Spawn the assigned agent to pick it up | -| **Draft PRs** | PR in draft from squad member | Check if agent needs to continue; if stalled, nudge | -| **Review feedback** | PR has `CHANGES_REQUESTED` review | Route feedback to PR author agent to address | -| **CI failures** | PR checks failing | Notify assigned agent to fix, or create a fix issue | -| **Approved PRs** | PR approved, CI green, ready to merge | Merge and close related issue | +| **Untriaged issues** | `squad` label, no `squad:{member}` label | Report to the coordinator for triage and assignment | +| **Assigned but unstarted** | `squad:{member}` label, no assignee or no PR | Report to the coordinator for agent dispatch | +| **Draft PRs** | PR in draft from squad member | Report status; the coordinator decides whether the agent should continue | +| **Review feedback** | PR has `CHANGES_REQUESTED` review | Report feedback to the coordinator for routing to the PR author agent | +| **CI failures** | PR checks failing | Report to the coordinator to route a fix or create a fix issue | +| **Approved PRs** | PR approved, CI green, ready for pre-merge review | Report readiness to the coordinator for verification and hand-off to a human; Ralph does not merge or close issues | | **No work found** | All clear | Report: "📋 Board is clear. Ralph is idling." Suggest `npx @bradygaster/squad-cli watch` for persistent polling. | -**Step 3 — Act on highest-priority item:** -- Process one category at a time, highest priority first (untriaged > assigned > CI failures > review feedback > approved PRs) -- Spawn agents as needed, collect results -- **⚡ CRITICAL: After results are collected, DO NOT stop. DO NOT wait for user input. IMMEDIATELY go back to Step 1 and scan again.** This is a loop — Ralph keeps cycling until the board is clear or the user says "idle". Each cycle is one "round". -- If multiple items exist in the same category, process them in parallel (spawn multiple agents) +### Authority boundaries + +- **Ralph is monitor/triage-only.** It detects, categorizes, and reports work; it does not dispatch agents, approve or merge pull requests, or close issues. +- **The coordinator owns pre-merge readiness and hand-off.** The coordinator verifies repository requirements, reviews, checks, and feedback, then presents a ready pull request to a human. The coordinator does not merge. +- **Only a human may merge pull requests.** After a human merge, the coordinator follows repository issue-closing policy; Ralph only reports status and never closes the issue. + +**Step 3 — Report and continue monitoring:** +- Report one category at a time, highest priority first (untriaged > assigned > CI failures > review feedback > approved PRs) +- The coordinator triages, assigns, dispatches agents, and coordinates follow-up work; Ralph reports what it detects. +- **⚡ CRITICAL: After the coordinator collects results, DO NOT stop. DO NOT wait for user input. IMMEDIATELY go back to Step 1 and scan again.** This is a loop — Ralph keeps monitoring until the board is clear or the user says "idle". Each cycle is one "round". +- If multiple items exist in the same category, report them together so the coordinator can process them in parallel. **Step 4 — Periodic check-in** (every 3-5 rounds): @@ -70,7 +76,7 @@ After every 3-5 rounds, pause and report before continuing: ``` 🔄 Ralph: Round {N} complete. - ✅ {X} issues closed, {Y} PRs merged + ✅ {X} follow-ups reported, {Y} PRs handed off for human review 📋 {Z} items remaining: {brief list} Continuing... (say "Ralph, idle" to stop) ``` @@ -107,7 +113,7 @@ Ralph's state is session-scoped (not persisted to disk): - **Active/idle** — whether the loop is running - **Round count** — how many check cycles completed - **Scope** — what categories to monitor (default: all) -- **Stats** — issues closed, PRs merged, items processed this session +- **Stats** — findings reported and items handed off this session ### Ralph on the Board @@ -120,22 +126,22 @@ When Ralph reports status, use this format: 🔴 Untriaged: 2 issues need triage 🟡 In Progress: 3 issues assigned, 1 draft PR 🟢 Ready: 1 PR approved, awaiting merge - ✅ Done: 5 issues closed this session + ✅ Reported: 5 findings reported this session -Next action: Triaging #42 — "Fix auth endpoint timeout" +Next action: Coordinator triages #42 — "Fix auth endpoint timeout" ``` ### Integration with Follow-Up Work -After the coordinator's step 6 ("Immediately assess: Does anything trigger follow-up work?"), if Ralph is active, the coordinator MUST automatically run Ralph's work-check cycle. **Do NOT return control to the user.** This creates a continuous pipeline: +After the coordinator's step 6 ("Immediately assess: Does anything trigger follow-up work?"), if Ralph is active, the coordinator MUST automatically run Ralph's monitoring cycle. **Do NOT return control to the user.** This creates a continuous pipeline: 1. User activates Ralph → work-check cycle runs -2. Work found → agents spawned → results collected -3. Follow-up work assessed → more agents if needed +2. Ralph reports findings → coordinator triages and coordinates work +3. Coordinator-managed agents work → results collected and follow-up assessed 4. Ralph scans GitHub again (Step 1) → IMMEDIATELY, no pause -5. More work found → repeat from step 2 +5. More work found → coordinator acts on it; Ralph continues monitoring 6. No more work → "📋 Board is clear. Ralph is idling." (suggest `npx @bradygaster/squad-cli watch` for persistent polling) -**Ralph does NOT ask "should I continue?" — Ralph KEEPS GOING.** Only stops on explicit "idle"/"stop" or session end. A clear board → idle-watch, not full stop. For persistent monitoring after the board clears, use `npx @bradygaster/squad-cli watch`. +**Ralph does NOT ask "should I continue?" — Ralph KEEPS MONITORING.** Only stops on explicit "idle"/"stop" or session end. A clear board → idle-watch, not full stop. For persistent monitoring after the board clears, use `npx @bradygaster/squad-cli watch`. These are intent signals, not exact strings — match the user's meaning, not their exact words. diff --git a/.squad/templates/ralph-reference.md b/.squad/templates/ralph-reference.md index 3d8b2b440..b1b13d877 100644 --- a/.squad/templates/ralph-reference.md +++ b/.squad/templates/ralph-reference.md @@ -2,9 +2,9 @@ ## Ralph — Work Monitor -Ralph is a built-in squad member whose job is keeping tabs on work. **Ralph tracks and drives the work queue.** Always on the roster, one job: make sure the team never sits idle. +Ralph is a built-in squad member whose job is keeping tabs on work. **Ralph is monitor/triage-only:** it scans, categorizes, and reports work so the coordinator can keep the queue moving. Ralph does not implement changes, dispatch agents, approve or merge pull requests, or close issues. -**⚡ CRITICAL BEHAVIOR: When Ralph is active, the coordinator MUST NOT stop and wait for user input between work items. Ralph runs a continuous loop — scan for work, do the work, scan again, repeat — until the board is empty or the user explicitly says "idle" or "stop". This is not optional. If work exists, keep going. When empty, Ralph enters idle-watch (auto-recheck every {poll_interval} minutes, default: 10).** +**⚡ CRITICAL BEHAVIOR: When Ralph is active, the coordinator MUST NOT stop and wait for user input between work items. Ralph runs a continuous monitoring loop — scan for work, report findings to the coordinator, scan again, repeat — until the board is empty or the user explicitly says "idle" or "stop". This is not optional. If work exists, keep coordinating it. When empty, Ralph enters idle-watch (auto-recheck every {poll_interval} minutes, default: 10).** **Between checks:** Ralph's in-session loop runs while work exists. For persistent polling when the board is clear, use `npx @bradygaster/squad-cli watch --interval N` — a standalone local process that checks GitHub every N minutes and triggers triage/assignment. See [Watch Mode](#watch-mode-squad-watch). @@ -23,12 +23,12 @@ Ralph always appears in `team.md`: `| Ralph | Work Monitor | — | 🔄 Monitor | "Ralph, check every N minutes" | Set idle-watch polling interval | | "Ralph, idle" / "Take a break" / "Stop monitoring" | Fully deactivate (stop loop + idle-watch) | | "Ralph, scope: just issues" / "Ralph, skip CI" | Adjust what Ralph monitors this session | -| References PR feedback or changes requested | Spawn agent to address PR review feedback | -| "merge PR #N" / "merge it" (recent context) | Merge via `gh pr merge` | +| References PR feedback or changes requested | Report it to the coordinator for routing to the PR author agent | +| "merge PR #N" / "merge it" (recent context) | Report the PR status to the coordinator; only a human may merge | These are intent signals, not exact strings — match meaning, not words. -When Ralph is active, run this check cycle after every batch of agent work completes (or immediately on activation): +When Ralph is active, run this check cycle after every batch of coordinator-managed agent work completes (or immediately on activation): **Step 1 — Scan for work** (run these in parallel): @@ -50,19 +50,25 @@ gh pr list --state open --draft --json number,title,author,labels,checks --limit | Category | Signal | Action | |----------|--------|--------| -| **Untriaged issues** | `squad` label, no `squad:{member}` label | Lead triages: reads issue, assigns `squad:{member}` label | -| **Assigned but unstarted** | `squad:{member}` label, no assignee or no PR | Spawn the assigned agent to pick it up | -| **Draft PRs** | PR in draft from squad member | Check if agent needs to continue; if stalled, nudge | -| **Review feedback** | PR has `CHANGES_REQUESTED` review | Route feedback to PR author agent to address | -| **CI failures** | PR checks failing | Notify assigned agent to fix, or create a fix issue | -| **Approved PRs** | PR approved, CI green, ready to merge | Merge and close related issue | +| **Untriaged issues** | `squad` label, no `squad:{member}` label | Report to the coordinator for triage and assignment | +| **Assigned but unstarted** | `squad:{member}` label, no assignee or no PR | Report to the coordinator for agent dispatch | +| **Draft PRs** | PR in draft from squad member | Report status; the coordinator decides whether the agent should continue | +| **Review feedback** | PR has `CHANGES_REQUESTED` review | Report feedback to the coordinator for routing to the PR author agent | +| **CI failures** | PR checks failing | Report to the coordinator to route a fix or create a fix issue | +| **Approved PRs** | PR approved, CI green, ready for pre-merge review | Report readiness to the coordinator for verification and hand-off to a human; Ralph does not merge or close issues | | **No work found** | All clear | Report: "📋 Board is clear. Ralph is idling." Suggest `npx @bradygaster/squad-cli watch` for persistent polling. | -**Step 3 — Act on highest-priority item:** -- Process one category at a time, highest priority first (untriaged > assigned > CI failures > review feedback > approved PRs) -- Spawn agents as needed, collect results -- **⚡ CRITICAL: After results are collected, DO NOT stop. DO NOT wait for user input. IMMEDIATELY go back to Step 1 and scan again.** This is a loop — Ralph keeps cycling until the board is clear or the user says "idle". Each cycle is one "round". -- If multiple items exist in the same category, process them in parallel (spawn multiple agents) +### Authority boundaries + +- **Ralph is monitor/triage-only.** It detects, categorizes, and reports work; it does not dispatch agents, approve or merge pull requests, or close issues. +- **The coordinator owns pre-merge readiness and hand-off.** The coordinator verifies repository requirements, reviews, checks, and feedback, then presents a ready pull request to a human. The coordinator does not merge. +- **Only a human may merge pull requests.** After a human merge, the coordinator follows repository issue-closing policy; Ralph only reports status and never closes the issue. + +**Step 3 — Report and continue monitoring:** +- Report one category at a time, highest priority first (untriaged > assigned > CI failures > review feedback > approved PRs) +- The coordinator triages, assigns, dispatches agents, and coordinates follow-up work; Ralph reports what it detects. +- **⚡ CRITICAL: After the coordinator collects results, DO NOT stop. DO NOT wait for user input. IMMEDIATELY go back to Step 1 and scan again.** This is a loop — Ralph keeps monitoring until the board is clear or the user says "idle". Each cycle is one "round". +- If multiple items exist in the same category, report them together so the coordinator can process them in parallel. **Step 4 — Periodic check-in** (every 3-5 rounds): @@ -70,7 +76,7 @@ After every 3-5 rounds, pause and report before continuing: ``` 🔄 Ralph: Round {N} complete. - ✅ {X} issues closed, {Y} PRs merged + ✅ {X} follow-ups reported, {Y} PRs handed off for human review 📋 {Z} items remaining: {brief list} Continuing... (say "Ralph, idle" to stop) ``` @@ -107,7 +113,7 @@ Ralph's state is session-scoped (not persisted to disk): - **Active/idle** — whether the loop is running - **Round count** — how many check cycles completed - **Scope** — what categories to monitor (default: all) -- **Stats** — issues closed, PRs merged, items processed this session +- **Stats** — findings reported and items handed off this session ### Ralph on the Board @@ -120,22 +126,22 @@ When Ralph reports status, use this format: 🔴 Untriaged: 2 issues need triage 🟡 In Progress: 3 issues assigned, 1 draft PR 🟢 Ready: 1 PR approved, awaiting merge - ✅ Done: 5 issues closed this session + ✅ Reported: 5 findings reported this session -Next action: Triaging #42 — "Fix auth endpoint timeout" +Next action: Coordinator triages #42 — "Fix auth endpoint timeout" ``` ### Integration with Follow-Up Work -After the coordinator's step 6 ("Immediately assess: Does anything trigger follow-up work?"), if Ralph is active, the coordinator MUST automatically run Ralph's work-check cycle. **Do NOT return control to the user.** This creates a continuous pipeline: +After the coordinator's step 6 ("Immediately assess: Does anything trigger follow-up work?"), if Ralph is active, the coordinator MUST automatically run Ralph's monitoring cycle. **Do NOT return control to the user.** This creates a continuous pipeline: 1. User activates Ralph → work-check cycle runs -2. Work found → agents spawned → results collected -3. Follow-up work assessed → more agents if needed +2. Ralph reports findings → coordinator triages and coordinates work +3. Coordinator-managed agents work → results collected and follow-up assessed 4. Ralph scans GitHub again (Step 1) → IMMEDIATELY, no pause -5. More work found → repeat from step 2 +5. More work found → coordinator acts on it; Ralph continues monitoring 6. No more work → "📋 Board is clear. Ralph is idling." (suggest `npx @bradygaster/squad-cli watch` for persistent polling) -**Ralph does NOT ask "should I continue?" — Ralph KEEPS GOING.** Only stops on explicit "idle"/"stop" or session end. A clear board → idle-watch, not full stop. For persistent monitoring after the board clears, use `npx @bradygaster/squad-cli watch`. +**Ralph does NOT ask "should I continue?" — Ralph KEEPS MONITORING.** Only stops on explicit "idle"/"stop" or session end. A clear board → idle-watch, not full stop. For persistent monitoring after the board clears, use `npx @bradygaster/squad-cli watch`. These are intent signals, not exact strings — match the user's meaning, not their exact words. diff --git a/packages/squad-cli/templates/ralph-reference.md b/packages/squad-cli/templates/ralph-reference.md index 3d8b2b440..b1b13d877 100644 --- a/packages/squad-cli/templates/ralph-reference.md +++ b/packages/squad-cli/templates/ralph-reference.md @@ -2,9 +2,9 @@ ## Ralph — Work Monitor -Ralph is a built-in squad member whose job is keeping tabs on work. **Ralph tracks and drives the work queue.** Always on the roster, one job: make sure the team never sits idle. +Ralph is a built-in squad member whose job is keeping tabs on work. **Ralph is monitor/triage-only:** it scans, categorizes, and reports work so the coordinator can keep the queue moving. Ralph does not implement changes, dispatch agents, approve or merge pull requests, or close issues. -**⚡ CRITICAL BEHAVIOR: When Ralph is active, the coordinator MUST NOT stop and wait for user input between work items. Ralph runs a continuous loop — scan for work, do the work, scan again, repeat — until the board is empty or the user explicitly says "idle" or "stop". This is not optional. If work exists, keep going. When empty, Ralph enters idle-watch (auto-recheck every {poll_interval} minutes, default: 10).** +**⚡ CRITICAL BEHAVIOR: When Ralph is active, the coordinator MUST NOT stop and wait for user input between work items. Ralph runs a continuous monitoring loop — scan for work, report findings to the coordinator, scan again, repeat — until the board is empty or the user explicitly says "idle" or "stop". This is not optional. If work exists, keep coordinating it. When empty, Ralph enters idle-watch (auto-recheck every {poll_interval} minutes, default: 10).** **Between checks:** Ralph's in-session loop runs while work exists. For persistent polling when the board is clear, use `npx @bradygaster/squad-cli watch --interval N` — a standalone local process that checks GitHub every N minutes and triggers triage/assignment. See [Watch Mode](#watch-mode-squad-watch). @@ -23,12 +23,12 @@ Ralph always appears in `team.md`: `| Ralph | Work Monitor | — | 🔄 Monitor | "Ralph, check every N minutes" | Set idle-watch polling interval | | "Ralph, idle" / "Take a break" / "Stop monitoring" | Fully deactivate (stop loop + idle-watch) | | "Ralph, scope: just issues" / "Ralph, skip CI" | Adjust what Ralph monitors this session | -| References PR feedback or changes requested | Spawn agent to address PR review feedback | -| "merge PR #N" / "merge it" (recent context) | Merge via `gh pr merge` | +| References PR feedback or changes requested | Report it to the coordinator for routing to the PR author agent | +| "merge PR #N" / "merge it" (recent context) | Report the PR status to the coordinator; only a human may merge | These are intent signals, not exact strings — match meaning, not words. -When Ralph is active, run this check cycle after every batch of agent work completes (or immediately on activation): +When Ralph is active, run this check cycle after every batch of coordinator-managed agent work completes (or immediately on activation): **Step 1 — Scan for work** (run these in parallel): @@ -50,19 +50,25 @@ gh pr list --state open --draft --json number,title,author,labels,checks --limit | Category | Signal | Action | |----------|--------|--------| -| **Untriaged issues** | `squad` label, no `squad:{member}` label | Lead triages: reads issue, assigns `squad:{member}` label | -| **Assigned but unstarted** | `squad:{member}` label, no assignee or no PR | Spawn the assigned agent to pick it up | -| **Draft PRs** | PR in draft from squad member | Check if agent needs to continue; if stalled, nudge | -| **Review feedback** | PR has `CHANGES_REQUESTED` review | Route feedback to PR author agent to address | -| **CI failures** | PR checks failing | Notify assigned agent to fix, or create a fix issue | -| **Approved PRs** | PR approved, CI green, ready to merge | Merge and close related issue | +| **Untriaged issues** | `squad` label, no `squad:{member}` label | Report to the coordinator for triage and assignment | +| **Assigned but unstarted** | `squad:{member}` label, no assignee or no PR | Report to the coordinator for agent dispatch | +| **Draft PRs** | PR in draft from squad member | Report status; the coordinator decides whether the agent should continue | +| **Review feedback** | PR has `CHANGES_REQUESTED` review | Report feedback to the coordinator for routing to the PR author agent | +| **CI failures** | PR checks failing | Report to the coordinator to route a fix or create a fix issue | +| **Approved PRs** | PR approved, CI green, ready for pre-merge review | Report readiness to the coordinator for verification and hand-off to a human; Ralph does not merge or close issues | | **No work found** | All clear | Report: "📋 Board is clear. Ralph is idling." Suggest `npx @bradygaster/squad-cli watch` for persistent polling. | -**Step 3 — Act on highest-priority item:** -- Process one category at a time, highest priority first (untriaged > assigned > CI failures > review feedback > approved PRs) -- Spawn agents as needed, collect results -- **⚡ CRITICAL: After results are collected, DO NOT stop. DO NOT wait for user input. IMMEDIATELY go back to Step 1 and scan again.** This is a loop — Ralph keeps cycling until the board is clear or the user says "idle". Each cycle is one "round". -- If multiple items exist in the same category, process them in parallel (spawn multiple agents) +### Authority boundaries + +- **Ralph is monitor/triage-only.** It detects, categorizes, and reports work; it does not dispatch agents, approve or merge pull requests, or close issues. +- **The coordinator owns pre-merge readiness and hand-off.** The coordinator verifies repository requirements, reviews, checks, and feedback, then presents a ready pull request to a human. The coordinator does not merge. +- **Only a human may merge pull requests.** After a human merge, the coordinator follows repository issue-closing policy; Ralph only reports status and never closes the issue. + +**Step 3 — Report and continue monitoring:** +- Report one category at a time, highest priority first (untriaged > assigned > CI failures > review feedback > approved PRs) +- The coordinator triages, assigns, dispatches agents, and coordinates follow-up work; Ralph reports what it detects. +- **⚡ CRITICAL: After the coordinator collects results, DO NOT stop. DO NOT wait for user input. IMMEDIATELY go back to Step 1 and scan again.** This is a loop — Ralph keeps monitoring until the board is clear or the user says "idle". Each cycle is one "round". +- If multiple items exist in the same category, report them together so the coordinator can process them in parallel. **Step 4 — Periodic check-in** (every 3-5 rounds): @@ -70,7 +76,7 @@ After every 3-5 rounds, pause and report before continuing: ``` 🔄 Ralph: Round {N} complete. - ✅ {X} issues closed, {Y} PRs merged + ✅ {X} follow-ups reported, {Y} PRs handed off for human review 📋 {Z} items remaining: {brief list} Continuing... (say "Ralph, idle" to stop) ``` @@ -107,7 +113,7 @@ Ralph's state is session-scoped (not persisted to disk): - **Active/idle** — whether the loop is running - **Round count** — how many check cycles completed - **Scope** — what categories to monitor (default: all) -- **Stats** — issues closed, PRs merged, items processed this session +- **Stats** — findings reported and items handed off this session ### Ralph on the Board @@ -120,22 +126,22 @@ When Ralph reports status, use this format: 🔴 Untriaged: 2 issues need triage 🟡 In Progress: 3 issues assigned, 1 draft PR 🟢 Ready: 1 PR approved, awaiting merge - ✅ Done: 5 issues closed this session + ✅ Reported: 5 findings reported this session -Next action: Triaging #42 — "Fix auth endpoint timeout" +Next action: Coordinator triages #42 — "Fix auth endpoint timeout" ``` ### Integration with Follow-Up Work -After the coordinator's step 6 ("Immediately assess: Does anything trigger follow-up work?"), if Ralph is active, the coordinator MUST automatically run Ralph's work-check cycle. **Do NOT return control to the user.** This creates a continuous pipeline: +After the coordinator's step 6 ("Immediately assess: Does anything trigger follow-up work?"), if Ralph is active, the coordinator MUST automatically run Ralph's monitoring cycle. **Do NOT return control to the user.** This creates a continuous pipeline: 1. User activates Ralph → work-check cycle runs -2. Work found → agents spawned → results collected -3. Follow-up work assessed → more agents if needed +2. Ralph reports findings → coordinator triages and coordinates work +3. Coordinator-managed agents work → results collected and follow-up assessed 4. Ralph scans GitHub again (Step 1) → IMMEDIATELY, no pause -5. More work found → repeat from step 2 +5. More work found → coordinator acts on it; Ralph continues monitoring 6. No more work → "📋 Board is clear. Ralph is idling." (suggest `npx @bradygaster/squad-cli watch` for persistent polling) -**Ralph does NOT ask "should I continue?" — Ralph KEEPS GOING.** Only stops on explicit "idle"/"stop" or session end. A clear board → idle-watch, not full stop. For persistent monitoring after the board clears, use `npx @bradygaster/squad-cli watch`. +**Ralph does NOT ask "should I continue?" — Ralph KEEPS MONITORING.** Only stops on explicit "idle"/"stop" or session end. A clear board → idle-watch, not full stop. For persistent monitoring after the board clears, use `npx @bradygaster/squad-cli watch`. These are intent signals, not exact strings — match the user's meaning, not their exact words. diff --git a/packages/squad-sdk/templates/ralph-reference.md b/packages/squad-sdk/templates/ralph-reference.md index 3d8b2b440..b1b13d877 100644 --- a/packages/squad-sdk/templates/ralph-reference.md +++ b/packages/squad-sdk/templates/ralph-reference.md @@ -2,9 +2,9 @@ ## Ralph — Work Monitor -Ralph is a built-in squad member whose job is keeping tabs on work. **Ralph tracks and drives the work queue.** Always on the roster, one job: make sure the team never sits idle. +Ralph is a built-in squad member whose job is keeping tabs on work. **Ralph is monitor/triage-only:** it scans, categorizes, and reports work so the coordinator can keep the queue moving. Ralph does not implement changes, dispatch agents, approve or merge pull requests, or close issues. -**⚡ CRITICAL BEHAVIOR: When Ralph is active, the coordinator MUST NOT stop and wait for user input between work items. Ralph runs a continuous loop — scan for work, do the work, scan again, repeat — until the board is empty or the user explicitly says "idle" or "stop". This is not optional. If work exists, keep going. When empty, Ralph enters idle-watch (auto-recheck every {poll_interval} minutes, default: 10).** +**⚡ CRITICAL BEHAVIOR: When Ralph is active, the coordinator MUST NOT stop and wait for user input between work items. Ralph runs a continuous monitoring loop — scan for work, report findings to the coordinator, scan again, repeat — until the board is empty or the user explicitly says "idle" or "stop". This is not optional. If work exists, keep coordinating it. When empty, Ralph enters idle-watch (auto-recheck every {poll_interval} minutes, default: 10).** **Between checks:** Ralph's in-session loop runs while work exists. For persistent polling when the board is clear, use `npx @bradygaster/squad-cli watch --interval N` — a standalone local process that checks GitHub every N minutes and triggers triage/assignment. See [Watch Mode](#watch-mode-squad-watch). @@ -23,12 +23,12 @@ Ralph always appears in `team.md`: `| Ralph | Work Monitor | — | 🔄 Monitor | "Ralph, check every N minutes" | Set idle-watch polling interval | | "Ralph, idle" / "Take a break" / "Stop monitoring" | Fully deactivate (stop loop + idle-watch) | | "Ralph, scope: just issues" / "Ralph, skip CI" | Adjust what Ralph monitors this session | -| References PR feedback or changes requested | Spawn agent to address PR review feedback | -| "merge PR #N" / "merge it" (recent context) | Merge via `gh pr merge` | +| References PR feedback or changes requested | Report it to the coordinator for routing to the PR author agent | +| "merge PR #N" / "merge it" (recent context) | Report the PR status to the coordinator; only a human may merge | These are intent signals, not exact strings — match meaning, not words. -When Ralph is active, run this check cycle after every batch of agent work completes (or immediately on activation): +When Ralph is active, run this check cycle after every batch of coordinator-managed agent work completes (or immediately on activation): **Step 1 — Scan for work** (run these in parallel): @@ -50,19 +50,25 @@ gh pr list --state open --draft --json number,title,author,labels,checks --limit | Category | Signal | Action | |----------|--------|--------| -| **Untriaged issues** | `squad` label, no `squad:{member}` label | Lead triages: reads issue, assigns `squad:{member}` label | -| **Assigned but unstarted** | `squad:{member}` label, no assignee or no PR | Spawn the assigned agent to pick it up | -| **Draft PRs** | PR in draft from squad member | Check if agent needs to continue; if stalled, nudge | -| **Review feedback** | PR has `CHANGES_REQUESTED` review | Route feedback to PR author agent to address | -| **CI failures** | PR checks failing | Notify assigned agent to fix, or create a fix issue | -| **Approved PRs** | PR approved, CI green, ready to merge | Merge and close related issue | +| **Untriaged issues** | `squad` label, no `squad:{member}` label | Report to the coordinator for triage and assignment | +| **Assigned but unstarted** | `squad:{member}` label, no assignee or no PR | Report to the coordinator for agent dispatch | +| **Draft PRs** | PR in draft from squad member | Report status; the coordinator decides whether the agent should continue | +| **Review feedback** | PR has `CHANGES_REQUESTED` review | Report feedback to the coordinator for routing to the PR author agent | +| **CI failures** | PR checks failing | Report to the coordinator to route a fix or create a fix issue | +| **Approved PRs** | PR approved, CI green, ready for pre-merge review | Report readiness to the coordinator for verification and hand-off to a human; Ralph does not merge or close issues | | **No work found** | All clear | Report: "📋 Board is clear. Ralph is idling." Suggest `npx @bradygaster/squad-cli watch` for persistent polling. | -**Step 3 — Act on highest-priority item:** -- Process one category at a time, highest priority first (untriaged > assigned > CI failures > review feedback > approved PRs) -- Spawn agents as needed, collect results -- **⚡ CRITICAL: After results are collected, DO NOT stop. DO NOT wait for user input. IMMEDIATELY go back to Step 1 and scan again.** This is a loop — Ralph keeps cycling until the board is clear or the user says "idle". Each cycle is one "round". -- If multiple items exist in the same category, process them in parallel (spawn multiple agents) +### Authority boundaries + +- **Ralph is monitor/triage-only.** It detects, categorizes, and reports work; it does not dispatch agents, approve or merge pull requests, or close issues. +- **The coordinator owns pre-merge readiness and hand-off.** The coordinator verifies repository requirements, reviews, checks, and feedback, then presents a ready pull request to a human. The coordinator does not merge. +- **Only a human may merge pull requests.** After a human merge, the coordinator follows repository issue-closing policy; Ralph only reports status and never closes the issue. + +**Step 3 — Report and continue monitoring:** +- Report one category at a time, highest priority first (untriaged > assigned > CI failures > review feedback > approved PRs) +- The coordinator triages, assigns, dispatches agents, and coordinates follow-up work; Ralph reports what it detects. +- **⚡ CRITICAL: After the coordinator collects results, DO NOT stop. DO NOT wait for user input. IMMEDIATELY go back to Step 1 and scan again.** This is a loop — Ralph keeps monitoring until the board is clear or the user says "idle". Each cycle is one "round". +- If multiple items exist in the same category, report them together so the coordinator can process them in parallel. **Step 4 — Periodic check-in** (every 3-5 rounds): @@ -70,7 +76,7 @@ After every 3-5 rounds, pause and report before continuing: ``` 🔄 Ralph: Round {N} complete. - ✅ {X} issues closed, {Y} PRs merged + ✅ {X} follow-ups reported, {Y} PRs handed off for human review 📋 {Z} items remaining: {brief list} Continuing... (say "Ralph, idle" to stop) ``` @@ -107,7 +113,7 @@ Ralph's state is session-scoped (not persisted to disk): - **Active/idle** — whether the loop is running - **Round count** — how many check cycles completed - **Scope** — what categories to monitor (default: all) -- **Stats** — issues closed, PRs merged, items processed this session +- **Stats** — findings reported and items handed off this session ### Ralph on the Board @@ -120,22 +126,22 @@ When Ralph reports status, use this format: 🔴 Untriaged: 2 issues need triage 🟡 In Progress: 3 issues assigned, 1 draft PR 🟢 Ready: 1 PR approved, awaiting merge - ✅ Done: 5 issues closed this session + ✅ Reported: 5 findings reported this session -Next action: Triaging #42 — "Fix auth endpoint timeout" +Next action: Coordinator triages #42 — "Fix auth endpoint timeout" ``` ### Integration with Follow-Up Work -After the coordinator's step 6 ("Immediately assess: Does anything trigger follow-up work?"), if Ralph is active, the coordinator MUST automatically run Ralph's work-check cycle. **Do NOT return control to the user.** This creates a continuous pipeline: +After the coordinator's step 6 ("Immediately assess: Does anything trigger follow-up work?"), if Ralph is active, the coordinator MUST automatically run Ralph's monitoring cycle. **Do NOT return control to the user.** This creates a continuous pipeline: 1. User activates Ralph → work-check cycle runs -2. Work found → agents spawned → results collected -3. Follow-up work assessed → more agents if needed +2. Ralph reports findings → coordinator triages and coordinates work +3. Coordinator-managed agents work → results collected and follow-up assessed 4. Ralph scans GitHub again (Step 1) → IMMEDIATELY, no pause -5. More work found → repeat from step 2 +5. More work found → coordinator acts on it; Ralph continues monitoring 6. No more work → "📋 Board is clear. Ralph is idling." (suggest `npx @bradygaster/squad-cli watch` for persistent polling) -**Ralph does NOT ask "should I continue?" — Ralph KEEPS GOING.** Only stops on explicit "idle"/"stop" or session end. A clear board → idle-watch, not full stop. For persistent monitoring after the board clears, use `npx @bradygaster/squad-cli watch`. +**Ralph does NOT ask "should I continue?" — Ralph KEEPS MONITORING.** Only stops on explicit "idle"/"stop" or session end. A clear board → idle-watch, not full stop. For persistent monitoring after the board clears, use `npx @bradygaster/squad-cli watch`. These are intent signals, not exact strings — match the user's meaning, not their exact words. diff --git a/templates/ralph-reference.md b/templates/ralph-reference.md index 3d8b2b440..b1b13d877 100644 --- a/templates/ralph-reference.md +++ b/templates/ralph-reference.md @@ -2,9 +2,9 @@ ## Ralph — Work Monitor -Ralph is a built-in squad member whose job is keeping tabs on work. **Ralph tracks and drives the work queue.** Always on the roster, one job: make sure the team never sits idle. +Ralph is a built-in squad member whose job is keeping tabs on work. **Ralph is monitor/triage-only:** it scans, categorizes, and reports work so the coordinator can keep the queue moving. Ralph does not implement changes, dispatch agents, approve or merge pull requests, or close issues. -**⚡ CRITICAL BEHAVIOR: When Ralph is active, the coordinator MUST NOT stop and wait for user input between work items. Ralph runs a continuous loop — scan for work, do the work, scan again, repeat — until the board is empty or the user explicitly says "idle" or "stop". This is not optional. If work exists, keep going. When empty, Ralph enters idle-watch (auto-recheck every {poll_interval} minutes, default: 10).** +**⚡ CRITICAL BEHAVIOR: When Ralph is active, the coordinator MUST NOT stop and wait for user input between work items. Ralph runs a continuous monitoring loop — scan for work, report findings to the coordinator, scan again, repeat — until the board is empty or the user explicitly says "idle" or "stop". This is not optional. If work exists, keep coordinating it. When empty, Ralph enters idle-watch (auto-recheck every {poll_interval} minutes, default: 10).** **Between checks:** Ralph's in-session loop runs while work exists. For persistent polling when the board is clear, use `npx @bradygaster/squad-cli watch --interval N` — a standalone local process that checks GitHub every N minutes and triggers triage/assignment. See [Watch Mode](#watch-mode-squad-watch). @@ -23,12 +23,12 @@ Ralph always appears in `team.md`: `| Ralph | Work Monitor | — | 🔄 Monitor | "Ralph, check every N minutes" | Set idle-watch polling interval | | "Ralph, idle" / "Take a break" / "Stop monitoring" | Fully deactivate (stop loop + idle-watch) | | "Ralph, scope: just issues" / "Ralph, skip CI" | Adjust what Ralph monitors this session | -| References PR feedback or changes requested | Spawn agent to address PR review feedback | -| "merge PR #N" / "merge it" (recent context) | Merge via `gh pr merge` | +| References PR feedback or changes requested | Report it to the coordinator for routing to the PR author agent | +| "merge PR #N" / "merge it" (recent context) | Report the PR status to the coordinator; only a human may merge | These are intent signals, not exact strings — match meaning, not words. -When Ralph is active, run this check cycle after every batch of agent work completes (or immediately on activation): +When Ralph is active, run this check cycle after every batch of coordinator-managed agent work completes (or immediately on activation): **Step 1 — Scan for work** (run these in parallel): @@ -50,19 +50,25 @@ gh pr list --state open --draft --json number,title,author,labels,checks --limit | Category | Signal | Action | |----------|--------|--------| -| **Untriaged issues** | `squad` label, no `squad:{member}` label | Lead triages: reads issue, assigns `squad:{member}` label | -| **Assigned but unstarted** | `squad:{member}` label, no assignee or no PR | Spawn the assigned agent to pick it up | -| **Draft PRs** | PR in draft from squad member | Check if agent needs to continue; if stalled, nudge | -| **Review feedback** | PR has `CHANGES_REQUESTED` review | Route feedback to PR author agent to address | -| **CI failures** | PR checks failing | Notify assigned agent to fix, or create a fix issue | -| **Approved PRs** | PR approved, CI green, ready to merge | Merge and close related issue | +| **Untriaged issues** | `squad` label, no `squad:{member}` label | Report to the coordinator for triage and assignment | +| **Assigned but unstarted** | `squad:{member}` label, no assignee or no PR | Report to the coordinator for agent dispatch | +| **Draft PRs** | PR in draft from squad member | Report status; the coordinator decides whether the agent should continue | +| **Review feedback** | PR has `CHANGES_REQUESTED` review | Report feedback to the coordinator for routing to the PR author agent | +| **CI failures** | PR checks failing | Report to the coordinator to route a fix or create a fix issue | +| **Approved PRs** | PR approved, CI green, ready for pre-merge review | Report readiness to the coordinator for verification and hand-off to a human; Ralph does not merge or close issues | | **No work found** | All clear | Report: "📋 Board is clear. Ralph is idling." Suggest `npx @bradygaster/squad-cli watch` for persistent polling. | -**Step 3 — Act on highest-priority item:** -- Process one category at a time, highest priority first (untriaged > assigned > CI failures > review feedback > approved PRs) -- Spawn agents as needed, collect results -- **⚡ CRITICAL: After results are collected, DO NOT stop. DO NOT wait for user input. IMMEDIATELY go back to Step 1 and scan again.** This is a loop — Ralph keeps cycling until the board is clear or the user says "idle". Each cycle is one "round". -- If multiple items exist in the same category, process them in parallel (spawn multiple agents) +### Authority boundaries + +- **Ralph is monitor/triage-only.** It detects, categorizes, and reports work; it does not dispatch agents, approve or merge pull requests, or close issues. +- **The coordinator owns pre-merge readiness and hand-off.** The coordinator verifies repository requirements, reviews, checks, and feedback, then presents a ready pull request to a human. The coordinator does not merge. +- **Only a human may merge pull requests.** After a human merge, the coordinator follows repository issue-closing policy; Ralph only reports status and never closes the issue. + +**Step 3 — Report and continue monitoring:** +- Report one category at a time, highest priority first (untriaged > assigned > CI failures > review feedback > approved PRs) +- The coordinator triages, assigns, dispatches agents, and coordinates follow-up work; Ralph reports what it detects. +- **⚡ CRITICAL: After the coordinator collects results, DO NOT stop. DO NOT wait for user input. IMMEDIATELY go back to Step 1 and scan again.** This is a loop — Ralph keeps monitoring until the board is clear or the user says "idle". Each cycle is one "round". +- If multiple items exist in the same category, report them together so the coordinator can process them in parallel. **Step 4 — Periodic check-in** (every 3-5 rounds): @@ -70,7 +76,7 @@ After every 3-5 rounds, pause and report before continuing: ``` 🔄 Ralph: Round {N} complete. - ✅ {X} issues closed, {Y} PRs merged + ✅ {X} follow-ups reported, {Y} PRs handed off for human review 📋 {Z} items remaining: {brief list} Continuing... (say "Ralph, idle" to stop) ``` @@ -107,7 +113,7 @@ Ralph's state is session-scoped (not persisted to disk): - **Active/idle** — whether the loop is running - **Round count** — how many check cycles completed - **Scope** — what categories to monitor (default: all) -- **Stats** — issues closed, PRs merged, items processed this session +- **Stats** — findings reported and items handed off this session ### Ralph on the Board @@ -120,22 +126,22 @@ When Ralph reports status, use this format: 🔴 Untriaged: 2 issues need triage 🟡 In Progress: 3 issues assigned, 1 draft PR 🟢 Ready: 1 PR approved, awaiting merge - ✅ Done: 5 issues closed this session + ✅ Reported: 5 findings reported this session -Next action: Triaging #42 — "Fix auth endpoint timeout" +Next action: Coordinator triages #42 — "Fix auth endpoint timeout" ``` ### Integration with Follow-Up Work -After the coordinator's step 6 ("Immediately assess: Does anything trigger follow-up work?"), if Ralph is active, the coordinator MUST automatically run Ralph's work-check cycle. **Do NOT return control to the user.** This creates a continuous pipeline: +After the coordinator's step 6 ("Immediately assess: Does anything trigger follow-up work?"), if Ralph is active, the coordinator MUST automatically run Ralph's monitoring cycle. **Do NOT return control to the user.** This creates a continuous pipeline: 1. User activates Ralph → work-check cycle runs -2. Work found → agents spawned → results collected -3. Follow-up work assessed → more agents if needed +2. Ralph reports findings → coordinator triages and coordinates work +3. Coordinator-managed agents work → results collected and follow-up assessed 4. Ralph scans GitHub again (Step 1) → IMMEDIATELY, no pause -5. More work found → repeat from step 2 +5. More work found → coordinator acts on it; Ralph continues monitoring 6. No more work → "📋 Board is clear. Ralph is idling." (suggest `npx @bradygaster/squad-cli watch` for persistent polling) -**Ralph does NOT ask "should I continue?" — Ralph KEEPS GOING.** Only stops on explicit "idle"/"stop" or session end. A clear board → idle-watch, not full stop. For persistent monitoring after the board clears, use `npx @bradygaster/squad-cli watch`. +**Ralph does NOT ask "should I continue?" — Ralph KEEPS MONITORING.** Only stops on explicit "idle"/"stop" or session end. A clear board → idle-watch, not full stop. For persistent monitoring after the board clears, use `npx @bradygaster/squad-cli watch`. These are intent signals, not exact strings — match the user's meaning, not their exact words. diff --git a/test/ralph-reference-authority.test.ts b/test/ralph-reference-authority.test.ts new file mode 100644 index 000000000..545d7a3c0 --- /dev/null +++ b/test/ralph-reference-authority.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const RALPH_REFERENCES = [ + '.squad-templates/ralph-reference.md', + '.squad/templates/ralph-reference.md', + 'templates/ralph-reference.md', + 'packages/squad-cli/templates/ralph-reference.md', + 'packages/squad-sdk/templates/ralph-reference.md', +] as const; + +function readReference(path: string): string { + return readFileSync(resolve(ROOT, path), 'utf-8').replace(/\r\n/g, '\n'); +} + +describe('Ralph reference authority', () => { + const canonical = readReference(RALPH_REFERENCES[0]); + + for (const path of RALPH_REFERENCES) { + describe(path, () => { + const content = readReference(path); + + it('matches the canonical reference', () => { + expect(content, `${path} must stay synchronized`).toBe(canonical); + }); + + it('keeps Ralph monitor/triage-only and removes merge or issue-closing authority', () => { + expect(content).toMatch(/Ralph is monitor\/triage-only/i); + expect(content).not.toMatch(/gh pr merge/i); + + const mergeTrigger = content + .split(/\r?\n/) + .find(line => line.includes('"merge PR #N"')); + expect(mergeTrigger).toMatch(/"merge it".*coordinator/i); + expect(mergeTrigger).not.toMatch(/merge via|gh pr merge/i); + + const approvedPrRow = content + .split(/\r?\n/) + .find(line => /^\|\s*\*\*Approved PRs\*\*/.test(line)); + expect(approvedPrRow).toMatch(/Report readiness.*coordinator.*human/i); + expect(approvedPrRow).toMatch(/Ralph does not merge or close issues/i); + }); + + it('assigns pre-merge readiness to the coordinator and merge authority to a human', () => { + expect(content).toMatch(/coordinator owns pre-merge readiness and hand-off/i); + expect(content).toMatch(/Only a human may merge pull requests/i); + expect(content).toMatch(/After a human merge, the coordinator follows repository issue-closing policy/i); + expect(content).toMatch(/Ralph only reports status and never closes the issue/i); + }); + }); + } +}); From fa14b2ea04d04723be84eefe0590100d11f0d9f6 Mon Sep 17 00:00:00 2001 From: brady gaster Date: Fri, 2 Oct 2026 18:02:30 -0700 Subject: [PATCH 4/4] fix: address assignment and roster review feedback Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .changeset/2079-team-roster-table-scope.md | 2 +- .changeset/assignment-safety-attribution.md | 2 +- .github/workflows/squad-heartbeat.yml | 17 +++ .github/workflows/squad-issue-assign.yml | 57 ++++++-- .github/workflows/squad-triage.yml | 16 +++ .../workflows/squad-heartbeat.yml | 17 +++ .../workflows/squad-issue-assign.yml | 51 ++++++- .squad-templates/workflows/squad-triage.yml | 16 +++ .../docs/features/copilot-coding-agent.md | 21 +-- .../templates/workflows/squad-heartbeat.yml | 17 +++ .../workflows/squad-issue-assign.yml | 51 ++++++- .../templates/workflows/squad-triage.yml | 16 +++ .../src/config/markdown-migration.ts | 2 +- .../templates/workflows/squad-heartbeat.yml | 17 +++ .../workflows/squad-issue-assign.yml | 51 ++++++- .../templates/workflows/squad-triage.yml | 16 +++ templates/workflows/squad-heartbeat.yml | 17 +++ templates/workflows/squad-issue-assign.yml | 51 ++++++- templates/workflows/squad-triage.yml | 16 +++ test/assignment-safety-attribution.test.ts | 133 ++++++++++++++++-- test/cli/health-command.test.ts | 19 +++ test/migration.test.ts | 15 ++ 22 files changed, 558 insertions(+), 62 deletions(-) diff --git a/.changeset/2079-team-roster-table-scope.md b/.changeset/2079-team-roster-table-scope.md index 76903c150..bb471411a 100644 --- a/.changeset/2079-team-roster-table-scope.md +++ b/.changeset/2079-team-roster-table-scope.md @@ -2,4 +2,4 @@ "@bradygaster/squad-sdk": patch --- -Limit team markdown table parsing to supported roster sections so auxiliary tables do not inflate agent counts. +Limit team markdown table parsing to supported roster sections, preserving the legacy Team Roster heading while excluding auxiliary tables. diff --git a/.changeset/assignment-safety-attribution.md b/.changeset/assignment-safety-attribution.md index 180f3a3f1..a90d9ca73 100644 --- a/.changeset/assignment-safety-attribution.md +++ b/.changeset/assignment-safety-attribution.md @@ -3,4 +3,4 @@ "@bradygaster/squad-sdk": patch --- -Make issue-assign the sole Copilot assignment workflow, fail visibly on missing tokens or unconfirmed API responses, and disclose deterministic keyword triage provenance without attributing it to Lead-agent analysis. +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. diff --git a/.github/workflows/squad-heartbeat.yml b/.github/workflows/squad-heartbeat.yml index 758db70d8..e1488b99b 100644 --- a/.github/workflows/squad-heartbeat.yml +++ b/.github/workflows/squad-heartbeat.yml @@ -32,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: @@ -133,6 +134,8 @@ jobs: core.info('📋 Board is clear — Ralph found no untriaged issues'); return; } + const autoAssign = //i + .test(fs.readFileSync('.squad/team.md', 'utf8')); for (const decision of results) { try { @@ -158,10 +161,24 @@ 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}`); + } } } diff --git a/.github/workflows/squad-issue-assign.yml b/.github/workflows/squad-issue-assign.yml index 3e8c569be..ecbe5d823 100644 --- a/.github/workflows/squad-issue-assign.yml +++ b/.github/workflows/squad-issue-assign.yml @@ -3,6 +3,12 @@ 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 @@ -10,17 +16,17 @@ permissions: # 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.label.name == 'squad:copilot' + 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 }} @@ -57,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, '-') @@ -163,14 +173,22 @@ 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 }); @@ -198,18 +216,33 @@ jobs: 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.`); - // Preserve the active workflow's parent-label repair after acceptance. - if (!context.payload.issue.labels.some(label => label.name === 'squad')) { - await github.rest.issues.addLabels({ owner, repo, issue_number, labels: ['squad'] }); - } await github.rest.issues.createComment({ owner, repo, issue_number, body: [ `### 🤖 Copilot assignment API accepted`, '', - `**Issue:** #${issue_number} — ${context.payload.issue.title}`, + `**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}`); + } diff --git a/.github/workflows/squad-triage.yml b/.github/workflows/squad-triage.yml index bf9b7d986..297458a3e 100644 --- a/.github/workflows/squad-triage.yml +++ b/.github/workflows/squad-triage.yml @@ -7,6 +7,7 @@ on: permissions: issues: write contents: read + actions: write # CI Hardening Phase 3 item A1: Prevent parallel runs from competing for resources concurrency: @@ -59,6 +60,11 @@ jobs: const content = fs.readFileSync(teamFile, 'utf8'); const lines = content.split('\n'); + if (issue.labels.some(label => /^squad:.+/.test(label.name))) { + core.info(`Issue #${issue.number} already has a squad member label — skipping conflicting triage.`); + return; + } + const autoAssign = //i.test(content); // Check if @copilot is on the team const hasCopilot = content.includes('🤖 Coding Agent'); @@ -351,3 +357,13 @@ jobs: }); core.info(`Triaged issue #${issue.number} → ${assignedMember.name} (${assignLabel})`); + if (isCopilot && 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(issue.number) } + }); + core.info(`Dispatched squad-issue-assign.yml for auto-assigned issue #${issue.number}`); + } diff --git a/.squad-templates/workflows/squad-heartbeat.yml b/.squad-templates/workflows/squad-heartbeat.yml index 3f2172c75..2336bcd20 100644 --- a/.squad-templates/workflows/squad-heartbeat.yml +++ b/.squad-templates/workflows/squad-heartbeat.yml @@ -23,6 +23,7 @@ permissions: issues: write contents: read pull-requests: read + actions: write jobs: heartbeat: @@ -66,6 +67,8 @@ jobs: core.info('📋 Board is clear — Ralph found no untriaged issues'); return; } + const autoAssign = //i + .test(fs.readFileSync('.squad/team.md', 'utf8')); for (const decision of results) { try { @@ -91,10 +94,24 @@ 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}`); + } } } diff --git a/.squad-templates/workflows/squad-issue-assign.yml b/.squad-templates/workflows/squad-issue-assign.yml index d3bc8e305..772fe2a3c 100644 --- a/.squad-templates/workflows/squad-issue-assign.yml +++ b/.squad-templates/workflows/squad-issue-assign.yml @@ -3,6 +3,12 @@ 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 @@ -11,11 +17,11 @@ permissions: 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.label.name == 'squad:copilot' + if: github.event_name == 'workflow_dispatch' || github.event.label.name == 'squad:copilot' uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b #v7 env: COPILOT_ASSIGN_TOKEN: ${{ secrets.COPILOT_ASSIGN_TOKEN }} @@ -32,8 +38,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, '-') @@ -122,14 +132,22 @@ 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@f28e40c7f34bde8b3046d885e986cb6290c5673b #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; + 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 }); @@ -162,9 +180,28 @@ jobs: body: [ `### 🤖 Copilot assignment API accepted`, '', - `**Issue:** #${issue_number} — ${context.payload.issue.title}`, + `**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@f28e40c7f34bde8b3046d885e986cb6290c5673b #v7 + 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}`); + } diff --git a/.squad-templates/workflows/squad-triage.yml b/.squad-templates/workflows/squad-triage.yml index 19a3b146b..87edfb21a 100644 --- a/.squad-templates/workflows/squad-triage.yml +++ b/.squad-templates/workflows/squad-triage.yml @@ -7,6 +7,7 @@ on: permissions: issues: write contents: read + actions: write jobs: triage: @@ -30,6 +31,11 @@ jobs: const content = fs.readFileSync(teamFile, 'utf8'); const lines = content.split('\n'); + if (issue.labels.some(label => /^squad:.+/.test(label.name))) { + core.info(`Issue #${issue.number} already has a squad member label — skipping conflicting triage.`); + return; + } + const autoAssign = //i.test(content); // Check if @copilot is on the team const hasCopilot = content.includes('🤖 Coding Agent'); @@ -318,3 +324,13 @@ jobs: }); core.info(`Triaged issue #${issue.number} → ${assignedMember.name} (${assignLabel})`); + if (isCopilot && 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(issue.number) } + }); + core.info(`Dispatched squad-issue-assign.yml for auto-assigned issue #${issue.number}`); + } diff --git a/docs/src/content/docs/features/copilot-coding-agent.md b/docs/src/content/docs/features/copilot-coding-agent.md index 714c90135..423678a26 100644 --- a/docs/src/content/docs/features/copilot-coding-agent.md +++ b/docs/src/content/docs/features/copilot-coding-agent.md @@ -132,16 +132,19 @@ When the `squad:copilot` label is added to an issue: 3. **Acceptance** — Only an HTTP 200/201 response containing the exact `copilot-swe-agent[bot]` assignee permits an acknowledgment comment. Missing or malformed evidence fails visibly without a success comment. 4. **Handoff** — Verify a Copilot session linked to the issue, or a linked `copilot/*` draft PR with Copilot activity. API acceptance, a bot assignee, or a green Actions job alone does **not** prove that a coding session started or completed. -`squad-issue-assign.yml` is the sole label-triggered Copilot assignment authority. +`squad-issue-assign.yml` is the sole Copilot assignment authority, whether +triggered by a label or an explicit dispatch. Heartbeat continues monitoring and applying triage labels, and triage continues -routing, but neither assigns Copilot directly. A failed request may have reached -GitHub before its response was lost; inspect the issue/session before retrying. - -Labels added with the default `GITHUB_TOKEN` do not trigger another Actions -workflow. If automated triage applies `squad:copilot` using that token, a human -or suitably authorized non-`GITHUB_TOKEN` integration must remove and reapply -the label to trigger the assignment workflow. Merely seeing the label is not -handoff evidence. +routing, but neither assigns Copilot directly. When auto-assign is enabled in +`team.md`, both workflows explicitly dispatch `squad-issue-assign.yml` after +automatically applying `squad:copilot`; that workflow alone performs the +PAT-backed assignment. The dispatch uses the default `GITHUB_TOKEN`, which +requires the workflows' narrowly scoped `actions: write` permission because +labels added with that token do not trigger another workflow. When auto-assign +is disabled, automated routing only applies labels and does not dispatch an +assignment. Manually adding `squad:copilot` still triggers the assignment +workflow. A failed request may have reached GitHub before its response was +lost; inspect the issue/session before retrying. --- diff --git a/packages/squad-cli/templates/workflows/squad-heartbeat.yml b/packages/squad-cli/templates/workflows/squad-heartbeat.yml index 3f2172c75..2336bcd20 100644 --- a/packages/squad-cli/templates/workflows/squad-heartbeat.yml +++ b/packages/squad-cli/templates/workflows/squad-heartbeat.yml @@ -23,6 +23,7 @@ permissions: issues: write contents: read pull-requests: read + actions: write jobs: heartbeat: @@ -66,6 +67,8 @@ jobs: core.info('📋 Board is clear — Ralph found no untriaged issues'); return; } + const autoAssign = //i + .test(fs.readFileSync('.squad/team.md', 'utf8')); for (const decision of results) { try { @@ -91,10 +94,24 @@ 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}`); + } } } diff --git a/packages/squad-cli/templates/workflows/squad-issue-assign.yml b/packages/squad-cli/templates/workflows/squad-issue-assign.yml index d3bc8e305..772fe2a3c 100644 --- a/packages/squad-cli/templates/workflows/squad-issue-assign.yml +++ b/packages/squad-cli/templates/workflows/squad-issue-assign.yml @@ -3,6 +3,12 @@ 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 @@ -11,11 +17,11 @@ permissions: 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.label.name == 'squad:copilot' + if: github.event_name == 'workflow_dispatch' || github.event.label.name == 'squad:copilot' uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b #v7 env: COPILOT_ASSIGN_TOKEN: ${{ secrets.COPILOT_ASSIGN_TOKEN }} @@ -32,8 +38,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, '-') @@ -122,14 +132,22 @@ 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@f28e40c7f34bde8b3046d885e986cb6290c5673b #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; + 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 }); @@ -162,9 +180,28 @@ jobs: body: [ `### 🤖 Copilot assignment API accepted`, '', - `**Issue:** #${issue_number} — ${context.payload.issue.title}`, + `**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@f28e40c7f34bde8b3046d885e986cb6290c5673b #v7 + 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}`); + } diff --git a/packages/squad-cli/templates/workflows/squad-triage.yml b/packages/squad-cli/templates/workflows/squad-triage.yml index 19a3b146b..87edfb21a 100644 --- a/packages/squad-cli/templates/workflows/squad-triage.yml +++ b/packages/squad-cli/templates/workflows/squad-triage.yml @@ -7,6 +7,7 @@ on: permissions: issues: write contents: read + actions: write jobs: triage: @@ -30,6 +31,11 @@ jobs: const content = fs.readFileSync(teamFile, 'utf8'); const lines = content.split('\n'); + if (issue.labels.some(label => /^squad:.+/.test(label.name))) { + core.info(`Issue #${issue.number} already has a squad member label — skipping conflicting triage.`); + return; + } + const autoAssign = //i.test(content); // Check if @copilot is on the team const hasCopilot = content.includes('🤖 Coding Agent'); @@ -318,3 +324,13 @@ jobs: }); core.info(`Triaged issue #${issue.number} → ${assignedMember.name} (${assignLabel})`); + if (isCopilot && 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(issue.number) } + }); + core.info(`Dispatched squad-issue-assign.yml for auto-assigned issue #${issue.number}`); + } diff --git a/packages/squad-sdk/src/config/markdown-migration.ts b/packages/squad-sdk/src/config/markdown-migration.ts index a29b9b4ad..86227a6ad 100644 --- a/packages/squad-sdk/src/config/markdown-migration.ts +++ b/packages/squad-sdk/src/config/markdown-migration.ts @@ -241,7 +241,7 @@ function parseTeamTable(lines: string[]): ParsedAgent[] { const heading = headingMatch[2]!.trim(); inRosterSection = headingMatch[1] === '##' && - /^(?:roster|members?|team\s+members?|agents?)$/i.test(heading); + /^(?:roster|team\s+roster|members?|team\s+members?|agents?)$/i.test(heading); headerCols = null; continue; } diff --git a/packages/squad-sdk/templates/workflows/squad-heartbeat.yml b/packages/squad-sdk/templates/workflows/squad-heartbeat.yml index 3f2172c75..2336bcd20 100644 --- a/packages/squad-sdk/templates/workflows/squad-heartbeat.yml +++ b/packages/squad-sdk/templates/workflows/squad-heartbeat.yml @@ -23,6 +23,7 @@ permissions: issues: write contents: read pull-requests: read + actions: write jobs: heartbeat: @@ -66,6 +67,8 @@ jobs: core.info('📋 Board is clear — Ralph found no untriaged issues'); return; } + const autoAssign = //i + .test(fs.readFileSync('.squad/team.md', 'utf8')); for (const decision of results) { try { @@ -91,10 +94,24 @@ 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}`); + } } } diff --git a/packages/squad-sdk/templates/workflows/squad-issue-assign.yml b/packages/squad-sdk/templates/workflows/squad-issue-assign.yml index d3bc8e305..772fe2a3c 100644 --- a/packages/squad-sdk/templates/workflows/squad-issue-assign.yml +++ b/packages/squad-sdk/templates/workflows/squad-issue-assign.yml @@ -3,6 +3,12 @@ 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 @@ -11,11 +17,11 @@ permissions: 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.label.name == 'squad:copilot' + if: github.event_name == 'workflow_dispatch' || github.event.label.name == 'squad:copilot' uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b #v7 env: COPILOT_ASSIGN_TOKEN: ${{ secrets.COPILOT_ASSIGN_TOKEN }} @@ -32,8 +38,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, '-') @@ -122,14 +132,22 @@ 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@f28e40c7f34bde8b3046d885e986cb6290c5673b #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; + 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 }); @@ -162,9 +180,28 @@ jobs: body: [ `### 🤖 Copilot assignment API accepted`, '', - `**Issue:** #${issue_number} — ${context.payload.issue.title}`, + `**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@f28e40c7f34bde8b3046d885e986cb6290c5673b #v7 + 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}`); + } diff --git a/packages/squad-sdk/templates/workflows/squad-triage.yml b/packages/squad-sdk/templates/workflows/squad-triage.yml index 19a3b146b..87edfb21a 100644 --- a/packages/squad-sdk/templates/workflows/squad-triage.yml +++ b/packages/squad-sdk/templates/workflows/squad-triage.yml @@ -7,6 +7,7 @@ on: permissions: issues: write contents: read + actions: write jobs: triage: @@ -30,6 +31,11 @@ jobs: const content = fs.readFileSync(teamFile, 'utf8'); const lines = content.split('\n'); + if (issue.labels.some(label => /^squad:.+/.test(label.name))) { + core.info(`Issue #${issue.number} already has a squad member label — skipping conflicting triage.`); + return; + } + const autoAssign = //i.test(content); // Check if @copilot is on the team const hasCopilot = content.includes('🤖 Coding Agent'); @@ -318,3 +324,13 @@ jobs: }); core.info(`Triaged issue #${issue.number} → ${assignedMember.name} (${assignLabel})`); + if (isCopilot && 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(issue.number) } + }); + core.info(`Dispatched squad-issue-assign.yml for auto-assigned issue #${issue.number}`); + } diff --git a/templates/workflows/squad-heartbeat.yml b/templates/workflows/squad-heartbeat.yml index 3f2172c75..2336bcd20 100644 --- a/templates/workflows/squad-heartbeat.yml +++ b/templates/workflows/squad-heartbeat.yml @@ -23,6 +23,7 @@ permissions: issues: write contents: read pull-requests: read + actions: write jobs: heartbeat: @@ -66,6 +67,8 @@ jobs: core.info('📋 Board is clear — Ralph found no untriaged issues'); return; } + const autoAssign = //i + .test(fs.readFileSync('.squad/team.md', 'utf8')); for (const decision of results) { try { @@ -91,10 +94,24 @@ 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}`); + } } } diff --git a/templates/workflows/squad-issue-assign.yml b/templates/workflows/squad-issue-assign.yml index d3bc8e305..772fe2a3c 100644 --- a/templates/workflows/squad-issue-assign.yml +++ b/templates/workflows/squad-issue-assign.yml @@ -3,6 +3,12 @@ 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 @@ -11,11 +17,11 @@ permissions: 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.label.name == 'squad:copilot' + if: github.event_name == 'workflow_dispatch' || github.event.label.name == 'squad:copilot' uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b #v7 env: COPILOT_ASSIGN_TOKEN: ${{ secrets.COPILOT_ASSIGN_TOKEN }} @@ -32,8 +38,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, '-') @@ -122,14 +132,22 @@ 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@f28e40c7f34bde8b3046d885e986cb6290c5673b #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; + 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 }); @@ -162,9 +180,28 @@ jobs: body: [ `### 🤖 Copilot assignment API accepted`, '', - `**Issue:** #${issue_number} — ${context.payload.issue.title}`, + `**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@f28e40c7f34bde8b3046d885e986cb6290c5673b #v7 + 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}`); + } diff --git a/templates/workflows/squad-triage.yml b/templates/workflows/squad-triage.yml index 19a3b146b..87edfb21a 100644 --- a/templates/workflows/squad-triage.yml +++ b/templates/workflows/squad-triage.yml @@ -7,6 +7,7 @@ on: permissions: issues: write contents: read + actions: write jobs: triage: @@ -30,6 +31,11 @@ jobs: const content = fs.readFileSync(teamFile, 'utf8'); const lines = content.split('\n'); + if (issue.labels.some(label => /^squad:.+/.test(label.name))) { + core.info(`Issue #${issue.number} already has a squad member label — skipping conflicting triage.`); + return; + } + const autoAssign = //i.test(content); // Check if @copilot is on the team const hasCopilot = content.includes('🤖 Coding Agent'); @@ -318,3 +324,13 @@ jobs: }); core.info(`Triaged issue #${issue.number} → ${assignedMember.name} (${assignLabel})`); + if (isCopilot && 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(issue.number) } + }); + core.info(`Dispatched squad-issue-assign.yml for auto-assigned issue #${issue.number}`); + } diff --git a/test/assignment-safety-attribution.test.ts b/test/assignment-safety-attribution.test.ts index d25280cb4..8f3427187 100644 --- a/test/assignment-safety-attribution.test.ts +++ b/test/assignment-safety-attribution.test.ts @@ -12,6 +12,7 @@ type Step = { }; type Workflow = { on: Record; + permissions?: Record; jobs: Record; }; @@ -52,19 +53,29 @@ async function execute(workflow: Workflow, { title = 'bug fix', response = accepted, requestError = '', + dispatchError = '', + eventName = 'issues', + issueLabels, + autoAssign = true, }: { label?: string; token?: string; title?: string; - response?: unknown; requestError?: string; + response?: unknown; requestError?: string; dispatchError?: string; + eventName?: string; issueLabels?: string[]; autoAssign?: boolean; } = {}) { const calls: string[] = []; const comments: string[] = []; const labels: string[][] = []; + const dispatches: unknown[] = []; const failures: string[] = []; const infos: string[] = []; - const issue = { number: 42, title, body: '', labels: [{ name: 'squad' }], assignees: [] }; + const currentLabels = issueLabels ?? (label === 'squad' ? ['squad'] : ['squad', label]); + const issue = { number: 42, title, body: '', labels: currentLabels.map(name => ({ name })), assignees: [] }; const context = { repo: { owner: 'example', repo: 'project' }, - payload: { issue, label: { name: label } }, + eventName, + payload: eventName === 'workflow_dispatch' + ? { inputs: { issue_number: '42' }, repository: { default_branch: 'dev' } } + : { issue, label: { name: label }, repository: { default_branch: 'dev' } }, }; const core = { info: (message: string) => infos.push(message), @@ -84,11 +95,18 @@ async function execute(workflow: Workflow, { calls.push('repo-read'); return { data: { default_branch: 'dev' } }; } }, + actions: { + createWorkflowDispatch: async (input: unknown) => { + calls.push('dispatch'); + dispatches.push(input); + if (dispatchError) throw new Error(dispatchError); + }, + }, issues: { listForRepo: async () => ({ data: [issue] }), get: async () => { calls.push('issue-read'); - return response; + return { data: issue }; }, addAssignees: async () => { calls.push('ordinary-assignment'); @@ -106,7 +124,7 @@ async function execute(workflow: Workflow, { }, }; const files: Record = { - '.squad/team.md': team, + '.squad/team.md': autoAssign ? team : team.replace('copilot-auto-assign: true', 'copilot-auto-assign: false'), '.squad/routing.md': routing, 'triage-results.json': JSON.stringify([ { issueNumber: 42, label: 'squad:copilot', assignTo: '@copilot', reason: 'keyword', source: 'routing' }, @@ -123,10 +141,12 @@ async function execute(workflow: Workflow, { if (!expression) return true; return Boolean(runInNewContext( expression + .replace(/github\.event_name/g, 'eventName') .replace(/github\.event\.label\.name/g, 'label') + .replace(/github\.event\.inputs\.issue_number/g, "'42'") .replace(/steps\.check-script\.outputs\.has_script/g, "'true'"), { - label, startsWith: (value: string, prefix: string) => value.startsWith(prefix), + eventName, label, startsWith: (value: string, prefix: string) => value.startsWith(prefix), success: () => failures.length === 0, hashFiles: () => 'fixture', }, )); @@ -154,13 +174,19 @@ async function execute(workflow: Workflow, { } } } - return { calls, comments, labels, failures, infos, github }; + return { calls, comments, labels, dispatches, failures, infos, github }; } for (const root of roots) { describe(`${root}: executable assignment and attribution contract`, () => { const assignment = () => load(root, 'squad-issue-assign.yml'); + it('supports an issue-scoped workflow-dispatch handoff', () => { + expect(assignment().on.workflow_dispatch, root).toMatchObject({ + inputs: { issue_number: { required: true, type: 'string' } }, + }); + }); + it('missing COPILOT_ASSIGN_TOKEN fails before any routing comment or API mutation', async () => { const result = await execute(assignment(), { token: '' }); expect(result.failures.join('\n'), `${root}: missing token must visibly fail`).toContain('COPILOT_ASSIGN_TOKEN'); @@ -180,6 +206,7 @@ for (const root of roots) { expect(result.failures.length, `${root}: ${input} must fail visibly`).toBeGreaterThan(0); expect(result.calls, `${root}: ${input} must never use ordinary bot assignment`).not.toContain('ordinary-assignment'); expect(result.comments, `${root}: ${input} must not announce successful routing`).toEqual([]); + expect(result.labels, `${root}: ${input} must not repair the parent label after failure`).toEqual([]); }); it('acknowledges only the verified API acceptance, after assignment, without promising a session', async () => { @@ -203,6 +230,32 @@ for (const root of roots) { .toBe('${{ secrets.COPILOT_ASSIGN_TOKEN }}'); }); + it('repairs a missing parent label only after assignment acknowledgment using the default token', async () => { + const result = await execute(assignment(), { issueLabels: ['squad:copilot'] }); + expect(result.failures, root).toEqual([]); + expect(result.labels, root).toContainEqual(['squad']); + expect(result.calls.indexOf('label'), `${root}: parent repair must follow the acknowledgment`) + .toBeGreaterThan(result.calls.indexOf('comment')); + const step = Object.values(assignment().jobs).flatMap(job => job.steps) + .find(step => step.name === 'Ensure parent squad label'); + expect(step?.with?.['github-token'], `${root}: parent repair must use GITHUB_TOKEN`).toBeUndefined(); + }); + + it('accepts explicit workflow-dispatch handoffs only while the Copilot label remains', async () => { + const result = await execute(assignment(), { eventName: 'workflow_dispatch' }); + expect(result.failures, root).toEqual([]); + expect(result.calls).toContain('agent-assignment'); + expect(result.comments.join('\n'), root).toContain('API accepted'); + expect(result.dispatches, root).toEqual([]); + + const stale = await execute(assignment(), { + eventName: 'workflow_dispatch', + issueLabels: ['squad'], + }); + expect(stale.failures.join('\n'), root).toContain('refusing stale Copilot handoff'); + expect(stale.calls).not.toContain('agent-assignment'); + }); + it('preserves named member routing without requiring a Copilot token', async () => { const result = await execute(assignment(), { label: 'squad:moss', token: '' }); expect(result.failures, root).toEqual([]); @@ -210,7 +263,13 @@ for (const root of roots) { expect(result.calls, root).not.toContain('agent-assignment'); }); - it('heartbeat monitors and labels but never assigns Copilot', async () => { + it('manual Copilot label assignment remains available when auto-assign is disabled', async () => { + const result = await execute(assignment(), { autoAssign: false }); + expect(result.failures, root).toEqual([]); + expect(result.calls, root).toContain('agent-assignment'); + }); + + it('heartbeat explicitly hands enabled Copilot auto-routing to issue-assign', async () => { const workflow = load(root, 'squad-heartbeat.yml'); expect(workflow.on, `${root}: monitoring triggers must remain`).toMatchObject({ issues: { types: ['closed', 'labeled'] }, @@ -220,8 +279,24 @@ for (const root of roots) { const result = await execute(workflow); expect(result.failures, root).toEqual([]); expect(result.labels, `${root}: heartbeat must still apply triage decisions`).toContainEqual(['squad:copilot']); - expect(result.calls, `${root}: heartbeat cannot compete with issue-assign`).not.toContain('agent-assignment'); + expect(result.dispatches, `${root}: heartbeat must explicitly dispatch issue-assign`).toHaveLength(1); + expect(workflow.permissions?.actions, `${root}: handoff requires workflow dispatch permission`).toBe('write'); + expect(result.dispatches, root).toContainEqual({ + owner: 'example', + repo: 'project', + workflow_id: 'squad-issue-assign.yml', + ref: 'dev', + inputs: { issue_number: '42' }, + }); + expect(result.calls, `${root}: heartbeat delegates assignment to issue-assign`).not.toContain('agent-assignment'); expect(result.calls, root).not.toContain('ordinary-assignment'); + + const disabled = await execute(workflow, { autoAssign: false }); + expect(disabled.dispatches, `${root}: disabled auto-assign must not dispatch`).toEqual([]); + + const failedDispatch = await execute(workflow, { dispatchError: 'workflow dispatch denied' }); + expect(failedDispatch.failures.join('\n'), `${root}: dispatch failure must be visible`) + .toContain('workflow dispatch denied'); }); it.each([ @@ -232,8 +307,10 @@ for (const root of roots) { ['unknown subject', 'squad:river', 'No specific domain match'], ['runtime quality', 'squad:river', 'No specific domain match'], ])('triage "%s" preserves routing and reports deterministic provenance, not Lead analysis', async (title, label, reason) => { - const result = await execute(load(root, 'squad-triage.yml'), { label: 'squad', title }); + const workflow = load(root, 'squad-triage.yml'); + const result = await execute(workflow, { label: 'squad', title }); expect(result.failures, `${root}: ${title}`).toEqual([]); + expect(workflow.permissions?.actions, `${root}: handoff requires workflow dispatch permission`).toBe('write'); expect(result.labels, `${root}: ${title} routing changed`).toEqual([[label], ['go:needs-research']]); expect(result.comments.join('\n'), `${root}: ${title}`).toContain(reason); expect(result.comments.join('\n'), `${root}: ${title} must disclose keyword provenance`) @@ -244,6 +321,42 @@ for (const root of roots) { expect(result.calls, `${root}: ${title} labels delegate to sole assignment authority`) .not.toContain('ordinary-assignment'); expect(result.calls, root).not.toContain('agent-assignment'); + expect(result.dispatches, `${root}: only auto-routed Copilot cases dispatch`).toHaveLength( + label === 'squad:copilot' ? 1 : 0, + ); + if (label === 'squad:copilot') { + expect(result.dispatches, `${root}: triage must dispatch the issue-specific handoff`).toContainEqual({ + owner: 'example', + repo: 'project', + workflow_id: 'squad-issue-assign.yml', + ref: 'dev', + inputs: { issue_number: '42' }, + }); + } + }); + + it('triage preserves the disabled auto-assign mode and skips pre-owned issues', async () => { + const workflow = load(root, 'squad-triage.yml'); + const disabled = await execute(workflow, { label: 'squad', autoAssign: false }); + expect(disabled.labels[0], root).toEqual(['squad:copilot']); + expect(disabled.dispatches, root).toEqual([]); + + const alreadyOwned = await execute(workflow, { + label: 'squad', + issueLabels: ['squad', 'squad:copilot'], + }); + expect(alreadyOwned.labels, root).toEqual([]); + expect(alreadyOwned.comments, root).toEqual([]); + expect(alreadyOwned.dispatches, root).toEqual([]); + }); + + it('triage fails visibly when its authorized assignment handoff is rejected', async () => { + const result = await execute(load(root, 'squad-triage.yml'), { + label: 'squad', + title: 'bug fix', + dispatchError: 'workflow dispatch denied', + }); + expect(result.failures.join('\n'), root).toContain('workflow dispatch denied'); }); }); } diff --git a/test/cli/health-command.test.ts b/test/cli/health-command.test.ts index 8dc76bed1..a78b5ffa0 100644 --- a/test/cli/health-command.test.ts +++ b/test/cli/health-command.test.ts @@ -278,6 +278,25 @@ describe('team readiness', () => { expect(result.message).toBe('team.md is valid (1 members)'); }); + it('accepts the legacy Team Roster table heading', () => { + writeSquad( + 'team.md', + `# Test Team + +## Team Roster + +| Name | Role | +|------|------| +| Alpha | Developer | +`, + ); + + const result = check(runSquadHealth(squadDir, repoRoot), 'team'); + + expect(result.status).toBe('pass'); + expect(result.message).toBe('team.md is valid (1 members)'); + }); + it('reports only roster members when team.md contains auxiliary tables', () => { writeSquad( 'team.md', diff --git a/test/migration.test.ts b/test/migration.test.ts index 1ec2d3995..b095567b8 100644 --- a/test/migration.test.ts +++ b/test/migration.test.ts @@ -309,6 +309,21 @@ describe('MarkdownMigration', () => { expect(warnings).toHaveLength(0); }); + it('should parse the legacy Team Roster table heading', () => { + const md = ` +## Team Roster + +| Name | Role | +|------|------| +| Alpha | Developer | +`; + const { agents } = parseTeamMarkdown(md); + + expect(agents).toEqual([ + expect.objectContaining({ name: 'alpha', role: 'Developer' }), + ]); + }); + it('should ignore tables outside supported roster sections', () => { const md = ` ## Members