Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/watch-verbose-mode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bradygaster/squad-cli": minor
---

Add --verbose flag to squad watch for debugging empty boards and silent failures (#781)
21 changes: 21 additions & 0 deletions packages/squad-cli/src/cli-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,8 @@ async function main(): Promise<void> {

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]
Expand Down Expand Up @@ -425,11 +427,30 @@ async function main(): Promise<void> {
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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -184,9 +185,13 @@ export class ExecuteCapability implements WatchCapability {
}

async execute(context: WatchContext): Promise<CapabilityResult> {
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 => ({
Expand All @@ -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' };
}
Expand Down
6 changes: 5 additions & 1 deletion packages/squad-cli/src/cli/commands/watch/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export interface WatchConfig {
logFile?: string;
/** Per-capability config: `true` / `false` / object with sub-options. */
capabilities: Record<string, boolean | Record<string, unknown>>;
/** Enable verbose diagnostic output for debugging. */
verbose?: boolean;
}

const DEFAULTS: WatchConfig = {
Expand Down Expand Up @@ -78,6 +80,7 @@ export function loadWatchConfig(
...(fileConfig.capabilities ?? {}),
...(cliOverrides.capabilities ?? {}),
},
verbose: cliOverrides.verbose ?? fileConfig.verbose ?? false,
};

return merged;
Expand All @@ -93,6 +96,7 @@ function normalizeFileConfig(raw: Record<string, unknown>): Partial<WatchConfig>
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') {
Expand All @@ -103,7 +107,7 @@ function normalizeFileConfig(raw: Record<string, unknown>): Partial<WatchConfig>

// Everything else is a capability key
const caps: Record<string, boolean | Record<string, unknown>> = {};
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))) {
Expand Down
68 changes: 65 additions & 3 deletions packages/squad-cli/src/cli/commands/watch/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────

Expand All @@ -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 ───────────────────────────────────

Expand Down Expand Up @@ -194,13 +196,18 @@ type PRBoardState = Pick<BoardState, 'drafts' | 'needsReview' | 'changesRequeste
totalOpen: number;
};

async function checkPRs(roster: ReturnType<typeof parseRoster>, adapter: PlatformAdapter): Promise<PRBoardState> {
async function checkPRs(roster: ReturnType<typeof parseRoster>, adapter: PlatformAdapter, vlog?: VerboseLogger): Promise<PRBoardState> {
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 };
}
Expand Down Expand Up @@ -273,11 +280,19 @@ async function runCheck(
autoAssign: boolean,
capabilities: MachineCapabilities | null,
adapter: PlatformAdapter,
vlog?: VerboseLogger,
): Promise<BoardState> {
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);

Expand Down Expand Up @@ -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}`);
Expand Down Expand Up @@ -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,
});
Comment on lines +657 to +668

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The startup verbose table hard-codes verbose: true, which can be misleading if the logger enablement logic changes (and is redundant since the table only prints when verbose is enabled). Prefer using config.verbose (or omitting this field entirely) so the diagnostic output reflects the actual configuration source-of-truth.

Copilot uses AI. Check for mistakes.

// 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');
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -752,6 +795,7 @@ export async function runWatch(dest: string, options: WatchOptions | WatchConfig

async function executeRound(): Promise<void> {
const ts = new Date().toLocaleTimeString();
const roundStart = Date.now();

// Circuit breaker gate
if (cbState.status === 'open') {
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions packages/squad-cli/src/cli/commands/watch/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
26 changes: 26 additions & 0 deletions packages/squad-cli/src/cli/commands/watch/verbose.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => {
if (enabled) {
for (const [k, v] of Object.entries(data)) {
console.log(`[verbose] ${k}: ${v}`);
}
}
},
};
}

export type VerboseLogger = ReturnType<typeof createVerboseLogger>;
Loading