Skip to content

Commit 7cd97e8

Browse files
bradygasterclaude
andauthored
fix(remote): a project linked to a team repository uses that team (satellite repos) (#2500)
* fix(remote): a project linked to a team repository uses that team After `squad link` / `squad init --mode remote`, init and upgrade scaffolded a placeholder local team (empty roster, routing, built-ins, casting) and Claude Code's member subagents came from it, so the coordinator saw an empty team. - init/upgrade skip team files in a linked project; local state stays. - Claude member subagents are projected from the linked team (ClientContext.teamDir) and read their charters via squad_state. - Copilot's agent file advertises the linked team's cast. - Every coordinator digest gets a "Linked team" rule: read team files only through squad_state, never create a local roster. - Host matrix: dual-root-remote-mode supported on every host (Claude validated live); the monorepo TEAM_ROOT digest gap stays open. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(remote): address linked-team review - squad client list expects the linked team's member files - a linked member's charter is carried in its subagent file; history via squad_state_read only when its authored tool list allows (never widened) - effort overrides come from the project's own config - teamRoot "." is not a linked team in the coordinator rule - init advertises the linked team's cast in Copilot's agent file - the interactive shell reads the linked team's roster Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(plugin): regenerate after rebase on #2496 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 0bb4ef1 commit 7cd97e8

16 files changed

Lines changed: 520 additions & 289 deletions

File tree

‎.changeset/claude-linked-team.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@bradygaster/squad-sdk": patch
3+
"@bradygaster/squad-cli": patch
4+
---
5+
6+
Projects linked to a team repository (`squad link`, `squad init --mode remote`) use that team: `squad init`/`squad upgrade` no longer scaffold a placeholder local roster, routing, charters or casting; Claude Code member subagents are projected from the linked team; and every coordinator digest reads a linked team through `squad_state`.

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ Claude Code is the first host after GitHub Copilot where Squad uses the host's o
8686
- `.squad/templates/human-members.md` ships again. The template index listed it, but it had been missing since 2025. It describes adding a human to the roster and now includes the sign-off gate: a request to do gated work is not the sign-off.
8787
- `squad doctor` no longer fails the `squad_state` bridge check when the team folder has two spellings, such as macOS `/var` and `/private/var`. It compares the real paths.
8888
- When `squad_state` is unavailable and `.squad/config.json` sets a non-local `stateBackend`, the non-Copilot coordinators stop and say so instead of writing `.squad/decisions.md` directly, which that backend never reads. Seen in a live test with `two-layer`.
89+
- **A project linked to a team repository uses that team in Claude Code.** After `squad link` or `squad init --mode remote`, `squad init` and `squad upgrade` scaffolded a placeholder local team (an empty roster, routing, the built-ins, casting), and Claude Code's member subagents came from that empty team. Now a linked project gets no local roster, routing, charters or casting; its member subagents are projected from the linked team and read their charters through `squad_state`; Copilot's agent file advertises the linked team's cast; and every coordinator's digest says to read a linked team only through `squad_state`. The team repository is never written.
8990
- `squad init --client claude` tells you to run `claude --agent squad --mcp-config .mcp.json`, not Copilot. With several hosts it gives one line per host (#2453).
9091
- `squad_state` starts when Claude Code is started in a subfolder of the repository, such as a package in a monorepo. Claude Code finds the root `.mcp.json` from there but starts the server in the subfolder, so `node .squad/squad.mjs state-mcp` failed with "Cannot find module". The committed command now runs a short `node -e` script that finds the repository root (the nearest folder with `.git`) and runs only the `.squad/squad.mjs` there, so a launcher in a package's own `.squad/` or above the repository is never run. Run `squad upgrade` to rewrite an existing `.mcp.json`.
9192
- `.squad/squad.mjs` finds a `squad` on PATH. It used to skip every PATH entry, so a repository with only a global Squad CLI failed with "no Squad CLI found". Arguments reach the CLI unchanged on Windows (through npm's `.cmd` shims, never cmd.exe) and Linux, and a new CI job runs the launcher tests on both.

‎docs/src/content/docs/reference/host-feature-matrix.md‎

Lines changed: 17 additions & 17 deletions
Large diffs are not rendered by default.

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import {
1919
type ClientId,
2020
type ProjectionFile,
2121
} from '@bradygaster/squad-sdk/client';
22-
import { readClientFlag, readConfiguredClients, readUnknownConfiguredClients, syncClients, reportClientResults, parseClients } from '../core/clients.js';
22+
import { linkedTeamDir, readClientFlag, readConfiguredClients, readUnknownConfiguredClients, syncClients, reportClientResults, parseClients } from '../core/clients.js';
2323
import { detectSquadDir } from '../core/detect-squad-dir.js';
2424
import { projectSquadStateMcpSpec, resolveCommittedSquadStateMcpSpec } from '../core/mcp-spec.js';
2525
import { getPackageVersion } from '../core/version.js';
@@ -62,6 +62,8 @@ export async function listClients(dest: string): Promise<ClientListResult> {
6262
root: dest,
6363
mcp: { command: spec.command, args: spec.args },
6464
squadDir: path.relative(dest, squadDir).split(path.sep).join('/') || '.squad',
65+
// A linked team's members are projected too, so they are expected here as well.
66+
...linkedTeamDir(dest),
6567
};
6668

6769
const clients: ClientStatus[] = CLIENT_IDS

‎packages/squad-cli/src/cli/core/clients.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
type ClientId,
2929
} from '@bradygaster/squad-sdk/client';
3030
import { projectSquadStateMcpSpec, resolveCommittedSquadStateMcpSpec } from './mcp-spec.js';
31+
import { resolveSquadPaths } from '@bradygaster/squad-sdk/resolution';
3132
import { getPackageVersion } from './version.js';
3233

3334
export { CLIENT_IDS, parseClients, type ClientId };
@@ -153,6 +154,23 @@ export function persistClaudePlugin(squadDir: string, on: boolean): void {
153154
* (it would otherwise re-warn on every upgrade); already-configured clients stay.
154155
* Returns the clients that were synced and per-file results.
155156
*/
157+
/**
158+
* The linked team's squad directory when the project at `dest` (its own `.squad/`, not a parent's)
159+
* uses a team repository (`squad link`, `init --mode remote`), else null.
160+
*/
161+
export function linkedTeamSquadDir(dest: string): string | null {
162+
const paths = resolveSquadPaths(dest);
163+
return paths?.mode === 'remote' && path.resolve(paths.projectDir) === path.resolve(dest, '.squad')
164+
? paths.teamSquadDir
165+
: null;
166+
}
167+
168+
/** `{ teamDir }` when `dest` is linked to a team repository (remote mode), else nothing. */
169+
export function linkedTeamDir(dest: string): { teamDir?: string } {
170+
const teamDir = linkedTeamSquadDir(dest);
171+
return teamDir ? { teamDir } : {};
172+
}
173+
156174
export async function syncClients(
157175
dest: string,
158176
squadDir: string,
@@ -189,6 +207,7 @@ export async function syncClients(
189207
root: dest,
190208
mcp: { command: spec.command, args: spec.args },
191209
squadDir: path.relative(dest, squadDir).split(path.sep).join('/') || '.squad',
210+
...linkedTeamDir(dest),
192211
};
193212
const results = installClients(clients, ctx);
194213
const failedPaths = new Set(results.filter((r) => r.action === 'error').map((r) => r.path));

‎packages/squad-cli/src/cli/core/init.ts‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import {
2323
reconcileSquadStateUserConfig,
2424
tombstoneStaleSquadStateInProjectMcp,
2525
} from './mcp-root.js';
26-
import { applyClientProjectionsForInit, readConfiguredClients, reportClientResults, type ClientId } from './clients.js';
26+
import { applyClientProjectionsForInit, linkedTeamSquadDir, readConfiguredClients, reportClientResults, type ClientId } from './clients.js';
2727
import { getHostProjection, readClaudePluginMode } from '@bradygaster/squad-sdk/client';
2828
import {
2929
readTeamMd,
@@ -322,9 +322,15 @@ export async function runInit(dest: string, options: RunInitOptions = {}): Promi
322322
showDeprecationWarning();
323323
}
324324

325+
// A project linked to a team repository (`squad link`, `init --mode remote`) uses that team.
326+
const linkedTeamDir = linkedTeamSquadDir(dest);
327+
const linkedTeam = linkedTeamDir !== null;
328+
325329
// Build SDK options
326330
const initOptions: InitOptions = {
327331
teamRoot: dest,
332+
linkedTeam,
333+
...(linkedTeamDir ? { linkedTeamDir } : {}),
328334
agentFileRoot,
329335
projectName: path.basename(dest) || 'my-project',
330336
agents: [
@@ -399,7 +405,7 @@ export async function runInit(dest: string, options: RunInitOptions = {}): Promi
399405
// entries with overwriteOnUpgrade: false and copies each one only if absent.
400406
try {
401407
const templatesDir = getTemplatesDir();
402-
ensureUserOwnedTemplates(dest, templatesDir);
408+
ensureUserOwnedTemplates(dest, templatesDir, linkedTeam);
403409
// Baseline for `squad upgrade`: hashes of every squad-owned file as shipped by this init.
404410
recordShippedFilesAfterInit(dest, squadDir, templatesDir, version);
405411
} catch {
@@ -594,8 +600,9 @@ export async function runInit(dest: string, options: RunInitOptions = {}): Promi
594600
// Ask if the user wants to add @copilot as an autonomous team member.
595601
// This enables .github/copilot-instructions.md and adds Coding Agent
596602
// to the team roster, allowing squad-labeled issues to be auto-assigned.
597-
const teamContent = readTeamMd(squadDir);
598-
if (!hasCopilot(teamContent)) {
603+
// A linked project's roster lives in the team repository: add @copilot there.
604+
const teamContent = linkedTeam ? '' : readTeamMd(squadDir);
605+
if (!linkedTeam && !hasCopilot(teamContent)) {
599606
if (isInteractiveSession()) {
600607
const { createInterface } = await import('node:readline/promises');
601608
const rl = createInterface({ input: process.stdin, output: process.stdout });

‎packages/squad-cli/src/cli/core/upgrade.ts‎

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import {
3434
reconcileSquadStateUserConfig,
3535
tombstoneStaleSquadStateInProjectMcp,
3636
} from './mcp-root.js';
37-
import { applyClientProjectionsForUpgrade, reportClientResults, type ClientId } from './clients.js';
37+
import { applyClientProjectionsForUpgrade, linkedTeamSquadDir, reportClientResults, type ClientId } from './clients.js';
3838
import {
3939
applyRetiredFiles,
4040
applySquadOwnedFiles,
@@ -387,7 +387,9 @@ function writeAgentTemplate(agentSrc: string, agentDest: string, cliVersion: str
387387
// still succeeds if rendering fails, but the stale-data risk is surfaced.
388388
if (options?.squadDir) {
389389
try {
390-
syncTeamCapabilities({ squadDir: options.squadDir, agentFile: agentDest, storage });
390+
// A linked project advertises the linked team's cast.
391+
const capabilitiesDir = linkedTeamSquadDir(path.resolve(options.squadDir, '..')) ?? options.squadDir;
392+
syncTeamCapabilities({ squadDir: capabilitiesDir, agentFile: agentDest, storage });
391393
} catch (error) {
392394
warn(
393395
`Could not refresh squad.agent.md team capabilities: ${
@@ -822,9 +824,14 @@ export function ensureGitignore(dest: string, options: { dryRun?: boolean } = {}
822824
/**
823825
* Create missing infrastructure directories
824826
*/
827+
/** Team directories a linked project (remote mode) leaves to its team repository. */
828+
const LINKED_TEAM_DIRECTORIES = new Set(['.squad/agents', '.squad/casting', '.squad/identity']);
829+
825830
export function ensureDirectories(dest: string): string[] {
826831
const created: string[] = [];
832+
const linked = linkedTeamSquadDir(dest) !== null;
827833
for (const dir of ENSURE_DIRECTORIES) {
834+
if (linked && LINKED_TEAM_DIRECTORIES.has(dir.replace(/\\/g, '/').replace(/\/$/, ''))) continue;
828835
const fullPath = path.join(dest, dir);
829836
if (!storage.existsSync(fullPath)) {
830837
storage.mkdirSync(fullPath, { recursive: true });
@@ -1131,6 +1138,7 @@ export async function ensureEsmImportsPatched(dest: string): Promise<boolean> {
11311138
* Idempotent; the later ensure pass finds nothing left to create.
11321139
*/
11331140
function preflightCastingPair(dest: string, templatesDir: string, filesUpdated: string[]): void {
1141+
if (linkedTeamSquadDir(dest) !== null) return; // The linked team repository holds the casting pair.
11341142
const created = ensureCastingDefaults(dest, templatesDir);
11351143
if (created.length > 0) {
11361144
success(`scaffolded ${created.length} default casting files`);
@@ -1144,6 +1152,7 @@ function preflightCastingPair(dest: string, templatesDir: string, filesUpdated:
11441152
* user-owned and is changed only by the explicit `squad roster` command.
11451153
*/
11461154
function prepareBuiltinAgents(dest: string, templatesDir: string, filesUpdated: string[]): void {
1155+
if (linkedTeamSquadDir(dest) !== null) return; // The built-ins' charters live in the linked team repository.
11471156
const raiMigration = migrateLegacyRaiAgent(dest);
11481157
if (raiMigration.migrated.length > 0) {
11491158
success(`migrated .squad/agents/Rai/ → .squad/agents/rai/${raiMigration.recordedInGit ? ' (recorded with git mv)' : ''}`);
@@ -1202,7 +1211,9 @@ async function runEnsureChecks(
12021211
filesUpdated.push(...dirsCreated);
12031212
}
12041213

1205-
if (!options.skipCasting) {
1214+
// A project linked to a team repository keeps casting and team templates there.
1215+
const linkedTeam = linkedTeamSquadDir(dest) !== null;
1216+
if (!options.skipCasting && !linkedTeam) {
12061217
const castingFiles = ensureCastingDefaults(dest, templatesDir);
12071218
if (castingFiles.length > 0) {
12081219
success(`scaffolded ${castingFiles.length} default casting files`);
@@ -1221,7 +1232,7 @@ async function runEnsureChecks(
12211232
filesUpdated.push(...memoryFiles);
12221233
}
12231234

1224-
const userOwned = ensureUserOwnedTemplates(dest, templatesDir);
1235+
const userOwned = ensureUserOwnedTemplates(dest, templatesDir, linkedTeam);
12251236
if (userOwned.length > 0) {
12261237
success(`installed ${userOwned.length} missing user-owned template(s): ${userOwned.join(', ')}`);
12271238
filesUpdated.push(...userOwned);
@@ -1525,10 +1536,13 @@ export function ensureBuiltinAgents(dest: string, templatesDir: string): string[
15251536
* (via runEnsureChecks) so that newly-added user-owned templates are installed
15261537
* for existing squads on upgrade without overwriting any prior customization.
15271538
*/
1528-
export function ensureUserOwnedTemplates(dest: string, templatesDir: string): string[] {
1539+
export function ensureUserOwnedTemplates(dest: string, templatesDir: string, linkedTeam = false): string[] {
15291540
const created: string[] = [];
15301541
const squadDir = path.join(dest, '.squad');
1531-
const userOwned = TEMPLATE_MANIFEST.filter(f => !f.overwriteOnUpgrade && !f.source.startsWith('skills/'));
1542+
// A project linked to a team repository keeps the team's own files (routing, ceremonies,
1543+
// identity) there: only files outside `.squad/` are seeded locally.
1544+
const userOwned = TEMPLATE_MANIFEST.filter(f => !f.overwriteOnUpgrade && !f.source.startsWith('skills/') &&
1545+
(!linkedTeam || f.destination.startsWith('../')));
15321546

15331547
for (const entry of userOwned) {
15341548
const srcPath = path.join(templatesDir, entry.source);

‎packages/squad-cli/src/cli/shell/lifecycle.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import path from 'node:path';
1111
import { FSStorageProvider } from '@bradygaster/squad-sdk';
1212
import { resolveStateDir } from '../core/effective-squad-dir.js';
13+
import { linkedTeamSquadDir } from '../core/clients.js';
1314
import { SessionRegistry } from './sessions.js';
1415
import { ShellRenderer } from './render.js';
1516
import type { ShellState, ShellMessage } from './types.js';
@@ -75,8 +76,9 @@ export class ShellLifecycle {
7576
throw err;
7677
}
7778

78-
// Resolve effective state dir (external when externalized)
79-
const squadDir = resolveStateDir(localSquadDir);
79+
// The team's files come from a linked team repository when there is one (remote mode), then
80+
// from external state when externalized. Project-local files (.init-prompt) stay in localSquadDir.
81+
const squadDir = resolveStateDir(linkedTeamSquadDir(this.options.teamRoot) ?? localSquadDir);
8082

8183
const teamPath = path.join(squadDir, 'team.md');
8284
const teamContent = await storage.read(teamPath);

‎packages/squad-sdk/src/client/adapters/claude-members.ts‎

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ export function renderClaudeMember(
137137
configEffort?: string,
138138
/** `squad_state` tool-name prefix: `mcp__squad_state__` (project `.mcp.json`) or the plugin's. */
139139
mcpPrefix = 'mcp__squad_state__',
140+
linked = false,
140141
): string {
141142
const parsed = parseCharterMarkdown(charter);
142143
const host = parseClaudeHostSettings(charter);
@@ -172,6 +173,22 @@ export function renderClaudeMember(
172173

173174
const charterPath = `${squadDir}/agents/${id}/charter.md`;
174175
const historyPath = `${squadDir}/agents/${id}/history.md`;
176+
// A linked team's files are outside this project, so the charter is carried in this file (it is
177+
// regenerated on every sync). The history is read through squad_state only when the authored tool
178+
// list allows it; the list is never widened.
179+
const canReadState =
180+
!host.tools || host.tools.some((t) => t === `${mcpPrefix}squad_state_read` || t === `${mcpPrefix}*` || t === mcpPrefix.replace(/__$/, ''));
181+
if (linked && !canReadState) {
182+
notes.push(`History not readable (the Tools list has no \`${mcpPrefix}squad_state_read\`; the spawn prompt must carry it)`);
183+
}
184+
const readCharter = linked
185+
? `This project uses a linked team repository. Your charter from it is below. ` +
186+
(canReadState
187+
? `Read your history with \`squad_state_read\` (key \`agents/${id}/history.md\`), not from \`${squadDir}/\`. `
188+
: `Your history is not available to you here: rely on the spawn prompt. `)
189+
: `Before you start, read your charter at \`${charterPath}\` and your history at \`${historyPath}\`. ` +
190+
`If the spawn prompt gives a TEAM ROOT, resolve these paths under it instead. `;
191+
const linkedCharter = linked ? `\n## Your charter (linked team repository)\n\n${charter.replace(/\r\n?/g, '\n').trim()}\n` : '';
175192
const notesBlock =
176193
notes.length > 0
177194
? `\n<!-- Charter preferences not projected to Claude Code: ${notes.join('; ').replace(/--/g, '- -')}. -->\n`
@@ -181,11 +198,11 @@ export function renderClaudeMember(
181198
`---\n${fields.map(([k, v]) => `${k}: ${v}`).join('\n')}\n---\n` +
182199
`${generatedNote}\n${notesBlock}\n` +
183200
`You are **${name}**${role ? `, ${role},` : ''} on this project's Squad team.\n\n` +
184-
`Before you start, read your charter at \`${charterPath}\` and your history at \`${historyPath}\`. ` +
185-
`If the spawn prompt gives a TEAM ROOT, resolve these paths under it instead. ` +
201+
readCharter +
186202
`Your charter is authoritative: follow its responsibilities and boundaries, and the task in the spawn prompt.\n\n` +
187203
`Squad dispatches specialists only from the coordinator: do not spawn subagents of your own. ` +
188-
`Read and write shared team state through the \`squad_state\` MCP tools (\`${mcpPrefix}*\`) when they are available.\n`
204+
`Read and write shared team state through the \`squad_state\` MCP tools (\`${mcpPrefix}*\`) when they are available.\n` +
205+
linkedCharter
189206
);
190207
}
191208

@@ -200,11 +217,14 @@ export function projectClaudeMembers(
200217
mcpPrefix?: string,
201218
): ProjectionFile[] {
202219
const squadDir = ctx.squadDir ?? '.squad';
203-
const squadAbs = path.resolve(ctx.root, squadDir);
220+
// A linked team repository (remote mode) holds the members and routing.
221+
const linked = ctx.teamDir !== undefined;
222+
const squadAbs = linked ? path.resolve(ctx.teamDir!) : path.resolve(ctx.root, squadDir);
204223
const agentsAbs = path.join(squadAbs, 'agents');
205224
const routing = readRouting(squadAbs);
206225
const ids = memberIds(agentsAbs);
207-
const effortConfig = readEffortConfig(squadAbs);
226+
// Effort overrides are this project's own settings, even when the team is linked.
227+
const effortConfig = readEffortConfig(path.resolve(ctx.root, squadDir));
208228
const files: ProjectionFile[] = [];
209229
for (const id of ids) {
210230
let charter: string;
@@ -218,7 +238,7 @@ export function projectClaudeMembers(
218238
kind: 'managed',
219239
path: `${CLAUDE_AGENTS_DIR}/${id}.md`,
220240
ownedMarker,
221-
content: renderClaudeMember(id, charter, routing, squadDir, generatedNote, effortConfig(id), mcpPrefix),
241+
content: renderClaudeMember(id, charter, routing, squadDir, generatedNote, effortConfig(id), mcpPrefix, linked),
222242
});
223243
}
224244

0 commit comments

Comments
 (0)