diff --git a/.changeset/cli-upgrade-fixes.md b/.changeset/cli-upgrade-fixes.md new file mode 100644 index 000000000..159088d0a --- /dev/null +++ b/.changeset/cli-upgrade-fixes.md @@ -0,0 +1,12 @@ +--- +"@bradygaster/squad-cli": patch +"@bradygaster/squad-sdk": patch +--- + +Fix CLI and upgrade bugs: +- `squad externalize` and `squad internalize` now appear in `squad --help` output (#1050) +- `squad build` writes files to the externalized state directory when applicable (#1048) +- `squad new agent` correctly adds agent definitions to `squad.config.ts` (#1047) +- `squad upgrade` backs up customized `squad.agent.md` before overwriting; supports `--dry-run` (#1052) +- Explicit `@agent` mentions bypass direct-response handler and route to named agent (#1029) +- `state-mcp` server uses lazy initialization to avoid blocking multi-MCP startup (#1353) diff --git a/packages/squad-cli/src/cli-entry.ts b/packages/squad-cli/src/cli-entry.ts index b7f231266..da09fc17f 100644 --- a/packages/squad-cli/src/cli-entry.ts +++ b/packages/squad-cli/src/cli-entry.ts @@ -274,6 +274,10 @@ async function main(): Promise { console.log(` upstream sync [name]`); console.log(` ${BOLD}economy${RESET} Toggle economy mode (cost-conscious model selection)`); console.log(` Usage: economy [on|off]`); + console.log(` ${BOLD}externalize${RESET} Move .squad/ state out of the working tree`); + console.log(` Stores state in platform-local storage`); + console.log(` Flags: --key (explicit project key)`); + console.log(` ${BOLD}internalize${RESET} Restore externalized state into the working tree`); console.log(` ${BOLD}version${RESET} Print installed version`); console.log(` ${BOLD}help${RESET} Show this help message`); @@ -401,6 +405,7 @@ async function main(): Promise { const selfUpgrade = args.includes('--self'); const forceUpgrade = args.includes('--force'); const insider = args.includes('--insider'); + const dryRun = args.includes('--dry-run'); const dest = hasGlobal ? (await lazySquadSdk()).resolveGlobalSquadPath() : getSquadStartDir(); // Parse --state-backend for backend migration @@ -464,6 +469,7 @@ async function main(): Promise { migrateDirectory: migrateDir, self: selfUpgrade, force: forceUpgrade, + dryRun, }); } diff --git a/packages/squad-cli/src/cli/commands/build.ts b/packages/squad-cli/src/cli/commands/build.ts index 234d0627f..55f077510 100644 --- a/packages/squad-cli/src/cli/commands/build.ts +++ b/packages/squad-cli/src/cli/commands/build.ts @@ -22,6 +22,7 @@ import { pathToFileURL } from 'node:url'; import { FSStorageProvider } from '@bradygaster/squad-sdk'; import { success, warn, info, dim, BOLD, RESET, YELLOW, GREEN, RED } from '../core/output.js'; import { fatal } from '../core/errors.js'; +import { effectiveSquadDir } from '../core/effective-squad-dir.js'; import type { SquadSDKConfig, @@ -430,7 +431,7 @@ interface BuildResult { drifted: string[]; } -function writeFiles(cwd: string, files: GeneratedFile[]): BuildResult { +function writeFiles(cwd: string, files: GeneratedFile[], stateDir?: string): BuildResult { const storage = new FSStorageProvider(); let written = 0; let skipped = 0; @@ -442,7 +443,14 @@ function writeFiles(cwd: string, files: GeneratedFile[]): BuildResult { continue; } - const absPath = path.join(cwd, file.relPath); + // When state is externalized, .squad/ paths are written to the external dir + const baseDir = (stateDir && file.relPath.startsWith('.squad/')) + ? path.join(stateDir, file.relPath.slice('.squad/'.length)) + : path.join(cwd, file.relPath); + + const absPath = (stateDir && file.relPath.startsWith('.squad/')) + ? baseDir + : path.join(cwd, file.relPath); storage.writeSync(absPath, file.content); written++; @@ -451,7 +459,7 @@ function writeFiles(cwd: string, files: GeneratedFile[]): BuildResult { return { files, written, skipped, drifted }; } -function checkDrift(cwd: string, files: GeneratedFile[]): { drifted: string[]; clean: string[] } { +function checkDrift(cwd: string, files: GeneratedFile[], stateDir?: string): { drifted: string[]; clean: string[] } { const storage = new FSStorageProvider(); const drifted: string[] = []; const clean: string[] = []; @@ -459,7 +467,10 @@ function checkDrift(cwd: string, files: GeneratedFile[]): { drifted: string[]; c for (const file of files) { if (isProtected(file.relPath)) continue; - const absPath = path.join(cwd, file.relPath); + const absPath = (stateDir && file.relPath.startsWith('.squad/')) + ? path.join(stateDir, file.relPath.slice('.squad/'.length)) + : path.join(cwd, file.relPath); + const existing = storage.readSync(absPath); if (existing === undefined) { drifted.push(file.relPath); @@ -500,6 +511,18 @@ export async function runBuild(cwd: string, options: BuildOptions = {}): Promise return; } + // Resolve effective state directory (respects externalized state) + let stateDir: string | undefined; + try { + const dirs = effectiveSquadDir(cwd); + if (dirs.stateDir !== dirs.local.path) { + stateDir = dirs.stateDir; + dim(` State dir: ${stateDir} (externalized)`); + } + } catch { + // No .squad/ found — build will proceed with default cwd-relative writes + } + // Load config const { config, source } = await loadSquadConfig(cwd); dim(` Config: ${path.relative(cwd, source) || source}`); @@ -514,7 +537,7 @@ export async function runBuild(cwd: string, options: BuildOptions = {}): Promise // --check mode if (options.check) { - const { drifted, clean } = checkDrift(cwd, files); + const { drifted, clean } = checkDrift(cwd, files, stateDir); if (drifted.length === 0) { success(`All ${clean.length} generated files match disk — no drift detected.`); return; @@ -542,7 +565,7 @@ export async function runBuild(cwd: string, options: BuildOptions = {}): Promise } // Default: write files - const result = writeFiles(cwd, files); + const result = writeFiles(cwd, files, stateDir); success(`squad build complete — generated ${result.written} file(s)`); if (result.skipped > 0) { diff --git a/packages/squad-cli/src/cli/commands/state-mcp.ts b/packages/squad-cli/src/cli/commands/state-mcp.ts index b2ec59b7c..b6800c3f9 100644 --- a/packages/squad-cli/src/cli/commands/state-mcp.ts +++ b/packages/squad-cli/src/cli/commands/state-mcp.ts @@ -160,29 +160,57 @@ export function createStateMcpSession( } export async function runStateMcp(startDir = process.cwd()): Promise { - const registry = createStateMcpToolRegistry(startDir); - const runtimeTools = new Map(registry.getTools().map(tool => [tool.name, tool])); - const tools = Object.entries(MCP_TOOL_ALIASES).map(([mcpName, runtimeName]) => { - const tool = runtimeTools.get(runtimeName); - if (!tool) throw new Error(`Missing Squad runtime state tool: ${runtimeName}`); - return { mcpName, runtimeName, tool }; - }); - const toolMap = new Map(tools.map(entry => [entry.mcpName, entry])); + // Lazy initialization: resolve state on first tool call, not at startup (#1353) + // This ensures the MCP server connects quickly and doesn't block other MCPs + let registry: ToolRegistry | undefined; + let initError: Error | undefined; + + function getRegistry(): ToolRegistry { + if (initError) throw initError; + if (!registry) { + try { + registry = createStateMcpToolRegistry(startDir); + } catch (err) { + initError = err instanceof Error ? err : new Error(String(err)); + throw initError; + } + } + return registry; + } + const server = new Server(SERVER_INFO, { capabilities: { tools: {} } }); - server.setRequestHandler(ListToolsRequestSchema, () => ({ - tools: tools.map(tool => ({ - name: tool.mcpName, - description: tool.tool.description, - inputSchema: tool.tool.parameters as { - type: 'object'; - properties?: Record; - required?: string[]; - }, - })), - })); + server.setRequestHandler(ListToolsRequestSchema, () => { + const reg = getRegistry(); + const runtimeTools = new Map(reg.getTools().map(tool => [tool.name, tool])); + const tools = Object.entries(MCP_TOOL_ALIASES).map(([mcpName, runtimeName]) => { + const tool = runtimeTools.get(runtimeName); + if (!tool) throw new Error(`Missing Squad runtime state tool: ${runtimeName}`); + return { mcpName, runtimeName, tool }; + }); + return { + tools: tools.map(tool => ({ + name: tool.mcpName, + description: tool.tool.description, + inputSchema: tool.tool.parameters as { + type: 'object'; + properties?: Record; + required?: string[]; + }, + })), + }; + }); server.setRequestHandler(CallToolRequestSchema, async request => { + const reg = getRegistry(); + const runtimeTools = new Map(reg.getTools().map(tool => [tool.name, tool])); + const tools = Object.entries(MCP_TOOL_ALIASES).map(([mcpName, runtimeName]) => { + const tool = runtimeTools.get(runtimeName); + if (!tool) throw new Error(`Missing Squad runtime state tool: ${runtimeName}`); + return { mcpName, runtimeName, tool }; + }); + const toolMap = new Map(tools.map(entry => [entry.mcpName, entry])); + const entry = toolMap.get(request.params.name); if (!entry) { throw new Error(`Unknown Squad state tool: ${request.params.name}`); diff --git a/packages/squad-cli/src/cli/core/command-help.ts b/packages/squad-cli/src/cli/core/command-help.ts index e92d8f961..8f76421ba 100644 --- a/packages/squad-cli/src/cli/core/command-help.ts +++ b/packages/squad-cli/src/cli/core/command-help.ts @@ -51,7 +51,10 @@ const COMMAND_HELP: Record = { console.log(`Usage: squad upgrade [options]\n`); console.log(`Overwrites Squad-owned files (squad.agent.md, .squad/templates/) while`); console.log(`leaving your team state under .squad/ and .ai-team/ untouched.\n`); + console.log(`Local customizations to squad.agent.md are backed up automatically.`); + console.log(`Use --dry-run to preview changes before applying.\n`); console.log(`Options:`); + console.log(` ${BOLD}--dry-run${RESET} Preview changes without writing`); console.log(` ${BOLD}--global${RESET} Upgrade the personal (global) squad`); console.log(` ${BOLD}--migrate-directory${RESET} Rename legacy .ai-team/ to .squad/`); console.log(` ${BOLD}--state-backend ${RESET} Migrate to a new backend (orphan|two-layer)`); diff --git a/packages/squad-cli/src/cli/core/upgrade.ts b/packages/squad-cli/src/cli/core/upgrade.ts index 67216c88e..fadd023af 100644 --- a/packages/squad-cli/src/cli/core/upgrade.ts +++ b/packages/squad-cli/src/cli/core/upgrade.ts @@ -150,6 +150,8 @@ export interface UpgradeOptions { force?: boolean; /** When --self, install the insider (prerelease) tag instead of latest. */ insider?: boolean; + /** Preview what upgrade would change without writing. */ + dryRun?: boolean; } export interface UpdateInfo { @@ -235,11 +237,38 @@ function detectIsGitHubForMcp(dest: string, config: Record): bo return true; } -function writeAgentTemplate(agentSrc: string, agentDest: string, cliVersion: string, mcpConfigMode: McpConfigMode, isGitHub: boolean): void { +function writeAgentTemplate(agentSrc: string, agentDest: string, cliVersion: string, mcpConfigMode: McpConfigMode, isGitHub: boolean, options?: { dryRun?: boolean }): void { let agentContent = storage.readSync(agentSrc) ?? ''; if (mcpConfigMode === 'agent-frontmatter') { agentContent = injectMcpFrontmatter(agentContent, isGitHub, cliVersion); } + + // Back up locally-customized squad.agent.md before overwriting (#1052) + if (storage.existsSync(agentDest)) { + const existing = (storage.readSync(agentDest) ?? '').replace(/\r\n/g, '\n'); + // Strip the version stamp before comparing (version line changes every upgrade) + const stripVersion = (s: string) => s.replace(/\n?/g, ''); + const normalizedExisting = stripVersion(existing); + const normalizedTemplate = stripVersion(agentContent.replace(/\r\n/g, '\n')); + + if (normalizedExisting !== normalizedTemplate && normalizedExisting.trim().length > 0) { + const backupPath = agentDest + '.local-backup'; + if (options?.dryRun) { + warn(`squad.agent.md has local customizations — would back up to ${path.basename(backupPath)}`); + return; + } + storage.writeSync(backupPath, existing); + warn(`squad.agent.md has local customizations — backed up to ${path.basename(backupPath)}`); + info(` To restore: copy ${path.basename(backupPath)} → ${path.basename(agentDest)}`); + } else if (options?.dryRun) { + info('squad.agent.md is unmodified — would refresh with latest template'); + return; + } + } else if (options?.dryRun) { + info('squad.agent.md does not exist — would create from template'); + return; + } + storage.writeSync(agentDest, agentContent); stampVersion(agentDest, cliVersion); } @@ -976,6 +1005,27 @@ export async function runUpgrade(dest: string, options: UpgradeOptions = {}): Pr const projectType = detectProjectType(dest); + // --dry-run: preview what upgrade would do without writing (#1052) + if (options.dryRun) { + info(`\n${bold('Dry run')} — previewing upgrade from ${oldVersion || 'unknown'} → ${cliVersion}\n`); + const templatesDir = getTemplatesDir(); + const agentSrc = path.join(templatesDir, 'squad.agent.md.template'); + if (storage.existsSync(agentSrc)) { + writeAgentTemplate(agentSrc, agentDest, cliVersion, mcpConfigMode, isGitHubForMcp, { dryRun: true }); + } + const filesToUpgrade = TEMPLATE_MANIFEST.filter(f => f.overwriteOnUpgrade && f.source !== 'squad.agent.md.template'); + if (filesToUpgrade.length > 0) { + info(`Would overwrite ${filesToUpgrade.length} squad-owned file(s):`); + for (const file of filesToUpgrade) { + const destPath = path.join(squadDirInfo.path, file.destination); + const exists = storage.existsSync(destPath); + console.log(` ${exists ? 'overwrite' : 'create'} ${file.destination}`); + } + } + console.log(`\nRun without --dry-run to apply.\n`); + return { fromVersion: oldVersion, toVersion: cliVersion, filesUpdated: [], migrationsRun: [] }; + } + if (isAlreadyCurrent) { info(`Already up to date (v${cliVersion})`); diff --git a/packages/squad-sdk/src/agents/onboarding.ts b/packages/squad-sdk/src/agents/onboarding.ts index 40c9f5b30..fa5a0945a 100644 --- a/packages/squad-sdk/src/agents/onboarding.ts +++ b/packages/squad-sdk/src/agents/onboarding.ts @@ -461,8 +461,60 @@ export async function addAgentToConfig( if (content === undefined) { return false; } - - // Simple heuristic: add routing rule if role matches common work types + + // Check if this agent is already defined in the config + const agentNamePattern = new RegExp(`name:\\s*['"]${agentName}['"]`); + if (agentNamePattern.test(content)) { + return false; // Agent already in config + } + + // Find the agents array and add new agent definition + // Match patterns like: agents: [ ... ] or .agents([ ... ]) + const agentsArrayPattern = /agents:\s*\[([\s\S]*?)\](?=\s*[,}\)])/g; + const builderPattern = /\.agents\(\s*\[([\s\S]*?)\]\s*\)/; + + let updatedContent = content; + let modified = false; + + const newAgentDef = ` { + name: '${agentName}', + role: '${role}', + }`; + + let arrayMatch: RegExpExecArray | null; + let targetMatch: RegExpExecArray | null = null; + while ((arrayMatch = agentsArrayPattern.exec(content)) !== null) { + const existingAgents = arrayMatch[1]!; + if (existingAgents.includes('{') || existingAgents.trim() === '') { + targetMatch = arrayMatch; + break; + } + } + + if (targetMatch) { + const existingAgents = targetMatch[1]!.trimEnd(); + const separator = existingAgents.trim() ? ',\n' : '\n'; + const updatedAgents = existingAgents + separator + newAgentDef; + updatedContent = + content.slice(0, targetMatch.index) + + `agents: [${updatedAgents}\n ]` + + content.slice(targetMatch.index + targetMatch[0].length); + modified = true; + } else { + const builderMatch = content.match(builderPattern); + if (builderMatch) { + const existingAgents = builderMatch[1]!.trimEnd(); + const separator = existingAgents.trim() ? ',\n' : '\n'; + const updatedAgents = existingAgents + separator + newAgentDef; + updatedContent = content.replace( + builderPattern, + `.agents([\n${updatedAgents}\n ])` + ); + modified = true; + } + } + + // Optionally add routing rule if role maps to a known work type const workTypeMap: Record = { 'developer': 'feature-dev', 'tester': 'testing', @@ -472,36 +524,33 @@ export async function addAgentToConfig( }; const workType = workTypeMap[role.toLowerCase()]; - if (!workType) { - return false; // No obvious work type mapping - } - - // Check if this work type already has a rule - const workTypePattern = new RegExp(`workType:\\s*['"]${workType}['"]`); - if (workTypePattern.test(content)) { - return false; // Already has a rule for this work type - } - - // Find the routing rules array and add new rule - const rulesPattern = /rules:\s*\[([^\]]*)\]/s; - const match = content.match(rulesPattern); - - if (!match) { - return false; // Cannot parse rules array - } - - const newRule = ` { + if (workType) { + const workTypePattern = new RegExp(`workType:\\s*['"]${workType}['"]`); + if (!workTypePattern.test(updatedContent)) { + const rulesPattern = /rules:\s*\[([\s\S]*?)\](?=\s*[,}\)])/; + const rulesMatch = updatedContent.match(rulesPattern); + if (rulesMatch) { + const newRule = ` { workType: '${workType}', agents: ['@${agentName}'], confidence: 'high' }`; - - const updatedRules = match[1]!.trim() + ',\n' + newRule; - const updatedContent = content.replace( - rulesPattern, - `rules: [\n${updatedRules}\n ]` - ); - + const existingRules = rulesMatch[1]!.trimEnd(); + const separator = existingRules.trim() ? ',\n' : '\n'; + const updatedRules = existingRules + separator + newRule; + updatedContent = updatedContent.replace( + rulesPattern, + `rules: [${updatedRules}\n ]` + ); + modified = true; + } + } + } + + if (!modified) { + return false; // Cannot find an agent array or routing rule to update + } + await storage.write(configPath, updatedContent); return true; } catch (error) { diff --git a/packages/squad-sdk/src/config/routing.ts b/packages/squad-sdk/src/config/routing.ts index 4c47eb08f..a69a3b371 100644 --- a/packages/squad-sdk/src/config/routing.ts +++ b/packages/squad-sdk/src/config/routing.ts @@ -238,6 +238,21 @@ export function compileRoutingRules(config: RoutingConfig): CompiledRouter { export function matchRoute(message: string, router: CompiledRouter): RoutingMatch { const lowerMessage = message.toLowerCase(); + // Explicit @agent mention — highest priority, always route to the named agent (#1029) + const explicitMention = lowerMessage.match(/(?:^|\s)@([a-z][a-z0-9_-]*)/); + if (explicitMention) { + const agentName = explicitMention[1]!; + // Verify the mentioned agent is a known team member (not @coordinator) + const allKnownAgents = router.workTypeRules.flatMap(r => r.agents.map(a => a.replace(/^@/, '').toLowerCase())); + if (allKnownAgents.includes(agentName) && agentName !== 'coordinator') { + return { + agents: [`@${agentName}`], + confidence: 'high', + reason: `Explicit agent mention: @${agentName}` + }; + } + } + // Try to match work type rules for (const rule of router.workTypeRules) { for (const pattern of rule.patterns) { diff --git a/packages/squad-sdk/src/coordinator/direct-response.ts b/packages/squad-sdk/src/coordinator/direct-response.ts index b8d64e2c0..df61ef8ff 100644 --- a/packages/squad-sdk/src/coordinator/direct-response.ts +++ b/packages/squad-sdk/src/coordinator/direct-response.ts @@ -177,6 +177,11 @@ export class DirectResponseHandler { const trimmed = message.trim(); if (trimmed.length === 0) return false; + // Never handle directly when message contains an explicit @agent mention (#1029) + if (/(?:^|\s)@[a-z][a-z0-9_-]*/i.test(trimmed)) { + return false; + } + // Check configurable patterns from squad config if (config?.routing?.rules) { for (const rule of config.routing.rules) { diff --git a/test/routing.test.ts b/test/routing.test.ts index a90089e79..12a1a95e2 100644 --- a/test/routing.test.ts +++ b/test/routing.test.ts @@ -208,6 +208,34 @@ describe('matchRoute', () => { expect(match.reason).toContain('fallback'); }); + it('routes explicit @agent mentions with high confidence', () => { + const match = matchRoute('@lead please review this feature', router); + + expect(match.agents).toEqual(['@lead']); + expect(match.confidence).toBe('high'); + expect(match.reason).toContain('Explicit agent mention: @lead'); + }); + + it('does not let @coordinator override normal routing or fallback', () => { + const matchedRoute = matchRoute('@coordinator implement new feature for authentication', router); + expect(matchedRoute.agents).toContain('Lead'); + expect(matchedRoute.confidence).toBe('medium'); + expect(matchedRoute.rule?.workType).toBe('feature-dev'); + + const fallbackRoute = matchRoute('@coordinator what is the weather today?', router); + expect(fallbackRoute.agents).toEqual(['@coordinator']); + expect(fallbackRoute.confidence).toBe('low'); + expect(fallbackRoute.reason).toContain('fallback'); + }); + + it('does not treat email addresses as explicit @agent mentions', () => { + const match = matchRoute('contact user@example.com about the new feature', router); + + expect(match.agents).toContain('Lead'); + expect(match.confidence).toBe('medium'); + expect(match.rule?.workType).toBe('feature-dev'); + }); + it('prefers more specific matches', () => { const specificConfig: RoutingConfig = { rules: [