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
15 changes: 15 additions & 0 deletions .changeset/machine-capability-routing.md
Original file line number Diff line number Diff line change
@@ -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
75 changes: 75 additions & 0 deletions .squad-templates/machine-capabilities.md
Original file line number Diff line number Diff line change
@@ -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.
28 changes: 23 additions & 5 deletions packages/squad-cli/src/cli/commands/watch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -155,21 +156,30 @@ async function runCheck(
modules: ReturnType<typeof parseModuleOwnership>,
roster: ReturnType<typeof parseRoster>,
hasCopilot: boolean,
autoAssign: boolean
autoAssign: boolean,
capabilities: MachineCapabilities | null = null
): Promise<BoardState> {
const timestamp = new Date().toLocaleTimeString();

try {
// 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));
});
Expand Down Expand Up @@ -269,6 +279,14 @@ export async function runWatch(dest: string, intervalMinutes: number): Promise<v
const rules = parseRoutingRules(routingContent);
const modules = parseModuleOwnership(routingContent);

// Load machine capabilities for needs:* label filtering (#514)
const { loadCapabilities } = await import('@bradygaster/squad-sdk/ralph/capabilities');
const capabilities = await loadCapabilities(path.dirname(squadDirInfo.path));

if (capabilities) {
console.log(`${DIM}📦 Machine: ${capabilities.machine} — ${capabilities.capabilities.length} capabilities loaded${RESET}`);
}

if (roster.length === 0) {
fatal('No squad members found in team.md');
}
Expand Down Expand Up @@ -300,7 +318,7 @@ export async function runWatch(dest: string, intervalMinutes: number): Promise<v

// Run immediately, then on interval
round++;
const state = await runCheck(rules, modules, roster, hasCopilot, autoAssign);
const state = await runCheck(rules, modules, roster, hasCopilot, autoAssign, capabilities);
await eventBus.emit({
type: 'agent:milestone',
sessionId: monitorSessionId,
Expand All @@ -315,7 +333,7 @@ export async function runWatch(dest: string, intervalMinutes: number): Promise<v
const intervalId = setInterval(
async () => {
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,
Expand Down
4 changes: 4 additions & 0 deletions packages/squad-sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
144 changes: 144 additions & 0 deletions packages/squad-sdk/src/ralph/capabilities.ts
Original file line number Diff line number Diff line change
@@ -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<MachineCapabilities | null> {
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<T extends { labels: { name: string }[] }>(
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 };
}
1 change: 1 addition & 0 deletions packages/squad-sdk/src/ralph/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Loading
Loading