diff --git a/.github/agents/squad.agent.md b/.github/agents/squad.agent.md index 1a3813a33..7d74af93f 100644 --- a/.github/agents/squad.agent.md +++ b/.github/agents/squad.agent.md @@ -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." --- - + 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) diff --git a/.gitignore b/.gitignore index 971f14978..30c1a24ef 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/test/capabilities.test.ts b/test/capabilities.test.ts index 8bd2b6248..940503b61 100644 --- a/test/capabilities.test.ts +++ b/test/capabilities.test.ts @@ -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'; @@ -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(); + }); }); \ No newline at end of file diff --git a/test/cli/upgrade-semver.test.ts b/test/cli/upgrade-semver.test.ts new file mode 100644 index 000000000..1fcea9f3b --- /dev/null +++ b/test/cli/upgrade-semver.test.ts @@ -0,0 +1,245 @@ +/** + * Unit tests for the upgrade module semver helpers and pluggable API. + * + * These tests cover the pure functions (parseVersion, compareVersions, isNewer) + * and the pluggable upgrade workflows (checkForUpdate, performUpgrade, upgradeSDK). + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { + parseVersion, + compareVersions, + isNewer, + checkForUpdate, + performUpgrade, + upgradeSDK, + setVersionFetcher, + setPackageJsonReader, + setPackageJsonWriter, +} from '@bradygaster/squad-cli/upgrade'; + +// ============================================================================ +// parseVersion +// ============================================================================ + +describe('parseVersion', () => { + it('parses a simple semver string', () => { + const v = parseVersion('1.2.3'); + expect(v.major).toBe(1); + expect(v.minor).toBe(2); + expect(v.patch).toBe(3); + expect(v.prerelease).toBe(''); + expect(v.raw).toBe('1.2.3'); + }); + + it('parses a version with prerelease suffix', () => { + const v = parseVersion('0.9.1-alpha.0'); + expect(v.major).toBe(0); + expect(v.minor).toBe(9); + expect(v.patch).toBe(1); + expect(v.prerelease).toBe('alpha.0'); + expect(v.raw).toBe('0.9.1-alpha.0'); + }); + + it('parses zero version', () => { + const v = parseVersion('0.0.0'); + expect(v.major).toBe(0); + expect(v.minor).toBe(0); + expect(v.patch).toBe(0); + }); + + it('throws on invalid version string', () => { + expect(() => parseVersion('not-a-version')).toThrow('Invalid version'); + expect(() => parseVersion('1.2')).toThrow('Invalid version'); + expect(() => parseVersion('')).toThrow('Invalid version'); + }); +}); + +// ============================================================================ +// compareVersions +// ============================================================================ + +describe('compareVersions', () => { + it('returns 0 for equal versions', () => { + expect(compareVersions('1.0.0', '1.0.0')).toBe(0); + expect(compareVersions('0.9.1', '0.9.1')).toBe(0); + }); + + it('compares major versions', () => { + expect(compareVersions('2.0.0', '1.0.0')).toBeGreaterThan(0); + expect(compareVersions('1.0.0', '2.0.0')).toBeLessThan(0); + }); + + it('compares minor versions', () => { + expect(compareVersions('1.2.0', '1.1.0')).toBeGreaterThan(0); + expect(compareVersions('1.1.0', '1.2.0')).toBeLessThan(0); + }); + + it('compares patch versions', () => { + expect(compareVersions('1.0.2', '1.0.1')).toBeGreaterThan(0); + expect(compareVersions('1.0.1', '1.0.2')).toBeLessThan(0); + }); + + it('release > prerelease for same base version', () => { + expect(compareVersions('1.0.0', '1.0.0-alpha')).toBeGreaterThan(0); + expect(compareVersions('1.0.0-alpha', '1.0.0')).toBeLessThan(0); + }); + + it('compares prerelease strings lexicographically', () => { + expect(compareVersions('1.0.0-alpha', '1.0.0-beta')).toBeLessThan(0); + expect(compareVersions('1.0.0-beta', '1.0.0-alpha')).toBeGreaterThan(0); + expect(compareVersions('1.0.0-alpha.0', '1.0.0-alpha.0')).toBe(0); + }); +}); + +// ============================================================================ +// isNewer +// ============================================================================ + +describe('isNewer', () => { + it('returns true when candidate is newer', () => { + expect(isNewer('1.0.0', '1.0.1')).toBe(true); + expect(isNewer('0.9.0', '1.0.0')).toBe(true); + }); + + it('returns false when candidate is same version', () => { + expect(isNewer('1.0.0', '1.0.0')).toBe(false); + }); + + it('returns false when candidate is older', () => { + expect(isNewer('1.0.1', '1.0.0')).toBe(false); + expect(isNewer('2.0.0', '1.9.9')).toBe(false); + }); + + it('release is newer than prerelease of the same base', () => { + expect(isNewer('1.0.0-alpha', '1.0.0')).toBe(true); + expect(isNewer('1.0.0', '1.0.0-alpha')).toBe(false); + }); +}); + +// ============================================================================ +// checkForUpdate (with pluggable version fetcher) +// ============================================================================ + +describe('checkForUpdate', () => { + beforeEach(() => { + setVersionFetcher(async () => '2.0.0'); + }); + + it('returns UpdateInfo when a newer version is available', async () => { + const result = await checkForUpdate('1.0.0'); + expect(result).not.toBeNull(); + expect(result!.newVersion).toBe('2.0.0'); + expect(result!.releaseUrl).toContain('v2.0.0'); + expect(result!.changelog).toContain('1.0.0'); + expect(result!.changelog).toContain('2.0.0'); + }); + + it('returns null when already on latest', async () => { + setVersionFetcher(async () => '1.0.0'); + const result = await checkForUpdate('1.0.0'); + expect(result).toBeNull(); + }); + + it('returns null when current is newer than latest', async () => { + setVersionFetcher(async () => '0.5.0'); + const result = await checkForUpdate('1.0.0'); + expect(result).toBeNull(); + }); +}); + +// ============================================================================ +// performUpgrade +// ============================================================================ + +describe('performUpgrade', () => { + it('succeeds when there is a newer version', async () => { + const info = { newVersion: '2.0.0', releaseUrl: 'https://example.com', changelog: 'changes' }; + const result = await performUpgrade(info, '1.0.0'); + expect(result.success).toBe(true); + expect(result.fromVersion).toBe('1.0.0'); + expect(result.toVersion).toBe('2.0.0'); + }); + + it('returns dry-run result when dryRun is true', async () => { + const info = { newVersion: '2.0.0', releaseUrl: 'https://example.com', changelog: 'changes' }; + const result = await performUpgrade(info, '1.0.0', { dryRun: true }); + expect(result.success).toBe(true); + expect(result.changes[0]).toContain('[dry-run]'); + }); + + it('fails when not forced and already on latest', async () => { + const info = { newVersion: '1.0.0', releaseUrl: 'https://example.com', changelog: 'changes' }; + const result = await performUpgrade(info, '1.0.0'); + expect(result.success).toBe(false); + expect(result.changes[0]).toContain('Already on latest'); + }); + + it('succeeds when forced even if already on latest', async () => { + const info = { newVersion: '1.0.0', releaseUrl: 'https://example.com', changelog: 'changes' }; + const result = await performUpgrade(info, '1.0.0', { force: true }); + expect(result.success).toBe(true); + }); +}); + +// ============================================================================ +// upgradeSDK (with pluggable reader/writer/fetcher) +// ============================================================================ + +describe('upgradeSDK', () => { + let writtenVersion: string | null; + + beforeEach(() => { + writtenVersion = null; + setVersionFetcher(async () => '2.0.0'); + setPackageJsonReader(async () => ({ + version: '1.0.0', + dependencies: { '@bradygaster/squad': '^1.0.0' }, + })); + setPackageJsonWriter(async (_dir, version) => { + writtenVersion = version; + }); + }); + + it('upgrades SDK when newer version is available', async () => { + const result = await upgradeSDK('/fake/project'); + expect(result.success).toBe(true); + expect(result.fromVersion).toBe('1.0.0'); + expect(result.toVersion).toBe('2.0.0'); + expect(writtenVersion).toBe('2.0.0'); + }); + + it('returns already on latest when no upgrade needed', async () => { + setVersionFetcher(async () => '1.0.0'); + const result = await upgradeSDK('/fake/project'); + expect(result.success).toBe(true); + expect(result.changes[0]).toContain('already on latest'); + }); + + it('reports when SDK package is not found in dependencies', async () => { + setPackageJsonReader(async () => ({ + version: '1.0.0', + dependencies: {}, + })); + const result = await upgradeSDK('/fake/project'); + expect(result.success).toBe(false); + expect(result.changes[0]).toContain('not found'); + }); + + it('handles dry-run mode', async () => { + const result = await upgradeSDK('/fake/project', { dryRun: true }); + expect(result.success).toBe(true); + expect(result.changes[0]).toContain('[dry-run]'); + expect(writtenVersion).toBeNull(); // Nothing written + }); + + it('strips ^ prefix from current version before comparison', async () => { + setPackageJsonReader(async () => ({ + version: '1.0.0', + dependencies: { '@bradygaster/squad': '^1.9.0' }, + })); + const result = await upgradeSDK('/fake/project'); + expect(result.success).toBe(true); + expect(result.fromVersion).toBe('1.9.0'); + }); +}); diff --git a/test/rate-limiting.test.ts b/test/rate-limiting.test.ts index 78b472c83..2216ee7b7 100644 --- a/test/rate-limiting.test.ts +++ b/test/rate-limiting.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { getTrafficLight, shouldProceed, @@ -6,9 +6,13 @@ import { PredictiveCircuitBreaker, canUseQuota, consumeQuota, + loadRatePool, type RatePool, type AgentPriority, } from '@bradygaster/squad-sdk/ralph/rate-limiting'; +import { mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; describe('getTrafficLight', () => { it('returns green when >20% remaining', () => { @@ -277,3 +281,59 @@ describe('consumeQuota', () => { expect(canUseQuota(pool, 'ralph')).toBe(false); }); }); + +describe('loadRatePool', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = path.join(os.tmpdir(), `squad-rp-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(path.join(tmpDir, '.squad'), { recursive: true }); + }); + + afterEach(() => { + try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ } + }); + + it('returns null when no rate-pool file exists', async () => { + const pool = await loadRatePool(tmpDir); + expect(pool).toBeNull(); + }); + + it('loads a valid rate-pool.json from teamRoot', async () => { + const futureExpiry = new Date(Date.now() + 300000).toISOString(); + const poolData: RatePool = { + totalLimit: 5000, + resetAt: futureExpiry, + allocations: { + picard: { priority: 0, allocated: 2000, used: 100, leaseExpiry: futureExpiry }, + }, + }; + writeFileSync( + path.join(tmpDir, '.squad', 'rate-pool.json'), + JSON.stringify(poolData), + ); + + const pool = await loadRatePool(tmpDir); + expect(pool).not.toBeNull(); + expect(pool!.totalLimit).toBe(5000); + expect(pool!.allocations.picard).toBeDefined(); + expect(pool!.allocations.picard!.allocated).toBe(2000); + }); + + it('returns null for malformed JSON', async () => { + writeFileSync( + path.join(tmpDir, '.squad', 'rate-pool.json'), + '{ broken JSON !!', + ); + + const pool = await loadRatePool(tmpDir); + expect(pool).toBeNull(); + }); + + it('returns null when teamRoot is undefined and no home fallback exists', async () => { + const pool = await loadRatePool(undefined); + // Result depends on whether ~/.squad/rate-pool.json exists, + // but the function should not throw. + expect(pool === null || typeof pool.totalLimit === 'number').toBe(true); + }); +});