From 5e219c1a393b987d51f1ca3e56ecacd2353d9f92 Mon Sep 17 00:00:00 2001 From: Copilot Date: Sun, 22 Mar 2026 22:14:21 +0200 Subject: [PATCH 1/3] feat: machine capability discovery & needs:* label routing (#514) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .changeset/machine-capability-routing.md | 10 ++ .squad-templates/machine-capabilities.md | 75 ++++++++++ packages/squad-sdk/src/ralph/capabilities.ts | 144 +++++++++++++++++++ packages/squad-sdk/src/ralph/index.ts | 2 + test/capabilities.test.ts | 107 ++++++++++++++ 5 files changed, 338 insertions(+) create mode 100644 .changeset/machine-capability-routing.md create mode 100644 .squad-templates/machine-capabilities.md create mode 100644 packages/squad-sdk/src/ralph/capabilities.ts create mode 100644 test/capabilities.test.ts diff --git a/.changeset/machine-capability-routing.md b/.changeset/machine-capability-routing.md new file mode 100644 index 000000000..d8cddf335 --- /dev/null +++ b/.changeset/machine-capability-routing.md @@ -0,0 +1,10 @@ +--- +"@bradygaster/squad-sdk": minor +--- + +feat: Machine capability discovery and needs:* label-based issue routing + +Added capability filtering to Ralph. Issues with needs:* labels are only +processed by Ralph instances with matching capabilities in machine-capabilities.json. + +Closes #514 diff --git a/.squad-templates/machine-capabilities.md b/.squad-templates/machine-capabilities.md new file mode 100644 index 000000000..1c6cd509c --- /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. diff --git a/packages/squad-sdk/src/ralph/capabilities.ts b/packages/squad-sdk/src/ralph/capabilities.ts new file mode 100644 index 000000000..1f42f7f2d --- /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 }; +} diff --git a/packages/squad-sdk/src/ralph/index.ts b/packages/squad-sdk/src/ralph/index.ts index f4ffa692b..41798ed6f 100644 --- a/packages/squad-sdk/src/ralph/index.ts +++ b/packages/squad-sdk/src/ralph/index.ts @@ -183,3 +183,5 @@ export class RalphMonitor { this.eventBus = null; } } + +export { loadCapabilities, canHandleIssue, filterByCapabilities, extractNeeds, type MachineCapabilities, KNOWN_CAPABILITIES } from './capabilities.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 From 48da9eac6cb40d05fdd947f391909450969a4c9b Mon Sep 17 00:00:00 2001 From: Copilot Date: Sun, 22 Mar 2026 22:23:45 +0200 Subject: [PATCH 2/3] feat: machine capability discovery & needs:* label routing (#514) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .changeset/machine-capability-routing.md | 13 ++++++--- .squad-templates/machine-capabilities.md | 2 +- package-lock.json | 14 +++++----- packages/squad-cli/src/cli/commands/watch.ts | 28 ++++++++++++++++---- packages/squad-sdk/package.json | 4 +++ packages/squad-sdk/src/ralph/capabilities.ts | 2 +- 6 files changed, 45 insertions(+), 18 deletions(-) diff --git a/.changeset/machine-capability-routing.md b/.changeset/machine-capability-routing.md index d8cddf335..2ac99caf7 100644 --- a/.changeset/machine-capability-routing.md +++ b/.changeset/machine-capability-routing.md @@ -1,10 +1,15 @@ --- "@bradygaster/squad-sdk": minor +"@bradygaster/squad-cli": minor --- -feat: Machine capability discovery and needs:* label-based issue routing +feat: Machine capability discovery and `needs:*` label-based issue routing -Added capability filtering to Ralph. Issues with needs:* labels are only -processed by Ralph instances with matching capabilities in machine-capabilities.json. +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`. -Closes #514 +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 index 1c6cd509c..b770fd04b 100644 --- a/.squad-templates/machine-capabilities.md +++ b/.squad-templates/machine-capabilities.md @@ -72,4 +72,4 @@ spec: 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. +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/package-lock.json b/package-lock.json index 705f9dbdf..a66706bf4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@bradygaster/squad", - "version": "0.8.25-build.6", + "version": "0.8.25-build.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bradygaster/squad", - "version": "0.8.25-build.6", + "version": "0.8.25-build.10", "license": "MIT", "workspaces": [ "packages/*" @@ -30,7 +30,7 @@ "vitest": "^3.0.0" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.5.0" } }, "node_modules/@alcalzone/ansi-tokenize": { @@ -8778,7 +8778,7 @@ }, "packages/squad-cli": { "name": "@bradygaster/squad-cli", - "version": "0.8.25-build.6", + "version": "0.8.25-build.10", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -8799,12 +8799,12 @@ "typescript": "^5.7.0" }, "engines": { - "node": ">=20" + "node": ">=22.5.0" } }, "packages/squad-sdk": { "name": "@bradygaster/squad-sdk", - "version": "0.8.25-build.6", + "version": "0.8.25-build.10", "license": "MIT", "dependencies": { "@github/copilot-sdk": "^0.1.32", @@ -8816,7 +8816,7 @@ "typescript": "^5.7.0" }, "engines": { - "node": ">=20" + "node": ">=22.5.0" }, "optionalDependencies": { "@opentelemetry/api": "^1.9.0", 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 5fc8902e3..e87befd7f 100644 --- a/packages/squad-sdk/package.json +++ b/packages/squad-sdk/package.json @@ -78,6 +78,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" + }, "./casting": { "types": "./dist/casting/index.d.ts", "import": "./dist/casting/index.js" diff --git a/packages/squad-sdk/src/ralph/capabilities.ts b/packages/squad-sdk/src/ralph/capabilities.ts index 1f42f7f2d..8a00223e8 100644 --- a/packages/squad-sdk/src/ralph/capabilities.ts +++ b/packages/squad-sdk/src/ralph/capabilities.ts @@ -141,4 +141,4 @@ export function filterByCapabilities( } return { handled, skipped }; -} +} \ No newline at end of file From a96e1699eda79e7e251bd76f6bdf470ba3214db1 Mon Sep 17 00:00:00 2001 From: bradygaster Date: Sun, 22 Mar 2026 21:18:35 -0700 Subject: [PATCH 3/3] fix: revert package-lock.json engine requirement to >=20.0.0 The lockfile had been regenerated with >=22.5.0 engine requirements, which is a breaking change. Reverting to keep Node >=20 compatibility. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- package-lock.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index a66706bf4..705f9dbdf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@bradygaster/squad", - "version": "0.8.25-build.10", + "version": "0.8.25-build.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bradygaster/squad", - "version": "0.8.25-build.10", + "version": "0.8.25-build.6", "license": "MIT", "workspaces": [ "packages/*" @@ -30,7 +30,7 @@ "vitest": "^3.0.0" }, "engines": { - "node": ">=22.5.0" + "node": ">=20.0.0" } }, "node_modules/@alcalzone/ansi-tokenize": { @@ -8778,7 +8778,7 @@ }, "packages/squad-cli": { "name": "@bradygaster/squad-cli", - "version": "0.8.25-build.10", + "version": "0.8.25-build.6", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -8799,12 +8799,12 @@ "typescript": "^5.7.0" }, "engines": { - "node": ">=22.5.0" + "node": ">=20" } }, "packages/squad-sdk": { "name": "@bradygaster/squad-sdk", - "version": "0.8.25-build.10", + "version": "0.8.25-build.6", "license": "MIT", "dependencies": { "@github/copilot-sdk": "^0.1.32", @@ -8816,7 +8816,7 @@ "typescript": "^5.7.0" }, "engines": { - "node": ">=22.5.0" + "node": ">=20" }, "optionalDependencies": { "@opentelemetry/api": "^1.9.0",