diff --git a/.github/agents.md b/.github/agents.md index f18b57aaa..d46d7e3a2 100644 --- a/.github/agents.md +++ b/.github/agents.md @@ -222,17 +222,21 @@ Squad's planning behavior is configurable through policy profiles. ### Selecting a Profile -Add an HTML comment to the issue body: +Add a policy directive line to the issue body: -```html - +```markdown +Squad-Policy: lean ``` +Use a plain visible line — **not** an HTML comment. gh-aw strips HTML comments +from issue bodies before the agent sees them, so a commented directive is +silently ignored. + Or configure at the repo level in `.squad/planning-policy.md` with YAML frontmatter. ### Priority Order -1. Issue body (``) — highest +1. Issue body (`Squad-Policy: ...`) — highest 2. Repository file (`.squad/planning-policy.md`) 3. Profile name (built-in) 4. Default — fallback diff --git a/test-fixtures/planning/aspiregregator/README.md b/test-fixtures/planning/aspiregregator/README.md index efd73d8bf..ed1b52d52 100644 --- a/test-fixtures/planning/aspiregregator/README.md +++ b/test-fixtures/planning/aspiregregator/README.md @@ -24,7 +24,7 @@ The Aspiregregator is a .NET Aspire community content aggregator application. Th ### Manual Validation -1. Compare each output file against the schema in `workflows/shared/planning-ontology.md` +1. Compare each output file against the schema in `workflows/shared/squad-planning-ontology.md` 2. Verify every `Structured data:` JSON block has `schema_version: "1"`, `origin_issue: 8`, `phases: []`, and the expected `squad_artifact` 3. Confirm traceability: every task → epic → initiative → triage item diff --git a/test/gh-aw-quality.test.ts b/test/gh-aw-quality.test.ts index 5d4adaba6..39a12361e 100644 --- a/test/gh-aw-quality.test.ts +++ b/test/gh-aw-quality.test.ts @@ -19,6 +19,15 @@ const SQUAD_WORKFLOW = join(WORKFLOWS_DIR, 'squad.md'); const SHARED_DIR = join(WORKFLOWS_DIR, 'shared'); const TEST_WORKSPACES_DIR = join(process.cwd(), '.test-workspaces'); +/** + * Some suites execute the workflow's own `bash`/`jq` snippets through + * `shell: '/bin/sh'` to prove the shipped one-liners behave as documented. + * That shell does not exist on a stock Windows dev box, so gate those suites + * instead of reporting spurious ENOENT failures. CI runs on Linux, where they + * always execute. + */ +const HAS_POSIX_SHELL = existsSync('/bin/sh'); + afterAll(() => { rmSync(TEST_WORKSPACES_DIR, { recursive: true, force: true }); }); @@ -27,9 +36,22 @@ afterAll(() => { // Helpers // --------------------------------------------------------------------------- +/** + * Read a text file with line endings normalized to LF. + * + * Markdown in this repo is not pinned to LF in .gitattributes, so Windows + * checkouts (core.autocrlf=true) materialize CRLF. Every assertion here — and + * gh-aw itself on the Linux runner — reasons in LF, so normalize on read rather + * than making each regex CRLF-aware. This also makes byte-budget measurements + * platform-independent. + */ +function readText(filePath: string): string { + return readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n'); +} + /** Extract YAML frontmatter from a markdown file (between --- delimiters). */ function extractFrontmatter(filePath: string): string { - const content = readFileSync(filePath, 'utf8'); + const content = readText(filePath); const match = content.match(/^---\n([\s\S]*?)\n---/); if (!match) throw new Error(`No frontmatter found in ${filePath}`); return match[1]; @@ -142,13 +164,16 @@ function extractModeTable(content: string): Array<{ command: string; mode: strin const section = modesSection[1]; - // Match 3-column table rows: | `command` | Mode | Description | - const tableRowRegex = /^\|\s*`([^`]+)`\s*\|\s*([^|]+?)\s*\|\s*([^|]*?)\s*\|/gm; + // Match table rows with 2 or 3 columns: | `command` | Mode | [Description] | + // The shipped table is 2-column; a 3-column-only pattern silently matched + // every other row (the trailing `|` of one row doubling as the opening `|` + // of the next), which under-reported coverage by half. + const tableRowRegex = /^\|\s*`([^`]+)`\s*\|\s*([^|\n]+?)\s*\|(?:\s*([^|\n]*?)\s*\|)?[ \t]*$/gm; let match: RegExpExecArray | null; while ((match = tableRowRegex.exec(section)) !== null) { const command = match[1].trim(); const mode = match[2].trim(); - const description = match[3].trim(); + const description = (match[3] ?? '').trim(); // Skip table headers if (command === 'Command' || mode === 'Mode') continue; rows.push({ command, mode, description }); @@ -281,7 +306,7 @@ describe('gh-aw: safe-output configuration', () => { // --------------------------------------------------------------------------- describe('gh-aw: mode dispatch completeness', () => { - const content = readFileSync(SQUAD_WORKFLOW, 'utf8'); + const content = readText(SQUAD_WORKFLOW); const modeTable = extractModeTable(content); it('mode table has entries', () => { @@ -301,7 +326,9 @@ describe('gh-aw: mode dispatch completeness', () => { const uniqueModes = [...new Set(modeTable.map(r => r.mode))]; for (const mode of uniqueModes) { - const normalized = mode.toLowerCase(); + // Strip qualifiers like "(fast-path)" — they annotate the table entry, + // they are not part of the section/skill name. + const normalized = mode.replace(/\([^)]*\)/g, '').trim().toLowerCase(); // Check if any heading contains the mode name (flexible match) // For multi-word modes like "Plan Accept", check for "plan accept" or "plan-accept" // Also check if the base mode has a section (e.g., "Cast" for "Cast Mode") @@ -370,7 +397,7 @@ describe('gh-aw: shared component imports', () => { const fullPath = join(WORKFLOWS_DIR, importPath); if (!existsSync(fullPath)) continue; - const content = readFileSync(fullPath, 'utf8'); + const content = readText(fullPath); expect(content.length, `${importPath} should not be empty`).toBeGreaterThan(0); expect(content, `${importPath} should contain at least one heading`).toMatch(/^#+\s+.+/m); } @@ -381,7 +408,7 @@ describe('gh-aw: shared component imports', () => { const fullPath = join(WORKFLOWS_DIR, importPath); if (!existsSync(fullPath)) continue; - const content = readFileSync(fullPath, 'utf8'); + const content = readText(fullPath); // Check for markdown links to local files (not URLs) const localLinks = content.match(/\[.*?\]\((?!https?:\/\/|#)([^)]+)\)/g) || []; for (const link of localLinks) { @@ -403,8 +430,8 @@ describe('gh-aw: shared component imports', () => { // --------------------------------------------------------------------------- describe('gh-aw: planning state machine', () => { - const ontologyContent = readFileSync(join(SHARED_DIR, 'planning-ontology.md'), 'utf8'); - const squadContent = readFileSync(SQUAD_WORKFLOW, 'utf8'); + const ontologyContent = readText(join(SHARED_DIR, 'squad-planning-ontology.md')); + const squadContent = readText(SQUAD_WORKFLOW); function registryArtifactKinds(): string[] { const registry = ontologyContent.match(/## 4\. Structured Artifact Registry([\s\S]*?)(?=\n---|\n## \d)/); @@ -455,7 +482,7 @@ describe('gh-aw: planning state machine', () => { it('Research fixture data supports downstream Triage discovery', () => { const fixtures = join(process.cwd(), 'test-fixtures', 'planning', 'aspiregregator'); - const researchBody = readFileSync(join(fixtures, 'research-output.md'), 'utf8'); + const researchBody = readText(join(fixtures, 'research-output.md')); const wrongOrigin = researchBody.replace('"origin_issue": 8', '"origin_issue": 999'); const discovered = findLatestArtifact( ['No structured artifact here', wrongOrigin, researchBody], @@ -485,7 +512,7 @@ describe('gh-aw: planning state machine', () => { ]); for (const [file, artifactKinds] of expected) { - const artifacts = extractStructuredData(readFileSync(join(fixtures, file), 'utf8')); + const artifacts = extractStructuredData(readText(join(fixtures, file))); expect(artifacts.map(item => item.squad_artifact), file).toEqual(artifactKinds); for (const artifact of artifacts) { expect(artifact.schema_version, file).toBe('1'); @@ -550,25 +577,25 @@ describe('gh-aw: workflow frontmatter schema', () => { describe('gh-aw: prompt budget & planning import regression', () => { const frontmatter = extractFrontmatter(SQUAD_WORKFLOW); const imports = extractImports(frontmatter); - const squadContent = readFileSync(SQUAD_WORKFLOW, 'utf8'); + const squadContent = readText(SQUAD_WORKFLOW); // gh-aw enforces a hard 100 KB prompt ceiling (102 400 bytes) const GH_AW_PROMPT_CEILING_KB = 100; const GH_AW_PROMPT_CEILING_BYTES = GH_AW_PROMPT_CEILING_KB * 1024; - it('planning-ontology.md is in the imports list', () => { - expect(imports, 'shared/planning-ontology.md must be imported').toContain('shared/planning-ontology.md'); + it('squad-planning-ontology.md is in the imports list', () => { + expect(imports, 'shared/squad-planning-ontology.md must be imported').toContain('shared/squad-planning-ontology.md'); }); - it('planning-policy.md is in the imports list', () => { - expect(imports, 'shared/planning-policy.md must be imported').toContain('shared/planning-policy.md'); + it('squad-planning-policy.md is in the imports list', () => { + expect(imports, 'shared/squad-planning-policy.md must be imported').toContain('shared/squad-planning-policy.md'); }); it('no runtime cat of planning files remains in squad.md', () => { expect( squadContent, - 'squad.md must not contain runtime `cat .github/workflows/shared/planning-*.md` instructions' - ).not.toMatch(/cat .github\/workflows\/shared\/planning-[\w-]+\.md/); + 'squad.md must not contain runtime `cat .github/workflows/shared/*planning-*.md` instructions' + ).not.toMatch(/cat .github\/workflows\/shared\/[\w-]*planning-[\w-]+\.md/); }); it(`combined prompt (workflow + all imports) is under ${GH_AW_PROMPT_CEILING_KB} KB`, () => { @@ -577,7 +604,7 @@ describe('gh-aw: prompt budget & planning import regression', () => { for (const importPath of imports) { const fullPath = join(WORKFLOWS_DIR, importPath); if (existsSync(fullPath)) { - const content = readFileSync(fullPath, 'utf8'); + const content = readText(fullPath); totalBytes += Buffer.byteLength(content, 'utf8'); } } @@ -595,7 +622,7 @@ describe('gh-aw: prompt budget & planning import regression', () => { let totalBytes = Buffer.byteLength(squadContent, 'utf8'); for (const importPath of imports) { const fullPath = join(WORKFLOWS_DIR, importPath); - if (existsSync(fullPath)) totalBytes += Buffer.byteLength(readFileSync(fullPath, 'utf8'), 'utf8'); + if (existsSync(fullPath)) totalBytes += Buffer.byteLength(readText(fullPath), 'utf8'); } const headroomBytes = GH_AW_PROMPT_CEILING_BYTES - totalBytes; // Informational — log bytes/headroom; fail only if headroom < 5 KB (regression guard) @@ -606,6 +633,191 @@ describe('gh-aw: prompt budget & planning import regression', () => { }); }); +// --------------------------------------------------------------------------- +// Test: inline skill extraction semantics (gh-aw setup step) +// +// The shipped workflow moves its mode playbooks and planning reference material +// into gh-aw inline `## skill:` blocks so they load on demand instead of +// sitting in every run's ambient prompt. +// +// This models the real extractor (gh-aw-actions setup/js/extract_inline_skills.cjs) +// so the invariants are enforced here rather than discovered at runtime: +// +// * a block runs from its start marker to a matching "## end skill: `name`" +// if one exists, otherwise to the next H2 heading or EOF; +// * when a block closes IMPLICITLY at an H2, everything between that boundary +// and the next start marker is DISCARDED — not extracted, not kept. +// +// That discard rule is the sharp edge: a skill marker placed above a body whose +// own sections are H2s silently drops the rest of the file (and, because the +// imports are concatenated ahead of the workflow body, potentially the router +// itself). Conservation is asserted below so that can never ship unnoticed. +// --------------------------------------------------------------------------- + +const SKILL_START_RE = /^##[ \t]+skill:[ \t]+`([a-z][a-z0-9_-]*)`[ \t]*$/gm; + +interface ExtractionResult { + ambient: string; + skills: { name: string; bytes: number }[]; + discardedBytes: number; + markerBytes: number; +} + +/** Mirror of gh-aw's inline-skill extraction, including its discard behaviour. */ +function extractInlineSkills(content: string): ExtractionResult { + const starts = [...content.matchAll(SKILL_START_RE)]; + if (starts.length === 0) { + return { ambient: content, skills: [], discardedBytes: 0, markerBytes: 0 }; + } + + const h2Positions = [...content.matchAll(/^## .*$/gm)] + .map(m => m.index!) + .filter(i => i !== undefined); + + let ambient = ''; + let cursor = 0; + let discardedBytes = 0; + let markerBytes = 0; + const skills: { name: string; bytes: number }[] = []; + + for (const start of starts) { + const startIdx = start.index!; + if (startIdx < cursor) continue; + + ambient += content.slice(cursor, startIdx); + markerBytes += Buffer.byteLength(start[0], 'utf8'); + + const bodyStart = startIdx + start[0].length; + const endMarker = new RegExp( + `^##[ \\t]+end[ \\t]+skill:[ \\t]+\`${start[1]}\`[ \\t]*$`, + 'm' + ).exec(content.slice(bodyStart)); + + if (endMarker) { + const bodyEnd = bodyStart + endMarker.index; + skills.push({ name: start[1], bytes: Buffer.byteLength(content.slice(bodyStart, bodyEnd), 'utf8') }); + markerBytes += Buffer.byteLength(endMarker[0], 'utf8'); + cursor = bodyEnd + endMarker[0].length; + continue; + } + + // Implicit close: next H2 after the start marker, else EOF. + const nextH2 = h2Positions.find(p => p > startIdx); + const bodyEnd = nextH2 ?? content.length; + skills.push({ name: start[1], bytes: Buffer.byteLength(content.slice(bodyStart, bodyEnd), 'utf8') }); + + // Everything from here to the next start marker is dropped on the floor. + const nextStart = starts.find(s => s.index! > startIdx)?.index ?? content.length; + discardedBytes += Buffer.byteLength(content.slice(bodyEnd, Math.max(bodyEnd, nextStart)), 'utf8'); + cursor = Math.max(bodyEnd, nextStart); + } + + ambient += content.slice(cursor); + return { ambient, skills, discardedBytes, markerBytes }; +} + +describe('gh-aw: inline skill extraction', () => { + const frontmatter = extractFrontmatter(SQUAD_WORKFLOW); + const imports = extractImports(frontmatter); + + // Reproduce the prompt a real run assembles: imports first, then the body. + const assembled = [ + ...imports + .map(rel => join(WORKFLOWS_DIR, rel)) + .filter(existsSync) + .map(readText), + readText(SQUAD_WORKFLOW).replace(/^---\n[\s\S]*?\n---\n/, ''), + ].join('\n'); + + const result = extractInlineSkills(assembled); + + // Sections that must stay in the ambient prompt — without these the agent + // cannot parse a command or route to a skill in the first place. + const REQUIRED_AMBIENT_SECTIONS = [ + 'Planning Artifact Data Contract', + 'Trigger Context', + 'Modes', + 'Parse Command', + 'Execute Mode', + 'Team Guard', + ]; + + const AMBIENT_BUDGET_BYTES = 40 * 1024; + + it('extracts every declared skill block', () => { + expect(result.skills.length, 'expected inline skill blocks to be extracted').toBeGreaterThan(20); + }); + + it('discards no content during extraction', () => { + const offenders = result.skills.filter(s => s.bytes < 200).map(s => s.name); + expect( + result.discardedBytes, + `${result.discardedBytes} bytes were silently dropped. A skill block closed at an H2 ` + + `instead of an explicit "## end skill:" marker. Suspiciously small skills: ` + + `${offenders.join(', ') || '(none)'}. Add an explicit end marker to any skill whose ` + + `body contains H2 headings.` + ).toBe(0); + }); + + it('keeps the router sections in the ambient prompt', () => { + for (const section of REQUIRED_AMBIENT_SECTIONS) { + expect( + result.ambient, + `"${section}" must remain in the ambient prompt — it is required to dispatch a mode` + ).toContain(section); + } + }); + + it('loses no bytes overall (ambient + skills + markers == source)', () => { + const accounted = + Buffer.byteLength(result.ambient, 'utf8') + + result.skills.reduce((n, s) => n + s.bytes, 0) + + result.markerBytes; + const source = Buffer.byteLength(assembled, 'utf8'); + // Marker lines carry trailing newlines that fall on either side of a split. + expect(Math.abs(source - accounted), 'byte conservation check failed').toBeLessThan( + result.skills.length * 4 + ); + }); + + it(`keeps the ambient prompt under ${AMBIENT_BUDGET_BYTES / 1024} KB`, () => { + const ambientBytes = Buffer.byteLength(result.ambient, 'utf8'); + expect( + ambientBytes, + `Ambient prompt is ${(ambientBytes / 1024).toFixed(1)} KB. Mode playbooks and planning ` + + `reference material belong in inline skills, not the always-loaded prompt.` + ).toBeLessThan(AMBIENT_BUDGET_BYTES); + }); + + it('gives every dispatchable mode a skill to load', () => { + const skillNames = new Set(result.skills.map(s => s.name)); + const modes = extractModeTable(readText(SQUAD_WORKFLOW)); + expect(modes.length, 'mode table should parse').toBeGreaterThan(15); + + const dispatchTable = readText(SQUAD_WORKFLOW); + for (const { mode } of modes) { + const slug = + 'squad-' + + mode + .replace(/\([^)]*\)/g, '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)/g, ''); + + const hasSkill = skillNames.has(slug); + // Some modes legitimately share a playbook; accept an explicit mapping row. + const hasMapping = new RegExp(`\\|[^|\\n]*${mode.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}[^|\\n]*\\|[^|\\n]*squad-`, 'i').test(dispatchTable); + + expect( + hasSkill || hasMapping, + `Mode "${mode}" has no "## skill: \`${slug}\`" block and no row in the Execute Mode ` + + `dispatch table pointing at a skill. It would have no playbook at runtime.` + ).toBe(true); + } + }); +}); + // --------------------------------------------------------------------------- // Test: gh-aw compilation retains durable state and Auto-Cast contracts // --------------------------------------------------------------------------- @@ -640,7 +852,7 @@ describe('gh-aw: compiled workflow contract', () => { stdio: 'pipe', }); - const compiled = readFileSync(join(workspace, '.github', 'workflows', 'squad.lock.yml'), 'utf8'); + const compiled = readText(join(workspace, '.github', 'workflows', 'squad.lock.yml')); expect(compiled).toContain('"auto_close_issue":false'); expect(compiled).toContain('"data_enabled":true'); expect(compiled).toContain('"required":["origin_issue","phases","schema_version","squad_artifact"]'); @@ -649,7 +861,7 @@ describe('gh-aw: compiled workflow contract', () => { // compiled file's own directory), so with the real `.github/workflows/` // deployment layout these are prefixed accordingly — verified against an // isolated compile outside this repo/worktree entirely. - expect(compiled).toContain('{{#runtime-import .github/workflows/shared/planning-ontology.md}}'); + expect(compiled).toContain('{{#runtime-import .github/workflows/shared/squad-planning-ontology.md}}'); expect(compiled).toContain('{{#runtime-import .github/workflows/squad.md}}'); expect(compiled).not.toMatch(//); } finally { @@ -663,7 +875,7 @@ describe('gh-aw: compiled workflow contract', () => { // --------------------------------------------------------------------------- describe('gh-aw: Plan Activate hardening behaviors', () => { - const content = readFileSync(SQUAD_WORKFLOW, 'utf8'); + const content = readText(SQUAD_WORKFLOW); it('includes output budget awareness guidance', () => { expect(content).toContain('Output Budget Awareness'); @@ -708,7 +920,7 @@ describe('gh-aw: Plan Activate hardening behaviors', () => { // --------------------------------------------------------------------------- describe('gh-aw: Plan Activate atomic task-call contract', () => { - const content = readFileSync(SQUAD_WORKFLOW, 'utf8'); + const content = readText(SQUAD_WORKFLOW); it('2c contains explicit ATOMIC CONTRACT heading', () => { expect(content).toMatch(/ATOMIC CONTRACT/i); @@ -746,7 +958,7 @@ describe('gh-aw: Plan Activate atomic task-call contract', () => { // --------------------------------------------------------------------------- describe('gh-aw: auto-cast pivot and resumable work (#1689)', () => { - const content = readFileSync(SQUAD_WORKFLOW, 'utf8'); + const content = readText(SQUAD_WORKFLOW); it('Team Guard section exists and lists covered modes', () => { expect(content).toMatch(/## Team Guard/); @@ -792,7 +1004,7 @@ describe('gh-aw: auto-cast pivot and resumable work (#1689)', () => { it('does not require unsupported Auto-Cast HTML markers', () => { expect(content).not.toMatch(/squad-(pending-intent|cast-opened|cast-pr)-v1/); - expect(readFileSync(join(SHARED_DIR, 'planning-ontology.md'), 'utf8')) + expect(readText(join(SHARED_DIR, 'squad-planning-ontology.md'))) .not.toMatch(/squad-(pending-intent|cast-opened|cast-pr)-v1/); }); @@ -866,7 +1078,7 @@ describe('gh-aw: auto-cast pivot and resumable work (#1689)', () => { // the guard, preventing false TEAM_PRESENT in fresh repos. // --------------------------------------------------------------------------- -describe('gh-aw: Team Guard roster-row detection — committed-HEAD git repo coverage (#1689 revision 3)', () => { +describe.skipIf(!HAS_POSIX_SHELL)('gh-aw: Team Guard roster-row detection — committed-HEAD git repo coverage (#1689 revision 3)', () => { // Content shared across test cases const SCAFFOLD_CONTENT = `# Squad Team\n\n## Members\n| Name | Role | Charter path | Status |\n|------|------|--------------|--------|\n`; const ONE_MEMBER_CONTENT = `# Squad Team\n\n## Members\n| Name | Role | Charter path | Status |\n|------|------|--------------|--------|\n| Eecom | Core Dev | .squad/agents/eecom/charter.md | active |\n`; @@ -1023,7 +1235,7 @@ describe('gh-aw: Team Guard roster-row detection — committed-HEAD git repo cov // --------------------------------------------------------------------------- describe('gh-aw: Auto-Cast prompt hygiene (#1689 revision)', () => { - const content = readFileSync(SQUAD_WORKFLOW, 'utf8'); + const content = readText(SQUAD_WORKFLOW); it('{original_command} does not appear anywhere in workflow — only canonical command variables are used', () => { expect(content).not.toMatch(/\{original_command\}/); @@ -1038,8 +1250,8 @@ describe('gh-aw: Auto-Cast prompt hygiene (#1689 revision)', () => { // Test: Cast PR dedup jq filter — behavioral coverage (#1689 revision) // --------------------------------------------------------------------------- -describe('gh-aw: Cast PR dedup jq filter behavioral coverage (#1689 revision)', () => { - const squadContent = readFileSync(SQUAD_WORKFLOW, 'utf8'); +describe.skipIf(!HAS_POSIX_SHELL)('gh-aw: Cast PR dedup jq filter behavioral coverage (#1689 revision)', () => { + const squadContent = readText(SQUAD_WORKFLOW); // Extract the exact --jq expression from squad.md so this test stays in sync. function extractJqFilter(): string { @@ -1116,7 +1328,7 @@ describe('gh-aw: Cast PR dedup jq filter behavioral coverage (#1689 revision)', // Complements the broader auto-Cast coverage in the '#1689' describe blocks above. // --------------------------------------------------------------------------- describe('gh-aw: Auto-Cast UX guidance — canonical fallback and Cast PR body return instruction (#1700)', () => { - const squadContent = readFileSync(SQUAD_WORKFLOW, 'utf8'); + const squadContent = readText(SQUAD_WORKFLOW); it('canonical_mode definition includes safe fallback for unresolvable mode', () => { expect(squadContent).toMatch(/if.*canonical_mode.*cannot be determined.*safe fallback|safe fallback.*\/squad/i); @@ -1141,7 +1353,7 @@ describe('gh-aw: Auto-Cast UX guidance — canonical fallback and Cast PR body r // --------------------------------------------------------------------------- describe('gh-aw: threat detection taxonomy — parse_error must not map to agentic-threat-detected (#1701)', () => { const LABEL_SYNC = join(process.cwd(), '.github/workflows/sync-squad-labels.yml'); - const labelSyncContent = readFileSync(LABEL_SYNC, 'utf8'); + const labelSyncContent = readText(LABEL_SYNC); it('sync-squad-labels.yml defines agentic-detection-failed for infrastructure failures', () => { expect(labelSyncContent).toContain('agentic-detection-failed'); diff --git a/workflows/shared/planning-ontology.md b/workflows/shared/squad-planning-ontology.md similarity index 98% rename from workflows/shared/planning-ontology.md rename to workflows/shared/squad-planning-ontology.md index b3e41d9a8..be64d509b 100644 --- a/workflows/shared/planning-ontology.md +++ b/workflows/shared/squad-planning-ontology.md @@ -1,3 +1,7 @@ +## skill: `squad-planning-ontology` +--- +description: Squad planning ontology — artifact schemas, lifecycle state machine, and the structured artifact registry. Load for any planning mode before producing or reading a planning artifact. +--- # Planning Ontology & Artifact Schemas > **Version:** 2.0 · **Owner:** Procedures · **Status:** Active @@ -411,3 +415,5 @@ All commands work identically under both execution models: 6. **Postcondition validation** — acceptance checks structure before creating real artifacts. 7. **Idempotent commands** — re-running updates rather than duplicates. 8. **Path independence** — fast path and granular path coexist without conflict. + +## end skill: `squad-planning-ontology` diff --git a/workflows/shared/planning-policy.md b/workflows/shared/squad-planning-policy.md similarity index 80% rename from workflows/shared/planning-policy.md rename to workflows/shared/squad-planning-policy.md index b8aa77761..93c070a89 100644 --- a/workflows/shared/planning-policy.md +++ b/workflows/shared/squad-planning-policy.md @@ -1,3 +1,7 @@ +## skill: `squad-planning-policy-schema` +--- +description: Squad planning policy schema — profiles, artifact limits, sizing, and precedence rules. Load together with squad-planning-policy when resolving policy for a planning mode. +--- # Planning Policy ## Purpose @@ -90,7 +94,7 @@ cycle_detection: warn ## How Policy is Resolved (precedence order) -1. **Issue body** — HTML comment `` or inline settings +1. **Issue body** — a `Squad-Policy:` directive line (see [Policy in Issue Body](#policy-in-issue-body)) 2. **Repository file** — `.squad/planning-policy.md` with YAML frontmatter 3. **Profile name** — Matches a built-in or custom profile 4. **Default** — All defaults apply @@ -112,15 +116,23 @@ settings: ## Policy in Issue Body -Embed policy inline for per-issue customization: +Embed policy inline for per-issue customization by adding a `Squad-Policy:` line +on its own line anywhere in the issue body: ```markdown - +Squad-Policy: lean ``` Or override specific settings: ```markdown - - +Squad-Policy: default +Squad-Setting: max_issues=30, require_milestones=false ``` + +> **Do not use HTML comments** (for example ``) for +> this. GitHub Agentic Workflows strips HTML comments before the agent sees the +> issue body, so a policy hidden in a comment is silently ignored. The directive +> must be visible Markdown text. + +## end skill: `squad-planning-policy-schema` diff --git a/workflows/squad-implement-worker.md b/workflows/squad-implement-worker.md index 0111a06e2..fb9e81272 100644 --- a/workflows/squad-implement-worker.md +++ b/workflows/squad-implement-worker.md @@ -52,25 +52,42 @@ safe-outputs: allowed-files: - "*.c" - "*.cc" + - "*.cjs" - "*.cpp" - "*.cs" + - "*.csproj" - "*.css" - "*.fs" + - "*.fsproj" - "*.go" + - "*.gradle" + - "*.h" + - "*.hpp" - "*.html" - "*.java" - "*.js" - "*.jsx" - "*.json" + - "*.kt" + - "*.kts" - "*.md" - "*.mjs" + - "*.php" + - "*.props" - "*.py" - "*.rb" - "*.rs" - "*.sh" + - "*.sln" + - "*.slnx" + - "*.sql" + - "*.svelte" + - "*.swift" + - "*.targets" - "*.toml" - "*.ts" - "*.tsx" + - "*.vue" - "*.yaml" - "*.yml" - "Dockerfile*" diff --git a/workflows/squad.md b/workflows/squad.md index abc696e95..b2c5453bd 100644 --- a/workflows/squad.md +++ b/workflows/squad.md @@ -36,8 +36,8 @@ network: - defaults imports: - shared/squad.md - - shared/planning-ontology.md - - shared/planning-policy.md + - shared/squad-planning-ontology.md + - shared/squad-planning-policy.md tools: bash: true github: @@ -190,10 +190,39 @@ Repository owners must configure Copilot setup steps separately when needed. ## Execute Mode -**MODE ISOLATION:** Execute ONLY the active mode's section. Other mode instructions do not apply. +Each mode's playbook ships as a **skill**. Load only the one skill for the parsed mode, then follow it verbatim. + +**MODE ISOLATION:** Execute ONLY the active mode's skill. Other modes' instructions do not apply — do not load more than one mode skill. **BREADCRUMB ≠ DELIVERABLE:** Every mode posts an acknowledgment first. This is never the deliverable — always complete ALL subsequent steps. +| Parsed mode | Skill to load | +|---|---| +| `cast` | `squad-cast` | +| `connect` | `squad-connect` | +| `adopt` | `squad-adopt` | +| `cast-member` | `squad-cast-member` | +| `retire` | `squad-retire` | +| `status` | `squad-status` | +| `research` | `squad-research` | +| `plan` | `squad-plan` | +| `plan revise` | `squad-plan-revise` | +| `triage` | `squad-triage` | +| `triage revise` | `squad-triage-revise` | +| `plan program` | `squad-plan-program` | +| `plan program revise` | `squad-plan-program-revise` | +| `plan implementation` | `squad-plan-implementation` | +| `plan validate` | `squad-plan-validate` | +| `plan accept` | `squad-plan-accept` | +| `plan accept scope` | `squad-plan-accept-scope` | +| `plan accept implementation` | `squad-plan-accept-implementation` | +| `plan activate` | `squad-plan-activate` | +| `implement` | `squad-implement` | + +**Planning modes only** — before running the mode skill, also load `squad-planning-policy` (policy resolution) and `squad-planning-ontology` (artifact schemas and the lifecycle state machine). The non-planning modes (Cast, Connect, Adopt, Cast Member, Retire, Status, Implement) must not load them. + +If the parsed mode's skill cannot be loaded, report the failure in plain language and stop. Never improvise a mode playbook from memory. + --- ## Team Guard @@ -264,7 +293,10 @@ gh pr list --state open --json number,url,headRefName --jq '[.[] | select(.headR --- -#### Cast Mode +## skill: `squad-cast` +--- +description: Cast a Squad team: analyze the repo, compose agents, assign character names, scaffold .squad/, open the Cast PR. +--- Analyze repo, compose team, assign character names from a fictional universe, generate `.squad/` scaffolding, open PR. @@ -359,9 +391,10 @@ Create `meet-the-squad.md` at repo root with: title, universe name, team table ( `add-comment`: `🧑‍🤝‍🧑 Your Squad is ready for review.\n\n**PR:** #{pr_number}\n\nMerge the PR to activate your team. Run /squad status afterward to verify.` +## skill: `squad-connect` +--- +description: Connect an existing external team source into Squad. --- - -#### Connect Mode Link repo to an external Squad source. Commits only a config pointer. @@ -374,9 +407,10 @@ Link repo to an external Squad source. Commits only a config pointer. 5. **Open PR:** `create-pull-request`: branch `squad/connect-{repo}`, files: `.squad/config.json`, `meet-the-squad.md` only. 6. **Post:** `🔗 Squad connection configured.\n\n**PR:** #{pr_number}` +## skill: `squad-adopt` +--- +description: Adopt a team definition from a URL into .squad/. --- - -#### Adopt Mode Fetch complete squad from remote, commit locally. No ongoing sync. @@ -390,9 +424,10 @@ Fetch complete squad from remote, commit locally. No ongoing sync. 6. **Open PR:** `create-pull-request`: branch `squad/adopt-{repo}`, files: `.squad/`, `.github/agents/squad.agent.md`, `meet-the-squad.md`. 7. **Post:** `📥 Squad adopted from remote source.\n\n**PR:** #{pr_number}` +## skill: `squad-cast-member` +--- +description: Add a single new member to an existing Squad team. --- - -#### Cast Member Mode Add/modify/rename a single team member within an existing squad. @@ -407,9 +442,10 @@ Subcommands: `/squad cast-member ` (add), `/squad cast-member renam 7. **Open PR:** On Squad PR: follow-up PR targeting existing branch. On issue: `create-pull-request` branch `squad/cast-member-{id}`, title `[squad] Add/Modify {Name}`. 8. **Post:** `👤 {Name} ({Role}) has been added to the team.\n\n**PR:** #{pr_number}` +## skill: `squad-retire` +--- +description: Retire a named member from the Squad team. --- - -#### Retire Mode Remove a member from active roster, archive charter. @@ -419,9 +455,10 @@ Remove a member from active roster, archive charter. 4. **Open PR:** Same context-aware behavior as Cast Member. Branch: `squad/retire-{id}`. 5. **Post:** `👋 {Name} has been retired from the team.\n\n**PR:** #{pr_number}` +## skill: `squad-status` +--- +description: Report current Squad team and planning lifecycle status. --- - -#### Status Mode Read-only team composition report. @@ -431,9 +468,10 @@ Read-only team composition report. 2. Read team.md + registry.json. 3. Post comment: team name, universe, member count, active members table, link to team.md. +## skill: `squad-implement` +--- +description: Dispatch implementation work to the squad-implement-worker workflow. --- - -#### Implement Mode Implement mode dispatches an isolated implementation worker for a regular issue. When invoked on an epic, it dispatches workers for up to three currently @@ -498,9 +536,10 @@ After each implementation pull request merges, this workflow runs again and fills newly available slots. Continue until the epic has no open children. `/squad implement` remains available as a manual recovery command. +## skill: `squad-research` +--- +description: Produce the research artifact that seeds planning for an issue. --- - -#### Research Mode Deep analysis → structured findings comment. Read-only + comment. Works on open/closed issues. @@ -531,9 +570,10 @@ Must be ≥200 chars of substantive findings. Tailor sections to scope. Confirm: structured artifact data posted, heading present, ≥200 chars substantive content, ≥1 recommendation. If ANY fails, go back and post now. +## skill: `squad-plan` +--- +description: Produce the plan artifact from research for an issue. --- - -#### Plan Mode Decompose issue into sub-issues as a comment. Does NOT create issues. Works on open/closed issues. @@ -560,9 +600,10 @@ Structure: `## 📋 Squad Plan — {Title}` → reference line → Phase tables Do NOT create issues. +## skill: `squad-plan-accept` +--- +description: Accept a plan (whole plan or a single phase) and record the accepted artifact. --- - -#### Plan Accept Mode (Fast Path) `/squad plan accept` [phase {N}] — combines scope+impl+activation for simple workflows. @@ -607,9 +648,10 @@ Artifact data varies: - Phase-specific: `data: {"squad_artifact":"phases-accepted","schema_version":"1","origin_issue":{issue_number},"phases":[{accumulated}]}` → Phase accepted table + remaining phases table - Full (no phases): `data: {"squad_artifact":"plan-accepted","schema_version":"1","origin_issue":{issue_number},"phases":[]}` → All issues table +## skill: `squad-plan-revise` +--- +description: Revise an existing plan artifact from reviewer feedback. --- - -#### Plan Revise Mode **Acknowledge:** `🤖 Squad is revising the plan…` @@ -619,9 +661,10 @@ Artifact data varies: 4. **EDIT the existing artifact comment** (never post a duplicate). 5. Prepend revision note. +## skill: `squad-triage` +--- +description: Triage a plan into classified work items and update lifecycle state. --- - -#### Triage Mode Classify research findings as work/decision/excluded. Bridge between research and planning. Works on open/closed issues. @@ -655,9 +698,10 @@ Structure: `## 🔍 Squad Triage — Dispositions` → Intent + reference lines Find/create the `lifecycle-state` artifact comment. Include `data: {"squad_artifact":"lifecycle-state","schema_version":"1","origin_issue":{issue_number},"phases":[]}`. Set Triage = `✅ Done`, state = Triaged, next = `/squad plan program`. +## skill: `squad-triage-revise` +--- +description: Revise triage dispositions from reviewer feedback. --- - -#### Triage Revise Mode **Acknowledge:** `🤖 Squad is revising triage dispositions…` @@ -667,25 +711,27 @@ Find/create the `lifecycle-state` artifact comment. Include `data: {"squad_artif 4. **EDIT the existing artifact comment** (one current artifact per issue). Prepend revision note. 5. Update lifecycle. +## skill: `squad-planning-policy` +--- +description: Resolve the active planning policy profile and overrides. Load before any planning mode executes. --- - -#### Planning Policy Resolution All planning modes resolve policy before executing. The planning policy schema is imported — follow it directly. Steps: -1. Check issue body HTML-comment content for a line beginning `squad-policy:` or `squad-setting:`. Match the comment content, not the HTML delimiters. +1. Scan the issue body for a line beginning `Squad-Policy:` or `Squad-Setting:` (case-insensitive, one directive per line). These are plain visible Markdown lines — never HTML comments, which gh-aw strips before you see the body. 2. Check repo for `.squad/planning-policy.md` with YAML frontmatter. 3. Match profile (`default`, `lean`, `enterprise`, `spike`, or custom). 4. Fall back to defaults for unset values. Apply: artifact limits, sizing constraints, hierarchy rules, GitHub representation, validation strictness. Report active policy in every plan output: `Policy: {profile} ({overrides or "no overrides"})`. +## skill: `squad-plan-program` +--- +description: Build the high-level program plan (initiatives, epics, stories, milestones, dependencies). --- - -#### Plan Program Mode High-level program plan (the WHAT). Transforms triage work items into initiatives/epics/stories/milestones/dependencies. Works on open/closed issues. @@ -729,9 +775,10 @@ Structure: `## 📋 Squad Program Plan` → Intent + triage ref → Milestones t Set Program Plan = `✅ Done`, state = Program Planned, next = `/squad plan accept scope`. +## skill: `squad-plan-program-revise` +--- +description: Revise the program plan from reviewer feedback. --- - -#### Plan Program Revise Mode **Acknowledge:** `🤖 Squad is revising the program plan…` @@ -744,9 +791,10 @@ Works on open/closed issues. 5. **EDIT existing comment**. Prepend revision note. 6. Update lifecycle. If scope was invalidated (override), set Scope back to `⬚ Pending`. +## skill: `squad-plan-implementation` +--- +description: Build the implementation plan (the HOW) from an accepted program plan. --- - -#### Plan Implementation Mode Decompose program plan into PR-sized tasks with deps, sizing, agent assignments. Works on open/closed issues. @@ -780,9 +828,10 @@ Structure: `## 🔧 Squad Implementation Plan` → Program ref → Phase tables Set Implementation Plan = `✅ Done`, state = Implementation planned, next = `/squad plan validate`. +## skill: `squad-plan-validate` +--- +description: Validate the implementation plan and emit a PASS/FAIL validation artifact. --- - -#### Plan Validate Mode Readiness gate checking plan artifacts for structural issues. @@ -828,9 +877,10 @@ Set Validation = `✅ Done` or `❌ Failed`. Next on pass: `/squad plan accept i Pass: suggest accept. Fail: suggest fix + re-validate. +## skill: `squad-plan-accept-scope` +--- +description: Accept the program-plan scope and record the scope-accepted artifact. --- - -#### Plan Accept Scope Mode `/squad plan accept scope` — locks the program plan (the WHAT). @@ -856,9 +906,10 @@ Content: `## ✅ Scope Accepted` → program plan version link, accepted by, dat Set Scope = `✅ Done`, next = `/squad plan implementation`. +## skill: `squad-plan-accept-implementation` +--- +description: Accept the implementation plan (whole or per phase) and lock it for activation. --- - -#### Plan Accept Implementation Mode `/squad plan accept implementation` [phase {N}] — locks the implementation plan (the HOW). Supports incremental phase acceptance. @@ -903,9 +954,10 @@ After phase acceptance, check if ready for automatic activation: 3. If prior phases not activated: tell user to activate them first. 4. Update lifecycle to reflect both acceptance and activation. +## skill: `squad-plan-activate` +--- +description: Activate an accepted plan by creating the epic and task issues on GitHub. --- - -#### Plan Activate Mode `/squad plan activate` [phase {N}] — creates real GitHub issues/milestones. Irreversible.