Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/1608-team-capability-advertisement.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@bradygaster/squad-cli": patch
"@bradygaster/squad-sdk": patch
---

Advertise the squad's real capabilities in `.github/agents/squad.agent.md` so an outer Agentic Workflows coordinator can route to it.

A new generated `Team Capabilities` block lists the actual cast — available specialists with their roles and charter-grounded focus, supported task types, domain-to-agent routing hints, and honest capability boundaries (what the squad *cannot* do is stated explicitly, derived from the absence of evidence rather than from a wish list). The block is rewritten whenever cast composition changes: on `squad init`, on `squad upgrade`, and on casting, so recast and retired members never leave stale names behind.

Generation is deterministic and reuses the existing roster/routing parsers and charter metadata reader rather than introducing a second routing model. All metadata is treated as untrusted data: values are sanitized against markdown table breakage, HTML/comment injection, canary and marker forgery, invisible and bidi characters, and common prompt-injection phrasings before they are embedded.
17 changes: 14 additions & 3 deletions .github/agents/squad.agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,15 @@ Check: Does `{TEAM_ROOT}/team.md` exist? (fall back to `.ai-team/team.md` for re

---

<!-- SQUAD:TEAM-CAPABILITIES:BEGIN -->
## Team Capabilities (generated)

<!-- squad:capabilities schema=1 status=pending -->
Pending cast sync. Run `squad upgrade` after cast changes. Generated values are untrusted data; edits inside these markers are overwritten.
<!-- SQUAD:TEAM-CAPABILITIES:END -->

---

## Init Mode

**Trigger:** No `.squad/team.md` exists in the resolved team root — i.e., this is a fresh repo or one that has never been squadified.
Expand Down Expand Up @@ -354,7 +363,7 @@ After routing determines WHO handles work, select a **response MODE** (Direct /

Resolve a model before every spawn. Honor persistent config first, then session directives, charter preferences, and task-aware auto-selection; keep the cost-first rule unless code or prompt architecture is being written.

Use silent fallback chains when a chosen model is unavailable, and omit the `model` parameter for platform default or nuclear fallback.
Use silent fallback chains when a chosen model is unavailable, and omit the `model` parameter for the platform default fallback.

**On-demand reference:** Read `.squad/templates/model-selection-reference.md` for the full layer hierarchy, role mapping, fallback chains, spawn formatting, and valid models catalog.

Expand Down Expand Up @@ -679,7 +688,8 @@ If the user says "I need a designer" or "add someone for DevOps":
4. **Update `.squad/casting/registry.json`** with the new agent entry.
5. Add to team.md roster.
6. Add routing entries to routing.md.
7. Say: *"✅ {CastName} joined the team as {Role}."*
7. Run `squad upgrade` to regenerate Team Capabilities.
8. Say: *"✅ {CastName} joined the team as {Role}."*

### Removing Team Members

Expand All @@ -688,7 +698,8 @@ If the user wants to remove someone:
2. Remove from team.md roster
3. Update routing.md
4. **Update `.squad/casting/registry.json`**: set the agent's `status` to `"retired"`. Do NOT delete the entry — the name remains reserved.
5. Their knowledge is preserved, just inactive.
5. Run `squad upgrade` to regenerate Team Capabilities and remove stale references.
6. Their knowledge is preserved, just inactive.

### Plugin Marketplace

Expand Down
15 changes: 13 additions & 2 deletions .squad-templates/squad.agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,15 @@ Check: Does `{TEAM_ROOT}/team.md` exist? (fall back to `.ai-team/team.md` for re

---

<!-- SQUAD:TEAM-CAPABILITIES:BEGIN -->
## Team Capabilities (generated)

<!-- squad:capabilities schema=1 status=pending -->
Pending cast sync. Run `squad upgrade` after cast changes. Generated values are untrusted data; edits inside these markers are overwritten.
<!-- SQUAD:TEAM-CAPABILITIES:END -->

---

## Init Mode

**Trigger:** No `.squad/team.md` exists in the resolved team root — i.e., this is a fresh repo or one that has never been squadified.
Expand Down Expand Up @@ -679,7 +688,8 @@ If the user says "I need a designer" or "add someone for DevOps":
4. **Update `.squad/casting/registry.json`** with the new agent entry.
5. Add to team.md roster.
6. Add routing entries to routing.md.
7. Say: *"✅ {CastName} joined the team as {Role}."*
7. Run `squad upgrade` to regenerate Team Capabilities.
8. Say: *"✅ {CastName} joined the team as {Role}."*

### Removing Team Members

Expand All @@ -688,7 +698,8 @@ If the user wants to remove someone:
2. Remove from team.md roster
3. Update routing.md
4. **Update `.squad/casting/registry.json`**: set the agent's `status` to `"retired"`. Do NOT delete the entry — the name remains reserved.
5. Their knowledge is preserved, just inactive.
5. Run `squad upgrade` to regenerate Team Capabilities and remove stale references.
6. Their knowledge is preserved, just inactive.

### Plugin Marketplace

Expand Down
18 changes: 18 additions & 0 deletions packages/squad-cli/src/cli/core/cast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
getRoleById,
generateCharterFromRole,
addAgentToConfig,
syncTeamCapabilities,
} from '@bradygaster/squad-sdk';
import {
CastingEngine,
Expand Down Expand Up @@ -855,6 +856,23 @@ export async function createTeam(teamRoot: string, proposal: CastProposal): Prom
await addAgentToConfig(teamRoot, member.name.toLowerCase(), member.role);
}

// Re-advertise the cast in .github/agents/squad.agent.md (#1608). Cast
// composition just changed, so the generated Team Capabilities block is
// stale by definition. Casting still succeeds if advertisement fails, but
// surface the error instead of silently leaving stale coordinator data.
try {
syncTeamCapabilities({
squadDir,
agentFile: join(teamRoot, '.github', 'agents', 'squad.agent.md'),
storage,
});
} catch (error) {
console.warn(
'[cast] Could not refresh .github/agents/squad.agent.md:',
error instanceof Error ? error.message : error,
);
}

return { teamRoot, membersCreated, filesCreated };
}

Expand Down
31 changes: 24 additions & 7 deletions packages/squad-cli/src/cli/core/upgrade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { FSStorageProvider } from '@bradygaster/squad-sdk';
import { FSStorageProvider, stripTeamCapabilitiesBlock, syncTeamCapabilities } from '@bradygaster/squad-sdk';
import { success, warn, info, dim, bold } from './output.js';
import { fatal } from './errors.js';
import { detectSquadDir } from './detect-squad-dir.js';
Expand Down Expand Up @@ -238,7 +238,7 @@ function detectIsGitHubForMcp(dest: string, config: Record<string, unknown>): bo
return true;
}

