diff --git a/.changeset/cross-squad-orchestration.md b/.changeset/cross-squad-orchestration.md new file mode 100644 index 000000000..c22bde31a --- /dev/null +++ b/.changeset/cross-squad-orchestration.md @@ -0,0 +1,6 @@ +--- +"@bradygaster/squad-sdk": minor +"@bradygaster/squad-cli": minor +--- + +Cross-squad orchestration — discovery, delegation, and manifest (#316). Adds manifest schema, `squad discover` / `squad delegate` commands, and runtime module for coordinating work across multiple Squad instances. diff --git a/.squad/manifest.json b/.squad/manifest.json new file mode 100644 index 000000000..c193b494f --- /dev/null +++ b/.squad/manifest.json @@ -0,0 +1,12 @@ +{ + "name": "squad", + "version": "1.0.0", + "description": "Squad — Programmable multi-agent runtime for GitHub Copilot", + "capabilities": ["multi-agent", "copilot", "sdk", "cli"], + "contact": { + "repo": "bradygaster/squad", + "labels": ["squad"] + }, + "accepts": ["issues", "prs"], + "skills": ["squad-conventions", "cli-wiring", "agent-collaboration"] +} diff --git a/.squad/manifest.schema.json b/.squad/manifest.schema.json new file mode 100644 index 000000000..f9603d756 --- /dev/null +++ b/.squad/manifest.schema.json @@ -0,0 +1,61 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Squad Manifest", + "description": "Public contract a squad exposes for cross-squad discovery and delegation.", + "type": "object", + "required": ["name", "capabilities", "contact", "accepts"], + "properties": { + "name": { + "type": "string", + "description": "Human-readable squad name (e.g., 'platform-squad').", + "minLength": 1 + }, + "version": { + "type": "string", + "description": "Schema version for forward compatibility.", + "default": "1.0.0" + }, + "description": { + "type": "string", + "description": "One-line description of this squad's purpose." + }, + "capabilities": { + "type": "array", + "description": "Capability tags (e.g., ['kubernetes', 'helm', 'monitoring']).", + "items": { "type": "string" }, + "minItems": 1 + }, + "contact": { + "type": "object", + "description": "How to reach this squad.", + "required": ["repo"], + "properties": { + "repo": { + "type": "string", + "description": "GitHub repository in 'owner/repo' format.", + "pattern": "^[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+$" + }, + "labels": { + "type": "array", + "description": "Labels to apply when creating issues for this squad.", + "items": { "type": "string" } + } + } + }, + "accepts": { + "type": "array", + "description": "Work types this squad accepts from other squads.", + "items": { + "type": "string", + "enum": ["issues", "prs"] + }, + "minItems": 1 + }, + "skills": { + "type": "array", + "description": "Named skills this squad offers.", + "items": { "type": "string" } + } + }, + "additionalProperties": false +} diff --git a/.squad/skills/cross-squad/SKILL.md b/.squad/skills/cross-squad/SKILL.md new file mode 100644 index 000000000..1d4e3a251 --- /dev/null +++ b/.squad/skills/cross-squad/SKILL.md @@ -0,0 +1,114 @@ +--- +name: "cross-squad" +description: "Coordinating work across multiple Squad instances" +domain: "orchestration" +confidence: "medium" +source: "manual" +tools: + - name: "squad-discover" + description: "List known squads and their capabilities" + when: "When you need to find which squad can handle a task" + - name: "squad-delegate" + description: "Create work in another squad's repository" + when: "When a task belongs to another squad's domain" +--- + +## Context +When an organization runs multiple Squad instances (e.g., platform-squad, frontend-squad, data-squad), those squads need to discover each other, share context, and hand off work across repository boundaries. This skill teaches agents how to coordinate across squads without creating tight coupling. + +Cross-squad orchestration applies when: +- A task requires capabilities owned by another squad +- An architectural decision affects multiple squads +- A feature spans multiple repositories with different squads +- A squad needs to request infrastructure, tooling, or support from another squad + +## Patterns + +### Discovery via Manifest +Each squad publishes a `.squad/manifest.json` declaring its name, capabilities, and contact information. Squads discover each other through: +1. **Well-known paths**: Check `.squad/manifest.json` in known org repos +2. **Upstream config**: Squads already listed in `.squad/upstream.json` are checked for manifests +3. **Explicit registry**: A central `squad-registry.json` can list all squads in an org + +```json +{ + "name": "platform-squad", + "version": "1.0.0", + "description": "Platform infrastructure team", + "capabilities": ["kubernetes", "helm", "monitoring", "ci-cd"], + "contact": { + "repo": "org/platform", + "labels": ["squad:platform"] + }, + "accepts": ["issues", "prs"], + "skills": ["helm-developer", "operator-developer", "pipeline-engineer"] +} +``` + +### Context Sharing +When delegating work, share only what the target squad needs: +- **Capability list**: What this squad can do (from manifest) +- **Relevant decisions**: Only decisions that affect the target squad +- **Handoff context**: A concise description of why this work is being delegated + +Do NOT share: +- Internal team state (casting history, session logs) +- Full decision archives (send only relevant excerpts) +- Authentication credentials or secrets + +### Work Handoff Protocol +1. **Check manifest**: Verify the target squad accepts the work type (issues, PRs) +2. **Create issue**: Use `gh issue create` in the target repo with: + - Title: `[cross-squad] ` + - Label: `squad:cross-squad` (or the squad's configured label) + - Body: Context, acceptance criteria, and link back to originating issue +3. **Track**: Record the cross-squad issue URL in the originating squad's orchestration log +4. **Poll**: Periodically check if the delegated issue is closed/completed + +### Feedback Loop +Track delegated work completion: +- Poll target issue status via `gh issue view` +- Update originating issue with status changes +- Close the feedback loop when delegated work merges + +## Examples + +### Discovering squads +```bash +# List all squads discoverable from upstreams and known repos +squad discover + +# Output: +# platform-squad → org/platform (kubernetes, helm, monitoring) +# frontend-squad → org/frontend (react, nextjs, storybook) +# data-squad → org/data (spark, airflow, dbt) +``` + +### Delegating work +```bash +# Delegate a task to the platform squad +squad delegate platform-squad "Add Prometheus metrics endpoint for the auth service" + +# Creates issue in org/platform with cross-squad label and context +``` + +### Manifest in squad.config.ts +```typescript +export default defineSquad({ + manifest: { + name: 'platform-squad', + capabilities: ['kubernetes', 'helm'], + contact: { repo: 'org/platform', labels: ['squad:platform'] }, + accepts: ['issues', 'prs'], + skills: ['helm-developer', 'operator-developer'], + }, +}); +``` + +## Anti-Patterns +- **Direct file writes across repos** — Never modify another squad's `.squad/` directory. Use issues and PRs as the communication protocol. +- **Tight coupling** — Don't depend on another squad's internal structure. Use the manifest as the public API contract. +- **Unbounded delegation** — Always include acceptance criteria and a timeout. Don't create open-ended requests. +- **Skipping discovery** — Don't hardcode squad locations. Use manifests and the discovery protocol. +- **Sharing secrets** — Never include credentials, tokens, or internal URLs in cross-squad issues. +- **Circular delegation** — Track delegation chains. If squad A delegates to B which delegates back to A, something is wrong. diff --git a/docs/blog/2026-03-17-cross-squad-orchestration.md b/docs/blog/2026-03-17-cross-squad-orchestration.md new file mode 100644 index 000000000..ea25d2165 --- /dev/null +++ b/docs/blog/2026-03-17-cross-squad-orchestration.md @@ -0,0 +1,266 @@ +--- +title: "Cross-Squad Orchestration: Work as a Network" +date: 2026-03-17 +author: "Squad (Copilot)" +wave: null +tags: [squad, orchestration, discovery, delegation, mesh] +status: published +hero: "Squads discover each other's capabilities and delegate work directly. No email chains, no context-switching—just structured, traceable collaboration." +--- + +# Cross-Squad Orchestration: Work as a Network + +> _Discover what other squads can do, delegate work to the right team, and build a connected mesh of specialized squads._ + +## The Problem + +In large organizations, work routing is chaotic: + +- A feature squad has a deployment problem → email to the infra squad +- Infra squad is overloaded → 3-day turnaround +- QA squad has capacity but nobody knows → work stalls + +Alternatively, squads work in isolation: + +- Each team maintains their own skills and patterns +- No visibility into what other squads know +- Repeated effort across teams + +Existing solutions (Jira hierarchies, manual registries, email lists) don't scale and create friction. + +Cross-squad orchestration solves this by making squads **discoverable and delegatable**. + +## How It Works + +### Publish Capabilities + +Each squad publishes `.squad/manifest.json`: + +```json +{ + "name": "infra-squad", + "capabilities": ["kubernetes", "terraform", "ci-cd"], + "accepts": ["issues"], + "contact": { + "labels": ["squad:infra", "priority:system"] + } +} +``` + +### Discover Squads + +```bash +squad discover +``` + +Your squad reads upstream manifests and builds a local index of known squads. + +### Delegate Work + +```bash +squad delegate infra-squad "Set up autoscaling for staging cluster" +``` + +An issue is created in infra-squad's repo with: +- Your squad context +- Proper labels for routing +- Link back to original request + +## Real-World Example + +### A Day in the Network + +**Morning**: Frontend squad needs a new API endpoint +```bash +squad delegate backend-squad "Create /api/v2/user-preferences endpoint" +``` +- Issue appears in backend-squad's backlog labeled `squad:frontend` + `api:user-preferences` +- Backend squad sees the full request and can estimate + +**Mid-morning**: Frontend squad discovers performance issues +```bash +squad delegate devops-squad "Profile and optimize React bundle size" +``` +- DevOps squad uses their profiling tools +- Returns findings back via PR comment + +**Afternoon**: Platform squad needs database migration +```bash +squad delegate dba-squad --file migration-plan.md +``` +- Plan is reviewed and approved +- DBA squad runs it during maintenance window + +**Next day**: DevOps squad learns an optimization technique +```bash +squad upstream propose platform-squad --skills +``` +- Shares the learning back upstream +- Frontend squad auto-syncs the skill + +## Configuration + +### Squad Manifest + +Place at `.squad/manifest.json`: + +```json +{ + "name": "my-squad", + "repo": "github.com/org/my-squad", + "description": "Handles user-facing API and client SDKs", + "capabilities": [ + "rest-api", + "graphql", + "sdk-generation", + "authentication" + ], + "accepts": [ + "issues", + "pull-requests" + ], + "contact": { + "team": "Backend Platform", + "labels": [ + "squad:backend", + "epic:api", + "priority:high" + ] + } +} +``` + +### Register Upstreams + +Squad discovery reads `.squad/upstream.json`: + +```json +{ + "upstreams": [ + { + "name": "platform-squad", + "source": "https://github.com/org/platform-squad", + "ref": "main" + } + ] +} +``` + +## Delegation Process + +When you delegate: + +1. Squad resolves the target squad name from discovery index +2. Creates a GitHub issue in the target repo +3. Applies labels from target's manifest +4. Includes cross-squad metadata (source, context) +5. Posts a comment in your squad linking to the created issue + +Example issue created in infra-squad: + +``` +Title: [DELEGATED] Create staging autoscaling policy + +From: feature-squad (#42 in original repo) +--- + +Please help us set up autoscaling for our staging cluster. +We're hitting ~80% CPU during peak load and need to scale to handle 2x traffic. + +Acceptance Criteria: +- Autoscaling configured for staging-app deployment +- Min replicas: 2, max: 10 +- Target: 70% CPU, 80% memory +- Verify with load test + +Labels: squad:feature, epic:performance, priority:high +``` + +## Use Cases + +### Matrix Organization + +``` +Platform Squad +├── Backend Squad (delegates infra work) +├── Frontend Squad (delegates performance work) +└── QA Squad (delegates automation work) +``` + +Each squad specializes. Work flows naturally to the right team. + +### Multi-Org Networks + +``` +Org A (Product) +├── Feature Squad A +└── Feature Squad B + +Org B (Platform) +├── Infra Squad +└── Data Squad +``` + +Cross-org squads delegate via upstreams. Org B squads handle infrastructure for Org A. + +### Shared Services + +``` +Platform/ +├── Auth Squad (auth policies, tokens, SSO) +├── Database Squad (migrations, optimization, backups) +└── Observability Squad (logging, metrics, tracing) +``` + +Any squad discovering Platform can delegate auth, database, or observability work. + +## Best Practices + +### Capability Naming + +Use specific, searchable terms: + +```json +{ + "capabilities": [ + "kubernetes-eks", + "terraform-aws", + "helm-charts", + "ci-github-actions" + ] +} +``` + +Not: `["devops"]` + +### Contact Labels + +Make it easy for squads to find your backlog: + +```json +{ + "contact": { + "labels": [ + "squad:platform", + "area:infrastructure", + "severity:normal" + ] + } +} +``` + +### Documentation + +Link to squad docs in the description: + +```json +{ + "description": "Kubernetes clusters, Terraform infra, CI/CD. See https://wiki.org/platform-squad for runbooks and policies." +} +``` + +## See Also + +- [Upstream Auto-Sync](/features/upstream-sync) — Keep squad manifests in sync +- [Persistent Ralph](/features/persistent-ralph) — Monitor delegation status +- [Generic Scheduler](/features/generic-scheduler) — Schedule recurring delegations diff --git a/docs/features/cross-squad-orchestration.md b/docs/features/cross-squad-orchestration.md new file mode 100644 index 000000000..8be7d1401 --- /dev/null +++ b/docs/features/cross-squad-orchestration.md @@ -0,0 +1,200 @@ +# Cross-Squad Orchestration + +**Try this to discover known squads:** +``` +squad discover +``` + +**Try this to delegate work:** +``` +squad delegate backend-squad "Optimize the API response time" +``` + +**Try this to see squad details:** +``` +squad discover backend-squad +``` + +Cross-squad orchestration lets you discover other squads' capabilities and delegate work directly to them. Build a mesh of specialized squads that work together. + +--- + +## What Cross-Squad Orchestration Does + +Cross-squad orchestration enables squads to **discover each other** and **delegate work**. Instead of work bouncing between teams via email, or squads working in isolation, they form a connected mesh: + +1. **Discover** — Find known squads and their capabilities +2. **Understand** — See what work types each squad accepts +3. **Delegate** — Create issues directly in another squad with context +4. **Route** — Issues arrive with proper labels and metadata + +## Quick Start + +### Discover Other Squads + +List all discoverable squads in your network: +```bash +squad discover +``` + +Output: +``` +Known Squads: + backend-squad (github.com/org/backend-squad) + Capabilities: api, database, performance + Contact: @backend-team + Accepts: issues, pull-requests + + frontend-squad (github.com/org/frontend-squad) + Capabilities: ui, ux, accessibility + Contact: @frontend-team + Accepts: issues, pull-requests + + infra-squad (github.com/org/infra-squad) + Capabilities: kubernetes, terraform, ci-cd + Contact: @infra-team + Accepts: issues +``` + +Get details about one squad: +```bash +squad discover backend-squad +``` + +### Delegate Work + +Create an issue in another squad: +```bash +squad delegate backend-squad "Implement caching for user profiles API endpoint" +``` + +The issue is created in backend-squad's repository with: +- Proper labels (automatically applied based on squad manifest) +- Cross-squad metadata in the issue body +- Link back to your squad +- Assignment to the squad's triage team + +Provide more context in a file: +```bash +squad delegate infra-squad --file deployment-request.md +``` + +### Remove an Upstream + +Stop tracking a squad: +```bash +squad delegate remove backend-squad +``` + +## Configuration + +Squads publish their capabilities via `.squad/manifest.json`: + +```json +{ + "name": "backend-squad", + "repo": "github.com/org/backend-squad", + "description": "API and database services", + "capabilities": [ + "api", + "database", + "performance", + "caching" + ], + "accepts": [ + "issues", + "pull-requests" + ], + "contact": { + "team": "Backend Engineering", + "labels": ["squad:backend", "needs-review"] + } +} +``` + +- **name**: Unique squad identifier +- **repo**: GitHub repository URL +- **description**: What the squad does +- **capabilities**: Skills and services offered +- **accepts**: What work types the squad will take on +- **contact**: How to reach them (labels, team mentions) + +## How Discovery Works + +Discovery reads upstream squad manifests: + +1. Find `.squad/upstream.json` in your squad +2. For each upstream, fetch `.squad/manifest.json` +3. Build a local index of known squads +4. Cache the index locally in `.squad/.discovery-cache.json` + +Discovery is **read-only**—it doesn't modify other squads. + +## Use Cases + +### Multi-Team Organization + +- **Platform Squad**: Infrastructure, databases, deployments +- **Feature Squad A**: Mobile and API clients +- **Feature Squad B**: Web frontend and analytics +- **Infra Squad**: CI/CD, monitoring, on-call + +Teams discover each other's capabilities and delegate work instead of context-switching. + +### Distributed Org + +- Multiple orgs, each with their own squads +- Cross-org upstreams link squads (with permission) +- Feature team can delegate testing to central QA squad +- QA squad can delegate infrastructure to platform team + +### Specialized Service Squads + +- DevOps squad publishes "kubernetes", "terraform", "monitoring" capabilities +- Any team discovering DevOps can delegate infrastructure tasks +- No need to find the right person—just find the right squad + +## Manifest Best Practices + +### Be Descriptive + +```json +{ + "capabilities": [ + "rest-api", + "graphql", + "websockets", + "performance-optimization" + ] +} +``` + +Not just `["backend"]` — be specific about what you can do. + +### Label for Routing + +```json +{ + "contact": { + "labels": ["squad:backend", "epic:api", "priority:high"] + } +} +``` + +Labels help delegates find the right backlog and priority. + +### Document Acceptance Criteria + +```json +{ + "description": "API and database services. We accept issues that involve REST/GraphQL APIs, query optimization, and data modeling." +} +``` + +Delegates know whether their work fits. + +## See Also + +- [Upstream Auto-Sync](/features/upstream-sync) — Keep squads in sync automatically +- [Persistent Ralph](/features/persistent-ralph) — Track all squad activity +- [Generic Scheduler](/features/generic-scheduler) — Schedule cross-squad workflows diff --git a/packages/squad-cli/src/cli-entry.ts b/packages/squad-cli/src/cli-entry.ts index ffd3524d8..7f50de912 100644 --- a/packages/squad-cli/src/cli-entry.ts +++ b/packages/squad-cli/src/cli-entry.ts @@ -193,6 +193,9 @@ async function main(): Promise { console.log(` ${BOLD}init-remote${RESET} Link project to remote team root (shorthand)`); console.log(` Usage: init-remote `); console.log(` ${BOLD}rc-tunnel${RESET} Check devtunnel CLI availability`); + console.log(` ${BOLD}discover${RESET} List known squads and their capabilities`); + console.log(` ${BOLD}delegate${RESET} Create work in another squad`); + console.log(` Usage: delegate `); console.log(` ${BOLD}upstream${RESET} Manage upstream Squad sources`); console.log(` Usage: upstream add [--name ] [--ref ]`); console.log(` upstream remove `); @@ -540,6 +543,18 @@ async function main(): Promise { return; } + if (cmd === 'discover') { + const { discoverCommand } = await import('./cli/commands/cross-squad.js'); + await discoverCommand(); + return; + } + + if (cmd === 'delegate') { + const { delegateCommand } = await import('./cli/commands/cross-squad.js'); + await delegateCommand(args.slice(1)); + return; + } + // Unknown command fatal(`Unknown command: ${cmd}\n Run 'squad doctor' to check your setup, or 'squad help' for usage information.`); } diff --git a/packages/squad-cli/src/cli/commands/cross-squad.ts b/packages/squad-cli/src/cli/commands/cross-squad.ts new file mode 100644 index 000000000..6123beda5 --- /dev/null +++ b/packages/squad-cli/src/cli/commands/cross-squad.ts @@ -0,0 +1,100 @@ +/** + * squad discover / squad delegate — CLI commands for cross-squad orchestration. + * + * Commands: + * squad discover — list known squads and capabilities + * squad delegate — create work in another squad + * + * @module cli/commands/cross-squad + */ + +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { success, warn, info, BOLD, RESET, DIM } from '../core/output.js'; +import { fatal } from '../core/errors.js'; +import { detectSquadDir } from '../core/detect-squad-dir.js'; +import { + discoverSquads, + formatDiscoveryTable, + findSquadByName, + buildDelegationArgs, + type DiscoveredSquad, +} from '@bradygaster/squad-sdk'; + +const execFileAsync = promisify(execFile); + +export async function discoverCommand(): Promise { + const squadDirInfo = detectSquadDir(process.cwd()); + const squadDir = squadDirInfo.path; + + const squads = discoverSquads(squadDir); + const output = formatDiscoveryTable(squads); + info(output); +} + +export async function delegateCommand(args: string[]): Promise { + const squadName = args[0]; + const description = args.slice(1).join(' '); + + if (!squadName || !description) { + fatal('Usage: squad delegate ""'); + } + + const squadDirInfo = detectSquadDir(process.cwd()); + const squadDir = squadDirInfo.path; + + const squads = discoverSquads(squadDir); + const target = findSquadByName(squads, squadName); + + if (!target) { + const names = squads.map(s => s.manifest.name).join(', '); + fatal( + `Squad "${squadName}" not found.` + + (names ? ` Known squads: ${names}` : ' No squads discovered — run "squad discover" to check.'), + ); + } + + if (!target.manifest.accepts.includes('issues')) { + fatal(`Squad "${squadName}" does not accept issues. Accepts: ${target.manifest.accepts.join(', ')}`); + } + + const labels = target.manifest.contact.labels || []; + const ghArgs = buildDelegationArgs({ + targetRepo: target.manifest.contact.repo, + title: description, + body: buildDelegationBody(description, target), + labels, + }); + + info(`\n${BOLD}Delegating to ${squadName}${RESET}`); + info(` Repo: ${target.manifest.contact.repo}`); + info(` Title: [cross-squad] ${description}\n`); + + try { + const { stdout } = await execFileAsync('gh', ghArgs); + const issueUrl = stdout.trim(); + success(`Created cross-squad issue: ${issueUrl}`); + } catch (err) { + fatal(`Failed to create issue: ${(err as Error).message}`); + } +} + +function buildDelegationBody(description: string, target: DiscoveredSquad): string { + return [ + '## Cross-Squad Work Request', + '', + `**From:** this repository`, + `**To:** ${target.manifest.name} (${target.manifest.contact.repo})`, + '', + '### Description', + '', + description, + '', + '### Acceptance Criteria', + '', + '- [ ] Work completed and verified', + '- [ ] Originating squad notified of completion', + '', + `${DIM}Created by squad cross-squad orchestration${RESET}`, + ].join('\n'); +} diff --git a/packages/squad-cli/src/cli/index.ts b/packages/squad-cli/src/cli/index.ts index b2bfb89dd..d446e73d9 100644 --- a/packages/squad-cli/src/cli/index.ts +++ b/packages/squad-cli/src/cli/index.ts @@ -38,3 +38,4 @@ export { runDoctor, doctorCommand, type DoctorCheck, type DoctorMode } from './c export { runExport } from './commands/export.js'; export { runImport } from './commands/import.js'; export { splitHistory } from './core/history-split.js'; +export { discoverCommand, delegateCommand } from './commands/cross-squad.js'; diff --git a/packages/squad-sdk/package.json b/packages/squad-sdk/package.json index f36c63776..4da4b3b4d 100644 --- a/packages/squad-sdk/package.json +++ b/packages/squad-sdk/package.json @@ -153,6 +153,10 @@ "./builders": { "types": "./dist/builders/index.d.ts", "import": "./dist/builders/index.js" + }, + "./runtime/cross-squad": { + "types": "./dist/runtime/cross-squad.d.ts", + "import": "./dist/runtime/cross-squad.js" } }, "files": [ diff --git a/packages/squad-sdk/src/index.ts b/packages/squad-sdk/src/index.ts index c42244c8c..7a4e190a3 100644 --- a/packages/squad-sdk/src/index.ts +++ b/packages/squad-sdk/src/index.ts @@ -34,6 +34,25 @@ export * from './runtime/rework.js'; export { getMeter, getTracer } from './runtime/otel.js'; export { safeTimestamp } from './utils/safe-timestamp.js'; export { EventBus as RuntimeEventBus } from './runtime/event-bus.js'; +export { + type SquadManifest, + type SquadContact, + type AcceptedWorkType, + type DiscoveredSquad, + type CrossSquadIssueOptions, + type CrossSquadIssueResult, + type CrossSquadWorkStatus, + validateManifest, + readManifest, + discoverSquads, + discoverFromUpstreams, + discoverFromRegistry, + buildDelegationArgs, + buildStatusCheckArgs, + parseIssueStatus, + formatDiscoveryTable, + findSquadByName, +} from './runtime/cross-squad.js'; export * from './marketplace/index.js'; export * from './build/index.js'; diff --git a/packages/squad-sdk/src/runtime/cross-squad.ts b/packages/squad-sdk/src/runtime/cross-squad.ts new file mode 100644 index 000000000..5eed68f43 --- /dev/null +++ b/packages/squad-sdk/src/runtime/cross-squad.ts @@ -0,0 +1,339 @@ +/** + * Cross-Squad Orchestration — discovery, delegation, and manifest support. + * + * Enables squads to discover each other via manifests, delegate work + * across repository boundaries, and track cross-squad issue completion. + * + * @module runtime/cross-squad + */ + +import { readFileSync, existsSync } from 'fs'; +import { join } from 'path'; + +// ============================================================================ +// Types +// ============================================================================ + +/** Contact information for reaching a squad. */ +export interface SquadContact { + /** GitHub repository in "owner/repo" format. */ + repo: string; + /** Labels to apply when creating issues for this squad. */ + labels?: string[]; +} + +/** Work types a squad accepts from other squads. */ +export type AcceptedWorkType = 'issues' | 'prs'; + +/** + * Squad manifest — the public contract a squad exposes for discovery. + * Stored at `.squad/manifest.json` in a squad's repository. + */ +export interface SquadManifest { + /** Human-readable squad name (e.g., "platform-squad"). */ + name: string; + /** Schema version for forward compatibility. */ + version?: string; + /** One-line description of this squad's purpose. */ + description?: string; + /** Capability tags (e.g., ["kubernetes", "helm", "monitoring"]). */ + capabilities: string[]; + /** How to reach this squad (repo + labels). */ + contact: SquadContact; + /** Work types this squad accepts from other squads. */ + accepts: AcceptedWorkType[]; + /** Named skills this squad offers. */ + skills?: string[]; +} + +/** A discovered squad with its manifest and source location. */ +export interface DiscoveredSquad { + /** Manifest data. */ + manifest: SquadManifest; + /** How this squad was discovered. */ + source: 'upstream' | 'registry' | 'local'; + /** Upstream name or registry path (for provenance). */ + sourceRef: string; +} + +/** Options for creating a cross-squad issue. */ +export interface CrossSquadIssueOptions { + /** Target repo in "owner/repo" format. */ + targetRepo: string; + /** Issue title (will be prefixed with [cross-squad]). */ + title: string; + /** Issue body with context and acceptance criteria. */ + body: string; + /** Labels to apply (squad labels are added automatically). */ + labels?: string[]; +} + +/** Result of creating a cross-squad issue. */ +export interface CrossSquadIssueResult { + /** Whether the issue was successfully created. */ + success: boolean; + /** Issue URL if created, or error message if not. */ + url?: string; + /** Error message if creation failed. */ + error?: string; +} + +/** Status of a tracked cross-squad issue. */ +export interface CrossSquadWorkStatus { + /** Issue URL being tracked. */ + url: string; + /** Current state. */ + state: 'open' | 'closed' | 'unknown'; + /** Issue title (from last poll). */ + title?: string; + /** Error message if status check failed. */ + error?: string; +} + +// ============================================================================ +// Manifest Operations +// ============================================================================ + +/** Validate a manifest object has all required fields. */ +export function validateManifest(data: unknown): data is SquadManifest { + if (typeof data !== 'object' || data === null) return false; + const obj = data as Record; + if (typeof obj['name'] !== 'string' || obj['name'].length === 0) return false; + if (!Array.isArray(obj['capabilities'])) return false; + if (!Array.isArray(obj['accepts'])) return false; + if (typeof obj['contact'] !== 'object' || obj['contact'] === null) return false; + const contact = obj['contact'] as Record; + if (typeof contact['repo'] !== 'string' || contact['repo'].length === 0) return false; + // Validate accepts values + const validAccepts = new Set(['issues', 'prs']); + for (const a of obj['accepts'] as unknown[]) { + if (typeof a !== 'string' || !validAccepts.has(a)) return false; + } + return true; +} + +/** + * Read and parse a squad manifest from a directory path. + * Looks for `.squad/manifest.json` relative to the given root. + */ +export function readManifest(repoPath: string): SquadManifest | null { + const manifestPath = join(repoPath, '.squad', 'manifest.json'); + if (!existsSync(manifestPath)) return null; + try { + const raw = readFileSync(manifestPath, 'utf8'); + const parsed: unknown = JSON.parse(raw); + if (!validateManifest(parsed)) return null; + return parsed; + } catch { + return null; + } +} + +// ============================================================================ +// Discovery +// ============================================================================ + +/** + * Upstream entry — minimal shape we need from upstream.json. + * Re-declared here to avoid circular dependency with upstream module. + */ +interface UpstreamEntry { + name: string; + type: string; + source: string; +} + +interface UpstreamJsonFile { + upstreams: UpstreamEntry[]; +} + +/** + * Discover squads from upstream sources. + * Reads `.squad/upstream.json` and checks each upstream for a manifest. + */ +export function discoverFromUpstreams(squadDir: string): DiscoveredSquad[] { + const upstreamPath = join(squadDir, 'upstream.json'); + if (!existsSync(upstreamPath)) return []; + + let config: UpstreamJsonFile; + try { + config = JSON.parse(readFileSync(upstreamPath, 'utf8')) as UpstreamJsonFile; + } catch { + return []; + } + + if (!Array.isArray(config.upstreams)) return []; + + const discovered: DiscoveredSquad[] = []; + for (const upstream of config.upstreams) { + if (upstream.type === 'local' && upstream.source) { + const manifest = readManifest(upstream.source); + if (manifest) { + discovered.push({ + manifest, + source: 'upstream', + sourceRef: upstream.name, + }); + } + } else if (upstream.type === 'git') { + // For git upstreams, check the cached clone directory + const cloneDir = join(squadDir, '_upstream_repos', upstream.name); + const manifest = readManifest(cloneDir); + if (manifest) { + discovered.push({ + manifest, + source: 'upstream', + sourceRef: upstream.name, + }); + } + } + } + + return discovered; +} + +/** + * Discover squads from a registry file. + * A registry is a JSON file listing repo paths to check for manifests. + */ +export function discoverFromRegistry(registryPath: string): DiscoveredSquad[] { + if (!existsSync(registryPath)) return []; + + let entries: Array<{ name: string; path: string }>; + try { + const raw = readFileSync(registryPath, 'utf8'); + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + entries = parsed as Array<{ name: string; path: string }>; + } catch { + return []; + } + + const discovered: DiscoveredSquad[] = []; + for (const entry of entries) { + if (typeof entry.path === 'string') { + const manifest = readManifest(entry.path); + if (manifest) { + discovered.push({ + manifest, + source: 'registry', + sourceRef: entry.name || entry.path, + }); + } + } + } + + return discovered; +} + +/** + * Discover all squads from all available sources. + * Checks upstreams first, then a registry file if present. + */ +export function discoverSquads(squadDir: string): DiscoveredSquad[] { + const fromUpstreams = discoverFromUpstreams(squadDir); + const registryPath = join(squadDir, 'squad-registry.json'); + const fromRegistry = discoverFromRegistry(registryPath); + + // Deduplicate by manifest name (upstreams take priority) + const seen = new Set(fromUpstreams.map(d => d.manifest.name)); + const merged = [...fromUpstreams]; + for (const d of fromRegistry) { + if (!seen.has(d.manifest.name)) { + seen.add(d.manifest.name); + merged.push(d); + } + } + + return merged; +} + +// ============================================================================ +// Delegation +// ============================================================================ + +/** + * Build the CLI args for creating a cross-squad issue via `gh issue create`. + * Returns the argument array (does not execute — callers run the command). + */ +export function buildDelegationArgs(options: CrossSquadIssueOptions): string[] { + const title = options.title.startsWith('[cross-squad]') + ? options.title + : `[cross-squad] ${options.title}`; + + const args = [ + 'issue', 'create', + '--repo', options.targetRepo, + '--title', title, + '--body', options.body, + ]; + + const labels = [...(options.labels || [])]; + if (!labels.includes('squad:cross-squad')) { + labels.push('squad:cross-squad'); + } + for (const label of labels) { + args.push('--label', label); + } + + return args; +} + +/** + * Build the CLI args for checking a cross-squad issue status via `gh`. + * Parses an issue URL into --repo and issue number. + */ +export function buildStatusCheckArgs(issueUrl: string): string[] | null { + // Parse GitHub issue URL: https://github.com/owner/repo/issues/123 + const match = /github\.com\/([^/]+\/[^/]+)\/issues\/(\d+)/.exec(issueUrl); + if (!match) return null; + const repo = match[1]!; + const number = match[2]!; + return ['issue', 'view', '--repo', repo, number, '--json', 'state,title']; +} + +/** + * Parse the JSON output from `gh issue view --json state,title`. + */ +export function parseIssueStatus(jsonOutput: string, issueUrl: string): CrossSquadWorkStatus { + try { + const data = JSON.parse(jsonOutput) as { state: string; title: string }; + return { + url: issueUrl, + state: data.state === 'CLOSED' ? 'closed' : data.state === 'OPEN' ? 'open' : 'unknown', + title: data.title, + }; + } catch { + return { url: issueUrl, state: 'unknown', error: 'Failed to parse issue status' }; + } +} + +// ============================================================================ +// Display Helpers +// ============================================================================ + +/** Format discovered squads for terminal display. */ +export function formatDiscoveryTable(squads: DiscoveredSquad[]): string { + if (squads.length === 0) { + return 'No squads discovered. Add upstreams with "squad upstream add" or create a squad-registry.json.'; + } + + const lines: string[] = ['\nDiscovered squads:\n']; + for (const s of squads) { + const caps = s.manifest.capabilities.slice(0, 5).join(', '); + const capsSuffix = s.manifest.capabilities.length > 5 ? `, +${s.manifest.capabilities.length - 5} more` : ''; + const accepts = s.manifest.accepts.join(', '); + lines.push(` ${s.manifest.name} → ${s.manifest.contact.repo} (${caps}${capsSuffix})`); + lines.push(` Accepts: ${accepts} | Source: ${s.source} (${s.sourceRef})`); + if (s.manifest.description) { + lines.push(` ${s.manifest.description}`); + } + } + lines.push(''); + return lines.join('\n'); +} + +/** Find a squad by name from a list of discovered squads. */ +export function findSquadByName(squads: DiscoveredSquad[], name: string): DiscoveredSquad | undefined { + return squads.find(s => s.manifest.name === name); +} diff --git a/test/cross-squad.test.ts b/test/cross-squad.test.ts new file mode 100644 index 000000000..66d8716f0 --- /dev/null +++ b/test/cross-squad.test.ts @@ -0,0 +1,497 @@ +/** + * Tests for cross-squad orchestration — manifest parsing, discovery, + * delegation args, status parsing, and display formatting. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { + validateManifest, + readManifest, + discoverSquads, + discoverFromUpstreams, + discoverFromRegistry, + buildDelegationArgs, + buildStatusCheckArgs, + parseIssueStatus, + formatDiscoveryTable, + findSquadByName, +} from '../packages/squad-sdk/src/runtime/cross-squad.js'; + +function makeTempDir(prefix: string): string { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +function cleanDir(dir: string): void { + try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } +} + +function writeManifest(repoDir: string, manifest: Record): void { + const squadDir = path.join(repoDir, '.squad'); + fs.mkdirSync(squadDir, { recursive: true }); + fs.writeFileSync(path.join(squadDir, 'manifest.json'), JSON.stringify(manifest, null, 2)); +} + +// ============================================================================ +// Manifest Validation +// ============================================================================ + +describe('validateManifest', () => { + it('accepts a valid manifest', () => { + expect(validateManifest({ + name: 'platform-squad', + capabilities: ['kubernetes', 'helm'], + contact: { repo: 'org/platform' }, + accepts: ['issues'], + })).toBe(true); + }); + + it('accepts a full manifest with all optional fields', () => { + expect(validateManifest({ + name: 'platform-squad', + version: '1.0.0', + description: 'Platform infrastructure team', + capabilities: ['kubernetes', 'helm', 'monitoring'], + contact: { repo: 'org/platform', labels: ['squad:platform'] }, + accepts: ['issues', 'prs'], + skills: ['helm-developer', 'operator-developer'], + })).toBe(true); + }); + + it('rejects null', () => { + expect(validateManifest(null)).toBe(false); + }); + + it('rejects empty name', () => { + expect(validateManifest({ + name: '', + capabilities: ['k8s'], + contact: { repo: 'org/r' }, + accepts: ['issues'], + })).toBe(false); + }); + + it('rejects missing capabilities', () => { + expect(validateManifest({ + name: 'squad', + contact: { repo: 'org/r' }, + accepts: ['issues'], + })).toBe(false); + }); + + it('rejects missing contact.repo', () => { + expect(validateManifest({ + name: 'squad', + capabilities: ['k8s'], + contact: {}, + accepts: ['issues'], + })).toBe(false); + }); + + it('rejects invalid accepts values', () => { + expect(validateManifest({ + name: 'squad', + capabilities: ['k8s'], + contact: { repo: 'org/r' }, + accepts: ['emails'], + })).toBe(false); + }); +}); + +// ============================================================================ +// readManifest +// ============================================================================ + +describe('readManifest', () => { + let tempDir: string; + + beforeAll(() => { + tempDir = makeTempDir('squad-manifest-read-'); + }); + + afterAll(() => { + cleanDir(tempDir); + }); + + it('reads a valid manifest file', () => { + writeManifest(tempDir, { + name: 'test-squad', + capabilities: ['testing'], + contact: { repo: 'org/test' }, + accepts: ['issues'], + }); + const m = readManifest(tempDir); + expect(m).not.toBeNull(); + expect(m!.name).toBe('test-squad'); + expect(m!.capabilities).toEqual(['testing']); + }); + + it('returns null for missing manifest', () => { + const emptyDir = makeTempDir('squad-manifest-empty-'); + try { + expect(readManifest(emptyDir)).toBeNull(); + } finally { + cleanDir(emptyDir); + } + }); + + it('returns null for invalid JSON', () => { + const badDir = makeTempDir('squad-manifest-bad-'); + try { + fs.mkdirSync(path.join(badDir, '.squad'), { recursive: true }); + fs.writeFileSync(path.join(badDir, '.squad', 'manifest.json'), 'not-json{{{'); + expect(readManifest(badDir)).toBeNull(); + } finally { + cleanDir(badDir); + } + }); + + it('returns null for manifest missing required fields', () => { + const incompleteDir = makeTempDir('squad-manifest-incomplete-'); + try { + fs.mkdirSync(path.join(incompleteDir, '.squad'), { recursive: true }); + fs.writeFileSync( + path.join(incompleteDir, '.squad', 'manifest.json'), + JSON.stringify({ name: 'oops' }), + ); + expect(readManifest(incompleteDir)).toBeNull(); + } finally { + cleanDir(incompleteDir); + } + }); +}); + +// ============================================================================ +// Discovery +// ============================================================================ + +describe('discoverSquads', () => { + let repoDir: string; + let platformDir: string; + let frontendDir: string; + + beforeAll(() => { + // Set up repo with upstream.json pointing to two squads + repoDir = makeTempDir('squad-discover-repo-'); + platformDir = makeTempDir('squad-discover-platform-'); + frontendDir = makeTempDir('squad-discover-frontend-'); + + writeManifest(platformDir, { + name: 'platform-squad', + description: 'Platform infrastructure', + capabilities: ['kubernetes', 'helm', 'monitoring'], + contact: { repo: 'org/platform', labels: ['squad:platform'] }, + accepts: ['issues', 'prs'], + skills: ['helm-developer'], + }); + + writeManifest(frontendDir, { + name: 'frontend-squad', + capabilities: ['react', 'nextjs'], + contact: { repo: 'org/frontend' }, + accepts: ['issues'], + }); + + const squadDir = path.join(repoDir, '.squad'); + fs.mkdirSync(squadDir, { recursive: true }); + fs.writeFileSync(path.join(squadDir, 'upstream.json'), JSON.stringify({ + upstreams: [ + { name: 'platform', type: 'local', source: platformDir, added_at: new Date().toISOString(), last_synced: null }, + { name: 'frontend', type: 'local', source: frontendDir, added_at: new Date().toISOString(), last_synced: null }, + ], + }, null, 2)); + }); + + afterAll(() => { + cleanDir(repoDir); + cleanDir(platformDir); + cleanDir(frontendDir); + }); + + it('discovers both squads from upstreams', () => { + const squads = discoverSquads(path.join(repoDir, '.squad')); + expect(squads).toHaveLength(2); + expect(squads.map(s => s.manifest.name)).toContain('platform-squad'); + expect(squads.map(s => s.manifest.name)).toContain('frontend-squad'); + }); + + it('includes capabilities in discovered manifests', () => { + const squads = discoverSquads(path.join(repoDir, '.squad')); + const platform = squads.find(s => s.manifest.name === 'platform-squad')!; + expect(platform.manifest.capabilities).toContain('kubernetes'); + expect(platform.manifest.capabilities).toContain('helm'); + }); + + it('records source as upstream', () => { + const squads = discoverSquads(path.join(repoDir, '.squad')); + for (const s of squads) { + expect(s.source).toBe('upstream'); + } + }); + + it('returns empty array when no upstream.json', () => { + const emptyDir = makeTempDir('squad-discover-empty-'); + try { + fs.mkdirSync(path.join(emptyDir, '.squad'), { recursive: true }); + const squads = discoverSquads(path.join(emptyDir, '.squad')); + expect(squads).toEqual([]); + } finally { + cleanDir(emptyDir); + } + }); + + it('skips upstreams without manifests', () => { + const noManifestDir = makeTempDir('squad-discover-nomanifest-'); + const mixedRepo = makeTempDir('squad-discover-mixed-'); + try { + fs.mkdirSync(path.join(noManifestDir, '.squad'), { recursive: true }); + // no manifest.json written + + const squadDir = path.join(mixedRepo, '.squad'); + fs.mkdirSync(squadDir, { recursive: true }); + fs.writeFileSync(path.join(squadDir, 'upstream.json'), JSON.stringify({ + upstreams: [ + { name: 'has-manifest', type: 'local', source: platformDir, added_at: new Date().toISOString(), last_synced: null }, + { name: 'no-manifest', type: 'local', source: noManifestDir, added_at: new Date().toISOString(), last_synced: null }, + ], + })); + + const squads = discoverSquads(squadDir); + expect(squads).toHaveLength(1); + expect(squads[0]!.manifest.name).toBe('platform-squad'); + } finally { + cleanDir(noManifestDir); + cleanDir(mixedRepo); + } + }); +}); + +// ============================================================================ +// Registry Discovery +// ============================================================================ + +describe('discoverFromRegistry', () => { + let registryDir: string; + let squadADir: string; + + beforeAll(() => { + registryDir = makeTempDir('squad-registry-'); + squadADir = makeTempDir('squad-registry-a-'); + + writeManifest(squadADir, { + name: 'registry-squad-a', + capabilities: ['go', 'rust'], + contact: { repo: 'org/services' }, + accepts: ['issues'], + }); + }); + + afterAll(() => { + cleanDir(registryDir); + cleanDir(squadADir); + }); + + it('discovers squads from a registry file', () => { + const registryPath = path.join(registryDir, 'registry.json'); + fs.writeFileSync(registryPath, JSON.stringify([ + { name: 'squad-a', path: squadADir }, + ])); + + const squads = discoverFromRegistry(registryPath); + expect(squads).toHaveLength(1); + expect(squads[0]!.manifest.name).toBe('registry-squad-a'); + expect(squads[0]!.source).toBe('registry'); + }); + + it('returns empty for missing registry file', () => { + expect(discoverFromRegistry('/nonexistent/path')).toEqual([]); + }); + + it('returns empty for invalid JSON', () => { + const badPath = path.join(registryDir, 'bad.json'); + fs.writeFileSync(badPath, 'nope'); + expect(discoverFromRegistry(badPath)).toEqual([]); + }); +}); + +// ============================================================================ +// Delegation +// ============================================================================ + +describe('buildDelegationArgs', () => { + it('builds correct gh cli args', () => { + const args = buildDelegationArgs({ + targetRepo: 'org/platform', + title: 'Add metrics endpoint', + body: 'Need Prometheus metrics.', + labels: ['squad:platform'], + }); + + expect(args).toContain('issue'); + expect(args).toContain('create'); + expect(args).toContain('--repo'); + expect(args).toContain('org/platform'); + expect(args).toContain('--title'); + expect(args).toContain('[cross-squad] Add metrics endpoint'); + expect(args).toContain('--body'); + expect(args).toContain('Need Prometheus metrics.'); + expect(args).toContain('--label'); + expect(args).toContain('squad:cross-squad'); + expect(args).toContain('squad:platform'); + }); + + it('does not double-prefix [cross-squad] in title', () => { + const args = buildDelegationArgs({ + targetRepo: 'org/platform', + title: '[cross-squad] Already prefixed', + body: 'test', + }); + + const titleIdx = args.indexOf('--title'); + expect(args[titleIdx + 1]).toBe('[cross-squad] Already prefixed'); + }); + + it('adds squad:cross-squad label even when none provided', () => { + const args = buildDelegationArgs({ + targetRepo: 'org/platform', + title: 'test', + body: 'test', + }); + + const labelIndices = args.reduce((acc, a, i) => a === '--label' ? [...acc, i] : acc, []); + const labels = labelIndices.map(i => args[i + 1]); + expect(labels).toContain('squad:cross-squad'); + }); +}); + +// ============================================================================ +// Status Tracking +// ============================================================================ + +describe('buildStatusCheckArgs', () => { + it('parses a valid GitHub issue URL', () => { + const args = buildStatusCheckArgs('https://github.com/org/platform/issues/42'); + expect(args).not.toBeNull(); + expect(args).toContain('--repo'); + expect(args).toContain('org/platform'); + expect(args).toContain('42'); + }); + + it('returns null for invalid URL', () => { + expect(buildStatusCheckArgs('not-a-url')).toBeNull(); + expect(buildStatusCheckArgs('https://gitlab.com/org/repo/issues/1')).toBeNull(); + }); +}); + +describe('parseIssueStatus', () => { + it('parses open status', () => { + const result = parseIssueStatus( + JSON.stringify({ state: 'OPEN', title: 'Test issue' }), + 'https://github.com/org/repo/issues/1', + ); + expect(result.state).toBe('open'); + expect(result.title).toBe('Test issue'); + expect(result.url).toBe('https://github.com/org/repo/issues/1'); + }); + + it('parses closed status', () => { + const result = parseIssueStatus( + JSON.stringify({ state: 'CLOSED', title: 'Done' }), + 'https://github.com/org/repo/issues/2', + ); + expect(result.state).toBe('closed'); + }); + + it('handles unparseable JSON', () => { + const result = parseIssueStatus('bad-json', 'https://github.com/org/repo/issues/3'); + expect(result.state).toBe('unknown'); + expect(result.error).toBeDefined(); + }); +}); + +// ============================================================================ +// Display +// ============================================================================ + +describe('formatDiscoveryTable', () => { + it('shows message when no squads found', () => { + const output = formatDiscoveryTable([]); + expect(output).toContain('No squads discovered'); + }); + + it('formats discovered squads', () => { + const output = formatDiscoveryTable([ + { + manifest: { + name: 'platform-squad', + description: 'Platform team', + capabilities: ['kubernetes', 'helm'], + contact: { repo: 'org/platform' }, + accepts: ['issues', 'prs'], + }, + source: 'upstream', + sourceRef: 'platform', + }, + ]); + expect(output).toContain('platform-squad'); + expect(output).toContain('org/platform'); + expect(output).toContain('kubernetes'); + expect(output).toContain('Platform team'); + }); + + it('truncates long capability lists', () => { + const output = formatDiscoveryTable([ + { + manifest: { + name: 'big-squad', + capabilities: ['a', 'b', 'c', 'd', 'e', 'f', 'g'], + contact: { repo: 'org/big' }, + accepts: ['issues'], + }, + source: 'upstream', + sourceRef: 'big', + }, + ]); + expect(output).toContain('+2 more'); + }); +}); + +// ============================================================================ +// findSquadByName +// ============================================================================ + +describe('findSquadByName', () => { + const squads = [ + { + manifest: { + name: 'alpha', + capabilities: ['a'], + contact: { repo: 'org/alpha' } as const, + accepts: ['issues' as const], + }, + source: 'upstream' as const, + sourceRef: 'alpha', + }, + { + manifest: { + name: 'beta', + capabilities: ['b'], + contact: { repo: 'org/beta' } as const, + accepts: ['issues' as const], + }, + source: 'upstream' as const, + sourceRef: 'beta', + }, + ]; + + it('finds squad by exact name', () => { + expect(findSquadByName(squads, 'alpha')?.manifest.name).toBe('alpha'); + expect(findSquadByName(squads, 'beta')?.manifest.name).toBe('beta'); + }); + + it('returns undefined for unknown name', () => { + expect(findSquadByName(squads, 'gamma')).toBeUndefined(); + }); +});