diff --git a/.github/workflows/squad-agent-binding-check.yml b/.github/workflows/squad-agent-binding-check.yml new file mode 100644 index 000000000..da5fe24eb --- /dev/null +++ b/.github/workflows/squad-agent-binding-check.yml @@ -0,0 +1,52 @@ +name: Squad Agent Binding Check + +on: + workflow_run: + # "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: + contents: read + issues: read + +jobs: + check: + 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: + 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..93b3bf00a --- /dev/null +++ b/scripts/check-agent-binding.mjs @@ -0,0 +1,234 @@ +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']); + +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}`); + } + 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; +} + +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 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' }; +} + +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[omissionKey] !== expected.omission && !(expected.omission === null && binding[omissionKey] === undefined)) { + throw new Error( + `issue #${binding.issue}: ${omissionKey} must be ${expected.omission ?? 'absent'}`, + ); + } + if (binding[omissionKey] && !VALID_OMISSIONS.has(binding[omissionKey])) { + throw new Error(`issue #${binding.issue}: invalid ${omissionKey}`); + } +} + +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 (!Number.isInteger(binding.epic_issue) || binding.epic_issue < 1) { + throw new Error(`issue #${binding.issue}: missing epic issue linkage`); + } + 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'}`, + ); + } +} + +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(); + const epics = new Map(); + const epicIssuesByIdentifier = new Map(); + for (const binding of artifact.bindings) { + 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 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, epics: epics.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.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); + 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..7d2403156 --- /dev/null +++ b/test/check-agent-binding.test.ts @@ -0,0 +1,315 @@ +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), + ])); +} + +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 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":[]} +\`\`\` +`); + 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 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 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, epics: 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 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([ + ['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 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' }), + ]); + expect(() => validateBindings(input, roster, new Map())).toThrow('labels could not be resolved'); + }); + + 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('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('workflows: [Squad, Squad CI]'); + 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('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).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', () => { + const binding = task({ issue: 1, epicIssue: 2, agent: 'Kint', label: 'squad:kint', epicLabel: 'squad:kint' }); + expect(() => validateBindings({ + squad_artifact: 'activated', + 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: [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 1b10edb15..7620c012d 100644 --- a/workflows/shared/squad-planning-ontology.md +++ b/workflows/shared/squad-planning-ontology.md @@ -308,6 +308,19 @@ been run, and an omitted row is not a pass. | 1 | | #NNN | S | <agent> | ``` +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. 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. + --- ## 4. Structured Artifact Registry diff --git a/workflows/squad.md b/workflows/squad.md index fd54a74b7..fdc629b6f 100644 --- a/workflows/squad.md +++ b/workflows/squad.md @@ -1319,7 +1319,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. @@ -1359,7 +1360,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 @@ -1373,7 +1374,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 @@ -1393,9 +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. -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 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: -Terminal (last phase): emit `data: {"squad_artifact":"activated","schema_version":"1","origin_issue":{issue_number},"phases":[{all_phases}]}` with an "All Phases Activated" heading. +`{"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}]}` → `## ✅ 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":[]}` → `## ✅ 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}]}` with an "All Phases Activated" heading and the accumulated `Activation bindings:` JSON array. ##### Step 5: Update Lifecycle