Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fleet-dispatch-hybrid.md
Original file line number Diff line number Diff line change
@@ -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
11 changes: 11 additions & 0 deletions .changeset/scratch-dir-utility.md
Original file line number Diff line number Diff line change
@@ -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
28 changes: 27 additions & 1 deletion packages/squad-cli/src/cli-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,20 +304,33 @@ async function main(): Promise<void> {
}

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,
Expand Down Expand Up @@ -370,6 +383,18 @@ async function main(): Promise<void> {
? 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;
Comment on lines +387 to +390

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.

--dispatch-mode is cast to the union type without runtime validation. If a user passes an invalid value, it will silently flow into config and later behave like task mode (because it won’t match fleet|hybrid), which is hard to diagnose. Consider validating against {task,fleet,hybrid} and either erroring or warning + defaulting to task (and, if fleet|hybrid, auto-enable fleet-dispatch to match the documented usage).

Copilot uses AI. Check for mistakes.
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]
Expand Down Expand Up @@ -400,6 +425,7 @@ async function main(): Promise<void> {
timeout,
copilotFlags,
agentCmd,
dispatchMode,
logFile,
capabilities: Object.keys(capabilities).length > 0 ? capabilities : undefined,
});
Expand Down
173 changes: 118 additions & 55 deletions packages/squad-cli/src/cli/commands/watch/capabilities/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -15,18 +17,32 @@ export interface ExecutableWorkItem {
assignees: Array<{ login: string }>;
}

/** Labels that block autonomous execution. */
const BLOCKED_LABELS: ReadonlySet<string> = 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)
}
Comment on lines +25 to +45

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.

New dispatch-mode behavior (classifyIssue + fleet/hybrid routing) isn’t covered by the existing watch execute tests. Adding focused unit tests for classifyIssue() and for the fleet/hybrid branching (e.g., fleet defers all, hybrid executes only write issues up to maxConcurrent) would help prevent regressions.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in commit e07e8f9. Added unit tests in \ est/cli/watch-execute.test.ts\ covering:

  • \classifyIssue(): read keywords (research, review, analyze, investigate, audit), write keywords (fix, implement, add, update, refactor), default-to-write, case-insensitive, mixed-keyword tie-breaking
  • Fleet mode: all executable issues dispatched
  • Hybrid mode: only read-heavy issues fleet-dispatched, write issues execute
  • Hybrid mode: assigned issues excluded from both paths

All 16 watch-execute tests pass.


/** Build agent command for a prompt. */
function buildAgentCommand(
Expand All @@ -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) => {
Expand Down Expand Up @@ -115,7 +185,6 @@ export class ExecuteCapability implements WatchCapability {

async execute(context: WatchContext): Promise<CapabilityResult> {
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
Expand All @@ -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}` };
}
}
}
}
Loading
Loading