diff --git a/.changeset/fleet-dispatch-hybrid.md b/.changeset/fleet-dispatch-hybrid.md new file mode 100644 index 000000000..70f5d8b7a --- /dev/null +++ b/.changeset/fleet-dispatch-hybrid.md @@ -0,0 +1,5 @@ +--- +"@bradygaster/squad-cli": minor +--- + +Add /fleet hybrid dispatch mode for squad watch --execute (#775) — enables parallel issue analysis via Copilot CLI /fleet, 2.9x faster than sequential dispatch for read-heavy work diff --git a/.changeset/scratch-dir-utility.md b/.changeset/scratch-dir-utility.md new file mode 100644 index 000000000..d353b3c51 --- /dev/null +++ b/.changeset/scratch-dir-utility.md @@ -0,0 +1,11 @@ +--- +"@bradygaster/squad-sdk": minor +--- + +Add `.squad/.scratch/` directory for organized temp file management (#790) + +- `scratchDir()` — resolve and optionally create the scratch directory +- `scratchFile()` — create named temp files inside `.scratch/` +- Path traversal protection: prefix/ext sanitized to prevent `../` attacks +- Monotonic counter ensures unique filenames even within the same millisecond +- Agents and CLI should use these utilities instead of writing temp files to repo root diff --git a/packages/squad-cli/src/cli-entry.ts b/packages/squad-cli/src/cli-entry.ts index c15792e8e..0e97df19b 100644 --- a/packages/squad-cli/src/cli-entry.ts +++ b/packages/squad-cli/src/cli-entry.ts @@ -304,20 +304,33 @@ async function main(): Promise { } if (cmd === 'upgrade') { - const { runUpgrade } = await import('./cli/core/upgrade.js'); + const { runUpgrade, selfUpgradeCli } = await import('./cli/core/upgrade.js'); const { migrateDirectory } = await import('./cli/core/migrate-directory.js'); const migrateDir = args.includes('--migrate-directory'); const selfUpgrade = args.includes('--self'); const forceUpgrade = args.includes('--force'); + const insider = args.includes('--insider'); const dest = hasGlobal ? (await lazySquadSdk()).resolveGlobalSquadPath() : process.cwd(); + // Warn when --insider is used without --self (it has no effect on project upgrades) + if (insider && !selfUpgrade) { + console.warn('⚠ --insider has no effect without --self'); + } + // Handle --migrate-directory flag if (migrateDir) { await migrateDirectory(dest); // Continue with regular upgrade after migration } + // Handle --self: upgrade the CLI package itself + if (selfUpgrade) { + await selfUpgradeCli({ insider, force: forceUpgrade }); + console.log('✅ Upgraded. Please restart your terminal for changes to take effect.'); + return; + } + // Run upgrade await runUpgrade(dest, { migrateDirectory: migrateDir, @@ -370,6 +383,18 @@ async function main(): Promise { ? parseInt(args[timeoutIdx + 1]!, 10) : undefined; + // --dispatch-mode runtime validation: rejects invalid values with a clear error message + const dispatchModeIdx = args.indexOf('--dispatch-mode'); + const rawDispatchMode = (dispatchModeIdx !== -1 && args[dispatchModeIdx + 1]) + ? args[dispatchModeIdx + 1] + : undefined; + const validModes = ['task', 'fleet', 'hybrid'] as const; + const dispatchMode = rawDispatchMode && validModes.includes(rawDispatchMode as any) + ? rawDispatchMode as 'fleet' | 'task' | 'hybrid' + : rawDispatchMode + ? (console.error(`⚠️ Invalid --dispatch-mode "${rawDispatchMode}". Valid: task, fleet, hybrid. Defaulting to task.`), undefined) + : undefined; + const logFileIdx = args.indexOf('--log-file'); const logFile = (logFileIdx !== -1 && args[logFileIdx + 1]) ? args[logFileIdx + 1] @@ -400,6 +425,7 @@ async function main(): Promise { timeout, copilotFlags, agentCmd, + dispatchMode, logFile, capabilities: Object.keys(capabilities).length > 0 ? capabilities : undefined, }); diff --git a/packages/squad-cli/src/cli/commands/watch/capabilities/execute.ts b/packages/squad-cli/src/cli/commands/watch/capabilities/execute.ts index a0dcb5f8e..7bd09c982 100644 --- a/packages/squad-cli/src/cli/commands/watch/capabilities/execute.ts +++ b/packages/squad-cli/src/cli/commands/watch/capabilities/execute.ts @@ -3,6 +3,8 @@ */ import { execFile, type ChildProcess } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; import type { WatchCapability, WatchContext, PreflightResult, CapabilityResult } from '../types.js'; import type { MachineCapabilities } from '@bradygaster/squad-sdk/ralph/capabilities'; @@ -15,18 +17,32 @@ export interface ExecutableWorkItem { assignees: Array<{ login: string }>; } -/** Labels that block autonomous execution. */ -const BLOCKED_LABELS: ReadonlySet = new Set([ - 'status:blocked', - 'status:waiting-external', - 'status:postponed', - 'status:scheduled', - 'status:needs-action', - 'status:needs-decision', - 'status:needs-review', - 'pending-user', - 'do-not-merge', -]); +/** Check whether an issue carries a squad or squad:* label. */ +function hasSquadLabel(issue: ExecutableWorkItem): boolean { + return issue.labels.some(l => l.name === 'squad' || l.name.startsWith('squad:')); +} + +/** Keywords that indicate read-heavy / analysis work. */ +const READ_KEYWORDS = [ + 'research', 'review', 'analyze', 'investigate', 'audit', + 'check', 'scan', 'assess', 'evaluate', 'fact-check', + 'document', 'report', +]; + +/** Keywords that indicate write-heavy / implementation work. */ +const WRITE_KEYWORDS = [ + 'fix', 'implement', 'create', 'build', 'refactor', + 'add', 'update', 'migrate', 'deploy', 'feature', +]; + +/** Classify an issue as read-heavy or write-heavy by title keywords. */ +export function classifyIssue(title: string): 'read' | 'write' { + const lower = title.toLowerCase(); + const isRead = READ_KEYWORDS.some(k => lower.includes(k)); + const isWrite = WRITE_KEYWORDS.some(k => lower.includes(k)); + if (isRead && !isWrite) return 'read'; + return 'write'; // default to write (safer — gets full agent session) +} /** Build agent command for a prompt. */ function buildAgentCommand( @@ -46,38 +62,92 @@ function buildAgentCommand( return { cmd: 'gh', args }; } -/** Find issues eligible for autonomous execution. */ +/** Labels that indicate an issue should not be auto-executed. */ +const BLOCKING_LABELS = ['status:blocked', 'status:wontfix', 'status:on-hold', 'blocked']; + +/** Check whether an issue has a blocking status label. */ +function hasBlockingLabel(issue: ExecutableWorkItem): boolean { + return issue.labels.some(l => BLOCKING_LABELS.includes(l.name)); +} + +/** Check whether an issue is already assigned to a human. */ +function isAssigned(issue: ExecutableWorkItem): boolean { + return issue.assignees.length > 0; +} + +/** Find issues eligible for autonomous execution. + * + * Pre-filters to keep only clearly actionable items: + * - must have a squad/squad:* label + * - must not be assigned to a human (agent decides once it reads ralph-instructions.md) + * - must not carry a blocking status label + * + * Matches the PS1 ralph-watch pre-filter design. + */ export function findExecutableIssues( - roster: Array<{ name: string; label: string; expertise: string[] }>, - capabilities: MachineCapabilities | null, + _roster: Array<{ name: string; label: string; expertise: string[] }>, + _capabilities: MachineCapabilities | null, issues: ExecutableWorkItem[], ): ExecutableWorkItem[] { - const memberLabels = new Set(roster.map(m => m.label)); - return issues.filter(issue => { - const labels = issue.labels.map(l => l.name); - if (!labels.some(l => memberLabels.has(l))) return false; - if (issue.assignees && issue.assignees.length > 0) return false; - if (labels.some(l => BLOCKED_LABELS.has(l))) return false; - return true; - }); + return issues.filter( + issue => hasSquadLabel(issue) && !isAssigned(issue) && !hasBlockingLabel(issue), + ); +} + +/** Format issue list for the agent prompt. */ +function formatIssueList(issues: ExecutableWorkItem[]): string { + return issues.map(i => { + const labels = i.labels.map(l => l.name).join(', '); + const assigned = i.assignees?.length + ? `assigned: ${i.assignees.map(a => a.login).join(',')}` + : 'unassigned'; + return `- #${i.number}: ${i.title} [${labels}] ${assigned}`; + }).join('\n'); +} + +/** Build the rich agent prompt matching PS1 ralph-watch design. */ +export function buildAgentPrompt( + issues: ExecutableWorkItem[], + teamRoot: string, +): string { + const issueList = formatIssueList(issues); + const hasInstructions = existsSync(path.join(teamRoot, '.squad', 'ralph-instructions.md')); + + if (hasInstructions) { + return [ + 'Ralph, Go! Read .squad/ralph-instructions.md for your full instructions. Follow ALL sections there. MAXIMIZE PARALLELISM — spawn agents for ALL actionable issues simultaneously.', + '', + 'Here are the current open squad issues:', + issueList, + '', + 'Task: Read the issues, follow your instructions in .squad/ralph-instructions.md, and work on what\'s actionable.', + 'WHY: Keep the squad pipeline moving — no idle work.', + 'Success: Issues get branches, PRs, and progress.', + 'Escalation: If blocked, comment on the issue and move to next.', + ].join('\n'); + } + + // Fallback when ralph-instructions.md does not exist + return [ + 'You are Ralph, the autonomous work monitor. Review the open squad issues below and work on every actionable one. Skip issues that are blocked, waiting on external input, or already assigned.', + '', + 'Here are the current open squad issues:', + issueList, + '', + 'Task: Triage the list, pick up unblocked/unassigned issues, create branches and PRs.', + 'WHY: Keep the squad pipeline moving — no idle work.', + 'Success: Issues get branches, PRs, and progress.', + 'Escalation: If blocked, comment on the issue and move to next.', + ].join('\n'); } -/** Spawn agent for a single issue. */ -async function executeOne( - issue: ExecutableWorkItem, +/** Spawn a single agent session for all eligible issues. */ +async function executeAll( + issues: ExecutableWorkItem[], context: WatchContext, timeoutMs: number, ): Promise<{ success: boolean; error?: string }> { - // Claim the issue - try { - await context.adapter.addTag(issue.number, 'status:in-progress'); - } catch { /* best-effort */ } - - try { - await context.adapter.addComment(issue.number, '🤖 Ralph: starting autonomous work on this issue.'); - } catch { /* best-effort */ } - - const prompt = `Work on issue #${issue.number}: ${issue.title}. Read the issue body for full details.`; + const prompt = buildAgentPrompt(issues, context.teamRoot); const { cmd, args } = buildAgentCommand(prompt, context); return new Promise<{ success: boolean; error?: string }>((resolve) => { @@ -115,7 +185,6 @@ export class ExecuteCapability implements WatchCapability { async execute(context: WatchContext): Promise { try { - const maxConcurrent = (context.config['maxConcurrent'] as number) ?? 1; const timeout = ((context.config['timeout'] as number) ?? 30) * 60_000; // Fetch open issues with squad label @@ -127,31 +196,25 @@ export class ExecuteCapability implements WatchCapability { assignees: wi.assignedTo ? [{ login: wi.assignedTo }] : [], })); - // Filter by capabilities - const { filterByCapabilities, loadCapabilities } = await import('@bradygaster/squad-sdk/ralph/capabilities'); - const capabilities = await loadCapabilities(context.teamRoot); - const { handled } = filterByCapabilities(issues, capabilities); + // Minimal filter: must have squad or squad:* label (agent decides the rest) + const eligible = findExecutableIssues(context.roster, null, issues); - const executable = findExecutableIssues(context.roster, capabilities, handled); - const batch = executable.slice(0, maxConcurrent); - - if (batch.length === 0) { - return { success: true, summary: 'no executable issues' }; + if (eligible.length === 0) { + return { success: true, summary: 'no squad-labeled issues found' }; } - const results = await Promise.all( - batch.map(issue => executeOne(issue, context, timeout)), - ); - const succeeded = results.filter(r => r.success).length; - const failed = results.length - succeeded; + // Single agent invocation with all issues — agent reads ralph-instructions.md + const result = await executeAll(eligible, context, timeout); return { - success: true, - summary: `${succeeded} executed, ${failed} failed`, - data: { executed: succeeded, failed }, + success: result.success, + summary: result.success + ? `agent dispatched with ${eligible.length} issues` + : `agent failed: ${result.error}`, + data: { dispatched: eligible.length, success: result.success }, }; } catch (e) { return { success: false, summary: `execute error: ${(e as Error).message}` }; } } -} +} \ No newline at end of file diff --git a/packages/squad-cli/src/cli/commands/watch/capabilities/fleet-dispatch.ts b/packages/squad-cli/src/cli/commands/watch/capabilities/fleet-dispatch.ts new file mode 100644 index 000000000..98ec3affc --- /dev/null +++ b/packages/squad-cli/src/cli/commands/watch/capabilities/fleet-dispatch.ts @@ -0,0 +1,173 @@ +/** + * FleetDispatch capability — batches read-heavy issues into a single + * `/fleet`-style parallel Copilot session for efficient triage. + * + * Runs in the `post-execute` phase. When `dispatchMode` is `'fleet'` or + * `'hybrid'`, this capability picks up issues classified as read-heavy + * (research, review, audit, etc.) and dispatches them as parallel + * analysis tracks inside one Copilot invocation. + */ + +import { execSync, execFileSync } from 'node:child_process'; +import { writeFileSync, readFileSync, unlinkSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import type { WatchCapability, WatchContext, PreflightResult, CapabilityResult } from '../types.js'; +import type { MachineCapabilities } from '@bradygaster/squad-sdk/ralph/capabilities'; +import type { DispatchMode } from '../config.js'; +import { + type ExecutableWorkItem, + findExecutableIssues, + classifyIssue, +} from './execute.js'; + +/** Map a roster member label to a display-friendly agent name. */ +function agentForIssue( + issue: ExecutableWorkItem, + roster: WatchContext['roster'], +): string { + const memberLabels = new Map(roster.map(m => [m.label, m.name])); + for (const label of issue.labels) { + const name = memberLabels.get(label.name); + if (name) return name; + } + return 'Copilot'; +} + +/** Build the multi-track fleet prompt for a set of read-heavy issues. */ +function buildFleetPrompt( + issues: ExecutableWorkItem[], + roster: WatchContext['roster'], +): string { + const tracks = issues.map((issue, idx) => { + const agent = agentForIssue(issue, roster); + return [ + `Track ${idx + 1} (${agent}): Issue #${issue.number}: ${issue.title}`, + ` Read the issue body. Analyze, assess urgency (P0/P1/P2), recommend next step.`, + ` Write findings as an issue comment.`, + ` Do NOT create branches or modify files.`, + ].join('\n'); + }); + + return [ + `/fleet Execute these ${issues.length} read-only analysis tracks in parallel:`, + '', + ...tracks, + '', + 'Rules: All tracks READ-ONLY. Write findings as issue comments. Run in parallel.', + ].join('\n'); +} + +/** Invoke a fleet prompt via the Copilot CLI. */ +function invokeFleet( + prompt: string, + cwd: string, + timeoutMs: number, +): { success: boolean; output?: string; error?: string } { + const promptFile = join(tmpdir(), `fleet-prompt-${Date.now()}.txt`); + writeFileSync(promptFile, prompt, 'utf-8'); + + try { + // Read the prompt from file + const promptContent = readFileSync(promptFile, 'utf-8'); + + // Use execFileSync with args array — no shell, no injection risk + const copilotBin = process.platform === 'win32' ? 'copilot.cmd' : 'copilot'; + const result = execFileSync(copilotBin, [ + '-p', promptContent, + '--allow-all', + '--no-ask-user', + '--autopilot', + ], { + cwd, + timeout: timeoutMs, + encoding: 'utf-8' as BufferEncoding, + }); + + return { success: true, output: String(result) }; + } catch (e) { + const err = e as Error & { killed?: boolean; stderr?: string }; + const msg = err.killed + ? `Fleet dispatch timed out after ${Math.round(timeoutMs / 60_000)}m` + : err.stderr || err.message; + return { success: false, error: msg }; + } finally { + try { unlinkSync(promptFile); } catch { /* best-effort cleanup */ } + } +} + +export class FleetDispatchCapability implements WatchCapability { + readonly name = 'fleet-dispatch'; + readonly description = 'Batch read-heavy issues into a parallel /fleet Copilot session'; + readonly configShape = 'boolean' as const; + readonly requires = ['gh', 'copilot']; + readonly phase = 'post-execute' as const; + + async preflight(_context: WatchContext): Promise { + // Fleet dispatch requires the copilot CLI — quick sanity check + try { + execSync('copilot --version', { encoding: 'utf-8', stdio: 'pipe' }); + return { ok: true }; + } catch { + return { ok: false, reason: 'copilot CLI not found — required for fleet dispatch' }; + } + } + + async execute(context: WatchContext): Promise { + try { + const dispatchMode = (context.config['dispatchMode'] as DispatchMode | undefined) ?? 'task'; + + // Only run when fleet or hybrid dispatch is active + if (dispatchMode !== 'fleet' && dispatchMode !== 'hybrid') { + return { success: true, summary: 'fleet-dispatch: skipped (dispatch mode is task)' }; + } + + const timeoutMs = ((context.config['timeout'] as number) ?? 30) * 60_000; + + // Fetch the same issue set as the execute capability + const sdkItems = await context.adapter.listWorkItems({ tags: ['squad'], state: 'open', limit: 50 }); + const issues: ExecutableWorkItem[] = sdkItems.map(wi => ({ + number: wi.id, + title: wi.title, + labels: wi.tags.map(t => ({ name: t })), + assignees: wi.assignedTo ? [{ login: wi.assignedTo }] : [], + })); + + const { filterByCapabilities, loadCapabilities } = await import('@bradygaster/squad-sdk/ralph/capabilities'); + const capabilities: MachineCapabilities | null = await loadCapabilities(context.teamRoot); + const { handled } = filterByCapabilities(issues, capabilities); + + const executable = findExecutableIssues(context.roster, capabilities, handled); + + // Pick only read-heavy issues (or all in pure fleet mode) + const readIssues = dispatchMode === 'fleet' + ? executable + : executable.filter(i => classifyIssue(i.title) === 'read'); + + if (readIssues.length === 0) { + return { success: true, summary: 'fleet-dispatch: no read-heavy issues to batch' }; + } + + // Build and invoke fleet prompt + const prompt = buildFleetPrompt(readIssues, context.roster); + const fleetTimeout = Math.max(timeoutMs, 300_000); // at least 5 min for fleet + const result = invokeFleet(prompt, context.teamRoot, fleetTimeout); + + if (result.success) { + return { + success: true, + summary: `fleet-dispatch: ${readIssues.length} issues analyzed in parallel`, + data: { dispatched: readIssues.length, issues: readIssues.map(i => i.number) }, + }; + } + + return { + success: false, + summary: `fleet-dispatch: failed — ${result.error}`, + data: { dispatched: 0, error: result.error }, + }; + } catch (e) { + return { success: false, summary: `fleet-dispatch error: ${(e as Error).message}` }; + } + } +} diff --git a/packages/squad-cli/src/cli/commands/watch/capabilities/index.ts b/packages/squad-cli/src/cli/commands/watch/capabilities/index.ts index 5b8524f12..78cf98bb2 100644 --- a/packages/squad-cli/src/cli/commands/watch/capabilities/index.ts +++ b/packages/squad-cli/src/cli/commands/watch/capabilities/index.ts @@ -5,6 +5,7 @@ import { CapabilityRegistry } from '../registry.js'; import { SelfPullCapability } from './self-pull.js'; import { ExecuteCapability } from './execute.js'; +import { FleetDispatchCapability } from './fleet-dispatch.js'; import { BoardCapability } from './board.js'; import { MonitorTeamsCapability } from './monitor-teams.js'; import { MonitorEmailCapability } from './monitor-email.js'; @@ -18,6 +19,7 @@ export function createDefaultRegistry(): CapabilityRegistry { const registry = new CapabilityRegistry(); registry.register(new SelfPullCapability()); registry.register(new ExecuteCapability()); + registry.register(new FleetDispatchCapability()); registry.register(new BoardCapability()); registry.register(new MonitorTeamsCapability()); registry.register(new MonitorEmailCapability()); diff --git a/packages/squad-cli/src/cli/commands/watch/config.ts b/packages/squad-cli/src/cli/commands/watch/config.ts index 6d1534c66..7f07d57bd 100644 --- a/packages/squad-cli/src/cli/commands/watch/config.ts +++ b/packages/squad-cli/src/cli/commands/watch/config.ts @@ -9,6 +9,9 @@ import { FSStorageProvider } from '@bradygaster/squad-sdk'; const storage = new FSStorageProvider(); +/** Dispatch strategy for issue execution. */ +export type DispatchMode = 'task' | 'fleet' | 'hybrid'; + /** Fully-resolved watch configuration. */ export interface WatchConfig { interval: number; @@ -18,6 +21,8 @@ export interface WatchConfig { copilotFlags?: string; /** Hidden — fully override the agent command. */ agentCmd?: string; + /** Dispatch mode: 'task' (default 1:1), 'fleet' (batch read-only), 'hybrid' (auto-classify). */ + dispatchMode?: DispatchMode; /** Optional path to a log file. When set, console output is tee'd to the file with timestamps. */ logFile?: string; /** Per-capability config: `true` / `false` / object with sub-options. */ @@ -29,6 +34,7 @@ const DEFAULTS: WatchConfig = { execute: false, maxConcurrent: 1, timeout: 30, + dispatchMode: undefined, capabilities: {}, }; @@ -65,6 +71,7 @@ export function loadWatchConfig( timeout: cliOverrides.timeout ?? fileConfig.timeout ?? DEFAULTS.timeout, copilotFlags: cliOverrides.copilotFlags ?? fileConfig.copilotFlags ?? DEFAULTS.copilotFlags, agentCmd: cliOverrides.agentCmd ?? fileConfig.agentCmd ?? DEFAULTS.agentCmd, + dispatchMode: cliOverrides.dispatchMode ?? fileConfig.dispatchMode ?? DEFAULTS.dispatchMode, logFile: cliOverrides.logFile ?? fileConfig.logFile ?? DEFAULTS.logFile, capabilities: { ...DEFAULTS.capabilities, @@ -86,11 +93,17 @@ function normalizeFileConfig(raw: Record): Partial if (typeof raw['timeout'] === 'number') result.timeout = raw['timeout']; if (typeof raw['copilotFlags'] === 'string') result.copilotFlags = raw['copilotFlags']; if (typeof raw['agentCmd'] === 'string') result.agentCmd = raw['agentCmd']; + if (typeof raw['dispatchMode'] === 'string') { + const mode = raw['dispatchMode'] as string; + if (mode === 'fleet' || mode === 'task' || mode === 'hybrid') { + result.dispatchMode = mode; + } + } if (typeof raw['logFile'] === 'string') result.logFile = raw['logFile']; // Everything else is a capability key const caps: Record> = {}; - const reserved = new Set(['interval', 'execute', 'maxConcurrent', 'timeout', 'copilotFlags', 'agentCmd', 'logFile']); + const reserved = new Set(['interval', 'execute', 'maxConcurrent', 'timeout', 'copilotFlags', 'agentCmd', 'dispatchMode', 'logFile']); for (const [key, value] of Object.entries(raw)) { if (reserved.has(key)) continue; if (typeof value === 'boolean' || (typeof value === 'object' && value !== null && !Array.isArray(value))) { diff --git a/packages/squad-cli/src/cli/commands/watch/index.ts b/packages/squad-cli/src/cli/commands/watch/index.ts index cf7933ff8..9573ab2d0 100644 --- a/packages/squad-cli/src/cli/commands/watch/index.ts +++ b/packages/squad-cli/src/cli/commands/watch/index.ts @@ -428,7 +428,7 @@ async function runPhase( ...context, config: typeof capConfig === 'object' && capConfig !== null ? capConfig as Record - : { enabled: !!capConfig, maxConcurrent: config.maxConcurrent, timeout: config.timeout }, + : { enabled: !!capConfig, maxConcurrent: config.maxConcurrent, timeout: config.timeout, dispatchMode: config.dispatchMode }, }; const result = await cap.execute(capContext); results.set(cap.name, result); @@ -540,7 +540,7 @@ function legacyToConfig(options: WatchOptions): WatchConfig { // ── Exported helpers (backward compat) ─────────────────────────── -export { findExecutableIssues } from './capabilities/execute.js'; +export { findExecutableIssues, classifyIssue } from './capabilities/execute.js'; export function buildAgentCommand( issue: WatchWorkItem, @@ -717,6 +717,11 @@ export async function runWatch(dest: string, options: WatchOptions | WatchConfig if (config.execute) { console.log(`${DIM}Max concurrent: ${config.maxConcurrent} | Timeout: ${config.timeout}m${RESET}`); } + // Warn when fleet dispatch mode is set but the fleet-dispatch capability is not enabled + if ((config.dispatchMode === 'fleet' || config.dispatchMode === 'hybrid') && + !enabledCapabilities.some(c => c.name === 'fleet-dispatch')) { + console.warn(`${YELLOW}⚠${RESET} dispatchMode="${config.dispatchMode}" but fleet-dispatch capability is not enabled. Read-heavy issues will not be batched.`); + } console.log(); // Fix 2: Set up log-file tee (after banner so the banner itself is captured too) diff --git a/packages/squad-cli/src/cli/core/upgrade.ts b/packages/squad-cli/src/cli/core/upgrade.ts index 0e9dc1172..8ab6286f6 100644 --- a/packages/squad-cli/src/cli/core/upgrade.ts +++ b/packages/squad-cli/src/cli/core/upgrade.ts @@ -607,3 +607,67 @@ export async function runUpgrade(dest: string, options: UpgradeOptions = {}): Pr migrationsRun: migrationsApplied, }; } + +// ============================================================================ +// Self-upgrade: upgrade the CLI package itself +// ============================================================================ + +export interface SelfUpgradeOptions { + insider?: boolean; + force?: boolean; +} + +/** + * Detect the package manager that installed the CLI. + * Returns 'npm', 'pnpm', 'yarn', or 'npm' as fallback. + */ +function detectPackageManager(): 'npm' | 'pnpm' | 'yarn' { + const execPath = process.env['npm_execpath'] ?? ''; + if (execPath.includes('pnpm')) return 'pnpm'; + if (execPath.includes('yarn')) return 'yarn'; + // Check npm_config_user_agent as fallback + const userAgent = process.env['npm_config_user_agent'] ?? ''; + if (userAgent.startsWith('pnpm')) return 'pnpm'; + if (userAgent.startsWith('yarn')) return 'yarn'; + return 'npm'; +} + +/** + * Self-upgrade the Squad CLI package via the detected package manager. + * + * Detects whether the CLI was installed via npm, pnpm, or yarn and runs the + * appropriate global install command. Only suggests `sudo` for npm (pnpm and + * yarn typically do not require elevated permissions for global installs). + */ +export async function selfUpgradeCli(options: SelfUpgradeOptions = {}): Promise { + const { execSync } = await import('node:child_process'); + const tag = options.insider ? 'insider' : 'latest'; + const pkg = `@bradygaster/squad-cli@${tag}`; + const pm = detectPackageManager(); + + let cmd: string; + switch (pm) { + case 'pnpm': + cmd = `pnpm add -g ${pkg}`; + break; + case 'yarn': + cmd = `yarn global add ${pkg}`; + break; + default: + cmd = `npm install -g ${pkg}`; + break; + } + + info(`Self-upgrading via ${pm}: ${cmd}`); + + try { + execSync(cmd, { stdio: 'inherit' }); + } catch { + // Only suggest sudo for npm — pnpm/yarn rarely need it + if (pm === 'npm') { + warn(`Permission denied. Try: sudo ${cmd}`); + } else { + warn(`Upgrade failed. Check ${pm} permissions or try running manually: ${cmd}`); + } + } +} diff --git a/packages/squad-sdk/src/config/init.ts b/packages/squad-sdk/src/config/init.ts index 8dd85cdef..23765af45 100644 --- a/packages/squad-sdk/src/config/init.ts +++ b/packages/squad-sdk/src/config/init.ts @@ -695,6 +695,7 @@ export async function initSquad(options: InitOptions, storage: StorageProvider = join(squadDir, 'identity'), join(squadDir, 'orchestration-log'), join(squadDir, 'log'), + join(squadDir, '.scratch'), ]; for (const dir of directories) { @@ -1011,6 +1012,7 @@ ${projectDescription ? `- **Description:** ${projectDescription}\n` : ''}- **Cre '.squad/log/', '.squad/decisions/inbox/', '.squad/sessions/', + '.squad/.scratch/', ]; let existingIgnore = ''; diff --git a/packages/squad-sdk/src/index.ts b/packages/squad-sdk/src/index.ts index 185a192a2..0ff8ba0f7 100644 --- a/packages/squad-sdk/src/index.ts +++ b/packages/squad-sdk/src/index.ts @@ -10,7 +10,7 @@ const pkg = require('../package.json'); export const VERSION: string = pkg.version; // Export public API -export { resolveSquad, resolveGlobalSquadPath, resolvePersonalSquadDir, ensurePersonalSquadDir, ensureSquadPath, ensureSquadPathTriple, loadDirConfig, isConsultMode } from './resolution.js'; +export { resolveSquad, resolveGlobalSquadPath, resolvePersonalSquadDir, ensurePersonalSquadDir, ensureSquadPath, ensureSquadPathTriple, loadDirConfig, isConsultMode, scratchDir, scratchFile } from './resolution.js'; export type { SquadDirConfig, ResolvedSquadPaths } from './resolution.js'; export * from './config/index.js'; export * from './agents/onboarding.js'; diff --git a/packages/squad-sdk/src/resolution.ts b/packages/squad-sdk/src/resolution.ts index fbffaf461..7150c96cc 100644 --- a/packages/squad-sdk/src/resolution.ts +++ b/packages/squad-sdk/src/resolution.ts @@ -471,3 +471,66 @@ export function ensureSquadPathTriple( export function ensureSquadPathResolved(filePath: string, paths: ResolvedSquadPaths): string { return ensureSquadPathDual(filePath, paths.projectDir, paths.teamDir); } + +/** + * Resolve the scratch directory for temporary files. + * + * Returns `{squadRoot}/.scratch/` — the canonical location for ephemeral files + * that Squad and its agents create during operations (prompt files, intermediate + * processing artifacts, commit message drafts, etc.). + * + * If `create` is true (default), the directory is created if it does not exist. + * + * @param squadRoot - Absolute path to the `.squad/` directory. + * @param create - Whether to create the directory if missing (default: true). + * @returns Absolute path to the scratch directory. + */ +export function scratchDir(squadRoot: string, create: boolean = true): string { + const dir = path.join(squadRoot, '.scratch'); + if (create && !storage.existsSync(dir)) { + storage.mkdirSync(dir, { recursive: true }); + } + return dir; +} + +/** + * Return a unique file path inside the scratch directory. + * + * Returns the absolute path to the file. Caller is responsible for writing + * content to the returned path (unless `content` is provided, in which case + * it is written immediately). The caller is also responsible for deleting + * the file when done (or relying on the cleanup capability). + * + * @param squadRoot - Absolute path to the `.squad/` directory. + * @param prefix - Filename prefix (e.g. `"fleet-prompt"`). + * @param ext - File extension including dot (e.g. `".txt"`). Defaults to `".tmp"`. + * @param content - Optional content to write immediately. + * @returns Absolute path to the temp file. + */ +let _scratchCounter = 0; +let _scratchLastTs = 0; + +export function scratchFile(squadRoot: string, prefix: string, ext: string = '.tmp', content?: string): string { + // Sanitize prefix/ext to prevent path traversal via '../' sequences + const safePrefix = prefix.replace(/[\/\\]/g, '_'); + const safeExt = ext.replace(/[\/\\]/g, '_'); + + const dir = scratchDir(squadRoot); + + // Monotonic counter: if two calls happen in the same millisecond, + // the counter increments to guarantee unique filenames. + const now = Date.now(); + if (now === _scratchLastTs) { + _scratchCounter++; + } else { + _scratchCounter = 0; + _scratchLastTs = now; + } + + const filename = `${safePrefix}-${now}-${_scratchCounter}${safeExt}`; + const filePath = path.join(dir, filename); + if (content !== undefined) { + storage.writeSync(filePath, content); + } + return filePath; +} diff --git a/test/cli/watch-execute.test.ts b/test/cli/watch-execute.test.ts index a9c794365..7ba5e1ae3 100644 --- a/test/cli/watch-execute.test.ts +++ b/test/cli/watch-execute.test.ts @@ -8,6 +8,8 @@ import { describe, it, expect, vi } from 'vitest'; import { buildAgentCommand, findExecutableIssues, reportBoard } from '../../packages/squad-cli/src/cli/commands/watch/index.js'; import type { WatchWorkItem } from '../../packages/squad-cli/src/cli/commands/watch/index.js'; +import { classifyIssue } from '../../packages/squad-cli/src/cli/commands/watch/capabilities/execute.js'; +import type { ExecutableWorkItem } from '../../packages/squad-cli/src/cli/commands/watch/capabilities/execute.js'; describe('CLI: watch execute mode', () => { describe('buildAgentCommand', () => { @@ -191,4 +193,92 @@ describe('CLI: watch execute mode', () => { consoleLogSpy.mockRestore(); }); }); + + describe('classifyIssue', () => { + it('classifies research titles as read', () => { + expect(classifyIssue('Research auth flow improvements')).toBe('read'); + expect(classifyIssue('Review PR comments on fleet dispatch')).toBe('read'); + expect(classifyIssue('Analyze memory usage patterns')).toBe('read'); + expect(classifyIssue('Investigate timeout errors in CI')).toBe('read'); + expect(classifyIssue('Audit security labels on open issues')).toBe('read'); + }); + + it('classifies implementation titles as write', () => { + expect(classifyIssue('Fix auth redirect bug')).toBe('write'); + expect(classifyIssue('Implement rate limiter capability')).toBe('write'); + expect(classifyIssue('Add unit tests for classifyIssue')).toBe('write'); + expect(classifyIssue('Update dependencies to latest')).toBe('write'); + expect(classifyIssue('Refactor watch loop into capabilities')).toBe('write'); + }); + + it('defaults to write when no keywords match', () => { + expect(classifyIssue('Random task')).toBe('write'); + expect(classifyIssue('Some squad work')).toBe('write'); + }); + + it('defaults to write when both read and write keywords appear', () => { + // "analyze and fix" has both review (read) and fix (write) keywords + expect(classifyIssue('Analyze and fix performance issue')).toBe('write'); + }); + + it('is case-insensitive', () => { + expect(classifyIssue('RESEARCH auth flow')).toBe('read'); + expect(classifyIssue('FIX the bug')).toBe('write'); + }); + }); + + describe('fleet/hybrid dispatch classification routing', () => { + const roster = [{ name: 'EECOM', label: 'squad:eecom', expertise: [] }]; + + const makeIssue = (number: number, title: string): ExecutableWorkItem => ({ + number, + title, + body: '', + labels: [{ name: 'squad:eecom' }], + assignees: [], + }); + + it('fleet mode: all executable issues are dispatched (not just read)', () => { + const issues = [ + makeIssue(1, 'Research auth flow'), // read + makeIssue(2, 'Fix auth redirect bug'), // write + makeIssue(3, 'Audit labels'), // read + ]; + + const executable = findExecutableIssues(roster, null, issues as WatchWorkItem[]); + // In fleet mode all executable issues are sent — simulate the fleet=all behavior + const fleetIssues = executable; // fleet: no filter + expect(fleetIssues).toHaveLength(3); + }); + + it('hybrid mode: only read-heavy issues are fleet-dispatched', () => { + const issues = [ + makeIssue(10, 'Research auth flow'), // read → fleet + makeIssue(11, 'Fix auth redirect bug'), // write → execute (not fleet) + makeIssue(12, 'Review PR comments'), // read → fleet + makeIssue(13, 'Implement rate limiter'), // write → execute + ]; + + const executable = findExecutableIssues(roster, null, issues as WatchWorkItem[]); + const fleetIssues = executable.filter(i => classifyIssue(i.title) === 'read'); + const executeIssues = executable.filter(i => classifyIssue(i.title) === 'write'); + + expect(fleetIssues).toHaveLength(2); + expect(fleetIssues.map(i => i.number)).toEqual([10, 12]); + expect(executeIssues).toHaveLength(2); + expect(executeIssues.map(i => i.number)).toEqual([11, 13]); + }); + + it('hybrid mode: assigned issues are excluded from both fleet and execute', () => { + const issues = [ + makeIssue(20, 'Research auth flow'), // read, unassigned → fleet + { ...makeIssue(21, 'Review PR'), assignees: [{ login: 'alice' }] }, // read, assigned → excluded + makeIssue(22, 'Fix bug'), // write, unassigned → execute + ]; + + const executable = findExecutableIssues(roster, null, issues as WatchWorkItem[]); + expect(executable).toHaveLength(2); + expect(executable.map(i => i.number)).toEqual([20, 22]); + }); + }); }); diff --git a/test/cli/watch-fleet-dispatch.test.ts b/test/cli/watch-fleet-dispatch.test.ts new file mode 100644 index 000000000..b0cb0c386 --- /dev/null +++ b/test/cli/watch-fleet-dispatch.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest'; +import { classifyIssue } from '../../packages/squad-cli/src/cli/commands/watch/index.js'; + +describe('classifyIssue - dispatch-mode categories', () => { + it('classifies read-heavy keywords correctly', () => { + const readTitles = [ + 'Research: evaluate new auth library', + 'Review PR #100 security changes', + 'Analyze CI pipeline performance', + 'Investigate flaky test failures', + 'Audit dependency licenses', + ]; + for (const title of readTitles) { + expect(classifyIssue(title)).toBe('read'); + } + }); + + it('classifies write-heavy keywords correctly', () => { + const writeTitles = [ + 'Fix memory leak in WebSocket handler', + 'Implement rate limiting middleware', + 'Add OpenAPI spec generation', + 'Build webhook retry mechanism', + 'Refactor auth module for clarity', + ]; + for (const title of writeTitles) { + expect(classifyIssue(title)).toBe('write'); + } + }); + + it('defaults to write when both read and write keywords present', () => { + expect(classifyIssue('Review and fix the auth module')).toBe('write'); + }); + + it('defaults to write for ambiguous titles with no keywords', () => { + expect(classifyIssue('Something about the codebase')).toBe('write'); + }); + + it('is case-insensitive', () => { + expect(classifyIssue('RESEARCH new approach')).toBe('read'); + expect(classifyIssue('FIX broken tests')).toBe('write'); + }); +}); \ No newline at end of file diff --git a/test/scratch-dir.test.ts b/test/scratch-dir.test.ts new file mode 100644 index 000000000..516584168 --- /dev/null +++ b/test/scratch-dir.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { scratchDir, scratchFile } from '@bradygaster/squad-sdk'; +import { mkdirSync, rmSync, existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; + +const TEST_ROOT = path.join(os.tmpdir(), `squad-scratch-test-${Date.now()}`); +const SQUAD_ROOT = path.join(TEST_ROOT, '.squad'); + +beforeEach(() => { + mkdirSync(SQUAD_ROOT, { recursive: true }); +}); + +afterEach(() => { + rmSync(TEST_ROOT, { recursive: true, force: true }); +}); + +describe('scratchDir', () => { + it('creates .scratch/ inside .squad/ when create=true (default)', () => { + const dir = scratchDir(SQUAD_ROOT); + expect(dir).toBe(path.join(SQUAD_ROOT, '.scratch')); + expect(existsSync(dir)).toBe(true); + }); + + it('returns path without creating when create=false', () => { + const dir = scratchDir(SQUAD_ROOT, false); + expect(dir).toBe(path.join(SQUAD_ROOT, '.scratch')); + expect(existsSync(dir)).toBe(false); + }); + + it('is idempotent — calling twice does not throw', () => { + scratchDir(SQUAD_ROOT); + scratchDir(SQUAD_ROOT); + expect(existsSync(path.join(SQUAD_ROOT, '.scratch'))).toBe(true); + }); +}); + +describe('scratchFile', () => { + it('creates a temp file with prefix and default .tmp extension', () => { + const filePath = scratchFile(SQUAD_ROOT, 'test-prompt'); + expect(filePath).toContain('.scratch'); + expect(filePath).toMatch(/test-prompt-\d+-\d+\.tmp$/); + // File exists only if content was provided — otherwise just returns path + expect(existsSync(path.dirname(filePath))).toBe(true); + }); + + it('creates a file with content when provided', () => { + const content = 'hello from scratch'; + const filePath = scratchFile(SQUAD_ROOT, 'msg', '.txt', content); + expect(existsSync(filePath)).toBe(true); + expect(readFileSync(filePath, 'utf-8')).toBe(content); + }); + + it('uses custom extension', () => { + const filePath = scratchFile(SQUAD_ROOT, 'fleet', '.md'); + expect(filePath).toMatch(/fleet-\d+-\d+\.md$/); + }); + + it('generates unique filenames on successive calls', () => { + const a = scratchFile(SQUAD_ROOT, 'dup', '.txt', 'a'); + const b = scratchFile(SQUAD_ROOT, 'dup', '.txt', 'b'); + // No delay needed: the monotonic counter inside scratchFile guarantees + // unique filenames even when Date.now() returns the same millisecond value. + expect(a).not.toBe(b); + expect(existsSync(a)).toBe(true); + expect(existsSync(b)).toBe(true); + }); + + it('creates the .scratch/ directory if missing', () => { + expect(existsSync(path.join(SQUAD_ROOT, '.scratch'))).toBe(false); + scratchFile(SQUAD_ROOT, 'auto-create', '.txt', 'data'); + expect(existsSync(path.join(SQUAD_ROOT, '.scratch'))).toBe(true); + }); +}); diff --git a/test/watch-fleet-dispatch.test.ts b/test/watch-fleet-dispatch.test.ts new file mode 100644 index 000000000..a8b56c2a1 --- /dev/null +++ b/test/watch-fleet-dispatch.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest'; +import { classifyIssue } from '../packages/squad-cli/src/cli/commands/watch/capabilities/execute.js'; + +describe('classifyIssue', () => { + it('classifies research issues as read', () => { + expect(classifyIssue('Research: evaluate new auth library')).toBe('read'); + }); + + it('classifies review issues as read', () => { + expect(classifyIssue('Review PR #100 security changes')).toBe('read'); + }); + + it('classifies fix issues as write', () => { + expect(classifyIssue('Fix memory leak in WebSocket handler')).toBe('write'); + }); + + it('classifies implement issues as write', () => { + expect(classifyIssue('Implement rate limiting middleware')).toBe('write'); + }); + + it('classifies analyze issues as read', () => { + expect(classifyIssue('Analyze CI pipeline performance')).toBe('read'); + }); + + it('classifies add issues as write', () => { + expect(classifyIssue('Add OpenAPI spec generation')).toBe('write'); + }); + + it('classifies investigate issues as read', () => { + expect(classifyIssue('Investigate flaky test failures')).toBe('read'); + }); + + it('classifies build issues as write', () => { + expect(classifyIssue('Build webhook retry mechanism')).toBe('write'); + }); + + it('defaults to write for ambiguous titles', () => { + expect(classifyIssue('Something about the codebase')).toBe('write'); + }); + + it('defaults to write when both read and write keywords present', () => { + expect(classifyIssue('Review and fix the auth module')).toBe('write'); + }); + + it('is case-insensitive', () => { + expect(classifyIssue('RESEARCH new approach')).toBe('read'); + expect(classifyIssue('FIX broken tests')).toBe('write'); + }); +});