Skip to content
Closed
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
8 changes: 6 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

## [0.13.1] - 2026-08-26

### Fixed

- Allow runtime state tools to persist the casting policy, registry, and history keys required by the coordinator (`casting/policy.json`, `casting/registry.json`, and `casting/history.json`), while continuing to reject unrelated casting paths (#1876, #1898).

## [0.13.0] - 2026-08-25

This release is focused on hardening the gh-aw (Agentic Workflows) integration shipped in v0.12.0: the install contract, workflow router, plan lifecycle, dispatch reliability, and CI coverage are all tightened. It also adds `squad health`, fixes `squad watch`/`squad loop` externalized-state routing, fixes `squad nap` archival, and hardens the SDK's state and scheduler paths.
Expand Down Expand Up @@ -539,5 +545,3 @@ First stable release since v0.9.4 (April 25). Consumes 97 changesets (sdk: 50, c
- New entry point: `src/cli-entry.ts` (CLI bootstrap separated from library exports)
- Migrated to npm workspace publishing (`@bradygaster/squad-sdk`, `@bradygaster/squad-cli`)
- Changesets infrastructure for independent package versioning


8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@bradygaster/squad",
"version": "0.13.0",
"version": "0.13.1",
"private": true,
"description": "Squad — Programmable multi-agent runtime for GitHub Copilot, built on @github/copilot-sdk",
"type": "module",
Expand Down
2 changes: 1 addition & 1 deletion packages/squad-cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@bradygaster/squad-cli",
"version": "0.13.0",
"version": "0.13.1",
"description": "Squad CLI — Command-line interface for the Squad multi-agent runtime",
"type": "module",
"bin": {
Expand Down
2 changes: 1 addition & 1 deletion packages/squad-sdk/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@bradygaster/squad-sdk",
"version": "0.13.0",
"version": "0.13.1",
"description": "Squad SDK — Programmable multi-agent runtime for GitHub Copilot",
"type": "module",
"main": "./dist/index.js",
Expand Down
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',
]);
Comment thread
bradygaster marked this conversation as resolved.
Resolved

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
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ source: "Extracted from squad.agent.md as part of the slimming effort (bradygast

## State backend note

Files below marked as **"Derived / append-only"** are **mutable state** — agents access them with runtime state tools (`squad_state_read`, `squad_state_write`, `squad_state_append`, `squad_state_delete`, `squad_state_list`). The runtime decides whether the configured backend stores them on disk, git-native state, or an external provider. Files marked as **"Authoritative"** are **static config** and always live on disk regardless of backend.
Files below marked as **"Derived / append-only"** are **mutable state** — agents access them with runtime state tools (`squad_state_read`, `squad_state_write`, `squad_state_append`, `squad_state_delete`, `squad_state_list`). The runtime decides whether the configured backend stores them on disk, git-native state, or an external provider. Files marked as **"Authoritative"** are canonical config and normally live on disk. The casting policy and registry are authoritative mutable config: coordinators access `casting/policy.json` and `casting/registry.json` through runtime state tools so the configured backend controls their storage, just like `casting/history.json`.

## File hierarchy

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
Loading