Skip to content
Draft
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
4 changes: 2 additions & 2 deletions .github/agents/squad.agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@ name: Squad
description: "Your AI team. Describe what you're building, get a team of specialists that live in your repo."
---

<!-- version: 0.0.0-source -->
<!-- version: 0.9.1 -->

You are **Squad (Coordinator)** — the orchestrator for this project's AI team.

### Coordinator Identity

- **Name:** Squad (Coordinator)
- **Version:** 0.0.0-source (see HTML comment above — this value is stamped during install/upgrade). Include it as `Squad v{version}` in your first response of each session (e.g., in the acknowledgment or greeting).
- **Version:** 0.9.1 (see HTML comment above — this value is stamped during install/upgrade). Include it as `Squad v0.9.1` in your first response of each session (e.g., in the acknowledgment or greeting).
- **Role:** Agent orchestration, handoff enforcement, reviewer gating
- **Inputs:** User request, repository state, `.squad/decisions.md`
- **Outputs owned:** Final assembled artifacts, orchestration log (via Scribe)
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,5 @@ docs/tests/screenshots/

# Images folder (root only — don't ignore docs/public/images/)
/images/
# Squad: ignore runtime state (logs, inbox, sessions)
.squad/.scratch/
127 changes: 127 additions & 0 deletions test/capabilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
getDeploymentMode,
getPodId,
generatePodCapabilitiesPath,
KNOWN_CAPABILITIES,
type MachineCapabilities,
} from '@bradygaster/squad-sdk/ralph/capabilities';
import { existsSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
Expand Down Expand Up @@ -239,4 +240,130 @@ describe('dual-mode deployment', () => {
process.env.SQUAD_POD_ID = 'my-pod-42';
expect(getPodId()).toBe('my-pod-42');
});
});

describe('generatePodCapabilitiesPath', () => {
it('builds the correct pod-specific manifest path', () => {
const result = generatePodCapabilitiesPath('/app', 'squad-worker-7b4f6');
expect(result).toBe(path.join('/app', '.squad', 'machine-capabilities-squad-worker-7b4f6.json'));
});

it('handles team root with trailing separator', () => {
const result = generatePodCapabilitiesPath('/app/', 'pod-1');
expect(result).toBe(path.join('/app', '.squad', 'machine-capabilities-pod-1.json'));
});

it('handles different pod identifiers', () => {
const result = generatePodCapabilitiesPath('/home/user/project', 'my-pod-42');
expect(result).toBe(path.join('/home/user/project', '.squad', 'machine-capabilities-my-pod-42.json'));
});
});

describe('KNOWN_CAPABILITIES', () => {
it('exports the expected set of well-known capabilities', () => {
expect(KNOWN_CAPABILITIES).toContain('browser');
expect(KNOWN_CAPABILITIES).toContain('gpu');
expect(KNOWN_CAPABILITIES).toContain('docker');
expect(KNOWN_CAPABILITIES).toContain('personal-gh');
expect(KNOWN_CAPABILITIES).toContain('emu-gh');
expect(KNOWN_CAPABILITIES).toContain('azure-cli');
expect(KNOWN_CAPABILITIES).toContain('onedrive');
expect(KNOWN_CAPABILITIES).toContain('teams-mcp');
});

it('is a readonly tuple (frozen)', () => {
// KNOWN_CAPABILITIES is declared `as const`, so it's readonly at runtime
expect(Array.isArray(KNOWN_CAPABILITIES)).toBe(true);
expect(KNOWN_CAPABILITIES.length).toBe(8);
});
});

describe('loadCapabilities edge cases', () => {
let savedPodId: string | undefined;
let savedMode: string | undefined;
let tmpDir: string;

beforeEach(() => {
savedPodId = process.env.SQUAD_POD_ID;
savedMode = process.env.SQUAD_DEPLOYMENT_MODE;
delete process.env.SQUAD_POD_ID;
delete process.env.SQUAD_DEPLOYMENT_MODE;

tmpDir = path.join(os.tmpdir(), `squad-cap-edge-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(path.join(tmpDir, '.squad'), { recursive: true });
});

afterEach(() => {
if (savedPodId !== undefined) process.env.SQUAD_POD_ID = savedPodId;
else delete process.env.SQUAD_POD_ID;
if (savedMode !== undefined) process.env.SQUAD_DEPLOYMENT_MODE = savedMode;
else delete process.env.SQUAD_DEPLOYMENT_MODE;

try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
});

it('returns null when no capabilities file exists anywhere', async () => {
const caps = await loadCapabilities(tmpDir);
// No file written → null (opt-in system)
expect(caps).toBeNull();
});

it('returns null when teamRoot is undefined and no home fallback', async () => {
// This tests the fallback path: only the home dir candidate is tried.
// We can't control the home dir, but if no file is there it returns null.
const caps = await loadCapabilities(undefined);
// Result depends on whether ~/.squad/machine-capabilities.json exists,
// but the function should not throw either way.
expect(caps === null || typeof caps.machine === 'string').toBe(true);
});

it('skips malformed JSON and returns null', async () => {
writeFileSync(
path.join(tmpDir, '.squad', 'machine-capabilities.json'),
'{ this is not valid JSON !!!',
);

const caps = await loadCapabilities(tmpDir);
expect(caps).toBeNull();
});

it('reads shared manifest in default agent-per-node mode', async () => {
const manifest: MachineCapabilities = {
machine: 'DEV-LAPTOP',
capabilities: ['browser', 'docker'],
missing: ['gpu'],
lastUpdated: '2026-04-01T00:00:00Z',
};
writeFileSync(
path.join(tmpDir, '.squad', 'machine-capabilities.json'),
JSON.stringify(manifest),
);

const caps = await loadCapabilities(tmpDir);
expect(caps).not.toBeNull();
expect(caps!.machine).toBe('DEV-LAPTOP');
expect(caps!.capabilities).toEqual(['browser', 'docker']);
expect(caps!.podId).toBeUndefined();
});

it('does not stamp podId in agent-per-node mode even if SQUAD_POD_ID is set', async () => {
process.env.SQUAD_POD_ID = 'some-pod';
// SQUAD_DEPLOYMENT_MODE is not set → defaults to agent-per-node

const manifest: MachineCapabilities = {
machine: 'NODE-1',
capabilities: ['browser'],
missing: [],
lastUpdated: '2026-04-01T00:00:00Z',
};
writeFileSync(
path.join(tmpDir, '.squad', 'machine-capabilities.json'),
JSON.stringify(manifest),
);

const caps = await loadCapabilities(tmpDir);
expect(caps).not.toBeNull();
expect(caps!.machine).toBe('NODE-1');
expect(caps!.podId).toBeUndefined();
});
});
Loading