From 6fc8df5758cad7b4d2a9c4fd3614a447a5fb7875 Mon Sep 17 00:00:00 2001 From: Copilot Date: Sat, 17 Oct 2026 22:15:57 +0300 Subject: [PATCH 01/46] feat(sdk): Squad 2.0 decoupled backend, harness SPI and MCP bridge Add SquadBackend contract with versioned envelope protocol, local and durable-http backends, harness registry, capability negotiation, versioned approvals, cancel-by-runId, and squad_backend_* MCP transport adapter. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 995eea17-c356-4912-90ea-5e5b144e7ba9 --- .../squad-cli/src/cli/commands/backend-mcp.ts | 194 +++++++ .../squad-cli/src/cli/commands/state-mcp.ts | 25 +- packages/squad-sdk/package.json | 4 + .../src/backend/durable-http-backend.ts | 216 ++++++++ .../squad-sdk/src/backend/harness-registry.ts | 47 ++ packages/squad-sdk/src/backend/index.ts | 27 + .../squad-sdk/src/backend/local-backend.ts | 430 ++++++++++++++++ packages/squad-sdk/src/backend/types.ts | 403 +++++++++++++++ packages/squad-sdk/src/index.ts | 3 + test/backend.test.ts | 483 ++++++++++++++++++ 10 files changed, 1827 insertions(+), 5 deletions(-) create mode 100644 packages/squad-cli/src/cli/commands/backend-mcp.ts create mode 100644 packages/squad-sdk/src/backend/durable-http-backend.ts create mode 100644 packages/squad-sdk/src/backend/harness-registry.ts create mode 100644 packages/squad-sdk/src/backend/index.ts create mode 100644 packages/squad-sdk/src/backend/local-backend.ts create mode 100644 packages/squad-sdk/src/backend/types.ts create mode 100644 test/backend.test.ts diff --git a/packages/squad-cli/src/cli/commands/backend-mcp.ts b/packages/squad-cli/src/cli/commands/backend-mcp.ts new file mode 100644 index 000000000..8c52c8d12 --- /dev/null +++ b/packages/squad-cli/src/cli/commands/backend-mcp.ts @@ -0,0 +1,194 @@ +import { + BackendError, + createSquadBackend, + resolveSquadState, + type BackendContentPart, + type ProtocolHello, + type SquadBackend, +} from '@bradygaster/squad-sdk'; + +/** + * MCP TRANSPORT ADAPTER over `SquadBackend`. MCP is only a local bridge for + * tool/execution access; the canonical conversation wire contract is the + * versioned envelope protocol defined in the SDK (`BackendEnvelope`, + * `BackendErrorInfo`, `NegotiatedCapabilities`). Errors are returned in the + * canonical `BackendErrorInfo` structure. + */ +export interface BackendMcpTool { + name: string; + description: string; + inputSchema: { type: 'object'; properties: Record; required?: string[] }; + call(args: Record): Promise; +} + +const cid = { type: 'string', description: 'Conversation id' }; +const seq = { type: 'integer', description: 'Turn sequence number' }; + +function str(args: Record, key: string): string { + const v = args[key]; + if (typeof v !== 'string' || !v) throw new BackendError('invalid-request', `'${key}' is required`, 400); + return v; +} + +function num(args: Record, key: string): number { + const v = args[key]; + if (typeof v !== 'number' || !Number.isInteger(v)) throw new BackendError('invalid-request', `'${key}' must be an integer`, 400); + return v; +} + +/** + * Backend selection: SQUAD_BACKEND_URL (+ SQUAD_BACKEND_TOKEN) → durable-http, + * otherwise the local backend over the repo's .squad directory. + */ +export function createMcpBackend(startDir: string, env: NodeJS.ProcessEnv = process.env): SquadBackend { + if (env['SQUAD_BACKEND_URL']) { + return createSquadBackend({ kind: 'durable-http', baseUrl: env['SQUAD_BACKEND_URL'], token: env['SQUAD_BACKEND_TOKEN'] }); + } + const context = resolveSquadState(startDir); + if (!context) throw new Error(`No .squad directory found from ${startDir}`); + return createSquadBackend({ kind: 'mcp', squadDir: context.paths.teamSquadDir }); +} + +export function createBackendMcpTools(getBackend: () => SquadBackend): BackendMcpTool[] { + return [ + { + name: 'squad_backend_create_conversation', + description: 'Create (or idempotently re-open) a Squad backend conversation.', + inputSchema: { type: 'object', properties: { + conversationId: { type: 'string' }, title: { type: 'string' }, metadata: { type: 'object' }, + } }, + call: a => getBackend().createConversation({ + conversationId: typeof a['conversationId'] === 'string' ? a['conversationId'] : undefined, + title: typeof a['title'] === 'string' ? a['title'] : undefined, + metadata: a['metadata'] as Record | undefined, + }), + }, + { + name: 'squad_backend_send_message', + description: 'Send a message; (runId, seq) is the idempotency key and epoch is the lease fencing token.', + inputSchema: { type: 'object', properties: { + conversationId: cid, + runId: { type: 'string' }, seq, + role: { type: 'string', enum: ['user', 'assistant', 'system', 'tool'] }, + content: { type: 'string' }, + parts: { type: 'array', items: { type: 'object' }, description: 'Typed content parts (text, data, resource, tool-call, tool-result)' }, + correlationId: { type: 'string' }, causationId: { type: 'string' }, + epoch: { type: 'integer' }, harness: { type: 'string' }, + extensions: { type: 'object', description: 'Keys must be x-.*' }, + }, required: ['conversationId', 'runId', 'seq', 'content'] }, + call: a => getBackend().sendMessage(str(a, 'conversationId'), { + runId: str(a, 'runId'), + seq: num(a, 'seq'), + role: (a['role'] as 'user' | undefined) ?? 'user', + content: str(a, 'content'), + parts: Array.isArray(a['parts']) ? a['parts'] as BackendContentPart[] : undefined, + correlationId: typeof a['correlationId'] === 'string' ? a['correlationId'] : undefined, + causationId: typeof a['causationId'] === 'string' ? a['causationId'] : undefined, + epoch: typeof a['epoch'] === 'number' ? a['epoch'] : undefined, + harness: typeof a['harness'] === 'string' ? a['harness'] : undefined, + extensions: a['extensions'] as Record | undefined, + }), + }, + { + name: 'squad_backend_get_transcript', + description: 'Read the full transcript of a conversation.', + inputSchema: { type: 'object', properties: { conversationId: cid }, required: ['conversationId'] }, + call: a => getBackend().getTranscript(str(a, 'conversationId')), + }, + { + name: 'squad_backend_get_turn', + description: 'Read a turn: state, tasks, messages and any pending approval.', + inputSchema: { type: 'object', properties: { conversationId: cid, seq }, required: ['conversationId', 'seq'] }, + call: a => getBackend().getTurn(str(a, 'conversationId'), num(a, 'seq')), + }, + { + name: 'squad_backend_cancel_turn', + description: 'Cancel a running turn.', + inputSchema: { type: 'object', properties: { conversationId: cid, seq }, required: ['conversationId', 'seq'] }, + call: a => getBackend().cancelTurn(str(a, 'conversationId'), num(a, 'seq')), + }, + { + name: 'squad_backend_cancel_run', + description: 'Cancel a run by its stable runId (distinct from turn seq).', + inputSchema: { type: 'object', properties: { conversationId: cid, runId: { type: 'string' }, reason: { type: 'string' } }, required: ['conversationId', 'runId'] }, + call: a => getBackend().cancelRun(str(a, 'conversationId'), str(a, 'runId'), typeof a['reason'] === 'string' ? a['reason'] : undefined), + }, + { + name: 'squad_backend_get_approvals', + description: 'List durable, versioned approval records (version 1 pending, 2 decided).', + inputSchema: { type: 'object', properties: { conversationId: cid }, required: ['conversationId'] }, + call: a => getBackend().getApprovals(str(a, 'conversationId')), + }, + { + name: 'squad_backend_respond_approval', + description: 'Approve or deny a pending approval; hash must match the request hash.', + inputSchema: { type: 'object', properties: { + conversationId: cid, approvalId: { type: 'string' }, hash: { type: 'string' }, + decision: { type: 'string', enum: ['approve', 'deny'] }, + reason: { type: 'string' }, epoch: { type: 'integer' }, + }, required: ['conversationId', 'approvalId', 'hash', 'decision'] }, + call: a => getBackend().respondApproval(str(a, 'conversationId'), { + approvalId: str(a, 'approvalId'), + hash: str(a, 'hash'), + decision: str(a, 'decision') === 'approve' ? 'approve' : 'deny', + reason: typeof a['reason'] === 'string' ? a['reason'] : undefined, + epoch: typeof a['epoch'] === 'number' ? a['epoch'] : undefined, + }), + }, + { + name: 'squad_backend_negotiate', + description: 'Negotiate protocol version and capabilities.', + inputSchema: { type: 'object', properties: { + protocols: { type: 'array', items: { type: 'string' } }, + clientName: { type: 'string' }, + contentParts: { type: 'array', items: { type: 'string' } }, + extensionNamespaces: { type: 'array', items: { type: 'string' } }, + capabilities: { type: 'array', items: { type: 'string' }, description: 'Client capability flags' }, + policyCapabilities: { type: 'array', items: { type: 'string' }, description: 'Policy-allowed capability flags' }, + }, required: ['protocols'] }, + call: a => getBackend().negotiate({ + protocols: Array.isArray(a['protocols']) ? a['protocols'] as string[] : [], + clientName: typeof a['clientName'] === 'string' ? a['clientName'] : undefined, + contentParts: a['contentParts'] as ProtocolHello['contentParts'], + extensionNamespaces: a['extensionNamespaces'] as string[] | undefined, + capabilities: a['capabilities'] as string[] | undefined, + policyCapabilities: a['policyCapabilities'] as string[] | undefined, + }), + }, + { + name: 'squad_backend_read_events', + description: 'Read canonical event envelopes after an opaque cursor (at-least-once; dedupe on id).', + inputSchema: { type: 'object', properties: { + conversationId: cid, + after: { type: 'string', description: 'Opaque cursor from a previous page' }, + limit: { type: 'integer' }, + stream: { type: 'string', enum: ['control', 'execution'] }, + }, required: ['conversationId'] }, + call: a => getBackend().readEvents(str(a, 'conversationId'), { + after: typeof a['after'] === 'string' ? a['after'] : undefined, + limit: typeof a['limit'] === 'number' ? a['limit'] : undefined, + stream: a['stream'] === 'control' || a['stream'] === 'execution' ? a['stream'] : undefined, + }), + }, + { + name: 'squad_backend_health', + description: 'Backend health.', + inputSchema: { type: 'object', properties: {} }, + call: () => getBackend().health(), + }, + ]; +} + +export async function callBackendMcpTool(tool: BackendMcpTool, args: Record): Promise<{ + content: Array<{ type: 'text'; text: string }>; + isError?: boolean; +}> { + try { + return { content: [{ type: 'text', text: JSON.stringify(await tool.call(args), null, 2) }] }; + } catch (err) { + const info = err instanceof BackendError + ? err.toInfo() + : { code: 'unavailable', message: err instanceof Error ? err.message : String(err), retryable: false }; + return { content: [{ type: 'text', text: JSON.stringify({ error: info }) }], isError: true }; + } +} diff --git a/packages/squad-cli/src/cli/commands/state-mcp.ts b/packages/squad-cli/src/cli/commands/state-mcp.ts index e41c88a5d..10a150487 100644 --- a/packages/squad-cli/src/cli/commands/state-mcp.ts +++ b/packages/squad-cli/src/cli/commands/state-mcp.ts @@ -5,6 +5,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprot import { resolveSquadState } from '@bradygaster/squad-sdk'; import { ToolRegistry } from '@bradygaster/squad-sdk/tools'; import { getPackageVersion } from '../core/version.js'; +import { callBackendMcpTool, createBackendMcpTools, createMcpBackend } from './backend-mcp.js'; export type JsonRpcRequest = { jsonrpc?: string; @@ -103,6 +104,9 @@ export function createStateMcpSession( return { mcpName, runtimeName, tool }; }); const toolMap = new Map(tools.map(entry => [entry.mcpName, entry])); + let backend: ReturnType | undefined; + const backendTools = createBackendMcpTools(() => (backend ??= createMcpBackend(startDir))); + const backendToolMap = new Map(backendTools.map(t => [t.name, t])); async function handleRequest(request: JsonRpcRequest): Promise { try { @@ -120,17 +124,22 @@ export function createStateMcpSession( case 'tools/list': write({ jsonrpc: '2.0', id: request.id ?? null, result: { - tools: tools.map(tool => ({ + tools: [...tools.map(tool => ({ name: tool.mcpName, description: tool.tool.description, inputSchema: tool.tool.parameters, - })), + })), ...backendTools.map(t => ({ name: t.name, description: t.description, inputSchema: t.inputSchema }))], } }); break; case 'tools/call': { const params = parseObject(request.params); const name = typeof params['name'] === 'string' ? params['name'] : ''; + const backendTool = backendToolMap.get(name); + if (backendTool) { + write({ jsonrpc: '2.0', id: request.id ?? null, result: await callBackendMcpTool(backendTool, parseObject(params['arguments'])) }); + break; + } const entry = toolMap.get(name); if (!entry) { write({ jsonrpc: '2.0', id: request.id ?? null, error: { code: -32602, message: `Unknown Squad state tool: ${name}` } }); @@ -180,6 +189,10 @@ export async function runStateMcp(startDir = process.cwd()): Promise { return registry; } + let backend: ReturnType | undefined; + const backendTools = createBackendMcpTools(() => (backend ??= createMcpBackend(startDir))); + const backendToolMap = new Map(backendTools.map(t => [t.name, t])); + const server = new Server(SERVER_INFO, { capabilities: { tools: {} } }); server.setRequestHandler(ListToolsRequestSchema, () => { @@ -191,7 +204,7 @@ export async function runStateMcp(startDir = process.cwd()): Promise { return { mcpName, runtimeName, tool }; }); return { - tools: tools.map(tool => ({ + tools: [...tools.map(tool => ({ name: tool.mcpName, description: tool.tool.description, inputSchema: tool.tool.parameters as { @@ -199,11 +212,13 @@ export async function runStateMcp(startDir = process.cwd()): Promise { properties?: Record; required?: string[]; }, - })), + })), ...backendTools.map(t => ({ name: t.name, description: t.description, inputSchema: t.inputSchema }))], }; }); server.setRequestHandler(CallToolRequestSchema, async request => { + const backendTool = backendToolMap.get(request.params.name); + if (backendTool) return callBackendMcpTool(backendTool, parseObject(request.params.arguments)); const reg = getRegistry(); const runtimeTools = new Map(reg.getTools().map(tool => [tool.name, tool])); const tools = Object.entries(MCP_TOOL_ALIASES).map(([mcpName, runtimeName]) => { @@ -237,7 +252,7 @@ export function printStateMcpHelp(): void { 'Usage: squad state-mcp', '', 'This command is intended to be launched by GitHub Copilot CLI through', - '.copilot/mcp-config.json. It exposes squad_decide and state.* tools', + '.copilot/mcp-config.json. It exposes squad_decide, squad_state_*, and squad_backend_* tools', 'backed by the configured Squad state backend.', '', ].join('\n')); diff --git a/packages/squad-sdk/package.json b/packages/squad-sdk/package.json index e3ebcd1a8..fa58da637 100644 --- a/packages/squad-sdk/package.json +++ b/packages/squad-sdk/package.json @@ -54,6 +54,10 @@ "types": "./dist/hooks/index.d.ts", "import": "./dist/hooks/index.js" }, + "./backend": { + "types": "./dist/backend/index.d.ts", + "import": "./dist/backend/index.js" + }, "./tools": { "types": "./dist/tools/index.d.ts", "import": "./dist/tools/index.js" diff --git a/packages/squad-sdk/src/backend/durable-http-backend.ts b/packages/squad-sdk/src/backend/durable-http-backend.ts new file mode 100644 index 000000000..b1e22ef55 --- /dev/null +++ b/packages/squad-sdk/src/backend/durable-http-backend.ts @@ -0,0 +1,216 @@ +import { BACKEND_PROTOCOL_VERSION, BackendError } from './types.js'; +import type { + ApprovalRecord, + ApprovalResponse, + BackendAdmission, + BackendConversation, + BackendErrorCode, + BackendEventPage, + BackendHealth, + BackendMessage, + BackendTurn, + CreateConversationOptions, + LeaseEpoch, + NegotiatedCapabilities, + ProtocolHello, + ReadEventsOptions, + SquadBackend, +} from './types.js'; + +export interface DurableHttpOptions { + baseUrl: string; + token?: string; + fetch?: typeof fetch; + maxRetries?: number; + retryDelayMs?: number; + epoch?: LeaseEpoch; +} + +const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]); + +/** Client for DurableSquad's `/v1/conversations` HTTP surface. */ +export class DurableHttpSquadBackend implements SquadBackend { + readonly kind = 'durable-http'; + private readonly base: string; + private readonly doFetch: typeof fetch; + private readonly maxRetries: number; + private readonly retryDelayMs: number; + private epoch: LeaseEpoch | undefined; + + constructor(private readonly options: DurableHttpOptions) { + if (!options.baseUrl) throw new BackendError('invalid-request', 'baseUrl is required', 400); + this.base = options.baseUrl.replace(/\/+$/, ''); + this.doFetch = options.fetch ?? globalThis.fetch.bind(globalThis); + this.maxRetries = options.maxRetries ?? 3; + this.retryDelayMs = options.retryDelayMs ?? 200; + this.epoch = options.epoch; + } + + negotiate(hello: ProtocolHello): Promise { + return this.request('POST', '/v1/capabilities', hello); + } + + readEvents(conversationId: string, options: ReadEventsOptions = {}): Promise { + const q = new URLSearchParams(); + if (options.after) q.set('after', options.after); + if (options.limit) q.set('limit', String(options.limit)); + if (options.stream) q.set('stream', options.stream); + const qs = q.toString(); + return this.request('GET', `/v1/conversations/${enc(conversationId)}/events${qs ? `?${qs}` : ''}`); + } + + async createConversation(options: CreateConversationOptions = {}): Promise { + const conv = await this.request('POST', '/v1/conversations', options, + options.conversationId ? { 'Idempotency-Key': `create:${options.conversationId}` } : undefined); + if (typeof conv.epoch === 'number') this.epoch = Math.max(this.epoch ?? 0, conv.epoch); + return conv; + } + + async sendMessage(conversationId: string, message: BackendMessage): Promise { + const epoch = message.epoch ?? this.epoch; + return this.request( + 'POST', + `/v1/conversations/${enc(conversationId)}/messages`, + { ...message, epoch }, + { ...(message.correlationId ? { 'X-Correlation-Id': message.correlationId } : {}), 'Idempotency-Key': `${message.runId}:${message.seq}` }, + epoch, + ); + } + + async getTranscript(conversationId: string): Promise { + const res = await this.request( + 'GET', `/v1/conversations/${enc(conversationId)}/transcript`); + return Array.isArray(res) ? res : res.messages ?? []; + } + + getTurn(conversationId: string, seq: number): Promise { + return this.request('GET', `/v1/conversations/${enc(conversationId)}/turns/${seq}`); + } + + cancelTurn(conversationId: string, seq: number): Promise { + return this.request('POST', `/v1/conversations/${enc(conversationId)}/turns/${seq}/cancel`, {}, undefined, this.epoch); + } + + cancelRun(conversationId: string, runId: string, reason?: string): Promise { + return this.request( + 'POST', + `/v1/conversations/${enc(conversationId)}/runs/${enc(runId)}/cancel`, + { reason }, + { 'Idempotency-Key': `cancel:${runId}` }, + this.epoch, + ); + } + + async getApprovals(conversationId: string): Promise { + const res = await this.request( + 'GET', `/v1/conversations/${enc(conversationId)}/approvals`); + return Array.isArray(res) ? res : res.approvals ?? []; + } + + respondApproval(conversationId: string, response: ApprovalResponse): Promise { + const epoch = response.epoch ?? this.epoch; + return this.request( + 'POST', + `/v1/conversations/${enc(conversationId)}/approvals/${enc(response.approvalId)}`, + { ...response, epoch }, + { 'Idempotency-Key': `approval:${response.approvalId}` }, + epoch, + ); + } + + /** Probes unversioned /health/live and /health/ready, falling back to /health. */ + async health(): Promise { + const [live, ready] = await Promise.all([this.probe('/health/live'), this.probe('/health/ready')]); + if (live.status === 'missing' && ready.status === 'missing') { + const legacy = await this.probe('/health'); + return { ok: legacy.status === 'ok', live: legacy.status === 'ok', ready: legacy.status === 'ok', backend: this.kind, detail: legacy.detail }; + } + const isLive = live.status === 'ok'; + const isReady = ready.status === 'ok'; + return { + ok: isLive && isReady, live: isLive, ready: isReady, backend: this.kind, + version: live.version ?? ready.version, detail: isLive && isReady ? undefined : ready.detail ?? live.detail, + }; + } + + private async probe(route: string): Promise<{ status: 'ok' | 'fail' | 'missing'; version?: string; detail?: string }> { + const headers: Record = { Accept: 'application/json' }; + if (this.options.token) headers['Authorization'] = `Bearer ${this.options.token}`; + try { + const res = await this.doFetch(`${this.base}${route}`, { method: 'GET', headers }); + if (res.status === 404) return { status: 'missing' }; + let body: { version?: string; detail?: string } = {}; + try { body = JSON.parse(await res.text()) as typeof body; } catch { /* plain-text probe */ } + return res.ok ? { status: 'ok', version: body.version } : { status: 'fail', detail: body.detail ?? `HTTP ${res.status}` }; + } catch (err) { + return { status: 'fail', detail: err instanceof Error ? err.message : String(err) }; + } + } + private async request( + method: string, + route: string, + body?: unknown, + extraHeaders?: Record, + epoch?: LeaseEpoch, + ): Promise { + const headers: Record = { + Accept: 'application/json', 'X-Squad-Protocol': BACKEND_PROTOCOL_VERSION, ...extraHeaders, + }; + if (body !== undefined) headers['Content-Type'] = 'application/json'; + if (this.options.token) headers['Authorization'] = `Bearer ${this.options.token}`; + if (epoch !== undefined) headers['X-Lease-Epoch'] = String(epoch); + + let lastError: unknown; + for (let attempt = 0; attempt <= this.maxRetries; attempt++) { + if (attempt > 0) await delay(this.retryDelayMs * 2 ** (attempt - 1)); + let res: Response; + try { + res = await this.doFetch(`${this.base}${route}`, { + method, headers, body: body === undefined ? undefined : JSON.stringify(body), + }); + } catch (err) { + lastError = new BackendError('unavailable', `Network error: ${err instanceof Error ? err.message : String(err)}`); + continue; + } + if (res.ok) { + const text = await res.text(); + return (text ? JSON.parse(text) : {}) as T; + } + const err = await toError(res); + if (err.retryable || RETRYABLE_STATUS.has(res.status)) { lastError = err; continue; } + throw err; + } + throw lastError instanceof BackendError ? lastError : new BackendError('unavailable', 'Request failed'); + } +} + +const enc = encodeURIComponent; +const delay = (ms: number): Promise => new Promise(r => setTimeout(r, ms)); + +async function toError(res: Response): Promise { + let detail = ''; + let code: BackendErrorCode | undefined; + let details: Record | undefined; + let correlationId = res.headers.get('x-correlation-id') ?? undefined; + try { + // Canonical shape is { error: { code, message, retryable, details } }; flat bodies are tolerated. + const body = JSON.parse(await res.text()) as Record; + const e = (body['error'] && typeof body['error'] === 'object' ? body['error'] : body) as Record; + detail = String(e['message'] ?? (typeof body['error'] === 'string' ? body['error'] : '') ?? ''); + if (typeof e['code'] === 'string' && isCode(e['code'])) code = e['code']; + if (e['details'] && typeof e['details'] === 'object') details = e['details'] as Record; + if (typeof e['correlationId'] === 'string') correlationId = e['correlationId']; + } catch { /* non-JSON body */ } + if (!code) { + code = res.status === 404 ? 'not-found' + : res.status === 409 ? 'conflict' + : res.status >= 500 || res.status === 429 ? 'unavailable' + : 'invalid-request'; + } + return new BackendError(code, detail || `HTTP ${res.status}`, res.status, details, correlationId); +} + +function isCode(c: string): c is BackendErrorCode { + return ['not-found', 'stale-epoch', 'approval-hash-mismatch', 'approval-not-pending', + 'invalid-request', 'unsupported-protocol', 'unavailable', 'conflict'].includes(c); +} diff --git a/packages/squad-sdk/src/backend/harness-registry.ts b/packages/squad-sdk/src/backend/harness-registry.ts new file mode 100644 index 000000000..34493fc03 --- /dev/null +++ b/packages/squad-sdk/src/backend/harness-registry.ts @@ -0,0 +1,47 @@ +import { BackendError } from './types.js'; +import type { ExtensionBag, HarnessAdapter, HarnessDescriptor, HarnessRegistry } from './types.js'; + +const EXTENSION_KEY = /^x-([a-z][a-z0-9]*(?:-[a-z0-9]+)*)\.[A-Za-z0-9_.-]+$/; + +/** Returns the harness namespace of an `x-.*` key, or undefined if malformed. */ +export function extensionNamespace(key: string): string | undefined { + return EXTENSION_KEY.exec(key)?.[1]; +} + +/** + * Validates that every key is `x-.*`. When `allowed` is given the + * harness part must be in it. Throws {@link BackendError} `invalid-request`. + */ +export function validateExtensions(extensions: ExtensionBag | undefined, allowed?: Iterable): void { + if (!extensions) return; + const allow = allowed ? new Set([...allowed].map(a => a.replace(/^x-/, ''))) : undefined; + for (const key of Object.keys(extensions)) { + const ns = extensionNamespace(key); + if (!ns) throw new BackendError('invalid-request', `Extension key '${key}' must match x-.*`, 400); + if (allow && !allow.has(ns)) { + throw new BackendError('invalid-request', `Extension namespace 'x-${ns}' is not supported here`, 400); + } + } +} + +export class DefaultHarnessRegistry implements HarnessRegistry { + private readonly adapters = new Map(); + + register(adapter: HarnessAdapter): void { + if (!/^[a-z][a-z0-9-]*$/.test(adapter.id)) { + throw new BackendError('invalid-request', `Invalid harness id '${adapter.id}'`, 400); + } + if (this.adapters.has(adapter.id)) { + throw new BackendError('conflict', `Harness '${adapter.id}' already registered`, 409); + } + this.adapters.set(adapter.id, adapter); + } + + get(id: string): HarnessAdapter | undefined { + return this.adapters.get(id); + } + + list(): HarnessDescriptor[] { + return [...this.adapters.values()].map(a => a.describe()); + } +} diff --git a/packages/squad-sdk/src/backend/index.ts b/packages/squad-sdk/src/backend/index.ts new file mode 100644 index 000000000..61d99dac1 --- /dev/null +++ b/packages/squad-sdk/src/backend/index.ts @@ -0,0 +1,27 @@ +import { DurableHttpSquadBackend } from './durable-http-backend.js'; +import { createFileStateStore, LocalSquadBackend } from './local-backend.js'; +import type { BackendConfig, SquadBackend } from './types.js'; + +export * from './types.js'; +export * from './harness-registry.js'; +export { LocalSquadBackend, MemoryStateStore, createFileStateStore } from './local-backend.js'; +export type { LocalSquadBackendOptions } from './local-backend.js'; +export { DurableHttpSquadBackend } from './durable-http-backend.js'; +export type { DurableHttpOptions } from './durable-http-backend.js'; + +/** Build a backend for `"local"`, `"mcp"` (in-process; shares the local contract) or `"durable-http"`. */ +export function createSquadBackend(config: BackendConfig): SquadBackend { + switch (config.kind) { + case 'local': + case 'mcp': + return new LocalSquadBackend({ + kind: config.kind, + state: config.state ?? createFileStateStore(config.squadDir), + harnesses: config.harnesses, + }); + case 'durable-http': + return new DurableHttpSquadBackend(config); + default: + throw new Error(`Unknown backend kind: ${(config as { kind: string }).kind}`); + } +} diff --git a/packages/squad-sdk/src/backend/local-backend.ts b/packages/squad-sdk/src/backend/local-backend.ts new file mode 100644 index 000000000..dc89eaf53 --- /dev/null +++ b/packages/squad-sdk/src/backend/local-backend.ts @@ -0,0 +1,430 @@ +import { randomUUID } from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { DefaultHarnessRegistry, validateExtensions } from './harness-registry.js'; +import { + ALL_CONTENT_PART_TYPES, + BACKEND_CAPABILITIES, + BACKEND_PROTOCOL_VERSION, + BackendError, + intersectCapabilities, + partsToText, + TERMINAL_RUN_STATES, +} from './types.js'; +import type { + ApprovalRecord, + ApprovalRequest, + ApprovalResponse, + BackendAdmission, + BackendConversation, + BackendCursor, + BackendEnvelope, + BackendEventPage, + BackendHealth, + BackendMessage, + BackendStateStore, + BackendTurn, + CreateConversationOptions, + HarnessEventEnvelope, + HarnessRegistry, + NegotiatedCapabilities, + ProtocolHello, + ReadEventsOptions, + SquadBackend, +} from './types.js'; + +interface StoredConversation { + info: BackendConversation; + transcript: BackendMessage[]; + turns: BackendTurn[]; + events?: BackendEnvelope[]; + approvals?: ApprovalRecord[]; +} + +const ID_RE = /^[A-Za-z0-9._-]{1,128}$/; +const ROOT = 'backend/conversations'; + +export class MemoryStateStore implements BackendStateStore { + private readonly files = new Map(); + read(p: string): string | undefined { return this.files.get(p); } + write(p: string, content: string): void { this.files.set(p, content); } + exists(p: string): boolean { return this.files.has(p); } + list(dir: string): string[] { + const prefix = dir.endsWith('/') ? dir : `${dir}/`; + return [...this.files.keys()].filter(k => k.startsWith(prefix)).map(k => k.slice(prefix.length)); + } +} + +/** File-system store rooted at a `.squad` directory. */ +export function createFileStateStore(squadDir: string): BackendStateStore { + const resolve = (rel: string): string => { + const full = path.resolve(squadDir, rel); + if (!full.startsWith(path.resolve(squadDir) + path.sep)) { + throw new BackendError('invalid-request', 'Path escapes state directory', 400); + } + return full; + }; + return { + read: rel => { + try { return fs.readFileSync(resolve(rel), 'utf8'); } catch { return undefined; } + }, + write: (rel, content) => { + const full = resolve(rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + const tmp = `${full}.${process.pid}.tmp`; + fs.writeFileSync(tmp, content, 'utf8'); + fs.renameSync(tmp, full); + }, + exists: rel => fs.existsSync(resolve(rel)), + list: rel => { + try { return fs.readdirSync(resolve(rel)); } catch { return []; } + }, + }; +} + +export interface LocalSquadBackendOptions { + state?: BackendStateStore; + harnesses?: HarnessRegistry; + kind?: string; +} + +export class LocalSquadBackend implements SquadBackend { + readonly kind: string; + private readonly state: BackendStateStore; + readonly harnesses: HarnessRegistry; + private readonly subscriptions = new Map void>(); + + constructor(options: LocalSquadBackendOptions = {}) { + this.kind = options.kind ?? 'local'; + this.state = options.state ?? new MemoryStateStore(); + this.harnesses = options.harnesses ?? new DefaultHarnessRegistry(); + } + + async createConversation(options: CreateConversationOptions = {}): Promise { + validateExtensions(options.metadata); + const id = options.conversationId ?? randomUUID(); + this.assertId(id); + const existing = this.load(id, false); + if (existing) return existing.info; // idempotent create + const info: BackendConversation = { + conversationId: id, + epoch: 1, + createdAt: new Date().toISOString(), + title: options.title, + metadata: options.metadata, + }; + const conv: StoredConversation = { info, transcript: [], turns: [], events: [] }; + this.emit(conv, 'control', 'conversation.created', { conversationId: id, epoch: 1 }); + this.save(conv); + return info; + } + + async sendMessage(conversationId: string, message: BackendMessage): Promise { + if (message?.parts?.length && !message.content) message = { ...message, content: partsToText(message.parts) }; + if (!message?.runId || !Number.isInteger(message.seq) || message.seq < 0) { + throw new BackendError('invalid-request', 'message requires runId and a non-negative integer seq', 400); + } + const conv = this.load(conversationId); + this.checkEpoch(conv, message.epoch); + const dup = conv.turns.find(t => t.runId === message.runId && t.seq === message.seq); + if (dup) { + return { + protocol: BACKEND_PROTOCOL_VERSION, conversationId, runId: dup.runId, seq: dup.seq, duplicate: true, + state: dup.state, epoch: conv.info.epoch, correlationId: message.correlationId, cursor: this.tailCursor(conv), + }; + } + + const adapter = message.harness ? this.harnesses.get(message.harness) : undefined; + if (message.harness && !adapter) { + throw new BackendError('invalid-request', `Unknown harness '${message.harness}'`, 400); + } + validateExtensions(message.extensions, adapter?.capabilities.extensionNamespaces); + + const stored: BackendMessage = { ...message, epoch: conv.info.epoch }; + const turn: BackendTurn = { + conversationId, + runId: message.runId, + seq: message.seq, + state: adapter ? 'running' : 'queued', + tasks: [], + messages: [stored], + extensions: message.extensions, + }; + conv.transcript.push(stored); + conv.turns.push(turn); + const accepted = this.emit(conv, 'control', 'message.accepted', + { runId: message.runId, seq: message.seq, state: turn.state }, message.correlationId, message.causationId); + this.save(conv); + + if (adapter) { + this.ensureSubscribed(conversationId, adapter.id); + try { + await adapter.submit({ + conversationId, runId: message.runId, seq: message.seq, + messages: [stored], extensions: message.extensions, + }); + } catch (err) { + const fresh = this.load(conversationId); + const t = this.findTurn(fresh, message.runId, message.seq); + if (t && !TERMINAL_RUN_STATES.includes(t.state)) { + t.state = 'failed'; + t.error = err instanceof Error ? err.message : String(err); + this.emit(fresh, 'control', 'turn.state', { runId: t.runId, seq: t.seq, state: 'failed', error: t.error }, message.correlationId, accepted.id); + this.save(fresh); + } + } + } + const latest = this.load(conversationId); + const final = this.findTurn(latest, message.runId, message.seq)!; + return { + protocol: BACKEND_PROTOCOL_VERSION, conversationId, runId: final.runId, seq: final.seq, duplicate: false, + state: final.state, epoch: conv.info.epoch, correlationId: message.correlationId, cursor: accepted.cursor, + }; + } + + async getTranscript(conversationId: string): Promise { + return this.load(conversationId).transcript; + } + + async getTurn(conversationId: string, seq: number): Promise { + return this.requireTurn(this.load(conversationId), seq); + } + + async cancelTurn(conversationId: string, seq: number): Promise { + const conv = this.load(conversationId); + const turn = this.requireTurn(conv, seq); + await this.cancelInternal(conv, turn); + return turn; + } + + async cancelRun(conversationId: string, runId: string, reason?: string): Promise { + const conv = this.load(conversationId); + const turns = conv.turns.filter(t => t.runId === runId); + if (turns.length === 0) throw new BackendError('not-found', `Run '${runId}' not found`, 404); + for (const turn of turns) await this.cancelInternal(conv, turn, reason); + return turns[turns.length - 1]!; + } + + async getApprovals(conversationId: string): Promise { + return this.load(conversationId).approvals ?? []; + } + async respondApproval(conversationId: string, response: ApprovalResponse): Promise { + const conv = this.load(conversationId); + this.checkEpoch(conv, response.epoch); + const turn = conv.turns.find(t => t.pendingApproval?.approvalId === response.approvalId); + if (!turn || !turn.pendingApproval) { + throw new BackendError('approval-not-pending', `No pending approval '${response.approvalId}'`, 409); + } + if (turn.pendingApproval.hash !== response.hash) { + throw new BackendError('approval-hash-mismatch', 'Approval hash does not match the pending request', 409); + } + turn.pendingApproval = undefined; + turn.state = response.decision === 'approve' ? 'running' : 'cancelled'; + const record = (conv.approvals ?? []).find(a => a.approvalId === response.approvalId); + if (record) { + record.version = 2; + record.state = response.decision === 'approve' ? 'approved' : 'denied'; + record.decision = response.decision; + record.reason = response.reason; + record.decidedAt = new Date().toISOString(); + } + this.emit(conv, 'control', 'approval.resolved', + { runId: turn.runId, seq: turn.seq, approvalId: response.approvalId, decision: response.decision, version: 2, state: turn.state }); + this.save(conv); + const adapter = this.adapterFor(turn); + if (adapter) await adapter.respondApproval(conversationId, response); + return this.requireTurn(this.load(conversationId), turn.seq); + } + + async negotiate(hello: ProtocolHello): Promise { + if (!hello?.protocols?.includes(BACKEND_PROTOCOL_VERSION)) { + throw new BackendError('unsupported-protocol', + `Server speaks protocol ${BACKEND_PROTOCOL_VERSION}; client offered [${(hello?.protocols ?? []).join(', ')}]`, 400); + } + const server = new Set(this.harnesses.list().flatMap(h => h.capabilities.extensionNamespaces)); + const wanted = hello.extensionNamespaces; + const effective = intersectCapabilities(BACKEND_CAPABILITIES, hello.capabilities, hello.policyCapabilities); + const parts = (hello.contentParts ?? ALL_CONTENT_PART_TYPES).filter(p => effective.includes(`content:${p}`)); + return { + protocol: BACKEND_PROTOCOL_VERSION, + clientCapabilities: hello.capabilities ? [...hello.capabilities] : [...BACKEND_CAPABILITIES], + backendCapabilities: [...BACKEND_CAPABILITIES], + policyCapabilities: hello.policyCapabilities ? [...hello.policyCapabilities] : undefined, + effectiveCapabilities: effective, + contentParts: ALL_CONTENT_PART_TYPES.filter(p => parts.includes(p)), + extensionNamespaces: [...server].filter(ns => !wanted || wanted.includes(ns)), + streams: (['control', 'execution'] as const).filter(s => effective.includes(`stream:${s}`)), + atLeastOnce: true, + resume: effective.includes('resume'), + approvals: effective.includes('approvals'), + harnesses: this.harnesses.list().map(h => h.id), + }; + } + async readEvents(conversationId: string, options: ReadEventsOptions = {}): Promise { + const conv = this.load(conversationId); + const tail = conv.events?.[conv.events.length - 1]?.sequence ?? 0; + const lastAcceptedSeq = conv.turns.reduce((m, t) => Math.max(m, t.seq), 0); + const after = options.after === undefined ? 0 : decodeCursor(options.after, conversationId); + if (after > tail) { + return { events: [], nextCursor: encodeCursor(conversationId, tail), lastAcceptedSeq, resumable: false }; + } + let events = (conv.events ?? []).filter(e => (e.sequence ?? 0) > after); + if (options.stream) events = events.filter(e => e.stream === options.stream); + if (options.limit !== undefined && options.limit > 0) events = events.slice(0, options.limit); + const last = events[events.length - 1]; + return { + events, + nextCursor: last?.cursor ?? options.after ?? encodeCursor(conversationId, 0), + lastAcceptedSeq, + resumable: true, + }; + } + async health(): Promise { + return { ok: true, live: true, ready: true, backend: this.kind, detail: `${this.harnesses.list().length} harness(es)` }; + } + + // ── internals ───────────────────────────────────────────────────── + + private emit( + conv: StoredConversation, + stream: BackendEnvelope['stream'], + type: string, + payload: unknown, + correlationId?: string, + causationId?: string, + ): BackendEnvelope { + conv.events ??= []; + const sequence = (conv.events[conv.events.length - 1]?.sequence ?? 0) + 1; + const id = conv.info.conversationId; + const env: BackendEnvelope = { + protocol: BACKEND_PROTOCOL_VERSION, + id: `evt_${id}_${sequence}`, + kind: 'event', stream, type, conversationId: id, sequence, + cursor: encodeCursor(id, sequence), correlationId, causationId, + timestamp: new Date().toISOString(), payload, + }; + conv.events.push(env); + return env; + } + + private tailCursor(conv: StoredConversation): BackendCursor { + return conv.events?.[conv.events.length - 1]?.cursor ?? encodeCursor(conv.info.conversationId, 0); + } + + private async cancelInternal(conv: StoredConversation, turn: BackendTurn, reason?: string): Promise { + if (TERMINAL_RUN_STATES.includes(turn.state)) return; + const record = (conv.approvals ?? []).find(a => a.approvalId === turn.pendingApproval?.approvalId); + if (record) { record.state = 'cancelled'; } + turn.state = 'cancelled'; + turn.pendingApproval = undefined; + const requested = this.emit(conv, 'control', 'cancellation.requested', { runId: turn.runId, seq: turn.seq, reason }); + this.emit(conv, 'control', 'turn.state', { runId: turn.runId, seq: turn.seq, state: 'cancelled' }, undefined, requested.id); + this.save(conv); + const adapter = this.adapterFor(turn); + if (adapter) await adapter.cancel(conv.info.conversationId, turn.runId, turn.seq); + } + + private adapterFor(turn: BackendTurn) { + const harness = turn.messages[0]?.harness; + return harness ? this.harnesses.get(harness) : undefined; + } + + private ensureSubscribed(conversationId: string, harnessId: string): void { + const key = `${conversationId}\u0000${harnessId}`; + if (this.subscriptions.has(key)) return; + const adapter = this.harnesses.get(harnessId)!; + this.subscriptions.set(key, adapter.subscribe(conversationId, ev => this.applyEvent(conversationId, ev))); + } + + private applyEvent(conversationId: string, incoming: HarnessEventEnvelope): void { + const ev = incoming; + let emitted: object = ev; + const conv = this.load(conversationId, false); + if (!conv) return; + const turn = this.findTurn(conv, ev.runId, ev.seq); + if (!turn || TERMINAL_RUN_STATES.includes(turn.state)) return; + switch (ev.type) { + case 'message': + turn.messages.push(ev.message); + conv.transcript.push(ev.message); + break; + case 'task': { + const task = turn.tasks.find(t => t.id === ev.id); + if (task) { task.state = ev.state; task.title = ev.title ?? task.title; } + else turn.tasks.push({ id: ev.id, state: ev.state, title: ev.title }); + break; + } + case 'approval-requested': { + const request: ApprovalRequest = { + ...ev.request, version: 1, scope: ev.request.scope ?? { kind: 'turn', runId: turn.runId, seq: turn.seq }, + }; + turn.pendingApproval = request; + turn.state = 'awaiting-approval'; + (conv.approvals ??= []).push({ + ...request, state: 'pending', runId: turn.runId, requestedAt: new Date().toISOString(), + }); + emitted = { ...ev, request }; + break; + } + case 'state': + turn.state = ev.state; + if (ev.error) turn.error = ev.error; + break; + } + this.emit(conv, ev.type === 'state' || ev.type === 'approval-requested' ? 'control' : 'execution', + ev.type === 'state' ? 'turn.state' : ev.type === 'approval-requested' ? 'approval.requested' : `harness.${ev.type}`, + { ...emitted }); + this.save(conv); + } + + private checkEpoch(conv: StoredConversation, epoch: number | undefined): void { + if (epoch === undefined) return; + if (epoch < conv.info.epoch) { + throw new BackendError('stale-epoch', `Epoch ${epoch} is stale; current epoch is ${conv.info.epoch}`, 409); + } + if (epoch > conv.info.epoch) { + conv.info.epoch = epoch; // newer lease holder takes over (fencing) + this.save(conv); + } + } + + private findTurn(conv: StoredConversation, runId: string, seq: number): BackendTurn | undefined { + return conv.turns.find(t => t.runId === runId && t.seq === seq); + } + + private requireTurn(conv: StoredConversation, seq: number): BackendTurn { + const turn = [...conv.turns].reverse().find(t => t.seq === seq); + if (!turn) throw new BackendError('not-found', `Turn ${seq} not found`, 404); + return turn; + } + + private assertId(id: string): void { + if (!ID_RE.test(id)) throw new BackendError('invalid-request', `Invalid conversation id '${id}'`, 400); + } + + private load(id: string): StoredConversation; + private load(id: string, required: false): StoredConversation | undefined; + private load(id: string, required = true): StoredConversation | undefined { + this.assertId(id); + const raw = this.state.read(`${ROOT}/${id}.json`); + if (!raw) { + if (required) throw new BackendError('not-found', `Conversation '${id}' not found`, 404); + return undefined; + } + return JSON.parse(raw) as StoredConversation; + } + + private save(conv: StoredConversation): void { + this.state.write(`${ROOT}/${conv.info.conversationId}.json`, JSON.stringify(conv, null, 2)); + } +} + +function encodeCursor(conversationId: string, sequence: number): BackendCursor { + return Buffer.from(`c1:${conversationId}:${sequence}`, 'utf8').toString('base64url'); +} + +function decodeCursor(cursor: BackendCursor, conversationId: string): number { + const m = /^c1:(.+):(\d+)$/.exec(Buffer.from(cursor, 'base64url').toString('utf8')); + if (!m || m[1] !== conversationId) throw new BackendError('invalid-request', 'Invalid cursor', 400); + return Number(m[2]); +} diff --git a/packages/squad-sdk/src/backend/types.ts b/packages/squad-sdk/src/backend/types.ts new file mode 100644 index 000000000..f922e712e --- /dev/null +++ b/packages/squad-sdk/src/backend/types.ts @@ -0,0 +1,403 @@ +/** + * Squad 2.0 backend/harness decoupling contract. + * + * `SquadBackend` (aka `IConversationBackend`) mirrors DurableSquad's + * conversation backend. Local CLI, MCP and remote durable modes all run + * through this one contract. + * + * @module backend/types + */ + +export type RunState = 'queued' | 'running' | 'awaiting-approval' | 'completed' | 'failed' | 'cancelled'; +export type TaskState = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'; + +export const TERMINAL_RUN_STATES: readonly RunState[] = ['completed', 'failed', 'cancelled']; + +/** Fencing token: a higher epoch supersedes a lower one; stale epochs are rejected. */ +export type LeaseEpoch = number; + +/** Wire protocol version carried by every envelope. */ +export const BACKEND_PROTOCOL_VERSION = '2.0'; + +/** Opaque, server-issued position in a conversation's event log. Clients must not parse it. */ +export type BackendCursor = string; + +export type BackendErrorCode = + | 'not-found' + | 'stale-epoch' + | 'approval-hash-mismatch' + | 'approval-not-pending' + | 'invalid-request' + | 'unsupported-protocol' + | 'unavailable' + | 'conflict'; + +const RETRYABLE_CODES: readonly BackendErrorCode[] = ['unavailable']; + +/** Canonical error structure shared by every transport (HTTP, MCP, in-process). */ +export interface BackendErrorInfo { + code: BackendErrorCode; + message: string; + retryable: boolean; + status?: number; + correlationId?: string; + details?: Record; +} + +export class BackendError extends Error { + readonly retryable: boolean; + constructor( + readonly code: BackendErrorCode, + message: string, + readonly status?: number, + readonly details?: Record, + readonly correlationId?: string, + ) { + super(message); + this.name = 'BackendError'; + this.retryable = RETRYABLE_CODES.includes(code); + } + + toInfo(): BackendErrorInfo { + return { + code: this.code, message: this.message, retryable: this.retryable, + status: this.status, correlationId: this.correlationId, details: this.details, + }; + } +} + +// ── Canonical envelope ────────────────────────────────────────────── + +/** + * Control stream: commands, admissions, lifecycle and approvals. + * Execution stream: harness output (messages, tasks, tool activity). + */ +export type BackendStream = 'control' | 'execution'; + +/** + * Canonical wire envelope. Hosts/transports (HTTP, MCP, in-process) translate + * to and from this shape. Delivery is at-least-once; consumers dedupe on + * `id` / `sequence`, and commands are idempotent via their own keys. + */ +export interface BackendEnvelope { + protocol: string; + /** Opaque unique id; stable across redelivery. */ + id: string; + kind: 'command' | 'event' | 'response'; + stream: BackendStream; + /** Event/command type, e.g. `message.accepted`, `turn.state`. */ + type: string; + conversationId: string; + /** Per-conversation monotonic sequence (events only). */ + sequence?: number; + /** Resume point: pass as `after` to read events following this one. */ + cursor?: BackendCursor; + /** Groups everything belonging to one client request/flow. */ + correlationId?: string; + /** Id of the envelope that directly caused this one. */ + causationId?: string; + timestamp: string; + payload: T; + error?: BackendErrorInfo; +} + +export interface ReadEventsOptions { + after?: BackendCursor; + limit?: number; + stream?: BackendStream; +} + +export interface BackendEventPage { + events: BackendEnvelope[]; + /** Cursor to resume from; equals the input cursor when there are no new events. */ + nextCursor: BackendCursor; + /** Highest message `seq` the backend has accepted for this conversation (client resume state). */ + lastAcceptedSeq: number; + /** alse when the supplied cursor is ahead of the log and the client must re-sync from the transcript. */ + resumable: boolean; +} + +export interface ProtocolHello { + /** Protocol versions the client speaks, preferred first. */ + protocols: string[]; + clientName?: string; + /** Content part types the client can render. */ + contentParts?: BackendContentPart['type'][]; + extensionNamespaces?: string[]; + /** Capability flags the client supports; omitted = no client-side restriction. */ + capabilities?: string[]; + /** Capability flags allowed by policy; omitted = no policy restriction. */ + policyCapabilities?: string[]; +} + +/** Capability flags understood by the backend (see `BACKEND_CAPABILITIES`). */ +export type CapabilityFlag = string; + +export const BACKEND_CAPABILITIES: readonly CapabilityFlag[] = [ + 'stream:control', 'stream:execution', 'resume', 'approvals', 'cancel:run', 'cancel:turn', + 'content:text', 'content:data', 'content:resource', 'content:tool-call', 'content:tool-result', +]; + +/** Effective capabilities = client ∩ backend ∩ policy (undefined sides are unrestricted). */ +export function intersectCapabilities( + backend: readonly CapabilityFlag[], + client?: readonly CapabilityFlag[], + policy?: readonly CapabilityFlag[], +): CapabilityFlag[] { + return backend.filter(c => (!client || client.includes(c)) && (!policy || policy.includes(c))); +} + +export interface NegotiatedCapabilities { + /** Capabilities the client declared (all backend capabilities when it declared none). */ + clientCapabilities: CapabilityFlag[]; + backendCapabilities: CapabilityFlag[]; + policyCapabilities?: CapabilityFlag[]; + /** Intersection of client, backend and (optional) policy capabilities. */ + effectiveCapabilities: CapabilityFlag[]; + protocol: string; + contentParts: BackendContentPart['type'][]; + extensionNamespaces: string[]; + streams: BackendStream[]; + atLeastOnce: true; + resume: boolean; + approvals: boolean; + harnesses: string[]; +} + +export type BackendContentPart = + | { type: 'text'; text: string } + | { type: 'data'; mimeType: string; data: unknown } + | { type: 'resource'; uri: string; mimeType?: string; title?: string } + | { type: 'tool-call'; callId: string; name: string; arguments?: unknown } + | { type: 'tool-result'; callId: string; result?: unknown; isError?: boolean }; + +export const ALL_CONTENT_PART_TYPES: BackendContentPart['type'][] = + ['text', 'data', 'resource', 'tool-call', 'tool-result']; + +/** Renders parts to plain text (used to keep `content` in sync). */ +export function partsToText(parts: BackendContentPart[]): string { + return parts.map(p => { + switch (p.type) { + case 'text': return p.text; + case 'resource': return p.title ?? p.uri; + case 'tool-call': return `[tool-call ${p.name}]`; + case 'tool-result': return `[tool-result ${p.callId}]`; + default: return `[data ${p.mimeType}]`; + } + }).join('\n'); +} + +/** + * Extension fields must live under an `x-.*` namespace, + * e.g. `x-copilot.model`, `x-claude.thinkingBudget`. + */ +export type ExtensionBag = Record; + +export interface BackendMessage { + /** Idempotency key, part 1 of `(runId, seq)`. */ + runId: string; + /** Idempotency key, part 2; monotonically increasing per run. */ + seq: number; + role: 'user' | 'assistant' | 'system' | 'tool'; + /** Plain-text rendering; derived from `parts` when omitted by the sender. */ + content: string; + /** Typed content; authoritative when present. */ + parts?: BackendContentPart[]; + correlationId?: string; + causationId?: string; + /** Fencing token held by the sender. */ + epoch?: LeaseEpoch; + /** Target harness/agent, if the caller wants to route explicitly. */ + harness?: string; + extensions?: ExtensionBag; +} + +export interface BackendAdmission { + protocol: string; + /** Cursor positioned after the admission event. */ + cursor?: BackendCursor; + correlationId?: string; + conversationId: string; + runId: string; + seq: number; + /** `true` when `(runId, seq)` had already been admitted; no new turn created. */ + duplicate: boolean; + state: RunState; + epoch: LeaseEpoch; +} + +export interface ApprovalScope { + kind: 'turn' | 'run' | 'conversation'; + runId?: string; + seq?: number; +} + +export interface ApprovalRequest { + approvalId: string; + turnSeq: number; + /** Monotonic resource version: 1 when requested. */ + version: number; + scope: ApprovalScope; + /** Human readable description of the action requiring approval. */ + action: string; + /** Hash of the canonical action payload; approvals must echo it. */ + hash: string; + payload?: unknown; +} + +/** Approval request as emitted by a harness; the backend stamps version and default scope. */ +export type HarnessApprovalRequest = Omit & { scope?: ApprovalScope }; + +/** Durable, versioned approval resource: version 1 = pending, 2 = decided. */ +export interface ApprovalRecord extends ApprovalRequest { + state: 'pending' | 'approved' | 'denied' | 'cancelled'; + runId: string; + requestedAt: string; + decidedAt?: string; + decision?: 'approve' | 'deny'; + reason?: string; +} + +export interface ApprovalResponse { + approvalId: string; + /** Must equal {@link ApprovalRequest.hash}. */ + hash: string; + decision: 'approve' | 'deny'; + reason?: string; + epoch?: LeaseEpoch; +} + +export interface BackendTurn { + conversationId: string; + runId: string; + seq: number; + state: RunState; + tasks: Array<{ id: string; state: TaskState; title?: string }>; + messages: BackendMessage[]; + pendingApproval?: ApprovalRequest; + error?: string; + extensions?: ExtensionBag; +} + +export interface BackendConversation { + conversationId: string; + epoch: LeaseEpoch; + createdAt: string; + title?: string; + metadata?: ExtensionBag; +} + +export interface CreateConversationOptions { + title?: string; + metadata?: ExtensionBag; + /** Caller-supplied id; the backend generates one when absent. */ + conversationId?: string; +} + +export interface BackendHealth { + /** Liveness (/health/live). */ + live?: boolean; + /** Readiness (/health/ready). */ + ready?: boolean; + ok: boolean; + backend: string; + version?: string; + detail?: string; +} + +/** + * The canonical conversation contract. MCP (`backend-mcp.ts`) is only a + * transport adapter over this interface; it is not the wire contract. + */ +export interface SquadBackend { + readonly kind: string; + /** Capability negotiation; throws `unsupported-protocol` if no common version. */ + negotiate(hello: ProtocolHello): Promise; + /** At-least-once event feed with opaque cursors; safe to re-read. */ + readEvents(conversationId: string, options?: ReadEventsOptions): Promise; + createConversation(options?: CreateConversationOptions): Promise; + sendMessage(conversationId: string, message: BackendMessage): Promise; + getTranscript(conversationId: string): Promise; + getTurn(conversationId: string, seq: number): Promise; + cancelTurn(conversationId: string, seq: number): Promise; + /** Cancel by stable runId (distinct from turn seq). Idempotent. */ + cancelRun(conversationId: string, runId: string, reason?: string): Promise; + /** Durable approval resources, in request order. */ + getApprovals(conversationId: string): Promise; + respondApproval(conversationId: string, response: ApprovalResponse): Promise; + health(): Promise; +} + +/** DurableSquad naming alias. */ +export type IConversationBackend = SquadBackend; + +// ── Harness SPI ───────────────────────────────────────────────────── + +export interface HarnessCapabilities { + streaming: boolean; + approvals: boolean; + cancellation: boolean; + tools: boolean; + /** Extension namespaces this harness understands, e.g. `x-copilot`. */ + extensionNamespaces: string[]; +} + +export interface HarnessSubmission { + conversationId: string; + runId: string; + seq: number; + messages: BackendMessage[]; + extensions?: ExtensionBag; +} + +export type HarnessEvent = + | { type: 'message'; message: BackendMessage } + | { type: 'task'; id: string; state: TaskState; title?: string } + | { type: 'approval-requested'; request: HarnessApprovalRequest } + | { type: 'state'; state: RunState; error?: string }; + +export type HarnessEventEnvelope = HarnessEvent & { runId: string; seq: number }; + +export interface HarnessDescriptor { + id: string; + displayName?: string; + version?: string; + capabilities: HarnessCapabilities; +} + +export interface HarnessAdapter { + readonly id: string; + readonly capabilities: HarnessCapabilities; + submit(submission: HarnessSubmission): Promise; + subscribe(conversationId: string, listener: (event: HarnessEventEnvelope) => void): () => void; + cancel(conversationId: string, runId: string, seq: number): Promise; + respondApproval(conversationId: string, response: ApprovalResponse): Promise; + describe(): HarnessDescriptor; +} + +export interface HarnessRegistry { + register(adapter: HarnessAdapter): void; + get(id: string): HarnessAdapter | undefined; + list(): HarnessDescriptor[]; +} + +export type BackendConfig = + | { kind: 'local'; squadDir: string; harnesses?: HarnessRegistry; state?: BackendStateStore } + | { kind: 'mcp'; squadDir: string; harnesses?: HarnessRegistry; state?: BackendStateStore } + | { + kind: 'durable-http'; + baseUrl: string; + token?: string; + fetch?: typeof fetch; + maxRetries?: number; + retryDelayMs?: number; + epoch?: LeaseEpoch; + }; + +/** Minimal subset of the SDK `StateBackend` used for persistence. */ +export interface BackendStateStore { + read(relativePath: string): string | undefined; + write(relativePath: string, content: string): void; + exists(relativePath: string): boolean; + list(relativeDir: string): string[]; +} diff --git a/packages/squad-sdk/src/index.ts b/packages/squad-sdk/src/index.ts index e15744fe3..e2d71367c 100644 --- a/packages/squad-sdk/src/index.ts +++ b/packages/squad-sdk/src/index.ts @@ -181,3 +181,6 @@ export type { DecisionEntry, GitRunner, } from './state/io/index.js'; + +// Squad 2.0 decoupled backend + harness SPI +export * from './backend/index.js'; diff --git a/test/backend.test.ts b/test/backend.test.ts new file mode 100644 index 000000000..78c2d99a0 --- /dev/null +++ b/test/backend.test.ts @@ -0,0 +1,483 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + BACKEND_PROTOCOL_VERSION, + BackendError, + DefaultHarnessRegistry, + DurableHttpSquadBackend, + LocalSquadBackend, + MemoryStateStore, + createSquadBackend, + extensionNamespace, + validateExtensions, + partsToText, + type HarnessApprovalRequest, + type HarnessAdapter, + type HarnessEventEnvelope, +} from '../packages/squad-sdk/src/backend/index.js'; + +class FakeHarness implements HarnessAdapter { + readonly id = 'copilot'; + readonly capabilities = { streaming: true, approvals: true, cancellation: true, tools: true, extensionNamespaces: ['x-copilot'] }; + submitted: Array<{ runId: string; seq: number }> = []; + cancelled: number[] = []; + approvals: string[] = []; + private listeners = new Set<(e: HarnessEventEnvelope) => void>(); + async submit(s: { runId: string; seq: number }) { this.submitted.push({ runId: s.runId, seq: s.seq }); } + subscribe(_c: string, l: (e: HarnessEventEnvelope) => void) { this.listeners.add(l); return () => this.listeners.delete(l); } + async cancel(_c: string, _r: string, seq: number) { this.cancelled.push(seq); } + async respondApproval(_c: string, r: { approvalId: string }) { this.approvals.push(r.approvalId); } + describe() { return { id: this.id, capabilities: this.capabilities }; } + emit(e: HarnessEventEnvelope) { this.listeners.forEach(l => l(e)); } +} + +const msg = (runId: string, seq: number, extra: object = {}) => ({ runId, seq, role: 'user' as const, content: `m${seq}`, ...extra }); + +async function rejectCode(p: Promise): Promise { + try { await p; } catch (e) { return e instanceof BackendError ? e.code : 'other'; } + return undefined; +} + +describe('LocalSquadBackend', () => { + it('runs the conversation lifecycle', async () => { + const b = new LocalSquadBackend(); + const conv = await b.createConversation({ conversationId: 'c1', title: 'T' }); + expect(conv.epoch).toBe(1); + expect((await b.createConversation({ conversationId: 'c1' })).createdAt).toBe(conv.createdAt); + const adm = await b.sendMessage('c1', msg('r1', 1)); + expect(adm).toMatchObject({ duplicate: false, state: 'queued', runId: 'r1', seq: 1 }); + expect(await b.getTranscript('c1')).toHaveLength(1); + expect((await b.getTurn('c1', 1)).messages[0]?.content).toBe('m1'); + expect((await b.cancelTurn('c1', 1)).state).toBe('cancelled'); + expect((await b.health()).ok).toBe(true); + expect(await rejectCode(b.getTurn('c1', 9))).toBe('not-found'); + expect(await rejectCode(b.getTranscript('nope'))).toBe('not-found'); + }); + + it('dedups on (runId, seq)', async () => { + const b = new LocalSquadBackend(); + await b.createConversation({ conversationId: 'c' }); + await b.sendMessage('c', msg('r1', 1)); + const dup = await b.sendMessage('c', msg('r1', 1, { content: 'changed' })); + expect(dup.duplicate).toBe(true); + await b.sendMessage('c', msg('r2', 1)); + const t = await b.getTranscript('c'); + expect(t).toHaveLength(2); + expect(t[0]?.content).toBe('m1'); + }); + + it('fences with lease epochs', async () => { + const b = new LocalSquadBackend(); + await b.createConversation({ conversationId: 'c' }); + expect((await b.sendMessage('c', msg('r', 1, { epoch: 3 }))).epoch).toBe(3); + expect(await rejectCode(b.sendMessage('c', msg('r', 2, { epoch: 2 })))).toBe('stale-epoch'); + }); + + it('persists through the state store', async () => { + const state = new MemoryStateStore(); + await new LocalSquadBackend({ state }).createConversation({ conversationId: 'p' }); + expect(await new LocalSquadBackend({ state }).getTranscript('p')).toEqual([]); + }); + + it('verifies hash-bound approvals', async () => { + const h = new FakeHarness(); + const harnesses = new DefaultHarnessRegistry(); + harnesses.register(h); + const b = new LocalSquadBackend({ harnesses }); + await b.createConversation({ conversationId: 'c' }); + const adm = await b.sendMessage('c', msg('r', 1, { harness: 'copilot', extensions: { 'x-copilot.model': 'x' } })); + expect(adm.state).toBe('running'); + const req: HarnessApprovalRequest = { approvalId: 'a1', turnSeq: 1, action: 'rm', hash: 'h1' }; + h.emit({ type: 'approval-requested', request: req, runId: 'r', seq: 1 }); + expect((await b.getTurn('c', 1)).state).toBe('awaiting-approval'); + + expect(await rejectCode(b.respondApproval('c', { approvalId: 'a1', hash: 'bad', decision: 'approve' }))).toBe('approval-hash-mismatch'); + expect(await rejectCode(b.respondApproval('c', { approvalId: 'zz', hash: 'h1', decision: 'approve' }))).toBe('approval-not-pending'); + const turn = await b.respondApproval('c', { approvalId: 'a1', hash: 'h1', decision: 'approve' }); + expect(turn.state).toBe('running'); + expect(h.approvals).toEqual(['a1']); + expect(await rejectCode(b.respondApproval('c', { approvalId: 'a1', hash: 'h1', decision: 'approve' }))).toBe('approval-not-pending'); + + h.emit({ type: 'message', runId: 'r', seq: 1, message: { runId: 'r', seq: 2, role: 'assistant', content: 'done' } }); + h.emit({ type: 'state', state: 'completed', runId: 'r', seq: 1 }); + expect((await b.getTurn('c', 1)).state).toBe('completed'); + expect(await b.getTranscript('c')).toHaveLength(2); + await b.cancelTurn('c', 1); + expect(h.cancelled).toEqual([]); + }); + + it('rejects unknown harnesses and bad extensions', async () => { + const harnesses = new DefaultHarnessRegistry(); + harnesses.register(new FakeHarness()); + const b = new LocalSquadBackend({ harnesses }); + await b.createConversation({ conversationId: 'c' }); + expect(await rejectCode(b.sendMessage('c', msg('r', 1, { harness: 'nope' })))).toBe('invalid-request'); + expect(await rejectCode(b.sendMessage('c', msg('r', 2, { harness: 'copilot', extensions: { 'x-claude.k': 1 } })))).toBe('invalid-request'); + expect(await rejectCode(b.sendMessage('c', msg('r', 3, { extensions: { model: 1 } })))).toBe('invalid-request'); + }); +}); + +describe('canonical envelope protocol', () => { + it('negotiates protocol and capabilities', async () => { + const harnesses = new DefaultHarnessRegistry(); + harnesses.register(new FakeHarness()); + const b = new LocalSquadBackend({ harnesses }); + const caps = await b.negotiate({ protocols: ['9.9', BACKEND_PROTOCOL_VERSION], contentParts: ['text', 'resource'] }); + expect(caps).toMatchObject({ protocol: BACKEND_PROTOCOL_VERSION, contentParts: ['text', 'resource'], atLeastOnce: true, harnesses: ['copilot'], resume: true, approvals: true }); + expect(caps.extensionNamespaces).toEqual(['x-copilot']); + expect(await rejectCode(b.negotiate({ protocols: ['1.0'] }))).toBe('unsupported-protocol'); + }); + + it('emits ordered envelopes with correlation/causation and resumable opaque cursors', async () => { + const h = new FakeHarness(); + const harnesses = new DefaultHarnessRegistry(); + harnesses.register(h); + const b = new LocalSquadBackend({ harnesses }); + await b.createConversation({ conversationId: 'c' }); + const adm = await b.sendMessage('c', msg('r', 1, { harness: 'copilot', correlationId: 'corr-1' })); + expect(adm.protocol).toBe(BACKEND_PROTOCOL_VERSION); + expect(adm.correlationId).toBe('corr-1'); + h.emit({ type: 'message', runId: 'r', seq: 1, message: { runId: 'r', seq: 2, role: 'assistant', content: 'hi' } }); + h.emit({ type: 'state', state: 'completed', runId: 'r', seq: 1 }); + + const page = await b.readEvents('c'); + expect(page.events.map(e => e.sequence)).toEqual([1, 2, 3, 4]); + expect(page.events.map(e => e.type)).toEqual(['conversation.created', 'message.accepted', 'harness.message', 'turn.state']); + expect(page.events.map(e => e.stream)).toEqual(['control', 'control', 'execution', 'control']); + expect(page.events.every(e => e.protocol === BACKEND_PROTOCOL_VERSION && e.kind === 'event')).toBe(true); + expect(page.events[1]!.correlationId).toBe('corr-1'); + expect(adm.cursor).toBe(page.events[1]!.cursor); + + // resume after the admission cursor; at-least-once re-reads are stable + const rest = await b.readEvents('c', { after: adm.cursor }); + expect(rest.events.map(e => e.sequence)).toEqual([3, 4]); + expect((await b.readEvents('c', { after: adm.cursor })).events.map(e => e.id)).toEqual(rest.events.map(e => e.id)); + expect((await b.readEvents('c', { after: rest.nextCursor })).events).toEqual([]); + expect((await b.readEvents('c', { stream: 'execution' })).events).toHaveLength(1); + expect((await b.readEvents('c', { limit: 2 })).events).toHaveLength(2); + expect(await rejectCode(b.readEvents('c', { after: 'garbage' }))).toBe('invalid-request'); + }); + + it('keeps typed content parts and derives text', async () => { + const b = new LocalSquadBackend(); + await b.createConversation({ conversationId: 'c' }); + await b.sendMessage('c', { runId: 'r', seq: 1, role: 'user', content: '', parts: [ + { type: 'text', text: 'look' }, { type: 'resource', uri: 'file:///a.ts', title: 'a.ts' }, + ] }); + const [m] = await b.getTranscript('c'); + expect(m!.parts).toHaveLength(2); + expect(m!.content).toBe('look\na.ts'); + expect(partsToText([{ type: 'tool-result', callId: 'x' }])).toBe('[tool-result x]'); + }); + + it('exposes canonical error structure', async () => { + const b = new LocalSquadBackend(); + try { await b.getTranscript('missing'); } catch (e) { + expect((e as BackendError).toInfo()).toMatchObject({ code: 'not-found', retryable: false, status: 404 }); + } + expect(new BackendError('unavailable', 'x').toInfo().retryable).toBe(true); + }); +}); + +function jsonRes(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); +} + +describe('DurableHttpSquadBackend', () => { + const mk = (fetchImpl: typeof fetch, extra: object = {}) => + new DurableHttpSquadBackend({ baseUrl: 'http://x/', token: 'tok', fetch: fetchImpl, retryDelayMs: 1, ...extra }); + + it('speaks the /v1/conversations wire protocol', async () => { + const calls: Array<{ url: string; method: string; headers: Record; body?: string }> = []; + const f = vi.fn(async (url: string, init: RequestInit) => { + calls.push({ url, method: init.method!, headers: init.headers as Record, body: init.body as string | undefined }); + if (url.endsWith('/v1/conversations')) return jsonRes(200, { conversationId: 'c', epoch: 4, createdAt: 'now' }); + if (url.endsWith('/messages')) return jsonRes(200, { conversationId: 'c', runId: 'r', seq: 1, duplicate: false, state: 'queued', epoch: 4 }); + if (url.endsWith('/transcript')) return jsonRes(200, { messages: [{ runId: 'r', seq: 1, role: 'user', content: 'hi' }] }); + if (url.endsWith('/cancel')) return jsonRes(200, { state: 'cancelled' }); + if (url.endsWith('/health/live') || url.endsWith('/health/ready')) return jsonRes(200, { status: 'ok', version: '1' }); + return jsonRes(200, { state: 'running', seq: 1 }); + }); + const b = mk(f as unknown as typeof fetch); + await b.createConversation(); + await b.sendMessage('c', msg('r', 1)); + expect(await b.getTranscript('c')).toHaveLength(1); + await b.getTurn('c', 1); + await b.cancelTurn('c', 1); + await b.respondApproval('c', { approvalId: 'a', hash: 'h', decision: 'approve' }); + expect((await b.health()).version).toBe('1'); + + expect(calls.map(c => `${c.method} ${c.url}`)).toEqual([ + 'POST http://x/v1/conversations', + 'POST http://x/v1/conversations/c/messages', + 'GET http://x/v1/conversations/c/transcript', + 'GET http://x/v1/conversations/c/turns/1', + 'POST http://x/v1/conversations/c/turns/1/cancel', + 'POST http://x/v1/conversations/c/approvals/a', + 'GET http://x/health/live', + 'GET http://x/health/ready', + ]); + const send = calls[1]!; + expect(send.headers['Idempotency-Key']).toBe('r:1'); + expect(send.headers['X-Lease-Epoch']).toBe('4'); // learned from createConversation + expect(send.headers['Authorization']).toBe('Bearer tok'); + }); + + it('retries transient failures with the same idempotency key', async () => { + const keys: string[] = []; + let n = 0; + const f = vi.fn(async (_u: string, init: RequestInit) => { + keys.push((init.headers as Record)['Idempotency-Key']!); + n++; + if (n === 1) throw new Error('ECONNRESET'); + if (n === 2) return jsonRes(503, { error: 'busy' }); + return jsonRes(200, { conversationId: 'c', runId: 'r', seq: 1, duplicate: false, state: 'queued', epoch: 1 }); + }); + const adm = await mk(f as unknown as typeof fetch).sendMessage('c', msg('r', 1)); + expect(adm.state).toBe('queued'); + expect(f).toHaveBeenCalledTimes(3); + expect(new Set(keys)).toEqual(new Set(['r:1'])); + }); + + it('does not retry 4xx and maps stale epoch', async () => { + const f = vi.fn(async () => jsonRes(409, { code: 'stale-epoch', message: 'epoch 1 < 2' })); + const b = mk(f as unknown as typeof fetch); + expect(await rejectCode(b.sendMessage('c', msg('r', 1, { epoch: 1 })))).toBe('stale-epoch'); + expect(f).toHaveBeenCalledTimes(1); + const f404 = vi.fn(async () => jsonRes(404, {})); + expect(await rejectCode(mk(f404 as unknown as typeof fetch).getTurn('c', 1))).toBe('not-found'); + }); + + it('probes /health/live and /health/ready, falls back to /health, reports not-ready', async () => { + const urls: string[] = []; + const ready503 = vi.fn(async (url: string) => { + urls.push(url); + return url.endsWith('/health/ready') ? jsonRes(503, { detail: 'warming up' }) : jsonRes(200, { version: '2' }); + }); + expect(await mk(ready503 as unknown as typeof fetch).health()).toMatchObject({ ok: false, live: true, ready: false, detail: 'warming up' }); + expect(urls.sort()).toEqual(['http://x/health/live', 'http://x/health/ready']); // 503 not retried + + const legacy = vi.fn(async (url: string) => (url.endsWith('/health') ? jsonRes(200, {}) : jsonRes(404, {}))); + expect(await mk(legacy as unknown as typeof fetch).health()).toMatchObject({ ok: true, live: true, ready: true }); + expect(legacy).toHaveBeenLastCalledWith('http://x/health', expect.anything()); + }); + + it('cancels by runId and lists approvals over HTTP', async () => { + const seen: Array<{ url: string; method: string; headers: Record; body?: string }> = []; + const f = vi.fn(async (url: string, init: RequestInit) => { + seen.push({ url, method: init.method!, headers: init.headers as Record, body: init.body as string | undefined }); + return url.endsWith('/approvals') ? jsonRes(200, { approvals: [{ approvalId: 'a', version: 2 }] }) : jsonRes(200, { runId: 'r/1', state: 'cancelled' }); + }); + const b = mk(f as unknown as typeof fetch, { epoch: 5 }); + await b.cancelRun('c', 'r/1', 'why'); + expect(seen[0]).toMatchObject({ method: 'POST', url: 'http://x/v1/conversations/c/runs/r%2F1/cancel' }); + expect(seen[0]!.headers['X-Lease-Epoch']).toBe('5'); + expect(JSON.parse(seen[0]!.body!)).toEqual({ reason: 'why' }); + expect(await b.getApprovals('c')).toEqual([{ approvalId: 'a', version: 2 }]); + expect(seen[1]).toMatchObject({ method: 'GET', url: 'http://x/v1/conversations/c/approvals' }); + }); + + it('maps canonical error bodies, sends protocol/correlation headers, and reads events', async () => { + const seen: Array<{ url: string; headers: Record }> = []; + const f = vi.fn(async (url: string, init: RequestInit) => { + seen.push({ url, headers: init.headers as Record }); + if (url.includes('/events')) return jsonRes(200, { events: [], nextCursor: 'cur' }); + if (url.endsWith('/v1/capabilities')) return jsonRes(200, { protocol: '2.0', contentParts: ['text'] }); + return jsonRes(409, { error: { code: 'approval-hash-mismatch', message: 'bad hash', retryable: false, details: { approvalId: 'a' }, correlationId: 'corr-9' } }); + }); + const b = mk(f as unknown as typeof fetch); + expect((await b.readEvents('c', { after: 'x y', limit: 5, stream: 'control' })).nextCursor).toBe('cur'); + expect(seen[0]!.url).toBe('http://x/v1/conversations/c/events?after=x+y&limit=5&stream=control'); + expect(seen[0]!.headers['X-Squad-Protocol']).toBe(BACKEND_PROTOCOL_VERSION); + expect((await b.negotiate({ protocols: ['2.0'] })).protocol).toBe('2.0'); + expect(seen[1]!.url).toBe('http://x/v1/capabilities'); + try { + await b.respondApproval('c', { approvalId: 'a', hash: 'h', decision: 'approve' }); + expect.unreachable(); + } catch (e) { + expect((e as BackendError).toInfo()).toMatchObject({ + code: 'approval-hash-mismatch', retryable: false, correlationId: 'corr-9', details: { approvalId: 'a' }, + }); + } + await b.sendMessage('c', msg('r', 1, { correlationId: 'corr-1' })).catch(() => undefined); + expect(seen[seen.length - 1]!.headers['X-Correlation-Id']).toBe('corr-1'); + }); + + it('gives up after maxRetries and health reports failure', async () => { + const f = vi.fn(async () => jsonRes(500, {})); + const b = mk(f as unknown as typeof fetch, { maxRetries: 2 }); + expect(await rejectCode(b.getTranscript('c'))).toBe('unavailable'); + expect(f).toHaveBeenCalledTimes(3); + expect((await b.health()).ok).toBe(false); + }); +}); + +describe('squad 2.0 wire lock-in', () => { + it('cancels by stable runId (distinct from seq), idempotently', async () => { + const h = new FakeHarness(); + const harnesses = new DefaultHarnessRegistry(); + harnesses.register(h); + const b = new LocalSquadBackend({ harnesses }); + await b.createConversation({ conversationId: 'c' }); + const adm = await b.sendMessage('c', msg('run-A', 7, { harness: 'copilot' })); + expect(adm).toMatchObject({ runId: 'run-A', seq: 7 }); + expect(await b.getTurn('c', 7)).toMatchObject({ runId: 'run-A', seq: 7 }); + const turn = await b.cancelRun('c', 'run-A', 'user asked'); + expect(turn).toMatchObject({ runId: 'run-A', seq: 7, state: 'cancelled' }); + await b.cancelRun('c', 'run-A'); + expect(h.cancelled).toEqual([7]); + const types = (await b.readEvents('c')).events.map(e => e.type); + expect(types.filter(t => t === 'cancellation.requested')).toHaveLength(1); + expect((await b.readEvents('c')).events.find(e => e.type === 'cancellation.requested')!.payload).toMatchObject({ runId: 'run-A', reason: 'user asked' }); + expect(await rejectCode(b.cancelRun('c', 'nope'))).toBe('not-found'); + }); + + it('computes effective capabilities as client ∩ backend ∩ policy', async () => { + const b = new LocalSquadBackend(); + const caps = await b.negotiate({ + protocols: [BACKEND_PROTOCOL_VERSION], + capabilities: ['approvals', 'resume', 'content:text', 'content:data', 'telepathy'], + policyCapabilities: ['approvals', 'content:text', 'cancel:run'], + }); + expect(caps.effectiveCapabilities).toEqual(['approvals', 'content:text']); + expect(caps.clientCapabilities).toContain('telepathy'); + expect(caps.backendCapabilities).not.toContain('telepathy'); + expect(caps.policyCapabilities).toEqual(['approvals', 'content:text', 'cancel:run']); + expect(caps).toMatchObject({ approvals: true, resume: false, contentParts: ['text'], streams: [] }); + const open = await b.negotiate({ protocols: [BACKEND_PROTOCOL_VERSION] }); + expect(open.effectiveCapabilities).toEqual(open.backendCapabilities); + }); + + it('exposes approvals as durable versioned resources', async () => { + const h = new FakeHarness(); + const harnesses = new DefaultHarnessRegistry(); + harnesses.register(h); + const b = new LocalSquadBackend({ harnesses }); + await b.createConversation({ conversationId: 'c' }); + await b.sendMessage('c', msg('r', 1, { harness: 'copilot' })); + h.emit({ type: 'approval-requested', runId: 'r', seq: 1, request: { approvalId: 'a1', turnSeq: 1, action: 'rm', hash: 'h' } }); + const [pending] = await b.getApprovals('c'); + expect(pending).toMatchObject({ approvalId: 'a1', version: 1, state: 'pending', runId: 'r', scope: { kind: 'turn', runId: 'r', seq: 1 } }); + expect((await b.getTurn('c', 1)).pendingApproval?.version).toBe(1); + await b.respondApproval('c', { approvalId: 'a1', hash: 'h', decision: 'deny', reason: 'no' }); + const [decided] = await b.getApprovals('c'); + expect(decided).toMatchObject({ version: 2, state: 'denied', decision: 'deny', reason: 'no' }); + expect(decided!.decidedAt).toBeDefined(); + }); + + it('reports lastAcceptedSeq and resumable on event pages', async () => { + const b = new LocalSquadBackend(); + await b.createConversation({ conversationId: 'c' }); + await b.sendMessage('c', msg('r', 4)); + const page = await b.readEvents('c'); + expect(page).toMatchObject({ lastAcceptedSeq: 4, resumable: true }); + const other = new LocalSquadBackend(); + await other.createConversation({ conversationId: 'c' }); + await other.sendMessage('c', msg('r', 1)); + await other.sendMessage('c', msg('r', 2)); + const ahead = await other.readEvents('c'); + const fresh = new LocalSquadBackend(); + await fresh.createConversation({ conversationId: 'c' }); + const stale = await fresh.readEvents('c', { after: ahead.nextCursor }); + expect(stale).toMatchObject({ resumable: false, events: [], lastAcceptedSeq: 0 }); + }); + + describe('golden trace', () => { + const normalize = (events: Array<{ type: string; stream: string; sequence?: number; payload: unknown }>) => + events.map(e => ({ type: e.type, stream: e.stream, sequence: e.sequence, payload: e.payload })); + + async function run(disconnect: boolean) { + const h = new FakeHarness(); + const harnesses = new DefaultHarnessRegistry(); + harnesses.register(h); + const state = new MemoryStateStore(); + let client = new LocalSquadBackend({ state, harnesses }); + await client.createConversation({ conversationId: 'c' }); + + // TurnAccepted(runId, seq) + const adm = await client.sendMessage('c', msg('r', 1, { harness: 'copilot' })); + expect(adm).toMatchObject({ runId: 'r', seq: 1, duplicate: false }); + // ApprovalRequested(v1) + h.emit({ type: 'approval-requested', runId: 'r', seq: 1, request: { approvalId: 'a1', turnSeq: 1, action: 'deploy', hash: 'hh' } }); + const seen = new Map(); + const first = await client.readEvents('c'); + first.events.forEach(e => seen.set(e.id, e)); + let cursor = first.nextCursor; + + if (disconnect) { + // disconnect: drop the client; reconnect (new instance, same durable state) from the last cursor. + client = new LocalSquadBackend({ state, harnesses }); + const replay = await client.readEvents('c', { after: first.events[0]!.cursor }); + expect(replay.resumable).toBe(true); + replay.events.forEach(e => seen.set(e.id, e)); // at-least-once redelivery, deduped by id + } + + // ApprovalDecided(v2), retried (at-most-once effect) + const decided = await client.respondApproval('c', { approvalId: 'a1', hash: 'hh', decision: 'approve' }); + expect(decided.state).toBe('running'); + expect(await rejectCode(client.respondApproval('c', { approvalId: 'a1', hash: 'hh', decision: 'approve' }))).toBe('approval-not-pending'); + expect((await client.sendMessage('c', msg('r', 1, { harness: 'copilot' }))).duplicate).toBe(true); + // CancellationRequested(runId) -> terminal, retried + expect((await client.cancelRun('c', 'r')).state).toBe('cancelled'); + await client.cancelRun('c', 'r'); + + const rest = await client.readEvents('c', { after: cursor }); + rest.events.forEach(e => seen.set(e.id, e)); + const all = await client.readEvents('c'); + expect([...seen.keys()]).toEqual(all.events.map(e => e.id)); + return { h, events: all.events, approvals: await client.getApprovals('c') }; + } + + it('keeps identical canonical history across disconnect/reconnect with at-most-once effects', async () => { + const steady = await run(false); + const resumed = await run(true); + expect(normalize(resumed.events)).toEqual(normalize(steady.events)); + expect(resumed.events.map(e => e.type)).toEqual([ + 'conversation.created', 'message.accepted', 'approval.requested', + 'approval.resolved', 'cancellation.requested', 'turn.state', + ]); + const requested = resumed.events.find(e => e.type === 'approval.requested')!; + const decided = resumed.events.find(e => e.type === 'approval.resolved')!; + expect((requested.payload as { request: { version: number } }).request.version).toBe(1); + expect(decided.payload).toMatchObject({ approvalId: 'a1', version: 2, decision: 'approve' }); + expect(resumed.events[5]!.payload).toMatchObject({ runId: 'r', state: 'cancelled' }); + expect(resumed.events[5]!.causationId).toBe(resumed.events[4]!.id); + expect(resumed.approvals[0]).toMatchObject({ version: 2, state: 'approved' }); + for (const r of [steady, resumed]) { + expect(r.h.submitted).toEqual([{ runId: 'r', seq: 1 }]); + expect(r.h.approvals).toEqual(['a1']); + expect(r.h.cancelled).toEqual([1]); + } + }); + }); +}); + +describe('harness registry and extensions', () => { + it('registers, lists and rejects duplicates / bad ids', () => { + const r = new DefaultHarnessRegistry(); + const h = new FakeHarness(); + r.register(h); + expect(r.get('copilot')).toBe(h); + expect(r.list().map(d => d.id)).toEqual(['copilot']); + expect(() => r.register(h)).toThrow(/already registered/); + expect(() => r.register({ ...h, id: 'Bad Id' } as unknown as HarnessAdapter)).toThrow(/Invalid harness id/); + }); + + it('validates x-.* keys', () => { + expect(extensionNamespace('x-copilot.model')).toBe('copilot'); + expect(extensionNamespace('x-claude-code.a.b')).toBe('claude-code'); + expect(extensionNamespace('copilot.model')).toBeUndefined(); + expect(extensionNamespace('x-copilot')).toBeUndefined(); + expect(() => validateExtensions({ 'x-a.b': 1 })).not.toThrow(); + expect(() => validateExtensions({ bad: 1 })).toThrow(BackendError); + expect(() => validateExtensions({ 'x-a.b': 1 }, ['x-c'])).toThrow(/not supported/); + expect(() => validateExtensions({ 'x-c.b': 1 }, ['x-c'])).not.toThrow(); + }); +}); + +describe('createSquadBackend', () => { + it('builds each backend kind', () => { + expect(createSquadBackend({ kind: 'local', squadDir: '.', state: new MemoryStateStore() }).kind).toBe('local'); + expect(createSquadBackend({ kind: 'mcp', squadDir: '.', state: new MemoryStateStore() }).kind).toBe('mcp'); + expect(createSquadBackend({ kind: 'durable-http', baseUrl: 'http://x' }).kind).toBe('durable-http'); + }); +}); From 56a1fa893c1f2ca0ddf535618ac0eb38e544af62 Mon Sep 17 00:00:00 2001 From: Copilot Date: Sat, 17 Oct 2026 22:15:59 +0300 Subject: [PATCH 02/46] test(sdk): Squad 2.0 security & conformance contracts Add security/isolation proposal and executable contract suite covering path/MCP/epoch guards, configured-vs-negotiated capabilities, versioned approvals with runId cancellation, loss-tolerant projection, and the golden cross-adapter conformance trace. Include packages/squad-sdk tests in vitest config. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 995eea17-c356-4912-90ea-5e5b144e7ba9 --- .../squad-2.0-security-and-isolation.md | 103 ++ .../test/squad-20-security-contracts.test.ts | 1029 +++++++++++++++++ vitest.config.ts | 2 +- 3 files changed, 1133 insertions(+), 1 deletion(-) create mode 100644 docs/proposals/squad-2.0-security-and-isolation.md create mode 100644 packages/squad-sdk/test/squad-20-security-contracts.test.ts diff --git a/docs/proposals/squad-2.0-security-and-isolation.md b/docs/proposals/squad-2.0-security-and-isolation.md new file mode 100644 index 000000000..447952960 --- /dev/null +++ b/docs/proposals/squad-2.0-security-and-isolation.md @@ -0,0 +1,103 @@ +# Squad 2.0 — Security, Threat Model & Isolation Gates + +**Status:** Proposal · **Owner:** Worf (Security & Reliability) · **Scope:** Client/Backend decoupling and multi-client projections (Claude Code, Kiro, OpenCode, Cursor, GitHub Copilot) plus the `DurableSquad` remote backend. +**Executable contract:** `packages/squad-sdk/test/squad-20-security-contracts.test.ts` + +## 1. Trust model + +| Actor | Trust | Notes | +|---|---|---| +| Client / harness (any of the five) | **Untrusted caller** | Prompt-injectable; may replay, reorder, or tamper with requests. | +| Projected files (`.claude/`, `.kiro/`, `.opencode/`, `.cursor/`, `.github/`, `.agents/`, `.squad/`, MCP configs) | **Untrusted input** | Writable by the user, other tools, and cloned repos. | +| Control Plane / Broker | **Trusted** | Sole authority for state writes, memory governance, approvals, leases. | +| `DurableSquad` remote backend | Trusted only through broker-issued epochs and idempotency keys | Network is hostile; replays and partitions are expected. | + +### Control Plane / Broker invariants +- **B1** Clients never write `.squad/` state, decisions, or memory directly; every mutation is a broker proposal. +- **B2** Approvals are **hash-bound**: approval = SHA-256 over canonicalized `{runId, action, args, epoch}` (sorted keys). Any change to the proposal invalidates it (no TOCTOU). +- **B3** Approvals are single-use; replay of the same `(runId, seq)` returns the recorded result, never re-executes. +- **B4** Broker never trusts client-supplied identity, epoch, or paths; they are re-derived/validated. +- **B5** Secrets are never persisted into projections, logs, or proposals; env values matching `token|secret|key|password|pat` are redacted in all diagnostics. + +## 2. Safe MCP configuration merging + +Targets: `.mcp.json`, `.kiro/settings/mcp.json`, `opencode.json` (key `mcp`), `.cursor/mcp.json` (refs #1893, #1296). + +- **M1** Merge is additive and scoped to Squad-owned server names; unrelated servers and top-level keys are preserved byte-semantically. +- **M2** Existing user `env` values win over Squad defaults; user env is never dropped or rewritten. +- **M3** Malformed JSON, non-object roots, or wrong-typed containers abort with no write (never "repair by overwrite"). +- **M4** Server names must match `^[A-Za-z0-9._-]{1,64}$`; `__proto__`/`constructor` rejected; objects built without prototype pollution. +- **M5** `command` is a single executable token: no whitespace or shell metacharacters (`; & | \` $ < > \n`), and shell interpreters (`sh`, `bash`, `cmd`, `powershell`, `pwsh`) are rejected. Arguments must be strings; remote `url` must be `https`. +- **M6** Writes are atomic (temp file + rename in same dir), idempotent, and validated before touching disk. +- **M7** Secrets are referenced (env passthrough / `${VAR}`), never inlined by Squad. + +## 3. Path, symlink & reparse-point guards + +Applies to every skill/agent/config path under `.claude/`, `.kiro/`, `.opencode/`, `.cursor/`, `.github/`, `.agents/`, `.squad/`. + +- **P1** Reject `..` segments (both `/` and `\`), absolute paths, drive-relative paths, UNC paths, NUL bytes, and NTFS alternate data streams (`:`). +- **P2** Walk each path segment with `lstat`; reject any symlink, dangling symlink, or NTFS junction/mount point (`FILE_ATTRIBUTE_REPARSE_POINT`, 0x400) — including the client directory itself. +- **P3** Verify the final path is within `realpath(root)` after resolution. +- **P4** Guards run on read **and** write, and again immediately before use (re-check to narrow TOCTOU; prefer `O_NOFOLLOW`/handle-based opens where available). +- **P5** Windows tests must exercise a real `mklink /J` junction. + +## 4. State-backend isolation + +- **S1** Each worktree has its own state namespace; no shared mutable file between worktrees. Cross-worktree reads go through the broker. +- **S2** Single writer via lease. Every lease acquisition bumps a strictly monotonic **epoch**; every write carries `(holder, epoch)`. +- **S3** Writes from a stale epoch or non-holder are **fenced** (rejected) with no partial effect. +- **S4** `DurableSquad` conversations are addressed by `(runId, seq)`; the backend treats duplicates as idempotent replays and out-of-order/gapped sequences as errors. +- **S5** A fenced client must re-acquire and re-read state before retrying; it cannot "resume" an old epoch. + +## 5. Security gates (CI-blocking) + +| Gate | Requirement | +|---|---| +| G1 | Contract suite `squad-20-security-contracts.test.ts` passes on Linux, macOS **and Windows**. | +| G2 | Every client projection routes all file access through the shared path guard (P1–P4); no direct `fs` on projected paths. | +| G3 | Every MCP writer uses the shared merge function (M1–M7); golden tests per client file. | +| G4 | Broker rejects unsigned/unbound approvals; fuzz test on proposal tampering. | +| G5 | Fencing integration test with two concurrent worktrees and a simulated lease takeover. | +| G6 | Secret scan on generated projections and logs. | +| G7 | `clients[]` non-authority: capability negotiation test (exhaustive ∩) and portable-config rejection tests pass (§7). | +| G8 | Versioned approval + `runId` cancellation contract tests pass (§8). | +| G9 | Golden Cross-Adapter Conformance Trace passes for all five projections (§9). | + +## 6. Known gaps / follow-ups +- Reference guards live in the test file; production code must adopt them and the suite should then import the real modules. +- Windows attribute-bit detection relies on Node reporting junctions as symlinks; add a native check if that changes. +- `DurableSquad` wire protocol auth (token binding to epoch) is not yet specified. +- Cursor expiry/retention policy is unspecified; the reference accepts any previously issued cursor. + +## 7. Configured vs. negotiated capabilities + +Persisted `clients[]` in `.squad/config.json` is **projection config only**. It decides which client files are generated; it never grants execution authority. + +- **C1** `effectiveCapabilities = clientCapabilities ∩ backendCapabilities ∩ policyCapabilities`, computed at session negotiation, not at config load. +- **C2** Any capability a client adapter requests that is absent from `backendCapabilities`, explicitly denied, or not granted by `policyCapabilities` is **stripped** and reported with a reason (`not-in-backend`, `denied-by-policy`, `not-granted-by-policy`). Explicit deny beats grant. +- **C3** With no negotiated session, authorization fails closed. Widening `clients[]` on disk can never widen `effectiveCapabilities`. +- **C4** In projection artifacts, `clients[].capabilities` is emitted as `requestedCapabilities`. Artifacts are built from a field whitelist; unknown fields are dropped, not copied. +- **C5** Portable `clients[]` and every client projection artifact MUST NOT contain credentials (key or value), provider thread IDs, machine-specific absolute paths (POSIX, drive, UNC, `~`), lease tokens, or raw checkpoint state. Loading a config that violates this is refused. + +## 8. Versioned durable approvals & `runId` cancellation + +An approval is a versioned resource: `{ approvalId, version, scope, expiresAt, status, runId, payloadHash }`. + +- **A1** `version` increments on every state transition: `v1` requested → `v2` decided (or expired). +- **A2** `payloadHash = sha256(canon({runId, scope, payload}))`. A decision carrying a stale `version` or a mismatched `payloadHash` is rejected with no state change. +- **A3** A decided or expired approval is terminal; replaying the v1 decision is rejected as stale, so side effects run at most once. +- **A4** Expiry is durable and enforced at decision time (`now >= expiresAt`). +- **A5** Cancellation targets the stable `runId`, never a turn `seq`. It is idempotent, and a no-op once the run is terminal. Exactly one terminal event per run; the first terminal transition wins a cancel-vs-complete race. +- **A6** Turn acceptance is sequenced by `seq` (gap/duplicate-runId rejected; same `(runId, seq)` is an idempotent replay), but `seq` is not an identifier for control operations. + +## 9. Loss-tolerant projection & golden conformance trace + +- **L1** Unknown future event kinds (e.g. `x-future.event`, or a missing/non-string `kind`) are preserved verbatim in canonical history (malformed input becomes `x-unknown` with the raw payload). Projections pass them through and clients render `unsupported:`; none may crash. +- **L2** Disposable telemetry (`telemetry.*`) is separate from durable events: bounded, never replayed, never part of canonical history or event ids. +- **L3** Client cursor payload exposes only `{ cursor, lastAcceptedSeq, resumable }`. Lease tokens and checkpoint state stay server-side behind the opaque cursor; forged, tampered, or cross-session cursors are rejected. + +**Golden Cross-Adapter Conformance Trace** (must pass for `copilot`, `claude`, `kiro`, `opencode`, `cursor`): + +`TurnAccepted(runId, seq)` → stream partial output → `ApprovalRequested(v1)` → disconnect → `reconnect(cursor)` → `ApprovalDecided(v2)` → race `CancellationRequested(runId)` against completion → terminal state + +Assertions: identical canonical history across all five projections (wire formats may differ); no duplicate events or side effects on reconnect or stale-decision retry; exactly one terminal state; no lease/checkpoint material in any client-visible payload. diff --git a/packages/squad-sdk/test/squad-20-security-contracts.test.ts b/packages/squad-sdk/test/squad-20-security-contracts.test.ts new file mode 100644 index 000000000..3726f8f9b --- /dev/null +++ b/packages/squad-sdk/test/squad-20-security-contracts.test.ts @@ -0,0 +1,1029 @@ +/** + * Squad 2.0 security & contract validation suite. + * Spec: docs/proposals/squad-2.0-security-and-isolation.md + * + * The reference guards below are the executable contract that every client + * projection (Claude Code, Kiro, OpenCode, Cursor, Copilot) and the + * DurableSquad backend must satisfy. Production implementations must pass + * this same suite. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createHash, randomBytes } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; + +// ---------- reference guards ---------- + +const CLIENT_DIRS = ['.claude', '.kiro', '.opencode', '.cursor', '.github', '.agents', '.squad']; +const FILE_ATTRIBUTE_REPARSE_POINT = 0x400; + +function isReparsePoint(p: string): boolean { + const st = fs.lstatSync(p); + if (st.isSymbolicLink()) return true; + // Node reports junctions as symlinks; also honor the attribute bit when exposed. + const attrs = (st as unknown as { fileAttributes?: number }).fileAttributes; + return typeof attrs === 'number' && (attrs & FILE_ATTRIBUTE_REPARSE_POINT) !== 0; +} + +/** Resolve `rel` under `root`; throws on traversal, absolute/UNC/ADS/NUL input, or any reparse point in the chain. */ +function safeResolve(root: string, rel: string): string { + if (typeof rel !== 'string' || rel.length === 0 || rel.includes('\0')) throw new Error('invalid path'); + if (path.isAbsolute(rel) || /^[\\/]{2}/.test(rel) || /^[a-zA-Z]:/.test(rel)) throw new Error('absolute path rejected'); + if (rel.includes(':')) throw new Error('alternate data stream rejected'); + const segments = rel.split(/[\\/]+/); + if (segments.some((s) => s === '..')) throw new Error('traversal rejected'); + const base = fs.realpathSync(root); + let cur = base; + for (const seg of segments) { + if (seg === '' || seg === '.') continue; + cur = path.join(cur, seg); + let exists = true; + try { fs.lstatSync(cur); } catch { exists = false; } + if (exists && isReparsePoint(cur)) throw new Error(`reparse point rejected: ${seg}`); + } + const rp = path.relative(base, cur); + if (rp.startsWith('..') || path.isAbsolute(rp)) throw new Error('escapes root'); + return cur; +} + +type McpServer = { command?: string; args?: string[]; env?: Record; url?: string; [k: string]: unknown }; + +const MCP_TARGETS: Array<{ file: string; key: string }> = [ + { file: '.mcp.json', key: 'mcpServers' }, + { file: '.kiro/settings/mcp.json', key: 'mcpServers' }, + { file: 'opencode.json', key: 'mcp' }, + { file: '.cursor/mcp.json', key: 'mcpServers' }, +]; + +const SHELL_META = /[;&|`$<>\n\r]/; +const SHELL_BINARIES = /^(?:.*[\\/])?(?:sh|bash|zsh|cmd|powershell|pwsh)(?:\.exe)?$/i; +const SECRET_KEY = /(token|secret|key|password|pat)$/i; + +function validateServer(name: string, s: McpServer): void { + if (!/^[A-Za-z0-9._-]{1,64}$/.test(name) || name === '__proto__' || name === 'constructor') throw new Error('bad server name'); + if (s.url !== undefined) { + if (!/^https:\/\//.test(s.url)) throw new Error('non-https url'); + return; + } + if (typeof s.command !== 'string' || !s.command) throw new Error('command required'); + if (SHELL_META.test(s.command) || /\s/.test(s.command)) throw new Error('unsafe command'); + if (SHELL_BINARIES.test(s.command)) throw new Error('shell interpreter command rejected'); + if (s.args !== undefined && (!Array.isArray(s.args) || s.args.some((a) => typeof a !== 'string' || a.includes('\0')))) { + throw new Error('bad args'); + } +} + +/** Merge a Squad-owned server into a client config without touching anything else. */ +function mergeMcp(existingText: string | null, key: string, name: string, server: McpServer): string { + validateServer(name, server); + let doc: Record = {}; + if (existingText !== null && existingText.trim() !== '') { + let parsed: unknown; + try { parsed = JSON.parse(existingText); } catch { throw new Error('existing config is malformed; refusing to overwrite'); } + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('existing config is not an object'); + doc = parsed as Record; + } + const servers = Object.hasOwn(doc, key) ? (doc[key] as Record) : {}; + if (servers === null || typeof servers !== 'object' || Array.isArray(servers)) throw new Error(`"${key}" is not an object`); + const prev = Object.hasOwn(servers, name) ? servers[name] : undefined; + const merged: McpServer = { ...server, ...(prev?.env || server.env ? { env: { ...(server.env ?? {}), ...(prev?.env ?? {}) } } : {}) }; + const next: Record = {}; + for (const [k, v] of Object.entries(servers)) Object.defineProperty(next, k, { value: v, enumerable: true, writable: true, configurable: true }); + next[name] = merged; + return JSON.stringify({ ...doc, [key]: next }, null, 2) + '\n'; +} + +function redactEnv(env: Record): Record { + return Object.fromEntries(Object.entries(env).map(([k, v]) => [k, SECRET_KEY.test(k) ? '***' : v])); +} + +interface Proposal { runId: string; action: string; args: unknown; epoch: number } +const canon = (v: unknown): string => + Array.isArray(v) ? `[${v.map(canon).join(',')}]` + : v && typeof v === 'object' ? `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canon((v as Record)[k])}`).join(',')}}` + : JSON.stringify(v); +const hashProposal = (p: Proposal) => createHash('sha256').update(canon(p)).digest('hex'); + +class Broker { + epoch = 0; + private holder: string | null = null; + private seen = new Map(); + private approvals = new Set(); + state: string[] = []; + + acquire(holder: string): number { this.epoch += 1; this.holder = holder; return this.epoch; } + approve(p: Proposal): string { const h = hashProposal(p); this.approvals.add(h); return h; } + apply(p: Proposal, approvalHash: string, seq: number, holder: string): unknown { + if (holder !== this.holder || p.epoch !== this.epoch) throw new Error('stale epoch: fenced'); + const key = `${p.runId}:${seq}`; + if (this.seen.has(key) && approvalHash === hashProposal(p)) return this.seen.get(key); // idempotent replay + if (approvalHash !== hashProposal(p) || !this.approvals.has(approvalHash)) throw new Error('approval hash mismatch'); + this.approvals.delete(approvalHash); // single use + this.state.push(`${p.action}@${seq}`); + this.seen.set(key, 'ok'); + return 'ok'; + } +} + +// ---------- capability negotiation (clients[] is projection config, never authority) ---------- + +type Capability = string; +interface PolicyCapabilities { granted: readonly Capability[]; denied?: readonly Capability[] } +type StripReason = 'not-in-backend' | 'denied-by-policy' | 'not-granted-by-policy'; +interface Negotiated { effective: Capability[]; stripped: Array<{ capability: Capability; reason: StripReason }> } + +/** effectiveCapabilities = clientCapabilities ∩ backendCapabilities ∩ policyCapabilities (explicit deny always wins). */ +function negotiate(client: readonly Capability[], backend: readonly Capability[], policy: PolicyCapabilities): Negotiated { + if (client.some((c) => typeof c !== 'string' || c === '')) throw new Error('bad capability'); + const be = new Set(backend); + const granted = new Set(policy.granted); + const denied = new Set(policy.denied ?? []); + const out: Negotiated = { effective: [], stripped: [] }; + for (const capability of new Set(client)) { + if (denied.has(capability)) out.stripped.push({ capability, reason: 'denied-by-policy' }); + else if (!be.has(capability)) out.stripped.push({ capability, reason: 'not-in-backend' }); + else if (!granted.has(capability)) out.stripped.push({ capability, reason: 'not-granted-by-policy' }); + else out.effective.push(capability); + } + return out; +} + +/** Only a live negotiation result can authorize execution; persisted config never can. */ +const authorize = (n: Negotiated | undefined, cap: Capability): boolean => n !== undefined && n.effective.includes(cap); + +const NON_PORTABLE_KEY = /(credential|password|passwd|secret|token|apikey|authorization|cookie|privatekey|leaseid|checkpoint|threadid|providerthread)/; +const ABS_PATH_VALUE = /^(?:[a-zA-Z]:[\\/]|\\\\|\/(?!\/)|~[\\/]|file:)|(?:^|[\s"'=])(?:[a-zA-Z]:[\\/]|\/(?:home|Users|root|etc|var|tmp|mnt|opt)\/)/; +const SECRET_VALUE = /(?:\bghp_|\bgho_|github_pat_|\bsk-[A-Za-z0-9]|\bxox[baprs]-|\bAKIA[0-9A-Z]{16}|\beyJ[A-Za-z0-9_-]{10,}\.|\bBearer\s)/; +const OPAQUE_INTERNAL_VALUE = /^(?:lease[-_]|cur_|(?:thread|conv)_[A-Za-z0-9]{6,}$)/i; + +/** Portable config/projection artifacts: no credentials, provider thread ids, machine paths, lease tokens or checkpoint state. */ +function assertPortable(v: unknown, at = '$'): void { + if (typeof v === 'string') { + if (ABS_PATH_VALUE.test(v)) throw new Error(`machine-specific path at ${at}`); + if (SECRET_VALUE.test(v)) throw new Error(`credential-like value at ${at}`); + if (OPAQUE_INTERNAL_VALUE.test(v)) throw new Error(`lease/provider-thread value at ${at}`); + return; + } + if (Array.isArray(v)) { v.forEach((x, i) => assertPortable(x, `${at}[${i}]`)); return; } + if (v !== null && typeof v === 'object') { + for (const [k, x] of Object.entries(v)) { + if (NON_PORTABLE_KEY.test(k.toLowerCase().replace(/[^a-z]/g, ''))) throw new Error(`non-portable field ${at}.${k}`); + assertPortable(x, `${at}.${k}`); + } + } +} + +interface ClientEntry { id: string; adapter: string; capabilities?: Capability[]; displayName?: string; [k: string]: unknown } +const ADAPTER_DIRS: Record = { copilot: '.github', claude: '.claude', kiro: '.kiro', opencode: '.opencode', cursor: '.cursor' }; + +function loadConfiguredClients(rootDir: string): ClientEntry[] { + const doc = JSON.parse(fs.readFileSync(safeResolve(rootDir, '.squad/config.json'), 'utf8')) as { clients?: unknown }; + if (!Array.isArray(doc.clients)) throw new Error('clients[] missing'); + assertPortable(doc.clients, 'clients'); + return doc.clients as ClientEntry[]; +} + +/** Whitelist projection: unknown fields are dropped, never copied through. */ +function projectClientArtifact(entry: ClientEntry): Record { + const dir = Object.hasOwn(ADAPTER_DIRS, entry.adapter) ? ADAPTER_DIRS[entry.adapter] : undefined; + if (!dir) throw new Error('unknown adapter'); + const artifact = { id: entry.id, adapter: entry.adapter, dir, requestedCapabilities: [...(entry.capabilities ?? [])] }; + assertPortable(artifact); + return artifact; +} + +// ---------- versioned durable approvals & runId-addressed cancellation ---------- + +type ApprovalStatus = 'requested' | 'approved' | 'denied' | 'expired'; +interface Approval { approvalId: string; version: number; scope: string; expiresAt: number; status: ApprovalStatus; runId: string; payloadHash: string } +const hashPayload = (runId: string, scope: string, payload: unknown) => createHash('sha256').update(canon({ runId, scope, payload })).digest('hex'); + +class ApprovalStore { + private items = new Map(); + constructor(private now: () => number) {} + + request(a: { approvalId: string; scope: string; runId: string; payload: unknown; ttlMs: number }): Approval { + if (this.items.has(a.approvalId)) throw new Error('approval exists'); + const rec: Approval = { approvalId: a.approvalId, version: 1, scope: a.scope, expiresAt: this.now() + a.ttlMs, status: 'requested', runId: a.runId, payloadHash: hashPayload(a.runId, a.scope, a.payload) }; + this.items.set(a.approvalId, rec); + return { ...rec }; + } + + /** v1 (requested) -> v2 (decided). Stale version or mismatched payloadHash is rejected with no state change. */ + decide(approvalId: string, expectedVersion: number, payloadHash: string, decision: 'approved' | 'denied'): Approval { + const rec = this.items.get(approvalId); + if (!rec) throw new Error('unknown approval'); + if (expectedVersion !== rec.version) throw new Error('stale version'); + if (rec.status !== 'requested') throw new Error('already decided'); + if (payloadHash !== rec.payloadHash) throw new Error('payloadHash mismatch'); + if (this.now() >= rec.expiresAt) { rec.status = 'expired'; rec.version += 1; throw new Error('approval expired'); } + rec.status = decision; + rec.version += 1; + return { ...rec }; + } + + get(approvalId: string): Approval | undefined { const r = this.items.get(approvalId); return r && { ...r }; } +} + +// ---------- canonical events, loss-tolerant ingest, client projections ---------- + +interface CanonicalEvent { id: number; kind: string; runId: string | null; seq: number | null; data: unknown } +const KNOWN_KINDS = new Set(['TurnAccepted', 'OutputDelta', 'ApprovalRequested', 'ApprovalDecided', 'CancellationRequested', 'TurnCompleted', 'TurnCancelled']); +interface ClientCursor { cursor: string; lastAcceptedSeq: number; resumable: boolean } + +class DurableSession { + readonly log: CanonicalEvent[] = []; + readonly telemetry: Array<{ kind: string; data: unknown }> = []; + readonly effects: string[] = []; + readonly approvals: ApprovalStore; + private runs = new Map(); + private lastAcceptedSeq = 0; + private cursorsByToken = new Map(); + private cursorsByIndex = new Map(); + private readonly leaseToken = `lease-${randomBytes(8).toString('hex')}`; + private readonly checkpointRaw = `ckpt-${randomBytes(8).toString('hex')}`; + + constructor(now: () => number, private telemetryCap = 8) { this.approvals = new ApprovalStore(now); } + + private append(kind: string, runId: string | null, seq: number | null, data: unknown = null): CanonicalEvent { + const e: CanonicalEvent = { id: this.log.length + 1, kind, runId, seq, data }; + this.log.push(e); + return e; + } + + /** Unknown kinds are preserved verbatim; `telemetry.*` is disposable and never enters the durable log. */ + ingest(raw: unknown): void { + const o = raw !== null && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record) : undefined; + const kind = o && typeof o.kind === 'string' && o.kind !== '' ? o.kind : undefined; + if (kind?.startsWith('telemetry.')) { this.emitTelemetry(kind, o!.data ?? null); return; } + if (!kind) { this.append('x-unknown', null, null, { raw }); return; } + this.append(kind, typeof o!.runId === 'string' ? o!.runId : null, typeof o!.seq === 'number' ? o!.seq : null, o!.data ?? null); + } + + emitTelemetry(kind: string, data: unknown): void { + this.telemetry.push({ kind, data }); + while (this.telemetry.length > this.telemetryCap) this.telemetry.shift(); + } + + acceptTurn(runId: string, seq: number): void { + const existing = this.runs.get(runId); + if (existing && existing.seq === seq) return; // duplicate delivery is an idempotent replay + if (existing || seq !== this.lastAcceptedSeq + 1) throw new Error('sequence gap or duplicate runId'); + this.lastAcceptedSeq = seq; + this.runs.set(runId, { seq, state: 'running' }); + this.append('TurnAccepted', runId, seq); + } + + private running(runId: string) { + const r = this.runs.get(runId); + if (!r) throw new Error('unknown run'); + return r; + } + + output(runId: string, text: string): void { + if (this.running(runId).state !== 'running') throw new Error('run is terminal'); + this.append('OutputDelta', runId, null, { text }); + } + + requestApproval(a: { approvalId: string; scope: string; runId: string; payload: unknown; ttlMs: number }): Approval { + if (this.running(a.runId).state !== 'running') throw new Error('run is terminal'); + const rec = this.approvals.request(a); + this.append('ApprovalRequested', a.runId, null, rec); + return rec; + } + + decide(approvalId: string, expectedVersion: number, payloadHash: string, decision: 'approved' | 'denied'): Approval { + const rec = this.approvals.decide(approvalId, expectedVersion, payloadHash, decision); + this.append('ApprovalDecided', rec.runId, null, rec); + if (rec.status === 'approved') this.effects.push(`apply:${rec.approvalId}`); + return rec; + } + + /** Cancellation is addressed by stable runId and is independent of turn seq. First terminal transition wins. */ + cancel(runId: string): 'cancelled' | 'already-terminal' { + const r = this.running(runId); + if (r.state !== 'running') return 'already-terminal'; + this.append('CancellationRequested', runId, null); + r.state = 'cancelled'; + this.append('TurnCancelled', runId, null); + return 'cancelled'; + } + + complete(runId: string): 'completed' | 'already-terminal' { + const r = this.running(runId); + if (r.state !== 'running') return 'already-terminal'; + r.state = 'completed'; + this.append('TurnCompleted', runId, null); + return 'completed'; + } + + state(runId: string) { return this.running(runId).state; } + + private issueCursor(index: number): ClientCursor { + let token = this.cursorsByIndex.get(index); + if (!token) { + token = `cur_${randomBytes(9).toString('hex')}`; + this.cursorsByIndex.set(index, token); + this.cursorsByToken.set(token, { index, leaseToken: this.leaseToken, checkpoint: { raw: this.checkpointRaw } }); + } + const resumable = [...this.runs.values()].some((r) => r.state === 'running'); + return { cursor: token, lastAcceptedSeq: this.lastAcceptedSeq, resumable }; + } + + /** Replays strictly after the cursor; the client only ever sees { cursor, lastAcceptedSeq, resumable }. */ + pull(token?: string): { events: CanonicalEvent[]; cursor: ClientCursor } { + let index = 0; + if (token !== undefined) { + const c = this.cursorsByToken.get(token); + if (!c) throw new Error('unknown cursor'); + index = c.index; + } + return { events: structuredClone(this.log.slice(index)), cursor: this.issueCursor(this.log.length) }; + } + + /** Test-only view of server-private values that must never reach a client. */ + internalSecrets(): string[] { return [this.leaseToken, this.checkpointRaw]; } +} + +interface Projection { name: string; encode(e: CanonicalEvent): unknown; decode(w: any): CanonicalEvent } +const PROJECTIONS: Projection[] = [ + { name: 'copilot', encode: (e) => ({ type: e.kind, id: e.id, runId: e.runId, seq: e.seq, data: e.data }), + decode: (w) => ({ id: w.id, kind: w.type, runId: w.runId, seq: w.seq, data: w.data }) }, + { name: 'claude', encode: (e) => ({ hook_event_name: e.kind, meta: { id: e.id, runId: e.runId, seq: e.seq }, payload: e.data }), + decode: (w) => ({ id: w.meta.id, kind: w.hook_event_name, runId: w.meta.runId, seq: w.meta.seq, data: w.payload }) }, + { name: 'kiro', encode: (e) => ({ event: e.kind, ref: [e.id, e.runId, e.seq], body: JSON.stringify(e.data) }), + decode: (w) => ({ id: w.ref[0], kind: w.event, runId: w.ref[1], seq: w.ref[2], data: JSON.parse(w.body) }) }, + { name: 'opencode', encode: (e) => ({ type: e.kind, properties: { id: e.id, runId: e.runId, seq: e.seq, data: e.data } }), + decode: (w) => ({ id: w.properties.id, kind: w.type, runId: w.properties.runId, seq: w.properties.seq, data: w.properties.data }) }, + { name: 'cursor', encode: (e) => ({ k: e.kind, i: e.id, r: e.runId, s: e.seq, d: e.data }), + decode: (w) => ({ id: w.i, kind: w.k, runId: w.r, seq: w.s, data: w.d }) }, +]; + +/** UI label that degrades gracefully on kinds it does not understand. */ +function renderEvent(e: CanonicalEvent): string { + return KNOWN_KINDS.has(e.kind) ? e.kind : `unsupported:${e.kind}`; +} + +class SimClient { + history: CanonicalEvent[] = []; + views: ClientCursor[] = []; + wire: string[] = []; + duplicatesDropped = 0; + private cursor?: string; + private connected = true; + constructor(private proj: Projection, private s: DurableSession) {} + + sync(): void { + if (!this.connected) throw new Error('disconnected'); + const { events, cursor } = this.s.pull(this.cursor); + for (const e of events) { + const frame = JSON.stringify(this.proj.encode(e)); + this.wire.push(frame); + const d = this.proj.decode(JSON.parse(frame)); + if (this.history.some((h) => h.id === d.id)) this.duplicatesDropped += 1; + else this.history.push(d); + } + this.cursor = cursor.cursor; + this.views.push(cursor); + } + disconnect(): void { this.connected = false; } + reconnect(): void { this.connected = true; this.sync(); } +} + +// ---------- tests ---------- + +let root: string; +let outside: string; +beforeEach(() => { + root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'sq20-root-'))); + outside = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'sq20-out-'))); + for (const d of CLIENT_DIRS) fs.mkdirSync(path.join(root, d, 'skills'), { recursive: true }); +}); +afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(outside, { recursive: true, force: true }); +}); + +function tryLink(target: string, p: string): boolean { + try { fs.symlinkSync(target, p, 'junction'); return true; } catch { return false; } +} + +describe('path / symlink / reparse-point guards across client directories', () => { + it.each(CLIENT_DIRS)('accepts a plain skill file under %s', (d) => { + fs.mkdirSync(path.join(root, d, 'skills', 'ok'), { recursive: true }); + fs.writeFileSync(path.join(root, d, 'skills', 'ok', 'SKILL.md'), '# ok'); + expect(safeResolve(root, `${d}/skills/ok/SKILL.md`)).toContain('SKILL.md'); + }); + + it.each(CLIENT_DIRS)('rejects ../ traversal out of %s', (d) => { + expect(() => safeResolve(root, `${d}/skills/../../../etc/passwd`)).toThrow(/traversal/); + expect(() => safeResolve(root, `${d}\\skills\\..\\..\\x`)).toThrow(/traversal/); + }); + + it.each(CLIENT_DIRS)('rejects symlink/junction skill dir escaping root in %s', (d) => { + const link = path.join(root, d, 'skills', 'evil'); + if (!tryLink(outside, link)) return; + fs.writeFileSync(path.join(outside, 'SKILL.md'), 'stolen'); + expect(() => safeResolve(root, `${d}/skills/evil/SKILL.md`)).toThrow(/reparse/); + }); + + it('rejects a client directory itself replaced by a link', () => { + fs.rmSync(path.join(root, '.cursor'), { recursive: true, force: true }); + if (!tryLink(outside, path.join(root, '.cursor'))) return; + expect(() => safeResolve(root, '.cursor/mcp.json')).toThrow(/reparse/); + }); + + it('rejects dangling symlinks', () => { + const link = path.join(root, '.kiro', 'skills', 'dangling'); + if (!tryLink(path.join(outside, 'nope'), link)) return; + expect(() => safeResolve(root, '.kiro/skills/dangling')).toThrow(/reparse/); + }); + + it.each([ + ['absolute posix', '/etc/passwd'], + ['absolute drive', 'C:\\Windows\\win.ini'], + ['UNC', '\\\\server\\share\\x'], + ['NUL byte', '.claude/skills/a\0b'], + ['ADS', '.claude/skills/a.md:stream'], + ['empty', ''], + ])('rejects %s input', (_n, p) => { + expect(() => safeResolve(root, p)).toThrow(); + }); + + it('detects real NTFS junctions via mklink /J (Windows only)', () => { + if (process.platform !== 'win32') return; + const target = path.join(outside, 'jt'); + fs.mkdirSync(target); + const j = path.join(root, '.agents', 'skills', 'junc'); + execFileSync('cmd.exe', ['/c', 'mklink', '/J', j, target], { stdio: 'ignore' }); + expect(isReparsePoint(j)).toBe(true); + expect(() => safeResolve(root, '.agents/skills/junc/x')).toThrow(/reparse/); + }); +}); + +describe('MCP config merge safety', () => { + const server: McpServer = { command: 'npx', args: ['-y', '@bradygaster/squad-mcp'], env: { SQUAD_MODE: 'client' } }; + + it.each(MCP_TARGETS)('preserves unrelated servers and top-level keys in $file', ({ key }) => { + const existing = JSON.stringify({ theme: 'dark', [key]: { github: { command: 'gh-mcp', env: { GH_TOKEN: 'keepme' } } } }); + const out = JSON.parse(mergeMcp(existing, key, 'squad', server)); + expect(out.theme).toBe('dark'); + expect(out[key].github).toEqual({ command: 'gh-mcp', env: { GH_TOKEN: 'keepme' } }); + expect(out[key].squad.command).toBe('npx'); + }); + + it('preserves user env vars on an existing squad entry; user values win', () => { + const existing = JSON.stringify({ mcpServers: { squad: { command: 'old', env: { SQUAD_MODE: 'custom', MY_VAR: '1' } } } }); + const out = JSON.parse(mergeMcp(existing, 'mcpServers', 'squad', server)); + expect(out.mcpServers.squad.env).toEqual({ SQUAD_MODE: 'custom', MY_VAR: '1' }); + }); + + it('is idempotent', () => { + const once = mergeMcp(null, 'mcpServers', 'squad', server); + expect(mergeMcp(once, 'mcpServers', 'squad', server)).toBe(once); + }); + + it('creates a config when none exists, including an empty file', () => { + expect(JSON.parse(mergeMcp(null, 'mcp', 'squad', server)).mcp.squad).toBeDefined(); + expect(JSON.parse(mergeMcp(' ', 'mcp', 'squad', server)).mcp.squad).toBeDefined(); + }); + + it.each(['{ not json', '[]', 'null', '"str"'])('refuses to clobber malformed config %j', (bad) => { + expect(() => mergeMcp(bad, 'mcpServers', 'squad', server)).toThrow(); + }); + + it('refuses when the server container has the wrong type', () => { + expect(() => mergeMcp('{"mcpServers":[]}', 'mcpServers', 'squad', server)).toThrow(/not an object/); + }); + + it.each([ + ['semicolon', 'node;rm'], + ['pipe', 'node|curl'], + ['subshell', '$(curl)'], + ['backtick', '`id`'], + ['newline', 'node\nrm'], + ['space in command', 'node server.js'], + ['bash', '/bin/bash'], + ['cmd', 'C:\\Windows\\System32\\cmd.exe'], + ['pwsh', 'pwsh'], + ])('rejects command injection via %s', (_n, command) => { + expect(() => mergeMcp(null, 'mcpServers', 'squad', { command })).toThrow(); + }); + + it('rejects non-string args, plain http urls, and prototype-pollution names', () => { + expect(() => mergeMcp(null, 'mcpServers', 'squad', { command: 'npx', args: [1 as unknown as string] })).toThrow(); + expect(() => mergeMcp(null, 'mcpServers', 'squad', { url: 'http://evil' })).toThrow(); + expect(() => mergeMcp(null, 'mcpServers', '__proto__', server)).toThrow(); + }); + + it('a __proto__ key in existing JSON does not pollute Object.prototype', () => { + mergeMcp('{"mcpServers":{"__proto__":{"polluted":true}}}', 'mcpServers', 'squad', server); + expect(({} as Record).polluted).toBeUndefined(); + }); + + it('redacts secret env values in diagnostics', () => { + const r = redactEnv({ GH_TOKEN: 'ghp_x', API_KEY: 'k', MODE: 'a', DB_PASSWORD: 'p' }); + expect(JSON.stringify(r)).not.toMatch(/ghp_x|"k"|"p"/); + expect(r.MODE).toBe('a'); + }); + + it('leaves the on-disk config untouched when validation fails', () => { + const f = path.join(root, '.mcp.json'); + fs.writeFileSync(f, '{"mcpServers":{"a":{"command":"x"}}}'); + const before = fs.readFileSync(f, 'utf8'); + expect(() => mergeMcp(before, 'mcpServers', 'squad', { command: 'a;b' })).toThrow(); + expect(fs.readFileSync(f, 'utf8')).toBe(before); + }); +}); + +describe('hash-bound approvals and epoch fencing', () => { + const base = (epoch: number): Proposal => ({ runId: 'r1', action: 'write-decision', args: { a: 1, b: [2, 3] }, epoch }); + + it('hash is stable across key order and sensitive to any change', () => { + const a = hashProposal({ runId: 'r', action: 'x', args: { a: 1, b: 2 }, epoch: 1 }); + expect(hashProposal({ runId: 'r', action: 'x', args: { b: 2, a: 1 }, epoch: 1 })).toBe(a); + expect(hashProposal({ runId: 'r', action: 'x', args: { a: 1, b: 3 }, epoch: 1 })).not.toBe(a); + expect(hashProposal({ runId: 'r', action: 'x', args: { a: 1, b: 2 }, epoch: 2 })).not.toBe(a); + }); + + it('executes an approved proposal exactly once per (runId, seq)', () => { + const b = new Broker(); const p = base(b.acquire('w1')); + const h = b.approve(p); + expect(b.apply(p, h, 1, 'w1')).toBe('ok'); + expect(b.apply(p, h, 1, 'w1')).toBe('ok'); + expect(b.state).toHaveLength(1); + }); + + it('rejects a tampered proposal under a valid approval hash', () => { + const b = new Broker(); const p = base(b.acquire('w1')); + const h = b.approve(p); + expect(() => b.apply({ ...p, args: { a: 999 } }, h, 1, 'w1')).toThrow(/mismatch/); + expect(b.state).toHaveLength(0); + }); + + it('rejects unapproved proposals and approval reuse for a new seq', () => { + const b = new Broker(); const p = base(b.acquire('w1')); + expect(() => b.apply(p, hashProposal(p), 1, 'w1')).toThrow(/mismatch/); + const h = b.approve(p); + b.apply(p, h, 1, 'w1'); + expect(() => b.apply(p, h, 2, 'w1')).toThrow(/mismatch/); + expect(b.state).toHaveLength(1); + }); + + it('fences a stale holder after lease takeover (epoch bump)', () => { + const b = new Broker(); + const p = base(b.acquire('worktree-A')); const h = b.approve(p); + b.acquire('worktree-B'); + expect(() => b.apply(p, h, 1, 'worktree-A')).toThrow(/fenced/); + expect(b.state).toHaveLength(0); + }); + + it('rejects a non-holder presenting the current epoch', () => { + const b = new Broker(); const p = base(b.acquire('A')); const h = b.approve(p); + expect(() => b.apply(p, h, 1, 'B')).toThrow(/fenced/); + }); + + it('epochs are strictly monotonic', () => { + const b = new Broker(); + expect([b.acquire('a'), b.acquire('b'), b.acquire('a')]).toEqual([1, 2, 3]); + }); + + it('isolates state between worktrees: separate brokers never share writes', () => { + const a = new Broker(); const c = new Broker(); + const pa = { ...base(a.acquire('A')), runId: 'rA' }; + a.apply(pa, a.approve(pa), 1, 'A'); + expect(c.state).toHaveLength(0); + expect(() => c.apply(pa, hashProposal(pa), 1, 'A')).toThrow(); + }); +}); + +describe('configured vs. negotiated capabilities (clients[] is not authority)', () => { + const ALL = ['read', 'write', 'shell', 'net']; + const subsets = (): string[][] => Array.from({ length: 1 << ALL.length }, (_, m) => ALL.filter((_c, i) => m & (1 << i))); + + it('effective = client ∩ backend ∩ policy for every combination (exhaustive)', () => { + for (const c of subsets()) for (const b of subsets()) for (const p of subsets()) { + const { effective } = negotiate(c, b, { granted: p }); + expect(effective).toEqual(c.filter((x) => b.includes(x) && p.includes(x))); + } + }); + + it('strips capabilities absent from the backend and reports why', () => { + const n = negotiate(['read', 'shell'], ['read'], { granted: ['read', 'shell'] }); + expect(n.effective).toEqual(['read']); + expect(n.stripped).toEqual([{ capability: 'shell', reason: 'not-in-backend' }]); + }); + + it('strips capabilities denied by policy; explicit deny beats grant', () => { + const n = negotiate(['read', 'write', 'net'], ALL, { granted: ['read', 'write', 'net'], denied: ['write'] }); + expect(n.effective).toEqual(['read', 'net']); + expect(n.stripped).toEqual([{ capability: 'write', reason: 'denied-by-policy' }]); + }); + + it('strips capabilities the policy never granted', () => { + const n = negotiate(['read', 'net'], ALL, { granted: ['read'] }); + expect(n.stripped).toEqual([{ capability: 'net', reason: 'not-granted-by-policy' }]); + }); + + it('rejects malformed capability requests and de-duplicates repeats', () => { + expect(() => negotiate([1 as unknown as string], ALL, { granted: ALL })).toThrow(/bad capability/); + expect(negotiate(['read', 'read'], ALL, { granted: ALL }).effective).toEqual(['read']); + }); + + it('persisted clients[] grants nothing: no negotiated session means no authority', () => { + fs.writeFileSync(path.join(root, '.squad', 'config.json'), JSON.stringify({ + clients: [{ id: 'c1', adapter: 'claude', capabilities: ['read', 'write', 'shell', 'admin'] }], + })); + const [entry] = loadConfiguredClients(root); + expect(entry.capabilities).toContain('shell'); + for (const cap of entry.capabilities!) expect(authorize(undefined, cap)).toBe(false); + }); + + it('widening clients[] on disk cannot widen effective capabilities', () => { + const backend = ['read', 'write']; + const policy = { granted: ['read', 'write', 'shell'], denied: ['write'] }; + const narrow = negotiate(['read'], backend, policy); + const widened = negotiate(['read', 'write', 'shell', 'admin'], backend, policy); + expect(widened.effective).toEqual(narrow.effective); + expect(authorize(widened, 'shell')).toBe(false); + expect(authorize(widened, 'write')).toBe(false); + expect(authorize(widened, 'admin')).toBe(false); + expect(authorize(widened, 'read')).toBe(true); + }); + + it.each(Object.keys(ADAPTER_DIRS))('projection artifact for %s is a whitelist and marks capabilities as requested only', (adapter) => { + const a = projectClientArtifact({ id: 'c', adapter, capabilities: ['read'], leaseToken: 'lease-abc', extra: 'dropped' }); + expect(Object.keys(a).sort()).toEqual(['adapter', 'dir', 'id', 'requestedCapabilities']); + expect(a.dir).toBe(ADAPTER_DIRS[adapter]); + expect(JSON.stringify(a)).not.toMatch(/lease|dropped/); + }); + + it('rejects an unknown adapter and a prototype-named adapter', () => { + expect(() => projectClientArtifact({ id: 'c', adapter: 'vim' })).toThrow(/unknown adapter/); + expect(() => projectClientArtifact({ id: 'c', adapter: 'constructor' })).toThrow(/unknown adapter/); + }); + + it.each([ + ['credential key', { apiKey: 'x' }], + ['password key', { password: 'x' }], + ['token key', { auth_token: 'x' }], + ['provider thread id key', { providerThreadId: 'abc' }], + ['thread id key', { thread_id: 'abc' }], + ['lease token key', { leaseToken: 'x' }], + ['raw checkpoint state', { checkpoint: { raw: 'x' } }], + ['nested checkpoint state', { meta: { rawCheckpointState: {} } }], + ['github pat value', { note: 'ghp_abcdefghijklmnopqrstuvwxyz0123456789' }], + ['bearer value', { note: 'Bearer abc.def' }], + ['provider thread value', { ref: 'thread_abc123XYZ' }], + ['lease value', { ref: 'lease-9f2c' }], + ['posix home path', { cwd: '/home/tamir/repo' }], + ['windows path', { cwd: 'C:\\Users\\tamir\\repo' }], + ['unc path', { cwd: '\\\\host\\share' }], + ['tilde path', { cwd: '~/repo' }], + ['embedded path', { cmd: 'run D:\\work\\x.ps1' }], + ])('portable clients[] rejects %s', (_n, extra) => { + expect(() => assertPortable([{ id: 'c', adapter: 'kiro', ...extra }])).toThrow(/non-portable|credential|machine-specific|lease/); + }); + + it('refuses to load a config whose clients[] is non-portable', () => { + fs.writeFileSync(path.join(root, '.squad', 'config.json'), JSON.stringify({ clients: [{ id: 'c', adapter: 'cursor', workdir: 'C:\\Users\\x' }] })); + expect(() => loadConfiguredClients(root)).toThrow(/machine-specific/); + }); + + it('accepts a clean, relative, portable clients[]', () => { + expect(() => assertPortable([{ id: 'c', adapter: 'opencode', capabilities: ['read'], dir: '.opencode/skills', url: 'https://example.com/mcp' }])).not.toThrow(); + }); +}); + +describe('versioned durable approvals', () => { + let t = 1_000; + const clock = () => t; + const req = { approvalId: 'ap-1', scope: 'tool:write', runId: 'run-1', payload: { file: 'a.md', n: 1 }, ttlMs: 60_000 }; + const hashOf = (payload: unknown, scope = 'tool:write', runId = 'run-1') => hashPayload(runId, scope, payload); + beforeEach(() => { t = 1_000; }); + + it('carries the full resource shape and starts at v1 requested', () => { + const a = new ApprovalStore(clock).request(req); + expect(Object.keys(a).sort()).toEqual(['approvalId', 'expiresAt', 'payloadHash', 'runId', 'scope', 'status', 'version']); + expect(a).toMatchObject({ version: 1, status: 'requested', runId: 'run-1', scope: 'tool:write', expiresAt: 61_000 }); + expect(a.payloadHash).toBe(hashOf(req.payload)); + }); + + it('increments version on state transition: v1 requested -> v2 decided', () => { + const s = new ApprovalStore(clock); + const a = s.request(req); + const d = s.decide('ap-1', a.version, a.payloadHash, 'approved'); + expect(d).toMatchObject({ version: 2, status: 'approved' }); + expect(s.get('ap-1')!.version).toBe(2); + }); + + it('rejects a stale version without mutating state', () => { + const s = new ApprovalStore(clock); + const a = s.request(req); + expect(() => s.decide('ap-1', 7, a.payloadHash, 'approved')).toThrow(/stale version/); + expect(s.get('ap-1')).toMatchObject({ version: 1, status: 'requested' }); + }); + + it('rejects replay of the v1 decision after v2 and a second decision at v2', () => { + const s = new ApprovalStore(clock); + const a = s.request(req); + s.decide('ap-1', 1, a.payloadHash, 'approved'); + expect(() => s.decide('ap-1', 1, a.payloadHash, 'approved')).toThrow(/stale version/); + expect(() => s.decide('ap-1', 2, a.payloadHash, 'denied')).toThrow(/already decided/); + expect(s.get('ap-1')!.status).toBe('approved'); + }); + + it('rejects a mismatched payloadHash (tampered payload, scope or run)', () => { + const s = new ApprovalStore(clock); + s.request(req); + for (const bad of [hashOf({ ...req.payload, n: 2 }), hashOf(req.payload, 'tool:shell'), hashOf(req.payload, 'tool:write', 'run-2'), 'deadbeef']) { + expect(() => s.decide('ap-1', 1, bad, 'approved')).toThrow(/payloadHash mismatch/); + } + expect(s.get('ap-1')).toMatchObject({ version: 1, status: 'requested' }); + }); + + it('payloadHash is key-order independent', () => { + expect(hashOf({ a: 1, b: 2 })).toBe(hashOf({ b: 2, a: 1 })); + }); + + it('expires durably: decision at/after expiresAt is refused and cannot be revived', () => { + const s = new ApprovalStore(clock); + const a = s.request(req); + t = a.expiresAt; + expect(() => s.decide('ap-1', 1, a.payloadHash, 'approved')).toThrow(/expired/); + expect(s.get('ap-1')).toMatchObject({ status: 'expired', version: 2 }); + expect(() => s.decide('ap-1', 2, a.payloadHash, 'approved')).toThrow(/already decided/); + }); + + it('unknown and duplicate approval ids are rejected', () => { + const s = new ApprovalStore(clock); + expect(() => s.decide('nope', 1, 'x', 'approved')).toThrow(/unknown approval/); + s.request(req); + expect(() => s.request(req)).toThrow(/exists/); + }); + + it('returned records are copies: callers cannot mutate durable state', () => { + const s = new ApprovalStore(clock); + const a = s.request(req); + a.status = 'approved'; a.version = 99; + expect(s.get('ap-1')).toMatchObject({ status: 'requested', version: 1 }); + }); +}); + +describe('runId-addressed cancellation', () => { + const mk = () => new DurableSession(() => 1_000); + + it('cancels by stable runId after later turns advanced seq', () => { + const s = mk(); + s.acceptTurn('run-1', 1); + s.acceptTurn('run-2', 2); + s.acceptTurn('run-3', 3); + expect(s.cancel('run-1')).toBe('cancelled'); + expect(s.state('run-1')).toBe('cancelled'); + expect(s.state('run-2')).toBe('running'); + expect(s.state('run-3')).toBe('running'); + }); + + it('seq is not an identifier: cancelling by a seq value fails', () => { + const s = mk(); + s.acceptTurn('run-1', 1); + expect(() => s.cancel('1')).toThrow(/unknown run/); + expect(s.state('run-1')).toBe('running'); + }); + + it('cancellation is idempotent and a no-op once terminal', () => { + const s = mk(); + s.acceptTurn('run-1', 1); + s.cancel('run-1'); + const n = s.log.length; + expect(s.cancel('run-1')).toBe('already-terminal'); + expect(s.complete('run-1')).toBe('already-terminal'); + expect(s.log).toHaveLength(n); + }); + + it('enforces turn sequencing: gaps and runId reuse are rejected, same (runId, seq) is an idempotent replay', () => { + const s = mk(); + s.acceptTurn('run-1', 1); + s.acceptTurn('run-1', 1); + expect(s.log.filter((e) => e.kind === 'TurnAccepted')).toHaveLength(1); + expect(() => s.acceptTurn('run-2', 5)).toThrow(/sequence gap/); + expect(() => s.acceptTurn('run-1', 2)).toThrow(/sequence gap/); + }); + + it('output and approval requests on a terminal run are refused', () => { + const s = mk(); + s.acceptTurn('run-1', 1); + s.complete('run-1'); + expect(() => s.output('run-1', 'late')).toThrow(/terminal/); + expect(() => s.requestApproval({ approvalId: 'a', scope: 's', runId: 'run-1', payload: {}, ttlMs: 1 })).toThrow(/terminal/); + }); +}); + +describe('loss-tolerant projection', () => { + const mk = () => new DurableSession(() => 1_000); + const future = { kind: 'x-future.event', runId: 'run-1', seq: 9, data: { nested: { a: [1, { b: null }] }, flag: true } }; + + it.each(PROJECTIONS)('$name projection preserves unknown future kinds without crashing', (proj) => { + const s = mk(); + s.acceptTurn('run-1', 1); + s.ingest(future); + s.ingest({ kind: 'x-other.v2', data: 'str' }); + const c = new SimClient(proj, s); + expect(() => c.sync()).not.toThrow(); + const got = c.history.find((e) => e.kind === 'x-future.event')!; + expect(got).toMatchObject({ kind: 'x-future.event', runId: 'run-1', seq: 9, data: future.data }); + expect(c.history.map((e) => renderEvent(e))).toEqual(['TurnAccepted', 'unsupported:x-future.event', 'unsupported:x-other.v2']); + }); + + it.each([ + ['missing kind', { data: 1 }], + ['numeric kind', { kind: 42, data: 1 }], + ['object kind', { kind: { a: 1 } }], + ['empty kind', { kind: '' }], + ['null', null], + ['string', 'garbage'], + ['array', [1, 2]], + ])('preserves malformed event (%s) as x-unknown with the raw payload', (_n, raw) => { + const s = mk(); + s.ingest(raw); + expect(s.log).toHaveLength(1); + expect(s.log[0].kind).toBe('x-unknown'); + expect(s.log[0].data).toEqual({ raw }); + for (const proj of PROJECTIONS) { + const c = new SimClient(proj, s); + c.sync(); + expect(canon(c.history)).toBe(canon(s.log)); + } + }); + + it('separates disposable telemetry from durable events', () => { + const s = mk(); + s.acceptTurn('run-1', 1); + s.ingest({ kind: 'telemetry.latency', data: { ms: 12 } }); + s.emitTelemetry('telemetry.tokens', { n: 3 }); + s.output('run-1', 'hi'); + expect(s.log.map((e) => e.kind)).toEqual(['TurnAccepted', 'OutputDelta']); + expect(s.telemetry.map((t) => t.kind)).toEqual(['telemetry.latency', 'telemetry.tokens']); + const c = new SimClient(PROJECTIONS[0], s); + c.sync(); + expect(c.wire.join('')).not.toMatch(/telemetry/); + }); + + it('telemetry is bounded and never changes durable history or its ids', () => { + const a = mk(); const b = mk(); + for (const s of [a, b]) { s.acceptTurn('run-1', 1); } + for (let i = 0; i < 100; i++) b.emitTelemetry('telemetry.tick', { i }); + for (const s of [a, b]) { s.output('run-1', 'x'); s.complete('run-1'); } + expect(b.telemetry.length).toBeLessThanOrEqual(8); + expect(canon(b.log)).toBe(canon(a.log)); + }); + + it('unknown kinds survive a disconnect/reconnect cycle', () => { + const s = mk(); + const c = new SimClient(PROJECTIONS[2], s); + c.sync(); + c.disconnect(); + s.ingest(future); + expect(() => c.sync()).toThrow(/disconnected/); + c.reconnect(); + expect(c.history.map((e) => e.kind)).toEqual(['x-future.event']); + }); +}); + +describe('golden cross-adapter conformance trace', () => { + /** + * TurnAccepted(runId, seq) → stream partial output → ApprovalRequested(v1) → disconnect → + * reconnect(cursor) → ApprovalDecided(v2) → race CancellationRequested(runId) vs completion → terminal. + */ + function golden(proj: Projection, winner: 'cancel' | 'complete') { + const s = new DurableSession(() => 1_000); + const c = new SimClient(proj, s); + const payload = { action: 'write-decision', file: 'decisions.md' }; + + s.acceptTurn('run-1', 1); + s.output('run-1', 'par'); + s.output('run-1', 'tial'); + const a1 = s.requestApproval({ approvalId: 'ap-1', scope: 'tool:write', runId: 'run-1', payload, ttlMs: 60_000 }); + c.sync(); + const beforeDrop = c.views[c.views.length - 1]; + + c.disconnect(); + c.reconnect(); // resumes from the stored cursor + c.reconnect(); // a second reconnect from the same cursor must be a no-op + s.decide('ap-1', a1.version, a1.payloadHash, 'approved'); + c.sync(); + + if (winner === 'cancel') { s.cancel('run-1'); s.complete('run-1'); } else { s.complete('run-1'); s.cancel('run-1'); } + c.sync(); + return { s, c, a1, beforeDrop }; + } + + const EXPECT = { + cancel: ['TurnAccepted', 'OutputDelta', 'OutputDelta', 'ApprovalRequested', 'ApprovalDecided', 'CancellationRequested', 'TurnCancelled'], + complete: ['TurnAccepted', 'OutputDelta', 'OutputDelta', 'ApprovalRequested', 'ApprovalDecided', 'TurnCompleted'], + } as const; + + it.each(['cancel', 'complete'] as const)('canonical history follows the trace when %s wins the race', (winner) => { + const { s } = golden(PROJECTIONS[0], winner); + expect(s.log.map((e) => e.kind)).toEqual(EXPECT[winner]); + expect(s.log.map((e) => e.id)).toEqual(s.log.map((_e, i) => i + 1)); + expect(s.log[0]).toMatchObject({ kind: 'TurnAccepted', runId: 'run-1', seq: 1 }); + expect(s.log[3].data).toMatchObject({ version: 1, status: 'requested', runId: 'run-1' }); + expect(s.log[4].data).toMatchObject({ version: 2, status: 'approved', runId: 'run-1' }); + }); + + it.each(['cancel', 'complete'] as const)('exactly one terminal state, no matter who wins (%s)', (winner) => { + const { s } = golden(PROJECTIONS[0], winner); + const terminals = s.log.filter((e) => e.kind === 'TurnCompleted' || e.kind === 'TurnCancelled'); + expect(terminals).toHaveLength(1); + expect(s.state('run-1')).toBe(winner === 'cancel' ? 'cancelled' : 'completed'); + }); + + it.each(['cancel', 'complete'] as const)('all 5 client projections observe identical canonical history (%s)', (winner) => { + const runs = PROJECTIONS.map((p) => ({ name: p.name, ...golden(p, winner) })); + expect(runs.map((r) => r.name)).toEqual(['copilot', 'claude', 'kiro', 'opencode', 'cursor']); + const reference = canon(runs[0].s.log); + for (const r of runs) { + expect(canon(r.c.history), r.name).toBe(reference); + expect(canon(r.c.history), r.name).toBe(canon(r.s.log)); + } + }); + + it('wire formats differ per adapter even though canonical history is identical', () => { + const wires = new Set(PROJECTIONS.map((p) => golden(p, 'cancel').c.wire[0])); + expect(wires.size).toBe(PROJECTIONS.length); + }); + + it.each(PROJECTIONS)('$name: reconnect causes no duplicate side effects or duplicate events', (proj) => { + const { s, c, a1 } = golden(proj, 'cancel'); + expect(s.effects).toEqual(['apply:ap-1']); + expect(c.duplicatesDropped).toBe(0); + expect(new Set(c.history.map((e) => e.id)).size).toBe(c.history.length); + expect(s.log.filter((e) => e.kind === 'ApprovalDecided')).toHaveLength(1); + + // Client retries the stale v1 decision after reconnecting: rejected, still exactly one effect. + expect(() => s.decide('ap-1', a1.version, a1.payloadHash, 'approved')).toThrow(/stale version/); + // Extra reconnects and fresh pulls from an old cursor never re-run effects. + for (let i = 0; i < 3; i++) { c.disconnect(); c.reconnect(); } + s.pull(); s.pull(); + expect(s.effects).toEqual(['apply:ap-1']); + expect(s.log.filter((e) => e.kind === 'ApprovalDecided')).toHaveLength(1); + }); + + it('replaying from an older cursor returns only the missed suffix', () => { + const s = new DurableSession(() => 1_000); + s.acceptTurn('run-1', 1); + const first = s.pull(); + s.output('run-1', 'a'); + s.output('run-1', 'b'); + const replay = s.pull(first.cursor.cursor); + expect(replay.events.map((e) => e.id)).toEqual([2, 3]); + expect(s.pull(first.cursor.cursor).events.map((e) => e.id)).toEqual([2, 3]); // idempotent + expect(s.pull(replay.cursor.cursor).events).toEqual([]); + }); + + it.each(PROJECTIONS)('$name: client cursor payload only exposes cursor, lastAcceptedSeq, resumable', (proj) => { + const { s, c, beforeDrop } = golden(proj, 'cancel'); + for (const v of c.views) expect(Object.keys(v).sort()).toEqual(['cursor', 'lastAcceptedSeq', 'resumable']); + expect(beforeDrop).toMatchObject({ lastAcceptedSeq: 1, resumable: true }); + expect(c.views[c.views.length - 1]).toMatchObject({ lastAcceptedSeq: 1, resumable: false }); + expect(beforeDrop.cursor).toMatch(/^cur_[0-9a-f]+$/); + + // Lease token and raw checkpoint state never appear in anything the client received. + const everything = JSON.stringify([c.views, c.wire, c.history]); + for (const secret of s.internalSecrets()) expect(everything).not.toContain(secret); + expect(everything).not.toMatch(/lease-|ckpt-/); + }); + + it('cursors are opaque: forged, tampered and cross-session cursors are rejected', () => { + const a = new DurableSession(() => 1_000); + const b = new DurableSession(() => 1_000); + a.acceptTurn('run-1', 1); + const cur = a.pull().cursor.cursor; + expect(() => a.pull('cur_deadbeef')).toThrow(/unknown cursor/); + expect(() => a.pull(`${cur}0`)).toThrow(/unknown cursor/); + expect(() => a.pull('1')).toThrow(/unknown cursor/); + expect(() => b.pull(cur)).toThrow(/unknown cursor/); + }); + + it('an unknown future event mid-trace does not disturb conformance across projections', () => { + const histories = PROJECTIONS.map((proj) => { + const s = new DurableSession(() => 1_000); + const c = new SimClient(proj, s); + s.acceptTurn('run-1', 1); + s.ingest({ kind: 'x-future.event', runId: 'run-1', data: { v: 3 } }); + s.ingest({ kind: 'telemetry.span', data: { ms: 1 } }); + s.output('run-1', 'ok'); + c.sync(); + c.disconnect(); c.reconnect(); + s.complete('run-1'); + c.sync(); + return canon(c.history); + }); + expect(new Set(histories).size).toBe(1); + expect(histories[0]).toContain('x-future.event'); + expect(histories[0]).not.toContain('telemetry'); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 0611b925e..035ed1b3b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ dedupe: ['@bradygaster/squad-sdk'], }, test: { - include: ['test/**/*.test.ts'], + include: ['test/**/*.test.ts', 'packages/squad-sdk/test/**/*.test.ts'], coverage: { provider: 'v8', reporter: ['text', 'text-summary', 'html'], From 1c89b26daee69e46179f4a5fcda6edbcce712394 Mon Sep 17 00:00:00 2001 From: Copilot Date: Sat, 17 Oct 2026 22:16:02 +0300 Subject: [PATCH 03/46] feat(clients): multi-client host adapters and --client flag Add ClientAdapter/ProjectionFile architecture in squad-sdk (copilot, claude, kiro, opencode, cursor) with host-agnostic coordinator core, per-host dispatch sections, declared ClientCapabilities, resolveEffectiveCapabilities, and portable clients[] sanitization. Non-clobbering JSON/marker-block merging. CLI: --client on init and upgrade, persisted to .squad/config.json clients[]; upgrade refreshes configured clients. Tests in test/clients.test.ts. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 995eea17-c356-4912-90ea-5e5b144e7ba9 --- packages/squad-cli/src/cli-entry.ts | 21 +- packages/squad-cli/src/cli/core/clients.ts | 126 +++++++ .../squad-cli/src/cli/core/command-help.ts | 2 + packages/squad-cli/src/cli/core/init.ts | 13 + packages/squad-cli/src/cli/core/upgrade.ts | 18 + packages/squad-sdk/package.json | 4 + packages/squad-sdk/src/clients/adapters.ts | 265 +++++++++++++ packages/squad-sdk/src/clients/apply.ts | 105 ++++++ .../squad-sdk/src/clients/capabilities.ts | 35 ++ packages/squad-sdk/src/clients/coordinator.ts | 70 ++++ packages/squad-sdk/src/clients/index.ts | 6 + packages/squad-sdk/src/clients/portable.ts | 49 +++ packages/squad-sdk/src/clients/types.ts | 83 +++++ test/clients.test.ts | 352 ++++++++++++++++++ 14 files changed, 1148 insertions(+), 1 deletion(-) create mode 100644 packages/squad-cli/src/cli/core/clients.ts create mode 100644 packages/squad-sdk/src/clients/adapters.ts create mode 100644 packages/squad-sdk/src/clients/apply.ts create mode 100644 packages/squad-sdk/src/clients/capabilities.ts create mode 100644 packages/squad-sdk/src/clients/coordinator.ts create mode 100644 packages/squad-sdk/src/clients/index.ts create mode 100644 packages/squad-sdk/src/clients/portable.ts create mode 100644 packages/squad-sdk/src/clients/types.ts create mode 100644 test/clients.test.ts diff --git a/packages/squad-cli/src/cli-entry.ts b/packages/squad-cli/src/cli-entry.ts index 75bf9f52d..65ce70e2c 100644 --- a/packages/squad-cli/src/cli-entry.ts +++ b/packages/squad-cli/src/cli-entry.ts @@ -161,6 +161,7 @@ async function main(): Promise { console.log(` --no-vscode-default (skip .vscode/settings.json update)`); console.log(` --preset (apply a preset after init)`); console.log(` --state-backend (local|orphan|two-layer)`); + console.log(` --client (copilot,claude,kiro,opencode,cursor|all)`); console.log(` Usage: init --mode remote `); console.log(` Creates .squad/config.json pointing to an external team root`); console.log(` ${BOLD}upgrade${RESET} Update Squad-owned files to latest version`); @@ -169,6 +170,7 @@ async function main(): Promise { console.log(` Flags: --global (upgrade personal squad)`); console.log(` --migrate-directory (rename .ai-team/ → .squad/)`); console.log(` --state-backend (migrate to orphan|two-layer)`); + console.log(` --client (add client hosts; configured clients always refresh)`); console.log(` ${BOLD}update-check${RESET} Report cached CLI update status (for tooling/CI)`); console.log(` Flags: --json (structured output), --refresh (bypass cache)`); console.log(` ${BOLD}migrate${RESET} Convert between markdown and SDK-First squad formats`); @@ -365,8 +367,16 @@ async function main(): Promise { // Parse --state-backend flag for init const sbIdx = args.indexOf('--state-backend'); const initStateBackend = (sbIdx !== -1 && args[sbIdx + 1]) ? args[sbIdx + 1] : undefined; + let initClients; + try { + const { readClientFlag } = await import('./cli/core/clients.js'); + initClients = readClientFlag(args); + } catch (err) { + console.error(`❌ ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } // Global init: suppress workflows (no GitHub CI in ~/.config/squad/) and bootstrap personal squad - runInit(dest, { includeWorkflows: !noWorkflows && !hasGlobal, sdk, roles, isGlobal: hasGlobal, stateBackend: initStateBackend, mcpFrontmatter, includeVscodeDefault: !noVscodeDefault }).then(async () => { + runInit(dest, { includeWorkflows: !noWorkflows && !hasGlobal, sdk, roles, isGlobal: hasGlobal, stateBackend: initStateBackend, mcpFrontmatter, clients: initClients, includeVscodeDefault: !noVscodeDefault }).then(async () => { if (presetName) { const { seedBuiltinPresets, applyPreset } = await import('@bradygaster/squad-sdk/presets'); const { resolvePresetsDir, ensureSquadHome } = await import('@bradygaster/squad-sdk/resolution'); @@ -413,6 +423,14 @@ async function main(): Promise { const forceUpgrade = args.includes('--force'); const insider = args.includes('--insider'); const dryRun = args.includes('--dry-run'); + let upgradeClients; + try { + const { readClientFlag } = await import('./cli/core/clients.js'); + upgradeClients = readClientFlag(args); + } catch (err) { + console.error(`❌ ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } const dest = hasGlobal ? (await lazySquadSdk()).resolveGlobalSquadPath() : getSquadStartDir(); // Parse --state-backend for backend migration @@ -477,6 +495,7 @@ async function main(): Promise { self: selfUpgrade, force: forceUpgrade, dryRun, + clients: upgradeClients, }); } diff --git a/packages/squad-cli/src/cli/core/clients.ts b/packages/squad-cli/src/cli/core/clients.ts new file mode 100644 index 000000000..0608db552 --- /dev/null +++ b/packages/squad-cli/src/cli/core/clients.ts @@ -0,0 +1,126 @@ +/** + * Shared `--client` handling for `squad init` and `squad upgrade`. + * + * Persists configured clients to `.squad/config.json` (`clients: string[]`) + * and (re)projects each client's host files without clobbering user settings. + * + * @module cli/core/clients + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { CLIENT_IDS, installClients, normalizeClientIds, parseClients, type ApplyResult, type ClientId, type TeamMember } from '@bradygaster/squad-sdk/clients'; +import { projectSquadStateMcpSpec, resolveSquadStateMcpSpec } from './mcp-spec.js'; +import { getPackageVersion } from './version.js'; + +export { CLIENT_IDS, parseClients }; + +/** Extract `--client ` / `--client=` from argv. Returns undefined when absent. */ +export function readClientFlag(args: readonly string[]): ClientId[] | undefined { + for (let i = 0; i < args.length; i++) { + const a = args[i]!; + if (a === '--client' || a === '--clients') { + const v = args[i + 1]; + if (!v || v.startsWith('--')) throw new Error(`--client requires a value: ${CLIENT_IDS.join(', ')}, all`); + return parseClients(v); + } + const m = /^--clients?=(.*)$/.exec(a); + if (m) return parseClients(m[1]); + } + return undefined; +} + +export function readConfiguredClients(squadDir: string): ClientId[] { + try { + const cfg = JSON.parse(fs.readFileSync(path.join(squadDir, 'config.json'), 'utf8')) as { clients?: unknown }; + return normalizeClientIds(cfg.clients).clients; + } catch { + // missing or malformed config: no configured clients + } + return []; +} + +export function persistClients(squadDir: string, clients: readonly ClientId[]): void { + const file = path.join(squadDir, 'config.json'); + let cfg: Record = {}; + try { + const parsed = JSON.parse(fs.readFileSync(file, 'utf8')) as unknown; + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) cfg = parsed as Record; + } catch { + // start fresh + } + cfg['clients'] = normalizeClientIds(clients).clients; + fs.mkdirSync(squadDir, { recursive: true }); + fs.writeFileSync(file, JSON.stringify(cfg, null, 2) + '\n', 'utf8'); +} + +/** Parse roster rows (`| Name | Role | ... |`) from team.md. */ +export function parseRoster(teamMd: string): TeamMember[] { + const members: TeamMember[] = []; + let nameCol = -1; + let roleCol = -1; + for (const line of teamMd.split(/\r?\n/)) { + if (!line.trim().startsWith('|')) { + nameCol = roleCol = -1; + continue; + } + const cells = line.trim().replace(/^\||\|$/g, '').split('|').map((c) => c.trim()); + if (nameCol === -1) { + nameCol = cells.findIndex((c) => /^name$/i.test(c)); + roleCol = cells.findIndex((c) => /^role$/i.test(c)); + continue; + } + if (roleCol === -1 || cells.every((c) => /^:?-+:?$/.test(c))) continue; + const name = cells[nameCol]; + const role = cells[roleCol]; + if (name && role) members.push({ name, role }); + } + return members; +} + +export interface SyncClientsOptions { + /** Explicit set from `--client`; when omitted, configured clients are refreshed. */ + clients?: readonly ClientId[]; + version?: string; +} + +/** + * Project the requested (or previously configured) clients and persist the set. + * Returns the clients that were synced and per-file results. + */ +export async function syncClients( + dest: string, + squadDir: string, + options: SyncClientsOptions = {}, +): Promise<{ clients: ClientId[]; results: ApplyResult[] }> { + const requested = options.clients; + const configured = readConfiguredClients(squadDir); + const clients = requested + ? CLIENT_IDS.filter((c) => requested.includes(c) || configured.includes(c)) + : configured; + if (clients.length === 0) return { clients: [], results: [] }; + + const version = options.version ?? getPackageVersion(); + const spec = projectSquadStateMcpSpec(await resolveSquadStateMcpSpec(version)); + let members: TeamMember[] = []; + try { + members = parseRoster(fs.readFileSync(path.join(squadDir, 'team.md'), 'utf8')); + } catch { + // no roster yet + } + const results = installClients(clients, { + root: dest, + mcp: { command: spec.command, args: spec.args }, + members, + squadDir: path.relative(dest, squadDir).split(path.sep).join('/') || '.squad', + }); + persistClients(squadDir, clients); + return { clients, results }; +} + +export function reportClientResults(results: readonly ApplyResult[], log: (m: string) => void = console.log): void { + for (const r of results) { + if (r.action === 'error') log(`⚠ ${r.path}: ${r.error}`); + else if (r.action !== 'unchanged') log(`✓ ${r.action} ${r.path}`); + } +} diff --git a/packages/squad-cli/src/cli/core/command-help.ts b/packages/squad-cli/src/cli/core/command-help.ts index 068f12101..00e5b9b83 100644 --- a/packages/squad-cli/src/cli/core/command-help.ts +++ b/packages/squad-cli/src/cli/core/command-help.ts @@ -43,6 +43,7 @@ const COMMAND_HELP: Record = { console.log(` ${BOLD}--no-workflows${RESET} Skip writing GitHub Actions workflows`); console.log(` ${BOLD}--preset ${RESET} Apply a curated preset after init`); console.log(` ${BOLD}--state-backend ${RESET} State backend (local|orphan|two-layer)`); + console.log(` ${BOLD}--client ${RESET} Client hosts to project: copilot,claude,kiro,opencode,cursor or all`); console.log(` ${BOLD}--mode remote ${RESET} Point at an external team root (creates .squad/config.json)\n`); }, @@ -58,6 +59,7 @@ const COMMAND_HELP: Record = { console.log(` ${BOLD}--global${RESET} Upgrade the personal (global) squad`); console.log(` ${BOLD}--migrate-directory${RESET} Rename legacy .ai-team/ to .squad/`); console.log(` ${BOLD}--state-backend ${RESET} Migrate to a new backend (orphan|two-layer)`); + console.log(` ${BOLD}--client ${RESET} Add client hosts (copilot,claude,kiro,opencode,cursor|all); configured clients always refresh`); console.log(` ${BOLD}--self${RESET} Upgrade the squad CLI package itself`); console.log(` ${BOLD}--insider${RESET} With --self, install the @insider tag`); console.log(` ${BOLD}--force${RESET} Overwrite files without prompting\n`); diff --git a/packages/squad-cli/src/cli/core/init.ts b/packages/squad-cli/src/cli/core/init.ts index 6a68677d1..726e58f00 100644 --- a/packages/squad-cli/src/cli/core/init.ts +++ b/packages/squad-cli/src/cli/core/init.ts @@ -19,6 +19,8 @@ import { describeMcpSpec, ensureUserOwnedTemplates } from './upgrade.js'; import { syncCopilotInstructionsFile } from './managed-file.js'; import { getTemplatesDir } from './templates.js'; import { ensureSquadStateMcpInRoot, tombstoneStaleSquadStateInProjectMcp } from './mcp-root.js'; +import { reportClientResults, syncClients } from './clients.js'; +import type { ClientId } from '@bradygaster/squad-sdk/clients'; import { readTeamMd, writeTeamMd, @@ -165,6 +167,8 @@ export interface RunInitOptions { stateBackend?: string; /** If true, write MCP server config into squad.agent.md frontmatter instead of .copilot/mcp-config.json */ mcpFrontmatter?: boolean; + /** Client hosts to project (copilot, claude, kiro, opencode, cursor) */ + clients?: ClientId[]; /** If false, skip writing "chat.newSession.defaultMode": "Squad" to .vscode/settings.json (default: true) */ includeVscodeDefault?: boolean; } @@ -495,6 +499,15 @@ export async function runInit(dest: string, options: RunInitOptions = {}): Promi // best-effort: .mcp.json write failure does not block init } + if (options.clients && options.clients.length > 0 && !options.isGlobal) { + try { + const synced = await syncClients(dest, squadDir, { clients: options.clients, version }); + reportClientResults(synced.results, (m) => console.log(m.startsWith('⚠') ? `${YELLOW}${m}${RESET}` : `${GREEN}${m}${RESET}`)); + } catch (err) { + console.warn(`${YELLOW}⚠ Could not project client files: ${err instanceof Error ? err.message : err}${RESET}`); + } + } + // Apply VS Code default chat session mode (opt-out: --no-vscode-default) if (options.includeVscodeDefault !== false && !options.isGlobal) { try { diff --git a/packages/squad-cli/src/cli/core/upgrade.ts b/packages/squad-cli/src/cli/core/upgrade.ts index 8d96e7079..c63e1d0fc 100644 --- a/packages/squad-cli/src/cli/core/upgrade.ts +++ b/packages/squad-cli/src/cli/core/upgrade.ts @@ -20,6 +20,8 @@ import { projectSquadStateMcpSpec, resolveSquadStateMcpSpec, type SquadStateMcpS import { copyManagedFile, syncCopilotInstructionsFile, writeManagedText } from './managed-file.js'; export { resolveSquadStateMcpSpec } from './mcp-spec.js'; import { ensureSquadStateMcpInRoot, tombstoneStaleSquadStateInProjectMcp } from './mcp-root.js'; +import { reportClientResults, syncClients } from './clients.js'; +import type { ClientId } from '@bradygaster/squad-sdk/clients'; const storage = new FSStorageProvider(); @@ -155,6 +157,8 @@ export interface UpgradeOptions { insider?: boolean; /** Preview what upgrade would change without writing. */ dryRun?: boolean; + /** Add these client hosts to the configured set; configured clients are always refreshed. */ + clients?: ClientId[]; } export interface UpdateInfo { @@ -1195,6 +1199,18 @@ export function ensureUserOwnedTemplates(dest: string, templatesDir: string): st return created; } +/** Refresh configured (plus any newly requested) client projections. */ +async function refreshClients(dest: string, squadDir: string, options: UpgradeOptions, filesUpdated: string[]): Promise { + try { + const synced = await syncClients(dest, squadDir, { clients: options.clients }); + if (synced.clients.length === 0) return; + reportClientResults(synced.results, (m) => (m.startsWith('⚠') ? warn(m.slice(2)) : success(m.slice(2)))); + filesUpdated.push(`clients (${synced.clients.join(', ')})`); + } catch (err) { + warn(`Could not refresh client files: ${err instanceof Error ? err.message : String(err)}`); + } +} + /** * Run the upgrade command */ @@ -1293,6 +1309,7 @@ export async function runUpgrade(dest: string, options: UpgradeOptions = {}): Pr // Run infrastructure ensure checks even when already current await runEnsureChecks(dest, templatesDir, filesUpdated); + await refreshClients(dest, squadDirInfo.path, options, filesUpdated); return { fromVersion: oldVersion, @@ -1378,6 +1395,7 @@ export async function runUpgrade(dest: string, options: UpgradeOptions = {}): Pr // Run infrastructure ensure checks await runEnsureChecks(dest, templatesDir, filesUpdated); + await refreshClients(dest, squadDirInfo.path, options, filesUpdated); console.log(); info(`Upgrade complete: v${fromLabel} → v${cliVersion}`); diff --git a/packages/squad-sdk/package.json b/packages/squad-sdk/package.json index e3ebcd1a8..1b7258773 100644 --- a/packages/squad-sdk/package.json +++ b/packages/squad-sdk/package.json @@ -18,6 +18,10 @@ "types": "./dist/types.d.ts", "import": "./dist/types.js" }, + "./clients": { + "types": "./dist/clients/index.d.ts", + "import": "./dist/clients/index.js" + }, "./config": { "types": "./dist/config/index.d.ts", "import": "./dist/config/index.js" diff --git a/packages/squad-sdk/src/clients/adapters.ts b/packages/squad-sdk/src/clients/adapters.ts new file mode 100644 index 000000000..3448942fe --- /dev/null +++ b/packages/squad-sdk/src/clients/adapters.ts @@ -0,0 +1,265 @@ +/** + * The five client host adapters and shared config-merge helpers. + * + * @module clients/adapters + */ + +import { CAPABILITIES } from './capabilities.js'; +import { buildCoordinatorInstructions, dispatchStrategy } from './coordinator.js'; +import type { ClientAdapter, ClientContext, ClientId, ProjectionFile, TeamMember } from './types.js'; +import { CLIENT_IDS } from './types.js'; + +export const SQUAD_STATE_KEY = 'squad_state'; +const DESCRIPTION = + 'Squad coordinator: routes work to a team of specialist agents and keeps shared team state consistent via the squad_state MCP backend.'; +const GENERATED_NOTE = ''; + +type Json = Record; + +function isObject(v: unknown): v is Json { + return !!v && typeof v === 'object' && !Array.isArray(v); +} + +/** + * Insert/refresh `.squad_state`. Squad owns the launch fields + * (`launch`); anything else already on the entry (env, tool filters, custom + * fields) and every other server/top-level key is preserved. `defaults` only + * apply when the entry did not previously exist or lacks the key. + */ +export function mergeMcpServer( + existing: Json, + container: string, + launch: Json, + defaults: Json = {}, + topLevelDefaults: Json = {}, +): Json { + const out: Json = { ...existing }; + for (const [k, v] of Object.entries(topLevelDefaults)) if (!(k in out)) out[k] = v; + const servers: Json = isObject(out[container]) ? { ...(out[container] as Json) } : {}; + const prev = isObject(servers[SQUAD_STATE_KEY]) ? (servers[SQUAD_STATE_KEY] as Json) : {}; + const entry: Json = { ...prev }; + for (const [k, v] of Object.entries(defaults)) if (!(k in entry)) entry[k] = v; + Object.assign(entry, launch); + servers[SQUAD_STATE_KEY] = entry; + out[container] = servers; + return out; +} + +function frontmatter(fields: Array<[string, string]>): string { + return `---\n${fields.map(([k, v]) => `${k}: ${v}`).join('\n')}\n---\n`; +} + +export function slugifyMember(name: string): string { + return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'member'; +} + +function yamlString(s: string): string { + return JSON.stringify(s); +} + +function mcpJson(ctx: ClientContext, withTools: boolean): ProjectionFile { + return { + kind: 'json', + path: '.mcp.json', + update: (existing) => + mergeMcpServer( + existing, + 'mcpServers', + { command: ctx.mcp.command, args: [...ctx.mcp.args] }, + withTools ? { env: {}, tools: ['*'] } : { env: {} }, + ), + }; +} + +const copilot: ClientAdapter = { + id: 'copilot', + capabilities: CAPABILITIES.copilot, + label: 'GitHub Copilot', + dispatch: dispatchStrategy('copilot'), + // `.github/agents/squad.agent.md` is rendered from the full coordinator template + // by the CLI init/upgrade pipeline; this adapter owns the MCP wiring. + project: (ctx) => [mcpJson(ctx, true)], +}; + +function claudeMember(m: TeamMember, ctx: ClientContext): ProjectionFile { + const slug = slugifyMember(m.name); + const squadDir = ctx.squadDir ?? '.squad'; + return { + kind: 'managed', + path: `.claude/agents/squad-${slug}.md`, + content: + frontmatter([ + ['name', `squad-${slug}`], + ['description', yamlString(`Squad specialist ${m.name} (${m.role}). Use for work routed to ${m.name}.`)], + ['tools', 'Read, Write, Edit, Bash, Glob, Grep, mcp__squad_state'], + ]) + + `${GENERATED_NOTE}\n\nYou are ${m.name}, the ${m.role} on this Squad team.\nFirst read your charter at \`${squadDir}/agents/${slug}/charter.md\` and follow it. Record decisions with the \`squad_decide\` MCP tool and append learnings to your history with \`squad_state_append\`.\n`, + }; +} + +const claude: ClientAdapter = { + id: 'claude', + capabilities: CAPABILITIES.claude, + label: 'Claude Code', + dispatch: dispatchStrategy('claude'), + project: (ctx) => [ + { + kind: 'managed', + path: '.claude/agents/squad.md', + content: + frontmatter([ + ['name', 'squad'], + ['description', yamlString(DESCRIPTION)], + ['tools', 'Task, Read, Write, Edit, Bash, Glob, Grep, mcp__squad_state'], + ]) + + `${GENERATED_NOTE}\n\n${buildCoordinatorInstructions('claude', ctx.squadDir)}`, + }, + ...(ctx.members ?? []).map((m) => claudeMember(m, ctx)), + mcpJson(ctx, false), + { + kind: 'block', + path: 'CLAUDE.md', + content: `## Squad\n\nThis project uses Squad. Run \`claude --agent squad\` (or delegate to the \`squad\` subagent) to work with the team defined in \`${ctx.squadDir ?? '.squad'}/team.md\`. Shared state is served by the \`squad_state\` MCP server in \`.mcp.json\`.`, + }, + ], +}; + +const kiro: ClientAdapter = { + id: 'kiro', + capabilities: CAPABILITIES.kiro, + label: 'Kiro', + dispatch: dispatchStrategy('kiro'), + project: (ctx) => [ + { + kind: 'managed', + path: '.kiro/steering/squad.md', + content: + frontmatter([['inclusion', 'always']]) + + `${GENERATED_NOTE}\n\n${buildCoordinatorInstructions('kiro', ctx.squadDir)}`, + }, + { + kind: 'managed', + path: '.kiro/agents/squad.json', + content: + JSON.stringify( + { + name: 'squad', + description: DESCRIPTION, + prompt: 'file://../steering/squad.md', + tools: ['*'], + includeMcpJson: true, + resources: [ + `file://../../${ctx.squadDir ?? '.squad'}/team.md`, + `file://../../${ctx.squadDir ?? '.squad'}/routing.md`, + ], + }, + null, + 2, + ) + '\n', + }, + { + kind: 'json', + path: '.kiro/settings/mcp.json', + update: (existing) => + mergeMcpServer( + existing, + 'mcpServers', + { command: ctx.mcp.command, args: [...ctx.mcp.args] }, + { env: {}, disabled: false, autoApprove: [] }, + ), + }, + ], +}; + +const opencode: ClientAdapter = { + id: 'opencode', + capabilities: CAPABILITIES.opencode, + label: 'OpenCode', + dispatch: dispatchStrategy('opencode'), + project: (ctx) => [ + { + kind: 'managed', + path: '.opencode/agent/squad.md', + content: + frontmatter([ + ['description', yamlString(DESCRIPTION)], + ['mode', 'primary'], + ]) + `${GENERATED_NOTE}\n\n${buildCoordinatorInstructions('opencode', ctx.squadDir)}`, + }, + { + kind: 'json', + path: 'opencode.json', + update: (existing) => + mergeMcpServer( + existing, + 'mcp', + { type: 'local', command: [ctx.mcp.command, ...ctx.mcp.args] }, + { enabled: true }, + { $schema: 'https://opencode.ai/config.json' }, + ), + }, + ], +}; + +const cursor: ClientAdapter = { + id: 'cursor', + capabilities: CAPABILITIES.cursor, + label: 'Cursor', + dispatch: dispatchStrategy('cursor'), + project: (ctx) => [ + { + kind: 'managed', + path: '.cursor/rules/squad.mdc', + content: + frontmatter([ + ['description', yamlString(DESCRIPTION)], + ['alwaysApply', 'true'], + ]) + `${GENERATED_NOTE}\n\n${buildCoordinatorInstructions('cursor', ctx.squadDir)}`, + }, + { + kind: 'managed', + path: '.cursor/agents/squad.md', + content: + frontmatter([ + ['name', 'squad'], + ['description', yamlString(DESCRIPTION)], + ['model', 'inherit'], + ]) + `${GENERATED_NOTE}\n\n${buildCoordinatorInstructions('cursor', ctx.squadDir)}`, + }, + { + kind: 'json', + path: '.cursor/mcp.json', + update: (existing) => + mergeMcpServer(existing, 'mcpServers', { command: ctx.mcp.command, args: [...ctx.mcp.args] }, { env: {} }), + }, + ], +}; + +export const CLIENT_ADAPTERS: Record = { copilot, claude, kiro, opencode, cursor }; + +export function getClientAdapter(id: ClientId): ClientAdapter { + return CLIENT_ADAPTERS[id]; +} + +/** + * Parse a `--client` value: comma-separated ids or `all`. Returns unique ids in + * canonical order. Throws on unknown ids. + */ +export function parseClients(value: string | readonly string[] | undefined): ClientId[] { + if (value === undefined) return []; + const parts = (typeof value === 'string' ? value.split(',') : [...value]) + .map((p) => p.trim().toLowerCase()) + .filter(Boolean); + if (parts.length === 0) throw new Error(`No client specified. Valid clients: ${CLIENT_IDS.join(', ')}, all`); + const wanted = new Set(); + for (const p of parts) { + if (p === 'all') { + CLIENT_IDS.forEach((c) => wanted.add(c)); + } else if ((CLIENT_IDS as readonly string[]).includes(p)) { + wanted.add(p as ClientId); + } else { + throw new Error(`Unknown client "${p}". Valid clients: ${CLIENT_IDS.join(', ')}, all`); + } + } + return CLIENT_IDS.filter((c) => wanted.has(c)); +} diff --git a/packages/squad-sdk/src/clients/apply.ts b/packages/squad-sdk/src/clients/apply.ts new file mode 100644 index 000000000..e4b0eeadb --- /dev/null +++ b/packages/squad-sdk/src/clients/apply.ts @@ -0,0 +1,105 @@ +/** + * Applies client projections to disk without clobbering user-owned settings. + * + * @module clients/apply + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { getClientAdapter } from './adapters.js'; +import type { ApplyResult, ClientContext, ClientId, ProjectionFile } from './types.js'; + +export const BLOCK_BEGIN = ''; +export const BLOCK_END = ''; + +/** Replace (or append) the Squad-managed block, leaving all other text untouched. */ +export function upsertBlock(existing: string, content: string): string { + const block = `${BLOCK_BEGIN}\n${content.trim()}\n${BLOCK_END}`; + const start = existing.indexOf(BLOCK_BEGIN); + const end = existing.indexOf(BLOCK_END); + if (start !== -1 && end > start) { + return existing.slice(0, start) + block + existing.slice(end + BLOCK_END.length); + } + const sep = existing.length === 0 ? '' : existing.endsWith('\n\n') ? '' : existing.endsWith('\n') ? '\n' : '\n\n'; + return `${existing}${sep}${block}\n`; +} + +/** Collect projection files for the given clients, in order. */ +export function projectClients(clients: readonly ClientId[], ctx: ClientContext): ProjectionFile[] { + return clients.flatMap((c) => getClientAdapter(c).project(ctx)); +} + +function readJsonObject(file: string): Record { + if (!fs.existsSync(file)) return {}; + const raw = fs.readFileSync(file, 'utf8'); + if (raw.trim() === '') return {}; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err) { + throw new Error(`Refusing to overwrite malformed ${file}: ${err instanceof Error ? err.message : String(err)}`); + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`Refusing to overwrite ${file}: root must be a JSON object`); + } + return parsed as Record; +} + +function writeIfChanged(file: string, next: string): ApplyResult['action'] { + const existed = fs.existsSync(file); + if (existed && fs.readFileSync(file, 'utf8') === next) return 'unchanged'; + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, next, 'utf8'); + return existed ? 'updated' : 'created'; +} + +/** + * Write projection files under `root`. Failures (e.g. a malformed user JSON + * file) are reported per-file and never abort the remaining files. + */ +export function applyProjection(root: string, files: readonly ProjectionFile[]): ApplyResult[] { + const results: ApplyResult[] = []; + const jsonByPath = new Map>>(); + const blockByPath = new Map(); + const order: Array<{ kind: 'file'; file: ProjectionFile } | { kind: 'json' | 'block'; path: string }> = []; + + for (const f of files) { + if (f.kind === 'managed') { + order.push({ kind: 'file', file: f }); + } else if (f.kind === 'json') { + if (!jsonByPath.has(f.path)) order.push({ kind: 'json', path: f.path }); + jsonByPath.set(f.path, [...(jsonByPath.get(f.path) ?? []), f]); + } else { + if (!blockByPath.has(f.path)) order.push({ kind: 'block', path: f.path }); + blockByPath.set(f.path, [...(blockByPath.get(f.path) ?? []), f.content]); + } + } + + for (const item of order) { + const rel = item.kind === 'file' ? (item.file as { path: string }).path : item.path; + const abs = path.join(root, rel); + try { + let action: ApplyResult['action']; + if (item.kind === 'file') { + action = writeIfChanged(abs, (item.file as { content: string }).content); + } else if (item.kind === 'json') { + let obj = readJsonObject(abs); + for (const f of jsonByPath.get(item.path)!) obj = f.update(obj); + action = writeIfChanged(abs, JSON.stringify(obj, null, 2) + '\n'); + } else { + let text = fs.existsSync(abs) ? fs.readFileSync(abs, 'utf8') : ''; + for (const c of blockByPath.get(item.path)!) text = upsertBlock(text, c); + action = writeIfChanged(abs, text); + } + results.push({ path: rel, action }); + } catch (err) { + results.push({ path: rel, action: 'error', error: err instanceof Error ? err.message : String(err) }); + } + } + return results; +} + +/** Project and apply the given clients in one step. */ +export function installClients(clients: readonly ClientId[], ctx: ClientContext): ApplyResult[] { + return applyProjection(ctx.root, projectClients(clients, ctx)); +} diff --git a/packages/squad-sdk/src/clients/capabilities.ts b/packages/squad-sdk/src/clients/capabilities.ts new file mode 100644 index 000000000..9c762bd45 --- /dev/null +++ b/packages/squad-sdk/src/clients/capabilities.ts @@ -0,0 +1,35 @@ +import type { ClientCapabilities, ClientId } from './types.js'; + +/** Declared (not probed) host capabilities; keep conservative. */ +export const CAPABILITIES: Record = { + copilot: { supportsSubagents: true, supportsMcpTransport: true, supportsNativeHooks: true, supportsSteeringFiles: true, transportModes: ['mcp-bridge', 'http-backend', 'in-process'] }, + claude: { supportsSubagents: true, supportsMcpTransport: true, supportsNativeHooks: true, supportsSteeringFiles: true, transportModes: ['mcp-bridge', 'http-backend'] }, + kiro: { supportsSubagents: true, supportsMcpTransport: true, supportsNativeHooks: true, supportsSteeringFiles: true, transportModes: ['mcp-bridge', 'http-backend'] }, + opencode: { supportsSubagents: true, supportsMcpTransport: true, supportsNativeHooks: true, supportsSteeringFiles: false, transportModes: ['mcp-bridge', 'http-backend'] }, + cursor: { supportsSubagents: true, supportsMcpTransport: true, supportsNativeHooks: false, supportsSteeringFiles: true, transportModes: ['mcp-bridge', 'http-backend'] }, +}; + +type CapabilityKey = Exclude; +const FLAGS: CapabilityKey[] = ['supportsSubagents', 'supportsMcpTransport', 'supportsNativeHooks', 'supportsSteeringFiles']; + +/** + * Strict intersection of client declaration, backend grant and policy. + * An omitted key in `backend`/`policy` means "no restriction from that party"; + * a present `false` (or `transportModes` list) restricts. The client's own + * declaration never widens what backend or policy allow. + */ +export function resolveEffectiveCapabilities( + client: ClientCapabilities, + backend: Partial = {}, + policy: Partial = {}, +): ClientCapabilities { + const out = { transportModes: [...client.transportModes] } as ClientCapabilities; + for (const k of FLAGS) { + out[k] = client[k] === true && backend[k] !== false && policy[k] !== false; + } + for (const limit of [backend.transportModes, policy.transportModes]) { + if (limit) out.transportModes = out.transportModes.filter((m) => limit.includes(m)); + } + if (!out.supportsMcpTransport) out.transportModes = out.transportModes.filter((m) => m !== 'mcp-bridge'); + return out; +} diff --git a/packages/squad-sdk/src/clients/coordinator.ts b/packages/squad-sdk/src/clients/coordinator.ts new file mode 100644 index 000000000..f79c44a92 --- /dev/null +++ b/packages/squad-sdk/src/clients/coordinator.ts @@ -0,0 +1,70 @@ +/** + * Host-agnostic coordinator protocol plus per-client dispatch sections. + * + * @module clients/coordinator + */ + +import { CAPABILITIES } from './capabilities.js'; +import type { ClientId, DispatchStrategy } from './types.js'; + +export function coreCoordinatorProtocol(squadDir = '.squad'): string { + return `# Squad Coordinator + +You are the Squad coordinator. You route work to a team of specialists and keep shared team state consistent. You do not do specialist work yourself when a team member owns it. + +## Core protocol (host-agnostic) + +1. **Load team context first.** Read \`${squadDir}/team.md\` (roster), \`${squadDir}/routing.md\` (who owns what) and the decisions ledger before routing anything. +2. **Route by ownership.** Match the request to the member whose role owns it per \`routing.md\`. If nobody owns it, ask the user or propose a new member. +3. **Spawn specialists with full context.** Each specialist prompt must include: the member's charter (\`${squadDir}/agents//charter.md\`), the task, relevant decisions, and the \`squad_state\` usage rules below. +4. **Shared state goes through the \`squad_state\` MCP server** (backend). Use \`squad_state_read\`, \`squad_state_list\`, \`squad_state_write\`, \`squad_state_append\`, \`squad_state_delete\` for team files, and \`squad_decide\` to record decisions. Never edit decision/state files directly when the MCP backend is available. +5. **Record decisions and learnings.** Specialists record decisions via \`squad_decide\`; append learnings to their own history via \`squad_state_append\`. +6. **Parallelize independent work**; serialize work with dependencies. Summarize results to the user concisely and say who did what. +7. **Backend contract vs. transport.** Conversation semantics, routing, durable state and authorization belong to the shared Squad backend contract (SquadBackend / DurableHttpSquadBackend), never to this host. The squad_state MCP server is only a local tool-transport bridge to that contract, not the contract itself; a host may reach the same backend over MCP or directly over HTTP. Host adapters only handle bootstrap, native dispatch/UI translation and local lifecycle. +8. **Honor user-owned files.** Never overwrite user-authored configuration; Squad only manages its own marked sections and keys. +`; +} + +const STRATEGIES: Record = { + copilot: { + summary: 'task / create_session / runSubagent', + section: `## Dispatch adapter: GitHub Copilot + +Spawn each specialist with the \`task\` tool (Copilot CLI), \`create_session\` (Copilot app) or \`runSubagent\` (VS Code). Pass the full specialist prompt from the core protocol. Run independent specialists in parallel by issuing multiple spawn calls in the same turn. The \`squad_state\` MCP server is loaded from \`.mcp.json\`.`, + }, + claude: { + summary: 'Claude Code Task subagent', + section: `## Dispatch adapter: Claude Code + +Spawn each specialist with the \`Task\` tool, using the matching subagent from \`.claude/agents/squad-.md\` when present, otherwise a general-purpose subagent with the member charter inlined in the prompt. Launch independent \`Task\` calls in one message to run them in parallel. Subagents cannot spawn subagents, so only the coordinator dispatches. The \`squad_state\` MCP tools are available as \`mcp__squad_state__*\` from \`.mcp.json\`.`, + }, + kiro: { + summary: 'Kiro subagent + steering, MCP backend', + section: `## Dispatch adapter: Kiro + +Kiro loads this file as steering (\`inclusion: always\`). Delegate each specialist task to a Kiro subagent, inlining the member charter in the delegation prompt; when subagents are unavailable, adopt the member's charter in turn and state which member is acting. All shared state flows through the \`squad_state\` MCP server configured in \`.kiro/settings/mcp.json\`.`, + }, + opencode: { + summary: '@agent mention / task tool, MCP backend', + section: `## Dispatch adapter: OpenCode + +Invoke specialists with the \`task\` tool (or an \`@agent\` mention for agents defined in \`.opencode/agent/\`), inlining the member charter in the prompt. Independent tasks may run in parallel. The \`squad_state\` MCP server is configured under \`mcp.squad_state\` in \`opencode.json\`; its tools appear as \`squad_state_*\`.`, + }, + cursor: { + summary: 'Cursor subagent / composer, MCP backend', + section: `## Dispatch adapter: Cursor + +Delegate specialist work to Cursor subagents (\`.cursor/agents/\`) when available, otherwise in composer/agent turns that adopt the member charter explicitly. Inline the charter and task in every delegation. The \`squad_state\` MCP server is configured in \`.cursor/mcp.json\`.`, + }, +}; + +export function dispatchStrategy(client: ClientId): DispatchStrategy { + return STRATEGIES[client]; +} + +/** Full coordinator instructions: host-agnostic core + the client's dispatch section. */ +export function buildCoordinatorInstructions(client: ClientId, squadDir = '.squad'): string { + const caps = CAPABILITIES[client]; + const transports = `## Transport\n\nThis host connects to the backend via: ${caps.transportModes.join(", ")} (subagents: ${caps.supportsSubagents ? "yes" : "no"}).\n`; + return `${coreCoordinatorProtocol(squadDir)}\n${STRATEGIES[client].section}\n\n${transports}`; +} diff --git a/packages/squad-sdk/src/clients/index.ts b/packages/squad-sdk/src/clients/index.ts new file mode 100644 index 000000000..44385ccbb --- /dev/null +++ b/packages/squad-sdk/src/clients/index.ts @@ -0,0 +1,6 @@ +export * from './types.js'; +export * from './coordinator.js'; +export * from './adapters.js'; +export * from './apply.js'; +export * from './capabilities.js'; +export * from './portable.js'; diff --git a/packages/squad-sdk/src/clients/portable.ts b/packages/squad-sdk/src/clients/portable.ts new file mode 100644 index 000000000..40c61471a --- /dev/null +++ b/packages/squad-sdk/src/clients/portable.ts @@ -0,0 +1,49 @@ +/** + * Portable `clients[]` hygiene: persisted config may only carry canonical + * client slugs, never credentials, provider thread ids, machine paths, + * leases or checkpoints. + * + * @module clients/portable + */ + +import { CLIENT_IDS, type ClientId } from './types.js'; + +export type RejectReason = 'non-string' | 'sensitive' | 'unknown'; +export interface RejectedClientValue { + value: unknown; + reason: RejectReason; +} + +const SENSITIVE = + /(token|secret|password|passwd|apikey|api[-_]key|bearer|credential|authorization|thread|lease|checkpoint|session[-_]?id|[a-z]:[\\/]|^[\\/~]|[\\/]|@|:\/\/|=)/i; + +/** + * Normalize an arbitrary persisted `clients` value to canonical, de-duplicated + * ids in canonical order. Everything else is dropped and reported. + */ +export function normalizeClientIds(input: unknown): { clients: ClientId[]; rejected: RejectedClientValue[] } { + const rejected: RejectedClientValue[] = []; + const seen = new Set(); + for (const v of Array.isArray(input) ? input : []) { + if (typeof v !== 'string') { + rejected.push({ value: typeof v === 'object' ? '[object]' : v, reason: 'non-string' }); + continue; + } + const slug = v.trim().toLowerCase(); + if ((CLIENT_IDS as readonly string[]).includes(slug)) seen.add(slug as ClientId); + else rejected.push({ value: SENSITIVE.test(v) ? '[redacted]' : v, reason: SENSITIVE.test(v) ? 'sensitive' : 'unknown' }); + } + return { clients: CLIENT_IDS.filter((c) => seen.has(c)), rejected }; +} + +/** Throws (without echoing the offending values) unless every entry is a canonical slug. */ +export function assertPortableClients(input: unknown): ClientId[] { + if (!Array.isArray(input)) throw new Error('clients must be an array of client ids'); + const { clients, rejected } = normalizeClientIds(input); + if (rejected.length > 0 || clients.length !== input.length) { + throw new Error( + `clients must contain only canonical ids (${CLIENT_IDS.join(', ')}) without duplicates; rejected ${rejected.length} non-portable value(s)`, + ); + } + return clients; +} diff --git a/packages/squad-sdk/src/clients/types.ts b/packages/squad-sdk/src/clients/types.ts new file mode 100644 index 000000000..a7954d662 --- /dev/null +++ b/packages/squad-sdk/src/clients/types.ts @@ -0,0 +1,83 @@ +/** + * Multi-client host adapter types. + * + * Squad's coordinator and its `squad_state` MCP backend are host-agnostic. + * A {@link ClientAdapter} projects them into the files a specific client + * host (Copilot, Claude Code, Kiro, OpenCode, Cursor) loads natively. + * + * @module clients/types + */ + +export const CLIENT_IDS = ['copilot', 'claude', 'kiro', 'opencode', 'cursor'] as const; +export type ClientId = (typeof CLIENT_IDS)[number]; + +/** Launch spec for the `squad_state` MCP server. */ +export interface McpLaunchSpec { + command: string; + args: string[]; +} + +export interface TeamMember { + name: string; + role: string; +} + +export interface ClientContext { + /** Project root the projection files are written under. */ + root: string; + /** How hosts should launch the `squad_state` MCP backend. */ + mcp: McpLaunchSpec; + /** Roster used to project per-member subagent files (hosts that support them). */ + members?: TeamMember[]; + /** Squad state directory name relative to root (default `.squad`). */ + squadDir?: string; +} + +/** A projected file, relative to {@link ClientContext.root}. */ +export type ProjectionFile = + /** Fully Squad-owned file; overwritten on every refresh. */ + | { kind: 'managed'; path: string; content: string } + /** Read-modify-write of a user-shared JSON config; only Squad keys are touched. */ + | { kind: 'json'; path: string; update: (existing: Record) => Record } + /** Marker-delimited block inside a user-owned text file. */ + | { kind: 'block'; path: string; content: string }; + +export type DispatchStrategy = { + /** Host-native way the coordinator spawns a specialist. */ + summary: string; + /** Markdown "Dispatch adapter" section embedded in the coordinator instructions. */ + section: string; +}; + +/** How a client reaches the shared Squad backend contract. */ +export type TransportMode = 'mcp-bridge' | 'http-backend' | 'in-process'; + +/** + * What a host can do natively. Adapters own bootstrap/projection, capability + * declaration, UI/dispatch translation and local lifecycle only; conversation + * semantics, routing, durable state and authorization live in the backend. + */ +export interface ClientCapabilities { + supportsSubagents: boolean; + supportsMcpTransport: boolean; + supportsNativeHooks: boolean; + supportsSteeringFiles: boolean; + /** Ways this host can connect to `SquadBackend`; `mcp-bridge` is a local tool transport, not the wire contract. */ + transportModes: TransportMode[]; +} + +export interface ClientAdapter { + readonly id: ClientId; + readonly label: string; + readonly capabilities: ClientCapabilities; + readonly dispatch: DispatchStrategy; + project(ctx: ClientContext): ProjectionFile[]; +} + +export type ApplyAction = 'created' | 'updated' | 'unchanged' | 'error'; + +export interface ApplyResult { + path: string; + action: ApplyAction; + error?: string; +} diff --git a/test/clients.test.ts b/test/clients.test.ts new file mode 100644 index 000000000..c39a16284 --- /dev/null +++ b/test/clients.test.ts @@ -0,0 +1,352 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + CLIENT_IDS, + CLIENT_ADAPTERS, + CAPABILITIES, + resolveEffectiveCapabilities, + normalizeClientIds, + assertPortableClients, + applyProjection, + buildCoordinatorInstructions, + installClients, + parseClients, + projectClients, + upsertBlock, + BLOCK_BEGIN, + type ClientContext, +} from '../packages/squad-sdk/src/clients/index.js'; +import { + parseRoster, + persistClients, + readClientFlag, + readConfiguredClients, + syncClients, +} from '../packages/squad-cli/src/cli/core/clients.js'; + +let root: string; +const mcp = { command: 'squad', args: ['state-mcp'] }; +const ctx = (over: Partial = {}): ClientContext => ({ root, mcp, ...over }); +const read = (p: string) => fs.readFileSync(path.join(root, p), 'utf8'); +const readJson = (p: string) => JSON.parse(read(p)); +const exists = (p: string) => fs.existsSync(path.join(root, p)); + +beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'squad-clients-')); +}); +afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); +}); + +describe('parseClients', () => { + it('parses comma lists in canonical order, deduped', () => { + expect(parseClients('cursor, claude,claude')).toEqual(['claude', 'cursor']); + }); + it('expands all', () => { + expect(parseClients('all')).toEqual([...CLIENT_IDS]); + }); + it('rejects unknown and empty values', () => { + expect(() => parseClients('vim')).toThrow(/Unknown client "vim"/); + expect(() => parseClients(' , ')).toThrow(/No client/); + }); +}); + +describe('coordinator instructions', () => { + it.each(CLIENT_IDS)('%s includes core protocol and its own dispatch section only', (id) => { + const text = buildCoordinatorInstructions(id); + expect(text).toContain('Core protocol (host-agnostic)'); + expect(text).toContain('squad_state'); + expect(text).toContain(CLIENT_ADAPTERS[id].dispatch.section); + for (const other of CLIENT_IDS.filter((c) => c !== id)) { + expect(text).not.toContain(CLIENT_ADAPTERS[other].dispatch.section); + } + }); + it('mentions the host-specific spawn primitive', () => { + expect(buildCoordinatorInstructions('copilot')).toMatch(/runSubagent/); + expect(buildCoordinatorInstructions('claude')).toMatch(/`Task`/); + expect(buildCoordinatorInstructions('opencode')).toMatch(/@agent/); + }); +}); + +describe('client capabilities', () => { + it.each(CLIENT_IDS)('%s declares complete capabilities', (id) => { + const caps = CLIENT_ADAPTERS[id].capabilities; + expect(caps).toBe(CAPABILITIES[id]); + for (const k of ['supportsSubagents', 'supportsMcpTransport', 'supportsNativeHooks', 'supportsSteeringFiles'] as const) { + expect(typeof caps[k]).toBe('boolean'); + } + expect(caps.transportModes.length).toBeGreaterThan(0); + for (const m of caps.transportModes) expect(['mcp-bridge', 'http-backend', 'in-process']).toContain(m); + expect(new Set(caps.transportModes).size).toBe(caps.transportModes.length); + }); + it('every MCP-capable host offers mcp-bridge and a direct http-backend path', () => { + for (const id of CLIENT_IDS) { + const caps = CLIENT_ADAPTERS[id].capabilities; + if (caps.supportsMcpTransport) expect(caps.transportModes).toContain('mcp-bridge'); + expect(caps.transportModes).toContain('http-backend'); + } + }); + it.each(CLIENT_IDS)('%s prompt frames MCP as a transport bridge over the backend contract', (id) => { + const text = buildCoordinatorInstructions(id); + expect(text).toMatch(/Backend contract vs\. transport/); + expect(text).toMatch(/SquadBackend/); + expect(text).toMatch(/transport bridge/); + expect(text).toContain('connects to the backend via: ' + CLIENT_ADAPTERS[id].capabilities.transportModes.join(', ')); + }); +}); + +describe('copilot adapter', () => { + it('projects only .mcp.json with squad_state', () => { + installClients(['copilot'], ctx()); + expect(readJson('.mcp.json').mcpServers.squad_state).toEqual({ + command: 'squad', args: ['state-mcp'], env: {}, tools: ['*'], + }); + expect(exists('.github/agents/squad.agent.md')).toBe(false); + }); +}); + +describe('claude adapter', () => { + it('projects agent, members, .mcp.json and CLAUDE.md block', () => { + installClients(['claude'], ctx({ members: [{ name: 'Data', role: 'Framework Expert' }] })); + const agent = read('.claude/agents/squad.md'); + expect(agent).toMatch(/^---\nname: squad\ndescription: ".*"\ntools: .*Task.*mcp__squad_state\n---/); + const member = read('.claude/agents/squad-data.md'); + expect(member).toContain('name: squad-data'); + expect(member).toContain('.squad/agents/data/charter.md'); + expect(readJson('.mcp.json').mcpServers.squad_state.command).toBe('squad'); + expect(read('CLAUDE.md')).toContain(BLOCK_BEGIN); + }); + it('preserves user CLAUDE.md content and replaces the block in place', () => { + fs.writeFileSync(path.join(root, 'CLAUDE.md'), '# Mine\n\nrules\n'); + installClients(['claude'], ctx()); + installClients(['claude'], ctx({ squadDir: '.team' })); + const md = read('CLAUDE.md'); + expect(md.startsWith('# Mine\n\nrules\n')).toBe(true); + expect(md.match(/squad:begin/g)).toHaveLength(1); + expect(md).toContain('.team/team.md'); + }); +}); + +describe('kiro adapter', () => { + it('projects steering, agent json and settings/mcp.json', () => { + installClients(['kiro'], ctx()); + expect(read('.kiro/steering/squad.md')).toMatch(/^---\ninclusion: always\n---/); + const agent = readJson('.kiro/agents/squad.json'); + expect(agent.name).toBe('squad'); + expect(agent.includeMcpJson).toBe(true); + expect(readJson('.kiro/settings/mcp.json').mcpServers.squad_state).toMatchObject({ + command: 'squad', args: ['state-mcp'], disabled: false, + }); + }); +}); + +describe('opencode adapter', () => { + it('projects primary agent and opencode.json mcp entry', () => { + installClients(['opencode'], ctx()); + expect(read('.opencode/agent/squad.md')).toMatch(/mode: primary/); + const cfg = readJson('opencode.json'); + expect(cfg.$schema).toBe('https://opencode.ai/config.json'); + expect(cfg.mcp.squad_state).toEqual({ type: 'local', command: ['squad', 'state-mcp'], enabled: true }); + }); +}); + +describe('cursor adapter', () => { + it('projects rule, agent and mcp.json', () => { + installClients(['cursor'], ctx()); + expect(read('.cursor/rules/squad.mdc')).toMatch(/alwaysApply: true/); + expect(read('.cursor/agents/squad.md')).toMatch(/name: squad/); + expect(readJson('.cursor/mcp.json').mcpServers.squad_state.command).toBe('squad'); + }); +}); + +describe('config merging', () => { + it('preserves user servers, env and custom fields; refreshes launch spec', () => { + const file = path.join(root, '.kiro/settings/mcp.json'); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify({ + other: 1, + mcpServers: { + mine: { command: 'x' }, + squad_state: { command: 'old', args: ['old'], env: { HTTP_PROXY: 'p' }, autoApprove: ['squad_state_read'] }, + }, + })); + installClients(['kiro'], ctx()); + const cfg = readJson('.kiro/settings/mcp.json'); + expect(cfg.other).toBe(1); + expect(cfg.mcpServers.mine).toEqual({ command: 'x' }); + expect(cfg.mcpServers.squad_state).toMatchObject({ + command: 'squad', args: ['state-mcp'], env: { HTTP_PROXY: 'p' }, autoApprove: ['squad_state_read'], + }); + }); + + it('keeps existing opencode.json settings', () => { + fs.writeFileSync(path.join(root, 'opencode.json'), JSON.stringify({ model: 'm', mcp: { mine: { type: 'remote', url: 'u' } } })); + installClients(['opencode'], ctx()); + const cfg = readJson('opencode.json'); + expect(cfg.model).toBe('m'); + expect(cfg.mcp.mine).toEqual({ type: 'remote', url: 'u' }); + expect(cfg.mcp.squad_state.type).toBe('local'); + }); + + it('shares .mcp.json between copilot and claude without duplicating', () => { + installClients(['copilot', 'claude'], ctx()); + expect(Object.keys(readJson('.mcp.json').mcpServers)).toEqual(['squad_state']); + }); + + it('is idempotent', () => { + installClients(['claude', 'kiro', 'opencode', 'cursor', 'copilot'], ctx()); + const second = installClients(['claude', 'kiro', 'opencode', 'cursor', 'copilot'], ctx()); + expect(second.every((r) => r.action === 'unchanged')).toBe(true); + }); + + it('refuses malformed JSON, leaves it intact, and still writes other files', () => { + fs.writeFileSync(path.join(root, '.cursor-bad'), ''); + fs.mkdirSync(path.join(root, '.cursor'), { recursive: true }); + fs.writeFileSync(path.join(root, '.cursor/mcp.json'), '{ // comment'); + const results = installClients(['cursor'], ctx()); + expect(results.find((r) => r.path === '.cursor/mcp.json')?.action).toBe('error'); + expect(read('.cursor/mcp.json')).toBe('{ // comment'); + expect(exists('.cursor/rules/squad.mdc')).toBe(true); + }); + + it('upsertBlock appends with spacing', () => { + expect(upsertBlock('a', 'x')).toBe(`a\n\n${BLOCK_BEGIN}\nx\n\n`); + }); + + it('applyProjection reports created then updated', () => { + const files = projectClients(['cursor'], ctx()); + expect(applyProjection(root, files).every((r) => r.action === 'created')).toBe(true); + fs.writeFileSync(path.join(root, '.cursor/rules/squad.mdc'), 'edited'); + const again = applyProjection(root, files); + expect(again.find((r) => r.path === '.cursor/rules/squad.mdc')?.action).toBe('updated'); + }); +}); + +describe('CLI --client handling', () => { + it('reads --client forms', () => { + expect(readClientFlag(['init'])).toBeUndefined(); + expect(readClientFlag(['init', '--client', 'claude,kiro'])).toEqual(['claude', 'kiro']); + expect(readClientFlag(['init', '--client=all'])).toEqual([...CLIENT_IDS]); + expect(() => readClientFlag(['init', '--client'])).toThrow(/requires a value/); + expect(() => readClientFlag(['init', '--client', '--force'])).toThrow(/requires a value/); + expect(() => readClientFlag(['init', '--client', 'nope'])).toThrow(/Unknown client/); + }); + + it('parses roster tables', () => { + const md = '| Name | Role | Charter | Status |\n|---|---|---|---|\n| Data | Framework Expert | c | ✅ |\n| Worf | Security | c | ✅ |\n'; + expect(parseRoster(md)).toEqual([ + { name: 'Data', role: 'Framework Expert' }, + { name: 'Worf', role: 'Security' }, + ]); + }); + + it('persists clients in config.json preserving other keys', () => { + const sq = path.join(root, '.squad'); + fs.mkdirSync(sq); + fs.writeFileSync(path.join(sq, 'config.json'), JSON.stringify({ stateBackend: 'local' })); + persistClients(sq, ['claude', 'cursor']); + expect(JSON.parse(fs.readFileSync(path.join(sq, 'config.json'), 'utf8'))).toEqual({ + stateBackend: 'local', clients: ['claude', 'cursor'], + }); + expect(readConfiguredClients(sq)).toEqual(['claude', 'cursor']); + }); + + it('syncClients adds requested clients to configured set and refreshes all', async () => { + const sq = path.join(root, '.squad'); + fs.mkdirSync(sq); + fs.writeFileSync(path.join(sq, 'team.md'), '| Name | Role |\n|---|---|\n| Data | Expert |\n'); + const prev = process.env['SQUAD_STANDALONE_HOME']; + delete process.env['SQUAD_STANDALONE_HOME']; + try { + const first = await syncClients(root, sq, { clients: ['claude'], version: '0.0.0' }); + expect(first.clients).toEqual(['claude']); + const second = await syncClients(root, sq, { clients: ['kiro'], version: '0.0.0' }); + expect(second.clients).toEqual(['claude', 'kiro']); + expect(readConfiguredClients(sq)).toEqual(['claude', 'kiro']); + expect(exists('.claude/agents/squad-data.md')).toBe(true); + expect(exists('.kiro/steering/squad.md')).toBe(true); + const bare = await syncClients(root, sq, { version: '0.0.0' }); + expect(bare.clients).toEqual(['claude', 'kiro']); + expect(readJson('.mcp.json').mcpServers.squad_state.args.at(-1)).toBe('state-mcp'); + } finally { + if (prev !== undefined) process.env['SQUAD_STANDALONE_HOME'] = prev; + } + }); + + it('syncClients is a no-op with nothing configured', async () => { + const sq = path.join(root, '.squad'); + fs.mkdirSync(sq); + expect(await syncClients(root, sq)).toEqual({ clients: [], results: [] }); + }); +}); + +describe('resolveEffectiveCapabilities', () => { + const client = CAPABILITIES.copilot; + it('returns the client declaration when backend and policy are silent', () => { + expect(resolveEffectiveCapabilities(client)).toEqual(client); + expect(resolveEffectiveCapabilities(client)).not.toBe(client); + }); + it('backend or policy false removes a flag; neither can grant beyond the client', () => { + const eff = resolveEffectiveCapabilities(client, { supportsSubagents: false }, { supportsNativeHooks: false }); + expect(eff.supportsSubagents).toBe(false); + expect(eff.supportsNativeHooks).toBe(false); + expect(eff.supportsSteeringFiles).toBe(true); + const cursor = CAPABILITIES.cursor; + expect(resolveEffectiveCapabilities(cursor, { supportsNativeHooks: true }, { supportsNativeHooks: true }).supportsNativeHooks).toBe(false); + }); + it('intersects transport modes from client, backend and policy', () => { + const eff = resolveEffectiveCapabilities(client, { transportModes: ['mcp-bridge', 'http-backend'] }, { transportModes: ['http-backend', 'in-process'] }); + expect(eff.transportModes).toEqual(['http-backend']); + expect(resolveEffectiveCapabilities(CAPABILITIES.claude, { transportModes: ['in-process'] }).transportModes).toEqual([]); + }); + it('drops mcp-bridge when MCP transport is not effectively supported', () => { + const eff = resolveEffectiveCapabilities(client, { supportsMcpTransport: false }); + expect(eff.supportsMcpTransport).toBe(false); + expect(eff.transportModes).not.toContain('mcp-bridge'); + expect(eff.transportModes).toContain('http-backend'); + }); + it('does not mutate inputs', () => { + const before = JSON.stringify(client); + resolveEffectiveCapabilities(client, { transportModes: [] }); + expect(JSON.stringify(client)).toBe(before); + }); +}); + +describe('portable clients[] hygiene', () => { + it('keeps canonical ids in canonical order, deduped and case-normalized', () => { + expect(normalizeClientIds(['Cursor', 'claude', 'claude']).clients).toEqual(['claude', 'cursor']); + }); + it('strips and classifies credentials, paths, thread ids, leases and checkpoints without echoing them', () => { + const bad = [ + 'ghp_token=abc123', 'C:\\Users\\me\\repo', '/home/me/.claude', 'https://x/y', 'thread_19:abc@thread.v2', + 'lease-42', 'checkpoint-7', 'a@b.com', 42, { k: 1 }, 'vim', + ]; + const { clients, rejected } = normalizeClientIds(['kiro', ...bad]); + expect(clients).toEqual(['kiro']); + expect(rejected).toHaveLength(bad.length); + expect(JSON.stringify(rejected)).not.toMatch(/ghp_|abc123|Users|thread_19|lease-42|a@b/); + expect(rejected.find((r) => r.value === 'vim')?.reason).toBe('unknown'); + expect(rejected.filter((r) => r.reason === 'sensitive').length).toBeGreaterThanOrEqual(7); + }); + it('treats non-arrays as empty', () => { + expect(normalizeClientIds('claude').clients).toEqual([]); + expect(normalizeClientIds(undefined).clients).toEqual([]); + }); + it('assertPortableClients rejects anything non-canonical without leaking values', () => { + expect(assertPortableClients(['claude', 'kiro'])).toEqual(['claude', 'kiro']); + expect(() => assertPortableClients(['claude', 'claude'])).toThrow(/canonical/); + expect(() => assertPortableClients(['C:\\secret\\token'])).toThrow(/rejected 1/); + try { assertPortableClients(['sk-secret-token']); } catch (e) { expect(String(e)).not.toContain('sk-secret'); } + expect(() => assertPortableClients('claude')).toThrow(/array/); + }); + it('config persistence/reading drops non-portable entries', () => { + const sq = path.join(root, '.squad'); + fs.mkdirSync(sq); + fs.writeFileSync(path.join(sq, 'config.json'), JSON.stringify({ clients: ['cursor', 'C:\\x', 'token=1', 'claude'] })); + expect(readConfiguredClients(sq)).toEqual(['claude', 'cursor']); + persistClients(sq, ['kiro', '/etc/passwd' as never, 'lease-1' as never]); + expect(JSON.parse(fs.readFileSync(path.join(sq, 'config.json'), 'utf8')).clients).toEqual(['kiro']); + }); +}); From 25bf9b4f2efc7488c7ce2df603732f4ff3780120 Mon Sep 17 00:00:00 2001 From: Copilot Date: Sat, 17 Oct 2026 22:16:11 +0300 Subject: [PATCH 04/46] docs: freeze Squad 2.0 protocol baseline Bring Brady's specification suite updates into the Squad 2.0 worktree, add the new v0.2 client/backend and client/harness protocol specs, and freeze the rc1 protocol baseline with ADR and immutable fixtures. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 995eea17-c356-4912-90ea-5e5b144e7ba9 --- docs/specification/README.md | 53 +- ...r-001-client-control-execution-boundary.md | 59 +++ docs/specification/artifact-inventory.md | 38 +- docs/specification/ceremony-v0.1.md | 65 --- docs/specification/charter-v0.1.md | 54 +- .../client-backend-decoupling-v0.2.md | 487 ++++++++++++++++++ .../client-harness-adapters-v0.2.md | 367 +++++++++++++ docs/specification/core-v0.1.md | 32 -- docs/specification/governance-review-v0.1.md | 118 ++--- .../interoperability-conventions-v0.1.md | 78 +-- test-fixtures/spec/suite-v0.1/index.json | 38 ++ .../spec/suite-v0.2/capability-matrix.json | 119 +++++ .../spec/suite-v0.2/golden-trace.json | 63 +++ test/specification-suite.test.ts | 2 + 14 files changed, 1278 insertions(+), 295 deletions(-) create mode 100644 docs/specification/adr-001-client-control-execution-boundary.md create mode 100644 docs/specification/client-backend-decoupling-v0.2.md create mode 100644 docs/specification/client-harness-adapters-v0.2.md create mode 100644 test-fixtures/spec/suite-v0.2/capability-matrix.json create mode 100644 test-fixtures/spec/suite-v0.2/golden-trace.json diff --git a/docs/specification/README.md b/docs/specification/README.md index 585d227e9..5293826ec 100644 --- a/docs/specification/README.md +++ b/docs/specification/README.md @@ -6,10 +6,13 @@ **Draft date:** 2026-09-25 -**Working-draft provenance:** [PR #2074](https://github.com/bradygaster/squad/pull/2074) -contains the combined Core, Charter, and suite revision. The suite draft -originated in [PR #2078](https://github.com/bradygaster/squad/pull/2078); -the combined branch preserves its source-commit provenance. +**Working-draft provenance:** [PR #2078](https://github.com/bradygaster/squad/pull/2078) +contains this suite revision. Its rejected review baseline is +[`c03ba950fde071c8ec58a64504fdb7b5cf1206ef`](https://github.com/bradygaster/squad/commit/c03ba950fde071c8ec58a64504fdb7b5cf1206ef). +Revision cycle 2 was authored by `agent-orchestration-dev`; final revision +cycle 3 is authored by `unit-contract-tester`. The original Architect author +and rejecting reviewers are excluded from implementation by the governance +lockout. The source index keeps `publication.revision` and `publication.digest` null while it is a working draft. After review, the exact head SHA is recorded in @@ -20,23 +23,13 @@ The derived release index is attached to the tag or release; it is not committed back onto the same commit. This records an immutable revision in the suite index without a self-referential hash that would invalidate the commit. -Publication metadata is not a conformance-claim binding. Every claimable -profile instead carries a non-null `revision` object in the suite registry and -its executable manifest. For Charter v0.1, that object binds the exact Charter -document bytes and the canonical manifest evidence payload with SHA-256 -digests. This makes working-draft claims reproducible while publication -metadata remains null. - **Document class:** Informative registry and navigation index **Foundation profile:** [Squad Charter Profile v0.1](charter-v0.1.md) -This directory maps a working inventory of stable or observable Squad surfaces, -including composition and initialization capabilities without profiles in -the [artifact inventory](artifact-inventory.md). It does not yet specify the -complete model for how independent Squads compose and interact. Publication +This directory maps all major stable or observable Squad surfaces. Publication status is independent from maturity: this suite is an unpublished working -draft. Charter is the only profile with executable conformance evidence. +draft even where a profile has executable conformance evidence. ## Maturity taxonomy @@ -70,11 +63,11 @@ deviations are summarized here and expanded in the | ID | Maturity / class | Dependencies | Claimable capabilities | Schema/manifest | Implementation status | Known deviations | Reader entry path | |---|---|---|---|---|---|---|---| | `squad-core/v0.1` | `experimental-normative-draft` / normative profile | None | None | None | Normative rules are exercised by Charter; no standalone Core manifest | Canonical JSON and standalone Core cases incomplete | [Core](core-v0.1.md), then Charter | -| `squad-interop/v0.1` | `requirements-draft` / requirements | Core | None | Reference evidence schema; no manifest | Schema and example records inform future profile design | No discovery, relational-invariant, or replay corpus | [Interoperability](interoperability-conventions-v0.1.md) | -| `squad-charter/v0.1` | `executable-normative-draft` / normative profile | Core | `squad-charter/v0.1/parse`, `squad-charter/v0.1/validate`, `squad-charter/v0.1/edit`, `squad-charter/v0.1/legacy-consume`, `squad-charter/v0.1/runtime` | Manifest, schema, and immutable revision binding | Parser, validator, editor, compiler, and cases implemented | None registered | [Charter](charter-v0.1.md) | +| `squad-interop/v0.1` | `experimental-schema` / normative schema | Core | None | Shared evidence schema; no manifest | Evidence validation is executable | No discovery or replay corpus | [Interoperability](interoperability-conventions-v0.1.md) | +| `squad-charter/v0.1` | `executable-normative-draft` / normative profile | Core | `squad-charter/v0.1/parse`, `squad-charter/v0.1/validate`, `squad-charter/v0.1/edit`, `squad-charter/v0.1/legacy-consume`, `squad-charter/v0.1/runtime` | Manifest and schema | Parser, validator, editor, compiler, and cases implemented | None registered | [Charter](charter-v0.1.md) | | `squad-team-routing/v0.1` | `requirements-draft` / requirements | Core, Interop, Charter | None | None | Roster and routing exist in multiple shapes | Fallback and ambiguity differ | [Roster, then routing](team-routing-v0.1.md) | | `squad-configuration-casting/v0.1` | `requirements-draft` / requirements | Core, Interop, Team/Routing | None | None | Configuration, registry, and history implemented | Legacy shapes and runtime-only keys | [Configuration, casting, provenance](configuration-casting-v0.1.md) | -| `squad-governance-review/v0.1` | `requirements-draft` / requirements | Core, Interop, Team/Routing | None | Reference schema and rejection fixture; no manifest | TypeScript checks exercise implementation expectations only | No portable relational-invariant or transition corpus | [Evidence shape, then promotion criteria](governance-review-v0.1.md) | +| `squad-governance-review/v0.1` | `experimental-schema` / normative schema | Core, Interop, Team/Routing | None | Evidence schema plus rejection fixture; no manifest | Rejection records are machine-valid | Baseline owner self-revision conflict | [Evidence contract, then transitions](governance-review-v0.1.md) | | `squad-execution-preferences/v0.1` | `requirements-draft` / requirements | Core, Interop, Charter, Configuration/Casting | None | None | Runtime selection exists | Provider catalogs and fallback are policy | [Execution preferences](execution-preferences-v0.1.md) | | `squad-planning-activation/v0.1` | `requirements-draft` / requirements | Core, Interop, Team/Routing, Work Lifecycle | None | None | gh-aw planning records exist | Safe-output and limits are provider-specific | [Planning and activation](planning-activation-v0.1.md) | | `squad-ceremony/v0.1` | `requirements-draft` / requirements | Core, Interop, Team/Routing | None | None | Builder preserves triggers and schedules | Event names, `auto`, and cooldown are unstable | [Ceremonies](ceremony-v0.1.md) | @@ -83,13 +76,15 @@ deviations are summarized here and expanded in the | `squad-coordination-handoff/v0.1` | `requirements-draft` / requirements | Core, Interop, Team/Routing | None | None | Session and handoff implementations exist | Ordering and recovery are provider-specific | [Coordination](coordination-handoff-v0.1.md) | | `squad-automation/v0.1` | `requirements-draft` / requirements | Core, Interop, Coordination | None | None | Local and GitHub schedulers exist | Time, retry, and drift evidence differ | [Automation](automation-v0.1.md) | | `squad-state-memory-requirements/v0.1` | `informative-draft` / informative | Core, Interop | None | None | Several state and memory providers exist | No common authority, recovery, or privacy corpus | [State authority, then memory classification](state-memory-requirements-v0.1.md) | +| `squad-client-backend-decoupling/v0.2` | `requirements-draft` / requirements | Core, Interop, Coordination, State/Memory Requirements | None | None | Local, MCP, and remote broker seams exist but do not yet share one contract | Admission, approvals, leases, and paging differ by backend | [Client/backend decoupling](client-backend-decoupling-v0.2.md) | +| `squad-client-harness-adapters/v0.2` | `requirements-draft` / requirements | Core, Interop, Client/Backend Decoupling | None | None | GitHub Copilot projections exist; other host projections are specified but not yet unified in one SDK seam | Host discovery, approvals, and background execution differ | [Client/harness adapters](client-harness-adapters-v0.2.md) | | `squad-portable-bundle/v1.0` | `informative-draft` / observed implementation surface | Core and Interop rules apply conceptually | None | Tested JSON shape; no published schema or manifest | `squad export` and `squad import` round-trip team, routing, decisions, casting, agents, and skills | Bundle `1.0` does not use Core profile grammar; histories, decisions, skills, and casting policy can contain sensitive data | [Coverage and promotion requirements](artifact-inventory.md#portable-export-and-import-bundle) | The machine-readable registry is [`test-fixtures/spec/suite-v0.1/index.json`](../../test-fixtures/spec/suite-v0.1/index.json). -The governance JSON is a reference fixture, not portable conformance evidence. -Charter v0.1 is the only registered profile with a manifest, conformance -classes, capability identifiers, and immutable claim revisions. +The governance JSON is registered as a fixture, not a conformance manifest. +Charter v0.1's manifest, schema, case IDs, and capability identifiers remain +unchanged. ## Textual dependency adjacency and DAG @@ -110,6 +105,8 @@ squad-ceremony/v0.1 -> [squad-core/v0.1, squad-interop/v0.1, squad-team-routing/ squad-coordination-handoff/v0.1 -> [squad-core/v0.1, squad-interop/v0.1, squad-team-routing/v0.1] squad-automation/v0.1 -> [squad-core/v0.1, squad-interop/v0.1, squad-coordination-handoff/v0.1] squad-state-memory-requirements/v0.1 -> [squad-core/v0.1, squad-interop/v0.1] +squad-client-backend-decoupling/v0.2 -> [squad-core/v0.1, squad-interop/v0.1, squad-coordination-handoff/v0.1, squad-state-memory-requirements/v0.1] +squad-client-harness-adapters/v0.2 -> [squad-core/v0.1, squad-interop/v0.1, squad-client-backend-decoupling/v0.2] squad-portable-bundle/v1.0 -> [conceptual Core and Interop constraints; no profile claim] ``` @@ -118,7 +115,7 @@ DAG reading order: ```text Core ├── Charter (only claimable profile) -├── Interop requirements and reference schema +├── Interop shared schema │ ├── Team/Routing │ │ ├── Configuration/Casting │ │ │ └── Execution Preferences @@ -130,13 +127,15 @@ Core │ │ └── Coordination │ │ └── Automation │ └── State/Memory Requirements +│ └── Client/Backend Decoupling v0.2 +│ └── Client/Harness Adapters v0.2 └── Portable Bundle observation (applies Core/Interop constraints without a claim) ``` There is no dependency cycle. Provider-neutral requirements precede the GitHub binding. State authority and memory classification remain separate promotion -tracks. This composition diagram is informative: dependency edges do not confer -transitive capabilities or conformance claims. +tracks, and the client/backend split builds on that authority rather than +replacing it. ## External reference policy @@ -164,6 +163,4 @@ versions, implementation versions, and the observed portable-bundle `1.0` version are distinct. Draft documents may change before promotion. Only a `publication.status` of `published` denotes an immutable publication; published normative revisions are immutable except for dated errata, and behavior changes -require a new profile version. Claimable working drafts remain bound to exact -content digests through each profile's `revision` object; mutable branch names, -pull request numbers, and null publication fields are never claim revisions. +require a new profile version. diff --git a/docs/specification/adr-001-client-control-execution-boundary.md b/docs/specification/adr-001-client-control-execution-boundary.md new file mode 100644 index 000000000..5d3157006 --- /dev/null +++ b/docs/specification/adr-001-client-control-execution-boundary.md @@ -0,0 +1,59 @@ +# ADR 001: Client, Control, and Execution Boundary + +**Status:** Accepted draft baseline + +**Protocol baseline:** `squad-protocol-v2.0-rc1` + +## Context + +Squad 2.0 needs one protocol baseline that survives multiple clients, multiple +execution fabrics, disconnect and reconnect, and background work that outlives +one host session. + +The design pressure comes from five host families and more than one transport. +If conversation semantics lived inside each adapter, or if MCP became the +conversation wire contract, protocol behavior would drift with host quirks and +transport limitations. + +## Decision + +Squad 2.0 is split into three planes: + +| Plane | Owns | +|---|---| +| Client | local UX, auth bootstrap, host event parsing, rendering, and transport adaptation | +| Control and Broker | conversation identity, sequencing, capabilities, approvals, routing, cancellation, durable event history, and compatibility policy | +| Execution | task workers, tool execution, checkpoints, retries, and transient telemetry | + +MCP is treated as an execution or tool bridge. It is useful for local broker +projection and host integration, but it is not the canonical conversation wire +contract. + +Future compatibility claims are gated by `G1` through `G9`: + +| Gate | Requirement | +|---|---| +| `G1` | stable `runId` independent from `seq` | +| `G2` | monotonic per-conversation ordering by `seq` | +| `G3` | durable versioned approvals with immutable payload hashes | +| `G4` | effective capabilities negotiated as `client ∩ backend ∩ policy` | +| `G5` | cursor-based recovery with `lastAcceptedSeq` and `resumable` | +| `G6` | cancellation scoped to `runId` | +| `G7` | operational health kept outside the protocol resource tree | +| `G8` | loss-tolerant projection of unknown durable events and extensions | +| `G9` | immutable golden fixtures in `test-fixtures/spec/suite-v0.2/` | + +## Consequences + +- adapters stay narrow and host-specific +- backend semantics stay portable and versioned +- transport swaps do not rewrite conversation meaning +- fixture drift becomes visible through the protocol baseline instead of being + silently absorbed by one host + +## Related artifacts + +- [Squad Client and Backend Decoupling v0.2](client-backend-decoupling-v0.2.md) +- [Squad Client and Harness Adapter Profile v0.2](client-harness-adapters-v0.2.md) +- `test-fixtures/spec/suite-v0.2/golden-trace.json` +- `test-fixtures/spec/suite-v0.2/capability-matrix.json` diff --git a/docs/specification/artifact-inventory.md b/docs/specification/artifact-inventory.md index 3f5783f45..78d414aed 100644 --- a/docs/specification/artifact-inventory.md +++ b/docs/specification/artifact-inventory.md @@ -7,43 +7,30 @@ **Purpose:** Repository-derived traceability, profile planning, and interoperability maturity evidence for issue #2069. -Artifact paths such as `agents/{id}/charter.md` are Squad-root-relative; -repository-local installations render them beneath `.squad/`. Implementation -links instead point to repository source, templates, and tests. +Paths in this table are Squad-root-relative. Repository-local installations +render them beneath `.squad/`. -This is a working inventory, not a complete specification of Squad composition. -The current suite focuses on artifacts and behavior within one Squad, with -separate observations of existing composition surfaces below. SubSquads, -upstream inheritance, peer discovery, and cross-Squad delegation have different -semantics; none acquires a portable contract from being listed here. - -| Capability | Implementation / artifacts | Spec status | Spec coverage | What's missing | +| Surface | Repository evidence | Disposition | Specification | Maturity or promotion rationale | |---|---|---|---|---| -| Discovery, negotiation, authority identity, shared evidence, secrets | Core/profile metadata, registry IDs, events, audit records | Requirements draft with a reference schema; no claim | Interoperability Conventions v0.1 | Discovery, relational invariants, and negotiation need portable manifests and cases | +| Discovery, negotiation, authority identity, shared evidence, secrets | Core/profile metadata, registry IDs, events, audit records | Experimental shared schema; no claim | Interoperability Conventions v0.1 | Evidence schema exists; discovery and negotiation need a manifest | | Core rules | profile identifiers, paths, diagnostics, trust boundary | Experimental normative working draft; no standalone claim | Core v0.1 | Charter depends on and exercises Core rules, but Core has no standalone manifest | -| Agent charter | `agents/{id}/charter.md`; [compiler](../../packages/squad-sdk/src/agents/charter-compiler.ts), [validator](../../packages/squad-sdk/src/agents/charter-validator.ts), [fixture manifest](../../test-fixtures/spec/charter-v0.1/manifest.json) | Executable normative working draft | [Charter v0.1](charter-v0.1.md) | Stable parser and round-trip evidence; publication remains `working-draft` | -| Team roster | `team.md`; [initialization and roster generation](../../packages/squad-sdk/src/config/init.ts) | Requirements draft; no claim | Team and Routing v0.1 | Promote independently with roster parsing, mutation, join, and diagnostic cases | +| Agent charter | `agents/{id}/charter.md`, compiler, validator, fixtures | Executable normative working draft | Charter v0.1 | Stable parser and round-trip evidence; publication remains `working-draft` | +| Team roster | `team.md`, roster templates, presets | Requirements draft; no claim | Team and Routing v0.1 | Promote independently with roster parsing, mutation, join, and diagnostic cases | | Routing ownership | `routing.md`, routing parser, coordinator tests | Requirements draft; no claim | Team and Routing v0.1 | Promote independently with priority, fallback, ambiguity, stale-write, and resolution cases | | Runtime configuration | `config.json`, config schema and loaders | Requirements draft; no claim | Configuration and Casting v0.1 | Promote independently with precedence and source-evidence cases | | Casting identity | `casting/registry.json`, `policy.json` | Requirements draft; no claim | Configuration and Casting v0.1 | Promote independently with identity, recast, join, and migration cases | | Casting provenance | `casting/history.json` | Requirements draft; no claim | Configuration and Casting v0.1 | Promote independently with append-only history, revision, replay, and CAS cases | -| Governance and review | `governance.md`, reviewer protocol, PR requirements | Requirements draft with reference checks; no claim | Governance and Review v0.1 | Add language-neutral relational invariants and a complete transition manifest | +| Governance and review | `governance.md`, reviewer protocol, PR requirements | Experimental evidence schema; no claim | Governance and Review v0.1 | Add complete transition manifest beyond the representative rejection fixture | | Execution preferences | charters, config, model selector, lifecycle manager | Requirements draft; no claim | Execution Preferences v0.1 | Add provider-neutral resolution cases and loss reporting | | PRD intake and planning | `prd-intake.md`, planning ontology, gh-aw workflows | Requirements draft; no claim | Planning and Activation v0.1 | Add portable planning-record and activation manifest | -| Ceremonies | [declaration template](../../.squad-templates/ceremonies.md), [ceremony reference](../../.squad-templates/ceremony-reference.md), [SDK builder](../../packages/squad-sdk/src/builders/index.ts), [retro workflow](../../workflows/squad-retro.md) | Requirements draft; no claim | [Ceremony v0.1](ceremony-v0.1.md) | Map definition/agenda/hooks separately from shared scheduling and execution; prove cooldown with injected clocks | +| Ceremonies | `ceremonies.md`, ceremony reference, retro workflow | Requirements draft; no claim | Ceremony v0.1 | Normalize event vocabulary and prove cooldown with injected clocks | | Work lifecycle | issue lifecycle template, platform interfaces, worktree tests | Requirements draft; no claim | Work Lifecycle v0.1 | Run complete transition traces against two providers | | GitHub lifecycle | GitHub adapter, gh-aw workflows, issue/PR templates | Requirements binding draft; no claim | GitHub Work Lifecycle Binding v0.1 | Add deterministic sanitized provider transcripts | | Handoffs and sessions | spawn reference, lifecycle manager, event buses | Requirements draft; no claim | Coordination and Handoff v0.1 | Add portable envelope, ordering, expiry, and recovery cases | | State backends | state backend interfaces, worktree/orphan/two-layer implementations | Informative requirements now | State and Memory Requirements v0.1 | Authority and recovery vary by backend; promotion requires backend-neutral CAS fixtures | | Memory governance | memory classes, providers, audit records | Informative requirements now | State and Memory Requirements v0.1 | Classification is promising but provider naming and durable authority are not stable | -| Automation | `schedule.json`; [scheduler and provider interfaces](../../packages/squad-sdk/src/runtime/scheduler.ts), [schedule CLI](../../packages/squad-cli/src/cli/commands/schedule.ts) | Requirements draft; no claim | [Automation v0.1](automation-v0.1.md) | Add portable trigger, clock, retry, drift, and result cases | -| Portable export/import | [`squad export`](../../packages/squad-cli/src/cli/commands/export.ts), [`squad import`](../../packages/squad-cli/src/cli/commands/import.ts) | Informative observed surface; no claim | `squad-portable-bundle/v1.0` coverage below | Tested implementation exists without a published schema or conformance manifest | -| Preset/template instantiation | [preset SDK](../../packages/squad-sdk/src/presets/index.ts), [preset CLI](../../packages/squad-cli/src/cli/commands/preset.ts), [initialization](../../packages/squad-sdk/src/config/init.ts) | Informative observed surface; no claim | No dedicated profile | Presets apply reusable agent charters; they are not full export/import snapshots. Definition versioning, initialization defaults, and conflict behavior need separate coverage | -| SubSquads / workstreams | [partition types](../../packages/squad-sdk/src/streams/types.ts), [resolver](../../packages/squad-sdk/src/streams/resolver.ts), [CLI](../../packages/squad-cli/src/cli/commands/streams.ts) | Informative observed surface; no claim | No dedicated profile | Scoped team partitions are distinct from independent peer Squads. Config path aliases, activation precedence, issue/folder scope, and isolation need portable cases | -| Upstream inheritance | [upstream types](../../packages/squad-sdk/src/upstream/types.ts), [resolver](../../packages/squad-sdk/src/upstream/resolver.ts), [CLI](../../packages/squad-cli/src/cli/commands/upstream.ts) | Informative observed surface; no claim | No dedicated profile | Context inheritance is distinct from discovery-only registration. Source revisions, precedence, trust, and synchronization need separate contracts | -| Peer Squad registry and discovery | `squad-registry.json`, `manifest.json`; [SDK](../../packages/squad-sdk/src/runtime/cross-squad.ts), [CLI](../../packages/squad-cli/src/cli/commands/cross-squad.ts) | Informative observed surface; no claim | No dedicated profile; not the specification discovery model | Registry entries identify peers without inheriting context. Manifest identity, stale discovery, and capability negotiation need portable cases | -| Cross-Squad communication and delegation | [discovery/delegation SDK](../../packages/squad-sdk/src/runtime/cross-squad.ts), [`squad delegate`](../../packages/squad-cli/src/cli/commands/cross-squad.ts) | Informative observed surface; no claim | Coordination and provider drafts are related, not a cross-Squad contract | GitHub issue-based work requests exist; delivery, acceptance, completion correlation, and delegation authority are not specified here | -| Creation/spawning of another Squad | [initialization API](../../packages/squad-sdk/src/config/init.ts), [preset application](../../packages/squad-sdk/src/presets/index.ts) | Scope gap; no claim | No dedicated profile | Local initialization is not evidence of autonomous cross-Squad spawning. Ownership, resource provisioning, isolation, and lifecycle require independent design and evidence | +| Automation | schedule manifest, scheduler, local and GitHub providers | Requirements draft; no claim | Automation v0.1 | Add portable trigger, clock, retry, drift, and result cases | +| Portable export/import | `squad export`, `squad import`, CLI round-trip tests | Informative observed surface; no claim | `squad-portable-bundle/v1.0` coverage below | Tested implementation exists without a published schema or conformance manifest | | Skills | `skills/{name}/SKILL.md`, loader and discovery | Deferred | Future Skills Profile | Promote after metadata precedence, imports, script trust, and compatibility fixtures stabilize | | Coordinator prompts and UI wording | templates, generated agent prompt, shell | Informative guidance | Implementation documentation | Prompts and wording are implementation inputs, not interoperability contracts | | Telemetry and cost accounting | OpenTelemetry and cost modules | Deferred | Future Observability Binding | Promote after common event identity and privacy/redaction contracts stabilize | @@ -94,9 +81,8 @@ is equally mature. The next promotion sequence is: **Recorded:** 2026-09-25 - Current `.squad/governance.md` permits accountable-owner revision after a - rejection; the reviewer protocol and Governance requirements draft propose - independent revision-author lockout. That relationship is not yet portable - conformance behavior. + rejection; the reviewer protocol and Governance Profile require independent + revision-author lockout. - Current model selectors contain vendor names and fallback lists. Those are implementation policy and do not conform as portable preference vocabulary. - Current ceremony conditions are natural language. They are compatible input, diff --git a/docs/specification/ceremony-v0.1.md b/docs/specification/ceremony-v0.1.md index 4e52a0fb1..02da77714 100644 --- a/docs/specification/ceremony-v0.1.md +++ b/docs/specification/ceremony-v0.1.md @@ -29,12 +29,6 @@ participants, decisions, actions, and completion records. It does not standardize meeting culture, agenda prose, facilitation prompts, timekeeping style, consensus, or synchronous human attendance. -This requirements draft is not a complete specification of today's ceremony -extensibility surface. It separates three concerns: a definition describes what -happens; shared scheduling describes when/how a trigger is delivered; an -execution record describes one run. The proposed execution lifecycle below is -not evidence that all of it is implemented. - ## 2. Declaration model A ceremony declares ID, enabled state, trigger mode, timing, condition @@ -50,44 +44,6 @@ Participant selectors are `accountable-owner`, `all-involved`, `all-relevant`, or explicit member IDs. Resolution must produce the exact participant set used. -### 2.1 Definition and current implementation mapping - -A definition also preserves its agenda/process, participant roles, inputs, -expected outputs, conditions, and extension hooks without standardizing their -prose or silently executing hooks. The -[SDK `defineCeremony()` builder](../../packages/squad-sdk/src/builders/index.ts) -accepts name, trigger, schedule, participants, agenda, and hooks. The -[configuration shape](../../packages/squad-sdk/src/config/schema.ts) and -[Markdown template](../../.squad-templates/ceremonies.md) are distinct existing -representations; the template includes agenda, timing, facilitator, conditions, -and an enforcement skill. The -[ceremony reference](../../.squad-templates/ceremony-reference.md) supplies -implementation guidance. - -These are observations, not a claim of lossless conversion between those -shapes. Hook invocation, process ordering, required input/output schemas, -plugin/template merge behavior, and a complete mapping of existing fields -remain promotion work. - -### 2.2 Shared trigger and scheduling boundary - -The [Automation requirements draft](automation-v0.1.md#2-manifest-model) owns -the proposed common `schedule.json` model, including cron, interval, event, and -startup triggers. Its [time semantics](automation-v0.1.md#4-trigger-and-time-semantics) -are shared scheduling concerns, not ceremony-specific definitions. The -[scheduler](../../packages/squad-sdk/src/runtime/scheduler.ts) and -[schedule CLI](../../packages/squad-cli/src/cli/commands/schedule.ts) implement -the current common scheduling surface. - -A future binding should reference a ceremony definition from a scheduled task -and supply trigger evidence to its executor, rather than define another cron -or retry engine here. The ceremony `manual`/`schedule`/`event:` vocabulary -above is a proposed declaration vocabulary, not a second `schedule.json` -schema. Mapping builder schedule text and Markdown timing/conditions to common -schedules, including non-cron triggers, is not yet specified or proven. -Eligibility evaluation and delivery belong to scheduling; ceremony-specific -cooldown, facilitation, and outcomes belong to execution. - ## 3. Execution record Every run records ceremony ID, run ID, trigger evidence, input revisions, @@ -111,21 +67,6 @@ Candidate operations are `ceremony.consume`, `ceremony.evaluate`, run; participants provide inputs; the recorder persists outcomes; the coordinator enforces claim and cooldown. -These names are non-contract placeholders for future profile work, not current -SDK entry points, claimable capabilities, or a normative interoperable API. -The intended responsibilities are: - -| Placeholder | Intended caller and input | Intended result | -|---|---|---| -| `ceremony.consume` | A loader reads a declaration and its format/version context | Parsed definition or diagnostics | -| `ceremony.evaluate` | A coordinator supplies a definition, trigger evidence, current inputs, and run history | Eligibility/suppression decision with reasons | -| `ceremony.facilitate` | An authorized executor supplies a claimed run, resolved participants, and definition | Proposed decisions/actions or an explicit failure | -| `ceremony.record` | A recorder receives execution outcomes and expected state revision | Persisted run/outcome references or a conflict | - -Exact types, invocation order, authorization, failure taxonomy, and atomicity -are deliberately unresolved. These descriptions do not authorize invocation -or establish an operation contract. - ## 6. Canonical and compatible behavior Canonical declarations use deterministic trigger references and participant @@ -151,9 +92,3 @@ Publish a manifest covering normalized manual, named-event, schedule, and legacy-`auto` inputs; selector resolution; injected-clock cooldown suppression; advisory and blocking failures; duplicate claims; stale inputs; and decision/action output validation. - -Before promotion, map the SDK builder, configuration, Markdown templates, and -plugin hooks to a complete definition model; bind shared Automation scheduling -without duplicating it; and replace or remove the placeholders in §5 with -versioned input/output, caller, authorization, and failure contracts. Definition -round trips must preserve agenda and hook metadata without executing it. diff --git a/docs/specification/charter-v0.1.md b/docs/specification/charter-v0.1.md index 391788d01..12be9b028 100644 --- a/docs/specification/charter-v0.1.md +++ b/docs/specification/charter-v0.1.md @@ -56,8 +56,6 @@ by `.squad/agents/{id}/charter.md` provide no profile path context. RFC 8174, defines requirement-keyword interpretation. - [CommonMark 0.31.2](https://spec.commonmark.org/0.31.2/) defines the Markdown terms used by the deterministic subset below. -- [RFC 8785](https://www.rfc-editor.org/rfc/rfc8785) defines the JSON - Canonicalization Scheme used for the evidence revision digest. ## 3. Association and unsupported versions @@ -299,8 +297,8 @@ The language-neutral field contract is [`manifest.schema.json`](../../test-fixtures/spec/charter-v0.1/manifest.schema.json). Every manifest contains: -- `$schema`, `schemaVersion`, `profile`, an immutable `revision` binding, the - complete `capabilities` list, and uniquely identified `cases`; +- `$schema`, `schemaVersion`, `profile`, the complete `capabilities` list, and + uniquely identified `cases`; - per-case `capabilities`, exactly one input source (`fixture` or `text`), nullable path/profile inputs, and the portable conformance API; - an always-present validation expectation with classification, capability @@ -318,56 +316,8 @@ Harnesses MUST validate the manifest against `manifest.schema.json` before executing cases. A manifest that fails schema validation is not a conformance suite and MUST NOT be partially executed. -### 12.1 Immutable claim revision - -A Charter conformance claim MUST copy the manifest's `revision.specification` -and `revision.evidence` objects without modification. Both revisions use -SHA-256 and lowercase hexadecimal digests prefixed by `sha256:`. - -The specification digest is computed over the exact UTF-8 bytes of -`docs/specification/charter-v0.1.md`. No line-ending normalization, Unicode -normalization, or whitespace transformation is applied. - -The evidence digest is computed over the manifest after removing only the -top-level `revision` member. The remaining JSON value is canonicalized using -the JSON Canonicalization Scheme in RFC 8785, identified in the manifest as -`rfc8785`. The SHA-256 digest is computed over the resulting UTF-8 bytes. - -The manifest schema permits only integers, strings, booleans, nulls, arrays, -and objects in the evidence projection. It does not permit non-integer numbers, -so the RFC 8785 number-serialization rules have one unambiguous integer input -domain here. Harnesses MUST recompute both digests before executing cases and -MUST reject a manifest when either digest disagrees. A branch name, pull -request number, publication revision, or other mutable reference is not a -substitute for either digest. - ## 13. Informative TypeScript implementation details -### 13.1 Portable charter and execution binding boundary - -The smaller portable shape is intentional: it identifies the agent and its -guidance/preferences, not every product configuration field. As specified in -[§3](#3-association-and-unsupported-versions), a copied charter still needs its -profile selection supplied by an API or accompanying manifest; it is not -self-describing. Adding an embedded version marker is a future profile-design -question, not a v0.1 requirement. - -An authenticated execution principal (for example, a managed or service -identity) belongs to trusted runtime configuration and provider binding, not -the charter's display or path identity. Tool requests likewise belong to -runtime/configuration integration; the runtime independently determines tool -availability and grants under [§11](#11-runtime-validation-and-security). -For example, the [current agent configuration](../../packages/squad-sdk/src/config/schema.ts) -has tool declarations; these declarations are not authorization. - -The suite does not yet define a portable principal-binding or tool-declaration -schema. [Interop authority requirements](interoperability-conventions-v0.1.md#3-stable-identity-and-authority) -are related design work, not an implemented execution binding. A namespaced -charter extension can preserve implementation metadata but cannot establish -authenticated identity or grant access. - -### 13.2 Reference API example - The exported TypeScript names, module paths, and example below are informative. They do not constrain implementations in other languages. diff --git a/docs/specification/client-backend-decoupling-v0.2.md b/docs/specification/client-backend-decoupling-v0.2.md new file mode 100644 index 000000000..375f81015 --- /dev/null +++ b/docs/specification/client-backend-decoupling-v0.2.md @@ -0,0 +1,487 @@ +# Squad Client and Backend Decoupling v0.2 + +**Status:** Working Draft + +**Document class:** Non-normative requirements draft + +**Profile identifier:** `squad-client-backend-decoupling/v0.2` + +**Maturity:** Requirements draft; non-claimable + +**Claimable capabilities:** None + +**Implementation status:** Squad already exposes local session APIs, the +`squad_state` MCP bridge, Copilot ACP sessions, and external durable runtimes, +but they do not yet share one portable conversation contract. + +**Known deviations:** Conversation admission, approval replay protection, task +leases, and transcript pagination are implemented differently across local, +MCP, and remote runtimes. + +**Depends on:** `squad-core/v0.1`, `squad-interop/v0.1`, +`squad-coordination-handoff/v0.1`, +`squad-state-memory-requirements/v0.1` + +## 1. Scope and non-goals + +This profile separates Squad's interactive surface from its broker and +execution runtimes. It defines the three-plane split, the portable +conversation resources, and the TypeScript contracts that let one client swap +between local, MCP, and remote durable backends without rewriting planner or +agent logic. + +It does not standardize LLM prompts, UI layout, GitHub-specific session tools, +token minting, transport codecs, storage engines, or the internal scheduler of +any execution fabric. + +This split follows the Q and Bubu convergence rule from the Squad 2.0 kickoff: +host adapters stop at host capabilities and transport translation. Conversation +semantics, ordering, idempotency, routing, leases, cancellation, durable +artifacts, checkpoints, observability joins, and authorization boundaries +belong to the shared backend contract rather than to any individual host. + +## 2. Three-plane architecture + +Squad 2.0 uses three cooperating planes: + +| Plane | Responsibility | Typical examples | +|---|---|---| +| Client Plane | gathers user intent, renders transcript state, sends approvals, and hosts local UX affordances | Copilot CLI, Copilot App, VS Code, Claude Code, Cursor, Kiro, OpenCode | +| Control and Broker Plane | owns conversation identity, admission, sequencing, transcript projection, task routing, approval digests, and lease fencing | in-process broker, `squad_state` MCP bridge, DurableSquad coordinator, DTS orchestration service | +| Execution Plane | runs agent turns, tools, background work, and durable task continuations | local Copilot session, spawned subagent session, MCP worker, durable task host | + +The planes are intentionally asymmetrical. Clients may change faster than the +broker, and execution fabrics may change faster than either, but the portable +conversation model remains stable at the control boundary. + +The control and broker plane is therefore the semantic center of gravity. It +owns the durable ordered event log, participant and membership rules, routing, +lease state, resumability, artifact semantics, canonical errors, and the +authorization boundary between host affordances and execution rights. + +## 3. Conversation resource model + +The broker surface is organized around one conversation resource and a small +set of stable subordinate views. + +| Resource | Purpose | Canonical shape | +|---|---|---| +| `/v1/conversations` | create or enumerate portable conversation identities | conversation descriptor | +| `/v1/capabilities` | report negotiated capabilities for one backend and one client projection | capability envelope | +| `/v1/conversations/{id}/messages` | submit user, system, approval, or tool messages for admission | `BackendMessage` plus admission metadata | +| `/v1/conversations/{id}/transcript` | read the materialized transcript in broker order | transcript snapshot | +| `/v1/conversations/{id}/turns/{seq}` | read one broker turn with its run and task state | `BackendTurn` | +| `/v1/conversations/{id}/runs/{runId}/cancel` | request lifecycle cancellation by stable run identity | cancellation request | +| `/v1/runs/{runId}/cancel` | optional global cancellation alias for the same lifecycle target | cancellation request | +| `/v1/conversations/{id}/approvals/{approvalId}` | resolve one hash-bound approval request | approval request and response envelope | + +These paths are a resource vocabulary, not an HTTP requirement. A filesystem +broker, MCP tool surface, or durable task API may project the same resources +through different transports as long as the identifiers, fields, and replay +rules stay stable. + +The canonical wire contract is the conversation resource model above. MCP may +carry broker operations for local convenience, but MCP itself is not the +portable conversation protocol. + +## 4. Portable TypeScript contracts + +The broker contracts are designed to live in one SDK-facing module and remain +independent from any host-specific tool schema. + +```ts +export type BackendRunState = + | 'queued' + | 'admitted' + | 'running' + | 'waiting-input' + | 'waiting-approval' + | 'completed' + | 'failed' + | 'cancelled' + | 'expired'; + +export type BackendTaskState = + | 'pending' + | 'leased' + | 'running' + | 'succeeded' + | 'failed' + | 'cancelled' + | 'expired'; + +export interface BackendLease { + leaseId: string; + holderId: string; + epoch: number; + acquiredAt: string; + expiresAt: string; +} + +export interface BackendMessage { + messageId: string; + conversationId: string; + kind: + | 'user' + | 'assistant' + | 'system' + | 'tool-call' + | 'tool-result' + | 'approval-request' + | 'approval-response'; + role: 'user' | 'assistant' | 'system' | 'tool'; + createdAt: string; + runId?: string; + turnSeq?: number; + taskId?: string; + idempotencyKey?: string; + causationId?: string; + correlationId?: string; + payloadHash: string; + body: Record; +} + +export interface BackendAdmission { + conversationId: string; + messageId: string; + runId: string; + state: BackendRunState; + admittedAt: string; + expectedTurnSeq: number; + lease?: BackendLease; + replayed: boolean; +} + +export interface BackendApprovalRequest { + approvalId: string; + version: number; + conversationId: string; + runId: string; + turnSeq: number; + scope: 'conversation' | 'turn' | 'task' | 'artifact'; + requestedAt: string; + expiresAt: string; + status: 'pending' | 'approved' | 'rejected' | 'cancelled' | 'expired'; + requestHash: string; + payloadHash: string; + prompt: string; + options?: string[]; +} + +export interface BackendTurn { + conversationId: string; + runId: string; + turnSeq: number; + state: BackendRunState; + startedAt: string; + completedAt?: string; + inputMessageIds: string[]; + outputMessageIds: string[]; + taskStates: Array<{ + taskId: string; + state: BackendTaskState; + executorId: string; + epoch: number; + }>; + approval?: BackendApprovalRequest; + transcriptHash: string; +} + +export interface ConversationDescriptor { + conversationId: string; + backendKind: 'local' | 'mcp' | 'remote'; + title?: string; + createdAt: string; + updatedAt: string; + currentRunState: BackendRunState; + currentTurnSeq: number; +} + +export interface ConversationTranscript { + conversation: ConversationDescriptor; + messages: BackendMessage[]; + turns: BackendTurn[]; + nextCursor?: string; + lastAcceptedSeq: number; + resumable: boolean; +} + +export interface CapabilityEnvelope { + clientCapabilities: string[]; + backendCapabilities: string[]; + policyCapabilities: string[]; + effectiveCapabilities: string[]; +} + +export interface ApprovalResponseInput { + approvalId: string; + requestHash: string; + decision: 'approved' | 'rejected' | 'cancelled'; + responseText?: string; + leaseId: string; + epoch: number; +} + +export interface IConversationBackend { + createConversation(input?: { title?: string; metadata?: Record }): + Promise; + getConversation(conversationId: string): Promise; + admitMessage( + conversationId: string, + message: Omit, + ): Promise; + getTranscript( + conversationId: string, + options?: { afterTurnSeq?: number; cursor?: string; limit?: number }, + ): Promise; + getCapabilities(conversationId?: string): Promise; + getTurn(conversationId: string, turnSeq: number): Promise; + subscribe( + conversationId: string, + listener: (event: BackendMessage | BackendTurn) => void | Promise, + ): Promise<() => Promise>; + cancelRun(conversationId: string, runId: string, reason?: string): Promise; + respondApproval( + conversationId: string, + response: ApprovalResponseInput, + ): Promise; + describe(): Promise<{ + backendKind: 'local' | 'mcp' | 'remote'; + supportsLeases: boolean; + supportsApprovals: boolean; + supportsPaging: boolean; + }>; +} + +export interface SquadBackend extends IConversationBackend { + listTasks(runId: string): Promise>; + reportTaskState(taskId: string, state: BackendTaskState, detail?: Record): + Promise; +} +``` + +`IConversationBackend` is the client-facing minimum surface. `SquadBackend` +extends it with the broker-owned lease and task operations needed by durable +or multi-worker control planes. + +The shared backend contract is also where versioned commands and events are +defined. Hosts translate into and out of this contract, but they do not fork +its meaning. + +Leases and checkpoints remain internal to the broker and execution planes. The +client-facing contract exposes only opaque resume hints such as `nextCursor`, +`lastAcceptedSeq`, and `resumable`. + +## 5. Protocol invariants and stream boundaries + +The portable backend contract carries a small set of fixed invariants: + +- commands and events are versioned independently from host transports; +- conversation, run, task, approval, artifact, and cursor identities are + opaque tokens; +- delivery is at-least-once, so commands are idempotent and replay-safe; +- every conversation exposes one monotonic broker sequence for admitted turns; +- capability negotiation happens at the broker boundary, not inside host UI + adapters; +- content parts are typed so transcripts can carry text, tool calls, tool + results, approvals, and artifact references without host-specific parsing; +- control-plane events and execution-plane events remain distinguishable even + when one transport carries both. + +Capability negotiation is modeled as an intersection: + +- `clientCapabilities` are declared by the host adapter; +- `backendCapabilities` are supported by the server; +- `policyCapabilities` are authorized by repository or operator policy; +- `effectiveCapabilities` are the intersection of those three sets. + +The control stream covers admissions, approvals, routing, leases, cancellation, +membership changes, and transcript projection. The execution stream covers task +claims, worker progress, tool execution, produced artifacts, checkpoints, and +observability records. One backend may multiplex both streams, but consumers +must still be able to tell which boundary an event belongs to. + +Operational health is not part of this negotiated contract. `/health/live` and +`/health/ready` are unversioned operational endpoints outside +`/v1/conversations` and bypass client projection. + +## 6. Admission and transcript semantics + +The broker owns message ordering. Clients may stage or buffer UI state +locally, but every durable transcript view is rebuilt from admitted broker +messages and turns. + +Admission has three steps: + +1. client submits a `BackendMessage` with a stable idempotency key; +2. broker returns `BackendAdmission` with the accepted run identity and next + expected turn sequence; +3. execution publishes `BackendTurn` and resulting output messages back + through the same conversation resource. + +Repeated submission of the same idempotency key returns the original admission. +Reusing the key with a different payload hash is a conflict. + +`runId` is exposed independently from turn sequence. `turnSeq` remains the +ordering and causation coordinate, while lifecycle cancellation and resumable +state target `runId`. Host completion signals are evidence for projection, not +the canonical lifecycle state. + +## 7. Run and task state machine + +Broker-level run state and execution-level task state are related but not +identical. + +```text +run: queued -> admitted -> running -> waiting-input|waiting-approval + -> completed|failed|cancelled|expired + +task: pending -> leased -> running -> succeeded|failed|cancelled|expired +``` + +The broker turn is the join point. A run remains `running` while one or more +leased tasks continue in the execution plane. A run becomes +`waiting-approval` only when the broker has emitted a `BackendApprovalRequest` +and no successor admission has yet been recorded. + +Lifecycle cancellation targets `runId` through a run-scoped cancel resource. +The backend may expose either the conversation-relative or global alias, but +both name the same stable run resource. + +## 8. Hash-bound approvals + +Approvals are durable versioned resources, not ordinary transcript messages or +ephemeral prompts. Approval replay safety is owned by the broker. Every +approval request carries: + +- the canonical `requestHash` of the broker-emitted approval payload; +- an explicit `version`, `scope`, `status`, `runId`, and immutable + `payloadHash`; +- an expiry timestamp; +- the conversation, run, and turn identities. + +`respondApproval` succeeds only when the client echoes the same +`approvalId`, `requestHash`, and current approval `version`. If any of those +values are stale, the broker rejects the response and requires the client to +re-read the approval resource or conversation transcript before resubmitting. + +## 9. Lease and epoch fencing + +Leases fence concurrent writers in the control plane and execution plane. +Clients do not manipulate those leases directly. They only observe whether the +backend can resume from a cursor and which sequence was last durably accepted. + +When the broker rotates an internal lease epoch, stale worker writes are +rejected instead of becoming last-writer-wins updates. This keeps a +disconnected worker from overwriting a newer broker decision after recovery +without leaking internal lease handles into client contracts. + +## 10. Backend plug-in shapes + +The same contract is projected through three backend families. + +### 10.1 Local backend + +The local backend runs broker and execution logic inside one process tree. It +typically stores transcript state in memory plus repository-local state +artifacts. This is the fastest development path and the reference surface for +single-user sessions. + +### 10.2 MCP broker backend + +The MCP backend projects the control plane through the `squad_state` server. +Conversation material may still live in local files or broker storage, but the +portable boundary is the MCP tool contract rather than direct file mutation. +This is the bridge used when a host can load `.mcp.json` but cannot link the +SDK directly. + +MCP is treated here as an execution and tool transport, or as a local CLI +convenience bridge. It is behind the shared conversation contract, not a +replacement for it. + +### 10.3 Remote durable backend + +The remote backend maps the same conversation contract onto DurableSquad, +Durable Task Scheduler, or another remote orchestration fabric. Durable +workers own retries and checkpoints, but they still expose the same +conversation descriptors, admissions, turns, task states, approval hashes, and +lease epochs back to the client plane. + +## 11. Protocol versioning and compatibility policy + +The frozen baseline for this draft is `squad-protocol-v2.0-rc1`. + +Breaking semantic changes to conversation identity, cancellation targeting, +approval resource behavior, capability intersection, cursor recovery, or the +golden trace require a protocol-major bump beyond this baseline. + +Additive evolution uses explicit negotiation and loss-tolerant projection: + +- new capabilities appear through `/v1/capabilities`; +- additive event kinds or `x-*` extension fields are preserved even when a + projection does not interpret them; +- adapters and clients continue forwarding unknown durable events instead of + collapsing the transcript or crashing the projection layer. + +Any future adapter or backend compatibility claim is gated by `G1` through +`G9`: + +| Gate | Compatibility proof | +|---|---| +| `G1` | stable `runId` exists independently from turn ordering | +| `G2` | turn ordering remains monotonic by `seq` within one conversation | +| `G3` | approvals are durable versioned resources with immutable payload hashes | +| `G4` | effective capabilities are negotiated as `client ∩ backend ∩ policy` | +| `G5` | recovery is cursor-based and exposes `lastAcceptedSeq` plus `resumable` | +| `G6` | cancellation targets `runId`, not host-local completion state | +| `G7` | operational health endpoints stay outside the protocol resource tree | +| `G8` | projections are loss-tolerant for unknown durable events and extensions | +| `G9` | the immutable golden fixtures in `test-fixtures/spec/suite-v0.2/` pass unchanged | + +The architectural rationale for those gates is recorded in +[ADR 001: Client, Control, and Execution Boundary](adr-001-client-control-execution-boundary.md). + +## 12. Golden conformance trace + +The reference golden trace for this draft is: + +```text +TurnAccepted(runId, seq) +-> ApprovalRequested(v1) +-> disconnect +-> reconnect(cursor) +-> ApprovalDecided(v2) +-> CancellationRequested(runId) +-> terminal state +``` + +This trace captures the core invariants of the split: stable `runId` +cancellation, monotonic `seq` ordering, durable approval versioning, resumable +cursor-based recovery, and a terminal state chosen by the broker rather than +by host-local projection. + +## 13. Relationship to coordination and state authority + +`squad-coordination-handoff/v0.1` defines message and causation envelopes +between coordinators and workers. This profile narrows that envelope into a +concrete conversation API with stable turn sequencing and transcript views. + +`squad-state-memory-requirements/v0.1` explains why one broker must remain the +authority for mutable state. This profile applies that rule specifically to +conversation transcripts, turn records, leases, and approvals. + +## 14. Promotion criteria + +Promote this draft only after the suite has: + +1. deterministic positive and negative fixtures for message admission, + transcript paging, duplicate idempotency keys, and conflicting payload + hashes; +2. lease and epoch traces that show stale worker writes being rejected after + broker failover; +3. approval fixtures covering expired, replayed, and digest-mismatched + responses; +4. one local backend, one MCP backend, and one remote durable backend passing + the same contract tests; +5. sanitized transcript evidence that demonstrates the three-plane split + without leaking tokens, tool secrets, or private prompt content. diff --git a/docs/specification/client-harness-adapters-v0.2.md b/docs/specification/client-harness-adapters-v0.2.md new file mode 100644 index 000000000..563832101 --- /dev/null +++ b/docs/specification/client-harness-adapters-v0.2.md @@ -0,0 +1,367 @@ +# Squad Client and Harness Adapter Profile v0.2 + +**Status:** Working Draft + +**Document class:** Non-normative requirements draft + +**Profile identifier:** `squad-client-harness-adapters/v0.2` + +**Maturity:** Requirements draft; non-claimable + +**Claimable capabilities:** None + +**Implementation status:** GitHub Copilot projections exist today, and the +repository already carries portable agent, rules, and MCP artifacts, but the +host-specific adapter seam is not yet codified as one SDK surface. + +**Known deviations:** Host discovery, approvals, session spawning, and MCP +config projection vary across Copilot, Claude Code, Kiro, OpenCode, and +Cursor. + +**Depends on:** `squad-core/v0.1`, `squad-interop/v0.1`, +`squad-client-backend-decoupling/v0.2` + +## 1. Scope and non-goals + +This profile defines the host-facing service provider interface that bridges +Squad's portable client plane to concrete agent hosts and execution harnesses. +It covers the `ClientAdapter` and `HarnessAdapter` contracts, the workspace +projection they expose, and the authoritative file paths each supported host +uses for agent and MCP wiring. + +It does not standardize the internal tool catalog of any host, the visual +presentation of approvals, extension packaging, or provider-specific pricing +and authentication. + +This profile follows the same Q and Bubu convergence rule: adapters own native +host bootstrap, transport mechanics, event parsing, tool registration, UI +rendering, and translation to canonical envelopes. They do not own +conversation semantics, routing, durable state, authorization policy, or the +canonical command and event vocabulary. + +## 2. Adapter roles + +Two related adapters project the same portable conversation model: + +| Adapter | Plane boundary | Responsibility | +|---|---|---| +| `ClientAdapter` | client plane to broker plane | translates host turns, approvals, and transcript subscriptions into backend calls | +| `HarnessAdapter` | broker plane to execution plane | translates portable run and task operations into the host's agent execution primitive | + +The client adapter is closest to the end-user surface. The harness adapter is +closest to spawned work, subagents, sessions, or durable workers. Some hosts +can implement both in one package, but the interfaces remain separate so local +UI behavior does not leak into execution orchestration. + +The client adapter therefore ends at the host capability boundary: auth, +session bootstrap, user-visible event rendering, approval affordances, and +subscription plumbing. The harness adapter ends at the execution capability +boundary: task launch, cancellation, tool invocation mechanics, and local +lifecycle management. Everything beyond those boundaries is expressed in the +shared backend contract from `squad-client-backend-decoupling/v0.2`. + +## 3. Portable TypeScript SPI + +```ts +export interface AdapterDescription { + adapterId: string; + hostKind: + | 'github-copilot' + | 'claude-code' + | 'kiro' + | 'opencode' + | 'cursor'; + hostVariant?: string; + displayName: string; + capabilities: { + approvals: boolean; + backgroundRuns: boolean; + mcpConfig: boolean; + subagents: boolean; + }; + projectedFiles: string[]; +} + +export interface AdapterCapabilityProjection { + clientCapabilities: string[]; + backendCapabilities: string[]; + policyCapabilities: string[]; + effectiveCapabilities: string[]; +} + +export interface ProjectWorkspaceProjection { + repoRoot: string; + workingDirectory: string; + workspaceKind: 'repository' | 'folder' | 'multi-root' | 'cloud'; + branch?: string; + squadAgentPath: string; + mcpConfigPath?: string; + supportFiles: string[]; +} + +export interface AdapterSubmission { + conversationId: string; + prompt: string; + metadata?: Record; +} + +export interface AdapterSubscription { + conversationId: string; + cursor?: string; +} + +export interface ApprovalResponse { + approvalId: string; + requestHash: string; + decision: 'approved' | 'rejected' | 'cancelled'; + responseText?: string; +} + +export interface ClientAdapter { + submit(input: AdapterSubmission): Promise<{ admissionId: string; runId: string }>; + subscribe( + input: AdapterSubscription, + onEvent: (event: Record) => void | Promise, + ): Promise<() => Promise>; + cancel(conversationId: string, runId: string, reason?: string): Promise; + respondApproval(conversationId: string, response: ApprovalResponse): Promise; + getCapabilities(conversationId?: string): Promise; + describe(): Promise; + projectWorkspace(): Promise; +} + +export interface HarnessAdapter { + submit(input: AdapterSubmission): Promise<{ taskId: string; runId: string }>; + subscribe( + input: AdapterSubscription, + onEvent: (event: Record) => void | Promise, + ): Promise<() => Promise>; + cancel(conversationId: string, runId: string, reason?: string): Promise; + respondApproval(conversationId: string, response: ApprovalResponse): Promise; + getCapabilities(conversationId?: string): Promise; + describe(): Promise; + projectWorkspace(): Promise; +} +``` + +The method set is intentionally identical. `describe` and `projectWorkspace` +let the broker reason about host capabilities and file projection without +special-casing each client in coordinator logic. + +Adapters translate host-native events and commands into canonical envelopes. +They do not redefine turn ordering, causation, correlation, cancellation, +artifact semantics, resumability, or canonical error classes. + +Projection is loss-tolerant. Unknown event kinds are preserved and forwarded as +opaque canonical envelopes rather than crashing the adapter. Disposable +telemetry remains separate from durable conversation events. + +## 4. Host projection contract + +Every host projection publishes the same minimum facts: + +- the authoritative Squad agent file path for that host; +- the authoritative MCP configuration path for that host, if the host supports + MCP; +- the host execution primitive used for `submit`; +- the workspace root and working directory used for `projectWorkspace`; +- whether approvals and background work are supported directly or must be + broker-mediated. + +If a host requires more than one file, the agent file remains the anchor and +all companion files are listed under `supportFiles`. + +Capability reporting is descriptive. A host may advertise that it can render +approvals or run background work, but the policy for whether a run may do so +still lives in the shared contract and broker state. + +Workspace projection config is also descriptive only. Persisted `clients[]` +entries in `.squad/config.json` are for workspace projection and host wiring, +not for authority. They exclude credentials, provider thread ids, machine +paths, leases, checkpoints, and any other mutable runtime state. + +## 5. Canonical file paths and execution primitives + +| Host | Agent anchor | MCP config | Support files | `submit` projection | +|---|---|---|---|---| +| GitHub Copilot | `.github/agents/squad.agent.md` | `.mcp.json` | `.github/copilot-instructions.md` when present | `create_session`, `task`, or `runSubagent` depending on surface | +| Claude Code | `.claude/agents/squad.md` | `.mcp.json` | `CLAUDE.md` | Claude `Task` subagent tool | +| Kiro | `.kiro/steering/squad.md` | `.kiro/settings/mcp.json` | Kiro spec and steering hooks | Kiro agent or spec execution hook | +| OpenCode | `.opencode/agent/squad.md` | `opencode.json` | host agent and MCP definition blocks in `opencode.json` | OpenCode agent execution entry declared in `opencode.json` | +| Cursor | `.cursor/agents/squad.md` | `.cursor/mcp.json` | `.cursor/rules/squad.mdc` | Cursor agent dispatch or inline agent run with projected rules | + +These file paths are the portable contract. A host-specific installer may +write additional generated files, but it may not move the canonical anchor +without changing the projection version. + +The `submit` column describes the host execution primitive only. It is not the +portable conversation wire contract. MCP-backed projections are still host +transport details behind the shared backend semantics. + +The capability view exposed by an adapter mirrors the backend intersection +model. `clientCapabilities` come from the adapter, `backendCapabilities` come +from the shared service, `policyCapabilities` come from authorization policy, +and `effectiveCapabilities` are the only capabilities a projected client can +actually exercise. + +## 6. GitHub Copilot projection + +GitHub Copilot is treated as one host family with three common surfaces: +CLI, Copilot App, and editor-hosted subagents. + +### 6.1 Canonical files + +- agent anchor: `.github/agents/squad.agent.md` +- MCP config: `.mcp.json` +- support files: `.github/copilot-instructions.md`, host-specific generated + artifacts under `.github/` when present + +### 6.2 `submit` + +`submit` selects one execution primitive based on the live surface: + +- Copilot App prefers `create_session` for commit-producing or long-lived work; +- Copilot CLI prefers `task` for background fan-out and read-only specialists; +- editor-hosted Copilot surfaces may project `runSubagent` when available. + +The adapter returns one portable run identity regardless of which primitive was +selected. + +That portable run identity is `runId`, not turn sequence. Host completion or +UI status remains projection evidence and does not replace broker lifecycle +state. + +### 6.3 `projectWorkspace` + +The adapter reports the repository root, the active working directory, the +branch when known, `.github/agents/squad.agent.md` as the agent anchor, and +`.mcp.json` as the MCP config path. If the host requires an explicit +`--additional-mcp-config @.mcp.json` workaround, that remains an execution +detail inside the harness adapter rather than a different workspace contract. + +## 7. Claude Code projection + +### 7.1 Canonical files + +- agent anchor: `.claude/agents/squad.md` +- MCP config: `.mcp.json` +- support files: `CLAUDE.md` + +### 7.2 `submit` + +`submit` maps to Claude Code's `Task` subagent tool. The harness adapter is +responsible for shaping the portable prompt, handling streaming callbacks, and +projecting Claude's task identity into the broker run identity. + +### 7.3 `projectWorkspace` + +The adapter reports the repository root, current working directory, and the +pair `.claude/agents/squad.md` plus `CLAUDE.md` so the broker can verify that +the projected host files are in sync with the portable Squad contract. + +## 8. Kiro projection + +### 8.1 Canonical files + +- agent anchor: `.kiro/steering/squad.md` +- MCP config: `.kiro/settings/mcp.json` +- support files: any Kiro spec hook definitions that import the Squad steering + file + +### 8.2 `submit` + +`submit` maps to the Kiro execution surface that can launch an agent or spec +hook with the projected Squad steering context. The portable adapter contract +does not require Kiro to expose the same tool names as Copilot. + +### 8.3 `projectWorkspace` + +The workspace projection always returns `.kiro/steering/squad.md` as the +authoritative Squad steering file and `.kiro/settings/mcp.json` as the MCP +config anchor for state and broker tools. + +## 9. OpenCode projection + +### 9.1 Canonical files + +- agent anchor: `.opencode/agent/squad.md` +- MCP config: `opencode.json` +- support files: any agent and server definition sections nested in + `opencode.json` + +### 9.2 `submit` + +`submit` maps to the agent execution entry declared by the OpenCode host. +Because OpenCode centralizes both agent and MCP definitions in `opencode.json`, +the harness adapter treats that file as the durable projection source even when +generated helpers exist elsewhere. + +### 9.3 `projectWorkspace` + +The adapter reports `.opencode/agent/squad.md` as the stable prompt anchor and +`opencode.json` as the host manifest that binds MCP servers and agent +definitions together. + +## 10. Cursor projection + +### 10.1 Canonical files + +- agent anchor: `.cursor/agents/squad.md` +- MCP config: `.cursor/mcp.json` +- support files: `.cursor/rules/squad.mdc` + +### 10.2 `submit` + +`submit` maps to Cursor's agent dispatch surface when available. If the host +only exposes inline execution, the harness adapter still uses the same +submission payload but records `backgroundRuns: false` in `describe`. + +### 10.3 `projectWorkspace` + +`projectWorkspace` returns both `.cursor/agents/squad.md` and +`.cursor/rules/squad.mdc`. The rule file is part of the portable projection +because it carries the host-specific behavioral glue that accompanies the agent +prompt. + +## 11. SDK file layout + +The reference SDK should keep the portable adapter contracts in a host-neutral +module and keep host projections in one sibling folder per host family. + +| Concern | Canonical TypeScript path | +|---|---| +| backend conversation contracts | `packages/squad-sdk/src/client/backend-contract.ts` | +| client and harness adapter SPI | `packages/squad-sdk/src/client/adapter-contract.ts` | +| host projection implementations | `packages/squad-sdk/src/client/adapters/` | +| backend implementations | `packages/squad-sdk/src/client/backends/` | +| public exports | `packages/squad-sdk/src/client/index.ts` | + +This profile fixes the contract locations so backend work, client work, and +contract validation can proceed in parallel without rediscovering file layout. + +Those files also enforce the architectural boundary: + +- `backend-contract.ts` owns conversation, participant, turn, task, artifact, + lease, cancellation, resumability, observability, and canonical error + semantics; +- `adapter-contract.ts` owns host capability description, workspace + projection, and translation hooks only. + +## 12. Promotion criteria + +Promote this draft only after the suite has: + +1. contract tests that run the same `ClientAdapter` and `HarnessAdapter` + assertions against at least GitHub Copilot plus one non-Copilot host; +2. fixtures that verify the canonical file-path projection for all five host + families; +3. approval and cancellation traces that demonstrate host-specific UI behavior + still round-trips through the same portable response envelope; +4. MCP projection tests that prove `.mcp.json`, `.kiro/settings/mcp.json`, + `opencode.json`, and `.cursor/mcp.json` are emitted with stable semantics; +5. broker-to-harness replay tests showing that the chosen host primitive + preserves run identity, transcript ordering, and background capability + reporting; +6. golden-trace validation proving that adapters survive disconnect and + reconnect without losing unknown events, durable approval versions, or + stable `runId` cancellation targeting. diff --git a/docs/specification/core-v0.1.md b/docs/specification/core-v0.1.md index fb7d5be91..ffe5bd488 100644 --- a/docs/specification/core-v0.1.md +++ b/docs/specification/core-v0.1.md @@ -46,17 +46,6 @@ appear in all capitals, as shown here. ## 3. Terms and conformance classes -- **Squad:** a selected Squad root and the artifacts interpreted under - explicitly identified profiles. The term does not imply support for every - known profile. -- **Implementation:** software that performs one or more declared profile - operations. -- **Profile:** an independently versioned contract for a defined artifact or - evidence surface. -- **Capability:** one exact `/` identifier. -- **Conformance claim:** a statement binding an implementation and version to - exact immutable specification and evidence revisions, capability identifiers, - and an applicable conformance class. - **Squad root:** the `.squad/` directory selected by an implementation. - **Artifact:** a file or directory beneath the Squad root described by a profile. @@ -70,12 +59,6 @@ An implementation MUST declare exact operational capability identifiers. Supporting one profile operation MUST NOT be represented as support for every operation in that profile or for every Squad artifact. -### 3.1 Informative composition guidance - -Profile dependencies establish an interpretation order. They do not confer -transitive capabilities or conformance: an implementation claiming a dependent -profile still declares and evidences each supported capability independently. - ## 4. Root discovery, paths, and containment An implementation MUST resolve one Squad root before resolving artifact paths. @@ -122,21 +105,6 @@ consumer MAY expose raw bytes without interpreting them. Core version, profile version, implementation version, and mutable state revision are independent. None implies another. -### 6.1 Conformance revision binding - -Every conformance claim MUST identify both the exact specification revision and -the exact evidence revision against which conformance was established. Each -revision MUST use an immutable, machine-readable identifier whose derivation is -defined by the applicable profile. A content digest is an immutable revision -identifier when the profile defines the hashed bytes or canonical projection. - -Mutable branch names, pull request numbers, human-readable draft labels, and -nullable publication metadata MUST NOT be used as either revision identifier. -A working draft MAY be claimable when its profile supplies valid immutable -specification and evidence revisions; working-draft status does not relax this -requirement. Publication status, maturity, claimability, and claim revision are -independent. - ## 7. Common representation rules Text artifacts MUST be UTF-8. Producers SHOULD write LF line endings and MUST diff --git a/docs/specification/governance-review-v0.1.md b/docs/specification/governance-review-v0.1.md index deefa4300..82b90242a 100644 --- a/docs/specification/governance-review-v0.1.md +++ b/docs/specification/governance-review-v0.1.md @@ -2,17 +2,17 @@ **Status:** Working Draft -**Document class:** Non-normative requirements draft +**Document class:** Normative experimental evidence schema; transition profile +requirements remain non-claimable **Profile identifier:** `squad-governance-review/v0.1` -**Maturity:** Requirements draft; non-claimable +**Maturity:** Experimental schema; non-claimable **Claimable capabilities:** None -**Implementation status:** A reference evidence schema, example record, and -TypeScript implementation checks exist. They do not establish portable -governance conformance. +**Implementation status:** The shared evidence schema validates representative +governance transitions. No executable transition manifest is registered. **Known deviations:** The rejected baseline allowed owner self-revision in repository governance and lacked one machine-readable transition contract. @@ -26,16 +26,15 @@ repository governance and lacked one machine-readable transition contract. **Section status:** Informative overview. -This draft collects proposed accountable ownership, reviewer independence, -blocking-verdict evidence, rejection lockout, reassignment, escalation, -approval, and completion requirements. It does not define code-review style, -preferred feedback wording, repository merge policy, or who is authorized by a -provider. +This profile defines accountable ownership, reviewer independence, blocking +verdict evidence, rejection lockout, reassignment, escalation, approval, and +completion transitions. It does not define code-review style, preferred +feedback wording, repository merge policy, or who is authorized by a provider. ## 2. Roles and vocabulary -**Section status:** Informative vocabulary used by the reference schema and -implementation checks. +**Section status:** Normative vocabulary for the experimental schema and suite +relationship checks. The **accountable owner** owns the requirement and final handoff. The **current revision author** produced the revision receiving the represented verdict. A @@ -46,21 +45,21 @@ policy disputes. ## 3. Evidence record -**Section status:** Informative reference shape. +**Section status:** Experimental normative contract enforced by the shared +schema and suite tests. -The reference fixture uses the shared evidence envelope in -`test-fixtures/spec/suite-v0.1/evidence.schema.json`. Its governance data -represents prior and current state, reviewed subject UID and revision, -accountable owner, current revision author, successor revision author, -reviewer, blockers and dispositions, lockout and eligible-author sets, +Every represented transition MUST use the shared evidence envelope conforming to +`test-fixtures/spec/suite-v0.1/evidence.schema.json`. Governance data includes +prior and current state, reviewed subject UID and revision, accountable owner, +current revision author, successor revision author, independent reviewer, +blockers and dispositions, lockout and eligible-author sets, escalation/deadlock status, replay disposition, gate result, actor, timestamp, -idempotency key, replay key, expected revision, and result revision. Schema -acceptance proves only that represented fields match this draft shape. +idempotency key, replay key, expected revision, and result revision. ## 4. State machine -**Section status:** Informative transition design exercised by reference schema -checks. +**Section status:** Experimental normative transition contract enforced by the +schema's transition pairs. The deterministic states are: @@ -74,36 +73,36 @@ Escalation uses: `rejected|reassignment-required -> escalated -> dispositioned` -The intended design allows only an approved review subject with applicable -external checks to become completed. Completion does not imply provider merge -unless a future binding says so. +Only an approved review subject with applicable external checks may become completed. +Completion does not imply provider merge unless a binding says so. ## 5. Blocking verdict contract -**Section status:** Informative promotion requirements. +**Section status:** Experimental normative rejection contract enforced by +transition conditionals and mutation tests. -A future portable rejection contract is expected to identify review-subject -location, violated requirement or invariant, reproducible evidence or concrete -failure scenario, and the minimum clearing condition. Preference-only feedback -is expected to remain advisory and not trigger lockout. +A rejection MUST identify review-subject location, violated requirement or invariant, +reproducible evidence or concrete failure scenario, and the minimum clearing +condition. Preference-only feedback MUST be advisory and MUST NOT trigger +lockout. ## 6. Reviewer independence and lockout state machine -**Section status:** Informative relationship requirements. Current TypeScript -checks are reference-implementation checks, not portable conformance evidence. +**Section status:** Experimental normative relationship contract enforced by +suite relationship and mutation tests. -The proposed design keeps the blocking reviewer distinct from the current -revision author. After a rejection, it adds the current revision author to the -review-subject revision-cycle lockout set and excludes that author from -authoring, co-authoring, pairing on, or directing the next revision. It also -excludes the reviewer from becoming the successor revision author and selects a -successor from the eligible set outside the lockout set. +The blocking reviewer MUST differ from the current revision author. After a +rejection, the current revision author is added to the review-subject +revision-cycle lockout set and MUST NOT author, co-author, pair on, or direct +the next revision. The reviewer MAY clarify the blocker but MUST NOT become the +successor revision author for the same cycle. The coordinator MUST select a +successor revision author from the eligible set and outside the lockout set. Lockout is scoped to the review subject and persists until a later revision is approved or an authority records an explicit override. A rejected revision adds its current revision author to the set. If no eligible successor revision -author remains, the proposed coordinator behavior records a deadlocked -escalation rather than silently readmitting a locked-out author. +author remains, the coordinator MUST record a deadlocked escalation rather than +silently readmit a locked-out author. The separately testable lockout states are `clear`, `author-locked`, `revision-assigned`, `revision-submitted`, `cleared`, and `deadlocked`. @@ -114,23 +113,26 @@ known draft-policy incompatibility, not a published conformance verdict. ## 7. Approval and reassignment -**Section status:** Informative promotion requirements. +**Section status:** Experimental normative contract enforced by transition +conditionals and mutation tests. -A future portable approval contract is expected to identify the reviewed -revision and blocker disposition, reject self-approval, and distinguish -successor reassignment from accountable ownership transfer. +Approval MUST identify the reviewed revision and blocker disposition. A +reviewer MUST NOT approve a revision they authored. Reassignment changes the +successor revision author, not the accountable owner, unless an authority +separately records ownership transfer. ## 8. Failure, idempotency, and concurrency -**Section status:** Informative promotion requirements. Current schema and -TypeScript checks cover selected shapes and relationships only. +**Section status:** Invalid transitions, self-review, locked-out successor +revision authors, and missing rejection evidence are experimental normative +contracts enforced by the schema or suite tests. Retry, storage, and +concurrency behavior remain informative future-profile requirements. -A future portable validator is expected to reject invalid transitions, -self-review, locked-out successor revision authors, and missing blocker -evidence. Future execution profiles should fail closed for stale -reviewed-subject revisions and duplicate event IDs, return the original result -for a repeated idempotency key, and use the prior event ID plus reviewed-subject -revision as compare-and-swap conditions. +Invalid transitions, self-review, locked-out successor revision authors, and +missing blocker evidence MUST fail validation. Future execution profiles should +fail closed for stale reviewed-subject revisions and duplicate event IDs, +return the original result for a repeated idempotency key, and use the prior +event ID plus reviewed-subject revision as compare-and-swap conditions. ## 9. Versioning and extensions @@ -153,10 +155,8 @@ or personal data. ## 11. Promotion criteria -Define language-neutral relational invariants and stable diagnostic codes, then -publish a transition manifest with positive and negative cases for approval, -advisory feedback, rejection, reviewer independence, lockout, repeated -rejection, successor eligibility, reassignment, escalation, deadlock, stale -CAS, replay links, blocker disposition, and external-check gating. The current -rejection JSON and TypeScript relationship checks are reference artifacts, not -portable conformance evidence. +Publish a transition manifest with positive and negative cases for approval, +advisory feedback, rejection, lockout, repeated rejection, reassignment, +eligible successor revision authors, escalation, deadlock, stale CAS, duplicate replay, +blocker disposition, and external-check gating. The registered rejection JSON +is an example fixture, not a conformance manifest. diff --git a/docs/specification/interoperability-conventions-v0.1.md b/docs/specification/interoperability-conventions-v0.1.md index d9df95713..352f55346 100644 --- a/docs/specification/interoperability-conventions-v0.1.md +++ b/docs/specification/interoperability-conventions-v0.1.md @@ -4,16 +4,17 @@ **Status:** Working Draft -**Document class:** Non-normative requirements draft +**Document class:** Normative shared-schema draft -**Profile identifier:** `squad-interop/v0.1` +**Capability identifier:** `squad-interop/v0.1` -**Maturity:** Requirements draft; non-claimable +**Maturity:** Experimental schema; non-claimable **Claimable capabilities:** None -**Implementation status:** A shared evidence schema and reference checks exist, -but no portable manifest establishes interoperability conformance. +**Implementation status:** The shared evidence schema is executable. Discovery, +negotiation, retention, and cross-implementation conformance manifests are not +published. **Known deviations:** Current implementations do not expose one discovery document, authority assertion shape, or replay-retention policy. @@ -22,12 +23,19 @@ document, authority assertion shape, or replay-retention policy. ## 1. Scope -These conventions collect proposed discovery, conformance-claim, authority, -version-negotiation, evidence, concurrency, duplicate-suppression, and privacy -requirements for future profiles and provider bindings. +These conventions define discovery, conformance claims, authority identity, +version negotiation, shared evidence, concurrency, duplicate suppression, and +privacy rules reused by every profile and provider binding. -**Section status:** Informative overview. Every section is non-claimable until -portable manifests and cases are registered. +Two downstream drafts apply this shared layer directly to Squad 2.0 runtime +portability: +[Squad Client and Backend Decoupling v0.2](client-backend-decoupling-v0.2.md) +binds the conversation and lease model, and +[Squad Client and Harness Adapter Profile v0.2](client-harness-adapters-v0.2.md) +binds the host-specific adapter and file-projection layer. + +**Section status:** Informative overview. Only the explicitly marked schema +contracts in §§4 and 6 are normative in this experimental draft. ## 2. Suite discovery and capability claims @@ -50,26 +58,26 @@ to avoid inferring nearest-version compatibility. ## 3. Stable identity and authority -**Section status:** Informative design requirements. +**Section status:** Informative design requirements except for authority fields +validated by the shared envelope in §4. Every actor, member, service, and portable resource has an immutable UID scoped by an issuer. Display names, member IDs used as paths, roles, casting personas, and provider logins are attributes rather than the authority UID. -Neutral observations identify the actor UID without implying authority. The -reference envelope schema checks represented authority fields such as action, -issuer, authenticated provider identity, authority basis, resource UID, and -policy revision. Passing that shape check is not an authority or conformance -claim. Casting or persona changes are not expected to alter authority. +Neutral observations identify the actor UID without implying authority. When +an authority assertion is present, the executable envelope schema requires +action, issuer, authenticated provider identity, authority basis, resource UID, +and policy revision. Casting or persona changes are not expected to alter +authority. ## 4. Shared event and evidence envelope -**Section status:** Informative reference shape exercised by -`evidence.schema.json` and implementation tests. +**Section status:** Experimental normative schema contract, enforced by +`evidence.schema.json` and `test/specification-suite.test.ts`. -Future portable events and evidence are expected to use a language-neutral -envelope based on `test-fixtures/spec/suite-v0.1/evidence.schema.json`. The -current reference shape carries: +Normative events and evidence MUST conform to +`test-fixtures/spec/suite-v0.1/evidence.schema.json`. The envelope carries: - specification and schema identifiers; - event ID, type, source, subject, and time; @@ -78,9 +86,10 @@ current reference shape carries: - resource UID, revision, expected prior revision, and result revision; - payload digest, redaction declaration, extensions, and typed data. -The reference schema places profile data under `data` and rejects redefined or -unknown top-level fields. These syntax checks do not establish relational, -storage, replay, or execution semantics. +Profile data MUST appear under `data`; the closed envelope schema rejects +redefined or unknown top-level fields. Evidence immutability and correction +events remain informative future-profile behavior because the schema cannot +enforce storage history. ## 5. Concurrency, retry, and replay @@ -99,14 +108,15 @@ payload digest, and declare replay windows, leases, and retry limits. ## 6. Canonical privacy and secrets exclusion -**Section status:** Informative security requirements and reference schema -shape pending portable mutation fixtures. +**Section status:** The redaction enumeration is an experimental normative +schema contract. Content exclusion and diagnostic behavior are informative +security requirements pending mutation fixtures. -The reference envelope represents redaction as `none`, `metadata-only`, or -`content-redacted`. Future portable files, records, and evidence are expected -to exclude credentials, access tokens, private keys, session cookies, secret -environment values, hidden prompts, unredacted sensitive tool output, and -provider authorization headers. +Producers MUST declare one of `none`, `metadata-only`, or `content-redacted` in +the envelope's redaction field. Portable files, records, and evidence are +expected to exclude credentials, access tokens, private keys, session cookies, +secret environment values, hidden prompts, unredacted sensitive tool output, +and provider authorization headers. Sensitive source material should be referenced by an access-controlled URI and digest. Future diagnostics should identify the excluded field or class without @@ -169,7 +179,7 @@ authority conformance. ## 10. Versioned references and precedents -The evidence envelope design references +The evidence envelope normatively uses [JSON Schema 2020-12 Core §8](https://json-schema.org/draft/2020-12/json-schema-core#section-8) for identifiers and references, [RFC 3339 §5.6](https://www.rfc-editor.org/rfc/rfc3339#section-5.6) for @@ -193,4 +203,6 @@ Publish a manifest with neutral-observation, complete and incomplete authority, privacy/redaction, expected/result revision, duplicate event, repeated idempotency key, conflicting payload digest, replay, unsupported version, and extension cases. Discovery and negotiation require a separate executable -document or API-result schema before this requirements draft can advertise a capability. +document or API-result schema before this dependency can advertise a +capability. The client/backend and client/harness drafts are expected to +reuse that eventual discovery surface rather than define a second one. diff --git a/test-fixtures/spec/suite-v0.1/index.json b/test-fixtures/spec/suite-v0.1/index.json index 1420c9114..510e4be8a 100644 --- a/test-fixtures/spec/suite-v0.1/index.json +++ b/test-fixtures/spec/suite-v0.1/index.json @@ -315,6 +315,44 @@ "knownDeviations": ["No backend-neutral CAS, recovery, migration, privacy, or deletion corpus exists."], "promotionCriteria": ["Meet the independently listed state-authority and memory-classification promotion gates."], "entryPoint": "Read state authority separately from memory classification." + }, + { + "id": "squad-client-backend-decoupling/v0.2", + "maturity": "requirements-draft", + "documentClass": "requirements", + "spec": "docs/specification/client-backend-decoupling-v0.2.md", + "revision": null, + "dialect": "requirements-only", + "dependencies": ["squad-core/v0.1", "squad-interop/v0.1", "squad-coordination-handoff/v0.1", "squad-state-memory-requirements/v0.1"], + "conformanceClasses": [], + "implementationRoles": ["producer", "consumer", "validator", "executor"], + "capabilities": [], + "manifest": null, + "manifestSchema": null, + "fixtures": [], + "implementationStatus": "Local, MCP, and remote durable conversation backends exist, but they do not yet share one portable client-facing contract.", + "knownDeviations": ["Admission, approval replay protection, lease fencing, and transcript paging differ across backend families."], + "promotionCriteria": ["Add shared contract tests plus sanitized local, MCP, and remote transcript traces for admission, leases, approvals, and replay handling."], + "entryPoint": "Read after coordination and state-authority requirements." + }, + { + "id": "squad-client-harness-adapters/v0.2", + "maturity": "requirements-draft", + "documentClass": "requirements", + "spec": "docs/specification/client-harness-adapters-v0.2.md", + "revision": null, + "dialect": "requirements-only", + "dependencies": ["squad-core/v0.1", "squad-interop/v0.1", "squad-client-backend-decoupling/v0.2"], + "conformanceClasses": [], + "implementationRoles": ["producer", "consumer", "validator", "executor"], + "capabilities": [], + "manifest": null, + "manifestSchema": null, + "fixtures": [], + "implementationStatus": "GitHub Copilot projections exist today, while other host projections are specified but not yet unified in one SDK adapter seam.", + "knownDeviations": ["Host file projection, approvals, background execution, and MCP wiring differ by client family."], + "promotionCriteria": ["Add host projection fixtures and contract tests for GitHub Copilot plus at least one non-Copilot adapter family."], + "entryPoint": "Read after the client/backend contract." } ] } diff --git a/test-fixtures/spec/suite-v0.2/capability-matrix.json b/test-fixtures/spec/suite-v0.2/capability-matrix.json new file mode 100644 index 000000000..481afb241 --- /dev/null +++ b/test-fixtures/spec/suite-v0.2/capability-matrix.json @@ -0,0 +1,119 @@ +{ + "protocol": "squad-protocol-v2.0-rc1", + "description": "Versioned client capability matrix and negotiation baseline for Squad 2.0.", + "intersectionRule": "effectiveCapabilities = clientCapabilities ∩ backendCapabilities ∩ policyCapabilities", + "backendCapabilities": [ + "approvals", + "background-runs", + "cancel-run", + "cursor-reconnect", + "mcp-bridge", + "subagents", + "typed-content-parts" + ], + "policyCapabilities": [ + "approvals", + "background-runs", + "cancel-run", + "cursor-reconnect", + "mcp-bridge", + "subagents", + "typed-content-parts" + ], + "clients": { + "copilot": { + "clientCapabilities": [ + "approvals", + "background-runs", + "cancel-run", + "cursor-reconnect", + "mcp-bridge", + "subagents", + "typed-content-parts" + ], + "effectiveCapabilities": [ + "approvals", + "background-runs", + "cancel-run", + "cursor-reconnect", + "mcp-bridge", + "subagents", + "typed-content-parts" + ] + }, + "claude": { + "clientCapabilities": [ + "approvals", + "cancel-run", + "cursor-reconnect", + "mcp-bridge", + "subagents", + "typed-content-parts" + ], + "effectiveCapabilities": [ + "approvals", + "cancel-run", + "cursor-reconnect", + "mcp-bridge", + "subagents", + "typed-content-parts" + ] + }, + "kiro": { + "clientCapabilities": [ + "approvals", + "cancel-run", + "cursor-reconnect", + "mcp-bridge", + "subagents", + "typed-content-parts" + ], + "effectiveCapabilities": [ + "approvals", + "cancel-run", + "cursor-reconnect", + "mcp-bridge", + "subagents", + "typed-content-parts" + ] + }, + "opencode": { + "clientCapabilities": [ + "approvals", + "background-runs", + "cancel-run", + "cursor-reconnect", + "mcp-bridge", + "subagents", + "typed-content-parts" + ], + "effectiveCapabilities": [ + "approvals", + "background-runs", + "cancel-run", + "cursor-reconnect", + "mcp-bridge", + "subagents", + "typed-content-parts" + ] + }, + "cursor": { + "clientCapabilities": [ + "approvals", + "cancel-run", + "cursor-reconnect", + "mcp-bridge", + "subagents", + "typed-content-parts" + ], + "effectiveCapabilities": [ + "approvals", + "cancel-run", + "cursor-reconnect", + "mcp-bridge", + "subagents", + "typed-content-parts" + ] + } + } +} diff --git a/test-fixtures/spec/suite-v0.2/golden-trace.json b/test-fixtures/spec/suite-v0.2/golden-trace.json new file mode 100644 index 000000000..8794c0018 --- /dev/null +++ b/test-fixtures/spec/suite-v0.2/golden-trace.json @@ -0,0 +1,63 @@ +{ + "protocol": "squad-protocol-v2.0-rc1", + "traceId": "golden-conformance-trace", + "description": "Canonical Squad 2.0 protocol recovery and cancellation trace.", + "conversationId": "conv-demo-001", + "runId": "run-demo-001", + "expectedTerminalState": "cancelled", + "events": [ + { + "seq": 11, + "event": "TurnAccepted", + "runId": "run-demo-001", + "turnSeq": 11, + "state": "running" + }, + { + "seq": 12, + "event": "ApprovalRequested", + "runId": "run-demo-001", + "approvalId": "approval-demo-001", + "version": 1, + "scope": "turn", + "status": "pending", + "payloadHash": "sha256:approvalpayloadv1", + "expiresAt": "2026-10-17T22:00:00Z" + }, + { + "seq": 13, + "event": "Disconnected", + "runId": "run-demo-001", + "cursor": "cursor-13" + }, + { + "seq": 14, + "event": "Reconnected", + "runId": "run-demo-001", + "cursor": "cursor-13", + "lastAcceptedSeq": 12, + "resumable": true + }, + { + "seq": 15, + "event": "ApprovalDecided", + "runId": "run-demo-001", + "approvalId": "approval-demo-001", + "version": 2, + "status": "approved", + "payloadHash": "sha256:approvalpayloadv1" + }, + { + "seq": 16, + "event": "CancellationRequested", + "runId": "run-demo-001", + "reason": "user-requested" + }, + { + "seq": 17, + "event": "RunTerminal", + "runId": "run-demo-001", + "state": "cancelled" + } + ] +} diff --git a/test/specification-suite.test.ts b/test/specification-suite.test.ts index 867a06d6f..0d3ccd94c 100644 --- a/test/specification-suite.test.ts +++ b/test/specification-suite.test.ts @@ -19,6 +19,8 @@ const requirementsDocuments = [ 'coordination-handoff-v0.1.md', 'automation-v0.1.md', 'state-memory-requirements-v0.1.md', + 'client-backend-decoupling-v0.2.md', + 'client-harness-adapters-v0.2.md', ] as const; async function readJson(file: string): Promise { From beaff5d42786a432bf6898b94298a06465a4853b Mon Sep 17 00:00:00 2001 From: Copilot Date: Sat, 17 Oct 2026 22:24:59 +0300 Subject: [PATCH 05/46] fix(2.0): align backend capability vocabulary with spec matrix and restore clobbered spec docs Backend capabilities now use the v0.2 capability-matrix vocabulary; lastAcceptedSeq is the last accepted event sequence; conversation.created no longer leaks the lease epoch; MCP send_message accepts typed parts without content. Restore Charter/Core/Ceremony/Governance/Interop/README/inventory docs that the spec branch regressed to older revisions, keeping only the v0.2 profile additions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 995eea17-c356-4912-90ea-5e5b144e7ba9 --- docs/specification/README.md | 55 ++++---- docs/specification/artifact-inventory.md | 38 ++++-- docs/specification/ceremony-v0.1.md | 65 ++++++++++ docs/specification/charter-v0.1.md | 54 +++++++- docs/specification/core-v0.1.md | 32 +++++ docs/specification/governance-review-v0.1.md | 118 +++++++++--------- .../interoperability-conventions-v0.1.md | 78 +++++------- .../squad-cli/src/cli/commands/backend-mcp.ts | 11 +- .../squad-sdk/src/backend/local-backend.ts | 11 +- packages/squad-sdk/src/backend/types.ts | 5 +- test/backend.test.ts | 16 +-- 11 files changed, 325 insertions(+), 158 deletions(-) diff --git a/docs/specification/README.md b/docs/specification/README.md index 5293826ec..bceba07e4 100644 --- a/docs/specification/README.md +++ b/docs/specification/README.md @@ -6,13 +6,10 @@ **Draft date:** 2026-09-25 -**Working-draft provenance:** [PR #2078](https://github.com/bradygaster/squad/pull/2078) -contains this suite revision. Its rejected review baseline is -[`c03ba950fde071c8ec58a64504fdb7b5cf1206ef`](https://github.com/bradygaster/squad/commit/c03ba950fde071c8ec58a64504fdb7b5cf1206ef). -Revision cycle 2 was authored by `agent-orchestration-dev`; final revision -cycle 3 is authored by `unit-contract-tester`. The original Architect author -and rejecting reviewers are excluded from implementation by the governance -lockout. +**Working-draft provenance:** [PR #2074](https://github.com/bradygaster/squad/pull/2074) +contains the combined Core, Charter, and suite revision. The suite draft +originated in [PR #2078](https://github.com/bradygaster/squad/pull/2078); +the combined branch preserves its source-commit provenance. The source index keeps `publication.revision` and `publication.digest` null while it is a working draft. After review, the exact head SHA is recorded in @@ -23,13 +20,23 @@ The derived release index is attached to the tag or release; it is not committed back onto the same commit. This records an immutable revision in the suite index without a self-referential hash that would invalidate the commit. +Publication metadata is not a conformance-claim binding. Every claimable +profile instead carries a non-null `revision` object in the suite registry and +its executable manifest. For Charter v0.1, that object binds the exact Charter +document bytes and the canonical manifest evidence payload with SHA-256 +digests. This makes working-draft claims reproducible while publication +metadata remains null. + **Document class:** Informative registry and navigation index **Foundation profile:** [Squad Charter Profile v0.1](charter-v0.1.md) -This directory maps all major stable or observable Squad surfaces. Publication +This directory maps a working inventory of stable or observable Squad surfaces, +including composition and initialization capabilities without profiles in +the [artifact inventory](artifact-inventory.md). It does not yet specify the +complete model for how independent Squads compose and interact. Publication status is independent from maturity: this suite is an unpublished working -draft even where a profile has executable conformance evidence. +draft. Charter is the only profile with executable conformance evidence. ## Maturity taxonomy @@ -63,11 +70,11 @@ deviations are summarized here and expanded in the | ID | Maturity / class | Dependencies | Claimable capabilities | Schema/manifest | Implementation status | Known deviations | Reader entry path | |---|---|---|---|---|---|---|---| | `squad-core/v0.1` | `experimental-normative-draft` / normative profile | None | None | None | Normative rules are exercised by Charter; no standalone Core manifest | Canonical JSON and standalone Core cases incomplete | [Core](core-v0.1.md), then Charter | -| `squad-interop/v0.1` | `experimental-schema` / normative schema | Core | None | Shared evidence schema; no manifest | Evidence validation is executable | No discovery or replay corpus | [Interoperability](interoperability-conventions-v0.1.md) | -| `squad-charter/v0.1` | `executable-normative-draft` / normative profile | Core | `squad-charter/v0.1/parse`, `squad-charter/v0.1/validate`, `squad-charter/v0.1/edit`, `squad-charter/v0.1/legacy-consume`, `squad-charter/v0.1/runtime` | Manifest and schema | Parser, validator, editor, compiler, and cases implemented | None registered | [Charter](charter-v0.1.md) | +| `squad-interop/v0.1` | `requirements-draft` / requirements | Core | None | Reference evidence schema; no manifest | Schema and example records inform future profile design | No discovery, relational-invariant, or replay corpus | [Interoperability](interoperability-conventions-v0.1.md) | +| `squad-charter/v0.1` | `executable-normative-draft` / normative profile | Core | `squad-charter/v0.1/parse`, `squad-charter/v0.1/validate`, `squad-charter/v0.1/edit`, `squad-charter/v0.1/legacy-consume`, `squad-charter/v0.1/runtime` | Manifest, schema, and immutable revision binding | Parser, validator, editor, compiler, and cases implemented | None registered | [Charter](charter-v0.1.md) | | `squad-team-routing/v0.1` | `requirements-draft` / requirements | Core, Interop, Charter | None | None | Roster and routing exist in multiple shapes | Fallback and ambiguity differ | [Roster, then routing](team-routing-v0.1.md) | | `squad-configuration-casting/v0.1` | `requirements-draft` / requirements | Core, Interop, Team/Routing | None | None | Configuration, registry, and history implemented | Legacy shapes and runtime-only keys | [Configuration, casting, provenance](configuration-casting-v0.1.md) | -| `squad-governance-review/v0.1` | `experimental-schema` / normative schema | Core, Interop, Team/Routing | None | Evidence schema plus rejection fixture; no manifest | Rejection records are machine-valid | Baseline owner self-revision conflict | [Evidence contract, then transitions](governance-review-v0.1.md) | +| `squad-governance-review/v0.1` | `requirements-draft` / requirements | Core, Interop, Team/Routing | None | Reference schema and rejection fixture; no manifest | TypeScript checks exercise implementation expectations only | No portable relational-invariant or transition corpus | [Evidence shape, then promotion criteria](governance-review-v0.1.md) | | `squad-execution-preferences/v0.1` | `requirements-draft` / requirements | Core, Interop, Charter, Configuration/Casting | None | None | Runtime selection exists | Provider catalogs and fallback are policy | [Execution preferences](execution-preferences-v0.1.md) | | `squad-planning-activation/v0.1` | `requirements-draft` / requirements | Core, Interop, Team/Routing, Work Lifecycle | None | None | gh-aw planning records exist | Safe-output and limits are provider-specific | [Planning and activation](planning-activation-v0.1.md) | | `squad-ceremony/v0.1` | `requirements-draft` / requirements | Core, Interop, Team/Routing | None | None | Builder preserves triggers and schedules | Event names, `auto`, and cooldown are unstable | [Ceremonies](ceremony-v0.1.md) | @@ -76,15 +83,15 @@ deviations are summarized here and expanded in the | `squad-coordination-handoff/v0.1` | `requirements-draft` / requirements | Core, Interop, Team/Routing | None | None | Session and handoff implementations exist | Ordering and recovery are provider-specific | [Coordination](coordination-handoff-v0.1.md) | | `squad-automation/v0.1` | `requirements-draft` / requirements | Core, Interop, Coordination | None | None | Local and GitHub schedulers exist | Time, retry, and drift evidence differ | [Automation](automation-v0.1.md) | | `squad-state-memory-requirements/v0.1` | `informative-draft` / informative | Core, Interop | None | None | Several state and memory providers exist | No common authority, recovery, or privacy corpus | [State authority, then memory classification](state-memory-requirements-v0.1.md) | +| `squad-portable-bundle/v1.0` | `informative-draft` / observed implementation surface | Core and Interop rules apply conceptually | None | Tested JSON shape; no published schema or manifest | `squad export` and `squad import` round-trip team, routing, decisions, casting, agents, and skills | Bundle `1.0` does not use Core profile grammar; histories, decisions, skills, and casting policy can contain sensitive data | [Coverage and promotion requirements](artifact-inventory.md#portable-export-and-import-bundle) | | `squad-client-backend-decoupling/v0.2` | `requirements-draft` / requirements | Core, Interop, Coordination, State/Memory Requirements | None | None | Local, MCP, and remote broker seams exist but do not yet share one contract | Admission, approvals, leases, and paging differ by backend | [Client/backend decoupling](client-backend-decoupling-v0.2.md) | | `squad-client-harness-adapters/v0.2` | `requirements-draft` / requirements | Core, Interop, Client/Backend Decoupling | None | None | GitHub Copilot projections exist; other host projections are specified but not yet unified in one SDK seam | Host discovery, approvals, and background execution differ | [Client/harness adapters](client-harness-adapters-v0.2.md) | -| `squad-portable-bundle/v1.0` | `informative-draft` / observed implementation surface | Core and Interop rules apply conceptually | None | Tested JSON shape; no published schema or manifest | `squad export` and `squad import` round-trip team, routing, decisions, casting, agents, and skills | Bundle `1.0` does not use Core profile grammar; histories, decisions, skills, and casting policy can contain sensitive data | [Coverage and promotion requirements](artifact-inventory.md#portable-export-and-import-bundle) | The machine-readable registry is [`test-fixtures/spec/suite-v0.1/index.json`](../../test-fixtures/spec/suite-v0.1/index.json). -The governance JSON is registered as a fixture, not a conformance manifest. -Charter v0.1's manifest, schema, case IDs, and capability identifiers remain -unchanged. +The governance JSON is a reference fixture, not portable conformance evidence. +Charter v0.1 is the only registered profile with a manifest, conformance +classes, capability identifiers, and immutable claim revisions. ## Textual dependency adjacency and DAG @@ -105,9 +112,9 @@ squad-ceremony/v0.1 -> [squad-core/v0.1, squad-interop/v0.1, squad-team-routing/ squad-coordination-handoff/v0.1 -> [squad-core/v0.1, squad-interop/v0.1, squad-team-routing/v0.1] squad-automation/v0.1 -> [squad-core/v0.1, squad-interop/v0.1, squad-coordination-handoff/v0.1] squad-state-memory-requirements/v0.1 -> [squad-core/v0.1, squad-interop/v0.1] +squad-portable-bundle/v1.0 -> [conceptual Core and Interop constraints; no profile claim] squad-client-backend-decoupling/v0.2 -> [squad-core/v0.1, squad-interop/v0.1, squad-coordination-handoff/v0.1, squad-state-memory-requirements/v0.1] squad-client-harness-adapters/v0.2 -> [squad-core/v0.1, squad-interop/v0.1, squad-client-backend-decoupling/v0.2] -squad-portable-bundle/v1.0 -> [conceptual Core and Interop constraints; no profile claim] ``` DAG reading order: @@ -115,7 +122,7 @@ DAG reading order: ```text Core ├── Charter (only claimable profile) -├── Interop shared schema +├── Interop requirements and reference schema │ ├── Team/Routing │ │ ├── Configuration/Casting │ │ │ └── Execution Preferences @@ -126,16 +133,16 @@ Core │ │ ├── Ceremony │ │ └── Coordination │ │ └── Automation +│ │ └── Client/Backend Decoupling v0.2 (also builds on State/Memory) +│ │ └── Client/Harness Adapters v0.2 │ └── State/Memory Requirements -│ └── Client/Backend Decoupling v0.2 -│ └── Client/Harness Adapters v0.2 └── Portable Bundle observation (applies Core/Interop constraints without a claim) ``` There is no dependency cycle. Provider-neutral requirements precede the GitHub binding. State authority and memory classification remain separate promotion -tracks, and the client/backend split builds on that authority rather than -replacing it. +tracks. This composition diagram is informative: dependency edges do not confer +transitive capabilities or conformance claims. ## External reference policy @@ -163,4 +170,6 @@ versions, implementation versions, and the observed portable-bundle `1.0` version are distinct. Draft documents may change before promotion. Only a `publication.status` of `published` denotes an immutable publication; published normative revisions are immutable except for dated errata, and behavior changes -require a new profile version. +require a new profile version. Claimable working drafts remain bound to exact +content digests through each profile's `revision` object; mutable branch names, +pull request numbers, and null publication fields are never claim revisions. diff --git a/docs/specification/artifact-inventory.md b/docs/specification/artifact-inventory.md index 78d414aed..3f5783f45 100644 --- a/docs/specification/artifact-inventory.md +++ b/docs/specification/artifact-inventory.md @@ -7,30 +7,43 @@ **Purpose:** Repository-derived traceability, profile planning, and interoperability maturity evidence for issue #2069. -Paths in this table are Squad-root-relative. Repository-local installations -render them beneath `.squad/`. +Artifact paths such as `agents/{id}/charter.md` are Squad-root-relative; +repository-local installations render them beneath `.squad/`. Implementation +links instead point to repository source, templates, and tests. -| Surface | Repository evidence | Disposition | Specification | Maturity or promotion rationale | +This is a working inventory, not a complete specification of Squad composition. +The current suite focuses on artifacts and behavior within one Squad, with +separate observations of existing composition surfaces below. SubSquads, +upstream inheritance, peer discovery, and cross-Squad delegation have different +semantics; none acquires a portable contract from being listed here. + +| Capability | Implementation / artifacts | Spec status | Spec coverage | What's missing | |---|---|---|---|---| -| Discovery, negotiation, authority identity, shared evidence, secrets | Core/profile metadata, registry IDs, events, audit records | Experimental shared schema; no claim | Interoperability Conventions v0.1 | Evidence schema exists; discovery and negotiation need a manifest | +| Discovery, negotiation, authority identity, shared evidence, secrets | Core/profile metadata, registry IDs, events, audit records | Requirements draft with a reference schema; no claim | Interoperability Conventions v0.1 | Discovery, relational invariants, and negotiation need portable manifests and cases | | Core rules | profile identifiers, paths, diagnostics, trust boundary | Experimental normative working draft; no standalone claim | Core v0.1 | Charter depends on and exercises Core rules, but Core has no standalone manifest | -| Agent charter | `agents/{id}/charter.md`, compiler, validator, fixtures | Executable normative working draft | Charter v0.1 | Stable parser and round-trip evidence; publication remains `working-draft` | -| Team roster | `team.md`, roster templates, presets | Requirements draft; no claim | Team and Routing v0.1 | Promote independently with roster parsing, mutation, join, and diagnostic cases | +| Agent charter | `agents/{id}/charter.md`; [compiler](../../packages/squad-sdk/src/agents/charter-compiler.ts), [validator](../../packages/squad-sdk/src/agents/charter-validator.ts), [fixture manifest](../../test-fixtures/spec/charter-v0.1/manifest.json) | Executable normative working draft | [Charter v0.1](charter-v0.1.md) | Stable parser and round-trip evidence; publication remains `working-draft` | +| Team roster | `team.md`; [initialization and roster generation](../../packages/squad-sdk/src/config/init.ts) | Requirements draft; no claim | Team and Routing v0.1 | Promote independently with roster parsing, mutation, join, and diagnostic cases | | Routing ownership | `routing.md`, routing parser, coordinator tests | Requirements draft; no claim | Team and Routing v0.1 | Promote independently with priority, fallback, ambiguity, stale-write, and resolution cases | | Runtime configuration | `config.json`, config schema and loaders | Requirements draft; no claim | Configuration and Casting v0.1 | Promote independently with precedence and source-evidence cases | | Casting identity | `casting/registry.json`, `policy.json` | Requirements draft; no claim | Configuration and Casting v0.1 | Promote independently with identity, recast, join, and migration cases | | Casting provenance | `casting/history.json` | Requirements draft; no claim | Configuration and Casting v0.1 | Promote independently with append-only history, revision, replay, and CAS cases | -| Governance and review | `governance.md`, reviewer protocol, PR requirements | Experimental evidence schema; no claim | Governance and Review v0.1 | Add complete transition manifest beyond the representative rejection fixture | +| Governance and review | `governance.md`, reviewer protocol, PR requirements | Requirements draft with reference checks; no claim | Governance and Review v0.1 | Add language-neutral relational invariants and a complete transition manifest | | Execution preferences | charters, config, model selector, lifecycle manager | Requirements draft; no claim | Execution Preferences v0.1 | Add provider-neutral resolution cases and loss reporting | | PRD intake and planning | `prd-intake.md`, planning ontology, gh-aw workflows | Requirements draft; no claim | Planning and Activation v0.1 | Add portable planning-record and activation manifest | -| Ceremonies | `ceremonies.md`, ceremony reference, retro workflow | Requirements draft; no claim | Ceremony v0.1 | Normalize event vocabulary and prove cooldown with injected clocks | +| Ceremonies | [declaration template](../../.squad-templates/ceremonies.md), [ceremony reference](../../.squad-templates/ceremony-reference.md), [SDK builder](../../packages/squad-sdk/src/builders/index.ts), [retro workflow](../../workflows/squad-retro.md) | Requirements draft; no claim | [Ceremony v0.1](ceremony-v0.1.md) | Map definition/agenda/hooks separately from shared scheduling and execution; prove cooldown with injected clocks | | Work lifecycle | issue lifecycle template, platform interfaces, worktree tests | Requirements draft; no claim | Work Lifecycle v0.1 | Run complete transition traces against two providers | | GitHub lifecycle | GitHub adapter, gh-aw workflows, issue/PR templates | Requirements binding draft; no claim | GitHub Work Lifecycle Binding v0.1 | Add deterministic sanitized provider transcripts | | Handoffs and sessions | spawn reference, lifecycle manager, event buses | Requirements draft; no claim | Coordination and Handoff v0.1 | Add portable envelope, ordering, expiry, and recovery cases | | State backends | state backend interfaces, worktree/orphan/two-layer implementations | Informative requirements now | State and Memory Requirements v0.1 | Authority and recovery vary by backend; promotion requires backend-neutral CAS fixtures | | Memory governance | memory classes, providers, audit records | Informative requirements now | State and Memory Requirements v0.1 | Classification is promising but provider naming and durable authority are not stable | -| Automation | schedule manifest, scheduler, local and GitHub providers | Requirements draft; no claim | Automation v0.1 | Add portable trigger, clock, retry, drift, and result cases | -| Portable export/import | `squad export`, `squad import`, CLI round-trip tests | Informative observed surface; no claim | `squad-portable-bundle/v1.0` coverage below | Tested implementation exists without a published schema or conformance manifest | +| Automation | `schedule.json`; [scheduler and provider interfaces](../../packages/squad-sdk/src/runtime/scheduler.ts), [schedule CLI](../../packages/squad-cli/src/cli/commands/schedule.ts) | Requirements draft; no claim | [Automation v0.1](automation-v0.1.md) | Add portable trigger, clock, retry, drift, and result cases | +| Portable export/import | [`squad export`](../../packages/squad-cli/src/cli/commands/export.ts), [`squad import`](../../packages/squad-cli/src/cli/commands/import.ts) | Informative observed surface; no claim | `squad-portable-bundle/v1.0` coverage below | Tested implementation exists without a published schema or conformance manifest | +| Preset/template instantiation | [preset SDK](../../packages/squad-sdk/src/presets/index.ts), [preset CLI](../../packages/squad-cli/src/cli/commands/preset.ts), [initialization](../../packages/squad-sdk/src/config/init.ts) | Informative observed surface; no claim | No dedicated profile | Presets apply reusable agent charters; they are not full export/import snapshots. Definition versioning, initialization defaults, and conflict behavior need separate coverage | +| SubSquads / workstreams | [partition types](../../packages/squad-sdk/src/streams/types.ts), [resolver](../../packages/squad-sdk/src/streams/resolver.ts), [CLI](../../packages/squad-cli/src/cli/commands/streams.ts) | Informative observed surface; no claim | No dedicated profile | Scoped team partitions are distinct from independent peer Squads. Config path aliases, activation precedence, issue/folder scope, and isolation need portable cases | +| Upstream inheritance | [upstream types](../../packages/squad-sdk/src/upstream/types.ts), [resolver](../../packages/squad-sdk/src/upstream/resolver.ts), [CLI](../../packages/squad-cli/src/cli/commands/upstream.ts) | Informative observed surface; no claim | No dedicated profile | Context inheritance is distinct from discovery-only registration. Source revisions, precedence, trust, and synchronization need separate contracts | +| Peer Squad registry and discovery | `squad-registry.json`, `manifest.json`; [SDK](../../packages/squad-sdk/src/runtime/cross-squad.ts), [CLI](../../packages/squad-cli/src/cli/commands/cross-squad.ts) | Informative observed surface; no claim | No dedicated profile; not the specification discovery model | Registry entries identify peers without inheriting context. Manifest identity, stale discovery, and capability negotiation need portable cases | +| Cross-Squad communication and delegation | [discovery/delegation SDK](../../packages/squad-sdk/src/runtime/cross-squad.ts), [`squad delegate`](../../packages/squad-cli/src/cli/commands/cross-squad.ts) | Informative observed surface; no claim | Coordination and provider drafts are related, not a cross-Squad contract | GitHub issue-based work requests exist; delivery, acceptance, completion correlation, and delegation authority are not specified here | +| Creation/spawning of another Squad | [initialization API](../../packages/squad-sdk/src/config/init.ts), [preset application](../../packages/squad-sdk/src/presets/index.ts) | Scope gap; no claim | No dedicated profile | Local initialization is not evidence of autonomous cross-Squad spawning. Ownership, resource provisioning, isolation, and lifecycle require independent design and evidence | | Skills | `skills/{name}/SKILL.md`, loader and discovery | Deferred | Future Skills Profile | Promote after metadata precedence, imports, script trust, and compatibility fixtures stabilize | | Coordinator prompts and UI wording | templates, generated agent prompt, shell | Informative guidance | Implementation documentation | Prompts and wording are implementation inputs, not interoperability contracts | | Telemetry and cost accounting | OpenTelemetry and cost modules | Deferred | Future Observability Binding | Promote after common event identity and privacy/redaction contracts stabilize | @@ -81,8 +94,9 @@ is equally mature. The next promotion sequence is: **Recorded:** 2026-09-25 - Current `.squad/governance.md` permits accountable-owner revision after a - rejection; the reviewer protocol and Governance Profile require independent - revision-author lockout. + rejection; the reviewer protocol and Governance requirements draft propose + independent revision-author lockout. That relationship is not yet portable + conformance behavior. - Current model selectors contain vendor names and fallback lists. Those are implementation policy and do not conform as portable preference vocabulary. - Current ceremony conditions are natural language. They are compatible input, diff --git a/docs/specification/ceremony-v0.1.md b/docs/specification/ceremony-v0.1.md index 02da77714..4e52a0fb1 100644 --- a/docs/specification/ceremony-v0.1.md +++ b/docs/specification/ceremony-v0.1.md @@ -29,6 +29,12 @@ participants, decisions, actions, and completion records. It does not standardize meeting culture, agenda prose, facilitation prompts, timekeeping style, consensus, or synchronous human attendance. +This requirements draft is not a complete specification of today's ceremony +extensibility surface. It separates three concerns: a definition describes what +happens; shared scheduling describes when/how a trigger is delivered; an +execution record describes one run. The proposed execution lifecycle below is +not evidence that all of it is implemented. + ## 2. Declaration model A ceremony declares ID, enabled state, trigger mode, timing, condition @@ -44,6 +50,44 @@ Participant selectors are `accountable-owner`, `all-involved`, `all-relevant`, or explicit member IDs. Resolution must produce the exact participant set used. +### 2.1 Definition and current implementation mapping + +A definition also preserves its agenda/process, participant roles, inputs, +expected outputs, conditions, and extension hooks without standardizing their +prose or silently executing hooks. The +[SDK `defineCeremony()` builder](../../packages/squad-sdk/src/builders/index.ts) +accepts name, trigger, schedule, participants, agenda, and hooks. The +[configuration shape](../../packages/squad-sdk/src/config/schema.ts) and +[Markdown template](../../.squad-templates/ceremonies.md) are distinct existing +representations; the template includes agenda, timing, facilitator, conditions, +and an enforcement skill. The +[ceremony reference](../../.squad-templates/ceremony-reference.md) supplies +implementation guidance. + +These are observations, not a claim of lossless conversion between those +shapes. Hook invocation, process ordering, required input/output schemas, +plugin/template merge behavior, and a complete mapping of existing fields +remain promotion work. + +### 2.2 Shared trigger and scheduling boundary + +The [Automation requirements draft](automation-v0.1.md#2-manifest-model) owns +the proposed common `schedule.json` model, including cron, interval, event, and +startup triggers. Its [time semantics](automation-v0.1.md#4-trigger-and-time-semantics) +are shared scheduling concerns, not ceremony-specific definitions. The +[scheduler](../../packages/squad-sdk/src/runtime/scheduler.ts) and +[schedule CLI](../../packages/squad-cli/src/cli/commands/schedule.ts) implement +the current common scheduling surface. + +A future binding should reference a ceremony definition from a scheduled task +and supply trigger evidence to its executor, rather than define another cron +or retry engine here. The ceremony `manual`/`schedule`/`event:` vocabulary +above is a proposed declaration vocabulary, not a second `schedule.json` +schema. Mapping builder schedule text and Markdown timing/conditions to common +schedules, including non-cron triggers, is not yet specified or proven. +Eligibility evaluation and delivery belong to scheduling; ceremony-specific +cooldown, facilitation, and outcomes belong to execution. + ## 3. Execution record Every run records ceremony ID, run ID, trigger evidence, input revisions, @@ -67,6 +111,21 @@ Candidate operations are `ceremony.consume`, `ceremony.evaluate`, run; participants provide inputs; the recorder persists outcomes; the coordinator enforces claim and cooldown. +These names are non-contract placeholders for future profile work, not current +SDK entry points, claimable capabilities, or a normative interoperable API. +The intended responsibilities are: + +| Placeholder | Intended caller and input | Intended result | +|---|---|---| +| `ceremony.consume` | A loader reads a declaration and its format/version context | Parsed definition or diagnostics | +| `ceremony.evaluate` | A coordinator supplies a definition, trigger evidence, current inputs, and run history | Eligibility/suppression decision with reasons | +| `ceremony.facilitate` | An authorized executor supplies a claimed run, resolved participants, and definition | Proposed decisions/actions or an explicit failure | +| `ceremony.record` | A recorder receives execution outcomes and expected state revision | Persisted run/outcome references or a conflict | + +Exact types, invocation order, authorization, failure taxonomy, and atomicity +are deliberately unresolved. These descriptions do not authorize invocation +or establish an operation contract. + ## 6. Canonical and compatible behavior Canonical declarations use deterministic trigger references and participant @@ -92,3 +151,9 @@ Publish a manifest covering normalized manual, named-event, schedule, and legacy-`auto` inputs; selector resolution; injected-clock cooldown suppression; advisory and blocking failures; duplicate claims; stale inputs; and decision/action output validation. + +Before promotion, map the SDK builder, configuration, Markdown templates, and +plugin hooks to a complete definition model; bind shared Automation scheduling +without duplicating it; and replace or remove the placeholders in §5 with +versioned input/output, caller, authorization, and failure contracts. Definition +round trips must preserve agenda and hook metadata without executing it. diff --git a/docs/specification/charter-v0.1.md b/docs/specification/charter-v0.1.md index 12be9b028..391788d01 100644 --- a/docs/specification/charter-v0.1.md +++ b/docs/specification/charter-v0.1.md @@ -56,6 +56,8 @@ by `.squad/agents/{id}/charter.md` provide no profile path context. RFC 8174, defines requirement-keyword interpretation. - [CommonMark 0.31.2](https://spec.commonmark.org/0.31.2/) defines the Markdown terms used by the deterministic subset below. +- [RFC 8785](https://www.rfc-editor.org/rfc/rfc8785) defines the JSON + Canonicalization Scheme used for the evidence revision digest. ## 3. Association and unsupported versions @@ -297,8 +299,8 @@ The language-neutral field contract is [`manifest.schema.json`](../../test-fixtures/spec/charter-v0.1/manifest.schema.json). Every manifest contains: -- `$schema`, `schemaVersion`, `profile`, the complete `capabilities` list, and - uniquely identified `cases`; +- `$schema`, `schemaVersion`, `profile`, an immutable `revision` binding, the + complete `capabilities` list, and uniquely identified `cases`; - per-case `capabilities`, exactly one input source (`fixture` or `text`), nullable path/profile inputs, and the portable conformance API; - an always-present validation expectation with classification, capability @@ -316,8 +318,56 @@ Harnesses MUST validate the manifest against `manifest.schema.json` before executing cases. A manifest that fails schema validation is not a conformance suite and MUST NOT be partially executed. +### 12.1 Immutable claim revision + +A Charter conformance claim MUST copy the manifest's `revision.specification` +and `revision.evidence` objects without modification. Both revisions use +SHA-256 and lowercase hexadecimal digests prefixed by `sha256:`. + +The specification digest is computed over the exact UTF-8 bytes of +`docs/specification/charter-v0.1.md`. No line-ending normalization, Unicode +normalization, or whitespace transformation is applied. + +The evidence digest is computed over the manifest after removing only the +top-level `revision` member. The remaining JSON value is canonicalized using +the JSON Canonicalization Scheme in RFC 8785, identified in the manifest as +`rfc8785`. The SHA-256 digest is computed over the resulting UTF-8 bytes. + +The manifest schema permits only integers, strings, booleans, nulls, arrays, +and objects in the evidence projection. It does not permit non-integer numbers, +so the RFC 8785 number-serialization rules have one unambiguous integer input +domain here. Harnesses MUST recompute both digests before executing cases and +MUST reject a manifest when either digest disagrees. A branch name, pull +request number, publication revision, or other mutable reference is not a +substitute for either digest. + ## 13. Informative TypeScript implementation details +### 13.1 Portable charter and execution binding boundary + +The smaller portable shape is intentional: it identifies the agent and its +guidance/preferences, not every product configuration field. As specified in +[§3](#3-association-and-unsupported-versions), a copied charter still needs its +profile selection supplied by an API or accompanying manifest; it is not +self-describing. Adding an embedded version marker is a future profile-design +question, not a v0.1 requirement. + +An authenticated execution principal (for example, a managed or service +identity) belongs to trusted runtime configuration and provider binding, not +the charter's display or path identity. Tool requests likewise belong to +runtime/configuration integration; the runtime independently determines tool +availability and grants under [§11](#11-runtime-validation-and-security). +For example, the [current agent configuration](../../packages/squad-sdk/src/config/schema.ts) +has tool declarations; these declarations are not authorization. + +The suite does not yet define a portable principal-binding or tool-declaration +schema. [Interop authority requirements](interoperability-conventions-v0.1.md#3-stable-identity-and-authority) +are related design work, not an implemented execution binding. A namespaced +charter extension can preserve implementation metadata but cannot establish +authenticated identity or grant access. + +### 13.2 Reference API example + The exported TypeScript names, module paths, and example below are informative. They do not constrain implementations in other languages. diff --git a/docs/specification/core-v0.1.md b/docs/specification/core-v0.1.md index ffe5bd488..fb7d5be91 100644 --- a/docs/specification/core-v0.1.md +++ b/docs/specification/core-v0.1.md @@ -46,6 +46,17 @@ appear in all capitals, as shown here. ## 3. Terms and conformance classes +- **Squad:** a selected Squad root and the artifacts interpreted under + explicitly identified profiles. The term does not imply support for every + known profile. +- **Implementation:** software that performs one or more declared profile + operations. +- **Profile:** an independently versioned contract for a defined artifact or + evidence surface. +- **Capability:** one exact `/` identifier. +- **Conformance claim:** a statement binding an implementation and version to + exact immutable specification and evidence revisions, capability identifiers, + and an applicable conformance class. - **Squad root:** the `.squad/` directory selected by an implementation. - **Artifact:** a file or directory beneath the Squad root described by a profile. @@ -59,6 +70,12 @@ An implementation MUST declare exact operational capability identifiers. Supporting one profile operation MUST NOT be represented as support for every operation in that profile or for every Squad artifact. +### 3.1 Informative composition guidance + +Profile dependencies establish an interpretation order. They do not confer +transitive capabilities or conformance: an implementation claiming a dependent +profile still declares and evidences each supported capability independently. + ## 4. Root discovery, paths, and containment An implementation MUST resolve one Squad root before resolving artifact paths. @@ -105,6 +122,21 @@ consumer MAY expose raw bytes without interpreting them. Core version, profile version, implementation version, and mutable state revision are independent. None implies another. +### 6.1 Conformance revision binding + +Every conformance claim MUST identify both the exact specification revision and +the exact evidence revision against which conformance was established. Each +revision MUST use an immutable, machine-readable identifier whose derivation is +defined by the applicable profile. A content digest is an immutable revision +identifier when the profile defines the hashed bytes or canonical projection. + +Mutable branch names, pull request numbers, human-readable draft labels, and +nullable publication metadata MUST NOT be used as either revision identifier. +A working draft MAY be claimable when its profile supplies valid immutable +specification and evidence revisions; working-draft status does not relax this +requirement. Publication status, maturity, claimability, and claim revision are +independent. + ## 7. Common representation rules Text artifacts MUST be UTF-8. Producers SHOULD write LF line endings and MUST diff --git a/docs/specification/governance-review-v0.1.md b/docs/specification/governance-review-v0.1.md index 82b90242a..deefa4300 100644 --- a/docs/specification/governance-review-v0.1.md +++ b/docs/specification/governance-review-v0.1.md @@ -2,17 +2,17 @@ **Status:** Working Draft -**Document class:** Normative experimental evidence schema; transition profile -requirements remain non-claimable +**Document class:** Non-normative requirements draft **Profile identifier:** `squad-governance-review/v0.1` -**Maturity:** Experimental schema; non-claimable +**Maturity:** Requirements draft; non-claimable **Claimable capabilities:** None -**Implementation status:** The shared evidence schema validates representative -governance transitions. No executable transition manifest is registered. +**Implementation status:** A reference evidence schema, example record, and +TypeScript implementation checks exist. They do not establish portable +governance conformance. **Known deviations:** The rejected baseline allowed owner self-revision in repository governance and lacked one machine-readable transition contract. @@ -26,15 +26,16 @@ repository governance and lacked one machine-readable transition contract. **Section status:** Informative overview. -This profile defines accountable ownership, reviewer independence, blocking -verdict evidence, rejection lockout, reassignment, escalation, approval, and -completion transitions. It does not define code-review style, preferred -feedback wording, repository merge policy, or who is authorized by a provider. +This draft collects proposed accountable ownership, reviewer independence, +blocking-verdict evidence, rejection lockout, reassignment, escalation, +approval, and completion requirements. It does not define code-review style, +preferred feedback wording, repository merge policy, or who is authorized by a +provider. ## 2. Roles and vocabulary -**Section status:** Normative vocabulary for the experimental schema and suite -relationship checks. +**Section status:** Informative vocabulary used by the reference schema and +implementation checks. The **accountable owner** owns the requirement and final handoff. The **current revision author** produced the revision receiving the represented verdict. A @@ -45,21 +46,21 @@ policy disputes. ## 3. Evidence record -**Section status:** Experimental normative contract enforced by the shared -schema and suite tests. +**Section status:** Informative reference shape. -Every represented transition MUST use the shared evidence envelope conforming to -`test-fixtures/spec/suite-v0.1/evidence.schema.json`. Governance data includes -prior and current state, reviewed subject UID and revision, accountable owner, -current revision author, successor revision author, independent reviewer, -blockers and dispositions, lockout and eligible-author sets, +The reference fixture uses the shared evidence envelope in +`test-fixtures/spec/suite-v0.1/evidence.schema.json`. Its governance data +represents prior and current state, reviewed subject UID and revision, +accountable owner, current revision author, successor revision author, +reviewer, blockers and dispositions, lockout and eligible-author sets, escalation/deadlock status, replay disposition, gate result, actor, timestamp, -idempotency key, replay key, expected revision, and result revision. +idempotency key, replay key, expected revision, and result revision. Schema +acceptance proves only that represented fields match this draft shape. ## 4. State machine -**Section status:** Experimental normative transition contract enforced by the -schema's transition pairs. +**Section status:** Informative transition design exercised by reference schema +checks. The deterministic states are: @@ -73,36 +74,36 @@ Escalation uses: `rejected|reassignment-required -> escalated -> dispositioned` -Only an approved review subject with applicable external checks may become completed. -Completion does not imply provider merge unless a binding says so. +The intended design allows only an approved review subject with applicable +external checks to become completed. Completion does not imply provider merge +unless a future binding says so. ## 5. Blocking verdict contract -**Section status:** Experimental normative rejection contract enforced by -transition conditionals and mutation tests. +**Section status:** Informative promotion requirements. -A rejection MUST identify review-subject location, violated requirement or invariant, -reproducible evidence or concrete failure scenario, and the minimum clearing -condition. Preference-only feedback MUST be advisory and MUST NOT trigger -lockout. +A future portable rejection contract is expected to identify review-subject +location, violated requirement or invariant, reproducible evidence or concrete +failure scenario, and the minimum clearing condition. Preference-only feedback +is expected to remain advisory and not trigger lockout. ## 6. Reviewer independence and lockout state machine -**Section status:** Experimental normative relationship contract enforced by -suite relationship and mutation tests. +**Section status:** Informative relationship requirements. Current TypeScript +checks are reference-implementation checks, not portable conformance evidence. -The blocking reviewer MUST differ from the current revision author. After a -rejection, the current revision author is added to the review-subject -revision-cycle lockout set and MUST NOT author, co-author, pair on, or direct -the next revision. The reviewer MAY clarify the blocker but MUST NOT become the -successor revision author for the same cycle. The coordinator MUST select a -successor revision author from the eligible set and outside the lockout set. +The proposed design keeps the blocking reviewer distinct from the current +revision author. After a rejection, it adds the current revision author to the +review-subject revision-cycle lockout set and excludes that author from +authoring, co-authoring, pairing on, or directing the next revision. It also +excludes the reviewer from becoming the successor revision author and selects a +successor from the eligible set outside the lockout set. Lockout is scoped to the review subject and persists until a later revision is approved or an authority records an explicit override. A rejected revision adds its current revision author to the set. If no eligible successor revision -author remains, the coordinator MUST record a deadlocked escalation rather than -silently readmit a locked-out author. +author remains, the proposed coordinator behavior records a deadlocked +escalation rather than silently readmitting a locked-out author. The separately testable lockout states are `clear`, `author-locked`, `revision-assigned`, `revision-submitted`, `cleared`, and `deadlocked`. @@ -113,26 +114,23 @@ known draft-policy incompatibility, not a published conformance verdict. ## 7. Approval and reassignment -**Section status:** Experimental normative contract enforced by transition -conditionals and mutation tests. +**Section status:** Informative promotion requirements. -Approval MUST identify the reviewed revision and blocker disposition. A -reviewer MUST NOT approve a revision they authored. Reassignment changes the -successor revision author, not the accountable owner, unless an authority -separately records ownership transfer. +A future portable approval contract is expected to identify the reviewed +revision and blocker disposition, reject self-approval, and distinguish +successor reassignment from accountable ownership transfer. ## 8. Failure, idempotency, and concurrency -**Section status:** Invalid transitions, self-review, locked-out successor -revision authors, and missing rejection evidence are experimental normative -contracts enforced by the schema or suite tests. Retry, storage, and -concurrency behavior remain informative future-profile requirements. +**Section status:** Informative promotion requirements. Current schema and +TypeScript checks cover selected shapes and relationships only. -Invalid transitions, self-review, locked-out successor revision authors, and -missing blocker evidence MUST fail validation. Future execution profiles should -fail closed for stale reviewed-subject revisions and duplicate event IDs, -return the original result for a repeated idempotency key, and use the prior -event ID plus reviewed-subject revision as compare-and-swap conditions. +A future portable validator is expected to reject invalid transitions, +self-review, locked-out successor revision authors, and missing blocker +evidence. Future execution profiles should fail closed for stale +reviewed-subject revisions and duplicate event IDs, return the original result +for a repeated idempotency key, and use the prior event ID plus reviewed-subject +revision as compare-and-swap conditions. ## 9. Versioning and extensions @@ -155,8 +153,10 @@ or personal data. ## 11. Promotion criteria -Publish a transition manifest with positive and negative cases for approval, -advisory feedback, rejection, lockout, repeated rejection, reassignment, -eligible successor revision authors, escalation, deadlock, stale CAS, duplicate replay, -blocker disposition, and external-check gating. The registered rejection JSON -is an example fixture, not a conformance manifest. +Define language-neutral relational invariants and stable diagnostic codes, then +publish a transition manifest with positive and negative cases for approval, +advisory feedback, rejection, reviewer independence, lockout, repeated +rejection, successor eligibility, reassignment, escalation, deadlock, stale +CAS, replay links, blocker disposition, and external-check gating. The current +rejection JSON and TypeScript relationship checks are reference artifacts, not +portable conformance evidence. diff --git a/docs/specification/interoperability-conventions-v0.1.md b/docs/specification/interoperability-conventions-v0.1.md index 352f55346..d9df95713 100644 --- a/docs/specification/interoperability-conventions-v0.1.md +++ b/docs/specification/interoperability-conventions-v0.1.md @@ -4,17 +4,16 @@ **Status:** Working Draft -**Document class:** Normative shared-schema draft +**Document class:** Non-normative requirements draft -**Capability identifier:** `squad-interop/v0.1` +**Profile identifier:** `squad-interop/v0.1` -**Maturity:** Experimental schema; non-claimable +**Maturity:** Requirements draft; non-claimable **Claimable capabilities:** None -**Implementation status:** The shared evidence schema is executable. Discovery, -negotiation, retention, and cross-implementation conformance manifests are not -published. +**Implementation status:** A shared evidence schema and reference checks exist, +but no portable manifest establishes interoperability conformance. **Known deviations:** Current implementations do not expose one discovery document, authority assertion shape, or replay-retention policy. @@ -23,19 +22,12 @@ document, authority assertion shape, or replay-retention policy. ## 1. Scope -These conventions define discovery, conformance claims, authority identity, -version negotiation, shared evidence, concurrency, duplicate suppression, and -privacy rules reused by every profile and provider binding. +These conventions collect proposed discovery, conformance-claim, authority, +version-negotiation, evidence, concurrency, duplicate-suppression, and privacy +requirements for future profiles and provider bindings. -Two downstream drafts apply this shared layer directly to Squad 2.0 runtime -portability: -[Squad Client and Backend Decoupling v0.2](client-backend-decoupling-v0.2.md) -binds the conversation and lease model, and -[Squad Client and Harness Adapter Profile v0.2](client-harness-adapters-v0.2.md) -binds the host-specific adapter and file-projection layer. - -**Section status:** Informative overview. Only the explicitly marked schema -contracts in §§4 and 6 are normative in this experimental draft. +**Section status:** Informative overview. Every section is non-claimable until +portable manifests and cases are registered. ## 2. Suite discovery and capability claims @@ -58,26 +50,26 @@ to avoid inferring nearest-version compatibility. ## 3. Stable identity and authority -**Section status:** Informative design requirements except for authority fields -validated by the shared envelope in §4. +**Section status:** Informative design requirements. Every actor, member, service, and portable resource has an immutable UID scoped by an issuer. Display names, member IDs used as paths, roles, casting personas, and provider logins are attributes rather than the authority UID. -Neutral observations identify the actor UID without implying authority. When -an authority assertion is present, the executable envelope schema requires -action, issuer, authenticated provider identity, authority basis, resource UID, -and policy revision. Casting or persona changes are not expected to alter -authority. +Neutral observations identify the actor UID without implying authority. The +reference envelope schema checks represented authority fields such as action, +issuer, authenticated provider identity, authority basis, resource UID, and +policy revision. Passing that shape check is not an authority or conformance +claim. Casting or persona changes are not expected to alter authority. ## 4. Shared event and evidence envelope -**Section status:** Experimental normative schema contract, enforced by -`evidence.schema.json` and `test/specification-suite.test.ts`. +**Section status:** Informative reference shape exercised by +`evidence.schema.json` and implementation tests. -Normative events and evidence MUST conform to -`test-fixtures/spec/suite-v0.1/evidence.schema.json`. The envelope carries: +Future portable events and evidence are expected to use a language-neutral +envelope based on `test-fixtures/spec/suite-v0.1/evidence.schema.json`. The +current reference shape carries: - specification and schema identifiers; - event ID, type, source, subject, and time; @@ -86,10 +78,9 @@ Normative events and evidence MUST conform to - resource UID, revision, expected prior revision, and result revision; - payload digest, redaction declaration, extensions, and typed data. -Profile data MUST appear under `data`; the closed envelope schema rejects -redefined or unknown top-level fields. Evidence immutability and correction -events remain informative future-profile behavior because the schema cannot -enforce storage history. +The reference schema places profile data under `data` and rejects redefined or +unknown top-level fields. These syntax checks do not establish relational, +storage, replay, or execution semantics. ## 5. Concurrency, retry, and replay @@ -108,15 +99,14 @@ payload digest, and declare replay windows, leases, and retry limits. ## 6. Canonical privacy and secrets exclusion -**Section status:** The redaction enumeration is an experimental normative -schema contract. Content exclusion and diagnostic behavior are informative -security requirements pending mutation fixtures. +**Section status:** Informative security requirements and reference schema +shape pending portable mutation fixtures. -Producers MUST declare one of `none`, `metadata-only`, or `content-redacted` in -the envelope's redaction field. Portable files, records, and evidence are -expected to exclude credentials, access tokens, private keys, session cookies, -secret environment values, hidden prompts, unredacted sensitive tool output, -and provider authorization headers. +The reference envelope represents redaction as `none`, `metadata-only`, or +`content-redacted`. Future portable files, records, and evidence are expected +to exclude credentials, access tokens, private keys, session cookies, secret +environment values, hidden prompts, unredacted sensitive tool output, and +provider authorization headers. Sensitive source material should be referenced by an access-controlled URI and digest. Future diagnostics should identify the excluded field or class without @@ -179,7 +169,7 @@ authority conformance. ## 10. Versioned references and precedents -The evidence envelope normatively uses +The evidence envelope design references [JSON Schema 2020-12 Core §8](https://json-schema.org/draft/2020-12/json-schema-core#section-8) for identifiers and references, [RFC 3339 §5.6](https://www.rfc-editor.org/rfc/rfc3339#section-5.6) for @@ -203,6 +193,4 @@ Publish a manifest with neutral-observation, complete and incomplete authority, privacy/redaction, expected/result revision, duplicate event, repeated idempotency key, conflicting payload digest, replay, unsupported version, and extension cases. Discovery and negotiation require a separate executable -document or API-result schema before this dependency can advertise a -capability. The client/backend and client/harness drafts are expected to -reuse that eventual discovery surface rather than define a second one. +document or API-result schema before this requirements draft can advertise a capability. diff --git a/packages/squad-cli/src/cli/commands/backend-mcp.ts b/packages/squad-cli/src/cli/commands/backend-mcp.ts index 8c52c8d12..7031eb11d 100644 --- a/packages/squad-cli/src/cli/commands/backend-mcp.ts +++ b/packages/squad-cli/src/cli/commands/backend-mcp.ts @@ -40,6 +40,13 @@ function num(args: Record, key: string): number { * Backend selection: SQUAD_BACKEND_URL (+ SQUAD_BACKEND_TOKEN) → durable-http, * otherwise the local backend over the repo's .squad directory. */ +/** content is optional when typed parts are supplied. */ +function sendContent(args: Record): string { + if (typeof args['content'] === 'string') return args['content']; + if (Array.isArray(args['parts']) && args['parts'].length > 0) return ''; + throw new BackendError('invalid-request', "'content' or 'parts' is required", 400); +} + export function createMcpBackend(startDir: string, env: NodeJS.ProcessEnv = process.env): SquadBackend { if (env['SQUAD_BACKEND_URL']) { return createSquadBackend({ kind: 'durable-http', baseUrl: env['SQUAD_BACKEND_URL'], token: env['SQUAD_BACKEND_TOKEN'] }); @@ -75,12 +82,12 @@ export function createBackendMcpTools(getBackend: () => SquadBackend): BackendMc correlationId: { type: 'string' }, causationId: { type: 'string' }, epoch: { type: 'integer' }, harness: { type: 'string' }, extensions: { type: 'object', description: 'Keys must be x-.*' }, - }, required: ['conversationId', 'runId', 'seq', 'content'] }, + }, required: ['conversationId', 'runId', 'seq'] }, call: a => getBackend().sendMessage(str(a, 'conversationId'), { runId: str(a, 'runId'), seq: num(a, 'seq'), role: (a['role'] as 'user' | undefined) ?? 'user', - content: str(a, 'content'), + content: sendContent(a), parts: Array.isArray(a['parts']) ? a['parts'] as BackendContentPart[] : undefined, correlationId: typeof a['correlationId'] === 'string' ? a['correlationId'] : undefined, causationId: typeof a['causationId'] === 'string' ? a['causationId'] : undefined, diff --git a/packages/squad-sdk/src/backend/local-backend.ts b/packages/squad-sdk/src/backend/local-backend.ts index dc89eaf53..1404d6ec0 100644 --- a/packages/squad-sdk/src/backend/local-backend.ts +++ b/packages/squad-sdk/src/backend/local-backend.ts @@ -114,7 +114,7 @@ export class LocalSquadBackend implements SquadBackend { metadata: options.metadata, }; const conv: StoredConversation = { info, transcript: [], turns: [], events: [] }; - this.emit(conv, 'control', 'conversation.created', { conversationId: id, epoch: 1 }); + this.emit(conv, 'control', 'conversation.created', { conversationId: id }); this.save(conv); return info; } @@ -244,7 +244,8 @@ export class LocalSquadBackend implements SquadBackend { const server = new Set(this.harnesses.list().flatMap(h => h.capabilities.extensionNamespaces)); const wanted = hello.extensionNamespaces; const effective = intersectCapabilities(BACKEND_CAPABILITIES, hello.capabilities, hello.policyCapabilities); - const parts = (hello.contentParts ?? ALL_CONTENT_PART_TYPES).filter(p => effective.includes(`content:${p}`)); + const typed = effective.includes('typed-content-parts'); + const parts = (hello.contentParts ?? ALL_CONTENT_PART_TYPES).filter(p => typed || p === 'text'); return { protocol: BACKEND_PROTOCOL_VERSION, clientCapabilities: hello.capabilities ? [...hello.capabilities] : [...BACKEND_CAPABILITIES], @@ -253,9 +254,9 @@ export class LocalSquadBackend implements SquadBackend { effectiveCapabilities: effective, contentParts: ALL_CONTENT_PART_TYPES.filter(p => parts.includes(p)), extensionNamespaces: [...server].filter(ns => !wanted || wanted.includes(ns)), - streams: (['control', 'execution'] as const).filter(s => effective.includes(`stream:${s}`)), + streams: ['control', 'execution'], atLeastOnce: true, - resume: effective.includes('resume'), + resume: effective.includes('cursor-reconnect'), approvals: effective.includes('approvals'), harnesses: this.harnesses.list().map(h => h.id), }; @@ -263,7 +264,7 @@ export class LocalSquadBackend implements SquadBackend { async readEvents(conversationId: string, options: ReadEventsOptions = {}): Promise { const conv = this.load(conversationId); const tail = conv.events?.[conv.events.length - 1]?.sequence ?? 0; - const lastAcceptedSeq = conv.turns.reduce((m, t) => Math.max(m, t.seq), 0); + const lastAcceptedSeq = tail; const after = options.after === undefined ? 0 : decodeCursor(options.after, conversationId); if (after > tail) { return { events: [], nextCursor: encodeCursor(conversationId, tail), lastAcceptedSeq, resumable: false }; diff --git a/packages/squad-sdk/src/backend/types.ts b/packages/squad-sdk/src/backend/types.ts index f922e712e..9c53659bd 100644 --- a/packages/squad-sdk/src/backend/types.ts +++ b/packages/squad-sdk/src/backend/types.ts @@ -111,7 +111,7 @@ export interface BackendEventPage { events: BackendEnvelope[]; /** Cursor to resume from; equals the input cursor when there are no new events. */ nextCursor: BackendCursor; - /** Highest message `seq` the backend has accepted for this conversation (client resume state). */ + /** Sequence of the last event the backend has accepted for this conversation (client resume state). */ lastAcceptedSeq: number; /** alse when the supplied cursor is ahead of the log and the client must re-sync from the transcript. */ resumable: boolean; @@ -134,8 +134,7 @@ export interface ProtocolHello { export type CapabilityFlag = string; export const BACKEND_CAPABILITIES: readonly CapabilityFlag[] = [ - 'stream:control', 'stream:execution', 'resume', 'approvals', 'cancel:run', 'cancel:turn', - 'content:text', 'content:data', 'content:resource', 'content:tool-call', 'content:tool-result', + 'approvals', 'background-runs', 'cancel-run', 'cursor-reconnect', 'mcp-bridge', 'subagents', 'typed-content-parts', ]; /** Effective capabilities = client ∩ backend ∩ policy (undefined sides are unrestricted). */ diff --git a/test/backend.test.ts b/test/backend.test.ts index 78c2d99a0..ae1d9bbfe 100644 --- a/test/backend.test.ts +++ b/test/backend.test.ts @@ -335,14 +335,16 @@ describe('squad 2.0 wire lock-in', () => { const b = new LocalSquadBackend(); const caps = await b.negotiate({ protocols: [BACKEND_PROTOCOL_VERSION], - capabilities: ['approvals', 'resume', 'content:text', 'content:data', 'telepathy'], - policyCapabilities: ['approvals', 'content:text', 'cancel:run'], + capabilities: ['approvals', 'cursor-reconnect', 'typed-content-parts', 'telepathy'], + policyCapabilities: ['approvals', 'typed-content-parts', 'cancel-run'], }); - expect(caps.effectiveCapabilities).toEqual(['approvals', 'content:text']); + expect(caps.effectiveCapabilities).toEqual(['approvals', 'typed-content-parts']); expect(caps.clientCapabilities).toContain('telepathy'); expect(caps.backendCapabilities).not.toContain('telepathy'); - expect(caps.policyCapabilities).toEqual(['approvals', 'content:text', 'cancel:run']); - expect(caps).toMatchObject({ approvals: true, resume: false, contentParts: ['text'], streams: [] }); + expect(caps.policyCapabilities).toEqual(['approvals', 'typed-content-parts', 'cancel-run']); + expect(caps).toMatchObject({ approvals: true, resume: false, streams: ['control', 'execution'] }); + const noTyped = await new LocalSquadBackend().negotiate({ protocols: [BACKEND_PROTOCOL_VERSION], capabilities: ['approvals'] }); + expect(noTyped.contentParts).toEqual(['text']); const open = await b.negotiate({ protocols: [BACKEND_PROTOCOL_VERSION] }); expect(open.effectiveCapabilities).toEqual(open.backendCapabilities); }); @@ -369,7 +371,7 @@ describe('squad 2.0 wire lock-in', () => { await b.createConversation({ conversationId: 'c' }); await b.sendMessage('c', msg('r', 4)); const page = await b.readEvents('c'); - expect(page).toMatchObject({ lastAcceptedSeq: 4, resumable: true }); + expect(page).toMatchObject({ lastAcceptedSeq: 2, resumable: true }); const other = new LocalSquadBackend(); await other.createConversation({ conversationId: 'c' }); await other.sendMessage('c', msg('r', 1)); @@ -378,7 +380,7 @@ describe('squad 2.0 wire lock-in', () => { const fresh = new LocalSquadBackend(); await fresh.createConversation({ conversationId: 'c' }); const stale = await fresh.readEvents('c', { after: ahead.nextCursor }); - expect(stale).toMatchObject({ resumable: false, events: [], lastAcceptedSeq: 0 }); + expect(stale).toMatchObject({ resumable: false, events: [], lastAcceptedSeq: 1 }); }); describe('golden trace', () => { From 675d4f94e808912c666ab13c7faa09369f425491 Mon Sep 17 00:00:00 2001 From: Copilot Date: Sat, 17 Oct 2026 22:24:59 +0300 Subject: [PATCH 06/46] test(2.0): end-to-end all-scenarios suite across merged Squad 2.0 codebase Covers client projections, capability intersection vs capability-matrix.json, LocalSquadBackend across 5 harnesses, DurableHttpSquadBackend against a mock DurableSquad, the MCP bridge on local and durable-http, and the normative golden trace. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 995eea17-c356-4912-90ea-5e5b144e7ba9 --- test/squad-20-all-scenarios.e2e.test.ts | 705 ++++++++++++++++++++++++ 1 file changed, 705 insertions(+) create mode 100644 test/squad-20-all-scenarios.e2e.test.ts diff --git a/test/squad-20-all-scenarios.e2e.test.ts b/test/squad-20-all-scenarios.e2e.test.ts new file mode 100644 index 000000000..e3eb8cecf --- /dev/null +++ b/test/squad-20-all-scenarios.e2e.test.ts @@ -0,0 +1,705 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +vi.mock('@bradygaster/squad-sdk', async () => ({ + ...(await import('../packages/squad-sdk/src/backend/index.js')), + resolveSquadState: vi.fn(), +})); + +import { resolveSquadState } from '@bradygaster/squad-sdk'; +import { + BACKEND_PROTOCOL_VERSION, + BackendError, + DefaultHarnessRegistry, + DurableHttpSquadBackend, + LocalSquadBackend, + MemoryStateStore, + createSquadBackend, + type BackendEnvelope, + type HarnessAdapter, + type HarnessEventEnvelope, + type SquadBackend, +} from '../packages/squad-sdk/src/backend/index.js'; +import { + BLOCK_BEGIN, + CLIENT_IDS, + SQUAD_STATE_KEY, + assertPortableClients, + getClientAdapter, + slugifyMember, + installClients, + normalizeClientIds, + parseClients, + projectClients, + resolveEffectiveCapabilities, + type ClientContext, + type ClientId, +} from '../packages/squad-sdk/src/clients/index.js'; +import { callBackendMcpTool, createBackendMcpTools, createMcpBackend } from '../packages/squad-cli/src/cli/commands/backend-mcp.js'; + +const SUITE = path.resolve(__dirname, '..', 'test-fixtures', 'spec', 'suite-v0.2'); +const matrix = JSON.parse(fs.readFileSync(path.join(SUITE, 'capability-matrix.json'), 'utf8')) as { + backendCapabilities: string[]; + policyCapabilities: string[]; + clients: Record; +}; +const golden = JSON.parse(fs.readFileSync(path.join(SUITE, 'golden-trace.json'), 'utf8')) as { + conversationId: string; + runId: string; + expectedTerminalState: string; + events: Array & { event: string }>; +}; + +// ── helpers ───────────────────────────────────────────────────────── + +class TestHarness implements HarnessAdapter { + readonly capabilities; + submitted: Array<{ runId: string; seq: number }> = []; + cancelled: Array<{ runId: string; seq: number }> = []; + approvals: string[] = []; + private listeners = new Set<(e: HarnessEventEnvelope) => void>(); + constructor(readonly id: string) { + this.capabilities = { streaming: true, approvals: true, cancellation: true, tools: true, extensionNamespaces: [`x-${id}`] }; + } + async submit(s: { runId: string; seq: number }) { this.submitted.push({ runId: s.runId, seq: s.seq }); } + subscribe(_c: string, l: (e: HarnessEventEnvelope) => void) { this.listeners.add(l); return () => this.listeners.delete(l); } + async cancel(_c: string, runId: string, seq: number) { this.cancelled.push({ runId, seq }); } + async respondApproval(_c: string, r: { approvalId: string }) { this.approvals.push(r.approvalId); } + describe() { return { id: this.id, capabilities: this.capabilities }; } + emit(e: HarnessEventEnvelope) { this.listeners.forEach(l => l(e)); } +} + +const HARNESS_IDS = ['copilot', 'claude', 'kiro', 'opencode', 'cursor'] as const; + +function makeRegistry() { + const registry = new DefaultHarnessRegistry(); + const harnesses = Object.fromEntries(HARNESS_IDS.map(id => [id, new TestHarness(id)])) as Record<(typeof HARNESS_IDS)[number], TestHarness>; + for (const h of Object.values(harnesses)) registry.register(h); + return { registry, harnesses }; +} + +const msg = (runId: string, seq: number, extra: object = {}) => ({ runId, seq, role: 'user' as const, content: `m${seq}`, ...extra }); + +async function errorOf(p: Promise): Promise { + try { await p; } catch (e) { return e as BackendError; } + return undefined; +} + +const json = (status: number, body: unknown, headers: Record = {}) => + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json', ...headers } }); + +/** In-memory stand-in for a DurableSquad server: real backend semantics behind the /v1 HTTP surface. */ +function createMockDurableSquad(local: LocalSquadBackend) { + const calls: Array<{ method: string; path: string; headers: Record; body?: any }> = []; + const failures = new Map(); + const health = { live: true, ready: true }; + const fetchImpl = (async (input: string, init: RequestInit = {}) => { + const url = new URL(input); + const method = init.method ?? 'GET'; + const headers = (init.headers ?? {}) as Record; + const body = init.body ? JSON.parse(init.body as string) : undefined; + calls.push({ method, path: url.pathname + url.search, headers, body }); + + const fail = failures.get(`${method} ${url.pathname}`); + if (fail && fail.remaining > 0) { + fail.remaining--; + return json(fail.status, { error: { code: 'unavailable', message: 'injected', retryable: true } }); + } + if (method === 'GET' && url.pathname === '/health/live') return health.live ? json(200, { status: 'ok', version: 'mock-1' }) : json(503, { detail: 'not live' }); + if (method === 'GET' && url.pathname === '/health/ready') return health.ready ? json(200, { status: 'ok' }) : json(503, { detail: 'degraded: warming up' }); + try { + let m: RegExpExecArray | null; + if (method === 'POST' && url.pathname === '/v1/capabilities') return json(200, await local.negotiate(body)); + if (method === 'POST' && url.pathname === '/v1/conversations') return json(200, await local.createConversation(body)); + if ((m = /^\/v1\/conversations\/([^/]+)\/(.+)$/.exec(url.pathname))) { + const id = decodeURIComponent(m[1]!); + const rest = m[2]!; + let r: RegExpExecArray | null; + if (method === 'POST' && rest === 'messages') return json(200, await local.sendMessage(id, body)); + if (method === 'GET' && rest === 'transcript') return json(200, { messages: await local.getTranscript(id) }); + if (method === 'GET' && rest === 'events') { + return json(200, await local.readEvents(id, { + after: url.searchParams.get('after') ?? undefined, + limit: url.searchParams.has('limit') ? Number(url.searchParams.get('limit')) : undefined, + stream: (url.searchParams.get('stream') as 'control' | 'execution' | null) ?? undefined, + })); + } + if (method === 'GET' && rest === 'approvals') return json(200, { approvals: await local.getApprovals(id) }); + if ((r = /^turns\/(\d+)$/.exec(rest)) && method === 'GET') return json(200, await local.getTurn(id, Number(r[1]))); + if ((r = /^turns\/(\d+)\/cancel$/.exec(rest)) && method === 'POST') return json(200, await local.cancelTurn(id, Number(r[1]))); + if ((r = /^runs\/([^/]+)\/cancel$/.exec(rest)) && method === 'POST') return json(200, await local.cancelRun(id, decodeURIComponent(r[1]!), body?.reason)); + if ((r = /^approvals\/([^/]+)$/.exec(rest)) && method === 'POST') return json(200, await local.respondApproval(id, body)); + } + return json(404, { error: { code: 'not-found', message: `no route ${method} ${url.pathname}`, retryable: false } }); + } catch (err) { + if (err instanceof BackendError) return json(err.status ?? 400, { error: err.toInfo() }); + throw err; + } + }) as unknown as typeof fetch; + return { fetch: fetchImpl, calls, failures, health }; +} + +function mkHttp(server: ReturnType, extra: object = {}) { + return new DurableHttpSquadBackend({ baseUrl: 'http://durable.test', token: 't', fetch: server.fetch, retryDelayMs: 1, ...extra }); +} + +const canonical = (events: BackendEnvelope[]) => + events.map(e => ({ type: e.type, stream: e.stream, sequence: e.sequence, payload: e.payload })); + +// ── Scenario 1: client projections ────────────────────────────────── + +describe('Scenario 1: client projections and --client', () => { + let root: string; + const ctx = (over: Partial = {}): ClientContext => ({ + root, + mcp: { command: 'npx', args: ['-y', '@bradygaster/squad-cli', 'state-mcp'] }, + members: [{ name: 'Data', role: 'Framework Expert' }, { name: "B'Elanna", role: 'Durable Systems' }], + ...over, + }); + beforeEach(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), 'squad20-clients-')); }); + afterEach(() => { fs.rmSync(root, { recursive: true, force: true }); }); + + it.each([...CLIENT_IDS, 'all'])('projects %s to disk and is idempotent', (name) => { + const clients = parseClients(name); + expect(clients).toEqual(name === 'all' ? [...CLIENT_IDS] : [name]); + const files = projectClients(clients, ctx()); + expect(files.length).toBeGreaterThan(0); + + const first = installClients(clients, ctx()); + expect(first.every(r => r.action === 'created' || r.action === 'updated')).toBe(true); + for (const f of files) expect(fs.existsSync(path.join(root, f.path)), f.path).toBe(true); + const mcpFiles = files.filter(f => f.kind === 'json'); + expect(mcpFiles.length).toBeGreaterThan(0); + for (const f of mcpFiles) { + expect(JSON.stringify(JSON.parse(fs.readFileSync(path.join(root, f.path), 'utf8')))).toContain(SQUAD_STATE_KEY); + } + + const second = installClients(clients, ctx()); + expect(second.map(r => r.action)).toEqual(second.map(() => 'unchanged')); + }); + + it('projects the host-specific files', () => { + installClients([...CLIENT_IDS], ctx()); + for (const rel of [ + '.claude/agents/squad.md', '.claude/agents/squad-data.md', 'CLAUDE.md', + '.kiro/steering/squad.md', '.kiro/agents/squad.json', '.kiro/settings/mcp.json', + '.opencode/agent/squad.md', 'opencode.json', + '.cursor/rules/squad.mdc', '.cursor/agents/squad.md', '.cursor/mcp.json', + ]) expect(fs.existsSync(path.join(root, rel)), rel).toBe(true); + expect(fs.readFileSync(path.join(root, `.claude/agents/squad-${slugifyMember("B'Elanna")}.md`), 'utf8')).toContain("B'Elanna"); + }); + + it('merges shared JSON configs without clobbering user entries', () => { + const userCfg: Record = { + '.mcp.json': JSON.stringify({ mcpServers: { mine: { command: 'mine' } } }), + '.kiro/settings/mcp.json': JSON.stringify({ mcpServers: { mine: { command: 'mine' } } }), + '.cursor/mcp.json': JSON.stringify({ mcpServers: { mine: { command: 'mine' } } }), + 'opencode.json': JSON.stringify({ model: 'my-model', mcp: { mine: { type: 'remote', url: 'u' } } }), + }; + for (const [rel, body] of Object.entries(userCfg)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), body); + } + installClients([...CLIENT_IDS], ctx()); + for (const rel of Object.keys(userCfg)) { + const parsed = JSON.parse(fs.readFileSync(path.join(root, rel), 'utf8')); + const text = JSON.stringify(parsed); + expect(text, rel).toContain('"mine"'); + expect(text, rel).toContain(SQUAD_STATE_KEY); + } + expect(JSON.parse(fs.readFileSync(path.join(root, 'opencode.json'), 'utf8')).model).toBe('my-model'); + }); + + it('refuses to overwrite malformed user JSON', () => { + fs.writeFileSync(path.join(root, '.mcp.json'), '{ not json'); + const results = installClients(['claude'], ctx()); + expect(results.some(r => r.action === 'error')).toBe(true); + expect(fs.readFileSync(path.join(root, '.mcp.json'), 'utf8')).toBe('{ not json'); + }); + + it('keeps CLAUDE.md user text and a single idempotent marker block', () => { + fs.writeFileSync(path.join(root, 'CLAUDE.md'), '# My project\n\nkeep me\n'); + installClients(['claude'], ctx()); + installClients(['claude'], ctx()); + installClients(['claude'], ctx({ members: [{ name: 'Worf', role: 'Security' }] })); + const text = fs.readFileSync(path.join(root, 'CLAUDE.md'), 'utf8'); + expect(text.startsWith('# My project\n\nkeep me\n')).toBe(true); + expect(text.split(BLOCK_BEGIN)).toHaveLength(2); + }); + + it('enforces portable client id hygiene', () => { + const { clients, rejected } = normalizeClientIds(['Claude ', ' claude', 'kiro', 'sk-token=abc', 'C:\\Users\\x', 'thread-123', 'nope', 5, {}]); + expect(clients).toEqual(['claude', 'kiro']); + expect(rejected.filter(r => r.reason === 'sensitive').length).toBeGreaterThanOrEqual(3); + expect(JSON.stringify(rejected)).not.toMatch(/sk-token|Users|thread-123/); + expect(normalizeClientIds('copilot').clients).toEqual([]); + expect(assertPortableClients(['copilot', 'cursor'])).toEqual(['copilot', 'cursor']); + expect(() => assertPortableClients(['claude', 'claude'])).toThrow(/canonical ids/); + expect(() => assertPortableClients(['sk-secret-token'])).toThrow(/rejected 1/); + expect(() => assertPortableClients('claude')).toThrow(/array/); + expect(() => parseClients('bogus')).toThrow(); + }); +}); + +// ── Scenario 2: capability intersection ───────────────────────────── + +describe('Scenario 2: cross-plane capability intersection', () => { + const intersect = (...sets: string[][]) => sets.reduce((a, b) => a.filter(x => b.includes(x))); + + it.each(CLIENT_IDS)('%s: adapter capabilities, backend negotiate() and capability-matrix.json agree', async (id: ClientId) => { + const adapter = getClientAdapter(id); + const entry = matrix.clients[id]!; + + // plane 1: adapter declaration narrowed by backend + policy + const eff = resolveEffectiveCapabilities( + adapter.capabilities, + { supportsNativeHooks: false }, + { transportModes: ['mcp-bridge', 'http-backend'], supportsSteeringFiles: false }, + ); + expect(eff.supportsNativeHooks).toBe(false); + expect(eff.supportsSteeringFiles).toBe(false); + expect(eff.supportsSubagents).toBe(adapter.capabilities.supportsSubagents); + expect(eff.transportModes).toEqual(adapter.capabilities.transportModes.filter(m => m !== 'in-process')); + expect(resolveEffectiveCapabilities(adapter.capabilities, {}, { supportsMcpTransport: false }).transportModes).not.toContain('mcp-bridge'); + expect(resolveEffectiveCapabilities(adapter.capabilities)).toEqual(adapter.capabilities); + + // plane 2: backend negotiation == client ∩ backend ∩ policy + const backend = new LocalSquadBackend(); + const neg = await backend.negotiate({ + protocols: [BACKEND_PROTOCOL_VERSION], clientName: id, + capabilities: entry.clientCapabilities, policyCapabilities: matrix.policyCapabilities, + }); + expect(neg.backendCapabilities).toEqual(matrix.backendCapabilities); + expect(neg.effectiveCapabilities).toEqual(intersect(entry.clientCapabilities, matrix.backendCapabilities, matrix.policyCapabilities)); + expect(neg.effectiveCapabilities).toEqual(entry.effectiveCapabilities); + expect(neg.resume).toBe(entry.effectiveCapabilities.includes('cursor-reconnect')); + expect(neg.approvals).toBe(entry.effectiveCapabilities.includes('approvals')); + + // plane 3: adapter declaration is consistent with the matrix + expect(entry.clientCapabilities.includes('subagents')).toBe(adapter.capabilities.supportsSubagents); + expect(entry.clientCapabilities.includes('mcp-bridge')) + .toBe(adapter.capabilities.supportsMcpTransport && adapter.capabilities.transportModes.includes('mcp-bridge')); + }); + + it('policy narrows, clients never widen', async () => { + const backend = new LocalSquadBackend(); + const policy = matrix.policyCapabilities.filter(c => c !== 'cancel-run' && c !== 'background-runs'); + const neg = await backend.negotiate({ protocols: [BACKEND_PROTOCOL_VERSION], capabilities: [...matrix.clients['copilot']!.clientCapabilities, 'telepathy'], policyCapabilities: policy }); + expect(neg.effectiveCapabilities).not.toContain('cancel-run'); + expect(neg.effectiveCapabilities).not.toContain('background-runs'); + expect(neg.effectiveCapabilities).not.toContain('telepathy'); + expect(neg.effectiveCapabilities).toEqual(intersect(neg.clientCapabilities, neg.backendCapabilities, policy)); + expect((await errorOf(backend.negotiate({ protocols: ['1.0'] })))?.code).toBe('unsupported-protocol'); + }); +}); + +// ── Scenario 3: LocalSquadBackend across 5 harnesses ──────────────── + +describe('Scenario 3: LocalSquadBackend + HarnessRegistry (5 harnesses)', () => { + it('registers all five harnesses', () => { + const { registry } = makeRegistry(); + expect(registry.list().map(h => h.id)).toEqual([...HARNESS_IDS]); + expect(registry.list().every(h => h.capabilities.extensionNamespaces.length === 1)).toBe(true); + }); + + it.each(HARNESS_IDS)('%s: typed multi-part messages, own x-%s.* accepted, foreign prefix rejected', async (id) => { + const { registry, harnesses } = makeRegistry(); + const b = new LocalSquadBackend({ harnesses: registry }); + await b.createConversation({ conversationId: 'c' }); + const parts = [ + { type: 'text' as const, text: 'hello' }, + { type: 'data' as const, mimeType: 'application/json', data: { a: 1 } }, + { type: 'resource' as const, uri: 'file:///x.ts', title: 'x.ts' }, + { type: 'tool-call' as const, callId: 't1', name: 'grep', arguments: { q: 'z' } }, + { type: 'tool-result' as const, callId: 't1', result: 'ok' }, + ]; + const adm = await b.sendMessage('c', { runId: 'r', seq: 1, role: 'user', content: '', parts, harness: id, extensions: { [`x-${id}.setting`]: 1 } }); + expect(adm).toMatchObject({ state: 'running', duplicate: false, protocol: BACKEND_PROTOCOL_VERSION }); + expect(harnesses[id].submitted).toEqual([{ runId: 'r', seq: 1 }]); + const [stored] = await b.getTranscript('c'); + expect(stored!.parts!.map(p => p.type)).toEqual(['text', 'data', 'resource', 'tool-call', 'tool-result']); + expect(stored!.content).toContain('hello'); + expect(stored!.content).toContain('x.ts'); + + const foreign = HARNESS_IDS.find(h => h !== id)!; + expect((await errorOf(b.sendMessage('c', msg('r', 2, { harness: id, extensions: { [`x-${foreign}.setting`]: 1 } }))))?.code).toBe('invalid-request'); + expect((await errorOf(b.sendMessage('c', msg('r', 3, { harness: id, extensions: { unprefixed: 1 } }))))?.code).toBe('invalid-request'); + expect((await errorOf(b.sendMessage('c', msg('r', 4, { harness: 'unknown' }))))?.code).toBe('invalid-request'); + expect((await b.getTranscript('c'))).toHaveLength(1); + }); + + it('dedups (runId, seq) and fences stale epochs', async () => { + const { registry, harnesses } = makeRegistry(); + const b = new LocalSquadBackend({ harnesses: registry }); + await b.createConversation({ conversationId: 'c' }); + await b.sendMessage('c', msg('r', 1, { harness: 'kiro', epoch: 2 })); + const dup = await b.sendMessage('c', msg('r', 1, { harness: 'kiro', epoch: 2, content: 'retry' })); + expect(dup.duplicate).toBe(true); + expect(harnesses.kiro.submitted).toHaveLength(1); + expect((await b.sendMessage('c', msg('r2', 1, { harness: 'kiro', epoch: 2 }))).duplicate).toBe(false); + const stale = await errorOf(b.sendMessage('c', msg('r', 2, { epoch: 1 }))); + expect(stale).toMatchObject({ code: 'stale-epoch', status: 409, retryable: false }); + expect((await b.sendMessage('c', msg('r', 2, { epoch: 5 }))).epoch).toBe(5); + expect((await errorOf(b.respondApproval('c', { approvalId: 'x', hash: 'h', decision: 'approve', epoch: 4 })))?.code).toBe('stale-epoch'); + }); + + it.each(HARNESS_IDS)('%s: versioned approvals v1→v2 with hash tamper rejection', async (id) => { + const { registry, harnesses } = makeRegistry(); + const b = new LocalSquadBackend({ harnesses: registry }); + await b.createConversation({ conversationId: 'c' }); + await b.sendMessage('c', msg('r', 1, { harness: id })); + harnesses[id].emit({ type: 'approval-requested', runId: 'r', seq: 1, request: { approvalId: 'a1', turnSeq: 1, action: 'deploy', hash: 'sha256:good' } }); + let [rec] = await b.getApprovals('c'); + expect(rec).toMatchObject({ version: 1, state: 'pending', scope: { kind: 'turn', runId: 'r', seq: 1 }, hash: 'sha256:good' }); + expect((await b.getTurn('c', 1)).state).toBe('awaiting-approval'); + + expect((await errorOf(b.respondApproval('c', { approvalId: 'a1', hash: 'sha256:evil', decision: 'approve' })))?.code).toBe('approval-hash-mismatch'); + expect(harnesses[id].approvals).toEqual([]); + [rec] = await b.getApprovals('c'); + expect(rec!.version).toBe(1); + + const turn = await b.respondApproval('c', { approvalId: 'a1', hash: 'sha256:good', decision: 'approve' }); + expect(turn.state).toBe('running'); + [rec] = await b.getApprovals('c'); + expect(rec).toMatchObject({ version: 2, state: 'approved', decision: 'approve' }); + expect((await errorOf(b.respondApproval('c', { approvalId: 'a1', hash: 'sha256:good', decision: 'approve' })))?.code).toBe('approval-not-pending'); + expect(harnesses[id].approvals).toEqual(['a1']); + }); + + it('persists conversations across backend instances sharing state', async () => { + const state = new MemoryStateStore(); + const a = new LocalSquadBackend({ state }); + await a.createConversation({ conversationId: 'c' }); + await a.sendMessage('c', msg('r', 1)); + const b = new LocalSquadBackend({ state }); + expect((await b.sendMessage('c', msg('r', 1))).duplicate).toBe(true); + expect(await b.getTranscript('c')).toHaveLength(1); + }); +}); + +// ── Scenario 4: DurableHttpSquadBackend lifecycle ─────────────────── + +describe('Scenario 4: DurableHttpSquadBackend full HTTP lifecycle', () => { + it('speaks every route of the DurableSquad surface', async () => { + const { registry, harnesses } = makeRegistry(); + const server = createMockDurableSquad(new LocalSquadBackend({ harnesses: registry })); + const b = mkHttp(server); + + const neg = await b.negotiate({ protocols: [BACKEND_PROTOCOL_VERSION], capabilities: ['approvals', 'cancel-run'] }); + expect(neg.effectiveCapabilities).toEqual(['approvals', 'cancel-run']); + const conv = await b.createConversation({ conversationId: 'c', title: 'T' }); + expect(conv.conversationId).toBe('c'); + const adm = await b.sendMessage('c', msg('run-1', 1, { harness: 'opencode', correlationId: 'corr-1' })); + expect(adm).toMatchObject({ runId: 'run-1', seq: 1, duplicate: false, correlationId: 'corr-1' }); + expect(await b.getTranscript('c')).toHaveLength(1); + expect(await b.getTurn('c', 1)).toMatchObject({ runId: 'run-1', seq: 1, state: 'running' }); + + harnesses.opencode.emit({ type: 'approval-requested', runId: 'run-1', seq: 1, request: { approvalId: 'a1', turnSeq: 1, action: 'x', hash: 'h1' } }); + const [pending] = await b.getApprovals('c'); + expect(pending).toMatchObject({ approvalId: 'a1', version: 1 }); + expect((await errorOf(b.respondApproval('c', { approvalId: 'a1', hash: 'tampered', decision: 'approve' })))?.code).toBe('approval-hash-mismatch'); + expect((await b.respondApproval('c', { approvalId: 'a1', hash: 'h1', decision: 'approve' })).state).toBe('running'); + expect((await b.getApprovals('c'))[0]).toMatchObject({ version: 2, state: 'approved' }); + + const page = await b.readEvents('c', { limit: 2 }); + expect(page.events).toHaveLength(2); + const rest = await b.readEvents('c', { after: page.nextCursor }); + expect(rest.events.map(e => e.sequence)).toEqual([3, 4]); + expect(rest).toMatchObject({ resumable: true, lastAcceptedSeq: 4 }); + expect((await b.readEvents('c', { stream: 'execution' })).events).toEqual([]); + + expect((await b.cancelRun('c', 'run-1', 'user')).state).toBe('cancelled'); + expect((await b.cancelRun('c', 'run-1')).state).toBe('cancelled'); + expect((await b.cancelTurn('c', 1)).state).toBe('cancelled'); + expect(harnesses.opencode.cancelled).toEqual([{ runId: 'run-1', seq: 1 }]); + expect((await errorOf(b.cancelRun('c', 'missing')))).toMatchObject({ code: 'not-found', status: 404 }); + expect(await b.health()).toMatchObject({ ok: true, live: true, ready: true }); + + const routes = new Set(server.calls.map(c => `${c.method} ${c.path.split('?')[0]!.replace(/\/c\//, '/:id/')}`)); + for (const r of [ + 'POST /v1/capabilities', 'POST /v1/conversations', 'POST /v1/conversations/:id/messages', + 'GET /v1/conversations/:id/transcript', 'GET /v1/conversations/:id/turns/1', 'POST /v1/conversations/:id/runs/run-1/cancel', + 'POST /v1/conversations/:id/turns/1/cancel', 'GET /v1/conversations/:id/approvals', 'POST /v1/conversations/:id/approvals/a1', + 'GET /v1/conversations/:id/events', 'GET /health/live', 'GET /health/ready', + ]) expect(routes, r).toContain(r); + expect(server.calls.some(c => c.path.includes('events?after='))).toBe(true); + for (const c of server.calls.filter(c => c.path.startsWith('/v1/'))) { + expect(c.headers['X-Squad-Protocol']).toBe(BACKEND_PROTOCOL_VERSION); + expect(c.headers['Authorization']).toBe('Bearer t'); + } + const send = server.calls.find(c => c.path.endsWith('/messages'))!; + expect(send.headers['Idempotency-Key']).toBe('run-1:1'); + expect(send.headers['X-Correlation-Id']).toBe('corr-1'); + }); + + it('retries transient 503s with a stable idempotency key; server effect happens once', async () => { + const local = new LocalSquadBackend(); + const server = createMockDurableSquad(local); + const b = mkHttp(server); + await b.createConversation({ conversationId: 'c' }); + server.failures.set('POST /v1/conversations/c/messages', { status: 503, remaining: 2 }); + const adm = await b.sendMessage('c', msg('r', 1)); + expect(adm.duplicate).toBe(false); + const sends = server.calls.filter(c => c.path.endsWith('/messages')); + expect(sends).toHaveLength(3); + expect(new Set(sends.map(s => s.headers['Idempotency-Key']))).toEqual(new Set(['r:1'])); + expect(await local.getTranscript('c')).toHaveLength(1); + + server.failures.set('GET /v1/conversations/c/transcript', { status: 500, remaining: 99 }); + const gaveUp = await errorOf(mkHttp(server, { maxRetries: 2 }).getTranscript('c')); + expect(gaveUp).toMatchObject({ code: 'unavailable', retryable: true }); + }); + + it('rejects stale epochs without retrying', async () => { + const server = createMockDurableSquad(new LocalSquadBackend()); + const b = mkHttp(server); + await b.createConversation({ conversationId: 'c' }); + await b.sendMessage('c', msg('r', 1, { epoch: 3 })); + const before = server.calls.length; + const err = await errorOf(b.sendMessage('c', msg('r', 2, { epoch: 2 }))); + expect(err).toMatchObject({ code: 'stale-epoch', status: 409, retryable: false }); + expect(server.calls.length - before).toBe(1); + expect(server.calls.at(-1)!.headers['X-Lease-Epoch']).toBe('2'); + }); + + it('reports liveness/readiness, degraded readiness, and falls back to /health', async () => { + const server = createMockDurableSquad(new LocalSquadBackend()); + const b = mkHttp(server); + expect(await b.health()).toMatchObject({ ok: true, live: true, ready: true, version: 'mock-1' }); + + server.health.ready = false; + server.calls.length = 0; + expect(await b.health()).toMatchObject({ ok: false, live: true, ready: false, detail: 'degraded: warming up' }); + expect(server.calls.filter(c => c.path === '/health/ready')).toHaveLength(1); // 503 probe is not retried + + server.health.live = false; + expect(await b.health()).toMatchObject({ ok: false, live: false, ready: false }); + + const legacy = vi.fn(async (u: string) => (u.endsWith('/health') ? json(200, {}) : json(404, {}))); + const old = new DurableHttpSquadBackend({ baseUrl: 'http://old.test', fetch: legacy as unknown as typeof fetch }); + expect(await old.health()).toMatchObject({ ok: true, live: true, ready: true }); + }); +}); + +// ── Scenario 5: MCP bridge ────────────────────────────────────────── + +describe('Scenario 5: backend-mcp.ts full tool coverage', () => { + const EXPECTED_TOOLS = [ + 'squad_backend_negotiate', 'squad_backend_create_conversation', 'squad_backend_send_message', + 'squad_backend_get_turn', 'squad_backend_get_transcript', 'squad_backend_read_events', + 'squad_backend_cancel_turn', 'squad_backend_cancel_run', 'squad_backend_get_approvals', + 'squad_backend_respond_approval', 'squad_backend_health', + ]; + + async function drive(backend: SquadBackend, harness: TestHarness) { + const tools = new Map(createBackendMcpTools(() => backend).map(t => [t.name, t])); + expect([...tools.keys()].sort()).toEqual([...EXPECTED_TOOLS].sort()); + for (const t of tools.values()) expect(t.inputSchema.type).toBe('object'); + const call = async (name: string, args: Record = {}) => { + const res = await callBackendMcpTool(tools.get(name)!, args); + return { isError: res.isError ?? false, body: JSON.parse(res.content[0]!.text) as any }; + }; + + const neg = await call('squad_backend_negotiate', { protocols: [BACKEND_PROTOCOL_VERSION], capabilities: ['approvals', 'cursor-reconnect'], policyCapabilities: ['approvals'] }); + expect(neg.body.effectiveCapabilities).toEqual(['approvals']); + expect((await call('squad_backend_negotiate', { protocols: ['0.1'] })).body.error).toMatchObject({ code: 'unsupported-protocol', retryable: false }); + + expect((await call('squad_backend_create_conversation', { conversationId: 'mcp-c', title: 'T' })).body.conversationId).toBe('mcp-c'); + const sent = await call('squad_backend_send_message', { + conversationId: 'mcp-c', runId: 'r', seq: 1, content: '', harness: 'claude', correlationId: 'k1', + parts: [{ type: 'text', text: 'hi' }], extensions: { 'x-claude.mode': 'plan' }, + }); + expect(sent.body).toMatchObject({ runId: 'r', seq: 1, duplicate: false, correlationId: 'k1' }); + expect((await call('squad_backend_send_message', { conversationId: 'mcp-c', runId: 'r', seq: 1, content: 'again' })).body.duplicate).toBe(true); + expect((await call('squad_backend_send_message', { conversationId: 'mcp-c', runId: 'r', seq: 9, content: 'x', extensions: { bad: 1 } })).body.error.code).toBe('invalid-request'); + expect((await call('squad_backend_send_message', { conversationId: 'mcp-c', seq: 1, content: 'x' })).isError).toBe(true); + + expect((await call('squad_backend_get_turn', { conversationId: 'mcp-c', seq: 1 })).body).toMatchObject({ runId: 'r', state: 'running' }); + expect((await call('squad_backend_get_transcript', { conversationId: 'mcp-c' })).body).toHaveLength(1); + + harness.emit({ type: 'approval-requested', runId: 'r', seq: 1, request: { approvalId: 'a1', turnSeq: 1, action: 'x', hash: 'hh' } }); + expect((await call('squad_backend_get_approvals', { conversationId: 'mcp-c' })).body[0]).toMatchObject({ version: 1, state: 'pending' }); + expect((await call('squad_backend_respond_approval', { conversationId: 'mcp-c', approvalId: 'a1', hash: 'bad', decision: 'approve' })).body.error.code).toBe('approval-hash-mismatch'); + expect((await call('squad_backend_respond_approval', { conversationId: 'mcp-c', approvalId: 'a1', hash: 'hh', decision: 'approve' })).body.state).toBe('running'); + expect((await call('squad_backend_get_approvals', { conversationId: 'mcp-c' })).body[0].version).toBe(2); + + const page = await call('squad_backend_read_events', { conversationId: 'mcp-c', limit: 2 }); + expect(page.body.events).toHaveLength(2); + const rest = await call('squad_backend_read_events', { conversationId: 'mcp-c', after: page.body.nextCursor, stream: 'control' }); + expect(rest.body.resumable).toBe(true); + expect(rest.body.events.every((e: BackendEnvelope) => e.stream === 'control')).toBe(true); + + await call('squad_backend_send_message', { conversationId: 'mcp-c', runId: 'r2', seq: 2, content: 'second', harness: 'claude' }); + expect((await call('squad_backend_cancel_turn', { conversationId: 'mcp-c', seq: 2 })).body.state).toBe('cancelled'); + expect((await call('squad_backend_cancel_run', { conversationId: 'mcp-c', runId: 'r', reason: 'stop' })).body).toMatchObject({ runId: 'r', state: 'cancelled' }); + expect((await call('squad_backend_cancel_run', { conversationId: 'mcp-c', runId: 'nope' })).body.error.code).toBe('not-found'); + expect((await call('squad_backend_health')).body).toMatchObject({ ok: true }); + } + + it('over the local backend', async () => { + const { registry, harnesses } = makeRegistry(); + await drive(new LocalSquadBackend({ harnesses: registry, kind: 'mcp' }), harnesses.claude); + }); + + it('over durable-http', async () => { + const { registry, harnesses } = makeRegistry(); + const server = createMockDurableSquad(new LocalSquadBackend({ harnesses: registry })); + await drive(mkHttp(server), harnesses.claude); + }); + + it('createMcpBackend selects durable-http via SQUAD_BACKEND_URL, else local over .squad', async () => { + const server = createMockDurableSquad(new LocalSquadBackend()); + vi.stubGlobal('fetch', server.fetch); + try { + const remote = createMcpBackend('.', { SQUAD_BACKEND_URL: 'http://durable.test', SQUAD_BACKEND_TOKEN: 'tok' }); + expect(remote.kind).toBe('durable-http'); + await remote.createConversation({ conversationId: 'c' }); + expect(server.calls[0]!.headers['Authorization']).toBe('Bearer tok'); + } finally { + vi.unstubAllGlobals(); + } + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'squad20-mcp-')); + try { + vi.mocked(resolveSquadState).mockReturnValue({ paths: { teamSquadDir: dir } } as never); + const local = createMcpBackend('.', {}); + expect(local.kind).toBe('mcp'); + await local.createConversation({ conversationId: 'c' }); + expect(fs.existsSync(path.join(dir, 'backend', 'conversations', 'c.json'))).toBe(true); + vi.mocked(resolveSquadState).mockReturnValue(null as never); + expect(() => createMcpBackend('.', {})).toThrow(/No .squad directory/); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('createSquadBackend builds each kind', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'squad20-factory-')); + try { + expect(createSquadBackend({ kind: 'local', squadDir: dir }).kind).toBe('local'); + expect(createSquadBackend({ kind: 'mcp', squadDir: dir }).kind).toBe('mcp'); + expect(createSquadBackend({ kind: 'durable-http', baseUrl: 'http://x' }).kind).toBe('durable-http'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +// ── Scenario 6: golden conformance trace ──────────────────────────── + +describe('Scenario 6: golden-trace.json conformance (local and durable-http)', () => { + const MAPPED: Record = { + TurnAccepted: 'message.accepted', + ApprovalRequested: 'approval.requested', + ApprovalDecided: 'approval.resolved', + CancellationRequested: 'cancellation.requested', + RunTerminal: 'turn.state', + }; + const ev = (name: string) => golden.events.find(e => e.event === name)!; + + async function execute(mode: 'local' | 'durable-http') { + const { registry, harnesses } = makeRegistry(); + const harness = harnesses.copilot; + const state = new MemoryStateStore(); + const local = new LocalSquadBackend({ state, harnesses: registry }); + const server = createMockDurableSquad(local); + const connect = (): SquadBackend => (mode === 'local' ? new LocalSquadBackend({ state, harnesses: registry }) : mkHttp(server)); + let client = connect(); + const { conversationId, runId } = golden; + await client.createConversation({ conversationId }); + + const accepted = ev('TurnAccepted'); + const adm = await client.sendMessage(conversationId, msg(runId, accepted['turnSeq'] as number, { harness: 'copilot' })); + expect(adm).toMatchObject({ runId, seq: accepted['turnSeq'], duplicate: false, state: accepted['state'] }); + + const requested = ev('ApprovalRequested'); + harness.emit({ type: 'approval-requested', runId, seq: accepted['turnSeq'] as number, request: { + approvalId: requested['approvalId'] as string, turnSeq: accepted['turnSeq'] as number, + action: 'golden', hash: requested['payloadHash'] as string, + } }); + const [v1] = await client.getApprovals(conversationId); + expect(v1).toMatchObject({ version: requested['version'], state: requested['status'], scope: { kind: requested['scope'] }, hash: requested['payloadHash'] }); + + // disconnect: remember the cursor, drop the client + const beforeDrop = await client.readEvents(conversationId); + const cursor = beforeDrop.nextCursor; + const seen = new Map(beforeDrop.events.map(e => [e.id, e])); + client = connect(); + + // reconnect(cursor): nothing missed, nothing new, resumable; redelivery is deduped by id + const reconnected = ev('Reconnected'); + const resume = await client.readEvents(conversationId, { after: cursor }); + expect(resume.resumable).toBe(reconnected['resumable']); + expect(resume.events).toEqual([]); + expect(resume.lastAcceptedSeq).toBe(beforeDrop.events.at(-1)!.sequence); + const replay = await client.readEvents(conversationId); + replay.events.forEach(e => seen.set(e.id, e)); + expect(seen.size).toBe(beforeDrop.events.length); + + const decided = ev('ApprovalDecided'); + const turn = await client.respondApproval(conversationId, { + approvalId: decided['approvalId'] as string, hash: decided['payloadHash'] as string, decision: 'approve', + }); + expect(turn.state).toBe('running'); + // at-most-once visible effect: a retried decision does not apply again + expect((await errorOf(client.respondApproval(conversationId, { + approvalId: decided['approvalId'] as string, hash: decided['payloadHash'] as string, decision: 'approve', + })))?.code).toBe('approval-not-pending'); + + const cancelled = await client.cancelRun(conversationId, runId, ev('CancellationRequested')['reason'] as string); + expect(cancelled.state).toBe(golden.expectedTerminalState); + await client.cancelRun(conversationId, runId); + expect((await client.sendMessage(conversationId, msg(runId, accepted['turnSeq'] as number, { harness: 'copilot' }))).duplicate).toBe(true); + + const finalPage = await client.readEvents(conversationId); + const approvals = await client.getApprovals(conversationId); + return { harness, finalPage, approvals, resume, reconnected, decided }; + } + + it.each(['local', 'durable-http'] as const)('%s: executes the normative trace', async (mode) => { + const r = await execute(mode); + const types = r.finalPage.events.map(e => e.type); + expect(types).toEqual([ + 'conversation.created', + ...golden.events.map(e => MAPPED[e.event]).filter((t): t is string => Boolean(t)), + ]); + + const byType = (t: string) => r.finalPage.events.find(e => e.type === t)!; + expect((byType('approval.requested').payload as any).request).toMatchObject({ version: 1, hash: ev('ApprovalRequested')['payloadHash'], scope: { kind: 'turn' } }); + expect(byType('approval.resolved').payload).toMatchObject({ version: 2, approvalId: r.decided['approvalId'], decision: 'approve' }); + expect(r.approvals).toHaveLength(1); + expect(r.approvals[0]).toMatchObject({ version: 2, state: 'approved', hash: ev('ApprovalDecided')['payloadHash'] }); + expect(byType('cancellation.requested').payload).toMatchObject({ runId: golden.runId, reason: 'user-requested' }); + const terminal = r.finalPage.events.at(-1)!; + expect(terminal.payload).toMatchObject({ runId: golden.runId, state: golden.expectedTerminalState }); + expect(terminal.causationId).toBe(byType('cancellation.requested').id); + + // reconnect semantics from the fixture + expect(r.reconnected['resumable']).toBe(true); + expect(r.resume.resumable).toBe(true); + expect(r.finalPage.resumable).toBe(true); + expect(r.finalPage.lastAcceptedSeq).toBe(terminal.sequence); + expect(r.finalPage.events.map(e => e.sequence)).toEqual(r.finalPage.events.map((_, i) => i + 1)); + + // exactly-once harness effects despite retries/redelivery + expect(r.harness.submitted).toEqual([{ runId: golden.runId, seq: ev('TurnAccepted')['turnSeq'] }]); + expect(r.harness.approvals).toEqual([ev('ApprovalDecided')['approvalId']]); + expect(r.harness.cancelled).toEqual([{ runId: golden.runId, seq: ev('TurnAccepted')['turnSeq'] }]); + + // no lease/checkpoint internals leak into client-facing pages or cursors + const wire = JSON.stringify(r.finalPage); + expect(wire).not.toMatch(/lease|checkpoint|epoch/i); + for (const e of r.finalPage.events) { + expect(Buffer.from(e.cursor!, 'base64url').toString('utf8')).not.toMatch(/lease|checkpoint|epoch/i); + } + }); + + it('produces identical canonical history on both backends', async () => { + const local = await execute('local'); + const remote = await execute('durable-http'); + expect(canonical(remote.finalPage.events)).toEqual(canonical(local.finalPage.events)); + expect(remote.finalPage.lastAcceptedSeq).toBe(local.finalPage.lastAcceptedSeq); + expect(remote.approvals.map(a => a.version)).toEqual(local.approvals.map(a => a.version)); + }); +}); From e50955a26054e4ac8d86df08e2529f88e2b87901 Mon Sep 17 00:00:00 2001 From: Copilot Date: Sat, 17 Oct 2026 22:27:39 +0300 Subject: [PATCH 07/46] docs: add Squad 2.0 architecture guides Closes #2069 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 995eea17-c356-4912-90ea-5e5b144e7ba9 --- docs/SQUAD-2.0-OVERVIEW.md | 333 +++++++++++++++ .../docs/concepts/squad-2-0-architecture.md | 145 +++++++ .../docs/scenarios/client-compatibility.md | 2 + .../scenarios/using-squad-across-clients.md | 100 +++++ docs/src/navigation.ts | 2 + .../durable-backend-and-custom-harness.md | 392 ++++++++++++++++++ docs/tutorials/multi-client-quickstart.md | 343 +++++++++++++++ test/docs-build.test.ts | 3 +- 8 files changed, 1319 insertions(+), 1 deletion(-) create mode 100644 docs/SQUAD-2.0-OVERVIEW.md create mode 100644 docs/src/content/docs/concepts/squad-2-0-architecture.md create mode 100644 docs/src/content/docs/scenarios/using-squad-across-clients.md create mode 100644 docs/tutorials/durable-backend-and-custom-harness.md create mode 100644 docs/tutorials/multi-client-quickstart.md diff --git a/docs/SQUAD-2.0-OVERVIEW.md b/docs/SQUAD-2.0-OVERVIEW.md new file mode 100644 index 000000000..2359b5364 --- /dev/null +++ b/docs/SQUAD-2.0-OVERVIEW.md @@ -0,0 +1,333 @@ +# Squad 2.0 Overview + +Squad 2.0 is the architectural split that stops treating "the editor" and "the backend" as the same thing. + +In Squad 1.x, a lot of the conversation model, routing assumptions, and execution behavior were effectively bundled with the host you happened to be using. That worked fine as long as your world was mostly one client, one runtime, one execution story. + +Squad 2.0 is the version where we deliberately break that coupling. + +The goal is simple: + +- keep the **team experience** consistent; +- let the **host client** be native to Copilot, Claude Code, Kiro, OpenCode, or Cursor; +- let the **backend** stay local or move to **DurableSquad** over HTTP; +- let the **execution harness** evolve independently without rewriting every client projection. + +That is what makes the same Squad team portable across hosts without turning the protocol into a pile of editor-specific hacks. + +--- + +## Why Squad 2.0 exists + +Squad 2.0 solves three practical problems. + +### 1. The client should not own the conversation contract + +A host can be fantastic at UI, subagents, or local tooling and still be a terrible place to define durable turn ordering, replay semantics, approvals, or cancellation. Those are backend concerns. + +### 2. MCP is useful, but MCP is not the whole protocol + +MCP is a great transport for exposing tools locally. It is not, by itself, the canonical wire contract for long-lived conversations, resumable event logs, versioned approvals, or `runId`-scoped cancellation. + +### 3. Execution fabric changes faster than the team model + +Sometimes you want local in-process execution. Sometimes you want a remote durable broker. Sometimes you want a custom harness. The team should not care. The protocol should. + +--- + +## The 3-plane architecture + +Squad 2.0 is intentionally split into three planes. + +```mermaid +flowchart LR + user[Developer] --> client + + subgraph CP[Client Plane\n@bradygaster/squad-sdk/clients] + client[Host-native adapter\nCopilot / Claude / Kiro / OpenCode / Cursor] + projection[Workspace projection\nagent files + MCP config + rules] + caps[ClientCapabilities\ndispatch + transport modes] + client --> projection + client --> caps + end + + subgraph BP[Control / Broker Plane\n@bradygaster/squad-sdk/backend] + backend[SquadBackend / IConversationBackend] + log[Durable ordered event log\nBackendEnvelope + monotonic seq] + auth[Capability negotiation\nclient ∩ backend ∩ policy] + approvals[Versioned approvals\nv1 requested -> v2 decided] + resume[Opaque resume cursor\nlastAcceptedSeq + resumable] + backend --> log + backend --> auth + backend --> approvals + backend --> resume + end + + subgraph EP[Execution Plane\nHarnessAdapter SPI] + harness[HarnessAdapter] + exec[Agent runs, tools, streaming, tasks] + fence[Lease / epoch fencing\n(runId, seq) dedupe] + harness --> exec + harness --> fence + end + + client --> backend + backend --> harness +``` + +### Plane 1: Client Plane (`@bradygaster/squad-sdk/clients`) + +The client plane is where Squad becomes native to the host you are actually using. + +| Concern | What lives here | Examples | +|---|---|---| +| Workspace projection | Host-native files and config | `.mcp.json`, `.claude/agents/squad.md`, `.kiro/settings/mcp.json`, `.cursor/rules/squad.mdc` | +| Capability declaration | Host features the adapter can honestly claim | `ClientCapabilities`, `transportModes`, steering support, subagent support | +| Coordinator dispatch | How the coordinator asks the host to run specialists | Copilot `task` / `create_session` / `runSubagent`, Claude `Task`, OpenCode `@agent`, Cursor subagents | +| Workspace description | The normalized host view of root, working dir, agent anchor, and MCP config | the `projectWorkspace()` concept from the adapter profile | + +The important rule is that the client plane owns **bootstrap and translation**, not conversation truth. + +It decides things like: + +- which files get generated for a client; +- how the host discovers the Squad coordinator; +- how that host dispatches specialists; +- whether the host reaches the backend through MCP, HTTP, or an in-process path. + +It does **not** decide durable sequencing, authorization, approval validity, or replay semantics. + +### Plane 2: Control / Broker Plane (`@bradygaster/squad-sdk/backend`) + +This is the semantic center of gravity. + +It owns the durable conversation contract through `SquadBackend` / `IConversationBackend`, including: + +- the ordered event log (`BackendEnvelope`); +- per-conversation monotonic `seq`; +- stable `runId` independent from `seq`; +- opaque resume cursors and reconnect semantics; +- capability negotiation through `/v1/capabilities`; +- versioned approvals; +- `runId`-scoped cancellation; +- canonical error shapes. + +```mermaid +sequenceDiagram + participant Host as Client adapter + participant Broker as SquadBackend + participant Harness as HarnessAdapter + + Host->>Broker: sendMessage(runId, seq) + Broker-->>Host: BackendAdmission + cursor + Broker->>Harness: submit(runId, seq) + Harness-->>Broker: partial output / task events + Harness-->>Broker: approval-requested(v1) + Host-xBroker: disconnect + Host->>Broker: readEvents(after=cursor) + Broker-->>Host: missed events + nextCursor + Host->>Broker: respondApproval(v2, payloadHash) + Host->>Broker: cancelRun(runId) + Broker-->>Host: terminal turn state +``` + +A few details matter a lot here: + +#### Durable ordered event log + +`BackendEnvelope` is the canonical event shape. Every transport can project into it, but the meaning lives here. + +#### Monotonic ordering + +`seq` is the per-conversation ordering coordinate. It is how the broker preserves canonical history. It is not the thing you cancel. + +#### Resume without leaking backend internals + +Clients reconnect with opaque cursors. The backend returns `nextCursor`, `lastAcceptedSeq`, and `resumable`. The client treats that cursor as a token, not a structure. + +#### Capability intersection + +Effective behavior is negotiated, not guessed: + +```text +effectiveCapabilities = clientCapabilities ∩ backendCapabilities ∩ policyCapabilities +``` + +That means: + +- the client cannot grant itself powers it does not have; +- the backend cannot accidentally expose things policy denied; +- a config file cannot widen authority just because it listed an optimistic client slug. + +#### Versioned approvals + +Approvals are durable resources. The pattern is: + +- **v1**: request is created; +- **v2**: request is decided; +- the decision must echo the immutable payload digest. + +In the current SDK types the response field is named `hash`; in the protocol docs and fixtures you will also see this described as the approval payload hash. Same idea, same safety rule: no stale or mutated approval payload may execute. + +#### `runId` cancellation + +Cancellation targets the stable run lifecycle, not a host-local completion flag and not a turn sequence. That is why the backend exposes `cancelRun(conversationId, runId, reason?)`. + +### Plane 3: Execution Plane (`HarnessAdapter` SPI) + +This is where work actually runs. + +A `HarnessAdapter` translates the broker contract into a concrete execution primitive such as a local session runner, a durable worker, or a provider-specific runtime. + +| Concern | What lives here | +|---|---| +| Work submission | `submit(conversationId, runId, seq, ...)` | +| Event streaming | `subscribe(...)` | +| Lifecycle control | `cancel(...)`, `respondApproval(...)` | +| Runtime description | `describe()` | +| Vendor extensions | `x-.*` namespaces | + +The broker expects the execution plane to respect these rules: + +- extension keys stay in the `x-.*` namespace; +- duplicate submissions are harmless because the broker dedupes on `(runId, seq)`; +- lease / epoch fencing prevents stale workers from writing after a takeover; +- harness-specific behavior must not change the canonical conversation semantics. + +--- + +## Why MCP is a transport bridge, not the wire contract + +This distinction is the part that saves Squad 2.0 from turning into five subtly incompatible products. + +```mermaid +flowchart TD + host[Client host] --> choice{How does this host reach the backend?} + choice --> mcp[MCP stdio bridge\n.mcp.json / .kiro/settings/mcp.json / opencode.json / .cursor/mcp.json] + choice --> http[Direct HTTP backend\nDurableHttpSquadBackend\nSQUAD_BACKEND_URL] + mcp --> contract[Same SquadBackend contract] + http --> contract +``` + +### Local bridge path + +A host can load Squad through local MCP configuration: + +- `.mcp.json` +- `.kiro/settings/mcp.json` +- `opencode.json` +- `.cursor/mcp.json` + +That is the **bridge**. + +### Direct backend path + +A host or application can also talk directly to the backend over HTTP using `DurableHttpSquadBackend` and `SQUAD_BACKEND_URL`. + +That is a **different transport** reaching the same contract. + +### Why the distinction matters + +If MCP were the protocol, every host quirk would become protocol law. + +If `SquadBackend` is the protocol, then: + +- MCP stays useful for local tool transport; +- HTTP stays useful for durable remote operation; +- both still produce the same durable conversation semantics. + +That is the whole game. + +--- + +## Security and conformance gates (`G1`-`G9`) + +Squad 2.0 is not "done" because the markdown says so. It is done when the gates hold. + +| Gate | What it proves | +|---|---| +| `G1` | stable `runId` exists independently from turn ordering | +| `G2` | per-conversation ordering stays monotonic by `seq` | +| `G3` | approvals are durable, versioned resources with immutable payload hashes | +| `G4` | effective capabilities are negotiated as `client ∩ backend ∩ policy` | +| `G5` | reconnect is cursor-based and reports `lastAcceptedSeq` + `resumable` | +| `G6` | cancellation targets `runId`, not host-local completion state | +| `G7` | health probes stay outside the versioned protocol resource tree | +| `G8` | projections are loss-tolerant for unknown durable events and extensions | +| `G9` | the frozen fixtures and cross-adapter trace pass unchanged | + +You can think of them in three buckets: + +### Security / isolation + +- path guards; +- non-clobbering MCP merges; +- no secrets in generated projections; +- stale writers get fenced. + +### Protocol correctness + +- ordering is monotonic; +- reconnect is resumable; +- approvals are versioned and hash-bound; +- cancellation is scoped to the run lifecycle. + +### Conformance + +- every adapter sees the same canonical history; +- unknown future events do not crash a projection; +- the golden fixtures remain immutable across implementations. + +--- + +## The golden conformance trace + +The canonical trace frozen under `squad-protocol-v2.0-rc1` is: + +```text +TurnAccepted(runId, seq) +-> stream partial output +-> ApprovalRequested(v1) +-> disconnect +-> reconnect(cursor) +-> ApprovalDecided(v2) +-> CancellationRequested(runId) +-> terminal state +``` + +That trace sounds small. It is doing a lot of work. + +It proves that the implementation can survive: + +- streaming output before the approval boundary; +- disconnection and reconnect from an opaque cursor; +- exactly-once approval effects even after replay attempts; +- the cancel-versus-complete race; +- identical canonical history across Copilot, Claude, Kiro, OpenCode, and Cursor projections. + +If one adapter renders prettier output than another, fine. If one adapter changes the canonical history, not fine. + +--- + +## What this buys you in practice + +When Squad 2.0 is wired correctly: + +- you can initialize the same repo for multiple clients with `--client` projections; +- you can start local with MCP and later move to `DurableSquad` over HTTP; +- you can swap or add harnesses without rewriting client projections; +- you can reason about failures using one contract instead of five host-specific stories. + +That is the point of the split. + +Not more abstraction for its own sake. Less accidental coupling. + +--- + +## Related docs + +- `docs/tutorials/multi-client-quickstart.md` +- `docs/tutorials/durable-backend-and-custom-harness.md` +- `docs/src/content/docs/concepts/squad-2-0-architecture.md` +- `docs/src/content/docs/scenarios/using-squad-across-clients.md` +- `docs/src/content/docs/scenarios/client-compatibility.md` diff --git a/docs/src/content/docs/concepts/squad-2-0-architecture.md b/docs/src/content/docs/concepts/squad-2-0-architecture.md new file mode 100644 index 000000000..806fac77b --- /dev/null +++ b/docs/src/content/docs/concepts/squad-2-0-architecture.md @@ -0,0 +1,145 @@ +--- +title: Squad 2.0 architecture +description: The three-plane client, control, and execution architecture that lets Squad run across Copilot, Claude Code, Kiro, OpenCode, and Cursor with either MCP or a durable HTTP backend. +--- + +# Squad 2.0 architecture + +Squad 2.0 is the version of Squad where the client host, the conversation backend, and the execution harness stop pretending to be one thing. + +That split is what makes one team portable across GitHub Copilot, Claude Code, Kiro, OpenCode, and Cursor while still letting the backend stay local or move to DurableSquad. + +## Why Squad 2.0 exists + +We wanted three things at the same time: + +- native host experiences in different clients; +- one durable conversation contract; +- pluggable execution runtimes. + +The simplest way to get all three is to separate them. + +## The three planes + +```mermaid +flowchart LR + subgraph CP[Client Plane] + c1[Workspace projections] + c2[ClientCapabilities] + c3[Coordinator dispatch adapters] + end + + subgraph BP[Control / Broker Plane] + b1[SquadBackend / IConversationBackend] + b2[BackendEnvelope + monotonic seq] + b3[Cursor resume + approvals + cancelRun] + end + + subgraph EP[Execution Plane] + e1[HarnessAdapter SPI] + e2[x-harness.* extensions] + e3[Lease / epoch fencing] + end + + CP --> BP --> EP +``` + +### Client Plane + +The client plane lives in `@bradygaster/squad-sdk/clients`. + +Its job is to project Squad into the files and affordances a host already understands: + +- Copilot: `.github/agents/squad.agent.md` and `.mcp.json` +- Claude Code: `.claude/agents/squad.md`, `CLAUDE.md`, `.mcp.json` +- Kiro: `.kiro/steering/squad.md`, `.kiro/agents/squad.json`, `.kiro/settings/mcp.json` +- OpenCode: `.opencode/agent/squad.md`, `opencode.json` +- Cursor: `.cursor/agents/squad.md`, `.cursor/rules/squad.mdc`, `.cursor/mcp.json` + +The adapter profile also defines the normalized `projectWorkspace()` view so hosts can describe their root, working directory, anchor file, and MCP config consistently. + +### Control / Broker Plane + +The control plane lives in `@bradygaster/squad-sdk/backend`. + +This is where the real contract lives: + +- durable ordered event log via `BackendEnvelope`; +- monotonic per-conversation `seq`; +- opaque resume cursors; +- capability intersection through `/v1/capabilities`; +- versioned approvals; +- `runId`-scoped cancellation; +- canonical health and error semantics. + +In short: the backend owns conversation truth. + +### Execution Plane + +The execution plane is the `HarnessAdapter` SPI. + +Harnesses are allowed to be different. Their effects are not allowed to change the canonical conversation history. + +That is why the broker enforces: + +- `(runId, seq)` idempotency; +- `x-.*` vendor namespaces; +- lease / epoch fencing; +- durable approval and cancellation rules. + +## MCP is a bridge, not the protocol + +A host can reach the same backend in two ways: + +```mermaid +flowchart TD + host[Host client] --> pick{Transport} + pick --> mcp[MCP bridge\nlocal stdio config] + pick --> http[HTTP backend\nDurableHttpSquadBackend] + mcp --> contract[SquadBackend contract] + http --> contract +``` + +That distinction matters. + +- `.mcp.json`, `.kiro/settings/mcp.json`, `opencode.json`, and `.cursor/mcp.json` are **transport configuration**. +- `SquadBackend` is the **conversation contract**. + +So a repo can start locally over MCP and later point at `SQUAD_BACKEND_URL` without rewriting every client projection. + +## Security and conformance gates + +Squad 2.0 compatibility claims are gated by `G1` through `G9`: + +| Gate | Proof | +|---|---| +| `G1` | stable `runId` independent from turn ordering | +| `G2` | monotonic `seq` ordering | +| `G3` | durable versioned approvals with immutable payload hashes | +| `G4` | `client ∩ backend ∩ policy` capability intersection | +| `G5` | cursor-based recovery with `lastAcceptedSeq` and `resumable` | +| `G6` | `runId` cancellation | +| `G7` | health endpoints outside the protocol tree | +| `G8` | loss-tolerant unknown events and extensions | +| `G9` | immutable golden fixtures and cross-adapter trace | + +## Golden conformance trace + +The trace every adapter has to survive is: + +```text +TurnAccepted(runId, seq) +-> ApprovalRequested(v1) +-> disconnect +-> reconnect(cursor) +-> ApprovalDecided(v2) +-> CancellationRequested(runId) +-> terminal state +``` + +If Copilot, Claude, Kiro, OpenCode, and Cursor all produce the same canonical history for that trace, the split is working. + +## Read next + +- [Using Squad across clients](../scenarios/using-squad-across-clients.md) +- [Client compatibility](../scenarios/client-compatibility.md) diff --git a/docs/src/content/docs/scenarios/client-compatibility.md b/docs/src/content/docs/scenarios/client-compatibility.md index 11eda0573..46989c302 100644 --- a/docs/src/content/docs/scenarios/client-compatibility.md +++ b/docs/src/content/docs/scenarios/client-compatibility.md @@ -2,6 +2,8 @@ > **Quick answer:** Not sure which interface to use? See [Choose your interface](../get-started/choose-your-interface.md) for a concise decision tree and comparison. +> **New in Squad 2.0:** `squad init --client copilot|claude|kiro|opencode|cursor|all` and `squad upgrade --client ...` now project host-native adapters while keeping the backend contract portable. Start with [Using Squad across clients](./using-squad-across-clients.md) for the hands-on walkthrough and [Squad 2.0 architecture](../concepts/squad-2-0-architecture.md) for the three-plane model, MCP-vs-HTTP transport story, and the `G1`-`G9` conformance gates. + Squad runs on multiple Copilot surfaces — each with its own agent spawning mechanism, tool set, and constraints. This document maps Squad's core capabilities across CLI, VS Code, JetBrains, and GitHub.com to help you understand what works where. ## Quick Reference diff --git a/docs/src/content/docs/scenarios/using-squad-across-clients.md b/docs/src/content/docs/scenarios/using-squad-across-clients.md new file mode 100644 index 000000000..fa352c3a1 --- /dev/null +++ b/docs/src/content/docs/scenarios/using-squad-across-clients.md @@ -0,0 +1,100 @@ +--- +title: Using Squad across clients +description: Step-by-step guidance for projecting one Squad team into Copilot, Claude Code, Kiro, OpenCode, and Cursor with the new --client adapters. +--- + +# Using Squad across clients + +Squad 2.0 adds host-native client projections through `squad init --client ...` and `squad upgrade --client ...`. + +The important idea is that one `.squad/` team can now project into multiple client hosts without changing the underlying backend contract. + +## Quick commands + +```bash +npx squad init --client claude +npx squad init --client kiro +npx squad init --client opencode +npx squad init --client cursor +npx squad init --client all +``` + +For an existing repo: + +```bash +npx squad upgrade --client copilot,claude,cursor +``` + +## What each adapter generates + +| Client | Generated files | +|---|---| +| Claude Code | `.claude/agents/squad.md`, optional `.claude/agents/squad-.md`, `.mcp.json`, `CLAUDE.md` managed block | +| Kiro | `.kiro/steering/squad.md`, `.kiro/agents/squad.json`, `.kiro/settings/mcp.json` | +| OpenCode | `.opencode/agent/squad.md`, `opencode.json` | +| Cursor | `.cursor/rules/squad.mdc`, `.cursor/agents/squad.md`, `.cursor/mcp.json` | +| Copilot | `.github/agents/squad.agent.md` plus `.mcp.json` | + +## Claude Code walkthrough + +1. Run `npx squad init --client claude`. +2. Open `.claude/agents/squad.md` to inspect the coordinator prompt. +3. Confirm `.mcp.json` contains `squad_state`. +4. Check `CLAUDE.md` for the managed Squad marker block. +5. Run `claude --agent squad` or delegate to the `squad` subagent. + +## Kiro walkthrough + +1. Run `npx squad init --client kiro`. +2. Verify `.kiro/steering/squad.md` and `.kiro/agents/squad.json` were created. +3. Confirm `.kiro/settings/mcp.json` contains the additive `squad_state` entry. +4. Open the repo in Kiro and use the generated `squad` agent. + +## OpenCode walkthrough + +1. Run `npx squad init --client opencode`. +2. Verify `.opencode/agent/squad.md` exists. +3. Check `opencode.json` for `mcp.squad_state`. +4. Invoke Squad with the generated agent anchor or host task flow. + +## Cursor walkthrough + +1. Run `npx squad init --client cursor`. +2. Verify `.cursor/rules/squad.mdc`, `.cursor/agents/squad.md`, and `.cursor/mcp.json` exist. +3. Open Cursor and select the `squad` agent. +4. Let the coordinator route work from there. + +## Polyglot repo walkthrough + +If you want one repo to work everywhere, either: + +```bash +npx squad init --client all +``` + +or incrementally: + +```bash +npx squad upgrade --client copilot,claude,cursor +``` + +Squad persists only canonical client ids in `.squad/config.json`: + +```json +{ + "clients": ["copilot", "claude", "cursor"] +} +``` + +That list is projection metadata only. It does not carry secrets, machine paths, URLs, thread ids, lease tokens, or checkpoint state. + +## Why the merge behavior matters + +Every client adapter uses additive config merging for shared JSON files. Existing MCP servers and top-level settings are preserved. Malformed JSON is refused instead of overwritten. + +That means you can safely add Squad to a repo that already has GitHub, Azure, or other MCP servers configured. + +## Read next + +- [Squad 2.0 architecture](../concepts/squad-2-0-architecture.md) +- [Client compatibility](./client-compatibility.md) diff --git a/docs/src/navigation.ts b/docs/src/navigation.ts index bed1f45d4..d7df86d37 100644 --- a/docs/src/navigation.ts +++ b/docs/src/navigation.ts @@ -22,6 +22,7 @@ export const NAV_SECTIONS: NavSection[] = [ dir: 'concepts', items: [ { title: 'Architecture', slug: 'concepts/architecture' }, + { title: 'Squad 2.0 Architecture', slug: 'concepts/squad-2-0-architecture' }, { title: 'Your Team', slug: 'concepts/your-team' }, { title: 'Agent Identity Provenance', slug: 'concepts/agent-identity-provenance' }, { title: 'Memory & Knowledge', slug: 'concepts/memory-and-knowledge' }, @@ -141,6 +142,7 @@ export const NAV_SECTIONS: NavSection[] = [ { title: 'Remote Q&A', slug: 'scenarios/remote-qa' }, { title: 'Disaster Recovery', slug: 'scenarios/disaster-recovery' }, { title: 'Client Compatibility', slug: 'scenarios/client-compatibility' }, + { title: 'Using Squad Across Clients', slug: 'scenarios/using-squad-across-clients' }, { title: 'Aspire Dashboard', slug: 'scenarios/aspire-dashboard' }, { title: 'Keep My Squad', slug: 'scenarios/keep-my-squad' }, { title: 'Troubleshooting', slug: 'scenarios/troubleshooting' }, diff --git a/docs/tutorials/durable-backend-and-custom-harness.md b/docs/tutorials/durable-backend-and-custom-harness.md new file mode 100644 index 000000000..03897b503 --- /dev/null +++ b/docs/tutorials/durable-backend-and-custom-harness.md @@ -0,0 +1,392 @@ +# Durable backend and custom harness tutorial + +This tutorial is for the moment when local Squad is no longer enough. + +Maybe you want a durable backend. Maybe you want reconnect after disconnect. Maybe you want your own execution harness. Good. This is the part where Squad 2.0 stops being theory. + +--- + +## Part 1: switch from local backend to DurableSquad over HTTP + +The current backend factory supports three modes: + +- `kind: 'local'` +- `kind: 'mcp'` +- `kind: 'durable-http'` + +If you want the same conversation contract backed by a remote durable service, use `durable-http`. + +### Step 1: point the process at the remote backend + +Bash: + +```bash +export SQUAD_BACKEND_URL="https://your-durable-squad.example.com" +export SQUAD_BACKEND_TOKEN="...optional bearer token..." +``` + +PowerShell: + +```powershell +$env:SQUAD_BACKEND_URL = "https://your-durable-squad.example.com" +$env:SQUAD_BACKEND_TOKEN = "...optional bearer token..." +``` + +The CLI-side MCP bridge already understands this convention: when `SQUAD_BACKEND_URL` is present, the bridge can route to `durable-http` instead of the local in-process backend. + +### Step 2: build the backend explicitly in TypeScript + +```ts +import { + BACKEND_PROTOCOL_VERSION, + createSquadBackend, + type BackendMessage, + type ProtocolHello, +} from '@bradygaster/squad-sdk/backend'; + +const baseUrl = process.env.SQUAD_BACKEND_URL; +if (!baseUrl) throw new Error('SQUAD_BACKEND_URL is required'); + +const backend = createSquadBackend({ + kind: 'durable-http', + baseUrl, + token: process.env.SQUAD_BACKEND_TOKEN, +}); + +const hello: ProtocolHello = { + protocols: [BACKEND_PROTOCOL_VERSION], + clientName: 'docs-sample', + contentParts: ['text', 'tool-call', 'tool-result'], + capabilities: ['stream:control', 'stream:execution', 'resume', 'approvals', 'cancel:run'], + policyCapabilities: ['stream:control', 'stream:execution', 'resume', 'approvals', 'cancel:run'], +}; + +const negotiated = await backend.negotiate(hello); +console.log(negotiated.effectiveCapabilities); +``` + +### Step 3: create a conversation and send a message + +```ts +const conversation = await backend.createConversation({ title: 'Durable demo' }); + +const message: BackendMessage = { + runId: 'run-001', + seq: 1, + role: 'user', + content: 'Please draft the release notes.', + correlationId: 'demo-release-notes', +}; + +const admission = await backend.sendMessage(conversation.conversationId, message); +console.log(admission.cursor); +``` + +### Step 4: reconnect with `readEvents()` after a disconnect + +The durable path is where cursor-based resume matters. + +```ts +let cursor = admission.cursor; + +const firstPage = await backend.readEvents(conversation.conversationId, { + after: cursor, + stream: 'control', + limit: 50, +}); + +cursor = firstPage.nextCursor; +console.log({ + lastAcceptedSeq: firstPage.lastAcceptedSeq, + resumable: firstPage.resumable, +}); + +// later, after losing the connection +const missed = await backend.readEvents(conversation.conversationId, { + after: cursor, + limit: 50, +}); + +cursor = missed.nextCursor; +``` + +Treat the cursor as opaque. Do not parse it. Do not synthesize one. Just store it and hand it back. + +### Step 5: work with versioned approvals + +The backend exposes durable approval resources in request order: + +```ts +const approvals = await backend.getApprovals(conversation.conversationId); +const pending = approvals.find((a) => a.state === 'pending'); + +if (pending) { + await backend.respondApproval(conversation.conversationId, { + approvalId: pending.approvalId, + hash: pending.hash, + decision: 'approve', + }); +} +``` + +A useful mental model: + +- approval **v1** is the request; +- approval **v2** is the decision; +- the response must echo the immutable hash / payload hash for the request. + +If the payload changed, or the approval is stale, the backend rejects it. That is exactly what you want. + +### Step 6: cancel by `runId` + +```ts +await backend.cancelRun(conversation.conversationId, 'run-001', 'user asked to stop'); +``` + +That is intentionally `runId`-scoped. Cancellation follows the run lifecycle, not a local spinner in one UI. + +--- + +## Part 2: write a custom `HarnessAdapter` + +The current runtime `HarnessAdapter` interface is intentionally small: + +- `submit` +- `subscribe` +- `cancel` +- `respondApproval` +- `describe` + +For a practical integration, it is perfectly fine to add a local `projectWorkspace()` helper on your concrete class so you also line up with the broader Squad 2.0 adapter profile. + +### A complete working example + +```ts +import { + DefaultHarnessRegistry, + validateExtensions, + type ApprovalResponse, + type HarnessAdapter, + type HarnessCapabilities, + type HarnessDescriptor, + type HarnessEventEnvelope, + type HarnessSubmission, +} from '@bradygaster/squad-sdk/backend'; + +import path from 'node:path'; + +type Listener = (event: HarnessEventEnvelope) => void; + +export class DemoHarnessAdapter implements HarnessAdapter { + readonly id = 'demo'; + + readonly capabilities: HarnessCapabilities = { + streaming: true, + approvals: true, + cancellation: true, + tools: true, + extensionNamespaces: ['x-demo'], + }; + + private readonly listeners = new Map>(); + private readonly cancelledRuns = new Set(); + + constructor(private readonly repoRoot: string) {} + + async submit(submission: HarnessSubmission): Promise { + validateExtensions(submission.extensions, this.capabilities.extensionNamespaces); + + this.emit(submission.conversationId, { + runId: submission.runId, + seq: submission.seq, + type: 'state', + state: 'running', + }); + + this.emit(submission.conversationId, { + runId: submission.runId, + seq: submission.seq, + type: 'message', + message: { + runId: submission.runId, + seq: submission.seq, + role: 'assistant', + content: 'Demo harness accepted the run.', + harness: this.id, + extensions: { + 'x-demo.worker': 'local-demo', + }, + }, + }); + + this.emit(submission.conversationId, { + runId: submission.runId, + seq: submission.seq, + type: 'approval-requested', + request: { + approvalId: `approval-${submission.runId}`, + turnSeq: submission.seq, + action: 'demo.write', + hash: 'sha256:demo-payload', + payload: { target: 'docs/demo.txt' }, + }, + }); + } + + subscribe(conversationId: string, listener: Listener): () => void { + const bucket = this.listeners.get(conversationId) ?? new Set(); + bucket.add(listener); + this.listeners.set(conversationId, bucket); + return () => { + bucket.delete(listener); + if (bucket.size === 0) this.listeners.delete(conversationId); + }; + } + + async cancel(conversationId: string, runId: string, seq: number): Promise { + this.cancelledRuns.add(runId); + this.emit(conversationId, { + runId, + seq, + type: 'state', + state: 'cancelled', + }); + } + + async respondApproval(conversationId: string, response: ApprovalResponse): Promise { + const approvalRunId = response.approvalId.replace(/^approval-/, ''); + this.emit(conversationId, { + runId: approvalRunId, + seq: 1, + type: 'message', + message: { + runId: approvalRunId, + seq: 1, + role: 'tool', + content: `Approval ${response.decision} for ${response.approvalId}`, + harness: this.id, + }, + }); + + if (!this.cancelledRuns.has(approvalRunId)) { + this.emit(conversationId, { + runId: approvalRunId, + seq: 1, + type: 'state', + state: 'completed', + }); + } + } + + describe(): HarnessDescriptor { + return { + id: this.id, + displayName: 'Demo harness', + version: '0.1.0', + capabilities: this.capabilities, + }; + } + + // Optional today, but useful and aligned with the broader adapter profile. + projectWorkspace() { + return { + repoRoot: this.repoRoot, + workingDirectory: path.join(this.repoRoot, 'docs'), + workspaceKind: 'repository' as const, + supportFiles: ['docs/demo.txt'], + }; + } + + private emit(conversationId: string, event: HarnessEventEnvelope) { + for (const listener of this.listeners.get(conversationId) ?? []) listener(event); + } +} + +const registry = new DefaultHarnessRegistry(); +registry.register(new DemoHarnessAdapter(process.cwd())); +``` + +### Why this example is worth copying + +It demonstrates the things that actually matter: + +- the harness advertises one stable id (`demo`); +- extension fields stay in the `x-demo.*` namespace; +- `validateExtensions()` protects that namespace boundary; +- approval and cancellation events flow back through the broker contract instead of becoming ad hoc side channels; +- the harness can expose workspace metadata without changing the broker API. + +### Where idempotency really lives + +The harness should behave sensibly if a submission is replayed, but the **canonical** dedupe contract lives in the broker at `(runId, seq)`. + +That is a good split of responsibility: + +- the broker guarantees durable semantics; +- the harness avoids obvious duplicate side effects; +- the client keeps one stable view of history. + +--- + +## Part 3: test against the golden conformance trace + +The protocol baseline for this work is `squad-protocol-v2.0-rc1`. + +The frozen story to verify is: + +```text +TurnAccepted(runId, seq) +-> stream partial output +-> ApprovalRequested(v1) +-> disconnect +-> reconnect(cursor) +-> ApprovalDecided(v2) +-> CancellationRequested(runId) +-> terminal state +``` + +### What your backend or harness must prove + +1. reconnect from a stored cursor returns only the missing suffix; +2. stale approval decisions do not re-run effects; +3. `cancelRun(runId)` is idempotent; +4. exactly one terminal state wins the cancel-vs-complete race; +5. all client projections observe the same canonical history. + +### Practical commands + +If you have the sibling worktrees checked out, these are the fastest validation passes: + +```bash +cd ..\squad-20-backend +npm test -- test/backend.test.ts +``` + +```bash +cd ..\squad-20-validation +npm test -- packages/squad-sdk/test/squad-20-security-contracts.test.ts +``` + +### What to look for in the validation suite + +The security / conformance suite specifically checks: + +- `G1` through `G9`; +- cursor opacity; +- approval replay rejection; +- cross-adapter canonical-history equality; +- unknown future events surviving reconnect without crashing a projection. + +If your harness or backend passes those checks, you are no longer just "integrated." You are speaking the same protocol. + +--- + +## Final advice + +If you are building something new, start local. + +If you need durable reconnect, multi-worker semantics, or remote orchestration, switch to `durable-http` early. + +If you need a custom execution story, implement `HarnessAdapter` without inventing a second protocol on the side. + +That is the Squad 2.0 trick: one contract, many hosts, many runtimes, way less drama. diff --git a/docs/tutorials/multi-client-quickstart.md b/docs/tutorials/multi-client-quickstart.md new file mode 100644 index 000000000..ad70032e4 --- /dev/null +++ b/docs/tutorials/multi-client-quickstart.md @@ -0,0 +1,343 @@ +# Multi-client quickstart for Squad 2.0 + +This is the hands-on version of Squad 2.0. + +The headline feature is the new `--client` flag on `squad init` and `squad upgrade`, which lets one repo project native files for different hosts while keeping the same underlying Squad team and backend contract. + +## What you need + +- a repo with Squad available through `npx squad` or a global `squad` install; +- a normal `.squad/` team directory; +- one or more target clients: Copilot, Claude Code, Kiro, OpenCode, or Cursor. + +If you already have Squad in the repo, use `upgrade`. If this is a fresh repo, use `init`. + +--- + +## Tutorial 1: Claude Code + +### Step 1: initialize the repo for Claude Code + +```bash +npx squad init --client claude +``` + +If the repo already has Squad and you are adding Claude later: + +```bash +npx squad upgrade --client claude +``` + +### Step 2: inspect the generated files + +You should see these Claude-facing files: + +```text +.claude/ + agents/ + squad.md + squad-.md # one per roster member when team.md already has named members +.mcp.json +CLAUDE.md +``` + +What each file does: + +- `.claude/agents/squad.md` is the coordinator entrypoint; +- `.claude/agents/squad-.md` are optional specialist wrappers that point back to each member's charter in `.squad/agents//charter.md`; +- `.mcp.json` wires the `squad_state` MCP bridge; +- `CLAUDE.md` gets a managed marker block instead of being fully overwritten. + +### Step 3: understand the `CLAUDE.md` marker block + +Squad does **not** take over the whole file. It updates only the managed block: + +```md + +## Squad + +This project uses Squad. Run `claude --agent squad` ... + +``` + +That means your own project notes above or below the block stay yours. + +### Step 4: invoke Squad in Claude Code + +Use either of these patterns: + +```text +claude --agent squad +``` + +Or, inside Claude Code, delegate to the `squad` subagent and let it route work from there. + +### Step 5: what the coordinator does in Claude + +The Claude projection tells the coordinator to dispatch specialists with the `Task` tool and to prefer per-member `.claude/agents/squad-.md` files when they exist. + +--- + +## Tutorial 2: Kiro + +### Step 1: initialize the repo for Kiro + +```bash +npx squad init --client kiro +``` + +Later refresh: + +```bash +npx squad upgrade --client kiro +``` + +### Step 2: inspect the generated files + +The Kiro projection is a little different from Claude's: + +```text +.kiro/ + steering/ + squad.md + agents/ + squad.json + settings/ + mcp.json +``` + +A practical note: the canonical prompt content lives in `.kiro/steering/squad.md`, while `.kiro/agents/squad.json` is the host registration file that points at that steering doc. + +### Step 3: what the files mean + +- `.kiro/steering/squad.md` is generated with `inclusion: always`, so the coordinator guidance is always present; +- `.kiro/agents/squad.json` declares the `squad` agent and includes the MCP config; +- `.kiro/settings/mcp.json` adds `squad_state` without deleting your other MCP servers. + +### Step 4: use Squad in Kiro + +Open the repo in Kiro and select or invoke the `squad` agent defined by `.kiro/agents/squad.json`. + +Because the steering file is always included, Kiro gets the same host-agnostic coordinator protocol plus the Kiro-specific dispatch adapter. + +--- + +## Tutorial 3: OpenCode + +### Step 1: initialize the repo for OpenCode + +```bash +npx squad init --client opencode +``` + +Or refresh an existing repo: + +```bash +npx squad upgrade --client opencode +``` + +### Step 2: inspect the generated files + +```text +.opencode/ + agent/ + squad.md +opencode.json +``` + +The current implementation writes `.opencode/agent/squad.md` as the agent anchor and `opencode.json` as the MCP/config surface. + +### Step 3: what gets configured + +- `.opencode/agent/squad.md` is the primary coordinator prompt; +- `opencode.json` receives an additive `mcp.squad_state` entry; +- existing model or MCP settings in `opencode.json` are preserved. + +Example generated MCP entry: + +```json +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "squad_state": { + "type": "local", + "command": ["squad", "state-mcp"], + "enabled": true + } + } +} +``` + +### Step 4: use Squad in OpenCode + +Invoke the coordinator with `@squad` if your OpenCode flow uses agent mentions, or use the host `task` primitive and let Squad route from there. + +--- + +## Tutorial 4: Cursor + +### Step 1: initialize the repo for Cursor + +```bash +npx squad init --client cursor +``` + +Later refresh: + +```bash +npx squad upgrade --client cursor +``` + +### Step 2: inspect the generated files + +```text +.cursor/ + rules/ + squad.mdc + agents/ + squad.md + mcp.json +``` + +### Step 3: why Cursor gets both a rule and an agent + +- `.cursor/rules/squad.mdc` keeps the always-apply coordinator guidance nearby; +- `.cursor/agents/squad.md` gives Cursor a named agent anchor; +- `.cursor/mcp.json` adds the `squad_state` bridge. + +### Step 4: use Squad in Cursor + +Pick the `squad` agent from Cursor's agent surface, or work from the composer with the generated rule in place. + +The dispatch adapter tells Cursor to prefer subagents when available and to inline a member charter when the host falls back to ordinary composer/agent turns. + +--- + +## Tutorial 5: one repo, multiple clients + +This is where Squad 2.0 gets fun. + +### Option A: initialize for every supported client + +```bash +npx squad init --client all +``` + +### Option B: add clients incrementally + +```bash +npx squad upgrade --client copilot,claude,cursor +``` + +### What gets persisted + +Squad stores only **portable client slugs** in `.squad/config.json`: + +```json +{ + "clients": ["copilot", "claude", "cursor"] +} +``` + +That list is intentionally boring. + +It does **not** contain: + +- tokens; +- machine paths; +- URLs; +- provider thread IDs; +- lease tokens; +- checkpoint state. + +The SDK enforces this with portable-client hygiene helpers such as `normalizeClientIds()` and `assertPortableClients()`. + +### Why this matters + +`clients[]` is projection config, not authority. + +It answers: + +- which host files should Squad generate; +- which projections should `squad upgrade` refresh. + +It does **not** answer: + +- what capabilities the current session may exercise; +- whether a given backend allows approvals, MCP, or HTTP; +- whether policy allows a client to do something. + +That is decided later through negotiated capabilities. + +### Non-clobbering JSON merge in practice + +Suppose you already had this in `.mcp.json`: + +```json +{ + "mcpServers": { + "github": { + "command": "npx", + "args": ["@modelcontextprotocol/server-github"] + } + } +} +``` + +After `npx squad upgrade --client copilot,claude`, Squad adds its own entry and preserves yours: + +```json +{ + "mcpServers": { + "github": { + "command": "npx", + "args": ["@modelcontextprotocol/server-github"] + }, + "squad_state": { + "command": "squad", + "args": ["state-mcp"], + "env": {}, + "tools": ["*"] + } + } +} +``` + +Same idea for: + +- `.kiro/settings/mcp.json` +- `opencode.json` +- `.cursor/mcp.json` + +Malformed user JSON is refused, not overwritten. + +### The payoff + +You can use the same `.squad/` team from different clients without forking your team memory or your backend contract. + +That is the real feature. + +--- + +## Quick verification checklist + +After any `init --client ...` or `upgrade --client ...` run: + +1. confirm the expected projection files exist; +2. open the generated agent anchor for your host; +3. verify the MCP config still contains your existing servers; +4. confirm `.squad/config.json` contains only canonical slugs; +5. start the host and invoke `squad` once. + +--- + +## When to use `upgrade` + +Use `npx squad upgrade --client ...` when: + +- you added a new client to an existing repo; +- your `.squad/team.md` roster changed and you want fresh per-member projections; +- the generated adapter templates changed in a newer Squad build; +- you want to refresh all previously configured client projections from `.squad/config.json`. + +If you omit `--client`, `upgrade` refreshes the clients already tracked in config. diff --git a/test/docs-build.test.ts b/test/docs-build.test.ts index c16040a03..a511f4191 100644 --- a/test/docs-build.test.ts +++ b/test/docs-build.test.ts @@ -33,6 +33,7 @@ const EXPECTED_SCENARIOS = [ 'aspire-dashboard', 'ci-cd-integration', 'client-compatibility', + 'using-squad-across-clients', 'cross-org-auth', 'disaster-recovery', 'existing-repo', @@ -101,7 +102,7 @@ const EXPECTED_FEATURES = [ 'worktrees', ]; -const EXPECTED_CONCEPTS = ['architecture', 'your-team', 'memory-and-knowledge', 'parallel-work', 'github-workflow', 'portability']; +const EXPECTED_CONCEPTS = ['architecture', 'squad-2-0-architecture', 'your-team', 'memory-and-knowledge', 'parallel-work', 'github-workflow', 'portability']; // Blog posts are discovered dynamically to avoid breaking tests when posts change const EXPECTED_BLOG = existsSync(BLOG_CONTENT_DIR) From ef4998356f5c385807a51657467a5a7202f97bc5 Mon Sep 17 00:00:00 2001 From: Copilot Date: Sat, 17 Oct 2026 22:28:50 +0300 Subject: [PATCH 08/46] chore(test): resolve Squad SDK entrypoints from source in vitest Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 995eea17-c356-4912-90ea-5e5b144e7ba9 --- vitest.config.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/vitest.config.ts b/vitest.config.ts index 035ed1b3b..59976fcb6 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,7 +1,16 @@ +import path from 'node:path'; import { defineConfig } from 'vitest/config'; +const sdkSrc = path.resolve(__dirname, 'packages/squad-sdk/src'); + export default defineConfig({ resolve: { + // Resolve the Squad 2.0 SDK entrypoints from source so tests run without a built dist. + alias: [ + { find: /^@bradygaster\/squad-sdk\/clients$/, replacement: path.join(sdkSrc, 'clients/index.ts') }, + { find: /^@bradygaster\/squad-sdk\/backend$/, replacement: path.join(sdkSrc, 'backend/index.ts') }, + { find: /^@bradygaster\/squad-sdk$/, replacement: path.join(sdkSrc, 'index.ts') }, + ], // Force vitest to resolve @bradygaster/squad-sdk from the workspace root, // not from a duplicate copy under packages/squad-cli/node_modules/. // Without this, vi.mock('@bradygaster/squad-sdk') targets the root copy From d9961cfebd1ba9f5d3be48e9ad4cc379532b98de Mon Sep 17 00:00:00 2001 From: Copilot Date: Sun, 18 Oct 2026 03:28:11 +0300 Subject: [PATCH 09/46] test(2.0): RC1-to-stable exit criteria (upgrade/rollback, fault injection, clean-room) Add uninstallClients/removeProjection for clean rollback of client projections, make harness event delivery idempotent (duplicate/out-of-order), and rehydrate harness subscriptions for in-flight turns after a broker restart. New e2e tests cover v0.1 upgrade+rollback, partial/duplicate/shuffled delivery and restart with an approval in flight, and a clean-room client+harness checked against golden-trace.json and capability-matrix.json. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 995eea17-c356-4912-90ea-5e5b144e7ba9 --- .../squad-sdk/src/backend/local-backend.ts | 20 +- packages/squad-sdk/src/clients/apply.ts | 104 +++- packages/squad-sdk/src/clients/types.ts | 2 +- test/squad-20-all-scenarios.e2e.test.ts | 484 ++++++++++++++++++ 4 files changed, 607 insertions(+), 3 deletions(-) diff --git a/packages/squad-sdk/src/backend/local-backend.ts b/packages/squad-sdk/src/backend/local-backend.ts index 1404d6ec0..ccb5d75be 100644 --- a/packages/squad-sdk/src/backend/local-backend.ts +++ b/packages/squad-sdk/src/backend/local-backend.ts @@ -330,6 +330,16 @@ export class LocalSquadBackend implements SquadBackend { return harness ? this.harnesses.get(harness) : undefined; } + /** After a broker restart, re-attach to harnesses of in-flight turns so their events keep flowing. */ + private rehydrate(conv: StoredConversation): void { + for (const turn of conv.turns) { + const harness = turn.messages[0]?.harness; + if (harness && !TERMINAL_RUN_STATES.includes(turn.state) && this.harnesses.get(harness)) { + this.ensureSubscribed(conv.info.conversationId, harness); + } + } + } + private ensureSubscribed(conversationId: string, harnessId: string): void { const key = `${conversationId}\u0000${harnessId}`; if (this.subscriptions.has(key)) return; @@ -346,16 +356,20 @@ export class LocalSquadBackend implements SquadBackend { if (!turn || TERMINAL_RUN_STATES.includes(turn.state)) return; switch (ev.type) { case 'message': + if (turn.messages.some(m => m.runId === ev.message.runId && m.seq === ev.message.seq && m.role === ev.message.role && m.content === ev.message.content)) return; turn.messages.push(ev.message); conv.transcript.push(ev.message); break; case 'task': { const task = turn.tasks.find(t => t.id === ev.id); + if (task && task.state === ev.state && (ev.title === undefined || ev.title === task.title)) return; if (task) { task.state = ev.state; task.title = ev.title ?? task.title; } else turn.tasks.push({ id: ev.id, state: ev.state, title: ev.title }); break; } case 'approval-requested': { + // harness delivery is at-least-once: a redelivered request must not create a second approval + if ((conv.approvals ?? []).some(a => a.approvalId === ev.request.approvalId)) return; const request: ApprovalRequest = { ...ev.request, version: 1, scope: ev.request.scope ?? { kind: 'turn', runId: turn.runId, seq: turn.seq }, }; @@ -368,6 +382,8 @@ export class LocalSquadBackend implements SquadBackend { break; } case 'state': + if (turn.state === ev.state) return; + if (turn.pendingApproval && !TERMINAL_RUN_STATES.includes(ev.state)) return; // only a decision resumes an awaiting turn turn.state = ev.state; if (ev.error) turn.error = ev.error; break; @@ -412,7 +428,9 @@ export class LocalSquadBackend implements SquadBackend { if (required) throw new BackendError('not-found', `Conversation '${id}' not found`, 404); return undefined; } - return JSON.parse(raw) as StoredConversation; + const conv = JSON.parse(raw) as StoredConversation; + this.rehydrate(conv); + return conv; } private save(conv: StoredConversation): void { diff --git a/packages/squad-sdk/src/clients/apply.ts b/packages/squad-sdk/src/clients/apply.ts index e4b0eeadb..016360ac4 100644 --- a/packages/squad-sdk/src/clients/apply.ts +++ b/packages/squad-sdk/src/clients/apply.ts @@ -6,7 +6,7 @@ import fs from 'node:fs'; import path from 'node:path'; -import { getClientAdapter } from './adapters.js'; +import { SQUAD_STATE_KEY, getClientAdapter } from './adapters.js'; import type { ApplyResult, ClientContext, ClientId, ProjectionFile } from './types.js'; export const BLOCK_BEGIN = ''; @@ -103,3 +103,105 @@ export function applyProjection(root: string, files: readonly ProjectionFile[]): export function installClients(clients: readonly ClientId[], ctx: ClientContext): ApplyResult[] { return applyProjection(ctx.root, projectClients(clients, ctx)); } + +/** Remove the Squad-managed block; collapses the separator that `upsertBlock` added. */ +export function removeBlock(existing: string): string { + const start = existing.indexOf(BLOCK_BEGIN); + const end = existing.indexOf(BLOCK_END); + if (start === -1 || end < start) return existing; + const before = existing.slice(0, start).replace(/\n{2,}$/, '\n'); + const after = existing.slice(end + BLOCK_END.length).replace(/^\n/, ''); + return before + after; +} + +const MCP_CONTAINERS = ['mcpServers', 'mcp', 'servers']; + +const OPENCODE_SCHEMA = 'https://opencode.ai/config.json'; + +function removeServerEntry(obj: Record): Record { + const out = { ...obj }; + const touched = MCP_CONTAINERS.some(k => { + const c = out[k]; + return !!c && typeof c === 'object' && !Array.isArray(c) && SQUAD_STATE_KEY in c; + }); + // the OpenCode projection adds its $schema; drop it together with the Squad entry + if (touched && out['$schema'] === OPENCODE_SCHEMA) delete out['$schema']; + for (const key of MCP_CONTAINERS) { + const container = out[key]; + if (container && typeof container === 'object' && !Array.isArray(container) && SQUAD_STATE_KEY in container) { + const { [SQUAD_STATE_KEY]: _removed, ...rest } = container as Record; + if (Object.keys(rest).length === 0) delete out[key]; + else out[key] = rest; + } + } + return out; +} + +function pruneEmptyDirs(root: string, rel: string): void { + let dir = path.dirname(path.join(root, rel)); + const stop = path.resolve(root); + while (path.resolve(dir).startsWith(stop + path.sep)) { + try { + if (fs.readdirSync(dir).length > 0) return; + fs.rmdirSync(dir); + } catch { + return; + } + dir = path.dirname(dir); + } +} + +/** + * Roll back a projection: deletes Squad-owned files, removes only the + * `squad_state` MCP entry from shared JSON configs (user servers and settings + * stay), and strips the marker block from user text files. `.squad/` state is + * never touched. Files left empty by the removal are deleted. + */ +export function removeProjection(root: string, files: readonly ProjectionFile[]): ApplyResult[] { + const results: ApplyResult[] = []; + const seen = new Set(); + for (const f of files) { + if (seen.has(f.path)) continue; + seen.add(f.path); + const abs = path.join(root, f.path); + try { + if (!fs.existsSync(abs)) { + results.push({ path: f.path, action: 'unchanged' }); + continue; + } + if (f.kind === 'managed') { + fs.rmSync(abs); + results.push({ path: f.path, action: 'removed' }); + } else if (f.kind === 'json') { + const current = readJsonObject(abs); + const next = removeServerEntry(current); + if (JSON.stringify(next) === JSON.stringify(current)) { + results.push({ path: f.path, action: 'unchanged' }); + continue; + } + if (Object.keys(next).length === 0) fs.rmSync(abs); + else fs.writeFileSync(abs, JSON.stringify(next, null, 2) + '\n', 'utf8'); + results.push({ path: f.path, action: Object.keys(next).length === 0 ? 'removed' : 'updated' }); + } else { + const text = fs.readFileSync(abs, 'utf8'); + const next = removeBlock(text); + if (next === text) { + results.push({ path: f.path, action: 'unchanged' }); + continue; + } + if (next.trim() === '') fs.rmSync(abs); + else fs.writeFileSync(abs, next, 'utf8'); + results.push({ path: f.path, action: next.trim() === '' ? 'removed' : 'updated' }); + } + pruneEmptyDirs(root, f.path); + } catch (err) { + results.push({ path: f.path, action: 'error', error: err instanceof Error ? err.message : String(err) }); + } + } + return results; +} + +/** Roll back the given clients' projections (inverse of {@link installClients}). */ +export function uninstallClients(clients: readonly ClientId[], ctx: ClientContext): ApplyResult[] { + return removeProjection(ctx.root, projectClients(clients, ctx)); +} \ No newline at end of file diff --git a/packages/squad-sdk/src/clients/types.ts b/packages/squad-sdk/src/clients/types.ts index a7954d662..9b570b22e 100644 --- a/packages/squad-sdk/src/clients/types.ts +++ b/packages/squad-sdk/src/clients/types.ts @@ -74,7 +74,7 @@ export interface ClientAdapter { project(ctx: ClientContext): ProjectionFile[]; } -export type ApplyAction = 'created' | 'updated' | 'unchanged' | 'error'; +export type ApplyAction = 'created' | 'updated' | 'unchanged' | 'removed' | 'error'; export interface ApplyResult { path: string; diff --git a/test/squad-20-all-scenarios.e2e.test.ts b/test/squad-20-all-scenarios.e2e.test.ts index e3eb8cecf..dbaec473e 100644 --- a/test/squad-20-all-scenarios.e2e.test.ts +++ b/test/squad-20-all-scenarios.e2e.test.ts @@ -30,6 +30,7 @@ import { getClientAdapter, slugifyMember, installClients, + uninstallClients, normalizeClientIds, parseClients, projectClients, @@ -38,6 +39,13 @@ import { type ClientId, } from '../packages/squad-sdk/src/clients/index.js'; import { callBackendMcpTool, createBackendMcpTools, createMcpBackend } from '../packages/squad-cli/src/cli/commands/backend-mcp.js'; +// Clean-room (exit criterion 3) code is typed strictly against the public subpath barrel. +import type { + BackendEnvelope as CREnvelope, + HarnessAdapter as CRHarness, + HarnessEventEnvelope as CRHarnessEvent, + SquadBackend as CRBackend, +} from '@bradygaster/squad-sdk/backend'; const SUITE = path.resolve(__dirname, '..', 'test-fixtures', 'spec', 'suite-v0.2'); const matrix = JSON.parse(fs.readFileSync(path.join(SUITE, 'capability-matrix.json'), 'utf8')) as { @@ -703,3 +711,479 @@ describe('Scenario 6: golden-trace.json conformance (local and durable-http)', ( expect(remote.approvals.map(a => a.version)).toEqual(local.approvals.map(a => a.version)); }); }); + +// ── RC1 → stable exit criterion 1: upgrade / rollback from v0.1 ───── + +describe('RC1→stable exit 1: upgrade and rollback from a v0.1 workspace', () => { + let root: string; + beforeEach(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), 'squad20-upgrade-')); }); + afterEach(() => { fs.rmSync(root, { recursive: true, force: true }); }); + + const write = (rel: string, body: string) => { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), body); + }; + const walk = (dir: string, base = ''): string[] => + fs.readdirSync(path.join(dir, base), { withFileTypes: true }).flatMap(d => + d.isDirectory() ? walk(dir, path.posix.join(base, d.name)) : [path.posix.join(base, d.name)]); + /** Byte snapshot of the workspace; JSON compared semantically. */ + const snapshot = () => new Map(walk(root).sort().map(rel => { + const text = fs.readFileSync(path.join(root, rel), 'utf8'); + return [rel, rel.endsWith('.json') ? JSON.stringify(JSON.parse(text)) : text] as const; + })); + const ctx = (): ClientContext => ({ + root, + mcp: { command: 'npx', args: ['-y', '@bradygaster/squad-cli', 'state-mcp'] }, + members: [{ name: 'Data', role: 'Framework Expert' }], + }); + const readJson = (rel: string) => JSON.parse(fs.readFileSync(path.join(root, rel), 'utf8')); + + const customServers = { + custom: { command: 'node', args: ['server.js'], env: { TOKEN_REF: 'from-env' } }, + remote: { url: 'https://example.test/mcp' }, + }; + + function seedV01() { + write('.squad/config.json', JSON.stringify({ version: '0.1', stateBackend: 'local' }, null, 2) + '\n'); + write('.squad/team.md', '# Team\n\n| Name | Role |\n|---|---|\n| Data | Framework Expert |\n'); + write('.squad/decisions.md', '# Decisions\n\n- keep this\n'); + write('.squad/agents/data/charter.md', '# Data\n'); + write('.squad/agents/data/history.md', '# History\n\n- learned things\n'); + write('.mcp.json', JSON.stringify({ mcpServers: customServers }, null, 2) + '\n'); + write('.cursor/mcp.json', JSON.stringify({ mcpServers: { custom: customServers.custom } }, null, 2) + '\n'); + write('.kiro/settings/mcp.json', JSON.stringify({ mcpServers: { remote: customServers.remote } }, null, 2) + '\n'); + write('opencode.json', JSON.stringify({ model: 'my-model', mcp: { custom: { type: 'local', command: ['node', 'x.js'] } } }, null, 2) + '\n'); + write('CLAUDE.md', '# My project\n\nHand-written notes.\n'); + } + + const upgrade = () => { + const results = installClients([...CLIENT_IDS], ctx()); + const cfgPath = path.join(root, '.squad/config.json'); + const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); + cfg.clients = normalizeClientIds([...CLIENT_IDS]).clients; + fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + '\n'); + return results; + }; + const rollback = (clients: readonly ClientId[] = CLIENT_IDS) => uninstallClients(clients, ctx()); + + it('upgrades v0.1 to all five client projections without touching .squad state or custom servers', () => { + seedV01(); + const before = snapshot(); + expect(readJson('.squad/config.json').clients).toBeUndefined(); + expect(normalizeClientIds(readJson('.squad/config.json').clients).clients).toEqual([]); + + expect(upgrade().every(r => r.action !== 'error')).toBe(true); + const after = snapshot(); + for (const [rel, body] of before) { + if (rel.startsWith('.squad/') && rel !== '.squad/config.json') expect(after.get(rel), rel).toBe(body); + } + expect(readJson('.squad/config.json')).toMatchObject({ version: '0.1', stateBackend: 'local', clients: [...CLIENT_IDS] }); + for (const f of projectClients([...CLIENT_IDS], ctx())) expect(fs.existsSync(path.join(root, f.path)), f.path).toBe(true); + + expect(readJson('.mcp.json').mcpServers).toMatchObject({ ...customServers, [SQUAD_STATE_KEY]: expect.anything() }); + expect(readJson('.cursor/mcp.json').mcpServers.custom).toEqual(customServers.custom); + expect(readJson('.kiro/settings/mcp.json').mcpServers.remote).toEqual(customServers.remote); + expect(readJson('opencode.json').model).toBe('my-model'); + expect(fs.readFileSync(path.join(root, 'CLAUDE.md'), 'utf8').startsWith('# My project\n\nHand-written notes.\n')).toBe(true); + + expect(upgrade().map(r => r.action)).toEqual(installClients([...CLIENT_IDS], ctx()).map(() => 'unchanged')); + }); + + it('rolls back to the exact pre-upgrade workspace', () => { + seedV01(); + const before = snapshot(); + upgrade(); + const results = rollback(); + expect(results.some(r => r.action === 'error')).toBe(false); + + const cfgPath = path.join(root, '.squad/config.json'); + const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); + delete cfg.clients; + fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + '\n'); + + expect(snapshot()).toEqual(before); + expect(readJson('.mcp.json').mcpServers).toEqual(customServers); + expect(fs.existsSync(path.join(root, '.claude'))).toBe(false); + expect(fs.existsSync(path.join(root, '.kiro/steering'))).toBe(false); + expect(rollback().every(r => r.action === 'unchanged')).toBe(true); // idempotent + }); + + it('rolls back a single client and leaves the others intact', () => { + seedV01(); + upgrade(); + rollback(['claude']); + expect(fs.existsSync(path.join(root, '.claude'))).toBe(false); + expect(fs.readFileSync(path.join(root, 'CLAUDE.md'), 'utf8')).toBe('# My project\n\nHand-written notes.\n'); + expect(readJson('.mcp.json').mcpServers).toEqual(customServers); + expect(fs.existsSync(path.join(root, '.kiro/agents/squad.json'))).toBe(true); + expect(readJson('.cursor/mcp.json').mcpServers[SQUAD_STATE_KEY]).toBeDefined(); + expect(fs.existsSync(path.join(root, '.squad/agents/data/history.md'))).toBe(true); + }); + + it('removes files the upgrade created from scratch and never deletes user files', () => { + write('.squad/config.json', '{}\n'); + write('notes.txt', 'mine\n'); + const before = snapshot(); + installClients([...CLIENT_IDS], ctx()); + expect(fs.existsSync(path.join(root, '.mcp.json'))).toBe(true); + rollback(); + expect(snapshot()).toEqual(before); + }); + + it('refuses to rewrite malformed user JSON during rollback', () => { + seedV01(); + upgrade(); + fs.writeFileSync(path.join(root, '.mcp.json'), '{ broken'); + const results = rollback(['claude']); + expect(results.find(r => r.path === '.mcp.json')!.action).toBe('error'); + expect(fs.readFileSync(path.join(root, '.mcp.json'), 'utf8')).toBe('{ broken'); + }); +}); + +// ── RC1 → stable exit criterion 2: restart and partial-delivery faults ── + +describe('RC1→stable exit 2: restart and partial-delivery fault injection', () => { + /** Client-side reassembly: dedupe by id, order by sequence, require contiguity from 1. */ + const reassemble = (events: BackendEnvelope[]) => { + const byId = new Map(events.map(e => [e.id, e])); + const list = [...byId.values()].sort((a, b) => a.sequence! - b.sequence!); + list.forEach((e, i) => expect(e.sequence, `gap or duplicate at index ${i}`).toBe(i + 1)); + return list; + }; + + /** Drain with partial pages, returning every page for later replay/shuffling. */ + async function drain(client: SquadBackend, id: string, limit: number, after?: string) { + const pages: BackendEnvelope[][] = []; + let cursor = after; + for (;;) { + const page = await client.readEvents(id, { after: cursor, limit }); + expect(page.events.length).toBeLessThanOrEqual(limit); + if (page.events.length === 0) return { pages, cursor, last: page }; + pages.push(page.events); + cursor = page.nextCursor; + } + } + + const snapshotStore = (state: MemoryStateStore): MemoryStateStore => { + const copy = new MemoryStateStore(); + for (const name of state.list('backend/conversations')) { + const rel = `backend/conversations/${name}`; + copy.write(rel, JSON.stringify(JSON.parse(state.read(rel)!))); + } + return copy; + }; + + it('keeps sequence continuity under partial pages, duplicate and out-of-order redelivery', async () => { + const { registry, harnesses } = makeRegistry(); + const h = harnesses.copilot; + const b = new LocalSquadBackend({ harnesses: registry }); + await b.createConversation({ conversationId: 'c' }); + for (const [run, seq] of [['r1', 1], ['r2', 2], ['r3', 3]] as const) await b.sendMessage('c', msg(run, seq, { harness: 'copilot' })); + + // duplicate command delivery, concurrently + const dups = await Promise.all([1, 2, 3].map(() => b.sendMessage('c', msg('r1', 1, { harness: 'copilot' })))); + expect(dups.every(d => d.duplicate)).toBe(true); + expect(h.submitted).toEqual([{ runId: 'r1', seq: 1 }, { runId: 'r2', seq: 2 }, { runId: 'r3', seq: 3 }]); + + // at-least-once harness delivery: every event twice, approval three times + const approval = { type: 'approval-requested' as const, runId: 'r1', seq: 1, request: { approvalId: 'a1', turnSeq: 1, action: 'x', hash: 'h1' } }; + h.emit(approval); h.emit(approval); h.emit(approval); + const reply = { type: 'message' as const, runId: 'r2', seq: 2, message: { runId: 'r2', seq: 20, role: 'assistant' as const, content: 'partial' } }; + h.emit(reply); h.emit(reply); + const task = { type: 'task' as const, runId: 'r3', seq: 3, id: 't1', state: 'running' as const, title: 'T' }; + h.emit(task); h.emit(task); + h.emit({ type: 'state', runId: 'r2', seq: 2, state: 'completed' }); + h.emit({ type: 'state', runId: 'r2', seq: 2, state: 'completed' }); + // out of order: late events for a terminal turn, and a stale 'running' for an awaiting turn + h.emit({ type: 'message', runId: 'r2', seq: 2, message: { runId: 'r2', seq: 21, role: 'assistant', content: 'too late' } }); + h.emit({ type: 'state', runId: 'r1', seq: 1, state: 'running' }); + + expect(await b.getApprovals('c')).toHaveLength(1); + expect((await b.getTurn('c', 1)).state).toBe('awaiting-approval'); + expect((await b.getTranscript('c')).filter(m => m.content === 'partial')).toHaveLength(1); + expect((await b.getTranscript('c')).some(m => m.content === 'too late')).toBe(false); + + const full = (await b.readEvents('c')).events; + const types = full.map(e => e.type); + expect(types.filter(t => t === 'approval.requested')).toHaveLength(1); + expect(types.filter(t => t === 'turn.state')).toHaveLength(1); + + // partial pages (limit 2) reassemble to the same canonical history + const { pages } = await drain(b, 'c', 2); + expect(pages.every(p => p.length <= 2)).toBe(true); + expect(pages.length).toBeGreaterThan(2); + expect(canonical(reassemble(pages.flat()))).toEqual(canonical(full)); + + // duplicate + reversed + shuffled delivery of those pages still reassembles identically + const delivered = [...pages.slice().reverse().map(p => p.slice().reverse()), ...pages, pages[0]!]; + expect(canonical(reassemble(delivered.flat()))).toEqual(canonical(full)); + + // decisions retried after the approval resolves are not applied twice + await b.respondApproval('c', { approvalId: 'a1', hash: 'h1', decision: 'approve' }); + expect((await errorOf(b.respondApproval('c', { approvalId: 'a1', hash: 'h1', decision: 'approve' })))?.code).toBe('approval-not-pending'); + expect(h.approvals).toEqual(['a1']); + }); + + it('survives a broker restart with an approval in flight', async () => { + const stateA = new MemoryStateStore(); + const a = makeRegistry(); + const brokerA = new LocalSquadBackend({ state: stateA, harnesses: a.registry }); + await brokerA.createConversation({ conversationId: 'c' }); + await brokerA.sendMessage('c', msg('r', 1, { harness: 'copilot' })); + a.harnesses.copilot.emit({ type: 'approval-requested', runId: 'r', seq: 1, request: { approvalId: 'a1', turnSeq: 1, action: 'deploy', hash: 'hh' } }); + + // client has consumed only part of the stream before the crash + const firstPage = await brokerA.readEvents('c', { limit: 2 }); + expect(firstPage.events.map(e => e.sequence)).toEqual([1, 2]); + const heldCursor = firstPage.nextCursor; + + // restart: fresh broker rehydrated from the persisted snapshot; harness reconnects as a new process + const b = makeRegistry(); + const brokerB = new LocalSquadBackend({ state: snapshotStore(stateA), harnesses: b.registry }); + const resumed = await brokerB.readEvents('c', { after: heldCursor, limit: 10 }); + expect(resumed.resumable).toBe(true); + expect(resumed.events.map(e => e.sequence)).toEqual([3]); + expect(resumed.events[0]!.type).toBe('approval.requested'); + expect(resumed.lastAcceptedSeq).toBe(3); + expect(canonical(reassemble([...firstPage.events, ...resumed.events]))).toEqual(canonical((await brokerA.readEvents('c')).events)); + + // approval survived the restart as a durable resource; nothing was resubmitted + expect(await brokerB.getApprovals('c')).toMatchObject([{ approvalId: 'a1', version: 1, state: 'pending', hash: 'hh' }]); + expect((await brokerB.getTurn('c', 1)).state).toBe('awaiting-approval'); + expect((await brokerB.sendMessage('c', msg('r', 1, { harness: 'copilot' }))).duplicate).toBe(true); + expect(b.harnesses.copilot.submitted).toEqual([]); + + // decision after restart reaches the NEW harness exactly once; tampering is still rejected + expect((await errorOf(brokerB.respondApproval('c', { approvalId: 'a1', hash: 'bad', decision: 'approve' })))?.code).toBe('approval-hash-mismatch'); + await brokerB.respondApproval('c', { approvalId: 'a1', hash: 'hh', decision: 'approve' }); + expect((await errorOf(brokerB.respondApproval('c', { approvalId: 'a1', hash: 'hh', decision: 'approve' })))?.code).toBe('approval-not-pending'); + expect(b.harnesses.copilot.approvals).toEqual(['a1']); + expect(a.harnesses.copilot.approvals).toEqual([]); + + // the reconnected harness keeps streaming into the rehydrated broker, duplicates included + const done = { type: 'message' as const, runId: 'r', seq: 1, message: { runId: 'r', seq: 2, role: 'assistant' as const, content: 'shipped' } }; + b.harnesses.copilot.emit(done); b.harnesses.copilot.emit(done); + b.harnesses.copilot.emit({ type: 'state', runId: 'r', seq: 1, state: 'completed' }); + // the dead broker's harness cannot corrupt the new store + a.harnesses.copilot.emit({ type: 'state', runId: 'r', seq: 1, state: 'failed' }); + + const finalEvents = reassemble((await brokerB.readEvents('c')).events); + expect(finalEvents.map(e => e.type)).toEqual([ + 'conversation.created', 'message.accepted', 'approval.requested', 'approval.resolved', 'harness.message', 'turn.state', + ]); + expect(finalEvents.slice(0, 3).map(e => e.id)).toEqual((await brokerA.readEvents('c')).events.slice(0, 3).map(e => e.id)); + expect((await brokerB.getTurn('c', 1)).state).toBe('completed'); + expect((await brokerB.getApprovals('c'))[0]).toMatchObject({ version: 2, state: 'approved' }); + expect((await brokerB.getTranscript('c')).filter(m => m.content === 'shipped')).toHaveLength(1); + }); + + it('recovers a lost response and transient 503s mid-stream over durable-http without duplicate effects', async () => { + const { registry, harnesses } = makeRegistry(); + const local = new LocalSquadBackend({ harnesses: registry }); + const server = createMockDurableSquad(local); + let dropNext = true; + const lossy = (async (url: string, init?: RequestInit) => { + const res = await server.fetch(url, init as RequestInit); + if (dropNext && init?.method === 'POST' && url.endsWith('/messages')) { + dropNext = false; + throw new TypeError('connection reset after server processed the request'); + } + return res; + }) as unknown as typeof fetch; + const client = mkHttp(server, { fetch: lossy }); + + await client.createConversation({ conversationId: 'c' }); + const adm = await client.sendMessage('c', msg('r', 1, { harness: 'copilot' })); + expect(adm.duplicate).toBe(true); // the retry hit the server's idempotency record + expect(harnesses.copilot.submitted).toEqual([{ runId: 'r', seq: 1 }]); + expect(await client.getTranscript('c')).toHaveLength(1); + expect(server.calls.filter(c => c.path.endsWith('/messages')).map(c => c.headers['Idempotency-Key'])).toEqual(['r:1', 'r:1']); + + harnesses.copilot.emit({ type: 'approval-requested', runId: 'r', seq: 1, request: { approvalId: 'a1', turnSeq: 1, action: 'x', hash: 'h' } }); + server.failures.set('GET /v1/conversations/c/events', { status: 503, remaining: 2 }); + const { pages } = await drain(client, 'c', 2); + expect(pages.every(p => p.length <= 2)).toBe(true); + const events = reassemble(pages.flat()); + expect(events.map(e => e.type)).toEqual(['conversation.created', 'message.accepted', 'approval.requested']); + + // a fresh client (new connection) resumes from a mid-stream cursor with identical results + const reconnect = mkHttp(server); + const tail = await reconnect.readEvents('c', { after: pages[0]!.at(-1)!.cursor }); + expect(tail.events.map(e => e.sequence)).toEqual([3]); + expect(tail).toMatchObject({ resumable: true, lastAcceptedSeq: 3 }); + }); +}); + +// ── RC1 → stable exit criterion 3: clean-room client + harness ────── + +describe('RC1→stable exit 3: clean-room client and harness against the public contract', () => { + /** Harness written only against the public HarnessAdapter type. */ + class CleanRoomHarness implements CRHarness { + readonly id = 'cleanroom'; + readonly capabilities = { streaming: true, approvals: true, cancellation: true, tools: false, extensionNamespaces: ['x-cleanroom'] }; + effects = { submits: 0, approvals: [] as string[], cancels: 0 }; + private listeners = new Set<(e: CRHarnessEvent) => void>(); + constructor(private readonly approval: { approvalId: string; hash: string }) {} + async submit(s: { runId: string; seq: number }) { + this.effects.submits++; + for (const l of this.listeners) { + l({ type: 'approval-requested', runId: s.runId, seq: s.seq, request: { approvalId: this.approval.approvalId, turnSeq: s.seq, action: 'clean-room', hash: this.approval.hash } }); + } + } + subscribe(_conversationId: string, listener: (e: CRHarnessEvent) => void) { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; } + async cancel() { this.effects.cancels++; } + async respondApproval(_c: string, r: { approvalId: string }) { this.effects.approvals.push(r.approvalId); } + describe() { return { id: this.id, capabilities: this.capabilities }; } + } + + /** Client that drives a recorded conformance trace using nothing but SquadBackend. */ + async function runFixture(connect: () => CRBackend, trace: typeof golden) { + const seen = new Map(); + let cursor: string | undefined; + const pull = async (c: CRBackend) => { + let last = { resumable: false, lastAcceptedSeq: 0 }; + for (;;) { + const page = await c.readEvents(trace.conversationId, { after: cursor, limit: 2 }); + last = { resumable: page.resumable, lastAcceptedSeq: page.lastAcceptedSeq }; + if (page.events.length === 0) return last; + page.events.forEach(e => seen.set(e.id, e)); + cursor = page.nextCursor; + } + }; + const maxSeq = () => Math.max(0, ...[...seen.values()].map(e => e.sequence ?? 0)); + let client: CRBackend | null = connect(); + const reconnects: Array<{ resumable: boolean; lastAcceptedSeq: number; seenBefore: number }> = []; + let beforeDrop = 0; + let terminal = ''; + for (const e of trace.events) { + switch (e.event) { + case 'TurnAccepted': { + await client!.createConversation({ conversationId: trace.conversationId }); + const adm = await client!.sendMessage(trace.conversationId, { + runId: e['runId'] as string, seq: e['turnSeq'] as number, role: 'user', content: 'go', harness: 'cleanroom', + extensions: { 'x-cleanroom.trace': trace.runId }, + }); + expect(adm.duplicate).toBe(false); + await pull(client!); + break; + } + case 'ApprovalRequested': + await pull(client!); + break; + case 'Disconnected': + beforeDrop = maxSeq(); + client = null; + break; + case 'Reconnected': { + client = connect(); + const meta = await pull(client); + reconnects.push({ ...meta, seenBefore: beforeDrop }); + break; + } + case 'ApprovalDecided': + await client!.respondApproval(trace.conversationId, { approvalId: e['approvalId'] as string, hash: e['payloadHash'] as string, decision: 'approve' }); + await pull(client!); + break; + case 'CancellationRequested': + await client!.cancelRun(trace.conversationId, e['runId'] as string, e['reason'] as string); + await pull(client!); + break; + case 'RunTerminal': { + const turn = await client!.getTurn(trace.conversationId, (trace.events[0]!['turnSeq'] as number)); + terminal = turn.state; + await pull(client!); + break; + } + } + } + const events = [...seen.values()].sort((a, b) => a.sequence! - b.sequence!); + events.forEach((e, i) => expect(e.sequence).toBe(i + 1)); + const approvals = await client!.getApprovals(trace.conversationId); + return { events, reconnects, terminal, approvals }; + } + + const ev = (name: string) => golden.events.find(e => e.event === name)!; + const NAME_OF: Record = { + 'message.accepted': 'TurnAccepted', 'approval.requested': 'ApprovalRequested', 'approval.resolved': 'ApprovalDecided', + 'cancellation.requested': 'CancellationRequested', 'turn.state': 'RunTerminal', + }; + + it('exposes the required runtime surface through the public package exports', async () => { + const backendPkg = await import('@bradygaster/squad-sdk/backend'); + const clientsPkg = await import('@bradygaster/squad-sdk/clients'); + for (const name of ['createSquadBackend', 'LocalSquadBackend', 'DurableHttpSquadBackend', 'DefaultHarnessRegistry', 'BackendError', + 'BACKEND_PROTOCOL_VERSION', 'BACKEND_CAPABILITIES', 'intersectCapabilities', 'validateExtensions']) { + expect(backendPkg, name).toHaveProperty(name); + } + for (const name of ['CLIENT_IDS', 'getClientAdapter', 'installClients', 'uninstallClients', 'resolveEffectiveCapabilities', 'normalizeClientIds', 'parseClients']) { + expect(clientsPkg, name).toHaveProperty(name); + } + const pkg = JSON.parse(fs.readFileSync(path.resolve(__dirname, '..', 'packages/squad-sdk/package.json'), 'utf8')); + expect(pkg.exports['./backend'].import).toBe('./dist/backend/index.js'); + expect(pkg.exports['./clients'].import).toBe('./dist/clients/index.js'); + }); + + it('declares capabilities per matrix and negotiates the expected intersection for all five clients', async () => { + const { createSquadBackend: create } = await import('@bradygaster/squad-sdk/backend'); + const { getClientAdapter: adapterOf, CLIENT_IDS: ids } = await import('@bradygaster/squad-sdk/clients'); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'squad20-cleanroom-caps-')); + try { + const backend = create({ kind: 'local', squadDir: dir }); + for (const id of ids) { + const declared = matrix.clients[id]!.clientCapabilities; + const neg = await backend.negotiate({ protocols: ['2.0'], clientName: adapterOf(id).label, capabilities: declared, policyCapabilities: matrix.policyCapabilities }); + expect(neg.effectiveCapabilities).toEqual(matrix.clients[id]!.effectiveCapabilities); + expect(neg.backendCapabilities).toEqual(matrix.backendCapabilities); + } + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it.each(['file-backed local', 'durable-http'] as const)('executes golden-trace.json end to end (%s)', async (mode) => { + const { createSquadBackend: create, DefaultHarnessRegistry: Registry } = await import('@bradygaster/squad-sdk/backend'); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'squad20-cleanroom-')); + try { + const approval = ev('ApprovalRequested'); + const harness = new CleanRoomHarness({ approvalId: approval['approvalId'] as string, hash: approval['payloadHash'] as string }); + const harnesses = new Registry(); + harnesses.register(harness); + let connect: () => CRBackend; + if (mode === 'file-backed local') { + connect = () => create({ kind: 'local', squadDir: dir, harnesses }); + } else { + const server = createMockDurableSquad(create({ kind: 'local', squadDir: dir, harnesses }) as LocalSquadBackend); + connect = () => mkHttp(server); + } + const r = await runFixture(connect, golden); + + // the canonical history is exactly the fixture's, in order (client-side Disconnected/Reconnected aside) + const expectedNames = golden.events.map(e => e.event).filter(n => n !== 'Disconnected' && n !== 'Reconnected'); + expect(r.events.map(e => NAME_OF[e.type]).filter(Boolean)).toEqual(expectedNames); + const by = (name: string) => r.events.find(e => NAME_OF[e.type] === name)!; + const requested = ev('ApprovalRequested'); + const decided = ev('ApprovalDecided'); + expect((by('ApprovalRequested').payload as any).request).toMatchObject({ + approvalId: requested['approvalId'], version: requested['version'], hash: requested['payloadHash'], scope: { kind: requested['scope'] }, + }); + expect(by('ApprovalDecided').payload).toMatchObject({ approvalId: decided['approvalId'], version: decided['version'], decision: 'approve' }); + expect(r.approvals).toHaveLength(1); + expect(r.approvals[0]).toMatchObject({ version: decided['version'], state: 'approved' }); + expect(by('CancellationRequested').payload).toMatchObject({ runId: golden.runId, reason: ev('CancellationRequested')['reason'] }); + expect(r.terminal).toBe(golden.expectedTerminalState); + expect(by('RunTerminal').payload).toMatchObject({ runId: golden.runId, state: golden.expectedTerminalState }); + + // Reconnected semantics from the fixture + const reconnected = ev('Reconnected'); + expect(r.reconnects).toHaveLength(1); + expect(r.reconnects[0]!.resumable).toBe(reconnected['resumable']); + expect(r.reconnects[0]!.seenBefore).toBe(by('ApprovalRequested').sequence); + + // each side effect happened exactly once, and nothing internal leaked + expect(harness.effects).toEqual({ submits: 1, approvals: [decided['approvalId']], cancels: 1 }); + expect(JSON.stringify(r.events)).not.toMatch(/lease|checkpoint|epoch/i); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); From c9ea24baa51406cda44c97ba48e4cd1a5d9e0a3b Mon Sep 17 00:00:00 2001 From: Copilot Date: Sun, 18 Oct 2026 06:25:14 +0300 Subject: [PATCH 10/46] test(2.0): real child-process stdio MCP and live client binary tests Project claude/opencode/kiro configs, parse them from disk, spawn state-mcp as a real child process over stdio JSON-RPC and exercise squad_state_* and all squad_backend_* tools including process restart and resume. Smoke-test installed claude/opencode/kiro binaries against the workspaces when present on PATH. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 995eea17-c356-4912-90ea-5e5b144e7ba9 --- test/squad-20-real-clients-stdio.e2e.test.ts | 449 +++++++++++++++++++ 1 file changed, 449 insertions(+) create mode 100644 test/squad-20-real-clients-stdio.e2e.test.ts diff --git a/test/squad-20-real-clients-stdio.e2e.test.ts b/test/squad-20-real-clients-stdio.e2e.test.ts new file mode 100644 index 000000000..8b3d5aeea --- /dev/null +++ b/test/squad-20-real-clients-stdio.e2e.test.ts @@ -0,0 +1,449 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { randomBytes } from 'node:crypto'; +import { + DefaultHarnessRegistry, + LocalSquadBackend, + createFileStateStore, + type HarnessAdapter, + type HarnessEventEnvelope, +} from '../packages/squad-sdk/src/backend/index.js'; +import { SQUAD_STATE_KEY, installClients, type ClientContext } from '../packages/squad-sdk/src/clients/index.js'; + +/** + * Real-process tests: the projected client config files are parsed from disk and the + * `state-mcp` server is spawned from exactly what they say, then driven over real + * stdin/stdout pipes with MCP JSON-RPC 2.0. When the real `claude`, `opencode` and + * `kiro` binaries are on PATH they are smoke-tested against the same workspaces. + */ + +const ROOT = path.resolve(__dirname, '..'); +const SDK_SRC = path.join(ROOT, 'packages', 'squad-sdk', 'src'); +const STATE_MCP = path.join(ROOT, 'packages', 'squad-cli', 'src', 'cli', 'commands', 'state-mcp.ts'); +const matrix = JSON.parse(fs.readFileSync(path.join(ROOT, 'test-fixtures', 'spec', 'suite-v0.2', 'capability-matrix.json'), 'utf8')) as { + clients: Record; + policyCapabilities: string[]; +}; + +const CLIENTS = ['claude', 'opencode', 'kiro'] as const; +type RealClient = (typeof CLIENTS)[number]; + +const BACKEND_TOOLS = [ + 'squad_backend_negotiate', 'squad_backend_create_conversation', 'squad_backend_send_message', + 'squad_backend_get_turn', 'squad_backend_get_transcript', 'squad_backend_read_events', + 'squad_backend_cancel_turn', 'squad_backend_cancel_run', 'squad_backend_get_approvals', + 'squad_backend_respond_approval', 'squad_backend_health', +]; +const STATE_TOOLS = ['squad_decide', 'squad_state_read', 'squad_state_write', 'squad_state_append', 'squad_state_delete', 'squad_state_list', 'squad_state_health']; + +// ── tiny TypeScript-aware Node loader so the real CLI source runs as a child process ── + +const HOOKS = ` +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import ts from 'typescript'; +const SDK_SRC = ${JSON.stringify(SDK_SRC)}; +const isFile = (p) => fs.existsSync(p) && fs.statSync(p).isFile(); +const hit = (f) => ({ url: pathToFileURL(f).href, shortCircuit: true }); +export async function resolve(spec, ctx, next) { + if (spec === '@bradygaster/squad-sdk') return hit(path.join(SDK_SRC, 'index.ts')); + const m = /^@bradygaster\\/squad-sdk\\/(.+)$/.exec(spec); + if (m) for (const c of [m[1] + '/index.ts', m[1] + '.ts']) if (isFile(path.join(SDK_SRC, c))) return hit(path.join(SDK_SRC, c)); + if ((spec.startsWith('./') || spec.startsWith('../')) && spec.endsWith('.js') && ctx.parentURL && ctx.parentURL.startsWith('file:')) { + const f = fileURLToPath(new URL(spec.slice(0, -3) + '.ts', ctx.parentURL)); + if (isFile(f)) return hit(f); + } + return next(spec, ctx); +} +export async function load(url, ctx, next) { + if (url.startsWith('file:') && url.endsWith('.ts')) { + const file = fileURLToPath(url); + const out = ts.transpileModule(fs.readFileSync(file, 'utf8'), { + fileName: file, + compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022, sourceMap: false }, + }); + return { format: 'module', source: out.outputText, shortCircuit: true }; + } + return next(url, ctx); +} +`; +const REGISTER = `import { register } from 'node:module';\nregister('./hooks.mjs', import.meta.url);\n`; +const ENTRY = `const { runStateMcp } = await import(${JSON.stringify(pathToFileURL(STATE_MCP).href)});\nawait runStateMcp(process.cwd());\n`; + +// ── MCP stdio client ──────────────────────────────────────────────── + +interface LaunchSpec { command: string; args: string[]; env?: Record } + +class McpStdioClient { + private proc: ChildProcessWithoutNullStreams; + private buf = ''; + private nextId = 1; + private pending = new Map void; reject: (e: Error) => void; timer: NodeJS.Timeout }>(); + stderr = ''; + exited: Promise; + + constructor(spec: LaunchSpec, cwd: string) { + this.proc = spawn(spec.command, spec.args, { cwd, env: { ...process.env, ...spec.env }, stdio: ['pipe', 'pipe', 'pipe'] }); + this.proc.stdout.setEncoding('utf8'); + this.proc.stdout.on('data', (d: string) => this.onData(d)); + this.proc.stderr.setEncoding('utf8'); + this.proc.stderr.on('data', (d: string) => { this.stderr += d; }); + this.exited = new Promise(res => this.proc.on('exit', code => { + for (const p of this.pending.values()) { clearTimeout(p.timer); p.reject(new Error(`server exited (${code}): ${this.stderr.slice(-2000)}`)); } + this.pending.clear(); + res(code); + })); + } + + get pid(): number | undefined { return this.proc.pid; } + + private onData(chunk: string): void { + this.buf += chunk; + for (;;) { + // newline-delimited JSON (SDK stdio transport); tolerate Content-Length framing too + const header = /^Content-Length:\s*(\d+)\r?\n\r?\n/i.exec(this.buf); + let raw: string; + if (header) { + const len = Number(header[1]); + const start = header[0].length; + if (this.buf.length < start + len) return; + raw = this.buf.slice(start, start + len); + this.buf = this.buf.slice(start + len); + } else { + const nl = this.buf.indexOf('\n'); + if (nl === -1) return; + raw = this.buf.slice(0, nl).trim(); + this.buf = this.buf.slice(nl + 1); + if (!raw) continue; + } + let msg: any; + try { msg = JSON.parse(raw); } catch { continue; } + const p = typeof msg.id === 'number' ? this.pending.get(msg.id) : undefined; + if (p) { clearTimeout(p.timer); this.pending.delete(msg.id); p.resolve(msg); } + } + } + + request(method: string, params?: unknown, timeoutMs = 60_000): Promise { + const id = this.nextId++; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`timeout waiting for ${method}; stderr: ${this.stderr.slice(-2000)}`)); + }, timeoutMs); + this.pending.set(id, { resolve, reject, timer }); + this.proc.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n'); + }); + } + + notify(method: string, params?: unknown): void { + this.proc.stdin.write(JSON.stringify({ jsonrpc: '2.0', method, params }) + '\n'); + } + + async handshake(): Promise { + const res = await this.request('initialize', { + protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'squad-real-client-test', version: '0.0.0' }, + }); + this.notify('notifications/initialized'); + return res.result; + } + + async call(name: string, args: Record = {}): Promise<{ isError: boolean; text: string; json: any }> { + const res = await this.request('tools/call', { name, arguments: args }); + if (res.error) throw new Error(`${name}: ${JSON.stringify(res.error)}`); + const text: string = res.result?.content?.[0]?.text ?? ''; + let json: any; + try { json = JSON.parse(text); } catch { json = undefined; } + return { isError: res.result?.isError === true, text, json }; + } + + async close(): Promise { + this.proc.stdin.end(); + const timer = setTimeout(() => this.proc.kill(), 3000); + await this.exited; + clearTimeout(timer); + } +} + +// ── workspace scaffolding ─────────────────────────────────────────── + +let tmpRoot: string; +let loaderDir: string; +const workspaces = new Map(); + +/** Minimal harness used only to leave a pending approval in persisted state (as a prior session would). */ +class SeedHarness implements HarnessAdapter { + readonly id = 'copilot'; + readonly capabilities = { streaming: true, approvals: true, cancellation: true, tools: true, extensionNamespaces: ['x-copilot'] }; + private listeners = new Set<(e: HarnessEventEnvelope) => void>(); + async submit() { /* no-op */ } + subscribe(_c: string, l: (e: HarnessEventEnvelope) => void) { this.listeners.add(l); return () => { this.listeners.delete(l); }; } + async cancel() { /* no-op */ } + async respondApproval() { /* no-op */ } + describe() { return { id: this.id, capabilities: this.capabilities }; } + emit(e: HarnessEventEnvelope) { this.listeners.forEach(l => l(e)); } +} + +async function scaffold(client: RealClient): Promise { + const ws = path.join(tmpRoot, `ws-${client}`); + fs.mkdirSync(path.join(ws, '.squad', 'agents', 'data'), { recursive: true }); + fs.writeFileSync(path.join(ws, '.squad', 'config.json'), JSON.stringify({ version: '2.0', stateBackend: 'local' }, null, 2) + '\n'); + fs.writeFileSync(path.join(ws, '.squad', 'team.md'), '# Team\n\n| Name | Role |\n|---|---|\n| Data | Framework Expert |\n'); + fs.writeFileSync(path.join(ws, '.squad', 'decisions.md'), '# Decisions\n'); + fs.writeFileSync(path.join(ws, '.squad', 'agents', 'data', 'charter.md'), '# Data\n'); + + const ctx: ClientContext = { + root: ws, + mcp: { command: process.execPath, args: ['--import', pathToFileURL(path.join(loaderDir, 'register.mjs')).href, path.join(loaderDir, 'entry.mjs')] }, + members: [{ name: 'Data', role: 'Framework Expert' }], + }; + const results = installClients([client], ctx); + expect(results.filter(r => r.action === 'error')).toEqual([]); + + // a previous session left an approval in flight + const registry = new DefaultHarnessRegistry(); + const seed = new SeedHarness(); + registry.register(seed); + const backend = new LocalSquadBackend({ state: createFileStateStore(path.join(ws, '.squad')), harnesses: registry }); + await backend.createConversation({ conversationId: 'seeded' }); + await backend.sendMessage('seeded', { runId: 'seed-run', seq: 1, role: 'user', content: 'deploy?', harness: 'copilot' }); + seed.emit({ type: 'approval-requested', runId: 'seed-run', seq: 1, request: { approvalId: 'appr-1', turnSeq: 1, action: 'deploy', hash: 'sha256:deploy-v1' } }); + return ws; +} + +/** Parse the launch command out of the files the client host would actually read. */ +function launchFromDisk(client: RealClient, ws: string): LaunchSpec { + const file = { claude: '.mcp.json', opencode: 'opencode.json', kiro: path.join('.kiro', 'settings', 'mcp.json') }[client]; + const parsed = JSON.parse(fs.readFileSync(path.join(ws, file), 'utf8')); + if (client === 'opencode') { + const entry = parsed.mcp[SQUAD_STATE_KEY]; + expect(entry).toMatchObject({ type: 'local', enabled: true }); + expect(parsed.$schema).toBe('https://opencode.ai/config.json'); + const [command, ...args] = entry.command as string[]; + return { command: command!, args, env: entry.environment }; + } + const entry = parsed.mcpServers[SQUAD_STATE_KEY]; + expect(typeof entry.command).toBe('string'); + return { command: entry.command, args: entry.args, env: entry.env }; +} + +beforeAll(async () => { + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'squad20-real-')); + // repo-local so the bundled `typescript` resolves from node_modules + loaderDir = path.join(ROOT, `.test-real-clients-${randomBytes(4).toString('hex')}`); + fs.mkdirSync(loaderDir, { recursive: true }); + fs.writeFileSync(path.join(loaderDir, 'hooks.mjs'), HOOKS); + fs.writeFileSync(path.join(loaderDir, 'register.mjs'), REGISTER); + fs.writeFileSync(path.join(loaderDir, 'entry.mjs'), ENTRY); + for (const c of CLIENTS) workspaces.set(c, await scaffold(c)); +}, 120_000); + +afterAll(() => { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + fs.rmSync(loaderDir, { recursive: true, force: true }); +}); + +// ── projections on disk ───────────────────────────────────────────── + +describe('real client projections on disk', () => { + it('writes the host config and agent files each client reads', () => { + const expected: Record = { + claude: ['.mcp.json', '.claude/agents/squad.md', '.claude/agents/squad-data.md', 'CLAUDE.md'], + opencode: ['opencode.json', '.opencode/agent/squad.md'], + kiro: ['.kiro/settings/mcp.json', '.kiro/agents/squad.json', '.kiro/steering/squad.md'], + }; + for (const c of CLIENTS) { + for (const rel of expected[c]) expect(fs.existsSync(path.join(workspaces.get(c)!, rel)), `${c}: ${rel}`).toBe(true); + } + expect(fs.readFileSync(path.join(workspaces.get('claude')!, '.claude/agents/squad.md'), 'utf8')).toMatch(/^---\n[\s\S]*mcp__squad_state[\s\S]*---\n/); + expect(JSON.parse(fs.readFileSync(path.join(workspaces.get('kiro')!, '.kiro/agents/squad.json'), 'utf8'))).toBeTypeOf('object'); + }); + + it.each(CLIENTS)('%s: config parses back to a launchable command', (c) => { + const spec = launchFromDisk(c, workspaces.get(c)!); + expect(spec.command).toBe(process.execPath); + expect(spec.args[0]).toBe('--import'); + }); +}); + +// ── real child-process stdio MCP ──────────────────────────────────── + +describe.each(CLIENTS)('real stdio MCP server launched from the %s config', (client) => { + let ws: string; + let server: McpStdioClient | undefined; + beforeAll(() => { ws = workspaces.get(client)!; }); + afterAll(async () => { await server?.close(); }); + + it('completes the MCP handshake and lists squad_state_* and all squad_backend_* tools', async () => { + server = new McpStdioClient(launchFromDisk(client, ws), ws); + const init = await server.handshake(); + expect(init.serverInfo.name).toBe('squad-state'); + expect(init.capabilities.tools).toBeDefined(); + expect(server.pid).toBeGreaterThan(0); + + const list = await server.request('tools/list', {}); + const names: string[] = list.result.tools.map((t: { name: string }) => t.name); + for (const t of [...STATE_TOOLS, ...BACKEND_TOOLS]) expect(names, t).toContain(t); + expect(names.filter(n => n.startsWith('squad_backend_')).sort()).toEqual([...BACKEND_TOOLS].sort()); + expect(names.filter(n => n.startsWith('squad_state_')).length).toBeGreaterThanOrEqual(6); + for (const t of list.result.tools) expect(t.inputSchema.type).toBe('object'); + }, 120_000); + + it('executes squad_state tools against the workspace .squad directory', async () => { + const health = await server!.call('squad_state_health'); + expect(health.isError).toBe(false); + expect(health.text.length).toBeGreaterThan(0); + + const written = await server!.call('squad_state_write', { key: 'agents/data/history.md', content: `# History\n\n- ${client} was here\n` }); + expect(written.isError).toBe(false); + const read = await server!.call('squad_state_read', { key: 'agents/data/history.md' }); + expect(read.text).toContain(`${client} was here`); + expect(fs.readFileSync(path.join(ws, '.squad', 'agents', 'data', 'history.md'), 'utf8')).toContain(`${client} was here`); + const listed = await server!.call('squad_state_list', { key: 'agents/data' }); + expect(listed.isError).toBe(false); + }); + + it('negotiates capabilities for the client per capability-matrix.json', async () => { + const entry = matrix.clients[client]!; + const neg = await server!.call('squad_backend_negotiate', { + protocols: ['2.0'], clientName: client, capabilities: entry.clientCapabilities, policyCapabilities: matrix.policyCapabilities, + }); + expect(neg.isError).toBe(false); + expect(neg.json.effectiveCapabilities).toEqual(entry.effectiveCapabilities); + expect(neg.json.protocol).toBe('2.0'); + const bad = await server!.call('squad_backend_negotiate', { protocols: ['0.1'] }); + expect(bad.isError).toBe(true); + expect(bad.json.error).toMatchObject({ code: 'unsupported-protocol', retryable: false }); + }); + + it('runs a backend conversation over real JSON-RPC: create, send, dedupe, page events, cancel', async () => { + const conv = await server!.call('squad_backend_create_conversation', { conversationId: `${client}-conv`, title: `real ${client}` }); + expect(conv.json).toMatchObject({ conversationId: `${client}-conv`, epoch: 1 }); + + const sent = await server!.call('squad_backend_send_message', { + conversationId: `${client}-conv`, runId: `${client}-run`, seq: 1, correlationId: 'corr-real', + parts: [{ type: 'text', text: 'hello from a real client' }, { type: 'resource', uri: 'file:///README.md', title: 'README' }], + }); + expect(sent.json).toMatchObject({ runId: `${client}-run`, seq: 1, duplicate: false, state: 'queued', correlationId: 'corr-real' }); + const dup = await server!.call('squad_backend_send_message', { conversationId: `${client}-conv`, runId: `${client}-run`, seq: 1, content: 'retry' }); + expect(dup.json.duplicate).toBe(true); + + const transcript = await server!.call('squad_backend_get_transcript', { conversationId: `${client}-conv` }); + expect(transcript.json).toHaveLength(1); + expect(transcript.json[0].content).toContain('hello from a real client'); + expect((await server!.call('squad_backend_get_turn', { conversationId: `${client}-conv`, seq: 1 })).json).toMatchObject({ runId: `${client}-run`, state: 'queued' }); + + const page1 = await server!.call('squad_backend_read_events', { conversationId: `${client}-conv`, limit: 1 }); + expect(page1.json.events.map((e: { sequence: number }) => e.sequence)).toEqual([1]); + const page2 = await server!.call('squad_backend_read_events', { conversationId: `${client}-conv`, after: page1.json.nextCursor }); + expect(page2.json.events.map((e: { type: string }) => e.type)).toEqual(['message.accepted']); + expect(page2.json).toMatchObject({ resumable: true, lastAcceptedSeq: 2 }); + + const cancelled = await server!.call('squad_backend_cancel_run', { conversationId: `${client}-conv`, runId: `${client}-run`, reason: 'real test' }); + expect(cancelled.json).toMatchObject({ runId: `${client}-run`, state: 'cancelled' }); + expect((await server!.call('squad_backend_cancel_run', { conversationId: `${client}-conv`, runId: 'nope' })).json.error.code).toBe('not-found'); + + // persisted by the child process into the real workspace + expect(fs.existsSync(path.join(ws, '.squad', 'backend', 'conversations', `${client}-conv.json`))).toBe(true); + }); + + it('serves durable, versioned approvals left by a prior session and enforces hash binding', async () => { + const approvals = await server!.call('squad_backend_get_approvals', { conversationId: 'seeded' }); + expect(approvals.json).toMatchObject([{ approvalId: 'appr-1', version: 1, state: 'pending', hash: 'sha256:deploy-v1' }]); + + const tampered = await server!.call('squad_backend_respond_approval', { conversationId: 'seeded', approvalId: 'appr-1', hash: 'sha256:evil', decision: 'approve' }); + expect(tampered.isError).toBe(true); + expect(tampered.json.error.code).toBe('approval-hash-mismatch'); + + const ok = await server!.call('squad_backend_respond_approval', { conversationId: 'seeded', approvalId: 'appr-1', hash: 'sha256:deploy-v1', decision: 'approve' }); + expect(ok.isError).toBe(false); + expect(ok.json).toMatchObject({ runId: 'seed-run', state: 'running' }); + expect((await server!.call('squad_backend_get_approvals', { conversationId: 'seeded' })).json[0]).toMatchObject({ version: 2, state: 'approved' }); + const again = await server!.call('squad_backend_respond_approval', { conversationId: 'seeded', approvalId: 'appr-1', hash: 'sha256:deploy-v1', decision: 'approve' }); + expect(again.json.error.code).toBe('approval-not-pending'); + }); + + it('reports backend health', async () => { + const health = await server!.call('squad_backend_health'); + expect(health.json).toMatchObject({ ok: true, backend: 'mcp' }); + }); + + it('survives a real server process restart and resumes from the old cursor', async () => { + const first = await server!.call('squad_backend_read_events', { conversationId: `${client}-conv`, limit: 2 }); + const cursor = first.json.nextCursor as string; + const oldPid = server!.pid; + await server!.close(); + + server = new McpStdioClient(launchFromDisk(client, ws), ws); + await server.handshake(); + expect(server.pid).not.toBe(oldPid); + const resumed = await server!.call('squad_backend_read_events', { conversationId: `${client}-conv`, after: cursor }); + expect(resumed.json.resumable).toBe(true); + const all = await server!.call('squad_backend_read_events', { conversationId: `${client}-conv` }); + const seqs = all.json.events.map((e: { sequence: number }) => e.sequence); + expect(seqs).toEqual(seqs.map((_: number, i: number) => i + 1)); + expect(resumed.json.events.map((e: { sequence: number }) => e.sequence)).toEqual(seqs.slice(2)); + expect((await server.call('squad_backend_get_approvals', { conversationId: 'seeded' })).json[0].version).toBe(2); + expect((await server.call('squad_backend_send_message', { conversationId: `${client}-conv`, runId: `${client}-run`, seq: 1, content: 'again' })).json.duplicate).toBe(true); + }, 120_000); +}); + +// ── live binaries (run for real whenever they are installed) ──────── + +function which(...names: string[]): string | undefined { + const exts = process.platform === 'win32' ? (process.env['PATHEXT'] ?? '.EXE;.CMD;.BAT').split(';') : ['']; + for (const dir of (process.env['PATH'] ?? '').split(path.delimiter)) { + for (const name of names) { + for (const ext of exts) { + const full = path.join(dir, name + ext.toLowerCase()); + const alt = path.join(dir, name + ext); + for (const f of [full, alt]) if (dir && fs.existsSync(f) && fs.statSync(f).isFile()) return f; + } + } + } + return undefined; +} + +function runBinary(bin: string, args: string[], cwd: string) { + const viaShell = /\.(cmd|bat)$/i.test(bin); + const r = spawnSync(viaShell ? `"${bin}"` : bin, args, { cwd, encoding: 'utf8', timeout: 90_000, shell: viaShell, env: { ...process.env, CI: '1', NO_COLOR: '1' } }); + return { status: r.status, out: `${r.stdout ?? ''}${r.stderr ?? ''}`, error: r.error }; +} + +/** xpect runs against the combined output of the real binary reading the projected workspace. */ +const BINARIES: Array<{ client: RealClient; names: string[]; configChecks: Array<{ args: string[]; expect: RegExp; reject?: RegExp }> }> = [ + // claude lists project servers from .mcp.json (pending until the user approves them interactively) + { client: 'claude', names: ['claude'], configChecks: [{ args: ['mcp', 'list'], expect: /squad_state/ }] }, + // opencode actually launches the server from opencode.json and health-checks it + { client: 'opencode', names: ['opencode'], configChecks: [{ args: ['mcp', 'list'], expect: /squad_state/, reject: /failed|timed out/i }] }, + { client: 'kiro', names: ['kiro-cli', 'kiro'], configChecks: [{ args: ['agent', 'list'], expect: /squad/i }] }, +]; + +describe('live client binaries (executed when present on PATH)', () => { + for (const b of BINARIES) { + const bin = which(...b.names); + const d = bin ? describe : describe.skip; + d(`${b.client} (${bin ?? 'not installed'})`, () => { + it('reports a version', () => { + const r = runBinary(bin!, ['--version'], workspaces.get(b.client)!); + expect(r.error).toBeUndefined(); + expect(r.status).toBe(0); + expect(r.out).toMatch(/\d+\.\d+/); + }, 150_000); + + for (const check of b.configChecks) { + it(`reads the projected workspace: ${check.args.join(' ')}`, () => { + const r = runBinary(bin!, check.args, workspaces.get(b.client)!); + expect(r.error, r.out).toBeUndefined(); + expect(r.status, r.out).toBe(0); + expect(r.out).toMatch(check.expect); + if (check.reject) expect(r.out).not.toMatch(check.reject); + }, 150_000); + } + }); + } +}); From 41c8a7889e480d3b9da800a59eb96d662598d379 Mon Sep 17 00:00:00 2001 From: Copilot Date: Sun, 18 Oct 2026 06:26:09 +0300 Subject: [PATCH 11/46] fix(clients): Kiro resource paths and coordinator roster, add real-CLI tests Real Kiro CLI resolves agent `resources` file:// paths against the workspace root (only `prompt` is relative to the agent config), so use file://.squad/team.md rather than ../../. Skip the Coordinator row when projecting Claude member subagents (it produced a bogus squad-squad agent). Add real-binary tests (claude, opencode, kiro-cli) that skip when absent. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 995eea17-c356-4912-90ea-5e5b144e7ba9 --- packages/squad-cli/src/cli/core/clients.ts | 3 +- packages/squad-sdk/src/clients/adapters.ts | 4 +- test/clients-real-cli.test.ts | 60 ++++++++++++++++++++++ test/clients.test.ts | 8 +++ 4 files changed, 72 insertions(+), 3 deletions(-) create mode 100644 test/clients-real-cli.test.ts diff --git a/packages/squad-cli/src/cli/core/clients.ts b/packages/squad-cli/src/cli/core/clients.ts index 0608db552..ebd4e517d 100644 --- a/packages/squad-cli/src/cli/core/clients.ts +++ b/packages/squad-cli/src/cli/core/clients.ts @@ -73,7 +73,8 @@ export function parseRoster(teamMd: string): TeamMember[] { if (roleCol === -1 || cells.every((c) => /^:?-+:?$/.test(c))) continue; const name = cells[nameCol]; const role = cells[roleCol]; - if (name && role) members.push({ name, role }); + // The coordinator is the host agent itself, not a dispatchable specialist. + if (name && role && !/^coordinator$/i.test(role)) members.push({ name, role }); } return members; } diff --git a/packages/squad-sdk/src/clients/adapters.ts b/packages/squad-sdk/src/clients/adapters.ts index 3448942fe..e72d435ed 100644 --- a/packages/squad-sdk/src/clients/adapters.ts +++ b/packages/squad-sdk/src/clients/adapters.ts @@ -149,8 +149,8 @@ const kiro: ClientAdapter = { tools: ['*'], includeMcpJson: true, resources: [ - `file://../../${ctx.squadDir ?? '.squad'}/team.md`, - `file://../../${ctx.squadDir ?? '.squad'}/routing.md`, + `file://${ctx.squadDir ?? '.squad'}/team.md`, + `file://${ctx.squadDir ?? '.squad'}/routing.md`, ], }, null, diff --git a/test/clients-real-cli.test.ts b/test/clients-real-cli.test.ts new file mode 100644 index 000000000..adccc0673 --- /dev/null +++ b/test/clients-real-cli.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { installClients } from '../packages/squad-sdk/src/clients/index.js'; + +vi.setConfig({ testTimeout: 120_000 }); + +// Real-binary checks: each block is skipped when the CLI is not installed. +const KIRO_DIR = path.join(process.env['LOCALAPPDATA'] ?? '', 'Kiro-Cli'); +const withKiro = { ...process.env, PATH: `${process.env['PATH']}${path.delimiter}${KIRO_DIR}` }; + +function run(cmd: string, args: string[], cwd: string, env: NodeJS.ProcessEnv = process.env) { + return spawnSync(cmd, args, { cwd, env, encoding: 'utf8', shell: process.platform === 'win32', timeout: 120_000 }); +} +const has = (cmd: string, env: NodeJS.ProcessEnv = process.env) => run(cmd, ['--version'], os.tmpdir(), env).status === 0; + +let root: string; +beforeAll(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'squad-real-cli-')); + run('git', ['init', '-q'], root); + const squad = path.join(root, '.squad'); + fs.mkdirSync(squad); + fs.writeFileSync(path.join(squad, 'team.md'), '| Name | Role | Charter | Status |\n|---|---|---|---|\n| Data | Expert | c | ok |\n'); + const results = installClients(['claude', 'opencode', 'kiro'], { + root, + mcp: { command: 'node', args: ['-e', '0'] }, + members: [{ name: 'Data', role: 'Expert' }], + }); + expect(results.filter((r) => r.action === 'error')).toEqual([]); +}); +afterAll(() => fs.rmSync(root, { recursive: true, force: true })); + +describe('real CLIs load Squad projections', () => { + it.skipIf(!has('claude'))('claude discovers the squad subagents and parses .mcp.json', () => { + const agents = run('claude', ['-p', '--agent', 'nonexistent-zzz', 'hi'], root); + const text = `${agents.stdout}${agents.stderr}`; + expect(text).toMatch(/Available agents:.*\bsquad\b/); + expect(text).toMatch(/squad-data/); + const mcp = run('claude', ['mcp', 'get', 'squad_state'], root); + expect(mcp.stdout).toMatch(/Type: stdio/); + }); + + it.skipIf(!has('opencode'))('opencode loads squad as a primary agent and connects the MCP server', () => { + const list = run('opencode', ['agent', 'list'], root); + expect(list.stdout).toMatch(/^squad \(primary\)/m); + const cfg = run('opencode', ['debug', 'config'], root); + expect(JSON.parse(cfg.stdout).agent.squad.mode).toBe('primary'); + }); + + it.skipIf(!has('kiro-cli', withKiro))('kiro validates and lists the squad agent', () => { + const v = run('kiro-cli', ['agent', 'validate', '--path', path.join('.kiro', 'agents', 'squad.json')], root, withKiro); + expect(v.status).toBe(0); + expect(`${v.stdout}${v.stderr}`).not.toMatch(/invalid/i); + const list = run('kiro-cli', ['agent', 'list'], root, withKiro); + const out = `${list.stdout}${list.stderr}`.replace(/\u001b\[[0-9;]*m/g, ''); + expect(out).toMatch(/squad\s+Workspace/); + }); +}); diff --git a/test/clients.test.ts b/test/clients.test.ts index c39a16284..59c8b1a5b 100644 --- a/test/clients.test.ts +++ b/test/clients.test.ts @@ -136,6 +136,9 @@ describe('kiro adapter', () => { const agent = readJson('.kiro/agents/squad.json'); expect(agent.name).toBe('squad'); expect(agent.includeMcpJson).toBe(true); + // Kiro resolves resources against the workspace root but prompt against the config file. + expect(agent.prompt).toBe('file://../steering/squad.md'); + expect(agent.resources).toEqual(['file://.squad/team.md', 'file://.squad/routing.md']); expect(readJson('.kiro/settings/mcp.json').mcpServers.squad_state).toMatchObject({ command: 'squad', args: ['state-mcp'], disabled: false, }); @@ -234,6 +237,11 @@ describe('CLI --client handling', () => { expect(() => readClientFlag(['init', '--client', 'nope'])).toThrow(/Unknown client/); }); + it('roster parsing skips the coordinator row', () => { + const md = '| Name | Role | Notes |\n|---|---|---|\n| Squad | Coordinator | x |\n\n| Name | Role | Charter | Status |\n|---|---|---|---|\n| Data | Expert | c | ok |\n'; + expect(parseRoster(md)).toEqual([{ name: 'Data', role: 'Expert' }]); + }); + it('parses roster tables', () => { const md = '| Name | Role | Charter | Status |\n|---|---|---|---|\n| Data | Framework Expert | c | ✅ |\n| Worf | Security | c | ✅ |\n'; expect(parseRoster(md)).toEqual([ From ad7246b15694c22d7c616c828f3d3fb0010a3081 Mon Sep 17 00:00:00 2001 From: Copilot Date: Sun, 18 Oct 2026 06:26:10 +0300 Subject: [PATCH 12/46] fix(clients): Kiro resource paths and coordinator roster, add real-CLI tests Real Kiro CLI resolves agent `resources` file:// paths against the workspace root (only `prompt` is relative to the agent config), so use file://.squad/team.md rather than ../../. Skip the Coordinator row when projecting Claude member subagents (it produced a bogus squad-squad agent). Add real-binary tests (claude, opencode, kiro-cli) that skip when absent. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 995eea17-c356-4912-90ea-5e5b144e7ba9 --- packages/squad-cli/src/cli/core/clients.ts | 3 +- packages/squad-sdk/src/clients/adapters.ts | 4 +- test/clients-real-cli.test.ts | 60 ++++++++++++++++++++++ test/clients.test.ts | 8 +++ 4 files changed, 72 insertions(+), 3 deletions(-) create mode 100644 test/clients-real-cli.test.ts diff --git a/packages/squad-cli/src/cli/core/clients.ts b/packages/squad-cli/src/cli/core/clients.ts index 0608db552..ebd4e517d 100644 --- a/packages/squad-cli/src/cli/core/clients.ts +++ b/packages/squad-cli/src/cli/core/clients.ts @@ -73,7 +73,8 @@ export function parseRoster(teamMd: string): TeamMember[] { if (roleCol === -1 || cells.every((c) => /^:?-+:?$/.test(c))) continue; const name = cells[nameCol]; const role = cells[roleCol]; - if (name && role) members.push({ name, role }); + // The coordinator is the host agent itself, not a dispatchable specialist. + if (name && role && !/^coordinator$/i.test(role)) members.push({ name, role }); } return members; } diff --git a/packages/squad-sdk/src/clients/adapters.ts b/packages/squad-sdk/src/clients/adapters.ts index 3448942fe..e72d435ed 100644 --- a/packages/squad-sdk/src/clients/adapters.ts +++ b/packages/squad-sdk/src/clients/adapters.ts @@ -149,8 +149,8 @@ const kiro: ClientAdapter = { tools: ['*'], includeMcpJson: true, resources: [ - `file://../../${ctx.squadDir ?? '.squad'}/team.md`, - `file://../../${ctx.squadDir ?? '.squad'}/routing.md`, + `file://${ctx.squadDir ?? '.squad'}/team.md`, + `file://${ctx.squadDir ?? '.squad'}/routing.md`, ], }, null, diff --git a/test/clients-real-cli.test.ts b/test/clients-real-cli.test.ts new file mode 100644 index 000000000..adccc0673 --- /dev/null +++ b/test/clients-real-cli.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { installClients } from '../packages/squad-sdk/src/clients/index.js'; + +vi.setConfig({ testTimeout: 120_000 }); + +// Real-binary checks: each block is skipped when the CLI is not installed. +const KIRO_DIR = path.join(process.env['LOCALAPPDATA'] ?? '', 'Kiro-Cli'); +const withKiro = { ...process.env, PATH: `${process.env['PATH']}${path.delimiter}${KIRO_DIR}` }; + +function run(cmd: string, args: string[], cwd: string, env: NodeJS.ProcessEnv = process.env) { + return spawnSync(cmd, args, { cwd, env, encoding: 'utf8', shell: process.platform === 'win32', timeout: 120_000 }); +} +const has = (cmd: string, env: NodeJS.ProcessEnv = process.env) => run(cmd, ['--version'], os.tmpdir(), env).status === 0; + +let root: string; +beforeAll(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'squad-real-cli-')); + run('git', ['init', '-q'], root); + const squad = path.join(root, '.squad'); + fs.mkdirSync(squad); + fs.writeFileSync(path.join(squad, 'team.md'), '| Name | Role | Charter | Status |\n|---|---|---|---|\n| Data | Expert | c | ok |\n'); + const results = installClients(['claude', 'opencode', 'kiro'], { + root, + mcp: { command: 'node', args: ['-e', '0'] }, + members: [{ name: 'Data', role: 'Expert' }], + }); + expect(results.filter((r) => r.action === 'error')).toEqual([]); +}); +afterAll(() => fs.rmSync(root, { recursive: true, force: true })); + +describe('real CLIs load Squad projections', () => { + it.skipIf(!has('claude'))('claude discovers the squad subagents and parses .mcp.json', () => { + const agents = run('claude', ['-p', '--agent', 'nonexistent-zzz', 'hi'], root); + const text = `${agents.stdout}${agents.stderr}`; + expect(text).toMatch(/Available agents:.*\bsquad\b/); + expect(text).toMatch(/squad-data/); + const mcp = run('claude', ['mcp', 'get', 'squad_state'], root); + expect(mcp.stdout).toMatch(/Type: stdio/); + }); + + it.skipIf(!has('opencode'))('opencode loads squad as a primary agent and connects the MCP server', () => { + const list = run('opencode', ['agent', 'list'], root); + expect(list.stdout).toMatch(/^squad \(primary\)/m); + const cfg = run('opencode', ['debug', 'config'], root); + expect(JSON.parse(cfg.stdout).agent.squad.mode).toBe('primary'); + }); + + it.skipIf(!has('kiro-cli', withKiro))('kiro validates and lists the squad agent', () => { + const v = run('kiro-cli', ['agent', 'validate', '--path', path.join('.kiro', 'agents', 'squad.json')], root, withKiro); + expect(v.status).toBe(0); + expect(`${v.stdout}${v.stderr}`).not.toMatch(/invalid/i); + const list = run('kiro-cli', ['agent', 'list'], root, withKiro); + const out = `${list.stdout}${list.stderr}`.replace(/\u001b\[[0-9;]*m/g, ''); + expect(out).toMatch(/squad\s+Workspace/); + }); +}); diff --git a/test/clients.test.ts b/test/clients.test.ts index c39a16284..59c8b1a5b 100644 --- a/test/clients.test.ts +++ b/test/clients.test.ts @@ -136,6 +136,9 @@ describe('kiro adapter', () => { const agent = readJson('.kiro/agents/squad.json'); expect(agent.name).toBe('squad'); expect(agent.includeMcpJson).toBe(true); + // Kiro resolves resources against the workspace root but prompt against the config file. + expect(agent.prompt).toBe('file://../steering/squad.md'); + expect(agent.resources).toEqual(['file://.squad/team.md', 'file://.squad/routing.md']); expect(readJson('.kiro/settings/mcp.json').mcpServers.squad_state).toMatchObject({ command: 'squad', args: ['state-mcp'], disabled: false, }); @@ -234,6 +237,11 @@ describe('CLI --client handling', () => { expect(() => readClientFlag(['init', '--client', 'nope'])).toThrow(/Unknown client/); }); + it('roster parsing skips the coordinator row', () => { + const md = '| Name | Role | Notes |\n|---|---|---|\n| Squad | Coordinator | x |\n\n| Name | Role | Charter | Status |\n|---|---|---|---|\n| Data | Expert | c | ok |\n'; + expect(parseRoster(md)).toEqual([{ name: 'Data', role: 'Expert' }]); + }); + it('parses roster tables', () => { const md = '| Name | Role | Charter | Status |\n|---|---|---|---|\n| Data | Framework Expert | c | ✅ |\n| Worf | Security | c | ✅ |\n'; expect(parseRoster(md)).toEqual([ From af93bcad41d3db78e0e11c494779428c8fd6d76b Mon Sep 17 00:00:00 2001 From: Copilot Date: Sun, 18 Oct 2026 06:27:57 +0300 Subject: [PATCH 13/46] test(2.0): locate per-user kiro-cli install when not on PATH Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 995eea17-c356-4912-90ea-5e5b144e7ba9 --- test/squad-20-real-clients-stdio.e2e.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/squad-20-real-clients-stdio.e2e.test.ts b/test/squad-20-real-clients-stdio.e2e.test.ts index 8b3d5aeea..f2cf4c4b1 100644 --- a/test/squad-20-real-clients-stdio.e2e.test.ts +++ b/test/squad-20-real-clients-stdio.e2e.test.ts @@ -396,7 +396,9 @@ describe.each(CLIENTS)('real stdio MCP server launched from the %s config', (cli function which(...names: string[]): string | undefined { const exts = process.platform === 'win32' ? (process.env['PATHEXT'] ?? '.EXE;.CMD;.BAT').split(';') : ['']; - for (const dir of (process.env['PATH'] ?? '').split(path.delimiter)) { + // well-known per-user install dirs for installers that do not update the inherited PATH + const extra = [process.env['LOCALAPPDATA'] && path.join(process.env['LOCALAPPDATA'], 'Kiro-Cli'), path.join(os.homedir(), '.local', 'bin')].filter((d): d is string => !!d); + for (const dir of [...(process.env['PATH'] ?? '').split(path.delimiter), ...extra]) { for (const name of names) { for (const ext of exts) { const full = path.join(dir, name + ext.toLowerCase()); From 5c82683d6ec514d9db1289fb1c69c50c9f3a3019 Mon Sep 17 00:00:00 2001 From: Copilot Date: Sun, 18 Oct 2026 07:08:15 +0300 Subject: [PATCH 14/46] feat(clients): add OpenAI Codex and Google Antigravity adapters Codex: AGENTS.md managed block (id-scoped), .codex/config.toml [mcp_servers.squad_state] via a non-destructive TOML table merge (startup timeout defaulted to 60s for cold npx), .codex/agents/squad-.toml. Antigravity: .agents/agents/squad.md (+ member subagents, mcpServers frontmatter) and a GEMINI.md block. Block markers can carry an id so several clients share one file. Real-binary tests for codex and agy; verified against codex-cli 0.163 and agy 1.4.0. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 995eea17-c356-4912-90ea-5e5b144e7ba9 --- packages/squad-cli/src/cli-entry.ts | 2 +- .../squad-cli/src/cli/core/command-help.ts | 4 +- packages/squad-sdk/src/clients/adapters.ts | 101 ++++++++++++++- packages/squad-sdk/src/clients/apply.ts | 119 ++++++++++++------ .../squad-sdk/src/clients/capabilities.ts | 3 +- packages/squad-sdk/src/clients/coordinator.ts | 12 ++ packages/squad-sdk/src/clients/types.ts | 6 +- test/clients-real-cli.test.ts | 21 +++- test/clients.test.ts | 78 +++++++++++- 9 files changed, 298 insertions(+), 48 deletions(-) diff --git a/packages/squad-cli/src/cli-entry.ts b/packages/squad-cli/src/cli-entry.ts index 65ce70e2c..620246761 100644 --- a/packages/squad-cli/src/cli-entry.ts +++ b/packages/squad-cli/src/cli-entry.ts @@ -161,7 +161,7 @@ async function main(): Promise { console.log(` --no-vscode-default (skip .vscode/settings.json update)`); console.log(` --preset (apply a preset after init)`); console.log(` --state-backend (local|orphan|two-layer)`); - console.log(` --client (copilot,claude,kiro,opencode,cursor|all)`); + console.log(` --client (copilot,claude,kiro,opencode,cursor,codex,antigravity|all)`); console.log(` Usage: init --mode remote `); console.log(` Creates .squad/config.json pointing to an external team root`); console.log(` ${BOLD}upgrade${RESET} Update Squad-owned files to latest version`); diff --git a/packages/squad-cli/src/cli/core/command-help.ts b/packages/squad-cli/src/cli/core/command-help.ts index 00e5b9b83..af24cd04b 100644 --- a/packages/squad-cli/src/cli/core/command-help.ts +++ b/packages/squad-cli/src/cli/core/command-help.ts @@ -43,7 +43,7 @@ const COMMAND_HELP: Record = { console.log(` ${BOLD}--no-workflows${RESET} Skip writing GitHub Actions workflows`); console.log(` ${BOLD}--preset ${RESET} Apply a curated preset after init`); console.log(` ${BOLD}--state-backend ${RESET} State backend (local|orphan|two-layer)`); - console.log(` ${BOLD}--client ${RESET} Client hosts to project: copilot,claude,kiro,opencode,cursor or all`); + console.log(` ${BOLD}--client ${RESET} Client hosts to project: copilot,claude,kiro,opencode,cursor,codex,antigravity or all`); console.log(` ${BOLD}--mode remote ${RESET} Point at an external team root (creates .squad/config.json)\n`); }, @@ -59,7 +59,7 @@ const COMMAND_HELP: Record = { console.log(` ${BOLD}--global${RESET} Upgrade the personal (global) squad`); console.log(` ${BOLD}--migrate-directory${RESET} Rename legacy .ai-team/ to .squad/`); console.log(` ${BOLD}--state-backend ${RESET} Migrate to a new backend (orphan|two-layer)`); - console.log(` ${BOLD}--client ${RESET} Add client hosts (copilot,claude,kiro,opencode,cursor|all); configured clients always refresh`); + console.log(` ${BOLD}--client ${RESET} Add client hosts (copilot,claude,kiro,opencode,cursor,codex,antigravity|all); configured clients always refresh`); console.log(` ${BOLD}--self${RESET} Upgrade the squad CLI package itself`); console.log(` ${BOLD}--insider${RESET} With --self, install the @insider tag`); console.log(` ${BOLD}--force${RESET} Overwrite files without prompting\n`); diff --git a/packages/squad-sdk/src/clients/adapters.ts b/packages/squad-sdk/src/clients/adapters.ts index e72d435ed..fb6923509 100644 --- a/packages/squad-sdk/src/clients/adapters.ts +++ b/packages/squad-sdk/src/clients/adapters.ts @@ -5,6 +5,7 @@ */ import { CAPABILITIES } from './capabilities.js'; +import { upsertTomlTable } from './apply.js'; import { buildCoordinatorInstructions, dispatchStrategy } from './coordinator.js'; import type { ClientAdapter, ClientContext, ClientId, ProjectionFile, TeamMember } from './types.js'; import { CLIENT_IDS } from './types.js'; @@ -235,7 +236,105 @@ const cursor: ClientAdapter = { ], }; -export const CLIENT_ADAPTERS: Record = { copilot, claude, kiro, opencode, cursor }; +const tomlString = (s: string) => JSON.stringify(s); +const tomlArray = (a: string[]) => `[${a.map(tomlString).join(', ')}]`; + +function codexMember(m: TeamMember, ctx: ClientContext): ProjectionFile { + const slug = slugifyMember(m.name); + const squadDir = ctx.squadDir ?? '.squad'; + return { + kind: 'managed', + path: `.codex/agents/squad-${slug}.toml`, + content: + `# Generated by Squad. Managed file: refreshed by \`squad upgrade\`; edit ${squadDir}/ instead.\n` + + `name = ${tomlString(`squad-${slug}`)}\n` + + `description = ${tomlString(`Squad specialist ${m.name} (${m.role}). Use for work routed to ${m.name}.`)}\n` + + `developer_instructions = ${tomlString( + `You are ${m.name}, the ${m.role} on this Squad team. First read your charter at ${squadDir}/agents/${slug}/charter.md and follow it. Record decisions with the squad_decide MCP tool and append learnings to your history with squad_state_append.`, + )}\n`, + }; +} + +const codex: ClientAdapter = { + id: 'codex', + capabilities: CAPABILITIES.codex, + label: 'OpenAI Codex', + dispatch: dispatchStrategy('codex'), + project: (ctx) => [ + // Codex has no main-agent file: AGENTS.md is its always-loaded instruction surface. + { kind: 'block', path: 'AGENTS.md', id: 'codex', content: `## Squad\n\n${buildCoordinatorInstructions('codex', ctx.squadDir)}` }, + { + kind: 'text', + path: '.codex/config.toml', + update: (existing) => + upsertTomlTable(existing, `mcp_servers.${SQUAD_STATE_KEY}`, { + command: tomlString(ctx.mcp.command), + args: tomlArray(ctx.mcp.args), + }, { startup_timeout_sec: '60' }), + }, + ...(ctx.members ?? []).map((m) => codexMember(m, ctx)), + ], +}; + +function antigravityAgent( + ctx: ClientContext, + name: string, + description: string, + body: string, + opts: { main: boolean; withMcp: boolean }, +): ProjectionFile { + const lines = [ + '---', + `name: ${name}`, + `description: ${yamlString(description)}`, + `mainAgent: ${opts.main}`, + `subagent: ${!opts.main}`, + ]; + if (opts.withMcp) { + lines.push( + 'mcpServers:', + ` - name: ${SQUAD_STATE_KEY}`, + ` command: ${yamlString(ctx.mcp.command)}`, + ` args: [${ctx.mcp.args.map(yamlString).join(', ')}]`, + ); + } + lines.push('---', GENERATED_NOTE, '', body); + return { kind: 'managed', path: `.agents/agents/${name}.md`, content: lines.join('\n') + '\n' }; +} + +const antigravity: ClientAdapter = { + id: 'antigravity', + capabilities: CAPABILITIES.antigravity, + label: 'Google Antigravity', + dispatch: dispatchStrategy('antigravity'), + project: (ctx) => { + const squadDir = ctx.squadDir ?? '.squad'; + return [ + antigravityAgent(ctx, 'squad', DESCRIPTION, buildCoordinatorInstructions('antigravity', ctx.squadDir), { + main: true, + withMcp: true, + }), + ...(ctx.members ?? []).map((m) => { + const slug = slugifyMember(m.name); + return antigravityAgent( + ctx, + `squad-${slug}`, + `Squad specialist ${m.name} (${m.role}). Use for work routed to ${m.name}.`, + `You are ${m.name}, the ${m.role} on this Squad team.\nFirst read your charter at \`${squadDir}/agents/${slug}/charter.md\` and follow it. Record decisions with the \`squad_decide\` MCP tool and append learnings to your history with \`squad_state_append\`.`, + { main: false, withMcp: true }, + ); + }), + { + kind: 'block', + path: 'GEMINI.md', + id: 'antigravity', + content: `## Squad\n\nThis project uses Squad. Run \`agy --agent squad\` to work with the team defined in \`${squadDir}/team.md\`. Shared state is served by the \`squad_state\` MCP server (see \`.agents/agents/squad.md\`).`, + }, + ]; + }, +}; + +export const CLIENT_ADAPTERS: Record = { copilot, claude, kiro, opencode, cursor, codex, antigravity }; export function getClientAdapter(id: ClientId): ClientAdapter { return CLIENT_ADAPTERS[id]; diff --git a/packages/squad-sdk/src/clients/apply.ts b/packages/squad-sdk/src/clients/apply.ts index e4b0eeadb..87020b88c 100644 --- a/packages/squad-sdk/src/clients/apply.ts +++ b/packages/squad-sdk/src/clients/apply.ts @@ -12,27 +12,77 @@ import type { ApplyResult, ClientContext, ClientId, ProjectionFile } from './typ export const BLOCK_BEGIN = ''; export const BLOCK_END = ''; -/** Replace (or append) the Squad-managed block, leaving all other text untouched. */ -export function upsertBlock(existing: string, content: string): string { - const block = `${BLOCK_BEGIN}\n${content.trim()}\n${BLOCK_END}`; - const start = existing.indexOf(BLOCK_BEGIN); - const end = existing.indexOf(BLOCK_END); +function markers(id?: string): { begin: string; end: string } { + return id + ? { begin: ``, end: `` } + : { begin: BLOCK_BEGIN, end: BLOCK_END }; +} + +/** + * Replace (or append) the Squad-managed block, leaving all other text untouched. + * Distinct `id`s keep independent blocks in one shared file (e.g. AGENTS.md). + */ +export function upsertBlock(existing: string, content: string, id?: string): string { + const { begin, end: endMarker } = markers(id); + const block = `${begin}\n${content.trim()}\n${endMarker}`; + const start = existing.indexOf(begin); + const end = existing.indexOf(endMarker); if (start !== -1 && end > start) { - return existing.slice(0, start) + block + existing.slice(end + BLOCK_END.length); + return existing.slice(0, start) + block + existing.slice(end + endMarker.length); } const sep = existing.length === 0 ? '' : existing.endsWith('\n\n') ? '' : existing.endsWith('\n') ? '\n' : '\n\n'; return `${existing}${sep}${block}\n`; } +/** + * Upsert `[table]` in TOML text, setting only the given managed keys (values are + * already TOML literals). Other keys, sub-tables and tables are left untouched. + */ +export function upsertTomlTable( + existing: string, + table: string, + managed: Record, + defaults: Record = {}, +): string { + const nl = existing.includes('\r\n') ? '\r\n' : '\n'; + const lines = existing === '' ? [] : existing.split(/\r?\n/); + const header = `[${table}]`; + const start = lines.findIndex((l) => l.trim() === header); + if (start === -1) { + const body = Object.entries({ ...managed, ...defaults }).map(([k, v]) => `${k} = ${v}`); + const prefix = existing === '' ? '' : existing.replace(/(\r?\n)*$/, '') + nl + nl; + return `${prefix}${[header, ...body].join(nl)}${nl}`; + } + let end = lines.length; + for (let i = start + 1; i < lines.length; i++) { + if (/^\s*\[/.test(lines[i]!)) { end = i; break; } + } + const section = lines.slice(start + 1, end); + const present = new Set(section.map((l) => /^\s*([A-Za-z0-9_-]+)\s*=/.exec(l)?.[1]).filter(Boolean)); + const remaining = { ...managed }; + for (const [k, v] of Object.entries(defaults)) if (!present.has(k)) remaining[k] = v; + const rewritten = section.map((l) => { + const m = /^\s*([A-Za-z0-9_-]+)\s*=/.exec(l); + if (m && m[1]! in remaining) { + const k = m[1]!; + const v = remaining[k]!; + delete remaining[k]; + return `${k} = ${v}`; + } + return l; + }); + let insertAt = rewritten.length; + while (insertAt > 0 && rewritten[insertAt - 1]!.trim() === '') insertAt--; + rewritten.splice(insertAt, 0, ...Object.entries(remaining).map(([k, v]) => `${k} = ${v}`)); + return [...lines.slice(0, start + 1), ...rewritten, ...lines.slice(end)].join(nl); +} /** Collect projection files for the given clients, in order. */ export function projectClients(clients: readonly ClientId[], ctx: ClientContext): ProjectionFile[] { return clients.flatMap((c) => getClientAdapter(c).project(ctx)); } -function readJsonObject(file: string): Record { - if (!fs.existsSync(file)) return {}; - const raw = fs.readFileSync(file, 'utf8'); - if (raw.trim() === '') return {}; +function parseJsonObject(file: string, raw: string | undefined): Record { + if (raw === undefined || raw.trim() === '') return {}; let parsed: unknown; try { parsed = JSON.parse(raw); @@ -44,7 +94,6 @@ function readJsonObject(file: string): Record { } return parsed as Record; } - function writeIfChanged(file: string, next: string): ApplyResult['action'] { const existed = fs.existsSync(file); if (existed && fs.readFileSync(file, 'utf8') === next) return 'unchanged'; @@ -59,46 +108,40 @@ function writeIfChanged(file: string, next: string): ApplyResult['action'] { */ export function applyProjection(root: string, files: readonly ProjectionFile[]): ApplyResult[] { const results: ApplyResult[] = []; - const jsonByPath = new Map>>(); - const blockByPath = new Map(); - const order: Array<{ kind: 'file'; file: ProjectionFile } | { kind: 'json' | 'block'; path: string }> = []; + const order: string[] = []; + const transforms = new Map string>>(); + const add = (rel: string, fn: (existing: string | undefined, abs: string) => string) => { + if (!transforms.has(rel)) { + order.push(rel); + transforms.set(rel, []); + } + transforms.get(rel)!.push(fn); + }; for (const f of files) { - if (f.kind === 'managed') { - order.push({ kind: 'file', file: f }); - } else if (f.kind === 'json') { - if (!jsonByPath.has(f.path)) order.push({ kind: 'json', path: f.path }); - jsonByPath.set(f.path, [...(jsonByPath.get(f.path) ?? []), f]); - } else { - if (!blockByPath.has(f.path)) order.push({ kind: 'block', path: f.path }); - blockByPath.set(f.path, [...(blockByPath.get(f.path) ?? []), f.content]); + if (f.kind === 'managed') add(f.path, () => f.content); + else if (f.kind === 'block') add(f.path, (t) => upsertBlock(t ?? '', f.content, f.id)); + else if (f.kind === 'text') add(f.path, (t) => f.update(t ?? '')); + else { + add(f.path, (t, abs) => { + const obj = parseJsonObject(abs, t); + return JSON.stringify(f.update(obj), null, 2) + '\n'; + }); } } - for (const item of order) { - const rel = item.kind === 'file' ? (item.file as { path: string }).path : item.path; + for (const rel of order) { const abs = path.join(root, rel); try { - let action: ApplyResult['action']; - if (item.kind === 'file') { - action = writeIfChanged(abs, (item.file as { content: string }).content); - } else if (item.kind === 'json') { - let obj = readJsonObject(abs); - for (const f of jsonByPath.get(item.path)!) obj = f.update(obj); - action = writeIfChanged(abs, JSON.stringify(obj, null, 2) + '\n'); - } else { - let text = fs.existsSync(abs) ? fs.readFileSync(abs, 'utf8') : ''; - for (const c of blockByPath.get(item.path)!) text = upsertBlock(text, c); - action = writeIfChanged(abs, text); - } - results.push({ path: rel, action }); + let text = fs.existsSync(abs) ? fs.readFileSync(abs, 'utf8') : undefined; + for (const fn of transforms.get(rel)!) text = fn(text, abs); + results.push({ path: rel, action: writeIfChanged(abs, text ?? '') }); } catch (err) { results.push({ path: rel, action: 'error', error: err instanceof Error ? err.message : String(err) }); } } return results; } - /** Project and apply the given clients in one step. */ export function installClients(clients: readonly ClientId[], ctx: ClientContext): ApplyResult[] { return applyProjection(ctx.root, projectClients(clients, ctx)); diff --git a/packages/squad-sdk/src/clients/capabilities.ts b/packages/squad-sdk/src/clients/capabilities.ts index 9c762bd45..8c492d66c 100644 --- a/packages/squad-sdk/src/clients/capabilities.ts +++ b/packages/squad-sdk/src/clients/capabilities.ts @@ -7,7 +7,8 @@ export const CAPABILITIES: Record = { kiro: { supportsSubagents: true, supportsMcpTransport: true, supportsNativeHooks: true, supportsSteeringFiles: true, transportModes: ['mcp-bridge', 'http-backend'] }, opencode: { supportsSubagents: true, supportsMcpTransport: true, supportsNativeHooks: true, supportsSteeringFiles: false, transportModes: ['mcp-bridge', 'http-backend'] }, cursor: { supportsSubagents: true, supportsMcpTransport: true, supportsNativeHooks: false, supportsSteeringFiles: true, transportModes: ['mcp-bridge', 'http-backend'] }, -}; + codex: { supportsSubagents: true, supportsMcpTransport: true, supportsNativeHooks: true, supportsSteeringFiles: true, transportModes: ['mcp-bridge', 'http-backend'] }, + antigravity: { supportsSubagents: true, supportsMcpTransport: true, supportsNativeHooks: false, supportsSteeringFiles: true, transportModes: ['mcp-bridge', 'http-backend'] },}; type CapabilityKey = Exclude; const FLAGS: CapabilityKey[] = ['supportsSubagents', 'supportsMcpTransport', 'supportsNativeHooks', 'supportsSteeringFiles']; diff --git a/packages/squad-sdk/src/clients/coordinator.ts b/packages/squad-sdk/src/clients/coordinator.ts index f79c44a92..95b5fc5fc 100644 --- a/packages/squad-sdk/src/clients/coordinator.ts +++ b/packages/squad-sdk/src/clients/coordinator.ts @@ -56,6 +56,18 @@ Invoke specialists with the \`task\` tool (or an \`@agent\` mention for agents d Delegate specialist work to Cursor subagents (\`.cursor/agents/\`) when available, otherwise in composer/agent turns that adopt the member charter explicitly. Inline the charter and task in every delegation. The \`squad_state\` MCP server is configured in \`.cursor/mcp.json\`.`, }, + codex: { + summary: 'Codex subagents (custom agents in .codex/agents), MCP backend', + section: `## Dispatch adapter: OpenAI Codex + +Codex spawns subagents when instructions ask for it, so for every routed task explicitly **spawn one agent per specialist** (use the matching custom agent from \`.codex/agents/squad-.toml\` when present, otherwise the \`worker\` agent with the member charter inlined) and wait for their results before summarizing. Spawn independent specialists in parallel. Subagents inherit this session's sandbox, approvals and MCP servers, so \`squad_state\` (configured in \`.codex/config.toml\`) is available to them. This project config only loads for trusted projects.`, + }, + antigravity: { + summary: 'Antigravity invoke_subagent, MCP backend', + section: `## Dispatch adapter: Google Antigravity + +Delegate each specialist task with the \`invoke_subagent\` tool: use a custom subagent from \`.agents/agents/squad-.md\` when present, otherwise the \`self\` subagent with the member charter inlined in the prompt. Subagents start with a clean context, so always include the charter, task and relevant decisions. Invoke independent subagents concurrently. The \`squad_state\` MCP server is attached to the Squad agent via its \`mcpServers\` frontmatter; if its tools are missing, register it globally with \`agy mcp add squad_state \` (the launch spec is in \`.agents/agents/squad.md\`).`, + }, }; export function dispatchStrategy(client: ClientId): DispatchStrategy { diff --git a/packages/squad-sdk/src/clients/types.ts b/packages/squad-sdk/src/clients/types.ts index a7954d662..83d0176b5 100644 --- a/packages/squad-sdk/src/clients/types.ts +++ b/packages/squad-sdk/src/clients/types.ts @@ -8,7 +8,7 @@ * @module clients/types */ -export const CLIENT_IDS = ['copilot', 'claude', 'kiro', 'opencode', 'cursor'] as const; +export const CLIENT_IDS = ['copilot', 'claude', 'kiro', 'opencode', 'cursor', 'codex', 'antigravity'] as const; export type ClientId = (typeof CLIENT_IDS)[number]; /** Launch spec for the `squad_state` MCP server. */ @@ -40,7 +40,9 @@ export type ProjectionFile = /** Read-modify-write of a user-shared JSON config; only Squad keys are touched. */ | { kind: 'json'; path: string; update: (existing: Record) => Record } /** Marker-delimited block inside a user-owned text file. */ - | { kind: 'block'; path: string; content: string }; + | { kind: 'block'; path: string; content: string; id?: string } + /** Read-modify-write of a user-shared text file (e.g. TOML) where only Squad's part changes. */ + | { kind: 'text'; path: string; update: (existing: string) => string }; export type DispatchStrategy = { /** Host-native way the coordinator spawns a specialist. */ diff --git a/test/clients-real-cli.test.ts b/test/clients-real-cli.test.ts index adccc0673..9fc597d0a 100644 --- a/test/clients-real-cli.test.ts +++ b/test/clients-real-cli.test.ts @@ -14,6 +14,8 @@ const withKiro = { ...process.env, PATH: `${process.env['PATH']}${path.delimiter function run(cmd: string, args: string[], cwd: string, env: NodeJS.ProcessEnv = process.env) { return spawnSync(cmd, args, { cwd, env, encoding: 'utf8', shell: process.platform === 'win32', timeout: 120_000 }); } +const CODEX = path.join(process.env['ProgramData'] ?? '', 'global-npm', 'node_modules', '@openai', 'codex', 'vendor', 'x86_64-pc-windows-msvc', 'bin', 'codex.exe'); +const hasCodex = fs.existsSync(CODEX); const has = (cmd: string, env: NodeJS.ProcessEnv = process.env) => run(cmd, ['--version'], os.tmpdir(), env).status === 0; let root: string; @@ -23,7 +25,7 @@ beforeAll(() => { const squad = path.join(root, '.squad'); fs.mkdirSync(squad); fs.writeFileSync(path.join(squad, 'team.md'), '| Name | Role | Charter | Status |\n|---|---|---|---|\n| Data | Expert | c | ok |\n'); - const results = installClients(['claude', 'opencode', 'kiro'], { + const results = installClients(['claude', 'opencode', 'kiro', 'codex', 'antigravity'], { root, mcp: { command: 'node', args: ['-e', '0'] }, members: [{ name: 'Data', role: 'Expert' }], @@ -57,4 +59,21 @@ describe('real CLIs load Squad projections', () => { const out = `${list.stdout}${list.stderr}`.replace(/\u001b\[[0-9;]*m/g, ''); expect(out).toMatch(/squad\s+Workspace/); }); + it.skipIf(!hasCodex)('codex loads the project config (trusted) and sees the squad_state server', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-home-')); + const key = fs.realpathSync.native(root).replace(/\\/g, '\\\\'); + fs.writeFileSync(path.join(home, 'config.toml'), `[projects."${key}"]\ntrust_level = "trusted"\n`); + try { + const r = run(`"${CODEX}"`, ['mcp', 'get', 'squad_state'], root, { ...process.env, CODEX_HOME: home }); + expect(r.stdout).toMatch(/enabled: true/); + expect(r.stdout).toMatch(/transport: stdio/); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + it.skipIf(!has('agy'))('antigravity discovers the squad agent', () => { + const r = run('agy', ['--agent', 'nonexistent-zzz', '-p', 'hi'], root); + expect(`${r.stdout}${r.stderr}`).toMatch(/available:.*\bsquad\b/); + }); }); diff --git a/test/clients.test.ts b/test/clients.test.ts index 59c8b1a5b..bbc115bf7 100644 --- a/test/clients.test.ts +++ b/test/clients.test.ts @@ -15,6 +15,7 @@ import { parseClients, projectClients, upsertBlock, + upsertTomlTable, BLOCK_BEGIN, type ClientContext, } from '../packages/squad-sdk/src/clients/index.js'; @@ -97,6 +98,79 @@ describe('client capabilities', () => { }); }); +describe('codex adapter', () => { + it('projects AGENTS.md block, .codex/config.toml mcp server and member agents', () => { + installClients(['codex'], ctx({ members: [{ name: 'Data', role: 'Expert' }] })); + const agents = read('AGENTS.md'); + expect(agents).toContain('\n`); + } + await updateCastingStateWithAgent(squadDir, name, role, slug); + await addAgentToConfig(teamRoot, slug, role); + known.add(slug); + registered.push(slug); + } + success(registered.length ? `Registered ${registered.length} member(s) in the casting registry: ${registered.join(', ')}` : 'Casting registry already matches team.md'); + return { registered, skipped }; +} + export async function runCast(cwd: string): Promise { // Resolve squad paths const paths = resolveSquadPaths(cwd); diff --git a/packages/squad-sdk/src/clients/adapters.ts b/packages/squad-sdk/src/clients/adapters.ts index d1bf93975..27752fba2 100644 --- a/packages/squad-sdk/src/clients/adapters.ts +++ b/packages/squad-sdk/src/clients/adapters.ts @@ -51,7 +51,7 @@ function frontmatter(fields: Array<[string, string]>): string { } export function slugifyMember(name: string): string { - return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'member'; + return name.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'member'; } function yamlString(s: string): string { @@ -307,6 +307,8 @@ const codex: ClientAdapter = { ], }; +const ANTIGRAVITY_TOOLS = ['view_file', 'write_to_file', 'replace_file_content', 'grep_search', 'list_dir', 'run_command', 'invoke_subagent']; + function antigravityAgent( ctx: ClientContext, name: string, @@ -322,6 +324,8 @@ function antigravityAgent( `subagent: ${!opts.main}`, ]; if (opts.model) lines.push(`model: ${opts.model}`); + // Antigravity treats an omitted `tools` list as "none": without run_command the agent cannot use gh/git/npm. + lines.push(`tools: [${ANTIGRAVITY_TOOLS.join(', ')}]`); if (opts.withMcp) { lines.push( 'mcpServers:', diff --git a/packages/squad-sdk/src/clients/coordinator.ts b/packages/squad-sdk/src/clients/coordinator.ts index 926fecc98..035d29f1c 100644 --- a/packages/squad-sdk/src/clients/coordinator.ts +++ b/packages/squad-sdk/src/clients/coordinator.ts @@ -18,7 +18,7 @@ You are the Squad coordinator. You route work to a team of specialists and keep 0. **Init Mode (only when there is truly no team).** Decide this with \`squad_state_read\` of \`team.md\` (or \`squad_state_list\`), NEVER with glob/grep/search tools: many hosts' glob/grep skip dot-directories like \`${S}/\` and will wrongly report "no matches". If \`team.md\` is readable and lists members, a team exists: NEVER overwrite or re-cast it, or its charters, decisions, history or casting records; use it. Only if \`team.md\` does not exist or has an empty roster: - **Phase 1 (propose).** Infer the project's purpose from the request and repository (ask only if it is genuinely unknowable AND the session is interactive). Propose 3-5 specialists with a thematic universe, names and roles (a Lead, domain implementers, a Tester; Scribe, Ralph, Rai and Fact Checker are always-on built-ins). - **Auto-confirm (default).** Do NOT stop to ask questions in Init Mode. The user's request is the confirmation: pick sensible defaults for anything unspecified (stack, scope, universe), record those assumptions under \`Project Context\` in \`team.md\`, and continue to Phase 2 in the SAME turn. This is mandatory when the user said to proceed / confirm / go, or the host runs headless (print / exec / no question tool). Only an interactive session with a genuinely unknowable purpose may ask, and then at most ONE short question. Never end the turn with only a proposal or questions. - - **Phase 2 (create).** If you can run shell commands, add EVERY member with the CLI, one call each: \`npx squad cast --name --role ""\` (it writes the charter, history, the \`team.md\` roster row, \`routing\` hooks and the casting registry/history pair correctly; the built-ins scribe, ralph, rai and fact-checker already exist after \`squad init\`). Then write/adjust \`${S}/routing.md\` as an ordinary file. NEVER hand-write or edit anything under \`${S}/casting/\` (\`registry.json\`, \`history.json\`, \`registry-history.commit.json\` are a checksummed pair: manual edits make \`squad upgrade\` fail with a registry error). If you cannot run shell commands, write \`team.md\` (a markdown table with \`Name\`, \`Role\`, \`Charter\`, \`Status\` columns; the Coordinator row stays separate), \`${S}/agents//charter.md\` and \`history.md\` as ordinary files, leave \`${S}/casting/\` untouched and tell the user to run \`squad cast --name --role ""\` per member to register them. + - **Phase 2 (create).** If you can run shell commands, add EVERY member with the CLI, one call each: \`npx squad cast --name --role ""\` (it writes the charter, history, the \`team.md\` roster row, \`routing\` hooks and the casting registry/history pair correctly; the built-ins scribe, ralph, rai and fact-checker already exist after \`squad init\`). Then write/adjust \`${S}/routing.md\` as an ordinary file. If \`squad cast --name\` is not recognised (an older \`squad\` build that only lists the cast; \`npx\` may fetch an old published version, so prefer the \`squad\` on PATH), write \`team.md\`, charters and histories as ordinary files and then run \`squad cast --sync\`, which registers every roster member in the casting registry idempotently (if that is also unavailable, tell the user to upgrade the squad CLI). NEVER hand-write or edit anything under \`${S}/casting/\` (\`registry.json\`, \`history.json\`, \`registry-history.commit.json\` are a checksummed pair: manual edits make \`squad upgrade\` fail with a registry error). If you cannot run shell commands, write \`team.md\` (a markdown table with \`Name\`, \`Role\`, \`Charter\`, \`Status\` columns; the Coordinator row stays separate), \`${S}/agents//charter.md\` and \`history.md\` as ordinary files, leave \`${S}/casting/\` untouched and tell the user to run \`squad cast --sync\` to register them. - **After casting**, STOP and report the roster unless the user's request also asked for work (setting up the team is not a request to build anything); then tell the user (and run, if you can run shell commands) \`npx squad upgrade\` so this host's per-member subagent files are projected; until then use the generic subagent with the charter inlined. - **Scratch space.** Use \`${S}/scratch/\` (inside the workspace) for temporary files; never write outside the workspace directory (hosts reject external-directory access). 1. **Load team context first.** Read \`${S}/team.md\` (roster), \`${S}/routing.md\` (who owns what), \`${S}/decisions.md\` and the decisions inbox before routing anything. Cache them for the session; re-read only when a state change was announced. diff --git a/test/clients.test.ts b/test/clients.test.ts index 7d1547e65..82bde21cf 100644 --- a/test/clients.test.ts +++ b/test/clients.test.ts @@ -19,6 +19,7 @@ import { BLOCK_BEGIN, type ClientContext, } from '../packages/squad-sdk/src/clients/index.js'; +import { runCastSync } from '../packages/squad-cli/src/cli/commands/cast.js'; import { parseRoster, persistClients, @@ -145,6 +146,7 @@ describe('antigravity adapter', () => { expect(main).toContain('subagent: false'); expect(main).toContain('mcpServers:'); expect(main).toContain('invoke_subagent'); + expect(main).toMatch(/^tools: \[.*run_command.*\]$/m); const sub = read('.agents/agents/squad-data.md'); expect(sub).toContain('mainAgent: false'); expect(sub).toContain('subagent: true'); @@ -153,6 +155,10 @@ describe('antigravity adapter', () => { }); describe('shared files and TOML helper', () => { + it('slugifies accented member names to ascii', () => { + installClients(['claude'], ctx({ members: [{ name: 'Tomás', role: 'Dev' }] })); + expect(exists('.claude/agents/squad-tomas.md')).toBe(true); + }); it('independent block ids coexist in one file', () => { let t = upsertBlock('', 'one', 'a'); t = upsertBlock(t, 'two', 'b'); @@ -224,7 +230,7 @@ describe('coordinator behavioral-contract parity', () => { ['Human + @copilot', [/Human members and the @copilot coding agent/, /Coding Agent/]], ['Process safety', [/Never kill processes by image name/]], ['Ralph', [/Ralph mode/, /squad:/, /board clear/]], - ['Init Mode guards', [/NEVER with glob\/grep\/search tools/, /Auto-confirm \(default\)/, /npx squad upgrade/, /npx squad cast --name/, /NEVER hand-write or edit anything under/, /checksummed pair/, /\.squad\/scratch\//, /never write outside the workspace/, /STOP and report the roster/]], + ['Init Mode guards', [/NEVER with glob\/grep\/search tools/, /Auto-confirm \(default\)/, /npx squad upgrade/, /npx squad cast --name/, /NEVER hand-write or edit anything under/, /checksummed pair/, /squad cast --sync/, /\.squad\/scratch\//, /never write outside the workspace/, /STOP and report the roster/]], ]; for (const id of CLIENT_IDS) { it(`${id} prompt carries the full coordinator contract`, () => { @@ -245,6 +251,20 @@ describe('coordinator behavioral-contract parity', () => { }); }); +describe('squad cast --sync', () => { + it('registers hand-written roster members idempotently', async () => { + const sq = path.join(root, '.squad'); + fs.mkdirSync(path.join(sq, 'agents'), { recursive: true }); + fs.writeFileSync(path.join(sq, 'team.md'), '| Name | Role | Charter | Status |\n|---|---|---|---|\n| Ripley | Lead | x | ok |\n| Tomás | Dev | x | ok |\n| Scribe | Decision Merger | x | ok |\n'); + const first = await runCastSync(root); + expect(first.registered).toEqual(['ripley', 'tomas']); + expect(Object.keys(JSON.parse(fs.readFileSync(path.join(sq, 'casting/registry.json'), 'utf8')).agents)).toEqual(['ripley', 'tomas']); + expect(fs.existsSync(path.join(sq, 'agents/ripley/charter.md'))).toBe(true); + const again = await runCastSync(root); + expect(again.registered).toEqual([]); + }); +}); + describe('copilot adapter', () => { it('projects only .mcp.json with squad_state', () => { installClients(['copilot'], ctx()); From 7e27f8feeb715a628ff0e46f5961eb0535641fa4 Mon Sep 17 00:00:00 2001 From: Copilot Date: Sun, 18 Oct 2026 11:56:03 +0300 Subject: [PATCH 22/46] docs/fix: per-vendor demo folders, interactive Claude (cli-tunnel) evidence, claude --mcp-config note Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 995eea17-c356-4912-90ea-5e5b144e7ba9 --- packages/squad-sdk/src/clients/adapters.ts | 2 +- test/clients.test.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/squad-sdk/src/clients/adapters.ts b/packages/squad-sdk/src/clients/adapters.ts index 27752fba2..f7755525e 100644 --- a/packages/squad-sdk/src/clients/adapters.ts +++ b/packages/squad-sdk/src/clients/adapters.ts @@ -129,7 +129,7 @@ const claude: ClientAdapter = { { kind: 'block', path: 'CLAUDE.md', - content: `## Squad\n\nThis project uses Squad. Run \`claude --agent squad\` (or delegate to the \`squad\` subagent) to work with the team defined in \`${ctx.squadDir ?? '.squad'}/team.md\`. Shared state is served by the \`squad_state\` MCP server in \`.mcp.json\`.`, + content: `## Squad\n\nThis project uses Squad. Run \`claude --agent squad --mcp-config .mcp.json\` (interactive Claude Code does not load project-scoped \`.mcp.json\` servers until approved, so pass the config explicitly; or delegate to the \`squad\` subagent) to work with the team defined in \`${ctx.squadDir ?? '.squad'}/team.md\`. Shared state is served by the \`squad_state\` MCP server in \`.mcp.json\`.`, }, // Claude Code creates embedded git worktrees here for isolated subagents. { kind: 'block', path: '.gitignore', id: 'claude', content: '.claude/worktrees/' }, diff --git a/test/clients.test.ts b/test/clients.test.ts index 82bde21cf..8b8f8d18e 100644 --- a/test/clients.test.ts +++ b/test/clients.test.ts @@ -286,6 +286,7 @@ describe('claude adapter', () => { expect(readJson('.mcp.json').mcpServers.squad_state.command).toBe('squad'); expect(read('CLAUDE.md')).toContain(BLOCK_BEGIN); expect(read('.gitignore')).toContain('.claude/worktrees/'); + expect(read('CLAUDE.md')).toContain('--mcp-config .mcp.json'); }); it('preserves user CLAUDE.md content and replaces the block in place', () => { fs.writeFileSync(path.join(root, 'CLAUDE.md'), '# Mine\n\nrules\n'); From cd70cbab03717030456201e3ef271c6f5d238f83 Mon Sep 17 00:00:00 2001 From: Copilot Date: Sun, 18 Oct 2026 12:35:52 +0300 Subject: [PATCH 23/46] fix(clients): host-prefixed tool names, dir vs file, builtin subagents, explicit fallback From reviewer feedback on live runs: model asked for squad_state_read but hosts expose prefixed names; directory keys read as files (EISDIR); Scribe/Ralph/Rai/Fact Checker subagents missing when team.md was rewritten; silent owner substitution. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 995eea17-c356-4912-90ea-5e5b144e7ba9 --- packages/squad-cli/src/cli/core/clients.ts | 15 ++++++++++++++- packages/squad-sdk/src/clients/coordinator.ts | 4 ++-- test/clients.test.ts | 3 +++ 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/squad-cli/src/cli/core/clients.ts b/packages/squad-cli/src/cli/core/clients.ts index abac39065..490725303 100644 --- a/packages/squad-cli/src/cli/core/clients.ts +++ b/packages/squad-cli/src/cli/core/clients.ts @@ -102,6 +102,19 @@ export function parseRoster(teamMd: string): TeamMember[] { return members; } +const BUILTIN_MEMBERS: TeamMember[] = [ + { name: 'Scribe', role: 'Decision Merger' }, + { name: 'Ralph', role: 'Work Monitor' }, + { name: 'Rai', role: 'RAI Reviewer' }, + { name: 'Fact Checker', role: 'Verification & Devil\'s Advocate' }, +]; + +/** Built-ins are always projected, even when a host agent rewrote team.md without them. */ +export function ensureBuiltinMembers(members: TeamMember[]): TeamMember[] { + const have = new Set(members.map((m) => m.name.toLowerCase())); + return [...members, ...BUILTIN_MEMBERS.filter((b) => !have.has(b.name.toLowerCase()))]; +} + export interface SyncClientsOptions { /** Explicit set from `--client`; when omitted, configured clients are refreshed. */ clients?: readonly ClientId[]; @@ -128,7 +141,7 @@ export async function syncClients( const spec = projectSquadStateMcpSpec(await resolveSquadStateMcpSpec(version)); let members: TeamMember[] = []; try { - members = parseRoster(fs.readFileSync(path.join(squadDir, 'team.md'), 'utf8')); + members = ensureBuiltinMembers(parseRoster(fs.readFileSync(path.join(squadDir, 'team.md'), 'utf8'))); } catch { // no roster yet } diff --git a/packages/squad-sdk/src/clients/coordinator.ts b/packages/squad-sdk/src/clients/coordinator.ts index 035d29f1c..8b847bcb2 100644 --- a/packages/squad-sdk/src/clients/coordinator.ts +++ b/packages/squad-sdk/src/clients/coordinator.ts @@ -24,7 +24,7 @@ You are the Squad coordinator. You route work to a team of specialists and keep 1. **Load team context first.** Read \`${S}/team.md\` (roster), \`${S}/routing.md\` (who owns what), \`${S}/decisions.md\` and the decisions inbox before routing anything. Cache them for the session; re-read only when a state change was announced. 2. **Route by ownership.** Match the request to the member whose role owns it per \`routing.md\`. Every work item has exactly ONE accountable owner (others contribute/review). If nobody owns it, ask the user or propose a new member. Named requests ("Dallas, fix X") go to that member. 3. **Spawn specialists with full context.** Each specialist prompt must include: the member's charter (\`${S}/agents//charter.md\`), the task with explicit deliverables AND exclusions (no silent scope expansion; approved expansion is routed as separate work), relevant decisions, the working directory, and the \`squad_state\` usage rules below. Name tasks ": " (e.g. "Dallas: build server") and never do the specialist's work yourself. -4. **Mutable state goes through the \`squad_state\` MCP server** (backend). Use \`squad_state_read\`, \`squad_state_list\`, \`squad_state_write\`, \`squad_state_append\`, \`squad_state_delete\` for runtime state (decisions inbox, agent history, logs, sessions, scratch) and \`squad_decide\` to record decisions. Never edit those files directly when the MCP backend is available. **Static team config is the exception:** \`team.md\`, \`routing.md\`, \`config.json\`, charters (\`${S}/agents//charter.md\`), templates and skills are NOT mutable state; the state tools reject writes to them, so create and edit them as ordinary files (\`squad_state_read\` / \`squad_state_list\` can still read them). +4. **Mutable state goes through the \`squad_state\` MCP server** (backend). Use \`squad_state_read\`, \`squad_state_list\`, \`squad_state_write\`, \`squad_state_append\`, \`squad_state_delete\` for runtime state (decisions inbox, agent history, logs, sessions, scratch) and \`squad_decide\` to record decisions. Never edit those files directly when the MCP backend is available. **Static team config is the exception:** \`team.md\`, \`routing.md\`, \`config.json\`, charters (\`${S}/agents//charter.md\`), templates and skills are NOT mutable state; the state tools reject writes to them, so create and edit them as ordinary files (\`squad_state_read\` / \`squad_state_list\` can still read them). **Tool names are host-prefixed:** hosts expose these tools as e.g. \`mcp__squad_state__squad_state_read\` (Claude Code), \`squad_state_squad_state_read\` (opencode) or \`@squad_state/squad_state_read\` (Kiro); always call the name actually present in your tool list. **Files vs directories:** \`squad_state_read\` takes a FILE key (e.g. \`decisions.md\`, \`agents/dallas/history.md\`); for a directory (\`identity\`, \`templates\`, \`decisions/inbox\`, \`agents\`) call \`squad_state_list\` instead, never read it (that fails with EISDIR). 5. **Record decisions and learnings.** Specialists record decisions via \`squad_decide\`; append learnings to their own history via \`squad_state_append\`. 6. **Parallelize independent work**; serialize work with dependencies. Fan out only independently deliverable outcomes with distinct owners and distinct files (warn when agents would share a working tree). Summarize results compactly and say who did what. 7. **Backend contract vs. transport.** Conversation semantics, routing, durable state and authorization belong to the shared Squad backend contract (SquadBackend / DurableHttpSquadBackend), never to this host. The squad_state MCP server is only a local tool-transport bridge to that contract, not the contract itself; a host may reach the same backend over MCP or directly over HTTP. Host adapters only handle bootstrap, native dispatch/UI translation and local lifecycle. @@ -45,7 +45,7 @@ You are the Squad coordinator. You route work to a team of specialists and keep 19. **PRD mode.** When given a PRD (file or pasted), save a reference, synchronously dispatch the Lead to decompose it into work items with owners and dependencies, show the plan for approval (auto-approve when non-interactive and told to proceed), then route approved items dependency-aware, in parallel where independent. Details: \`${S}/templates/prd-intake.md\`. 20. **Cross-squad delegation.** "Squad" with the lowercase product meaning is a separate peer Squad install (its own \`${S}/\`), not a generic group of agents. Requests to delegate to another squad use the \`cross-squad\` / \`cross-squad-communication\` skills (registry, sync, GitHub issues), never ad-hoc subagent fan-out; if it is ambiguous whether the user means a peer squad or ad-hoc subagents, ask one two-choice question (or, non-interactively, pick ad-hoc subagents and say so). 21. **Human members and the @copilot coding agent.** \`team.md\` may list humans (they are never spawned: route their items as requests/notifications and wait) and an \`@copilot\` Coding Agent member (asynchronous; assigned by GitHub issue assignment, works on \`copilot/*\` branches and draft PRs; never spawn it; review its PRs like any other). -22. **Anti-patterns.** Do not role-play a specialist's output, fabricate results, skip dispatch, give generic task names, or serialize independent work. Never claim a step happened unless a tool result shows it. When a specialist fails or hangs, report it, retry once with a narrower task, then escalate. Never kill processes by image name (no \`taskkill /IM node.exe\`, \`pkill node\`, \`killall\`): that can terminate your own host or model proxy; stop only a specific PID you started. +22. **Anti-patterns.** Do not role-play a specialist's output, fabricate results, skip dispatch, give generic task names, or serialize independent work. Never claim a step happened unless a tool result shows it. **Unknown or missing member:** if the user or a plan names a member who is not on the roster (or has no subagent file yet), say so explicitly, name the fallback you chose (e.g. "Parker is not on the roster; routing to Lambert, the Tester"), and record the substitution in your report; never silently swap owners. **Built-in members** (Scribe, Ralph, Rai, Fact Checker) always exist even if \`team.md\` was rewritten: if their \`squad-\` subagent is not available, use the generic subagent with \`${S}/agents//charter.md\` inlined. When a specialist fails or hangs, report it, retry once with a narrower task, then escalate. Never kill processes by image name (no \`taskkill /IM node.exe\`, \`pkill node\`, \`killall\`): that can terminate your own host or model proxy; stop only a specific PID you started. `; } diff --git a/test/clients.test.ts b/test/clients.test.ts index 8b8f8d18e..1cf26292e 100644 --- a/test/clients.test.ts +++ b/test/clients.test.ts @@ -229,6 +229,8 @@ describe('coordinator behavioral-contract parity', () => { ['Cross-Squad', [/Cross-squad delegation/, /peer Squad install/, /ad-hoc subagent/]], ['Human + @copilot', [/Human members and the @copilot coding agent/, /Coding Agent/]], ['Process safety', [/Never kill processes by image name/]], + ['Tool naming + dirs', [/Tool names are host-prefixed/, /squad_state_squad_state_read/, /EISDIR/, /for a directory/]], + ['Explicit fallback', [/Unknown or missing member/, /never silently swap owners/, /Built-in members/]], ['Ralph', [/Ralph mode/, /squad:/, /board clear/]], ['Init Mode guards', [/NEVER with glob\/grep\/search tools/, /Auto-confirm \(default\)/, /npx squad upgrade/, /npx squad cast --name/, /NEVER hand-write or edit anything under/, /checksummed pair/, /squad cast --sync/, /\.squad\/scratch\//, /never write outside the workspace/, /STOP and report the roster/]], ]; @@ -445,6 +447,7 @@ describe('CLI --client handling', () => { expect(readConfiguredClients(sq)).toEqual(['claude', 'kiro']); expect(exists('.claude/agents/squad-data.md')).toBe(true); expect(exists('.kiro/steering/squad.md')).toBe(true); + for (const b of ['scribe', 'ralph', 'rai', 'fact-checker']) expect(exists('.claude/agents/squad-' + b + '.md')).toBe(true); const bare = await syncClients(root, sq, { version: '0.0.0' }); expect(bare.clients).toEqual(['claude', 'kiro']); expect(readJson('.mcp.json').mcpServers.squad_state.args.at(-1)).toBe('state-mcp'); From ccc566088a4820f8b65647f9e04af1d526ce3e22 Mon Sep 17 00:00:00 2001 From: Copilot Date: Sun, 18 Oct 2026 12:54:31 +0300 Subject: [PATCH 24/46] docs: refresh kiro and antigravity demo screenshots (antigravity finished via claude-sonnet-4-6 model, per-model quota) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 995eea17-c356-4912-90ea-5e5b144e7ba9 From 098631b543dde4b7563cd2e5db9f40e39e64242e Mon Sep 17 00:00:00 2001 From: Copilot Date: Sun, 18 Oct 2026 14:42:07 +0300 Subject: [PATCH 25/46] feat(clients): make per-member subagent files opt-in Default is now a single coordinator file per host, like Copilot. Member subagent files are projected only with --member-agents, config.json memberAgents: true, or when clientModels is configured. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 995eea17-c356-4912-90ea-5e5b144e7ba9 --- packages/squad-cli/src/cli-entry.ts | 4 ++- packages/squad-cli/src/cli/core/clients.ts | 30 ++++++++++++++++++++-- packages/squad-cli/src/cli/core/init.ts | 4 ++- packages/squad-cli/src/cli/core/upgrade.ts | 4 ++- test/clients.test.ts | 17 +++++++++++- 5 files changed, 53 insertions(+), 6 deletions(-) diff --git a/packages/squad-cli/src/cli-entry.ts b/packages/squad-cli/src/cli-entry.ts index 028292285..b6408747c 100644 --- a/packages/squad-cli/src/cli-entry.ts +++ b/packages/squad-cli/src/cli-entry.ts @@ -162,6 +162,7 @@ async function main(): Promise { console.log(` --preset (apply a preset after init)`); console.log(` --state-backend (local|orphan|two-layer)`); console.log(` --client (copilot,claude,kiro,opencode,cursor,codex,antigravity|all)`); + console.log(` --member-agents (also project one subagent file per team member)`); console.log(` Usage: init --mode remote `); console.log(` Creates .squad/config.json pointing to an external team root`); console.log(` ${BOLD}upgrade${RESET} Update Squad-owned files to latest version`); @@ -376,7 +377,7 @@ async function main(): Promise { process.exit(1); } // Global init: suppress workflows (no GitHub CI in ~/.config/squad/) and bootstrap personal squad - runInit(dest, { includeWorkflows: !noWorkflows && !hasGlobal, sdk, roles, isGlobal: hasGlobal, stateBackend: initStateBackend, mcpFrontmatter, clients: initClients, includeVscodeDefault: !noVscodeDefault }).then(async () => { + runInit(dest, { includeWorkflows: !noWorkflows && !hasGlobal, sdk, roles, isGlobal: hasGlobal, stateBackend: initStateBackend, mcpFrontmatter, clients: initClients, memberAgents: args.includes('--member-agents') ? true : undefined, includeVscodeDefault: !noVscodeDefault }).then(async () => { if (presetName) { const { seedBuiltinPresets, applyPreset } = await import('@bradygaster/squad-sdk/presets'); const { resolvePresetsDir, ensureSquadHome } = await import('@bradygaster/squad-sdk/resolution'); @@ -496,6 +497,7 @@ async function main(): Promise { force: forceUpgrade, dryRun, clients: upgradeClients, + memberAgents: args.includes('--member-agents') ? true : undefined, }); } diff --git a/packages/squad-cli/src/cli/core/clients.ts b/packages/squad-cli/src/cli/core/clients.ts index 490725303..d1a1ae1c2 100644 --- a/packages/squad-cli/src/cli/core/clients.ts +++ b/packages/squad-cli/src/cli/core/clients.ts @@ -63,6 +63,27 @@ export function readClientModels(squadDir: string): Partial = {}; + try { + const p = JSON.parse(fs.readFileSync(file, 'utf8')) as unknown; + if (p && typeof p === 'object' && !Array.isArray(p)) cfg = p as Record; + } catch { + // start fresh + } + cfg['memberAgents'] = on; + fs.writeFileSync(file, JSON.stringify(cfg, null, 2) + '\n', 'utf8'); +} + export function persistClients(squadDir: string, clients: readonly ClientId[]): void { const file = path.join(squadDir, 'config.json'); let cfg: Record = {}; @@ -118,6 +139,8 @@ export function ensureBuiltinMembers(members: TeamMember[]): TeamMember[] { export interface SyncClientsOptions { /** Explicit set from `--client`; when omitted, configured clients are refreshed. */ clients?: readonly ClientId[]; + /** Project per-member subagent files (opt-in; also on when config.json memberAgents is true or clientModels is set). */ + memberAgents?: boolean; version?: string; } @@ -139,20 +162,23 @@ export async function syncClients( const version = options.version ?? getPackageVersion(); const spec = projectSquadStateMcpSpec(await resolveSquadStateMcpSpec(version)); + const models = readClientModels(squadDir); + const wantMembers = options.memberAgents ?? (readMemberAgentsConfig(squadDir) || Object.keys(models).length > 0); let members: TeamMember[] = []; try { - members = ensureBuiltinMembers(parseRoster(fs.readFileSync(path.join(squadDir, 'team.md'), 'utf8'))); + if (wantMembers) members = ensureBuiltinMembers(parseRoster(fs.readFileSync(path.join(squadDir, 'team.md'), 'utf8'))); } catch { // no roster yet } const results = installClients(clients, { - memberModels: readClientModels(squadDir), + memberModels: models, root: dest, mcp: { command: spec.command, args: spec.args }, members, squadDir: path.relative(dest, squadDir).split(path.sep).join('/') || '.squad', }); persistClients(squadDir, clients); + if (options.memberAgents !== undefined) persistMemberAgents(squadDir, options.memberAgents); return { clients, results }; } diff --git a/packages/squad-cli/src/cli/core/init.ts b/packages/squad-cli/src/cli/core/init.ts index 726e58f00..8c55fa255 100644 --- a/packages/squad-cli/src/cli/core/init.ts +++ b/packages/squad-cli/src/cli/core/init.ts @@ -169,6 +169,8 @@ export interface RunInitOptions { mcpFrontmatter?: boolean; /** Client hosts to project (copilot, claude, kiro, opencode, cursor) */ clients?: ClientId[]; + /** Also project per-member subagent files for hosts that support them. */ + memberAgents?: boolean; /** If false, skip writing "chat.newSession.defaultMode": "Squad" to .vscode/settings.json (default: true) */ includeVscodeDefault?: boolean; } @@ -501,7 +503,7 @@ export async function runInit(dest: string, options: RunInitOptions = {}): Promi if (options.clients && options.clients.length > 0 && !options.isGlobal) { try { - const synced = await syncClients(dest, squadDir, { clients: options.clients, version }); + const synced = await syncClients(dest, squadDir, { clients: options.clients, memberAgents: options.memberAgents, version }); reportClientResults(synced.results, (m) => console.log(m.startsWith('⚠') ? `${YELLOW}${m}${RESET}` : `${GREEN}${m}${RESET}`)); } catch (err) { console.warn(`${YELLOW}⚠ Could not project client files: ${err instanceof Error ? err.message : err}${RESET}`); diff --git a/packages/squad-cli/src/cli/core/upgrade.ts b/packages/squad-cli/src/cli/core/upgrade.ts index c63e1d0fc..86bb43aa5 100644 --- a/packages/squad-cli/src/cli/core/upgrade.ts +++ b/packages/squad-cli/src/cli/core/upgrade.ts @@ -159,6 +159,8 @@ export interface UpgradeOptions { dryRun?: boolean; /** Add these client hosts to the configured set; configured clients are always refreshed. */ clients?: ClientId[]; + /** Also project per-member subagent files for hosts that support them. */ + memberAgents?: boolean; } export interface UpdateInfo { @@ -1202,7 +1204,7 @@ export function ensureUserOwnedTemplates(dest: string, templatesDir: string): st /** Refresh configured (plus any newly requested) client projections. */ async function refreshClients(dest: string, squadDir: string, options: UpgradeOptions, filesUpdated: string[]): Promise { try { - const synced = await syncClients(dest, squadDir, { clients: options.clients }); + const synced = await syncClients(dest, squadDir, { clients: options.clients, memberAgents: options.memberAgents }); if (synced.clients.length === 0) return; reportClientResults(synced.results, (m) => (m.startsWith('⚠') ? warn(m.slice(2)) : success(m.slice(2)))); filesUpdated.push(`clients (${synced.clients.join(', ')})`); diff --git a/test/clients.test.ts b/test/clients.test.ts index 1cf26292e..78510ff7e 100644 --- a/test/clients.test.ts +++ b/test/clients.test.ts @@ -440,7 +440,7 @@ describe('CLI --client handling', () => { const prev = process.env['SQUAD_STANDALONE_HOME']; delete process.env['SQUAD_STANDALONE_HOME']; try { - const first = await syncClients(root, sq, { clients: ['claude'], version: '0.0.0' }); + const first = await syncClients(root, sq, { clients: ['claude'], memberAgents: true, version: '0.0.0' }); expect(first.clients).toEqual(['claude']); const second = await syncClients(root, sq, { clients: ['kiro'], version: '0.0.0' }); expect(second.clients).toEqual(['claude', 'kiro']); @@ -456,6 +456,21 @@ describe('CLI --client handling', () => { } }); + it('member subagent files are opt-in (coordinator only by default)', async () => { + const sq = path.join(root, '.squad'); + fs.mkdirSync(sq); + fs.writeFileSync(path.join(sq, 'team.md'), '| Name | Role |\n|---|---|\n| Data | Expert |\n'); + await syncClients(root, sq, { clients: ['claude', 'opencode'], version: '0.0.0' }); + expect(exists('.claude/agents/squad.md')).toBe(true); + expect(exists('.claude/agents/squad-data.md')).toBe(false); + expect(exists('.opencode/agent/squad-scribe.md')).toBe(false); + await syncClients(root, sq, { memberAgents: true, version: '0.0.0' }); + expect(exists('.claude/agents/squad-data.md')).toBe(true); + expect(exists('.claude/agents/squad-scribe.md')).toBe(true); + await syncClients(root, sq, { version: '0.0.0' }); + expect(JSON.parse(fs.readFileSync(path.join(sq, 'config.json'), 'utf8')).memberAgents).toBe(true); + }); + it('syncClients is a no-op with nothing configured', async () => { const sq = path.join(root, '.squad'); fs.mkdirSync(sq); From 01a0773af3c9d85d9fad8cae57b4b3d0abeaa482 Mon Sep 17 00:00:00 2001 From: Copilot Date: Sun, 18 Oct 2026 15:04:41 +0300 Subject: [PATCH 26/46] refactor(clients): drop per-member subagent files; keep accented names Hosts now use only .squad/agents//charter.md (inlined into generic subagent spawns), like Copilot. Removes member/clientModels projection, --member-agents, roster parsing and the accent-stripping slug change. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 995eea17-c356-4912-90ea-5e5b144e7ba9 --- docs/tutorials/multi-client-quickstart.md | 6 +- packages/squad-cli/src/cli-entry.ts | 4 +- packages/squad-cli/src/cli/commands/cast.ts | 2 - packages/squad-cli/src/cli/core/clients.ts | 97 +----------------- packages/squad-cli/src/cli/core/init.ts | 4 +- packages/squad-cli/src/cli/core/upgrade.ts | 4 +- packages/squad-sdk/src/clients/adapters.ts | 82 +--------------- packages/squad-sdk/src/clients/coordinator.ts | 12 +-- packages/squad-sdk/src/clients/types.ts | 13 --- test/clients-real-cli.test.ts | 2 - test/clients.test.ts | 98 +++---------------- 11 files changed, 29 insertions(+), 295 deletions(-) diff --git a/docs/tutorials/multi-client-quickstart.md b/docs/tutorials/multi-client-quickstart.md index ad70032e4..86a06e0ad 100644 --- a/docs/tutorials/multi-client-quickstart.md +++ b/docs/tutorials/multi-client-quickstart.md @@ -36,7 +36,6 @@ You should see these Claude-facing files: .claude/ agents/ squad.md - squad-.md # one per roster member when team.md already has named members .mcp.json CLAUDE.md ``` @@ -44,7 +43,6 @@ CLAUDE.md What each file does: - `.claude/agents/squad.md` is the coordinator entrypoint; -- `.claude/agents/squad-.md` are optional specialist wrappers that point back to each member's charter in `.squad/agents//charter.md`; - `.mcp.json` wires the `squad_state` MCP bridge; - `CLAUDE.md` gets a managed marker block instead of being fully overwritten. @@ -74,7 +72,7 @@ Or, inside Claude Code, delegate to the `squad` subagent and let it route work f ### Step 5: what the coordinator does in Claude -The Claude projection tells the coordinator to dispatch specialists with the `Task` tool and to prefer per-member `.claude/agents/squad-.md` files when they exist. +The Claude projection tells the coordinator to dispatch specialists with the `Task` tool and to inline each member's charter from `.squad/agents//charter.md` into the subagent prompt. --- @@ -336,7 +334,7 @@ After any `init --client ...` or `upgrade --client ...` run: Use `npx squad upgrade --client ...` when: - you added a new client to an existing repo; -- your `.squad/team.md` roster changed and you want fresh per-member projections; +- your `.squad/team.md` roster changed and you want fresh client projections; - the generated adapter templates changed in a newer Squad build; - you want to refresh all previously configured client projections from `.squad/config.json`. diff --git a/packages/squad-cli/src/cli-entry.ts b/packages/squad-cli/src/cli-entry.ts index b6408747c..028292285 100644 --- a/packages/squad-cli/src/cli-entry.ts +++ b/packages/squad-cli/src/cli-entry.ts @@ -162,7 +162,6 @@ async function main(): Promise { console.log(` --preset (apply a preset after init)`); console.log(` --state-backend (local|orphan|two-layer)`); console.log(` --client (copilot,claude,kiro,opencode,cursor,codex,antigravity|all)`); - console.log(` --member-agents (also project one subagent file per team member)`); console.log(` Usage: init --mode remote `); console.log(` Creates .squad/config.json pointing to an external team root`); console.log(` ${BOLD}upgrade${RESET} Update Squad-owned files to latest version`); @@ -377,7 +376,7 @@ async function main(): Promise { process.exit(1); } // Global init: suppress workflows (no GitHub CI in ~/.config/squad/) and bootstrap personal squad - runInit(dest, { includeWorkflows: !noWorkflows && !hasGlobal, sdk, roles, isGlobal: hasGlobal, stateBackend: initStateBackend, mcpFrontmatter, clients: initClients, memberAgents: args.includes('--member-agents') ? true : undefined, includeVscodeDefault: !noVscodeDefault }).then(async () => { + runInit(dest, { includeWorkflows: !noWorkflows && !hasGlobal, sdk, roles, isGlobal: hasGlobal, stateBackend: initStateBackend, mcpFrontmatter, clients: initClients, includeVscodeDefault: !noVscodeDefault }).then(async () => { if (presetName) { const { seedBuiltinPresets, applyPreset } = await import('@bradygaster/squad-sdk/presets'); const { resolvePresetsDir, ensureSquadHome } = await import('@bradygaster/squad-sdk/resolution'); @@ -497,7 +496,6 @@ async function main(): Promise { force: forceUpgrade, dryRun, clients: upgradeClients, - memberAgents: args.includes('--member-agents') ? true : undefined, }); } diff --git a/packages/squad-cli/src/cli/commands/cast.ts b/packages/squad-cli/src/cli/commands/cast.ts index bbc42fca9..ea7f02d36 100644 --- a/packages/squad-cli/src/cli/commands/cast.ts +++ b/packages/squad-cli/src/cli/commands/cast.ts @@ -38,8 +38,6 @@ export interface CastAddOptions { function slugifyAgentName(name: string): string { return name - .normalize('NFD') - .replace(/[\u0300-\u036f]/g, '') .toLowerCase() .trim() .replace(/[^a-z0-9]+/g, '-') diff --git a/packages/squad-cli/src/cli/core/clients.ts b/packages/squad-cli/src/cli/core/clients.ts index d1a1ae1c2..4af01f257 100644 --- a/packages/squad-cli/src/cli/core/clients.ts +++ b/packages/squad-cli/src/cli/core/clients.ts @@ -9,7 +9,7 @@ import fs from 'node:fs'; import path from 'node:path'; -import { CLIENT_IDS, installClients, normalizeClientIds, parseClients, type ApplyResult, type ClientId, type TeamMember } from '@bradygaster/squad-sdk/clients'; +import { CLIENT_IDS, installClients, normalizeClientIds, parseClients, type ApplyResult, type ClientId } from '@bradygaster/squad-sdk/clients'; import { projectSquadStateMcpSpec, resolveSquadStateMcpSpec } from './mcp-spec.js'; import { getPackageVersion } from './version.js'; @@ -40,50 +40,6 @@ export function readConfiguredClients(squadDir: string): ClientId[] { return []; } -/** `.squad/config.json` `clientModels`: `{ : { : "" } }`; non-string entries are ignored. */ -export function readClientModels(squadDir: string): Partial>> { - const out: Partial>> = {}; - try { - const cfg = JSON.parse(fs.readFileSync(path.join(squadDir, 'config.json'), 'utf8')) as { clientModels?: unknown }; - const cm = cfg.clientModels; - if (cm && typeof cm === 'object' && !Array.isArray(cm)) { - for (const id of CLIENT_IDS) { - const per = (cm as Record)[id]; - if (!per || typeof per !== 'object' || Array.isArray(per)) continue; - const clean: Record = {}; - for (const [k, v] of Object.entries(per as Record)) { - if (typeof v === 'string' && /^[A-Za-z0-9._:/@\[\]+-]+$/.test(v)) clean[k.toLowerCase()] = v; - } - if (Object.keys(clean).length > 0) out[id] = clean; - } - } - } catch { - // no config or malformed: inherit models - } - return out; -} - -export function readMemberAgentsConfig(squadDir: string): boolean { - try { - return (JSON.parse(fs.readFileSync(path.join(squadDir, 'config.json'), 'utf8')) as { memberAgents?: unknown }).memberAgents === true; - } catch { - return false; - } -} - -export function persistMemberAgents(squadDir: string, on: boolean): void { - const file = path.join(squadDir, 'config.json'); - let cfg: Record = {}; - try { - const p = JSON.parse(fs.readFileSync(file, 'utf8')) as unknown; - if (p && typeof p === 'object' && !Array.isArray(p)) cfg = p as Record; - } catch { - // start fresh - } - cfg['memberAgents'] = on; - fs.writeFileSync(file, JSON.stringify(cfg, null, 2) + '\n', 'utf8'); -} - export function persistClients(squadDir: string, clients: readonly ClientId[]): void { const file = path.join(squadDir, 'config.json'); let cfg: Record = {}; @@ -98,49 +54,9 @@ export function persistClients(squadDir: string, clients: readonly ClientId[]): fs.writeFileSync(file, JSON.stringify(cfg, null, 2) + '\n', 'utf8'); } -/** Parse roster rows (`| Name | Role | ... |`) from team.md. */ -export function parseRoster(teamMd: string): TeamMember[] { - const members: TeamMember[] = []; - let nameCol = -1; - let roleCol = -1; - for (const line of teamMd.split(/\r?\n/)) { - if (!line.trim().startsWith('|')) { - nameCol = roleCol = -1; - continue; - } - const cells = line.trim().replace(/^\||\|$/g, '').split('|').map((c) => c.trim()); - if (nameCol === -1) { - nameCol = cells.findIndex((c) => /^name$/i.test(c)); - roleCol = cells.findIndex((c) => /^role$/i.test(c)); - continue; - } - if (roleCol === -1 || cells.every((c) => /^:?-+:?$/.test(c))) continue; - const name = cells[nameCol]; - const role = cells[roleCol]; - // The coordinator is the host agent itself, not a dispatchable specialist. - if (name && role && !/^coordinator$/i.test(role)) members.push({ name, role }); - } - return members; -} - -const BUILTIN_MEMBERS: TeamMember[] = [ - { name: 'Scribe', role: 'Decision Merger' }, - { name: 'Ralph', role: 'Work Monitor' }, - { name: 'Rai', role: 'RAI Reviewer' }, - { name: 'Fact Checker', role: 'Verification & Devil\'s Advocate' }, -]; - -/** Built-ins are always projected, even when a host agent rewrote team.md without them. */ -export function ensureBuiltinMembers(members: TeamMember[]): TeamMember[] { - const have = new Set(members.map((m) => m.name.toLowerCase())); - return [...members, ...BUILTIN_MEMBERS.filter((b) => !have.has(b.name.toLowerCase()))]; -} - export interface SyncClientsOptions { /** Explicit set from `--client`; when omitted, configured clients are refreshed. */ clients?: readonly ClientId[]; - /** Project per-member subagent files (opt-in; also on when config.json memberAgents is true or clientModels is set). */ - memberAgents?: boolean; version?: string; } @@ -162,23 +78,12 @@ export async function syncClients( const version = options.version ?? getPackageVersion(); const spec = projectSquadStateMcpSpec(await resolveSquadStateMcpSpec(version)); - const models = readClientModels(squadDir); - const wantMembers = options.memberAgents ?? (readMemberAgentsConfig(squadDir) || Object.keys(models).length > 0); - let members: TeamMember[] = []; - try { - if (wantMembers) members = ensureBuiltinMembers(parseRoster(fs.readFileSync(path.join(squadDir, 'team.md'), 'utf8'))); - } catch { - // no roster yet - } const results = installClients(clients, { - memberModels: models, root: dest, mcp: { command: spec.command, args: spec.args }, - members, squadDir: path.relative(dest, squadDir).split(path.sep).join('/') || '.squad', }); persistClients(squadDir, clients); - if (options.memberAgents !== undefined) persistMemberAgents(squadDir, options.memberAgents); return { clients, results }; } diff --git a/packages/squad-cli/src/cli/core/init.ts b/packages/squad-cli/src/cli/core/init.ts index 8c55fa255..726e58f00 100644 --- a/packages/squad-cli/src/cli/core/init.ts +++ b/packages/squad-cli/src/cli/core/init.ts @@ -169,8 +169,6 @@ export interface RunInitOptions { mcpFrontmatter?: boolean; /** Client hosts to project (copilot, claude, kiro, opencode, cursor) */ clients?: ClientId[]; - /** Also project per-member subagent files for hosts that support them. */ - memberAgents?: boolean; /** If false, skip writing "chat.newSession.defaultMode": "Squad" to .vscode/settings.json (default: true) */ includeVscodeDefault?: boolean; } @@ -503,7 +501,7 @@ export async function runInit(dest: string, options: RunInitOptions = {}): Promi if (options.clients && options.clients.length > 0 && !options.isGlobal) { try { - const synced = await syncClients(dest, squadDir, { clients: options.clients, memberAgents: options.memberAgents, version }); + const synced = await syncClients(dest, squadDir, { clients: options.clients, version }); reportClientResults(synced.results, (m) => console.log(m.startsWith('⚠') ? `${YELLOW}${m}${RESET}` : `${GREEN}${m}${RESET}`)); } catch (err) { console.warn(`${YELLOW}⚠ Could not project client files: ${err instanceof Error ? err.message : err}${RESET}`); diff --git a/packages/squad-cli/src/cli/core/upgrade.ts b/packages/squad-cli/src/cli/core/upgrade.ts index 86bb43aa5..c63e1d0fc 100644 --- a/packages/squad-cli/src/cli/core/upgrade.ts +++ b/packages/squad-cli/src/cli/core/upgrade.ts @@ -159,8 +159,6 @@ export interface UpgradeOptions { dryRun?: boolean; /** Add these client hosts to the configured set; configured clients are always refreshed. */ clients?: ClientId[]; - /** Also project per-member subagent files for hosts that support them. */ - memberAgents?: boolean; } export interface UpdateInfo { @@ -1204,7 +1202,7 @@ export function ensureUserOwnedTemplates(dest: string, templatesDir: string): st /** Refresh configured (plus any newly requested) client projections. */ async function refreshClients(dest: string, squadDir: string, options: UpgradeOptions, filesUpdated: string[]): Promise { try { - const synced = await syncClients(dest, squadDir, { clients: options.clients, memberAgents: options.memberAgents }); + const synced = await syncClients(dest, squadDir, { clients: options.clients }); if (synced.clients.length === 0) return; reportClientResults(synced.results, (m) => (m.startsWith('⚠') ? warn(m.slice(2)) : success(m.slice(2)))); filesUpdated.push(`clients (${synced.clients.join(', ')})`); diff --git a/packages/squad-sdk/src/clients/adapters.ts b/packages/squad-sdk/src/clients/adapters.ts index f7755525e..2fe59d7ab 100644 --- a/packages/squad-sdk/src/clients/adapters.ts +++ b/packages/squad-sdk/src/clients/adapters.ts @@ -7,7 +7,7 @@ import { CAPABILITIES } from './capabilities.js'; import { upsertTomlTable } from './apply.js'; import { buildCoordinatorInstructions, dispatchStrategy } from './coordinator.js'; -import type { ClientAdapter, ClientContext, ClientId, ProjectionFile, TeamMember } from './types.js'; +import type { ClientAdapter, ClientContext, ClientId, ProjectionFile } from './types.js'; import { CLIENT_IDS } from './types.js'; export const SQUAD_STATE_KEY = 'squad_state'; @@ -50,10 +50,6 @@ function frontmatter(fields: Array<[string, string]>): string { return `---\n${fields.map(([k, v]) => `${k}: ${v}`).join('\n')}\n---\n`; } -export function slugifyMember(name: string): string { - return name.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'member'; -} - function yamlString(s: string): string { return JSON.stringify(s); } @@ -82,31 +78,6 @@ const copilot: ClientAdapter = { project: (ctx) => [mcpJson(ctx, true)], }; -function modelFor(ctx: ClientContext, client: ClientId, slug: string): string | undefined { - const m = ctx.memberModels?.[client]; - const v = m?.[slug] ?? m?.['*']; - // Model ids are written unquoted into YAML/TOML: accept only plain id characters. - return v && /^[A-Za-z0-9._:/@\[\]+-]+$/.test(v) ? v : undefined; -} - -function claudeMember(m: TeamMember, ctx: ClientContext): ProjectionFile { - const slug = slugifyMember(m.name); - const squadDir = ctx.squadDir ?? '.squad'; - const model = modelFor(ctx, 'claude', slug); - return { - kind: 'managed', - path: `.claude/agents/squad-${slug}.md`, - content: - frontmatter([ - ['name', `squad-${slug}`], - ['description', yamlString(`Squad specialist ${m.name} (${m.role}). Use for work routed to ${m.name}.`)], - ['tools', 'Read, Write, Edit, Bash, Glob, Grep, mcp__squad_state'], - ...(model ? ([['model', model]] as Array<[string, string]>) : []), - ]) + - `${GENERATED_NOTE}\n\nYou are ${m.name}, the ${m.role} on this Squad team.\nFirst read your charter at \`${squadDir}/agents/${slug}/charter.md\` and follow it. Record decisions with the \`squad_decide\` MCP tool and append learnings to your history with \`squad_state_append\`.\n`, - }; -} - const claude: ClientAdapter = { id: 'claude', capabilities: CAPABILITIES.claude, @@ -124,7 +95,6 @@ const claude: ClientAdapter = { ]) + `${GENERATED_NOTE}\n\n${buildCoordinatorInstructions('claude', ctx.squadDir)}`, }, - ...(ctx.members ?? []).map((m) => claudeMember(m, ctx)), mcpJson(ctx, false), { kind: 'block', @@ -183,23 +153,6 @@ const kiro: ClientAdapter = { ], }; -function opencodeMember(m: TeamMember, ctx: ClientContext): ProjectionFile { - const slug = slugifyMember(m.name); - const squadDir = ctx.squadDir ?? '.squad'; - const model = modelFor(ctx, 'opencode', slug); - return { - kind: 'managed', - path: `.opencode/agent/squad-${slug}.md`, - content: - frontmatter([ - ['description', yamlString(`Squad specialist ${m.name} (${m.role}). Use for work routed to ${m.name}.`)], - ['mode', 'subagent'], - ...(model ? ([['model', model]] as Array<[string, string]>) : []), - ]) + - `${GENERATED_NOTE}\n\nYou are ${m.name}, the ${m.role} on this Squad team.\nFirst read your charter at \`${squadDir}/agents/${slug}/charter.md\` and follow it. Record decisions with the \`squad_decide\` MCP tool and append learnings to your history with \`squad_state_append\`.\n`, - }; -} - const opencode: ClientAdapter = { id: 'opencode', capabilities: CAPABILITIES.opencode, @@ -215,7 +168,6 @@ const opencode: ClientAdapter = { ['mode', 'primary'], ]) + `${GENERATED_NOTE}\n\n${buildCoordinatorInstructions('opencode', ctx.squadDir)}`, }, - ...(ctx.members ?? []).map((m) => opencodeMember(m, ctx)), { kind: 'json', path: 'opencode.json', @@ -268,24 +220,6 @@ const cursor: ClientAdapter = { const tomlString = (s: string) => JSON.stringify(s); const tomlArray = (a: string[]) => `[${a.map(tomlString).join(', ')}]`; -function codexMember(m: TeamMember, ctx: ClientContext): ProjectionFile { - const slug = slugifyMember(m.name); - const squadDir = ctx.squadDir ?? '.squad'; - const model = modelFor(ctx, 'codex', slug); - return { - kind: 'managed', - path: `.codex/agents/squad-${slug}.toml`, - content: - `# Generated by Squad. Managed file: refreshed by \`squad upgrade\`; edit ${squadDir}/ instead.\n` + - `name = ${tomlString(`squad-${slug}`)}\n` + - (model ? `model = ${tomlString(model)}\n` : '') + - `description = ${tomlString(`Squad specialist ${m.name} (${m.role}). Use for work routed to ${m.name}.`)}\n` + - `developer_instructions = ${tomlString( - `You are ${m.name}, the ${m.role} on this Squad team. First read your charter at ${squadDir}/agents/${slug}/charter.md and follow it. Record decisions with the squad_decide MCP tool and append learnings to your history with squad_state_append.`, - )}\n`, - }; -} - const codex: ClientAdapter = { id: 'codex', capabilities: CAPABILITIES.codex, @@ -303,7 +237,6 @@ const codex: ClientAdapter = { args: tomlArray(ctx.mcp.args), }, { startup_timeout_sec: '60' }), }, - ...(ctx.members ?? []).map((m) => codexMember(m, ctx)), ], }; @@ -314,7 +247,7 @@ function antigravityAgent( name: string, description: string, body: string, - opts: { main: boolean; withMcp: boolean; model?: string }, + opts: { main: boolean; withMcp: boolean }, ): ProjectionFile { const lines = [ '---', @@ -323,7 +256,6 @@ function antigravityAgent( `mainAgent: ${opts.main}`, `subagent: ${!opts.main}`, ]; - if (opts.model) lines.push(`model: ${opts.model}`); // Antigravity treats an omitted `tools` list as "none": without run_command the agent cannot use gh/git/npm. lines.push(`tools: [${ANTIGRAVITY_TOOLS.join(', ')}]`); if (opts.withMcp) { @@ -350,16 +282,6 @@ const antigravity: ClientAdapter = { main: true, withMcp: true, }), - ...(ctx.members ?? []).map((m) => { - const slug = slugifyMember(m.name); - return antigravityAgent( - ctx, - `squad-${slug}`, - `Squad specialist ${m.name} (${m.role}). Use for work routed to ${m.name}.`, - `You are ${m.name}, the ${m.role} on this Squad team.\nFirst read your charter at \`${squadDir}/agents/${slug}/charter.md\` and follow it. Record decisions with the \`squad_decide\` MCP tool and append learnings to your history with \`squad_state_append\`.`, - { main: false, withMcp: true, model: modelFor(ctx, 'antigravity', slug) }, - ); - }), { kind: 'block', path: 'GEMINI.md', diff --git a/packages/squad-sdk/src/clients/coordinator.ts b/packages/squad-sdk/src/clients/coordinator.ts index 8b847bcb2..c3042354b 100644 --- a/packages/squad-sdk/src/clients/coordinator.ts +++ b/packages/squad-sdk/src/clients/coordinator.ts @@ -19,7 +19,7 @@ You are the Squad coordinator. You route work to a team of specialists and keep - **Phase 1 (propose).** Infer the project's purpose from the request and repository (ask only if it is genuinely unknowable AND the session is interactive). Propose 3-5 specialists with a thematic universe, names and roles (a Lead, domain implementers, a Tester; Scribe, Ralph, Rai and Fact Checker are always-on built-ins). - **Auto-confirm (default).** Do NOT stop to ask questions in Init Mode. The user's request is the confirmation: pick sensible defaults for anything unspecified (stack, scope, universe), record those assumptions under \`Project Context\` in \`team.md\`, and continue to Phase 2 in the SAME turn. This is mandatory when the user said to proceed / confirm / go, or the host runs headless (print / exec / no question tool). Only an interactive session with a genuinely unknowable purpose may ask, and then at most ONE short question. Never end the turn with only a proposal or questions. - **Phase 2 (create).** If you can run shell commands, add EVERY member with the CLI, one call each: \`npx squad cast --name --role ""\` (it writes the charter, history, the \`team.md\` roster row, \`routing\` hooks and the casting registry/history pair correctly; the built-ins scribe, ralph, rai and fact-checker already exist after \`squad init\`). Then write/adjust \`${S}/routing.md\` as an ordinary file. If \`squad cast --name\` is not recognised (an older \`squad\` build that only lists the cast; \`npx\` may fetch an old published version, so prefer the \`squad\` on PATH), write \`team.md\`, charters and histories as ordinary files and then run \`squad cast --sync\`, which registers every roster member in the casting registry idempotently (if that is also unavailable, tell the user to upgrade the squad CLI). NEVER hand-write or edit anything under \`${S}/casting/\` (\`registry.json\`, \`history.json\`, \`registry-history.commit.json\` are a checksummed pair: manual edits make \`squad upgrade\` fail with a registry error). If you cannot run shell commands, write \`team.md\` (a markdown table with \`Name\`, \`Role\`, \`Charter\`, \`Status\` columns; the Coordinator row stays separate), \`${S}/agents//charter.md\` and \`history.md\` as ordinary files, leave \`${S}/casting/\` untouched and tell the user to run \`squad cast --sync\` to register them. - - **After casting**, STOP and report the roster unless the user's request also asked for work (setting up the team is not a request to build anything); then tell the user (and run, if you can run shell commands) \`npx squad upgrade\` so this host's per-member subagent files are projected; until then use the generic subagent with the charter inlined. + - **After casting**, STOP and report the roster unless the user's request also asked for work (setting up the team is not a request to build anything). - **Scratch space.** Use \`${S}/scratch/\` (inside the workspace) for temporary files; never write outside the workspace directory (hosts reject external-directory access). 1. **Load team context first.** Read \`${S}/team.md\` (roster), \`${S}/routing.md\` (who owns what), \`${S}/decisions.md\` and the decisions inbox before routing anything. Cache them for the session; re-read only when a state change was announced. 2. **Route by ownership.** Match the request to the member whose role owns it per \`routing.md\`. Every work item has exactly ONE accountable owner (others contribute/review). If nobody owns it, ask the user or propose a new member. Named requests ("Dallas, fix X") go to that member. @@ -45,7 +45,7 @@ You are the Squad coordinator. You route work to a team of specialists and keep 19. **PRD mode.** When given a PRD (file or pasted), save a reference, synchronously dispatch the Lead to decompose it into work items with owners and dependencies, show the plan for approval (auto-approve when non-interactive and told to proceed), then route approved items dependency-aware, in parallel where independent. Details: \`${S}/templates/prd-intake.md\`. 20. **Cross-squad delegation.** "Squad" with the lowercase product meaning is a separate peer Squad install (its own \`${S}/\`), not a generic group of agents. Requests to delegate to another squad use the \`cross-squad\` / \`cross-squad-communication\` skills (registry, sync, GitHub issues), never ad-hoc subagent fan-out; if it is ambiguous whether the user means a peer squad or ad-hoc subagents, ask one two-choice question (or, non-interactively, pick ad-hoc subagents and say so). 21. **Human members and the @copilot coding agent.** \`team.md\` may list humans (they are never spawned: route their items as requests/notifications and wait) and an \`@copilot\` Coding Agent member (asynchronous; assigned by GitHub issue assignment, works on \`copilot/*\` branches and draft PRs; never spawn it; review its PRs like any other). -22. **Anti-patterns.** Do not role-play a specialist's output, fabricate results, skip dispatch, give generic task names, or serialize independent work. Never claim a step happened unless a tool result shows it. **Unknown or missing member:** if the user or a plan names a member who is not on the roster (or has no subagent file yet), say so explicitly, name the fallback you chose (e.g. "Parker is not on the roster; routing to Lambert, the Tester"), and record the substitution in your report; never silently swap owners. **Built-in members** (Scribe, Ralph, Rai, Fact Checker) always exist even if \`team.md\` was rewritten: if their \`squad-\` subagent is not available, use the generic subagent with \`${S}/agents//charter.md\` inlined. When a specialist fails or hangs, report it, retry once with a narrower task, then escalate. Never kill processes by image name (no \`taskkill /IM node.exe\`, \`pkill node\`, \`killall\`): that can terminate your own host or model proxy; stop only a specific PID you started. +22. **Anti-patterns.** Do not role-play a specialist's output, fabricate results, skip dispatch, give generic task names, or serialize independent work. Never claim a step happened unless a tool result shows it. **Unknown or missing member:** if the user or a plan names a member who is not on the roster, say so explicitly, name the fallback you chose (e.g. "Parker is not on the roster; routing to Lambert, the Tester"), and record the substitution in your report; never silently swap owners. **Built-in members** (Scribe, Ralph, Rai, Fact Checker) always exist even if \`team.md\` was rewritten: spawn them as generic subagents with \`${S}/agents//charter.md\` inlined. When a specialist fails or hangs, report it, retry once with a narrower task, then escalate. Never kill processes by image name (no \`taskkill /IM node.exe\`, \`pkill node\`, \`killall\`): that can terminate your own host or model proxy; stop only a specific PID you started. `; } @@ -60,7 +60,7 @@ Spawn each specialist with the \`task\` tool (Copilot CLI), \`create_session\` ( summary: 'Claude Code Task subagent', section: `## Dispatch adapter: Claude Code -Spawn each specialist with the \`Task\` tool, using the matching subagent from \`.claude/agents/squad-.md\` when present, otherwise a general-purpose subagent with the member charter inlined in the prompt. Launch independent \`Task\` calls in one message to run them in parallel. Subagents cannot spawn subagents, so only the coordinator dispatches. The \`squad_state\` MCP tools are available as \`mcp__squad_state__*\` from \`.mcp.json\`.`, +Spawn each specialist with the \`Task\` tool, using a general-purpose subagent with the member's charter (\`.squad/agents//charter.md\`) inlined in the prompt. Launch independent \`Task\` calls in one message to run them in parallel. Subagents cannot spawn subagents, so only the coordinator dispatches. The \`squad_state\` MCP tools are available as \`mcp__squad_state__*\` from \`.mcp.json\`.`, }, kiro: { summary: 'Kiro subagent + steering, MCP backend', @@ -72,7 +72,7 @@ Kiro loads this file as steering (\`inclusion: always\`). Delegate each speciali summary: '@agent mention / task tool, MCP backend', section: `## Dispatch adapter: OpenCode -Invoke specialists with the \`task\` tool using the matching subagent \`squad-\` from \`.opencode/agent/squad-.md\` (each runs as its own child session and can pin its own \`model\` via \`clientModels\` in \`.squad/config.json\`); if a member file is missing, use the \`general\` subagent with the member charter inlined. You can also \`@squad-\` mention. Independent tasks may run in parallel. The \`squad_state\` MCP server is configured under \`mcp.squad_state\` in \`opencode.json\`; its tools appear as \`squad_state_*\`.`, +Invoke specialists with the \`task\` tool using the \`general\` subagent with the member's charter (\`.squad/agents//charter.md\`) inlined (each runs as its own child session). Independent tasks may run in parallel. The \`squad_state\` MCP server is configured under \`mcp.squad_state\` in \`opencode.json\`; its tools appear as \`squad_state_*\`.`, }, cursor: { summary: 'Cursor subagent / composer, MCP backend', @@ -84,13 +84,13 @@ Delegate specialist work to Cursor subagents (\`.cursor/agents/\`) when availabl summary: 'Codex subagents (custom agents in .codex/agents), MCP backend', section: `## Dispatch adapter: OpenAI Codex -Codex spawns subagents when instructions ask for it, so for every routed task explicitly **spawn one agent per specialist** (use the matching custom agent from \`.codex/agents/squad-.toml\` when present, otherwise the \`worker\` agent with the member charter inlined) and wait for their results before summarizing. Spawn independent specialists in parallel. Subagents inherit this session's sandbox, approvals and MCP servers, so \`squad_state\` (configured in \`.codex/config.toml\`) is available to them. This project config only loads for trusted projects.`, +Codex spawns subagents when instructions ask for it, so for every routed task explicitly **spawn one agent per specialist** (use the \`worker\` agent with the member's charter inlined) and wait for their results before summarizing. Spawn independent specialists in parallel. Subagents inherit this session's sandbox, approvals and MCP servers, so \`squad_state\` (configured in \`.codex/config.toml\`) is available to them. This project config only loads for trusted projects.`, }, antigravity: { summary: 'Antigravity invoke_subagent, MCP backend', section: `## Dispatch adapter: Google Antigravity -Delegate each specialist task with the \`invoke_subagent\` tool: use a custom subagent from \`.agents/agents/squad-.md\` when present, otherwise the \`self\` subagent with the member charter inlined in the prompt. Subagents start with a clean context, so always include the charter, task and relevant decisions. Invoke independent subagents concurrently. The \`squad_state\` MCP server is attached to the Squad agent via its \`mcpServers\` frontmatter; if its tools are missing, register it globally with \`agy mcp add squad_state \` (the launch spec is in \`.agents/agents/squad.md\`).`, +Delegate each specialist task with the \`invoke_subagent\` tool: use the \`self\` subagent with the member's charter inlined in the prompt. Subagents start with a clean context, so always include the charter, task and relevant decisions. Invoke independent subagents concurrently. The \`squad_state\` MCP server is attached to the Squad agent via its \`mcpServers\` frontmatter; if its tools are missing, register it globally with \`agy mcp add squad_state \` (the launch spec is in \`.agents/agents/squad.md\`).`, }, }; diff --git a/packages/squad-sdk/src/clients/types.ts b/packages/squad-sdk/src/clients/types.ts index 3f60e1fba..486db7531 100644 --- a/packages/squad-sdk/src/clients/types.ts +++ b/packages/squad-sdk/src/clients/types.ts @@ -17,24 +17,11 @@ export interface McpLaunchSpec { args: string[]; } -export interface TeamMember { - name: string; - role: string; -} - export interface ClientContext { /** Project root the projection files are written under. */ root: string; /** How hosts should launch the `squad_state` MCP backend. */ mcp: McpLaunchSpec; - /** Roster used to project per-member subagent files (hosts that support them). */ - members?: TeamMember[]; - /** - * Per-client, per-member model overrides keyed by member slug (`*` = every member), - * e.g. `{ opencode: { reviewer: 'github-copilot/claude-opus-4.5' } }`. Model ids are - * client-specific, so they are never shared across clients. Unset = inherit. - */ - memberModels?: Partial>>; /** Squad state directory name relative to root (default `.squad`). */ squadDir?: string; } diff --git a/test/clients-real-cli.test.ts b/test/clients-real-cli.test.ts index 9fc597d0a..deb1a16ec 100644 --- a/test/clients-real-cli.test.ts +++ b/test/clients-real-cli.test.ts @@ -28,7 +28,6 @@ beforeAll(() => { const results = installClients(['claude', 'opencode', 'kiro', 'codex', 'antigravity'], { root, mcp: { command: 'node', args: ['-e', '0'] }, - members: [{ name: 'Data', role: 'Expert' }], }); expect(results.filter((r) => r.action === 'error')).toEqual([]); }); @@ -39,7 +38,6 @@ describe('real CLIs load Squad projections', () => { const agents = run('claude', ['-p', '--agent', 'nonexistent-zzz', 'hi'], root); const text = `${agents.stdout}${agents.stderr}`; expect(text).toMatch(/Available agents:.*\bsquad\b/); - expect(text).toMatch(/squad-data/); const mcp = run('claude', ['mcp', 'get', 'squad_state'], root); expect(mcp.stdout).toMatch(/Type: stdio/); }); diff --git a/test/clients.test.ts b/test/clients.test.ts index 78510ff7e..6446e5670 100644 --- a/test/clients.test.ts +++ b/test/clients.test.ts @@ -21,10 +21,8 @@ import { } from '../packages/squad-sdk/src/clients/index.js'; import { runCastSync } from '../packages/squad-cli/src/cli/commands/cast.js'; import { - parseRoster, persistClients, readClientFlag, - readClientModels, readConfiguredClients, syncClients, } from '../packages/squad-cli/src/cli/core/clients.js'; @@ -69,7 +67,7 @@ describe('coordinator instructions', () => { it('mentions the host-specific spawn primitive', () => { expect(buildCoordinatorInstructions('copilot')).toMatch(/runSubagent/); expect(buildCoordinatorInstructions('claude')).toMatch(/`Task`/); - expect(buildCoordinatorInstructions('opencode')).toMatch(/@squad-/); + expect(buildCoordinatorInstructions('opencode')).toMatch(/`general` subagent/); }); }); @@ -101,8 +99,8 @@ describe('client capabilities', () => { }); describe('codex adapter', () => { - it('projects AGENTS.md block, .codex/config.toml mcp server and member agents', () => { - installClients(['codex'], ctx({ members: [{ name: 'Data', role: 'Expert' }] })); + it('projects AGENTS.md block and .codex/config.toml mcp server only', () => { + installClients(['codex'], ctx()); const agents = read('AGENTS.md'); expect(agents).toContain('\n`); - } - await updateCastingStateWithAgent(squadDir, name, role, slug); - await addAgentToConfig(teamRoot, slug, role); - known.add(slug); - registered.push(slug); - } - success(registered.length ? `Registered ${registered.length} member(s) in the casting registry: ${registered.join(', ')}` : 'Casting registry already matches team.md'); - return { registered, skipped }; -} - export async function runCast(cwd: string): Promise { // Resolve squad paths const paths = resolveSquadPaths(cwd); diff --git a/packages/squad-sdk/src/clients/coordinator.ts b/packages/squad-sdk/src/clients/coordinator.ts index c3042354b..8e1d99806 100644 --- a/packages/squad-sdk/src/clients/coordinator.ts +++ b/packages/squad-sdk/src/clients/coordinator.ts @@ -18,7 +18,7 @@ You are the Squad coordinator. You route work to a team of specialists and keep 0. **Init Mode (only when there is truly no team).** Decide this with \`squad_state_read\` of \`team.md\` (or \`squad_state_list\`), NEVER with glob/grep/search tools: many hosts' glob/grep skip dot-directories like \`${S}/\` and will wrongly report "no matches". If \`team.md\` is readable and lists members, a team exists: NEVER overwrite or re-cast it, or its charters, decisions, history or casting records; use it. Only if \`team.md\` does not exist or has an empty roster: - **Phase 1 (propose).** Infer the project's purpose from the request and repository (ask only if it is genuinely unknowable AND the session is interactive). Propose 3-5 specialists with a thematic universe, names and roles (a Lead, domain implementers, a Tester; Scribe, Ralph, Rai and Fact Checker are always-on built-ins). - **Auto-confirm (default).** Do NOT stop to ask questions in Init Mode. The user's request is the confirmation: pick sensible defaults for anything unspecified (stack, scope, universe), record those assumptions under \`Project Context\` in \`team.md\`, and continue to Phase 2 in the SAME turn. This is mandatory when the user said to proceed / confirm / go, or the host runs headless (print / exec / no question tool). Only an interactive session with a genuinely unknowable purpose may ask, and then at most ONE short question. Never end the turn with only a proposal or questions. - - **Phase 2 (create).** If you can run shell commands, add EVERY member with the CLI, one call each: \`npx squad cast --name --role ""\` (it writes the charter, history, the \`team.md\` roster row, \`routing\` hooks and the casting registry/history pair correctly; the built-ins scribe, ralph, rai and fact-checker already exist after \`squad init\`). Then write/adjust \`${S}/routing.md\` as an ordinary file. If \`squad cast --name\` is not recognised (an older \`squad\` build that only lists the cast; \`npx\` may fetch an old published version, so prefer the \`squad\` on PATH), write \`team.md\`, charters and histories as ordinary files and then run \`squad cast --sync\`, which registers every roster member in the casting registry idempotently (if that is also unavailable, tell the user to upgrade the squad CLI). NEVER hand-write or edit anything under \`${S}/casting/\` (\`registry.json\`, \`history.json\`, \`registry-history.commit.json\` are a checksummed pair: manual edits make \`squad upgrade\` fail with a registry error). If you cannot run shell commands, write \`team.md\` (a markdown table with \`Name\`, \`Role\`, \`Charter\`, \`Status\` columns; the Coordinator row stays separate), \`${S}/agents//charter.md\` and \`history.md\` as ordinary files, leave \`${S}/casting/\` untouched and tell the user to run \`squad cast --sync\` to register them. + - **Phase 2 (create).** If you can run shell commands, add EVERY member with the CLI, one call each: \`npx squad cast --name --role ""\` (it writes the charter, history, the \`team.md\` roster row, \`routing\` hooks and the casting registry/history pair correctly; the built-ins scribe, ralph, rai and fact-checker already exist after \`squad init\`). Then write/adjust \`${S}/routing.md\` as an ordinary file. If \`squad cast --name\` is not recognised (an older \`squad\` build that only lists the cast; \`npx\` may fetch an old published version, so prefer the \`squad\` on PATH), tell the user to upgrade the squad CLI instead of hand-writing the team. NEVER hand-write or edit anything under \`${S}/casting/\` (\`registry.json\`, \`history.json\`, \`registry-history.commit.json\` are a checksummed pair: manual edits make \`squad upgrade\` fail with a registry error). If you cannot run shell commands, write \`team.md\` (a markdown table with \`Name\`, \`Role\`, \`Charter\`, \`Status\` columns; the Coordinator row stays separate), \`${S}/agents//charter.md\` and \`history.md\` as ordinary files, leave \`${S}/casting/\` untouched and tell the user to run \`squad cast --name --role ""\` for each member to register them. - **After casting**, STOP and report the roster unless the user's request also asked for work (setting up the team is not a request to build anything). - **Scratch space.** Use \`${S}/scratch/\` (inside the workspace) for temporary files; never write outside the workspace directory (hosts reject external-directory access). 1. **Load team context first.** Read \`${S}/team.md\` (roster), \`${S}/routing.md\` (who owns what), \`${S}/decisions.md\` and the decisions inbox before routing anything. Cache them for the session; re-read only when a state change was announced. diff --git a/test/clients.test.ts b/test/clients.test.ts index 6446e5670..178197b83 100644 --- a/test/clients.test.ts +++ b/test/clients.test.ts @@ -19,7 +19,6 @@ import { BLOCK_BEGIN, type ClientContext, } from '../packages/squad-sdk/src/clients/index.js'; -import { runCastSync } from '../packages/squad-cli/src/cli/commands/cast.js'; import { persistClients, readClientFlag, @@ -195,7 +194,7 @@ describe('coordinator behavioral-contract parity', () => { ['Tool naming + dirs', [/Tool names are host-prefixed/, /squad_state_squad_state_read/, /EISDIR/, /for a directory/]], ['Explicit fallback', [/Unknown or missing member/, /never silently swap owners/, /Built-in members/]], ['Ralph', [/Ralph mode/, /squad:/, /board clear/]], - ['Init Mode guards', [/NEVER with glob\/grep\/search tools/, /Auto-confirm \(default\)/, /npx squad cast --name/, /NEVER hand-write or edit anything under/, /checksummed pair/, /squad cast --sync/, /\.squad\/scratch\//, /never write outside the workspace/, /STOP and report the roster/]], + ['Init Mode guards', [/NEVER with glob\/grep\/search tools/, /Auto-confirm \(default\)/, /npx squad cast --name/, /NEVER hand-write or edit anything under/, /checksummed pair/, /\.squad\/scratch\//, /never write outside the workspace/, /STOP and report the roster/]], ]; for (const id of CLIENT_IDS) { it(`${id} prompt carries the full coordinator contract`, () => { @@ -216,20 +215,6 @@ describe('coordinator behavioral-contract parity', () => { }); }); -describe('squad cast --sync', () => { - it('registers hand-written roster members idempotently', async () => { - const sq = path.join(root, '.squad'); - fs.mkdirSync(path.join(sq, 'agents'), { recursive: true }); - fs.writeFileSync(path.join(sq, 'team.md'), '| Name | Role | Charter | Status |\n|---|---|---|---|\n| Ripley | Lead | x | ok |\n| Tomas | Dev | x | ok |\n| Scribe | Decision Merger | x | ok |\n'); - const first = await runCastSync(root); - expect(first.registered).toEqual(['ripley', 'tomas']); - expect(Object.keys(JSON.parse(fs.readFileSync(path.join(sq, 'casting/registry.json'), 'utf8')).agents)).toEqual(['ripley', 'tomas']); - expect(fs.existsSync(path.join(sq, 'agents/ripley/charter.md'))).toBe(true); - const again = await runCastSync(root); - expect(again.registered).toEqual([]); - }); -}); - describe('copilot adapter', () => { it('projects only .mcp.json with squad_state', () => { installClients(['copilot'], ctx()); From 5f95bd80d9e41e47de740fafb6e0dc5469420e86 Mon Sep 17 00:00:00 2001 From: Copilot Date: Sun, 18 Oct 2026 16:46:02 +0300 Subject: [PATCH 28/46] docs(demos): interactive cli-tunnel evidence for opencode, Codex, Antigravity Fresh repos, squad init --client X, casting with squad cast --name, parallel build. Antigravity's build step hit the per-model quota; Kiro is unavailable (monthly limit). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 995eea17-c356-4912-90ea-5e5b144e7ba9 From d780ecad4176e674cba0a79adebff4401640ede9 Mon Sep 17 00:00:00 2001 From: Copilot Date: Sun, 18 Oct 2026 17:24:45 +0300 Subject: [PATCH 29/46] docs(demos): captions, controlled-demo label, fresh-session Scribe frames Adds phase captions and a permissive-mode disclaimer to the interactive screenshots, plus restart/recovery + Scribe-merge frames for opencode and Codex (replacing the mid-"Working" Codex ending). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 995eea17-c356-4912-90ea-5e5b144e7ba9 From 8db8d6a9d309276512e854e4b52cb50394539f09 Mon Sep 17 00:00:00 2001 From: Copilot Date: Sun, 18 Oct 2026 17:33:56 +0300 Subject: [PATCH 30/46] docs(demos): Claude interactive start and fresh-session Scribe frames Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 995eea17-c356-4912-90ea-5e5b144e7ba9 From 9f00181ba3268d80152ca211b18f542acd978046 Mon Sep 17 00:00:00 2001 From: Copilot Date: Sun, 18 Oct 2026 17:57:04 +0300 Subject: [PATCH 31/46] docs(demos): one continuous interactive Claude session (cast, build, issues, Ralph) Fresh private repo tamirdresher/squad-ralph-claude: init -> cast for the project -> parallel build -> two squad-labelled issues -> "Ralph, go" until both PRs merged (34/34 tests on main). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 995eea17-c356-4912-90ea-5e5b144e7ba9 From ae3195bc808f7da2922147eaadbeb4cd5d977d44 Mon Sep 17 00:00:00 2001 From: Copilot Date: Sun, 18 Oct 2026 19:01:33 +0300 Subject: [PATCH 32/46] test(clients): real cursor-agent loads the generated .cursor/mcp.json Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 995eea17-c356-4912-90ea-5e5b144e7ba9 --- test/clients-real-cli.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/test/clients-real-cli.test.ts b/test/clients-real-cli.test.ts index deb1a16ec..3ad690a09 100644 --- a/test/clients-real-cli.test.ts +++ b/test/clients-real-cli.test.ts @@ -25,7 +25,7 @@ beforeAll(() => { const squad = path.join(root, '.squad'); fs.mkdirSync(squad); fs.writeFileSync(path.join(squad, 'team.md'), '| Name | Role | Charter | Status |\n|---|---|---|---|\n| Data | Expert | c | ok |\n'); - const results = installClients(['claude', 'opencode', 'kiro', 'codex', 'antigravity'], { + const results = installClients(['claude', 'opencode', 'kiro', 'codex', 'antigravity', 'cursor'], { root, mcp: { command: 'node', args: ['-e', '0'] }, }); @@ -74,4 +74,10 @@ describe('real CLIs load Squad projections', () => { const r = run('agy', ['--agent', 'nonexistent-zzz', '-p', 'hi'], root); expect(`${r.stdout}${r.stderr}`).toMatch(/available:.*\bsquad\b/); }); + + it.skipIf(!has('cursor-agent'))('cursor-agent loads .cursor/mcp.json and starts the squad_state server', () => { + run('cursor-agent', ['mcp', 'enable', 'squad_state'], root); + const list = run('cursor-agent', ['mcp', 'list'], root); + expect(`${list.stdout}${list.stderr}`).toMatch(/squad_state/); + }); }); From 9eef3f735179362873183d3dd8920a3b90720d73 Mon Sep 17 00:00:00 2001 From: Copilot Date: Sun, 18 Oct 2026 19:40:44 +0300 Subject: [PATCH 33/46] refactor(sdk): HostProjection + spec ClientAdapter facade, src/client layout, capability tokens Per Bubu's green light on the spec-alignment questions: - HostProjection is the pure workspace projection (no runtime dependency); ClientAdapter (client/adapter-contract.ts) is the thin spec-compliant facade (describe, projectWorkspace, getCapabilities; submit/subscribe/cancel/ respondApproval delegate to a bound runtime, else AdapterUnboundError). - Canonical home is packages/squad-sdk/src/client/{adapter-contract.ts,adapters/}; src/clients re-exports the exact same symbols (deprecated). - Canonical string[] capability tokens are authoritative; boolean flags are a derived view; conflicting token+flag input throws. Advertised capabilities stay separate from negotiated effective capabilities. - Update stale e2e tests for coordinator-only projection and prompt wording. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 995eea17-c356-4912-90ea-5e5b144e7ba9 --- .../squad-sdk/src/client/adapter-contract.ts | 201 ++++++++++++++++++ .../{clients => client/adapters}/adapters.ts | 30 +-- .../src/{clients => client/adapters}/apply.ts | 6 +- .../src/client/adapters/capabilities.ts | 120 +++++++++++ .../adapters}/coordinator.ts | 2 +- .../squad-sdk/src/client/adapters/index.ts | 6 + .../{clients => client/adapters}/portable.ts | 2 +- .../src/{clients => client/adapters}/types.ts | 10 +- packages/squad-sdk/src/client/index.ts | 5 + .../squad-sdk/src/clients/capabilities.ts | 36 ---- packages/squad-sdk/src/clients/index.ts | 14 +- test/clients.test.ts | 76 +++++++ test/squad-20-all-scenarios.e2e.test.ts | 11 +- test/squad-20-real-clients-stdio.e2e.test.ts | 3 +- 14 files changed, 451 insertions(+), 71 deletions(-) create mode 100644 packages/squad-sdk/src/client/adapter-contract.ts rename packages/squad-sdk/src/{clients => client/adapters}/adapters.ts (92%) rename packages/squad-sdk/src/{clients => client/adapters}/apply.ts (98%) create mode 100644 packages/squad-sdk/src/client/adapters/capabilities.ts rename packages/squad-sdk/src/{clients => client/adapters}/coordinator.ts (99%) create mode 100644 packages/squad-sdk/src/client/adapters/index.ts rename packages/squad-sdk/src/{clients => client/adapters}/portable.ts (98%) rename packages/squad-sdk/src/{clients => client/adapters}/types.ts (89%) delete mode 100644 packages/squad-sdk/src/clients/capabilities.ts diff --git a/packages/squad-sdk/src/client/adapter-contract.ts b/packages/squad-sdk/src/client/adapter-contract.ts new file mode 100644 index 000000000..a705695b8 --- /dev/null +++ b/packages/squad-sdk/src/client/adapter-contract.ts @@ -0,0 +1,201 @@ +/** + * Spec-level client adapter contract (squad-client-harness-adapters/v0.2). + * + * {@link ClientAdapter} is a thin composition facade: workspace projection and + * capability description come from a pure {@link HostProjection}; runtime + * operations (`submit`, `subscribe`, `cancel`, `respondApproval`) delegate to a + * bound harness/backend and fail with {@link AdapterUnboundError} when none is bound. + * + * @module client/adapter-contract + */ + +import { + CAPABILITY_TOKENS_BY_HOST, + assertConsistentCapabilities, + resolveEffectiveCapabilityTokens, +} from './adapters/capabilities.js'; +import { getHostProjection } from './adapters/adapters.js'; +import type { ClientContext, ClientId, HostProjection } from './adapters/types.js'; + +export type { ClientCapabilityToken } from './adapters/capabilities.js'; + +export type AdapterHostKind = + | 'github-copilot' + | 'claude-code' + | 'kiro' + | 'opencode' + | 'cursor' + | 'codex' + | 'antigravity'; + +export interface AdapterDescription { + adapterId: string; + hostKind: AdapterHostKind; + hostVariant?: string; + displayName: string; + /** Advertised (not negotiated) capabilities as booleans, derived from the canonical tokens. */ + capabilities: { + approvals: boolean; + backgroundRuns: boolean; + mcpConfig: boolean; + subagents: boolean; + }; + projectedFiles: string[]; +} + +export interface AdapterCapabilityProjection { + clientCapabilities: string[]; + backendCapabilities: string[]; + policyCapabilities: string[]; + effectiveCapabilities: string[]; +} + +export interface ProjectWorkspaceProjection { + repoRoot: string; + workingDirectory: string; + workspaceKind: 'repository' | 'folder' | 'multi-root' | 'cloud'; + branch?: string; + squadAgentPath: string; + mcpConfigPath?: string; + supportFiles: string[]; +} + +export interface AdapterSubmission { + conversationId: string; + prompt: string; + metadata?: Record; +} + +export interface AdapterSubscription { + conversationId: string; + cursor?: string; +} + +export interface ApprovalResponse { + approvalId: string; + requestHash: string; + decision: 'approved' | 'rejected' | 'cancelled'; + responseText?: string; +} + +export interface ClientAdapter { + submit(input: AdapterSubmission): Promise<{ admissionId: string; runId: string }>; + subscribe( + input: AdapterSubscription, + onEvent: (event: Record) => void | Promise, + ): Promise<() => Promise>; + cancel(conversationId: string, runId: string, reason?: string): Promise; + respondApproval(conversationId: string, response: ApprovalResponse): Promise; + getCapabilities(conversationId?: string): Promise; + describe(): Promise; + projectWorkspace(): Promise; +} + +/** Runtime delegate: a bound `HarnessAdapter` or `SquadBackend` client. */ +export interface RuntimeBinding { + submit(input: AdapterSubmission): Promise<{ admissionId: string; runId: string }>; + subscribe( + input: AdapterSubscription, + onEvent: (event: Record) => void | Promise, + ): Promise<() => Promise>; + cancel(conversationId: string, runId: string, reason?: string): Promise; + respondApproval(conversationId: string, response: ApprovalResponse): Promise; + /** Canonical capability tokens granted by the backend for this conversation. */ + backendCapabilities?(conversationId?: string): string[] | Promise; + /** Canonical capability tokens allowed by authorization policy for this conversation. */ + policyCapabilities?(conversationId?: string): string[] | Promise; +} + +export class AdapterUnboundError extends Error { + constructor(operation: string, host: string) { + super(`${host}: "${operation}" needs a bound runtime (HarnessAdapter/SquadBackend); only workspace projection is available.`); + this.name = 'AdapterUnboundError'; + } +} + +const HOST_KIND: Record = { + copilot: 'github-copilot', + claude: 'claude-code', + kiro: 'kiro', + opencode: 'opencode', + cursor: 'cursor', + codex: 'codex', + antigravity: 'antigravity', +}; + +/** Authoritative agent anchor and MCP config path per host. */ +const ANCHORS: Record = { + copilot: { agent: '.github/agents/squad.agent.md', mcp: '.mcp.json' }, + claude: { agent: '.claude/agents/squad.md', mcp: '.mcp.json' }, + kiro: { agent: '.kiro/steering/squad.md', mcp: '.kiro/settings/mcp.json' }, + opencode: { agent: '.opencode/agent/squad.md', mcp: 'opencode.json' }, + cursor: { agent: '.cursor/agents/squad.md', mcp: '.cursor/mcp.json' }, + codex: { agent: 'AGENTS.md', mcp: '.codex/config.toml' }, + antigravity: { agent: '.agents/agents/squad.md' }, +}; + +export interface CreateClientAdapterOptions { + host: ClientId | HostProjection; + ctx: ClientContext; + runtime?: RuntimeBinding; + /** + * Optional boolean flags from a caller that also holds the token view; a + * disagreement with the host's canonical tokens throws. + */ + declaredFlags?: Parameters[1]; +} + +export function createClientAdapter(options: CreateClientAdapterOptions): ClientAdapter { + const projection = typeof options.host === 'string' ? getHostProjection(options.host) : options.host; + const tokens = CAPABILITY_TOKENS_BY_HOST[projection.id]; + if (options.declaredFlags) assertConsistentCapabilities(tokens, options.declaredFlags); + const { ctx, runtime } = options; + const anchors = ANCHORS[projection.id]; + const requireRuntime = (op: string): RuntimeBinding => { + if (!runtime) throw new AdapterUnboundError(op, projection.label); + return runtime; + }; + const files = () => [...new Set(projection.project(ctx).map((f) => f.path))]; + + return { + submit: async (input) => requireRuntime('submit').submit(input), + subscribe: async (input, onEvent) => requireRuntime('subscribe').subscribe(input, onEvent), + cancel: async (conversationId, runId, reason) => requireRuntime('cancel').cancel(conversationId, runId, reason), + respondApproval: async (conversationId, response) => requireRuntime('respondApproval').respondApproval(conversationId, response), + async getCapabilities(conversationId) { + const backend = runtime?.backendCapabilities ? await runtime.backendCapabilities(conversationId) : undefined; + const policy = runtime?.policyCapabilities ? await runtime.policyCapabilities(conversationId) : undefined; + return { + clientCapabilities: [...tokens], + backendCapabilities: backend ? [...backend] : [], + policyCapabilities: policy ? [...policy] : [], + effectiveCapabilities: resolveEffectiveCapabilityTokens(tokens, backend, policy), + }; + }, + async describe() { + return { + adapterId: `squad-${projection.id}`, + hostKind: HOST_KIND[projection.id], + displayName: projection.label, + capabilities: { + approvals: tokens.includes('approvals'), + backgroundRuns: tokens.includes('background-runs'), + mcpConfig: tokens.includes('mcp-bridge'), + subagents: tokens.includes('subagents'), + }, + projectedFiles: files(), + }; + }, + async projectWorkspace() { + const all = files(); + return { + repoRoot: ctx.root, + workingDirectory: ctx.root, + workspaceKind: 'repository', + squadAgentPath: anchors.agent, + ...(anchors.mcp ? { mcpConfigPath: anchors.mcp } : {}), + supportFiles: all.filter((p) => p !== anchors.agent && p !== anchors.mcp), + }; + }, + }; +} diff --git a/packages/squad-sdk/src/clients/adapters.ts b/packages/squad-sdk/src/client/adapters/adapters.ts similarity index 92% rename from packages/squad-sdk/src/clients/adapters.ts rename to packages/squad-sdk/src/client/adapters/adapters.ts index 2fe59d7ab..668fc6a78 100644 --- a/packages/squad-sdk/src/clients/adapters.ts +++ b/packages/squad-sdk/src/client/adapters/adapters.ts @@ -1,13 +1,13 @@ /** * The five client host adapters and shared config-merge helpers. * - * @module clients/adapters + * @module client/adapters */ import { CAPABILITIES } from './capabilities.js'; import { upsertTomlTable } from './apply.js'; import { buildCoordinatorInstructions, dispatchStrategy } from './coordinator.js'; -import type { ClientAdapter, ClientContext, ClientId, ProjectionFile } from './types.js'; +import type { HostProjection, ClientContext, ClientId, ProjectionFile } from './types.js'; import { CLIENT_IDS } from './types.js'; export const SQUAD_STATE_KEY = 'squad_state'; @@ -68,7 +68,7 @@ function mcpJson(ctx: ClientContext, withTools: boolean): ProjectionFile { }; } -const copilot: ClientAdapter = { +const copilot: HostProjection = { id: 'copilot', capabilities: CAPABILITIES.copilot, label: 'GitHub Copilot', @@ -78,7 +78,7 @@ const copilot: ClientAdapter = { project: (ctx) => [mcpJson(ctx, true)], }; -const claude: ClientAdapter = { +const claude: HostProjection = { id: 'claude', capabilities: CAPABILITIES.claude, label: 'Claude Code', @@ -106,7 +106,7 @@ const claude: ClientAdapter = { ], }; -const kiro: ClientAdapter = { +const kiro: HostProjection = { id: 'kiro', capabilities: CAPABILITIES.kiro, label: 'Kiro', @@ -153,7 +153,7 @@ const kiro: ClientAdapter = { ], }; -const opencode: ClientAdapter = { +const opencode: HostProjection = { id: 'opencode', capabilities: CAPABILITIES.opencode, label: 'OpenCode', @@ -183,7 +183,7 @@ const opencode: ClientAdapter = { ], }; -const cursor: ClientAdapter = { +const cursor: HostProjection = { id: 'cursor', capabilities: CAPABILITIES.cursor, label: 'Cursor', @@ -220,7 +220,7 @@ const cursor: ClientAdapter = { const tomlString = (s: string) => JSON.stringify(s); const tomlArray = (a: string[]) => `[${a.map(tomlString).join(', ')}]`; -const codex: ClientAdapter = { +const codex: HostProjection = { id: 'codex', capabilities: CAPABILITIES.codex, label: 'OpenAI Codex', @@ -270,7 +270,7 @@ function antigravityAgent( return { kind: 'managed', path: `.agents/agents/${name}.md`, content: lines.join('\n') + '\n' }; } -const antigravity: ClientAdapter = { +const antigravity: HostProjection = { id: 'antigravity', capabilities: CAPABILITIES.antigravity, label: 'Google Antigravity', @@ -292,12 +292,18 @@ const antigravity: ClientAdapter = { }, }; -export const CLIENT_ADAPTERS: Record = { copilot, claude, kiro, opencode, cursor, codex, antigravity }; +export const HOST_PROJECTIONS: Record = { copilot, claude, kiro, opencode, cursor, codex, antigravity }; -export function getClientAdapter(id: ClientId): ClientAdapter { - return CLIENT_ADAPTERS[id]; +/** @deprecated Use {@link HOST_PROJECTIONS}. Same object. */ +export const CLIENT_ADAPTERS = HOST_PROJECTIONS; + +export function getHostProjection(id: ClientId): HostProjection { + return HOST_PROJECTIONS[id]; } +/** @deprecated Use {@link getHostProjection}. */ +export const getClientAdapter = getHostProjection; + /** * Parse a `--client` value: comma-separated ids or `all`. Returns unique ids in * canonical order. Throws on unknown ids. diff --git a/packages/squad-sdk/src/clients/apply.ts b/packages/squad-sdk/src/client/adapters/apply.ts similarity index 98% rename from packages/squad-sdk/src/clients/apply.ts rename to packages/squad-sdk/src/client/adapters/apply.ts index f64051bb0..1cd350d00 100644 --- a/packages/squad-sdk/src/clients/apply.ts +++ b/packages/squad-sdk/src/client/adapters/apply.ts @@ -1,12 +1,12 @@ /** * Applies client projections to disk without clobbering user-owned settings. * - * @module clients/apply + * @module client/adapters/apply */ import fs from 'node:fs'; import path from 'node:path'; -import { SQUAD_STATE_KEY, getClientAdapter } from './adapters.js'; +import { SQUAD_STATE_KEY, getHostProjection } from './adapters.js'; import type { ApplyResult, ClientContext, ClientId, ProjectionFile } from './types.js'; export const BLOCK_BEGIN = ''; @@ -78,7 +78,7 @@ export function upsertTomlTable( } /** Collect projection files for the given clients, in order. */ export function projectClients(clients: readonly ClientId[], ctx: ClientContext): ProjectionFile[] { - return clients.flatMap((c) => getClientAdapter(c).project(ctx)); + return clients.flatMap((c) => getHostProjection(c).project(ctx)); } function parseJsonObject(file: string, raw: string | undefined): Record { diff --git a/packages/squad-sdk/src/client/adapters/capabilities.ts b/packages/squad-sdk/src/client/adapters/capabilities.ts new file mode 100644 index 000000000..0da8e0a26 --- /dev/null +++ b/packages/squad-sdk/src/client/adapters/capabilities.ts @@ -0,0 +1,120 @@ +import type { ClientCapabilities, ClientId, TransportMode } from './types.js'; + +/** + * Canonical capability tokens (client-harness-adapters/v0.2). The token array is + * authoritative; {@link ClientCapabilities} boolean flags are a derived view. + * `native-hooks` and `steering-files` are projection-level tokens. + */ +export const CAPABILITY_TOKENS = [ + 'approvals', + 'background-runs', + 'cancel-run', + 'cursor-reconnect', + 'mcp-bridge', + 'subagents', + 'typed-content-parts', + 'native-hooks', + 'steering-files', +] as const; +export type ClientCapabilityToken = (typeof CAPABILITY_TOKENS)[number]; + +const FLAG_TOKENS = { + supportsSubagents: 'subagents', + supportsMcpTransport: 'mcp-bridge', + supportsNativeHooks: 'native-hooks', + supportsSteeringFiles: 'steering-files', +} as const satisfies Record, ClientCapabilityToken>; + +/** Derive the boolean flag view from canonical tokens. */ +export function capabilityFlagsFromTokens( + tokens: readonly string[], + transportModes: readonly TransportMode[] = ['mcp-bridge', 'http-backend'], +): ClientCapabilities { + const has = (t: string) => tokens.includes(t); + return { + supportsSubagents: has('subagents'), + supportsMcpTransport: has('mcp-bridge'), + supportsNativeHooks: has('native-hooks'), + supportsSteeringFiles: has('steering-files'), + transportModes: transportModes.filter((m) => m !== 'mcp-bridge' || has('mcp-bridge')), + }; +} + +/** Token view of a flag set (inverse of {@link capabilityFlagsFromTokens}, without runtime-only tokens). */ +export function capabilityTokensFromFlags(flags: Partial): ClientCapabilityToken[] { + return (Object.entries(FLAG_TOKENS) as Array<[keyof typeof FLAG_TOKENS, ClientCapabilityToken]>) + .filter(([k]) => flags[k] === true) + .map(([, token]) => token); +} + +/** Reject callers that pass both a token array and boolean flags that disagree. */ +export function assertConsistentCapabilities(tokens: readonly string[], flags: Partial): void { + for (const [k, token] of Object.entries(FLAG_TOKENS) as Array<[keyof typeof FLAG_TOKENS, ClientCapabilityToken]>) { + if (flags[k] !== undefined && flags[k] !== tokens.includes(token)) { + throw new Error(`Conflicting capabilities: ${k}=${flags[k]} but token "${token}" is ${tokens.includes(token) ? 'present' : 'absent'}`); + } + } +} + +/** Declared (not probed) host capability tokens; keep conservative. Advertised, not negotiated. */ +export const CAPABILITY_TOKENS_BY_HOST: Record = { + copilot: ['approvals', 'background-runs', 'mcp-bridge', 'subagents', 'native-hooks', 'steering-files'], + claude: ['approvals', 'background-runs', 'mcp-bridge', 'subagents', 'native-hooks', 'steering-files'], + kiro: ['approvals', 'mcp-bridge', 'subagents', 'native-hooks', 'steering-files'], + opencode: ['approvals', 'mcp-bridge', 'subagents', 'native-hooks'], + cursor: ['approvals', 'mcp-bridge', 'subagents', 'steering-files'], + codex: ['approvals', 'background-runs', 'mcp-bridge', 'subagents', 'native-hooks', 'steering-files'], + antigravity: ['approvals', 'mcp-bridge', 'subagents', 'steering-files'], +}; + +const TRANSPORTS: Record = { + copilot: ['mcp-bridge', 'http-backend', 'in-process'], + claude: ['mcp-bridge', 'http-backend'], + kiro: ['mcp-bridge', 'http-backend'], + opencode: ['mcp-bridge', 'http-backend'], + cursor: ['mcp-bridge', 'http-backend'], + codex: ['mcp-bridge', 'http-backend'], + antigravity: ['mcp-bridge', 'http-backend'], +}; + +/** Derived boolean view of {@link CAPABILITY_TOKENS_BY_HOST}. */ +export const CAPABILITIES: Record = Object.fromEntries( + (Object.keys(CAPABILITY_TOKENS_BY_HOST) as ClientId[]).map((id) => [id, capabilityFlagsFromTokens(CAPABILITY_TOKENS_BY_HOST[id], TRANSPORTS[id])]), +) as Record; + +/** + * Strict intersection of advertised client tokens, backend grant and policy. + * An omitted `backend`/`policy` means "no restriction from that party". The + * result is the negotiated `effectiveCapabilities`, never the advertised set. + */ +export function resolveEffectiveCapabilityTokens( + client: readonly string[], + backend?: readonly string[], + policy?: readonly string[], +): string[] { + return client.filter((t) => (!backend || backend.includes(t)) && (!policy || policy.includes(t))); +} +type CapabilityKey = Exclude; +const FLAGS: CapabilityKey[] = ['supportsSubagents', 'supportsMcpTransport', 'supportsNativeHooks', 'supportsSteeringFiles']; + +/** + * Strict intersection of client declaration, backend grant and policy. + * An omitted key in `backend`/`policy` means "no restriction from that party"; + * a present `false` (or `transportModes` list) restricts. The client's own + * declaration never widens what backend or policy allow. + */ +export function resolveEffectiveCapabilities( + client: ClientCapabilities, + backend: Partial = {}, + policy: Partial = {}, +): ClientCapabilities { + const out = { transportModes: [...client.transportModes] } as ClientCapabilities; + for (const k of FLAGS) { + out[k] = client[k] === true && backend[k] !== false && policy[k] !== false; + } + for (const limit of [backend.transportModes, policy.transportModes]) { + if (limit) out.transportModes = out.transportModes.filter((m) => limit.includes(m)); + } + if (!out.supportsMcpTransport) out.transportModes = out.transportModes.filter((m) => m !== 'mcp-bridge'); + return out; +} diff --git a/packages/squad-sdk/src/clients/coordinator.ts b/packages/squad-sdk/src/client/adapters/coordinator.ts similarity index 99% rename from packages/squad-sdk/src/clients/coordinator.ts rename to packages/squad-sdk/src/client/adapters/coordinator.ts index 8e1d99806..020e9e434 100644 --- a/packages/squad-sdk/src/clients/coordinator.ts +++ b/packages/squad-sdk/src/client/adapters/coordinator.ts @@ -1,7 +1,7 @@ /** * Host-agnostic coordinator protocol plus per-client dispatch sections. * - * @module clients/coordinator + * @module client/adapters/coordinator */ import { CAPABILITIES } from './capabilities.js'; diff --git a/packages/squad-sdk/src/client/adapters/index.ts b/packages/squad-sdk/src/client/adapters/index.ts new file mode 100644 index 000000000..44385ccbb --- /dev/null +++ b/packages/squad-sdk/src/client/adapters/index.ts @@ -0,0 +1,6 @@ +export * from './types.js'; +export * from './coordinator.js'; +export * from './adapters.js'; +export * from './apply.js'; +export * from './capabilities.js'; +export * from './portable.js'; diff --git a/packages/squad-sdk/src/clients/portable.ts b/packages/squad-sdk/src/client/adapters/portable.ts similarity index 98% rename from packages/squad-sdk/src/clients/portable.ts rename to packages/squad-sdk/src/client/adapters/portable.ts index 40c61471a..fe0c84e52 100644 --- a/packages/squad-sdk/src/clients/portable.ts +++ b/packages/squad-sdk/src/client/adapters/portable.ts @@ -3,7 +3,7 @@ * client slugs, never credentials, provider thread ids, machine paths, * leases or checkpoints. * - * @module clients/portable + * @module client/adapters/portable */ import { CLIENT_IDS, type ClientId } from './types.js'; diff --git a/packages/squad-sdk/src/clients/types.ts b/packages/squad-sdk/src/client/adapters/types.ts similarity index 89% rename from packages/squad-sdk/src/clients/types.ts rename to packages/squad-sdk/src/client/adapters/types.ts index 486db7531..34bcb5d88 100644 --- a/packages/squad-sdk/src/clients/types.ts +++ b/packages/squad-sdk/src/client/adapters/types.ts @@ -2,10 +2,10 @@ * Multi-client host adapter types. * * Squad's coordinator and its `squad_state` MCP backend are host-agnostic. - * A {@link ClientAdapter} projects them into the files a specific client + * A {@link HostProjection} projects them into the files a specific client * host (Copilot, Claude Code, Kiro, OpenCode, Cursor) loads natively. * - * @module clients/types + * @module client/adapters/types */ export const CLIENT_IDS = ['copilot', 'claude', 'kiro', 'opencode', 'cursor', 'codex', 'antigravity'] as const; @@ -61,7 +61,11 @@ export interface ClientCapabilities { transportModes: TransportMode[]; } -export interface ClientAdapter { +/** + * Pure workspace projection for one host: no backend or runtime dependency. + * The spec-level runtime `ClientAdapter` (see `client/adapter-contract`) composes this. + */ +export interface HostProjection { readonly id: ClientId; readonly label: string; readonly capabilities: ClientCapabilities; diff --git a/packages/squad-sdk/src/client/index.ts b/packages/squad-sdk/src/client/index.ts index 25f5453b1..90f07ed09 100644 --- a/packages/squad-sdk/src/client/index.ts +++ b/packages/squad-sdk/src/client/index.ts @@ -245,3 +245,8 @@ export class SquadClientWithPool { } } + +// Host adapters (spec: client-harness-adapters/v0.2) +export * from './adapter-contract.js'; +export * from './adapters/index.js'; + diff --git a/packages/squad-sdk/src/clients/capabilities.ts b/packages/squad-sdk/src/clients/capabilities.ts deleted file mode 100644 index 8c492d66c..000000000 --- a/packages/squad-sdk/src/clients/capabilities.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { ClientCapabilities, ClientId } from './types.js'; - -/** Declared (not probed) host capabilities; keep conservative. */ -export const CAPABILITIES: Record = { - copilot: { supportsSubagents: true, supportsMcpTransport: true, supportsNativeHooks: true, supportsSteeringFiles: true, transportModes: ['mcp-bridge', 'http-backend', 'in-process'] }, - claude: { supportsSubagents: true, supportsMcpTransport: true, supportsNativeHooks: true, supportsSteeringFiles: true, transportModes: ['mcp-bridge', 'http-backend'] }, - kiro: { supportsSubagents: true, supportsMcpTransport: true, supportsNativeHooks: true, supportsSteeringFiles: true, transportModes: ['mcp-bridge', 'http-backend'] }, - opencode: { supportsSubagents: true, supportsMcpTransport: true, supportsNativeHooks: true, supportsSteeringFiles: false, transportModes: ['mcp-bridge', 'http-backend'] }, - cursor: { supportsSubagents: true, supportsMcpTransport: true, supportsNativeHooks: false, supportsSteeringFiles: true, transportModes: ['mcp-bridge', 'http-backend'] }, - codex: { supportsSubagents: true, supportsMcpTransport: true, supportsNativeHooks: true, supportsSteeringFiles: true, transportModes: ['mcp-bridge', 'http-backend'] }, - antigravity: { supportsSubagents: true, supportsMcpTransport: true, supportsNativeHooks: false, supportsSteeringFiles: true, transportModes: ['mcp-bridge', 'http-backend'] },}; - -type CapabilityKey = Exclude; -const FLAGS: CapabilityKey[] = ['supportsSubagents', 'supportsMcpTransport', 'supportsNativeHooks', 'supportsSteeringFiles']; - -/** - * Strict intersection of client declaration, backend grant and policy. - * An omitted key in `backend`/`policy` means "no restriction from that party"; - * a present `false` (or `transportModes` list) restricts. The client's own - * declaration never widens what backend or policy allow. - */ -export function resolveEffectiveCapabilities( - client: ClientCapabilities, - backend: Partial = {}, - policy: Partial = {}, -): ClientCapabilities { - const out = { transportModes: [...client.transportModes] } as ClientCapabilities; - for (const k of FLAGS) { - out[k] = client[k] === true && backend[k] !== false && policy[k] !== false; - } - for (const limit of [backend.transportModes, policy.transportModes]) { - if (limit) out.transportModes = out.transportModes.filter((m) => limit.includes(m)); - } - if (!out.supportsMcpTransport) out.transportModes = out.transportModes.filter((m) => m !== 'mcp-bridge'); - return out; -} diff --git a/packages/squad-sdk/src/clients/index.ts b/packages/squad-sdk/src/clients/index.ts index 44385ccbb..7e03f6f40 100644 --- a/packages/squad-sdk/src/clients/index.ts +++ b/packages/squad-sdk/src/clients/index.ts @@ -1,6 +1,8 @@ -export * from './types.js'; -export * from './coordinator.js'; -export * from './adapters.js'; -export * from './apply.js'; -export * from './capabilities.js'; -export * from './portable.js'; +/** + * @deprecated Canonical home is ../client/adapters (+ ../client/adapter-contract). + * Kept as a re-export of the exact same symbols for @bradygaster/squad-sdk/clients. + * + * @module clients + */ +export * from '../client/adapters/index.js'; + diff --git a/test/clients.test.ts b/test/clients.test.ts index 178197b83..033a6cf5d 100644 --- a/test/clients.test.ts +++ b/test/clients.test.ts @@ -26,6 +26,14 @@ import { syncClients, } from '../packages/squad-cli/src/cli/core/clients.js'; +import * as canonical from '../packages/squad-sdk/src/client/adapters/index.js'; +import * as deprecatedClients from '../packages/squad-sdk/src/clients/index.js'; +import { + AdapterUnboundError, + createClientAdapter, + type RuntimeBinding, +} from '../packages/squad-sdk/src/client/adapter-contract.js'; + let root: string; const mcp = { command: 'squad', args: ['state-mcp'] }; const ctx = (over: Partial = {}): ClientContext => ({ root, mcp, ...over }); @@ -463,3 +471,71 @@ describe('portable clients[] hygiene', () => { expect(JSON.parse(fs.readFileSync(path.join(sq, 'config.json'), 'utf8')).clients).toEqual(['kiro']); }); }); + +describe('HostProjection / ClientAdapter facade (spec v0.2)', () => { + it('deprecated ./clients re-exports the exact same symbols as client/adapters', () => { + for (const k of Object.keys(canonical)) expect((deprecatedClients as Record)[k]).toBe((canonical as Record)[k]); + expect(canonical.CLIENT_ADAPTERS).toBe(canonical.HOST_PROJECTIONS); + expect(canonical.getClientAdapter).toBe(canonical.getHostProjection); + }); + + it('capability tokens are authoritative; boolean flags are a derived view', () => { + for (const id of CLIENT_IDS) { + const tokens = canonical.CAPABILITY_TOKENS_BY_HOST[id]; + expect(canonical.CAPABILITIES[id]).toEqual(canonical.capabilityFlagsFromTokens(tokens, canonical.CAPABILITIES[id].transportModes)); + } + expect(canonical.capabilityTokensFromFlags({ supportsSubagents: true, supportsMcpTransport: false })).toEqual(['subagents']); + }); + + it('rejects conflicting token array and boolean flags', () => { + expect(() => createClientAdapter({ host: 'claude', ctx: ctx(), declaredFlags: { supportsSubagents: false } })).toThrow(/Conflicting capabilities/); + expect(() => createClientAdapter({ host: 'claude', ctx: ctx(), declaredFlags: { supportsSubagents: true } })).not.toThrow(); + }); + + it('describe and projectWorkspace report spec anchors for every host', async () => { + const anchors: Record = { + copilot: ['.github/agents/squad.agent.md', '.mcp.json'], + claude: ['.claude/agents/squad.md', '.mcp.json'], + kiro: ['.kiro/steering/squad.md', '.kiro/settings/mcp.json'], + opencode: ['.opencode/agent/squad.md', 'opencode.json'], + cursor: ['.cursor/agents/squad.md', '.cursor/mcp.json'], + }; + for (const [id, [agent, mcp]] of Object.entries(anchors)) { + const a = createClientAdapter({ host: id as never, ctx: ctx() }); + const ws = await a.projectWorkspace(); + expect(ws.squadAgentPath).toBe(agent); + expect(ws.mcpConfigPath).toBe(mcp); + const d = await a.describe(); + expect(d.capabilities.mcpConfig).toBe(true); + expect(d.projectedFiles).toContain(mcp); + } + const cursorWs = await createClientAdapter({ host: 'cursor', ctx: ctx() }).projectWorkspace(); + expect(cursorWs.supportFiles).toContain('.cursor/rules/squad.mdc'); + expect((await createClientAdapter({ host: 'claude', ctx: ctx() }).projectWorkspace()).supportFiles).toContain('CLAUDE.md'); + expect((await createClientAdapter({ host: 'claude', ctx: ctx() }).describe()).hostKind).toBe('claude-code'); + }); + + it('runtime calls need a bound runtime; with one they delegate and effective capabilities are the intersection', async () => { + const unbound = createClientAdapter({ host: 'opencode', ctx: ctx() }); + await expect(unbound.submit({ conversationId: 'c', prompt: 'p' })).rejects.toBeInstanceOf(AdapterUnboundError); + expect((await unbound.getCapabilities()).effectiveCapabilities).toEqual(canonical.CAPABILITY_TOKENS_BY_HOST.opencode); + + const calls: string[] = []; + const runtime: RuntimeBinding = { + submit: async (i) => (calls.push('submit:' + i.prompt), { admissionId: 'a', runId: 'r' }), + subscribe: async () => async () => undefined, + cancel: async (_c, runId) => void calls.push('cancel:' + runId), + respondApproval: async (_c, r) => void calls.push('approve:' + r.decision), + backendCapabilities: () => ['subagents', 'mcp-bridge', 'approvals'], + policyCapabilities: async () => ['subagents', 'mcp-bridge'], + }; + const a = createClientAdapter({ host: 'opencode', ctx: ctx(), runtime }); + expect(await a.submit({ conversationId: 'c', prompt: 'hi' })).toEqual({ admissionId: 'a', runId: 'r' }); + await a.cancel('c', 'r'); + await a.respondApproval('c', { approvalId: 'x', requestHash: 'h', decision: 'approved' }); + expect(calls).toEqual(['submit:hi', 'cancel:r', 'approve:approved']); + const caps = await a.getCapabilities('c'); + expect(caps.effectiveCapabilities).toEqual(['mcp-bridge', 'subagents']); + expect(caps.clientCapabilities).toContain('native-hooks'); + }); +}); diff --git a/test/squad-20-all-scenarios.e2e.test.ts b/test/squad-20-all-scenarios.e2e.test.ts index a97a31a32..48d8fc730 100644 --- a/test/squad-20-all-scenarios.e2e.test.ts +++ b/test/squad-20-all-scenarios.e2e.test.ts @@ -28,7 +28,6 @@ import { SQUAD_STATE_KEY, assertPortableClients, getClientAdapter, - slugifyMember, buildCoordinatorInstructions, installClients, uninstallClients, @@ -164,7 +163,6 @@ describe('Scenario 1: client projections and --client', () => { const ctx = (over: Partial = {}): ClientContext => ({ root, mcp: { command: 'npx', args: ['-y', '@bradygaster/squad-cli', 'state-mcp'] }, - members: [{ name: 'Data', role: 'Framework Expert' }, { name: "B'Elanna", role: 'Durable Systems' }], ...over, }); beforeEach(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), 'squad20-clients-')); }); @@ -193,12 +191,12 @@ describe('Scenario 1: client projections and --client', () => { it('projects the host-specific files', () => { installClients([...CLIENT_IDS], ctx()); for (const rel of [ - '.claude/agents/squad.md', '.claude/agents/squad-data.md', 'CLAUDE.md', + '.claude/agents/squad.md', 'CLAUDE.md', '.kiro/steering/squad.md', '.kiro/agents/squad.json', '.kiro/settings/mcp.json', '.opencode/agent/squad.md', 'opencode.json', '.cursor/rules/squad.mdc', '.cursor/agents/squad.md', '.cursor/mcp.json', ]) expect(fs.existsSync(path.join(root, rel)), rel).toBe(true); - expect(fs.readFileSync(path.join(root, `.claude/agents/squad-${slugifyMember("B'Elanna")}.md`), 'utf8')).toContain("B'Elanna"); + expect(fs.existsSync(path.join(root, '.claude/agents/squad-data.md'))).toBe(false); }); it('merges shared JSON configs without clobbering user entries', () => { @@ -233,7 +231,7 @@ describe('Scenario 1: client projections and --client', () => { fs.writeFileSync(path.join(root, 'CLAUDE.md'), '# My project\n\nkeep me\n'); installClients(['claude'], ctx()); installClients(['claude'], ctx()); - installClients(['claude'], ctx({ members: [{ name: 'Worf', role: 'Security' }] })); + installClients(['claude'], ctx()); const text = fs.readFileSync(path.join(root, 'CLAUDE.md'), 'utf8'); expect(text.startsWith('# My project\n\nkeep me\n')).toBe(true); expect(text.split(BLOCK_BEGIN)).toHaveLength(2); @@ -736,7 +734,6 @@ describe('RC1→stable exit 1: upgrade and rollback from a v0.1 workspace', () = const ctx = (): ClientContext => ({ root, mcp: { command: 'npx', args: ['-y', '@bradygaster/squad-cli', 'state-mcp'] }, - members: [{ name: 'Data', role: 'Framework Expert' }], }); const readJson = (rel: string) => JSON.parse(fs.readFileSync(path.join(root, rel), 'utf8')); @@ -1198,7 +1195,7 @@ describe('coordinator prompt (found with real models)', () => { // real run: opencode's Glob skips dot-dirs, concluded "no team" and overwrote team.md expect(text).toMatch(/Init Mode \(only when there is truly no team\)/); expect(text).toMatch(/squad_state_read.{0,12} of .{0,3}team\.md/); - expect(text).toMatch(/NOT with glob\/search tools/); + expect(text).toMatch(/(NOT|NEVER) with glob\/(grep\/)?search tools/); expect(text).toMatch(/dot-directories/); expect(text).toMatch(/NEVER overwrite or re-cast/); }); diff --git a/test/squad-20-real-clients-stdio.e2e.test.ts b/test/squad-20-real-clients-stdio.e2e.test.ts index f2cf4c4b1..8b3361e0c 100644 --- a/test/squad-20-real-clients-stdio.e2e.test.ts +++ b/test/squad-20-real-clients-stdio.e2e.test.ts @@ -199,7 +199,6 @@ async function scaffold(client: RealClient): Promise { const ctx: ClientContext = { root: ws, mcp: { command: process.execPath, args: ['--import', pathToFileURL(path.join(loaderDir, 'register.mjs')).href, path.join(loaderDir, 'entry.mjs')] }, - members: [{ name: 'Data', role: 'Framework Expert' }], }; const results = installClients([client], ctx); expect(results.filter(r => r.action === 'error')).toEqual([]); @@ -252,7 +251,7 @@ afterAll(() => { describe('real client projections on disk', () => { it('writes the host config and agent files each client reads', () => { const expected: Record = { - claude: ['.mcp.json', '.claude/agents/squad.md', '.claude/agents/squad-data.md', 'CLAUDE.md'], + claude: ['.mcp.json', '.claude/agents/squad.md', 'CLAUDE.md'], opencode: ['opencode.json', '.opencode/agent/squad.md'], kiro: ['.kiro/settings/mcp.json', '.kiro/agents/squad.json', '.kiro/steering/squad.md'], }; From 06682ae1c4bc65df8a8258f598a93f7533451074 Mon Sep 17 00:00:00 2001 From: Copilot Date: Sun, 18 Oct 2026 19:55:52 +0300 Subject: [PATCH 34/46] docs(demos): interactive Cursor Agent session (cast, build, issues; Ralph blocked by usage limit) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 995eea17-c356-4912-90ea-5e5b144e7ba9 From 6bddfeae5c7c328ce9b92e8d04737bfa71f864ca Mon Sep 17 00:00:00 2001 From: tamirdresher Date: Sun, 18 Oct 2026 20:26:33 +0300 Subject: [PATCH 35/46] chore: exclude local demo screenshots from the upstream PR (kept on the fork branch) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 995eea17-c356-4912-90ea-5e5b144e7ba9 From 51a5a96943598e6f066c0296b6c1eb71b9e6908b Mon Sep 17 00:00:00 2001 From: Copilot Date: Sun, 18 Oct 2026 20:29:55 +0300 Subject: [PATCH 36/46] chore: sync PR branch with squad/2.0 (backend-mcp moved to core/, spec/capability-matrix updates, casting registry repair/backfill, 126-test e2e suite) Tree equals squad/2.0 (e74920c2) minus docs/demos/squad-2.0-clients/ (kept off the upstream PR). Fixes test/cli-command-wiring.test.ts by moving the internal backend-mcp helper to cli/core/. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 995eea17-c356-4912-90ea-5e5b144e7ba9 --- .../client-harness-adapters-v0.2.md | 170 ++++++-- .../squad-cli/src/cli/commands/state-mcp.ts | 2 +- .../src/cli/{commands => core}/backend-mcp.ts | 0 packages/squad-cli/src/cli/core/init.ts | 19 +- packages/squad-cli/src/cli/core/upgrade.ts | 20 +- .../src/casting/handwritten-registry.ts | 261 ++++++++++++ packages/squad-sdk/src/casting/index.ts | 10 + .../src/client/adapters/coordinator.ts | 1 + packages/squad-sdk/src/clients/index.ts | 1 + packages/squad-sdk/src/config/init.ts | 5 +- test/clients-real-cli.test.ts | 9 +- test/squad-20-all-scenarios.e2e.test.ts | 385 +++++++++++++++++- vitest.config.ts | 2 + 13 files changed, 846 insertions(+), 39 deletions(-) rename packages/squad-cli/src/cli/{commands => core}/backend-mcp.ts (100%) create mode 100644 packages/squad-sdk/src/casting/handwritten-registry.ts diff --git a/docs/specification/client-harness-adapters-v0.2.md b/docs/specification/client-harness-adapters-v0.2.md index 563832101..0a6438e34 100644 --- a/docs/specification/client-harness-adapters-v0.2.md +++ b/docs/specification/client-harness-adapters-v0.2.md @@ -12,11 +12,11 @@ **Implementation status:** GitHub Copilot projections exist today, and the repository already carries portable agent, rules, and MCP artifacts, but the -host-specific adapter seam is not yet codified as one SDK surface. +full seven-host projection model is still being locked into one SDK surface. -**Known deviations:** Host discovery, approvals, session spawning, and MCP -config projection vary across Copilot, Claude Code, Kiro, OpenCode, and -Cursor. +**Known deviations:** Host discovery, approvals, session spawning, MCP config +projection, and rollback behavior vary across Copilot, Claude Code, Kiro, +OpenCode, Cursor, Codex, and Antigravity. **Depends on:** `squad-core/v0.1`, `squad-interop/v0.1`, `squad-client-backend-decoupling/v0.2` @@ -45,13 +45,17 @@ Two related adapters project the same portable conversation model: | Adapter | Plane boundary | Responsibility | |---|---|---| -| `ClientAdapter` | client plane to broker plane | translates host turns, approvals, and transcript subscriptions into backend calls | +| `HostProjection` | workspace projection only | writes, updates, and removes host-owned files without runtime or backend dependencies | +| `ClientAdapter` | client plane to broker plane | composes `HostProjection` with runtime bindings and translates host turns, approvals, and transcript subscriptions into backend calls | | `HarnessAdapter` | broker plane to execution plane | translates portable run and task operations into the host's agent execution primitive | -The client adapter is closest to the end-user surface. The harness adapter is -closest to spawned work, subagents, sessions, or durable workers. Some hosts -can implement both in one package, but the interfaces remain separate so local -UI behavior does not leak into execution orchestration. +`HostProjection` is pure workspace projection. It owns file creation, merge, +upgrade, and byte-preserving rollback for host artifacts, and it has zero +runtime or backend dependency. The client adapter is closest to the end-user +surface. The harness adapter is closest to spawned work, subagents, sessions, +or durable workers. Some hosts can implement all three in one package, but the +interfaces remain separate so local UI behavior does not leak into execution +or backend semantics. The client adapter therefore ends at the host capability boundary: auth, session bootstrap, user-visible event rendering, approval affordances, and @@ -70,15 +74,12 @@ export interface AdapterDescription { | 'claude-code' | 'kiro' | 'opencode' - | 'cursor'; + | 'cursor' + | 'codex' + | 'antigravity'; hostVariant?: string; displayName: string; - capabilities: { - approvals: boolean; - backgroundRuns: boolean; - mcpConfig: boolean; - subagents: boolean; - }; + capabilities: string[]; projectedFiles: string[]; } @@ -99,6 +100,22 @@ export interface ProjectWorkspaceProjection { supportFiles: string[]; } +export interface HostProjection { + describe(): Promise<{ + hostKind: + | 'github-copilot' + | 'claude-code' + | 'kiro' + | 'opencode' + | 'cursor' + | 'codex' + | 'antigravity'; + projectedFiles: string[]; + }>; + projectWorkspace(): Promise; + removeProjection(input?: { rollback: 'byte-preserving' | 'managed-only' }): Promise; +} + export interface AdapterSubmission { conversationId: string; prompt: string; @@ -148,6 +165,10 @@ The method set is intentionally identical. `describe` and `projectWorkspace` let the broker reason about host capabilities and file projection without special-casing each client in coordinator logic. +`ClientAdapter` is a composition facade: it combines a pure `HostProjection` +for `describe`, `projectWorkspace`, and `removeProjection` with runtime +bindings to `HarnessAdapter` and `SquadBackend`. + Adapters translate host-native events and commands into canonical envelopes. They do not redefine turn ordering, causation, correlation, cancellation, artifact semantics, resumability, or canonical error classes. @@ -163,6 +184,8 @@ Every host projection publishes the same minimum facts: - the authoritative Squad agent file path for that host; - the authoritative MCP configuration path for that host, if the host supports MCP; +- the companion files the host requires and whether Squad owns them fully or + only through marker blocks; - the host execution primitive used for `submit`; - the workspace root and working directory used for `projectWorkspace`; - whether approvals and background work are supported directly or must be @@ -180,15 +203,25 @@ entries in `.squad/config.json` are for workspace projection and host wiring, not for authority. They exclude credentials, provider thread ids, machine paths, leases, checkpoints, and any other mutable runtime state. +Canonical capability tokens are authoritative. The current baseline tokens are +`approvals`, `background-runs`, `cancel-run`, `cursor-reconnect`, +`mcp-bridge`, `subagents`, and `typed-content-parts`. Any boolean +`ClientCapabilities` flags are derived views only. If an adapter receives both +token lists and boolean flags as conflicting inputs, the projection rejects the +configuration instead of guessing. Advertised adapter capabilities remain +distinct from negotiated `effectiveCapabilities`. + ## 5. Canonical file paths and execution primitives | Host | Agent anchor | MCP config | Support files | `submit` projection | |---|---|---|---|---| | GitHub Copilot | `.github/agents/squad.agent.md` | `.mcp.json` | `.github/copilot-instructions.md` when present | `create_session`, `task`, or `runSubagent` depending on surface | | Claude Code | `.claude/agents/squad.md` | `.mcp.json` | `CLAUDE.md` | Claude `Task` subagent tool | -| Kiro | `.kiro/steering/squad.md` | `.kiro/settings/mcp.json` | Kiro spec and steering hooks | Kiro agent or spec execution hook | +| Kiro | `.kiro/steering/squad.md` | `.kiro/settings/mcp.json` | `.kiro/agents/squad.json`, Kiro spec and steering hooks | Kiro agent or spec execution hook | | OpenCode | `.opencode/agent/squad.md` | `opencode.json` | host agent and MCP definition blocks in `opencode.json` | OpenCode agent execution entry declared in `opencode.json` | | Cursor | `.cursor/agents/squad.md` | `.cursor/mcp.json` | `.cursor/rules/squad.mdc` | Cursor agent dispatch or inline agent run with projected rules | +| Codex | `AGENTS.md` marker block | `.codex/config.toml` | `AGENTS.md` managed marker block | Codex agent execution surface | +| Antigravity | `.agents/agents/squad.md` | None | `GEMINI.md` marker block | Antigravity agent execution surface | These file paths are the portable contract. A host-specific installer may write additional generated files, but it may not move the canonical anchor @@ -204,6 +237,15 @@ from the shared service, `policyCapabilities` come from authorization policy, and `effectiveCapabilities` are the only capabilities a projected client can actually exercise. +Projection updates follow exact ownership rules: + +- fully Squad-owned generated files are upgraded in place and removed by + `removeProjection`; +- marker-block integrations preserve non-Squad bytes outside the managed + region and use byte-preserving rollback when projection is removed; +- merge and upgrade routines operate only on the host's declared companion + files and keep unrelated host configuration untouched. + ## 6. GitHub Copilot projection GitHub Copilot is treated as one host family with three common surfaces: @@ -265,8 +307,13 @@ the projected host files are in sync with the portable Squad contract. - agent anchor: `.kiro/steering/squad.md` - MCP config: `.kiro/settings/mcp.json` -- support files: any Kiro spec hook definitions that import the Squad steering - file +- support files: `.kiro/agents/squad.json` plus any Kiro spec hook + definitions that import the Squad steering file + +`.kiro/agents/squad.json` carries workspace-root `file://.squad/team.md` +resource URIs while its `prompt` path remains relative to `.kiro/agents/`. +That split is part of the canonical projection and must survive upgrade and +rollback unchanged. ### 8.2 `submit` @@ -277,8 +324,9 @@ does not require Kiro to expose the same tool names as Copilot. ### 8.3 `projectWorkspace` The workspace projection always returns `.kiro/steering/squad.md` as the -authoritative Squad steering file and `.kiro/settings/mcp.json` as the MCP -config anchor for state and broker tools. +authoritative Squad steering file, `.kiro/agents/squad.json` as the agent +descriptor, and `.kiro/settings/mcp.json` as the MCP config anchor for state +and broker tools. ## 9. OpenCode projection @@ -323,7 +371,66 @@ submission payload but records `backgroundRuns: false` in `describe`. because it carries the host-specific behavioral glue that accompanies the agent prompt. -## 11. SDK file layout +## 11. Codex projection + +### 11.1 Canonical files + +- agent anchor: `AGENTS.md` managed marker block +- MCP config: `.codex/config.toml` +- support files: `AGENTS.md` only outside the generated TOML entry + +Codex is currently a compatibility-tier projection. Squad owns only its marker +block inside `AGENTS.md`, leaving all other bytes intact. + +### 11.2 `projectWorkspace` + +The projection reports the `AGENTS.md` marker block as the agent anchor and +the `[mcp_servers.squad_state]` table inside `.codex/config.toml` as the MCP +anchor. + +### 11.3 Rollback + +`removeProjection` deletes only the managed marker block in `AGENTS.md` and +the Squad-owned TOML table, preserving all surrounding bytes verbatim. + +## 12. Antigravity projection + +### 12.1 Canonical files + +- agent anchor: `.agents/agents/squad.md` +- MCP config: None +- support files: `GEMINI.md` managed marker block + +Antigravity is currently an experimental-tier projection. Its companion +`GEMINI.md` integration is marker-scoped rather than full-file ownership. + +### 12.2 `projectWorkspace` + +The projection reports `.agents/agents/squad.md` as the prompt anchor and the +`GEMINI.md` marker block as the companion integration surface. + +### 12.3 Rollback + +Rollback removes only Squad-managed bytes and restores the preexisting file +verbatim outside the marker block. + +## 13. Upgrade and rollback rules + +Companion-file ownership is explicit: + +- `.kiro/steering/squad.md`, `.kiro/agents/squad.json`, and + `.kiro/settings/mcp.json` are fully Squad-managed once projected; +- `.claude/worktrees/` integration uses Squad-owned ID-scoped `.gitignore` + marker blocks with `` and + ``; +- `AGENTS.md` and `GEMINI.md` integrations are marker-scoped and therefore use + byte-preserving rollback outside those managed regions. + +Merge and upgrade behavior follows the same rule: full ownership allows +replacement with the current projection version, while marker-scoped ownership +updates only the managed block and keeps surrounding bytes untouched. + +## 14. SDK file layout The reference SDK should keep the portable adapter contracts in a host-neutral module and keep host projections in one sibling folder per host family. @@ -335,6 +442,7 @@ module and keep host projections in one sibling folder per host family. | host projection implementations | `packages/squad-sdk/src/client/adapters/` | | backend implementations | `packages/squad-sdk/src/client/backends/` | | public exports | `packages/squad-sdk/src/client/index.ts` | +| deprecated alias | `@bradygaster/squad-sdk/clients` via `src/clients/index.ts` | This profile fixes the contract locations so backend work, client work, and contract validation can proceed in parallel without rediscovering file layout. @@ -344,24 +452,30 @@ Those files also enforce the architectural boundary: - `backend-contract.ts` owns conversation, participant, turn, task, artifact, lease, cancellation, resumability, observability, and canonical error semantics; -- `adapter-contract.ts` owns host capability description, workspace - projection, and translation hooks only. +- `adapter-contract.ts` owns `HostProjection`, `ClientAdapter`, + `HarnessAdapter`, host capability description, workspace projection, and + translation hooks only; +- `src/clients/index.ts` remains a deprecated re-export alias and must not + become the canonical contract location again. -## 12. Promotion criteria +## 15. Promotion criteria Promote this draft only after the suite has: 1. contract tests that run the same `ClientAdapter` and `HarnessAdapter` assertions against at least GitHub Copilot plus one non-Copilot host; -2. fixtures that verify the canonical file-path projection for all five host +2. fixtures that verify the canonical file-path projection for all seven host families; 3. approval and cancellation traces that demonstrate host-specific UI behavior still round-trips through the same portable response envelope; 4. MCP projection tests that prove `.mcp.json`, `.kiro/settings/mcp.json`, - `opencode.json`, and `.cursor/mcp.json` are emitted with stable semantics; + `opencode.json`, `.cursor/mcp.json`, and `.codex/config.toml` are emitted + with stable semantics; 5. broker-to-harness replay tests showing that the chosen host primitive preserves run identity, transcript ordering, and background capability reporting; 6. golden-trace validation proving that adapters survive disconnect and reconnect without losing unknown events, durable approval versions, or - stable `runId` cancellation targeting. + stable `runId` cancellation targeting; +7. rollback tests proving that marker-scoped integrations restore surrounding + bytes unchanged. diff --git a/packages/squad-cli/src/cli/commands/state-mcp.ts b/packages/squad-cli/src/cli/commands/state-mcp.ts index 10a150487..1c4884965 100644 --- a/packages/squad-cli/src/cli/commands/state-mcp.ts +++ b/packages/squad-cli/src/cli/commands/state-mcp.ts @@ -5,7 +5,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprot import { resolveSquadState } from '@bradygaster/squad-sdk'; import { ToolRegistry } from '@bradygaster/squad-sdk/tools'; import { getPackageVersion } from '../core/version.js'; -import { callBackendMcpTool, createBackendMcpTools, createMcpBackend } from './backend-mcp.js'; +import { callBackendMcpTool, createBackendMcpTools, createMcpBackend } from '../core/backend-mcp.js'; export type JsonRpcRequest = { jsonrpc?: string; diff --git a/packages/squad-cli/src/cli/commands/backend-mcp.ts b/packages/squad-cli/src/cli/core/backend-mcp.ts similarity index 100% rename from packages/squad-cli/src/cli/commands/backend-mcp.ts rename to packages/squad-cli/src/cli/core/backend-mcp.ts diff --git a/packages/squad-cli/src/cli/core/init.ts b/packages/squad-cli/src/cli/core/init.ts index 726e58f00..bc84cd0b8 100644 --- a/packages/squad-cli/src/cli/core/init.ts +++ b/packages/squad-cli/src/cli/core/init.ts @@ -93,6 +93,21 @@ export function detectParentGitRepo(dest: string): string | null { return null; } +/** + * True only when a human can actually answer a prompt: stdin AND stdout are TTYs and the run + * is not CI / explicitly non-interactive. squad init must never block on stdin otherwise + * (headless model runs, pipes, redirected output). + */ +export function isInteractiveSession( + env: NodeJS.ProcessEnv = process.env, + stdin: { isTTY?: boolean } = process.stdin, + stdout: { isTTY?: boolean } = process.stdout, +): boolean { + if (!stdin.isTTY || !stdout.isTTY) return false; + const off = (v: string | undefined): boolean => v !== undefined && v !== '' && v !== '0' && v.toLowerCase() !== 'false'; + return !off(env['CI']) && !off(env['SQUAD_NONINTERACTIVE']); +} + /** True when animations should be suppressed (NO_COLOR, dumb term, non-TTY). */ export function isInitNoColor(): boolean { return ( @@ -227,7 +242,7 @@ export async function runInit(dest: string, options: RunInitOptions = {}): Promi console.log(); let useShared = true; - if (process.stdin.isTTY) { + if (isInteractiveSession()) { const { createInterface } = await import('node:readline/promises'); const rl = createInterface({ input: process.stdin, output: process.stdout }); try { @@ -540,7 +555,7 @@ export async function runInit(dest: string, options: RunInitOptions = {}): Promi // to the team roster, allowing squad-labeled issues to be auto-assigned. const teamContent = readTeamMd(squadDir); if (!hasCopilot(teamContent)) { - if (process.stdin.isTTY) { + if (isInteractiveSession()) { const { createInterface } = await import('node:readline/promises'); const rl = createInterface({ input: process.stdin, output: process.stdout }); try { diff --git a/packages/squad-cli/src/cli/core/upgrade.ts b/packages/squad-cli/src/cli/core/upgrade.ts index c63e1d0fc..8a1a4178f 100644 --- a/packages/squad-cli/src/cli/core/upgrade.ts +++ b/packages/squad-cli/src/cli/core/upgrade.ts @@ -8,7 +8,7 @@ import path from 'node:path'; import { execFileSync } from 'node:child_process'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { FSStorageProvider, stripTeamCapabilitiesBlock, syncTeamCapabilities } from '@bradygaster/squad-sdk'; -import { ensureCastingRegistryPair, preflightCastingRegistryPair } from '@bradygaster/squad-sdk/casting'; +import { backfillCastingRegistryFromTeam, ensureCastingRegistryPair, preflightCastingRegistryPair, repairHandwrittenCastingRegistry } from '@bradygaster/squad-sdk/casting'; import { success, warn, info, dim, bold } from './output.js'; import { fatal } from './errors.js'; import { detectSquadDir } from './detect-squad-dir.js'; @@ -526,6 +526,9 @@ const GITIGNORE_ENTRIES = [ '.squad/sessions/', '.squad/.cache/', '.squad-workstream', + // Claude Code creates embedded-repo worktrees here, and Windows shells can leave a stray reserved `NUL` file + '.claude/worktrees/', + 'NUL', ]; const ENSURE_DIRECTORIES = [ @@ -598,7 +601,8 @@ export function ensureGitignore(dest: string): string[] { const existingLines = content.split('\n'); const added: string[] = []; for (const entry of GITIGNORE_ENTRIES) { - if (content.includes(entry)) continue; + // NUL is short enough to appear inside other words, so match it as a whole line + if (entry === 'NUL' ? existingLines.some((l) => l.trim() === 'NUL') : content.includes(entry)) continue; if (isAlreadyCoveredByParent(entry, existingLines)) continue; added.push(entry); } @@ -653,6 +657,12 @@ export function ensureCastingDefaults(dest: string, templatesDir?: string): stri const created: string[] = []; try { storage.mkdirSync(castingDir, { recursive: true }); + // LLM-handwritten registries (role keys, agents arrays, no schema) are normalized instead of crashing upgrade + const repair = repairHandwrittenCastingRegistry(castingDir); + if (repair.repaired) { + warn('Normalized a handwritten .squad/casting/registry.json (original kept as registry.json.handwritten.bak)'); + created.push(...repair.files.map((f) => `.squad/casting/${f}`)); + } const pair = ensureCastingRegistryPair( castingDir, templateContent( @@ -668,6 +678,12 @@ export function ensureCastingDefaults(dest: string, templatesDir?: string): stri if (pair.created) { created.push('.squad/casting/registry.json', '.squad/casting/history.json'); } + // team.md + charters written directly by a model on an older CLI leave the registry empty: backfill it from the roster + const backfill = backfillCastingRegistryFromTeam(castingDir, path.join(dest, '.squad', 'team.md')); + if (backfill.repaired) { + warn('Backfilled .squad/casting/registry.json from .squad/team.md (previous files kept as *.handwritten.bak)'); + created.push(...backfill.files.map((f) => `.squad/casting/${f}`).filter((f) => !created.includes(f))); + } const policyPath = path.join(castingDir, 'policy.json'); if (!storage.existsSync(policyPath)) { diff --git a/packages/squad-sdk/src/casting/handwritten-registry.ts b/packages/squad-sdk/src/casting/handwritten-registry.ts new file mode 100644 index 000000000..d5469fec6 --- /dev/null +++ b/packages/squad-sdk/src/casting/handwritten-registry.ts @@ -0,0 +1,261 @@ +/** + * Tolerant repair of LLM-handwritten `.squad/casting/registry.json`. + * + * Models doing Init Mode often write the registry in an ad-hoc shape (role keys, + * an `agents` array, `{ name, role, status }` entries, no schema fields). The + * durable registry reader rejects those, which used to crash `squad upgrade`. + * This module normalizes such shapes into the canonical + * `squad-agent-provenance/v1` registry; canonical and valid legacy registries are + * never touched. + * + * @module casting/handwritten-registry + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { readCastingRegistryPair } from './durable-registry.js'; +import { + AGENT_PROVENANCE_SCHEMA, + AGENT_PROVENANCE_VERSION, + parseAgentProvenanceRegistry, + type AgentProvenanceRecord, + type AgentProvenanceRegistry, + type AgentProvenanceStatus, +} from './agent-provenance.js'; + +const META_KEYS = new Set([ + 'schema', 'schema_version', 'version', 'revision', 'generated_at', 'universe', 'created_at', 'updated_at', 'description', '$schema', +]); +const STATUSES: readonly AgentProvenanceStatus[] = ['active', 'inactive', 'retired']; + +const isRecord = (v: unknown): v is Record => !!v && typeof v === 'object' && !Array.isArray(v); +const str = (v: unknown): string | undefined => (typeof v === 'string' && v.trim() ? v.trim() : undefined); +const isTs = (v: unknown): v is string => typeof v === 'string' && v.length > 0 && !Number.isNaN(Date.parse(v)); + +export function toAgentId(value: string): string { + return value + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +interface RawEntry { key?: string; value: unknown } + +function collectEntries(root: Record): RawEntry[] { + const agents = root['agents']; + if (Array.isArray(agents)) return agents.map((value) => ({ value })); + if (isRecord(agents)) return Object.entries(agents).map(([key, value]) => ({ key, value })); + // no wrapper: every non-meta top-level key is an agent (or role) entry + return Object.entries(root) + .filter(([key, value]) => !META_KEYS.has(key) && (isRecord(value) || typeof value === 'string')) + .map(([key, value]) => ({ key, value })); +} + +/** + * Normalize an arbitrary handwritten registry into the canonical shape. + * Returns `undefined` when nothing usable can be recovered. + */ +export function normalizeHandwrittenCastingRegistry(value: unknown, now = new Date().toISOString()): AgentProvenanceRegistry | undefined { + const rootRaw = Array.isArray(value) ? { agents: value } : value; + if (!isRecord(rootRaw)) return undefined; + const firstEntryUniverse = collectEntries(rootRaw).map((e) => (isRecord(e.value) ? str(e.value['universe']) : undefined)).find(Boolean); + const universe = str(rootRaw['universe']) ?? firstEntryUniverse ?? 'unspecified'; + const generatedAt = isTs(rootRaw['generated_at']) ? rootRaw['generated_at'] : now; + + const agents: Record = {}; + const names = new Set(); + for (const { key, value: raw } of collectEntries(rootRaw)) { + let name: string | undefined; + let role: string | undefined; + let rec: Record = {}; + if (typeof raw === 'string') { + // `"lead": "Avery"` (role -> name) or `"avery": "Lead / Architect"` is ambiguous; treat the key as the role + name = str(raw); + role = key; + } else if (isRecord(raw)) { + rec = raw; + name = str(rec['persistent_name']) ?? str(rec['display_name']) ?? str(rec['name']) ?? str(rec['agent']) ?? str(rec['id']); + role = str(rec['role']) ?? str(rec['title']); + // role-keyed entry without an explicit role: the key is the role + if (!role && key && name && toAgentId(key) !== toAgentId(name)) role = key; + name ??= key; + } + if (!name) continue; + const id = toAgentId(str(rec['id']) ?? (key && toAgentId(key) === toAgentId(name) ? key : name)); + if (!id || agents[id] || names.has(name.toLowerCase())) continue; + names.add(name.toLowerCase()); + // A handwritten roster is a single genesis cast: one universe, everyone active, one timestamp. + agents[id] = { + display_name: name, + persistent_name: name, + role: role ?? id, + universe, + status: 'active', + created_at: generatedAt, + updated_at: generatedAt, + }; + } + if (Object.keys(agents).length === 0) return undefined; + return { + schema: AGENT_PROVENANCE_SCHEMA, + schema_version: AGENT_PROVENANCE_VERSION, + revision: 1, + generated_at: generatedAt, + agents, + }; +} + +function isAcceptable(value: unknown): boolean { + if (!isRecord(value)) return false; + if (value['schema'] !== undefined) { + try { return parseAgentProvenanceRegistry(value).completeness === 'complete'; } catch { return false; } + } + // legacy registries are read by the durable reader; only repair what it cannot read + if (!isRecord(value['agents'])) return false; + return Object.keys(value['agents']).length > 0 && Object.values(value['agents']).every((a) => isRecord(a) + && !!str(a['persistent_name']) && isTs(a['created_at']) && !!str(a['universe']) + && (STATUSES as readonly string[]).includes(String(a['status']))); +} + +const COMMIT_MANIFEST = 'registry-history.commit.json'; + +function commitManifestMismatch(castingDir: string): boolean { + try { + readCastingRegistryPair(castingDir); + return false; + } catch (error) { + return /commit manifest/i.test(error instanceof Error ? error.message : String(error)); + } +} + +export interface RepairResult { + repaired: boolean; + /** Relative file names written or created inside the casting directory. */ + files: string[]; + /** Stale files backed up (as `*.handwritten.bak`) and removed, e.g. the commit manifest. */ + removed?: string[]; + reason?: string; +} + +/** + * Repair `registry.json` in place when it is handwritten and unreadable. The + * original is kept as `registry.json.handwritten.bak`; `history.json` is (re)written + * as the matching genesis cast (an existing one is backed up) so the pair is consistent. Already valid + * registries, absent registries and unrecoverable content are left untouched. + */ +export function repairHandwrittenCastingRegistry(castingDir: string, now = new Date().toISOString()): RepairResult { + const registryPath = path.join(castingDir, 'registry.json'); + if (!fs.existsSync(registryPath)) return { repaired: false, files: [] }; + let parsed: unknown; + try { + parsed = JSON.parse(fs.readFileSync(registryPath, 'utf8')); + } catch { + return { repaired: false, files: [], reason: 'registry.json is not valid JSON' }; + } + const manifestPath = path.join(castingDir, COMMIT_MANIFEST); + const hasManifest = fs.existsSync(manifestPath); + // a hand-edited registry that is still well-formed can nevertheless disagree with the checksummed commit manifest + const manifestMismatch = hasManifest && isAcceptable(parsed) && commitManifestMismatch(castingDir); + if (isAcceptable(parsed) && !manifestMismatch) return { repaired: false, files: [] }; + const normalized = normalizeHandwrittenCastingRegistry(parsed, now); + if (!normalized) return { repaired: false, files: [], reason: 'registry.json has no recoverable agents' }; + + return { repaired: true, ...writeGenesisPair(castingDir, normalized) }; +} + +/** + * Write `normalized` as a genesis registry plus the matching history, backing up whatever + * was there. History must describe the same genesis cast or the durable reader rejects + * the pair as mixed-generation; a commit manifest pins old bytes, so it is backed up and removed. + */ +function writeGenesisPair(castingDir: string, normalized: AgentProvenanceRegistry): { files: string[]; removed?: string[] } { + const registryPath = path.join(castingDir, 'registry.json'); + const files: string[] = []; + const removed: string[] = []; + const backup = `${registryPath}.handwritten.bak`; + if (fs.existsSync(registryPath) && !fs.existsSync(backup)) fs.copyFileSync(registryPath, backup); + fs.writeFileSync(registryPath, JSON.stringify(normalized, null, 2) + '\n', 'utf8'); + files.push('registry.json'); + const historyPath = path.join(castingDir, 'history.json'); + if (fs.existsSync(historyPath) && !fs.existsSync(`${historyPath}.handwritten.bak`)) { + fs.copyFileSync(historyPath, `${historyPath}.handwritten.bak`); + } + const firstAgent = Object.values(normalized.agents)[0]!; + const history = { + universe_usage_history: [{ universe: firstAgent.universe, used_at: normalized.generated_at }], + assignment_cast_snapshots: { + 'handwritten-cast-import': { + universe: firstAgent.universe, + created_at: normalized.generated_at, + agents: Object.keys(normalized.agents), + }, + }, + }; + fs.writeFileSync(historyPath, JSON.stringify(history, null, 2) + '\n', 'utf8'); + files.push('history.json'); + const manifestPath = path.join(castingDir, COMMIT_MANIFEST); + if (fs.existsSync(manifestPath)) { + const manifestBackup = `${manifestPath}.handwritten.bak`; + if (!fs.existsSync(manifestBackup)) fs.copyFileSync(manifestPath, manifestBackup); + fs.rmSync(manifestPath); + removed.push(COMMIT_MANIFEST); + } + return { files, ...(removed.length > 0 ? { removed } : {}) }; +} + +// ── backfill from team.md ─────────────────────────────────────────── + +const BUILTIN_NAMES = new Set(['scribe', 'ralph', 'rai', 'fact checker', 'fact-checker', 'squad', 'coordinator', 'copilot', '@copilot']); + +export interface TeamRosterMember { name: string; role: string } + +/** Parse specialist rows from `team.md` (`## Members` table, else the first Name/Role table). */ +export function parseTeamRoster(teamMd: string): { members: TeamRosterMember[]; universe?: string } { + const universe = /universe\**\s*[:|]\s*\**\s*([^\n|*]+)/i.exec(teamMd)?.[1]?.trim(); + const lines = teamMd.split(/\r?\n/); + const membersHeading = lines.findIndex((l) => /^##\s+members\b/i.test(l.trim())); + const scope = membersHeading === -1 + ? lines + : lines.slice(membersHeading + 1, (() => { const n = lines.findIndex((l, i) => i > membersHeading && /^##\s/.test(l)); return n === -1 ? lines.length : n; })()); + const members: TeamRosterMember[] = []; + let nameCol = -1; + let roleCol = -1; + let statusCol = -1; + for (const line of scope) { + if (!line.trim().startsWith('|')) { if (members.length > 0) break; nameCol = roleCol = statusCol = -1; continue; } + const cells = line.trim().replace(/^\||\|$/g, '').split('|').map((x) => x.trim()); + if (nameCol === -1) { + nameCol = cells.findIndex((x) => /^name$/i.test(x)); + roleCol = cells.findIndex((x) => /^role$/i.test(x)); + statusCol = cells.findIndex((x) => /^status$/i.test(x)); + continue; + } + if (roleCol === -1 || cells.every((x) => /^:?-+:?$/.test(x))) continue; + const name = cells[nameCol]?.replace(/[*_`]/g, '').trim(); + const role = cells[roleCol]?.replace(/[*_`]/g, '').trim(); + const status = statusCol === -1 ? '' : (cells[statusCol] ?? ''); + if (!name || !role || BUILTIN_NAMES.has(name.toLowerCase()) || name.startsWith('@') || /human|retired|alumni/i.test(status) || /human/i.test(role)) continue; + members.push({ name, role }); + } + return { members, ...(universe ? { universe } : {}) }; +} + +/** + * Backfill an EMPTY casting registry from the `team.md` roster. Models on older CLIs (no + * `squad cast --name`) write team.md and charters directly and leave `registry.json` at + * `agents: {}`. Registries that already have agents, absent registries and empty rosters + * are left untouched. + */ +export function backfillCastingRegistryFromTeam(castingDir: string, teamMdPath: string, now = new Date().toISOString()): RepairResult { + const registryPath = path.join(castingDir, 'registry.json'); + if (!fs.existsSync(registryPath) || !fs.existsSync(teamMdPath)) return { repaired: false, files: [] }; + let current: unknown; + try { current = JSON.parse(fs.readFileSync(registryPath, 'utf8')); } catch { return { repaired: false, files: [], reason: 'registry.json is not valid JSON' }; } + if (!isRecord(current) || !isRecord(current['agents']) || Object.keys(current['agents']).length > 0) return { repaired: false, files: [] }; + const { members, universe } = parseTeamRoster(fs.readFileSync(teamMdPath, 'utf8')); + const normalized = normalizeHandwrittenCastingRegistry({ universe, agents: members }, now); + if (!normalized) return { repaired: false, files: [], reason: 'team.md has no specialist members' }; + return { repaired: true, ...writeGenesisPair(castingDir, normalized) }; +} diff --git a/packages/squad-sdk/src/casting/index.ts b/packages/squad-sdk/src/casting/index.ts index 93b70b254..0a51cd538 100644 --- a/packages/squad-sdk/src/casting/index.ts +++ b/packages/squad-sdk/src/casting/index.ts @@ -75,6 +75,16 @@ export { type CastingRecoveryPathState, } from './durable-registry.js'; +export { + backfillCastingRegistryFromTeam, + normalizeHandwrittenCastingRegistry, + parseTeamRoster, + repairHandwrittenCastingRegistry, + toAgentId, + type RepairResult, + type TeamRosterMember, +} from './handwritten-registry.js'; + // --- Legacy Types (kept for backward compat) --- export interface CastingUniverse { diff --git a/packages/squad-sdk/src/client/adapters/coordinator.ts b/packages/squad-sdk/src/client/adapters/coordinator.ts index 020e9e434..265584f3e 100644 --- a/packages/squad-sdk/src/client/adapters/coordinator.ts +++ b/packages/squad-sdk/src/client/adapters/coordinator.ts @@ -46,6 +46,7 @@ You are the Squad coordinator. You route work to a team of specialists and keep 20. **Cross-squad delegation.** "Squad" with the lowercase product meaning is a separate peer Squad install (its own \`${S}/\`), not a generic group of agents. Requests to delegate to another squad use the \`cross-squad\` / \`cross-squad-communication\` skills (registry, sync, GitHub issues), never ad-hoc subagent fan-out; if it is ambiguous whether the user means a peer squad or ad-hoc subagents, ask one two-choice question (or, non-interactively, pick ad-hoc subagents and say so). 21. **Human members and the @copilot coding agent.** \`team.md\` may list humans (they are never spawned: route their items as requests/notifications and wait) and an \`@copilot\` Coding Agent member (asynchronous; assigned by GitHub issue assignment, works on \`copilot/*\` branches and draft PRs; never spawn it; review its PRs like any other). 22. **Anti-patterns.** Do not role-play a specialist's output, fabricate results, skip dispatch, give generic task names, or serialize independent work. Never claim a step happened unless a tool result shows it. **Unknown or missing member:** if the user or a plan names a member who is not on the roster, say so explicitly, name the fallback you chose (e.g. "Parker is not on the roster; routing to Lambert, the Tester"), and record the substitution in your report; never silently swap owners. **Built-in members** (Scribe, Ralph, Rai, Fact Checker) always exist even if \`team.md\` was rewritten: spawn them as generic subagents with \`${S}/agents//charter.md\` inlined. When a specialist fails or hangs, report it, retry once with a narrower task, then escalate. Never kill processes by image name (no \`taskkill /IM node.exe\`, \`pkill node\`, \`killall\`): that can terminate your own host or model proxy; stop only a specific PID you started. +23. **Process hygiene.** NEVER kill processes by image or name (\`taskkill /IM\`, \`Stop-Process -Name\`, \`pkill\`, \`killall\`): that can kill your own host or proxy mid-run. Stop only the exact PID you started yourself (capture it when you launch the process), and leave everything else running. `; } diff --git a/packages/squad-sdk/src/clients/index.ts b/packages/squad-sdk/src/clients/index.ts index 7e03f6f40..0eb164985 100644 --- a/packages/squad-sdk/src/clients/index.ts +++ b/packages/squad-sdk/src/clients/index.ts @@ -4,5 +4,6 @@ * * @module clients */ +export * from '../client/adapter-contract.js'; export * from '../client/adapters/index.js'; diff --git a/packages/squad-sdk/src/config/init.ts b/packages/squad-sdk/src/config/init.ts index 97c6da928..cf417b35c 100644 --- a/packages/squad-sdk/src/config/init.ts +++ b/packages/squad-sdk/src/config/init.ts @@ -1417,6 +1417,8 @@ ${projectDescription ? `- **Description:** ${projectDescription}\n` : ''}- **Cre '.squad/sessions/', '.squad/.scratch/', '.squad/.cache/', + '.claude/worktrees/', + 'NUL', ]; let existingIgnore = ''; @@ -1424,7 +1426,8 @@ ${projectDescription ? `- **Description:** ${projectDescription}\n` : ''}- **Cre existingIgnore = storage.readSync(gitignorePath) ?? ''; } - const missingIgnore = ignoreEntries.filter(entry => !existingIgnore.includes(entry)); + const ignoreLines = existingIgnore.split(/\r?\n/).map(l => l.trim()); + const missingIgnore = ignoreEntries.filter(entry => (entry === 'NUL' ? !ignoreLines.includes('NUL') : !existingIgnore.includes(entry))); if (missingIgnore.length > 0) { const block = (existingIgnore && !existingIgnore.endsWith('\n') ? '\n' : '') + '# Squad: ignore runtime state (logs, inbox, sessions)\n' diff --git a/test/clients-real-cli.test.ts b/test/clients-real-cli.test.ts index 3ad690a09..b91231bae 100644 --- a/test/clients-real-cli.test.ts +++ b/test/clients-real-cli.test.ts @@ -31,7 +31,14 @@ beforeAll(() => { }); expect(results.filter((r) => r.action === 'error')).toEqual([]); }); -afterAll(() => fs.rmSync(root, { recursive: true, force: true })); +afterAll(() => { + // real CLIs (e.g. opencode's background server) can still hold the cwd on Windows: retry, then ignore + try { + fs.rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 300 }); + } catch { + // best-effort cleanup of an OS temp dir + } +}); describe('real CLIs load Squad projections', () => { it.skipIf(!has('claude'))('claude discovers the squad subagents and parses .mcp.json', () => { diff --git a/test/squad-20-all-scenarios.e2e.test.ts b/test/squad-20-all-scenarios.e2e.test.ts index 48d8fc730..aa757f5df 100644 --- a/test/squad-20-all-scenarios.e2e.test.ts +++ b/test/squad-20-all-scenarios.e2e.test.ts @@ -3,8 +3,9 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -vi.mock('@bradygaster/squad-sdk', async () => ({ - ...(await import('../packages/squad-sdk/src/backend/index.js')), +// keep the real SDK (squad upgrade code imports much of it); only resolveSquadState is stubbed +vi.mock('@bradygaster/squad-sdk', async (importOriginal) => ({ + ...(await importOriginal>()), resolveSquadState: vi.fn(), })); @@ -38,7 +39,18 @@ import { type ClientContext, type ClientId, } from '../packages/squad-sdk/src/clients/index.js'; -import { callBackendMcpTool, createBackendMcpTools, createMcpBackend } from '../packages/squad-cli/src/cli/commands/backend-mcp.js'; +import { + backfillCastingRegistryFromTeam, + ensureCastingRegistryPair, + normalizeHandwrittenCastingRegistry, + parseTeamRoster, + parseAgentProvenanceRegistry, + readCastingRegistryPair, + repairHandwrittenCastingRegistry, +} from '../packages/squad-sdk/src/casting/index.js'; +import { ensureCastingDefaults, ensureGitignore } from '../packages/squad-cli/src/cli/core/upgrade.js'; +import { isInteractiveSession, runInit } from '../packages/squad-cli/src/cli/core/init.js'; +import { callBackendMcpTool, createBackendMcpTools, createMcpBackend } from '../packages/squad-cli/src/cli/core/backend-mcp.js'; // Clean-room (exit criterion 3) code is typed strictly against the public subpath barrel. import type { BackendEnvelope as CREnvelope, @@ -196,7 +208,9 @@ describe('Scenario 1: client projections and --client', () => { '.opencode/agent/squad.md', 'opencode.json', '.cursor/rules/squad.mdc', '.cursor/agents/squad.md', '.cursor/mcp.json', ]) expect(fs.existsSync(path.join(root, rel)), rel).toBe(true); - expect(fs.existsSync(path.join(root, '.claude/agents/squad-data.md'))).toBe(false); + // members live in the shared .squad/agents//charter.md path (like Copilot): no per-member client files by default + expect(fs.readdirSync(path.join(root, '.claude/agents'))).toEqual(['squad.md']); + expect(fs.existsSync(path.join(root, '.opencode/agent/data.md'))).toBe(false); }); it('merges shared JSON configs without clobbering user entries', () => { @@ -1207,4 +1221,367 @@ describe('coordinator prompt (found with real models)', () => { for (const f of ['team.md', 'routing.md', 'config.json', 'charter.md']) expect(text).toContain(f); expect(text).toMatch(/state tools? reject|reject writes/i); }); +}); +describe('field findings from the live demo: Init Mode, scratch files, casting registry', () => { + it.each(CLIENT_IDS)('%s: non-interactive Init Mode casts immediately and registers the roster with squad cast --name', (id) => { + const text = buildCoordinatorInstructions(id); + // live finding: `claude -p "... proceed ..."` still paused to ask for confirmation + expect(text).toMatch(/Auto-confirm \(default\)/); + expect(text).toMatch(/Do NOT stop to ask questions in Init Mode/); + expect(text).toMatch(/SAME turn/); + expect(text).toMatch(/headless/); + // members are cast into the shared .squad/agents path and registered with the CLI, not projected per client + expect(text).toMatch(/tell the user to upgrade the squad CLI instead of hand-writing the team/); + expect(text).toMatch(/`npx squad cast --name --role/); + }); + + it.each(CLIENT_IDS)('%s: keeps scratch files inside the workspace', (id) => { + const text = buildCoordinatorInstructions(id); + // live finding: OpenCode rejected a subagent call that touched OS %TEMP% + expect(text).toMatch(/Scratch space/); + expect(text).toMatch(/never write outside the workspace directory/); + expect(text).toMatch(/external-directory access/); + }); + + it.each(CLIENT_IDS)('%s: casts through the CLI and forbids hand-written casting files', (id) => { + const text = buildCoordinatorInstructions(id); + // live finding: LLM-handwritten casting/registry.json broke `squad upgrade` + expect(text).toMatch(/npx squad cast --name --role/); + expect(text).toMatch(/NEVER hand-write or edit anything under .{0,20}casting\//); + expect(text).toContain('registry-history.commit.json'); + }); + describe('handwritten casting/registry.json is repaired instead of crashing squad upgrade', () => { + const shapes: Record = { + 'role-keyed entries without a wrapper': { + lead: { name: 'Avery', status: 'active' }, + backend: { name: 'Nova', role: 'Backend Developer' }, + tester: 'Quinn', + }, + 'agents as an array': { universe: 'Star Trek', agents: [{ name: 'Data', role: 'Expert' }, { name: "B'Elanna", role: 'Engineer', status: 'ACTIVE' }] }, + 'simplified object with version and name/role/status': { + version: 1, universe: 'Squad', agents: { avery: { name: 'Avery', role: 'Lead', status: 'active' }, nova: { name: 'Nova', role: 'Dev', status: 'weird' } }, + }, + 'bare array': [{ name: 'Ada', role: 'Core' }, { name: 'Linus', role: 'CLI' }], + }; + let castingDir: string; + beforeEach(() => { castingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'squad20-casting-')); }); + afterEach(() => { fs.rmSync(castingDir, { recursive: true, force: true }); }); + const write = (name: string, body: unknown) => + fs.writeFileSync(path.join(castingDir, name), typeof body === 'string' ? body : JSON.stringify(body, null, 2)); + + it.each(Object.keys(shapes))('normalizes: %s', (name) => { + const reg = normalizeHandwrittenCastingRegistry(shapes[name], '2026-10-18T00:00:00.000Z')!; + expect(reg).toBeDefined(); + const parsed = parseAgentProvenanceRegistry(reg); + expect(parsed.completeness).toBe('complete'); + for (const [id, rec] of Object.entries(reg.agents)) { + expect(id).toMatch(/^[a-z0-9]+(?:-[a-z0-9]+)*$/); + expect(rec.persistent_name).toBe(rec.display_name); + expect(['active', 'inactive', 'retired']).toContain(rec.status); + } + }); + + it('maps role keys and names to canonical ids, roles and statuses', () => { + const reg = normalizeHandwrittenCastingRegistry(shapes['role-keyed entries without a wrapper'])!; + expect(Object.keys(reg.agents).sort()).toEqual(['avery', 'nova', 'quinn']); + expect(reg.agents['avery']).toMatchObject({ display_name: 'Avery', role: 'lead', status: 'active' }); + expect(reg.agents['nova']).toMatchObject({ role: 'Backend Developer' }); + expect(reg.agents['quinn']).toMatchObject({ display_name: 'Quinn', role: 'tester' }); + const belanna = normalizeHandwrittenCastingRegistry(shapes['agents as an array'])!; + expect(Object.keys(belanna.agents)).toEqual(['data', 'b-elanna']); + expect(belanna.agents['b-elanna']!.status).toBe('active'); + expect(normalizeHandwrittenCastingRegistry(shapes['simplified object with version and name/role/status'])!.agents['nova']!.status).toBe('active'); + }); + + it.each(Object.keys(shapes))('repairs on disk, keeps a backup, creates history and is then readable: %s', (name) => { + write('registry.json', shapes[name]); + const original = fs.readFileSync(path.join(castingDir, 'registry.json'), 'utf8'); + const result = repairHandwrittenCastingRegistry(castingDir); + expect(result).toMatchObject({ repaired: true, files: ['registry.json', 'history.json'] }); + expect(fs.readFileSync(path.join(castingDir, 'registry.json.handwritten.bak'), 'utf8')).toBe(original); + const pair = readCastingRegistryPair(castingDir); + expect(Object.keys(pair.registry.agents).length).toBeGreaterThan(0); + expect(repairHandwrittenCastingRegistry(castingDir)).toEqual({ repaired: false, files: [] }); + }); + + it('does not touch canonical registries, absent registries or unrecoverable content', () => { + expect(repairHandwrittenCastingRegistry(castingDir)).toEqual({ repaired: false, files: [] }); + const canonical = normalizeHandwrittenCastingRegistry(shapes['bare array'])!; + write('registry.json', canonical); + write('history.json', { universe_usage_history: [], assignment_cast_snapshots: {} }); + const before = fs.readFileSync(path.join(castingDir, 'registry.json'), 'utf8'); + expect(repairHandwrittenCastingRegistry(castingDir).repaired).toBe(false); + expect(fs.readFileSync(path.join(castingDir, 'registry.json'), 'utf8')).toBe(before); + + write('registry.json', '{ not json'); + expect(repairHandwrittenCastingRegistry(castingDir)).toMatchObject({ repaired: false, reason: expect.stringMatching(/not valid JSON/) }); + write('registry.json', { agents: {}, note: 'empty' }); + expect(repairHandwrittenCastingRegistry(castingDir)).toMatchObject({ repaired: false, reason: expect.stringMatching(/no recoverable agents/) }); + expect(fs.existsSync(path.join(castingDir, 'registry.json.handwritten.bak'))).toBe(false); + }); + + describe('after `squad init` wrote a registry-history.commit.json', () => { + const MANIFEST = 'registry-history.commit.json'; + const initPair = () => { + const reg = JSON.stringify({ schema: 'squad-agent-provenance/v1', schema_version: 1, revision: 1, generated_at: '1970-01-01T00:00:00.000Z', agents: {} }); + const hist = JSON.stringify({ universe_usage_history: [], assignment_cast_snapshots: {} }); + ensureCastingRegistryPair(castingDir, reg, hist, 'test init'); + expect(fs.existsSync(path.join(castingDir, MANIFEST))).toBe(true); + expect(() => readCastingRegistryPair(castingDir)).not.toThrow(); + }; + + it('reproduces the original failure: a hand-edited registry makes the pair unreadable', () => { + initPair(); + write('registry.json', shapes['role-keyed entries without a wrapper']); + expect(() => readCastingRegistryPair(castingDir)).toThrow(/consistent casting registry\/history pair/); + }); + + it('repair backs up and removes the stale manifest so the pair reads again', () => { + initPair(); + write('registry.json', shapes['role-keyed entries without a wrapper']); + const manifestBytes = fs.readFileSync(path.join(castingDir, MANIFEST), 'utf8'); + const result = repairHandwrittenCastingRegistry(castingDir); + expect(result).toMatchObject({ repaired: true, removed: [MANIFEST] }); + expect(fs.existsSync(path.join(castingDir, MANIFEST))).toBe(false); + expect(fs.readFileSync(path.join(castingDir, `${MANIFEST}.handwritten.bak`), 'utf8')).toBe(manifestBytes); + expect(Object.keys(readCastingRegistryPair(castingDir).registry.agents).sort()).toEqual(['avery', 'nova', 'quinn']); + expect(repairHandwrittenCastingRegistry(castingDir)).toEqual({ repaired: false, files: [] }); + }); + + it('also repairs a well-formed canonical edit that no longer matches the manifest, keeping its agents', () => { + initPair(); + const edited = normalizeHandwrittenCastingRegistry({ agents: [{ name: 'Ada', role: 'Core' }, { name: 'Linus', role: 'CLI' }] })!; + write('registry.json', edited); + expect(() => readCastingRegistryPair(castingDir)).toThrow(/commit manifest/); + expect(repairHandwrittenCastingRegistry(castingDir)).toMatchObject({ repaired: true, removed: [MANIFEST] }); + expect(Object.keys(readCastingRegistryPair(castingDir).registry.agents).sort()).toEqual(['ada', 'linus']); + }); + + it('leaves an untouched init pair and its manifest alone', () => { + initPair(); + const before = fs.readFileSync(path.join(castingDir, MANIFEST), 'utf8'); + expect(repairHandwrittenCastingRegistry(castingDir)).toEqual({ repaired: false, files: [] }); + expect(fs.readFileSync(path.join(castingDir, MANIFEST), 'utf8')).toBe(before); + }); + + it('squad upgrade (ensureCastingDefaults) survives a stale manifest', () => { + const dest = fs.mkdtempSync(path.join(os.tmpdir(), 'squad20-upgrade-manifest-')); + try { + const dir = path.join(dest, '.squad', 'casting'); + fs.mkdirSync(dir, { recursive: true }); + const reg = JSON.stringify({ schema: 'squad-agent-provenance/v1', schema_version: 1, revision: 1, generated_at: '1970-01-01T00:00:00.000Z', agents: {} }); + ensureCastingRegistryPair(dir, reg, JSON.stringify({ universe_usage_history: [], assignment_cast_snapshots: {} }), 'test init'); + fs.writeFileSync(path.join(dir, 'registry.json'), JSON.stringify(shapes['agents as an array'])); + const created = ensureCastingDefaults(dest); + expect(created).toEqual(expect.arrayContaining(['.squad/casting/registry.json'])); + expect(Object.keys(readCastingRegistryPair(dir).registry.agents).sort()).toEqual(['b-elanna', 'data']); + } finally { + fs.rmSync(dest, { recursive: true, force: true }); + } + }); + }); + + it('squad upgrade (ensureCastingDefaults) no longer crashes on a handwritten registry', () => { + const dest = fs.mkdtempSync(path.join(os.tmpdir(), 'squad20-upgrade-casting-')); + try { + const dir = path.join(dest, '.squad', 'casting'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'registry.json'), JSON.stringify(shapes['role-keyed entries without a wrapper'])); + const created = ensureCastingDefaults(dest); + expect(created).toEqual(expect.arrayContaining(['.squad/casting/registry.json', '.squad/casting/history.json'])); + expect(Object.keys(readCastingRegistryPair(dir).registry.agents).sort()).toEqual(['avery', 'nova', 'quinn']); + expect(fs.existsSync(path.join(dir, 'policy.json'))).toBe(true); + expect(ensureCastingDefaults(dest)).toEqual([]); + } finally { + fs.rmSync(dest, { recursive: true, force: true }); + } + }); + }); +}); +describe('field findings from the multi-client demos: process hygiene, .gitignore, non-TTY init, registry backfill', () => { + it.each(CLIENT_IDS)('%s: forbids killing processes by image name', (id) => { + const text = buildCoordinatorInstructions(id); + // live finding: a subagent ran `taskkill /IM node.exe` and killed its own proxy mid-run + expect(text).toMatch(/Process hygiene/); + for (const cmd of ['taskkill /IM', 'Stop-Process -Name', 'pkill', 'killall']) expect(text, cmd).toContain(cmd); + expect(text).toMatch(/NEVER kill processes by image or name/); + expect(text).toMatch(/exact PID you started yourself/); + }); + + describe('.gitignore scaffolding', () => { + let dest: string; + beforeEach(() => { dest = fs.mkdtempSync(path.join(os.tmpdir(), 'squad20-gitignore-')); }); + afterEach(() => { fs.rmSync(dest, { recursive: true, force: true }); }); + const lines = () => fs.readFileSync(path.join(dest, '.gitignore'), 'utf8').split(/\r?\n/); + + it('adds .claude/worktrees/ and NUL on upgrade, idempotently', () => { + const added = ensureGitignore(dest); + expect(added).toEqual(expect.arrayContaining(['.claude/worktrees/', 'NUL'])); + expect(lines()).toEqual(expect.arrayContaining(['.claude/worktrees/', 'NUL'])); + expect(ensureGitignore(dest)).toEqual([]); + expect(lines().filter(l => l === 'NUL')).toHaveLength(1); + }); + + it('matches NUL as a whole line only and respects existing entries / parent coverage', () => { + fs.writeFileSync(path.join(dest, '.gitignore'), 'NULLABLE.md\nnode_modules/\n'); + expect(ensureGitignore(dest)).toContain('NUL'); + expect(lines()).toEqual(expect.arrayContaining(['NULLABLE.md', 'NUL'])); + + const dest2 = fs.mkdtempSync(path.join(os.tmpdir(), 'squad20-gitignore2-')); + try { + fs.writeFileSync(path.join(dest2, '.gitignore'), 'NUL\n.claude/\n'); + const added = ensureGitignore(dest2); + expect(added).not.toContain('NUL'); + expect(added).not.toContain('.claude/worktrees/'); // covered by the .claude/ parent + } finally { + fs.rmSync(dest2, { recursive: true, force: true }); + } + }); + }); + + describe('squad init never blocks on stdin when non-interactive', () => { + const tty = { isTTY: true }; + const pipe = { isTTY: undefined }; + it('isInteractiveSession needs a TTY on stdin and stdout and no CI / opt-out', () => { + expect(isInteractiveSession({}, tty, tty)).toBe(true); + expect(isInteractiveSession({}, pipe, tty)).toBe(false); + expect(isInteractiveSession({}, tty, pipe)).toBe(false); + expect(isInteractiveSession({}, pipe, pipe)).toBe(false); + expect(isInteractiveSession({ CI: 'true' }, tty, tty)).toBe(false); + expect(isInteractiveSession({ CI: '1' }, tty, tty)).toBe(false); + expect(isInteractiveSession({ CI: '0' }, tty, tty)).toBe(true); + expect(isInteractiveSession({ CI: 'false' }, tty, tty)).toBe(true); + expect(isInteractiveSession({ SQUAD_NONINTERACTIVE: '1' }, tty, tty)).toBe(false); + }); + + it('runInit completes without stdin (non-TTY) and scaffolds .gitignore entries', async () => { + const dest = fs.mkdtempSync(path.join(os.tmpdir(), 'squad20-init-')); + const savedStdin = process.stdin.isTTY; + try { + (process.stdin as { isTTY?: boolean }).isTTY = undefined; + const quiet = vi.spyOn(console, 'log').mockImplementation(() => {}); + const write = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + try { + await Promise.race([ + runInit(dest, { includeWorkflows: false } as never), + new Promise((_, reject) => setTimeout(() => reject(new Error('runInit blocked waiting for stdin')), 60_000)), + ]); + } finally { + quiet.mockRestore(); + write.mockRestore(); + } + expect(fs.existsSync(path.join(dest, '.squad'))).toBe(true); + const ignore = fs.readFileSync(path.join(dest, '.gitignore'), 'utf8').split(/\r?\n/); + expect(ignore).toEqual(expect.arrayContaining(['.claude/worktrees/', 'NUL'])); + } finally { + (process.stdin as { isTTY?: boolean }).isTTY = savedStdin; + fs.rmSync(dest, { recursive: true, force: true }); + } + }, 90_000); + }); + + describe('casting registry backfill from team.md (older CLI without `squad cast --name`)', () => { + const TEAM = [ + '# Squad Team', '', '**Universe:** Star Trek', '', + '## Coordinator', '| Name | Role |', '|---|---|', '| Squad | Coordinator |', '', + '## Members', '', + '| Name | Role | Charter | Status |', '|------|------|---------|--------|', + "| Data | Framework Expert | .squad/agents/data/charter.md | ✅ Active |", + "| B'Elanna | Durable Systems | .squad/agents/belanna/charter.md | ✅ Active |", + '| Pat | Human reviewer | - | human |', '', + '## Built-in Support Agents', '| Name | Role |', '|---|---|', '| Scribe | Session Logger |', '| Ralph | Work Monitor |', '', + ].join('\n'); + let dest: string; + beforeEach(() => { dest = fs.mkdtempSync(path.join(os.tmpdir(), 'squad20-backfill-')); fs.mkdirSync(path.join(dest, '.squad'), { recursive: true }); }); + afterEach(() => { fs.rmSync(dest, { recursive: true, force: true }); }); + + it('parses only routable specialists (no coordinator, built-ins, humans) and the universe', () => { + const { members, universe } = parseTeamRoster(TEAM); + expect(members).toEqual([{ name: 'Data', role: 'Framework Expert' }, { name: "B'Elanna", role: 'Durable Systems' }]); + expect(universe).toBe('Star Trek'); + expect(parseTeamRoster('| Name | Role |\n|---|---|\n| Ada | Core |\n').members).toEqual([{ name: 'Ada', role: 'Core' }]); + expect(parseTeamRoster('no table here').members).toEqual([]); + }); + + it('squad upgrade backfills an empty registry from team.md and the pair is readable', () => { + fs.writeFileSync(path.join(dest, '.squad', 'team.md'), TEAM); + const created = ensureCastingDefaults(dest); + expect(created).toEqual(expect.arrayContaining(['.squad/casting/registry.json', '.squad/casting/history.json'])); + const dir = path.join(dest, '.squad', 'casting'); + const pair = readCastingRegistryPair(dir); + expect(Object.keys(pair.registry.agents).sort()).toEqual(['b-elanna', 'data']); + expect(pair.registry.agents['data']).toMatchObject({ display_name: 'Data', role: 'Framework Expert', universe: 'Star Trek', status: 'active' }); + expect(fs.existsSync(path.join(dir, 'registry-history.commit.json'))).toBe(false); + expect(fs.existsSync(path.join(dir, 'registry.json.handwritten.bak'))).toBe(true); + // a second upgrade creates nothing, keeps the same roster, and never backfills again + expect(ensureCastingDefaults(dest)).toEqual([]); + const again = readCastingRegistryPair(dir); + expect(Object.keys(again.registry.agents).sort()).toEqual(['b-elanna', 'data']); + expect(backfillCastingRegistryFromTeam(dir, path.join(dest, '.squad', 'team.md'))).toEqual({ repaired: false, files: [] }); + }); + + it('leaves populated registries, missing team.md and empty rosters alone', () => { + const dir = path.join(dest, '.squad', 'casting'); + fs.mkdirSync(dir, { recursive: true }); + expect(backfillCastingRegistryFromTeam(dir, path.join(dest, '.squad', 'team.md'))).toEqual({ repaired: false, files: [] }); + ensureCastingDefaults(dest); // empty registry, no team.md + expect(backfillCastingRegistryFromTeam(dir, path.join(dest, '.squad', 'team.md'))).toEqual({ repaired: false, files: [] }); + fs.writeFileSync(path.join(dest, '.squad', 'team.md'), '# Team\n\n## Members\n\n| Name | Role |\n|---|---|\n| Scribe | Logger |\n'); + expect(backfillCastingRegistryFromTeam(dir, path.join(dest, '.squad', 'team.md'))).toMatchObject({ repaired: false, reason: expect.stringMatching(/no specialist members/) }); + + // a registry that already has agents is never overwritten + fs.writeFileSync(path.join(dest, '.squad', 'team.md'), TEAM); + const populated = normalizeHandwrittenCastingRegistry({ agents: [{ name: 'Ada', role: 'Core' }] })!; + fs.writeFileSync(path.join(dir, 'registry.json'), JSON.stringify(populated)); + fs.rmSync(path.join(dir, 'registry-history.commit.json'), { force: true }); + expect(backfillCastingRegistryFromTeam(dir, path.join(dest, '.squad', 'team.md'))).toEqual({ repaired: false, files: [] }); + expect(Object.keys(JSON.parse(fs.readFileSync(path.join(dir, 'registry.json'), 'utf8')).agents)).toEqual(['ada']); + }); + }); +}); +describe('claude .gitignore marker block is projected and rolled back', () => { + let root: string; + beforeEach(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), 'squad20-claude-ignore-')); }); + afterEach(() => { fs.rmSync(root, { recursive: true, force: true }); }); + const ctx = (): ClientContext => ({ root, mcp: { command: 'node', args: ['-e', '0'] }, members: [{ name: 'Data', role: 'Expert' }] }); + + it('adds .claude/worktrees/ inside an id-scoped block, keeps user lines, and removal restores the file exactly', () => { + fs.writeFileSync(path.join(root, '.gitignore'), 'node_modules/\ndist/\n'); + installClients(['claude'], ctx()); + const withBlock = fs.readFileSync(path.join(root, '.gitignore'), 'utf8'); + expect(withBlock.startsWith('node_modules/\ndist/\n')).toBe(true); + expect(withBlock).toContain('.claude/worktrees/'); + expect(withBlock).toMatch(/squad:begin:claude/); + expect(installClients(['claude'], ctx()).every(r => r.action === 'unchanged')).toBe(true); + + uninstallClients(['claude'], ctx()); + expect(fs.readFileSync(path.join(root, '.gitignore'), 'utf8')).toBe('node_modules/\ndist/\n'); + }); + + it('deletes a .gitignore that Squad created itself', () => { + installClients(['claude'], ctx()); + expect(fs.existsSync(path.join(root, '.gitignore'))).toBe(true); + uninstallClients(['claude'], ctx()); + expect(fs.existsSync(path.join(root, '.gitignore'))).toBe(false); + }); +}); +describe('client adapter contract is exposed canonically and through the deprecated /clients alias', () => { + it('@bradygaster/squad-sdk/client and /clients export the same adapter symbols', async () => { + const canonical = await import('@bradygaster/squad-sdk/client'); + const alias = await import('@bradygaster/squad-sdk/clients'); + for (const name of ['createClientAdapter', 'AdapterUnboundError', 'CAPABILITY_TOKENS_BY_HOST', 'capabilityFlagsFromTokens', 'resolveEffectiveCapabilityTokens', + 'getClientAdapter', 'installClients', 'CLIENT_IDS']) { + expect(canonical, name).toHaveProperty(name); + expect((alias as Record)[name], name).toBe((canonical as Record)[name]); + } + }); + + it('every host declares tokens that map onto the capability flags consistently', async () => { + const { CAPABILITY_TOKENS_BY_HOST, capabilityFlagsFromTokens } = await import('@bradygaster/squad-sdk/client'); + expect(Object.keys(CAPABILITY_TOKENS_BY_HOST).sort()).toEqual([...CLIENT_IDS].sort()); + for (const id of CLIENT_IDS) { + const flags = capabilityFlagsFromTokens(CAPABILITY_TOKENS_BY_HOST[id], getClientAdapter(id).capabilities.transportModes); + expect(flags.supportsSubagents, id).toBe(getClientAdapter(id).capabilities.supportsSubagents); + } + }); }); \ No newline at end of file diff --git a/vitest.config.ts b/vitest.config.ts index 59976fcb6..6150844b6 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,7 +7,9 @@ export default defineConfig({ resolve: { // Resolve the Squad 2.0 SDK entrypoints from source so tests run without a built dist. alias: [ + { find: /^@bradygaster\/squad-sdk\/client$/, replacement: path.join(sdkSrc, 'client/index.ts') }, { find: /^@bradygaster\/squad-sdk\/clients$/, replacement: path.join(sdkSrc, 'clients/index.ts') }, + { find: /^@bradygaster\/squad-sdk\/casting$/, replacement: path.join(sdkSrc, 'casting/index.ts') }, { find: /^@bradygaster\/squad-sdk\/backend$/, replacement: path.join(sdkSrc, 'backend/index.ts') }, { find: /^@bradygaster\/squad-sdk$/, replacement: path.join(sdkSrc, 'index.ts') }, ], From 6d095b91f89cf10a949dcfa54fcc5484054cee64 Mon Sep 17 00:00:00 2001 From: Copilot Date: Sun, 18 Oct 2026 20:37:40 +0300 Subject: [PATCH 37/46] fix(ci): lint/changeset/spellcheck + docs for multi-host clients - move squad-20-security-contracts test under test/ so eslint's project service finds it - add changeset (Policy Gates: changelog) and cspell words (kiro, affordances, Bubu, resumability) - docs: --client in README, CLI/config/SDK references, scenarios page (Codex, Antigravity, troubleshooting, no per-member files), quickstart tutorials, CONTRIBUTING adapter checklist Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 995eea17-c356-4912-90ea-5e5b144e7ba9 --- .changeset/squad-2-0-multi-host-clients.md | 6 ++++ CONTRIBUTING.md | 11 +++++++ README.md | 15 +++++++-- cspell.json | 4 +++ docs/src/content/docs/reference/cli.md | 2 ++ docs/src/content/docs/reference/config.md | 2 ++ docs/src/content/docs/reference/sdk.md | 9 +++++ .../scenarios/using-squad-across-clients.md | 33 +++++++++++++++++-- docs/tutorials/multi-client-quickstart.md | 28 +++++++++++++--- .../squad-20-security-contracts.test.ts | 0 10 files changed, 101 insertions(+), 9 deletions(-) create mode 100644 .changeset/squad-2-0-multi-host-clients.md rename {packages/squad-sdk/test => test}/squad-20-security-contracts.test.ts (100%) diff --git a/.changeset/squad-2-0-multi-host-clients.md b/.changeset/squad-2-0-multi-host-clients.md new file mode 100644 index 000000000..b4cd01314 --- /dev/null +++ b/.changeset/squad-2-0-multi-host-clients.md @@ -0,0 +1,6 @@ +--- +"@bradygaster/squad-sdk": minor +"@bradygaster/squad-cli": minor +--- + +Squad 2.0: decoupled backend and multi-host client adapters. Add `squad init --client` and `squad upgrade --client` (copilot, claude, kiro, opencode, cursor, codex, antigravity) with additive, non-clobbering config merges, persisted in `.squad/config.json` `clients[]`; add the `HostProjection` / spec `ClientAdapter` SDK surface with canonical capability tokens under `@bradygaster/squad-sdk/client` (and the deprecated `/clients` alias), and a host-agnostic coordinator prompt with per-host dispatch. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 70d964960..53ebc9ea5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -121,6 +121,17 @@ npm run test:watch npm run lint ``` +### Adding or changing a client host adapter + +Host adapters live in `packages/squad-sdk/src/client/adapters/` (the `squad-sdk/clients` export is a deprecated alias for the same symbols). + +1. Add the host id to `CLIENT_IDS` and a `HostProjection` in `adapters.ts`: it returns declarative `ProjectionFile`s (`managed`, `json`, `block`, `text`) and never owns routing, durable state or authorization. +2. Declare its capability tokens in `capabilities.ts` (tokens are authoritative; boolean flags are derived) and add its anchor/MCP paths to `client/adapter-contract.ts`. +3. Add its dispatch section in `coordinator.ts` (how the host spawns a specialist) and keep the shared prompt host-agnostic. +4. Merge shared config (JSON/TOML) additively: only Squad's own keys, preserve everything else, refuse malformed input. +5. Add unit tests in `test/clients.test.ts` and, if the host has a real CLI, a skipped-when-absent check in `test/clients-real-cli.test.ts`. +6. Update `docs/src/content/docs/scenarios/using-squad-across-clients.md`, `docs/tutorials/multi-client-quickstart.md`, the CLI reference and add a changeset. + ### 5. Keeping Your Fork in Sync Before opening or updating a PR, rebase your branch on the latest upstream dev: diff --git a/README.md b/README.md index a564a4f24..f2dd98deb 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,17 @@ squad upgrade Use `--force` to re-apply updates even when your installed version already matches the latest. +### Use Squad in other AI hosts + +Squad is not tied to one client. Add `--client` to project the same team and the `squad_state` backend into Claude Code, Kiro, OpenCode, Cursor, OpenAI Codex or Google Antigravity (alongside Copilot): + +```bash +squad init --client claude,opencode # or: --client all +squad upgrade --client codex # add a host later; refreshes every configured host +``` + +Each host gets a native coordinator file plus MCP wiring; your own settings in `.mcp.json`, `opencode.json` and the like are never clobbered. See [Using Squad across clients](docs/src/content/docs/scenarios/using-squad-across-clients.md) and the [multi-client quickstart](docs/tutorials/multi-client-quickstart.md). + --- ## Local Development Installation @@ -163,8 +174,8 @@ Say **"squad commands"** in chat to see a categorized menu of common operations | Command | What it does | |---------|-------------| -| `squad init` | **Init** — scaffold Squad in the current directory (idempotent — safe to run multiple times); alias: `cast`; use `--global` to init in personal squad directory, `--mode remote ` for dual-root mode | -| `squad upgrade` | Update Squad-owned files to latest; never touches your team state; use `--global` to upgrade personal squad, `--migrate-directory` to rename `.ai-team/` → `.squad/` | +| `squad init` | **Init** — scaffold Squad in the current directory (idempotent — safe to run multiple times); alias: `cast`; `--client ` also projects Claude Code, Kiro, OpenCode, Cursor, Codex and Antigravity; use `--global` to init in personal squad directory, `--mode remote ` for dual-root mode | +| `squad upgrade` | Update Squad-owned files to latest; never touches your team state; `--client ` adds hosts and refreshes every configured projection; use `--global` to upgrade personal squad, `--migrate-directory` to rename `.ai-team/` → `.squad/` | | `squad upgrade --self` | Update the Squad CLI package itself; add `--insider` for dev-channel prerelease builds | | `squad update-check` | Report cached CLI update status for tooling/CI; use `--json` for structured output, `--refresh` to bypass the cache | | `squad status` | Show which squad is active and why | diff --git a/cspell.json b/cspell.json index fa0e1d2bc..8e6236949 100644 --- a/cspell.json +++ b/cspell.json @@ -17,6 +17,10 @@ "aadnesd", "ADO", "AKIA", + "affordances", + "kiro", + "Bubu", + "resumability", "amolchanov", "anthropics", "Artsdalen", diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index 306d60a75..f2423922a 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -30,6 +30,7 @@ prerequisites, all supported methods, and update commands. | `squad` | **Deprecated** — Enter interactive shell (no args). Use `copilot --agent squad` instead. | No | | `squad init` | Initialize Squad in the current repo (idempotent — safe to run multiple times) | No | | `squad init --state-backend ` | Initialize with a specific state backend (`local`, `orphan`, `two-layer`) | No | +| `squad init --client ` | Also project Squad into other AI hosts: comma-separated `copilot`, `claude`, `kiro`, `opencode`, `cursor`, `codex`, `antigravity`, or `all`; persisted in `.squad/config.json` `clients[]` | No | | `squad init --global` | Create a personal squad in your platform-specific directory | No | | `squad init --no-vscode-default` | Skip writing `chat.newSession.defaultMode` to `.vscode/settings.json` | No | | `squad init --mode remote ` | Initialize linked to a remote team root (dual-root mode) | No | @@ -41,6 +42,7 @@ prerequisites, all supported methods, and update commands. | `squad status` | Show which squad is active and why | Yes | | `squad doctor` | Validate squad setup integrity and diagnose issues (alias: `heartbeat`) | Yes | | `squad upgrade` | Upgrade Squad-owned files to latest version; locally customized files are saved as `.local-backup` | Yes | +| `squad upgrade --client ` | Add hosts to the configured set and refresh every configured host projection; user settings in `.mcp.json`, `.kiro/settings/mcp.json`, `opencode.json`, `.cursor/mcp.json` and `.codex/config.toml` are preserved | Yes | | `squad upgrade --state-backend ` | Migrate state backend (`orphan`, `two-layer`); installs git hooks automatically | Yes | | `squad upgrade --migrate-directory` | Rename legacy `.ai-team/` directory to `.squad/` | Yes | | `squad triage` | Auto-triage issues and assign to team (primary name; `watch` is an alias) | Yes | diff --git a/docs/src/content/docs/reference/config.md b/docs/src/content/docs/reference/config.md index 61edf51e9..dcdd63440 100644 --- a/docs/src/content/docs/reference/config.md +++ b/docs/src/content/docs/reference/config.md @@ -78,6 +78,8 @@ Each builder (`defineSquad()`, `defineTeam()`, `defineAgent()`, etc.) validates └── orchestration-log/ # Coordinator state ``` +`.squad/config.json` may also carry `clients`: the portable list of host projections to keep fresh (`copilot`, `claude`, `kiro`, `opencode`, `cursor`, `codex`, `antigravity`). It never holds credentials, paths, URLs or session ids. See [Using Squad across clients](../scenarios/using-squad-across-clients.md). + Commit this directory. It's your team's brain. Anyone who clones the repo gets the full team with all their knowledge. --- diff --git a/docs/src/content/docs/reference/sdk.md b/docs/src/content/docs/reference/sdk.md index 1f72499f1..5ea0d4dea 100644 --- a/docs/src/content/docs/reference/sdk.md +++ b/docs/src/content/docs/reference/sdk.md @@ -340,6 +340,15 @@ Every accepted sample is also emitted as a typed `context:utilization` event and The root package also exports cost tracking, offline mode, runtime events, OpenTelemetry metrics, and internationalization helpers. +## Client host adapters + +`@bradygaster/squad-sdk/client` (and the deprecated alias `@bradygaster/squad-sdk/clients`) projects the host-agnostic Squad coordinator into the files each AI host loads natively. + +- `HostProjection` is a pure workspace projection (`project(ctx)`) for `copilot`, `claude`, `kiro`, `opencode`, `cursor`, `codex` and `antigravity`. Get one with `getHostProjection(id)`; apply with `installClients(ids, ctx)`. +- `createClientAdapter({ host, ctx, runtime? })` returns the spec-level `ClientAdapter` facade: `describe()`, `projectWorkspace()` and `getCapabilities()` work locally; `submit`, `subscribe`, `cancel` and `respondApproval` delegate to a bound `RuntimeBinding` and otherwise throw `AdapterUnboundError`. +- Capabilities are canonical string tokens (`CAPABILITY_TOKENS_BY_HOST`); the boolean `ClientCapabilities` flags are a derived view, and passing conflicting flags throws. Advertised capabilities stay separate from negotiated `effectiveCapabilities` (client ∩ backend ∩ policy). +- JSON and TOML config merges only touch Squad's own keys; `normalizeClientIds()` / `assertPortableClients()` keep `clients[]` free of credentials and paths. + ## Other Public Modules The root package exports or re-exports APIs for: diff --git a/docs/src/content/docs/scenarios/using-squad-across-clients.md b/docs/src/content/docs/scenarios/using-squad-across-clients.md index fa352c3a1..d6e6d2be0 100644 --- a/docs/src/content/docs/scenarios/using-squad-across-clients.md +++ b/docs/src/content/docs/scenarios/using-squad-across-clients.md @@ -1,6 +1,6 @@ --- title: Using Squad across clients -description: Step-by-step guidance for projecting one Squad team into Copilot, Claude Code, Kiro, OpenCode, and Cursor with the new --client adapters. +description: Step-by-step guidance for projecting one Squad team into Copilot, Claude Code, Kiro, OpenCode, Cursor, OpenAI Codex, and Google Antigravity with the --client adapters. --- # Using Squad across clients @@ -16,6 +16,8 @@ npx squad init --client claude npx squad init --client kiro npx squad init --client opencode npx squad init --client cursor +npx squad init --client codex +npx squad init --client antigravity npx squad init --client all ``` @@ -29,19 +31,23 @@ npx squad upgrade --client copilot,claude,cursor | Client | Generated files | |---|---| -| Claude Code | `.claude/agents/squad.md`, optional `.claude/agents/squad-.md`, `.mcp.json`, `CLAUDE.md` managed block | +| Claude Code | `.claude/agents/squad.md`, `.mcp.json`, `CLAUDE.md` managed block, `.gitignore` block for `.claude/worktrees/` | | Kiro | `.kiro/steering/squad.md`, `.kiro/agents/squad.json`, `.kiro/settings/mcp.json` | | OpenCode | `.opencode/agent/squad.md`, `opencode.json` | | Cursor | `.cursor/rules/squad.mdc`, `.cursor/agents/squad.md`, `.cursor/mcp.json` | +| OpenAI Codex | `AGENTS.md` managed block, `.codex/config.toml` (`mcp_servers.squad_state`) | +| Google Antigravity | `.agents/agents/squad.md` (with the `squad_state` MCP server in its frontmatter), `GEMINI.md` managed block | | Copilot | `.github/agents/squad.agent.md` plus `.mcp.json` | +Every host gets one coordinator file. Team members are not projected as separate host agents: the coordinator spawns each specialist as a generic subagent with that member's `.squad/agents//charter.md` inlined, exactly as Copilot does. + ## Claude Code walkthrough 1. Run `npx squad init --client claude`. 2. Open `.claude/agents/squad.md` to inspect the coordinator prompt. 3. Confirm `.mcp.json` contains `squad_state`. 4. Check `CLAUDE.md` for the managed Squad marker block. -5. Run `claude --agent squad` or delegate to the `squad` subagent. +5. Run `claude --agent squad --mcp-config .mcp.json` or delegate to the `squad` subagent. Interactive Claude Code does not load project-scoped `.mcp.json` servers until they are approved, so pass the config explicitly. ## Kiro walkthrough @@ -64,6 +70,27 @@ npx squad upgrade --client copilot,claude,cursor 3. Open Cursor and select the `squad` agent. 4. Let the coordinator route work from there. +## Codex walkthrough + +1. Run `npx squad init --client codex`. +2. Check `AGENTS.md` for the managed Squad block and `.codex/config.toml` for `[mcp_servers.squad_state]`. +3. Mark the project trusted in your Codex home config (use the full path, not an 8.3 short path). +4. Start `codex` in the repo; the coordinator spawns one Codex agent per specialist. + +## Antigravity walkthrough + +1. Run `npx squad init --client antigravity`. +2. Verify `.agents/agents/squad.md` and the `GEMINI.md` block. +3. Run `agy --agent squad` (dismiss the first-run verbosity banner with Esc). +4. Quota is per model: if one model is exhausted, start with another via `--model`. + +## Troubleshooting + +- **`squad cast --name` writes nothing:** an old published `squad` is first on `PATH`. Put the current build first. +- **Team files appear in your personal squad folder:** `squad init` links a repo to an existing personal squad; set `SQUAD_NO_PERSONAL=1` to skip that. +- **Cursor:** run `cursor-agent mcp enable squad_state` once so the generated MCP server is approved. +- **Models:** each host needs its own model access (a Copilot license covers Copilot and, through the GitHub Copilot provider, OpenCode; Kiro, Antigravity and Cursor need their own sign-in). + ## Polyglot repo walkthrough If you want one repo to work everywhere, either: diff --git a/docs/tutorials/multi-client-quickstart.md b/docs/tutorials/multi-client-quickstart.md index 86a06e0ad..3ad9d8a23 100644 --- a/docs/tutorials/multi-client-quickstart.md +++ b/docs/tutorials/multi-client-quickstart.md @@ -8,7 +8,7 @@ The headline feature is the new `--client` flag on `squad init` and `squad upgra - a repo with Squad available through `npx squad` or a global `squad` install; - a normal `.squad/` team directory; -- one or more target clients: Copilot, Claude Code, Kiro, OpenCode, or Cursor. +- one or more target clients: Copilot, Claude Code, Kiro, OpenCode, Cursor, OpenAI Codex, or Google Antigravity. If you already have Squad in the repo, use `upgrade`. If this is a fresh repo, use `init`. @@ -54,7 +54,7 @@ Squad does **not** take over the whole file. It updates only the managed block: ## Squad -This project uses Squad. Run `claude --agent squad` ... +This project uses Squad. Run `claude --agent squad --mcp-config .mcp.json` ... ``` @@ -65,7 +65,7 @@ That means your own project notes above or below the block stay yours. Use either of these patterns: ```text -claude --agent squad +claude --agent squad --mcp-config .mcp.json ``` Or, inside Claude Code, delegate to the `squad` subagent and let it route work from there. @@ -211,7 +211,27 @@ The dispatch adapter tells Cursor to prefer subagents when available and to inli --- -## Tutorial 5: one repo, multiple clients +## Tutorial 5: OpenAI Codex + +```bash +npx squad init --client codex +``` + +Squad writes an `AGENTS.md` managed block (Codex has no separate main-agent file) and merges `[mcp_servers.squad_state]` into `.codex/config.toml` without touching your other settings. Start `codex` in the repo; the coordinator spawns one agent per specialist and waits for them. + +--- + +## Tutorial 6: Google Antigravity + +```bash +npx squad init --client antigravity +``` + +Squad writes `.agents/agents/squad.md` (the `squad_state` MCP server is declared in its frontmatter, because Antigravity has no project-level MCP file) and a `GEMINI.md` managed block. Run `agy --agent squad`. + +--- + +## Tutorial 7: one repo, multiple clients This is where Squad 2.0 gets fun. diff --git a/packages/squad-sdk/test/squad-20-security-contracts.test.ts b/test/squad-20-security-contracts.test.ts similarity index 100% rename from packages/squad-sdk/test/squad-20-security-contracts.test.ts rename to test/squad-20-security-contracts.test.ts From 20689bf86a5e766444fec57bae0a1e5bdf48f233 Mon Sep 17 00:00:00 2001 From: Copilot Date: Sun, 18 Oct 2026 20:44:54 +0300 Subject: [PATCH 38/46] chore(pr): drop local worktree paths, internal names and personal-name fixtures from docs/tests Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 995eea17-c356-4912-90ea-5e5b144e7ba9 --- cspell.json | 1 - docs/proposals/squad-2.0-security-and-isolation.md | 2 +- docs/specification/client-backend-decoupling-v0.2.md | 2 +- docs/specification/client-harness-adapters-v0.2.md | 2 +- docs/tutorials/durable-backend-and-custom-harness.md | 6 ++---- test/squad-20-security-contracts.test.ts | 4 ++-- 6 files changed, 7 insertions(+), 10 deletions(-) diff --git a/cspell.json b/cspell.json index 8e6236949..f32e9a10e 100644 --- a/cspell.json +++ b/cspell.json @@ -19,7 +19,6 @@ "AKIA", "affordances", "kiro", - "Bubu", "resumability", "amolchanov", "anthropics", diff --git a/docs/proposals/squad-2.0-security-and-isolation.md b/docs/proposals/squad-2.0-security-and-isolation.md index 447952960..5dac70cda 100644 --- a/docs/proposals/squad-2.0-security-and-isolation.md +++ b/docs/proposals/squad-2.0-security-and-isolation.md @@ -1,7 +1,7 @@ # Squad 2.0 — Security, Threat Model & Isolation Gates **Status:** Proposal · **Owner:** Worf (Security & Reliability) · **Scope:** Client/Backend decoupling and multi-client projections (Claude Code, Kiro, OpenCode, Cursor, GitHub Copilot) plus the `DurableSquad` remote backend. -**Executable contract:** `packages/squad-sdk/test/squad-20-security-contracts.test.ts` +**Executable contract:** `test/squad-20-security-contracts.test.ts` ## 1. Trust model diff --git a/docs/specification/client-backend-decoupling-v0.2.md b/docs/specification/client-backend-decoupling-v0.2.md index 375f81015..3c11a86f8 100644 --- a/docs/specification/client-backend-decoupling-v0.2.md +++ b/docs/specification/client-backend-decoupling-v0.2.md @@ -34,7 +34,7 @@ It does not standardize LLM prompts, UI layout, GitHub-specific session tools, token minting, transport codecs, storage engines, or the internal scheduler of any execution fabric. -This split follows the Q and Bubu convergence rule from the Squad 2.0 kickoff: +This split follows the client/backend convergence rule agreed for Squad 2.0: host adapters stop at host capabilities and transport translation. Conversation semantics, ordering, idempotency, routing, leases, cancellation, durable artifacts, checkpoints, observability joins, and authorization boundaries diff --git a/docs/specification/client-harness-adapters-v0.2.md b/docs/specification/client-harness-adapters-v0.2.md index 0a6438e34..46dd9fea1 100644 --- a/docs/specification/client-harness-adapters-v0.2.md +++ b/docs/specification/client-harness-adapters-v0.2.md @@ -33,7 +33,7 @@ It does not standardize the internal tool catalog of any host, the visual presentation of approvals, extension packaging, or provider-specific pricing and authentication. -This profile follows the same Q and Bubu convergence rule: adapters own native +This profile follows the same convergence rule: adapters own native host bootstrap, transport mechanics, event parsing, tool registration, UI rendering, and translation to canonical envelopes. They do not own conversation semantics, routing, durable state, authorization policy, or the diff --git a/docs/tutorials/durable-backend-and-custom-harness.md b/docs/tutorials/durable-backend-and-custom-harness.md index 03897b503..0ca70a0e7 100644 --- a/docs/tutorials/durable-backend-and-custom-harness.md +++ b/docs/tutorials/durable-backend-and-custom-harness.md @@ -355,16 +355,14 @@ TurnAccepted(runId, seq) ### Practical commands -If you have the sibling worktrees checked out, these are the fastest validation passes: +These are the fastest validation passes from the repo root: ```bash -cd ..\squad-20-backend npm test -- test/backend.test.ts ``` ```bash -cd ..\squad-20-validation -npm test -- packages/squad-sdk/test/squad-20-security-contracts.test.ts +npm test -- test/squad-20-security-contracts.test.ts ``` ### What to look for in the validation suite diff --git a/test/squad-20-security-contracts.test.ts b/test/squad-20-security-contracts.test.ts index 3726f8f9b..010e9dd32 100644 --- a/test/squad-20-security-contracts.test.ts +++ b/test/squad-20-security-contracts.test.ts @@ -678,8 +678,8 @@ describe('configured vs. negotiated capabilities (clients[] is not authority)', ['bearer value', { note: 'Bearer abc.def' }], ['provider thread value', { ref: 'thread_abc123XYZ' }], ['lease value', { ref: 'lease-9f2c' }], - ['posix home path', { cwd: '/home/tamir/repo' }], - ['windows path', { cwd: 'C:\\Users\\tamir\\repo' }], + ['posix home path', { cwd: '/home/user/repo' }], + ['windows path', { cwd: 'C:\\Users\\user\\repo' }], ['unc path', { cwd: '\\\\host\\share' }], ['tilde path', { cwd: '~/repo' }], ['embedded path', { cmd: 'run D:\\work\\x.ps1' }], From cb86aef07b5b4673ce7f4105f50c558c6b054317 Mon Sep 17 00:00:00 2001 From: Copilot Date: Sun, 18 Oct 2026 20:48:14 +0300 Subject: [PATCH 39/46] chore(release): bump squad-sdk and squad-cli to the 2.0 major via changeset Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 995eea17-c356-4912-90ea-5e5b144e7ba9 --- .changeset/squad-2-0-multi-host-clients.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/squad-2-0-multi-host-clients.md b/.changeset/squad-2-0-multi-host-clients.md index b4cd01314..98683938b 100644 --- a/.changeset/squad-2-0-multi-host-clients.md +++ b/.changeset/squad-2-0-multi-host-clients.md @@ -1,6 +1,6 @@ --- -"@bradygaster/squad-sdk": minor -"@bradygaster/squad-cli": minor +"@bradygaster/squad-sdk": major +"@bradygaster/squad-cli": major --- Squad 2.0: decoupled backend and multi-host client adapters. Add `squad init --client` and `squad upgrade --client` (copilot, claude, kiro, opencode, cursor, codex, antigravity) with additive, non-clobbering config merges, persisted in `.squad/config.json` `clients[]`; add the `HostProjection` / spec `ClientAdapter` SDK surface with canonical capability tokens under `@bradygaster/squad-sdk/client` (and the deprecated `/clients` alias), and a host-agnostic coordinator prompt with per-host dispatch. From 699da4db99af079175f680b5c7fed29e041ce8a7 Mon Sep 17 00:00:00 2001 From: Copilot Date: Sun, 18 Oct 2026 20:53:30 +0300 Subject: [PATCH 40/46] fix(clients): address Copilot review on client adapters - containment guard (no ../, absolute or symlinked components) for install/uninstall - forward --client on 'init --mode remote' - never overwrite a malformed .squad/config.json when persisting clients - reject wrong-typed MCP containers/entries instead of replacing them - TOML: replace multiline arrays whole, tolerant headers, remove sub-tables on uninstall - keep the shared .mcp.json entry while another host still uses it (uninstallClients keep) - apply squadDir to the dispatch section as well Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 995eea17-c356-4912-90ea-5e5b144e7ba9 --- packages/squad-cli/src/cli-entry.ts | 19 +- packages/squad-cli/src/cli/core/clients.ts | 16 +- .../squad-sdk/src/client/adapters/adapters.ts | 6 + .../squad-sdk/src/client/adapters/apply.ts | 163 ++++++++++++++---- .../src/client/adapters/coordinator.ts | 3 +- test/clients.test.ts | 66 +++++++ 6 files changed, 224 insertions(+), 49 deletions(-) diff --git a/packages/squad-cli/src/cli-entry.ts b/packages/squad-cli/src/cli-entry.ts index 620246761..f63dad2da 100644 --- a/packages/squad-cli/src/cli-entry.ts +++ b/packages/squad-cli/src/cli-entry.ts @@ -343,6 +343,15 @@ async function main(): Promise { const modeIdx = args.indexOf('--mode'); const mode = (modeIdx !== -1 && args[modeIdx + 1]) ? args[modeIdx + 1] : undefined; + let initClients; + try { + const { readClientFlag } = await import('./cli/core/clients.js'); + initClients = readClientFlag(args); + } catch (err) { + console.error(`❌ ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } + if (mode === 'remote') { const teamPath = args[modeIdx + 2]; if (!teamPath) { @@ -351,7 +360,7 @@ async function main(): Promise { const { writeRemoteConfig } = await import('./cli/commands/init-remote.js'); const dest = process.cwd(); writeRemoteConfig(dest, teamPath); - await runInit(dest); + await runInit(dest, { clients: initClients }); return; } @@ -367,14 +376,6 @@ async function main(): Promise { // Parse --state-backend flag for init const sbIdx = args.indexOf('--state-backend'); const initStateBackend = (sbIdx !== -1 && args[sbIdx + 1]) ? args[sbIdx + 1] : undefined; - let initClients; - try { - const { readClientFlag } = await import('./cli/core/clients.js'); - initClients = readClientFlag(args); - } catch (err) { - console.error(`❌ ${err instanceof Error ? err.message : String(err)}`); - process.exit(1); - } // Global init: suppress workflows (no GitHub CI in ~/.config/squad/) and bootstrap personal squad runInit(dest, { includeWorkflows: !noWorkflows && !hasGlobal, sdk, roles, isGlobal: hasGlobal, stateBackend: initStateBackend, mcpFrontmatter, clients: initClients, includeVscodeDefault: !noVscodeDefault }).then(async () => { if (presetName) { diff --git a/packages/squad-cli/src/cli/core/clients.ts b/packages/squad-cli/src/cli/core/clients.ts index 4af01f257..88e04e19b 100644 --- a/packages/squad-cli/src/cli/core/clients.ts +++ b/packages/squad-cli/src/cli/core/clients.ts @@ -43,11 +43,17 @@ export function readConfiguredClients(squadDir: string): ClientId[] { export function persistClients(squadDir: string, clients: readonly ClientId[]): void { const file = path.join(squadDir, 'config.json'); let cfg: Record = {}; - try { - const parsed = JSON.parse(fs.readFileSync(file, 'utf8')) as unknown; - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) cfg = parsed as Record; - } catch { - // start fresh + if (fs.existsSync(file)) { + let parsed: unknown; + try { + parsed = JSON.parse(fs.readFileSync(file, 'utf8')); + } catch (err) { + throw new Error(`Refusing to overwrite malformed ${file}: ${err instanceof Error ? err.message : String(err)}`); + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`Refusing to overwrite ${file}: root must be a JSON object`); + } + cfg = parsed as Record; } cfg['clients'] = normalizeClientIds(clients).clients; fs.mkdirSync(squadDir, { recursive: true }); diff --git a/packages/squad-sdk/src/client/adapters/adapters.ts b/packages/squad-sdk/src/client/adapters/adapters.ts index 668fc6a78..e9e2b78a0 100644 --- a/packages/squad-sdk/src/client/adapters/adapters.ts +++ b/packages/squad-sdk/src/client/adapters/adapters.ts @@ -36,7 +36,13 @@ export function mergeMcpServer( ): Json { const out: Json = { ...existing }; for (const [k, v] of Object.entries(topLevelDefaults)) if (!(k in out)) out[k] = v; + if (out[container] !== undefined && !isObject(out[container])) { + throw new Error(`Refusing to overwrite "${container}": expected a JSON object`); + } const servers: Json = isObject(out[container]) ? { ...(out[container] as Json) } : {}; + if (servers[SQUAD_STATE_KEY] !== undefined && !isObject(servers[SQUAD_STATE_KEY])) { + throw new Error(`Refusing to overwrite "${container}.${SQUAD_STATE_KEY}": expected a JSON object`); + } const prev = isObject(servers[SQUAD_STATE_KEY]) ? (servers[SQUAD_STATE_KEY] as Json) : {}; const entry: Json = { ...prev }; for (const [k, v] of Object.entries(defaults)) if (!(k in entry)) entry[k] = v; diff --git a/packages/squad-sdk/src/client/adapters/apply.ts b/packages/squad-sdk/src/client/adapters/apply.ts index 1cd350d00..bfd2e3931 100644 --- a/packages/squad-sdk/src/client/adapters/apply.ts +++ b/packages/squad-sdk/src/client/adapters/apply.ts @@ -34,9 +34,56 @@ export function upsertBlock(existing: string, content: string, id?: string): str return `${existing}${sep}${block}\n`; } +/** Normalized dotted key of a `[table]` header line (quotes/whitespace tolerant), or undefined. */ +function tableHeaderKey(line: string): string | undefined { + const m = /^\s*\[(?!\[)([^\]]+)\]\s*(?:#.*)?$/.exec(line); + if (!m) return undefined; + return m[1]!.split('.').map((s) => s.trim().replace(/^["']|["']$/g, '')).join('.'); +} + +/** Index of the last line of the assignment starting at `i` (handles multiline arrays). */ +function assignmentEnd(lines: string[], i: number): number { + const first = lines[i]!; + const eq = first.indexOf('='); + if (eq === -1) return i; + let depth = 0; + let inStr: string | null = null; + for (let n = i; n < lines.length; n++) { + const text = n === i ? first.slice(eq + 1) : lines[n]!; + for (let k = 0; k < text.length; k++) { + const ch = text[k]!; + if (inStr) { + if (ch === '\\' && inStr === '"') k++; + else if (ch === inStr) inStr = null; + } else if (ch === '"' || ch === "'") inStr = ch; + else if (ch === '#') break; + else if (ch === '[') depth++; + else if (ch === ']') depth--; + } + if (depth <= 0) return n; + } + return lines.length - 1; +} + +/** [start, end) of the table with key `table` (start at its header), or undefined. */ +function findTable(lines: string[], table: string, from = 0): [number, number] | undefined { + const want = table.split('.').join('.'); + for (let i = from; i < lines.length; i++) { + if (tableHeaderKey(lines[i]!) !== want) continue; + let end = lines.length; + for (let j = i + 1; j < lines.length; j++) { + if (/^\s*\[/.test(lines[j]!) && tableHeaderKey(lines[j]!) !== undefined) { end = j; break; } + if (/^\s*[A-Za-z0-9_"'-]+\s*=/.test(lines[j]!)) j = assignmentEnd(lines, j); + } + return [i, end]; + } + return undefined; +} + /** * Upsert `[table]` in TOML text, setting only the given managed keys (values are * already TOML literals). Other keys, sub-tables and tables are left untouched. + * Multiline values (e.g. `args = [` arrays) are replaced as a whole. */ export function upsertTomlTable( existing: string, @@ -47,40 +94,71 @@ export function upsertTomlTable( const nl = existing.includes('\r\n') ? '\r\n' : '\n'; const lines = existing === '' ? [] : existing.split(/\r?\n/); const header = `[${table}]`; - const start = lines.findIndex((l) => l.trim() === header); - if (start === -1) { + const span = findTable(lines, table); + if (!span) { const body = Object.entries({ ...managed, ...defaults }).map(([k, v]) => `${k} = ${v}`); const prefix = existing === '' ? '' : existing.replace(/(\r?\n)*$/, '') + nl + nl; return `${prefix}${[header, ...body].join(nl)}${nl}`; } - let end = lines.length; - for (let i = start + 1; i < lines.length; i++) { - if (/^\s*\[/.test(lines[i]!)) { end = i; break; } - } - const section = lines.slice(start + 1, end); - const present = new Set(section.map((l) => /^\s*([A-Za-z0-9_-]+)\s*=/.exec(l)?.[1]).filter(Boolean)); + const [start, end] = span; + const present = new Set(); + const rewritten: string[] = []; const remaining = { ...managed }; - for (const [k, v] of Object.entries(defaults)) if (!present.has(k)) remaining[k] = v; - const rewritten = section.map((l) => { - const m = /^\s*([A-Za-z0-9_-]+)\s*=/.exec(l); - if (m && m[1]! in remaining) { - const k = m[1]!; - const v = remaining[k]!; + const defaultsLeft = { ...defaults }; + for (let i = start + 1; i < end; i++) { + const line = lines[i]!; + const m = /^\s*([A-Za-z0-9_-]+)\s*=/.exec(line); + if (!m) { rewritten.push(line); continue; } + const k = m[1]!; + const last = assignmentEnd(lines, i); + present.add(k); + if (k in remaining) { + rewritten.push(`${k} = ${remaining[k]!}`); delete remaining[k]; - return `${k} = ${v}`; + } else { + for (let n = i; n <= last; n++) rewritten.push(lines[n]!); } - return l; - }); + i = last; + } + for (const k of Object.keys(defaultsLeft)) if (present.has(k)) delete defaultsLeft[k]; let insertAt = rewritten.length; while (insertAt > 0 && rewritten[insertAt - 1]!.trim() === '') insertAt--; - rewritten.splice(insertAt, 0, ...Object.entries(remaining).map(([k, v]) => `${k} = ${v}`)); + rewritten.splice(insertAt, 0, ...Object.entries({ ...remaining, ...defaultsLeft }).map(([k, v]) => `${k} = ${v}`)); return [...lines.slice(0, start + 1), ...rewritten, ...lines.slice(end)].join(nl); } + /** Collect projection files for the given clients, in order. */ export function projectClients(clients: readonly ClientId[], ctx: ClientContext): ProjectionFile[] { return clients.flatMap((c) => getHostProjection(c).project(ctx)); } +/** + * Resolve `rel` under `root` and refuse anything that could escape it: absolute or + * `..` paths, and symlink/junction components on the way (existing ones are checked). + */ +export function resolveInsideRoot(root: string, rel: string): string { + if (path.isAbsolute(rel) || rel.split(/[\\/]/).includes('..')) { + throw new Error(`Refusing path outside the workspace: ${rel}`); + } + const rootAbs = path.resolve(root); + const abs = path.resolve(rootAbs, rel); + if (abs !== rootAbs && !abs.startsWith(rootAbs + path.sep)) { + throw new Error(`Refusing path outside the workspace: ${rel}`); + } + let cur = rootAbs; + for (const part of path.relative(rootAbs, abs).split(path.sep)) { + cur = path.join(cur, part); + let st: fs.Stats; + try { + st = fs.lstatSync(cur); + } catch { + break; + } + if (st.isSymbolicLink()) throw new Error(`Refusing symlinked path component: ${path.relative(rootAbs, cur)}`); + } + return abs; +} + function parseJsonObject(file: string, raw: string | undefined): Record { if (raw === undefined || raw.trim() === '') return {}; let parsed: unknown; @@ -131,8 +209,8 @@ export function applyProjection(root: string, files: readonly ProjectionFile[]): } for (const rel of order) { - const abs = path.join(root, rel); try { + const abs = resolveInsideRoot(root, rel); let text = fs.existsSync(abs) ? fs.readFileSync(abs, 'utf8') : undefined; for (const fn of transforms.get(rel)!) text = fn(text, abs); results.push({ path: rel, action: writeIfChanged(abs, text ?? '') }); @@ -158,20 +236,29 @@ export function removeBlock(existing: string, id?: string): string { return before + after; } -/** Remove a whole `[table]` (up to the next table header) from TOML text; other tables are untouched. */ +/** Remove a whole `[table]` and its `[table.*]` sub-tables; other tables are untouched. */ export function removeTomlTable(existing: string, table: string): string { const nl = existing.includes('\r\n') ? '\r\n' : '\n'; - const lines = existing.split(/\r?\n/); - const start = lines.findIndex((l) => l.trim() === `[${table}]`); - if (start === -1) return existing; - let end = lines.length; - for (let i = start + 1; i < lines.length; i++) { - if (/^\s*\[/.test(lines[i]!)) { end = i; break; } + let lines = existing.split(/\r?\n/); + const isOurs = (key: string | undefined) => key !== undefined && (key === table || key.startsWith(`${table}.`)); + let changed = false; + for (;;) { + let start = -1; + for (let i = 0; i < lines.length; i++) if (isOurs(tableHeaderKey(lines[i]!))) { start = i; break; } + if (start === -1) break; + let end = lines.length; + for (let j = start + 1; j < lines.length; j++) { + if (/^\s*\[/.test(lines[j]!) && tableHeaderKey(lines[j]!) !== undefined) { end = j; break; } + if (/^\s*[A-Za-z0-9_"'-]+\s*=/.test(lines[j]!)) j = assignmentEnd(lines, j); + } + const before = lines.slice(0, start); + while (before.length > 0 && before[before.length - 1]!.trim() === '') before.pop(); + const after = lines.slice(end); + lines = [...before, ...(before.length > 0 && after.length > 0 ? [''] : []), ...after]; + changed = true; } - const before = lines.slice(0, start); - while (before.length > 0 && before[before.length - 1]!.trim() === '') before.pop(); - const after = lines.slice(end); - const joined = [...before, ...(before.length > 0 && after.length > 0 ? [''] : []), ...after].join(nl); + if (!changed) return existing; + const joined = lines.join(nl); return joined.trim() === '' ? '' : joined.replace(/(\r?\n)*$/, nl); } @@ -224,8 +311,8 @@ export function removeProjection(root: string, files: readonly ProjectionFile[]) for (const f of files) { if (seen.has(f.path)) continue; seen.add(f.path); - const abs = path.join(root, f.path); try { + const abs = resolveInsideRoot(root, f.path); if (!fs.existsSync(abs)) { results.push({ path: f.path, action: 'unchanged' }); continue; @@ -262,7 +349,15 @@ export function removeProjection(root: string, files: readonly ProjectionFile[]) return results; } -/** Roll back the given clients' projections (inverse of {@link installClients}). */ -export function uninstallClients(clients: readonly ClientId[], ctx: ClientContext): ApplyResult[] { - return removeProjection(ctx.root, projectClients(clients, ctx)); +/** + * Roll back the given clients' projections (inverse of {@link installClients}). + * Pass the clients that stay installed as `keep`: shared JSON configs (e.g. the + * `.mcp.json` Copilot and Claude both use) keep their `squad_state` entry while + * any kept client still projects the same file. + */ +export function uninstallClients(clients: readonly ClientId[], ctx: ClientContext, keep: readonly ClientId[] = []): ApplyResult[] { + const keepPaths = new Set( + projectClients(keep.filter((c) => !clients.includes(c)), ctx).filter((f) => f.kind === 'json').map((f) => f.path), + ); + return removeProjection(ctx.root, projectClients(clients, ctx).filter((f) => !(f.kind === 'json' && keepPaths.has(f.path)))); } \ No newline at end of file diff --git a/packages/squad-sdk/src/client/adapters/coordinator.ts b/packages/squad-sdk/src/client/adapters/coordinator.ts index 265584f3e..f1cb71de1 100644 --- a/packages/squad-sdk/src/client/adapters/coordinator.ts +++ b/packages/squad-sdk/src/client/adapters/coordinator.ts @@ -103,5 +103,6 @@ export function dispatchStrategy(client: ClientId): DispatchStrategy { export function buildCoordinatorInstructions(client: ClientId, squadDir = '.squad'): string { const caps = CAPABILITIES[client]; const transports = `## Transport\n\nThis host connects to the backend via: ${caps.transportModes.join(", ")} (subagents: ${caps.supportsSubagents ? "yes" : "no"}).\n`; - return `${coreCoordinatorProtocol(squadDir)}\n${STRATEGIES[client].section}\n\n${transports}`; + const dispatch = STRATEGIES[client].section.split('.squad/').join(`${squadDir}/`); + return `${coreCoordinatorProtocol(squadDir)}\n${dispatch}\n\n${transports}`; } diff --git a/test/clients.test.ts b/test/clients.test.ts index 033a6cf5d..7432bc2d6 100644 --- a/test/clients.test.ts +++ b/test/clients.test.ts @@ -34,6 +34,8 @@ import { type RuntimeBinding, } from '../packages/squad-sdk/src/client/adapter-contract.js'; +import { applyProjection, removeTomlTable, uninstallClients, upsertTomlTable } from '../packages/squad-sdk/src/client/adapters/index.js'; + let root: string; const mcp = { command: 'squad', args: ['state-mcp'] }; const ctx = (over: Partial = {}): ClientContext => ({ root, mcp, ...over }); @@ -539,3 +541,67 @@ describe('HostProjection / ClientAdapter facade (spec v0.2)', () => { expect(caps.clientCapabilities).toContain('native-hooks'); }); }); + +describe('review hardening (Copilot review on #2238)', () => { + it('rejects ../ and symlinked path components instead of writing outside the workspace', () => { + const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'squad-outside-')); + try { + const esc = applyProjection(root, [{ kind: 'managed', path: '../escape.txt', content: 'x' }]); + expect(esc[0]!.action).toBe('error'); + expect(fs.existsSync(path.join(path.dirname(root), 'escape.txt'))).toBe(false); + fs.symlinkSync(outside, path.join(root, '.cursor'), 'junction'); + const res = installClients(['cursor'], ctx()); + expect(res.filter((r) => r.path.startsWith('.cursor/')).every((r) => r.action === 'error')).toBe(true); + expect(fs.readdirSync(outside)).toEqual([]); + const un = uninstallClients(['cursor'], ctx()); + expect(un.filter((r) => r.path.startsWith('.cursor/')).every((r) => r.action !== 'removed')).toBe(true); + } finally { + fs.rmSync(outside, { recursive: true, force: true }); + } + }); + + it('refuses wrong-typed MCP containers and squad_state entries per file', () => { + fs.writeFileSync(path.join(root, '.mcp.json'), JSON.stringify({ mcpServers: [] })); + const res = installClients(['claude'], ctx()); + expect(res.find((r) => r.path === '.mcp.json')!.action).toBe('error'); + expect(readJson('.mcp.json')).toEqual({ mcpServers: [] }); + fs.writeFileSync(path.join(root, '.mcp.json'), JSON.stringify({ mcpServers: { squad_state: 'x' } })); + expect(installClients(['claude'], ctx()).find((r) => r.path === '.mcp.json')!.action).toBe('error'); + }); + + it('TOML: replaces multiline arrays whole, tolerates header spacing, removes child tables', () => { + const src = '[ mcp_servers . squad_state ]\ncommand = "old"\nargs = [\n "a",\n "b",\n]\n\n[mcp_servers.squad_state.env]\nK = "v"\n\n[other]\nx = 1\n'; + const out = upsertTomlTable(src, 'mcp_servers.squad_state', { command: '"squad"', args: '["state-mcp"]' }); + expect(out).toContain('command = "squad"'); + expect(out).toContain('args = ["state-mcp"]'); + expect(out).not.toContain('"a"'); + expect(out).not.toContain('"b"'); + expect(out).toContain('[other]'); + const gone = removeTomlTable(out, 'mcp_servers.squad_state'); + expect(gone).not.toContain('squad_state'); + expect(gone).not.toContain('K = "v"'); + expect(gone).toContain('[other]'); + }); + + it('uninstalling one host keeps the shared .mcp.json entry while another host still uses it', () => { + installClients(['copilot', 'claude'], ctx()); + uninstallClients(['claude'], ctx(), ['copilot']); + expect(readJson('.mcp.json').mcpServers.squad_state).toBeDefined(); + uninstallClients(['copilot'], ctx()); + expect(exists('.mcp.json')).toBe(false); + }); + + it('applies a custom squadDir to the dispatch section too', () => { + const text = buildCoordinatorInstructions('claude', '.team'); + expect(text).toContain('.team/agents//charter.md'); + expect(text).not.toContain('.squad/agents'); + }); + + it('persistClients refuses to overwrite a malformed config.json', () => { + const sq = path.join(root, '.squad'); + fs.mkdirSync(sq); + fs.writeFileSync(path.join(sq, 'config.json'), '{ not json'); + expect(() => persistClients(sq, ['claude'])).toThrow(/Refusing to overwrite malformed/); + expect(fs.readFileSync(path.join(sq, 'config.json'), 'utf8')).toBe('{ not json'); + }); +}); From 0b715e27b914f442b4a3b2d13367196177de6566 Mon Sep 17 00:00:00 2001 From: Copilot Date: Sun, 18 Oct 2026 21:04:19 +0300 Subject: [PATCH 41/46] fix(backend): address Copilot review on local/durable-http backends (11 comments) Symlink-safe file store, cross-process lock + single-transaction state changes, epoch fencing for harness events and omitted epochs, retryable approval/cancel delivery, run-identity lookups, approval cancel versioning, idempotent HTTP createConversation, per-conversation learned epochs. 10 regression tests; fix duplicate imports in test/clients.test.ts. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 995eea17-c356-4912-90ea-5e5b144e7ba9 --- .../squad-cli/src/cli/core/backend-mcp.ts | 8 +- .../src/backend/durable-http-backend.ts | 40 +- .../squad-sdk/src/backend/local-backend.ts | 401 +++++++++++++----- packages/squad-sdk/src/backend/types.ts | 19 +- test/backend.test.ts | 164 +++++++ test/clients.test.ts | 2 +- test/squad-20-all-scenarios.e2e.test.ts | 9 +- 7 files changed, 504 insertions(+), 139 deletions(-) diff --git a/packages/squad-cli/src/cli/core/backend-mcp.ts b/packages/squad-cli/src/cli/core/backend-mcp.ts index 7031eb11d..4a195d4d3 100644 --- a/packages/squad-cli/src/cli/core/backend-mcp.ts +++ b/packages/squad-cli/src/cli/core/backend-mcp.ts @@ -111,14 +111,14 @@ export function createBackendMcpTools(getBackend: () => SquadBackend): BackendMc { name: 'squad_backend_cancel_turn', description: 'Cancel a running turn.', - inputSchema: { type: 'object', properties: { conversationId: cid, seq }, required: ['conversationId', 'seq'] }, - call: a => getBackend().cancelTurn(str(a, 'conversationId'), num(a, 'seq')), + inputSchema: { type: 'object', properties: { conversationId: cid, seq, epoch: { type: 'integer', description: 'Lease epoch; required once the conversation is fenced' } }, required: ['conversationId', 'seq'] }, + call: a => getBackend().cancelTurn(str(a, 'conversationId'), num(a, 'seq'), typeof a['epoch'] === 'number' ? a['epoch'] : undefined), }, { name: 'squad_backend_cancel_run', description: 'Cancel a run by its stable runId (distinct from turn seq).', - inputSchema: { type: 'object', properties: { conversationId: cid, runId: { type: 'string' }, reason: { type: 'string' } }, required: ['conversationId', 'runId'] }, - call: a => getBackend().cancelRun(str(a, 'conversationId'), str(a, 'runId'), typeof a['reason'] === 'string' ? a['reason'] : undefined), + inputSchema: { type: 'object', properties: { conversationId: cid, runId: { type: 'string' }, reason: { type: 'string' }, epoch: { type: 'integer', description: 'Lease epoch; required once the conversation is fenced' } }, required: ['conversationId', 'runId'] }, + call: a => getBackend().cancelRun(str(a, 'conversationId'), str(a, 'runId'), typeof a['reason'] === 'string' ? a['reason'] : undefined, typeof a['epoch'] === 'number' ? a['epoch'] : undefined), }, { name: 'squad_backend_get_approvals', diff --git a/packages/squad-sdk/src/backend/durable-http-backend.ts b/packages/squad-sdk/src/backend/durable-http-backend.ts index b1e22ef55..fab8a1228 100644 --- a/packages/squad-sdk/src/backend/durable-http-backend.ts +++ b/packages/squad-sdk/src/backend/durable-http-backend.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'node:crypto'; import { BACKEND_PROTOCOL_VERSION, BackendError } from './types.js'; import type { ApprovalRecord, @@ -35,7 +36,10 @@ export class DurableHttpSquadBackend implements SquadBackend { private readonly doFetch: typeof fetch; private readonly maxRetries: number; private readonly retryDelayMs: number; - private epoch: LeaseEpoch | undefined; + /** Initial epoch for conversations this client has not learned one for. */ + private readonly defaultEpoch: LeaseEpoch | undefined; + /** Learned lease epoch per conversation: one conversation's epoch must never leak into another. */ + private readonly epochs = new Map(); constructor(private readonly options: DurableHttpOptions) { if (!options.baseUrl) throw new BackendError('invalid-request', 'baseUrl is required', 400); @@ -43,7 +47,7 @@ export class DurableHttpSquadBackend implements SquadBackend { this.doFetch = options.fetch ?? globalThis.fetch.bind(globalThis); this.maxRetries = options.maxRetries ?? 3; this.retryDelayMs = options.retryDelayMs ?? 200; - this.epoch = options.epoch; + this.defaultEpoch = options.epoch; } negotiate(hello: ProtocolHello): Promise { @@ -60,21 +64,25 @@ export class DurableHttpSquadBackend implements SquadBackend { } async createConversation(options: CreateConversationOptions = {}): Promise { - const conv = await this.request('POST', '/v1/conversations', options, - options.conversationId ? { 'Idempotency-Key': `create:${options.conversationId}` } : undefined); - if (typeof conv.epoch === 'number') this.epoch = Math.max(this.epoch ?? 0, conv.epoch); + // one id per call, reused by every retry attempt, so a lost response never creates a second conversation + const conversationId = options.conversationId ?? randomUUID(); + const conv = await this.request('POST', '/v1/conversations', { ...options, conversationId }, + { 'Idempotency-Key': `create:${conversationId}` }); + this.learn(conv.conversationId ?? conversationId, conv.epoch); return conv; } async sendMessage(conversationId: string, message: BackendMessage): Promise { - const epoch = message.epoch ?? this.epoch; - return this.request( + const epoch = message.epoch ?? this.epochOf(conversationId); + const admission = await this.request( 'POST', `/v1/conversations/${enc(conversationId)}/messages`, { ...message, epoch }, { ...(message.correlationId ? { 'X-Correlation-Id': message.correlationId } : {}), 'Idempotency-Key': `${message.runId}:${message.seq}` }, epoch, ); + this.learn(conversationId, admission.epoch); + return admission; } async getTranscript(conversationId: string): Promise { @@ -87,17 +95,17 @@ export class DurableHttpSquadBackend implements SquadBackend { return this.request('GET', `/v1/conversations/${enc(conversationId)}/turns/${seq}`); } - cancelTurn(conversationId: string, seq: number): Promise { - return this.request('POST', `/v1/conversations/${enc(conversationId)}/turns/${seq}/cancel`, {}, undefined, this.epoch); + cancelTurn(conversationId: string, seq: number, epoch?: LeaseEpoch): Promise { + return this.request('POST', `/v1/conversations/${enc(conversationId)}/turns/${seq}/cancel`, {}, undefined, epoch ?? this.epochOf(conversationId)); } - cancelRun(conversationId: string, runId: string, reason?: string): Promise { + cancelRun(conversationId: string, runId: string, reason?: string, epoch?: LeaseEpoch): Promise { return this.request( 'POST', `/v1/conversations/${enc(conversationId)}/runs/${enc(runId)}/cancel`, { reason }, { 'Idempotency-Key': `cancel:${runId}` }, - this.epoch, + epoch ?? this.epochOf(conversationId), ); } @@ -108,7 +116,7 @@ export class DurableHttpSquadBackend implements SquadBackend { } respondApproval(conversationId: string, response: ApprovalResponse): Promise { - const epoch = response.epoch ?? this.epoch; + const epoch = response.epoch ?? this.epochOf(conversationId); return this.request( 'POST', `/v1/conversations/${enc(conversationId)}/approvals/${enc(response.approvalId)}`, @@ -119,6 +127,14 @@ export class DurableHttpSquadBackend implements SquadBackend { } /** Probes unversioned /health/live and /health/ready, falling back to /health. */ + private epochOf(conversationId: string): LeaseEpoch | undefined { + return this.epochs.get(conversationId) ?? this.defaultEpoch; + } + + private learn(conversationId: string, epoch: unknown): void { + if (typeof epoch === 'number') this.epochs.set(conversationId, Math.max(this.epochs.get(conversationId) ?? 0, epoch)); + } + async health(): Promise { const [live, ready] = await Promise.all([this.probe('/health/live'), this.probe('/health/ready')]); if (live.status === 'missing' && ready.status === 'missing') { diff --git a/packages/squad-sdk/src/backend/local-backend.ts b/packages/squad-sdk/src/backend/local-backend.ts index ccb5d75be..a2327b491 100644 --- a/packages/squad-sdk/src/backend/local-backend.ts +++ b/packages/squad-sdk/src/backend/local-backend.ts @@ -27,22 +27,33 @@ import type { CreateConversationOptions, HarnessEventEnvelope, HarnessRegistry, + LeaseEpoch, NegotiatedCapabilities, ProtocolHello, ReadEventsOptions, SquadBackend, } from './types.js'; +/** A harness call (cancel / approval) that was decided and persisted but not yet acknowledged. */ +type PendingDelivery = + | { id: string; kind: 'cancel'; runId: string; seq: number } + | { id: string; kind: 'approval'; runId: string; seq: number; response: ApprovalResponse }; + interface StoredConversation { info: BackendConversation; transcript: BackendMessage[]; turns: BackendTurn[]; events?: BackendEnvelope[]; approvals?: ApprovalRecord[]; + /** Set once any caller presented an epoch: from then on every write must present one. */ + fenced?: boolean; + /** Decisions awaiting an idempotent harness acknowledgement; retried until delivered. */ + deliveries?: PendingDelivery[]; } const ID_RE = /^[A-Za-z0-9._-]{1,128}$/; const ROOT = 'backend/conversations'; +const LOCKS = 'backend/locks'; export class MemoryStateStore implements BackendStateStore { private readonly files = new Map(); @@ -55,29 +66,75 @@ export class MemoryStateStore implements BackendStateStore { } } -/** File-system store rooted at a `.squad` directory. */ +function sleepSync(ms: number): void { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +const LOCK_TIMEOUT_MS = 10_000; +const LOCK_STALE_MS = 30_000; + +/** File-system store rooted at a `.squad` directory. Never follows symlinks/junctions below the root. */ export function createFileStateStore(squadDir: string): BackendStateStore { + const root = path.resolve(squadDir); const resolve = (rel: string): string => { - const full = path.resolve(squadDir, rel); - if (!full.startsWith(path.resolve(squadDir) + path.sep)) { + const full = path.resolve(root, rel); + if (!full.startsWith(root + path.sep)) { throw new BackendError('invalid-request', 'Path escapes state directory', 400); } + // a lexical prefix check is not containment: reject any existing symlink/junction between root and target + let cur = root; + for (const part of path.relative(root, full).split(path.sep)) { + cur = path.join(cur, part); + let st: fs.Stats; + try { st = fs.lstatSync(cur); } catch { break; } // the rest does not exist yet + if (st.isSymbolicLink()) { + throw new BackendError('invalid-request', 'State path contains a symbolic link or junction', 400); + } + } return full; }; return { + lock: key => { + const lockPath = resolve(`${key}.lock`); + fs.mkdirSync(path.dirname(lockPath), { recursive: true }); + const deadline = Date.now() + LOCK_TIMEOUT_MS; + for (;;) { + try { + const fd = fs.openSync(lockPath, 'wx'); + fs.writeSync(fd, String(process.pid)); + fs.closeSync(fd); + break; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err; + try { + if (Date.now() - fs.statSync(lockPath).mtimeMs > LOCK_STALE_MS) { fs.rmSync(lockPath, { force: true }); continue; } + } catch { continue; } + if (Date.now() > deadline) throw new BackendError('unavailable', 'Timed out waiting for the conversation lock', 503); + sleepSync(10); + } + } + return () => { try { fs.rmSync(lockPath, { force: true }); } catch { /* already gone */ } }; + }, read: rel => { - try { return fs.readFileSync(resolve(rel), 'utf8'); } catch { return undefined; } + try { return fs.readFileSync(resolve(rel), 'utf8'); } catch (e) { + if (e instanceof BackendError) throw e; + return undefined; + } }, write: (rel, content) => { const full = resolve(rel); fs.mkdirSync(path.dirname(full), { recursive: true }); - const tmp = `${full}.${process.pid}.tmp`; - fs.writeFileSync(tmp, content, 'utf8'); + resolve(rel); // re-check after directories were created + const tmp = `${full}.${process.pid}.${randomUUID()}.tmp`; + fs.writeFileSync(tmp, content, { encoding: 'utf8', flag: 'wx' }); fs.renameSync(tmp, full); }, exists: rel => fs.existsSync(resolve(rel)), list: rel => { - try { return fs.readdirSync(resolve(rel)); } catch { return []; } + try { return fs.readdirSync(resolve(rel)); } catch (e) { + if (e instanceof BackendError) throw e; + return []; + } }, }; } @@ -104,19 +161,24 @@ export class LocalSquadBackend implements SquadBackend { validateExtensions(options.metadata); const id = options.conversationId ?? randomUUID(); this.assertId(id); - const existing = this.load(id, false); - if (existing) return existing.info; // idempotent create - const info: BackendConversation = { - conversationId: id, - epoch: 1, - createdAt: new Date().toISOString(), - title: options.title, - metadata: options.metadata, - }; - const conv: StoredConversation = { info, transcript: [], turns: [], events: [] }; - this.emit(conv, 'control', 'conversation.created', { conversationId: id }); - this.save(conv); - return info; + const release = this.state.lock?.(`${LOCKS}/${id}`); + try { + const existing = this.load(id, false); + if (existing) return existing.info; // idempotent create + const info: BackendConversation = { + conversationId: id, + epoch: 1, + createdAt: new Date().toISOString(), + title: options.title, + metadata: options.metadata, + }; + const conv: StoredConversation = { info, transcript: [], turns: [], events: [] }; + this.emit(conv, 'control', 'conversation.created', { conversationId: id }); + this.save(conv); + return info; + } finally { + release?.(); + } } async sendMessage(conversationId: string, message: BackendMessage): Promise { @@ -124,61 +186,70 @@ export class LocalSquadBackend implements SquadBackend { if (!message?.runId || !Number.isInteger(message.seq) || message.seq < 0) { throw new BackendError('invalid-request', 'message requires runId and a non-negative integer seq', 400); } - const conv = this.load(conversationId); - this.checkEpoch(conv, message.epoch); - const dup = conv.turns.find(t => t.runId === message.runId && t.seq === message.seq); - if (dup) { - return { - protocol: BACKEND_PROTOCOL_VERSION, conversationId, runId: dup.runId, seq: dup.seq, duplicate: true, - state: dup.state, epoch: conv.info.epoch, correlationId: message.correlationId, cursor: this.tailCursor(conv), - }; - } - - const adapter = message.harness ? this.harnesses.get(message.harness) : undefined; - if (message.harness && !adapter) { - throw new BackendError('invalid-request', `Unknown harness '${message.harness}'`, 400); - } - validateExtensions(message.extensions, adapter?.capabilities.extensionNamespaces); - - const stored: BackendMessage = { ...message, epoch: conv.info.epoch }; - const turn: BackendTurn = { - conversationId, - runId: message.runId, - seq: message.seq, - state: adapter ? 'running' : 'queued', - tasks: [], - messages: [stored], - extensions: message.extensions, - }; - conv.transcript.push(stored); - conv.turns.push(turn); - const accepted = this.emit(conv, 'control', 'message.accepted', - { runId: message.runId, seq: message.seq, state: turn.state }, message.correlationId, message.causationId); - this.save(conv); + const msg = message; + const admitted = this.tx(conversationId, (conv) => { + this.checkEpoch(conv, msg.epoch); + const dup = conv.turns.find(t => t.runId === msg.runId && t.seq === msg.seq); + if (dup) { + return { + duplicate: true as const, + admission: { + protocol: BACKEND_PROTOCOL_VERSION, conversationId, runId: dup.runId, seq: dup.seq, duplicate: true, + state: dup.state, epoch: conv.info.epoch, correlationId: msg.correlationId, cursor: this.tailCursor(conv), + } satisfies BackendAdmission, + }; + } + const adapter = msg.harness ? this.harnesses.get(msg.harness) : undefined; + if (msg.harness && !adapter) { + throw new BackendError('invalid-request', `Unknown harness '${msg.harness}'`, 400); + } + validateExtensions(msg.extensions, adapter?.capabilities.extensionNamespaces); + + const epoch = conv.info.epoch; + const stored: BackendMessage = { ...msg, epoch }; + const turn: BackendTurn = { + conversationId, + runId: msg.runId, + seq: msg.seq, + state: adapter ? 'running' : 'queued', + tasks: [], + messages: [stored], + extensions: msg.extensions, + epoch, + }; + conv.transcript.push(stored); + conv.turns.push(turn); + const accepted = this.emit(conv, 'control', 'message.accepted', + { runId: msg.runId, seq: msg.seq, state: turn.state }, msg.correlationId, msg.causationId); + return { duplicate: false as const, adapter, stored, epoch, accepted }; + }); + if (admitted.duplicate) return admitted.admission; + + const { adapter, stored, epoch, accepted } = admitted; if (adapter) { this.ensureSubscribed(conversationId, adapter.id); try { await adapter.submit({ - conversationId, runId: message.runId, seq: message.seq, - messages: [stored], extensions: message.extensions, + conversationId, runId: msg.runId, seq: msg.seq, + messages: [stored], extensions: msg.extensions, epoch, }); } catch (err) { - const fresh = this.load(conversationId); - const t = this.findTurn(fresh, message.runId, message.seq); - if (t && !TERMINAL_RUN_STATES.includes(t.state)) { - t.state = 'failed'; - t.error = err instanceof Error ? err.message : String(err); - this.emit(fresh, 'control', 'turn.state', { runId: t.runId, seq: t.seq, state: 'failed', error: t.error }, message.correlationId, accepted.id); - this.save(fresh); - } + this.tx(conversationId, (fresh) => { + const t = this.findTurn(fresh, msg.runId, msg.seq); + if (t && !TERMINAL_RUN_STATES.includes(t.state)) { + t.state = 'failed'; + t.error = err instanceof Error ? err.message : String(err); + this.emit(fresh, 'control', 'turn.state', { runId: t.runId, seq: t.seq, state: 'failed', error: t.error }, msg.correlationId, accepted.id); + } + }); } } const latest = this.load(conversationId); - const final = this.findTurn(latest, message.runId, message.seq)!; + const final = this.findTurn(latest, msg.runId, msg.seq)!; return { protocol: BACKEND_PROTOCOL_VERSION, conversationId, runId: final.runId, seq: final.seq, duplicate: false, - state: final.state, epoch: conv.info.epoch, correlationId: message.correlationId, cursor: accepted.cursor, + state: final.state, epoch: latest.info.epoch, correlationId: msg.correlationId, cursor: accepted.cursor, }; } @@ -190,50 +261,71 @@ export class LocalSquadBackend implements SquadBackend { return this.requireTurn(this.load(conversationId), seq); } - async cancelTurn(conversationId: string, seq: number): Promise { - const conv = this.load(conversationId); - const turn = this.requireTurn(conv, seq); - await this.cancelInternal(conv, turn); - return turn; + async cancelTurn(conversationId: string, seq: number, epoch?: LeaseEpoch): Promise { + const target = this.tx(conversationId, (conv) => { + this.checkEpoch(conv, epoch); + const turn = this.requireTurn(conv, seq); + this.markCancelled(conv, turn); + return { runId: turn.runId, seq: turn.seq }; + }); + await this.flushDeliveries(conversationId); + return this.findTurn(this.load(conversationId), target.runId, target.seq)!; } - async cancelRun(conversationId: string, runId: string, reason?: string): Promise { - const conv = this.load(conversationId); - const turns = conv.turns.filter(t => t.runId === runId); - if (turns.length === 0) throw new BackendError('not-found', `Run '${runId}' not found`, 404); - for (const turn of turns) await this.cancelInternal(conv, turn, reason); - return turns[turns.length - 1]!; + async cancelRun(conversationId: string, runId: string, reason?: string, epoch?: LeaseEpoch): Promise { + const last = this.tx(conversationId, (conv) => { + this.checkEpoch(conv, epoch); + const turns = conv.turns.filter(t => t.runId === runId); + if (turns.length === 0) throw new BackendError('not-found', `Run '${runId}' not found`, 404); + for (const turn of turns) this.markCancelled(conv, turn, reason); + const tail = turns[turns.length - 1]!; + return { runId: tail.runId, seq: tail.seq }; + }); + // harness calls happen outside the transaction; each acknowledgement reloads fresh state + await this.flushDeliveries(conversationId); + return this.findTurn(this.load(conversationId), last.runId, last.seq)!; } async getApprovals(conversationId: string): Promise { return this.load(conversationId).approvals ?? []; } + async respondApproval(conversationId: string, response: ApprovalResponse): Promise { - const conv = this.load(conversationId); - this.checkEpoch(conv, response.epoch); - const turn = conv.turns.find(t => t.pendingApproval?.approvalId === response.approvalId); - if (!turn || !turn.pendingApproval) { - throw new BackendError('approval-not-pending', `No pending approval '${response.approvalId}'`, 409); - } - if (turn.pendingApproval.hash !== response.hash) { - throw new BackendError('approval-hash-mismatch', 'Approval hash does not match the pending request', 409); - } - turn.pendingApproval = undefined; - turn.state = response.decision === 'approve' ? 'running' : 'cancelled'; - const record = (conv.approvals ?? []).find(a => a.approvalId === response.approvalId); - if (record) { - record.version = 2; - record.state = response.decision === 'approve' ? 'approved' : 'denied'; - record.decision = response.decision; - record.reason = response.reason; - record.decidedAt = new Date().toISOString(); - } - this.emit(conv, 'control', 'approval.resolved', - { runId: turn.runId, seq: turn.seq, approvalId: response.approvalId, decision: response.decision, version: 2, state: turn.state }); - this.save(conv); - const adapter = this.adapterFor(turn); - if (adapter) await adapter.respondApproval(conversationId, response); - return this.requireTurn(this.load(conversationId), turn.seq); + const target = this.tx(conversationId, (conv) => { + this.checkEpoch(conv, response.epoch); + const turn = conv.turns.find(t => t.pendingApproval?.approvalId === response.approvalId); + if (!turn || !turn.pendingApproval || turn.state !== 'awaiting-approval') { + // an already-decided approval whose harness acknowledgement is still outstanding may be retried verbatim + const outstanding = (conv.deliveries ?? []).find(d => d.kind === 'approval' + && d.response.approvalId === response.approvalId + && d.response.hash === response.hash + && d.response.decision === response.decision); + if (outstanding) return { runId: outstanding.runId, seq: outstanding.seq }; + throw new BackendError('approval-not-pending', `No pending approval '${response.approvalId}'`, 409); + } + if (turn.pendingApproval.hash !== response.hash) { + throw new BackendError('approval-hash-mismatch', 'Approval hash does not match the pending request', 409); + } + turn.pendingApproval = undefined; + turn.state = response.decision === 'approve' ? 'running' : 'cancelled'; + const record = (conv.approvals ?? []).find(a => a.approvalId === response.approvalId); + if (record) { + record.version = 2; + record.state = response.decision === 'approve' ? 'approved' : 'denied'; + record.decision = response.decision; + record.reason = response.reason; + record.decidedAt = new Date().toISOString(); + } + this.emit(conv, 'control', 'approval.resolved', + { runId: turn.runId, seq: turn.seq, approvalId: response.approvalId, decision: response.decision, version: 2, state: turn.state }); + if (this.adapterFor(turn)) { + (conv.deliveries ??= []).push({ id: randomUUID(), kind: 'approval', runId: turn.runId, seq: turn.seq, response }); + } + return { runId: turn.runId, seq: turn.seq }; + }); + await this.flushDeliveries(conversationId); + // reload by the approval's own (runId, seq): different runs may share a seq + return this.findTurn(this.load(conversationId), target.runId, target.seq)!; } async negotiate(hello: ProtocolHello): Promise { @@ -261,6 +353,7 @@ export class LocalSquadBackend implements SquadBackend { harnesses: this.harnesses.list().map(h => h.id), }; } + async readEvents(conversationId: string, options: ReadEventsOptions = {}): Promise { const conv = this.load(conversationId); const tail = conv.events?.[conv.events.length - 1]?.sequence ?? 0; @@ -280,12 +373,30 @@ export class LocalSquadBackend implements SquadBackend { resumable: true, }; } + async health(): Promise { return { ok: true, live: true, ready: true, backend: this.kind, detail: `${this.harnesses.list().length} harness(es)` }; } // ── internals ───────────────────────────────────────────────────── + /** + * One complete read-modify-write of a conversation, serialized across processes by the store lock + * (when the store provides one). Harness calls must never run inside `fn`. + */ + private tx(conversationId: string, fn: (conv: StoredConversation) => T): T { + this.assertId(conversationId); + const release = this.state.lock?.(`${LOCKS}/${conversationId}`); + try { + const conv = this.load(conversationId); + const result = fn(conv); + this.save(conv); + return result; + } finally { + release?.(); + } + } + private emit( conv: StoredConversation, stream: BackendEnvelope['stream'], @@ -312,17 +423,58 @@ export class LocalSquadBackend implements SquadBackend { return conv.events?.[conv.events.length - 1]?.cursor ?? encodeCursor(conv.info.conversationId, 0); } - private async cancelInternal(conv: StoredConversation, turn: BackendTurn, reason?: string): Promise { + /** Resolve a still-pending approval as cancelled: advance its version and stamp the decision time. */ + private cancelPendingApproval(conv: StoredConversation, turn: BackendTurn): void { + const approvalId = turn.pendingApproval?.approvalId; + if (!approvalId) return; + const record = (conv.approvals ?? []).find(a => a.approvalId === approvalId); + if (record && record.state === 'pending') { + record.state = 'cancelled'; + record.version = 2; + record.decidedAt = new Date().toISOString(); + this.emit(conv, 'control', 'approval.resolved', + { runId: turn.runId, seq: turn.seq, approvalId, version: 2, state: 'cancelled' }); + } + turn.pendingApproval = undefined; + } + + /** Persist the cancellation (and an outstanding harness delivery); the harness call happens later. */ + private markCancelled(conv: StoredConversation, turn: BackendTurn, reason?: string): void { if (TERMINAL_RUN_STATES.includes(turn.state)) return; - const record = (conv.approvals ?? []).find(a => a.approvalId === turn.pendingApproval?.approvalId); - if (record) { record.state = 'cancelled'; } + this.cancelPendingApproval(conv, turn); turn.state = 'cancelled'; - turn.pendingApproval = undefined; const requested = this.emit(conv, 'control', 'cancellation.requested', { runId: turn.runId, seq: turn.seq, reason }); this.emit(conv, 'control', 'turn.state', { runId: turn.runId, seq: turn.seq, state: 'cancelled' }, undefined, requested.id); - this.save(conv); - const adapter = this.adapterFor(turn); - if (adapter) await adapter.cancel(conv.info.conversationId, turn.runId, turn.seq); + if (this.adapterFor(turn)) { + (conv.deliveries ??= []).push({ id: randomUUID(), kind: 'cancel', runId: turn.runId, seq: turn.seq }); + } + } + + /** + * Deliver persisted decisions to their harnesses. A delivery is removed only after the harness acknowledged + * it, so a failure leaves it queued and a verbatim retry re-delivers; adapters must treat `cancel` and + * `respondApproval` as idempotent. Failure surfaces as a retryable `unavailable` error. + */ + private async flushDeliveries(conversationId: string): Promise { + for (;;) { + const conv = this.load(conversationId); + const next = conv.deliveries?.[0]; + if (!next) return; + const turn = this.findTurn(conv, next.runId, next.seq); + const adapter = turn ? this.adapterFor(turn) : undefined; + try { + if (adapter) { + if (next.kind === 'cancel') await adapter.cancel(conversationId, next.runId, next.seq); + else await adapter.respondApproval(conversationId, next.response); + } + } catch (err) { + throw new BackendError('unavailable', + `Harness ${next.kind} delivery failed and will be retried: ${err instanceof Error ? err.message : String(err)}`, 503); + } + this.tx(conversationId, (fresh) => { + fresh.deliveries = (fresh.deliveries ?? []).filter(d => d.id !== next.id); + }); + } } private adapterFor(turn: BackendTurn) { @@ -348,12 +500,21 @@ export class LocalSquadBackend implements SquadBackend { } private applyEvent(conversationId: string, incoming: HarnessEventEnvelope): void { - const ev = incoming; + try { + this.tx(conversationId, conv => this.applyEventLocked(conv, incoming)); + } catch (err) { + if (err instanceof BackendError && err.code === 'not-found') return; + throw err; + } + } + + private applyEventLocked(conv: StoredConversation, ev: HarnessEventEnvelope): void { let emitted: object = ev; - const conv = this.load(conversationId, false); - if (!conv) return; const turn = this.findTurn(conv, ev.runId, ev.seq); if (!turn || TERMINAL_RUN_STATES.includes(turn.state)) return; + // fencing: output issued under an older lease than the conversation's current one is stale + const eventEpoch = ev.epoch ?? turn.epoch ?? 1; + if (eventEpoch < conv.info.epoch) return; switch (ev.type) { case 'message': if (turn.messages.some(m => m.runId === ev.message.runId && m.seq === ev.message.seq && m.role === ev.message.role && m.content === ev.message.content)) return; @@ -384,6 +545,8 @@ export class LocalSquadBackend implements SquadBackend { case 'state': if (turn.state === ev.state) return; if (turn.pendingApproval && !TERMINAL_RUN_STATES.includes(ev.state)) return; // only a decision resumes an awaiting turn + // a terminal state closes any approval still pending so a late decision cannot revive the turn + if (TERMINAL_RUN_STATES.includes(ev.state)) this.cancelPendingApproval(conv, turn); turn.state = ev.state; if (ev.error) turn.error = ev.error; break; @@ -391,18 +554,26 @@ export class LocalSquadBackend implements SquadBackend { this.emit(conv, ev.type === 'state' || ev.type === 'approval-requested' ? 'control' : 'execution', ev.type === 'state' ? 'turn.state' : ev.type === 'approval-requested' ? 'approval.requested' : `harness.${ev.type}`, { ...emitted }); - this.save(conv); } + /** + * Lease fencing. Once any caller has presented an epoch the conversation is fenced: writes must present the + * current (or a newer, taking over) epoch, and omitting it is rejected rather than treated as a bypass. + */ private checkEpoch(conv: StoredConversation, epoch: number | undefined): void { - if (epoch === undefined) return; + if (epoch === undefined) { + if (conv.fenced) { + throw new BackendError('stale-epoch', + `Conversation is lease-fenced at epoch ${conv.info.epoch}; the write must present its epoch`, 409); + } + return; + } + if (!Number.isInteger(epoch) || epoch < 1) throw new BackendError('invalid-request', 'epoch must be a positive integer', 400); if (epoch < conv.info.epoch) { throw new BackendError('stale-epoch', `Epoch ${epoch} is stale; current epoch is ${conv.info.epoch}`, 409); } - if (epoch > conv.info.epoch) { - conv.info.epoch = epoch; // newer lease holder takes over (fencing) - this.save(conv); - } + conv.fenced = true; + if (epoch > conv.info.epoch) conv.info.epoch = epoch; // newer lease holder takes over } private findTurn(conv: StoredConversation, runId: string, seq: number): BackendTurn | undefined { diff --git a/packages/squad-sdk/src/backend/types.ts b/packages/squad-sdk/src/backend/types.ts index 9c53659bd..ea778c025 100644 --- a/packages/squad-sdk/src/backend/types.ts +++ b/packages/squad-sdk/src/backend/types.ts @@ -276,6 +276,8 @@ export interface BackendTurn { pendingApproval?: ApprovalRequest; error?: string; extensions?: ExtensionBag; + /** Lease epoch the turn was admitted under; harness output is fenced against it. */ + epoch?: LeaseEpoch; } export interface BackendConversation { @@ -318,9 +320,9 @@ export interface SquadBackend { sendMessage(conversationId: string, message: BackendMessage): Promise; getTranscript(conversationId: string): Promise; getTurn(conversationId: string, seq: number): Promise; - cancelTurn(conversationId: string, seq: number): Promise; + cancelTurn(conversationId: string, seq: number, epoch?: LeaseEpoch): Promise; /** Cancel by stable runId (distinct from turn seq). Idempotent. */ - cancelRun(conversationId: string, runId: string, reason?: string): Promise; + cancelRun(conversationId: string, runId: string, reason?: string, epoch?: LeaseEpoch): Promise; /** Durable approval resources, in request order. */ getApprovals(conversationId: string): Promise; respondApproval(conversationId: string, response: ApprovalResponse): Promise; @@ -347,6 +349,8 @@ export interface HarnessSubmission { seq: number; messages: BackendMessage[]; extensions?: ExtensionBag; + /** Lease epoch of the admission; echo it on every event for the run. */ + epoch?: LeaseEpoch; } export type HarnessEvent = @@ -355,7 +359,11 @@ export type HarnessEvent = | { type: 'approval-requested'; request: HarnessApprovalRequest } | { type: 'state'; state: RunState; error?: string }; -export type HarnessEventEnvelope = HarnessEvent & { runId: string; seq: number }; +/** + * Harness output for a run. `epoch` is the lease epoch the harness is acting under; when omitted the + * turn's admission epoch applies. Output whose epoch is older than the conversation's is fenced off. + */ +export type HarnessEventEnvelope = HarnessEvent & { runId: string; seq: number; epoch?: LeaseEpoch }; export interface HarnessDescriptor { id: string; @@ -395,6 +403,11 @@ export type BackendConfig = /** Minimal subset of the SDK `StateBackend` used for persistence. */ export interface BackendStateStore { + /** + * Cross-process mutual exclusion for a read-modify-write of one key. Returns the release function. + * Stores shared between processes MUST implement it; single-process stores may omit it. + */ + lock?(key: string): () => void; read(relativePath: string): string | undefined; write(relativePath: string, content: string): void; exists(relativePath: string): boolean; diff --git a/test/backend.test.ts b/test/backend.test.ts index ae1d9bbfe..781880a94 100644 --- a/test/backend.test.ts +++ b/test/backend.test.ts @@ -6,6 +6,7 @@ import { DurableHttpSquadBackend, LocalSquadBackend, MemoryStateStore, + createFileStateStore, createSquadBackend, extensionNamespace, validateExtensions, @@ -483,3 +484,166 @@ describe('createSquadBackend', () => { expect(createSquadBackend({ kind: 'durable-http', baseUrl: 'http://x' }).kind).toBe('durable-http'); }); }); + +describe('PR #2238 review regressions (local backend)', () => { + const setup = (opts: { failApprovals?: { n: number }; state?: MemoryStateStore } = {}) => { + const h = new FakeHarness(); + const origApprove = h.respondApproval.bind(h); + h.respondApproval = async (c: string, r: { approvalId: string }) => { + if (opts.failApprovals && opts.failApprovals.n > 0) { opts.failApprovals.n--; throw new Error('harness down'); } + return origApprove(c, r); + }; + const harnesses = new DefaultHarnessRegistry(); + harnesses.register(h); + const state = opts.state ?? new MemoryStateStore(); + return { h, harnesses, state, make: () => new LocalSquadBackend({ state, harnesses }) }; + }; + const ask = (runId: string, seq: number, id = 'a1') => + ({ type: 'approval-requested' as const, runId, seq, request: { approvalId: id, turnSeq: seq, action: 'x', hash: 'h' } }); + + it('1. rejects symlinked/junctioned state paths', async () => { + const fs = await import('node:fs'); + const os = await import('node:os'); + const path = await import('node:path'); + const base = fs.mkdtempSync(path.join(os.tmpdir(), 'sq-sym-')); + try { + const root = path.join(base, 'squad'); const outside = path.join(base, 'outside'); + fs.mkdirSync(root); fs.mkdirSync(outside); + fs.symlinkSync(outside, path.join(root, 'backend'), 'junction'); + const b = new LocalSquadBackend({ state: createFileStateStore(root) }); + expect(await rejectCode(b.createConversation({ conversationId: 'c' }))).toBe('invalid-request'); + expect(fs.readdirSync(outside)).toEqual([]); + } finally { fs.rmSync(base, { recursive: true, force: true }); } + }); + + it('2+6. concurrent writers on one file store never lose an update', async () => { + const fs = await import('node:fs'); + const os = await import('node:os'); + const path = await import('node:path'); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sq-lock-')); + try { + const a = new LocalSquadBackend({ state: createFileStateStore(dir) }); + const b = new LocalSquadBackend({ state: createFileStateStore(dir) }); + await a.createConversation({ conversationId: 'c' }); + await Promise.all(Array.from({ length: 12 }, (_, i) => (i % 2 ? a : b).sendMessage('c', msg(`r${i}`, 1)))); + await Promise.all([a.cancelRun('c', 'r0'), b.cancelRun('c', 'r1')]); + expect(await a.getTranscript('c')).toHaveLength(12); + expect((await b.getTurn('c', 1)).runId).toBeDefined(); + expect((await a.readEvents('c')).events.filter(e => e.type === 'turn.cancelled' || /cancel/.test(e.type)).length).toBeGreaterThanOrEqual(2); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }); + + it('3. a terminal harness event cancels the pending approval; a late approval cannot revive the turn', async () => { + const { h, make } = setup(); + const b = make(); + await b.createConversation({ conversationId: 'c' }); + await b.sendMessage('c', msg('r', 1, { harness: 'copilot' })); + h.emit(ask('r', 1)); + h.emit({ type: 'state', state: 'completed', runId: 'r', seq: 1 }); + expect((await b.getApprovals('c'))[0]).toMatchObject({ state: 'cancelled', version: 2 }); + expect(await rejectCode(b.respondApproval('c', { approvalId: 'a1', hash: 'h', decision: 'approve' }))).toBe('approval-not-pending'); + expect((await b.getTurn('c', 1)).state).toBe('completed'); + }); + + it('4. harness events from a superseded epoch are dropped', async () => { + const { h, make } = setup(); + const b = make(); + await b.createConversation({ conversationId: 'c' }); + await b.sendMessage('c', msg('r', 1, { harness: 'copilot', epoch: 2 })); + h.emit({ type: 'state', state: 'completed', runId: 'r', seq: 1, epoch: 1 }); + expect((await b.getTurn('c', 1)).state).not.toBe('completed'); + h.emit({ type: 'state', state: 'completed', runId: 'r', seq: 1, epoch: 2 }); + expect((await b.getTurn('c', 1)).state).toBe('completed'); + }); + + it('5. omitting the epoch on a fenced conversation is rejected for every write', async () => { + const { make } = setup(); + const b = make(); + await b.createConversation({ conversationId: 'c' }); + await b.sendMessage('c', msg('r', 1, { epoch: 3 })); + expect(await rejectCode(b.sendMessage('c', msg('r', 2)))).toBe('stale-epoch'); + expect(await rejectCode(b.cancelTurn('c', 1))).toBe('stale-epoch'); + expect(await rejectCode(b.cancelRun('c', 'r'))).toBe('stale-epoch'); + expect((await b.cancelRun('c', 'r', 'ok', 3)).state).toBe('cancelled'); + }); + + it('7. a failed approval delivery is retryable and clears once the harness acknowledges', async () => { + const fail = { n: 1 }; + const { h, make } = setup({ failApprovals: fail }); + const b = make(); + await b.createConversation({ conversationId: 'c' }); + await b.sendMessage('c', msg('r', 1, { harness: 'copilot' })); + h.emit(ask('r', 1)); + const first = await b.respondApproval('c', { approvalId: 'a1', hash: 'h', decision: 'approve' }).catch(e => e); + expect(first).toBeInstanceOf(BackendError); + expect(first).toMatchObject({ code: 'unavailable', retryable: true }); + expect(h.approvals).toEqual([]); + const turn = await b.respondApproval('c', { approvalId: 'a1', hash: 'h', decision: 'approve' }); + expect(turn.state).toBe('running'); + expect(h.approvals).toEqual(['a1']); + await b.respondApproval('c', { approvalId: 'a1', hash: 'h', decision: 'approve' }).catch(() => undefined); + expect(h.approvals).toEqual(['a1']); + }); + + it('8. run identity (runId, seq) survives reload and selects the right turn', async () => { + const { h, make, state } = setup(); + const b = make(); + await b.createConversation({ conversationId: 'c' }); + await b.sendMessage('c', msg('r1', 1, { harness: 'copilot' })); + await b.sendMessage('c', msg('r2', 1, { harness: 'copilot' })); + h.emit(ask('r2', 1, 'a2')); + const reloaded = new LocalSquadBackend({ state, harnesses: setup().harnesses }); + const turn = await reloaded.respondApproval('c', { approvalId: 'a2', hash: 'h', decision: 'deny' }); + expect(turn).toMatchObject({ runId: 'r2', seq: 1 }); + expect((await reloaded.cancelRun('c', 'r1')).runId).toBe('r1'); + }); + + it('9. cancelling a pending approval bumps version and timestamp', async () => { + const { h, make } = setup(); + const b = make(); + await b.createConversation({ conversationId: 'c' }); + await b.sendMessage('c', msg('r', 1, { harness: 'copilot' })); + h.emit(ask('r', 1)); + const before = (await b.getApprovals('c'))[0]!; + await b.cancelRun('c', 'r'); + const after = (await b.getApprovals('c'))[0]!; + expect(before.version).toBe(1); + expect(after).toMatchObject({ state: 'cancelled', version: 2 }); + expect(after.decidedAt).toBeDefined(); + }); +}); + +describe('PR #2238 review regressions (durable-http)', () => { + const mkClient = (handler: (url: string, init: RequestInit) => Promise | Response, extra: object = {}) => + new DurableHttpSquadBackend({ baseUrl: 'http://d.test', token: 't', retryDelayMs: 1, fetch: (async (u: string, i: RequestInit) => handler(String(u), i ?? {})) as never, ...extra }); + const json = (status: number, body: unknown) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); + + it('10. createConversation retries reuse one conversation id and idempotency key', async () => { + const seen: Array<{ key: string | null; id: string }> = []; + let n = 0; + const b = mkClient((_u, init) => { + const headers = new Headers(init.headers as HeadersInit); + const body = JSON.parse(String(init.body)); + seen.push({ key: headers.get('Idempotency-Key'), id: body.conversationId }); + return ++n === 1 ? json(503, { error: { code: 'unavailable', message: 'x', retryable: true } }) : json(200, { conversationId: body.conversationId, epoch: 1, createdAt: 't' }); + }); + await b.createConversation(); + expect(seen).toHaveLength(2); + expect(seen[0]!.key).toBeTruthy(); + expect(seen[1]).toEqual(seen[0]); + expect(seen[0]!.key).toContain(seen[0]!.id); + }); + + it('11. a learned epoch is scoped to its conversation', async () => { + const epochs: Array = []; + const b = mkClient((u, init) => { + epochs.push(new Headers(init.headers as HeadersInit).get('X-Lease-Epoch')); + const id = /conversations\/([^/]+)/.exec(u)![1]; + return json(200, { runId: 'r', seq: 1, duplicate: false, state: 'queued', epoch: id === 'a' ? 7 : undefined }); + }); + await b.sendMessage('a', msg('r', 1)); + await b.sendMessage('a', msg('r', 2)); + await b.sendMessage('b', msg('r', 1)); + expect(epochs).toEqual([null, '7', null]); + }); +}); \ No newline at end of file diff --git a/test/clients.test.ts b/test/clients.test.ts index 7432bc2d6..0c3c916bb 100644 --- a/test/clients.test.ts +++ b/test/clients.test.ts @@ -34,7 +34,7 @@ import { type RuntimeBinding, } from '../packages/squad-sdk/src/client/adapter-contract.js'; -import { applyProjection, removeTomlTable, uninstallClients, upsertTomlTable } from '../packages/squad-sdk/src/client/adapters/index.js'; +import { removeTomlTable, uninstallClients } from '../packages/squad-sdk/src/client/adapters/index.js'; let root: string; const mcp = { command: 'squad', args: ['state-mcp'] }; diff --git a/test/squad-20-all-scenarios.e2e.test.ts b/test/squad-20-all-scenarios.e2e.test.ts index aa757f5df..c42ef0e95 100644 --- a/test/squad-20-all-scenarios.e2e.test.ts +++ b/test/squad-20-all-scenarios.e2e.test.ts @@ -120,6 +120,7 @@ function createMockDurableSquad(local: LocalSquadBackend) { const method = init.method ?? 'GET'; const headers = (init.headers ?? {}) as Record; const body = init.body ? JSON.parse(init.body as string) : undefined; + const leaseEpoch = headers['X-Lease-Epoch'] === undefined ? undefined : Number(headers['X-Lease-Epoch']); calls.push({ method, path: url.pathname + url.search, headers, body }); const fail = failures.get(`${method} ${url.pathname}`); @@ -148,8 +149,8 @@ function createMockDurableSquad(local: LocalSquadBackend) { } if (method === 'GET' && rest === 'approvals') return json(200, { approvals: await local.getApprovals(id) }); if ((r = /^turns\/(\d+)$/.exec(rest)) && method === 'GET') return json(200, await local.getTurn(id, Number(r[1]))); - if ((r = /^turns\/(\d+)\/cancel$/.exec(rest)) && method === 'POST') return json(200, await local.cancelTurn(id, Number(r[1]))); - if ((r = /^runs\/([^/]+)\/cancel$/.exec(rest)) && method === 'POST') return json(200, await local.cancelRun(id, decodeURIComponent(r[1]!), body?.reason)); + if ((r = /^turns\/(\d+)\/cancel$/.exec(rest)) && method === 'POST') return json(200, await local.cancelTurn(id, Number(r[1]), leaseEpoch)); + if ((r = /^runs\/([^/]+)\/cancel$/.exec(rest)) && method === 'POST') return json(200, await local.cancelRun(id, decodeURIComponent(r[1]!), body?.reason, leaseEpoch)); if ((r = /^approvals\/([^/]+)$/.exec(rest)) && method === 'POST') return json(200, await local.respondApproval(id, body)); } return json(404, { error: { code: 'not-found', message: `no route ${method} ${url.pathname}`, retryable: false } }); @@ -626,7 +627,7 @@ describe('Scenario 6: golden-trace.json conformance (local and durable-http)', ( const state = new MemoryStateStore(); const local = new LocalSquadBackend({ state, harnesses: registry }); const server = createMockDurableSquad(local); - const connect = (): SquadBackend => (mode === 'local' ? new LocalSquadBackend({ state, harnesses: registry }) : mkHttp(server)); + const connect = (): SquadBackend => (mode === 'local' ? new LocalSquadBackend({ state, harnesses: registry }) : mkHttp(server, { epoch: 1 })); let client = connect(); const { conversationId, runId } = golden; await client.createConversation({ conversationId }); @@ -1166,7 +1167,7 @@ describe('RC1→stable exit 3: clean-room client and harness against the public connect = () => create({ kind: 'local', squadDir: dir, harnesses }); } else { const server = createMockDurableSquad(create({ kind: 'local', squadDir: dir, harnesses }) as LocalSquadBackend); - connect = () => mkHttp(server); + connect = () => mkHttp(server, { epoch: 1 }); } const r = await runFixture(connect, golden); From 03778747524865958b2f629c0db1d697366802c0 Mon Sep 17 00:00:00 2001 From: Copilot Date: Sun, 18 Oct 2026 21:05:58 +0300 Subject: [PATCH 42/46] test(backend): race/fault matrix for PR #2238 review (orderings, restart, lost response, per-conversation epochs, symlink parents) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 995eea17-c356-4912-90ea-5e5b144e7ba9 --- test/backend.test.ts | 142 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) diff --git a/test/backend.test.ts b/test/backend.test.ts index 781880a94..8b2b15201 100644 --- a/test/backend.test.ts +++ b/test/backend.test.ts @@ -646,4 +646,146 @@ describe('PR #2238 review regressions (durable-http)', () => { await b.sendMessage('b', msg('r', 1)); expect(epochs).toEqual([null, '7', null]); }); +}); +describe('PR #2238 race/fault matrix', () => { + const mk = () => { + const h = new FakeHarness(); + const harnesses = new DefaultHarnessRegistry(); + harnesses.register(h); + const state = new MemoryStateStore(); + return { h, harnesses, state, make: () => new LocalSquadBackend({ state, harnesses }) }; + }; + const ask = (runId: string) => ({ type: 'approval-requested' as const, runId, seq: 1, request: { approvalId: 'a1', turnSeq: 1, action: 'x', hash: 'h' } }); + const done = (runId: string) => ({ type: 'state' as const, state: 'completed' as const, runId, seq: 1 }); + const approve = (b: LocalSquadBackend) => b.respondApproval('c', { approvalId: 'a1', hash: 'h', decision: 'approve' }); + + it('2. a missing leaf under a symlinked parent cannot escape, and a swapped parent is caught', async () => { + const fs = await import('node:fs'); + const os = await import('node:os'); + const path = await import('node:path'); + const base = fs.mkdtempSync(path.join(os.tmpdir(), 'sq-swap-')); + try { + const root = path.join(base, 'squad'); const outside = path.join(base, 'outside'); + fs.mkdirSync(path.join(root, 'backend', 'conversations'), { recursive: true }); fs.mkdirSync(outside); + const store = createFileStateStore(root); + store.write('backend/conversations/ok.json', '{}'); + fs.rmSync(path.join(root, 'backend', 'conversations'), { recursive: true }); + fs.symlinkSync(outside, path.join(root, 'backend', 'conversations'), 'junction'); + expect(() => store.write('backend/conversations/new/leaf.json', '{}')).toThrow(/symbolic link|escapes/); + expect(() => store.read('backend/conversations/ok.json')).toThrow(/symbolic link/); + expect(fs.readdirSync(outside)).toEqual([]); + } finally { fs.rmSync(base, { recursive: true, force: true }); } + }); + + it('1. interleaved writers from two instances serialize without lost updates', async () => { + const { make, h } = mk(); + const a = make(); const b = make(); + await a.createConversation({ conversationId: 'c' }); + await Promise.all([a.sendMessage('c', msg('r1', 1, { harness: 'copilot' })), b.sendMessage('c', msg('r2', 1, { harness: 'copilot' }))]); + h.emit(ask('r1')); + await Promise.all([a.cancelRun('c', 'r1'), b.cancelRun('c', 'r2')]); + expect((await a.getTurn('c', 1)).runId).toBeDefined(); + expect((await a.getTranscript('c')).map(m => m.runId).sort()).toEqual(['r1', 'r2']); + expect((await b.getApprovals('c'))[0]).toMatchObject({ state: 'cancelled', version: 2 }); + }); + + it('3. stale or missing epoch fails closed for mutations and harness events', async () => { + const { make, h } = mk(); + const b = make(); + await b.createConversation({ conversationId: 'c' }); + await b.sendMessage('c', msg('r', 1, { harness: 'copilot', epoch: 4 })); + h.emit(ask('r')); + expect(await rejectCode(b.respondApproval('c', { approvalId: 'a1', hash: 'h', decision: 'approve', epoch: 3 }))).toBe('stale-epoch'); + h.emit({ ...done('r'), epoch: 3 }); + expect((await b.getTurn('c', 1)).state).not.toBe('completed'); + expect(await rejectCode(b.cancelTurn('c', 1, 2))).toBe('stale-epoch'); + }); + + it('5. cancel / approval / terminal event in every order never revives a terminal turn', async () => { + const orders: Array> = [ + ['cancel', 'approve', 'terminal'], ['cancel', 'terminal', 'approve'], ['approve', 'cancel', 'terminal'], + ['approve', 'terminal', 'cancel'], ['terminal', 'cancel', 'approve'], ['terminal', 'approve', 'cancel'], + ]; + for (const order of orders) { + const { make, h } = mk(); + const b = make(); + await b.createConversation({ conversationId: 'c' }); + await b.sendMessage('c', msg('r', 1, { harness: 'copilot' })); + h.emit(ask('r')); + for (const step of order) { + if (step === 'cancel') await b.cancelRun('c', 'r'); + else if (step === 'terminal') h.emit(done('r')); + else await approve(b).catch(() => undefined); + } + const turn = await b.getTurn('c', 1); + const [ap] = await b.getApprovals('c'); + expect(['running', 'queued', 'awaiting-approval'], order.join('>')).not.toContain(turn.state); + expect(ap!.state, order.join('>')).not.toBe('pending'); + expect(ap!.version, order.join('>')).toBeGreaterThanOrEqual(2); + expect(ap!.decidedAt, order.join('>')).toBeDefined(); + } + }); + + it('6. restart preserves (runId, seq), approval version and terminal state', async () => { + const { make, h, state, harnesses } = mk(); + const b = make(); + await b.createConversation({ conversationId: 'c' }); + await b.sendMessage('c', msg('r1', 1, { harness: 'copilot' })); + await b.sendMessage('c', msg('r2', 1, { harness: 'copilot' })); + h.emit(ask('r2')); + await b.cancelRun('c', 'r2'); + h.emit(done('r1')); + const re = new LocalSquadBackend({ state, harnesses }); + expect((await re.getApprovals('c'))[0]).toMatchObject({ runId: 'r2', state: 'cancelled', version: 2 }); + expect(await re.cancelRun('c', 'r2')).toMatchObject({ runId: 'r2', seq: 1, state: 'cancelled' }); + expect(await re.cancelRun('c', 'r1')).toMatchObject({ runId: 'r1', seq: 1 }); + expect((await re.sendMessage('c', msg('r1', 1, { harness: 'copilot' }))).duplicate).toBe(true); + }); + + it('4. a failed delivery rolls forward as retryable and the decision is not final until acknowledged', async () => { + const { make, h } = mk(); + const b = make(); + await b.createConversation({ conversationId: 'c' }); + await b.sendMessage('c', msg('r', 1, { harness: 'copilot' })); + h.emit(ask('r')); + const orig = h.respondApproval.bind(h); + let fail = true; + h.respondApproval = async (c: string, r: { approvalId: string }) => { if (fail) throw new Error('down'); return orig(c, r); }; + expect(await rejectCode(approve(b))).toBe('unavailable'); + expect(h.approvals).toEqual([]); + fail = false; + expect((await approve(b)).state).toBe('running'); + expect(h.approvals).toEqual(['a1']); + }); + + const json = (status: number, body: unknown) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); + + it('7. createConversation keeps one Idempotency-Key when the response is lost', async () => { + const keys: Array = []; const ids: string[] = []; + let n = 0; + const b = new DurableHttpSquadBackend({ baseUrl: 'http://d.test', token: 't', retryDelayMs: 1, fetch: (async (_u: string, init: RequestInit) => { + keys.push(new Headers(init.headers as HeadersInit).get('Idempotency-Key')); + const body = JSON.parse(String(init.body)); ids.push(body.conversationId); + if (++n < 3) throw new TypeError('connection reset'); + return json(200, { conversationId: body.conversationId, epoch: 1, createdAt: 't' }); + }) as never }); + await b.createConversation(); + expect(keys).toHaveLength(3); + expect(new Set(keys).size).toBe(1); + expect(new Set(ids).size).toBe(1); + }); + + it('8. two conversations advance independent epochs without cross-talk', async () => { + const sent: Record> = { a: [], b: [] }; + const next: Record = { a: 5, b: 2 }; + const b = new DurableHttpSquadBackend({ baseUrl: 'http://d.test', token: 't', retryDelayMs: 1, fetch: (async (u: string, init: RequestInit) => { + const id = /conversations\/([^/]+)/.exec(u)![1]!; + sent[id]!.push(new Headers(init.headers as HeadersInit).get('X-Lease-Epoch')); + return json(200, { runId: 'r', seq: 1, duplicate: false, state: 'queued', epoch: next[id] }); + }) as never }); + await b.sendMessage('a', msg('r', 1)); await b.sendMessage('b', msg('r', 1)); + next['a'] = 6; + await b.sendMessage('a', msg('r', 2)); await b.sendMessage('b', msg('r', 2)); + expect(sent).toEqual({ a: [null, '5'], b: [null, '2'] }); + }); }); \ No newline at end of file From 6451747dddad7ae0c5b0d96fb4da1296774c461b Mon Sep 17 00:00:00 2001 From: Copilot Date: Sun, 18 Oct 2026 21:34:59 +0300 Subject: [PATCH 43/46] fix(backend): lock crash recovery, monotonic approval versions, epoch-rejection audit, stable outbox operationId Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 995eea17-c356-4912-90ea-5e5b144e7ba9 --- .../squad-sdk/src/backend/local-backend.ts | 61 +++++++--- packages/squad-sdk/src/backend/types.ts | 6 +- test/backend.test.ts | 110 +++++++++++++++++- 3 files changed, 157 insertions(+), 20 deletions(-) diff --git a/packages/squad-sdk/src/backend/local-backend.ts b/packages/squad-sdk/src/backend/local-backend.ts index a2327b491..4ca76eff6 100644 --- a/packages/squad-sdk/src/backend/local-backend.ts +++ b/packages/squad-sdk/src/backend/local-backend.ts @@ -36,8 +36,8 @@ import type { /** A harness call (cancel / approval) that was decided and persisted but not yet acknowledged. */ type PendingDelivery = - | { id: string; kind: 'cancel'; runId: string; seq: number } - | { id: string; kind: 'approval'; runId: string; seq: number; response: ApprovalResponse }; + | { operationId: string; kind: 'cancel'; runId: string; seq: number } + | { operationId: string; kind: 'approval'; runId: string; seq: number; response: ApprovalResponse }; interface StoredConversation { info: BackendConversation; @@ -74,7 +74,15 @@ const LOCK_TIMEOUT_MS = 10_000; const LOCK_STALE_MS = 30_000; /** File-system store rooted at a `.squad` directory. Never follows symlinks/junctions below the root. */ -export function createFileStateStore(squadDir: string): BackendStateStore { +export interface FileStateStoreOptions { + /** A lock file untouched for longer than this is treated as abandoned by a crashed holder. */ + staleLockMs?: number; + lockTimeoutMs?: number; +} + +export function createFileStateStore(squadDir: string, options: FileStateStoreOptions = {}): BackendStateStore { + const staleLockMs = options.staleLockMs ?? LOCK_STALE_MS; + const lockTimeoutMs = options.lockTimeoutMs ?? LOCK_TIMEOUT_MS; const root = path.resolve(squadDir); const resolve = (rel: string): string => { const full = path.resolve(root, rel); @@ -97,23 +105,38 @@ export function createFileStateStore(squadDir: string): BackendStateStore { lock: key => { const lockPath = resolve(`${key}.lock`); fs.mkdirSync(path.dirname(lockPath), { recursive: true }); - const deadline = Date.now() + LOCK_TIMEOUT_MS; + const deadline = Date.now() + lockTimeoutMs; + const token = `${process.pid}:${randomUUID()}`; for (;;) { try { const fd = fs.openSync(lockPath, 'wx'); - fs.writeSync(fd, String(process.pid)); + fs.writeSync(fd, token); fs.closeSync(fd); break; } catch (err) { if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err; + // Crash recovery: an abandoned lock is claimed by atomic rename, so exactly one waiter wins it. + // The claimed file is re-checked; if it turns out to be a live holder's lock it is put back untouched. try { - if (Date.now() - fs.statSync(lockPath).mtimeMs > LOCK_STALE_MS) { fs.rmSync(lockPath, { force: true }); continue; } + const observed = fs.readFileSync(lockPath, 'utf8'); + if (Date.now() - fs.statSync(lockPath).mtimeMs > staleLockMs) { + const claimed = `${lockPath}.stale.${process.pid}.${randomUUID()}`; + fs.renameSync(lockPath, claimed); + let still = false; + try { still = fs.readFileSync(claimed, 'utf8') === observed && Date.now() - fs.statSync(claimed).mtimeMs > staleLockMs; } catch { /* gone */ } + if (still) { fs.rmSync(claimed, { force: true }); continue; } + try { fs.linkSync(claimed, lockPath); } catch { /* a newer holder exists; ours was already replaced */ } + fs.rmSync(claimed, { force: true }); + continue; + } } catch { continue; } if (Date.now() > deadline) throw new BackendError('unavailable', 'Timed out waiting for the conversation lock', 503); sleepSync(10); } } - return () => { try { fs.rmSync(lockPath, { force: true }); } catch { /* already gone */ } }; + return () => { + try { if (fs.readFileSync(lockPath, 'utf8') === token) fs.rmSync(lockPath, { force: true }); } catch { /* already gone */ } + }; }, read: rel => { try { return fs.readFileSync(resolve(rel), 'utf8'); } catch (e) { @@ -310,16 +333,16 @@ export class LocalSquadBackend implements SquadBackend { turn.state = response.decision === 'approve' ? 'running' : 'cancelled'; const record = (conv.approvals ?? []).find(a => a.approvalId === response.approvalId); if (record) { - record.version = 2; + record.version = (record.version ?? 1) + 1; record.state = response.decision === 'approve' ? 'approved' : 'denied'; record.decision = response.decision; record.reason = response.reason; record.decidedAt = new Date().toISOString(); } this.emit(conv, 'control', 'approval.resolved', - { runId: turn.runId, seq: turn.seq, approvalId: response.approvalId, decision: response.decision, version: 2, state: turn.state }); + { runId: turn.runId, seq: turn.seq, approvalId: response.approvalId, decision: response.decision, version: record?.version ?? 2, state: turn.state }); if (this.adapterFor(turn)) { - (conv.deliveries ??= []).push({ id: randomUUID(), kind: 'approval', runId: turn.runId, seq: turn.seq, response }); + (conv.deliveries ??= []).push({ operationId: randomUUID(), kind: 'approval', runId: turn.runId, seq: turn.seq, response }); } return { runId: turn.runId, seq: turn.seq }; }); @@ -430,10 +453,10 @@ export class LocalSquadBackend implements SquadBackend { const record = (conv.approvals ?? []).find(a => a.approvalId === approvalId); if (record && record.state === 'pending') { record.state = 'cancelled'; - record.version = 2; + record.version = (record.version ?? 1) + 1; record.decidedAt = new Date().toISOString(); this.emit(conv, 'control', 'approval.resolved', - { runId: turn.runId, seq: turn.seq, approvalId, version: 2, state: 'cancelled' }); + { runId: turn.runId, seq: turn.seq, approvalId, version: record.version, state: 'cancelled' }); } turn.pendingApproval = undefined; } @@ -446,7 +469,7 @@ export class LocalSquadBackend implements SquadBackend { const requested = this.emit(conv, 'control', 'cancellation.requested', { runId: turn.runId, seq: turn.seq, reason }); this.emit(conv, 'control', 'turn.state', { runId: turn.runId, seq: turn.seq, state: 'cancelled' }, undefined, requested.id); if (this.adapterFor(turn)) { - (conv.deliveries ??= []).push({ id: randomUUID(), kind: 'cancel', runId: turn.runId, seq: turn.seq }); + (conv.deliveries ??= []).push({ operationId: randomUUID(), kind: 'cancel', runId: turn.runId, seq: turn.seq }); } } @@ -464,15 +487,15 @@ export class LocalSquadBackend implements SquadBackend { const adapter = turn ? this.adapterFor(turn) : undefined; try { if (adapter) { - if (next.kind === 'cancel') await adapter.cancel(conversationId, next.runId, next.seq); - else await adapter.respondApproval(conversationId, next.response); + if (next.kind === 'cancel') await adapter.cancel(conversationId, next.runId, next.seq, next.operationId); + else await adapter.respondApproval(conversationId, next.response, next.operationId); } } catch (err) { throw new BackendError('unavailable', `Harness ${next.kind} delivery failed and will be retried: ${err instanceof Error ? err.message : String(err)}`, 503); } this.tx(conversationId, (fresh) => { - fresh.deliveries = (fresh.deliveries ?? []).filter(d => d.id !== next.id); + fresh.deliveries = (fresh.deliveries ?? []).filter(d => d.operationId !== next.operationId); }); } } @@ -514,7 +537,11 @@ export class LocalSquadBackend implements SquadBackend { if (!turn || TERMINAL_RUN_STATES.includes(turn.state)) return; // fencing: output issued under an older lease than the conversation's current one is stale const eventEpoch = ev.epoch ?? turn.epoch ?? 1; - if (eventEpoch < conv.info.epoch) return; + if (eventEpoch < conv.info.epoch) { + this.emit(conv, 'control', 'harness.epoch-rejected', + { runId: turn.runId, seq: turn.seq, eventType: ev.type, rejectedEpoch: eventEpoch, currentEpoch: conv.info.epoch }); + return; + } switch (ev.type) { case 'message': if (turn.messages.some(m => m.runId === ev.message.runId && m.seq === ev.message.seq && m.role === ev.message.role && m.content === ev.message.content)) return; diff --git a/packages/squad-sdk/src/backend/types.ts b/packages/squad-sdk/src/backend/types.ts index ea778c025..c462d39ee 100644 --- a/packages/squad-sdk/src/backend/types.ts +++ b/packages/squad-sdk/src/backend/types.ts @@ -377,8 +377,10 @@ export interface HarnessAdapter { readonly capabilities: HarnessCapabilities; submit(submission: HarnessSubmission): Promise; subscribe(conversationId: string, listener: (event: HarnessEventEnvelope) => void): () => void; - cancel(conversationId: string, runId: string, seq: number): Promise; - respondApproval(conversationId: string, response: ApprovalResponse): Promise; + /** Idempotent: deliveries are retried. operationId is stable across retries and restarts of one delivery. */ + cancel(conversationId: string, runId: string, seq: number, operationId?: string): Promise; + /** Idempotent: deliveries are retried. operationId is stable across retries and restarts of one delivery. */ + respondApproval(conversationId: string, response: ApprovalResponse, operationId?: string): Promise; describe(): HarnessDescriptor; } diff --git a/test/backend.test.ts b/test/backend.test.ts index 8b2b15201..97ff1a135 100644 --- a/test/backend.test.ts +++ b/test/backend.test.ts @@ -26,7 +26,8 @@ class FakeHarness implements HarnessAdapter { async submit(s: { runId: string; seq: number }) { this.submitted.push({ runId: s.runId, seq: s.seq }); } subscribe(_c: string, l: (e: HarnessEventEnvelope) => void) { this.listeners.add(l); return () => this.listeners.delete(l); } async cancel(_c: string, _r: string, seq: number) { this.cancelled.push(seq); } - async respondApproval(_c: string, r: { approvalId: string }) { this.approvals.push(r.approvalId); } + ops: Array = []; + async respondApproval(_c: string, r: { approvalId: string }, op?: string) { this.approvals.push(r.approvalId); this.ops.push(op); } describe() { return { id: this.id, capabilities: this.capabilities }; } emit(e: HarnessEventEnvelope) { this.listeners.forEach(l => l(e)); } } @@ -788,4 +789,111 @@ describe('PR #2238 race/fault matrix', () => { await b.sendMessage('a', msg('r', 2)); await b.sendMessage('b', msg('r', 2)); expect(sent).toEqual({ a: [null, '5'], b: [null, '2'] }); }); +}); +describe('PR #2238 final hardening', () => { + const mk = () => { + const h = new FakeHarness(); + const harnesses = new DefaultHarnessRegistry(); + harnesses.register(h); + const state = new MemoryStateStore(); + return { h, harnesses, state }; + }; + const ask = (id = 'a1') => ({ type: 'approval-requested' as const, runId: 'r', seq: 1, request: { approvalId: id, turnSeq: 1, action: 'x', hash: 'h' } }); + + it('lock: recovers an abandoned lock exactly once and never steals a live one', async () => { + const fs = await import('node:fs'); + const os = await import('node:os'); + const path = await import('node:path'); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sq-stale-')); + try { + const store = createFileStateStore(dir, { staleLockMs: 5_000, lockTimeoutMs: 300 }); + const lockPath = path.join(dir, 'backend', 'locks', 'k.lock'); + fs.mkdirSync(path.dirname(lockPath), { recursive: true }); + + // live lock inside the threshold: the waiter times out and the holder's file is untouched + const release = store.lock!('backend/locks/k'); + expect(() => store.lock!('backend/locks/k')).toThrow(/Timed out/); + expect(fs.existsSync(lockPath)).toBe(true); + release(); + expect(fs.existsSync(lockPath)).toBe(false); + + // abandoned lock (crashed holder) older than the threshold is recovered + fs.writeFileSync(lockPath, 'crashed:holder'); + const old = new Date(Date.now() - 60_000); + fs.utimesSync(lockPath, old, old); + const r2 = store.lock!('backend/locks/k'); + expect(fs.readFileSync(lockPath, 'utf8')).not.toBe('crashed:holder'); + // a second waiter cannot take the recovered (now live) lock + expect(() => store.lock!('backend/locks/k')).toThrow(/Timed out/); + r2(); + expect(fs.readdirSync(path.dirname(lockPath)).filter(n => n.includes('.stale.'))).toEqual([]); + + // a stale release by a superseded holder must not delete the new holder's lock + const a = store.lock!('backend/locks/k'); + fs.writeFileSync(lockPath, 'someone-else'); + a(); + expect(fs.readFileSync(lockPath, 'utf8')).toBe('someone-else'); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }); + + it('approval version is monotonic (prev + 1), not hardcoded', async () => { + const { h, harnesses, state } = mk(); + const b = new LocalSquadBackend({ state, harnesses }); + await b.createConversation({ conversationId: 'c' }); + await b.sendMessage('c', msg('r', 1, { harness: 'copilot' })); + h.emit(ask()); + // simulate a record already advanced past v1 (e.g. an amended approval) before the decision + const key = 'backend/conversations/c.json'; + const doc = JSON.parse(state.read(key)!); + doc.approvals[0].version = 4; + state.write(key, JSON.stringify(doc)); + const fresh = new LocalSquadBackend({ state, harnesses }); + await fresh.respondApproval('c', { approvalId: 'a1', hash: 'h', decision: 'approve' }); + expect((await fresh.getApprovals('c'))[0]!.version).toBe(5); + + h.emit({ ...ask('a2'), runId: 'r2', seq: 1 } as never); + await b.sendMessage('c', msg('r2', 1, { harness: 'copilot' })); + h.emit({ ...ask('a2'), runId: 'r2' }); + const doc2 = JSON.parse(state.read(key)!); + doc2.approvals.find((a: { approvalId: string }) => a.approvalId === 'a2').version = 7; + state.write(key, JSON.stringify(doc2)); + await new LocalSquadBackend({ state, harnesses }).cancelRun('c', 'r2'); + expect((await b.getApprovals('c')).find(a => a.approvalId === 'a2')!.version).toBe(8); + }); + + it('a stale-epoch harness event leaves an auditable control event', async () => { + const { h, harnesses, state } = mk(); + const b = new LocalSquadBackend({ state, harnesses }); + await b.createConversation({ conversationId: 'c' }); + await b.sendMessage('c', msg('r', 1, { harness: 'copilot', epoch: 3 })); + h.emit({ type: 'state', state: 'completed', runId: 'r', seq: 1, epoch: 2 }); + expect((await b.getTurn('c', 1)).state).not.toBe('completed'); + const audit = (await b.readEvents('c')).events.filter(e => e.type === 'harness.epoch-rejected'); + expect(audit).toHaveLength(1); + expect(audit[0]).toMatchObject({ stream: 'control', payload: { runId: 'r', seq: 1, eventType: 'state', rejectedEpoch: 2, currentEpoch: 3 } }); + }); + + it('approval outbox keeps one stable operationId across retries and restart', async () => { + const { h, harnesses, state } = mk(); + const b = new LocalSquadBackend({ state, harnesses }); + await b.createConversation({ conversationId: 'c' }); + await b.sendMessage('c', msg('r', 1, { harness: 'copilot' })); + h.emit(ask()); + const orig = h.respondApproval.bind(h); + let fail = 2; + h.respondApproval = async (c: string, r: { approvalId: string }, op?: string) => { + h.ops.push(op); + if (fail-- > 0) throw new Error('down'); + return orig(c, r, op); + }; + const resp = { approvalId: 'a1', hash: 'h', decision: 'approve' as const }; + expect(await rejectCode(b.respondApproval('c', resp))).toBe('unavailable'); + const restarted = new LocalSquadBackend({ state, harnesses }); // outbox reloads from durable state + expect(await rejectCode(restarted.respondApproval('c', resp))).toBe('unavailable'); + expect((await restarted.respondApproval('c', resp)).state).toBe('running'); + const used = h.ops.filter(o => o !== undefined); + expect(used.length).toBeGreaterThanOrEqual(3); + expect(new Set(used).size).toBe(1); + expect(used[0]).toMatch(/^[0-9a-f-]{36}$/); + }); }); \ No newline at end of file From 89a10b11b6593029264802654ea23b297a90ccfc Mon Sep 17 00:00:00 2001 From: Copilot Date: Mon, 19 Oct 2026 00:41:29 +0300 Subject: [PATCH 44/46] fix(squad-2.0): Brady re-review round 3 (13 defects + 40 CI failures) CI: drop the root SDK source alias from vitest.config.ts (dual module instances), wrap --client help lines to <=80 cols. Backend: normalize legacy outbox ids, recovery-mutex stale-lock handling, persisted admission cursor + payload conflict detection, keep deliveries when the harness is missing, no re-entrant load on synchronous replay, createMcpBackend honors the configured state backend, HTTP body reads retried. Client/casting: TOML [[array_table]] boundaries and quoted keys, capability tokens 1:1 with capability-matrix.json, retired agents preserved and repairs committed through the canonical manifest writer. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 995eea17-c356-4912-90ea-5e5b144e7ba9 --- .github/agents/squad.agent.md | 4 +- packages/squad-cli/src/cli-entry.ts | 4 +- .../squad-cli/src/cli/core/backend-mcp.ts | 14 +- .../squad-cli/src/cli/core/command-help.ts | 4 +- .../src/backend/durable-http-backend.ts | 14 +- packages/squad-sdk/src/backend/index.ts | 4 +- .../squad-sdk/src/backend/local-backend.ts | 126 ++++++++++++++-- .../src/backend/state-backend-store.ts | 26 ++++ .../src/casting/handwritten-registry.ts | 101 +++++++++---- .../squad-sdk/src/client/adapters/apply.ts | 42 ++++-- .../src/client/adapters/capabilities.ts | 38 +++-- test/backend.test.ts | 139 +++++++++++++++++- test/clients.test.ts | 42 ++++++ test/squad-20-all-scenarios.e2e.test.ts | 103 +++++++++++-- test/squad-20-real-clients-stdio.e2e.test.ts | 16 +- vitest.config.ts | 13 +- 16 files changed, 580 insertions(+), 110 deletions(-) create mode 100644 packages/squad-sdk/src/backend/state-backend-store.ts diff --git a/.github/agents/squad.agent.md b/.github/agents/squad.agent.md index 57a62308e..04865f4f8 100644 --- a/.github/agents/squad.agent.md +++ b/.github/agents/squad.agent.md @@ -6,14 +6,14 @@ tools: ["*"] - + You are **Squad (Coordinator)** — the orchestrator for this project's AI team. ### Coordinator Identity - **Name:** Squad (Coordinator) -- **Version:** 0.0.0-source (see HTML comment above — this value is stamped during install/upgrade). Include it as `Squad v{version}` in your first response of each session (e.g., in the acknowledgment or greeting). +- **Version:** 1.0.0 (see HTML comment above — this value is stamped during install/upgrade). Include it as `Squad v1.0.0` in your first response of each session (e.g., in the acknowledgment or greeting). - **Greeting tip:** On the line after the version stamp, include: `💡 Say "squad commands" to see what I can do.` — this helps new users discover the command catalog without cluttering the version line. - **Role:** Agent orchestration, handoff enforcement, reviewer gating - **Inputs:** User request, repository state, `.squad/decisions.md` diff --git a/packages/squad-cli/src/cli-entry.ts b/packages/squad-cli/src/cli-entry.ts index f63dad2da..317f50c76 100644 --- a/packages/squad-cli/src/cli-entry.ts +++ b/packages/squad-cli/src/cli-entry.ts @@ -161,7 +161,7 @@ async function main(): Promise { console.log(` --no-vscode-default (skip .vscode/settings.json update)`); console.log(` --preset (apply a preset after init)`); console.log(` --state-backend (local|orphan|two-layer)`); - console.log(` --client (copilot,claude,kiro,opencode,cursor,codex,antigravity|all)`); + console.log(` --client (client hosts to project, or "all")`); console.log(` Usage: init --mode remote `); console.log(` Creates .squad/config.json pointing to an external team root`); console.log(` ${BOLD}upgrade${RESET} Update Squad-owned files to latest version`); @@ -170,7 +170,7 @@ async function main(): Promise { console.log(` Flags: --global (upgrade personal squad)`); console.log(` --migrate-directory (rename .ai-team/ → .squad/)`); console.log(` --state-backend (migrate to orphan|two-layer)`); - console.log(` --client (add client hosts; configured clients always refresh)`); + console.log(` --client (add client hosts; refresh configured)`); console.log(` ${BOLD}update-check${RESET} Report cached CLI update status (for tooling/CI)`); console.log(` Flags: --json (structured output), --refresh (bypass cache)`); console.log(` ${BOLD}migrate${RESET} Convert between markdown and SDK-First squad formats`); diff --git a/packages/squad-cli/src/cli/core/backend-mcp.ts b/packages/squad-cli/src/cli/core/backend-mcp.ts index 4a195d4d3..893578c4f 100644 --- a/packages/squad-cli/src/cli/core/backend-mcp.ts +++ b/packages/squad-cli/src/cli/core/backend-mcp.ts @@ -1,6 +1,7 @@ import { BackendError, createSquadBackend, + createStateBackendStore, resolveSquadState, type BackendContentPart, type ProtocolHello, @@ -47,13 +48,20 @@ function sendContent(args: Record): string { throw new BackendError('invalid-request', "'content' or 'parts' is required", 400); } -export function createMcpBackend(startDir: string, env: NodeJS.ProcessEnv = process.env): SquadBackend { +export function createMcpBackend( + startDir: string, + env: NodeJS.ProcessEnv = process.env, + resolveState: (dir: string) => Pick>, 'paths' | 'backend'> | null = resolveSquadState, +): SquadBackend { if (env['SQUAD_BACKEND_URL']) { return createSquadBackend({ kind: 'durable-http', baseUrl: env['SQUAD_BACKEND_URL'], token: env['SQUAD_BACKEND_TOKEN'] }); } - const context = resolveSquadState(startDir); + const context = resolveState(startDir); if (!context) throw new Error(`No .squad directory found from ${startDir}`); - return createSquadBackend({ kind: 'mcp', squadDir: context.paths.teamSquadDir }); + // honor the project's configured state backend (git-notes, orphan, ...); the local backend keeps the + // lock-protected, symlink-guarded file store over the team .squad directory + const state = context.backend.name === 'local' ? undefined : createStateBackendStore(context.backend); + return createSquadBackend({ kind: 'mcp', squadDir: context.paths.teamSquadDir, state }); } export function createBackendMcpTools(getBackend: () => SquadBackend): BackendMcpTool[] { diff --git a/packages/squad-cli/src/cli/core/command-help.ts b/packages/squad-cli/src/cli/core/command-help.ts index af24cd04b..d7df85909 100644 --- a/packages/squad-cli/src/cli/core/command-help.ts +++ b/packages/squad-cli/src/cli/core/command-help.ts @@ -43,7 +43,7 @@ const COMMAND_HELP: Record = { console.log(` ${BOLD}--no-workflows${RESET} Skip writing GitHub Actions workflows`); console.log(` ${BOLD}--preset ${RESET} Apply a curated preset after init`); console.log(` ${BOLD}--state-backend ${RESET} State backend (local|orphan|two-layer)`); - console.log(` ${BOLD}--client ${RESET} Client hosts to project: copilot,claude,kiro,opencode,cursor,codex,antigravity or all`); + console.log(` ${BOLD}--client ${RESET} Client hosts to project (or "all"):`); console.log(` ${BOLD}--mode remote ${RESET} Point at an external team root (creates .squad/config.json)\n`); }, @@ -59,7 +59,7 @@ const COMMAND_HELP: Record = { console.log(` ${BOLD}--global${RESET} Upgrade the personal (global) squad`); console.log(` ${BOLD}--migrate-directory${RESET} Rename legacy .ai-team/ to .squad/`); console.log(` ${BOLD}--state-backend ${RESET} Migrate to a new backend (orphan|two-layer)`); - console.log(` ${BOLD}--client ${RESET} Add client hosts (copilot,claude,kiro,opencode,cursor,codex,antigravity|all); configured clients always refresh`); + console.log(` ${BOLD}--client ${RESET} Add client hosts (or "all"):`); console.log(` ${BOLD}--self${RESET} Upgrade the squad CLI package itself`); console.log(` ${BOLD}--insider${RESET} With --self, install the @insider tag`); console.log(` ${BOLD}--force${RESET} Overwrite files without prompting\n`); diff --git a/packages/squad-sdk/src/backend/durable-http-backend.ts b/packages/squad-sdk/src/backend/durable-http-backend.ts index fab8a1228..ec1694eef 100644 --- a/packages/squad-sdk/src/backend/durable-http-backend.ts +++ b/packages/squad-sdk/src/backend/durable-http-backend.ts @@ -180,19 +180,19 @@ export class DurableHttpSquadBackend implements SquadBackend { for (let attempt = 0; attempt <= this.maxRetries; attempt++) { if (attempt > 0) await delay(this.retryDelayMs * 2 ** (attempt - 1)); let res: Response; + let text: string; try { res = await this.doFetch(`${this.base}${route}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), }); + // the body is part of the attempt: a stream failing after the headers arrived is retried like any network error + text = await res.text(); } catch (err) { lastError = new BackendError('unavailable', `Network error: ${err instanceof Error ? err.message : String(err)}`); continue; } - if (res.ok) { - const text = await res.text(); - return (text ? JSON.parse(text) : {}) as T; - } - const err = await toError(res); + if (res.ok) return (text ? JSON.parse(text) : {}) as T; + const err = toError(res, text); if (err.retryable || RETRYABLE_STATUS.has(res.status)) { lastError = err; continue; } throw err; } @@ -203,14 +203,14 @@ export class DurableHttpSquadBackend implements SquadBackend { const enc = encodeURIComponent; const delay = (ms: number): Promise => new Promise(r => setTimeout(r, ms)); -async function toError(res: Response): Promise { +function toError(res: Response, text: string): BackendError { let detail = ''; let code: BackendErrorCode | undefined; let details: Record | undefined; let correlationId = res.headers.get('x-correlation-id') ?? undefined; try { // Canonical shape is { error: { code, message, retryable, details } }; flat bodies are tolerated. - const body = JSON.parse(await res.text()) as Record; + const body = JSON.parse(text) as Record; const e = (body['error'] && typeof body['error'] === 'object' ? body['error'] : body) as Record; detail = String(e['message'] ?? (typeof body['error'] === 'string' ? body['error'] : '') ?? ''); if (typeof e['code'] === 'string' && isCode(e['code'])) code = e['code']; diff --git a/packages/squad-sdk/src/backend/index.ts b/packages/squad-sdk/src/backend/index.ts index 61d99dac1..9e245573f 100644 --- a/packages/squad-sdk/src/backend/index.ts +++ b/packages/squad-sdk/src/backend/index.ts @@ -5,7 +5,9 @@ import type { BackendConfig, SquadBackend } from './types.js'; export * from './types.js'; export * from './harness-registry.js'; export { LocalSquadBackend, MemoryStateStore, createFileStateStore } from './local-backend.js'; -export type { LocalSquadBackendOptions } from './local-backend.js'; +export type { LocalSquadBackendOptions, FileStateStoreOptions } from './local-backend.js'; +export { createStateBackendStore } from './state-backend-store.js'; +export type { StateBackendLike } from './state-backend-store.js'; export { DurableHttpSquadBackend } from './durable-http-backend.js'; export type { DurableHttpOptions } from './durable-http-backend.js'; diff --git a/packages/squad-sdk/src/backend/local-backend.ts b/packages/squad-sdk/src/backend/local-backend.ts index 4ca76eff6..3ff9b393f 100644 --- a/packages/squad-sdk/src/backend/local-backend.ts +++ b/packages/squad-sdk/src/backend/local-backend.ts @@ -74,10 +74,40 @@ const LOCK_TIMEOUT_MS = 10_000; const LOCK_STALE_MS = 30_000; /** File-system store rooted at a `.squad` directory. Never follows symlinks/junctions below the root. */ +/** + * Remove an abandoned lock under an exclusive `.recover` mutex. While holding it the lock is re-read: it is + * only removed if it is still byte-for-byte (and mtime-for-mtime) the stale lock that was observed, so a lock + * a faster waiter has since re-acquired is never touched and two waiters can never both own the lock. + */ +function recoverStaleLock(lockPath: string, observed: string, observedMtime: number, staleLockMs: number): boolean { + const mutex = `${lockPath}.recover`; + let fd: number; + try { + fd = fs.openSync(mutex, 'wx'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err; + // a crashed recoverer must not wedge recovery forever + try { if (Date.now() - fs.statSync(mutex).mtimeMs > staleLockMs) fs.rmSync(mutex, { force: true }); } catch { /* raced */ } + return false; + } + try { + let current: string; + let mtime: number; + try { current = fs.readFileSync(lockPath, 'utf8'); mtime = fs.statSync(lockPath).mtimeMs; } catch { return true; } // already gone + if (current !== observed || mtime !== observedMtime || Date.now() - mtime <= staleLockMs) return true; // replaced by a live holder + fs.rmSync(lockPath, { force: true }); + return true; + } finally { + fs.closeSync(fd); + fs.rmSync(mutex, { force: true }); + } +} export interface FileStateStoreOptions { /** A lock file untouched for longer than this is treated as abandoned by a crashed holder. */ staleLockMs?: number; lockTimeoutMs?: number; + /** Test seam: runs after a stale lock was observed and before recovery starts. */ + beforeStaleRecovery?: (lockPath: string) => void; } export function createFileStateStore(squadDir: string, options: FileStateStoreOptions = {}): BackendStateStore { @@ -119,14 +149,11 @@ export function createFileStateStore(squadDir: string, options: FileStateStoreOp // The claimed file is re-checked; if it turns out to be a live holder's lock it is put back untouched. try { const observed = fs.readFileSync(lockPath, 'utf8'); - if (Date.now() - fs.statSync(lockPath).mtimeMs > staleLockMs) { - const claimed = `${lockPath}.stale.${process.pid}.${randomUUID()}`; - fs.renameSync(lockPath, claimed); - let still = false; - try { still = fs.readFileSync(claimed, 'utf8') === observed && Date.now() - fs.statSync(claimed).mtimeMs > staleLockMs; } catch { /* gone */ } - if (still) { fs.rmSync(claimed, { force: true }); continue; } - try { fs.linkSync(claimed, lockPath); } catch { /* a newer holder exists; ours was already replaced */ } - fs.rmSync(claimed, { force: true }); + const observedMtime = fs.statSync(lockPath).mtimeMs; + if (Date.now() - observedMtime > staleLockMs) { + options.beforeStaleRecovery?.(lockPath); + if (recoverStaleLock(lockPath, observed, observedMtime, staleLockMs)) continue; + sleepSync(10); continue; } } catch { continue; } @@ -173,6 +200,8 @@ export class LocalSquadBackend implements SquadBackend { private readonly state: BackendStateStore; readonly harnesses: HarnessRegistry; private readonly subscriptions = new Map void>(); + private readonly loading = new Map(); + private readonly txActive = new Set(); constructor(options: LocalSquadBackendOptions = {}) { this.kind = options.kind ?? 'local'; @@ -214,11 +243,18 @@ export class LocalSquadBackend implements SquadBackend { this.checkEpoch(conv, msg.epoch); const dup = conv.turns.find(t => t.runId === msg.runId && t.seq === msg.seq); if (dup) { + // (runId, seq) is an idempotency key: the same key with a different payload is a client bug, not a retry + const original = dup.messages[0]; + if (original && !sameMessagePayload(original, msg)) { + throw new BackendError('conflict', `Idempotency key (${msg.runId}, ${msg.seq}) was reused with a different message payload`, 409); + } return { duplicate: true as const, admission: { protocol: BACKEND_PROTOCOL_VERSION, conversationId, runId: dup.runId, seq: dup.seq, duplicate: true, - state: dup.state, epoch: conv.info.epoch, correlationId: msg.correlationId, cursor: this.tailCursor(conv), + state: dup.state, epoch: conv.info.epoch, correlationId: msg.correlationId, + // the cursor at the ORIGINAL admission, so a client that lost the first response replays everything after it + cursor: this.admissionCursor(conv, dup), } satisfies BackendAdmission, }; } @@ -341,7 +377,7 @@ export class LocalSquadBackend implements SquadBackend { } this.emit(conv, 'control', 'approval.resolved', { runId: turn.runId, seq: turn.seq, approvalId: response.approvalId, decision: response.decision, version: record?.version ?? 2, state: turn.state }); - if (this.adapterFor(turn)) { + if (turn.messages[0]?.harness) { (conv.deliveries ??= []).push({ operationId: randomUUID(), kind: 'approval', runId: turn.runId, seq: turn.seq, response }); } return { runId: turn.runId, seq: turn.seq }; @@ -410,12 +446,14 @@ export class LocalSquadBackend implements SquadBackend { private tx(conversationId: string, fn: (conv: StoredConversation) => T): T { this.assertId(conversationId); const release = this.state.lock?.(`${LOCKS}/${conversationId}`); + this.txActive.add(conversationId); try { const conv = this.load(conversationId); const result = fn(conv); this.save(conv); return result; } finally { + this.txActive.delete(conversationId); release?.(); } } @@ -442,6 +480,13 @@ export class LocalSquadBackend implements SquadBackend { return env; } + private admissionCursor(conv: StoredConversation, turn: BackendTurn): BackendCursor { + const accepted = (conv.events ?? []).find(e => e.type === 'message.accepted' + && (e.payload as { runId?: string; seq?: number }).runId === turn.runId + && (e.payload as { runId?: string; seq?: number }).seq === turn.seq); + return accepted?.cursor ?? this.tailCursor(conv); + } + private tailCursor(conv: StoredConversation): BackendCursor { return conv.events?.[conv.events.length - 1]?.cursor ?? encodeCursor(conv.info.conversationId, 0); } @@ -468,7 +513,7 @@ export class LocalSquadBackend implements SquadBackend { turn.state = 'cancelled'; const requested = this.emit(conv, 'control', 'cancellation.requested', { runId: turn.runId, seq: turn.seq, reason }); this.emit(conv, 'control', 'turn.state', { runId: turn.runId, seq: turn.seq, state: 'cancelled' }, undefined, requested.id); - if (this.adapterFor(turn)) { + if (turn.messages[0]?.harness) { (conv.deliveries ??= []).push({ operationId: randomUUID(), kind: 'cancel', runId: turn.runId, seq: turn.seq }); } } @@ -484,7 +529,13 @@ export class LocalSquadBackend implements SquadBackend { const next = conv.deliveries?.[0]; if (!next) return; const turn = this.findTurn(conv, next.runId, next.seq); - const adapter = turn ? this.adapterFor(turn) : undefined; + const harness = turn?.messages[0]?.harness; + const adapter = harness ? this.harnesses.get(harness) : undefined; + if (harness && !adapter) { + // never discard a durable delivery because the harness is not registered (yet): keep it for a retry + throw new BackendError('unavailable', + `Harness '${harness}' is not registered; the ${next.kind} delivery stays queued and will be retried`, 503); + } try { if (adapter) { if (next.kind === 'cancel') await adapter.cancel(conversationId, next.runId, next.seq, next.operationId); @@ -519,10 +570,26 @@ export class LocalSquadBackend implements SquadBackend { const key = `${conversationId}\u0000${harnessId}`; if (this.subscriptions.has(key)) return; const adapter = this.harnesses.get(harnessId)!; - this.subscriptions.set(key, adapter.subscribe(conversationId, ev => this.applyEvent(conversationId, ev))); + // track BEFORE subscribing: an adapter that replays synchronously must not re-enter rehydration + this.subscriptions.set(key, () => undefined); + try { + this.subscriptions.set(key, adapter.subscribe(conversationId, ev => this.applyEvent(conversationId, ev))); + } catch (err) { + this.subscriptions.delete(key); + throw err; + } } private applyEvent(conversationId: string, incoming: HarnessEventEnvelope): void { + const loading = this.loading.get(conversationId); + if (loading) { + // synchronous replay while this conversation is being loaded: apply to the in-memory state instead of + // re-entering load() (and the non-reentrant lock). Persisted by the enclosing tx, or re-applied + // (idempotently) right after the load when there is none. + this.applyEventLocked(loading, incoming); + if (!this.txActive.has(conversationId)) queueMicrotask(() => this.applyEvent(conversationId, incoming)); + return; + } try { this.tx(conversationId, conv => this.applyEventLocked(conv, incoming)); } catch (err) { @@ -627,7 +694,13 @@ export class LocalSquadBackend implements SquadBackend { return undefined; } const conv = JSON.parse(raw) as StoredConversation; - this.rehydrate(conv); + normalizeDeliveries(conv); + this.loading.set(id, conv); + try { + this.rehydrate(conv); + } finally { + this.loading.delete(id); + } return conv; } @@ -636,6 +709,31 @@ export class LocalSquadBackend implements SquadBackend { } } +/** Compare the idempotency-relevant payload of an accepted message with a retried one. */ +function sameMessagePayload(a: BackendMessage, b: BackendMessage): boolean { + return a.role === b.role + && a.content === b.content + && JSON.stringify(a.parts ?? null) === JSON.stringify(b.parts ?? null) + && JSON.stringify(a.extensions ?? null) === JSON.stringify(b.extensions ?? null); +} + +/** + * Outbox entries persisted by earlier versions carry `id` (or nothing) instead of `operationId`. Normalize them + * deterministically so each entry is individually addressable: acknowledging one must never drop another. + */ +function normalizeDeliveries(conv: StoredConversation): void { + if (!conv.deliveries?.length) return; + const seen = new Set(); + conv.deliveries.forEach((d, i) => { + const legacy = d as PendingDelivery & { id?: string }; + let op = legacy.operationId || legacy.id || `legacy:${d.kind}:${d.runId}:${d.seq}:${i}`; + if (seen.has(op)) op = `${op}:${i}`; + seen.add(op); + legacy.operationId = op; + delete legacy.id; + }); +} + function encodeCursor(conversationId: string, sequence: number): BackendCursor { return Buffer.from(`c1:${conversationId}:${sequence}`, 'utf8').toString('base64url'); } diff --git a/packages/squad-sdk/src/backend/state-backend-store.ts b/packages/squad-sdk/src/backend/state-backend-store.ts new file mode 100644 index 000000000..2e3d29995 --- /dev/null +++ b/packages/squad-sdk/src/backend/state-backend-store.ts @@ -0,0 +1,26 @@ +import type { BackendStateStore } from './types.js'; + +/** The synchronous subset of the SDK's `StateBackend` the conversation store needs. */ +export interface StateBackendLike { + read(relativePath: string): string | undefined; + write(relativePath: string, content: string): void; + exists(relativePath: string): boolean; + list(relativeDir: string): string[]; + readonly name?: string; +} + +/** + * Adapt a configured SDK state backend (git-notes, orphan, ...) to the backend conversation store, so + * conversations live wherever the project's `stateBackend` puts mutable squad state instead of always on disk. + * + * These backends have no cross-process lock primitive, so `lock` is omitted: the local backend's transactions + * are synchronous and therefore still atomic within one process. + */ +export function createStateBackendStore(backend: StateBackendLike): BackendStateStore { + return { + read: p => backend.read(p), + write: (p, content) => backend.write(p, content), + exists: p => backend.exists(p), + list: dir => backend.list(dir), + }; +} diff --git a/packages/squad-sdk/src/casting/handwritten-registry.ts b/packages/squad-sdk/src/casting/handwritten-registry.ts index d5469fec6..73c553cc5 100644 --- a/packages/squad-sdk/src/casting/handwritten-registry.ts +++ b/packages/squad-sdk/src/casting/handwritten-registry.ts @@ -13,7 +13,11 @@ import fs from 'node:fs'; import path from 'node:path'; -import { readCastingRegistryPair } from './durable-registry.js'; +import { + acquireCastingRegistryLock, + commitCastingRegistryPair, + readCastingRegistryPair, +} from './durable-registry.js'; import { AGENT_PROVENANCE_SCHEMA, AGENT_PROVENANCE_VERSION, @@ -30,6 +34,10 @@ const STATUSES: readonly AgentProvenanceStatus[] = ['active', 'inactive', 'retir const isRecord = (v: unknown): v is Record => !!v && typeof v === 'object' && !Array.isArray(v); const str = (v: unknown): string | undefined => (typeof v === 'string' && v.trim() ? v.trim() : undefined); +function normalizeStatus(v: unknown): AgentProvenanceStatus { + const s = typeof v === 'string' ? v.trim().toLowerCase() : ''; + return (STATUSES as readonly string[]).includes(s) ? (s as AgentProvenanceStatus) : 'active'; +} const isTs = (v: unknown): v is string => typeof v === 'string' && v.length > 0 && !Number.isNaN(Date.parse(v)); export function toAgentId(value: string): string { @@ -92,7 +100,9 @@ export function normalizeHandwrittenCastingRegistry(value: unknown, now = new Da persistent_name: name, role: role ?? id, universe, - status: 'active', + // preserve a recorded lifecycle status: a retired agent must not be silently resurrected + status: normalizeStatus(rec['status']), + ...(normalizeStatus(rec['status']) === 'retired' ? { retired_at: isTs(rec['retired_at']) ? rec['retired_at'] : generatedAt } : {}), created_at: generatedAt, updated_at: generatedAt, }; @@ -159,6 +169,11 @@ export function repairHandwrittenCastingRegistry(castingDir: string, now = new D // a hand-edited registry that is still well-formed can nevertheless disagree with the checksummed commit manifest const manifestMismatch = hasManifest && isAcceptable(parsed) && commitManifestMismatch(castingDir); if (isAcceptable(parsed) && !manifestMismatch) return { repaired: false, files: [] }; + // a well-formed registry that only disagrees with the manifest keeps its revision and history: just re-commit + // the same pair through the canonical writer so the manifest matches again + if (manifestMismatch && recommitExistingPair(castingDir)) { + return { repaired: true, files: ['registry.json', 'history.json', COMMIT_MANIFEST] }; + } const normalized = normalizeHandwrittenCastingRegistry(parsed, now); if (!normalized) return { repaired: false, files: [], reason: 'registry.json has no recoverable agents' }; @@ -166,45 +181,77 @@ export function repairHandwrittenCastingRegistry(castingDir: string, now = new D } /** - * Write `normalized` as a genesis registry plus the matching history, backing up whatever - * was there. History must describe the same genesis cast or the durable reader rejects - * the pair as mixed-generation; a commit manifest pins old bytes, so it is backed up and removed. + * Re-commit an existing, individually valid registry/history pair so a stale commit manifest is replaced by a + * canonical one. Returns false (leaving everything untouched) when the pair is not self-consistent, in which + * case the caller rebuilds it from the registry's agents instead. + */ +function recommitExistingPair(castingDir: string): boolean { + const registryPath = path.join(castingDir, 'registry.json'); + const historyPath = path.join(castingDir, 'history.json'); + if (!fs.existsSync(historyPath)) return false; + const registryRaw = fs.readFileSync(registryPath, 'utf8'); + const historyRaw = fs.readFileSync(historyPath, 'utf8'); + let registry: unknown; + let history: unknown; + try { registry = JSON.parse(registryRaw); history = JSON.parse(historyRaw); } catch { return false; } + if (!isRecord(registry) || !isRecord(history) || typeof registry['revision'] !== 'number') return false; + const { transaction_id: _rt, ...reg } = registry; + const { transaction_id: _ht, registry_revision: _hr, ...hist } = history; + void _rt; void _ht; void _hr; + for (const f of [registryPath, historyPath, path.join(castingDir, COMMIT_MANIFEST)]) { + if (fs.existsSync(f) && !fs.existsSync(`${f}.handwritten.bak`)) fs.copyFileSync(f, `${f}.handwritten.bak`); + } + const release = acquireCastingRegistryLock(castingDir, 'casting manifest repair'); + try { + commitCastingRegistryPair(castingDir, registryRaw, reg, historyRaw, hist, registry['revision']); + readCastingRegistryPair(castingDir); + return true; + } catch { + return false; + } finally { + release(); + } +} +/** + * Write `normalized` as a repaired registry + history through the SDK's canonical commit writer, so the pair + * carries a valid `registry-history.commit.json` manifest and passes `readCastingRegistryPair`. Whatever was + * there is backed up as `*.handwritten.bak` first. + * + * History describes the same cast (one genesis snapshot listing every agent) or the durable reader rejects the + * pair as mixed-generation. Recorded `retired` / `inactive` statuses are kept as-is (a retired agent gets a + * `retired_at`), never silently reactivated. */ function writeGenesisPair(castingDir: string, normalized: AgentProvenanceRegistry): { files: string[]; removed?: string[] } { const registryPath = path.join(castingDir, 'registry.json'); - const files: string[] = []; - const removed: string[] = []; - const backup = `${registryPath}.handwritten.bak`; - if (fs.existsSync(registryPath) && !fs.existsSync(backup)) fs.copyFileSync(registryPath, backup); - fs.writeFileSync(registryPath, JSON.stringify(normalized, null, 2) + '\n', 'utf8'); - files.push('registry.json'); const historyPath = path.join(castingDir, 'history.json'); - if (fs.existsSync(historyPath) && !fs.existsSync(`${historyPath}.handwritten.bak`)) { - fs.copyFileSync(historyPath, `${historyPath}.handwritten.bak`); + const manifestPath = path.join(castingDir, COMMIT_MANIFEST); + for (const f of [registryPath, historyPath, manifestPath]) { + if (fs.existsSync(f) && !fs.existsSync(`${f}.handwritten.bak`)) fs.copyFileSync(f, `${f}.handwritten.bak`); } - const firstAgent = Object.values(normalized.agents)[0]!; - const history = { - universe_usage_history: [{ universe: firstAgent.universe, used_at: normalized.generated_at }], + const agents = Object.values(normalized.agents); + const universe = agents[0]!.universe; + const history: Record = { + universe_usage_history: [{ universe, used_at: normalized.generated_at }], assignment_cast_snapshots: { 'handwritten-cast-import': { - universe: firstAgent.universe, + universe, created_at: normalized.generated_at, agents: Object.keys(normalized.agents), }, }, }; - fs.writeFileSync(historyPath, JSON.stringify(history, null, 2) + '\n', 'utf8'); - files.push('history.json'); - const manifestPath = path.join(castingDir, COMMIT_MANIFEST); - if (fs.existsSync(manifestPath)) { - const manifestBackup = `${manifestPath}.handwritten.bak`; - if (!fs.existsSync(manifestBackup)) fs.copyFileSync(manifestPath, manifestBackup); - fs.rmSync(manifestPath); - removed.push(COMMIT_MANIFEST); + const { transaction_id: _t, ...registry } = normalized as unknown as Record; + void _t; + const release = acquireCastingRegistryLock(castingDir, 'handwritten registry repair'); + try { + const previousRegistryRaw = fs.existsSync(registryPath) ? fs.readFileSync(registryPath, 'utf8') : undefined; + const previousHistoryRaw = fs.existsSync(historyPath) ? fs.readFileSync(historyPath, 'utf8') : undefined; + commitCastingRegistryPair(castingDir, previousRegistryRaw, registry, previousHistoryRaw, history, normalized.revision); + } finally { + release(); } - return { files, ...(removed.length > 0 ? { removed } : {}) }; + return { files: ['registry.json', 'history.json', COMMIT_MANIFEST] }; } - // ── backfill from team.md ─────────────────────────────────────────── const BUILTIN_NAMES = new Set(['scribe', 'ralph', 'rai', 'fact checker', 'fact-checker', 'squad', 'coordinator', 'copilot', '@copilot']); diff --git a/packages/squad-sdk/src/client/adapters/apply.ts b/packages/squad-sdk/src/client/adapters/apply.ts index bfd2e3931..52134938d 100644 --- a/packages/squad-sdk/src/client/adapters/apply.ts +++ b/packages/squad-sdk/src/client/adapters/apply.ts @@ -34,13 +34,27 @@ export function upsertBlock(existing: string, content: string, id?: string): str return `${existing}${sep}${block}\n`; } -/** Normalized dotted key of a `[table]` header line (quotes/whitespace tolerant), or undefined. */ +const SECTION_RE = /^\s*(\[\[?)\s*([^\]\r\n]+?)\s*(\]\]?)\s*(?:#.*)?$/; + +/** A TOML section boundary: `[table]` or `[[array_table]]` (quotes/whitespace tolerant). */ +function sectionHeader(line: string): { key: string; array: boolean } | undefined { + const m = SECTION_RE.exec(line); + if (!m || m[1]!.length !== m[3]!.length) return undefined; + const key = m[2]!.split('.').map((s) => s.trim().replace(/^["']|["']$/g, '')).join('.'); + return { key, array: m[1]!.length === 2 }; +} + +/** Normalized dotted key of a `[table]` header line, or undefined (including for `[[array]]` headers). */ function tableHeaderKey(line: string): string | undefined { - const m = /^\s*\[(?!\[)([^\]]+)\]\s*(?:#.*)?$/.exec(line); - if (!m) return undefined; - return m[1]!.split('.').map((s) => s.trim().replace(/^["']|["']$/g, '')).join('.'); + const h = sectionHeader(line); + return h && !h.array ? h.key : undefined; } +/** Unquoted key of a `key = value` line (bare, "basic" or 'literal'), or undefined. */ +function assignmentKey(line: string): string | undefined { + const m = /^\s*(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9_-]+))\s*=/.exec(line); + return m ? (m[1] ?? m[2] ?? m[3]) : undefined; +} /** Index of the last line of the assignment starting at `i` (handles multiline arrays). */ function assignmentEnd(lines: string[], i: number): number { const first = lines[i]!; @@ -72,8 +86,8 @@ function findTable(lines: string[], table: string, from = 0): [number, number] | if (tableHeaderKey(lines[i]!) !== want) continue; let end = lines.length; for (let j = i + 1; j < lines.length; j++) { - if (/^\s*\[/.test(lines[j]!) && tableHeaderKey(lines[j]!) !== undefined) { end = j; break; } - if (/^\s*[A-Za-z0-9_"'-]+\s*=/.test(lines[j]!)) j = assignmentEnd(lines, j); + if (sectionHeader(lines[j]!) !== undefined) { end = j; break; } + if (assignmentKey(lines[j]!) !== undefined) j = assignmentEnd(lines, j); } return [i, end]; } @@ -107,9 +121,8 @@ export function upsertTomlTable( const defaultsLeft = { ...defaults }; for (let i = start + 1; i < end; i++) { const line = lines[i]!; - const m = /^\s*([A-Za-z0-9_-]+)\s*=/.exec(line); - if (!m) { rewritten.push(line); continue; } - const k = m[1]!; + const k = assignmentKey(line); + if (k === undefined) { rewritten.push(line); continue; } const last = assignmentEnd(lines, i); present.add(k); if (k in remaining) { @@ -240,16 +253,19 @@ export function removeBlock(existing: string, id?: string): string { export function removeTomlTable(existing: string, table: string): string { const nl = existing.includes('\r\n') ? '\r\n' : '\n'; let lines = existing.split(/\r?\n/); - const isOurs = (key: string | undefined) => key !== undefined && (key === table || key.startsWith(`${table}.`)); + const isOurs = (line: string) => { + const h = sectionHeader(line); + return h !== undefined && (h.key === table || h.key.startsWith(`${table}.`)) && (!h.array || h.key !== table); + }; let changed = false; for (;;) { let start = -1; - for (let i = 0; i < lines.length; i++) if (isOurs(tableHeaderKey(lines[i]!))) { start = i; break; } + for (let i = 0; i < lines.length; i++) if (isOurs(lines[i]!)) { start = i; break; } if (start === -1) break; let end = lines.length; for (let j = start + 1; j < lines.length; j++) { - if (/^\s*\[/.test(lines[j]!) && tableHeaderKey(lines[j]!) !== undefined) { end = j; break; } - if (/^\s*[A-Za-z0-9_"'-]+\s*=/.test(lines[j]!)) j = assignmentEnd(lines, j); + if (sectionHeader(lines[j]!) !== undefined) { end = j; break; } + if (assignmentKey(lines[j]!) !== undefined) j = assignmentEnd(lines, j); } const before = lines.slice(0, start); while (before.length > 0 && before[before.length - 1]!.trim() === '') before.pop(); diff --git a/packages/squad-sdk/src/client/adapters/capabilities.ts b/packages/squad-sdk/src/client/adapters/capabilities.ts index 0da8e0a26..e8a9d5687 100644 --- a/packages/squad-sdk/src/client/adapters/capabilities.ts +++ b/packages/squad-sdk/src/client/adapters/capabilities.ts @@ -56,17 +56,37 @@ export function assertConsistentCapabilities(tokens: readonly string[], flags: P } } -/** Declared (not probed) host capability tokens; keep conservative. Advertised, not negotiated. */ -export const CAPABILITY_TOKENS_BY_HOST: Record = { - copilot: ['approvals', 'background-runs', 'mcp-bridge', 'subagents', 'native-hooks', 'steering-files'], - claude: ['approvals', 'background-runs', 'mcp-bridge', 'subagents', 'native-hooks', 'steering-files'], - kiro: ['approvals', 'mcp-bridge', 'subagents', 'native-hooks', 'steering-files'], - opencode: ['approvals', 'mcp-bridge', 'subagents', 'native-hooks'], - cursor: ['approvals', 'mcp-bridge', 'subagents', 'steering-files'], - codex: ['approvals', 'background-runs', 'mcp-bridge', 'subagents', 'native-hooks', 'steering-files'], - antigravity: ['approvals', 'mcp-bridge', 'subagents', 'steering-files'], +/** + * The runtime/transport baseline every host declares, 1:1 with + * `test-fixtures/spec/suite-v0.2/capability-matrix.json` (`clientCapabilities`). The negotiated set is the + * intersection of this with the backend grant and policy, so omitting a token here silently negotiates the + * feature away (resume, typed parts, cancellation). + */ +const RUNTIME_TOKENS: Record = { + copilot: ['approvals', 'background-runs', 'cancel-run', 'cursor-reconnect', 'mcp-bridge', 'subagents', 'typed-content-parts'], + claude: ['approvals', 'cancel-run', 'cursor-reconnect', 'mcp-bridge', 'subagents', 'typed-content-parts'], + kiro: ['approvals', 'cancel-run', 'cursor-reconnect', 'mcp-bridge', 'subagents', 'typed-content-parts'], + opencode: ['approvals', 'background-runs', 'cancel-run', 'cursor-reconnect', 'mcp-bridge', 'subagents', 'typed-content-parts'], + cursor: ['approvals', 'cancel-run', 'cursor-reconnect', 'mcp-bridge', 'subagents', 'typed-content-parts'], + codex: ['approvals', 'cancel-run', 'cursor-reconnect', 'mcp-bridge', 'subagents', 'typed-content-parts'], + antigravity: ['approvals', 'cancel-run', 'cursor-reconnect', 'mcp-bridge', 'subagents', 'typed-content-parts'], +}; + +/** Projection-level tokens (what the host can be given as files), additional to the runtime baseline. */ +const PROJECTION_TOKENS: Record = { + copilot: ['native-hooks', 'steering-files'], + claude: ['native-hooks', 'steering-files'], + kiro: ['native-hooks', 'steering-files'], + opencode: ['native-hooks'], + cursor: ['steering-files'], + codex: ['native-hooks', 'steering-files'], + antigravity: ['steering-files'], }; +/** Declared (not probed) host capability tokens: runtime baseline plus projection tokens. Advertised, not negotiated. */ +export const CAPABILITY_TOKENS_BY_HOST: Record = Object.fromEntries( + (Object.keys(RUNTIME_TOKENS) as ClientId[]).map((id) => [id, [...RUNTIME_TOKENS[id], ...PROJECTION_TOKENS[id]]]), +) as unknown as Record; const TRANSPORTS: Record = { copilot: ['mcp-bridge', 'http-backend', 'in-process'], claude: ['mcp-bridge', 'http-backend'], diff --git a/test/backend.test.ts b/test/backend.test.ts index 97ff1a135..d51898476 100644 --- a/test/backend.test.ts +++ b/test/backend.test.ts @@ -59,8 +59,9 @@ describe('LocalSquadBackend', () => { const b = new LocalSquadBackend(); await b.createConversation({ conversationId: 'c' }); await b.sendMessage('c', msg('r1', 1)); - const dup = await b.sendMessage('c', msg('r1', 1, { content: 'changed' })); + const dup = await b.sendMessage('c', msg('r1', 1)); expect(dup.duplicate).toBe(true); + expect(await rejectCode(b.sendMessage('c', msg('r1', 1, { content: 'changed' })))).toBe('conflict'); await b.sendMessage('c', msg('r2', 1)); const t = await b.getTranscript('c'); expect(t).toHaveLength(2); @@ -896,4 +897,140 @@ describe('PR #2238 final hardening', () => { expect(new Set(used).size).toBe(1); expect(used[0]).toMatch(/^[0-9a-f-]{36}$/); }); +}); +describe('PR review round 3 (Brady re-review of 6451747d)', () => { + const mk = (h = new FakeHarness()) => { + const harnesses = new DefaultHarnessRegistry(); + harnesses.register(h); + return { h, harnesses, state: new MemoryStateStore() }; + }; + const ask = (runId = 'r', id = 'a1') => ({ type: 'approval-requested' as const, runId, seq: 1, request: { approvalId: id, turnSeq: 1, action: 'x', hash: 'h' } }); + + it('P1 legacy outbox entries (id, no operationId): acknowledging one never drops another', async () => { + const { h, harnesses, state } = mk(); + const b = new LocalSquadBackend({ state, harnesses }); + await b.createConversation({ conversationId: 'c' }); + await b.sendMessage('c', msg('r1', 1, { harness: 'copilot' })); + await b.sendMessage('c', msg('r2', 1, { harness: 'copilot' })); + const key = 'backend/conversations/c.json'; + const doc = JSON.parse(state.read(key)!); + doc.turns.forEach((t: { state: string }) => { t.state = 'cancelled'; }); + doc.deliveries = [{ id: 'L1', kind: 'cancel', runId: 'r1', seq: 1 }, { kind: 'cancel', runId: 'r2', seq: 1 }, { kind: 'cancel', runId: 'r1', seq: 1 }]; + state.write(key, JSON.stringify(doc)); + h.cancel = async (_c: string, runId: string, seq: number) => { if (runId === 'r2') throw new Error('down'); h.cancelled.push(seq); }; + expect(await rejectCode(new LocalSquadBackend({ state, harnesses }).cancelTurn('c', 1))).toBe('unavailable'); + const left = JSON.parse(state.read(key)!).deliveries as Array<{ operationId: string; runId: string }>; + expect(left.map(d => d.runId)).toEqual(['r2', 'r1']); + expect(new Set(left.map(d => d.operationId)).size).toBe(2); + expect(left.every(d => !!d.operationId && !('id' in d))).toBe(true); + }); + + it('P1 a missing harness keeps the durable delivery and reports a retryable error', async () => { + const { h, harnesses, state } = mk(); + const b = new LocalSquadBackend({ state, harnesses }); + await b.createConversation({ conversationId: 'c' }); + await b.sendMessage('c', msg('r', 1, { harness: 'copilot' })); + h.emit(ask()); + const bare = new LocalSquadBackend({ state, harnesses: new DefaultHarnessRegistry() }); + const err = await bare.cancelRun('c', 'r').catch(e => e); + expect(err).toMatchObject({ code: 'unavailable', retryable: true }); + expect(JSON.parse(state.read('backend/conversations/c.json')!).deliveries).toHaveLength(1); + await new LocalSquadBackend({ state, harnesses }).cancelRun('c', 'r'); + expect(h.cancelled).toEqual([1]); + expect(JSON.parse(state.read('backend/conversations/c.json')!).deliveries ?? []).toHaveLength(0); + }); + + it('P1 an admission retry returns the ORIGINAL admission cursor so unseen events are replayed', async () => { + const { h, harnesses, state } = mk(); + const b = new LocalSquadBackend({ state, harnesses }); + await b.createConversation({ conversationId: 'c' }); + const first = await b.sendMessage('c', msg('r', 1, { harness: 'copilot' })); + h.emit(ask()); + h.emit({ type: 'task', runId: 'r', seq: 1, id: 't1', state: 'running' } as never); + const retry = await b.sendMessage('c', msg('r', 1, { harness: 'copilot' })); + expect(retry.duplicate).toBe(true); + expect(retry.cursor).toBe(first.cursor); + const replay = await b.readEvents('c', { after: retry.cursor }); + expect(replay.events.map(e => e.type)).toContain('approval.requested'); + }); + + it('P2 the same (runId, seq) with a different payload is a conflict, an identical one a duplicate', async () => { + const b = new LocalSquadBackend(); + await b.createConversation({ conversationId: 'c' }); + await b.sendMessage('c', msg('r', 1)); + expect((await b.sendMessage('c', msg('r', 1, { correlationId: 'other' }))).duplicate).toBe(true); + for (const changed of [{ content: 'different' }, { role: 'assistant' as const }, { parts: [{ type: 'text' as const, text: 'p' }], content: 'm1' }]) { + expect(await b.sendMessage('c', msg('r', 1, changed)).catch(e => e)).toMatchObject({ code: 'conflict', status: 409 }); + } + expect(await b.getTranscript('c')).toHaveLength(1); + }); + + it('P2 synchronous replay during restart recovery does not re-enter load() or deadlock the lock', async () => { + const fs = await import('node:fs'); + const os = await import('node:os'); + const path = await import('node:path'); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sq-replay-')); + try { + let subscribes = 0; + const replay: HarnessEventEnvelope[] = [ask() as never]; + class ReplayHarness extends FakeHarness { + override subscribe(c: string, l: (e: HarnessEventEnvelope) => void) { + subscribes++; + replay.forEach(e => l(e)); // synchronous replay before the subscription is returned + return super.subscribe(c, l); + } + } + const h = new ReplayHarness(); + const harnesses = new DefaultHarnessRegistry(); + harnesses.register(h); + const store = () => createFileStateStore(dir, { lockTimeoutMs: 400 }); + const first = new LocalSquadBackend({ state: store(), harnesses: (() => { const r = new DefaultHarnessRegistry(); r.register(new FakeHarness()); return r; })() }); + await first.createConversation({ conversationId: 'c' }); + await first.sendMessage('c', msg('r', 1, { harness: 'copilot' })); + subscribes = 0; + const restarted = new LocalSquadBackend({ state: store(), harnesses }); + const approvals = await restarted.getApprovals('c'); + expect(approvals.map(a => a.approvalId)).toEqual(['a1']); + expect(subscribes).toBe(1); + await new Promise(r => setTimeout(r, 20)); // the post-load persistence of the replayed event + expect((await new LocalSquadBackend({ state: store(), harnesses }).getApprovals('c')).map(a => a.approvalId)).toEqual(['a1']); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }); + + it('P1 overlapping stale-lock recovery (A observes, B takes over, C waits) never yields two owners', async () => { + const fs = await import('node:fs'); + const os = await import('node:os'); + const path = await import('node:path'); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sq-abc-')); + try { + const lockPath = path.join(dir, 'backend', 'locks', 'k.lock'); + fs.mkdirSync(path.dirname(lockPath), { recursive: true }); + fs.writeFileSync(lockPath, 'crashed:T1'); + const old = new Date(Date.now() - 60_000); + fs.utimesSync(lockPath, old, old); + let armed = true; + const storeA = createFileStateStore(dir, { + staleLockMs: 5_000, lockTimeoutMs: 300, + // B recovers the stale lock and acquires it right after A observed the stale T1 but before A recovers + beforeStaleRecovery: (lp) => { if (armed) { armed = false; fs.rmSync(lp); fs.writeFileSync(lp, 'B:live'); } }, + }); + expect(() => storeA.lock!('backend/locks/k')).toThrow(/Timed out/); // A must not steal B's live lock + expect(fs.readFileSync(lockPath, 'utf8')).toBe('B:live'); + const storeC = createFileStateStore(dir, { staleLockMs: 5_000, lockTimeoutMs: 300 }); + expect(() => storeC.lock!('backend/locks/k')).toThrow(/Timed out/); // nor can C acquire it + expect(fs.readFileSync(lockPath, 'utf8')).toBe('B:live'); + expect(fs.readdirSync(path.dirname(lockPath)).filter(n => n.endsWith('.recover'))).toEqual([]); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }); + + it('P2 an HTTP body-read failure is retried like any other transient failure', async () => { + let calls = 0; + const b = new DurableHttpSquadBackend({ baseUrl: 'http://d.test', retryDelayMs: 1, fetch: (async () => { + calls++; + if (calls === 1) return { ok: true, status: 200, headers: new Headers(), text: async () => { throw new TypeError('terminated'); } } as unknown as Response; + return new Response(JSON.stringify({ runId: 'r', seq: 1, duplicate: false, state: 'queued' }), { status: 200 }); + }) as never }); + expect((await b.sendMessage('c', msg('r', 1))).runId).toBe('r'); + expect(calls).toBe(2); + }); }); \ No newline at end of file diff --git a/test/clients.test.ts b/test/clients.test.ts index 0c3c916bb..b76061d68 100644 --- a/test/clients.test.ts +++ b/test/clients.test.ts @@ -605,3 +605,45 @@ describe('review hardening (Copilot review on #2238)', () => { expect(fs.readFileSync(path.join(sq, 'config.json'), 'utf8')).toBe('{ not json'); }); }); + +describe('TOML array-of-tables and quoted keys (PR review round 3)', () => { + const doc = [ + '[mcp_servers.squad_state]', + '"command" = "old"', + "'cwd' = \"/x\"", + 'args = ["a"]', + '', + '[[skills.config]]', + 'path = "/skills/one"', + '', + '[[skills.config]]', + 'path = "/skills/two"', + '', + ].join('\n'); + + it('upsertTomlTable stops at [[array_table]] and rewrites quoted managed keys in place', () => { + const out = upsertTomlTable(doc, 'mcp_servers.squad_state', { command: '"squad"', args: '["state-mcp"]' }); + expect(out.match(/command\s*=/g)).toHaveLength(1); + expect(out).toContain('command = "squad"'); + expect(out).toContain("'cwd' = \"/x\""); + expect(out).toContain('args = ["state-mcp"]'); + expect(out.match(/\[\[skills\.config\]\]/g)).toHaveLength(2); + expect(out).toContain('path = "/skills/one"'); + expect(out).toContain('path = "/skills/two"'); + // the managed block did not swallow the array tables: they still follow it + expect(out.indexOf('[[skills.config]]')).toBeGreaterThan(out.indexOf('args = ["state-mcp"]')); + }); + + it('inserts a missing key before the next [[array_table]], not after it', () => { + const out = upsertTomlTable('[t]\nk = 1\n\n[[arr]]\nv = 2\n', 't', { added: '"x"' }); + expect(out.indexOf('added = "x"')).toBeLessThan(out.indexOf('[[arr]]')); + expect(out).toContain('[[arr]]\nv = 2'); + }); + + it('removeTomlTable removes only the managed table and keeps the array tables', () => { + const out = removeTomlTable(doc, 'mcp_servers.squad_state'); + expect(out).not.toContain('squad_state'); + expect(out.match(/\[\[skills\.config\]\]/g)).toHaveLength(2); + expect(out).toContain('path = "/skills/two"'); + }); +}); \ No newline at end of file diff --git a/test/squad-20-all-scenarios.e2e.test.ts b/test/squad-20-all-scenarios.e2e.test.ts index c42ef0e95..a613a51a2 100644 --- a/test/squad-20-all-scenarios.e2e.test.ts +++ b/test/squad-20-all-scenarios.e2e.test.ts @@ -22,7 +22,7 @@ import { type HarnessAdapter, type HarnessEventEnvelope, type SquadBackend, -} from '../packages/squad-sdk/src/backend/index.js'; +} from '@bradygaster/squad-sdk'; import { BLOCK_BEGIN, CLIENT_IDS, @@ -38,7 +38,7 @@ import { resolveEffectiveCapabilities, type ClientContext, type ClientId, -} from '../packages/squad-sdk/src/clients/index.js'; +} from '@bradygaster/squad-sdk/clients'; import { backfillCastingRegistryFromTeam, ensureCastingRegistryPair, @@ -47,7 +47,7 @@ import { parseAgentProvenanceRegistry, readCastingRegistryPair, repairHandwrittenCastingRegistry, -} from '../packages/squad-sdk/src/casting/index.js'; +} from '@bradygaster/squad-sdk/casting'; import { ensureCastingDefaults, ensureGitignore } from '../packages/squad-cli/src/cli/core/upgrade.js'; import { isInteractiveSession, runInit } from '../packages/squad-cli/src/cli/core/init.js'; import { callBackendMcpTool, createBackendMcpTools, createMcpBackend } from '../packages/squad-cli/src/cli/core/backend-mcp.js'; @@ -358,8 +358,9 @@ describe('Scenario 3: LocalSquadBackend + HarnessRegistry (5 harnesses)', () => const b = new LocalSquadBackend({ harnesses: registry }); await b.createConversation({ conversationId: 'c' }); await b.sendMessage('c', msg('r', 1, { harness: 'kiro', epoch: 2 })); - const dup = await b.sendMessage('c', msg('r', 1, { harness: 'kiro', epoch: 2, content: 'retry' })); + const dup = await b.sendMessage('c', msg('r', 1, { harness: 'kiro', epoch: 2 })); expect(dup.duplicate).toBe(true); + expect(await errorOf(b.sendMessage('c', msg('r', 1, { harness: 'kiro', epoch: 2, content: 'changed' })))).toMatchObject({ code: 'conflict', status: 409 }); expect(harnesses.kiro.submitted).toHaveLength(1); expect((await b.sendMessage('c', msg('r2', 1, { harness: 'kiro', epoch: 2 }))).duplicate).toBe(false); const stale = await errorOf(b.sendMessage('c', msg('r', 2, { epoch: 1 }))); @@ -535,7 +536,9 @@ describe('Scenario 5: backend-mcp.ts full tool coverage', () => { parts: [{ type: 'text', text: 'hi' }], extensions: { 'x-claude.mode': 'plan' }, }); expect(sent.body).toMatchObject({ runId: 'r', seq: 1, duplicate: false, correlationId: 'k1' }); - expect((await call('squad_backend_send_message', { conversationId: 'mcp-c', runId: 'r', seq: 1, content: 'again' })).body.duplicate).toBe(true); + expect((await call('squad_backend_send_message', { conversationId: 'mcp-c', runId: 'r', seq: 1, content: '', harness: 'claude', + parts: [{ type: 'text', text: 'hi' }], extensions: { 'x-claude.mode': 'plan' } })).body.duplicate).toBe(true); + expect((await call('squad_backend_send_message', { conversationId: 'mcp-c', runId: 'r', seq: 1, content: 'again' })).body.error).toMatchObject({ code: 'conflict' }); expect((await call('squad_backend_send_message', { conversationId: 'mcp-c', runId: 'r', seq: 9, content: 'x', extensions: { bad: 1 } })).body.error.code).toBe('invalid-request'); expect((await call('squad_backend_send_message', { conversationId: 'mcp-c', seq: 1, content: 'x' })).isError).toBe(true); @@ -572,6 +575,28 @@ describe('Scenario 5: backend-mcp.ts full tool coverage', () => { await drive(mkHttp(server), harnesses.claude); }); + it('R3-9: createMcpBackend honors a configured (non-local) state backend instead of writing .squad/backend', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'squad20-mcp-orphan-')); + try { + const files = new Map(); + const orphan = { + name: 'orphan', + read: (p: string) => files.get(p), + write: (p: string, c: string) => { files.set(p, c); }, + exists: (p: string) => files.has(p), + list: (d: string) => [...files.keys()].filter(k => k.startsWith(`${d}/`)).map(k => k.slice(d.length + 1)), + }; + const backend = createMcpBackend('.', {}, () => ({ paths: { teamSquadDir: dir }, backend: orphan }) as never); + await backend.createConversation({ conversationId: 'c' }); + await backend.sendMessage('c', msg('r', 1)); + expect([...files.keys()]).toContain('backend/conversations/c.json'); + expect(fs.existsSync(path.join(dir, 'backend'))).toBe(false); + expect((await createMcpBackend('.', {}, () => ({ paths: { teamSquadDir: dir }, backend: orphan }) as never).getTranscript('c'))).toHaveLength(1); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + it('createMcpBackend selects durable-http via SQUAD_BACKEND_URL, else local over .squad', async () => { const server = createMockDurableSquad(new LocalSquadBackend()); vi.stubGlobal('fetch', server.fetch); @@ -585,7 +610,7 @@ describe('Scenario 5: backend-mcp.ts full tool coverage', () => { } const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'squad20-mcp-')); try { - vi.mocked(resolveSquadState).mockReturnValue({ paths: { teamSquadDir: dir } } as never); + vi.mocked(resolveSquadState).mockReturnValue({ paths: { teamSquadDir: dir }, backend: { name: 'local' } } as never); const local = createMcpBackend('.', {}); expect(local.kind).toBe('mcp'); await local.createConversation({ conversationId: 'c' }); @@ -1298,13 +1323,49 @@ describe('field findings from the live demo: Init Mode, scratch files, casting r write('registry.json', shapes[name]); const original = fs.readFileSync(path.join(castingDir, 'registry.json'), 'utf8'); const result = repairHandwrittenCastingRegistry(castingDir); - expect(result).toMatchObject({ repaired: true, files: ['registry.json', 'history.json'] }); + expect(result).toMatchObject({ repaired: true, files: ['registry.json', 'history.json', 'registry-history.commit.json'] }); expect(fs.readFileSync(path.join(castingDir, 'registry.json.handwritten.bak'), 'utf8')).toBe(original); const pair = readCastingRegistryPair(castingDir); expect(Object.keys(pair.registry.agents).length).toBeGreaterThan(0); expect(repairHandwrittenCastingRegistry(castingDir)).toEqual({ repaired: false, files: [] }); }); + it('R3-13: preserves retired agents, keeps a valid history and commits a canonical manifest', () => { + const MANIFEST = 'registry-history.commit.json'; + write('registry.json', { universe: 'Star Trek', agents: [ + { name: 'Data', role: 'Expert', status: 'active' }, + { name: 'Tuvok', role: 'Security', status: 'retired' }, + { name: 'Neelix', role: 'Morale', status: 'inactive' }, + ] }); + const result = repairHandwrittenCastingRegistry(castingDir); + expect(result).toMatchObject({ repaired: true, files: ['registry.json', 'history.json', MANIFEST] }); + const pair = readCastingRegistryPair(castingDir); + expect(pair.registry.agents['tuvok']!.status).toBe('retired'); + expect(pair.registry.agents['neelix']!.status).toBe('inactive'); + expect(pair.registry.agents['data']!.status).toBe('active'); + const manifest = JSON.parse(fs.readFileSync(path.join(castingDir, MANIFEST), 'utf8')); + expect(manifest).toMatchObject({ registry_revision: 1, transaction_id: expect.any(String) }); + expect(repairHandwrittenCastingRegistry(castingDir)).toEqual({ repaired: false, files: [] }); + }); + + it('R3-13: a manifest-only mismatch keeps the existing history and revision', () => { + const MANIFEST = 'registry-history.commit.json'; + write('registry.json', shapes['agents as an array']); + repairHandwrittenCastingRegistry(castingDir); + const before = readCastingRegistryPair(castingDir); + const historyBefore = JSON.parse(fs.readFileSync(path.join(castingDir, 'history.json'), 'utf8')); + // semantically identical registry, different bytes: only the commit manifest disagrees now + fs.writeFileSync(path.join(castingDir, 'registry.json'), JSON.stringify(before.registry)); + expect(() => readCastingRegistryPair(castingDir)).toThrow(/commit manifest/); + expect(repairHandwrittenCastingRegistry(castingDir)).toMatchObject({ repaired: true }); + const after = readCastingRegistryPair(castingDir); + expect(after.registry.revision).toBe(before.registry.revision); + expect(Object.keys(after.registry.agents)).toEqual(Object.keys(before.registry.agents)); + const historyAfter = JSON.parse(fs.readFileSync(path.join(castingDir, 'history.json'), 'utf8')); + expect(historyAfter.assignment_cast_snapshots).toEqual(historyBefore.assignment_cast_snapshots); + expect(historyAfter.universe_usage_history).toEqual(historyBefore.universe_usage_history); + expect(fs.existsSync(path.join(castingDir, MANIFEST))).toBe(true); + }); it('does not touch canonical registries, absent registries or unrecoverable content', () => { expect(repairHandwrittenCastingRegistry(castingDir)).toEqual({ repaired: false, files: [] }); const canonical = normalizeHandwrittenCastingRegistry(shapes['bare array'])!; @@ -1337,13 +1398,14 @@ describe('field findings from the live demo: Init Mode, scratch files, casting r expect(() => readCastingRegistryPair(castingDir)).toThrow(/consistent casting registry\/history pair/); }); - it('repair backs up and removes the stale manifest so the pair reads again', () => { + it('repair backs up the stale manifest and commits a valid one so the pair reads again', () => { initPair(); write('registry.json', shapes['role-keyed entries without a wrapper']); const manifestBytes = fs.readFileSync(path.join(castingDir, MANIFEST), 'utf8'); const result = repairHandwrittenCastingRegistry(castingDir); - expect(result).toMatchObject({ repaired: true, removed: [MANIFEST] }); - expect(fs.existsSync(path.join(castingDir, MANIFEST))).toBe(false); + expect(result).toMatchObject({ repaired: true, files: ['registry.json', 'history.json', MANIFEST] }); + expect(fs.existsSync(path.join(castingDir, MANIFEST))).toBe(true); + expect(fs.readFileSync(path.join(castingDir, MANIFEST), 'utf8')).not.toBe(manifestBytes); expect(fs.readFileSync(path.join(castingDir, `${MANIFEST}.handwritten.bak`), 'utf8')).toBe(manifestBytes); expect(Object.keys(readCastingRegistryPair(castingDir).registry.agents).sort()).toEqual(['avery', 'nova', 'quinn']); expect(repairHandwrittenCastingRegistry(castingDir)).toEqual({ repaired: false, files: [] }); @@ -1354,8 +1416,9 @@ describe('field findings from the live demo: Init Mode, scratch files, casting r const edited = normalizeHandwrittenCastingRegistry({ agents: [{ name: 'Ada', role: 'Core' }, { name: 'Linus', role: 'CLI' }] })!; write('registry.json', edited); expect(() => readCastingRegistryPair(castingDir)).toThrow(/commit manifest/); - expect(repairHandwrittenCastingRegistry(castingDir)).toMatchObject({ repaired: true, removed: [MANIFEST] }); + expect(repairHandwrittenCastingRegistry(castingDir)).toMatchObject({ repaired: true }); expect(Object.keys(readCastingRegistryPair(castingDir).registry.agents).sort()).toEqual(['ada', 'linus']); + expect(fs.existsSync(path.join(castingDir, MANIFEST))).toBe(true); }); it('leaves an untouched init pair and its manifest alone', () => { @@ -1512,7 +1575,7 @@ describe('field findings from the multi-client demos: process hygiene, .gitignor const pair = readCastingRegistryPair(dir); expect(Object.keys(pair.registry.agents).sort()).toEqual(['b-elanna', 'data']); expect(pair.registry.agents['data']).toMatchObject({ display_name: 'Data', role: 'Framework Expert', universe: 'Star Trek', status: 'active' }); - expect(fs.existsSync(path.join(dir, 'registry-history.commit.json'))).toBe(false); + expect(fs.existsSync(path.join(dir, 'registry-history.commit.json'))).toBe(true); expect(fs.existsSync(path.join(dir, 'registry.json.handwritten.bak'))).toBe(true); // a second upgrade creates nothing, keeps the same roster, and never backfills again expect(ensureCastingDefaults(dest)).toEqual([]); @@ -1577,6 +1640,22 @@ describe('client adapter contract is exposed canonically and through the depreca } }); + it('R3-12: CAPABILITY_TOKENS_BY_HOST carries the full capability-matrix baseline for all 7 hosts', async () => { + const { CAPABILITY_TOKENS_BY_HOST, resolveEffectiveCapabilityTokens } = await import('@bradygaster/squad-sdk/client'); + const projection = new Set(['native-hooks', 'steering-files']); + for (const id of CLIENT_IDS) { + const declared = CAPABILITY_TOKENS_BY_HOST[id]; + const runtime = declared.filter(t => !projection.has(t)).sort(); + expect(runtime, id).toEqual([...matrix.clients[id]!.clientCapabilities].sort()); + for (const t of ['cancel-run', 'cursor-reconnect', 'typed-content-parts']) expect(declared, `${id}:${t}`).toContain(t); + expect(resolveEffectiveCapabilityTokens(declared, matrix.backendCapabilities, matrix.policyCapabilities).sort(), id) + .toEqual([...matrix.clients[id]!.effectiveCapabilities].sort()); + } + expect(CAPABILITY_TOKENS_BY_HOST.copilot).toContain('background-runs'); + expect(CAPABILITY_TOKENS_BY_HOST.opencode).toContain('background-runs'); + expect(CAPABILITY_TOKENS_BY_HOST.claude).not.toContain('background-runs'); + }); + it('every host declares tokens that map onto the capability flags consistently', async () => { const { CAPABILITY_TOKENS_BY_HOST, capabilityFlagsFromTokens } = await import('@bradygaster/squad-sdk/client'); expect(Object.keys(CAPABILITY_TOKENS_BY_HOST).sort()).toEqual([...CLIENT_IDS].sort()); diff --git a/test/squad-20-real-clients-stdio.e2e.test.ts b/test/squad-20-real-clients-stdio.e2e.test.ts index 8b3361e0c..e6a4bef70 100644 --- a/test/squad-20-real-clients-stdio.e2e.test.ts +++ b/test/squad-20-real-clients-stdio.e2e.test.ts @@ -328,7 +328,10 @@ describe.each(CLIENTS)('real stdio MCP server launched from the %s config', (cli parts: [{ type: 'text', text: 'hello from a real client' }, { type: 'resource', uri: 'file:///README.md', title: 'README' }], }); expect(sent.json).toMatchObject({ runId: `${client}-run`, seq: 1, duplicate: false, state: 'queued', correlationId: 'corr-real' }); - const dup = await server!.call('squad_backend_send_message', { conversationId: `${client}-conv`, runId: `${client}-run`, seq: 1, content: 'retry' }); + const dup = await server!.call('squad_backend_send_message', { + conversationId: `${client}-conv`, runId: `${client}-run`, seq: 1, correlationId: 'corr-real', + parts: [{ type: 'text', text: 'hello from a real client' }, { type: 'resource', uri: 'file:///README.md', title: 'README' }], + }); expect(dup.json.duplicate).toBe(true); const transcript = await server!.call('squad_backend_get_transcript', { conversationId: `${client}-conv` }); @@ -359,11 +362,13 @@ describe.each(CLIENTS)('real stdio MCP server launched from the %s config', (cli expect(tampered.json.error.code).toBe('approval-hash-mismatch'); const ok = await server!.call('squad_backend_respond_approval', { conversationId: 'seeded', approvalId: 'appr-1', hash: 'sha256:deploy-v1', decision: 'approve' }); - expect(ok.isError).toBe(false); - expect(ok.json).toMatchObject({ runId: 'seed-run', state: 'running' }); + // the seeding harness ('copilot') is not registered in this server process: the decision is durable but its + // delivery stays queued and is reported as retryable instead of being silently discarded + expect(ok.isError).toBe(true); + expect(ok.json.error).toMatchObject({ code: 'unavailable', retryable: true }); expect((await server!.call('squad_backend_get_approvals', { conversationId: 'seeded' })).json[0]).toMatchObject({ version: 2, state: 'approved' }); const again = await server!.call('squad_backend_respond_approval', { conversationId: 'seeded', approvalId: 'appr-1', hash: 'sha256:deploy-v1', decision: 'approve' }); - expect(again.json.error.code).toBe('approval-not-pending'); + expect(again.json.error).toMatchObject({ code: 'unavailable', retryable: true }); }); it('reports backend health', async () => { @@ -387,7 +392,8 @@ describe.each(CLIENTS)('real stdio MCP server launched from the %s config', (cli expect(seqs).toEqual(seqs.map((_: number, i: number) => i + 1)); expect(resumed.json.events.map((e: { sequence: number }) => e.sequence)).toEqual(seqs.slice(2)); expect((await server.call('squad_backend_get_approvals', { conversationId: 'seeded' })).json[0].version).toBe(2); - expect((await server.call('squad_backend_send_message', { conversationId: `${client}-conv`, runId: `${client}-run`, seq: 1, content: 'again' })).json.duplicate).toBe(true); + expect((await server.call('squad_backend_send_message', { conversationId: `${client}-conv`, runId: `${client}-run`, seq: 1, correlationId: 'corr-real', + parts: [{ type: 'text', text: 'hello from a real client' }, { type: 'resource', uri: 'file:///README.md', title: 'README' }] })).json.duplicate).toBe(true); }, 120_000); }); diff --git a/vitest.config.ts b/vitest.config.ts index 6150844b6..0611b925e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,18 +1,7 @@ -import path from 'node:path'; import { defineConfig } from 'vitest/config'; -const sdkSrc = path.resolve(__dirname, 'packages/squad-sdk/src'); - export default defineConfig({ resolve: { - // Resolve the Squad 2.0 SDK entrypoints from source so tests run without a built dist. - alias: [ - { find: /^@bradygaster\/squad-sdk\/client$/, replacement: path.join(sdkSrc, 'client/index.ts') }, - { find: /^@bradygaster\/squad-sdk\/clients$/, replacement: path.join(sdkSrc, 'clients/index.ts') }, - { find: /^@bradygaster\/squad-sdk\/casting$/, replacement: path.join(sdkSrc, 'casting/index.ts') }, - { find: /^@bradygaster\/squad-sdk\/backend$/, replacement: path.join(sdkSrc, 'backend/index.ts') }, - { find: /^@bradygaster\/squad-sdk$/, replacement: path.join(sdkSrc, 'index.ts') }, - ], // Force vitest to resolve @bradygaster/squad-sdk from the workspace root, // not from a duplicate copy under packages/squad-cli/node_modules/. // Without this, vi.mock('@bradygaster/squad-sdk') targets the root copy @@ -20,7 +9,7 @@ export default defineConfig({ dedupe: ['@bradygaster/squad-sdk'], }, test: { - include: ['test/**/*.test.ts', 'packages/squad-sdk/test/**/*.test.ts'], + include: ['test/**/*.test.ts'], coverage: { provider: 'v8', reporter: ['text', 'text-summary', 'html'], From 47bd105cbfbb7d1f2a145250e73dbcd141c09859 Mon Sep 17 00:00:00 2001 From: Copilot Date: Mon, 19 Oct 2026 01:04:24 +0300 Subject: [PATCH 45/46] fix(squad-2.0): recover abandoned .recover mutexes, write legacy outbox through before delivery, journey + repro tests Restores .github/agents/squad.agent.md (a local test run had stamped it, failing template-sync in CI). Repairs a handwritten casting registry before the strict upgrade preflight and keeps config.json writes inside the squad dir. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 995eea17-c356-4912-90ea-5e5b144e7ba9 --- .github/agents/squad.agent.md | 4 +- packages/squad-cli/src/cli/core/clients.ts | 5 +- packages/squad-cli/src/cli/core/upgrade.ts | 3 + .../squad-sdk/src/backend/local-backend.ts | 55 ++- test/backend.test.ts | 107 ++++++ test/squad-20-all-scenarios.e2e.test.ts | 314 +++++++++++++++++- 6 files changed, 475 insertions(+), 13 deletions(-) diff --git a/.github/agents/squad.agent.md b/.github/agents/squad.agent.md index 04865f4f8..57a62308e 100644 --- a/.github/agents/squad.agent.md +++ b/.github/agents/squad.agent.md @@ -6,14 +6,14 @@ tools: ["*"] - + You are **Squad (Coordinator)** — the orchestrator for this project's AI team. ### Coordinator Identity - **Name:** Squad (Coordinator) -- **Version:** 1.0.0 (see HTML comment above — this value is stamped during install/upgrade). Include it as `Squad v1.0.0` in your first response of each session (e.g., in the acknowledgment or greeting). +- **Version:** 0.0.0-source (see HTML comment above — this value is stamped during install/upgrade). Include it as `Squad v{version}` in your first response of each session (e.g., in the acknowledgment or greeting). - **Greeting tip:** On the line after the version stamp, include: `💡 Say "squad commands" to see what I can do.` — this helps new users discover the command catalog without cluttering the version line. - **Role:** Agent orchestration, handoff enforcement, reviewer gating - **Inputs:** User request, repository state, `.squad/decisions.md` diff --git a/packages/squad-cli/src/cli/core/clients.ts b/packages/squad-cli/src/cli/core/clients.ts index 88e04e19b..787f5b55d 100644 --- a/packages/squad-cli/src/cli/core/clients.ts +++ b/packages/squad-cli/src/cli/core/clients.ts @@ -9,7 +9,7 @@ import fs from 'node:fs'; import path from 'node:path'; -import { CLIENT_IDS, installClients, normalizeClientIds, parseClients, type ApplyResult, type ClientId } from '@bradygaster/squad-sdk/clients'; +import { CLIENT_IDS, installClients, normalizeClientIds, parseClients, resolveInsideRoot, type ApplyResult, type ClientId } from '@bradygaster/squad-sdk/clients'; import { projectSquadStateMcpSpec, resolveSquadStateMcpSpec } from './mcp-spec.js'; import { getPackageVersion } from './version.js'; @@ -41,7 +41,8 @@ export function readConfiguredClients(squadDir: string): ClientId[] { } export function persistClients(squadDir: string, clients: readonly ClientId[]): void { - const file = path.join(squadDir, 'config.json'); + // never follow a symlink/junction at (or above) config.json: the write must stay inside the squad directory + const file = resolveInsideRoot(squadDir, 'config.json'); let cfg: Record = {}; if (fs.existsSync(file)) { let parsed: unknown; diff --git a/packages/squad-cli/src/cli/core/upgrade.ts b/packages/squad-cli/src/cli/core/upgrade.ts index 8a1a4178f..22ff684b3 100644 --- a/packages/squad-cli/src/cli/core/upgrade.ts +++ b/packages/squad-cli/src/cli/core/upgrade.ts @@ -1259,6 +1259,9 @@ export async function runUpgrade(dest: string, options: UpgradeOptions = {}): Pr const projectType = detectProjectType(dest); + // repair a handwritten registry BEFORE the strict preflight, which would otherwise reject it and make the + // repair in ensureCastingDefaults unreachable + if (!options.dryRun) repairHandwrittenCastingRegistry(path.join(squadDirInfo.path, 'casting')); const castingMigrated = preflightCastingRegistryPair( path.join(squadDirInfo.path, 'casting'), options.dryRun ?? false, diff --git a/packages/squad-sdk/src/backend/local-backend.ts b/packages/squad-sdk/src/backend/local-backend.ts index 3ff9b393f..134c33eab 100644 --- a/packages/squad-sdk/src/backend/local-backend.ts +++ b/packages/squad-sdk/src/backend/local-backend.ts @@ -73,21 +73,25 @@ function sleepSync(ms: number): void { const LOCK_TIMEOUT_MS = 10_000; const LOCK_STALE_MS = 30_000; -/** File-system store rooted at a `.squad` directory. Never follows symlinks/junctions below the root. */ /** * Remove an abandoned lock under an exclusive `.recover` mutex. While holding it the lock is re-read: it is * only removed if it is still byte-for-byte (and mtime-for-mtime) the stale lock that was observed, so a lock * a faster waiter has since re-acquired is never touched and two waiters can never both own the lock. + * + * The mutex itself is protected against a reclaimer that dies while holding it: it carries an owner token and + * is only held for microseconds, so once it is older than `recoverStaleMs` it is claimed by atomic rename + * (one winner), re-verified against the token that was observed, and discarded. A live mutex is never taken. */ -function recoverStaleLock(lockPath: string, observed: string, observedMtime: number, staleLockMs: number): boolean { +function recoverStaleLock(lockPath: string, observed: string, observedMtime: number, staleLockMs: number, recoverStaleMs: number): boolean { const mutex = `${lockPath}.recover`; + const token = `${process.pid}:${randomUUID()}`; let fd: number; try { fd = fs.openSync(mutex, 'wx'); + fs.writeSync(fd, token); } catch (err) { if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err; - // a crashed recoverer must not wedge recovery forever - try { if (Date.now() - fs.statSync(mutex).mtimeMs > staleLockMs) fs.rmSync(mutex, { force: true }); } catch { /* raced */ } + reclaimAbandonedMutex(mutex, recoverStaleMs); return false; } try { @@ -99,13 +103,33 @@ function recoverStaleLock(lockPath: string, observed: string, observedMtime: num return true; } finally { fs.closeSync(fd); - fs.rmSync(mutex, { force: true }); + try { if (fs.readFileSync(mutex, 'utf8') === token) fs.rmSync(mutex, { force: true }); } catch { /* already reclaimed */ } } } + +function reclaimAbandonedMutex(mutex: string, recoverStaleMs: number): void { + let observed: string; + try { + if (Date.now() - fs.statSync(mutex).mtimeMs <= recoverStaleMs) return; // held by a live reclaimer + observed = fs.readFileSync(mutex, 'utf8'); + } catch { return; } + const dead = `${mutex}.dead.${process.pid}.${randomUUID()}`; + try { fs.renameSync(mutex, dead); } catch { return; } // another waiter claimed it first + let claimed: string | undefined; + try { claimed = fs.readFileSync(dead, 'utf8'); } catch { /* unreadable: treat as the abandoned one */ } + if (claimed !== undefined && claimed !== observed) { + // we raced a fresh mutex: put it back (no-op if a newer one already exists) + try { fs.linkSync(dead, mutex); } catch { /* newer owner present */ } + } + fs.rmSync(dead, { force: true }); +} +/** File-system store rooted at a `.squad` directory. Never follows symlinks/junctions below the root. */ export interface FileStateStoreOptions { /** A lock file untouched for longer than this is treated as abandoned by a crashed holder. */ staleLockMs?: number; lockTimeoutMs?: number; + /** An abandoned .recover mutex (its holder died) older than this is reclaimed. Defaults to min(staleLockMs, 2s). */ + recoverStaleMs?: number; /** Test seam: runs after a stale lock was observed and before recovery starts. */ beforeStaleRecovery?: (lockPath: string) => void; } @@ -113,6 +137,7 @@ export interface FileStateStoreOptions { export function createFileStateStore(squadDir: string, options: FileStateStoreOptions = {}): BackendStateStore { const staleLockMs = options.staleLockMs ?? LOCK_STALE_MS; const lockTimeoutMs = options.lockTimeoutMs ?? LOCK_TIMEOUT_MS; + const recoverStaleMs = options.recoverStaleMs ?? Math.min(staleLockMs, 2_000); const root = path.resolve(squadDir); const resolve = (rel: string): string => { const full = path.resolve(root, rel); @@ -147,16 +172,17 @@ export function createFileStateStore(squadDir: string, options: FileStateStoreOp if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err; // Crash recovery: an abandoned lock is claimed by atomic rename, so exactly one waiter wins it. // The claimed file is re-checked; if it turns out to be a live holder's lock it is put back untouched. + let recoveryBusy = false; try { const observed = fs.readFileSync(lockPath, 'utf8'); const observedMtime = fs.statSync(lockPath).mtimeMs; if (Date.now() - observedMtime > staleLockMs) { options.beforeStaleRecovery?.(lockPath); - if (recoverStaleLock(lockPath, observed, observedMtime, staleLockMs)) continue; - sleepSync(10); - continue; + if (recoverStaleLock(lockPath, observed, observedMtime, staleLockMs, recoverStaleMs)) continue; + recoveryBusy = true; } } catch { continue; } + if (recoveryBusy && Date.now() <= deadline) { sleepSync(10); continue; } if (Date.now() > deadline) throw new BackendError('unavailable', 'Timed out waiting for the conversation lock', 503); sleepSync(10); } @@ -524,6 +550,9 @@ export class LocalSquadBackend implements SquadBackend { * `respondApproval` as idempotent. Failure surfaces as a retryable `unavailable` error. */ private async flushDeliveries(conversationId: string): Promise { + // Legacy entries (`id`, or no `operationId`) are normalized deterministically on load; write that back under + // the lock BEFORE any external delivery so a crash mid-delivery restarts with the very same operationId. + if (this.outboxNeedsWriteThrough(conversationId)) this.tx(conversationId, () => undefined); for (;;) { const conv = this.load(conversationId); const next = conv.deliveries?.[0]; @@ -551,6 +580,16 @@ export class LocalSquadBackend implements SquadBackend { } } + private outboxNeedsWriteThrough(conversationId: string): boolean { + try { + const raw = this.state.read(`${ROOT}/${conversationId}.json`); + const deliveries = raw ? (JSON.parse(raw) as { deliveries?: Array> }).deliveries : undefined; + return !!deliveries?.some(d => !d['operationId'] || 'id' in d); + } catch { + return false; + } + } + private adapterFor(turn: BackendTurn) { const harness = turn.messages[0]?.harness; return harness ? this.harnesses.get(harness) : undefined; diff --git a/test/backend.test.ts b/test/backend.test.ts index d51898476..1187a02bc 100644 --- a/test/backend.test.ts +++ b/test/backend.test.ts @@ -1033,4 +1033,111 @@ describe('PR review round 3 (Brady re-review of 6451747d)', () => { expect((await b.sendMessage('c', msg('r', 1))).runId).toBe('r'); expect(calls).toBe(2); }); +}); +describe('PR review round 4: reclaimer death and outbox write-through', () => { + const tmp = async () => { + const fs = await import('node:fs'); + const os = await import('node:os'); + const path = await import('node:path'); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sq-r4-')); + const lockPath = path.join(dir, 'backend', 'locks', 'k.lock'); + fs.mkdirSync(path.dirname(lockPath), { recursive: true }); + const age = (p: string, ms: number) => { const t = new Date(Date.now() - ms); fs.utimesSync(p, t, t); }; + return { fs, path, dir, lockPath, age }; + }; + + it('A/B/C with a REAL second reclaimer: B recovers and holds, A and C never become owners', async () => { + const { fs, path, dir, lockPath, age } = await tmp(); + try { + fs.writeFileSync(lockPath, 'crashed:T1'); + age(lockPath, 60_000); + const opts = { staleLockMs: 5_000, lockTimeoutMs: 300, recoverStaleMs: 100 }; + const storeB = createFileStateStore(dir, opts); + let releaseB: (() => void) | undefined; + let armed = true; + const storeA = createFileStateStore(dir, { + ...opts, + // A has observed the stale T1; B runs its entire recovery + acquisition before A starts recovering + beforeStaleRecovery: () => { if (armed) { armed = false; releaseB = storeB.lock!('backend/locks/k'); } }, + }); + expect(() => storeA.lock!('backend/locks/k')).toThrow(/Timed out/); + expect(releaseB).toBeDefined(); + const holder = fs.readFileSync(lockPath, 'utf8'); + expect(holder).not.toBe('crashed:T1'); + expect(() => createFileStateStore(dir, opts).lock!('backend/locks/k')).toThrow(/Timed out/); + expect(fs.readFileSync(lockPath, 'utf8')).toBe(holder); // still B's + releaseB!(); + const releaseC = createFileStateStore(dir, opts).lock!('backend/locks/k'); + releaseC(); + expect(fs.readdirSync(path.dirname(lockPath)).filter(n => n.includes('.recover'))).toEqual([]); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }); + + it('a reclaimer that died while holding ${lockPath}.recover cannot wedge recovery forever', async () => { + const { fs, path, dir, lockPath, age } = await tmp(); + try { + fs.writeFileSync(lockPath, 'crashed:T1'); + age(lockPath, 60_000); + fs.writeFileSync(`${lockPath}.recover`, 'dead-reclaimer:1'); + age(`${lockPath}.recover`, 60_000); + const store = createFileStateStore(dir, { staleLockMs: 5_000, lockTimeoutMs: 2_000, recoverStaleMs: 100 }); + const release = store.lock!('backend/locks/k'); // abandoned mutex is reclaimed, then the stale lock + expect(fs.readFileSync(lockPath, 'utf8')).not.toBe('crashed:T1'); + release(); + expect(fs.readdirSync(path.dirname(lockPath)).filter(n => n.includes('.recover') || n.includes('.dead.'))).toEqual([]); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }); + + it('a LIVE ${lockPath}.recover mutex is never taken: single-winner exclusion holds', async () => { + const { fs, dir, lockPath, age } = await tmp(); + try { + fs.writeFileSync(lockPath, 'crashed:T1'); + age(lockPath, 60_000); + fs.writeFileSync(`${lockPath}.recover`, 'live-reclaimer:1'); // fresh mtime + const store = createFileStateStore(dir, { staleLockMs: 5_000, lockTimeoutMs: 300, recoverStaleMs: 5_000 }); + expect(() => store.lock!('backend/locks/k')).toThrow(/Timed out/); + expect(fs.readFileSync(`${lockPath}.recover`, 'utf8')).toBe('live-reclaimer:1'); + expect(fs.readFileSync(lockPath, 'utf8')).toBe('crashed:T1'); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }); + + it('legacy outbox normalization is written through under the lock BEFORE harness delivery and stays stable across a crash', async () => { + const { fs, path, dir } = await tmp(); + try { + const h = new FakeHarness(); + const harnesses = new DefaultHarnessRegistry(); + harnesses.register(h); + const make = () => new LocalSquadBackend({ state: createFileStateStore(dir, { lockTimeoutMs: 1_000 }), harnesses }); + const b = make(); + await b.createConversation({ conversationId: 'c' }); + await b.sendMessage('c', msg('r1', 1, { harness: 'copilot' })); + await b.sendMessage('c', msg('r2', 1, { harness: 'copilot' })); + const file = path.join(dir, 'backend', 'conversations', 'c.json'); + const doc = JSON.parse(fs.readFileSync(file, 'utf8')); + doc.turns.forEach((t: { state: string }) => { t.state = 'cancelled'; }); + doc.deliveries = [{ id: 'legacy-1', kind: 'cancel', runId: 'r1', seq: 1 }, { kind: 'cancel', runId: 'r2', seq: 1 }]; + fs.writeFileSync(file, JSON.stringify(doc)); + + const seen: Array<{ op?: string; onDisk: string[]; legacyIdsLeft: boolean }> = []; + h.cancel = (async (_c: string, _r: string, _s: number, op?: string) => { + const disk = JSON.parse(fs.readFileSync(file, 'utf8')).deliveries as Array<{ operationId?: string; id?: string }>; + seen.push({ op, onDisk: disk.map(d => d.operationId ?? ''), legacyIdsLeft: disk.some(d => 'id' in d) }); + throw new Error('crash mid-delivery'); + }) as never; + + expect(await rejectCode(make().cancelTurn('c', 1))).toBe('unavailable'); + expect(seen).toHaveLength(1); + // at the moment of delivery, every entry on disk already carried its final operationId and no legacy id + expect(seen[0]!.onDisk.every(Boolean)).toBe(true); + expect(seen[0]!.legacyIdsLeft).toBe(false); + expect(seen[0]!.op).toBe('legacy-1'); + expect(seen[0]!.onDisk[0]).toBe(seen[0]!.op); + expect(new Set(seen[0]!.onDisk).size).toBe(2); + + // restart: the same operationId is passed again (nothing regenerated) + expect(await rejectCode(make().cancelTurn('c', 1))).toBe('unavailable'); + expect(seen[1]!.op).toBe(seen[0]!.op); + expect(seen[1]!.onDisk).toEqual(seen[0]!.onDisk); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }); }); \ No newline at end of file diff --git a/test/squad-20-all-scenarios.e2e.test.ts b/test/squad-20-all-scenarios.e2e.test.ts index a613a51a2..1f7a9d053 100644 --- a/test/squad-20-all-scenarios.e2e.test.ts +++ b/test/squad-20-all-scenarios.e2e.test.ts @@ -17,6 +17,7 @@ import { DurableHttpSquadBackend, LocalSquadBackend, MemoryStateStore, + createFileStateStore, createSquadBackend, type BackendEnvelope, type HarnessAdapter, @@ -30,6 +31,7 @@ import { assertPortableClients, getClientAdapter, buildCoordinatorInstructions, + createClientAdapter, installClients, uninstallClients, normalizeClientIds, @@ -38,6 +40,7 @@ import { resolveEffectiveCapabilities, type ClientContext, type ClientId, + type RuntimeBinding, } from '@bradygaster/squad-sdk/clients'; import { backfillCastingRegistryFromTeam, @@ -48,8 +51,9 @@ import { readCastingRegistryPair, repairHandwrittenCastingRegistry, } from '@bradygaster/squad-sdk/casting'; -import { ensureCastingDefaults, ensureGitignore } from '../packages/squad-cli/src/cli/core/upgrade.js'; +import { ensureCastingDefaults, ensureGitignore, runUpgrade } from '../packages/squad-cli/src/cli/core/upgrade.js'; import { isInteractiveSession, runInit } from '../packages/squad-cli/src/cli/core/init.js'; +import { persistClients } from '../packages/squad-cli/src/cli/core/clients.js'; import { callBackendMcpTool, createBackendMcpTools, createMcpBackend } from '../packages/squad-cli/src/cli/core/backend-mcp.js'; // Clean-room (exit criterion 3) code is typed strictly against the public subpath barrel. import type { @@ -1664,4 +1668,312 @@ describe('client adapter contract is exposed canonically and through the depreca expect(flags.supportsSubagents, id).toBe(getClientAdapter(id).capabilities.supportsSubagents); } }); +}); + +// ── Journey and Brady re-review repros ────────────────────────────── + +describe('Full non-bypass journey: host adapter → broker → harness → approval → restart/reconnect', () => { + type Broker = { client: () => SquadBackend; restart: () => void; harness: () => TestHarness }; + + /** An MCP client facade: every call goes through the backend-mcp.ts tool surface as JSON, like a real MCP host. */ + function mcpFacade(getBackend: () => SquadBackend): SquadBackend { + const tools = new Map(createBackendMcpTools(getBackend).map(t => [t.name, t])); + const call = async (name: string, args: Record) => { + const res = await callBackendMcpTool(tools.get(name)!, args); + const body = JSON.parse(res.content[0]!.text); + if (res.isError) throw new BackendError(body.error.code, body.error.message, body.error.status ?? 500); + return body; + }; + return { + kind: 'mcp-client', + createConversation: o => call('squad_backend_create_conversation', { ...o }), + sendMessage: (c, m) => call('squad_backend_send_message', { conversationId: c, ...m }), + readEvents: (c, o) => call('squad_backend_read_events', { conversationId: c, ...o }), + respondApproval: (c, r) => call('squad_backend_respond_approval', { conversationId: c, ...r }), + cancelRun: (c, runId, reason) => call('squad_backend_cancel_run', { conversationId: c, runId, reason }), + getApprovals: c => call('squad_backend_get_approvals', { conversationId: c }), + negotiate: h => call('squad_backend_negotiate', { ...h }), + } as unknown as SquadBackend; + } + + const brokers: Record Broker> = { + 'MCP broker (state-mcp tools over a file store)': (dir) => { + let harness!: TestHarness; let local!: LocalSquadBackend; + const start = () => { + harness = new TestHarness('copilot'); + const registry = new DefaultHarnessRegistry(); registry.register(harness); + local = new LocalSquadBackend({ state: createFileStateStore(dir), harnesses: registry }); + }; + start(); + return { client: () => mcpFacade(() => local), restart: start, harness: () => harness }; + }, + 'HTTP broker (DurableSquad /v1 surface over a file store)': (dir) => { + let harness!: TestHarness; let server!: ReturnType; + const start = () => { + harness = new TestHarness('copilot'); + const registry = new DefaultHarnessRegistry(); registry.register(harness); + server = createMockDurableSquad(new LocalSquadBackend({ state: createFileStateStore(dir), harnesses: registry })); + }; + start(); + // a reconnecting host persists its lease epoch (a fresh client without one is fenced out) + return { client: () => mkHttp(server, { epoch: 1 }), restart: start, harness: () => harness }; + }, + }; + + it.each(Object.keys(brokers))('%s', async (name) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'squad20-journey-')); + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'squad20-journey-ws-')); + try { + const broker = brokers[name]!(dir); + const ctx: ClientContext = { root, mcp: { command: 'squad', args: ['state-mcp'] }, members: [{ name: 'Data', role: 'Expert' }] }; + installClients(['claude'], ctx); + expect(fs.existsSync(path.join(root, '.mcp.json'))).toBe(true); + + // the host adapter speaks only the adapter contract; its runtime is the broker client (no direct backend access) + let client = broker.client(); + let runCounter = 0; + const runtimeFor = (c: () => SquadBackend): RuntimeBinding => ({ + submit: async ({ conversationId, prompt }) => { + const runId = `run-${++runCounter}`; + const adm = await c().sendMessage(conversationId, { runId, seq: 1, role: 'user', content: prompt, harness: 'copilot' }); + return { admissionId: adm.cursor ?? runId, runId }; + }, + subscribe: async ({ conversationId, cursor }, onEvent) => { + const page = await c().readEvents(conversationId, cursor ? { after: cursor } : {}); + for (const e of page.events) await onEvent({ ...e, resumable: page.resumable, nextCursor: page.nextCursor }); + return async () => undefined; + }, + cancel: async (conversationId, runId, reason) => { await c().cancelRun(conversationId, runId, reason); }, + respondApproval: async (conversationId, r) => { + await c().respondApproval(conversationId, { approvalId: r.approvalId, hash: r.requestHash, decision: r.decision === 'approved' ? 'approve' : 'deny' }); + }, + backendCapabilities: async () => matrix.backendCapabilities, + policyCapabilities: async () => matrix.policyCapabilities, + }); + const adapter = createClientAdapter({ host: 'claude', ctx, runtime: runtimeFor(() => client) }); + expect((await adapter.describe()).projectedFiles.length).toBeGreaterThan(0); + expect((await adapter.getCapabilities('j')).effectiveCapabilities).toEqual(matrix.clients['claude']!.effectiveCapabilities); + + await client.createConversation({ conversationId: 'j' }); + const { runId } = await adapter.submit({ conversationId: 'j', prompt: 'deploy to prod?' }); + expect(broker.harness().submitted).toEqual([{ runId, seq: 1 }]); + + // harness execution asks for approval + broker.harness().emit({ type: 'approval-requested', runId, seq: 1, request: { approvalId: 'appr-j', turnSeq: 1, action: 'deploy', hash: 'sha256:j' } }); + const seen: Array> = []; + await adapter.subscribe({ conversationId: 'j' }, e => { seen.push(e); }); + const requested = seen.find(e => e['type'] === 'approval.requested'); + expect(requested).toBeDefined(); + const cursor = seen.at(-1)!['nextCursor'] as string; + + // hash binding is enforced through the whole stack, then the real decision reaches the harness + expect((await errorOf(adapter.respondApproval('j', { approvalId: 'appr-j', requestHash: 'sha256:evil', decision: 'approved' })))?.code).toBe('approval-hash-mismatch'); + await adapter.respondApproval('j', { approvalId: 'appr-j', requestHash: 'sha256:j', decision: 'approved' }); + expect(broker.harness().approvals).toEqual(['appr-j']); + + // broker restart: new process state, same durable store; the host reconnects with its saved cursor + broker.restart(); + client = broker.client(); + const afterRestart: Array> = []; + await adapter.subscribe({ conversationId: 'j', cursor }, e => { afterRestart.push(e); }); + expect(afterRestart.every(e => (e['sequence'] as number) > (seen.at(-1)!['sequence'] as number))).toBe(true); + expect(afterRestart.some(e => e['type'] === 'approval.resolved')).toBe(true); + expect((await client.getApprovals('j'))[0]).toMatchObject({ approvalId: 'appr-j', state: 'approved', version: 2 }); + + // the re-attached harness finishes the run; the host sees it from its cursor, exactly once, in order + broker.harness().emit({ type: 'state', state: 'completed', runId, seq: 1 }); + const tail: Array> = []; + await adapter.subscribe({ conversationId: 'j', cursor: afterRestart.at(-1)?.['nextCursor'] as string ?? cursor }, e => { tail.push(e); }); + const all = (await client.readEvents('j')).events; + expect(all.map(e => e.sequence)).toEqual(all.map((_, i) => i + 1)); + expect(all.some(e => e.type === 'turn.state' && (e.payload as { state?: string }).state === 'completed')).toBe(true); + expect((await client.sendMessage('j', msg(runId, 1, { harness: 'copilot', content: 'deploy to prod?' }))).duplicate).toBe(true); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); + +describe('Brady re-review 13 defect repros', () => { + const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'squad20-brady-')); + const reg = (h: TestHarness) => { const r = new DefaultHarnessRegistry(); r.register(h); return r; }; + + it('1. vitest.config.ts has no SDK source alias (no dual module instances)', () => { + const cfg = fs.readFileSync(path.resolve(__dirname, '..', 'vitest.config.ts'), 'utf8'); + expect(cfg).not.toMatch(/find:\s*\/\^@bradygaster/); + expect(cfg).not.toMatch(/squad-sdk\/src/); + }); + + it('2. every --client help line fits 80 columns', () => { + for (const f of ['packages/squad-cli/src/cli-entry.ts', 'packages/squad-cli/src/cli/core/command-help.ts']) { + const lines = fs.readFileSync(path.resolve(__dirname, '..', f), 'utf8').split(/\r?\n/).filter(l => l.includes('console.log(`') && l.includes('--client')); + expect(lines.length).toBeGreaterThan(0); + for (const l of lines) { + const visible = l.replace(/^\s*console\.log\(`/, '').replace(/`\);?\s*$/, '').replace(/\$\{[A-Z_]+\}/g, ''); + expect(visible.length, visible).toBeLessThanOrEqual(80); + } + } + }); + + it('3. legacy outbox entries without operationId never drop each other', async () => { + const h = new TestHarness('copilot'); const state = new MemoryStateStore(); + const b = new LocalSquadBackend({ state, harnesses: reg(h) }); + await b.createConversation({ conversationId: 'c' }); + for (const r of ['r1', 'r2']) await b.sendMessage('c', msg(r, 1, { harness: 'copilot' })); + const key = 'backend/conversations/c.json'; + const doc = JSON.parse(state.read(key)!); + doc.turns.forEach((t: { state: string }) => { t.state = 'cancelled'; }); + doc.deliveries = [{ kind: 'cancel', runId: 'r1', seq: 1 }, { kind: 'cancel', runId: 'r2', seq: 1 }]; + state.write(key, JSON.stringify(doc)); + h.cancel = async (_c: string, runId: string) => { if (runId === 'r2') throw new Error('down'); }; + expect((await errorOf(new LocalSquadBackend({ state, harnesses: reg(h) }).cancelTurn('c', 1)))?.code).toBe('unavailable'); + const left = JSON.parse(state.read(key)!).deliveries as Array<{ runId: string; operationId: string }>; + expect(left.map(d => d.runId)).toEqual(['r2']); + expect(left[0]!.operationId).toBeTruthy(); + }); + + it('4. overlapping stale-lock reclaimers never produce two owners', () => { + const dir = tmp(); + try { + const lockPath = path.join(dir, 'backend', 'locks', 'k.lock'); + fs.mkdirSync(path.dirname(lockPath), { recursive: true }); + fs.writeFileSync(lockPath, 'crashed:T1'); + const old = new Date(Date.now() - 60_000); fs.utimesSync(lockPath, old, old); + const o = { staleLockMs: 5_000, lockTimeoutMs: 250, recoverStaleMs: 100 }; + let releaseB: (() => void) | undefined; let armed = true; + const a = createFileStateStore(dir, { ...o, beforeStaleRecovery: () => { if (armed) { armed = false; releaseB = createFileStateStore(dir, o).lock!('backend/locks/k'); } } }); + expect(() => a.lock!('backend/locks/k')).toThrow(/Timed out/); + expect(() => createFileStateStore(dir, o).lock!('backend/locks/k')).toThrow(/Timed out/); + releaseB!(); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }); + + it('5. an admission retry replays from the ORIGINAL cursor; 8. a different payload conflicts', async () => { + const h = new TestHarness('copilot'); + const b = new LocalSquadBackend({ harnesses: reg(h) }); + await b.createConversation({ conversationId: 'c' }); + const first = await b.sendMessage('c', msg('r', 1, { harness: 'copilot' })); + h.emit({ type: 'approval-requested', runId: 'r', seq: 1, request: { approvalId: 'a', turnSeq: 1, action: 'x', hash: 'h' } }); + const retry = await b.sendMessage('c', msg('r', 1, { harness: 'copilot' })); + expect(retry).toMatchObject({ duplicate: true, cursor: first.cursor }); + expect((await b.readEvents('c', { after: retry.cursor })).events.map(e => e.type)).toContain('approval.requested'); + expect((await errorOf(b.sendMessage('c', msg('r', 1, { harness: 'copilot', content: 'other' }))))?.code).toBe('conflict'); + }); + + it('6. a missing harness keeps the durable delivery and fails retryably', async () => { + const h = new TestHarness('copilot'); const state = new MemoryStateStore(); + const b = new LocalSquadBackend({ state, harnesses: reg(h) }); + await b.createConversation({ conversationId: 'c' }); + await b.sendMessage('c', msg('r', 1, { harness: 'copilot' })); + const err = await errorOf(new LocalSquadBackend({ state }).cancelRun('c', 'r')); + expect(err).toMatchObject({ code: 'unavailable', retryable: true }); + await new LocalSquadBackend({ state, harnesses: reg(h) }).cancelRun('c', 'r'); + expect(h.cancelled).toEqual([{ runId: 'r', seq: 1 }]); + }); + + it('7. synchronous replay during rehydration neither recurses nor deadlocks', async () => { + const dir = tmp(); + try { + const store = () => createFileStateStore(dir, { lockTimeoutMs: 400 }); + const seed = new TestHarness('copilot'); + const first = new LocalSquadBackend({ state: store(), harnesses: reg(seed) }); + await first.createConversation({ conversationId: 'c' }); + await first.sendMessage('c', msg('r', 1, { harness: 'copilot' })); + let subscribes = 0; + class Replay extends TestHarness { + override subscribe(c: string, l: (e: HarnessEventEnvelope) => void) { + subscribes++; + l({ type: 'approval-requested', runId: 'r', seq: 1, request: { approvalId: 'a', turnSeq: 1, action: 'x', hash: 'h' } }); + return super.subscribe(c, l); + } + } + const restarted = new LocalSquadBackend({ state: store(), harnesses: reg(new Replay('copilot')) }); + expect((await restarted.getApprovals('c')).map(a => a.approvalId)).toEqual(['a']); + expect(subscribes).toBe(1); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }); + + it('9. the MCP broker honors a configured state backend', async () => { + const files = new Map(); + const orphan = { name: 'orphan', read: (p: string) => files.get(p), write: (p: string, c: string) => { files.set(p, c); }, exists: (p: string) => files.has(p), list: () => [] as string[] }; + const dir = tmp(); + try { + const b = createMcpBackend('.', {}, () => ({ paths: { teamSquadDir: dir }, backend: orphan }) as never); + await b.createConversation({ conversationId: 'c' }); + expect([...files.keys()]).toContain('backend/conversations/c.json'); + expect(fs.existsSync(path.join(dir, 'backend'))).toBe(false); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }); + + it('10. an HTTP body-read failure is retried', async () => { + let calls = 0; + const b = new DurableHttpSquadBackend({ baseUrl: 'http://d.test', retryDelayMs: 1, fetch: (async () => { + if (++calls === 1) return { ok: true, status: 200, headers: new Headers(), text: async () => { throw new TypeError('terminated'); } } as unknown as Response; + return json(200, { runId: 'r', seq: 1, duplicate: false, state: 'queued' }); + }) as never }); + expect((await b.sendMessage('c', msg('r', 1))).runId).toBe('r'); + expect(calls).toBe(2); + }); + + it('11. TOML [[array_table]] boundaries and quoted keys survive upsert/remove', async () => { + const { upsertTomlTable, removeTomlTable } = await import('@bradygaster/squad-sdk/client'); + const doc = '[mcp_servers.squad_state]\n"command" = "old"\n\n[[skills.config]]\npath = "/a"\n'; + const out = upsertTomlTable(doc, 'mcp_servers.squad_state', { command: '"squad"' }); + expect(out.match(/command\s*=/g)).toHaveLength(1); + expect(out).toContain('[[skills.config]]\npath = "/a"'); + expect(removeTomlTable(doc, 'mcp_servers.squad_state')).toContain('[[skills.config]]'); + }); + + it('12. capability tokens match capability-matrix.json for all 7 hosts', async () => { + const { CAPABILITY_TOKENS_BY_HOST } = await import('@bradygaster/squad-sdk/client'); + for (const id of CLIENT_IDS) { + expect(CAPABILITY_TOKENS_BY_HOST[id].filter(t => !['native-hooks', 'steering-files'].includes(t)).sort()).toEqual([...matrix.clients[id]!.clientCapabilities].sort()); + } + }); + + it('13. casting repair keeps retired agents and commits a valid manifest', () => { + const dir = tmp(); + try { + fs.writeFileSync(path.join(dir, 'registry.json'), JSON.stringify({ universe: 'Star Trek', agents: [{ name: 'Data', role: 'Expert' }, { name: 'Tuvok', role: 'Security', status: 'retired' }] })); + expect(repairHandwrittenCastingRegistry(dir)).toMatchObject({ repaired: true, files: ['registry.json', 'history.json', 'registry-history.commit.json'] }); + expect(readCastingRegistryPair(dir).registry.agents['tuvok']!.status).toBe('retired'); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }); +}); + +describe('Open review threads on #2239', () => { + it('runUpgrade repairs a handwritten casting registry instead of failing the strict preflight', async () => { + const dest = fs.mkdtempSync(path.join(os.tmpdir(), 'squad20-upgrade-hw-')); + const quiet = vi.spyOn(console, 'log').mockImplementation(() => {}); + const write = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + const savedStdin = process.stdin.isTTY; + try { + (process.stdin as { isTTY?: boolean }).isTTY = undefined; + await runInit(dest, { includeWorkflows: false } as never); + const dir = path.join(dest, '.squad', 'casting'); + fs.writeFileSync(path.join(dir, 'registry.json'), JSON.stringify({ universe: 'Star Trek', agents: [{ name: 'Data', role: 'Expert' }, { name: 'Tuvok', role: 'Security', status: 'retired' }] })); + await runUpgrade(dest, { force: true } as never); + const pair = readCastingRegistryPair(dir); + expect(Object.keys(pair.registry.agents).sort()).toEqual(['data', 'tuvok']); + expect(pair.registry.agents['tuvok']!.status).toBe('retired'); + } finally { + (process.stdin as { isTTY?: boolean }).isTTY = savedStdin; + quiet.mockRestore(); + write.mockRestore(); + fs.rmSync(dest, { recursive: true, force: true }); + } + }, 120_000); + + it('persistClients refuses to write through a symlinked config.json', () => { + const base = fs.mkdtempSync(path.join(os.tmpdir(), 'squad20-cfg-')); + try { + const squadDir = path.join(base, '.squad'); const outside = path.join(base, 'outside.json'); + fs.mkdirSync(squadDir); + fs.writeFileSync(outside, '{"keep":true}'); + try { fs.symlinkSync(outside, path.join(squadDir, 'config.json'), 'file'); } catch { return; } // symlinks need privileges on Windows + expect(() => persistClients(squadDir, ['claude'])).toThrow(/symlink|symbolic link|outside/i); + expect(fs.readFileSync(outside, 'utf8')).toBe('{"keep":true}'); + } finally { fs.rmSync(base, { recursive: true, force: true }); } + }); }); \ No newline at end of file From 24d6670b7264336ac53a3f10081be832ae50140d Mon Sep 17 00:00:00 2001 From: Copilot Date: Mon, 19 Oct 2026 01:24:34 +0300 Subject: [PATCH 46/46] fix(upgrade): repair handwritten casting registries before preflight only when no cast history exists A blanket pre-repair defeated the deliberate fail-closed refusals for registries with real history (test/cli/legacy-casting-upgrade). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 995eea17-c356-4912-90ea-5e5b144e7ba9 --- packages/squad-cli/src/cli/core/upgrade.ts | 24 +++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/squad-cli/src/cli/core/upgrade.ts b/packages/squad-cli/src/cli/core/upgrade.ts index 22ff684b3..a7f53e14b 100644 --- a/packages/squad-cli/src/cli/core/upgrade.ts +++ b/packages/squad-cli/src/cli/core/upgrade.ts @@ -1227,6 +1227,20 @@ async function refreshClients(dest: string, squadDir: string, options: UpgradeOp } } +/** True when history.json records any assignment snapshot or universe usage (i.e. real cast history exists). */ +function hasCastHistory(castingDir: string): boolean { + const raw = storage.readSync(path.join(castingDir, 'history.json')); + if (raw === undefined) return false; + try { + const history = JSON.parse(raw) as Record; + const snapshots = history['assignment_cast_snapshots']; + const usage = history['universe_usage_history']; + const hasSnapshots = !!snapshots && typeof snapshots === 'object' && Object.keys(snapshots).length > 0; + return hasSnapshots || (Array.isArray(usage) && usage.length > 0); + } catch { + return true; // unreadable history: do not guess, fail closed + } +} /** * Run the upgrade command */ @@ -1259,9 +1273,13 @@ export async function runUpgrade(dest: string, options: UpgradeOptions = {}): Pr const projectType = detectProjectType(dest); - // repair a handwritten registry BEFORE the strict preflight, which would otherwise reject it and make the - // repair in ensureCastingDefaults unreachable - if (!options.dryRun) repairHandwrittenCastingRegistry(path.join(squadDirInfo.path, 'casting')); + // A handwritten registry (model wrote registry.json itself over the empty init pair) is repaired BEFORE the + // strict preflight, which would otherwise reject it and make the repair in ensureCastingDefaults unreachable. + // Only when there is no cast history to contradict: registries with real history keep failing closed. + if (!options.dryRun) { + const castingDir = path.join(squadDirInfo.path, 'casting'); + if (!hasCastHistory(castingDir)) repairHandwrittenCastingRegistry(castingDir); + } const castingMigrated = preflightCastingRegistryPair( path.join(squadDirInfo.path, 'casting'), options.dryRun ?? false,