diff --git a/.changeset/2056-doctor-remote-state-dir.md b/.changeset/2056-doctor-remote-state-dir.md new file mode 100644 index 000000000..b51d87519 --- /dev/null +++ b/.changeset/2056-doctor-remote-state-dir.md @@ -0,0 +1,5 @@ +--- +"@bradygaster/squad-cli": patch +--- + +`squad doctor` now validates team state files (`team.md`, `routing.md`, `agents/`, `casting/registry.json`, `decisions.md`) against the resolved team root in remote (linked) mode instead of the local `.squad/` stub, which only holds `config.json`. Previously this caused false "file not found" failures for correctly configured `squad link` setups even though `squad status` and `squad cast` resolved the team fine. Fixes #2056. diff --git a/packages/squad-cli/src/cli/commands/doctor.ts b/packages/squad-cli/src/cli/commands/doctor.ts index bc1d93ced..345861d8b 100644 --- a/packages/squad-cli/src/cli/commands/doctor.ts +++ b/packages/squad-cli/src/cli/commands/doctor.ts @@ -16,7 +16,7 @@ import { FSStorageProvider, resolveStateBackend, type StateBackendType } from '@ import { readCastingRegistryPair } from '@bradygaster/squad-sdk/casting'; import { resolveSquadPaths, clearResolveSquadCache } from '@bradygaster/squad-sdk/resolution'; import { isMutableStateKey, MUTABLE_STATE_PATHS } from '@bradygaster/squad-sdk/tools'; -import { resolveStateDir } from '../core/effective-squad-dir.js'; +import { effectiveSquadDir } from '../core/effective-squad-dir.js'; const storage = new FSStorageProvider(); @@ -796,8 +796,11 @@ export async function runDoctor(cwd?: string): Promise { // 5–9 standard files (only if .squad/ exists) if (isDirectory(squadDir)) { - // Resolve effective state dir for externalized files - const stateDir = resolveStateDir(squadDir); + // Resolve effective state dir for externalized files and remote team + // roots — mirrors the dual-root resolver used by `squad cast`/`squad status` + // so linked (remote-mode) projects are validated against the team root's + // .squad/ instead of the local stub that only holds config.json (#2056). + const { stateDir } = effectiveSquadDir(resolvedCwd); checks.push(checkTeamMd(stateDir)); checks.push(checkRoutingMd(stateDir)); checks.push(checkAgentsDir(stateDir)); diff --git a/test/cli/doctor.test.ts b/test/cli/doctor.test.ts index 3ab8d9add..cbb35242a 100644 --- a/test/cli/doctor.test.ts +++ b/test/cli/doctor.test.ts @@ -116,6 +116,39 @@ describe('squad doctor', () => { expect(rootCheck?.status).toBe('fail'); }); + it('validates state files against the resolved team root in remote mode (#2056)', async () => { + // Central team root: TEST_ROOT/.squad holds the real team state. + await scaffold(TEST_ROOT); + + // Linked project: only .squad/config.json + the agent discovery file, + // pointing back at the central root via a relative teamRoot. + const linkedProject = join(TEST_ROOT, 'linked-project'); + await mkdir(join(linkedProject, '.squad'), { recursive: true }); + await writeFile( + join(linkedProject, '.squad', 'config.json'), + JSON.stringify({ version: 1, teamRoot: '..', projectKey: null }), + ); + await mkdir(join(linkedProject, '.github', 'agents'), { recursive: true }); + await writeFile(join(linkedProject, '.github', 'agents', 'squad.agent.md'), '# Squad Agent\n'); + + const checks = await runDoctor(linkedProject); + + const rootCheck = checks.find((c: DoctorCheck) => c.name === 'team root resolves'); + expect(rootCheck?.status).toBe('pass'); + + for (const name of [ + 'team.md found with ## Members header', + 'routing.md found', + 'agents/ directory exists', + 'casting/registry.json exists', + 'decisions.md exists', + ]) { + const check = checks.find((c: DoctorCheck) => c.name === name); + expect(check, `expected a check named "${name}"`).toBeDefined(); + expect(check?.status, `expected "${name}" to pass, got: ${check?.message}`).toBe('pass'); + } + }); + // One row per MUTABLE_STATE_PATHS entry: the state tools could write each of // these to the team repo root before #2107. `scaffold()` already puts // decisions.md in the team's .squad/, so that row is a merge, not a move.