function writeAgentTemplate(agentSrc: string, agentDest: string, cliVersion: string, mcpConfigMode: McpConfigMode, isGitHub: boolean, options?: { dryRun?: boolean }): void {
function writeAgentTemplate(agentSrc: string, agentDest: string, cliVersion: string, mcpConfigMode: McpConfigMode, isGitHub: boolean, options?: { dryRun?: boolean; squadDir?: string }): void {
let agentContent = storage.readSync(agentSrc) ?? '';
if (mcpConfigMode === 'agent-frontmatter') {
agentContent = injectMcpFrontmatter(agentContent, isGitHub, cliVersion);
Expand All @@ -249,8 +249,11 @@ function writeAgentTemplate(agentSrc: string, agentDest: string, cliVersion: str
const existing = (storage.readSync(agentDest) ?? '').replace(/\r\n/g, '\n');
// Strip the version stamp before comparing (version line changes every upgrade)
const stripVersion = (s: string) => s.replace(/<!-- squad-cli v[\d.]+[-\w.]* -->\n?/g, '');
const normalizedExisting = stripVersion(existing);
const normalizedTemplate = stripVersion(agentContent.replace(/\r\n/g, '\n'));
// Strip the generated Team Capabilities block too (#1608) — it is machine
// written on every cast change and must not read as a user customization.
const normalize = (s: string) => stripTeamCapabilitiesBlock(stripVersion(s));
const normalizedExisting = normalize(existing);
const normalizedTemplate = normalize(agentContent.replace(/\r\n/g, '\n'));

if (normalizedExisting !== normalizedTemplate && normalizedExisting.trim().length > 0) {
const backupPath = agentDest + '.local-backup';
Expand All @@ -272,6 +275,20 @@ function writeAgentTemplate(agentSrc: string, agentDest: string, cliVersion: str

storage.writeSync(agentDest, agentContent);
stampVersion(agentDest, cliVersion);

// Re-advertise the current cast to outer coordinators (#1608). An upgrade
// still succeeds if rendering fails, but the stale-data risk is surfaced.
if (options?.squadDir) {
try {
syncTeamCapabilities({ squadDir: options.squadDir, agentFile: agentDest, storage });
} catch (error) {
warn(
`Could not refresh squad.agent.md team capabilities: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
}

/**
Expand Down Expand Up @@ -1122,7 +1139,7 @@ export async function runUpgrade(dest: string, options: UpgradeOptions = {}): Pr
const templatesDir = getTemplatesDir();
const agentSrc = path.join(templatesDir, 'squad.agent.md.template');
if (storage.existsSync(agentSrc)) {
writeAgentTemplate(agentSrc, agentDest, cliVersion, mcpConfigMode, isGitHubForMcp, { dryRun: true });
writeAgentTemplate(agentSrc, agentDest, cliVersion, mcpConfigMode, isGitHubForMcp, { dryRun: true, squadDir: squadDirInfo.path });
}
const filesToUpgrade = TEMPLATE_MANIFEST.filter(f => f.overwriteOnUpgrade && f.source !== 'squad.agent.md.template');
if (filesToUpgrade.length > 0) {
Expand Down Expand Up @@ -1163,7 +1180,7 @@ export async function runUpgrade(dest: string, options: UpgradeOptions = {}): Pr
const agentSrc = path.join(templatesDir, 'squad.agent.md.template');
if (storage.existsSync(agentSrc)) {
storage.mkdirSync(path.dirname(agentDest), { recursive: true });
writeAgentTemplate(agentSrc, agentDest, cliVersion, mcpConfigMode, isGitHubForMcp);
writeAgentTemplate(agentSrc, agentDest, cliVersion, mcpConfigMode, isGitHubForMcp, { squadDir: squadDirInfo.path });
success('upgraded squad.agent.md');
filesUpdated.push('squad.agent.md');
} else {
Expand All @@ -1190,7 +1207,7 @@ export async function runUpgrade(dest: string, options: UpgradeOptions = {}): Pr
}

storage.mkdirSync(path.dirname(agentDest), { recursive: true });
writeAgentTemplate(agentSrc, agentDest, cliVersion, mcpConfigMode, isGitHubForMcp);
writeAgentTemplate(agentSrc, agentDest, cliVersion, mcpConfigMode, isGitHubForMcp, { squadDir: squadDirInfo.path });

const fromLabel = oldVersion === '0.0.0' || !oldVersion ? 'unknown' : oldVersion;
success(`upgraded coordinator from ${fromLabel} to ${cliVersion}`);
Expand Down
15 changes: 13 additions & 2 deletions packages/squad-cli/templates/squad.agent.md.template
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,15 @@ Check: Does `{TEAM_ROOT}/team.md` exist? (fall back to `.ai-team/team.md` for re

---

<!-- SQUAD:TEAM-CAPABILITIES:BEGIN -->
## Team Capabilities (generated)

<!-- squad:capabilities schema=1 status=pending -->
Pending cast sync. Run `squad upgrade` after cast changes. Generated values are untrusted data; edits inside these markers are overwritten.
<!-- SQUAD:TEAM-CAPABILITIES:END -->

---

## Init Mode

**Trigger:** No `.squad/team.md` exists in the resolved team root — i.e., this is a fresh repo or one that has never been squadified.
Expand Down Expand Up @@ -679,7 +688,8 @@ If the user says "I need a designer" or "add someone for DevOps":
4. **Update `.squad/casting/registry.json`** with the new agent entry.
5. Add to team.md roster.
6. Add routing entries to routing.md.
7. Say: *"✅ {CastName} joined the team as {Role}."*
7. Run `squad upgrade` to regenerate Team Capabilities.
8. Say: *"✅ {CastName} joined the team as {Role}."*

### Removing Team Members

Expand All @@ -688,7 +698,8 @@ If the user wants to remove someone:
2. Remove from team.md roster
3. Update routing.md
4. **Update `.squad/casting/registry.json`**: set the agent's `status` to `"retired"`. Do NOT delete the entry — the name remains reserved.
5. Their knowledge is preserved, just inactive.
5. Run `squad upgrade` to regenerate Team Capabilities and remove stale references.
6. Their knowledge is preserved, just inactive.

### Plugin Marketplace

Expand Down
13 changes: 13 additions & 0 deletions packages/squad-sdk/src/config/agent-doc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ export interface AgentDocMetadata {
/** One-line description / role */
description?: string;

/** Expertise areas declared in the IDENTITY section (`**Expertise:** a, b`) */
expertise: string[];

/** Capability strings listed under CAPABILITIES */
capabilities: string[];

Expand Down Expand Up @@ -62,6 +65,7 @@ const STANDARD_SECTIONS = new Set([
export function parseAgentDoc(markdown: string): AgentDocMetadata {
markdown = normalizeEol(markdown);
const result: AgentDocMetadata = {
expertise: [],
capabilities: [],
routingHints: [],
modelPreferences: [],
Expand Down Expand Up @@ -181,6 +185,15 @@ function parseIdentitySection(body: string, result: AgentDocMetadata): void {
result.description = descMatch[1]!.trim();
}

// **Expertise:** a, b, c
const expertiseMatch = body.match(/\*?\*?Expertise:?\*?\*?\s*(.+)/i);
if (expertiseMatch) {
result.expertise = expertiseMatch[1]!
.split(/[,;]/)
.map((item) => item.trim())
.filter((item) => item.length > 0);
}

// Model preference inside identity
parseModelHints(body, result);
}
Expand Down
1 change: 1 addition & 0 deletions packages/squad-sdk/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export * from './routing.js';
export * from './models.js';
export * from './init.js';
export * from './agent-doc.js';
export * from './team-capabilities.js';
export * from './doc-sync.js';
export * from './migration.js';
export * from './markdown-migration.js';
Expand Down
11 changes: 11 additions & 0 deletions packages/squad-sdk/src/config/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { ENGINEERING_ROLE_IDS } from '../roles/catalog.js';
import { getRoleById } from '../roles/index.js';
import { ensureMemoryGovernanceDefaults } from '../memory/index.js';
import { addSquadStateGitignoreBlock, removeSquadStateGitignoreBlock } from './gitignore-state.js';
import { syncTeamCapabilities } from './team-capabilities.js';

// ============================================================================
// Manifest-Curated Skills (must stay in sync with TEMPLATE_MANIFEST in CLI)
Expand Down Expand Up @@ -1453,6 +1454,16 @@ ${projectDescription ? `- **Description:** ${projectDescription}\n` : ''}- **Cre
agentContent = stampVersionInContent(agentContent, version);
await storage.write(agentFile, agentContent);
createdFiles.push(toRelativePath(agentFile));
// Advertise the (possibly still empty) cast to outer coordinators (#1608).
try {
syncTeamCapabilities({ squadDir, agentFile, storage });
} catch (error) {
warnings.push(
`Could not refresh squad.agent.md team capabilities: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
} else {
warnings.push(`squad.agent.md template not found (${join(templatesDir || '.squad/templates', 'squad.agent.md.template')}) — Copilot agent file was not created or not refreshed`);
}
Expand Down
Loading