diff --git a/.changeset/fix-1399-cast-external-state.md b/.changeset/fix-1399-cast-external-state.md new file mode 100644 index 000000000..b26e3245d --- /dev/null +++ b/.changeset/fix-1399-cast-external-state.md @@ -0,0 +1,6 @@ +--- +"@bradygaster/squad-sdk": minor +"@bradygaster/squad-cli": patch +--- + +`squad cast` now discovers project agents from the external state dir when state is externalized. `LocalAgentSource` accepts an optional explicit agents directory that overrides the `.squad/agents` probing, since externalized state keeps agents at `/agents` with no `.squad` nesting. diff --git a/packages/squad-cli/src/cli/commands/cast.ts b/packages/squad-cli/src/cli/commands/cast.ts index 778d69144..d51c5864c 100644 --- a/packages/squad-cli/src/cli/commands/cast.ts +++ b/packages/squad-cli/src/cli/commands/cast.ts @@ -10,7 +10,7 @@ import * as path from 'node:path'; import { LocalAgentSource } from '@bradygaster/squad-sdk/config/agent-source'; import { resolvePersonalAgents, mergeSessionCast } from '@bradygaster/squad-sdk/agents/personal'; -import { resolveSquadPaths } from '@bradygaster/squad-sdk/resolution'; +import { resolveSquadPaths, resolveExternalStateDir } from '@bradygaster/squad-sdk/resolution'; import { BOLD, RESET, DIM, GREEN, YELLOW } from '../core/output.js'; import { fatal } from '../core/errors.js'; @@ -32,7 +32,17 @@ export async function runCast(cwd: string): Promise { paths.mode === 'remote' ? paths.teamDir : path.resolve(paths.projectDir, '..'); - const projectSource = new LocalAgentSource(agentBase); + // #1399: when state is externalized, agents live at /agents + // (no .squad nesting), so the base-path probing above can't reach them — + // externalize sets teamRoot '.' which lands here as remote mode with a + // repo-local teamDir. Hand LocalAgentSource the explicit directory instead. + // (resolveExternalStateDir comes from the /resolution subpath on purpose: + // importing the sdk root barrel here adds seconds to this module's load.) + const externalAgentsDir = + paths.config?.stateLocation === 'external' && paths.config.projectKey + ? path.join(resolveExternalStateDir(paths.config.projectKey, false), 'agents') + : undefined; + const projectSource = new LocalAgentSource(agentBase, undefined, undefined, externalAgentsDir); const projectAgents = await projectSource.listAgents(); // Discover personal agents diff --git a/packages/squad-sdk/src/config/agent-source.ts b/packages/squad-sdk/src/config/agent-source.ts index 7c13a665d..175789bf6 100644 --- a/packages/squad-sdk/src/config/agent-source.ts +++ b/packages/squad-sdk/src/config/agent-source.ts @@ -103,12 +103,22 @@ export class LocalAgentSource implements AgentSource { private basePath: string, private storage: StorageProvider = new FSStorageProvider(), private state?: SquadState, + /** + * Explicit agents directory — skips the `/.squad/agents` + * probing. Needed for externalized state, where agents live at + * `/agents` with no `.squad` nesting (#1399). + */ + private agentsDir?: string, ) {} /** * Resolve the agents directory, preferring .squad/agents over .ai-team/agents. + * An explicit `agentsDir` from the constructor wins over probing. */ private async resolveAgentsDir(): Promise { + if (this.agentsDir) { + return (await this.storage.isDirectory(this.agentsDir)) ? this.agentsDir : null; + } for (const dir of AGENT_DIRS) { const fullPath = path.join(this.basePath, dir); if (await this.storage.isDirectory(fullPath)) return fullPath; diff --git a/test/cli/cast.test.ts b/test/cli/cast.test.ts index 992681412..984959fe2 100644 --- a/test/cli/cast.test.ts +++ b/test/cli/cast.test.ts @@ -150,4 +150,51 @@ describe('squad cast', () => { expect(output).toContain('RemoteAgent'); expect(output).toContain('Session Cast'); }); + + it('discovers agents from the external state dir when state is externalized (#1399)', async () => { + const extGlobal = join(TEST_ROOT, 'global'); + const projectKey = `test-cast-ext-${randomBytes(4).toString('hex')}`; + const origAppData = process.env['APPDATA']; + const origXdg = process.env['XDG_CONFIG_HOME']; + // Point resolveGlobalSquadPath() inside extGlobal (not the real user dir) + if (process.platform === 'win32') process.env['APPDATA'] = extGlobal; + else process.env['XDG_CONFIG_HOME'] = extGlobal; + + try { + // Local repo: marker .squad as `squad externalize` leaves it, plus a + // stale local agent that must NOT be discovered + const sq = join(TEST_ROOT, '.squad'); + await mkdir(join(sq, 'agents', 'stale-agent'), { recursive: true }); + await writeFile( + join(sq, 'config.json'), + JSON.stringify({ version: 1, teamRoot: '.', projectKey, stateLocation: 'external' }), + ); + await writeFile( + join(sq, 'agents', 'stale-agent', 'charter.md'), + `## Identity\n\n**Name:** StaleAgent\n**Role:** Leftover\n`, + ); + + // External state dir: agents live directly under it, no .squad nesting + const extAgent = join(extGlobal, 'squad', 'projects', projectKey, 'agents', 'ext-agent'); + await mkdir(extAgent, { recursive: true }); + await writeFile( + join(extAgent, 'charter.md'), + `## Identity\n\n**Name:** ExternalAgent\n**Role:** External Dev\n`, + ); + + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + const { runCast } = await import('@bradygaster/squad-cli/commands/cast'); + await runCast(TEST_ROOT); + + const output = logSpy.mock.calls.map(c => c.join(' ')).join('\n'); + expect(output).toContain('ExternalAgent'); + expect(output).not.toContain('StaleAgent'); + } finally { + if (origAppData === undefined) delete process.env['APPDATA']; + else process.env['APPDATA'] = origAppData; + if (origXdg === undefined) delete process.env['XDG_CONFIG_HOME']; + else process.env['XDG_CONFIG_HOME'] = origXdg; + } + }); }); diff --git a/test/config-integration.test.ts b/test/config-integration.test.ts index 8d1496330..f052cdeb8 100644 --- a/test/config-integration.test.ts +++ b/test/config-integration.test.ts @@ -171,6 +171,37 @@ describe('Integration: LocalAgentSource discovery', () => { expect(manifests).toEqual([]); }); + it('uses an explicit agentsDir override instead of probing .squad/agents (#1399)', async () => { + const base = fixture('project-external'); + // Decoy in the probed location — must lose when the override is set + const probedDir = join(base, '.squad', 'agents'); + mkdirSync(probedDir, { recursive: true }); + setupAgentDir(probedDir, 'decoy', MINIMAL_CHARTER); + // External-state layout: agents directly under the state dir, no .squad nesting + const externalAgents = join(base, 'external-state', 'agents'); + mkdirSync(externalAgents, { recursive: true }); + setupAgentDir(externalAgents, 'fenster', SAMPLE_CHARTER); + + const source = new LocalAgentSource(base, undefined, undefined, externalAgents); + const manifests = await source.listAgents(); + + expect(manifests.length).toBe(1); + expect(manifests[0].name).toBe('Fenster'); + }); + + it('returns empty array when the agentsDir override does not exist', async () => { + const base = fixture('project-external-missing'); + const probedDir = join(base, '.squad', 'agents'); + mkdirSync(probedDir, { recursive: true }); + setupAgentDir(probedDir, 'decoy', MINIMAL_CHARTER); + + // Override points at a missing dir — must not fall back to probing + const source = new LocalAgentSource(base, undefined, undefined, join(base, 'nope', 'agents')); + const manifests = await source.listAgents(); + + expect(manifests).toEqual([]); + }); + it('skips agents with missing charter.md', async () => { const base = fixture('project-missing-charter'); const agentsDir = join(base, '.squad', 'agents');