diff --git a/CHANGELOG.md b/CHANGELOG.md index 98d71c979..6df1e2439 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. @@ -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 - - diff --git a/package-lock.json b/package-lock.json index e316ea2dc..84d871d33 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@bradygaster/squad", - "version": "0.13.0", + "version": "0.13.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bradygaster/squad", - "version": "0.13.0", + "version": "0.13.1", "license": "MIT", "workspaces": [ "packages/*" @@ -8151,7 +8151,7 @@ }, "packages/squad-cli": { "name": "@bradygaster/squad-cli", - "version": "0.13.0", + "version": "0.13.1", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -8192,7 +8192,7 @@ }, "packages/squad-sdk": { "name": "@bradygaster/squad-sdk", - "version": "0.13.0", + "version": "0.13.1", "license": "MIT", "dependencies": { "@github/copilot-sdk": "^1.0.11", diff --git a/package.json b/package.json index 1939ffcf8..5cf64cd07 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/squad-cli/package.json b/packages/squad-cli/package.json index 2dc23ef2f..a1a33422f 100644 --- a/packages/squad-cli/package.json +++ b/packages/squad-cli/package.json @@ -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": { diff --git a/packages/squad-sdk/package.json b/packages/squad-sdk/package.json index d99945d96..40bb22cc9 100644 --- a/packages/squad-sdk/package.json +++ b/packages/squad-sdk/package.json @@ -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", diff --git a/packages/squad-sdk/src/tools/index.ts b/packages/squad-sdk/src/tools/index.ts index d815b41ad..817eccd9a 100644 --- a/packages/squad-sdk/src/tools/index.ts +++ b/packages/squad-sdk/src/tools/index.ts @@ -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/') || @@ -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.', ); } } diff --git a/packages/squad-sdk/templates/skills/coordinator-source-of-truth/SKILL.md b/packages/squad-sdk/templates/skills/coordinator-source-of-truth/SKILL.md index 3a992ba0a..c54af5027 100644 --- a/packages/squad-sdk/templates/skills/coordinator-source-of-truth/SKILL.md +++ b/packages/squad-sdk/templates/skills/coordinator-source-of-truth/SKILL.md @@ -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 diff --git a/test/cli/state-mcp.test.ts b/test/cli/state-mcp.test.ts index d3ee8053c..84db6868d 100644 --- a/test/cli/state-mcp.test.ts +++ b/test/cli/state-mcp.test.ts @@ -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"'); } @@ -40,7 +40,6 @@ describe('state-mcp bridge', () => { beforeEach(() => { if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true }); mkdirSync(TMP, { recursive: true }); - initTwoLayerSquad(); }); afterEach(() => { @@ -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)); @@ -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)); @@ -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, + ); }); diff --git a/test/state-backend.test.ts b/test/state-backend.test.ts index f43de8cfe..58b91335a 100644 --- a/test/state-backend.test.ts +++ b/test/state-backend.test.ts @@ -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());