diff --git a/.changeset/machine-capability-routing.md b/.changeset/machine-capability-routing.md new file mode 100644 index 000000000..2ac99caf7 --- /dev/null +++ b/.changeset/machine-capability-routing.md @@ -0,0 +1,15 @@ +--- +"@bradygaster/squad-sdk": minor +"@bradygaster/squad-cli": minor +--- + +feat: Machine capability discovery and `needs:*` label-based issue routing + +Added capability filtering to Ralph's watch command. Issues with `needs:*` labels +(e.g., `needs:gpu`, `needs:browser`) are only processed by Ralph instances whose +machine has those capabilities declared in `machine-capabilities.json`. + +This enables multi-machine Squad deployments where different machines handle +different types of work based on their available tooling. + +Closes #514 \ No newline at end of file diff --git a/.squad-templates/machine-capabilities.md b/.squad-templates/machine-capabilities.md new file mode 100644 index 000000000..b770fd04b --- /dev/null +++ b/.squad-templates/machine-capabilities.md @@ -0,0 +1,75 @@ +# Machine Capability Discovery & Label-Based Routing + +> Enable Ralph to skip issues requiring capabilities the current machine lacks. + +## Overview + +When running Squad across multiple machines (laptops, DevBoxes, GPU servers, Kubernetes nodes), each machine has different tooling. The capability system lets you declare what each machine can do, and Ralph automatically routes work accordingly. + +## Setup + +### 1. Create a Capabilities Manifest + +Create `~/.squad/machine-capabilities.json` (user-wide) or `.squad/machine-capabilities.json` (project-local): + +```json +{ + "machine": "MY-LAPTOP", + "capabilities": ["browser", "personal-gh", "onedrive"], + "missing": ["gpu", "docker", "azure-speech"], + "lastUpdated": "2026-03-22T00:00:00Z" +} +``` + +### 2. Label Issues with Requirements + +Add `needs:*` labels to issues that require specific capabilities: + +| Label | Meaning | +|-------|---------| +| `needs:browser` | Requires Playwright / browser automation | +| `needs:gpu` | Requires NVIDIA GPU | +| `needs:personal-gh` | Requires personal GitHub account | +| `needs:emu-gh` | Requires Enterprise Managed User account | +| `needs:azure-cli` | Requires authenticated Azure CLI | +| `needs:docker` | Requires Docker daemon | +| `needs:onedrive` | Requires OneDrive sync | +| `needs:teams-mcp` | Requires Teams MCP tools | + +Custom capabilities are supported — any `needs:X` label works if `X` is in the machine's `capabilities` array. + +### 3. Run Ralph + +```bash +squad watch --interval 5 +``` + +Ralph will log skipped issues: +``` +⏭️ Skipping #42 "Train ML model" — missing: gpu +✓ Triaged #43 "Fix CSS layout" → Picard (routing-rule) +``` + +## How It Works + +1. Ralph loads `machine-capabilities.json` at startup +2. For each open issue, Ralph extracts `needs:*` labels +3. If any required capability is missing, the issue is skipped +4. Issues without `needs:*` labels are always processed (opt-in system) + +## Kubernetes Integration + +On Kubernetes, machine capabilities map to node labels: + +```yaml +# Node labels (set by capability DaemonSet or manually) +node.squad.dev/gpu: "true" +node.squad.dev/browser: "true" + +# Pod spec uses nodeSelector +spec: + nodeSelector: + node.squad.dev/gpu: "true" +``` + +A DaemonSet can run capability discovery on each node and maintain labels automatically. See the [squad-on-aks](https://github.com/tamirdresher/squad-on-aks) project for a complete Kubernetes deployment example. \ No newline at end of file diff --git a/packages/squad-cli/src/cli/commands/watch.ts b/packages/squad-cli/src/cli/commands/watch.ts index 8cdb176a8..ba94521d1 100644 --- a/packages/squad-cli/src/cli/commands/watch.ts +++ b/packages/squad-cli/src/cli/commands/watch.ts @@ -17,6 +17,7 @@ import { import { RalphMonitor } from '@bradygaster/squad-sdk/ralph'; import { EventBus } from '@bradygaster/squad-sdk/runtime/event-bus'; import { ghAvailable, ghAuthenticated, ghIssueList, ghIssueEdit, ghPrList, type GhIssue, type GhPullRequest } from '../core/gh-cli.js'; +import type { MachineCapabilities } from '@bradygaster/squad-sdk/ralph/capabilities'; export interface BoardState { untriaged: number; @@ -155,7 +156,8 @@ async function runCheck( modules: ReturnType, roster: ReturnType, hasCopilot: boolean, - autoAssign: boolean + autoAssign: boolean, + capabilities: MachineCapabilities | null = null ): Promise { const timestamp = new Date().toLocaleTimeString(); @@ -163,13 +165,21 @@ async function runCheck( // Fetch open issues with squad label const issues = await ghIssueList({ label: 'squad', state: 'open', limit: 20 }); + // Filter by machine capabilities (#514) + const { filterByCapabilities } = await import('@bradygaster/squad-sdk/ralph/capabilities'); + const { handled: capableIssues, skipped: incapableIssues } = filterByCapabilities(issues, capabilities); + + for (const { issue, missing } of incapableIssues) { + console.log(`${DIM}[${timestamp}] ⏭️ Skipping #${issue.number} "${issue.title}" — missing: ${missing.join(', ')}${RESET}`); + } + // Find untriaged issues (no squad:{member} label) const memberLabels = roster.map(m => m.label); - const untriaged = issues.filter(issue => { + const untriaged = capableIssues.filter(issue => { const issueLabels = issue.labels.map(l => l.name); return !memberLabels.some(ml => issueLabels.includes(ml)); }); - const assignedIssues = issues.filter(issue => { + const assignedIssues = capableIssues.filter(issue => { const issueLabels = issue.labels.map(l => l.name); return memberLabels.some(ml => issueLabels.includes(ml)); }); @@ -269,6 +279,14 @@ export async function runWatch(dest: string, intervalMinutes: number): Promise { round++; - const roundState = await runCheck(rules, modules, roster, hasCopilot, autoAssign); + const roundState = await runCheck(rules, modules, roster, hasCopilot, autoAssign, capabilities); await eventBus.emit({ type: 'agent:milestone', sessionId: monitorSessionId, diff --git a/packages/squad-sdk/package.json b/packages/squad-sdk/package.json index f274ba605..8c3802ecf 100644 --- a/packages/squad-sdk/package.json +++ b/packages/squad-sdk/package.json @@ -86,6 +86,10 @@ "types": "./dist/ralph/triage.d.ts", "import": "./dist/ralph/triage.js" }, + "./ralph/capabilities": { + "types": "./dist/ralph/capabilities.d.ts", + "import": "./dist/ralph/capabilities.js" + }, "./ralph/rate-limiting": { "types": "./dist/ralph/rate-limiting.d.ts", "import": "./dist/ralph/rate-limiting.js" diff --git a/packages/squad-sdk/src/ralph/capabilities.ts b/packages/squad-sdk/src/ralph/capabilities.ts new file mode 100644 index 000000000..8a00223e8 --- /dev/null +++ b/packages/squad-sdk/src/ralph/capabilities.ts @@ -0,0 +1,144 @@ +/** + * Machine Capability Discovery & Label-Based Routing + * + * Enables Ralph to filter issues by machine capabilities. + * Issues with `needs:*` labels are only processed if the + * local machine has those capabilities. + * + * @see https://github.com/bradygaster/squad/issues/514 + */ + +import { readFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; + +/** Machine capability manifest */ +export interface MachineCapabilities { + machine: string; + capabilities: string[]; + missing: string[]; + lastUpdated: string; +} + +/** Well-known capability identifiers */ +export const KNOWN_CAPABILITIES = [ + 'browser', // Playwright/browser automation + 'gpu', // NVIDIA GPU available + 'personal-gh', // Personal GitHub account + 'emu-gh', // Enterprise Managed User account + 'azure-cli', // Azure CLI authenticated + 'docker', // Docker daemon running + 'onedrive', // OneDrive sync available + 'teams-mcp', // Teams MCP tools +] as const; + +export type KnownCapability = typeof KNOWN_CAPABILITIES[number]; + +/** Prefix for capability requirement labels */ +const NEEDS_PREFIX = 'needs:'; + +/** + * Load machine capabilities from the standard location. + * Checks (in order): + * 1. `.squad/machine-capabilities.json` in the team root + * 2. `~/.squad/machine-capabilities.json` in the user home + * + * Returns null if no capabilities file exists (all issues pass through). + */ +export async function loadCapabilities( + teamRoot?: string +): Promise { + const candidates: string[] = []; + + if (teamRoot) { + candidates.push(path.join(teamRoot, '.squad', 'machine-capabilities.json')); + } + candidates.push(path.join(os.homedir(), '.squad', 'machine-capabilities.json')); + + for (const candidate of candidates) { + if (existsSync(candidate)) { + try { + const raw = await readFile(candidate, 'utf8'); + return JSON.parse(raw) as MachineCapabilities; + } catch { + // Malformed file — skip + } + } + } + + return null; +} + +/** + * Extract `needs:*` requirements from issue labels. + * + * @example + * extractNeeds(['bug', 'needs:gpu', 'needs:browser', 'squad:picard']) + * // → ['gpu', 'browser'] + */ +export function extractNeeds(labels: string[]): string[] { + return labels + .filter(l => l.startsWith(NEEDS_PREFIX)) + .map(l => l.slice(NEEDS_PREFIX.length)); +} + +/** + * Check if the machine can handle an issue based on `needs:*` labels. + * + * Returns `{ canHandle: true }` if: + * - The issue has no `needs:*` labels (any machine can handle it) + * - All `needs:*` requirements are in the machine's capabilities + * + * Returns `{ canHandle: false, missing: [...] }` if any are missing. + */ +export function canHandleIssue( + issueLabels: string[], + capabilities: MachineCapabilities | null +): { canHandle: true } | { canHandle: false; missing: string[] } { + const needs = extractNeeds(issueLabels); + + // No requirements — any machine can handle + if (needs.length === 0) { + return { canHandle: true }; + } + + // No capabilities file — pass through (opt-in system) + if (!capabilities) { + return { canHandle: true }; + } + + const capSet = new Set(capabilities.capabilities); + const missing = needs.filter(n => !capSet.has(n)); + + if (missing.length === 0) { + return { canHandle: true }; + } + + return { canHandle: false, missing }; +} + +/** + * Filter issues to only those this machine can handle. + * Issues without `needs:*` labels always pass through. + */ +export function filterByCapabilities( + issues: T[], + capabilities: MachineCapabilities | null +): { handled: T[]; skipped: { issue: T; missing: string[] }[] } { + const handled: T[] = []; + const skipped: { issue: T; missing: string[] }[] = []; + + for (const issue of issues) { + const labelNames = issue.labels.map(l => l.name); + const result = canHandleIssue(labelNames, capabilities); + + if (result.canHandle) { + handled.push(issue); + } else { + skipped.push({ issue, missing: result.missing }); + } + } + + return { handled, skipped }; +} \ No newline at end of file diff --git a/packages/squad-sdk/src/ralph/index.ts b/packages/squad-sdk/src/ralph/index.ts index cb946b201..4a773f23c 100644 --- a/packages/squad-sdk/src/ralph/index.ts +++ b/packages/squad-sdk/src/ralph/index.ts @@ -184,4 +184,5 @@ export class RalphMonitor { } } +export { loadCapabilities, canHandleIssue, filterByCapabilities, extractNeeds, type MachineCapabilities, KNOWN_CAPABILITIES } from './capabilities.js'; export { getTrafficLight, shouldProceed, getRetryDelay, PredictiveCircuitBreaker, canUseQuota, loadRatePool, type RatePool, type RatePoolAllocation, type RateSample, type TrafficLight, type AgentPriority } from './rate-limiting.js'; diff --git a/test/capabilities.test.ts b/test/capabilities.test.ts new file mode 100644 index 000000000..dd4cf9627 --- /dev/null +++ b/test/capabilities.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect } from 'vitest'; +import { + extractNeeds, + canHandleIssue, + filterByCapabilities, + type MachineCapabilities, +} from '@bradygaster/squad-sdk/ralph/capabilities'; + +const gpuMachine: MachineCapabilities = { + machine: 'GPU-SERVER', + capabilities: ['gpu', 'docker', 'browser'], + missing: ['onedrive', 'teams-mcp'], + lastUpdated: '2026-03-22T00:00:00Z', +}; + +const laptopMachine: MachineCapabilities = { + machine: 'MY-LAPTOP', + capabilities: ['browser', 'personal-gh', 'onedrive'], + missing: ['gpu', 'docker'], + lastUpdated: '2026-03-22T00:00:00Z', +}; + +describe('extractNeeds', () => { + it('extracts needs:* labels', () => { + expect(extractNeeds(['bug', 'needs:gpu', 'squad:picard'])).toEqual(['gpu']); + }); + + it('handles multiple needs', () => { + expect(extractNeeds(['needs:gpu', 'needs:browser', 'needs:docker'])) + .toEqual(['gpu', 'browser', 'docker']); + }); + + it('returns empty for no needs labels', () => { + expect(extractNeeds(['bug', 'enhancement', 'squad:data'])).toEqual([]); + }); + + it('returns empty for empty array', () => { + expect(extractNeeds([])).toEqual([]); + }); +}); + +describe('canHandleIssue', () => { + it('passes issues with no needs labels', () => { + expect(canHandleIssue(['bug', 'squad:picard'], gpuMachine)).toEqual({ canHandle: true }); + }); + + it('passes when all needs are met', () => { + expect(canHandleIssue(['needs:gpu', 'needs:docker'], gpuMachine)) + .toEqual({ canHandle: true }); + }); + + it('fails when needs are missing', () => { + const result = canHandleIssue(['needs:gpu', 'needs:docker'], laptopMachine); + expect(result.canHandle).toBe(false); + if (!result.canHandle) { + expect(result.missing).toEqual(['gpu', 'docker']); + } + }); + + it('passes all issues when capabilities is null (opt-in)', () => { + expect(canHandleIssue(['needs:gpu'], null)).toEqual({ canHandle: true }); + }); + + it('reports only missing capabilities', () => { + const result = canHandleIssue(['needs:browser', 'needs:gpu'], laptopMachine); + expect(result.canHandle).toBe(false); + if (!result.canHandle) { + expect(result.missing).toEqual(['gpu']); + } + }); +}); + +describe('filterByCapabilities', () => { + const issues = [ + { number: 1, title: 'Bug fix', labels: [{ name: 'bug' }] }, + { number: 2, title: 'GPU task', labels: [{ name: 'needs:gpu' }] }, + { number: 3, title: 'Browser task', labels: [{ name: 'needs:browser' }] }, + { number: 4, title: 'Both', labels: [{ name: 'needs:gpu' }, { name: 'needs:browser' }] }, + ]; + + it('passes all issues when capabilities is null', () => { + const { handled, skipped } = filterByCapabilities(issues, null); + expect(handled).toHaveLength(4); + expect(skipped).toHaveLength(0); + }); + + it('filters correctly for GPU machine', () => { + const { handled, skipped } = filterByCapabilities(issues, gpuMachine); + expect(handled.map(i => i.number)).toEqual([1, 2, 3, 4]); + expect(skipped).toHaveLength(0); + }); + + it('filters correctly for laptop', () => { + const { handled, skipped } = filterByCapabilities(issues, laptopMachine); + expect(handled.map(i => i.number)).toEqual([1, 3]); + expect(skipped).toHaveLength(2); + expect(skipped[0].issue.number).toBe(2); + expect(skipped[0].missing).toEqual(['gpu']); + expect(skipped[1].issue.number).toBe(4); + }); + + it('handles empty issue list', () => { + const { handled, skipped } = filterByCapabilities([], gpuMachine); + expect(handled).toHaveLength(0); + expect(skipped).toHaveLength(0); + }); +}); \ No newline at end of file