From a23a18d0618131b20858ad104c0908c3a49364b3 Mon Sep 17 00:00:00 2001 From: brady gaster Date: Mon, 24 Aug 2026 16:10:44 -0700 Subject: [PATCH 1/4] fix(gh-aw): enforce activation agent bindings Closes #1801 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../workflows/squad-agent-binding-check.yml | 47 +++++ scripts/check-agent-binding.mjs | 193 ++++++++++++++++++ test/check-agent-binding.test.ts | 179 ++++++++++++++++ workflows/shared/squad-planning-ontology.md | 9 + workflows/squad.md | 45 +++- 5 files changed, 471 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/squad-agent-binding-check.yml create mode 100644 scripts/check-agent-binding.mjs create mode 100644 test/check-agent-binding.test.ts diff --git a/.github/workflows/squad-agent-binding-check.yml b/.github/workflows/squad-agent-binding-check.yml new file mode 100644 index 000000000..f6b4eb2d9 --- /dev/null +++ b/.github/workflows/squad-agent-binding-check.yml @@ -0,0 +1,47 @@ +name: Squad Agent Binding Check + +on: + workflow_run: + workflows: [Squad] + types: [completed] + +permissions: + contents: read + issues: read + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7 + - name: Collect activation evidence from the completed run + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 #v9 + with: + script: | + const fs = require('node:fs'); + const started = new Date(context.payload.workflow_run.run_started_at); + const completed = new Date(context.payload.workflow_run.updated_at); + const comments = await github.paginate(github.rest.issues.listCommentsForRepo, { + ...context.repo, + since: started.toISOString(), + per_page: 100, + }); + const activationComments = comments + .filter(comment => new Date(comment.created_at) <= completed) + .filter(comment => /Structured data:/i.test(comment.body ?? '') && /activated/i.test(comment.body ?? '')) + .map(comment => ({ + body: comment.body, + issue: Number(comment.issue_url.split('/').at(-1)), + })); + fs.writeFileSync( + `${process.env.RUNNER_TEMP}/activation-comments.json`, + JSON.stringify(activationComments), + ); + - name: Check agent bindings + env: + GITHUB_TOKEN: ${{ github.token }} + run: >- + node scripts/check-agent-binding.mjs + --comments-file="$RUNNER_TEMP/activation-comments.json" + --team-file=".squad/team.md" + --repo="${{ github.repository }}" diff --git a/scripts/check-agent-binding.mjs b/scripts/check-agent-binding.mjs new file mode 100644 index 000000000..c4367c789 --- /dev/null +++ b/scripts/check-agent-binding.mjs @@ -0,0 +1,193 @@ +import { readFile } from 'node:fs/promises'; +import { pathToFileURL } from 'node:url'; + +const ACTIVATION_ARTIFACTS = new Set(['activated', 'phases-activated']); +const VALID_OMISSIONS = new Set(['multi-owner', 'non-roster', 'copilot']); + +function normalize(value) { + return typeof value === 'string' ? value.trim().toLowerCase() : ''; +} + +export function parseStructuredData(comment) { + const blocks = [...comment.matchAll(/Structured data:\s*```json\s*([\s\S]*?)```/gi)]; + if (blocks.length === 0) { + if (/Structured data:/i.test(comment) && /"squad_artifact"\s*:\s*"?(?:phases-)?activated/i.test(comment)) { + throw new Error('activation structured data block could not be parsed'); + } + return null; + } + + let parsed; + try { + parsed = JSON.parse(blocks.at(-1)[1]); + } catch (error) { + throw new Error(`activation structured data is invalid JSON: ${error.message}`); + } + return parsed; +} + +export function parseRoster(teamMarkdown) { + const lines = teamMarkdown.replace(/\r/g, '').split('\n'); + const section = lines.findIndex(line => /^##\s+Members\s*$/.test(line.trim())); + if (section < 0) throw new Error('roster has no ## Members section'); + + const nextSection = lines.findIndex((line, index) => index > section && /^##\s/.test(line)); + const table = lines.slice(section + 1, nextSection < 0 ? undefined : nextSection) + .filter(line => line.trim().startsWith('|')); + if (table.length < 3) throw new Error('roster Members table is missing or empty'); + + const cells = line => line.split('|').slice(1, -1).map(cell => cell.trim()); + const header = cells(table[0]); + const nameIndex = header.findIndex(cell => normalize(cell) === 'name'); + if (nameIndex < 0) throw new Error('roster Members table has no Name column'); + + const names = table.slice(2) + .map(line => normalize(cells(line)[nameIndex])) + .filter(Boolean); + if (names.length === 0) throw new Error('roster Members table has no members'); + return new Set(names); +} + +function expectedBinding(binding, roster) { + if (!binding || typeof binding !== 'object' || !Number.isInteger(binding.issue) || binding.issue < 1) { + throw new Error('binding has no valid issue number'); + } + if (!['task', 'epic'].includes(binding.kind)) throw new Error(`issue #${binding.issue}: invalid binding kind`); + if (!normalize(binding.epic)) throw new Error(`issue #${binding.issue}: missing epic linkage`); + if (!Array.isArray(binding.agents) || binding.agents.length === 0) { + throw new Error(`issue #${binding.issue}: agents must be a non-empty array`); + } + + const agents = [...new Set(binding.agents.map(normalize).filter(Boolean))]; + if (agents.length !== binding.agents.length) { + throw new Error(`issue #${binding.issue}: agents contain empty or duplicate values`); + } + + if (binding.kind === 'task') { + if (!normalize(binding.task)) throw new Error(`issue #${binding.issue}: task binding has no plan task number`); + const agent = normalize(binding.agent); + if (!agent || agents.length !== 1 || agents[0] !== agent) { + throw new Error(`issue #${binding.issue}: task agent does not match its agents array`); + } + } + + let label = null; + let omission = null; + if (binding.kind === 'epic' && agents.length > 1) { + omission = 'multi-owner'; + } else if (agents[0] === '@copilot') { + omission = 'copilot'; + } else if (!roster.has(agents[0])) { + omission = 'non-roster'; + } else { + label = `squad:${agents[0]}`; + } + + if (binding.label !== label && !(label === null && binding.label === undefined)) { + throw new Error(`issue #${binding.issue}: reported label ${binding.label ?? '(none)'} does not match ${label ?? 'bare squad'}`); + } + if (binding.omission_reason !== omission && !(omission === null && binding.omission_reason === undefined)) { + throw new Error(`issue #${binding.issue}: omission report must be ${omission ?? 'absent'}`); + } + if (binding.omission_reason && !VALID_OMISSIONS.has(binding.omission_reason)) { + throw new Error(`issue #${binding.issue}: invalid omission reason`); + } + return label; +} + +export function validateBindings(artifact, roster, labelsByIssue) { + return validateActivation(artifact, roster, labelsByIssue); +} + +export function validateActivation(artifact, roster, labelsByIssue, expectedOrigin) { + if (!artifact || !ACTIVATION_ARTIFACTS.has(artifact.squad_artifact)) return { skipped: true }; + if (artifact.schema_version !== '1') throw new Error('activation artifact schema_version must be 1'); + if (!Number.isInteger(artifact.origin_issue) || artifact.origin_issue < 1) { + throw new Error('activation artifact origin_issue is invalid'); + } + if (expectedOrigin !== undefined && artifact.origin_issue !== expectedOrigin) { + throw new Error(`activation artifact origin_issue ${artifact.origin_issue} does not match comment issue ${expectedOrigin}`); + } + if (!Array.isArray(artifact.phases) || artifact.phases.some(phase => !Number.isInteger(phase) || phase < 1)) { + throw new Error('activation artifact phases are invalid'); + } + if (!Array.isArray(artifact.bindings) || artifact.bindings.length === 0) { + throw new Error('activation artifact bindings are missing or empty'); + } + + const seen = new Set(); + for (const binding of artifact.bindings) { + const expected = expectedBinding(binding, roster); + if (seen.has(binding.issue)) throw new Error(`issue #${binding.issue}: duplicate binding`); + seen.add(binding.issue); + + const labels = labelsByIssue.get(binding.issue); + if (!labels) throw new Error(`issue #${binding.issue}: labels could not be resolved`); + if (!labels.has('squad')) throw new Error(`issue #${binding.issue}: missing squad label`); + + const actualAgentLabels = [...labels].filter(label => label.startsWith('squad:')); + const expectedAgentLabels = expected ? [expected] : []; + if (actualAgentLabels.length !== expectedAgentLabels.length || actualAgentLabels[0] !== expectedAgentLabels[0]) { + throw new Error( + `issue #${binding.issue}: expected ${expected ?? 'bare squad'}, found ${actualAgentLabels.join(', ') || 'bare squad'}`, + ); + } + } + return { skipped: false, checked: seen.size }; +} + +async function fetchLabels(repo, issues, token) { + const labels = new Map(); + for (const issue of issues) { + const response = await fetch(`https://api.github.com/repos/${repo}/issues/${issue}`, { + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${token}`, + 'X-GitHub-Api-Version': '2022-11-28', + }, + }); + if (!response.ok) throw new Error(`issue #${issue}: GitHub API returned ${response.status}`); + const body = await response.json(); + labels.set(issue, new Set(body.labels.map(label => normalize(typeof label === 'string' ? label : label.name)))); + } + return labels; +} + +async function main() { + const args = Object.fromEntries(process.argv.slice(2).map(arg => { + const [key, ...value] = arg.replace(/^--/, '').split('='); + return [key, value.join('=')]; + })); + if ((!args['comment-file'] && !args['comments-file']) || !args['team-file'] || !args.repo) { + throw new Error('usage: check-agent-binding.mjs --comment-file=PATH|--comments-file=PATH --team-file=PATH --repo=OWNER/REPO'); + } + + const roster = parseRoster(await readFile(args['team-file'], 'utf8')); + const token = process.env.GITHUB_TOKEN; + if (!token) throw new Error('GITHUB_TOKEN is required for activation binding checks'); + + const comments = args['comments-file'] + ? JSON.parse(await readFile(args['comments-file'], 'utf8')) + : [{ body: await readFile(args['comment-file'], 'utf8') }]; + if (!Array.isArray(comments)) throw new Error('comments file must contain an array'); + + let checked = 0; + for (const comment of comments) { + const artifact = parseStructuredData(comment.body ?? ''); + if (!artifact || !ACTIVATION_ARTIFACTS.has(artifact.squad_artifact)) continue; + const issues = Array.isArray(artifact.bindings) + ? artifact.bindings.map(binding => binding?.issue).filter(Number.isInteger) + : []; + const labels = await fetchLabels(args.repo, [...new Set(issues)], token); + const result = validateActivation(artifact, roster, labels, comment.issue); + checked += result.checked; + } + console.log(checked === 0 ? 'No activation artifact found; skipping.' : `Validated ${checked} activation bindings.`); +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch(error => { + console.error(`Agent binding check failed: ${error.message}`); + process.exitCode = 1; + }); +} diff --git a/test/check-agent-binding.test.ts b/test/check-agent-binding.test.ts new file mode 100644 index 000000000..d37bb6a08 --- /dev/null +++ b/test/check-agent-binding.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { + parseRoster, + parseStructuredData, + validateActivation, + validateBindings, +} from '../scripts/check-agent-binding.mjs'; + +const roster = parseRoster(` +## Members +| Name | Role | +|------|------| +| Kint | Lead | +| McManus | Dev | +`); + +function labels(entries: Record) { + return new Map(Object.entries(entries).map(([issue, issueLabels]) => [ + Number(issue), + new Set(issueLabels), + ])); +} + +describe('deterministic post-activation agent binding guard (#1801)', () => { + it('parses the last Structured data block from an activation comment', () => { + const artifact = parseStructuredData(` +Structured data: +\`\`\`json +{"squad_artifact":"activated","schema_version":"1","origin_issue":1,"phases":[],"bindings":[{"kind":"task","task":"6","issue":17,"epic":"2.1","agent":"McManus","agents":["mcmanus"],"label":"squad:mcmanus"}]} +\`\`\` +`); + expect(artifact.bindings[0]).toMatchObject({ task: '6', issue: 17, agent: 'McManus' }); + }); + + it('rejects the observed #1859 correspondence failure despite both agents being roster names', () => { + const artifact = { + squad_artifact: 'activated', schema_version: '1', origin_issue: 1, phases: [], + bindings: [ + { kind: 'task', task: '6', issue: 17, epic: '2.1', agent: 'McManus', agents: ['mcmanus'], label: 'squad:mcmanus' }, + ], + }; + expect(() => validateBindings(artifact, roster, labels({ 17: ['squad', 'squad:kint'] }))) + .toThrow('expected squad:mcmanus, found squad:kint'); + }); + + it('accepts multi-wave task numbers and checks each task against its own mapping', () => { + const artifact = { + squad_artifact: 'phases-activated', schema_version: '1', origin_issue: 1, phases: [2], + bindings: [ + { kind: 'task', task: '2.1', issue: 21, epic: '2', agent: 'Kint', agents: ['kint'], label: 'squad:kint' }, + { kind: 'task', task: '2.2', issue: 22, epic: '2', agent: 'McManus', agents: ['mcmanus'], label: 'squad:mcmanus' }, + ], + }; + expect(validateBindings(artifact, roster, labels({ + 21: ['squad', 'squad:kint'], + 22: ['squad', 'squad:mcmanus'], + }))).toEqual({ skipped: false, checked: 2 }); + }); + + it('derives epic behavior from the distinct task-agent set', () => { + const singleOwner = { + squad_artifact: 'activated', schema_version: '1', origin_issue: 1, phases: [], + bindings: [ + { kind: 'epic', issue: 6, epic: '2.1', agents: ['mcmanus'], label: 'squad:mcmanus' }, + ], + }; + expect(validateBindings(singleOwner, roster, labels({ 6: ['squad', 'squad:mcmanus'] })).checked).toBe(1); + + const multiOwner = { + squad_artifact: 'activated', schema_version: '1', origin_issue: 1, phases: [], + bindings: [ + { kind: 'epic', issue: 7, epic: '1.2', agents: ['kint', 'mcmanus'], omission_reason: 'multi-owner' }, + ], + }; + expect(validateBindings(multiOwner, roster, labels({ 7: ['squad'] })).checked).toBe(1); + }); + + it('rejects the observed #1860 summary/label inconsistencies', () => { + const falseSingleOwnerReport = { + squad_artifact: 'activated', schema_version: '1', origin_issue: 1, phases: [], + bindings: [ + { kind: 'epic', issue: 6, epic: '2.1', agents: ['mcmanus'], label: 'squad:mcmanus' }, + ], + }; + expect(() => validateBindings(falseSingleOwnerReport, roster, labels({ 6: ['squad'] }))) + .toThrow('expected squad:mcmanus, found bare squad'); + + const omittedMultiOwnerReport = { + squad_artifact: 'activated', schema_version: '1', origin_issue: 1, phases: [], + bindings: [ + { kind: 'epic', issue: 7, epic: '1.2', agents: ['kint', 'mcmanus'] }, + ], + }; + expect(() => validateBindings(omittedMultiOwnerReport, roster, labels({ 7: ['squad'] }))) + .toThrow('omission report must be multi-owner'); + }); + + it.each([ + ['missing', undefined], + ['empty', []], + ])('fails closed when activation bindings are %s', (_name, bindings) => { + expect(() => validateBindings({ + squad_artifact: 'activated', + schema_version: '1', + origin_issue: 1, + phases: [], + bindings, + }, roster, new Map())) + .toThrow('bindings are missing or empty'); + }); + + it('fails closed when activation evidence has no parseable structured block', () => { + expect(() => parseStructuredData( + 'Structured data:\n```json\n{"squad_artifact":"activated","bindings":[', + )).toThrow('could not be parsed'); + }); + + it('fails closed when a binding issue cannot be resolved', () => { + const artifact = { + squad_artifact: 'activated', schema_version: '1', origin_issue: 1, phases: [], + bindings: [ + { kind: 'task', task: '1', issue: 99, epic: '1.1', agent: 'Kint', agents: ['kint'], label: 'squad:kint' }, + ], + }; + expect(() => validateBindings(artifact, roster, new Map())).toThrow('labels could not be resolved'); + }); + + it('requires non-roster omissions to be reported and carry no agent label', () => { + const artifact = { + squad_artifact: 'activated', schema_version: '1', origin_issue: 1, phases: [], + bindings: [ + { kind: 'task', task: '3', issue: 30, epic: '1.2', agent: 'Reviewer', agents: ['reviewer'] }, + ], + }; + expect(() => validateBindings(artifact, roster, labels({ 30: ['squad'] }))) + .toThrow('omission report must be non-roster'); + + const reportedArtifact = { + squad_artifact: 'activated', schema_version: '1', origin_issue: 1, phases: [], + bindings: [ + { + kind: 'task', + task: '3', + issue: 30, + epic: '1.2', + agent: 'Reviewer', + agents: ['reviewer'], + omission_reason: 'non-roster', + }, + ], + }; + expect(validateBindings(reportedArtifact, roster, labels({ 30: ['squad'] })).checked).toBe(1); + }); + + it('wires a plain read-only workflow to the checker', () => { + const workflow = readFileSync(join(process.cwd(), '.github', 'workflows', 'squad-agent-binding-check.yml'), 'utf8'); + expect(workflow).toContain('workflow_run:'); + expect(workflow).toContain('issues: read'); + expect(workflow).toContain('node scripts/check-agent-binding.mjs'); + expect(workflow).not.toContain('issues: write'); + }); + + it('fails closed on an incomplete envelope or mismatched origin issue', () => { + expect(() => validateBindings({ + squad_artifact: 'activated', + bindings: [{ kind: 'task', task: '1', issue: 1, epic: '1', agent: 'Kint', agents: ['kint'], label: 'squad:kint' }], + }, roster, labels({ 1: ['squad', 'squad:kint'] }))).toThrow('schema_version'); + + expect(() => validateActivation({ + squad_artifact: 'activated', + schema_version: '1', + origin_issue: 8, + phases: [], + bindings: [{ kind: 'task', task: '1', issue: 1, epic: '1', agent: 'Kint', agents: ['kint'], label: 'squad:kint' }], + }, roster, labels({ 1: ['squad', 'squad:kint'] }), 9)).toThrow('does not match comment issue'); + }); +}); diff --git a/workflows/shared/squad-planning-ontology.md b/workflows/shared/squad-planning-ontology.md index 1b10edb15..5944d244e 100644 --- a/workflows/shared/squad-planning-ontology.md +++ b/workflows/shared/squad-planning-ontology.md @@ -308,6 +308,15 @@ been run, and an omitted row is not a pass. | 1 | | #NNN | S | <agent> | ``` +Activation safe-output data also carries a non-empty `bindings` array. Each task +entry maps its plan task number and epic to the returned GitHub issue number and +raw agent assignment. Each epic entry maps its epic identifier and returned issue +number to the distinct agent set derived from its tasks. Entries record the label +that was actually applied, or an `omission_reason` (`multi-owner`, `non-roster`, +or `copilot`) when policy requires bare `squad`. This mapping is mandatory for +`phases-activated` and `activated` artifacts so the post-activation checker can +fail closed without matching model-authored titles. + --- ## 4. Structured Artifact Registry diff --git a/workflows/squad.md b/workflows/squad.md index fd54a74b7..d38c620b4 100644 --- a/workflows/squad.md +++ b/workflows/squad.md @@ -74,6 +74,37 @@ safe-outputs: items: type: integer minimum: 1 + bindings: + type: array + items: + type: object + properties: + kind: + type: string + enum: [task, epic] + issue: + type: integer + minimum: 1 + task: + type: string + epic: + type: string + agent: + type: string + agents: + type: array + items: + type: string + label: + type: string + omission_reason: + type: string + required: + - kind + - issue + - epic + - agents + additionalProperties: false required: - squad_artifact - schema_version @@ -1393,9 +1424,19 @@ Add `blockedBy` via API for tasks and epics. Graceful fallback. Never fail activ Phase artifact: `data: {"squad_artifact":"phases-activated","schema_version":"1","origin_issue":{issue_number},"phases":[{accumulated}]}` → `## ✅ Phase {N} Activated — {count} issues` + issue table + remaining phases table. -Full artifact: `data: {"squad_artifact":"activated","schema_version":"1","origin_issue":{issue_number},"phases":[]}` → `## ✅ Plan Activated — {epic_count} epics, {task_count} tasks` + hierarchy summary, created epics table, created tasks table, dependency order. +Every phase and full activation artifact MUST include a non-empty `bindings` array built only from successful `create-issue` results. Emit one task binding per created/recognized task: + +`{"kind":"task","task":"{plan # cell}","issue":{created issue number},"epic":"{Epic cell}","agent":"{raw Agent cell}","agents":["{lowercased Agent cell}"],"label":"squad:{agent}"}`. + +Emit one epic binding per created/recognized epic: + +`{"kind":"epic","issue":{created epic issue number},"epic":"{Epic identifier}","agents":["{distinct lowercased task agents}"],"label":"squad:{agent}"}` for a single roster owner, or omit `label` and set `"omission_reason":"multi-owner"` for multiple owners. For a task whose agent is not certified by TG-2, omit `label` and set `"omission_reason":"non-roster"`; for `@copilot`, use `"omission_reason":"copilot"`. Never omit a created issue from `bindings`, never infer an issue number, and never emit an empty array. The deterministic post-activation workflow treats missing, empty, malformed, or unresolved bindings as a failure. + +Phase artifact: `data: {"squad_artifact":"phases-activated","schema_version":"1","origin_issue":{issue_number},"phases":[{accumulated}],"bindings":[{this phase's bindings}]}` → `## ✅ Phase {N} Activated — {count} issues` + issue table + remaining phases table. + +Full artifact: `data: {"squad_artifact":"activated","schema_version":"1","origin_issue":{issue_number},"phases":[],"bindings":[{all bindings}]}` → `## ✅ Plan Activated — {epic_count} epics, {task_count} tasks` + hierarchy summary, created epics table, created tasks table, dependency order. -Terminal (last phase): emit `data: {"squad_artifact":"activated","schema_version":"1","origin_issue":{issue_number},"phases":[{all_phases}]}` with an "All Phases Activated" heading. +Terminal (last phase): emit `data: {"squad_artifact":"activated","schema_version":"1","origin_issue":{issue_number},"phases":[{all_phases}],"bindings":[{bindings accumulated across every activated phase}]}` with an "All Phases Activated" heading. ##### Step 5: Update Lifecycle From 553803d5b3dfb43f04939493ea4b7f569655bcbe Mon Sep 17 00:00:00 2001 From: brady gaster <bradygaster@github.com> Date: Mon, 24 Aug 2026 16:35:29 -0700 Subject: [PATCH 2/4] fix(gh-aw): address binding guard review Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 38c1464f-cc45-4217-bd43-09655eee99c8 --- .../workflows/squad-agent-binding-check.yml | 3 + scripts/check-agent-binding.mjs | 134 +++++--- test/check-agent-binding.test.ts | 305 ++++++++++++------ workflows/shared/squad-planning-ontology.md | 13 +- workflows/squad.md | 38 ++- 5 files changed, 328 insertions(+), 165 deletions(-) diff --git a/.github/workflows/squad-agent-binding-check.yml b/.github/workflows/squad-agent-binding-check.yml index f6b4eb2d9..9d43777bd 100644 --- a/.github/workflows/squad-agent-binding-check.yml +++ b/.github/workflows/squad-agent-binding-check.yml @@ -14,6 +14,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 #v6 + with: + node-version: 22 - name: Collect activation evidence from the completed run uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 #v9 with: diff --git a/scripts/check-agent-binding.mjs b/scripts/check-agent-binding.mjs index c4367c789..9d8d7e9e5 100644 --- a/scripts/check-agent-binding.mjs +++ b/scripts/check-agent-binding.mjs @@ -2,7 +2,7 @@ import { readFile } from 'node:fs/promises'; import { pathToFileURL } from 'node:url'; const ACTIVATION_ARTIFACTS = new Set(['activated', 'phases-activated']); -const VALID_OMISSIONS = new Set(['multi-owner', 'non-roster', 'copilot']); +const VALID_OMISSIONS = new Set(['multi-owner', 'non-roster']); function normalize(value) { return typeof value === 'string' ? value.trim().toLowerCase() : ''; @@ -48,51 +48,63 @@ export function parseRoster(teamMarkdown) { return new Set(names); } -function expectedBinding(binding, roster) { - if (!binding || typeof binding !== 'object' || !Number.isInteger(binding.issue) || binding.issue < 1) { - throw new Error('binding has no valid issue number'); - } - if (!['task', 'epic'].includes(binding.kind)) throw new Error(`issue #${binding.issue}: invalid binding kind`); - if (!normalize(binding.epic)) throw new Error(`issue #${binding.issue}: missing epic linkage`); - if (!Array.isArray(binding.agents) || binding.agents.length === 0) { - throw new Error(`issue #${binding.issue}: agents must be a non-empty array`); - } +function expectedLabel(agent, roster) { + if (agent === '@copilot') return { label: 'squad:copilot', omission: null }; + if (roster.has(agent)) return { label: `squad:${agent}`, omission: null }; + return { label: null, omission: 'non-roster' }; +} - const agents = [...new Set(binding.agents.map(normalize).filter(Boolean))]; - if (agents.length !== binding.agents.length) { - throw new Error(`issue #${binding.issue}: agents contain empty or duplicate values`); +function validateReportedOutcome(binding, prefix, expected) { + const labelKey = prefix ? `${prefix}_label` : 'label'; + const omissionKey = prefix ? `${prefix}_omission_reason` : 'omission_reason'; + if (binding[labelKey] !== expected.label && !(expected.label === null && binding[labelKey] === undefined)) { + throw new Error( + `issue #${binding.issue}: reported ${labelKey} ${binding[labelKey] ?? '(none)'} does not match ${expected.label ?? 'bare squad'}`, + ); } - - if (binding.kind === 'task') { - if (!normalize(binding.task)) throw new Error(`issue #${binding.issue}: task binding has no plan task number`); - const agent = normalize(binding.agent); - if (!agent || agents.length !== 1 || agents[0] !== agent) { - throw new Error(`issue #${binding.issue}: task agent does not match its agents array`); - } + if (binding[omissionKey] !== expected.omission && !(expected.omission === null && binding[omissionKey] === undefined)) { + throw new Error( + `issue #${binding.issue}: ${omissionKey} must be ${expected.omission ?? 'absent'}`, + ); } - - let label = null; - let omission = null; - if (binding.kind === 'epic' && agents.length > 1) { - omission = 'multi-owner'; - } else if (agents[0] === '@copilot') { - omission = 'copilot'; - } else if (!roster.has(agents[0])) { - omission = 'non-roster'; - } else { - label = `squad:${agents[0]}`; + if (binding[omissionKey] && !VALID_OMISSIONS.has(binding[omissionKey])) { + throw new Error(`issue #${binding.issue}: invalid ${omissionKey}`); } +} - if (binding.label !== label && !(label === null && binding.label === undefined)) { - throw new Error(`issue #${binding.issue}: reported label ${binding.label ?? '(none)'} does not match ${label ?? 'bare squad'}`); +function validateTaskBinding(binding, roster) { + if (!binding || typeof binding !== 'object' || !Number.isInteger(binding.issue) || binding.issue < 1) { + throw new Error('binding has no valid issue number'); } - if (binding.omission_reason !== omission && !(omission === null && binding.omission_reason === undefined)) { - throw new Error(`issue #${binding.issue}: omission report must be ${omission ?? 'absent'}`); + if (!Number.isInteger(binding.epic_issue) || binding.epic_issue < 1) { + throw new Error(`issue #${binding.issue}: missing epic issue linkage`); } - if (binding.omission_reason && !VALID_OMISSIONS.has(binding.omission_reason)) { - throw new Error(`issue #${binding.issue}: invalid omission reason`); + if (!normalize(binding.task)) throw new Error(`issue #${binding.issue}: binding has no plan task number`); + if (!normalize(binding.epic)) throw new Error(`issue #${binding.issue}: missing epic linkage`); + const agent = normalize(binding.agent); + if (!agent) throw new Error(`issue #${binding.issue}: binding has no agent`); + if (!Array.isArray(binding.epic_agents) || binding.epic_agents.length === 0) { + throw new Error(`issue #${binding.issue}: epic_agents must be a non-empty array`); + } + const epicAgents = [...new Set(binding.epic_agents.map(normalize).filter(Boolean))].sort(); + if (epicAgents.length !== binding.epic_agents.length || !epicAgents.includes(agent)) { + throw new Error(`issue #${binding.issue}: epic_agents are empty, duplicated, or exclude the task agent`); + } + const expected = expectedLabel(agent, roster); + validateReportedOutcome(binding, '', expected); + return { epicAgents, expected }; +} + +function validateActualLabels(issue, labels, expected) { + if (!labels) throw new Error(`issue #${issue}: labels could not be resolved`); + if (!labels.has('squad')) throw new Error(`issue #${issue}: missing squad label`); + const actualAgentLabels = [...labels].filter(label => label.startsWith('squad:')); + const expectedAgentLabels = expected.label ? [expected.label] : []; + if (actualAgentLabels.length !== expectedAgentLabels.length || actualAgentLabels[0] !== expectedAgentLabels[0]) { + throw new Error( + `issue #${issue}: expected ${expected.label ?? 'bare squad'}, found ${actualAgentLabels.join(', ') || 'bare squad'}`, + ); } - return label; } export function validateBindings(artifact, roster, labelsByIssue) { @@ -116,24 +128,44 @@ export function validateActivation(artifact, roster, labelsByIssue, expectedOrig } const seen = new Set(); + const epics = new Map(); + const epicIssuesByIdentifier = new Map(); for (const binding of artifact.bindings) { - const expected = expectedBinding(binding, roster); + const { epicAgents, expected } = validateTaskBinding(binding, roster); if (seen.has(binding.issue)) throw new Error(`issue #${binding.issue}: duplicate binding`); seen.add(binding.issue); + validateActualLabels(binding.issue, labelsByIssue.get(binding.issue), expected); - const labels = labelsByIssue.get(binding.issue); - if (!labels) throw new Error(`issue #${binding.issue}: labels could not be resolved`); - if (!labels.has('squad')) throw new Error(`issue #${binding.issue}: missing squad label`); - - const actualAgentLabels = [...labels].filter(label => label.startsWith('squad:')); - const expectedAgentLabels = expected ? [expected] : []; - if (actualAgentLabels.length !== expectedAgentLabels.length || actualAgentLabels[0] !== expectedAgentLabels[0]) { - throw new Error( - `issue #${binding.issue}: expected ${expected ?? 'bare squad'}, found ${actualAgentLabels.join(', ') || 'bare squad'}`, - ); + const epicIdentifier = normalize(binding.epic); + const priorEpicIssue = epicIssuesByIdentifier.get(epicIdentifier); + if (priorEpicIssue !== undefined && priorEpicIssue !== binding.epic_issue) { + throw new Error(`epic ${epicIdentifier}: maps to multiple epic issue numbers`); + } + epicIssuesByIdentifier.set(epicIdentifier, binding.epic_issue); + + const epic = epics.get(binding.epic_issue) ?? { + epic: epicIdentifier, + agents: epicAgents, + bindings: [], + }; + if (epic.epic !== epicIdentifier) { + throw new Error(`epic issue #${binding.epic_issue}: conflicting epic identifiers`); + } + if (epic.agents.join('\0') !== epicAgents.join('\0')) { + throw new Error(`epic issue #${binding.epic_issue}: inconsistent epic_agents sets`); } + epic.bindings.push(binding); + epics.set(binding.epic_issue, epic); + } + + for (const [epicIssue, epic] of epics) { + const expected = epic.agents.length > 1 + ? { label: null, omission: 'multi-owner' } + : expectedLabel(epic.agents[0], roster); + for (const binding of epic.bindings) validateReportedOutcome(binding, 'epic', expected); + validateActualLabels(epicIssue, labelsByIssue.get(epicIssue), expected); } - return { skipped: false, checked: seen.size }; + return { skipped: false, checked: seen.size, epics: epics.size }; } async function fetchLabels(repo, issues, token) { @@ -176,7 +208,7 @@ async function main() { const artifact = parseStructuredData(comment.body ?? ''); if (!artifact || !ACTIVATION_ARTIFACTS.has(artifact.squad_artifact)) continue; const issues = Array.isArray(artifact.bindings) - ? artifact.bindings.map(binding => binding?.issue).filter(Number.isInteger) + ? artifact.bindings.flatMap(binding => [binding?.issue, binding?.epic_issue]).filter(Number.isInteger) : []; const labels = await fetchLabels(args.repo, [...new Set(issues)], token); const result = validateActivation(artifact, roster, labels, comment.issue); diff --git a/test/check-agent-binding.test.ts b/test/check-agent-binding.test.ts index d37bb6a08..2d654d248 100644 --- a/test/check-agent-binding.test.ts +++ b/test/check-agent-binding.test.ts @@ -23,78 +23,142 @@ function labels(entries: Record<number, string[]>) { ])); } +function artifact(bindings: object[], phased = false) { + return { + squad_artifact: phased ? 'phases-activated' : 'activated', + schema_version: '1', + origin_issue: 1, + phases: phased ? [2] : [], + bindings, + }; +} + +function task({ + task = '1', + issue, + epic = '2.1', + epicIssue = 6, + agent, + epicAgents = [agent], + label, + omission, + epicLabel, + epicOmission, +}: { + task?: string; + issue: number; + epic?: string; + epicIssue?: number; + agent: string; + epicAgents?: string[]; + label?: string; + omission?: string; + epicLabel?: string; + epicOmission?: string; +}) { + return { + task, + issue, + epic, + epic_issue: epicIssue, + agent, + epic_agents: epicAgents, + ...(label ? { label } : {}), + ...(omission ? { omission_reason: omission } : {}), + ...(epicLabel ? { epic_label: epicLabel } : {}), + ...(epicOmission ? { epic_omission_reason: epicOmission } : {}), + }; +} + describe('deterministic post-activation agent binding guard (#1801)', () => { it('parses the last Structured data block from an activation comment', () => { - const artifact = parseStructuredData(` + const parsed = parseStructuredData(` Structured data: \`\`\`json -{"squad_artifact":"activated","schema_version":"1","origin_issue":1,"phases":[],"bindings":[{"kind":"task","task":"6","issue":17,"epic":"2.1","agent":"McManus","agents":["mcmanus"],"label":"squad:mcmanus"}]} +{"squad_artifact":"activated","schema_version":"1","origin_issue":1,"phases":[],"bindings":[{"task":"6","issue":17,"epic":"2.1","epic_issue":6,"agent":"McManus","epic_agents":["mcmanus"],"label":"squad:mcmanus","epic_label":"squad:mcmanus"}]} \`\`\` `); - expect(artifact.bindings[0]).toMatchObject({ task: '6', issue: 17, agent: 'McManus' }); + expect(parsed.bindings[0]).toMatchObject({ task: '6', issue: 17, agent: 'McManus' }); }); it('rejects the observed #1859 correspondence failure despite both agents being roster names', () => { - const artifact = { - squad_artifact: 'activated', schema_version: '1', origin_issue: 1, phases: [], - bindings: [ - { kind: 'task', task: '6', issue: 17, epic: '2.1', agent: 'McManus', agents: ['mcmanus'], label: 'squad:mcmanus' }, - ], - }; - expect(() => validateBindings(artifact, roster, labels({ 17: ['squad', 'squad:kint'] }))) - .toThrow('expected squad:mcmanus, found squad:kint'); + const input = artifact([ + task({ task: '6', issue: 17, agent: 'McManus', label: 'squad:mcmanus', epicLabel: 'squad:mcmanus' }), + ]); + expect(() => validateBindings(input, roster, labels({ + 17: ['squad', 'squad:kint'], + 6: ['squad', 'squad:mcmanus'], + }))).toThrow('expected squad:mcmanus, found squad:kint'); }); it('accepts multi-wave task numbers and checks each task against its own mapping', () => { - const artifact = { - squad_artifact: 'phases-activated', schema_version: '1', origin_issue: 1, phases: [2], - bindings: [ - { kind: 'task', task: '2.1', issue: 21, epic: '2', agent: 'Kint', agents: ['kint'], label: 'squad:kint' }, - { kind: 'task', task: '2.2', issue: 22, epic: '2', agent: 'McManus', agents: ['mcmanus'], label: 'squad:mcmanus' }, - ], - }; - expect(validateBindings(artifact, roster, labels({ + const input = artifact([ + task({ + task: '2.1', + issue: 21, + epic: '2', + epicIssue: 20, + agent: 'Kint', + epicAgents: ['kint', 'mcmanus'], + label: 'squad:kint', + epicOmission: 'multi-owner', + }), + task({ + task: '2.2', + issue: 22, + epic: '2', + epicIssue: 20, + agent: 'McManus', + epicAgents: ['kint', 'mcmanus'], + label: 'squad:mcmanus', + epicOmission: 'multi-owner', + }), + ], true); + expect(validateBindings(input, roster, labels({ + 20: ['squad'], 21: ['squad', 'squad:kint'], 22: ['squad', 'squad:mcmanus'], - }))).toEqual({ skipped: false, checked: 2 }); + }))).toEqual({ skipped: false, checked: 2, epics: 1 }); }); - it('derives epic behavior from the distinct task-agent set', () => { - const singleOwner = { - squad_artifact: 'activated', schema_version: '1', origin_issue: 1, phases: [], - bindings: [ - { kind: 'epic', issue: 6, epic: '2.1', agents: ['mcmanus'], label: 'squad:mcmanus' }, - ], - }; - expect(validateBindings(singleOwner, roster, labels({ 6: ['squad', 'squad:mcmanus'] })).checked).toBe(1); - - const multiOwner = { - squad_artifact: 'activated', schema_version: '1', origin_issue: 1, phases: [], - bindings: [ - { kind: 'epic', issue: 7, epic: '1.2', agents: ['kint', 'mcmanus'], omission_reason: 'multi-owner' }, - ], - }; - expect(validateBindings(multiOwner, roster, labels({ 7: ['squad'] })).checked).toBe(1); + it('derives single- and multi-owner epic behavior from task bindings', () => { + const singleOwner = artifact([ + task({ issue: 17, agent: 'McManus', label: 'squad:mcmanus', epicLabel: 'squad:mcmanus' }), + ]); + expect(validateBindings(singleOwner, roster, labels({ + 6: ['squad', 'squad:mcmanus'], + 17: ['squad', 'squad:mcmanus'], + })).epics).toBe(1); + + const multiOwner = artifact([ + task({ issue: 21, agent: 'Kint', epicAgents: ['kint', 'mcmanus'], label: 'squad:kint', epicOmission: 'multi-owner' }), + task({ issue: 22, agent: 'McManus', epicAgents: ['mcmanus', 'kint'], label: 'squad:mcmanus', epicOmission: 'multi-owner' }), + ]); + expect(validateBindings(multiOwner, roster, labels({ + 6: ['squad'], + 21: ['squad', 'squad:kint'], + 22: ['squad', 'squad:mcmanus'], + })).epics).toBe(1); }); - it('rejects the observed #1860 summary/label inconsistencies', () => { - const falseSingleOwnerReport = { - squad_artifact: 'activated', schema_version: '1', origin_issue: 1, phases: [], - bindings: [ - { kind: 'epic', issue: 6, epic: '2.1', agents: ['mcmanus'], label: 'squad:mcmanus' }, - ], - }; - expect(() => validateBindings(falseSingleOwnerReport, roster, labels({ 6: ['squad'] }))) - .toThrow('expected squad:mcmanus, found bare squad'); - - const omittedMultiOwnerReport = { - squad_artifact: 'activated', schema_version: '1', origin_issue: 1, phases: [], - bindings: [ - { kind: 'epic', issue: 7, epic: '1.2', agents: ['kint', 'mcmanus'] }, - ], - }; - expect(() => validateBindings(omittedMultiOwnerReport, roster, labels({ 7: ['squad'] }))) - .toThrow('omission report must be multi-owner'); + it('rejects the observed #1860 epic report/label inconsistencies', () => { + const falseSingleOwnerReport = artifact([ + task({ issue: 17, agent: 'McManus', label: 'squad:mcmanus', epicLabel: 'squad:mcmanus' }), + ]); + expect(() => validateBindings(falseSingleOwnerReport, roster, labels({ + 6: ['squad'], + 17: ['squad', 'squad:mcmanus'], + }))).toThrow('expected squad:mcmanus, found bare squad'); + + const omittedMultiOwnerReport = artifact([ + task({ issue: 21, agent: 'Kint', epicAgents: ['kint', 'mcmanus'], label: 'squad:kint' }), + task({ issue: 22, agent: 'McManus', epicAgents: ['kint', 'mcmanus'], label: 'squad:mcmanus' }), + ]); + expect(() => validateBindings(omittedMultiOwnerReport, roster, labels({ + 6: ['squad'], + 21: ['squad', 'squad:kint'], + 22: ['squad', 'squad:mcmanus'], + }))).toThrow('epic_omission_reason must be multi-owner'); }); it.each([ @@ -107,8 +171,7 @@ Structured data: origin_issue: 1, phases: [], bindings, - }, roster, new Map())) - .toThrow('bindings are missing or empty'); + }, roster, new Map())).toThrow('bindings are missing or empty'); }); it('fails closed when activation evidence has no parseable structured block', () => { @@ -117,63 +180,117 @@ Structured data: )).toThrow('could not be parsed'); }); - it('fails closed when a binding issue cannot be resolved', () => { - const artifact = { - squad_artifact: 'activated', schema_version: '1', origin_issue: 1, phases: [], - bindings: [ - { kind: 'task', task: '1', issue: 99, epic: '1.1', agent: 'Kint', agents: ['kint'], label: 'squad:kint' }, - ], - }; - expect(() => validateBindings(artifact, roster, new Map())).toThrow('labels could not be resolved'); + it('fails closed when a task or epic issue cannot be resolved', () => { + const input = artifact([ + task({ issue: 99, agent: 'Kint', label: 'squad:kint', epicLabel: 'squad:kint' }), + ]); + expect(() => validateBindings(input, roster, new Map())).toThrow('labels could not be resolved'); }); - it('requires non-roster omissions to be reported and carry no agent label', () => { - const artifact = { - squad_artifact: 'activated', schema_version: '1', origin_issue: 1, phases: [], - bindings: [ - { kind: 'task', task: '3', issue: 30, epic: '1.2', agent: 'Reviewer', agents: ['reviewer'] }, - ], - }; - expect(() => validateBindings(artifact, roster, labels({ 30: ['squad'] }))) - .toThrow('omission report must be non-roster'); - - const reportedArtifact = { - squad_artifact: 'activated', schema_version: '1', origin_issue: 1, phases: [], - bindings: [ - { - kind: 'task', - task: '3', - issue: 30, - epic: '1.2', - agent: 'Reviewer', - agents: ['reviewer'], - omission_reason: 'non-roster', - }, - ], - }; - expect(validateBindings(reportedArtifact, roster, labels({ 30: ['squad'] })).checked).toBe(1); + it('requires non-roster omissions for both task and sole-owner epic', () => { + const missingReport = artifact([ + task({ issue: 30, agent: 'Reviewer' }), + ]); + expect(() => validateBindings(missingReport, roster, labels({ + 6: ['squad'], + 30: ['squad'], + }))).toThrow('omission_reason must be non-roster'); + + const reported = artifact([ + task({ + issue: 30, + agent: 'Reviewer', + omission: 'non-roster', + epicOmission: 'non-roster', + }), + ]); + expect(validateBindings(reported, roster, labels({ + 6: ['squad'], + 30: ['squad'], + })).checked).toBe(1); + }); + + it('maps the special @copilot assignment to squad:copilot', () => { + const input = artifact([ + task({ + issue: 40, + agent: '@copilot', + label: 'squad:copilot', + epicLabel: 'squad:copilot', + }), + ]); + expect(validateBindings(input, roster, labels({ + 6: ['squad', 'squad:copilot'], + 40: ['squad', 'squad:copilot'], + })).checked).toBe(1); + }); + + it('uses the full epic agent set for phased activation', () => { + const input = artifact([ + task({ + issue: 50, + epicIssue: 60, + agent: 'Kint', + epicAgents: ['kint', 'mcmanus'], + label: 'squad:kint', + epicOmission: 'multi-owner', + }), + ], true); + expect(validateBindings(input, roster, labels({ + 50: ['squad', 'squad:kint'], + 60: ['squad'], + })).epics).toBe(1); }); - it('wires a plain read-only workflow to the checker', () => { + it('rejects one epic identifier mapped to multiple epic issue numbers', () => { + const input = artifact([ + task({ issue: 70, epicIssue: 60, agent: 'Kint', epicAgents: ['kint', 'mcmanus'], label: 'squad:kint', epicOmission: 'multi-owner' }), + task({ issue: 71, epicIssue: 61, agent: 'McManus', epicAgents: ['kint', 'mcmanus'], label: 'squad:mcmanus', epicOmission: 'multi-owner' }), + ]); + expect(() => validateBindings(input, roster, labels({ + 60: ['squad'], + 61: ['squad'], + 70: ['squad', 'squad:kint'], + 71: ['squad', 'squad:mcmanus'], + }))).toThrow('maps to multiple epic issue numbers'); + }); + + it('wires a plain read-only workflow to Node 22 and the checker', () => { const workflow = readFileSync(join(process.cwd(), '.github', 'workflows', 'squad-agent-binding-check.yml'), 'utf8'); expect(workflow).toContain('workflow_run:'); expect(workflow).toContain('issues: read'); + expect(workflow).toContain('actions/setup-node@820762786026740c76f36085b0efc47a31fe5020'); + expect(workflow).toContain('node-version: 22'); expect(workflow).toContain('node scripts/check-agent-binding.mjs'); expect(workflow).not.toContain('issues: write'); }); + it('makes every binding item a complete task-to-epic mapping', () => { + const workflow = readFileSync(join(process.cwd(), 'workflows', 'squad.md'), 'utf8').replace(/\r\n/g, '\n'); + expect(workflow).toMatch( + /required:\n\s+- issue\n\s+- task\n\s+- epic_issue\n\s+- epic\n\s+- agent\n\s+- epic_agents/, + ); + }); + it('fails closed on an incomplete envelope or mismatched origin issue', () => { + const binding = task({ issue: 1, epicIssue: 2, agent: 'Kint', label: 'squad:kint', epicLabel: 'squad:kint' }); expect(() => validateBindings({ squad_artifact: 'activated', - bindings: [{ kind: 'task', task: '1', issue: 1, epic: '1', agent: 'Kint', agents: ['kint'], label: 'squad:kint' }], - }, roster, labels({ 1: ['squad', 'squad:kint'] }))).toThrow('schema_version'); + bindings: [binding], + }, roster, labels({ + 1: ['squad', 'squad:kint'], + 2: ['squad', 'squad:kint'], + }))).toThrow('schema_version'); expect(() => validateActivation({ squad_artifact: 'activated', schema_version: '1', origin_issue: 8, phases: [], - bindings: [{ kind: 'task', task: '1', issue: 1, epic: '1', agent: 'Kint', agents: ['kint'], label: 'squad:kint' }], - }, roster, labels({ 1: ['squad', 'squad:kint'] }), 9)).toThrow('does not match comment issue'); + bindings: [binding], + }, roster, labels({ + 1: ['squad', 'squad:kint'], + 2: ['squad', 'squad:kint'], + }), 9)).toThrow('does not match comment issue'); }); }); diff --git a/workflows/shared/squad-planning-ontology.md b/workflows/shared/squad-planning-ontology.md index 5944d244e..b3df8035a 100644 --- a/workflows/shared/squad-planning-ontology.md +++ b/workflows/shared/squad-planning-ontology.md @@ -308,12 +308,13 @@ been run, and an omitted row is not a pass. | 1 | <title> | #NNN | S | <agent> | ``` -Activation safe-output data also carries a non-empty `bindings` array. Each task -entry maps its plan task number and epic to the returned GitHub issue number and -raw agent assignment. Each epic entry maps its epic identifier and returned issue -number to the distinct agent set derived from its tasks. Entries record the label -that was actually applied, or an `omission_reason` (`multi-owner`, `non-roster`, -or `copilot`) when policy requires bare `squad`. This mapping is mandatory for +Activation safe-output data also carries a non-empty `bindings` array. Each entry +maps a plan task number and raw agent assignment to its returned task issue number, +epic identifier, returned epic issue number, and the epic's complete distinct agent +set from the full accepted plan (including other activation phases). It records both task and derived +epic labels actually applied, or their omission reasons (`multi-owner` or +`non-roster`) when policy requires bare `squad`. The special `@copilot` assignment +records the actual `squad:copilot` label. This mapping is mandatory for `phases-activated` and `activated` artifacts so the post-activation checker can fail closed without matching model-authored titles. diff --git a/workflows/squad.md b/workflows/squad.md index d38c620b4..d87f32e20 100644 --- a/workflows/squad.md +++ b/workflows/squad.md @@ -79,19 +79,22 @@ safe-outputs: items: type: object properties: - kind: - type: string - enum: [task, epic] issue: type: integer minimum: 1 task: type: string + minLength: 1 epic: type: string + minLength: 1 + epic_issue: + type: integer + minimum: 1 agent: type: string - agents: + minLength: 1 + epic_agents: type: array items: type: string @@ -99,11 +102,19 @@ safe-outputs: type: string omission_reason: type: string + enum: [non-roster] + epic_label: + type: string + epic_omission_reason: + type: string + enum: [multi-owner, non-roster] required: - - kind - issue + - task + - epic_issue - epic - - agents + - agent + - epic_agents additionalProperties: false required: - squad_artifact @@ -1350,7 +1361,8 @@ Count expected issues before starting. If total > 50: recommend phased activatio 3. Reproduce the certified `ROSTER_MEMBER:` lines verbatim in the summary as the provenance of the labels applied — the summary may name only values TG-2 emitted. 4. For every `Agent` value, mint `squad:{agent}` only when its lowercased form matches - a certified `ROSTER_MEMBER:` name, or the value is exactly `@copilot`. + a certified `ROSTER_MEMBER:` name. The special value `@copilot` maps to the + existing `squad:copilot` routing label — never `squad:@copilot`. 5. A value matching no certified name and not `@copilot` MUST NOT become a `squad:{agent}` label: apply only `squad` for that issue and record the value under a `Non-roster agent values` heading, naming the certified set it should come from. @@ -1390,7 +1402,7 @@ Root → Epics → Tasks. Phase-specific: filter to matching phase heading. **2b. Create Epic Issues:** `create-issue` per epic (dedup by title `[Epic] {name}` if already exists from prior phase). - Title: `[Epic] {name}` -- Labels: `squad` (0075ca), `squad:{agent}` (e4e669) where `{agent}` is **derived from this epic's own tasks**: collect the `Agent` values of every implementation-plan row whose `Epic` cell names this epic. Exactly one distinct value → mint `squad:{that agent}`. Two or more → multi-owner epic: apply only `squad` and record it under `Non-roster agent values`. Never mint a single agent label for a multi-owner epic, and never choose one of several. +- Labels: `squad` (0075ca), `squad:{agent}` (e4e669) where `{agent}` is **derived from this epic's own tasks**: collect the `Agent` values of every implementation-plan row whose `Epic` cell names this epic. Exactly one distinct roster value → mint `squad:{that agent}`; exactly `@copilot` → mint `squad:copilot`. Two or more → multi-owner epic: apply only `squad` and record it under `Non-roster agent values`. Never mint a single agent label for a multi-owner epic, and never choose one of several. - Body: outcome, stories, epic-level acceptance criteria, context (parent, initiative, milestone, deps) - Parent: sub-issue of root intent issue - Milestone: assigned @@ -1404,7 +1416,7 @@ Root → Epics → Tasks. Phase-specific: filter to matching phase heading. > **DO NOT** compose or buffer multiple task bodies before making calls. One compose → one call → one verify, repeated per task. - Title: task title -- Labels: `squad` (0075ca), `squad:{agent}` (e4e669) where `{agent}` is **this task's own `Agent` cell**, lowercased — read from the implementation-plan row whose `#` matches this task. Never inherit the parent epic's agent, and never carry the previous task's value forward: re-read the `Agent` cell for every task, because consecutive tasks under one epic routinely have different agents. No `size:*` labels unless policy says so. +- Labels: `squad` (0075ca), `squad:{agent}` (e4e669) where `{agent}` is **this task's own `Agent` cell**, lowercased — read from the implementation-plan row whose `#` matches this task. Map `@copilot` to `squad:copilot`. Never inherit the parent epic's agent, and never carry the previous task's value forward: re-read the `Agent` cell for every task, because consecutive tasks under one epic routinely have different agents. No `size:*` labels unless policy says so. - Body: one sentence describing scope; 1-2 acceptance criteria; one compact context line (parent epic, size, deps) - Parent: sub-issue of EPIC (not root) - Milestone: same as parent epic @@ -1424,13 +1436,11 @@ Add `blockedBy` via API for tasks and epics. Graceful fallback. Never fail activ Phase artifact: `data: {"squad_artifact":"phases-activated","schema_version":"1","origin_issue":{issue_number},"phases":[{accumulated}]}` → `## ✅ Phase {N} Activated — {count} issues` + issue table + remaining phases table. -Every phase and full activation artifact MUST include a non-empty `bindings` array built only from successful `create-issue` results. Emit one task binding per created/recognized task: - -`{"kind":"task","task":"{plan # cell}","issue":{created issue number},"epic":"{Epic cell}","agent":"{raw Agent cell}","agents":["{lowercased Agent cell}"],"label":"squad:{agent}"}`. +Every phase and full activation artifact MUST include a non-empty `bindings` array built only from successful `create-issue` results. Emit one binding per created/recognized task: -Emit one epic binding per created/recognized epic: +`{"task":"{plan # cell}","issue":{created task issue number},"epic":"{Epic cell}","epic_issue":{created epic issue number},"agent":"{raw Agent cell}","epic_agents":["{all distinct lowercased Agent cells for this epic across the full accepted plan}"],"label":"squad:{lowercased Agent cell}","epic_label":"squad:{sole lowercased epic task agent}"}`. For `@copilot`, use `squad:copilot`. Every binding for one epic MUST carry the same complete `epic_agents` set, including agents assigned in other activation phases. -`{"kind":"epic","issue":{created epic issue number},"epic":"{Epic identifier}","agents":["{distinct lowercased task agents}"],"label":"squad:{agent}"}` for a single roster owner, or omit `label` and set `"omission_reason":"multi-owner"` for multiple owners. For a task whose agent is not certified by TG-2, omit `label` and set `"omission_reason":"non-roster"`; for `@copilot`, use `"omission_reason":"copilot"`. Never omit a created issue from `bindings`, never infer an issue number, and never emit an empty array. The deterministic post-activation workflow treats missing, empty, malformed, or unresolved bindings as a failure. +For a multi-owner epic, omit `epic_label` and set `"epic_omission_reason":"multi-owner"` on each of its task bindings. For a task whose agent is not certified by TG-2, omit `label` and set `"omission_reason":"non-roster"`; if that task is the epic's sole owner, likewise omit `epic_label` and set `"epic_omission_reason":"non-roster"`. Never omit a created task from `bindings`, never infer an issue number, and never emit an empty array. The deterministic post-activation workflow treats missing, empty, malformed, or unresolved bindings as a failure. The safe-output schema deliberately uses one uniform task-binding shape because gh-aw's data schema dialect does not support conditional `if`/`then` or `allOf`; the checker enforces activation-only presence and cross-row epic consistency. Phase artifact: `data: {"squad_artifact":"phases-activated","schema_version":"1","origin_issue":{issue_number},"phases":[{accumulated}],"bindings":[{this phase's bindings}]}` → `## ✅ Phase {N} Activated — {count} issues` + issue table + remaining phases table. From 12f537b41994746e8b8ef3ec389923375cd956e2 Mon Sep 17 00:00:00 2001 From: brady gaster <bradygaster@github.com> Date: Mon, 24 Aug 2026 16:36:24 -0700 Subject: [PATCH 3/4] fix(gh-aw): harden epic binding evidence Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 38c1464f-cc45-4217-bd43-09655eee99c8 --- .github/workflows/squad-agent-binding-check.yml | 4 +++- test/check-agent-binding.test.ts | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/squad-agent-binding-check.yml b/.github/workflows/squad-agent-binding-check.yml index 9d43777bd..da5fe24eb 100644 --- a/.github/workflows/squad-agent-binding-check.yml +++ b/.github/workflows/squad-agent-binding-check.yml @@ -2,7 +2,9 @@ name: Squad Agent Binding Check on: workflow_run: - workflows: [Squad] + # "Squad" is the installed gh-aw workflow; "Squad CI" keeps this source + # repository's checker wired to a workflow that exists here. + workflows: [Squad, Squad CI] types: [completed] permissions: diff --git a/test/check-agent-binding.test.ts b/test/check-agent-binding.test.ts index 2d654d248..afb73f73b 100644 --- a/test/check-agent-binding.test.ts +++ b/test/check-agent-binding.test.ts @@ -258,6 +258,7 @@ Structured data: it('wires a plain read-only workflow to Node 22 and the checker', () => { const workflow = readFileSync(join(process.cwd(), '.github', 'workflows', 'squad-agent-binding-check.yml'), 'utf8'); expect(workflow).toContain('workflow_run:'); + expect(workflow).toContain('workflows: [Squad, Squad CI]'); expect(workflow).toContain('issues: read'); expect(workflow).toContain('actions/setup-node@820762786026740c76f36085b0efc47a31fe5020'); expect(workflow).toContain('node-version: 22'); From d85c21bd8c07f60dbfecc0f82eb666d1c274a7bd Mon Sep 17 00:00:00 2001 From: brady gaster <bradygaster@github.com> Date: Mon, 24 Aug 2026 16:45:45 -0700 Subject: [PATCH 4/4] fix(gh-aw): keep binding evidence under size limit Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 38c1464f-cc45-4217-bd43-09655eee99c8 --- scripts/check-agent-binding.mjs | 9 ++++ test/check-agent-binding.test.ts | 28 +++++++++--- workflows/shared/squad-planning-ontology.md | 7 ++- workflows/squad.md | 50 ++------------------- 4 files changed, 41 insertions(+), 53 deletions(-) diff --git a/scripts/check-agent-binding.mjs b/scripts/check-agent-binding.mjs index 9d8d7e9e5..93b3bf00a 100644 --- a/scripts/check-agent-binding.mjs +++ b/scripts/check-agent-binding.mjs @@ -23,6 +23,15 @@ export function parseStructuredData(comment) { } catch (error) { throw new Error(`activation structured data is invalid JSON: ${error.message}`); } + if (ACTIVATION_ARTIFACTS.has(parsed.squad_artifact)) { + const bindingBlock = /Activation bindings:\s*```json\s*([\s\S]*?)```/i.exec(comment); + if (!bindingBlock) return parsed; + try { + parsed.bindings = JSON.parse(bindingBlock[1]); + } catch (error) { + throw new Error(`activation bindings are invalid JSON: ${error.message}`); + } + } return parsed; } diff --git a/test/check-agent-binding.test.ts b/test/check-agent-binding.test.ts index afb73f73b..7d2403156 100644 --- a/test/check-agent-binding.test.ts +++ b/test/check-agent-binding.test.ts @@ -73,9 +73,13 @@ function task({ describe('deterministic post-activation agent binding guard (#1801)', () => { it('parses the last Structured data block from an activation comment', () => { const parsed = parseStructuredData(` +Activation bindings: +\`\`\`json +[{"task":"6","issue":17,"epic":"2.1","epic_issue":6,"agent":"McManus","epic_agents":["mcmanus"],"label":"squad:mcmanus","epic_label":"squad:mcmanus"}] +\`\`\` Structured data: \`\`\`json -{"squad_artifact":"activated","schema_version":"1","origin_issue":1,"phases":[],"bindings":[{"task":"6","issue":17,"epic":"2.1","epic_issue":6,"agent":"McManus","epic_agents":["mcmanus"],"label":"squad:mcmanus","epic_label":"squad:mcmanus"}]} +{"squad_artifact":"activated","schema_version":"1","origin_issue":1,"phases":[]} \`\`\` `); expect(parsed.bindings[0]).toMatchObject({ task: '6', issue: 17, agent: 'McManus' }); @@ -180,6 +184,19 @@ Structured data: )).toThrow('could not be parsed'); }); + it('fails closed when the activation bindings block is malformed', () => { + expect(() => parseStructuredData(` +Activation bindings: +\`\`\`json +[{"task": +\`\`\` +Structured data: +\`\`\`json +{"squad_artifact":"activated","schema_version":"1","origin_issue":1,"phases":[]} +\`\`\` +`)).toThrow('activation bindings are invalid JSON'); + }); + it('fails closed when a task or epic issue cannot be resolved', () => { const input = artifact([ task({ issue: 99, agent: 'Kint', label: 'squad:kint', epicLabel: 'squad:kint' }), @@ -266,11 +283,12 @@ Structured data: expect(workflow).not.toContain('issues: write'); }); - it('makes every binding item a complete task-to-epic mapping', () => { + it('specifies every binding item as a complete task-to-epic mapping', () => { const workflow = readFileSync(join(process.cwd(), 'workflows', 'squad.md'), 'utf8').replace(/\r\n/g, '\n'); - expect(workflow).toMatch( - /required:\n\s+- issue\n\s+- task\n\s+- epic_issue\n\s+- epic\n\s+- agent\n\s+- epic_agents/, - ); + expect(workflow).toContain('Activation bindings:'); + expect(workflow).toContain('"task":"{plan # cell}"'); + expect(workflow).toContain('"epic_issue":{created epic issue number}'); + expect(workflow).toContain('"epic_agents":["{all distinct lowercased Agent cells'); }); it('fails closed on an incomplete envelope or mismatched origin issue', () => { diff --git a/workflows/shared/squad-planning-ontology.md b/workflows/shared/squad-planning-ontology.md index b3df8035a..7620c012d 100644 --- a/workflows/shared/squad-planning-ontology.md +++ b/workflows/shared/squad-planning-ontology.md @@ -308,14 +308,17 @@ been run, and an omitted row is not a pass. | 1 | <title> | #NNN | S | <agent> | ``` -Activation safe-output data also carries a non-empty `bindings` array. Each entry +The activation artifact body also carries an `Activation bindings:` fenced JSON +block containing a non-empty array. Each entry maps a plan task number and raw agent assignment to its returned task issue number, epic identifier, returned epic issue number, and the epic's complete distinct agent set from the full accepted plan (including other activation phases). It records both task and derived epic labels actually applied, or their omission reasons (`multi-owner` or `non-roster`) when policy requires bare `squad`. The special `@copilot` assignment records the actual `squad:copilot` label. This mapping is mandatory for -`phases-activated` and `activated` artifacts so the post-activation checker can +`phases-activated` and `activated` artifacts. It remains in the body rather than +the safe-output `data` envelope because gh-aw expands nested data schemas beyond +GitHub's expression-size limit. The post-activation checker can still fail closed without matching model-authored titles. --- diff --git a/workflows/squad.md b/workflows/squad.md index d87f32e20..fdc629b6f 100644 --- a/workflows/squad.md +++ b/workflows/squad.md @@ -74,48 +74,6 @@ safe-outputs: items: type: integer minimum: 1 - bindings: - type: array - items: - type: object - properties: - issue: - type: integer - minimum: 1 - task: - type: string - minLength: 1 - epic: - type: string - minLength: 1 - epic_issue: - type: integer - minimum: 1 - agent: - type: string - minLength: 1 - epic_agents: - type: array - items: - type: string - label: - type: string - omission_reason: - type: string - enum: [non-roster] - epic_label: - type: string - epic_omission_reason: - type: string - enum: [multi-owner, non-roster] - required: - - issue - - task - - epic_issue - - epic - - agent - - epic_agents - additionalProperties: false required: - squad_artifact - schema_version @@ -1436,17 +1394,17 @@ Add `blockedBy` via API for tasks and epics. Graceful fallback. Never fail activ Phase artifact: `data: {"squad_artifact":"phases-activated","schema_version":"1","origin_issue":{issue_number},"phases":[{accumulated}]}` → `## ✅ Phase {N} Activated — {count} issues` + issue table + remaining phases table. -Every phase and full activation artifact MUST include a non-empty `bindings` array built only from successful `create-issue` results. Emit one binding per created/recognized task: +Every phase and full activation artifact body MUST include an `Activation bindings:` fenced JSON block containing a non-empty array built only from successful `create-issue` results. Emit one object per created/recognized task: `{"task":"{plan # cell}","issue":{created task issue number},"epic":"{Epic cell}","epic_issue":{created epic issue number},"agent":"{raw Agent cell}","epic_agents":["{all distinct lowercased Agent cells for this epic across the full accepted plan}"],"label":"squad:{lowercased Agent cell}","epic_label":"squad:{sole lowercased epic task agent}"}`. For `@copilot`, use `squad:copilot`. Every binding for one epic MUST carry the same complete `epic_agents` set, including agents assigned in other activation phases. For a multi-owner epic, omit `epic_label` and set `"epic_omission_reason":"multi-owner"` on each of its task bindings. For a task whose agent is not certified by TG-2, omit `label` and set `"omission_reason":"non-roster"`; if that task is the epic's sole owner, likewise omit `epic_label` and set `"epic_omission_reason":"non-roster"`. Never omit a created task from `bindings`, never infer an issue number, and never emit an empty array. The deterministic post-activation workflow treats missing, empty, malformed, or unresolved bindings as a failure. The safe-output schema deliberately uses one uniform task-binding shape because gh-aw's data schema dialect does not support conditional `if`/`then` or `allOf`; the checker enforces activation-only presence and cross-row epic consistency. -Phase artifact: `data: {"squad_artifact":"phases-activated","schema_version":"1","origin_issue":{issue_number},"phases":[{accumulated}],"bindings":[{this phase's bindings}]}` → `## ✅ Phase {N} Activated — {count} issues` + issue table + remaining phases table. +Phase artifact: `data: {"squad_artifact":"phases-activated","schema_version":"1","origin_issue":{issue_number},"phases":[{accumulated}]}` → `## ✅ Phase {N} Activated — {count} issues` + issue table + remaining phases table + the `Activation bindings:` JSON array. -Full artifact: `data: {"squad_artifact":"activated","schema_version":"1","origin_issue":{issue_number},"phases":[],"bindings":[{all bindings}]}` → `## ✅ Plan Activated — {epic_count} epics, {task_count} tasks` + hierarchy summary, created epics table, created tasks table, dependency order. +Full artifact: `data: {"squad_artifact":"activated","schema_version":"1","origin_issue":{issue_number},"phases":[]}` → `## ✅ Plan Activated — {epic_count} epics, {task_count} tasks` + hierarchy summary, created epics table, created tasks table, dependency order + the `Activation bindings:` JSON array. -Terminal (last phase): emit `data: {"squad_artifact":"activated","schema_version":"1","origin_issue":{issue_number},"phases":[{all_phases}],"bindings":[{bindings accumulated across every activated phase}]}` with an "All Phases Activated" heading. +Terminal (last phase): emit `data: {"squad_artifact":"activated","schema_version":"1","origin_issue":{issue_number},"phases":[{all_phases}]}` with an "All Phases Activated" heading and the accumulated `Activation bindings:` JSON array. ##### Step 5: Update Lifecycle