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/casting-runtime-state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@bradygaster/squad-cli': patch
'@bradygaster/squad-sdk': patch
---

Allow the casting policy, registry, and history to be persisted through runtime state tools.
9 changes: 8 additions & 1 deletion packages/squad-sdk/src/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,10 +255,17 @@ function normalizeStateToolDir(dir?: string): string {
return normalized;
}

const MUTABLE_CASTING_STATE_KEYS = new Set([
'casting/policy.json',
'casting/registry.json',
'casting/history.json',
]);

function validateMutableStateToolKey(key: string): void {
const isMutable =
key === 'decisions.md' ||
key.startsWith('decisions/inbox/') ||
MUTABLE_CASTING_STATE_KEYS.has(key) ||
/^agents\/[a-zA-Z0-9_-]+\/history\.md$/.test(key) ||
key.startsWith('log/') ||
key.startsWith('orchestration-log/') ||
Expand All @@ -268,7 +275,7 @@ function validateMutableStateToolKey(key: string): void {

if (!isMutable) {
throw new Error(
'State mutations are limited to mutable runtime state (decisions, inbox, logs, sessions, scratch files, agent history, and identity). Static config such as config.json, team.md, routing.md, charters, templates, and skills must not be changed with state tools.',
'State mutations are limited to mutable runtime state (decisions, inbox, casting policy/registry/history, logs, sessions, scratch files, agent history, and identity). Static config such as config.json, team.md, routing.md, charters, templates, and skills must not be changed with state tools.',
);
}
}
Expand Down
53 changes: 49 additions & 4 deletions test/cli/state-mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,14 @@ function git(args: string): string {
return execSync(`git ${args}`, { cwd: TMP, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
}

function initTwoLayerSquad(): void {
function initSquad(stateBackend: 'orphan' | 'two-layer'): void {
mkdirSync(join(TMP, '.squad'), { recursive: true });
writeFileSync(join(TMP, '.squad', 'config.json'), JSON.stringify({ stateBackend: 'two-layer' }, null, 2));
writeFileSync(join(TMP, '.squad', 'config.json'), JSON.stringify({ stateBackend }, null, 2));
writeFileSync(join(TMP, 'README.md'), '# state mcp test\n');
git('init');
git('config user.email "test@test.com"');
git('config user.name "Test"');
git('add .');
git('add README.md .squad/config.json');
git('commit -m "init"');
}

Expand All @@ -40,7 +40,6 @@ describe('state-mcp bridge', () => {
beforeEach(() => {
if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true });
mkdirSync(TMP, { recursive: true });
initTwoLayerSquad();
});

afterEach(() => {
Expand All @@ -49,6 +48,7 @@ describe('state-mcp bridge', () => {
});

it('lists Squad state tools for MCP clients', async () => {
initSquad('two-layer');
const messages: JsonRpcMessage[] = [];
const session = createStateMcpSession(TMP, message => messages.push(message as JsonRpcMessage));

Expand All @@ -63,6 +63,7 @@ describe('state-mcp bridge', () => {
});

it('writes and reads two-layer state without mutating the worktree .squad files', async () => {
initSquad('two-layer');
const messages: JsonRpcMessage[] = [];
const session = createStateMcpSession(TMP, message => messages.push(message as JsonRpcMessage));

Expand Down Expand Up @@ -92,4 +93,48 @@ describe('state-mcp bridge', () => {
expect(existsSync(join(TMP, '.squad', 'decisions', 'inbox', 'mcp-proof.md'))).toBe(false);
expect(readFileSync(join(TMP, '.squad', 'config.json'), 'utf8')).toContain('two-layer');
});

it.each(['orphan', 'two-layer'] as const)(
'writes all casting runtime state keys through the %s backend',
async (stateBackend) => {
initSquad(stateBackend);
const messages: JsonRpcMessage[] = [];
const session = createStateMcpSession(TMP, message => messages.push(message as JsonRpcMessage));
const castingState = [
['casting/policy.json', '{"mode":"auto"}\n'],
['casting/registry.json', '{"agents":{}}\n'],
['casting/history.json', '{"events":[]}\n'],
] as const;

for (const [key, content] of castingState) {
const writeIndex = messages.length;
await session.handleRequest({
jsonrpc: '2.0',
id: `write-${key}`,
method: 'tools/call',
params: {
name: 'squad_state_write',
arguments: { key, content },
},
});
expect(resultAsRecord(messages[writeIndex]!)['isError']).not.toBe(true);

const readIndex = messages.length;
await session.handleRequest({
jsonrpc: '2.0',
id: `read-${key}`,
method: 'tools/call',
params: {
name: 'squad_state_read',
arguments: { key },
},
});
expect(resultAsRecord(messages[readIndex]!)['content']).toEqual([{ type: 'text', text: content }]);
expect(existsSync(join(TMP, '.squad', ...key.split('/')))).toBe(false);
}

expect(git('status --porcelain')).toBe('');
},
30_000,
);
});
24 changes: 24 additions & 0 deletions test/state-backend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -757,6 +757,30 @@ describe('ToolRegistry state tools with git-native backend', () => {
expect(backend.read('sessions/session-1/state.md')).toBeUndefined();
});

it('allows exactly the three casting runtime state keys', { timeout: 30_000 }, async () => {
const backend = new OrphanBranchBackend(TMP);
const adapter = new StateBackendStorageAdapter(backend, squadDir());
const registry = new ToolRegistry(squadDir(), undefined, adapter);
const write = registry.getTool('squad_state_write')!;

const castingKeys = [
'casting/policy.json',
'casting/registry.json',
'casting/history.json',
];
for (const key of castingKeys) {
await expect(write.handler({ key, content: '{}\n' })).resolves.toMatchObject({ resultType: 'success' });
expect(backend.read(key)).toBe('{}\n');
expect(existsSync(join(squadDir(), key))).toBe(false);
}

await expect(write.handler({ key: 'casting/agents.json', content: '{}\n' })).resolves.toMatchObject({ resultType: 'failure' });
await expect(write.handler({ key: 'casting/archive/history.json', content: '{}\n' })).resolves.toMatchObject({ resultType: 'failure' });
expect(backend.read('casting/agents.json')).toBeUndefined();
expect(backend.read('casting/archive/history.json')).toBeUndefined();
expect(git('status --porcelain')).toBe('');
});

it('routes existing squad_decide writes through configured backend storage', { timeout: 20_000 }, async () => {
const backend = new OrphanBranchBackend(TMP);
const adapter = new StateBackendStorageAdapter(backend, squadDir());
Expand Down