Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/claude-linked-team.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@bradygaster/squad-sdk": patch
"@bradygaster/squad-cli": patch
---

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`.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ Claude Code is the first host after GitHub Copilot where Squad uses the host's o
- `.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.
- `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.
- 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`.
- **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.
- `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).
- `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`.
- `.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.
Expand Down
34 changes: 17 additions & 17 deletions docs/src/content/docs/reference/host-feature-matrix.md

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion packages/squad-cli/src/cli/commands/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
type ClientId,
type ProjectionFile,
} from '@bradygaster/squad-sdk/client';
import { readClientFlag, readConfiguredClients, readUnknownConfiguredClients, syncClients, reportClientResults, parseClients } from '../core/clients.js';
import { linkedTeamDir, readClientFlag, readConfiguredClients, readUnknownConfiguredClients, syncClients, reportClientResults, parseClients } from '../core/clients.js';
import { detectSquadDir } from '../core/detect-squad-dir.js';
import { projectSquadStateMcpSpec, resolveCommittedSquadStateMcpSpec } from '../core/mcp-spec.js';
import { getPackageVersion } from '../core/version.js';
Expand Down Expand Up @@ -62,6 +62,8 @@ export async function listClients(dest: string): Promise<ClientListResult> {
root: dest,
mcp: { command: spec.command, args: spec.args },
squadDir: path.relative(dest, squadDir).split(path.sep).join('/') || '.squad',
// A linked team's members are projected too, so they are expected here as well.
...linkedTeamDir(dest),
};

const clients: ClientStatus[] = CLIENT_IDS
Expand Down
19 changes: 19 additions & 0 deletions packages/squad-cli/src/cli/core/clients.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
type ClientId,
} from '@bradygaster/squad-sdk/client';
import { projectSquadStateMcpSpec, resolveCommittedSquadStateMcpSpec } from './mcp-spec.js';
import { resolveSquadPaths } from '@bradygaster/squad-sdk/resolution';
import { getPackageVersion } from './version.js';

