diff --git a/.changeset/cli-stubs-cast-build-watch-memory-provider.md b/.changeset/cli-stubs-cast-build-watch-memory-provider.md new file mode 100644 index 000000000..8a0eee70f --- /dev/null +++ b/.changeset/cli-stubs-cast-build-watch-memory-provider.md @@ -0,0 +1,5 @@ +--- +"@bradygaster/squad-cli": patch +--- + +Implement persisted agent creation for `squad cast --name --role ` and `squad hire`, implement `squad build --watch` with initial build and live rebuilds, and report unsupported `squad memory provider --provider copilot` via `SquadError` with aligned help text (#2191, #2192, #2193). diff --git a/packages/squad-cli/src/cli-entry.ts b/packages/squad-cli/src/cli-entry.ts index 1a4b160d8..75bf9f52d 100644 --- a/packages/squad-cli/src/cli-entry.ts +++ b/packages/squad-cli/src/cli-entry.ts @@ -798,24 +798,31 @@ async function main(): Promise { if (cmd === 'cast' || cmd === 'hire') { const nameIdx = args.indexOf('--name'); - const name = (nameIdx !== -1 && args[nameIdx + 1]) ? args[nameIdx + 1] : undefined; const roleIdx = args.indexOf('--role'); - const role = (roleIdx !== -1 && args[roleIdx + 1]) ? args[roleIdx + 1] : undefined; - - // `squad cast` with no wizard flags shows the roster; `squad hire` always runs the wizard - if (cmd === 'cast' && !name && !role) { - const { runCast } = await import('./cli/commands/cast.js'); - await runCast(getSquadStartDir()); + const hasNameFlag = nameIdx !== -1; + const hasRoleFlag = roleIdx !== -1; + const force = args.includes('--force'); + + const readFlagValue = (idx: number): string | undefined => { + if (idx === -1) return undefined; + const val = args[idx + 1]; + return val && !val.startsWith('-') ? val : undefined; + }; + + if (cmd === 'hire' || hasNameFlag || hasRoleFlag) { + const name = readFlagValue(nameIdx); + const role = readFlagValue(roleIdx); + if (!name || !role) { + fatal('Usage: squad cast --name --role (or: squad hire --name --role )'); + } + const { runCastAdd } = await import('./cli/commands/cast.js'); + await runCastAdd(getSquadStartDir(), { name, role, force }); return; } - console.log('🎬 Squad cast — team creation wizard starting... (full implementation pending)'); - if (name) { - console.log(` Name: ${name}`); - } - if (role) { - console.log(` Role: ${role}`); - } + // `squad cast` with no wizard flags shows the roster + const { runCast } = await import('./cli/commands/cast.js'); + await runCast(getSquadStartDir()); return; } diff --git a/packages/squad-cli/src/cli/commands/build.ts b/packages/squad-cli/src/cli/commands/build.ts index 55f077510..e974ef2e0 100644 --- a/packages/squad-cli/src/cli/commands/build.ts +++ b/packages/squad-cli/src/cli/commands/build.ts @@ -17,6 +17,7 @@ * @module cli/commands/build */ +import fs from 'node:fs'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; import { FSStorageProvider } from '@bradygaster/squad-sdk'; @@ -66,7 +67,7 @@ interface LoadedConfig { * 2. squad.config.ts * 3. squad.config.js */ -async function loadSquadConfig(cwd: string): Promise { +async function loadSquadConfig(cwd: string, cacheBust = false): Promise { const storage = new FSStorageProvider(); const candidates = [ path.join(cwd, 'squad', 'index.ts'), @@ -77,7 +78,8 @@ async function loadSquadConfig(cwd: string): Promise { for (const candidate of candidates) { if (storage.existsSync(candidate)) { try { - const url = pathToFileURL(candidate).href; + const baseUrl = pathToFileURL(candidate).href; + const url = cacheBust ? `${baseUrl}?update=${Date.now()}-${Math.random()}` : baseUrl; const mod = await import(url); const config: SquadSDKConfig = mod.default ?? mod.config ?? mod.squadConfig; if (!config) { @@ -495,6 +497,14 @@ export interface BuildOptions { check?: boolean; dryRun?: boolean; watch?: boolean; + /** Optional AbortSignal to stop `--watch` mode cleanly (used in tests and programmatic callers). */ + signal?: AbortSignal; + /** Optional callback invoked when the watcher is active and ready. */ + onWatchReady?: (sourcePath: string) => void; + /** Optional callback invoked after each rebuild in `--watch` mode. */ + onRebuild?: (result: { written: number; skipped: number; source: string }) => void; + /** Debounce interval in milliseconds for `--watch` rebuilds (default: 75ms). */ + debounceMs?: number; } /** @@ -504,13 +514,6 @@ export interface BuildOptions { * @param options - CLI flags */ export async function runBuild(cwd: string, options: BuildOptions = {}): Promise { - // --watch stub - if (options.watch) { - info(`${YELLOW}⏳${RESET} Watch mode coming soon — rebuilding on file changes is not yet implemented.`); - info(` For now, run ${BOLD}squad build${RESET} manually after changes.`); - return; - } - // Resolve effective state directory (respects externalized state) let stateDir: string | undefined; try { @@ -564,13 +567,114 @@ export async function runBuild(cwd: string, options: BuildOptions = {}): Promise return; } - // Default: write files + // Default: write files (initial build) const result = writeFiles(cwd, files, stateDir); success(`squad build complete — generated ${result.written} file(s)`); if (result.skipped > 0) { dim(` (${result.skipped} protected file(s) skipped)`); } + + // --watch mode: stay alive and rebuild when the config file changes + if (options.watch) { + const relSource = path.relative(cwd, source) || path.basename(source); + info(`${YELLOW}👀${RESET} Watching ${BOLD}${relSource}${RESET} for changes... (Ctrl+C to stop)`); + await watchSquadConfig(cwd, source, stateDir, options); + } +} + +async function watchSquadConfig( + cwd: string, + initialSource: string, + stateDir: string | undefined, + options: BuildOptions, +): Promise { + if (options.signal?.aborted) return; + + const watchDir = path.dirname(initialSource); + const targetBasename = path.basename(initialSource); + const debounceMs = options.debounceMs ?? 75; + let lastContent = ''; + try { + lastContent = fs.readFileSync(initialSource, 'utf-8'); + } catch { + // Ignore initial read failure + } + + await new Promise((resolve) => { + let debounceTimer: ReturnType | undefined; + let rebuilding = false; + let pendingRebuild = false; + let closed = false; + + const cleanup = () => { + if (closed) return; + closed = true; + if (debounceTimer) clearTimeout(debounceTimer); + watcher.close(); + process.off('SIGINT', onSignal); + process.off('SIGTERM', onSignal); + options.signal?.removeEventListener('abort', onAbort); + resolve(); + }; + + const onSignal = () => cleanup(); + const onAbort = () => cleanup(); + + const triggerRebuild = async () => { + if (closed) return; + if (rebuilding) { + pendingRebuild = true; + return; + } + rebuilding = true; + try { + let currentContent = ''; + try { + currentContent = fs.readFileSync(initialSource, 'utf-8'); + } catch { + // File may be mid-rename during atomic save; wait for next event + return; + } + if (currentContent === lastContent) { + return; + } + lastContent = currentContent; + + const { config: updatedConfig, source: updatedSource } = await loadSquadConfig(cwd, true); + const updatedFiles = buildFilePlan(updatedConfig); + const rebuildResult = writeFiles(cwd, updatedFiles, stateDir); + success(`squad build complete — generated ${rebuildResult.written} file(s)`); + options.onRebuild?.({ + written: rebuildResult.written, + skipped: rebuildResult.skipped, + source: updatedSource, + }); + } catch (err) { + warn(`Rebuild failed: ${err instanceof Error ? err.message : String(err)}`); + } finally { + rebuilding = false; + if (pendingRebuild && !closed) { + pendingRebuild = false; + void triggerRebuild(); + } + } + }; + + const watcher = fs.watch(watchDir, (_eventType, filename) => { + if (closed) return; + if (filename && filename !== targetBasename) return; + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + void triggerRebuild(); + }, debounceMs); + }); + + process.on('SIGINT', onSignal); + process.on('SIGTERM', onSignal); + options.signal?.addEventListener('abort', onAbort, { once: true }); + options.onWatchReady?.(initialSource); + }); } // --------------------------------------------------------------------------- diff --git a/packages/squad-cli/src/cli/commands/cast.ts b/packages/squad-cli/src/cli/commands/cast.ts index dbaf8920f..619abeebf 100644 --- a/packages/squad-cli/src/cli/commands/cast.ts +++ b/packages/squad-cli/src/cli/commands/cast.ts @@ -8,12 +8,373 @@ */ import * as path from 'node:path'; +import { + FSStorageProvider, + addAgentToConfig, + generateCharterFromRole, + getRoleById, + syncTeamCapabilities, +} from '@bradygaster/squad-sdk'; +import { + acquireCastingRegistryLockAsync, + commitCastingRegistryPair, + prepareCastingRegistryPairLocked, + recoverCastingRegistryTransaction, + reconcileAgentProvenanceRegistry, +} from '@bradygaster/squad-sdk/casting'; import { LocalAgentSource } from '@bradygaster/squad-sdk/config/agent-source'; import { resolvePersonalAgents, mergeSessionCast } from '@bradygaster/squad-sdk/agents/personal'; import { resolveSquadPaths, resolveExternalStateDir } from '@bradygaster/squad-sdk/resolution'; -import { BOLD, RESET, DIM, GREEN, YELLOW } from '../core/output.js'; +import { BOLD, RESET, DIM, GREEN, YELLOW, info, success } from '../core/output.js'; import { fatal } from '../core/errors.js'; +const storage = new FSStorageProvider(); + +export interface CastAddOptions { + name: string; + role: string; + force?: boolean; +} + +function slugifyAgentName(name: string): string { + return name + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, ''); +} + +function buildAgentCharter(name: string, role: string, slug: string): string { + const roleId = role.toLowerCase().trim().replace(/\s+/g, '-'); + const catalogCharter = generateCharterFromRole(roleId, name); + if (catalogCharter) return catalogCharter; + + const catalogRole = getRoleById(roleId); + const displayRole = catalogRole?.title ?? role; + const vibe = catalogRole?.vibe ?? 'Focused and reliable. Gets the job done without fanfare.'; + + return `# ${name} — ${displayRole} + +> ${vibe} + +## Identity + +- **Name:** ${name} +- **Role:** ${displayRole} +- **Expertise:** ${displayRole} +- **Style:** Direct and focused. + +## What I Own + +- ${displayRole} domain tasks + +## How I Work + +- Read decisions.md before starting +- Write decisions to inbox when making team-relevant choices +- Focused, practical, gets things done + +## Boundaries + +**I handle:** ${displayRole} tasks + +**I don't handle:** Work outside my domain — the coordinator routes that elsewhere. + +**When I'm unsure:** I say so and suggest who might know. + +## Model + +- **Preferred:** auto +- **Rationale:** Coordinator selects the best model based on task type +- **Fallback:** Standard chain + +## Collaboration + +Before starting work, run \`git rev-parse --show-toplevel\` to find the repo root, or use the \`TEAM ROOT\` provided in the spawn prompt. All \`.squad/\` paths must be resolved relative to this root. + +Before starting work, read \`.squad/decisions.md\` for team decisions that affect me. +After making a decision others should know, write it to \`.squad/decisions/inbox/${slug}-{brief-slug}.md\`. +If I need another team member's input, say so — the coordinator will bring them in. + +## Voice + +${vibe} +`; +} + +function updateTeamMdWithAgent(squadDir: string, name: string, role: string, slug: string): void { + const teamPath = path.join(squadDir, 'team.md'); + const row = `| ${name} | ${role} | \`.squad/agents/${slug}/charter.md\` | ✅ Active |`; + const existing = storage.readSync(teamPath); + + if (!existing) { + const fresh = [ + '# Squad Team', + '', + '## Coordinator', + '', + '| Name | Role | Notes |', + '|------|------|-------|', + '| Squad | Coordinator | Routes work, enforces handoffs and reviewer gates. |', + '', + '## Members', + '', + '| Name | Role | Charter | Status |', + '|------|------|---------|--------|', + row, + '', + ].join('\n'); + storage.writeSync(teamPath, fresh); + return; + } + + // Check if agent row already exists in team.md + const charterRef = `.squad/agents/${slug}/charter.md`; + if (existing.includes(charterRef)) { + return; + } + + const membersHeader = '## Members'; + const membersIdx = existing.indexOf(membersHeader); + if (membersIdx === -1) { + const appended = `${existing.trimEnd()}\n\n## Members\n\n| Name | Role | Charter | Status |\n|------|------|---------|--------|\n${row}\n`; + storage.writeSync(teamPath, appended); + return; + } + + const afterHeader = existing.slice(membersIdx); + const nextHeaderMatch = afterHeader.slice(membersHeader.length).match(/\n## /); + const sectionEnd = nextHeaderMatch && nextHeaderMatch.index !== undefined + ? membersIdx + membersHeader.length + nextHeaderMatch.index + : existing.length; + const section = existing.slice(membersIdx, sectionEnd); + const sectionLines = section.split('\n'); + + let lastTableLineRel = -1; + for (let i = sectionLines.length - 1; i >= 0; i--) { + if (sectionLines[i]!.trimStart().startsWith('|')) { + lastTableLineRel = i; + break; + } + } + + if (lastTableLineRel === -1) { + const newSection = `${membersHeader}\n\n| Name | Role | Charter | Status |\n|------|------|---------|--------|\n${row}\n`; + const updated = existing.slice(0, membersIdx) + newSection + existing.slice(sectionEnd); + storage.writeSync(teamPath, updated); + return; + } + + const before = sectionLines.slice(0, lastTableLineRel + 1).join('\n'); + const after = sectionLines.slice(lastTableLineRel + 1).join('\n'); + const newSection = `${before}\n${row}${after ? '\n' + after : '\n'}`; + const updated = existing.slice(0, membersIdx) + newSection + existing.slice(sectionEnd); + storage.writeSync(teamPath, updated); +} + +function updateRoutingMdWithAgent(squadDir: string, name: string, role: string): void { + const routingPath = path.join(squadDir, 'routing.md'); + const row = `| ${role} | ${name} | — |`; + const existing = storage.readSync(routingPath); + if (!existing) { + const fresh = [ + '# Squad Routing', + '', + '## Work Type → Agent', + '', + '| Work Type | Primary | Secondary |', + '|-----------|---------|----------|', + row, + '', + ].join('\n'); + storage.writeSync(routingPath, fresh); + return; + } + + if (existing.includes(`| ${name} |`)) { + return; + } + + const routingHeader = '## Work Type → Agent'; + const headerIdx = existing.indexOf(routingHeader); + if (headerIdx === -1) { + const appended = `${existing.trimEnd()}\n\n${routingHeader}\n\n| Work Type | Primary | Secondary |\n|-----------|---------|----------|\n${row}\n`; + storage.writeSync(routingPath, appended); + return; + } + + const afterHeader = existing.slice(headerIdx); + const nextHeaderMatch = afterHeader.slice(routingHeader.length).match(/\n## /); + const sectionEnd = nextHeaderMatch && nextHeaderMatch.index !== undefined + ? headerIdx + routingHeader.length + nextHeaderMatch.index + : existing.length; + const section = existing.slice(headerIdx, sectionEnd); + const sectionLines = section.split('\n'); + + let lastTableLineRel = -1; + for (let i = sectionLines.length - 1; i >= 0; i--) { + if (sectionLines[i]!.trimStart().startsWith('|')) { + lastTableLineRel = i; + break; + } + } + + if (lastTableLineRel === -1) { + const newSection = `${routingHeader}\n\n| Work Type | Primary | Secondary |\n|-----------|---------|----------|\n${row}\n`; + const updated = existing.slice(0, headerIdx) + newSection + existing.slice(sectionEnd); + storage.writeSync(routingPath, updated); + return; + } + + const before = sectionLines.slice(0, lastTableLineRel + 1).join('\n'); + const after = sectionLines.slice(lastTableLineRel + 1).join('\n'); + const newSection = `${before}\n${row}${after ? '\n' + after : '\n'}`; + const updated = existing.slice(0, headerIdx) + newSection + existing.slice(sectionEnd); + storage.writeSync(routingPath, updated); +} + +async function updateCastingStateWithAgent( + squadDir: string, + name: string, + role: string, + slug: string, +): Promise { + const castingDir = path.join(squadDir, 'casting'); + await storage.mkdir(castingDir, { recursive: true }); + + const registryPath = path.join(castingDir, 'registry.json'); + const historyPath = path.join(castingDir, 'history.json'); + if (storage.existsSync(registryPath) && !storage.existsSync(historyPath)) { + storage.writeSync( + historyPath, + JSON.stringify({ assignment_cast_snapshots: {}, universe_usage_history: [] }, null, 2) + '\n', + ); + } + + const releaseLock = await acquireCastingRegistryLockAsync(castingDir, 'CLI cast add'); + try { + recoverCastingRegistryTransaction(castingDir); + const existingPair = prepareCastingRegistryPairLocked(castingDir); + const now = new Date().toISOString(); + const existingRegistry = existingPair.registryRaw?.trim() ? existingPair.registry : undefined; + const registry = reconcileAgentProvenanceRegistry( + existingRegistry, + [{ id: slug, displayName: name, role, universe: 'cli-cast' }], + { generatedAt: now, retireMissing: false }, + ); + const priorHistory = (existingPair.history ?? {}) as { + assignment_cast_snapshots?: Record; + universe_usage_history?: unknown[]; + }; + const history = { + assignment_cast_snapshots: { + ...(priorHistory.assignment_cast_snapshots ?? {}), + [`cli-cast-r${registry.revision}-${now}`]: { + created_at: now, + agents: [slug], + universe: 'cli-cast', + }, + }, + universe_usage_history: [ + ...(priorHistory.universe_usage_history ?? []), + { universe: 'cli-cast', used_at: now }, + ], + }; + commitCastingRegistryPair( + castingDir, + existingPair.registryRaw, + registry as unknown as Record, + existingPair.historyRaw, + history, + registry.revision, + ); + const policyPath = path.join(castingDir, 'policy.json'); + if (!storage.existsSync(policyPath)) { + storage.writeSync(policyPath, JSON.stringify({ universe_allowlist: ['*'], max_capacity: 25 }, null, 2) + '\n'); + } + } finally { + releaseLock(); + } +} + +/** + * Add a new specialist agent to the active project squad (`squad cast --name --role ` or `squad hire --name --role `). + */ +export async function runCastAdd(cwd: string, options: CastAddOptions): Promise<{ + agentId: string; + agentDir: string; + charterPath: string; + historyPath: string; +}> { + const rawName = options.name?.trim(); + const rawRole = options.role?.trim(); + if (!rawName || !rawRole) { + fatal('Usage: squad cast --name --role (or: squad hire --name --role )'); + } + + const slug = slugifyAgentName(rawName); + if (!slug) { + fatal(`Invalid agent name: "${rawName}". Must contain at least one alphanumeric character.`); + } + + const paths = resolveSquadPaths(cwd); + if (!paths) { + fatal('No squad found. Run "squad init" first.'); + } + + const externalSquadDir = + paths.config?.stateLocation === 'external' && paths.config.projectKey + ? resolveExternalStateDir(paths.config.projectKey, false) + : undefined; + const squadDir = externalSquadDir ?? paths.teamSquadDir; + const teamRoot = path.resolve(paths.projectDir, '..'); + const agentDir = path.join(squadDir, 'agents', slug); + const charterPath = path.join(agentDir, 'charter.md'); + const historyPath = path.join(agentDir, 'history.md'); + + if (storage.existsSync(charterPath) && !options.force) { + fatal(`Agent "${rawName}" already exists at ${charterPath} (use --force to overwrite)`); + } + + const roleId = rawRole.toLowerCase().replace(/\s+/g, '-'); + const catalogRole = getRoleById(roleId); + const displayRole = catalogRole?.title ?? rawRole; + const charterContent = buildAgentCharter(rawName, rawRole, slug); + const historyContent = `# ${rawName} — History\n\n## Core Context\n\n- **Role:** ${displayRole}\n- **Joined:** ${new Date().toISOString().split('T')[0]}\n\n## Learnings\n\n\n`; + + storage.mkdirSync(agentDir, { recursive: true }); + storage.writeSync(charterPath, charterContent); + if (!storage.existsSync(historyPath) || options.force) { + storage.writeSync(historyPath, historyContent); + } + + updateTeamMdWithAgent(squadDir, rawName, displayRole, slug); + updateRoutingMdWithAgent(squadDir, rawName, displayRole); + await updateCastingStateWithAgent(squadDir, rawName, displayRole, slug); + await addAgentToConfig(teamRoot, slug, displayRole); + + try { + syncTeamCapabilities({ + squadDir, + agentFile: path.join(teamRoot, '.github', 'agents', 'squad.agent.md'), + storage, + }); + } catch { + // Non-fatal if squad.agent.md does not exist or cannot be refreshed + } + + success(`Added agent ${rawName} (${displayRole})`); + info(` Charter: ${charterPath}`); + info(` History: ${historyPath}`); + + return { + agentId: slug, + agentDir, + charterPath, + historyPath, + }; +} + /** * Run the cast command — show merged session cast. */ diff --git a/packages/squad-cli/src/cli/commands/memory.ts b/packages/squad-cli/src/cli/commands/memory.ts index 0e1b15d25..0adc55aa6 100644 --- a/packages/squad-cli/src/cli/commands/memory.ts +++ b/packages/squad-cli/src/cli/commands/memory.ts @@ -6,6 +6,7 @@ import { } from '@bradygaster/squad-sdk'; import fs from 'node:fs'; import path from 'node:path'; +import { SquadError } from '../core/errors.js'; const REAL_COPILOT_UNAVAILABLE_REASON = 'Real Copilot Memory API unavailable: no concrete callable API was found in installed @github/copilot SDK/tooling. Squad will not fake provider=copilot; use hostInjectedCopilotAdapter only when a host supplies a client.'; @@ -278,7 +279,7 @@ export async function runMemoryCommand(projectRoot: string, args: string[]): Pro if (operation === 'provider' || operation === 'providers' || operation === 'status') { if (readFlag(commandArgs, '--provider') === 'copilot' || commandArgs.includes('--default-copilot')) { - throw new Error(REAL_COPILOT_UNAVAILABLE_REASON); + throw new SquadError(REAL_COPILOT_UNAVAILABLE_REASON); } if (commandArgs.includes('--enable-host-injected-copilot-adapter') || commandArgs.includes('--enable-copilot')) { const requireApproval = parseBoolean(readFlag(commandArgs, '--require-approval'), true); @@ -337,9 +338,9 @@ export async function runMemoryCommand(projectRoot: string, args: string[]): Pro ' write --content "..." --class LOCAL --title "..." --author scribe [--load-guidance ALWAYS|ON-DEMAND|ARCHIVE|NEVER]', ' search --query "testing strategy"', ' provider [--enable-host-injected-copilot-adapter|--disable-host-injected-copilot-adapter] [--require-approval true|false]', - ' provider --provider copilot fails unless a real Copilot Memory API module is present.', + ' provider --provider copilot is unsupported in this release (no concrete callable Copilot Memory API is shipped in @github/copilot).', ' diagnostics: --log-level none|error|info|debug, --verbose, SQUAD_MEMORY_LOG_LEVEL, or .squad/config.json memory.logLevel (stderr; never logs raw memory content or search text).', - 'hostInjectedCopilotAdapter is not real provider=copilot persistence; enabling config alone never fakes remote memory.', + 'hostInjectedCopilotAdapter is an optional host-injected client adapter, not native provider=copilot persistence; enabling config alone never fakes remote memory.', ].join('\n')); } catch (error) { diagnostics.error('command.error', { diff --git a/packages/squad-cli/src/cli/core/command-help.ts b/packages/squad-cli/src/cli/core/command-help.ts index 90ff42619..068f12101 100644 --- a/packages/squad-cli/src/cli/core/command-help.ts +++ b/packages/squad-cli/src/cli/core/command-help.ts @@ -142,13 +142,14 @@ const COMMAND_HELP: Record = { cast: (version) => { header('cast', version, 'Show roster or add a new agent to the team'); console.log(`Usage: squad cast ${DIM}# show the current cast (roster)${RESET}`); - console.log(` squad cast --name --role ${DIM}# add a new agent${RESET}\n`); + console.log(` squad cast --name --role [--force] ${DIM}# add a new agent${RESET}\n`); console.log(`With no flags, displays the current session cast (project + personal agents).`); - console.log(`With --name/--role, launches the team creation wizard.\n`); + console.log(`With --name and --role (or via "squad hire --name --role "), adds a new specialist agent to the project squad.\n`); console.log(`Options:`); - console.log(` ${BOLD}--name ${RESET} Pre-fill the agent name`); - console.log(` ${BOLD}--role ${RESET} Pre-select a built-in role (see 'squad roles')`); - console.log(`\nAlias: ${BOLD}squad hire${RESET} (always runs the creation wizard)\n`); + console.log(` ${BOLD}--name ${RESET} Name for the new agent (required when adding)`); + console.log(` ${BOLD}--role ${RESET} Role for the new agent (see 'squad roles')`); + console.log(` ${BOLD}--force${RESET} Overwrite existing agent charter if already present`); + console.log(`\nAlias: ${BOLD}squad hire --name --role ${RESET}\n`); }, copilot: (version) => { @@ -215,8 +216,12 @@ const COMMAND_HELP: Record = { console.log(`Usage: squad memory [options]\n`); console.log(`Common subcommands:`); console.log(` ${BOLD}write --content "..." --class LOCAL${RESET} Write a memory entry`); - console.log(` ${BOLD}list${RESET} List memory entries`); - console.log(` ${BOLD}read ${RESET} Read a single entry\n`); + console.log(` ${BOLD}search --query "..."${RESET} Search governed memory`); + console.log(` ${BOLD}provider${RESET} Inspect or configure memory providers\n`); + console.log(`Provider notes:`); + console.log(` • Native Copilot Memory (${BOLD}--provider copilot${RESET}) is unsupported in this release.`); + console.log(` • ${BOLD}hostInjectedCopilotAdapter${RESET} is an optional host-supplied client adapter,`); + console.log(` distinct from native Copilot Memory persistence.\n`); console.log(`Diagnostics:`); console.log(` ${BOLD}--log-level info|debug${RESET} Increase log verbosity`); console.log(` ${BOLD}--verbose${RESET} Shortcut for --log-level debug\n`); diff --git a/test/build-command.test.ts b/test/build-command.test.ts index 329ddf95c..6e8fc508b 100644 --- a/test/build-command.test.ts +++ b/test/build-command.test.ts @@ -708,3 +708,60 @@ describe('squad build — edge cases', () => { expect(result.filesWritten).toHaveLength(5); }); }); + +describe('squad build --watch (#2192)', () => { + it('runs an initial build and rebuilds when squad.config.js changes', async () => { + const root = makeTmpDir(); + const squadDir = scaffoldSquadDir(root); + const configPath = join(root, 'squad.config.js'); + + const makeConfigJs = (teamName: string, agentRole: string) => `export default { + version: '1.0.0', + team: { name: ${JSON.stringify(teamName)}, description: 'Watch test team' }, + agents: [ + { name: 'watcher', role: ${JSON.stringify(agentRole)}, charter: 'Watches the build pipeline.' } + ] +};\n`; + + writeFileSync(configPath, makeConfigJs('Initial Watch Team', 'Initial Builder'), 'utf-8'); + + const { runBuild: runCliBuild } = await import('../packages/squad-cli/src/cli/commands/build.js'); + const controller = new AbortController(); + + let resolveRebuild!: () => void; + const rebuildPromise = new Promise((resolve) => { + resolveRebuild = resolve; + }); + + const watchTask = runCliBuild(root, { + watch: true, + signal: controller.signal, + debounceMs: 25, + onWatchReady: () => { + expect(existsSync(join(squadDir, 'team.md'))).toBe(true); + expect(readFileSync(join(squadDir, 'team.md'), 'utf-8')).toContain('Initial Watch Team'); + expect(readFileSync(join(squadDir, 'agents', 'watcher', 'charter.md'), 'utf-8')).toContain('Initial Builder'); + + setTimeout(() => { + writeFileSync(configPath, makeConfigJs('Updated Watch Team', 'Updated Architect'), 'utf-8'); + }, 40); + }, + onRebuild: () => { + resolveRebuild(); + controller.abort(); + }, + }); + + await Promise.race([ + rebuildPromise, + new Promise((_, reject) => setTimeout(() => { + controller.abort(); + reject(new Error('Timed out waiting for squad build --watch rebuild')); + }, 5000)), + ]); + await watchTask; + + expect(readFileSync(join(squadDir, 'team.md'), 'utf-8')).toContain('Updated Watch Team'); + expect(readFileSync(join(squadDir, 'agents', 'watcher', 'charter.md'), 'utf-8')).toContain('Updated Architect'); + }); +}); diff --git a/test/cli/cast.test.ts b/test/cli/cast.test.ts index ba2e663ef..588824eec 100644 --- a/test/cli/cast.test.ts +++ b/test/cli/cast.test.ts @@ -9,7 +9,7 @@ */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { mkdir, rm, writeFile } from 'fs/promises'; +import { mkdir, rm, writeFile, readFile } from 'fs/promises'; import { join } from 'path'; import { existsSync } from 'fs'; import { randomBytes } from 'crypto'; @@ -223,4 +223,50 @@ describe('squad cast', () => { else process.env['XDG_CONFIG_HOME'] = origXdg; } }); + + it('persists a new agent to disk when runCastAdd is called with --name and --role (#2191)', async () => { + const sq = join(TEST_ROOT, '.squad'); + await mkdir(join(sq, 'agents', 'test-agent'), { recursive: true }); + await writeFile(join(sq, 'agents', 'test-agent', 'charter.md'), SAMPLE_CHARTER); + await writeFile(join(sq, 'team.md'), '# Team\n\n## Members\n\n| Name | Role | Charter | Status |\n|------|------|---------|--------|\n| TestAgent | Core Dev | `.squad/agents/test-agent/charter.md` | ✅ Active |\n'); + await writeFile(join(sq, 'routing.md'), '# Routing\n'); + await writeFile(join(sq, 'decisions.md'), '# Decisions\n'); + + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + const { runCastAdd, runCast } = await import('@bradygaster/squad-cli/commands/cast'); + const created = await runCastAdd(TEST_ROOT, { name: 'Probe', role: 'developer' }); + + expect(created.agentId).toBe('probe'); + expect(existsSync(join(sq, 'agents', 'probe', 'charter.md'))).toBe(true); + expect(existsSync(join(sq, 'agents', 'probe', 'history.md'))).toBe(true); + + const charter = await readFile(join(sq, 'agents', 'probe', 'charter.md'), 'utf-8'); + expect(charter).toContain('Probe'); + + const teamMd = await readFile(join(sq, 'team.md'), 'utf-8'); + expect(teamMd).toContain('| Probe |'); + expect(teamMd).toContain('.squad/agents/probe/charter.md'); + + const registryRaw = await readFile(join(sq, 'casting', 'registry.json'), 'utf-8'); + const registry = JSON.parse(registryRaw) as { agents: Record }; + expect(registry.agents['probe']?.display_name).toBe('Probe'); + + logSpy.mockClear(); + await runCast(TEST_ROOT); + const castOutput = logSpy.mock.calls.map(c => c.join(' ')).join('\n'); + expect(castOutput).toContain('Probe'); + }); + + it('rejects missing --name or --role with SquadError (#2191)', async () => { + await scaffold(TEST_ROOT); + const { runCastAdd } = await import('@bradygaster/squad-cli/commands/cast'); + + await expect(runCastAdd(TEST_ROOT, { name: 'Probe', role: '' })).rejects.toThrow( + /Usage: squad cast --name --role /, + ); + await expect(runCastAdd(TEST_ROOT, { name: '', role: 'developer' })).rejects.toThrow( + /Usage: squad cast --name --role /, + ); + }); }); diff --git a/test/memory-governance.test.ts b/test/memory-governance.test.ts index 68db7b643..a5c0b15ea 100644 --- a/test/memory-governance.test.ts +++ b/test/memory-governance.test.ts @@ -457,6 +457,34 @@ describe('LocalMemoryStore', () => { }); }); + it('surfaces SquadError for provider=copilot and documents native provider limitation in help (#2193)', async () => { + const root = testRoot('memory-provider-cli-squad-error'); + const { SquadError } = await import('../packages/squad-cli/src/cli/core/errors.js'); + + let caught: unknown; + try { + await runMemoryCommand(root, ['provider', '--provider', 'copilot']); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(SquadError); + expect((caught as Error).message).toContain('Real Copilot Memory API unavailable'); + + const helpOutput: string[] = []; + const originalLog = console.log; + console.log = (value?: unknown) => { + helpOutput.push(String(value)); + }; + try { + await runMemoryCommand(root, ['help']); + } finally { + console.log = originalLog; + } + + expect(helpOutput.join('\n')).toContain('provider --provider copilot is unsupported in this release'); + expect(helpOutput.join('\n')).toContain('hostInjectedCopilotAdapter'); + }); + it('CLI emits safe memory diagnostics when log level is enabled', async () => { const root = testRoot('memory-cli-diagnostics');