diff --git a/.ai-team-templates/constraint-tracking.md b/.ai-team-templates/constraint-tracking.md
new file mode 100644
index 000000000..1936c3ff1
--- /dev/null
+++ b/.ai-team-templates/constraint-tracking.md
@@ -0,0 +1,38 @@
+# Constraint Budget Tracking
+
+When the user or system imposes constraints (question limits, revision limits, time budgets), maintain a visible counter in your responses and in the artifact.
+
+## Format
+
+```
+π Clarifying questions used: 2 / 3
+```
+
+## Rules
+
+- Update the counter each time the constraint is consumed
+- When a constraint is exhausted, state it: `π Question budget exhausted (3/3). Proceeding with current information.`
+- If no constraints are active, do not display counters
+- Include the final constraint status in multi-agent artifacts
+
+## Example Session
+
+```
+Coordinator: Spawning agents to analyze requirements...
+π Clarifying questions used: 0 / 3
+
+Agent asks clarification: "Should we support OAuth?"
+Coordinator: Checking with user...
+π Clarifying questions used: 1 / 3
+
+Agent asks clarification: "What's the rate limit?"
+Coordinator: Checking with user...
+π Clarifying questions used: 2 / 3
+
+Agent asks clarification: "Do we need RBAC?"
+Coordinator: Checking with user...
+π Clarifying questions used: 3 / 3
+
+Agent asks clarification: "Should we cache responses?"
+Coordinator: π Question budget exhausted (3/3). Proceeding without clarification.
+```
diff --git a/.ai-team-templates/copilot-instructions.md b/.ai-team-templates/copilot-instructions.md
new file mode 100644
index 000000000..ddc20f12c
--- /dev/null
+++ b/.ai-team-templates/copilot-instructions.md
@@ -0,0 +1,46 @@
+# Copilot Coding Agent β Squad Instructions
+
+You are working on a project that uses **Squad**, an AI team framework. When picking up issues autonomously, follow these guidelines.
+
+## Team Context
+
+Before starting work on any issue:
+
+1. Read `.squad/team.md` for the team roster, member roles, and your capability profile.
+2. Read `.squad/routing.md` for work routing rules.
+3. If the issue has a `squad:{member}` label, read that member's charter at `.squad/agents/{member}/charter.md` to understand their domain expertise and coding style β work in their voice.
+
+## Capability Self-Check
+
+Before starting work, check your capability profile in `.squad/team.md` under the **Coding Agent β Capabilities** section.
+
+- **π’ Good fit** β proceed autonomously.
+- **π‘ Needs review** β proceed, but note in the PR description that a squad member should review.
+- **π΄ Not suitable** β do NOT start work. Instead, comment on the issue:
+ ```
+ π€ This issue doesn't match my capability profile (reason: {why}). Suggesting reassignment to a squad member.
+ ```
+
+## Branch Naming
+
+Use the squad branch convention:
+```
+squad/{issue-number}-{kebab-case-slug}
+```
+Example: `squad/42-fix-login-validation`
+
+## PR Guidelines
+
+When opening a PR:
+- Reference the issue: `Closes #{issue-number}`
+- If the issue had a `squad:{member}` label, mention the member: `Working as {member} ({role})`
+- If this is a π‘ needs-review task, add to the PR description: `β οΈ This task was flagged as "needs review" β please have a squad member review before merging.`
+- Follow any project conventions in `.squad/decisions.md`
+
+## Decisions
+
+If you make a decision that affects other team members, write it to:
+```
+.squad/decisions/inbox/copilot-{brief-slug}.md
+```
+The Scribe will merge it into the shared decisions file.
diff --git a/.ai-team-templates/identity/now.md b/.ai-team-templates/identity/now.md
new file mode 100644
index 000000000..04e1dfeeb
--- /dev/null
+++ b/.ai-team-templates/identity/now.md
@@ -0,0 +1,9 @@
+---
+updated_at: {timestamp}
+focus_area: {brief description}
+active_issues: []
+---
+
+# What We're Focused On
+
+{Narrative description of current focus β 1-3 sentences. Updated by coordinator at session start.}
diff --git a/.ai-team-templates/identity/wisdom.md b/.ai-team-templates/identity/wisdom.md
new file mode 100644
index 000000000..c3b978e4f
--- /dev/null
+++ b/.ai-team-templates/identity/wisdom.md
@@ -0,0 +1,15 @@
+---
+last_updated: {timestamp}
+---
+
+# Team Wisdom
+
+Reusable patterns and heuristics learned through work. NOT transcripts β each entry is a distilled, actionable insight.
+
+## Patterns
+
+
+
+## Anti-Patterns
+
+
diff --git a/.ai-team-templates/mcp-config.md b/.ai-team-templates/mcp-config.md
new file mode 100644
index 000000000..2e361ee4b
--- /dev/null
+++ b/.ai-team-templates/mcp-config.md
@@ -0,0 +1,90 @@
+# MCP Integration β Configuration and Samples
+
+MCP (Model Context Protocol) servers extend Squad with tools for external services β Trello, Aspire dashboards, Azure, Notion, and more. The user configures MCP servers in their environment; Squad discovers and uses them.
+
+> **Full patterns:** Read `.squad/skills/mcp-tool-discovery/SKILL.md` for discovery patterns, domain-specific usage, and graceful degradation.
+
+## Config File Locations
+
+Users configure MCP servers at these locations (checked in priority order):
+1. **Repository-level:** `.copilot/mcp-config.json` (team-shared, committed to repo)
+2. **Workspace-level:** `.vscode/mcp.json` (VS Code workspaces)
+3. **User-level:** `~/.copilot/mcp-config.json` (personal)
+4. **CLI override:** `--additional-mcp-config` flag (session-specific)
+
+## Sample Config β Trello
+
+```json
+{
+ "mcpServers": {
+ "trello": {
+ "command": "npx",
+ "args": ["-y", "@trello/mcp-server"],
+ "env": {
+ "TRELLO_API_KEY": "${TRELLO_API_KEY}",
+ "TRELLO_TOKEN": "${TRELLO_TOKEN}"
+ }
+ }
+ }
+}
+```
+
+## Sample Config β GitHub
+
+```json
+{
+ "mcpServers": {
+ "github": {
+ "command": "npx",
+ "args": ["-y", "@modelcontextprotocol/server-github"],
+ "env": {
+ "GITHUB_TOKEN": "${GITHUB_TOKEN}"
+ }
+ }
+ }
+}
+```
+
+## Sample Config β Azure
+
+```json
+{
+ "mcpServers": {
+ "azure": {
+ "command": "npx",
+ "args": ["-y", "@azure/mcp-server"],
+ "env": {
+ "AZURE_SUBSCRIPTION_ID": "${AZURE_SUBSCRIPTION_ID}",
+ "AZURE_CLIENT_ID": "${AZURE_CLIENT_ID}",
+ "AZURE_CLIENT_SECRET": "${AZURE_CLIENT_SECRET}",
+ "AZURE_TENANT_ID": "${AZURE_TENANT_ID}"
+ }
+ }
+ }
+}
+```
+
+## Sample Config β Aspire
+
+```json
+{
+ "mcpServers": {
+ "aspire": {
+ "command": "npx",
+ "args": ["-y", "@aspire/mcp-server"],
+ "env": {
+ "ASPIRE_DASHBOARD_URL": "${ASPIRE_DASHBOARD_URL}"
+ }
+ }
+ }
+}
+```
+
+## Authentication Notes
+
+- **GitHub MCP requires a separate token** from the `gh` CLI auth. Generate at https://github.com/settings/tokens
+- **Trello requires API key + token** from https://trello.com/power-ups/admin
+- **Azure requires service principal credentials** β see Azure docs for setup
+- **Aspire uses the dashboard URL** β typically `http://localhost:18888` during local dev
+
+Auth is a real blocker for some MCP servers. Users need separate tokens for GitHub MCP, Azure MCP, Trello MCP, etc. This is a documentation problem, not a code problem.
diff --git a/.ai-team-templates/multi-agent-format.md b/.ai-team-templates/multi-agent-format.md
new file mode 100644
index 000000000..b655ee942
--- /dev/null
+++ b/.ai-team-templates/multi-agent-format.md
@@ -0,0 +1,28 @@
+# Multi-Agent Artifact Format
+
+When multiple agents contribute to a final artifact (document, analysis, design), use this format. The assembled result must include:
+
+- Termination condition
+- Constraint budgets (if active)
+- Reviewer verdicts (if any)
+- Raw agent outputs appendix
+
+## Assembly Structure
+
+The assembled result goes at the top. Below it, include:
+
+```
+## APPENDIX: RAW AGENT OUTPUTS
+
+### {Name} ({Role}) β Raw Output
+{Paste agent's verbatim response here, unedited}
+
+### {Name} ({Role}) β Raw Output
+{Paste agent's verbatim response here, unedited}
+```
+
+## Appendix Rules
+
+This appendix is for diagnostic integrity. Do not edit, summarize, or polish the raw outputs. The Coordinator may not rewrite raw agent outputs; it may only paste them verbatim and assemble the final artifact above.
+
+See `.squad/templates/run-output.md` for the complete output format template.
diff --git a/.ai-team-templates/plugin-marketplace.md b/.ai-team-templates/plugin-marketplace.md
new file mode 100644
index 000000000..893632816
--- /dev/null
+++ b/.ai-team-templates/plugin-marketplace.md
@@ -0,0 +1,49 @@
+# Plugin Marketplace
+
+Plugins are curated agent templates, skills, instructions, and prompts shared by the community via GitHub repositories (e.g., `github/awesome-copilot`, `anthropics/skills`). They provide ready-made expertise for common domains β cloud platforms, frameworks, testing strategies, etc.
+
+## Marketplace State
+
+Registered marketplace sources are stored in `.squad/plugins/marketplaces.json`:
+
+```json
+{
+ "marketplaces": [
+ {
+ "name": "awesome-copilot",
+ "source": "github/awesome-copilot",
+ "added_at": "2026-02-14T00:00:00Z"
+ }
+ ]
+}
+```
+
+## CLI Commands
+
+Users manage marketplaces via the CLI:
+- `squad plugin marketplace add {owner/repo}` β Register a GitHub repo as a marketplace source
+- `squad plugin marketplace remove {name}` β Remove a registered marketplace
+- `squad plugin marketplace list` β List registered marketplaces
+- `squad plugin marketplace browse {name}` β List available plugins in a marketplace
+
+## When to Browse
+
+During the **Adding Team Members** flow, AFTER allocating a name but BEFORE generating the charter:
+
+1. Read `.squad/plugins/marketplaces.json`. If the file doesn't exist or `marketplaces` is empty, skip silently.
+2. For each registered marketplace, search for plugins whose name or description matches the new member's role or domain keywords.
+3. Present matching plugins to the user: *"Found '{plugin-name}' in {marketplace} marketplace β want me to install it as a skill for {CastName}?"*
+4. If the user accepts, install the plugin (see below). If they decline or skip, proceed without it.
+
+## How to Install a Plugin
+
+1. Read the plugin content from the marketplace repository (the plugin's `SKILL.md` or equivalent).
+2. Copy it into the agent's skills directory: `.squad/skills/{plugin-name}/SKILL.md`
+3. If the plugin includes charter-level instructions (role boundaries, tool preferences), merge those into the agent's `charter.md`.
+4. Log the installation in the agent's `history.md`: *"π¦ Plugin '{plugin-name}' installed from {marketplace}."*
+
+## Graceful Degradation
+
+- **No marketplaces configured:** Skip the marketplace check entirely. No warning, no prompt.
+- **Marketplace unreachable:** Warn the user (*"β Couldn't reach {marketplace} β continuing without it"*) and proceed with team member creation normally.
+- **No matching plugins:** Inform the user (*"No matching plugins found in configured marketplaces"*) and proceed.
diff --git a/.ai-team-templates/workflows/squad-ci.yml b/.ai-team-templates/workflows/squad-ci.yml
new file mode 100644
index 000000000..2f809d70f
--- /dev/null
+++ b/.ai-team-templates/workflows/squad-ci.yml
@@ -0,0 +1,24 @@
+name: Squad CI
+
+on:
+ pull_request:
+ branches: [dev, preview, main, insider]
+ types: [opened, synchronize, reopened]
+ push:
+ branches: [dev, insider]
+
+permissions:
+ contents: read
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+
+ - name: Run tests
+ run: node --test test/*.test.js
diff --git a/.ai-team-templates/workflows/squad-docs.yml b/.ai-team-templates/workflows/squad-docs.yml
new file mode 100644
index 000000000..307d502c5
--- /dev/null
+++ b/.ai-team-templates/workflows/squad-docs.yml
@@ -0,0 +1,50 @@
+name: Squad Docs β Build & Deploy
+
+on:
+ workflow_dispatch:
+ push:
+ branches: [preview]
+ paths:
+ - 'docs/**'
+ - '.github/workflows/squad-docs.yml'
+
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+concurrency:
+ group: pages
+ cancel-in-progress: true
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+
+ - name: Install build dependencies
+ run: npm install --no-save markdown-it markdown-it-anchor
+
+ - name: Build docs site
+ run: node docs/build.js --out _site --base /squad
+
+ - name: Upload Pages artifact
+ uses: actions/upload-pages-artifact@v3
+ with:
+ path: _site
+
+ deploy:
+ needs: build
+ runs-on: ubuntu-latest
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ steps:
+ - name: Deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/.ai-team-templates/workflows/squad-heartbeat.yml b/.ai-team-templates/workflows/squad-heartbeat.yml
new file mode 100644
index 000000000..a3caa6ae7
--- /dev/null
+++ b/.ai-team-templates/workflows/squad-heartbeat.yml
@@ -0,0 +1,315 @@
+name: Squad Heartbeat (Ralph)
+
+on:
+ schedule:
+ # Every 30 minutes β adjust or remove if not needed
+ - cron: '*/30 * * * *'
+
+ # React to completed work or new squad work
+ issues:
+ types: [closed, labeled]
+ pull_request:
+ types: [closed]
+
+ # Manual trigger
+ workflow_dispatch:
+
+permissions:
+ issues: write
+ contents: read
+ pull-requests: read
+
+jobs:
+ heartbeat:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Ralph β Check for squad work
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const fs = require('fs');
+
+ // Read team roster β check .squad/ first, fall back to .ai-team/
+ let teamFile = '.squad/team.md';
+ if (!fs.existsSync(teamFile)) {
+ teamFile = '.ai-team/team.md';
+ }
+ if (!fs.existsSync(teamFile)) {
+ core.info('No .squad/team.md or .ai-team/team.md found β Ralph has nothing to monitor');
+ return;
+ }
+
+ const content = fs.readFileSync(teamFile, 'utf8');
+
+ // Check if Ralph is on the roster
+ if (!content.includes('Ralph') || !content.includes('π')) {
+ core.info('Ralph not on roster β heartbeat disabled');
+ return;
+ }
+
+ // Parse members from roster
+ const lines = content.split('\n');
+ const members = [];
+ let inMembersTable = false;
+ for (const line of lines) {
+ if (line.match(/^##\s+(Members|Team Roster)/i)) {
+ inMembersTable = true;
+ continue;
+ }
+ if (inMembersTable && line.startsWith('## ')) break;
+ if (inMembersTable && line.startsWith('|') && !line.includes('---') && !line.includes('Name')) {
+ const cells = line.split('|').map(c => c.trim()).filter(Boolean);
+ if (cells.length >= 2 && !['Scribe', 'Ralph'].includes(cells[0])) {
+ members.push({
+ name: cells[0],
+ role: cells[1],
+ label: `squad:${cells[0].toLowerCase()}`
+ });
+ }
+ }
+ }
+
+ if (members.length === 0) {
+ core.info('No squad members found β nothing to monitor');
+ return;
+ }
+
+ // 1. Find untriaged issues (labeled "squad" but no "squad:{member}" label)
+ const { data: squadIssues } = await github.rest.issues.listForRepo({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ labels: 'squad',
+ state: 'open',
+ per_page: 20
+ });
+
+ const memberLabels = members.map(m => m.label);
+ const untriaged = squadIssues.filter(issue => {
+ const issueLabels = issue.labels.map(l => l.name);
+ return !memberLabels.some(ml => issueLabels.includes(ml));
+ });
+
+ // 2. Find assigned but unstarted issues (has squad:{member} label, no assignee)
+ const unstarted = [];
+ for (const member of members) {
+ try {
+ const { data: memberIssues } = await github.rest.issues.listForRepo({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ labels: member.label,
+ state: 'open',
+ per_page: 10
+ });
+ for (const issue of memberIssues) {
+ if (!issue.assignees || issue.assignees.length === 0) {
+ unstarted.push({ issue, member });
+ }
+ }
+ } catch (e) {
+ // Label may not exist yet
+ }
+ }
+
+ // 3. Find squad issues missing triage verdict (no go:* label)
+ const missingVerdict = squadIssues.filter(issue => {
+ const labels = issue.labels.map(l => l.name);
+ return !labels.some(l => l.startsWith('go:'));
+ });
+
+ // 4. Find go:yes issues missing release target
+ const goYesIssues = squadIssues.filter(issue => {
+ const labels = issue.labels.map(l => l.name);
+ return labels.includes('go:yes') && !labels.some(l => l.startsWith('release:'));
+ });
+
+ // 4b. Find issues missing type: label
+ const missingType = squadIssues.filter(issue => {
+ const labels = issue.labels.map(l => l.name);
+ return !labels.some(l => l.startsWith('type:'));
+ });
+
+ // 5. Find open PRs that need attention
+ const { data: openPRs } = await github.rest.pulls.list({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ state: 'open',
+ per_page: 20
+ });
+
+ const squadPRs = openPRs.filter(pr =>
+ pr.labels.some(l => l.name.startsWith('squad'))
+ );
+
+ // Build status summary
+ const summary = [];
+ if (untriaged.length > 0) {
+ summary.push(`π΄ **${untriaged.length} untriaged issue(s)** need triage`);
+ }
+ if (unstarted.length > 0) {
+ summary.push(`π‘ **${unstarted.length} assigned issue(s)** have no assignee`);
+ }
+ if (missingVerdict.length > 0) {
+ summary.push(`βͺ **${missingVerdict.length} issue(s)** missing triage verdict (no \`go:\` label)`);
+ }
+ if (goYesIssues.length > 0) {
+ summary.push(`βͺ **${goYesIssues.length} approved issue(s)** missing release target (no \`release:\` label)`);
+ }
+ if (missingType.length > 0) {
+ summary.push(`βͺ **${missingType.length} issue(s)** missing \`type:\` label`);
+ }
+ if (squadPRs.length > 0) {
+ const drafts = squadPRs.filter(pr => pr.draft).length;
+ const ready = squadPRs.length - drafts;
+ if (drafts > 0) summary.push(`π‘ **${drafts} draft PR(s)** in progress`);
+ if (ready > 0) summary.push(`π’ **${ready} PR(s)** open for review/merge`);
+ }
+
+ if (summary.length === 0) {
+ core.info('π Board is clear β Ralph found no pending work');
+ return;
+ }
+
+ core.info(`π Ralph found work:\n${summary.join('\n')}`);
+
+ // Auto-triage untriaged issues
+ for (const issue of untriaged) {
+ const issueText = `${issue.title}\n${issue.body || ''}`.toLowerCase();
+ let assignedMember = null;
+ let reason = '';
+
+ // Simple keyword-based routing
+ for (const member of members) {
+ const role = member.role.toLowerCase();
+ if ((role.includes('frontend') || role.includes('ui')) &&
+ (issueText.includes('ui') || issueText.includes('frontend') ||
+ issueText.includes('css') || issueText.includes('component'))) {
+ assignedMember = member;
+ reason = 'Matches frontend/UI domain';
+ break;
+ }
+ if ((role.includes('backend') || role.includes('api') || role.includes('server')) &&
+ (issueText.includes('api') || issueText.includes('backend') ||
+ issueText.includes('database') || issueText.includes('endpoint'))) {
+ assignedMember = member;
+ reason = 'Matches backend/API domain';
+ break;
+ }
+ if ((role.includes('test') || role.includes('qa')) &&
+ (issueText.includes('test') || issueText.includes('bug') ||
+ issueText.includes('fix') || issueText.includes('regression'))) {
+ assignedMember = member;
+ reason = 'Matches testing/QA domain';
+ break;
+ }
+ }
+
+ // Default to Lead
+ if (!assignedMember) {
+ const lead = members.find(m =>
+ m.role.toLowerCase().includes('lead') ||
+ m.role.toLowerCase().includes('architect')
+ );
+ if (lead) {
+ assignedMember = lead;
+ reason = 'No domain match β routed to Lead';
+ }
+ }
+
+ if (assignedMember) {
+ // Add member label
+ await github.rest.issues.addLabels({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: issue.number,
+ labels: [assignedMember.label]
+ });
+
+ // Post triage comment
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: issue.number,
+ body: [
+ `### π Ralph β Auto-Triage`,
+ '',
+ `**Assigned to:** ${assignedMember.name} (${assignedMember.role})`,
+ `**Reason:** ${reason}`,
+ '',
+ `> Ralph auto-triaged this issue via the squad heartbeat. To reassign, swap the \`squad:*\` label.`
+ ].join('\n')
+ });
+
+ core.info(`Auto-triaged #${issue.number} β ${assignedMember.name}`);
+ }
+ }
+
+ # Copilot auto-assign step (uses PAT if available)
+ - name: Ralph β Assign @copilot issues
+ if: success()
+ uses: actions/github-script@v7
+ with:
+ github-token: ${{ secrets.COPILOT_ASSIGN_TOKEN || secrets.GITHUB_TOKEN }}
+ script: |
+ const fs = require('fs');
+
+ let teamFile = '.squad/team.md';
+ if (!fs.existsSync(teamFile)) {
+ teamFile = '.ai-team/team.md';
+ }
+ if (!fs.existsSync(teamFile)) return;
+
+ const content = fs.readFileSync(teamFile, 'utf8');
+
+ // Check if @copilot is on the team with auto-assign
+ const hasCopilot = content.includes('π€ Coding Agent') || content.includes('@copilot');
+ const autoAssign = content.includes('');
+ if (!hasCopilot || !autoAssign) return;
+
+ // Find issues labeled squad:copilot with no assignee
+ try {
+ const { data: copilotIssues } = await github.rest.issues.listForRepo({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ labels: 'squad:copilot',
+ state: 'open',
+ per_page: 5
+ });
+
+ const unassigned = copilotIssues.filter(i =>
+ !i.assignees || i.assignees.length === 0
+ );
+
+ if (unassigned.length === 0) {
+ core.info('No unassigned squad:copilot issues');
+ return;
+ }
+
+ // Get repo default branch
+ const { data: repoData } = await github.rest.repos.get({
+ owner: context.repo.owner,
+ repo: context.repo.repo
+ });
+
+ for (const issue of unassigned) {
+ try {
+ await github.request('POST /repos/{owner}/{repo}/issues/{issue_number}/assignees', {
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: issue.number,
+ assignees: ['copilot-swe-agent[bot]'],
+ agent_assignment: {
+ target_repo: `${context.repo.owner}/${context.repo.repo}`,
+ base_branch: repoData.default_branch,
+ custom_instructions: `Read .squad/team.md (or .ai-team/team.md) for team context and .squad/routing.md (or .ai-team/routing.md) for routing rules.`
+ }
+ });
+ core.info(`Assigned copilot-swe-agent[bot] to #${issue.number}`);
+ } catch (e) {
+ core.warning(`Failed to assign @copilot to #${issue.number}: ${e.message}`);
+ }
+ }
+ } catch (e) {
+ core.info(`No squad:copilot label found or error: ${e.message}`);
+ }
diff --git a/.ai-team-templates/workflows/squad-insider-release.yml b/.ai-team-templates/workflows/squad-insider-release.yml
new file mode 100644
index 000000000..a3124d194
--- /dev/null
+++ b/.ai-team-templates/workflows/squad-insider-release.yml
@@ -0,0 +1,61 @@
+name: Squad Insider Release
+
+on:
+ push:
+ branches: [insider]
+
+permissions:
+ contents: write
+
+jobs:
+ release:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+
+ - name: Run tests
+ run: node --test test/*.test.js
+
+ - name: Read version from package.json
+ id: version
+ run: |
+ VERSION=$(node -e "console.log(require('./package.json').version)")
+ SHORT_SHA=$(git rev-parse --short HEAD)
+ INSIDER_VERSION="${VERSION}-insider+${SHORT_SHA}"
+ INSIDER_TAG="v${INSIDER_VERSION}"
+ echo "version=$VERSION" >> "$GITHUB_OUTPUT"
+ echo "short_sha=$SHORT_SHA" >> "$GITHUB_OUTPUT"
+ echo "insider_version=$INSIDER_VERSION" >> "$GITHUB_OUTPUT"
+ echo "insider_tag=$INSIDER_TAG" >> "$GITHUB_OUTPUT"
+ echo "π¦ Base Version: $VERSION (Short SHA: $SHORT_SHA)"
+ echo "π·οΈ Insider Version: $INSIDER_VERSION"
+ echo "π Insider Tag: $INSIDER_TAG"
+
+ - name: Create git tag
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+ git tag -a "${{ steps.version.outputs.insider_tag }}" -m "Insider Release ${{ steps.version.outputs.insider_tag }}"
+ git push origin "${{ steps.version.outputs.insider_tag }}"
+
+ - name: Create GitHub Release
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ gh release create "${{ steps.version.outputs.insider_tag }}" \
+ --title "${{ steps.version.outputs.insider_tag }}" \
+ --notes "This is an insider/development build of Squad. Install with:\`\`\`bash\nnpx github:bradygaster/squad#${{ steps.version.outputs.insider_tag }}\n\`\`\`\n\n**Note:** Insider builds may be unstable and are intended for early adopters and testing only." \
+ --prerelease
+
+ - name: Verify release
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ gh release view "${{ steps.version.outputs.insider_tag }}"
+ echo "β
Insider Release ${{ steps.version.outputs.insider_tag }} created and verified."
diff --git a/.ai-team-templates/workflows/squad-issue-assign.yml b/.ai-team-templates/workflows/squad-issue-assign.yml
new file mode 100644
index 000000000..ad140f42d
--- /dev/null
+++ b/.ai-team-templates/workflows/squad-issue-assign.yml
@@ -0,0 +1,161 @@
+name: Squad Issue Assign
+
+on:
+ issues:
+ types: [labeled]
+
+permissions:
+ issues: write
+ contents: read
+
+jobs:
+ assign-work:
+ # Only trigger on squad:{member} labels (not the base "squad" label)
+ if: startsWith(github.event.label.name, 'squad:')
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Identify assigned member and trigger work
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const fs = require('fs');
+ const issue = context.payload.issue;
+ const label = context.payload.label.name;
+
+ // Extract member name from label (e.g., "squad:ripley" β "ripley")
+ const memberName = label.replace('squad:', '').toLowerCase();
+
+ // Read team roster β check .squad/ first, fall back to .ai-team/
+ let teamFile = '.squad/team.md';
+ if (!fs.existsSync(teamFile)) {
+ teamFile = '.ai-team/team.md';
+ }
+ if (!fs.existsSync(teamFile)) {
+ core.warning('No .squad/team.md or .ai-team/team.md found β cannot assign work');
+ return;
+ }
+
+ const content = fs.readFileSync(teamFile, 'utf8');
+ const lines = content.split('\n');
+
+ // Check if this is a coding agent assignment
+ const isCopilotAssignment = memberName === 'copilot';
+
+ let assignedMember = null;
+ if (isCopilotAssignment) {
+ assignedMember = { name: '@copilot', role: 'Coding Agent' };
+ } else {
+ let inMembersTable = false;
+ for (const line of lines) {
+ if (line.match(/^##\s+(Members|Team Roster)/i)) {
+ inMembersTable = true;
+ continue;
+ }
+ if (inMembersTable && line.startsWith('## ')) {
+ break;
+ }
+ if (inMembersTable && line.startsWith('|') && !line.includes('---') && !line.includes('Name')) {
+ const cells = line.split('|').map(c => c.trim()).filter(Boolean);
+ if (cells.length >= 2 && cells[0].toLowerCase() === memberName) {
+ assignedMember = { name: cells[0], role: cells[1] };
+ break;
+ }
+ }
+ }
+ }
+
+ if (!assignedMember) {
+ core.warning(`No member found matching label "${label}"`);
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: issue.number,
+ body: `β οΈ No squad member found matching label \`${label}\`. Check \`.squad/team.md\` (or \`.ai-team/team.md\`) for valid member names.`
+ });
+ return;
+ }
+
+ // Post assignment acknowledgment
+ let comment;
+ if (isCopilotAssignment) {
+ comment = [
+ `### π€ Routed to @copilot (Coding Agent)`,
+ '',
+ `**Issue:** #${issue.number} β ${issue.title}`,
+ '',
+ `@copilot has been assigned and will pick this up automatically.`,
+ '',
+ `> The coding agent will create a \`copilot/*\` branch and open a draft PR.`,
+ `> Review the PR as you would any team member's work.`,
+ ].join('\n');
+ } else {
+ comment = [
+ `### π Assigned to ${assignedMember.name} (${assignedMember.role})`,
+ '',
+ `**Issue:** #${issue.number} β ${issue.title}`,
+ '',
+ `${assignedMember.name} will pick this up in the next Copilot session.`,
+ '',
+ `> **For Copilot coding agent:** If enabled, this issue will be worked automatically.`,
+ `> Otherwise, start a Copilot session and say:`,
+ `> \`${assignedMember.name}, work on issue #${issue.number}\``,
+ ].join('\n');
+ }
+
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: issue.number,
+ body: comment
+ });
+
+ core.info(`Issue #${issue.number} assigned to ${assignedMember.name} (${assignedMember.role})`);
+
+ # Separate step: assign @copilot using PAT (required for coding agent)
+ - name: Assign @copilot coding agent
+ if: github.event.label.name == 'squad:copilot'
+ uses: actions/github-script@v7
+ with:
+ github-token: ${{ secrets.COPILOT_ASSIGN_TOKEN }}
+ script: |
+ const owner = context.repo.owner;
+ const repo = context.repo.repo;
+ const issue_number = context.payload.issue.number;
+
+ // Get the default branch name (main, master, etc.)
+ const { data: repoData } = await github.rest.repos.get({ owner, repo });
+ const baseBranch = repoData.default_branch;
+
+ try {
+ await github.request('POST /repos/{owner}/{repo}/issues/{issue_number}/assignees', {
+ owner,
+ repo,
+ issue_number,
+ assignees: ['copilot-swe-agent[bot]'],
+ agent_assignment: {
+ target_repo: `${owner}/${repo}`,
+ base_branch: baseBranch,
+ custom_instructions: '',
+ custom_agent: '',
+ model: ''
+ },
+ headers: {
+ 'X-GitHub-Api-Version': '2022-11-28'
+ }
+ });
+ core.info(`Assigned copilot-swe-agent to issue #${issue_number} (base: ${baseBranch})`);
+ } catch (err) {
+ core.warning(`Assignment with agent_assignment failed: ${err.message}`);
+ // Fallback: try without agent_assignment
+ try {
+ await github.rest.issues.addAssignees({
+ owner, repo, issue_number,
+ assignees: ['copilot-swe-agent']
+ });
+ core.info(`Fallback assigned copilot-swe-agent to issue #${issue_number}`);
+ } catch (err2) {
+ core.warning(`Fallback also failed: ${err2.message}`);
+ }
+ }
diff --git a/.ai-team-templates/workflows/squad-label-enforce.yml b/.ai-team-templates/workflows/squad-label-enforce.yml
new file mode 100644
index 000000000..633d220df
--- /dev/null
+++ b/.ai-team-templates/workflows/squad-label-enforce.yml
@@ -0,0 +1,181 @@
+name: Squad Label Enforce
+
+on:
+ issues:
+ types: [labeled]
+
+permissions:
+ issues: write
+ contents: read
+
+jobs:
+ enforce:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Enforce mutual exclusivity
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const issue = context.payload.issue;
+ const appliedLabel = context.payload.label.name;
+
+ // Namespaces with mutual exclusivity rules
+ const EXCLUSIVE_PREFIXES = ['go:', 'release:', 'type:', 'priority:'];
+
+ // Skip if not a managed namespace label
+ if (!EXCLUSIVE_PREFIXES.some(p => appliedLabel.startsWith(p))) {
+ core.info(`Label ${appliedLabel} is not in a managed namespace β skipping`);
+ return;
+ }
+
+ const allLabels = issue.labels.map(l => l.name);
+
+ // Handle go: namespace (mutual exclusivity)
+ if (appliedLabel.startsWith('go:')) {
+ const otherGoLabels = allLabels.filter(l =>
+ l.startsWith('go:') && l !== appliedLabel
+ );
+
+ if (otherGoLabels.length > 0) {
+ // Remove conflicting go: labels
+ for (const label of otherGoLabels) {
+ await github.rest.issues.removeLabel({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: issue.number,
+ name: label
+ });
+ core.info(`Removed conflicting label: ${label}`);
+ }
+
+ // Post update comment
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: issue.number,
+ body: `π·οΈ Triage verdict updated β \`${appliedLabel}\``
+ });
+ }
+
+ // Auto-apply release:backlog if go:yes and no release target
+ if (appliedLabel === 'go:yes') {
+ const hasReleaseLabel = allLabels.some(l => l.startsWith('release:'));
+ if (!hasReleaseLabel) {
+ await github.rest.issues.addLabels({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: issue.number,
+ labels: ['release:backlog']
+ });
+
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: issue.number,
+ body: `π Marked as \`release:backlog\` β assign a release target when ready.`
+ });
+
+ core.info('Applied release:backlog for go:yes issue');
+ }
+ }
+
+ // Remove release: labels if go:no
+ if (appliedLabel === 'go:no') {
+ const releaseLabels = allLabels.filter(l => l.startsWith('release:'));
+ if (releaseLabels.length > 0) {
+ for (const label of releaseLabels) {
+ await github.rest.issues.removeLabel({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: issue.number,
+ name: label
+ });
+ core.info(`Removed release label from go:no issue: ${label}`);
+ }
+ }
+ }
+ }
+
+ // Handle release: namespace (mutual exclusivity)
+ if (appliedLabel.startsWith('release:')) {
+ const otherReleaseLabels = allLabels.filter(l =>
+ l.startsWith('release:') && l !== appliedLabel
+ );
+
+ if (otherReleaseLabels.length > 0) {
+ // Remove conflicting release: labels
+ for (const label of otherReleaseLabels) {
+ await github.rest.issues.removeLabel({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: issue.number,
+ name: label
+ });
+ core.info(`Removed conflicting label: ${label}`);
+ }
+
+ // Post update comment
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: issue.number,
+ body: `π·οΈ Release target updated β \`${appliedLabel}\``
+ });
+ }
+ }
+
+ // Handle type: namespace (mutual exclusivity)
+ if (appliedLabel.startsWith('type:')) {
+ const otherTypeLabels = allLabels.filter(l =>
+ l.startsWith('type:') && l !== appliedLabel
+ );
+
+ if (otherTypeLabels.length > 0) {
+ for (const label of otherTypeLabels) {
+ await github.rest.issues.removeLabel({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: issue.number,
+ name: label
+ });
+ core.info(`Removed conflicting label: ${label}`);
+ }
+
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: issue.number,
+ body: `π·οΈ Issue type updated β \`${appliedLabel}\``
+ });
+ }
+ }
+
+ // Handle priority: namespace (mutual exclusivity)
+ if (appliedLabel.startsWith('priority:')) {
+ const otherPriorityLabels = allLabels.filter(l =>
+ l.startsWith('priority:') && l !== appliedLabel
+ );
+
+ if (otherPriorityLabels.length > 0) {
+ for (const label of otherPriorityLabels) {
+ await github.rest.issues.removeLabel({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: issue.number,
+ name: label
+ });
+ core.info(`Removed conflicting label: ${label}`);
+ }
+
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: issue.number,
+ body: `π·οΈ Priority updated β \`${appliedLabel}\``
+ });
+ }
+ }
+
+ core.info(`Label enforcement complete for ${appliedLabel}`);
diff --git a/.ai-team-templates/workflows/squad-main-guard.yml b/.ai-team-templates/workflows/squad-main-guard.yml
new file mode 100644
index 000000000..7ea0dbe17
--- /dev/null
+++ b/.ai-team-templates/workflows/squad-main-guard.yml
@@ -0,0 +1,129 @@
+name: Squad Protected Branch Guard
+
+on:
+ pull_request:
+ branches: [main, preview, insider]
+ types: [opened, synchronize, reopened]
+ push:
+ branches: [main, preview, insider]
+
+permissions:
+ contents: read
+ pull-requests: read
+
+jobs:
+ guard:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Check for forbidden paths
+ uses: actions/github-script@v7
+ with:
+ script: |
+ // Fetch all files changed - handles both PR and push events
+ let files = [];
+
+ if (context.eventName === 'pull_request') {
+ // PR event: use pulls.listFiles API
+ let page = 1;
+ while (true) {
+ const resp = await github.rest.pulls.listFiles({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ pull_number: context.payload.pull_request.number,
+ per_page: 100,
+ page
+ });
+ files.push(...resp.data);
+ if (resp.data.length < 100) break;
+ page++;
+ }
+ } else if (context.eventName === 'push') {
+ // Push event: compare against base branch
+ const base = context.payload.before;
+ const head = context.payload.after;
+
+ // If this is not a force push and base exists, compare commits
+ if (base && base !== '0000000000000000000000000000000000000000') {
+ const comparison = await github.rest.repos.compareCommits({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ base,
+ head
+ });
+ files = comparison.data.files || [];
+ } else {
+ // Force push or initial commit: list all files in the current tree
+ core.info('Force push detected or initial commit, checking tree state');
+ const { data: tree } = await github.rest.git.getTree({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ tree_sha: head,
+ recursive: 'true'
+ });
+ files = tree.tree
+ .filter(item => item.type === 'blob')
+ .map(item => ({ filename: item.path, status: 'added' }));
+ }
+ }
+
+ // Check each file against forbidden path rules
+ // Allow removals ΞΓΓΆ deleting forbidden files from protected branches is fine
+ const forbidden = files
+ .filter(f => f.status !== 'removed')
+ .map(f => f.filename)
+ .filter(f => {
+ // .ai-team/** and .squad/** ΞΓΓΆ ALL team state files, zero exceptions
+ if (f === '.ai-team' || f.startsWith('.ai-team/') || f === '.squad' || f.startsWith('.squad/')) return true;
+ // .ai-team-templates/** ΞΓΓΆ Squad's own templates, stay on dev
+ if (f === '.ai-team-templates' || f.startsWith('.ai-team-templates/')) return true;
+ // team-docs/** ΞΓΓΆ ALL internal team docs, zero exceptions
+ if (f.startsWith('team-docs/')) return true;
+ // docs/proposals/** ΞΓΓΆ internal design proposals, stay on dev
+ if (f.startsWith('docs/proposals/')) return true;
+ return false;
+ });
+
+ if (forbidden.length === 0) {
+ core.info('Ξ£à No forbidden paths found in PR ΞΓΓΆ all clear.');
+ return;
+ }
+
+ // Build a clear, actionable error message
+ const lines = [
+ '## β‘ΖΓΒ½ Forbidden files detected in PR to main',
+ '',
+ 'The following files must NOT be merged into `main`.',
+ '`.ai-team/` and `.squad/` are runtime team state ΞΓΓΆ they belong on dev branches only.',
+ '`.ai-team-templates/` is Squad\'s internal planning ΞΓΓΆ it belongs on dev branches only.',
+ '`team-docs/` is internal team content ΞΓΓΆ it belongs on dev branches only.',
+ '`docs/proposals/` is internal design proposals ΞΓΓΆ it belongs on dev branches only.',
+ '',
+ '### Forbidden files found:',
+ '',
+ ...forbidden.map(f => `- \`${f}\``),
+ '',
+ '### How to fix:',
+ '',
+ '```bash',
+ '# Remove tracked .ai-team/ files (keeps local copies):',
+ 'git rm --cached -r .ai-team/',
+ '',
+ '# Remove tracked .squad/ files (keeps local copies):',
+ 'git rm --cached -r .squad/',
+ '',
+ '# Remove tracked team-docs/ files:',
+ 'git rm --cached -r team-docs/',
+ '',
+ '# Commit the removal and push:',
+ 'git commit -m "chore: remove forbidden paths from PR"',
+ 'git push',
+ '```',
+ '',
+ '> ΞΓΓ‘β©βΓ
`.ai-team/` and `.squad/` are committed on `dev` and feature branches by design.',
+ '> The guard workflow is the enforcement mechanism that keeps these files off `main` and `preview`.',
+ '> `git rm --cached` untracks them from this PR without deleting your local copies.',
+ ];
+
+ core.setFailed(lines.join('\n'));
diff --git a/.ai-team-templates/workflows/squad-preview.yml b/.ai-team-templates/workflows/squad-preview.yml
new file mode 100644
index 000000000..9298c364e
--- /dev/null
+++ b/.ai-team-templates/workflows/squad-preview.yml
@@ -0,0 +1,55 @@
+name: Squad Preview Validation
+
+on:
+ push:
+ branches: [preview]
+
+permissions:
+ contents: read
+
+jobs:
+ validate:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+
+ - name: Validate version consistency
+ run: |
+ VERSION=$(node -e "console.log(require('./package.json').version)")
+ if ! grep -q "## \[$VERSION\]" CHANGELOG.md 2>/dev/null; then
+ echo "::error::Version $VERSION not found in CHANGELOG.md β update CHANGELOG.md before release"
+ exit 1
+ fi
+ echo "β
Version $VERSION validated in CHANGELOG.md"
+
+ - name: Run tests
+ run: node --test test/*.test.js
+
+ - name: Check no .ai-team/ or .squad/ files are tracked
+ run: |
+ FOUND_FORBIDDEN=0
+ if git ls-files --error-unmatch .ai-team/ 2>/dev/null; then
+ echo "::error::β .ai-team/ files are tracked on preview β this must not ship."
+ FOUND_FORBIDDEN=1
+ fi
+ if git ls-files --error-unmatch .squad/ 2>/dev/null; then
+ echo "::error::β .squad/ files are tracked on preview β this must not ship."
+ FOUND_FORBIDDEN=1
+ fi
+ if [ $FOUND_FORBIDDEN -eq 1 ]; then
+ exit 1
+ fi
+ echo "β
No .ai-team/ or .squad/ files tracked β clean for release."
+
+ - name: Validate package.json version
+ run: |
+ VERSION=$(node -e "console.log(require('./package.json').version)")
+ if [ -z "$VERSION" ]; then
+ echo "::error::β No version field found in package.json."
+ exit 1
+ fi
+ echo "β
package.json version: $VERSION"
diff --git a/.ai-team-templates/workflows/squad-promote.yml b/.ai-team-templates/workflows/squad-promote.yml
new file mode 100644
index 000000000..07bac3261
--- /dev/null
+++ b/.ai-team-templates/workflows/squad-promote.yml
@@ -0,0 +1,121 @@
+name: Squad Promote
+
+on:
+ workflow_dispatch:
+ inputs:
+ dry_run:
+ description: 'Dry run β show what would happen without pushing'
+ required: false
+ default: 'false'
+ type: choice
+ options: ['false', 'true']
+
+permissions:
+ contents: write
+
+jobs:
+ dev-to-preview:
+ name: Promote dev β preview
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Configure git
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+
+ - name: Fetch all branches
+ run: git fetch --all
+
+ - name: Show current state (dry run info)
+ run: |
+ echo "=== dev HEAD ===" && git log origin/dev -1 --oneline
+ echo "=== preview HEAD ===" && git log origin/preview -1 --oneline
+ echo "=== Files that would be stripped ==="
+ git diff origin/preview..origin/dev --name-only | grep -E "^(\.(ai-team|squad|ai-team-templates|squad-templates)|team-docs/|docs/proposals/)" || echo "(none)"
+
+ - name: Merge dev β preview (strip forbidden paths)
+ if: ${{ inputs.dry_run == 'false' }}
+ run: |
+ git checkout preview
+ git merge origin/dev --no-commit --no-ff -X theirs || true
+
+ # Strip forbidden paths from merge commit
+ git rm -rf --cached --ignore-unmatch \
+ .ai-team/ \
+ .squad/ \
+ .ai-team-templates/ \
+ .squad-templates/ \
+ team-docs/ \
+ "docs/proposals/" || true
+
+ # Commit if there are staged changes
+ if ! git diff --cached --quiet; then
+ git commit -m "chore: promote dev β preview (v$(node -e "console.log(require('./package.json').version)"))"
+ git push origin preview
+ echo "β
Pushed preview branch"
+ else
+ echo "βΉοΈ Nothing to commit β preview is already up to date"
+ fi
+
+ - name: Dry run complete
+ if: ${{ inputs.dry_run == 'true' }}
+ run: echo "π Dry run complete β no changes pushed."
+
+ preview-to-main:
+ name: Promote preview β main (release)
+ needs: dev-to-preview
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Configure git
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+
+ - name: Fetch all branches
+ run: git fetch --all
+
+ - name: Show current state
+ run: |
+ echo "=== preview HEAD ===" && git log origin/preview -1 --oneline
+ echo "=== main HEAD ===" && git log origin/main -1 --oneline
+ echo "=== Version ===" && node -e "console.log('v' + require('./package.json').version)"
+
+ - name: Validate preview is release-ready
+ run: |
+ git checkout preview
+ VERSION=$(node -e "console.log(require('./package.json').version)")
+ if ! grep -q "## \[$VERSION\]" CHANGELOG.md 2>/dev/null; then
+ echo "::error::Version $VERSION not found in CHANGELOG.md β update before releasing"
+ exit 1
+ fi
+ echo "β
Version $VERSION has CHANGELOG entry"
+
+ # Verify no forbidden files on preview
+ FORBIDDEN=$(git ls-files | grep -E "^(\.(ai-team|squad|ai-team-templates|squad-templates)/|team-docs/|docs/proposals/)" || true)
+ if [ -n "$FORBIDDEN" ]; then
+ echo "::error::Forbidden files found on preview: $FORBIDDEN"
+ exit 1
+ fi
+ echo "β
No forbidden files on preview"
+
+ - name: Merge preview β main
+ if: ${{ inputs.dry_run == 'false' }}
+ run: |
+ git checkout main
+ git merge origin/preview --no-ff -m "chore: promote preview β main (v$(node -e "console.log(require('./package.json').version)"))"
+ git push origin main
+ echo "β
Pushed main β squad-release.yml will tag and publish the release"
+
+ - name: Dry run complete
+ if: ${{ inputs.dry_run == 'true' }}
+ run: echo "π Dry run complete β no changes pushed."
diff --git a/.ai-team-templates/workflows/squad-release.yml b/.ai-team-templates/workflows/squad-release.yml
new file mode 100644
index 000000000..bbd5de793
--- /dev/null
+++ b/.ai-team-templates/workflows/squad-release.yml
@@ -0,0 +1,77 @@
+name: Squad Release
+
+on:
+ push:
+ branches: [main]
+
+permissions:
+ contents: write
+
+jobs:
+ release:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+
+ - name: Run tests
+ run: node --test test/*.test.js
+
+ - name: Validate version consistency
+ run: |
+ VERSION=$(node -e "console.log(require('./package.json').version)")
+ if ! grep -q "## \[$VERSION\]" CHANGELOG.md 2>/dev/null; then
+ echo "::error::Version $VERSION not found in CHANGELOG.md β update CHANGELOG.md before release"
+ exit 1
+ fi
+ echo "β
Version $VERSION validated in CHANGELOG.md"
+
+ - name: Read version from package.json
+ id: version
+ run: |
+ VERSION=$(node -e "console.log(require('./package.json').version)")
+ echo "version=$VERSION" >> "$GITHUB_OUTPUT"
+ echo "tag=v$VERSION" >> "$GITHUB_OUTPUT"
+ echo "π¦ Version: $VERSION (tag: v$VERSION)"
+
+ - name: Check if tag already exists
+ id: check_tag
+ run: |
+ if git rev-parse "refs/tags/${{ steps.version.outputs.tag }}" >/dev/null 2>&1; then
+ echo "exists=true" >> "$GITHUB_OUTPUT"
+ echo "βοΈ Tag ${{ steps.version.outputs.tag }} already exists β skipping release."
+ else
+ echo "exists=false" >> "$GITHUB_OUTPUT"
+ echo "π Tag ${{ steps.version.outputs.tag }} does not exist β creating release."
+ fi
+
+ - name: Create git tag
+ if: steps.check_tag.outputs.exists == 'false'
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+ git tag -a "${{ steps.version.outputs.tag }}" -m "Release ${{ steps.version.outputs.tag }}"
+ git push origin "${{ steps.version.outputs.tag }}"
+
+ - name: Create GitHub Release
+ if: steps.check_tag.outputs.exists == 'false'
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ gh release create "${{ steps.version.outputs.tag }}" \
+ --title "${{ steps.version.outputs.tag }}" \
+ --generate-notes \
+ --latest
+
+ - name: Verify release
+ if: steps.check_tag.outputs.exists == 'false'
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ gh release view "${{ steps.version.outputs.tag }}"
+ echo "β
Release ${{ steps.version.outputs.tag }} created and verified."
diff --git a/.ai-team-templates/workflows/squad-triage.yml b/.ai-team-templates/workflows/squad-triage.yml
new file mode 100644
index 000000000..a58be9b29
--- /dev/null
+++ b/.ai-team-templates/workflows/squad-triage.yml
@@ -0,0 +1,260 @@
+name: Squad Triage
+
+on:
+ issues:
+ types: [labeled]
+
+permissions:
+ issues: write
+ contents: read
+
+jobs:
+ triage:
+ if: github.event.label.name == 'squad'
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Triage issue via Lead agent
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const fs = require('fs');
+ const issue = context.payload.issue;
+
+ // Read team roster β check .squad/ first, fall back to .ai-team/
+ let teamFile = '.squad/team.md';
+ if (!fs.existsSync(teamFile)) {
+ teamFile = '.ai-team/team.md';
+ }
+ if (!fs.existsSync(teamFile)) {
+ core.warning('No .squad/team.md or .ai-team/team.md found β cannot triage');
+ return;
+ }
+
+ const content = fs.readFileSync(teamFile, 'utf8');
+ const lines = content.split('\n');
+
+ // Check if @copilot is on the team
+ const hasCopilot = content.includes('π€ Coding Agent');
+ const copilotAutoAssign = content.includes('');
+
+ // Parse @copilot capability profile
+ let goodFitKeywords = [];
+ let needsReviewKeywords = [];
+ let notSuitableKeywords = [];
+
+ if (hasCopilot) {
+ // Extract capability tiers from team.md
+ const goodFitMatch = content.match(/π’\s*Good fit[^:]*:\s*(.+)/i);
+ const needsReviewMatch = content.match(/π‘\s*Needs review[^:]*:\s*(.+)/i);
+ const notSuitableMatch = content.match(/π΄\s*Not suitable[^:]*:\s*(.+)/i);
+
+ if (goodFitMatch) {
+ goodFitKeywords = goodFitMatch[1].toLowerCase().split(',').map(s => s.trim());
+ } else {
+ goodFitKeywords = ['bug fix', 'test coverage', 'lint', 'format', 'dependency update', 'small feature', 'scaffolding', 'doc fix', 'documentation'];
+ }
+ if (needsReviewMatch) {
+ needsReviewKeywords = needsReviewMatch[1].toLowerCase().split(',').map(s => s.trim());
+ } else {
+ needsReviewKeywords = ['medium feature', 'refactoring', 'api endpoint', 'migration'];
+ }
+ if (notSuitableMatch) {
+ notSuitableKeywords = notSuitableMatch[1].toLowerCase().split(',').map(s => s.trim());
+ } else {
+ notSuitableKeywords = ['architecture', 'system design', 'security', 'auth', 'encryption', 'performance'];
+ }
+ }
+
+ const members = [];
+ let inMembersTable = false;
+ for (const line of lines) {
+ if (line.match(/^##\s+(Members|Team Roster)/i)) {
+ inMembersTable = true;
+ continue;
+ }
+ if (inMembersTable && line.startsWith('## ')) {
+ break;
+ }
+ if (inMembersTable && line.startsWith('|') && !line.includes('---') && !line.includes('Name')) {
+ const cells = line.split('|').map(c => c.trim()).filter(Boolean);
+ if (cells.length >= 2 && cells[0] !== 'Scribe') {
+ members.push({
+ name: cells[0],
+ role: cells[1]
+ });
+ }
+ }
+ }
+
+ // Read routing rules β check .squad/ first, fall back to .ai-team/
+ let routingFile = '.squad/routing.md';
+ if (!fs.existsSync(routingFile)) {
+ routingFile = '.ai-team/routing.md';
+ }
+ let routingContent = '';
+ if (fs.existsSync(routingFile)) {
+ routingContent = fs.readFileSync(routingFile, 'utf8');
+ }
+
+ // Find the Lead
+ const lead = members.find(m =>
+ m.role.toLowerCase().includes('lead') ||
+ m.role.toLowerCase().includes('architect') ||
+ m.role.toLowerCase().includes('coordinator')
+ );
+
+ if (!lead) {
+ core.warning('No Lead role found in team roster β cannot triage');
+ return;
+ }
+
+ // Build triage context
+ const memberList = members.map(m =>
+ `- **${m.name}** (${m.role}) β label: \`squad:${m.name.toLowerCase()}\``
+ ).join('\n');
+
+ // Determine best assignee based on issue content and routing
+ const issueText = `${issue.title}\n${issue.body || ''}`.toLowerCase();
+
+ let assignedMember = null;
+ let triageReason = '';
+ let copilotTier = null;
+
+ // First, evaluate @copilot fit if enabled
+ if (hasCopilot) {
+ const isNotSuitable = notSuitableKeywords.some(kw => issueText.includes(kw));
+ const isGoodFit = !isNotSuitable && goodFitKeywords.some(kw => issueText.includes(kw));
+ const isNeedsReview = !isNotSuitable && !isGoodFit && needsReviewKeywords.some(kw => issueText.includes(kw));
+
+ if (isGoodFit) {
+ copilotTier = 'good-fit';
+ assignedMember = { name: '@copilot', role: 'Coding Agent' };
+ triageReason = 'π’ Good fit for @copilot β matches capability profile';
+ } else if (isNeedsReview) {
+ copilotTier = 'needs-review';
+ assignedMember = { name: '@copilot', role: 'Coding Agent' };
+ triageReason = 'π‘ Routing to @copilot (needs review) β a squad member should review the PR';
+ } else if (isNotSuitable) {
+ copilotTier = 'not-suitable';
+ // Fall through to normal routing
+ }
+ }
+
+ // If not routed to @copilot, use keyword-based routing
+ if (!assignedMember) {
+ for (const member of members) {
+ const role = member.role.toLowerCase();
+ if ((role.includes('frontend') || role.includes('ui')) &&
+ (issueText.includes('ui') || issueText.includes('frontend') ||
+ issueText.includes('css') || issueText.includes('component') ||
+ issueText.includes('button') || issueText.includes('page') ||
+ issueText.includes('layout') || issueText.includes('design'))) {
+ assignedMember = member;
+ triageReason = 'Issue relates to frontend/UI work';
+ break;
+ }
+ if ((role.includes('backend') || role.includes('api') || role.includes('server')) &&
+ (issueText.includes('api') || issueText.includes('backend') ||
+ issueText.includes('database') || issueText.includes('endpoint') ||
+ issueText.includes('server') || issueText.includes('auth'))) {
+ assignedMember = member;
+ triageReason = 'Issue relates to backend/API work';
+ break;
+ }
+ if ((role.includes('test') || role.includes('qa') || role.includes('quality')) &&
+ (issueText.includes('test') || issueText.includes('bug') ||
+ issueText.includes('fix') || issueText.includes('regression') ||
+ issueText.includes('coverage'))) {
+ assignedMember = member;
+ triageReason = 'Issue relates to testing/quality work';
+ break;
+ }
+ if ((role.includes('devops') || role.includes('infra') || role.includes('ops')) &&
+ (issueText.includes('deploy') || issueText.includes('ci') ||
+ issueText.includes('pipeline') || issueText.includes('docker') ||
+ issueText.includes('infrastructure'))) {
+ assignedMember = member;
+ triageReason = 'Issue relates to DevOps/infrastructure work';
+ break;
+ }
+ }
+ }
+
+ // Default to Lead if no routing match
+ if (!assignedMember) {
+ assignedMember = lead;
+ triageReason = 'No specific domain match β assigned to Lead for further analysis';
+ }
+
+ const isCopilot = assignedMember.name === '@copilot';
+ const assignLabel = isCopilot ? 'squad:copilot' : `squad:${assignedMember.name.toLowerCase()}`;
+
+ // Add the member-specific label
+ await github.rest.issues.addLabels({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: issue.number,
+ labels: [assignLabel]
+ });
+
+ // Apply default triage verdict
+ await github.rest.issues.addLabels({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: issue.number,
+ labels: ['go:needs-research']
+ });
+
+ // Auto-assign @copilot if enabled
+ if (isCopilot && copilotAutoAssign) {
+ try {
+ await github.rest.issues.addAssignees({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: issue.number,
+ assignees: ['copilot']
+ });
+ } catch (err) {
+ core.warning(`Could not auto-assign @copilot: ${err.message}`);
+ }
+ }
+
+ // Build copilot evaluation note
+ let copilotNote = '';
+ if (hasCopilot && !isCopilot) {
+ if (copilotTier === 'not-suitable') {
+ copilotNote = `\n\n**@copilot evaluation:** π΄ Not suitable β issue involves work outside the coding agent's capability profile.`;
+ } else {
+ copilotNote = `\n\n**@copilot evaluation:** No strong capability match β routed to squad member.`;
+ }
+ }
+
+ // Post triage comment
+ const comment = [
+ `### ποΈ Squad Triage β ${lead.name} (${lead.role})`,
+ '',
+ `**Issue:** #${issue.number} β ${issue.title}`,
+ `**Assigned to:** ${assignedMember.name} (${assignedMember.role})`,
+ `**Reason:** ${triageReason}`,
+ copilotTier === 'needs-review' ? `\nβ οΈ **PR review recommended** β a squad member should review @copilot's work on this one.` : '',
+ copilotNote,
+ '',
+ `---`,
+ '',
+ `**Team roster:**`,
+ memberList,
+ hasCopilot ? `- **@copilot** (Coding Agent) β label: \`squad:copilot\`` : '',
+ '',
+ `> To reassign, remove the current \`squad:*\` label and add the correct one.`,
+ ].filter(Boolean).join('\n');
+
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: issue.number,
+ body: comment
+ });
+
+ core.info(`Triaged issue #${issue.number} β ${assignedMember.name} (${assignLabel})`);
diff --git a/.ai-team-templates/workflows/sync-squad-labels.yml b/.ai-team-templates/workflows/sync-squad-labels.yml
new file mode 100644
index 000000000..fbcfd9cc2
--- /dev/null
+++ b/.ai-team-templates/workflows/sync-squad-labels.yml
@@ -0,0 +1,169 @@
+name: Sync Squad Labels
+
+on:
+ push:
+ paths:
+ - '.squad/team.md'
+ - '.ai-team/team.md'
+ workflow_dispatch:
+
+permissions:
+ issues: write
+ contents: read
+
+jobs:
+ sync-labels:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Parse roster and sync labels
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const fs = require('fs');
+ let teamFile = '.squad/team.md';
+ if (!fs.existsSync(teamFile)) {
+ teamFile = '.ai-team/team.md';
+ }
+
+ if (!fs.existsSync(teamFile)) {
+ core.info('No .squad/team.md or .ai-team/team.md found β skipping label sync');
+ return;
+ }
+
+ const content = fs.readFileSync(teamFile, 'utf8');
+ const lines = content.split('\n');
+
+ // Parse the Members table for agent names
+ const members = [];
+ let inMembersTable = false;
+ for (const line of lines) {
+ if (line.match(/^##\s+(Members|Team Roster)/i)) {
+ inMembersTable = true;
+ continue;
+ }
+ if (inMembersTable && line.startsWith('## ')) {
+ break;
+ }
+ if (inMembersTable && line.startsWith('|') && !line.includes('---') && !line.includes('Name')) {
+ const cells = line.split('|').map(c => c.trim()).filter(Boolean);
+ if (cells.length >= 2 && cells[0] !== 'Scribe') {
+ members.push({
+ name: cells[0],
+ role: cells[1]
+ });
+ }
+ }
+ }
+
+ core.info(`Found ${members.length} squad members: ${members.map(m => m.name).join(', ')}`);
+
+ // Check if @copilot is on the team
+ const hasCopilot = content.includes('π€ Coding Agent');
+
+ // Define label color palette for squad labels
+ const SQUAD_COLOR = '9B8FCC';
+ const MEMBER_COLOR = '9B8FCC';
+ const COPILOT_COLOR = '10b981';
+
+ // Define go: and release: labels (static)
+ const GO_LABELS = [
+ { name: 'go:yes', color: '0E8A16', description: 'Ready to implement' },
+ { name: 'go:no', color: 'B60205', description: 'Not pursuing' },
+ { name: 'go:needs-research', color: 'FBCA04', description: 'Needs investigation' }
+ ];
+
+ const RELEASE_LABELS = [
+ { name: 'release:v0.4.0', color: '6B8EB5', description: 'Targeted for v0.4.0' },
+ { name: 'release:v0.5.0', color: '6B8EB5', description: 'Targeted for v0.5.0' },
+ { name: 'release:v0.6.0', color: '8B7DB5', description: 'Targeted for v0.6.0' },
+ { name: 'release:v1.0.0', color: '8B7DB5', description: 'Targeted for v1.0.0' },
+ { name: 'release:backlog', color: 'D4E5F7', description: 'Not yet targeted' }
+ ];
+
+ const TYPE_LABELS = [
+ { name: 'type:feature', color: 'DDD1F2', description: 'New capability' },
+ { name: 'type:bug', color: 'FF0422', description: 'Something broken' },
+ { name: 'type:spike', color: 'F2DDD4', description: 'Research/investigation β produces a plan, not code' },
+ { name: 'type:docs', color: 'D4E5F7', description: 'Documentation work' },
+ { name: 'type:chore', color: 'D4E5F7', description: 'Maintenance, refactoring, cleanup' },
+ { name: 'type:epic', color: 'CC4455', description: 'Parent issue that decomposes into sub-issues' }
+ ];
+
+ // High-signal labels β these MUST visually dominate all others
+ const SIGNAL_LABELS = [
+ { name: 'bug', color: 'FF0422', description: 'Something isn\'t working' },
+ { name: 'feedback', color: '00E5FF', description: 'User feedback β high signal, needs attention' }
+ ];
+
+ const PRIORITY_LABELS = [
+ { name: 'priority:p0', color: 'B60205', description: 'Blocking release' },
+ { name: 'priority:p1', color: 'D93F0B', description: 'This sprint' },
+ { name: 'priority:p2', color: 'FBCA04', description: 'Next sprint' }
+ ];
+
+ // Ensure the base "squad" triage label exists
+ const labels = [
+ { name: 'squad', color: SQUAD_COLOR, description: 'Squad triage inbox β Lead will assign to a member' }
+ ];
+
+ for (const member of members) {
+ labels.push({
+ name: `squad:${member.name.toLowerCase()}`,
+ color: MEMBER_COLOR,
+ description: `Assigned to ${member.name} (${member.role})`
+ });
+ }
+
+ // Add @copilot label if coding agent is on the team
+ if (hasCopilot) {
+ labels.push({
+ name: 'squad:copilot',
+ color: COPILOT_COLOR,
+ description: 'Assigned to @copilot (Coding Agent) for autonomous work'
+ });
+ }
+
+ // Add go:, release:, type:, priority:, and high-signal labels
+ labels.push(...GO_LABELS);
+ labels.push(...RELEASE_LABELS);
+ labels.push(...TYPE_LABELS);
+ labels.push(...PRIORITY_LABELS);
+ labels.push(...SIGNAL_LABELS);
+
+ // Sync labels (create or update)
+ for (const label of labels) {
+ try {
+ await github.rest.issues.getLabel({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ name: label.name
+ });
+ // Label exists β update it
+ await github.rest.issues.updateLabel({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ name: label.name,
+ color: label.color,
+ description: label.description
+ });
+ core.info(`Updated label: ${label.name}`);
+ } catch (err) {
+ if (err.status === 404) {
+ // Label doesn't exist β create it
+ await github.rest.issues.createLabel({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ name: label.name,
+ color: label.color,
+ description: label.description
+ });
+ core.info(`Created label: ${label.name}`);
+ } else {
+ throw err;
+ }
+ }
+ }
+
+ core.info(`Label sync complete: ${labels.length} labels synced`);
diff --git a/.ai-team/agents/beast/history.md b/.ai-team/agents/beast/history.md
index c7e07b68d..2cd9919f4 100644
--- a/.ai-team/agents/beast/history.md
+++ b/.ai-team/agents/beast/history.md
@@ -1,60 +1,20 @@
# Project Context
-- **Owner:** Jeffrey T. Fritz (csharpfritz@users.noreply.github.com)
+- **Owner:** Jeffrey T. Fritz
- **Project:** BlazorWebFormsComponents β Blazor components emulating ASP.NET Web Forms controls for migration
- **Stack:** C#, Blazor, .NET, ASP.NET Web Forms, bUnit, xUnit, MkDocs, Playwright
- **Created:** 2026-02-10
## Learnings
-
-
-- **Doc structure pattern:** Each component doc follows a consistent structure: title β intro paragraph with MS docs link β Features Supported β Features NOT Supported β Web Forms Declarative Syntax β Blazor Razor Syntax (with examples) β HTML Output β Migration Notes (Before/After) β Examples β See Also. Admonitions (`!!! note`, `!!! warning`, `!!! tip`) are used for gotchas and important notes.
-- **mkdocs.yml nav is alphabetical:** Components are listed alphabetically within their category sections (Editor Controls, Data Controls, Validation Controls, Navigation Controls, Login Controls, Utility Features).
-- **Calendar doc already existed:** The Calendar component doc was already present at `docs/EditorControls/Calendar.md` and in the mkdocs nav β likely created alongside the component PR. No changes needed.
-- **PageService doc existed on PR branch but not on dev:** The basepage services branch (`copilot/create-basepage-for-services`) already had a comprehensive `docs/UtilityFeatures/PageService.md`. I created a fresh version on dev that matches the project doc conventions.
-- **ImageMap is in Navigation Controls, not Editor Controls:** Despite being image-related, ImageMap is categorized under Navigation Controls in the mkdocs nav, alongside HyperLink, Menu, SiteMapPath, and TreeView.
-- **Style migration pattern:** Web Forms used `TableItemStyle` child elements (e.g., `