Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/linked-team-git-backend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bradygaster/squad-sdk": patch
---

A linked project (`teamRoot` in `.squad/config.json`) on the `orphan` or `two-layer` state backend can use `squad_state` again. `resolveSquadState` ran the git backend in the project repo and rooted its storage adapter at the project's `.squad`, while every tool builds paths under the team's `.squad`, so each `memory.*`, `squad_state_write` and `squad_state_read` call failed with "path is outside squadDir". The team repo owns the state, so the git backend now runs in the team repo (on its `squad-state` branch) and the adapter is rooted at the team's `.squad`, the same root the local backend already used. The team repo's git top level must be exactly the team repo (compared after resolving links, drive letters and case), so a team dir that only sits inside another repository, or a `.squad` linked out of the team repo, never gets state written into that other repository. In those cases, and when the team root is not in a git repository, resolution fails with a message that says so, instead of failing on every call.
2 changes: 2 additions & 0 deletions docs/src/content/docs/features/state-backends.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ If a non-default backend fails to initialize (e.g., Git is not available, permis
```
Warning: State backend 'two-layer' failed: <reason>. Falling back to 'local'.
```
### Linked Projects
A project linked to a team repo (`teamRoot` in its `.squad/config.json`, which `squad link` writes) keeps its state with the team. With `orphan` or `two-layer`, that is the team repo's `squad-state` branch, not the project's. The backend type is still read from the project's `.squad/config.json`. The team repo must be a Git repository of its own: its Git root has to be the team repo itself (a worktree counts), after symlinks and junctions are resolved. If the team root isn't in a Git repository, only sits inside another repository, or its `.squad/` is a link out of the team repo, Squad stops with an error naming the team root instead of falling back to `local` or writing state into another repository.

---
## Comparison
Expand Down
98 changes: 93 additions & 5 deletions packages/squad-sdk/src/resolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import os from 'node:os';
import crypto from 'node:crypto';
import { execFileSync } from 'node:child_process';
import { FSStorageProvider } from './storage/fs-storage-provider.js';
import { resolveStateBackend, StateBackendStorageAdapter, type StateBackend, type StateBackendType } from './state-backend.js';
import { configuredStateBackend, resolveStateBackend, StateBackendStorageAdapter, type StateBackend, type StateBackendType } from './state-backend.js';
import type { StorageProvider } from './storage/storage-provider.js';

const storage = new FSStorageProvider();
Expand Down Expand Up @@ -928,7 +928,11 @@ export interface SquadStateContext {
paths: ResolvedSquadPaths;
/** The active state backend (local, git-notes, or orphan) */
backend: StateBackend;
/** The repo root directory (for git-native backends) */
/**
* The project's repo root directory (worktree-aware). For a linked team on a
* git backend, the state branch lives in the team repo instead; the backend
* is already bound to it, so callers keep using this for project paths.
*/
repoRoot: string;
/** StorageProvider backed by the active state backend — pass to SDK modules */
storage: StorageProvider;
Expand Down Expand Up @@ -961,18 +965,102 @@ export function resolveSquadState(startDir?: string, cliOverride?: StateBackendT
repoRoot = path.resolve(paths.projectDir, '..');
}

// A linked team owns its state (#2320): a git backend keeps it on the team
// repo's state branch, not the project's. Fail closed when that repo cannot
// be established exactly, instead of falling back to 'local' or to whatever
// repository happens to contain the team dir. Only the backend moves:
// repoRoot stays the project's repository, where hooks and agent turns work.
let stateRepoRoot = repoRoot;
if (paths.mode === 'remote') {
const { type } = configuredStateBackend(paths.projectDir, cliOverride);
if (type === 'orphan' || type === 'two-layer') {
stateRepoRoot = linkedTeamRepoRoot(paths, type);
}
}

// Resolve the backend from config + CLI override
const backend = resolveStateBackend(paths.projectDir, repoRoot, cliOverride);
const backend = resolveStateBackend(paths.projectDir, stateRepoRoot, cliOverride);

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

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

/**
* The repository a linked team's git state backend writes to (#2320).
*
* The team repo is the directory that holds the team's squad dir: `teamRoot` itself when
* it contains `.squad/` (what `squad link` writes), the parent when `teamRoot` names the
* `.squad/` dir, or `teamRoot` when the squad files sit at its root. Its git top level must
* be exactly that directory (a linked worktree's root counts). Containing it is not enough:
* a team dir with no repository of its own that sits inside another one, whether reached
* through `..`, an absolute `teamRoot` or a symlink/junction, is refused, and so is a team
* `.squad` that is a link out of the team dir. Paths are compared canonically (realpath,
* so links, 8.3 names and drive-letter/case differences on Windows don't matter).
*/
function linkedTeamRepoRoot(paths: ResolvedSquadPaths, type: StateBackendType): string {
const prefix = `The linked team at ${paths.teamDir} uses the '${type}' state backend, which keeps state on a branch of the team repo`;
const fix = `or set "stateBackend" to "local" in ${path.join(paths.projectDir, 'config.json')}.`;
const teamDir = canonicalDir(paths.teamDir);
const squadDir = canonicalDir(paths.teamSquadDir);
if (teamDir === null || squadDir === null || !storage.existsSync(teamDir) || !storage.isDirectorySync(teamDir)) {
throw new Error(`${prefix}, but the team root does not exist or cannot be resolved. Check "teamRoot", ${fix}`);
}

// The team repo, and the squad dir it should contain. A link may move the team as a whole
// (teamRoot itself), but must not take the squad dir out of the team repo.
let teamRepo: string;
let expectedParent: string | null;
if (paths.teamSquadDir !== paths.teamDir) {
teamRepo = teamDir; // teamRoot is the team repo, with the squad dir nested in it
expectedParent = path.dirname(squadDir);
} else if (isSquadDirName(path.basename(paths.teamDir)) || isSquadDirName(path.basename(teamDir))) {
expectedParent = canonicalDir(path.dirname(paths.teamDir)); // teamRoot names the squad dir itself
teamRepo = path.dirname(teamDir);
} else {
teamRepo = teamDir; // squad files at the root of teamRoot
expectedParent = teamDir;
}
if (expectedParent === null || !samePath(expectedParent, teamRepo)) {
throw new Error(`${prefix}, but the team's squad dir resolves to ${squadDir}, outside the team repo it was configured in. ` +
`Squad will not write the team's state into another location. Replace the link with a real directory, ${fix}`);
}

const topLevel = gitTopLevel(teamRepo);
if (topLevel === null) {
throw new Error(`${prefix}, but the team root is not in a git repository. Initialize git in the team repo, ${fix}`);
}
const canonicalTop = canonicalDir(topLevel);
if (canonicalTop === null || !samePath(canonicalTop, teamRepo)) {
throw new Error(`${prefix}, but the team repo ${teamRepo} is not the root of a git repository: it is inside the ` +
`repository at ${canonicalTop ?? topLevel}. Squad will not write the team's state into another repository. ` +
`Initialize git in the team repo, ${fix}`);
}
return topLevel; // git's own spelling, as the backend receives for an unlinked project
}

/** `.squad` or `.ai-team`; case-insensitive on Windows, where `C:\team\.SQUAD` names the same directory. */
function isSquadDirName(name: string): boolean {
const n = process.platform === 'win32' ? name.toLowerCase() : name;
return n === '.squad' || n === '.ai-team';
}

/** `git rev-parse --show-toplevel` from `dir`, or null when it is not in a repository. */
function gitTopLevel(dir: string): string | null {
if (!storage.existsSync(dir) || !storage.isDirectorySync(dir)) return null;
try {
return execFileSync('git', ['rev-parse', '--show-toplevel'], {
cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'],
}).trim() || null;
} catch {
return null;
}
}
19 changes: 16 additions & 3 deletions packages/squad-sdk/src/state-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1486,7 +1486,14 @@ export class TwoLayerBackend implements StateBackend {

export interface StateBackendConfig { stateBackend?: StateBackendType; }

export function resolveStateBackend(squadDir: string, repoRoot: string, cliOverride?: StateBackendType): StateBackend {
/**
* The backend a squad dir asks for: the CLI override, then `config.json`, then 'local'.
* `explicit` is false only when neither names one.
*/
export function configuredStateBackend(
squadDir: string,
cliOverride?: StateBackendType,
): { type: StateBackendType; explicit: boolean } {
let configBackend: StateBackendType | undefined;
try {
const configPath = path.join(squadDir, 'config.json');
Expand All @@ -1501,8 +1508,14 @@ export function resolveStateBackend(squadDir: string, repoRoot: string, cliOverr
const msg = err instanceof Error ? err.message : String(err);
console.warn(`⚠️ Failed to read state backend config from ${path.join(squadDir, 'config.json')}: ${msg}`);
}
const explicitBackend = cliOverride !== undefined || configBackend !== undefined;
const chosen = normalizeBackendType(cliOverride ?? configBackend ?? 'local');
return {
type: normalizeBackendType(cliOverride ?? configBackend ?? 'local'),
explicit: cliOverride !== undefined || configBackend !== undefined,
};
}

export function resolveStateBackend(squadDir: string, repoRoot: string, cliOverride?: StateBackendType): StateBackend {
const { type: chosen, explicit: explicitBackend } = configuredStateBackend(squadDir, cliOverride);
try {
return createBackend(chosen, squadDir, repoRoot);
} catch (err) {
Expand Down
1 change: 1 addition & 0 deletions scripts/release/test-reconciliation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export const CATEGORY_RULES = Object.freeze([
runsOn: 'non-win32',
reason: "it.runIf(process.platform !== 'win32'): POSIX-only legacy symlink-hash reconciliation",
},
{ category: 'env-gated', file: /linked-team-state-boundary\.test\.ts$/, title: /^\[platform: win32\] a teamRoot naming /, reason: 'it.runIf(win32): Windows-only drive-letter and case-insensitive team root resolution' },
{ category: 'env-gated', file: /profile-boundary\.test\.ts$/, reason: 'host lacks 8.3 short names or symlink/junction privilege' },
{ category: 'env-gated', file: /state-backend\.test\.ts$/, reason: 'platform-gated state backend case' },
{ category: 'env-gated', file: /docs-build\.test\.ts$/, reason: 'docs dependencies not installed' },
Expand Down
87 changes: 87 additions & 0 deletions test/cli/hook-linked-project.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/**
* `squad hook claude PreToolUse` in a linked project on a git state backend (#2320).
*
* The team repo holds the state branch, but the edits happen in the project repo, so the
* hook must keep checking reviewer lockouts against project paths.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { execFileSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Readable } from 'node:stream';
import { clearResolveSquadCache } from '@bradygaster/squad-sdk';
import { emptyLockoutStore, rejectRevision, writeLockouts } from '../../packages/squad-sdk/src/client/adapters/lockout.js';
import { runHookCommand } from '../../packages/squad-cli/src/cli/commands/hook.js';

let root: string;

const git = (cwd: string, args: string[]) =>
execFileSync('git', ['-c', 'user.name=Test', '-c', 'user.email=test', ...args], {
cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'],
}).trim();

function initRepo(dir: string): void {
writeFileSync(join(dir, 'README.md'), '# repo\n');
git(dir, ['init']);
git(dir, ['add', '-A']);
git(dir, ['commit', '-m', 'init']);
}

/** Run the hook command with `input` on stdin and return what it printed. */
async function runHook(event: string, input: Record<string, unknown>): Promise<string> {
const stdin = Readable.from([Buffer.from(JSON.stringify(input))]);
vi.spyOn(process, 'stdin', 'get').mockReturnValue(stdin as unknown as typeof process.stdin);
const printed: string[] = [];
vi.spyOn(process.stdout, 'write').mockImplementation((chunk: string | Uint8Array) => {
printed.push(String(chunk));
return true;
});
await runHookCommand(['claude', event]);
return printed.join('');
}

beforeEach(() => {
// Outside this checkout: anywhere under it, `git rev-parse` would find the Squad repo itself.
root = mkdtempSync(join(tmpdir(), 'squad-hook-linked-'));
clearResolveSquadCache();
});

afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
clearResolveSquadCache();
rmSync(root, { recursive: true, force: true });
});

describe('squad hook claude in a linked project on a git state backend (#2320)', () => {
it.each(['orphan', 'two-layer'])('PreToolUse still warns a locked-out member editing the project on %s', async (stateBackend) => {
const team = join(root, 'team');
const teamSquad = join(team, '.squad');
mkdirSync(teamSquad, { recursive: true });
writeFileSync(join(teamSquad, 'team.md'), '# Team\n');
writeFileSync(join(teamSquad, 'config.json'), '{ "claudeHooks": "advisory" }\n');
writeLockouts(teamSquad, rejectRevision(emptyLockoutStore(), {
subject: 'issue-42', author: 'dallas', reviewer: 'ripley', reason: 'missing tests',
paths: ['src/auth/**'], roster: ['dallas', 'lambert', 'ripley'], at: '2026-11-10T12:00:00.000Z',
}));
initRepo(team);

const project = join(root, 'project');
mkdirSync(join(project, '.squad'), { recursive: true });
writeFileSync(join(project, '.squad', 'config.json'), JSON.stringify({ version: 1, teamRoot: '../team', stateBackend }));
initRepo(project);
vi.stubEnv('CLAUDE_PROJECT_DIR', project);

const printed = await runHook('PreToolUse', {
hook_event_name: 'PreToolUse',
agent_type: 'dallas',
tool_name: 'Edit',
tool_input: { file_path: join(project, 'src', 'auth', 'login.ts') },
cwd: project,
});

expect(printed).toMatch(/dallas is locked out of issue-42/);
expect(printed).toContain('src/auth/login.ts');
});
});
69 changes: 67 additions & 2 deletions test/cli/state-mcp.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { clearResolveSquadCache } from '@bradygaster/squad-sdk';
import { clearResolveSquadCache, resolveSquadState } from '@bradygaster/squad-sdk';
import { execFileSync } from 'node:child_process';
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { randomBytes } from 'node:crypto';
import { tmpdir } from 'node:os';
import { createStateMcpSession } from '../../packages/squad-cli/src/cli/commands/state-mcp.js';

const TMP = join(process.cwd(), `.test-state-mcp-${randomBytes(4).toString('hex')}`);
Expand Down Expand Up @@ -324,4 +325,68 @@ describe('state-mcp bridge', () => {
expect(existsSync(inbox) ? readdirSync(inbox).length : 0, `teamRoot=../team: no decision in ${inbox}`).toBeGreaterThan(0);
expect(existsSync(join(TMP, 'team', 'decisions')), 'teamRoot=../team: decision written outside .squad').toBe(false);
});

it.each(['orphan', 'two-layer'] as const)(
"keeps a linked team's state on the team repo's %s backend (#2320)",
{ timeout: 60_000 },
async (stateBackend) => {
const team = join(TMP, 'team');
const project = join(TMP, 'project');
const gitIn = (cwd: string, args: string[]) =>
execFileSync('git', ['-c', 'user.name=Test', '-c', 'user.email=test', ...args], {
cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'],
}).trim();
for (const dir of [team, project]) {
mkdirSync(join(dir, '.squad'), { recursive: true });
writeFileSync(join(dir, 'README.md'), '# repo\n');
gitIn(dir, ['init']);
}
writeFileSync(join(team, '.squad', 'team.md'), '# Team\n');
writeFileSync(join(project, '.squad', 'config.json'), JSON.stringify({ version: 1, teamRoot: '../team', stateBackend }));
for (const dir of [team, project]) {
gitIn(dir, ['add', '-A']);
gitIn(dir, ['commit', '-m', 'init']);
}

const messages: JsonRpcMessage[] = [];
const session = createStateMcpSession(project, message => messages.push(message as JsonRpcMessage));
const call = async (name: string, args: Record<string, unknown>) => {
await session.handleRequest({ jsonrpc: '2.0', id: messages.length, method: 'tools/call', params: { name, arguments: args } });
return resultAsRecord(messages[messages.length - 1]!);
};

expect((await call('squad_state_write', { key: 'decisions/inbox/linked-proof.md', content: '# Linked proof\n' }))['isError']).not.toBe(true);
expect((await call('squad_state_read', { key: 'decisions/inbox/linked-proof.md' }))['content'])
.toEqual([{ type: 'text', text: '# Linked proof\n' }]);
expect((await call('memory.write', { content: 'Use Playwright for end-to-end tests', class: 'LOCAL' }))['isError']).not.toBe(true);

// The state lives with the team repo, on its state branch, and nowhere in the project.
expect(gitIn(team, ['for-each-ref', '--format=%(refname)', 'refs/heads/squad-state'])).toBe('refs/heads/squad-state');
expect(gitIn(project, ['for-each-ref', '--format=%(refname)', 'refs/heads/squad-state', 'refs/notes'])).toBe('');
expect(existsSync(join(team, '.squad', 'decisions', 'inbox', 'linked-proof.md'))).toBe(false);
expect(gitIn(team, ['status', '--porcelain'])).toBe('');
if (stateBackend === 'orphan') {
expect(gitIn(team, ['show', 'squad-state:decisions/inbox/linked-proof.md'])).toBe('# Linked proof');
}
},
);

it('fails clearly when a linked team on a git backend is not in a git repository (#2320)', () => {
// Outside this repo: anywhere under the checkout, `git rev-parse` would find it.
const root = mkdtempSync(join(tmpdir(), 'squad-linked-nogit-'));
try {
mkdirSync(join(root, 'team', '.squad'), { recursive: true });
writeFileSync(join(root, 'team', '.squad', 'team.md'), '# Team\n');
mkdirSync(join(root, 'project', '.squad'), { recursive: true });
writeFileSync(
join(root, 'project', '.squad', 'config.json'),
JSON.stringify({ version: 1, teamRoot: '../team', stateBackend: 'orphan' }),
);

expect(() => resolveSquadState(join(root, 'project'))).toThrow(/team root is not in a git repository/);
} finally {
clearResolveSquadCache();
rmSync(root, { recursive: true, force: true });
}
});
});
Loading
Loading