From 13ec1772df925d5eeb170225da14967b85080472 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 03:02:09 -0700 Subject: [PATCH 1/5] feat(teams): add browser PKCE auth, default to Graph PowerShell client ID - Add authorization code flow with PKCE as primary auth method (local HTTP server, browser redirect, 120s timeout) - Keep device code flow as fallback for headless/SSH environments - Default client ID to Microsoft Graph PowerShell (14d82eec...) - Default tenant to 'organizations' (works for any Entra org) - Make tenantId and clientId optional in config interfaces - Relax factory validation: Teams works with zero config - Zero external dependencies (node:http, node:crypto, node:child_process) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../squad-sdk/src/platform/comms-teams.ts | 550 ++++++++++++++++++ packages/squad-sdk/src/platform/comms.ts | 34 +- packages/squad-sdk/src/platform/types.ts | 9 + 3 files changed, 587 insertions(+), 6 deletions(-) create mode 100644 packages/squad-sdk/src/platform/comms-teams.ts diff --git a/packages/squad-sdk/src/platform/comms-teams.ts b/packages/squad-sdk/src/platform/comms-teams.ts new file mode 100644 index 000000000..fb531d401 --- /dev/null +++ b/packages/squad-sdk/src/platform/comms-teams.ts @@ -0,0 +1,550 @@ +/** + * Microsoft Teams communication adapter — bidirectional chat via Graph API. + * + * Auth priority: cached token → refresh → browser PKCE → device code fallback. + * Uses the Microsoft Graph PowerShell first-party client ID by default (works + * in every Microsoft tenant with no custom Entra registration required). + * Tokens stored in ~/.squad/teams-tokens.json. + * + * @module platform/comms-teams + */ + +import { join } from 'node:path'; +import { homedir, platform } from 'node:os'; +import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { createServer } from 'node:http'; +import { randomBytes, createHash } from 'node:crypto'; +import { exec } from 'node:child_process'; +import type { CommunicationAdapter, CommunicationChannel, CommunicationReply } from './types.js'; + +// ─── Defaults ──────────────────────────────────────────────────────── + +/** Microsoft Graph PowerShell — first-party, present in every tenant */ +const DEFAULT_CLIENT_ID = '14d82eec-204b-4c2f-b7e8-296a70dab67e'; +/** Multi-tenant "organizations" endpoint — works for any Entra org */ +const DEFAULT_TENANT_ID = 'organizations'; + +// ─── Config ────────────────────────────────────────────────────────── + +export interface TeamsCommsConfig { + tenantId?: string; + clientId?: string; + /** User to message — UPN like "bradyg@microsoft.com" or "me" for self-chat */ + recipientUpn?: string; + /** Existing chat ID (skip chat creation if known) */ + chatId?: string; + /** Teams channel ID (alternative to 1:1 chat) */ + channelId?: string; + teamId?: string; +} + +// ─── Token Storage ─────────────────────────────────────────────────── + +interface StoredTokens { + accessToken: string; + refreshToken: string; + expiresAt: number; // epoch ms +} + +const SQUAD_DIR = join(homedir(), '.squad'); +const TOKEN_PATH = join(SQUAD_DIR, 'teams-tokens.json'); + +function loadTokens(): StoredTokens | null { + try { + if (!existsSync(TOKEN_PATH)) return null; + const raw = readFileSync(TOKEN_PATH, 'utf-8'); + return JSON.parse(raw) as StoredTokens; + } catch { + return null; + } +} + +function saveTokens(tokens: StoredTokens): void { + if (!existsSync(SQUAD_DIR)) { + mkdirSync(SQUAD_DIR, { recursive: true }); + } + writeFileSync(TOKEN_PATH, JSON.stringify(tokens, null, 2), 'utf-8'); +} + +// ─── Graph Helpers ─────────────────────────────────────────────────── + +const GRAPH_BASE = 'https://graph.microsoft.com/v1.0'; +const SCOPES = 'Chat.ReadWrite ChatMessage.Send ChatMessage.Read User.Read offline_access'; + +async function graphFetch( + url: string, + accessToken: string, + options: { method?: string; body?: unknown } = {}, +): Promise { + const res = await fetch(url, { + method: options.method ?? 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: options.body ? JSON.stringify(options.body) : undefined, + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`Graph API ${res.status}: ${res.statusText} — ${text}`); + } + const contentType = res.headers.get('content-type') ?? ''; + if (contentType.includes('application/json')) { + return res.json(); + } + return undefined; +} + +// ─── Auth Flows ────────────────────────────────────────────────────── + +interface TokenResponse { + access_token: string; + refresh_token: string; + expires_in: number; + error?: string; + error_description?: string; +} + +function parseTokens(data: TokenResponse): StoredTokens { + return { + accessToken: data.access_token, + refreshToken: data.refresh_token, + expiresAt: Date.now() + data.expires_in * 1000, + }; +} + +// ─── Browser Auth (Authorization Code + PKCE) ──────────────────────── + +const SUCCESS_HTML = ` +Squad — Authenticated + +

