feat(cli): Add squad loop command — prompt-driven continuous work loop (#761)#765
feat(cli): Add squad loop command — prompt-driven continuous work loop (#761)#765bradygaster wants to merge 6 commits intodevfrom
Conversation
…t 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>
- 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>
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>
Cover parseLoopFile (frontmatter parsing, defaults, edge cases) and generateLoopFile (boilerplate generation, round-trip validation). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Adds a new squad loop CLI command that runs a prompt-driven continuous work loop from loop.md, plus supporting docs/tests/templates, and extends the SDK communications layer with a Teams adapter/config surface.
Changes:
- Implement
squad loop(frontmatter parsing +--initscaffolding + interval runner) and wire it into the CLI entrypoint. - Add
loop.mdtemplate, unit tests for parsing/scaffolding, and user-facing documentation. - Add Teams communication configuration in SDK types and a new Teams communication adapter, plus factory wiring.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 14 comments.
Show a summary per file
| File | Description |
|---|---|
packages/squad-cli/src/cli/commands/loop.ts |
Core loop implementation (parser/scaffold/runner + capability phases). |
packages/squad-cli/src/cli-entry.ts |
CLI wiring for squad loop, help output, and --init. |
templates/loop.md |
Boilerplate loop.md template content. |
test/cli/loop.test.ts |
Unit tests for parseLoopFile() / generateLoopFile(). |
docs/src/content/docs/features/loop.md |
Feature guide for the Loop command. |
docs/src/content/docs/reference/cli.md |
CLI reference updates to include Loop. |
packages/squad-sdk/src/platform/types.ts |
Adds Teams-specific config shape under CommunicationConfig. |
packages/squad-sdk/src/platform/comms.ts |
Wires teams-webhook channel to Teams adapter and reads Teams config. |
packages/squad-sdk/src/platform/comms-teams.ts |
New Teams adapter using Microsoft Graph auth + messaging + reply polling. |
.changeset/loop-command.md |
Changeset for @bradygaster/squad-cli minor bump. |
| import path from 'node:path'; | ||
| import { execFile } from 'node:child_process'; | ||
| import { FSStorageProvider } from '@bradygaster/squad-sdk'; | ||
|
|
||
| const storage = new FSStorageProvider(); | ||
|
|
||
| import { detectSquadDir } from '../core/detect-squad-dir.js'; | ||
| import { fatal } from '../core/errors.js'; | ||
| import { GREEN, RED, DIM, BOLD, RESET, YELLOW } from '../core/output.js'; |
There was a problem hiding this comment.
There’s a statement (const storage = new FSStorageProvider();) between import declarations. In ESM/TypeScript, all static import statements must come before any other statements, so this will fail to compile. Move the storage initialization below the imports (or convert the later imports to dynamic imports).
| let inFrontmatter = false; | ||
| let frontmatterLines: string[] = []; | ||
| let bodyStart = 0; | ||
|
|
||
| if (lines[0]?.trim() === '---') { | ||
| inFrontmatter = true; |
There was a problem hiding this comment.
inFrontmatter is assigned but never read. This will trip TS/ESLint no-unused-vars (and makes the parser harder to follow). Remove it or use it to control parsing state.
| let inFrontmatter = false; | |
| let frontmatterLines: string[] = []; | |
| let bodyStart = 0; | |
| if (lines[0]?.trim() === '---') { | |
| inFrontmatter = true; | |
| let frontmatterLines: string[] = []; | |
| let bodyStart = 0; | |
| if (lines[0]?.trim() === '---') { |
| if (isNaN(interval) || interval < 1) { | ||
| fatal('interval must be a positive number of minutes'); | ||
| } | ||
|
|
There was a problem hiding this comment.
timeoutMinutes is used to derive timeoutMs for execFile, but it’s never validated. If loop.md (or --timeout) provides 0/negative/NaN, Node will treat it inconsistently (immediate timeout or throw). Add the same validation you do for interval (positive number of minutes).
| if (isNaN(timeoutMinutes) || timeoutMinutes < 1) { | |
| fatal('timeout must be a positive number of minutes'); | |
| } |
| await new Promise<void>((resolve) => { | ||
| execFile(cmd, args, { cwd: teamRoot, timeout: timeoutMs, maxBuffer: 50 * 1024 * 1024 }, (err) => { | ||
| if (err) { | ||
| const execErr = err as Error & { killed?: boolean }; | ||
| const msg = execErr.killed | ||
| ? `Timed out after ${timeoutMinutes}m` | ||
| : execErr.message; | ||
| console.error(`${RED}✗${RESET} [${new Date().toLocaleTimeString()}] Round ${round} failed: ${msg}`); | ||
| } else { | ||
| console.log(`${GREEN}✓${RESET} [${new Date().toLocaleTimeString()}] Round ${round} complete`); | ||
| } | ||
| resolve(); | ||
| }); | ||
| }); | ||
|
|
||
| // Phase 2: housekeeping (monitor-email, monitor-teams, retro, decision-hygiene) | ||
| await runPhase('housekeeping', enabledCapabilities, roundContext, watchConfig); | ||
| } | ||
|
|
||
| // Run immediately, then on interval | ||
| await executeRound(); | ||
|
|
||
| return new Promise<void>((resolve) => { | ||
| const intervalId = setInterval( | ||
| async () => { | ||
| if (roundInProgress) return; | ||
| roundInProgress = true; | ||
| try { | ||
| await executeRound(); | ||
| } catch (e) { | ||
| console.error(`${RED}✗${RESET} Round error: ${(e as Error).message}`); | ||
| } finally { | ||
| roundInProgress = false; | ||
| } | ||
| }, | ||
| interval * 60 * 1000, | ||
| ); | ||
|
|
||
| // Graceful shutdown | ||
| let isShuttingDown = false; | ||
| const shutdown = () => { | ||
| if (isShuttingDown) return; | ||
| isShuttingDown = true; | ||
| clearInterval(intervalId); | ||
| process.off('SIGINT', shutdown); | ||
| process.off('SIGTERM', shutdown); | ||
| console.log(`\n${DIM}🔄 Squad Loop — stopped${RESET}`); | ||
| resolve(); | ||
| }; | ||
|
|
||
| process.on('SIGINT', shutdown); | ||
| process.on('SIGTERM', shutdown); | ||
| }); |
There was a problem hiding this comment.
Graceful shutdown clears the interval, but it doesn’t stop an in-flight execFile child process. If the user presses Ctrl+C while a round is running, the loop will likely keep the process alive until the Copilot command exits or times out. Consider keeping a reference to the current child process and terminating it in shutdown() (and/or set a flag to skip housekeeping).
| const fileIdx = args.indexOf('--file'); | ||
| const filePath = (fileIdx !== -1 && args[fileIdx + 1]) ? args[fileIdx + 1]! : 'loop.md'; | ||
| const { FSStorageProvider } = await import('@bradygaster/squad-sdk'); | ||
| const storage = new FSStorageProvider(); | ||
| const absPath = filePath.startsWith('/') || /^[A-Za-z]:/.test(filePath) | ||
| ? filePath | ||
| : `${process.cwd()}/${filePath}`; | ||
| if (storage.existsSync(absPath)) { |
There was a problem hiding this comment.
--init builds absPath with string concatenation (${process.cwd()}/${filePath}), which breaks on Windows paths and doesn’t normalize .. segments. Use node:path.resolve(process.cwd(), filePath) (and keep the absolute-path detection consistent with the rest of the CLI).
| /** 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 */ }); | ||
| } |
There was a problem hiding this comment.
openBrowser() uses exec() with an interpolated command string containing the URL. Even though inputs are mostly generated, this is still shell invocation and can be fragile across platforms (quoting/escaping) and increases command-injection risk if the URL ever contains unexpected characters. Prefer spawn/execFile with argument arrays (or a small cross-platform “open” helper) so the URL is passed as a parameter rather than via a shell string.
| 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; |
There was a problem hiding this comment.
The Graph messages polling URL includes spaces and an unencoded ISO timestamp inside $filter (e.g., createdDateTime gt 2026-...). This will produce an invalid URL (spaces) and/or an invalid OData filter. Build the query with URL/URLSearchParams and encodeURIComponent, and confirm the correct datetime filter syntax for Graph.
| 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; | |
| const url = new URL(`${GRAPH_BASE}/chats/${chatId}/messages`); | |
| url.searchParams.set('$filter', `createdDateTime gt ${sinceIso}`); | |
| url.searchParams.set('$top', '50'); | |
| url.searchParams.set('$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.toString(), accessToken)) as typeof data; |
| // "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; | ||
| } | ||
|
|
There was a problem hiding this comment.
In “me” mode, ensureChat() picks the first oneOnOne chat returned and treats it as a self-chat. That list can include 1:1 chats with other people, so this can easily send messages to the wrong recipient. Either require an explicit recipientUpn/chatId, or implement filtering that positively identifies a self-chat (or always create a new chat and persist its id).
| // "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; | |
| } | |
| // "me" mode — create a self-chat instead of reusing the first oneOnOne chat, | |
| // because /me/chats can include 1:1 chats with other people. | |
| if (!upn || upn === 'me') { |
| 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<CommunicationReply[]> { | ||
| 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`; | ||
|
|
There was a problem hiding this comment.
postUpdate() returns { id: msg.id } where msg.id is a message ID, but pollForReplies() treats threadId as a chat ID (/chats/{chatId}/messages) unless resolvedChatId is set. This breaks the CommunicationAdapter contract for Teams (and will fail entirely for channel-mode posts). Consider returning the chat/channel thread identifier as id (e.g., chatId for 1:1, or {teamId,channelId,messageId} via a serialized id) and update pollForReplies() accordingly.
| @@ -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<string, unknown>; | |||
| const comms = parsed.communications as Record<string, unknown> | undefined; | |||
| if (comms?.teams && typeof comms.teams === 'object') { | |||
| return comms.teams as CommunicationConfig['teams']; | |||
| } | |||
| } catch { /* ignore */ } | |||
| return undefined; | |||
| } | |||
There was a problem hiding this comment.
New Teams adapter selection and .squad/config.json parsing (teams config + readTeamsConfig) aren’t covered by tests. There are existing unit tests for the communication adapter contract/factory; adding a test that verifies createCommunicationAdapter() returns TeamsCommunicationAdapter when communications.channel is teams-webhook (and that communications.teams is passed through) would prevent regressions.
diberry
left a comment
There was a problem hiding this comment.
Architectural Review — PR #765
1,781 additions across 10 files. Two distinct features bundled: a loop command and a Teams communication adapter.
✅ Architectural Pros
-
Smart reuse of watch capabilities. Loop only activates
pre-scan+housekeepingphases from the existing capability registry, skipping core triage. Avoids duplicating the capability system while carving out a clean subset. -
Safety gate pattern.
configured: truein frontmatter prevents accidental runaway loops. Simple and effective. -
No YAML dependency. The regex-based frontmatter parser handles the simple
key: valueformat needed without addingjs-yaml. Good for keeping the CLI lean. -
execFileoverexec. UsingexecFilewith arrays avoids shell injection. The prompt content fromloop.mdis passed as a single--messageargument, not interpolated into a shell string. -
21 unit tests for the parser. Good edge case coverage on
parseLoopFile— unclosed frontmatter, NaN interval, quoted/unquoted strings, round-trip validation.
❌ Architectural Cons & Hidden Risks
🔴 HIGH RISK
1. comms-teams.ts is 550 lines with ZERO tests.
The PR has 21 tests — all for the loop parser. The entire Teams adapter (OAuth PKCE flow, device code flow, token refresh, Graph API calls, local HTTP server for auth callback, token file management) is completely untested. This is the most complex code in the PR and the most likely to break in production.
2. Teams adapter loads for ALL SDK users — cold-start tax.
comms.ts now unconditionally import { TeamsCommunicationAdapter } from './comms-teams.js' at the top. Since comms.ts is re-exported from the barrel (platform/index.ts), every import from '@bradygaster/squad-sdk/platform' now loads 550 lines of Teams auth code, a local HTTP server factory, and crypto imports — even if Teams is never used.
3. Plaintext OAuth tokens at ~/.squad/teams-tokens.json.
Access tokens and refresh tokens stored as unencrypted JSON with default file permissions. The tokens carry Chat.ReadWrite ChatMessage.Send ChatMessage.Read User.Read — enough to read and send Teams messages as the user.
4. Hardcoded Microsoft first-party client ID (14d82eec-204b-4c2f-b7e8-296a70dab67e).
This is the Microsoft Graph PowerShell app's client ID. Using it means Squad authenticates as "Microsoft Graph PowerShell" in every tenant. Microsoft can revoke or change its redirect URI configuration at any time, enterprise tenants may have conditional access policies blocking this app, and it's against Microsoft identity platform guidance to reuse first-party app registrations in third-party code.
🟡 MEDIUM RISK
5. CommunicationConfig becoming a dumping ground.
The generic interface now has teams?: { tenantId, clientId, recipientUpn, chatId, channelId, teamId } bolted on. If Slack, Discord, email each get the same treatment, this interface becomes an unstructured union. Better pattern: discriminated union keyed by channel or adapterConfig?: Record<string, unknown>.
6. Two unrelated features in one PR.
The loop command and the Teams adapter are logically independent. Bundling them means the loop feature can't ship until Teams concerns are resolved, review is harder, and revert granularity is lost.
7. Type-assertion lie in the platform adapter fallback.
adapter = { type: 'github' as const } as ReturnType<typeof createPlatformAdapter>;This stub doesn't implement any methods. If any capability calls adapter.listWorkItems() or similar, it crashes at runtime with undefined is not a function. The as cast suppresses the compiler warning.
8. Coupling loop to watch internals.
loop.ts imports CapabilityRegistry, createDefaultRegistry, WatchCapability, WatchContext, WatchPhase, CapabilityResult, WatchConfig — deep coupling to watch's internal types. Changes to watch's capability system now have two consumers to validate against, but only watch tests cover capability execution.
🟢 LOW RISK
9. No preflight check for gh copilot. The loop's core mechanism is execFile('gh', ['copilot', ...]). If gh or the copilot extension isn't installed, every round fails with an opaque error.
10. Module-level const storage = new FSStorageProvider() in loop.ts. Side effect on import — if the constructor throws, the entire CLI module fails to load.
Recommendation
Split the PR. Ship the loop command (it's solid). Gate the Teams adapter behind a separate PR with tests, lazy-loading (import() instead of top-level import), encrypted token storage, and its own app registration guidance.
🔄 Retrospective — Why Didn't the Squad Catch These Issues?PR #765 was authored entirely by @copilot (the GitHub Copilot coding agent) working autonomously on issue #761. The commit history shows the Teams adapter commit predates the loop work — it was already on the branch when @copilot picked up #761. Two features shipped in one PR by accident, not by design. The PR arrived with 10 issues across architecture, security, testing, and API design. The squad has the right agents to catch every one of them: Flight (architecture), FIDO (testing), RETRO (security). None were activated. Root Cause AnalysisPrimary: @copilot operates outside the squad's orchestration layer. The entire Squad system — coordinator routing, Design Review ceremonies, reviewer gates, Scribe logging — only activates when work flows through the coordinator. @copilot doesn't. It picks up issues via GitHub assignment, works in its own branch, and opens PRs directly. No ceremony is triggered. No reviewer is spawned. No security check happens. The squad has a Design Review ceremony configured to auto-trigger "before multi-agent task involving 2+ agents modifying shared systems." This PR modified shared SDK types and the CLI entry point — it qualified. But the ceremony never fired because @copilot never asked the coordinator for work. Secondary: @copilot tests what's easy, skips what's hard. @copilot wrote 21 tests for parseLoopFile and generateLoopFile — pure functions with no dependencies. It wrote zero tests for the 550-line Teams adapter because that requires mocking Graph API, HTTP servers, and OAuth flows. Known pattern: optimizes for what compiles and passes, not for what needs coverage. Secondary: No PR-level review trigger for squad agents. The squad has squad-pr-readiness.yml — but it posts an informational checklist, not an enforcement gate. There's no mechanism that says "when @copilot opens a PR, spawn Flight + FIDO + RETRO to review before it's marked ready." Secondary: Architectural issues are invisible to linters and compilers. Every issue found — module-level side effects, type-assertion lies, hardcoded client IDs, plaintext token storage, async API breaks, feature bundling — passes tsc, ESLint, and all tests. These are judgment calls that require an agent (or human) who understands the system's architecture. Recommendations
The Meta-LessonThe squad's agents are good. The squad's process has a hole. Every issue found was catchable by agents already on the roster. The failure wasn't capability — it was activation. @copilot is the one team member that bypasses the coordinator, and that bypass is exactly where quality dropped. This PR has been split into:
@diberry — please review both split PRs. The retro recommendations above should be tracked as separate issues. |
…op.ts code issues, fix docs defaults - Remove comms-teams.ts from this PR (Teams adapter moves to separate PR per architectural review) - Revert comms.ts and types.ts to pre-Teams state - Move FSStorageProvider init below all imports in loop.ts - Remove unused inFrontmatter variable from parseLoopFile - Add timeoutMinutes validation (must be positive number) - Track in-flight child process and kill on graceful shutdown - Fix --init path construction to use path.resolve instead of string concat - Fix interval default docs: 5→10 minutes in features/loop.md and reference/cli.md Closes #761 Agent-Logs-Url: https://github.com/bradygaster/squad/sessions/bbd19e9d-9c7a-4b12-994d-cf324d8d5a7e Co-authored-by: diberry <41597107+diberry@users.noreply.github.com>
Addressed in commit fe8af61: Removed Teams adapter from this PR — deleted Fixed
Fixed Fixed docs: interval default corrected from 5→10 minutes in both Warning Firewall rules blocked me from connecting to one or more addresses (expand for details)I tried to connect to the following addresses, but was blocked by firewall rules:
If you need me to access, download, or install something from one of these locations, you can either:
|
…utdown, path.resolve, docs Closes #765 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Cherry-pick squad.agent.md.template changes from PR #765 that implement decisions archiving hard gates (20KB/51KB thresholds) and history summarization gates (15KB) — needed to prevent runaway file growth during continuous loop operation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Summary
Adds the
squad loopcommand — a prompt-driven continuous work loop that reads aloop.mdfile and runs it on a configurable interval, no GitHub issues required.What's included
Core implementation (
loop.ts)parseLoopFile— YAML frontmatter parser (no yaml dependency) withconfigured: truesafety gategenerateLoopFile— boilerplate scaffold for--initrunLoop— main loop withsetInterval,SIGINT/SIGTERMgraceful shutdown (kills in-flight child process on Ctrl+C), capability phase runnertimeoutMinutesvalidated as a positive number (same guard asinterval)FSStorageProviderinitialized after all import declarationsCLI entry (
cli-entry.ts)--helpoutput with examples--initto scaffold aloop.md(path resolved viapath.resolvefor correct cross-platform handling)--file,--interval,--timeout,--copilot-flags,--agent-cmd--self-pull,--monitor-email,--monitor-teams, etc.)Template
templates/loop.md— well-documented boilerplate with guidanceTests (
test/cli/loop.test.ts)parseLoopFileandgenerateLoopFileDocs
docs/src/content/docs/features/loop.md— full feature guide (interval default corrected to 10 minutes throughout)docs/src/content/docs/reference/cli.mdwith loop reference (interval default corrected to 10 minutes)Changeset
@bradygaster/squad-cliDesign decisions
configured: truerequired as a safety gate (prevents accidental loops)setInterval+ signal handlers); graceful shutdown now kills any in-flightexecFilechild process immediatelyHow to use