export { CLIENT_IDS, parseClients, type ClientId };
Expand Down Expand Up @@ -153,6 +154,23 @@ export function persistClaudePlugin(squadDir: string, on: boolean): void {
* (it would otherwise re-warn on every upgrade); already-configured clients stay.
* Returns the clients that were synced and per-file results.
*/
/**
* The linked team's squad directory when the project at `dest` (its own `.squad/`, not a parent's)
* uses a team repository (`squad link`, `init --mode remote`), else null.
*/
export function linkedTeamSquadDir(dest: string): string | null {
const paths = resolveSquadPaths(dest);
return paths?.mode === 'remote' && path.resolve(paths.projectDir) === path.resolve(dest, '.squad')
? paths.teamSquadDir
: null;
}

/** `{ teamDir }` when `dest` is linked to a team repository (remote mode), else nothing. */
export function linkedTeamDir(dest: string): { teamDir?: string } {
const teamDir = linkedTeamSquadDir(dest);
return teamDir ? { teamDir } : {};
}

export async function syncClients(
dest: string,
squadDir: string,
Expand Down Expand Up @@ -189,6 +207,7 @@ export async function syncClients(
root: dest,
mcp: { command: spec.command, args: spec.args },
squadDir: path.relative(dest, squadDir).split(path.sep).join('/') || '.squad',
...linkedTeamDir(dest),
Comment thread
bradygaster marked this conversation as resolved.
Resolved
};
const results = installClients(clients, ctx);
const failedPaths = new Set(results.filter((r) => r.action === 'error').map((r) => r.path));
Expand Down
15 changes: 11 additions & 4 deletions packages/squad-cli/src/cli/core/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import {
reconcileSquadStateUserConfig,
tombstoneStaleSquadStateInProjectMcp,
} from './mcp-root.js';
import { applyClientProjectionsForInit, readConfiguredClients, reportClientResults, type ClientId } from './clients.js';
import { applyClientProjectionsForInit, linkedTeamSquadDir, readConfiguredClients, reportClientResults, type ClientId } from './clients.js';
import { getHostProjection, readClaudePluginMode } from '@bradygaster/squad-sdk/client';
import {
readTeamMd,
Expand Down Expand Up @@ -322,9 +322,15 @@ export async function runInit(dest: string, options: RunInitOptions = {}): Promi
showDeprecationWarning();
}

// A project linked to a team repository (`squad link`, `init --mode remote`) uses that team.
const linkedTeamDir = linkedTeamSquadDir(dest);
const linkedTeam = linkedTeamDir !== null;

// Build SDK options
const initOptions: InitOptions = {
teamRoot: dest,
linkedTeam,
...(linkedTeamDir ? { linkedTeamDir } : {}),
agentFileRoot,
projectName: path.basename(dest) || 'my-project',
agents: [
Expand Down Expand Up @@ -399,7 +405,7 @@ export async function runInit(dest: string, options: RunInitOptions = {}): Promi
// entries with overwriteOnUpgrade: false and copies each one only if absent.
try {
const templatesDir = getTemplatesDir();
ensureUserOwnedTemplates(dest, templatesDir);
ensureUserOwnedTemplates(dest, templatesDir, linkedTeam);
// Baseline for `squad upgrade`: hashes of every squad-owned file as shipped by this init.
recordShippedFilesAfterInit(dest, squadDir, templatesDir, version);
} catch {
Expand Down Expand Up @@ -594,8 +600,9 @@ export async function runInit(dest: string, options: RunInitOptions = {}): Promi
// Ask if the user wants to add @copilot as an autonomous team member.
// This enables .github/copilot-instructions.md and adds Coding Agent
// to the team roster, allowing squad-labeled issues to be auto-assigned.
const teamContent = readTeamMd(squadDir);
if (!hasCopilot(teamContent)) {
// A linked project's roster lives in the team repository: add @copilot there.
const teamContent = linkedTeam ? '' : readTeamMd(squadDir);
if (!linkedTeam && !hasCopilot(teamContent)) {
if (isInteractiveSession()) {
const { createInterface } = await import('node:readline/promises');
const rl = createInterface({ input: process.stdin, output: process.stdout });
Expand Down
26 changes: 20 additions & 6 deletions packages/squad-cli/src/cli/core/upgrade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import {
reconcileSquadStateUserConfig,
tombstoneStaleSquadStateInProjectMcp,
} from './mcp-root.js';
import { applyClientProjectionsForUpgrade, reportClientResults, type ClientId } from './clients.js';
import { applyClientProjectionsForUpgrade, linkedTeamSquadDir, reportClientResults, type ClientId } from './clients.js';
import {
applyRetiredFiles,
applySquadOwnedFiles,
Expand Down Expand Up @@ -387,7 +387,9 @@ function writeAgentTemplate(agentSrc: string, agentDest: string, cliVersion: str
// still succeeds if rendering fails, but the stale-data risk is surfaced.
if (options?.squadDir) {
try {
syncTeamCapabilities({ squadDir: options.squadDir, agentFile: agentDest, storage });
// A linked project advertises the linked team's cast.
const capabilitiesDir = linkedTeamSquadDir(path.resolve(options.squadDir, '..')) ?? options.squadDir;
syncTeamCapabilities({ squadDir: capabilitiesDir, agentFile: agentDest, storage });
} catch (error) {
warn(
`Could not refresh squad.agent.md team capabilities: ${
Expand Down Expand Up @@ -822,9 +824,14 @@ export function ensureGitignore(dest: string, options: { dryRun?: boolean } = {}
/**
* Create missing infrastructure directories
*/
/** Team directories a linked project (remote mode) leaves to its team repository. */
const LINKED_TEAM_DIRECTORIES = new Set(['.squad/agents', '.squad/casting', '.squad/identity']);

export function ensureDirectories(dest: string): string[] {
const created: string[] = [];
const linked = linkedTeamSquadDir(dest) !== null;
for (const dir of ENSURE_DIRECTORIES) {
if (linked && LINKED_TEAM_DIRECTORIES.has(dir.replace(/\\/g, '/').replace(/\/$/, ''))) continue;
const fullPath = path.join(dest, dir);
if (!storage.existsSync(fullPath)) {
storage.mkdirSync(fullPath, { recursive: true });
Expand Down Expand Up @@ -1131,6 +1138,7 @@ export async function ensureEsmImportsPatched(dest: string): Promise<boolean> {
* Idempotent; the later ensure pass finds nothing left to create.
*/
function preflightCastingPair(dest: string, templatesDir: string, filesUpdated: string[]): void {
if (linkedTeamSquadDir(dest) !== null) return; // The linked team repository holds the casting pair.
const created = ensureCastingDefaults(dest, templatesDir);
if (created.length > 0) {
success(`scaffolded ${created.length} default casting files`);
Expand All @@ -1144,6 +1152,7 @@ function preflightCastingPair(dest: string, templatesDir: string, filesUpdated:
* user-owned and is changed only by the explicit `squad roster` command.
*/
function prepareBuiltinAgents(dest: string, templatesDir: string, filesUpdated: string[]): void {
if (linkedTeamSquadDir(dest) !== null) return; // The built-ins' charters live in the linked team repository.
const raiMigration = migrateLegacyRaiAgent(dest);
if (raiMigration.migrated.length > 0) {
success(`migrated .squad/agents/Rai/ → .squad/agents/rai/${raiMigration.recordedInGit ? ' (recorded with git mv)' : ''}`);
Expand Down Expand Up @@ -1202,7 +1211,9 @@ async function runEnsureChecks(
filesUpdated.push(...dirsCreated);
}

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

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

for (const entry of userOwned) {
const srcPath = path.join(templatesDir, entry.source);
Expand Down
6 changes: 4 additions & 2 deletions packages/squad-cli/src/cli/shell/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import path from 'node:path';
import { FSStorageProvider } from '@bradygaster/squad-sdk';
import { resolveStateDir } from '../core/effective-squad-dir.js';
import { linkedTeamSquadDir } from '../core/clients.js';
import { SessionRegistry } from './sessions.js';
import { ShellRenderer } from './render.js';
import type { ShellState, ShellMessage } from './types.js';
Expand Down Expand Up @@ -75,8 +76,9 @@ export class ShellLifecycle {
throw err;
}

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

const teamPath = path.join(squadDir, 'team.md');
const teamContent = await storage.read(teamPath);
Expand Down
32 changes: 26 additions & 6 deletions packages/squad-sdk/src/client/adapters/claude-members.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ export function renderClaudeMember(
configEffort?: string,
/** `squad_state` tool-name prefix: `mcp__squad_state__` (project `.mcp.json`) or the plugin's. */
mcpPrefix = 'mcp__squad_state__',
linked = false,
): string {
const parsed = parseCharterMarkdown(charter);
const host = parseClaudeHostSettings(charter);
Expand Down Expand Up @@ -172,6 +173,22 @@ export function renderClaudeMember(

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

Expand All @@ -200,11 +217,14 @@ export function projectClaudeMembers(
mcpPrefix?: string,
): ProjectionFile[] {
const squadDir = ctx.squadDir ?? '.squad';
const squadAbs = path.resolve(ctx.root, squadDir);
// A linked team repository (remote mode) holds the members and routing.
const linked = ctx.teamDir !== undefined;
const squadAbs = linked ? path.resolve(ctx.teamDir!) : path.resolve(ctx.root, squadDir);
Comment thread
bradygaster marked this conversation as resolved.
Resolved
const agentsAbs = path.join(squadAbs, 'agents');
const routing = readRouting(squadAbs);
const ids = memberIds(agentsAbs);
const effortConfig = readEffortConfig(squadAbs);
// Effort overrides are this project's own settings, even when the team is linked.
const effortConfig = readEffortConfig(path.resolve(ctx.root, squadDir));
const files: ProjectionFile[] = [];
for (const id of ids) {
let charter: string;
Expand All @@ -218,7 +238,7 @@ export function projectClaudeMembers(
kind: 'managed',
path: `${CLAUDE_AGENTS_DIR}/${id}.md`,
ownedMarker,
content: renderClaudeMember(id, charter, routing, squadDir, generatedNote, effortConfig(id), mcpPrefix),
content: renderClaudeMember(id, charter, routing, squadDir, generatedNote, effortConfig(id), mcpPrefix, linked),
});
}

Expand Down
Loading
Loading