✅ Authentication successful!

You can close this tab and return to the terminal.

`; + +/** Open a URL in the user's default browser. */ +function openBrowser(url: string): void { + const cmd = + platform() === 'win32' ? `start "" "${url}"` + : platform() === 'darwin' ? `open "${url}"` + : `xdg-open "${url}"`; + exec(cmd, () => { /* fire-and-forget */ }); +} + +/** Base64-URL encode (no padding). */ +function base64url(buf: Buffer): string { + return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +async function startBrowserAuthFlow(tenantId: string, clientId: string): Promise { + const codeVerifier = base64url(randomBytes(32)); + const codeChallenge = base64url(createHash('sha256').update(codeVerifier).digest()); + + return new Promise((resolve, reject) => { + const server = createServer((req, res) => { + const reqUrl = new URL(req.url ?? '/', `http://localhost`); + const code = reqUrl.searchParams.get('code'); + const error = reqUrl.searchParams.get('error'); + + if (error) { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(`

Authentication failed

${error}

`); + cleanup(); + reject(new Error(`Browser auth denied: ${error}`)); + return; + } + + if (!code) { + res.writeHead(400, { 'Content-Type': 'text/plain' }); + res.end('Missing authorization code'); + return; + } + + // Exchange code for tokens + const redirectUri = `http://localhost:${(server.address() as { port: number }).port}`; + const tokenUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`; + + fetch(tokenUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + client_id: clientId, + code, + redirect_uri: redirectUri, + code_verifier: codeVerifier, + scope: SCOPES, + }), + }) + .then(async (tokenRes) => { + const data = (await tokenRes.json()) as TokenResponse; + if (!data.access_token) { + throw new Error(`Token exchange failed: ${data.error} — ${data.error_description}`); + } + const tokens = parseTokens(data); + saveTokens(tokens); + + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(SUCCESS_HTML); + cleanup(); + resolve(tokens); + }) + .catch((err) => { + res.writeHead(500, { 'Content-Type': 'text/plain' }); + res.end('Token exchange failed'); + cleanup(); + reject(err); + }); + }); + + // Timeout — 120 seconds to complete browser auth + const timer = setTimeout(() => { + cleanup(); + reject(new Error('Browser auth timed out after 120 seconds')); + }, 120_000); + + function cleanup() { + clearTimeout(timer); + server.close(); + } + + // Bind to a random available port + server.listen(0, '127.0.0.1', () => { + const port = (server.address() as { port: number }).port; + const redirectUri = `http://localhost:${port}`; + const authorizeUrl = + `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize` + + `?client_id=${encodeURIComponent(clientId)}` + + `&response_type=code` + + `&redirect_uri=${encodeURIComponent(redirectUri)}` + + `&scope=${encodeURIComponent(SCOPES)}` + + `&code_challenge=${encodeURIComponent(codeChallenge)}` + + `&code_challenge_method=S256` + + `&prompt=select_account`; + + console.log('🌐 Opening browser for Teams authentication...'); + openBrowser(authorizeUrl); + }); + }); +} + +// ─── Device Code Flow (fallback) ───────────────────────────────────── + +interface DeviceCodeResponse { + device_code: string; + user_code: string; + verification_uri: string; + expires_in: number; + interval: number; + message: string; +} + +async function startDeviceCodeFlow(tenantId: string, clientId: string): Promise { + const deviceCodeUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/devicecode`; + const tokenUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`; + + const dcRes = await fetch(deviceCodeUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: clientId, + scope: SCOPES, + }), + }); + if (!dcRes.ok) { + throw new Error(`Device code request failed: ${dcRes.status} ${await dcRes.text()}`); + } + const dcData = (await dcRes.json()) as DeviceCodeResponse; + + console.log(`\n🔐 Teams authentication required`); + console.log(` ${dcData.message}\n`); + + const pollInterval = (dcData.interval || 5) * 1000; + const deadline = Date.now() + dcData.expires_in * 1000; + + while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, pollInterval)); + + const tokenRes = await fetch(tokenUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + client_id: clientId, + device_code: dcData.device_code, + }), + }); + + const tokenData = (await tokenRes.json()) as TokenResponse; + + if (tokenData.access_token) { + const tokens = parseTokens(tokenData); + saveTokens(tokens); + console.log(`✅ Teams authentication successful — tokens saved\n`); + return tokens; + } + + if (tokenData.error === 'authorization_pending') continue; + + if (tokenData.error === 'slow_down') { + await new Promise((r) => setTimeout(r, 5000)); + continue; + } + + throw new Error(`Device code auth failed: ${tokenData.error} — ${tokenData.error_description}`); + } + + throw new Error('Device code flow timed out — user did not authenticate in time'); +} + +// ─── Token Refresh ─────────────────────────────────────────────────── + +async function refreshAccessToken( + tenantId: string, + clientId: string, + refreshToken: string, +): Promise { + const tokenUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`; + + const res = await fetch(tokenUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + client_id: clientId, + refresh_token: refreshToken, + scope: SCOPES, + }), + }); + + const data = (await res.json()) as TokenResponse; + if (!data.access_token) { + throw new Error(`Token refresh failed: ${data.error} — ${data.error_description}`); + } + + const tokens: StoredTokens = { + accessToken: data.access_token, + refreshToken: data.refresh_token ?? refreshToken, + expiresAt: Date.now() + data.expires_in * 1000, + }; + saveTokens(tokens); + return tokens; +} + +// ─── Adapter ───────────────────────────────────────────────────────── + +export class TeamsCommunicationAdapter implements CommunicationAdapter { + readonly channel: CommunicationChannel = 'teams-webhook'; + + private tokens: StoredTokens | null = null; + private resolvedChatId: string | null; + private readonly clientId: string; + private readonly tenantId: string; + + constructor(private readonly config: TeamsCommsConfig) { + this.resolvedChatId = config.chatId ?? null; + this.clientId = config.clientId ?? DEFAULT_CLIENT_ID; + this.tenantId = config.tenantId ?? DEFAULT_TENANT_ID; + } + + /** + * Ensure we have a valid access token. + * Priority: cached → refresh → browser PKCE → device code fallback. + */ + private async ensureAuthenticated(): Promise { + if (!this.tokens) { + this.tokens = loadTokens(); + } + + // Valid token — return it + if (this.tokens && Date.now() < this.tokens.expiresAt - 60_000) { + return this.tokens.accessToken; + } + + // Expired but have refresh token — try refresh + if (this.tokens?.refreshToken) { + try { + this.tokens = await refreshAccessToken( + this.tenantId, + this.clientId, + this.tokens.refreshToken, + ); + return this.tokens.accessToken; + } catch { + console.warn('⚠️ Token refresh failed — re-authenticating...'); + } + } + + // Try browser auth code flow with PKCE first + try { + this.tokens = await startBrowserAuthFlow(this.tenantId, this.clientId); + console.log('✅ Teams authentication successful — tokens saved'); + return this.tokens.accessToken; + } catch { + console.log('Browser auth unavailable, falling back to device code...'); + } + + // Fallback — device code flow (works in headless/SSH environments) + this.tokens = await startDeviceCodeFlow(this.tenantId, this.clientId); + return this.tokens.accessToken; + } + + /** + * Find or create a 1:1 chat with the recipient. + */ + private async ensureChat(accessToken: string): Promise { + if (this.resolvedChatId) return this.resolvedChatId; + + const upn = this.config.recipientUpn; + + // "me" mode — find or create a self-chat + if (!upn || upn === 'me') { + const chatsRes = (await graphFetch( + `${GRAPH_BASE}/me/chats?$filter=chatType eq 'oneOnOne'&$top=10`, + accessToken, + )) as { value: Array<{ id: string }> }; + + if (chatsRes.value.length > 0) { + this.resolvedChatId = chatsRes.value[0]!.id; + return this.resolvedChatId; + } + + const meRes = (await graphFetch(`${GRAPH_BASE}/me`, accessToken)) as { id: string }; + const chatRes = (await graphFetch(`${GRAPH_BASE}/chats`, accessToken, { + method: 'POST', + body: { + chatType: 'oneOnOne', + members: [ + { + '@odata.type': '#microsoft.graph.aadUserConversationMember', + roles: ['owner'], + 'user@odata.bind': `https://graph.microsoft.com/v1.0/users('${meRes.id}')`, + }, + ], + }, + })) as { id: string }; + this.resolvedChatId = chatRes.id; + return this.resolvedChatId; + } + + // Create 1:1 chat with recipient UPN + const chatRes = (await graphFetch(`${GRAPH_BASE}/chats`, accessToken, { + method: 'POST', + body: { + chatType: 'oneOnOne', + members: [ + { + '@odata.type': '#microsoft.graph.aadUserConversationMember', + roles: ['owner'], + 'user@odata.bind': `https://graph.microsoft.com/v1.0/users('${upn}')`, + }, + ], + }, + })) as { id: string }; + this.resolvedChatId = chatRes.id; + return this.resolvedChatId; + } + + async postUpdate(options: { + title: string; + body: string; + category?: string; + author?: string; + }): Promise<{ id: string; url?: string }> { + const accessToken = await this.ensureAuthenticated(); + + // Use channel message if teamId + channelId are configured + if (this.config.teamId && this.config.channelId) { + const url = `${GRAPH_BASE}/teams/${this.config.teamId}/channels/${this.config.channelId}/messages`; + const msg = (await graphFetch(url, accessToken, { + method: 'POST', + body: { + body: { + contentType: 'html', + content: formatTeamsMessage(options.title, options.body, options.author), + }, + }, + })) as { id: string }; + + return { + id: msg.id, + url: `https://teams.microsoft.com/l/channel/${this.config.channelId}`, + }; + } + + // 1:1 chat mode + const chatId = await this.ensureChat(accessToken); + const url = `${GRAPH_BASE}/chats/${chatId}/messages`; + const msg = (await graphFetch(url, accessToken, { + method: 'POST', + body: { + body: { + contentType: 'html', + content: formatTeamsMessage(options.title, options.body, options.author), + }, + }, + })) as { id: string }; + + return { + id: msg.id, + url: this.getNotificationUrl(chatId), + }; + } + + async pollForReplies(options: { + threadId: string; + since: Date; + }): Promise { + const accessToken = await this.ensureAuthenticated(); + const chatId = this.resolvedChatId ?? options.threadId; + + const sinceIso = options.since.toISOString(); + const url = `${GRAPH_BASE}/chats/${chatId}/messages?$filter=createdDateTime gt ${sinceIso}&$top=50&$orderby=createdDateTime asc`; + + let data: { value: Array<{ id: string; body: { content: string }; from: { user: { displayName: string; id: string } } | null; createdDateTime: string }> }; + try { + data = (await graphFetch(url, accessToken)) as typeof data; + } catch { + return []; + } + + let myId: string | null = null; + try { + const me = (await graphFetch(`${GRAPH_BASE}/me`, accessToken)) as { id: string }; + myId = me.id; + } catch { /* ignore */ } + + return data.value + .filter((m) => { + if (!m.from?.user) return false; + if (myId && m.from.user.id === myId) return false; + return true; + }) + .map((m) => ({ + author: m.from?.user?.displayName ?? 'unknown', + body: stripHtml(m.body.content), + timestamp: new Date(m.createdDateTime), + id: m.id, + })); + } + + getNotificationUrl(threadId: string): string | undefined { + const chatId = this.resolvedChatId ?? threadId; + return `https://teams.microsoft.com/l/chat/${encodeURIComponent(chatId)}`; + } +} + +// ─── Formatting ────────────────────────────────────────────────────── + +function formatTeamsMessage(title: string, body: string, author?: string): string { + const authorLine = author ? `Posted by ${escapeHtml(author)}
` : ''; + return `${escapeHtml(title)}
${authorLine}
${escapeHtml(body).replace(/\n/g, '
')}`; +} + +function escapeHtml(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +function stripHtml(html: string): string { + return html.replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').trim(); +} diff --git a/packages/squad-sdk/src/platform/comms.ts b/packages/squad-sdk/src/platform/comms.ts index 9f503b2bb..204fd3060 100644 --- a/packages/squad-sdk/src/platform/comms.ts +++ b/packages/squad-sdk/src/platform/comms.ts @@ -13,6 +13,7 @@ import type { CommunicationAdapter, CommunicationChannel, CommunicationConfig } import { FileLogCommunicationAdapter } from './comms-file-log.js'; import { GitHubDiscussionsCommunicationAdapter } from './comms-github-discussions.js'; import { ADODiscussionCommunicationAdapter } from './comms-ado-discussions.js'; +import { TeamsCommunicationAdapter } from './comms-teams.js'; import { detectPlatform, getRemoteUrl, parseGitHubRemote, parseAzureDevOpsRemote } from './detect.js'; const storage = new FSStorageProvider(); @@ -46,7 +47,7 @@ export function createCommunicationAdapter(repoRoot: string): CommunicationAdapt // Explicit config wins if (config?.channel) { - return createAdapterByChannel(config.channel, repoRoot); + return createAdapterByChannel(config.channel, repoRoot, config); } // Auto-detect from platform @@ -84,7 +85,11 @@ export function createCommunicationAdapter(repoRoot: string): CommunicationAdapt return new FileLogCommunicationAdapter(repoRoot); } -function createAdapterByChannel(channel: CommunicationChannel, repoRoot: string): CommunicationAdapter { +function createAdapterByChannel( + channel: CommunicationChannel, + repoRoot: string, + config?: CommunicationConfig, +): CommunicationAdapter { const remoteUrl = getRemoteUrl(repoRoot); switch (channel) { @@ -100,13 +105,30 @@ function createAdapterByChannel(channel: CommunicationChannel, repoRoot: string) if (!info) throw new Error(`Cannot parse ADO remote: ${remoteUrl}`); return new ADODiscussionCommunicationAdapter(info.org, info.project); } - case 'teams-webhook': - // Teams webhook adapter would go here — for now fall back to file log - console.warn('Teams webhook adapter not yet implemented — using file log fallback'); - return new FileLogCommunicationAdapter(repoRoot); + case 'teams-webhook': { + const teamsConfig = config?.teams ?? readTeamsConfig(repoRoot) ?? {}; + return new TeamsCommunicationAdapter(teamsConfig); + } case 'file-log': return new FileLogCommunicationAdapter(repoRoot); default: return new FileLogCommunicationAdapter(repoRoot); } } + +/** + * Read Teams-specific config from `.squad/config.json`. + */ +function readTeamsConfig(repoRoot: string): CommunicationConfig['teams'] | undefined { + const configPath = join(repoRoot, '.squad', 'config.json'); + if (!storage.existsSync(configPath)) return undefined; + try { + const raw = storage.readSync(configPath) ?? ''; + const parsed = JSON.parse(raw) as Record; + const comms = parsed.communications as Record | undefined; + if (comms?.teams && typeof comms.teams === 'object') { + return comms.teams as CommunicationConfig['teams']; + } + } catch { /* ignore */ } + return undefined; +} diff --git a/packages/squad-sdk/src/platform/types.ts b/packages/squad-sdk/src/platform/types.ts index 1bc6ca639..2fc04a9a9 100644 --- a/packages/squad-sdk/src/platform/types.ts +++ b/packages/squad-sdk/src/platform/types.ts @@ -87,6 +87,15 @@ export interface CommunicationConfig { postDecisions?: boolean; /** Post escalations when agents are blocked */ postEscalations?: boolean; + /** Teams-specific configuration */ + teams?: { + tenantId?: string; + clientId?: string; + recipientUpn?: string; + chatId?: string; + channelId?: string; + teamId?: string; + }; } /** From 5bb6f434e6848de7a6ccb3418a69464246392764 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 15:32:53 -0700 Subject: [PATCH 2/5] Add documentation for squad loop command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create docs/src/content/docs/features/loop.md: comprehensive feature guide * Experimental warning banner * Try-this examples (basic, --init, with monitoring) * What loop does (prompt-driven work, no issues) * Prerequisites (gh CLI, Copilot access) * Getting started step-by-step guide * Frontmatter reference table * Writing good loop prompts with examples * Composing with capabilities (monitoring, self-pull) * Complete CLI reference * Configuration section - Update docs/src/content/docs/reference/cli.md * Update command count (16 → 17 commands) * Add loop commands to table in alphabetical order * Add detailed squad loop section with flags and examples * Include frontmatter reference and example loop.md * Link to full feature documentation Follows Microsoft Style Guide (sentence-case headings, active voice, second person). Matches ralph.md and other feature documentation patterns. Closes #761 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/src/content/docs/features/loop.md | 328 +++++++++++++++++++++++++ docs/src/content/docs/reference/cli.md | 90 ++++++- 2 files changed, 415 insertions(+), 3 deletions(-) create mode 100644 docs/src/content/docs/features/loop.md diff --git a/docs/src/content/docs/features/loop.md b/docs/src/content/docs/features/loop.md new file mode 100644 index 000000000..6da97fc4b --- /dev/null +++ b/docs/src/content/docs/features/loop.md @@ -0,0 +1,328 @@ +# Loop — Prompt-driven work loop + +> ⚠️ **Experimental** — Squad is alpha software. APIs, commands, and behavior may change between releases. + +**Try this to initialize a loop:** +``` +squad loop --init +``` + +**Try this to run your loop:** +``` +squad loop +``` + +**Try this with monitoring:** +``` +squad loop --monitor-email --monitor-teams +``` + +Loop reads a `loop.md` prompt file and runs it as a continuous work loop. No GitHub issues needed — the prompt is the work driver. Each cycle, Loop sends the prompt to Copilot, collects the work, and loops again at your chosen interval. + +--- + +## What loop does + +Loop is a prompt-driven work engine. Unlike Ralph (which routes GitHub issues to team members), Loop takes a **single `.md` file** with a prompt and your work directives, then runs that prompt continuously. + +The `loop.md` file contains: + +- **Frontmatter** — configuration (how often to loop, timeout, whether you've set up the loop) +- **Prompt** — the actual work driver (what Copilot should do each cycle) + +When you run `squad loop`, it: + +1. Reads `loop.md` +2. Checks that frontmatter is marked `configured: true` +3. Sends the prompt to Copilot +4. Collects the output (work done, decisions made, artifacts created) +5. Waits for the interval +6. Repeats + +**Why use Loop instead of Ralph?** + +- **No issue queue** — You drive the work with a prompt, not GitHub labels +- **Continuous** — One cycle after another, forever (or until you stop it) +- **Lightweight** — One file to maintain, no routing rules or complex setup +- **Flexible** — Mixed modes (team work queue + monitoring), repeated tasks (watch a folder, check metrics, cleanup jobs) + +## Prerequisites + +Loop requires: + +- **GitHub CLI (`gh`)** — for authentication and GitHub integration (optional, but recommended) +- **Copilot CLI access** — to send prompts to Copilot +- **A `loop.md` file** — the prompt file that drives your work + +## Getting started + +### Step 1: Initialize your loop + +```bash +squad loop --init +``` + +This creates a starter `loop.md` file in your project root: + +```markdown +--- +configured: false +interval: 5 +timeout: 30 +description: "My work loop" +--- + +# Work Loop Prompt + +You are a team member on this squad. Each cycle, you will: + +1. Check for pending work +2. Complete what you can within the timeout +3. Document your results + +Start with small, focused tasks. Expand the scope once you're confident the loop is working. +``` + +### Step 2: Edit `loop.md` + +Update the prompt to describe the work you want done each cycle: + +```markdown +--- +configured: false +interval: 10 +timeout: 20 +description: "Monitor and fix failing CI" +--- + +# CI Monitoring Loop + +Each cycle, you will: + +1. Check GitHub Actions workflows for failures in the main branch +2. If any workflow failed in the last 10 minutes, investigate the failure +3. If it's a flaky test, flag it +4. If it's a real issue, create a PR with a fix +5. Report findings (failures found, fixes created, flaky tests) + +Keep runs focused — 20 minutes max per cycle. +``` + +### Step 3: Enable the loop + +Set `configured: true` in the frontmatter to unlock the loop: + +```markdown +--- +configured: true +interval: 10 +timeout: 20 +description: "Monitor and fix failing CI" +--- +``` + +### Step 4: Run the loop + +```bash +squad loop +``` + +Loop will run your prompt every 10 minutes until you press Ctrl+C. + +## Frontmatter reference + +The YAML frontmatter at the top of `loop.md` controls Loop's behavior: + +| Field | Type | Required | Default | Description | +|-------|------|:--------:|:-------:|-------------| +| `configured` | boolean | Yes | `false` | Safety check — must be `true` to run. Prevents accidental execution of incomplete loops. | +| `interval` | number | No | `5` | Minutes between cycles. Loop will wait this long after each cycle completes before running again. | +| `timeout` | number | No | `30` | Max runtime in minutes for each cycle. If Copilot doesn't finish within this time, the cycle is marked incomplete and the next cycle starts. | +| `description` | string | No | `""` | Human-readable description of what this loop does. Shown in logs and status. | + +Example: + +```markdown +--- +configured: true +interval: 15 +timeout: 45 +description: "Process inbox and clean up stale branches" +--- +``` + +## Writing a good loop prompt + +A good loop prompt is: + +- **Specific** — Clear about what work to do each cycle +- **Bounded** — Doesn't try to do everything at once; respects the timeout +- **Observable** — Reports what it did so you can track progress +- **Idempotent** — Safe to run repeatedly; doesn't duplicate work or corrupt state + +### Example: Team work queue + +```markdown +--- +configured: true +interval: 5 +timeout: 15 +description: "Process team work queue from Teams" +--- + +# Team Work Queue + +Each cycle: + +1. Query our Teams channel for pending action items (messages with 🎯 emoji) +2. For each item, create a GitHub issue with label `teams:pending` +3. Triage to the right team member based on topic +4. Report how many items were added + +Keep this quick — 15 minutes per cycle, process at most 3 items. +``` + +### Example: Monitoring and alerting + +```markdown +--- +configured: true +interval: 30 +timeout: 20 +description: "Monitor uptime and alert team" +--- + +# Uptime Monitor + +Each cycle: + +1. Check status.io for incidents on our services +2. Query monitoring dashboard for error rates +3. If any error rate > 5%, post alert to Teams #alerts channel +4. Report findings (status, error rates, alerts sent) + +This is passive — no fixing, just reporting. +``` + +### Example: Mixed mode (queue + monitoring) + +```markdown +--- +configured: true +interval: 10 +timeout: 30 +description: "Work queue + monitoring + cleanup" +--- + +# Daily Squad Loop + +Each cycle, in order: + +1. **Monitor** — Check for CI failures, Dependabot alerts +2. **Triage** — Create issues for alerts +3. **Work** — Claim the next issue from the backlog +4. **Cleanup** — Delete stale feature branches older than 30 days +5. **Report** — Summary of work, alerts, deletions + +Budget: 30 minutes per cycle. Start with most urgent work, drop to less urgent if running tight on time. +``` + +## Composing with capabilities + +Loop works with Squad's monitoring and bridge capabilities. Add flags to extend what Loop can see and do: + +```bash +# Monitor email for actionable items each cycle +squad loop --monitor-email + +# Monitor Teams for action items each cycle +squad loop --monitor-teams + +# Both email and Teams +squad loop --monitor-email --monitor-teams + +# Enable self-pull (fetch latest code before each cycle) +squad loop --self-pull + +# Combine multiple capabilities +squad loop --monitor-email --monitor-teams --self-pull +``` + +When enabled, these capabilities are available inside your loop prompt as context. For example, with `--monitor-email`, your prompt can reference email alerts and action items. + +## CLI reference + +All `squad loop` flags: + +| Flag | Type | Description | Example | +|------|------|-------------|---------| +| `--init` | boolean | Create a starter `loop.md` file | `squad loop --init` | +| `--file ` | string | Path to loop file (default: `loop.md`) | `squad loop --file scripts/monitor.md` | +| `--interval ` | number | Override loop interval in minutes | `squad loop --interval 3` | +| `--timeout ` | number | Override cycle timeout in minutes | `squad loop --timeout 60` | +| `--copilot-flags "..."` | string | Pass extra flags to Copilot CLI | `squad loop --copilot-flags "--model gpt-4"` | +| `--agent-cmd ` | string | Custom agent command (advanced) | `squad loop --agent-cmd my-agent-wrapper` | +| `--monitor-email` | boolean | Scan email for alerts each cycle | `squad loop --monitor-email` | +| `--monitor-teams` | boolean | Scan Teams for action items each cycle | `squad loop --monitor-teams` | +| `--self-pull` | boolean | Run `git fetch && git pull` before each cycle | `squad loop --self-pull` | + +### Examples + +**Basic loop:** +```bash +squad loop +``` + +**Custom loop file:** +```bash +squad loop --file scripts/cleanup.md +``` + +**Faster interval:** +```bash +squad loop --interval 3 --timeout 15 +``` + +**With monitoring:** +```bash +squad loop --monitor-email --monitor-teams --self-pull +``` + +**Override frontmatter with CLI:** +```bash +squad loop --interval 2 --timeout 45 +``` + +CLI flags override frontmatter values. If your `loop.md` says `interval: 10` but you run `squad loop --interval 3`, Loop uses 3 minutes. + +## Configuration + +Instead of command-line flags, you can configure Loop in `.squad/config.json`: + +```json +{ + "loop": { + "interval": 5, + "timeout": 30, + "file": "loop.md", + "monitorEmail": true, + "monitorTeams": false, + "selfPull": true, + "copilotFlags": "--model gpt-4" + } +} +``` + +Resolution order (first match wins): + +1. CLI flags (`--interval 10`) +2. `.squad/config.json` `loop` section +3. `loop.md` frontmatter +4. Defaults (interval: 5, timeout: 30) + +## Notes + +- Loop is session-scoped — it runs in your terminal and stops when you press Ctrl+C +- Each cycle gets its own Copilot session; state is not preserved between cycles unless your prompt explicitly handles it +- Loop respects `.squad/` team context: charters, routing, decisions, and directives are all available to the prompt +- For fully unattended monitoring, use `squad watch` instead — it's designed for running in a separate terminal 24/7 diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index d472a3e60..b5ebaece9 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -22,7 +22,7 @@ squad init --- -## CLI Commands (16 commands) +## CLI Commands (17 commands) | Command | Description | Requires `.squad/` | |---------|-------------|:------------------:| @@ -30,12 +30,15 @@ squad init | `squad init` | Initialize Squad in the current repo (idempotent — safe to run multiple times) | No | | `squad init --global` | Create a personal squad in your platform-specific directory | No | | `squad init --mode remote ` | Initialize linked to a remote team root (dual-root mode) | No | +| `squad link ` | Link project to a remote team root | Yes | +| `squad loop` | Run a prompt-driven work loop from `loop.md` | Yes | +| `squad loop --init` | Create a starter `loop.md` file | Yes | +| `squad loop --file ` | Run a loop from a custom file path | Yes | | `squad start [--tunnel] [--port N] [--command cmd]` | Start Copilot with remote phone access via PTY and WebSocket | No | | `squad status` | Show which squad is active and why | Yes | | `squad doctor` | Validate squad setup integrity and diagnose issues (alias: `heartbeat`) | Yes | | `squad upgrade` | Upgrade Squad-owned files to latest version | Yes | | `squad upgrade --migrate-directory` | Rename legacy `.ai-team/` directory to `.squad/` | Yes | -| `squad link ` | Link project to a remote team root | Yes | | `squad triage` | Auto-triage issues and assign to team (primary name; `watch` is an alias) | Yes | | `squad triage --interval ` | Continuous triage (default: every 10 min) | Yes | | `squad watch --execute` | Enable work execution (spawn Copilot to work on issues) | Yes | @@ -115,7 +118,88 @@ For details on architecture, security, mobile keyboard, and troubleshooting, see --- -## Interactive Shell +### squad loop + +Run a prompt-driven work loop from a `loop.md` file. Each cycle, Loop sends your prompt to Copilot and loops again at your chosen interval. + +**Basic usage:** + +```bash +squad loop # Run the loop from loop.md +squad loop --init # Create a starter loop.md +squad loop --file scripts/monitor.md # Run a custom loop file +``` + +**Flags:** + +- `--init` — Create a starter `loop.md` file in your project +- `--file ` — Path to loop file (default: `loop.md` in project root) +- `--interval ` — Override loop interval in minutes (default: from frontmatter) +- `--timeout ` — Override cycle timeout in minutes (default: from frontmatter) +- `--copilot-flags "..."` — Pass extra flags to Copilot CLI +- `--agent-cmd ` — Custom agent command (advanced) +- `--monitor-email` — Scan email for alerts each cycle (requires WorkIQ MCP) +- `--monitor-teams` — Scan Teams for action items each cycle (requires WorkIQ MCP) +- `--self-pull` — Run `git fetch && git pull` before each cycle + +**Frontmatter reference:** + +Loop.md requires YAML frontmatter with: + +| Field | Type | Description | +|-------|------|-------------| +| `configured` | boolean | Safety check — must be `true` to run (prevents accidental execution) | +| `interval` | number | Minutes between cycles (default: 5) | +| `timeout` | number | Max runtime in minutes per cycle (default: 30) | +| `description` | string | Human-readable description of the loop | + +**Examples:** + +```bash +# Create a starter loop +squad loop --init + +# Edit loop.md, then run it +squad loop + +# Run with faster interval (overrides frontmatter) +squad loop --interval 3 + +# Run with monitoring +squad loop --monitor-email --monitor-teams + +# Run a named loop file +squad loop --file scripts/ci-monitor.md + +# Run with custom Copilot model +squad loop --copilot-flags "--model gpt-4" +``` + +**Example loop.md:** + +```markdown +--- +configured: true +interval: 5 +timeout: 20 +description: "Monitor failing CI and fix issues" +--- + +# CI Monitor Loop + +Each cycle, you will: + +1. Check GitHub Actions for failures in main branch +2. If failures exist, investigate the top 1-2 +3. If fixable, create a PR with the fix +4. Report findings (failures found, fixes created) + +Keep cycles to 20 minutes max. +``` + +For complete documentation and examples, see [Loop — Prompt-driven work loop](../features/loop.md). + +--- Enter the shell with `squad` (no arguments). You'll see: From 1c5a4687c60f48fb8ea72ede111942cfa52cb16b Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 15:38:11 -0700 Subject: [PATCH 3/5] feat(cli): implement squad loop command (#761) Add prompt-driven continuous work loop that reads loop.md and runs it on a configurable interval without requiring GitHub issues. - parseLoopFile: YAML frontmatter parser with configured/interval/timeout - generateLoopFile: boilerplate loop.md for --init - runLoop: main loop with setInterval, SIGINT/SIGTERM shutdown - Capability integration: pre-scan + housekeeping phases (self-pull, monitor-email, monitor-teams, retro, decision-hygiene) - CLI flags: --init, --file, --interval, --timeout, --copilot-flags, --agent-cmd, plus all capability --name flags - Safety gate: configured: true required in frontmatter - templates/loop.md added as canonical boilerplate Closes #761 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .changeset/loop-command.md | 9 + packages/squad-cli/src/cli-entry.ts | 106 ++++- packages/squad-cli/src/cli/commands/loop.ts | 436 ++++++++++++++++++++ templates/loop.md | 46 +++ 4 files changed, 586 insertions(+), 11 deletions(-) create mode 100644 .changeset/loop-command.md create mode 100644 packages/squad-cli/src/cli/commands/loop.ts create mode 100644 templates/loop.md diff --git a/.changeset/loop-command.md b/.changeset/loop-command.md new file mode 100644 index 000000000..80097834d --- /dev/null +++ b/.changeset/loop-command.md @@ -0,0 +1,9 @@ +--- +"@bradygaster/squad-cli": minor +--- + +feat(cli): Add `squad loop` command — prompt-driven continuous work loop + +New `squad loop` command reads a `loop.md` file and runs it as a continuous work loop. +No GitHub issues required — the prompt is the work driver. Includes `--init` to scaffold +a boilerplate loop file, frontmatter validation, and composable capability flags. diff --git a/packages/squad-cli/src/cli-entry.ts b/packages/squad-cli/src/cli-entry.ts index c6101a71c..8f49331da 100644 --- a/packages/squad-cli/src/cli-entry.ts +++ b/packages/squad-cli/src/cli-entry.ts @@ -177,9 +177,12 @@ async function main(): Promise { console.log(` --retro enforce retrospective checks`); console.log(` --decision-hygiene auto-merge decision inbox`); console.log(` Disable: --no- overrides config.json`); - console.log(` ${BOLD}loop${RESET} Continuous work loop (Ralph mode)`); - console.log(` Usage: loop [--filter