diff --git a/.changeset/fix-1397-1398-external-state-reads.md b/.changeset/fix-1397-1398-external-state-reads.md new file mode 100644 index 000000000..ba881a51a --- /dev/null +++ b/.changeset/fix-1397-1398-external-state-reads.md @@ -0,0 +1,5 @@ +--- +"@bradygaster/squad-cli": patch +--- + +`squad copilot` and `squad rc` now follow externalized state: roster reads and writes go to the external state dir when `.squad/config.json` has the `stateLocation: external` marker, instead of always using the local `.squad/team.md`. diff --git a/packages/squad-cli/src/cli/commands/copilot.ts b/packages/squad-cli/src/cli/commands/copilot.ts index 72a7c637b..e974a0240 100644 --- a/packages/squad-cli/src/cli/commands/copilot.ts +++ b/packages/squad-cli/src/cli/commands/copilot.ts @@ -8,7 +8,7 @@ import { FSStorageProvider } from '@bradygaster/squad-sdk'; const storage = new FSStorageProvider(); import { success, dim, bold, info, BOLD, RESET, DIM } from '../core/output.js'; -import { detectSquadDir } from '../core/detect-squad-dir.js'; +import { effectiveSquadDir } from '../core/effective-squad-dir.js'; import { readTeamMd, writeTeamMd, @@ -27,15 +27,15 @@ export interface CopilotFlags { * Run copilot command */ export async function runCopilot(dest: string, flags: CopilotFlags): Promise { - const squadDirInfo = detectSquadDir(dest); - const squadDir = squadDirInfo.path; + // Roster lives in the effective state dir when state is externalized (#1397) + const { stateDir } = effectiveSquadDir(dest); - // Ensure squad directory exists - if (!storage.existsSync(squadDir)) { + // Ensure squad state exists + if (!storage.existsSync(stateDir)) { throw new Error('No squad found — run init first, then add the copilot agent.'); } - let content = readTeamMd(squadDir); + let content = readTeamMd(stateDir); const copilotExists = hasCopilot(content); // Remove copilot @@ -47,7 +47,7 @@ export async function runCopilot(dest: string, flags: CopilotFlags): Promise { + const agents: Array<{ name: string; role: string }> = []; + const stateDir = resolveStateDir(squadDir); + const teamMd = storage.readSync(path.join(stateDir, 'team.md')) ?? ''; + const memberLines = teamMd.split('\n').filter(l => l.startsWith('|') && l.includes('Active')); + for (const line of memberLines) { + const cols = line.split('|').map(c => c.trim()).filter(Boolean); + if (cols.length >= 2 && cols[0] !== 'Name') { + agents.push({ name: cols[0]!, role: cols[1]! }); + } + } + return agents; +} + export async function runRC(cwd: string, options: RCOptions): Promise { const { repo, branch } = getGitInfo(cwd); const machine = getMachineId(); @@ -55,14 +75,7 @@ export async function runRC(cwd: string, options: RCOptions): Promise { const agents: Array<{name: string; role: string}> = []; if (squadDir) { try { - const teamMd = storage.readSync(path.join(squadDir, 'team.md')) ?? ''; - const memberLines = teamMd.split('\n').filter(l => l.startsWith('|') && l.includes('Active')); - for (const line of memberLines) { - const cols = line.split('|').map(c => c.trim()).filter(Boolean); - if (cols.length >= 2 && cols[0] !== 'Name') { - agents.push({ name: cols[0]!, role: cols[1]! }); - } - } + agents.push(...loadRosterAgents(squadDir)); console.log(` ${GREEN}✓${RESET} Loaded ${agents.length} agents from team.md\n`); } catch { console.log(` ${YELLOW}⚠${RESET} Could not read team.md\n`); diff --git a/test/cli/copilot.test.ts b/test/cli/copilot.test.ts index dacf68809..6c9d9dd9e 100644 --- a/test/cli/copilot.test.ts +++ b/test/cli/copilot.test.ts @@ -4,7 +4,7 @@ * Tests module exports and error handling for missing squad directory. */ -import { describe, it, expect, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mkdirSync, rmSync, existsSync, writeFileSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { randomBytes } from 'node:crypto'; @@ -64,3 +64,65 @@ describe('CLI: copilot command', () => { await expect(runCopilot(TEST_ROOT, {})).resolves.toBeUndefined(); }); }); + +describe('CLI: copilot with externalized state (#1397)', () => { + const EXT_GLOBAL = join(tmpdir(), `.test-cli-copilot-ext-global-${randomBytes(4).toString('hex')}`); + const EXT_PROJECT_KEY = `test-copilot-ext-${randomBytes(4).toString('hex')}`; + const externalStateDir = join(EXT_GLOBAL, 'squad', 'projects', EXT_PROJECT_KEY); + const origAppData = process.env['APPDATA']; + const origXdgConfig = process.env['XDG_CONFIG_HOME']; + + beforeEach(() => { + if (existsSync(TEST_ROOT)) rmSync(TEST_ROOT, { recursive: true, force: true }); + if (existsSync(EXT_GLOBAL)) rmSync(EXT_GLOBAL, { recursive: true, force: true }); + + // Point resolveGlobalSquadPath() inside EXT_GLOBAL (not the real user dir) + if (process.platform === 'win32') { + process.env['APPDATA'] = EXT_GLOBAL; + } else { + process.env['XDG_CONFIG_HOME'] = EXT_GLOBAL; + } + + // Local repo: thin .squad/ holding only the marker `squad externalize` leaves behind + mkdirSync(join(TEST_ROOT, '.squad'), { recursive: true }); + writeFileSync( + join(TEST_ROOT, '.squad', 'config.json'), + JSON.stringify({ version: 1, teamRoot: '.', projectKey: EXT_PROJECT_KEY, stateLocation: 'external' }, null, 2) + ); + + // External state dir: the real roster + mkdirSync(externalStateDir, { recursive: true }); + writeFileSync(join(externalStateDir, 'team.md'), '# Team\n\n## Members\n\n## Project Context\n'); + }); + + afterEach(() => { + if (origAppData === undefined) delete process.env['APPDATA']; + else process.env['APPDATA'] = origAppData; + if (origXdgConfig === undefined) delete process.env['XDG_CONFIG_HOME']; + else process.env['XDG_CONFIG_HOME'] = origXdgConfig; + + if (existsSync(TEST_ROOT)) rmSync(TEST_ROOT, { recursive: true, force: true }); + if (existsSync(EXT_GLOBAL)) rmSync(EXT_GLOBAL, { recursive: true, force: true }); + }); + + it('adds copilot to the external team.md, not a local copy', async () => { + const { runCopilot } = await import('@bradygaster/squad-cli/commands/copilot'); + + await runCopilot(TEST_ROOT, {}); + + const external = readFileSync(join(externalStateDir, 'team.md'), 'utf-8'); + expect(external).toContain('Coding Agent'); + // The marker-only local .squad/ must not grow a team.md + expect(existsSync(join(TEST_ROOT, '.squad', 'team.md'))).toBe(false); + }); + + it('removes copilot from the external team.md with --off', async () => { + const { runCopilot } = await import('@bradygaster/squad-cli/commands/copilot'); + + await runCopilot(TEST_ROOT, {}); + await runCopilot(TEST_ROOT, { off: true }); + + const external = readFileSync(join(externalStateDir, 'team.md'), 'utf-8'); + expect(external).not.toContain('🤖 Coding Agent'); + }); +}); diff --git a/test/cli/rc.test.ts b/test/cli/rc.test.ts index dc2cb9ab1..1eaf17d15 100644 --- a/test/cli/rc.test.ts +++ b/test/cli/rc.test.ts @@ -459,3 +459,72 @@ describe('CLI: rc command', () => { }); }); }); + +describe('loadRosterAgents — externalized state (#1398)', () => { + const suffix = Math.random().toString(36).slice(2, 10); + const ROOT = path.join(os.tmpdir(), `.test-rc-roster-${suffix}`); + const EXT_GLOBAL = path.join(os.tmpdir(), `.test-rc-roster-global-${suffix}`); + const PROJECT_KEY = `test-rc-ext-${suffix}`; + const externalStateDir = path.join(EXT_GLOBAL, 'squad', 'projects', PROJECT_KEY); + const origAppData = process.env['APPDATA']; + const origXdgConfig = process.env['XDG_CONFIG_HOME']; + const ROSTER_MD = + '# Team\n\n| Name | Role | Status |\n|------|------|--------|\n| Kovash | Lead | Active |\n| Edie | Dev | Active |\n'; + + beforeEach(() => { + for (const d of [ROOT, EXT_GLOBAL]) { + if (fs.existsSync(d)) fs.rmSync(d, { recursive: true, force: true }); + } + fs.mkdirSync(path.join(ROOT, '.squad'), { recursive: true }); + }); + + afterEach(() => { + if (origAppData === undefined) delete process.env['APPDATA']; + else process.env['APPDATA'] = origAppData; + if (origXdgConfig === undefined) delete process.env['XDG_CONFIG_HOME']; + else process.env['XDG_CONFIG_HOME'] = origXdgConfig; + + for (const d of [ROOT, EXT_GLOBAL]) { + if (fs.existsSync(d)) fs.rmSync(d, { recursive: true, force: true }); + } + }); + + it('reads the roster from local .squad when state is not externalized', async () => { + const { loadRosterAgents } = await import('@bradygaster/squad-cli/commands/rc'); + fs.writeFileSync(path.join(ROOT, '.squad', 'team.md'), ROSTER_MD); + + const agents = loadRosterAgents(path.join(ROOT, '.squad')); + + expect(agents).toEqual([ + { name: 'Kovash', role: 'Lead' }, + { name: 'Edie', role: 'Dev' }, + ]); + }); + + it('follows the stateLocation marker to the external team.md', async () => { + const { loadRosterAgents } = await import('@bradygaster/squad-cli/commands/rc'); + + // Point resolveGlobalSquadPath() inside EXT_GLOBAL (not the real user dir) + if (process.platform === 'win32') { + process.env['APPDATA'] = EXT_GLOBAL; + } else { + process.env['XDG_CONFIG_HOME'] = EXT_GLOBAL; + } + + fs.writeFileSync( + path.join(ROOT, '.squad', 'config.json'), + JSON.stringify({ version: 1, teamRoot: '.', projectKey: PROJECT_KEY, stateLocation: 'external' }) + ); + // Stale local roster that must NOT win over the external one + fs.writeFileSync(path.join(ROOT, '.squad', 'team.md'), '# Team\n| Stale | Old | Active |\n'); + fs.mkdirSync(externalStateDir, { recursive: true }); + fs.writeFileSync(path.join(externalStateDir, 'team.md'), ROSTER_MD); + + const agents = loadRosterAgents(path.join(ROOT, '.squad')); + + expect(agents).toEqual([ + { name: 'Kovash', role: 'Lead' }, + { name: 'Edie', role: 'Dev' }, + ]); + }); +});