diff --git a/.squad/templates/orchestration-log.md b/.squad/templates/orchestration-log.md index 08e131565..da368f2b7 100644 --- a/.squad/templates/orchestration-log.md +++ b/.squad/templates/orchestration-log.md @@ -1,27 +1,28 @@ -# Orchestration Log Entry - -> One file per agent spawn. Saved to `.squad/orchestration-log/{timestamp}-{agent-name}.md` - ---- - -### {timestamp} — {task summary} - -| Field | Value | -|-------|-------| -| **Agent routed** | {Name} ({Role}) | -| **Why chosen** | {Routing rationale — what in the request matched this agent} | -| **Mode** | {`background` / `sync`} | -| **Why this mode** | {Brief reason — e.g., "No hard data dependencies" or "User needs to approve architecture"} | -| **Files authorized to read** | {Exact file paths the agent was told to read} | -| **File(s) agent must produce** | {Exact file paths the agent is expected to create or modify} | -| **Outcome** | {Completed / Rejected by {Reviewer} / Escalated} | - ---- - -## Rules - -1. **One file per agent spawn.** Named `{timestamp}-{agent-name}.md`. Timestamps must be filename-safe (replace colons with hyphens, e.g., `2026-02-23T20-16-27Z`). -2. **Log BEFORE spawning.** The entry must exist before the agent runs. -3. **Update outcome AFTER the agent completes.** Fill in the Outcome field. -4. **Never delete or edit past entries.** Append-only. -5. **If a reviewer rejects work,** log the rejection as a new entry with the revision agent. +# Orchestration Log Entry + +> One file per agent spawn. Saved to `.squad/orchestration-log/{timestamp}-{agent-name}.md` + +--- + +### {timestamp} — {task summary} + +| Field | Value | +|-------|-------| +| **Agent routed** | {Name} ({Role}) | +| **Why chosen** | {Routing rationale — what in the request matched this agent} | +| **Mode** | {`background` / `sync`} | +| **Why this mode** | {Brief reason — e.g., "No hard data dependencies" or "User needs to approve architecture"} | +| **Files authorized to read** | {Exact file paths the agent was told to read} | +| **File(s) agent must produce** | {Exact file paths the agent is expected to create or modify} | +| **Outcome** | {Completed / Rejected by {Reviewer} / Escalated} | +| **Token usage** | {inputTokens} in / {outputTokens} out — ${estimatedCostUsd} | + +--- + +## Rules + +1. **One file per agent spawn.** Named `{timestamp}-{agent-name}.md`. Timestamps must be filename-safe (replace colons with hyphens, e.g., `2026-02-23T20-16-27Z`). +2. **Log BEFORE spawning.** The entry must exist before the agent runs. +3. **Update outcome AFTER the agent completes.** Fill in the Outcome field. +4. **Never delete or edit past entries.** Append-only. +5. **If a reviewer rejects work,** log the rejection as a new entry with the revision agent. diff --git a/docs/src/content/docs/features/cost-tracking.md b/docs/src/content/docs/features/cost-tracking.md new file mode 100644 index 000000000..13b638421 --- /dev/null +++ b/docs/src/content/docs/features/cost-tracking.md @@ -0,0 +1,89 @@ +# Token Usage & Cost Tracking + +> ⚠️ **Experimental** — Squad is alpha software. APIs, commands, and behavior may change between releases. + +Squad can track token usage and estimated cost for each agent spawn, roll that data up by session, and expose it through orchestration logs, terminal summaries, and telemetry backends. + +--- + +## Overview + +- Squad tracks token usage (input/output tokens) and estimated cost per agent spawn +- Usage data is recorded in orchestration logs and available via `squad cost` CLI +- Optional budget limits can be configured per agent or per session + +--- + +## How It Works + +- The `CostTracker` class (`packages/squad-sdk/src/runtime/cost-tracker.ts`) accumulates token data +- Each orchestration log entry includes a **Token usage** row +- OTel metrics (`squad.tokens.input`, `squad.tokens.output`, `squad.tokens.cost`) are emitted when telemetry is enabled + +The orchestration log template stores usage in a markdown table row like this: + +```md +| **Token usage** | 12,450 in / 3,200 out — $0.0234 | +``` + +--- + +## Viewing Costs + +```bash +squad cost # current session costs +squad cost --all # all historical costs +squad cost --agent fenster # costs for specific agent +``` + +**Example output:** + +```text +=== Squad Cost Summary === +Total input tokens: 12,450 +Total output tokens: 3,200 +Estimated cost: $0.0234 + +--- By Agent --- + fenster: 12,450in / 3,200out ($0.0234) [1 turns, model: claude-sonnet-4.5] + +--- By Session --- + session-abc123: 12,450in / 3,200out ($0.0234) [1 turns] +``` + +--- + +## Budget Configuration + +```typescript +import { defineSquad, defineAgent, defineBudget } from '@bradygaster/squad-sdk'; + +export default defineSquad({ + defaults: { + budget: defineBudget({ + perAgentSpawn: 50000, + perSession: 500000, + warnAt: 0.8, + }), + }, + agents: [ + defineAgent({ + name: 'fenster', + role: 'Core Dev', + budget: defineBudget({ perAgentSpawn: 100000 }), + }), + ], +}); +``` + +- `perAgentSpawn` limits an individual agent invocation +- `perSession` limits the total budget for the coordinator session +- `warnAt` emits warnings when usage reaches a fraction of the configured limit + +--- + +## OTel Integration + +- Token metrics are exported as OpenTelemetry counters when telemetry is enabled +- Compatible with Aspire dashboard, Grafana, and any OTel-compatible backend +- Metrics: `squad.tokens.input`, `squad.tokens.output`, `squad.tokens.cost` diff --git a/packages/squad-cli/package.json b/packages/squad-cli/package.json index 2495f7706..cd5073d58 100644 --- a/packages/squad-cli/package.json +++ b/packages/squad-cli/package.json @@ -136,6 +136,10 @@ "types": "./dist/cli/commands/copilot.d.ts", "import": "./dist/cli/commands/copilot.js" }, + "./commands/cost": { + "types": "./dist/cli/commands/cost.d.ts", + "import": "./dist/cli/commands/cost.js" + }, "./commands/copilot-bridge": { "types": "./dist/cli/commands/copilot-bridge.d.ts", "import": "./dist/cli/commands/copilot-bridge.js" diff --git a/packages/squad-cli/src/cli-entry.ts b/packages/squad-cli/src/cli-entry.ts index 030047f4a..81e75328c 100644 --- a/packages/squad-cli/src/cli-entry.ts +++ b/packages/squad-cli/src/cli-entry.ts @@ -79,6 +79,7 @@ import path from 'node:path'; import { fatal, SquadError } from './cli/core/errors.js'; import { BOLD, RESET, DIM, RED, GREEN, YELLOW } from './cli/core/output.js'; import { runInit } from './cli/core/init.js'; +import { runCost } from './cli/commands/cost.js'; import { getPackageVersion } from './cli/core/version.js'; // Lazy-load squad-sdk to avoid triggering @github/copilot-sdk import on Node 24+ @@ -145,6 +146,8 @@ async function main(): Promise { console.log(` ${BOLD}status${RESET} Show which squad is active and why`); console.log(` ${BOLD}roles${RESET} List built-in Squad roles`); console.log(` Usage: roles [--category ] [--search ]`); + console.log(` ${BOLD}cost${RESET} Report token usage from orchestration logs`); + console.log(` Flags: --all, --agent `); console.log(` ${BOLD}triage${RESET} Scan for work and categorize issues`); console.log(` Usage: triage [--interval ]`); console.log(` Default: checks every 10 minutes (Ctrl+C to stop)`); @@ -416,6 +419,23 @@ async function main(): Promise { return; } + if (cmd === 'cost') { + const sdk = await lazySquadSdk(); + const localSquad = sdk.resolveSquad(process.cwd()); + const globalPath = sdk.resolveGlobalSquadPath(); + const globalSquadDir = path.join(globalPath, '.squad'); + const teamRoot = localSquad + ? path.resolve(localSquad, '..') + : (fs.existsSync(globalSquadDir) ? globalPath : null); + + if (!teamRoot) { + fatal('No squad found. Run "squad init" first.'); + } + + await runCost(args.slice(1), teamRoot); + return; + } + if (cmd === 'build') { const { runBuild } = await import('./cli/commands/build.js'); const hasCheck = args.includes('--check'); diff --git a/packages/squad-cli/src/cli/commands/cost.ts b/packages/squad-cli/src/cli/commands/cost.ts new file mode 100644 index 000000000..2aad9905a --- /dev/null +++ b/packages/squad-cli/src/cli/commands/cost.ts @@ -0,0 +1,236 @@ +import { readdir, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { fatal } from '../core/errors.js'; + +interface CostArgs { + showAll: boolean; + agentFilter?: string; +} + +interface CostEntry { + agent: string; + inputTokens: number; + outputTokens: number; + estimatedCost: number; + timestamp: string; +} + +interface AgentTotals { + agent: string; + inputTokens: number; + outputTokens: number; + estimatedCost: number; + spawns: number; +} + +const TOKEN_USAGE_RE = + /\|\s*\*\*Token usage\*\*\s*\|\s*([\d,]+)\s+in\s*\/\s*([\d,]+)\s+out\s*[—-]\s*\$([\d.,]+)\s*\|/i; +const AGENT_RE = /\|\s*\*\*Agent routed\*\*\s*\|\s*(.+?)\s*\|/i; +const SAFE_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z/; + +function parseArgs(args: string[]): CostArgs { + const showAll = args.includes('--all'); + const agentIdx = args.indexOf('--agent'); + const agentFilter = agentIdx !== -1 ? args[agentIdx + 1]?.trim() : undefined; + + if (agentIdx !== -1 && !agentFilter) { + fatal('Usage: squad cost [--all] [--agent ]'); + } + + return { showAll, agentFilter }; +} + +function extractTimestamp(fileName: string): string | null { + return fileName.match(SAFE_TIMESTAMP_RE)?.[0] ?? null; +} + +function extractAgent(agentCell: string): string { + return agentCell.replace(/\s*\([^)]*\)\s*$/, '').trim(); +} + +function parseNumber(raw: string): number { + return Number(raw.replace(/,/g, '')); +} + +function parseCostEntry(content: string, timestamp: string): CostEntry | null { + const tokenMatch = content.match(TOKEN_USAGE_RE); + const agentMatch = content.match(AGENT_RE); + + if (!tokenMatch || !agentMatch) { + return null; + } + + const agent = extractAgent(agentMatch[1] ?? ''); + const inputTokens = parseNumber(tokenMatch[1] ?? '0'); + const outputTokens = parseNumber(tokenMatch[2] ?? '0'); + const estimatedCost = Number((tokenMatch[3] ?? '0').replace(/,/g, '')); + + if (!agent || Number.isNaN(inputTokens) || Number.isNaN(outputTokens) || Number.isNaN(estimatedCost)) { + return null; + } + + return { + agent, + inputTokens, + outputTokens, + estimatedCost, + timestamp, + }; +} + +function sortNewestFirst(entries: readonly string[]): string[] { + return [...entries].sort((a, b) => b.localeCompare(a)); +} + +function formatInteger(value: number): string { + return new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(value); +} + +function formatCost(value: number): string { + return `$${value.toFixed(4)}`; +} + +function printNoData(): void { + console.log('💰 No token usage data found in orchestration logs.'); + console.log(' Token tracking is recorded when agents report usage in their responses.'); +} + +function printSummary(entries: CostEntry[], agentFilter?: string): void { + const totalsByAgent = new Map(); + + for (const entry of entries) { + const current = totalsByAgent.get(entry.agent) ?? { + agent: entry.agent, + inputTokens: 0, + outputTokens: 0, + estimatedCost: 0, + spawns: 0, + }; + + current.inputTokens += entry.inputTokens; + current.outputTokens += entry.outputTokens; + current.estimatedCost += entry.estimatedCost; + current.spawns += 1; + totalsByAgent.set(entry.agent, current); + } + + const rows = [...totalsByAgent.values()].sort((a, b) => b.estimatedCost - a.estimatedCost || a.agent.localeCompare(b.agent)); + const total = rows.reduce( + (acc, row) => { + acc.inputTokens += row.inputTokens; + acc.outputTokens += row.outputTokens; + acc.estimatedCost += row.estimatedCost; + acc.spawns += row.spawns; + return acc; + }, + { inputTokens: 0, outputTokens: 0, estimatedCost: 0, spawns: 0 }, + ); + + const agentWidth = Math.max( + 'Agent'.length, + 'Total'.length, + ...rows.map(row => row.agent.length), + ) + 2; + const inputWidth = Math.max('Input Tokens'.length, ...rows.map(row => formatInteger(row.inputTokens).length), formatInteger(total.inputTokens).length) + 2; + const outputWidth = Math.max('Output Tokens'.length, ...rows.map(row => formatInteger(row.outputTokens).length), formatInteger(total.outputTokens).length) + 2; + const costWidth = Math.max('Est. Cost'.length, ...rows.map(row => formatCost(row.estimatedCost).length), formatCost(total.estimatedCost).length) + 2; + + const divider = ` ${'─'.repeat(agentWidth)}${' '.repeat(2)}${'─'.repeat(inputWidth)}${' '.repeat(2)}${'─'.repeat(outputWidth)}${' '.repeat(2)}${'─'.repeat(costWidth)}`; + const formatRow = (label: string, inputTokens: number, outputTokens: number, estimatedCost: number): string => + ` ${label.padEnd(agentWidth)}${formatInteger(inputTokens).padStart(inputWidth)} ${formatInteger(outputTokens).padStart(outputWidth)} ${formatCost(estimatedCost).padStart(costWidth)}`; + + console.log('💰 Squad Cost Summary'); + console.log('━━━━━━━━━━━━━━━━━━━━'); + console.log(); + console.log( + ` ${'Agent'.padEnd(agentWidth)}${'Input Tokens'.padStart(inputWidth)} ${'Output Tokens'.padStart(outputWidth)} ${'Est. Cost'.padStart(costWidth)}`, + ); + console.log(divider); + for (const row of rows) { + console.log(formatRow(row.agent, row.inputTokens, row.outputTokens, row.estimatedCost)); + } + console.log(divider); + console.log(formatRow('Total', total.inputTokens, total.outputTokens, total.estimatedCost)); + console.log(); + + const filterLabel = agentFilter ? ` for ${agentFilter}` : ''; + console.log(` 📊 ${rows.length} agent${rows.length === 1 ? '' : 's'} across ${total.spawns} spawn${total.spawns === 1 ? '' : 's'}${filterLabel}`); +} + +async function listMarkdownFiles(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }); + return entries + .filter(entry => entry.isFile() && entry.name.endsWith('.md')) + .map(entry => entry.name); +} + +async function getCurrentSessionCutoff(teamRoot: string): Promise { + const sessionLogDir = join(teamRoot, '.squad', 'log'); + + try { + const logFiles = sortNewestFirst(await listMarkdownFiles(sessionLogDir)); + for (const file of logFiles) { + const timestamp = extractTimestamp(file); + if (timestamp) { + return timestamp; + } + } + return null; + } catch { + return null; + } +} + +export async function runCost(args: string[], teamRoot: string): Promise { + const { showAll, agentFilter } = parseArgs(args); + const orchestrationDir = join(teamRoot, '.squad', 'orchestration-log'); + + let files: string[]; + try { + files = sortNewestFirst(await listMarkdownFiles(orchestrationDir)); + } catch { + printNoData(); + return; + } + + if (files.length === 0) { + printNoData(); + return; + } + + const cutoffTimestamp = showAll ? null : await getCurrentSessionCutoff(teamRoot); + const filteredFiles = files.filter(file => { + const timestamp = extractTimestamp(file); + if (!timestamp) { + return false; + } + return cutoffTimestamp ? timestamp > cutoffTimestamp : true; + }); + + const entries: CostEntry[] = []; + for (const file of filteredFiles) { + const timestamp = extractTimestamp(file); + if (!timestamp) { + continue; + } + + const content = await readFile(join(orchestrationDir, file), 'utf8'); + const parsed = parseCostEntry(content, timestamp); + if (!parsed) { + continue; + } + + if (agentFilter && parsed.agent.toLowerCase() !== agentFilter.toLowerCase()) { + continue; + } + + entries.push(parsed); + } + + if (entries.length === 0) { + printNoData(); + return; + } + + printSummary(entries, agentFilter); +} diff --git a/packages/squad-cli/src/cli/index.ts b/packages/squad-cli/src/cli/index.ts index d446e73d9..74003b903 100644 --- a/packages/squad-cli/src/cli/index.ts +++ b/packages/squad-cli/src/cli/index.ts @@ -34,6 +34,7 @@ export { export * from './core/workflows.js'; export * from './core/team-md.js'; export { runCopilot, type CopilotFlags } from './commands/copilot.js'; +export { runCost } from './commands/cost.js'; export { runDoctor, doctorCommand, type DoctorCheck, type DoctorMode } from './commands/doctor.js'; export { runExport } from './commands/export.js'; export { runImport } from './commands/import.js'; diff --git a/packages/squad-cli/templates/squad.agent.md b/packages/squad-cli/templates/squad.agent.md index dffd7ee10..3f72a05d2 100644 --- a/packages/squad-cli/templates/squad.agent.md +++ b/packages/squad-cli/templates/squad.agent.md @@ -720,10 +720,13 @@ prompt: | You are the Scribe. Read .squad/agents/scribe/charter.md. TEAM ROOT: {team_root} - SPAWN MANIFEST: {spawn_manifest} +SPAWN MANIFEST: {spawn_manifest} Tasks (in order): - 1. ORCHESTRATION LOG: Write .squad/orchestration-log/{timestamp}-{agent}.md per agent. Use filename-safe ISO 8601 UTC timestamp (replace colons with hyphens, e.g., `2026-02-23T20-16-27Z` not `2026-02-23T20:16:27Z`). + Include token usage from agent responses in the manifest when available. + + Tasks (in order): + 1. ORCHESTRATION LOG: Write .squad/orchestration-log/{timestamp}-{agent}.md per agent. Include token usage (inputTokens, outputTokens, estimatedCostUsd) from the spawn manifest in the **Token usage** row. Use filename-safe ISO 8601 UTC timestamp (replace colons with hyphens, e.g., `2026-02-23T20-16-27Z` not `2026-02-23T20:16:27Z`). 2. SESSION LOG: Write .squad/log/{timestamp}-{topic}.md. Brief. Use filename-safe ISO 8601 UTC timestamp (replace colons with hyphens, e.g., `2026-02-23T20-16-27Z`). 3. DECISION INBOX: Merge .squad/decisions/inbox/ → decisions.md, delete inbox files. Deduplicate. 4. CROSS-AGENT: Append team updates to affected agents' history.md. diff --git a/packages/squad-sdk/src/builders/index.ts b/packages/squad-sdk/src/builders/index.ts index 5b1d2baf2..6485d29fb 100644 --- a/packages/squad-sdk/src/builders/index.ts +++ b/packages/squad-sdk/src/builders/index.ts @@ -1,494 +1,526 @@ -/** - * Builder Functions — SDK-First Squad Mode - * - * Each builder accepts a strongly-typed config object, validates it at - * runtime (manual type-guards — no zod dependency), and returns the - * validated value with the same type. The pattern mirrors `defineConfig()` - * in config/schema.ts: identity-passthrough with runtime safety. - * - * @module builders - */ - -import type { - TeamDefinition, - AgentDefinition, - ModelPreference, - DefaultsDefinition, - RoutingDefinition, - CeremonyDefinition, - HooksDefinition, - CastingDefinition, - TelemetryDefinition, - SkillDefinition, - SquadSDKConfig, -} from './types.js'; - -// Re-export every type so consumers can `import { defineTeam, TeamDefinition } from './builders'` -export type { - AgentRef, - ScheduleExpression, - BuilderModelId, - ModelPreference, - DefaultsDefinition, - TeamDefinition, - AgentCapability, - AgentDefinition, - RoutingRule, - RoutingDefinition, - CeremonyDefinition, - HooksDefinition, - CastingDefinition, - TelemetryDefinition, - SkillDefinition, - SkillTool, - SquadSDKConfig, -} from './types.js'; - -// --------------------------------------------------------------------------- -// Validation helpers (private) -// --------------------------------------------------------------------------- - -class BuilderValidationError extends Error { - constructor(builder: string, reason: string) { - super(`[${builder}] ${reason}`); - this.name = 'BuilderValidationError'; - } -} - -function assertNonEmptyString(value: unknown, field: string, builder: string): asserts value is string { - if (typeof value !== 'string' || value.length === 0) { - throw new BuilderValidationError(builder, `"${field}" must be a non-empty string`); - } -} - -function assertArray(value: unknown, field: string, builder: string): asserts value is readonly unknown[] { - if (!Array.isArray(value)) { - throw new BuilderValidationError(builder, `"${field}" must be an array`); - } -} - -function assertObject(value: unknown, builder: string): asserts value is object { - if (typeof value !== 'object' || value === null || Array.isArray(value)) { - throw new BuilderValidationError(builder, 'config must be a plain object'); - } -} - -function assertOptionalString(value: unknown, field: string, builder: string): void { - if (value !== undefined && typeof value !== 'string') { - throw new BuilderValidationError(builder, `"${field}" must be a string when provided`); - } -} - -function assertOptionalBoolean(value: unknown, field: string, builder: string): void { - if (value !== undefined && typeof value !== 'boolean') { - throw new BuilderValidationError(builder, `"${field}" must be a boolean when provided`); - } -} - -function assertOptionalNumber(value: unknown, field: string, builder: string): void { - if (value !== undefined && typeof value !== 'number') { - throw new BuilderValidationError(builder, `"${field}" must be a number when provided`); - } -} - -function assertOptionalArray(value: unknown, field: string, builder: string): void { - if (value !== undefined && !Array.isArray(value)) { - throw new BuilderValidationError(builder, `"${field}" must be an array when provided`); - } -} - -function assertStringUnion( - value: unknown, - allowed: readonly T[], - field: string, - builder: string, -): asserts value is T { - if (!allowed.includes(value as T)) { - throw new BuilderValidationError(builder, `"${field}" must be one of: ${allowed.join(', ')}`); - } -} - -/** Validates a model field that accepts string or ModelPreference object. */ -function assertModelPreference(value: unknown, field: string, builder: string): void { - if (value === undefined) return; - if (typeof value === 'string') return; - if (typeof value === 'object' && value !== null && !Array.isArray(value)) { - const obj = value as Record; - assertNonEmptyString(obj.preferred, `${field}.preferred`, builder); - assertOptionalString(obj.rationale, `${field}.rationale`, builder); - assertOptionalString(obj.fallback, `${field}.fallback`, builder); - return; - } - throw new BuilderValidationError(builder, `"${field}" must be a model string or { preferred, rationale?, fallback? }`); -} - -// --------------------------------------------------------------------------- -// defineTeam -// --------------------------------------------------------------------------- - -/** - * Define team metadata, project context, and member roster. - * - * ```ts - * const team = defineTeam({ - * name: 'Core Squad', - * description: 'The main engineering team', - * members: ['@edie', '@fenster', '@hockney'], - * }); - * ``` - */ -export function defineTeam(config: TeamDefinition): TeamDefinition { - assertObject(config, 'defineTeam'); - assertNonEmptyString(config.name, 'name', 'defineTeam'); - assertOptionalString(config.description, 'description', 'defineTeam'); - assertOptionalString(config.projectContext, 'projectContext', 'defineTeam'); - assertArray(config.members, 'members', 'defineTeam'); - for (const member of config.members) { - assertNonEmptyString(member, 'members[]', 'defineTeam'); - } - return config; -} - -// --------------------------------------------------------------------------- -// defineAgent -// --------------------------------------------------------------------------- - -const AGENT_STATUSES = ['active', 'inactive', 'retired'] as const; -const CAPABILITY_LEVELS = ['expert', 'proficient', 'basic'] as const; - -/** - * Define a single agent with its role, charter, model preference, - * tools, and capability profile. - * - * ```ts - * const edie = defineAgent({ - * name: 'edie', - * role: 'TypeScript Engineer', - * charter: '.squad/agents/edie/charter.md', - * model: 'claude-sonnet-4', - * tools: ['grep', 'edit', 'powershell'], - * capabilities: [{ name: 'type-system', level: 'expert' }], - * status: 'active', - * }); - * ``` - */ -export function defineAgent(config: AgentDefinition): AgentDefinition { - assertObject(config, 'defineAgent'); - assertNonEmptyString(config.name, 'name', 'defineAgent'); - assertNonEmptyString(config.role, 'role', 'defineAgent'); - assertOptionalString(config.description, 'description', 'defineAgent'); - assertOptionalString(config.charter, 'charter', 'defineAgent'); - assertModelPreference(config.model, 'model', 'defineAgent'); - assertOptionalArray(config.tools, 'tools', 'defineAgent'); - assertOptionalArray(config.capabilities, 'capabilities', 'defineAgent'); - - if (config.status !== undefined) { - assertStringUnion(config.status, AGENT_STATUSES, 'status', 'defineAgent'); - } - - if (config.capabilities) { - for (const cap of config.capabilities) { - assertObject(cap, 'defineAgent'); - assertNonEmptyString(cap.name, 'capabilities[].name', 'defineAgent'); - assertStringUnion(cap.level, CAPABILITY_LEVELS, 'capabilities[].level', 'defineAgent'); - } - } - - return config; -} - -// --------------------------------------------------------------------------- -// defineRouting -// --------------------------------------------------------------------------- - -const ROUTING_TIERS = ['direct', 'lightweight', 'standard', 'full'] as const; -const FALLBACK_BEHAVIORS = ['ask', 'default-agent', 'coordinator'] as const; - -/** - * Define typed routing rules with pattern matching, priority, and tier. - * - * ```ts - * const routing = defineRouting({ - * rules: [ - * { pattern: 'feature-*', agents: ['@edie'], tier: 'standard', priority: 1 }, - * { pattern: 'docs-*', agents: ['@mcmanus'], tier: 'lightweight' }, - * ], - * defaultAgent: '@coordinator', - * fallback: 'coordinator', - * }); - * ``` - */ -export function defineRouting(config: RoutingDefinition): RoutingDefinition { - assertObject(config, 'defineRouting'); - assertArray(config.rules, 'rules', 'defineRouting'); - assertOptionalString(config.defaultAgent, 'defaultAgent', 'defineRouting'); - - if (config.fallback !== undefined) { - assertStringUnion(config.fallback, FALLBACK_BEHAVIORS, 'fallback', 'defineRouting'); - } - - for (const rule of config.rules) { - assertObject(rule, 'defineRouting'); - assertNonEmptyString(rule.pattern, 'rules[].pattern', 'defineRouting'); - assertArray(rule.agents, 'rules[].agents', 'defineRouting'); - for (const agent of rule.agents) { - assertNonEmptyString(agent, 'rules[].agents[]', 'defineRouting'); - } - if (rule.tier !== undefined) { - assertStringUnion(rule.tier, ROUTING_TIERS, 'rules[].tier', 'defineRouting'); - } - assertOptionalNumber(rule.priority, 'rules[].priority', 'defineRouting'); - assertOptionalString(rule.description, 'rules[].description', 'defineRouting'); - } - - return config; -} - -// --------------------------------------------------------------------------- -// defineCeremony -// --------------------------------------------------------------------------- - -/** - * Define a ceremony with schedule, participants, and agenda. - * - * ```ts - * const standup = defineCeremony({ - * name: 'standup', - * trigger: 'schedule', - * schedule: '0 9 * * 1-5', - * participants: ['@edie', '@fenster', '@hockney'], - * agenda: 'Yesterday / Today / Blockers', - * }); - * ``` - */ -export function defineCeremony(config: CeremonyDefinition): CeremonyDefinition { - assertObject(config, 'defineCeremony'); - assertNonEmptyString(config.name, 'name', 'defineCeremony'); - assertOptionalString(config.trigger, 'trigger', 'defineCeremony'); - assertOptionalString(config.schedule, 'schedule', 'defineCeremony'); - assertOptionalArray(config.participants, 'participants', 'defineCeremony'); - assertOptionalString(config.agenda, 'agenda', 'defineCeremony'); - assertOptionalArray(config.hooks, 'hooks', 'defineCeremony'); - - return config; -} - -// --------------------------------------------------------------------------- -// defineHooks -// --------------------------------------------------------------------------- - -/** - * Define the governance hook pipeline. - * - * ```ts - * const hooks = defineHooks({ - * allowedWritePaths: ['src/**', 'test/**', '.squad/**'], - * blockedCommands: ['rm -rf /', 'DROP TABLE'], - * maxAskUser: 3, - * scrubPii: true, - * reviewerLockout: true, - * }); - * ``` - */ -export function defineHooks(config: HooksDefinition): HooksDefinition { - assertObject(config, 'defineHooks'); - assertOptionalArray(config.allowedWritePaths, 'allowedWritePaths', 'defineHooks'); - assertOptionalArray(config.blockedCommands, 'blockedCommands', 'defineHooks'); - assertOptionalNumber(config.maxAskUser, 'maxAskUser', 'defineHooks'); - assertOptionalBoolean(config.scrubPii, 'scrubPii', 'defineHooks'); - assertOptionalBoolean(config.reviewerLockout, 'reviewerLockout', 'defineHooks'); - - return config; -} - -// --------------------------------------------------------------------------- -// defineCasting -// --------------------------------------------------------------------------- - -const OVERFLOW_STRATEGIES = ['reject', 'generic', 'rotate'] as const; - -/** - * Define casting configuration — universe allowlists and overflow. - * - * ```ts - * const casting = defineCasting({ - * allowlistUniverses: ['The Usual Suspects', 'Breaking Bad'], - * overflowStrategy: 'generic', - * capacity: { 'The Usual Suspects': 8 }, - * }); - * ``` - */ -export function defineCasting(config: CastingDefinition): CastingDefinition { - assertObject(config, 'defineCasting'); - assertOptionalArray(config.allowlistUniverses, 'allowlistUniverses', 'defineCasting'); - - if (config.overflowStrategy !== undefined) { - assertStringUnion(config.overflowStrategy, OVERFLOW_STRATEGIES, 'overflowStrategy', 'defineCasting'); - } - - if (config.capacity !== undefined) { - assertObject(config.capacity, 'defineCasting'); - for (const [universe, count] of Object.entries(config.capacity)) { - if (typeof count !== 'number' || count < 0) { - throw new BuilderValidationError( - 'defineCasting', - `capacity["${universe}"] must be a non-negative number`, - ); - } - } - } - - return config; -} - -// --------------------------------------------------------------------------- -// defineTelemetry -// --------------------------------------------------------------------------- - -/** - * Define OpenTelemetry configuration. - * - * ```ts - * const telemetry = defineTelemetry({ - * enabled: true, - * endpoint: 'http://localhost:4317', - * serviceName: 'squad', - * sampleRate: 1.0, - * aspireDefaults: true, - * }); - * ``` - */ -export function defineTelemetry(config: TelemetryDefinition): TelemetryDefinition { - assertObject(config, 'defineTelemetry'); - assertOptionalBoolean(config.enabled, 'enabled', 'defineTelemetry'); - assertOptionalString(config.endpoint, 'endpoint', 'defineTelemetry'); - assertOptionalString(config.serviceName, 'serviceName', 'defineTelemetry'); - assertOptionalNumber(config.sampleRate, 'sampleRate', 'defineTelemetry'); - assertOptionalBoolean(config.aspireDefaults, 'aspireDefaults', 'defineTelemetry'); - - if (config.sampleRate !== undefined && (config.sampleRate < 0 || config.sampleRate > 1)) { - throw new BuilderValidationError('defineTelemetry', '"sampleRate" must be between 0.0 and 1.0'); - } - - return config; -} - -// --------------------------------------------------------------------------- -// defineSkill -// --------------------------------------------------------------------------- - -const CONFIDENCE_LEVELS = ['low', 'medium', 'high'] as const; -const SKILL_SOURCES = ['manual', 'observed', 'earned', 'extracted'] as const; - -/** - * Define a reusable skill with patterns, context, and examples. - * - * ```ts - * const skill = defineSkill({ - * name: 'init-mode', - * description: 'Team initialization flow (Phase 1 + Phase 2)', - * domain: 'orchestration', - * confidence: 'high', - * source: 'extracted', - * content: '## Context\n...\n## Patterns\n...', - * tools: [{ name: 'ask_user', description: 'Confirm team roster', when: 'Phase 1 proposal' }], - * }); - * ``` - */ -export function defineSkill(config: SkillDefinition): SkillDefinition { - assertObject(config, 'defineSkill'); - assertNonEmptyString(config.name, 'name', 'defineSkill'); - assertNonEmptyString(config.description, 'description', 'defineSkill'); - assertNonEmptyString(config.domain, 'domain', 'defineSkill'); - assertNonEmptyString(config.content, 'content', 'defineSkill'); - - if (config.confidence !== undefined) { - assertStringUnion(config.confidence, CONFIDENCE_LEVELS, 'confidence', 'defineSkill'); - } - if (config.source !== undefined) { - assertStringUnion(config.source, SKILL_SOURCES, 'source', 'defineSkill'); - } - assertOptionalArray(config.tools, 'tools', 'defineSkill'); - - if (config.tools) { - for (const tool of config.tools) { - assertObject(tool, 'defineSkill'); - assertNonEmptyString(tool.name, 'tools[].name', 'defineSkill'); - assertNonEmptyString(tool.description, 'tools[].description', 'defineSkill'); - assertNonEmptyString(tool.when, 'tools[].when', 'defineSkill'); - } - } - - return config; -} - -// --------------------------------------------------------------------------- -// defineDefaults -// --------------------------------------------------------------------------- - -/** - * Define squad-level defaults applied to all agents unless overridden. - * - * ```ts - * const defaults = defineDefaults({ - * model: { preferred: 'claude-sonnet-4', rationale: 'Good balance of speed and quality', fallback: 'claude-haiku-4.5' }, - * }); - * ``` - */ -export function defineDefaults(config: DefaultsDefinition): DefaultsDefinition { - assertObject(config, 'defineDefaults'); - assertModelPreference(config.model, 'model', 'defineDefaults'); - return config; -} - -// --------------------------------------------------------------------------- -// defineSquad — top-level composition -// --------------------------------------------------------------------------- - -/** - * Compose all builder outputs into a single SDK config. - * - * ```ts - * export default defineSquad({ - * version: '1.0.0', - * team: defineTeam({ name: 'Core', members: ['@edie'] }), - * agents: [defineAgent({ name: 'edie', role: 'TypeScript Engineer' })], - * routing: defineRouting({ rules: [...] }), - * defaults: defineDefaults({ model: 'claude-sonnet-4' }), - * }); - * ``` - */ -export function defineSquad(config: SquadSDKConfig): SquadSDKConfig { - assertObject(config, 'defineSquad'); - assertOptionalString(config.version, 'version', 'defineSquad'); - - // Validate nested sections via their respective builders - defineTeam(config.team); - assertArray(config.agents, 'agents', 'defineSquad'); - for (const agent of config.agents) { - defineAgent(agent); - } - - if (config.defaults !== undefined) defineDefaults(config.defaults); - if (config.routing !== undefined) defineRouting(config.routing); - if (config.ceremonies !== undefined) { - assertArray(config.ceremonies, 'ceremonies', 'defineSquad'); - for (const ceremony of config.ceremonies) { - defineCeremony(ceremony); - } - } - if (config.hooks !== undefined) defineHooks(config.hooks); - if (config.casting !== undefined) defineCasting(config.casting); - if (config.telemetry !== undefined) defineTelemetry(config.telemetry); - if (config.skills !== undefined) { - assertArray(config.skills, 'skills', 'defineSquad'); - for (const skill of config.skills) { - defineSkill(skill); - } - } - - return config; -} - -/** Exported for testing — not part of the public API contract. */ -export { BuilderValidationError }; +/** + * Builder Functions — SDK-First Squad Mode + * + * Each builder accepts a strongly-typed config object, validates it at + * runtime (manual type-guards — no zod dependency), and returns the + * validated value with the same type. The pattern mirrors `defineConfig()` + * in config/schema.ts: identity-passthrough with runtime safety. + * + * @module builders + */ + +import type { + TeamDefinition, + AgentDefinition, + BudgetDefinition, + ModelPreference, + DefaultsDefinition, + RoutingDefinition, + CeremonyDefinition, + HooksDefinition, + CastingDefinition, + TelemetryDefinition, + SkillDefinition, + SquadSDKConfig, +} from './types.js'; + +// Re-export every type so consumers can `import { defineTeam, TeamDefinition } from './builders'` +export type { + AgentRef, + ScheduleExpression, + BuilderModelId, + BudgetDefinition, + ModelPreference, + DefaultsDefinition, + TeamDefinition, + AgentCapability, + AgentDefinition, + RoutingRule, + RoutingDefinition, + CeremonyDefinition, + HooksDefinition, + CastingDefinition, + TelemetryDefinition, + SkillDefinition, + SkillTool, + SquadSDKConfig, +} from './types.js'; + +// --------------------------------------------------------------------------- +// Validation helpers (private) +// --------------------------------------------------------------------------- + +class BuilderValidationError extends Error { + constructor(builder: string, reason: string) { + super(`[${builder}] ${reason}`); + this.name = 'BuilderValidationError'; + } +} + +function assertNonEmptyString(value: unknown, field: string, builder: string): asserts value is string { + if (typeof value !== 'string' || value.length === 0) { + throw new BuilderValidationError(builder, `"${field}" must be a non-empty string`); + } +} + +function assertArray(value: unknown, field: string, builder: string): asserts value is readonly unknown[] { + if (!Array.isArray(value)) { + throw new BuilderValidationError(builder, `"${field}" must be an array`); + } +} + +function assertObject(value: unknown, builder: string): asserts value is object { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new BuilderValidationError(builder, 'config must be a plain object'); + } +} + +function assertOptionalString(value: unknown, field: string, builder: string): void { + if (value !== undefined && typeof value !== 'string') { + throw new BuilderValidationError(builder, `"${field}" must be a string when provided`); + } +} + +function assertOptionalBoolean(value: unknown, field: string, builder: string): void { + if (value !== undefined && typeof value !== 'boolean') { + throw new BuilderValidationError(builder, `"${field}" must be a boolean when provided`); + } +} + +function assertOptionalNumber(value: unknown, field: string, builder: string): void { + if (value !== undefined && typeof value !== 'number') { + throw new BuilderValidationError(builder, `"${field}" must be a number when provided`); + } +} + +function assertOptionalArray(value: unknown, field: string, builder: string): void { + if (value !== undefined && !Array.isArray(value)) { + throw new BuilderValidationError(builder, `"${field}" must be an array when provided`); + } +} + +function assertStringUnion( + value: unknown, + allowed: readonly T[], + field: string, + builder: string, +): asserts value is T { + if (!allowed.includes(value as T)) { + throw new BuilderValidationError(builder, `"${field}" must be one of: ${allowed.join(', ')}`); + } +} + +/** Validates a model field that accepts string or ModelPreference object. */ +function assertModelPreference(value: unknown, field: string, builder: string): void { + if (value === undefined) return; + if (typeof value === 'string') return; + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + const obj = value as Record; + assertNonEmptyString(obj.preferred, `${field}.preferred`, builder); + assertOptionalString(obj.rationale, `${field}.rationale`, builder); + assertOptionalString(obj.fallback, `${field}.fallback`, builder); + return; + } + throw new BuilderValidationError(builder, `"${field}" must be a model string or { preferred, rationale?, fallback? }`); +} + +// --------------------------------------------------------------------------- +// defineBudget +// --------------------------------------------------------------------------- + +export function defineBudget(config: BudgetDefinition): BudgetDefinition { + assertObject(config, 'defineBudget'); + if (config.perAgentSpawn !== undefined) { + if (typeof config.perAgentSpawn !== 'number' || !Number.isFinite(config.perAgentSpawn) || config.perAgentSpawn <= 0) { + throw new BuilderValidationError('defineBudget', '"perAgentSpawn" must be a finite positive number'); + } + } + if (config.perSession !== undefined) { + if (typeof config.perSession !== 'number' || !Number.isFinite(config.perSession) || config.perSession <= 0) { + throw new BuilderValidationError('defineBudget', '"perSession" must be a finite positive number'); + } + } + if (config.warnAt !== undefined) { + if (typeof config.warnAt !== 'number' || !Number.isFinite(config.warnAt) || config.warnAt < 0 || config.warnAt > 1) { + throw new BuilderValidationError('defineBudget', '"warnAt" must be a finite number between 0 and 1'); + } + } + return config; +} + +// --------------------------------------------------------------------------- +// defineTeam +// --------------------------------------------------------------------------- + +/** + * Define team metadata, project context, and member roster. + * + * ```ts + * const team = defineTeam({ + * name: 'Core Squad', + * description: 'The main engineering team', + * members: ['@edie', '@fenster', '@hockney'], + * }); + * ``` + */ +export function defineTeam(config: TeamDefinition): TeamDefinition { + assertObject(config, 'defineTeam'); + assertNonEmptyString(config.name, 'name', 'defineTeam'); + assertOptionalString(config.description, 'description', 'defineTeam'); + assertOptionalString(config.projectContext, 'projectContext', 'defineTeam'); + assertArray(config.members, 'members', 'defineTeam'); + for (const member of config.members) { + assertNonEmptyString(member, 'members[]', 'defineTeam'); + } + return config; +} + +// --------------------------------------------------------------------------- +// defineAgent +// --------------------------------------------------------------------------- + +const AGENT_STATUSES = ['active', 'inactive', 'retired'] as const; +const CAPABILITY_LEVELS = ['expert', 'proficient', 'basic'] as const; + +/** + * Define a single agent with its role, charter, model preference, + * tools, and capability profile. + * + * ```ts + * const edie = defineAgent({ + * name: 'edie', + * role: 'TypeScript Engineer', + * charter: '.squad/agents/edie/charter.md', + * model: 'claude-sonnet-4', + * tools: ['grep', 'edit', 'powershell'], + * capabilities: [{ name: 'type-system', level: 'expert' }], + * status: 'active', + * }); + * ``` + */ +export function defineAgent(config: AgentDefinition): AgentDefinition { + assertObject(config, 'defineAgent'); + assertNonEmptyString(config.name, 'name', 'defineAgent'); + assertNonEmptyString(config.role, 'role', 'defineAgent'); + assertOptionalString(config.description, 'description', 'defineAgent'); + assertOptionalString(config.charter, 'charter', 'defineAgent'); + assertModelPreference(config.model, 'model', 'defineAgent'); + if (config.budget !== undefined) { + defineBudget(config.budget); + } + assertOptionalArray(config.tools, 'tools', 'defineAgent'); + assertOptionalArray(config.capabilities, 'capabilities', 'defineAgent'); + + if (config.status !== undefined) { + assertStringUnion(config.status, AGENT_STATUSES, 'status', 'defineAgent'); + } + + if (config.capabilities) { + for (const cap of config.capabilities) { + assertObject(cap, 'defineAgent'); + assertNonEmptyString(cap.name, 'capabilities[].name', 'defineAgent'); + assertStringUnion(cap.level, CAPABILITY_LEVELS, 'capabilities[].level', 'defineAgent'); + } + } + + return config; +} + +// --------------------------------------------------------------------------- +// defineRouting +// --------------------------------------------------------------------------- + +const ROUTING_TIERS = ['direct', 'lightweight', 'standard', 'full'] as const; +const FALLBACK_BEHAVIORS = ['ask', 'default-agent', 'coordinator'] as const; + +/** + * Define typed routing rules with pattern matching, priority, and tier. + * + * ```ts + * const routing = defineRouting({ + * rules: [ + * { pattern: 'feature-*', agents: ['@edie'], tier: 'standard', priority: 1 }, + * { pattern: 'docs-*', agents: ['@mcmanus'], tier: 'lightweight' }, + * ], + * defaultAgent: '@coordinator', + * fallback: 'coordinator', + * }); + * ``` + */ +export function defineRouting(config: RoutingDefinition): RoutingDefinition { + assertObject(config, 'defineRouting'); + assertArray(config.rules, 'rules', 'defineRouting'); + assertOptionalString(config.defaultAgent, 'defaultAgent', 'defineRouting'); + + if (config.fallback !== undefined) { + assertStringUnion(config.fallback, FALLBACK_BEHAVIORS, 'fallback', 'defineRouting'); + } + + for (const rule of config.rules) { + assertObject(rule, 'defineRouting'); + assertNonEmptyString(rule.pattern, 'rules[].pattern', 'defineRouting'); + assertArray(rule.agents, 'rules[].agents', 'defineRouting'); + for (const agent of rule.agents) { + assertNonEmptyString(agent, 'rules[].agents[]', 'defineRouting'); + } + if (rule.tier !== undefined) { + assertStringUnion(rule.tier, ROUTING_TIERS, 'rules[].tier', 'defineRouting'); + } + assertOptionalNumber(rule.priority, 'rules[].priority', 'defineRouting'); + assertOptionalString(rule.description, 'rules[].description', 'defineRouting'); + } + + return config; +} + +// --------------------------------------------------------------------------- +// defineCeremony +// --------------------------------------------------------------------------- + +/** + * Define a ceremony with schedule, participants, and agenda. + * + * ```ts + * const standup = defineCeremony({ + * name: 'standup', + * trigger: 'schedule', + * schedule: '0 9 * * 1-5', + * participants: ['@edie', '@fenster', '@hockney'], + * agenda: 'Yesterday / Today / Blockers', + * }); + * ``` + */ +export function defineCeremony(config: CeremonyDefinition): CeremonyDefinition { + assertObject(config, 'defineCeremony'); + assertNonEmptyString(config.name, 'name', 'defineCeremony'); + assertOptionalString(config.trigger, 'trigger', 'defineCeremony'); + assertOptionalString(config.schedule, 'schedule', 'defineCeremony'); + assertOptionalArray(config.participants, 'participants', 'defineCeremony'); + assertOptionalString(config.agenda, 'agenda', 'defineCeremony'); + assertOptionalArray(config.hooks, 'hooks', 'defineCeremony'); + + return config; +} + +// --------------------------------------------------------------------------- +// defineHooks +// --------------------------------------------------------------------------- + +/** + * Define the governance hook pipeline. + * + * ```ts + * const hooks = defineHooks({ + * allowedWritePaths: ['src/**', 'test/**', '.squad/**'], + * blockedCommands: ['rm -rf /', 'DROP TABLE'], + * maxAskUser: 3, + * scrubPii: true, + * reviewerLockout: true, + * }); + * ``` + */ +export function defineHooks(config: HooksDefinition): HooksDefinition { + assertObject(config, 'defineHooks'); + assertOptionalArray(config.allowedWritePaths, 'allowedWritePaths', 'defineHooks'); + assertOptionalArray(config.blockedCommands, 'blockedCommands', 'defineHooks'); + assertOptionalNumber(config.maxAskUser, 'maxAskUser', 'defineHooks'); + assertOptionalBoolean(config.scrubPii, 'scrubPii', 'defineHooks'); + assertOptionalBoolean(config.reviewerLockout, 'reviewerLockout', 'defineHooks'); + + return config; +} + +// --------------------------------------------------------------------------- +// defineCasting +// --------------------------------------------------------------------------- + +const OVERFLOW_STRATEGIES = ['reject', 'generic', 'rotate'] as const; + +/** + * Define casting configuration — universe allowlists and overflow. + * + * ```ts + * const casting = defineCasting({ + * allowlistUniverses: ['The Usual Suspects', 'Breaking Bad'], + * overflowStrategy: 'generic', + * capacity: { 'The Usual Suspects': 8 }, + * }); + * ``` + */ +export function defineCasting(config: CastingDefinition): CastingDefinition { + assertObject(config, 'defineCasting'); + assertOptionalArray(config.allowlistUniverses, 'allowlistUniverses', 'defineCasting'); + + if (config.overflowStrategy !== undefined) { + assertStringUnion(config.overflowStrategy, OVERFLOW_STRATEGIES, 'overflowStrategy', 'defineCasting'); + } + + if (config.capacity !== undefined) { + assertObject(config.capacity, 'defineCasting'); + for (const [universe, count] of Object.entries(config.capacity)) { + if (typeof count !== 'number' || count < 0) { + throw new BuilderValidationError( + 'defineCasting', + `capacity["${universe}"] must be a non-negative number`, + ); + } + } + } + + return config; +} + +// --------------------------------------------------------------------------- +// defineTelemetry +// --------------------------------------------------------------------------- + +/** + * Define OpenTelemetry configuration. + * + * ```ts + * const telemetry = defineTelemetry({ + * enabled: true, + * endpoint: 'http://localhost:4317', + * serviceName: 'squad', + * sampleRate: 1.0, + * aspireDefaults: true, + * }); + * ``` + */ +export function defineTelemetry(config: TelemetryDefinition): TelemetryDefinition { + assertObject(config, 'defineTelemetry'); + assertOptionalBoolean(config.enabled, 'enabled', 'defineTelemetry'); + assertOptionalString(config.endpoint, 'endpoint', 'defineTelemetry'); + assertOptionalString(config.serviceName, 'serviceName', 'defineTelemetry'); + assertOptionalNumber(config.sampleRate, 'sampleRate', 'defineTelemetry'); + assertOptionalBoolean(config.aspireDefaults, 'aspireDefaults', 'defineTelemetry'); + + if (config.sampleRate !== undefined && (config.sampleRate < 0 || config.sampleRate > 1)) { + throw new BuilderValidationError('defineTelemetry', '"sampleRate" must be between 0.0 and 1.0'); + } + + return config; +} + +// --------------------------------------------------------------------------- +// defineSkill +// --------------------------------------------------------------------------- + +const CONFIDENCE_LEVELS = ['low', 'medium', 'high'] as const; +const SKILL_SOURCES = ['manual', 'observed', 'earned', 'extracted'] as const; + +/** + * Define a reusable skill with patterns, context, and examples. + * + * ```ts + * const skill = defineSkill({ + * name: 'init-mode', + * description: 'Team initialization flow (Phase 1 + Phase 2)', + * domain: 'orchestration', + * confidence: 'high', + * source: 'extracted', + * content: '## Context\n...\n## Patterns\n...', + * tools: [{ name: 'ask_user', description: 'Confirm team roster', when: 'Phase 1 proposal' }], + * }); + * ``` + */ +export function defineSkill(config: SkillDefinition): SkillDefinition { + assertObject(config, 'defineSkill'); + assertNonEmptyString(config.name, 'name', 'defineSkill'); + assertNonEmptyString(config.description, 'description', 'defineSkill'); + assertNonEmptyString(config.domain, 'domain', 'defineSkill'); + assertNonEmptyString(config.content, 'content', 'defineSkill'); + + if (config.confidence !== undefined) { + assertStringUnion(config.confidence, CONFIDENCE_LEVELS, 'confidence', 'defineSkill'); + } + if (config.source !== undefined) { + assertStringUnion(config.source, SKILL_SOURCES, 'source', 'defineSkill'); + } + assertOptionalArray(config.tools, 'tools', 'defineSkill'); + + if (config.tools) { + for (const tool of config.tools) { + assertObject(tool, 'defineSkill'); + assertNonEmptyString(tool.name, 'tools[].name', 'defineSkill'); + assertNonEmptyString(tool.description, 'tools[].description', 'defineSkill'); + assertNonEmptyString(tool.when, 'tools[].when', 'defineSkill'); + } + } + + return config; +} + +// --------------------------------------------------------------------------- +// defineDefaults +// --------------------------------------------------------------------------- + +/** + * Define squad-level defaults applied to all agents unless overridden. + * + * ```ts + * const defaults = defineDefaults({ + * model: { preferred: 'claude-sonnet-4', rationale: 'Good balance of speed and quality', fallback: 'claude-haiku-4.5' }, + * }); + * ``` + */ +export function defineDefaults(config: DefaultsDefinition): DefaultsDefinition { + assertObject(config, 'defineDefaults'); + assertModelPreference(config.model, 'model', 'defineDefaults'); + if (config.budget !== undefined) { + defineBudget(config.budget); + } + return config; +} + +// --------------------------------------------------------------------------- +// defineSquad — top-level composition +// --------------------------------------------------------------------------- + +/** + * Compose all builder outputs into a single SDK config. + * + * ```ts + * export default defineSquad({ + * version: '1.0.0', + * team: defineTeam({ name: 'Core', members: ['@edie'] }), + * agents: [defineAgent({ name: 'edie', role: 'TypeScript Engineer' })], + * routing: defineRouting({ rules: [...] }), + * defaults: defineDefaults({ model: 'claude-sonnet-4' }), + * }); + * ``` + */ +export function defineSquad(config: SquadSDKConfig): SquadSDKConfig { + assertObject(config, 'defineSquad'); + assertOptionalString(config.version, 'version', 'defineSquad'); + + // Validate nested sections via their respective builders + defineTeam(config.team); + assertArray(config.agents, 'agents', 'defineSquad'); + for (const agent of config.agents) { + defineAgent(agent); + } + + if (config.defaults !== undefined) defineDefaults(config.defaults); + if (config.routing !== undefined) defineRouting(config.routing); + if (config.ceremonies !== undefined) { + assertArray(config.ceremonies, 'ceremonies', 'defineSquad'); + for (const ceremony of config.ceremonies) { + defineCeremony(ceremony); + } + } + if (config.hooks !== undefined) defineHooks(config.hooks); + if (config.casting !== undefined) defineCasting(config.casting); + if (config.telemetry !== undefined) defineTelemetry(config.telemetry); + if (config.skills !== undefined) { + assertArray(config.skills, 'skills', 'defineSquad'); + for (const skill of config.skills) { + defineSkill(skill); + } + } + + return config; +} + +/** Exported for testing — not part of the public API contract. */ +export { BuilderValidationError }; diff --git a/packages/squad-sdk/src/builders/types.ts b/packages/squad-sdk/src/builders/types.ts index 78e8260a4..5fe37332a 100644 --- a/packages/squad-sdk/src/builders/types.ts +++ b/packages/squad-sdk/src/builders/types.ts @@ -1,294 +1,312 @@ -/** - * Builder Types — SDK-First Squad Mode - * - * These types define the config surface for the builder functions - * (`defineTeam`, `defineAgent`, etc.). They are the SDK-mode complement - * to the existing SquadConfig / schema types. Treat these interfaces - * as the public contract for programmatic team definition. - * - * @module builders/types - */ - -// --------------------------------------------------------------------------- -// Shared primitives -// --------------------------------------------------------------------------- - -/** Reference to an agent by name (e.g. `"@edie"` or `"edie"`). */ -export type AgentRef = string; - -/** Cron-like schedule expression or human-readable trigger. */ -export type ScheduleExpression = string; - -/** Model identifier (same domain as runtime ModelId). */ -export type BuilderModelId = string; - -// --------------------------------------------------------------------------- -// ModelPreference — structured model selection -// --------------------------------------------------------------------------- - -/** - * Structured model preference for an agent or squad-level default. - * Supports the 4-layer model selection hierarchy: - * 1. User override (runtime) - * 2. Charter / config preference (this type) - * 3. Task-aware auto-selection (runtime) - * 4. Default (haiku) - */ -export interface ModelPreference { - /** Preferred model identifier (e.g. `"claude-sonnet-4.5"`). */ - readonly preferred: BuilderModelId; - - /** Why this model was chosen — helps coordinators respect the preference. */ - readonly rationale?: string; - - /** Fallback model if the preferred model is unavailable. */ - readonly fallback?: BuilderModelId; -} - -// --------------------------------------------------------------------------- -// DefaultsDefinition — squad-level defaults -// --------------------------------------------------------------------------- - -/** Squad-level defaults applied to all agents unless overridden. */ -export interface DefaultsDefinition { - /** Default model preference for agents that don't specify one. */ - readonly model?: BuilderModelId | ModelPreference; -} - -// --------------------------------------------------------------------------- -// TeamDefinition -// --------------------------------------------------------------------------- - -export interface TeamDefinition { - /** Human-readable team name. */ - readonly name: string; - - /** One-liner describing the team's purpose. */ - readonly description?: string; - - /** Freeform project context injected into agent system prompts. */ - readonly projectContext?: string; - - /** Ordered list of agent refs that belong to this team. */ - readonly members: readonly AgentRef[]; -} - -// --------------------------------------------------------------------------- -// AgentDefinition -// --------------------------------------------------------------------------- - -/** Agent capability descriptor. */ -export interface AgentCapability { - /** Capability name (e.g. `"code-review"`, `"testing"`). */ - readonly name: string; - - /** Proficiency level. */ - readonly level: 'expert' | 'proficient' | 'basic'; -} - -export interface AgentDefinition { - /** Unique agent identifier (kebab-case, no `@` prefix). */ - readonly name: string; - - /** Human-readable role title. */ - readonly role: string; - - /** One-line tagline or description (rendered as blockquote in charter). */ - readonly description?: string; - - /** Path to charter markdown or inline charter text. */ - readonly charter?: string; - - /** Preferred model identifier or structured model preference. */ - readonly model?: BuilderModelId | ModelPreference; - - /** Tools this agent is allowed to use. */ - readonly tools?: readonly string[]; - - /** Typed capability list. */ - readonly capabilities?: readonly AgentCapability[]; - - /** Agent lifecycle status. */ - readonly status?: 'active' | 'inactive' | 'retired'; -} - -// --------------------------------------------------------------------------- -// RoutingDefinition -// --------------------------------------------------------------------------- - -export interface RoutingRule { - /** Glob or regex pattern to match against work type / issue labels. */ - readonly pattern: string; - - /** Agent(s) to route matching work to. */ - readonly agents: readonly AgentRef[]; - - /** Routing tier controls how much ceremony surrounds execution. */ - readonly tier?: 'direct' | 'lightweight' | 'standard' | 'full'; - - /** Numeric priority — lower wins. */ - readonly priority?: number; - - /** Human-readable description or examples for this rule. */ - readonly description?: string; -} - -export interface RoutingDefinition { - /** Ordered list of routing rules (first match wins at equal priority). */ - readonly rules: readonly RoutingRule[]; - - /** Fallback agent when no rule matches. */ - readonly defaultAgent?: AgentRef; - - /** Fallback behaviour when routing is ambiguous. */ - readonly fallback?: 'ask' | 'default-agent' | 'coordinator'; -} - -// --------------------------------------------------------------------------- -// CeremonyDefinition -// --------------------------------------------------------------------------- - -export interface CeremonyDefinition { - /** Ceremony name (e.g. `"standup"`, `"retrospective"`). */ - readonly name: string; - - /** What triggers this ceremony (e.g. `"schedule"`, `"pr-merged"`). */ - readonly trigger?: string; - - /** Cron expression or human-readable schedule. */ - readonly schedule?: ScheduleExpression; - - /** Agents that participate. */ - readonly participants?: readonly AgentRef[]; - - /** Freeform agenda / template. */ - readonly agenda?: string; - - /** Hook names that fire during this ceremony. */ - readonly hooks?: readonly string[]; -} - -// --------------------------------------------------------------------------- -// HooksDefinition -// --------------------------------------------------------------------------- - -export interface HooksDefinition { - /** Glob patterns for paths agents are allowed to write. */ - readonly allowedWritePaths?: readonly string[]; - - /** Shell commands that agents must never execute. */ - readonly blockedCommands?: readonly string[]; - - /** Max number of ask-user prompts per session. */ - readonly maxAskUser?: number; - - /** Scrub PII from agent output before persisting. */ - readonly scrubPii?: boolean; - - /** Prevent the PR author from approving their own PR. */ - readonly reviewerLockout?: boolean; -} - -// --------------------------------------------------------------------------- -// CastingDefinition -// --------------------------------------------------------------------------- - -export interface CastingDefinition { - /** Fictional universes from which agent personas are drawn. */ - readonly allowlistUniverses?: readonly string[]; - - /** Strategy when the universe is at capacity. */ - readonly overflowStrategy?: 'reject' | 'generic' | 'rotate'; - - /** Max agents per universe (keyed by universe name). */ - readonly capacity?: Readonly>; -} - -// --------------------------------------------------------------------------- -// TelemetryDefinition -// --------------------------------------------------------------------------- - -export interface TelemetryDefinition { - /** Master on/off switch. */ - readonly enabled?: boolean; - - /** OTLP endpoint URL. */ - readonly endpoint?: string; - - /** OTel service name. */ - readonly serviceName?: string; - - /** Trace sample rate (0.0 – 1.0). */ - readonly sampleRate?: number; - - /** Apply Aspire-compatible defaults for dashboard integration. */ - readonly aspireDefaults?: boolean; -} - -// --------------------------------------------------------------------------- -// SkillDefinition -// --------------------------------------------------------------------------- - -/** MCP tool relevant to a skill. */ -export interface SkillTool { - readonly name: string; - readonly description: string; - readonly when: string; -} - -/** - * Skill definition for SDK-First mode. - * Skills are reusable capabilities that agents or the coordinator can load on demand. - */ -export interface SkillDefinition { - /** Unique skill name (kebab-case) */ - readonly name: string; - /** Human-readable description */ - readonly description: string; - /** Domain category (e.g., 'orchestration', 'testing', 'api-design') */ - readonly domain: string; - /** Confidence level in this skill */ - readonly confidence?: 'low' | 'medium' | 'high'; - /** How the skill was learned */ - readonly source?: 'manual' | 'observed' | 'earned' | 'extracted'; - /** The skill content — patterns, context, examples, anti-patterns */ - readonly content: string; - /** Optional MCP tools relevant to this skill */ - readonly tools?: readonly SkillTool[]; -} - -// --------------------------------------------------------------------------- -// SquadSDKConfig — top-level config that composes all builders -// --------------------------------------------------------------------------- - -export interface SquadSDKConfig { - /** Schema version for forward-compat. */ - readonly version?: string; - - /** Team metadata. */ - readonly team: TeamDefinition; - - /** Agent definitions. */ - readonly agents: readonly AgentDefinition[]; - - /** Squad-level defaults applied to agents unless overridden. */ - readonly defaults?: DefaultsDefinition; - - /** Routing rules. */ - readonly routing?: RoutingDefinition; - - /** Ceremony definitions. */ - readonly ceremonies?: readonly CeremonyDefinition[]; - - /** Hook / governance definitions. */ - readonly hooks?: HooksDefinition; - - /** Casting configuration. */ - readonly casting?: CastingDefinition; - - /** Telemetry / OTel configuration. */ - readonly telemetry?: TelemetryDefinition; - - /** Skill definitions. */ - readonly skills?: readonly SkillDefinition[]; -} +/** + * Builder Types — SDK-First Squad Mode + * + * These types define the config surface for the builder functions + * (`defineTeam`, `defineAgent`, etc.). They are the SDK-mode complement + * to the existing SquadConfig / schema types. Treat these interfaces + * as the public contract for programmatic team definition. + * + * @module builders/types + */ + +// --------------------------------------------------------------------------- +// Shared primitives +// --------------------------------------------------------------------------- + +/** Reference to an agent by name (e.g. `"@edie"` or `"edie"`). */ +export type AgentRef = string; + +/** Cron-like schedule expression or human-readable trigger. */ +export type ScheduleExpression = string; + +/** Model identifier (same domain as runtime ModelId). */ +export type BuilderModelId = string; + +// --------------------------------------------------------------------------- +// ModelPreference — structured model selection +// --------------------------------------------------------------------------- + +/** + * Structured model preference for an agent or squad-level default. + * Supports the 4-layer model selection hierarchy: + * 1. User override (runtime) + * 2. Charter / config preference (this type) + * 3. Task-aware auto-selection (runtime) + * 4. Default (haiku) + */ +export interface ModelPreference { + /** Preferred model identifier (e.g. `"claude-sonnet-4.5"`). */ + readonly preferred: BuilderModelId; + + /** Why this model was chosen — helps coordinators respect the preference. */ + readonly rationale?: string; + + /** Fallback model if the preferred model is unavailable. */ + readonly fallback?: BuilderModelId; +} + +/** Budget configuration for token/cost limits. */ +export interface BudgetDefinition { + /** Max tokens per individual agent spawn (prompt + completion). */ + readonly perAgentSpawn?: number; + + /** Max total tokens per coordinator session. */ + readonly perSession?: number; + + /** Warn the user when budget utilization reaches this fraction (0.0-1.0, default 0.8). */ + readonly warnAt?: number; +} + +// --------------------------------------------------------------------------- +// DefaultsDefinition — squad-level defaults +// --------------------------------------------------------------------------- + +/** Squad-level defaults applied to all agents unless overridden. */ +export interface DefaultsDefinition { + /** Default model preference for agents that don't specify one. */ + readonly model?: BuilderModelId | ModelPreference; + + /** Default budget limits applied to agents that don't specify one. */ + readonly budget?: BudgetDefinition; +} + +// --------------------------------------------------------------------------- +// TeamDefinition +// --------------------------------------------------------------------------- + +export interface TeamDefinition { + /** Human-readable team name. */ + readonly name: string; + + /** One-liner describing the team's purpose. */ + readonly description?: string; + + /** Freeform project context injected into agent system prompts. */ + readonly projectContext?: string; + + /** Ordered list of agent refs that belong to this team. */ + readonly members: readonly AgentRef[]; +} + +// --------------------------------------------------------------------------- +// AgentDefinition +// --------------------------------------------------------------------------- + +/** Agent capability descriptor. */ +export interface AgentCapability { + /** Capability name (e.g. `"code-review"`, `"testing"`). */ + readonly name: string; + + /** Proficiency level. */ + readonly level: 'expert' | 'proficient' | 'basic'; +} + +export interface AgentDefinition { + /** Unique agent identifier (kebab-case, no `@` prefix). */ + readonly name: string; + + /** Human-readable role title. */ + readonly role: string; + + /** One-line tagline or description (rendered as blockquote in charter). */ + readonly description?: string; + + /** Path to charter markdown or inline charter text. */ + readonly charter?: string; + + /** Preferred model identifier or structured model preference. */ + readonly model?: BuilderModelId | ModelPreference; + + /** Optional token budget for this agent. */ + readonly budget?: BudgetDefinition; + + /** Tools this agent is allowed to use. */ + readonly tools?: readonly string[]; + + /** Typed capability list. */ + readonly capabilities?: readonly AgentCapability[]; + + /** Agent lifecycle status. */ + readonly status?: 'active' | 'inactive' | 'retired'; +} + +// --------------------------------------------------------------------------- +// RoutingDefinition +// --------------------------------------------------------------------------- + +export interface RoutingRule { + /** Glob or regex pattern to match against work type / issue labels. */ + readonly pattern: string; + + /** Agent(s) to route matching work to. */ + readonly agents: readonly AgentRef[]; + + /** Routing tier controls how much ceremony surrounds execution. */ + readonly tier?: 'direct' | 'lightweight' | 'standard' | 'full'; + + /** Numeric priority — lower wins. */ + readonly priority?: number; + + /** Human-readable description or examples for this rule. */ + readonly description?: string; +} + +export interface RoutingDefinition { + /** Ordered list of routing rules (first match wins at equal priority). */ + readonly rules: readonly RoutingRule[]; + + /** Fallback agent when no rule matches. */ + readonly defaultAgent?: AgentRef; + + /** Fallback behaviour when routing is ambiguous. */ + readonly fallback?: 'ask' | 'default-agent' | 'coordinator'; +} + +// --------------------------------------------------------------------------- +// CeremonyDefinition +// --------------------------------------------------------------------------- + +export interface CeremonyDefinition { + /** Ceremony name (e.g. `"standup"`, `"retrospective"`). */ + readonly name: string; + + /** What triggers this ceremony (e.g. `"schedule"`, `"pr-merged"`). */ + readonly trigger?: string; + + /** Cron expression or human-readable schedule. */ + readonly schedule?: ScheduleExpression; + + /** Agents that participate. */ + readonly participants?: readonly AgentRef[]; + + /** Freeform agenda / template. */ + readonly agenda?: string; + + /** Hook names that fire during this ceremony. */ + readonly hooks?: readonly string[]; +} + +// --------------------------------------------------------------------------- +// HooksDefinition +// --------------------------------------------------------------------------- + +export interface HooksDefinition { + /** Glob patterns for paths agents are allowed to write. */ + readonly allowedWritePaths?: readonly string[]; + + /** Shell commands that agents must never execute. */ + readonly blockedCommands?: readonly string[]; + + /** Max number of ask-user prompts per session. */ + readonly maxAskUser?: number; + + /** Scrub PII from agent output before persisting. */ + readonly scrubPii?: boolean; + + /** Prevent the PR author from approving their own PR. */ + readonly reviewerLockout?: boolean; +} + +// --------------------------------------------------------------------------- +// CastingDefinition +// --------------------------------------------------------------------------- + +export interface CastingDefinition { + /** Fictional universes from which agent personas are drawn. */ + readonly allowlistUniverses?: readonly string[]; + + /** Strategy when the universe is at capacity. */ + readonly overflowStrategy?: 'reject' | 'generic' | 'rotate'; + + /** Max agents per universe (keyed by universe name). */ + readonly capacity?: Readonly>; +} + +// --------------------------------------------------------------------------- +// TelemetryDefinition +// --------------------------------------------------------------------------- + +export interface TelemetryDefinition { + /** Master on/off switch. */ + readonly enabled?: boolean; + + /** OTLP endpoint URL. */ + readonly endpoint?: string; + + /** OTel service name. */ + readonly serviceName?: string; + + /** Trace sample rate (0.0 – 1.0). */ + readonly sampleRate?: number; + + /** Apply Aspire-compatible defaults for dashboard integration. */ + readonly aspireDefaults?: boolean; +} + +// --------------------------------------------------------------------------- +// SkillDefinition +// --------------------------------------------------------------------------- + +/** MCP tool relevant to a skill. */ +export interface SkillTool { + readonly name: string; + readonly description: string; + readonly when: string; +} + +/** + * Skill definition for SDK-First mode. + * Skills are reusable capabilities that agents or the coordinator can load on demand. + */ +export interface SkillDefinition { + /** Unique skill name (kebab-case) */ + readonly name: string; + /** Human-readable description */ + readonly description: string; + /** Domain category (e.g., 'orchestration', 'testing', 'api-design') */ + readonly domain: string; + /** Confidence level in this skill */ + readonly confidence?: 'low' | 'medium' | 'high'; + /** How the skill was learned */ + readonly source?: 'manual' | 'observed' | 'earned' | 'extracted'; + /** The skill content — patterns, context, examples, anti-patterns */ + readonly content: string; + /** Optional MCP tools relevant to this skill */ + readonly tools?: readonly SkillTool[]; +} + +// --------------------------------------------------------------------------- +// SquadSDKConfig — top-level config that composes all builders +// --------------------------------------------------------------------------- + +export interface SquadSDKConfig { + /** Schema version for forward-compat. */ + readonly version?: string; + + /** Team metadata. */ + readonly team: TeamDefinition; + + /** Agent definitions. */ + readonly agents: readonly AgentDefinition[]; + + /** Squad-level defaults applied to agents unless overridden. */ + readonly defaults?: DefaultsDefinition; + + /** Routing rules. */ + readonly routing?: RoutingDefinition; + + /** Ceremony definitions. */ + readonly ceremonies?: readonly CeremonyDefinition[]; + + /** Hook / governance definitions. */ + readonly hooks?: HooksDefinition; + + /** Casting configuration. */ + readonly casting?: CastingDefinition; + + /** Telemetry / OTel configuration. */ + readonly telemetry?: TelemetryDefinition; + + /** Skill definitions. */ + readonly skills?: readonly SkillDefinition[]; +} diff --git a/packages/squad-sdk/src/index.ts b/packages/squad-sdk/src/index.ts index 7a4e190a3..903c56885 100644 --- a/packages/squad-sdk/src/index.ts +++ b/packages/squad-sdk/src/index.ts @@ -65,6 +65,7 @@ export * from './streams/index.js'; export { defineTeam, defineAgent, + defineBudget, defineRouting, defineCeremony, defineHooks, @@ -79,6 +80,7 @@ export type { AgentRef, ScheduleExpression, BuilderModelId, + BudgetDefinition, ModelPreference, DefaultsDefinition, TeamDefinition, diff --git a/packages/squad-sdk/templates/orchestration-log.md b/packages/squad-sdk/templates/orchestration-log.md index 37d94d193..d714e4fcd 100644 --- a/packages/squad-sdk/templates/orchestration-log.md +++ b/packages/squad-sdk/templates/orchestration-log.md @@ -1,27 +1,28 @@ -# Orchestration Log Entry - -> One file per agent spawn. Saved to `.squad/orchestration-log/{timestamp}-{agent-name}.md` - ---- - -### {timestamp} — {task summary} - -| Field | Value | -|-------|-------| -| **Agent routed** | {Name} ({Role}) | -| **Why chosen** | {Routing rationale — what in the request matched this agent} | -| **Mode** | {`background` / `sync`} | -| **Why this mode** | {Brief reason — e.g., "No hard data dependencies" or "User needs to approve architecture"} | -| **Files authorized to read** | {Exact file paths the agent was told to read} | -| **File(s) agent must produce** | {Exact file paths the agent is expected to create or modify} | -| **Outcome** | {Completed / Rejected by {Reviewer} / Escalated} | - ---- - -## Rules - -1. **One file per agent spawn.** Named `{timestamp}-{agent-name}.md`. -2. **Log BEFORE spawning.** The entry must exist before the agent runs. -3. **Update outcome AFTER the agent completes.** Fill in the Outcome field. -4. **Never delete or edit past entries.** Append-only. -5. **If a reviewer rejects work,** log the rejection as a new entry with the revision agent. +# Orchestration Log Entry + +> One file per agent spawn. Saved to `.squad/orchestration-log/{timestamp}-{agent-name}.md` + +--- + +### {timestamp} — {task summary} + +| Field | Value | +|-------|-------| +| **Agent routed** | {Name} ({Role}) | +| **Why chosen** | {Routing rationale — what in the request matched this agent} | +| **Mode** | {`background` / `sync`} | +| **Why this mode** | {Brief reason — e.g., "No hard data dependencies" or "User needs to approve architecture"} | +| **Files authorized to read** | {Exact file paths the agent was told to read} | +| **File(s) agent must produce** | {Exact file paths the agent is expected to create or modify} | +| **Outcome** | {Completed / Rejected by {Reviewer} / Escalated} | +| **Token usage** | {inputTokens} in / {outputTokens} out — ${estimatedCostUsd} | + +--- + +## Rules + +1. **One file per agent spawn.** Named `{timestamp}-{agent-name}.md`. +2. **Log BEFORE spawning.** The entry must exist before the agent runs. +3. **Update outcome AFTER the agent completes.** Fill in the Outcome field. +4. **Never delete or edit past entries.** Append-only. +5. **If a reviewer rejects work,** log the rejection as a new entry with the revision agent. diff --git a/packages/squad-sdk/templates/squad.agent.md b/packages/squad-sdk/templates/squad.agent.md index 970747bab..4c7c6c30b 100644 --- a/packages/squad-sdk/templates/squad.agent.md +++ b/packages/squad-sdk/templates/squad.agent.md @@ -720,10 +720,13 @@ prompt: | You are the Scribe. Read .squad/agents/scribe/charter.md. TEAM ROOT: {team_root} - SPAWN MANIFEST: {spawn_manifest} +SPAWN MANIFEST: {spawn_manifest} Tasks (in order): - 1. ORCHESTRATION LOG: Write .squad/orchestration-log/{timestamp}-{agent}.md per agent. Use ISO 8601 UTC timestamp. + Include token usage from agent responses in the manifest when available. + + Tasks (in order): + 1. ORCHESTRATION LOG: Write .squad/orchestration-log/{timestamp}-{agent}.md per agent. Include token usage (inputTokens, outputTokens, estimatedCostUsd) from the spawn manifest in the **Token usage** row. Use ISO 8601 UTC timestamp. 2. SESSION LOG: Write .squad/log/{timestamp}-{topic}.md. Brief. Use ISO 8601 UTC timestamp. 3. DECISION INBOX: Merge .squad/decisions/inbox/ → decisions.md, delete inbox files. Deduplicate. 4. CROSS-AGENT: Append team updates to affected agents' history.md. diff --git a/test/cli/cost.test.ts b/test/cli/cost.test.ts new file mode 100644 index 000000000..8d9da35f0 --- /dev/null +++ b/test/cli/cost.test.ts @@ -0,0 +1,110 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdirSync, rmSync, writeFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { randomBytes } from 'node:crypto'; +import { tmpdir } from 'node:os'; + +const TEST_ROOT = join(tmpdir(), `.test-cli-cost-${randomBytes(4).toString('hex')}`); +const TEAM_ROOT = join(TEST_ROOT, 'team'); +const ORCH_DIR = join(TEAM_ROOT, '.squad', 'orchestration-log'); +const LOG_DIR = join(TEAM_ROOT, '.squad', 'log'); + +function writeOrchestrationLog(fileName: string, agent: string, inputTokens: string, outputTokens: string, cost: string): void { + writeFileSync( + join(ORCH_DIR, fileName), + [ + '# Orchestration Log Entry', + '', + '| Field | Value |', + '|-------|-------|', + `| **Agent routed** | ${agent} (Specialist) |`, + `| **Token usage** | ${inputTokens} in / ${outputTokens} out — $${cost} |`, + '', + ].join('\n'), + 'utf8', + ); +} + +describe('CLI: cost command', () => { + beforeEach(() => { + if (existsSync(TEST_ROOT)) rmSync(TEST_ROOT, { recursive: true, force: true }); + mkdirSync(ORCH_DIR, { recursive: true }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + if (existsSync(TEST_ROOT)) rmSync(TEST_ROOT, { recursive: true, force: true }); + }); + + it('module exports runCost function', async () => { + const mod = await import('../../packages/squad-cli/src/cli/commands/cost.ts'); + expect(typeof mod.runCost).toBe('function'); + }); + + it('aggregates all orchestration log usage with --all', async () => { + const { runCost } = await import('../../packages/squad-cli/src/cli/commands/cost.ts'); + writeOrchestrationLog('2026-03-18T10-00-00Z-flight.md', 'Flight', '12,450', '3,200', '0.0234'); + writeOrchestrationLog('2026-03-18T10-01-00Z-control.md', 'Control', '8,100', '2,800', '0.0156'); + writeOrchestrationLog('2026-03-18T10-02-00Z-flight.md', 'Flight', '550', '100', '0.0010'); + + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await runCost(['--all'], TEAM_ROOT); + + const output = logSpy.mock.calls.map(call => call.join(' ')).join('\n'); + expect(output).toContain('💰 Squad Cost Summary'); + expect(output).toContain('Flight'); + expect(output).toContain('13,000'); + expect(output).toContain('3,300'); + expect(output).toContain('$0.0244'); + expect(output).toContain('Control'); + expect(output).toContain('21,100'); + expect(output).toContain('6,100'); + expect(output).toContain('$0.0400'); + expect(output).toContain('2 agents across 3 spawns'); + }); + + it('defaults to entries newer than the latest session log', async () => { + const { runCost } = await import('../../packages/squad-cli/src/cli/commands/cost.ts'); + mkdirSync(LOG_DIR, { recursive: true }); + writeFileSync(join(LOG_DIR, '2026-03-18T10-01-30Z-session.md'), '# session\n', 'utf8'); + writeOrchestrationLog('2026-03-18T10-01-00Z-flight.md', 'Flight', '500', '100', '0.0010'); + writeOrchestrationLog('2026-03-18T10-02-00Z-booster.md', 'Booster', '900', '200', '0.0025'); + + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await runCost([], TEAM_ROOT); + + const output = logSpy.mock.calls.map(call => call.join(' ')).join('\n'); + expect(output).toContain('Booster'); + expect(output).not.toContain('Flight'); + expect(output).toContain('1 agent across 1 spawn'); + }); + + it('filters results by agent name', async () => { + const { runCost } = await import('../../packages/squad-cli/src/cli/commands/cost.ts'); + writeOrchestrationLog('2026-03-18T10-00-00Z-flight.md', 'Flight', '100', '50', '0.0010'); + writeOrchestrationLog('2026-03-18T10-01-00Z-control.md', 'Control', '200', '70', '0.0020'); + + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await runCost(['--all', '--agent', 'control'], TEAM_ROOT); + + const output = logSpy.mock.calls.map(call => call.join(' ')).join('\n'); + expect(output).toContain('Control'); + expect(output).not.toContain('Flight'); + expect(output).toContain('1 agent across 1 spawn for control'); + }); + + it('prints the no-data message when orchestration log directory is missing', async () => { + const { runCost } = await import('../../packages/squad-cli/src/cli/commands/cost.ts'); + rmSync(join(TEAM_ROOT, '.squad'), { recursive: true, force: true }); + + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await runCost([], TEAM_ROOT); + + const output = logSpy.mock.calls.map(call => call.join(' ')).join('\n'); + expect(output).toContain('No token usage data found in orchestration logs.'); + }); +}); diff --git a/test/cost-tracking.test.ts b/test/cost-tracking.test.ts new file mode 100644 index 000000000..a6e7c5823 --- /dev/null +++ b/test/cost-tracking.test.ts @@ -0,0 +1,366 @@ +import { describe, it, expect } from 'vitest'; + +import { defineBudget } from '../packages/squad-sdk/src/builders/index.js'; +import { CostTracker } from '../packages/squad-sdk/src/runtime/cost-tracker.js'; + +function parseUsageFromLog(content: string): { + inputTokens: number; + outputTokens: number; + estimatedCost: number; +} | null { + const match = + /\|\s*\*\*Token usage\*\*\s*\|\s*([\d,]+)\s+in\s*\/\s*([\d,]+)\s+out\s+—\s+\$([0-9]+(?:\.[0-9]+)?)\s*\|/.exec( + content, + ); + + if (!match) { + return null; + } + + return { + inputTokens: Number(match[1].replaceAll(',', '')), + outputTokens: Number(match[2].replaceAll(',', '')), + estimatedCost: Number(match[3]), + }; +} + +describe('CostTracker', () => { + it('CostTracker starts with zero totals', () => { + const tracker = new CostTracker(); + const summary = tracker.getSummary(); + + expect(summary.totalInputTokens).toBe(0); + expect(summary.totalOutputTokens).toBe(0); + expect(summary.totalEstimatedCost).toBe(0); + expect(summary.agents.size).toBe(0); + expect(summary.sessions.size).toBe(0); + }); + + it('recordUsage accumulates tokens and cost', () => { + const tracker = new CostTracker(); + + tracker.recordUsage({ + sessionId: 'session-1', + agentName: 'fenster', + model: 'claude-sonnet-4.5', + inputTokens: 100, + outputTokens: 50, + estimatedCost: 0.01, + }); + tracker.recordUsage({ + sessionId: 'session-1', + agentName: 'fenster', + model: 'claude-sonnet-4.5', + inputTokens: 40, + outputTokens: 10, + estimatedCost: 0.0025, + }); + + const summary = tracker.getSummary(); + expect(summary.totalInputTokens).toBe(140); + expect(summary.totalOutputTokens).toBe(60); + expect(summary.totalEstimatedCost).toBeCloseTo(0.0125); + }); + + it('recordUsage groups by agent name', () => { + const tracker = new CostTracker(); + + tracker.recordUsage({ + sessionId: 'session-1', + agentName: 'fenster', + model: 'claude-sonnet-4.5', + inputTokens: 100, + outputTokens: 20, + estimatedCost: 0.01, + }); + tracker.recordUsage({ + sessionId: 'session-2', + agentName: 'fenster', + model: 'claude-opus-4.1', + inputTokens: 200, + outputTokens: 60, + estimatedCost: 0.03, + }); + tracker.recordUsage({ + sessionId: 'session-3', + agentName: 'mcmanus', + model: 'claude-haiku-4.5', + inputTokens: 30, + outputTokens: 10, + estimatedCost: 0.001, + }); + + const fenster = tracker.getSummary().agents.get('fenster'); + const mcmanus = tracker.getSummary().agents.get('mcmanus'); + + expect(fenster).toMatchObject({ + agentName: 'fenster', + inputTokens: 300, + outputTokens: 80, + turnCount: 2, + model: 'claude-opus-4.1', + }); + expect(fenster?.estimatedCost).toBeCloseTo(0.04); + expect(mcmanus).toMatchObject({ + agentName: 'mcmanus', + inputTokens: 30, + outputTokens: 10, + turnCount: 1, + }); + }); + + it('recordUsage groups by session ID', () => { + const tracker = new CostTracker(); + + tracker.recordUsage({ + sessionId: 'session-1', + agentName: 'fenster', + model: 'claude-sonnet-4.5', + inputTokens: 120, + outputTokens: 30, + estimatedCost: 0.01, + }); + tracker.recordUsage({ + sessionId: 'session-1', + agentName: 'mcmanus', + model: 'claude-haiku-4.5', + inputTokens: 80, + outputTokens: 25, + estimatedCost: 0.003, + }); + + const session = tracker.getSummary().sessions.get('session-1'); + + expect(session).toMatchObject({ + sessionId: 'session-1', + inputTokens: 200, + outputTokens: 55, + turnCount: 2, + }); + expect(session?.estimatedCost).toBeCloseTo(0.013); + }); + + it('recordFallback increments fallback counter', () => { + const tracker = new CostTracker(); + + tracker.recordUsage({ + sessionId: 'session-1', + agentName: 'fenster', + model: 'claude-sonnet-4.5', + inputTokens: 75, + outputTokens: 20, + estimatedCost: 0.004, + }); + tracker.recordFallback('fenster'); + tracker.recordFallback('fenster'); + + expect(tracker.getSummary().agents.get('fenster')?.fallbackCount).toBe(2); + }); + + it('getSummary returns correct totals after multiple recordings', () => { + const tracker = new CostTracker(); + + tracker.recordUsage({ + sessionId: 'session-1', + agentName: 'fenster', + model: 'claude-sonnet-4.5', + inputTokens: 500, + outputTokens: 120, + estimatedCost: 0.02, + }); + tracker.recordUsage({ + sessionId: 'session-2', + agentName: 'mcmanus', + model: 'claude-haiku-4.5', + inputTokens: 250, + outputTokens: 80, + estimatedCost: 0.005, + }); + tracker.recordUsage({ + sessionId: 'session-2', + agentName: 'mcmanus', + model: 'claude-haiku-4.5', + inputTokens: 150, + outputTokens: 40, + estimatedCost: 0.0025, + isFallback: true, + }); + + const summary = tracker.getSummary(); + + expect(summary.totalInputTokens).toBe(900); + expect(summary.totalOutputTokens).toBe(240); + expect(summary.totalEstimatedCost).toBeCloseTo(0.0275); + expect(summary.agents.size).toBe(2); + expect(summary.sessions.size).toBe(2); + expect(summary.agents.get('mcmanus')?.fallbackCount).toBe(1); + }); + + it('formatSummary includes agent breakdown', () => { + const tracker = new CostTracker(); + + tracker.recordUsage({ + sessionId: 'session-1', + agentName: 'fenster', + model: 'claude-sonnet-4.5', + inputTokens: 12450, + outputTokens: 3200, + estimatedCost: 0.0234, + isFallback: true, + }); + + const output = tracker.formatSummary(); + + expect(output).toContain('--- By Agent ---'); + expect(output).toContain('fenster'); + expect(output).toContain('12,450in / 3,200out'); + expect(output).toContain('$0.0234'); + expect(output).toContain('1 fallbacks'); + }); + + it('formatSummary includes session breakdown', () => { + const tracker = new CostTracker(); + + tracker.recordUsage({ + sessionId: 'session-abc', + agentName: 'fenster', + model: 'claude-sonnet-4.5', + inputTokens: 500, + outputTokens: 150, + estimatedCost: 0.01, + }); + + const output = tracker.formatSummary(); + + expect(output).toContain('--- By Session ---'); + expect(output).toContain('session-abc'); + expect(output).toContain('500in / 150out'); + }); + + it('reset clears all data', () => { + const tracker = new CostTracker(); + + tracker.recordUsage({ + sessionId: 'session-1', + agentName: 'fenster', + model: 'claude-sonnet-4.5', + inputTokens: 100, + outputTokens: 40, + estimatedCost: 0.005, + }); + tracker.recordFallback('fenster'); + + tracker.reset(); + + const summary = tracker.getSummary(); + expect(summary.totalInputTokens).toBe(0); + expect(summary.totalOutputTokens).toBe(0); + expect(summary.totalEstimatedCost).toBe(0); + expect(summary.agents.size).toBe(0); + expect(summary.sessions.size).toBe(0); + }); +}); + +describe('BudgetDefinition', () => { + it('defineBudget accepts valid config (perAgentSpawn + perSession + warnAt)', () => { + const budget = defineBudget({ + perAgentSpawn: 50000, + perSession: 500000, + warnAt: 0.8, + }); + + expect(budget).toEqual({ + perAgentSpawn: 50000, + perSession: 500000, + warnAt: 0.8, + }); + }); + + it('defineBudget accepts partial config (only perAgentSpawn)', () => { + expect(defineBudget({ perAgentSpawn: 100000 })).toEqual({ + perAgentSpawn: 100000, + }); + }); + + it('defineBudget accepts empty object', () => { + expect(defineBudget({})).toEqual({}); + }); + + it('defineBudget rejects negative perAgentSpawn', () => { + expect(() => defineBudget({ perAgentSpawn: -1 })).toThrow(/perAgentSpawn/); + }); + + it('defineBudget rejects zero perAgentSpawn', () => { + expect(() => defineBudget({ perAgentSpawn: 0 })).toThrow(/perAgentSpawn/); + }); + + it('defineBudget rejects warnAt > 1', () => { + expect(() => defineBudget({ warnAt: 1.1 })).toThrow(/warnAt/); + }); + + it('defineBudget rejects warnAt < 0', () => { + expect(() => defineBudget({ warnAt: -0.1 })).toThrow(/warnAt/); + }); + + it('defineBudget rejects non-number perSession', () => { + expect(() => defineBudget({ perSession: '500000' as unknown as number })).toThrow(/perSession/); + }); + + it('defineBudget rejects NaN for perAgentSpawn', () => { + expect(() => defineBudget({ perAgentSpawn: NaN })).toThrow(/perAgentSpawn/); + }); + + it('defineBudget rejects NaN for perSession', () => { + expect(() => defineBudget({ perSession: NaN })).toThrow(/perSession/); + }); + + it('defineBudget rejects NaN for warnAt', () => { + expect(() => defineBudget({ warnAt: NaN })).toThrow(/warnAt/); + }); + + it('defineBudget rejects Infinity for perAgentSpawn', () => { + expect(() => defineBudget({ perAgentSpawn: Infinity })).toThrow(/perAgentSpawn/); + }); + + it('defineBudget rejects Infinity for perSession', () => { + expect(() => defineBudget({ perSession: Infinity })).toThrow(/perSession/); + }); +}); + +describe('parseUsageFromLog', () => { + it('Parses valid usage row: `| **Token usage** | 12,450 in / 3,200 out — $0.0234 |`', () => { + const content = `# Orchestration Log + +| Field | Value | +|-------|-------| +| **Token usage** | 12,450 in / 3,200 out — $0.0234 | +`; + + expect(parseUsageFromLog(content)).toEqual({ + inputTokens: 12450, + outputTokens: 3200, + estimatedCost: 0.0234, + }); + }); + + it('Returns null for logs without usage data', () => { + const content = `# Orchestration Log + +| Field | Value | +|-------|-------| +| **Outcome** | Completed | +`; + + expect(parseUsageFromLog(content)).toBeNull(); + }); + + it('Handles commas in numbers', () => { + const content = '| **Token usage** | 123,456 in / 7,890 out — $1.2345 |'; + + expect(parseUsageFromLog(content)).toEqual({ + inputTokens: 123456, + outputTokens: 7890, + estimatedCost: 1.2345, + }); + }); +}); diff --git a/test/docs-build.test.ts b/test/docs-build.test.ts index ea8829307..352c85145 100644 --- a/test/docs-build.test.ts +++ b/test/docs-build.test.ts @@ -55,6 +55,7 @@ const EXPECTED_FEATURES = [ 'consult-mode', 'context-hygiene', 'copilot-coding-agent', + 'cost-tracking', 'directives', 'distributed-mesh', 'enterprise-platforms',