diff --git a/.changeset/watch-verbose-mode.md b/.changeset/watch-verbose-mode.md new file mode 100644 index 000000000..3dc92ae8d --- /dev/null +++ b/.changeset/watch-verbose-mode.md @@ -0,0 +1,5 @@ +--- +"@bradygaster/squad-cli": minor +--- + +Add --verbose flag to squad watch for debugging empty boards and silent failures (#781) diff --git a/packages/squad-cli/src/cli-entry.ts b/packages/squad-cli/src/cli-entry.ts index 0e97df19b..25ddd4d67 100644 --- a/packages/squad-cli/src/cli-entry.ts +++ b/packages/squad-cli/src/cli-entry.ts @@ -363,6 +363,8 @@ async function main(): Promise { const execute = args.includes('--execute') ? true : undefined; + const verbose = args.includes('--verbose') || args.includes('-v'); + const copilotFlagsIdx = args.indexOf('--copilot-flags'); const copilotFlags = (copilotFlagsIdx !== -1 && args[copilotFlagsIdx + 1]) ? args[copilotFlagsIdx + 1] @@ -425,11 +427,30 @@ async function main(): Promise { timeout, copilotFlags, agentCmd, + verbose, dispatchMode, logFile, capabilities: Object.keys(capabilities).length > 0 ? capabilities : undefined, }); + // After parsing all flags, check for positional args that look like prompts. + // Skip values that follow known value-flags (e.g. "--interval 5" → "5" is not positional). + const knownValueFlags = new Set([ + '--interval', '--copilot-flags', '--agent-cmd', '--max-concurrent', '--timeout', '--board-project', + ]); + const watchArgStart = args.indexOf(cmd) + 1; + const watchArgs = args.slice(watchArgStart); + const positionalArgs: string[] = []; + for (let i = 0; i < watchArgs.length; i++) { + const arg = watchArgs[i]!; + if (knownValueFlags.has(arg)) { i++; continue; } + if (arg.startsWith('-')) continue; + positionalArgs.push(arg); + } + if (positionalArgs.length > 0 && config.verbose) { + console.log(`[verbose] ⚠️ Positional args ignored by watch: "${positionalArgs.join(' ')}". Use --execute to process issues.`); + } + await runWatch(process.cwd(), config); return; } 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 7bd09c982..8954c6480 100644 --- a/packages/squad-cli/src/cli/commands/watch/capabilities/execute.ts +++ b/packages/squad-cli/src/cli/commands/watch/capabilities/execute.ts @@ -7,6 +7,7 @@ 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'; +import { createVerboseLogger } from '../verbose.js'; /** Normalized work item for execution. */ export interface ExecutableWorkItem { @@ -184,9 +185,13 @@ export class ExecuteCapability implements WatchCapability { } async execute(context: WatchContext): Promise { + const vlog = createVerboseLogger(context.verbose ?? false); + try { const timeout = ((context.config['timeout'] as number) ?? 30) * 60_000; + vlog.log(`Execute: agentCmd=${context.agentCmd ?? 'gh copilot'}, timeout=${timeout / 60_000}m`); + // Fetch open issues with squad label const sdkItems = await context.adapter.listWorkItems({ tags: ['squad'], state: 'open', limit: 50 }); const issues: ExecutableWorkItem[] = sdkItems.map(wi => ({ @@ -199,6 +204,12 @@ export class ExecuteCapability implements WatchCapability { // Minimal filter: must have squad or squad:* label (agent decides the rest) const eligible = findExecutableIssues(context.roster, null, issues); + vlog.log(`Execute: ${issues.length} total issues, ${eligible.length} eligible`); + for (const issue of eligible.slice(0, 5)) { + const labels = issue.labels.map(l => l.name).join(', '); + vlog.log(` → #${issue.number}: "${issue.title}" [${labels}]`); + } + if (eligible.length === 0) { return { success: true, summary: 'no squad-labeled issues found' }; } diff --git a/packages/squad-cli/src/cli/commands/watch/config.ts b/packages/squad-cli/src/cli/commands/watch/config.ts index 7f07d57bd..27cb49c16 100644 --- a/packages/squad-cli/src/cli/commands/watch/config.ts +++ b/packages/squad-cli/src/cli/commands/watch/config.ts @@ -27,6 +27,8 @@ export interface WatchConfig { logFile?: string; /** Per-capability config: `true` / `false` / object with sub-options. */ capabilities: Record>; + /** Enable verbose diagnostic output for debugging. */ + verbose?: boolean; } const DEFAULTS: WatchConfig = { @@ -78,6 +80,7 @@ export function loadWatchConfig( ...(fileConfig.capabilities ?? {}), ...(cliOverrides.capabilities ?? {}), }, + verbose: cliOverrides.verbose ?? fileConfig.verbose ?? false, }; return merged; @@ -93,6 +96,7 @@ 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['verbose'] === 'boolean') result.verbose = raw['verbose']; if (typeof raw['dispatchMode'] === 'string') { const mode = raw['dispatchMode'] as string; if (mode === 'fleet' || mode === 'task' || mode === 'hybrid') { @@ -103,7 +107,7 @@ function normalizeFileConfig(raw: Record): Partial // Everything else is a capability key const caps: Record> = {}; - const reserved = new Set(['interval', 'execute', 'maxConcurrent', 'timeout', 'copilotFlags', 'agentCmd', 'dispatchMode', 'logFile']); + const reserved = new Set(['interval', 'execute', 'maxConcurrent', 'timeout', 'copilotFlags', 'agentCmd', 'verbose', '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 9573ab2d0..9c18e2125 100644 --- a/packages/squad-cli/src/cli/commands/watch/index.ts +++ b/packages/squad-cli/src/cli/commands/watch/index.ts @@ -40,6 +40,7 @@ import type { WatchConfig } from './config.js'; import type { WatchCapability, WatchContext, WatchPhase, CapabilityResult } from './types.js'; import { CapabilityRegistry } from './registry.js'; import { createDefaultRegistry } from './capabilities/index.js'; +import { createVerboseLogger, type VerboseLogger } from './verbose.js'; // ── Re-exports for backward compatibility ──────────────────────── @@ -48,6 +49,7 @@ export { loadWatchConfig } from './config.js'; export type { WatchCapability, WatchContext, WatchPhase, PreflightResult, CapabilityResult } from './types.js'; export { CapabilityRegistry } from './registry.js'; export { createDefaultRegistry } from './capabilities/index.js'; +export { createVerboseLogger, type VerboseLogger } from './verbose.js'; // ── Watch Platform Abstraction ─────────────────────────────────── @@ -194,13 +196,18 @@ type PRBoardState = Pick, adapter: PlatformAdapter): Promise { +async function checkPRs(roster: ReturnType, adapter: PlatformAdapter, vlog?: VerboseLogger): Promise { const timestamp = new Date().toLocaleTimeString(); const prs = await listWatchPullRequests(adapter, { state: 'open', limit: 20 }); const squadPRs = prs.filter(pr => pr.labels.some(l => l.name.startsWith('squad')) || pr.headRefName.startsWith('squad/'), ); + vlog?.log(`PRs found: ${prs.length} total`); + for (const pr of squadPRs) { + vlog?.log(` PR #${pr.number}: "${pr.title}" draft=${pr.isDraft} review=${pr.reviewDecision ?? 'none'}`); + } + if (squadPRs.length === 0) { return { drafts: 0, needsReview: 0, changesRequested: 0, ciFailures: 0, readyToMerge: 0, totalOpen: 0 }; } @@ -273,11 +280,19 @@ async function runCheck( autoAssign: boolean, capabilities: MachineCapabilities | null, adapter: PlatformAdapter, + vlog?: VerboseLogger, ): Promise { const timestamp = new Date().toLocaleTimeString(); try { const issues = await listWatchWorkItems(adapter, { label: 'squad', state: 'open', limit: 20 }); + vlog?.log(`Issues found: ${issues.length} total`); + for (const issue of issues) { + const labels = issue.labels?.map((l: { name: string }) => l.name).join(', ') ?? 'none'; + const assignees = issue.assignees?.map((a: { login: string }) => a.login).join(', ') ?? 'none'; + vlog?.log(` #${issue.number}: "${issue.title}" [${labels}] assignees=[${assignees}]`); + } + const { filterByCapabilities } = await import('@bradygaster/squad-sdk/ralph/capabilities'); const { handled: capableIssues, skipped: incapableIssues } = filterByCapabilities(issues, capabilities); @@ -330,7 +345,7 @@ async function runCheck( } } - const prState = await checkPRs(roster, adapter); + const prState = await checkPRs(roster, adapter, vlog); return { untriaged: untriaged.length, assigned: assignedIssues.length, executed: 0, ...prState }; } catch (e) { console.error(`${RED}✗${RESET} [${timestamp}] Check failed: ${(e as Error).message}`); @@ -636,6 +651,33 @@ export async function runWatch(dest: string, options: WatchOptions | WatchConfig return fatal(`Could not detect platform: ${(err as Error).message}`); } + // Verbose logger + const vlog = createVerboseLogger(config.verbose ?? false); + + vlog.section('Startup'); + vlog.table({ + repo: teamRoot, + platform: adapter.type, + // This table only prints when verbose mode is active, so verbose is always true here. + verbose: config.verbose ?? false, + interval: `${config.interval}m`, + execute: config.execute ?? false, + agentCmd: config.agentCmd ?? '(default: gh copilot)', + dispatchMode: config.capabilities['wave-dispatch'] ? 'wave' : 'task', + maxConcurrent: config.maxConcurrent ?? 1, + }); + + // Auth check (verbose only — avoid unnecessary process spawn on every startup) + if (config.verbose && adapter.type === 'github') { + try { + const authOut = execFileSync('gh', ['auth', 'status'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); + const activeAccount = authOut.match(/Logged in to .* account (\S+)/)?.[1]; + vlog.log(`Auth: ${activeAccount ?? 'unknown'}`); + } catch { + vlog.warn('gh auth status failed — API calls may fail silently'); + } + } + // Verify platform CLI availability if (adapter.type === 'github') { if (!(await ghAvailable())) fatal('gh CLI not found — install from https://cli.github.com'); @@ -702,6 +744,7 @@ export async function runWatch(dest: string, options: WatchOptions | WatchConfig config: {}, agentCmd: config.agentCmd, copilotFlags: config.copilotFlags, + verbose: config.verbose, }; const enabledCapabilities = await preflightCapabilities(registry, config, baseContext); @@ -752,6 +795,7 @@ export async function runWatch(dest: string, options: WatchOptions | WatchConfig async function executeRound(): Promise { const ts = new Date().toLocaleTimeString(); + const roundStart = Date.now(); // Circuit breaker gate if (cbState.status === 'open') { @@ -797,6 +841,7 @@ export async function runWatch(dest: string, options: WatchOptions | WatchConfig // Fix 1: Print round start marker console.log(`\n${DIM}Starting round ${round}...${RESET}`); + vlog.section(`Round ${round}`); // Phase 1: pre-scan (self-pull, subsquad discovery) await runPhase('pre-scan', enabledCapabilities, roundContext, config); @@ -808,7 +853,7 @@ export async function runWatch(dest: string, options: WatchOptions | WatchConfig } // Core: triage (always runs — not a capability) - const roundState = await runCheck(rules, modules, roster, hasCopilot, autoAssign, capabilities, adapter); + const roundState = await runCheck(rules, modules, roster, hasCopilot, autoAssign, capabilities, adapter, vlog); // Phase 2: post-triage (two-pass hydration) await runPhase('post-triage', enabledCapabilities, roundContext, config); @@ -832,6 +877,19 @@ export async function runWatch(dest: string, options: WatchOptions | WatchConfig timestamp: new Date(), }); await monitor.healthCheck(); + + // Verbose board counters + vlog.table({ + untriaged: roundState.untriaged, + assigned: roundState.assigned, + drafts: roundState.drafts, + needsReview: roundState.needsReview, + changesRequested: roundState.changesRequested, + ciFailures: roundState.ciFailures, + readyToMerge: roundState.readyToMerge, + executed: roundState.executed, + }); + reportBoard(roundState, round); // Fix 1: Print next poll time @@ -852,6 +910,10 @@ export async function runWatch(dest: string, options: WatchOptions | WatchConfig cbState.consecutiveFailures = 0; } saveCBState(squadDirInfo.path, cbState); + + const roundEnd = Date.now(); + const elapsed = ((roundEnd - roundStart) / 1000).toFixed(1); + vlog.log(`Round ${round} complete (${elapsed}s)`); } // Run immediately, then on interval diff --git a/packages/squad-cli/src/cli/commands/watch/types.ts b/packages/squad-cli/src/cli/commands/watch/types.ts index f20b06206..b41e76bc2 100644 --- a/packages/squad-cli/src/cli/commands/watch/types.ts +++ b/packages/squad-cli/src/cli/commands/watch/types.ts @@ -37,6 +37,8 @@ export interface WatchContext { /** Hidden --agent-cmd override. */ agentCmd?: string; copilotFlags?: string; + /** Verbose diagnostic output enabled. */ + verbose?: boolean; } /** Contract that every watch capability must implement. */ diff --git a/packages/squad-cli/src/cli/commands/watch/verbose.ts b/packages/squad-cli/src/cli/commands/watch/verbose.ts new file mode 100644 index 000000000..02fce06ea --- /dev/null +++ b/packages/squad-cli/src/cli/commands/watch/verbose.ts @@ -0,0 +1,26 @@ +/** + * Verbose logger for squad watch debugging. + * Only outputs when --verbose is enabled. + */ +export function createVerboseLogger(enabled: boolean) { + return { + log: (...args: unknown[]) => { + if (enabled) console.log('[verbose]', ...args); + }, + warn: (...args: unknown[]) => { + if (enabled) console.log('[verbose] ⚠️', ...args); + }, + section: (title: string) => { + if (enabled) console.log(`[verbose] ── ${title} ──`); + }, + table: (data: Record) => { + if (enabled) { + for (const [k, v] of Object.entries(data)) { + console.log(`[verbose] ${k}: ${v}`); + } + } + }, + }; +} + +export type VerboseLogger = ReturnType;