Skip to content

Commit 059b31e

Browse files
cherie-qCopilotbradygastertamirdresher
authored
fix(sdk,cli): resolve linked team state for both teamRoot forms (#2119)
teamRoot can name the team repo (what `squad link` writes) or the team's .squad/ dir (what the docs show), but callers assumed different forms: effectiveSquadDir and cast appended .squad, while the state MCP server did not. The docs form broke `squad loop`/`watch`/`cast`; the link form made squad_decide write decisions outside .squad/. Add ResolvedSquadPaths.teamSquadDir, which resolves either form to the team's squad dir (legacy .ai-team included), and use it in effectiveSquadDir, cast, state-mcp, and the local-backend storage root. Document on teamDir that callers must not join team-state paths onto it. `squad doctor` now warns when a linked team repo still holds state-mcp output outside .squad/. It checks every path the state tools can write (exported as MUTABLE_STATE_PATHS, with isMutableStateKey) and lists each .md/.json file to move, or to merge by hand when the same file already exists under .squad/. It reports only and does not move files. The SDK changeset is minor because teamSquadDir is a required field on the exported ResolvedSquadPaths type. Closes #2107 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Brady Gaster <41929050+bradygaster@users.noreply.github.com> Co-authored-by: Tamir Dresher <tamir.dresher@gmail.com>
1 parent b846a1d commit 059b31e

16 files changed

Lines changed: 417 additions & 36 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"@bradygaster/squad-sdk": minor
3+
"@bradygaster/squad-cli": patch
4+
---
5+
6+
Linked teams now work with either `teamRoot` form: the team repository (what `squad link` writes) or the team's `.squad/` directory (what the docs show). `resolveSquadPaths()` adds `teamSquadDir`, the team's squad directory for both forms, and `resolveSquadState()` roots local-backend storage there. `squad loop`/`watch`, `squad cast`, and the state MCP server read team state from `teamSquadDir`, so `squad loop` no longer reports "No squad found" for the docs form, and `squad_decide` no longer writes decisions outside `.squad/` for the `squad link` form. Closes #2107.
7+
8+
`teamSquadDir` is a required field on the exported `ResolvedSquadPaths` type, so code that constructs that type must now set it. `@bradygaster/squad-sdk/tools` also exports `MUTABLE_STATE_PATHS` and `isMutableStateKey()`, the list of state paths that the state tools can write.
9+
10+
If you linked a team with `squad link`: state that the state MCP server wrote to the team repo root (`decisions.md`, `decisions/inbox/`, `casting/policy.json`, `agents/*/history.md`, `log/`, `orchestration-log/`, `sessions/`, `.scratch/`, `identity/`) now belongs in `<team>/.squad/`. Squad does not move it. Run `squad doctor` to find leftovers. It lists the `.md` and `.json` files to move to the same relative path under `<team>/.squad/` (for example `<team>/identity/now.md` to `<team>/.squad/identity/now.md`), and the files that already exist there, which you must merge by hand without overwriting the newer state. Do not replace the existing `<team>/.squad/` directories. Skills that `squad_skill` wrote went to `.github/skills/` in the folder that contains the team repo; move any you want to keep to `<team>/.github/skills/`. Doctor does not check that folder.

‎docs/src/content/docs/guide/faq.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,8 @@ If your project links to a remote team repository:
131131

132132
The path should be relative to your **project root** (where `.squad/` or `squad.config.ts` lives), not to the `.squad/` directory itself.
133133

134+
`teamRoot` can name either the team repository (`"../team-repo"`, which is what `squad link` writes) or its `.squad/` directory (`"../team-repo/.squad"`). Squad resolves both forms to the same team directory.
135+
134136
**Verify the fix:**
135137
```bash
136138
squad doctor

‎packages/squad-cli/src/cli/commands/cast.ts‎

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,11 @@ export async function runCast(cwd: string): Promise<void> {
2525
}
2626

2727
// Discover project agents.
28-
// LocalAgentSource appends .squad/agents to its base path, so we must supply:
29-
// - local mode: parent of paths.projectDir (the repo root)
30-
// - remote mode: paths.teamDir (the team repo root, which itself contains .squad/agents)
31-
const agentBase =
32-
paths.mode === 'remote'
33-
? paths.teamDir
34-
: path.resolve(paths.projectDir, '..');
28+
// LocalAgentSource appends .squad/agents to its base path, so local mode
29+
// supplies the parent of paths.projectDir (the repo root). Remote mode hands
30+
// it the team's agents dir explicitly, because teamRoot may name either the
31+
// team repo or its .squad/ dir (#2107).
32+
const agentBase = path.resolve(paths.projectDir, '..');
3533
// #1399: when state is externalized, agents live at <externalStateDir>/agents
3634
// (no .squad nesting), so the base-path probing above can't reach them —
3735
// externalize sets teamRoot '.' which lands here as remote mode with a
@@ -42,7 +40,10 @@ export async function runCast(cwd: string): Promise<void> {
4240
paths.config?.stateLocation === 'external' && paths.config.projectKey
4341
? path.join(resolveExternalStateDir(paths.config.projectKey, false), 'agents')
4442
: undefined;
45-
const projectSource = new LocalAgentSource(agentBase, undefined, undefined, externalAgentsDir);
43+
const agentsDir =
44+
externalAgentsDir
45+
?? (paths.mode === 'remote' ? path.join(paths.teamSquadDir, 'agents') : undefined);
46+
const projectSource = new LocalAgentSource(agentBase, undefined, undefined, agentsDir);
4647
const projectAgents = await projectSource.listAgents();
4748

4849
// Discover personal agents

‎packages/squad-cli/src/cli/commands/doctor.ts‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import path from 'node:path';
1414
import { execFile, execFileSync } from 'node:child_process';
1515
import { FSStorageProvider, resolveStateBackend, type StateBackendType } from '@bradygaster/squad-sdk';
1616
import { readCastingRegistryPair } from '@bradygaster/squad-sdk/casting';
17+
import { resolveSquadPaths, clearResolveSquadCache } from '@bradygaster/squad-sdk/resolution';
18+
import { isMutableStateKey, MUTABLE_STATE_PATHS } from '@bradygaster/squad-sdk/tools';
1719
import { resolveStateDir } from '../core/effective-squad-dir.js';
1820

1921
const storage = new FSStorageProvider();
@@ -158,6 +160,77 @@ function checkTeamRootResolves(squadDir: string, teamRoot: string): DoctorCheck
158160
};
159161
}
160162

163+
/** Squad writes state as .md and .json; other files in a code dir named log/ etc. are not ours. */
164+
const STRAY_STATE_FILE = /\.(md|json)$/;
165+
const STRAY_STATE_MAX_DEPTH = 4;
166+
const STRAY_STATE_LIST_LIMIT = 10;
167+
168+
/** Push `/`-separated keys of files under `base/rel`. Skips dirs it cannot list. */
169+
function collectFileKeys(base: string, rel: string, depth: number, out: string[]): void {
170+
let entries: string[];
171+
try {
172+
entries = storage.listSync(path.join(base, rel));
173+
} catch {
174+
return;
175+
}
176+
for (const name of entries) {
177+
const key = `${rel}/${name}`;
178+
const full = path.join(base, key);
179+
if (isDirectory(full)) {
180+
if (depth > 1) collectFileKeys(base, key, depth - 1, out);
181+
} else {
182+
out.push(key);
183+
}
184+
}
185+
}
186+
187+
function formatKeyList(keys: string[]): string {
188+
const shown = keys.slice(0, STRAY_STATE_LIST_LIMIT).join(', ');
189+
return keys.length > STRAY_STATE_LIST_LIMIT ? `${shown}, and ${keys.length - STRAY_STATE_LIST_LIMIT} more` : shown;
190+
}
191+
192+
/**
193+
* Before #2107, a `squad link` project (teamRoot = the team repo) made the
194+
* state MCP server write team state to `<teamRoot>/<key>` instead of
195+
* `<teamRoot>/.squad/<key>`. Squad no longer reads those files. Report every
196+
* state-tool path (MUTABLE_STATE_PATHS) found there, split into files to move
197+
* and files to merge by hand because a newer copy exists. Read-only.
198+
*/
199+
function checkStrandedLinkedTeamState(squadDir: string): DoctorCheck | undefined {
200+
// Doctor must see the current disk, not a walk cached by an earlier call.
201+
clearResolveSquadCache();
202+
const paths = resolveSquadPaths(path.dirname(squadDir));
203+
if (!paths || paths.mode !== 'remote' || paths.projectDir !== path.resolve(squadDir)) return undefined;
204+
if (paths.teamSquadDir === paths.teamDir) return undefined;
205+
206+
const candidates: string[] = [];
207+
for (const { root, kind } of MUTABLE_STATE_PATHS) {
208+
const full = path.join(paths.teamDir, root);
209+
if (kind === 'file') {
210+
if (fileExists(full) && !isDirectory(full)) candidates.push(root);
211+
} else if (isDirectory(full)) {
212+
collectFileKeys(paths.teamDir, root, STRAY_STATE_MAX_DEPTH, candidates);
213+
}
214+
}
215+
// Match only what the state tools could write, so an unrelated code dir
216+
// named agents/ or log/ does not trigger advice to move it.
217+
const stray = candidates.filter(key => isMutableStateKey(key) && STRAY_STATE_FILE.test(key));
218+
if (stray.length === 0) return undefined;
219+
220+
const toMerge = stray.filter(key => fileExists(path.join(paths.teamSquadDir, key)));
221+
const toMove = stray.filter(key => !toMerge.includes(key));
222+
const parts = [
223+
`found ${stray.length} team state file(s) in ${paths.teamDir}, outside the team's squad dir. Squad now reads team state from ${paths.teamSquadDir}.`,
224+
];
225+
// teamSquadDir normally has its own agents/ and decisions/, so tell the user
226+
// to move files, not to move (and replace) the directories.
227+
if (toMove.length > 0) parts.push(`Move each file to the same relative path under it: ${formatKeyList(toMove)}.`);
228+
if (toMerge.length > 0) parts.push(`A file already exists at the same relative path for these; merge by hand and do not overwrite the newer state: ${formatKeyList(toMerge)}.`);
229+
parts.push('Do not replace the existing directories there.');
230+
231+
return { name: 'linked team state location', status: 'warn', message: parts.join(' ') };
232+
}
233+
161234
function checkTeamMd(squadDir: string): DoctorCheck {
162235
const teamPath = path.join(squadDir, 'team.md');
163236
if (!fileExists(teamPath)) {
@@ -717,6 +790,8 @@ export async function runDoctor(cwd?: string): Promise<DoctorCheck[]> {
717790
// 4. Remote team root resolution
718791
if (mode === 'remote' && teamRoot) {
719792
checks.push(checkTeamRootResolves(squadDir, teamRoot));
793+
const strayState = checkStrandedLinkedTeamState(squadDir);
794+
if (strayState) checks.push(strayState);
720795
}
721796

722797
// 5–9 standard files (only if .squad/ exists)

‎packages/squad-cli/src/cli/commands/state-mcp.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ export function createStateMcpToolRegistry(startDir: string): ToolRegistry {
6969
if (!context) {
7070
throw new Error(`No .squad directory found from ${startDir}`);
7171
}
72-
return new ToolRegistry(context.paths.teamDir, undefined, context.storage);
72+
return new ToolRegistry(context.paths.teamSquadDir, undefined, context.storage);
7373
}
7474

7575
function normalizeToolResult(result: unknown): { content: Array<{ type: 'text'; text: string }>; isError?: boolean } {

‎packages/squad-cli/src/cli/core/effective-squad-dir.ts‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
* @module cli/core/effective-squad-dir
88
*/
99

10-
import path from 'node:path';
1110
import { detectSquadDir, type SquadDirInfo } from './detect-squad-dir.js';
1211
import {
1312
loadDirConfig,
@@ -52,7 +51,7 @@ export function effectiveSquadDir(dest: string): EffectiveSquadDirs {
5251
const paths = resolveSquadPaths(dest);
5352
const teamSquadDir =
5453
paths?.mode === 'remote'
55-
? path.join(paths.teamDir, paths.name)
54+
? paths.teamSquadDir
5655
: local.path;
5756
return {
5857
local,

‎packages/squad-sdk/src/resolution.ts‎

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -132,8 +132,19 @@ export interface ResolvedSquadPaths {
132132
mode: 'local' | 'remote';
133133
/** Project-local .squad/ (decisions, logs) */
134134
projectDir: string;
135-
/** Team identity root (agents, casting, skills) */
135+
/**
136+
* Team identity root, exactly as configured. In remote mode this is the
137+
* resolved `teamRoot`, which may be either the team repo (parent of
138+
* `.squad/`) or the team's `.squad/` dir itself. Do not join team-state
139+
* paths (`.squad`, `agents/`, `decisions/`) onto it — the result depends on
140+
* which form the user wrote. Use {@link ResolvedSquadPaths.teamSquadDir}.
141+
*/
136142
teamDir: string;
143+
/**
144+
* The team's squad directory (team.md, agents/, casting/), whichever form
145+
* `teamRoot` uses. Equals projectDir in local mode.
146+
*/
147+
teamSquadDir: string;
137148
/** User's personal squad dir, null if not found or disabled */
138149
personalDir: string | null;
139150
config: SquadDirConfig | null;
@@ -344,6 +355,26 @@ export function isConsultMode(config: SquadDirConfig | null): boolean {
344355
return config?.consult === true;
345356
}
346357

358+
/**
359+
* Resolve the team's squad directory from a remote `teamRoot` (#2107).
360+
*
361+
* `squad link` writes `teamRoot` as the team repo (the parent of `.squad/`),
362+
* while the docs show the team's `.squad/` dir itself; the coordinator accepts
363+
* both. Prefer a nested squad dir; otherwise use teamDir when it already is
364+
* one. Falls back to the nested path so a missing team reports "not found".
365+
*/
366+
function resolveTeamSquadDir(teamDir: string, name: '.squad' | '.ai-team'): string {
367+
const names = name === '.squad' ? ['.squad', '.ai-team'] : ['.ai-team', '.squad'];
368+
for (const candidate of names) {
369+
const nested = path.join(teamDir, candidate);
370+
if (storage.existsSync(nested) && storage.isDirectorySync(nested)) return nested;
371+
}
372+
if (names.includes(path.basename(teamDir)) || storage.existsSync(path.join(teamDir, 'team.md'))) {
373+
return teamDir;
374+
}
375+
return path.join(teamDir, name);
376+
}
377+
347378
/**
348379
* Resolve dual-root squad paths (projectDir / teamDir).
349380
*
@@ -377,6 +408,7 @@ export function resolveSquadPaths(startDir?: string): ResolvedSquadPaths | null
377408
mode: 'remote',
378409
projectDir,
379410
teamDir,
411+
teamSquadDir: resolveTeamSquadDir(teamDir, name),
380412
personalDir: resolvePersonalSquadDir(),
381413
config,
382414
name,
@@ -389,6 +421,7 @@ export function resolveSquadPaths(startDir?: string): ResolvedSquadPaths | null
389421
mode: 'local',
390422
projectDir,
391423
teamDir: projectDir,
424+
teamSquadDir: projectDir,
392425
personalDir: resolvePersonalSquadDir(),
393426
config,
394427
name,
@@ -851,12 +884,12 @@ export function resolveSquadState(startDir?: string, cliOverride?: StateBackendT
851884

852885
// For local backend, use FSStorageProvider directly (more capable).
853886
// For git-notes/orphan, bridge via StateBackendStorageAdapter.
854-
// rootDir is paths.teamDir (matches the squadRoot every local-backend
887+
// rootDir is paths.teamSquadDir (matches the squadRoot every local-backend
855888
// caller — e.g. ToolRegistry in state-mcp.ts — builds its paths against),
856889
// so the traversal guard actually validates instead of no-op'ing on an
857890
// unset rootDir and letting a bad upstream path resolve silently.
858891
const stateStorage: StorageProvider = backend.name === 'local'
859-
? new FSStorageProvider(paths.teamDir)
892+
? new FSStorageProvider(paths.teamSquadDir)
860893
: new StateBackendStorageAdapter(backend, paths.projectDir);
861894

862895
return { paths, backend, repoRoot, storage: stateStorage };

‎packages/squad-sdk/src/tools/index.ts‎

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -255,23 +255,31 @@ function normalizeStateToolDir(dir?: string): string {
255255
return normalized;
256256
}
257257

258-
const MUTABLE_CASTING_STATE_KEYS = new Set([
259-
'casting/policy.json',
260-
]);
258+
/**
259+
* State paths, relative to the squad dir, that the state tools may write.
260+
* `root` is a file or the directory to search; `pattern`, when set, is the
261+
* only key shape allowed under that directory.
262+
*/
263+
export const MUTABLE_STATE_PATHS: ReadonlyArray<{ readonly root: string; readonly kind: 'file' | 'dir'; readonly pattern?: RegExp }> = [
264+
{ root: 'decisions.md', kind: 'file' },
265+
{ root: 'decisions/inbox', kind: 'dir' },
266+
{ root: 'casting/policy.json', kind: 'file' },
267+
{ root: 'agents', kind: 'dir', pattern: /^agents\/[a-zA-Z0-9_-]+\/history\.md$/ },
268+
{ root: 'log', kind: 'dir' },
269+
{ root: 'orchestration-log', kind: 'dir' },
270+
{ root: 'sessions', kind: 'dir' },
271+
{ root: '.scratch', kind: 'dir' },
272+
{ root: 'identity', kind: 'dir' },
273+
];
274+
275+
/** True when the state tools may write `key` (a normalized, `/`-separated state key). */
276+
export function isMutableStateKey(key: string): boolean {
277+
return MUTABLE_STATE_PATHS.some(({ root, kind, pattern }) =>
278+
kind === 'file' ? key === root : key.startsWith(`${root}/`) && (!pattern || pattern.test(key)));
279+
}
261280

262281
function validateMutableStateToolKey(key: string): void {
263-
const isMutable =
264-
key === 'decisions.md' ||
265-
key.startsWith('decisions/inbox/') ||
266-
MUTABLE_CASTING_STATE_KEYS.has(key) ||
267-
/^agents\/[a-zA-Z0-9_-]+\/history\.md$/.test(key) ||
268-
key.startsWith('log/') ||
269-
key.startsWith('orchestration-log/') ||
270-
key.startsWith('sessions/') ||
271-
key.startsWith('.scratch/') ||
272-
key.startsWith('identity/');
273-
274-
if (!isMutable) {
282+
if (!isMutableStateKey(key)) {
275283
throw new Error(
276284
'State mutations are limited to mutable runtime state (decisions, inbox, casting policy, logs, sessions, scratch files, agent history, and identity). The casting registry/history pair must only be changed through the atomic casting protocol. Static config such as config.json, team.md, routing.md, charters, templates, and skills must not be changed with state tools.',
277285
);

‎test/cli/cast.test.ts‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* Verifies the cast command correctly discovers project agents
55
* by deriving the correct base path from resolveSquadPaths():
66
* - local mode: parent of paths.projectDir (repo root)
7-
* - remote mode: paths.teamDir (team repo root)
7+
* - remote mode: paths.teamSquadDir/agents (either teamRoot form, #2107)
88
* Regression tests for #871 (double-nested .squad/.squad/agents path).
99
*/
1010

@@ -151,6 +151,32 @@ describe('squad cast', () => {
151151
expect(output).toContain('Session Cast');
152152
});
153153

154+
it("discovers agents in remote mode when teamRoot names the team's .squad dir (#2107)", async () => {
155+
const projectRoot = join(TEST_ROOT, 'project');
156+
const teamRoot = join(TEST_ROOT, 'team');
157+
158+
const projectSq = join(projectRoot, '.squad');
159+
await mkdir(projectSq, { recursive: true });
160+
await writeFile(
161+
join(projectSq, 'config.json'),
162+
JSON.stringify({ version: 1, teamRoot: '../team/.squad' }),
163+
);
164+
165+
await mkdir(join(teamRoot, '.squad', 'agents', 'remote-agent'), { recursive: true });
166+
await writeFile(
167+
join(teamRoot, '.squad', 'agents', 'remote-agent', 'charter.md'),
168+
`## Identity\n\n**Name:** RemoteAgent\n**Role:** Remote Engineer\n`,
169+
);
170+
171+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
172+
173+
const { runCast } = await import('@bradygaster/squad-cli/commands/cast');
174+
await runCast(projectRoot);
175+
176+
const output = logSpy.mock.calls.map(c => c.join(' ')).join('\n');
177+
expect(output, 'teamRoot=../team/.squad').toContain('RemoteAgent');
178+
});
179+
154180
it('discovers agents from the external state dir when state is externalized (#1399)', async () => {
155181
const extGlobal = join(TEST_ROOT, 'global');
156182
const projectKey = `test-cast-ext-${randomBytes(4).toString('hex')}`;

0 commit comments

Comments
 (0)