From 60abbcf75604f840c04389287a5c65c095c115a9 Mon Sep 17 00:00:00 2001 From: Tamir Dresher Date: Wed, 4 Mar 2026 19:47:24 +0200 Subject: [PATCH 1/9] feat: add Squad Streams for multi-Codespace scaling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the Streams feature that enables horizontal scaling by allowing multiple Squad instances (e.g., in different Codespaces) to each handle a scoped subset of work via labeled streams. New modules: - packages/squad-sdk/src/streams/ — types, resolver, and issue filter - packages/squad-cli/src/cli/commands/streams.ts — CLI subcommand Stream resolution chain: SQUAD_TEAM env var > .squad-stream file > single-stream auto-select > null Changes: - Add StreamDefinition, StreamConfig, ResolvedStream types - Add resolveStream(), loadStreamsConfig(), getStreamLabelFilter() - Add filterIssuesByStream() for stream-aware triage - Add 'squad streams list|status|activate' CLI commands - Update InitOptions to support streams, generating .squad/streams.json - Add .squad-stream to .gitignore (local activation file) - Add Stream Awareness section to squad.agent.md template - Update SDK barrel exports (index.ts, types.ts) - Add 44 tests covering resolution, filtering, init, and edge cases - Add docs: features/streams.md, scenarios/multi-codespace.md, specs/streams-prd.md Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitignore | 2 + docs/features/streams.md | 131 ++++ docs/scenarios/multi-codespace.md | 127 ++++ docs/specs/streams-prd.md | 96 +++ package-lock.json | 8 +- package.json | 4 + packages/squad-cli/package.json | 4 + packages/squad-cli/src/cli-entry.ts | 10 + .../squad-cli/src/cli/commands/streams.ts | 166 +++++ packages/squad-sdk/package.json | 4 + packages/squad-sdk/src/config/init.ts | 34 + packages/squad-sdk/src/index.ts | 1 + packages/squad-sdk/src/streams/filter.ts | 42 ++ packages/squad-sdk/src/streams/index.ts | 9 + packages/squad-sdk/src/streams/resolver.ts | 130 ++++ packages/squad-sdk/src/streams/types.ts | 40 ++ packages/squad-sdk/src/types.ts | 5 + templates/squad.agent.md | 11 + test/streams.test.ts | 592 ++++++++++++++++++ 19 files changed, 1412 insertions(+), 4 deletions(-) create mode 100644 docs/features/streams.md create mode 100644 docs/scenarios/multi-codespace.md create mode 100644 docs/specs/streams-prd.md create mode 100644 packages/squad-cli/src/cli/commands/streams.ts create mode 100644 packages/squad-sdk/src/streams/filter.ts create mode 100644 packages/squad-sdk/src/streams/index.ts create mode 100644 packages/squad-sdk/src/streams/resolver.ts create mode 100644 packages/squad-sdk/src/streams/types.ts create mode 100644 test/streams.test.ts diff --git a/.gitignore b/.gitignore index 862a7cfa..fef2ead4 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ coverage/ .test-cli-* # Docs site generated files docs/dist/ +# Squad: stream activation file (local to this machine) +.squad-stream diff --git a/docs/features/streams.md b/docs/features/streams.md new file mode 100644 index 00000000..164d8bc5 --- /dev/null +++ b/docs/features/streams.md @@ -0,0 +1,131 @@ +# Squad Streams + +> Scale Squad across multiple Codespaces by partitioning work into labeled streams. + +## What Are Streams? + +A **stream** is a named partition of work within a Squad project. Each stream targets a specific GitHub label (e.g., `team:ui`, `team:backend`) and optionally restricts agents to certain directories. Multiple Squad instances — each running in its own Codespace — can each activate a different stream, enabling parallel work across teams. + +## Why Streams? + +Squad was originally designed for a single team per repository. As projects grow, a single Codespace becomes a bottleneck: + +- **Model rate limits** — One Codespace hitting API limits slows the whole team +- **Context overload** — Ralph picks up all issues, not just the relevant ones +- **Folder conflicts** — Multiple agents editing the same files causes merge pain + +Streams solve this by giving each Codespace a scoped view of the project. + +## Configuration + +### 1. Create `.squad/streams.json` + +```json +{ + "streams": [ + { + "name": "ui-team", + "labelFilter": "team:ui", + "folderScope": ["apps/web", "packages/ui"], + "workflow": "branch-per-issue", + "description": "Frontend team — React, CSS, components" + }, + { + "name": "backend-team", + "labelFilter": "team:backend", + "folderScope": ["apps/api", "packages/core"], + "workflow": "branch-per-issue", + "description": "Backend team — APIs, database, services" + }, + { + "name": "infra-team", + "labelFilter": "team:infra", + "folderScope": [".github", "infrastructure"], + "workflow": "direct", + "description": "Infrastructure — CI/CD, deployment, monitoring" + } + ], + "defaultWorkflow": "branch-per-issue" +} +``` + +### 2. Activate a Stream + +There are three ways to tell Squad which stream to use: + +#### Environment Variable (recommended for Codespaces) + +```bash +export SQUAD_TEAM=ui-team +``` + +Set this in your Codespace's environment or devcontainer.json: + +```json +{ + "containerEnv": { + "SQUAD_TEAM": "ui-team" + } +} +``` + +#### .squad-stream File (local activation) + +```bash +squad streams activate ui-team +``` + +This writes a `.squad-stream` file (gitignored) so the setting is local to your machine. + +#### Auto-select (single stream) + +If `streams.json` contains only one stream, it's automatically selected. + +### 3. Resolution Priority + +1. `SQUAD_TEAM` env var (highest) +2. `.squad-stream` file +3. Single-stream auto-select +4. No stream (classic single-squad mode) + +## Stream Definition Fields + +| Field | Required | Description | +|-------|----------|-------------| +| `name` | Yes | Unique stream identifier (kebab-case) | +| `labelFilter` | Yes | GitHub label to filter issues | +| `folderScope` | No | Directories this stream may modify | +| `workflow` | No | `branch-per-issue` (default) or `direct` | +| `description` | No | Human-readable purpose | + +## CLI Reference + +```bash +# List configured streams +squad streams list + +# Show stream activity (branches, PRs) +squad streams status + +# Activate a stream locally +squad streams activate +``` + +## How It Works + +### Triage (Ralph) + +When a stream is active, Ralph's triage only picks up issues labeled with the stream's `labelFilter`. Unmatched issues are left for other streams or the main squad. + +### Workflow Enforcement + +- **branch-per-issue** (default): Every issue gets its own branch and PR. Agents never commit directly to main. +- **direct**: Agents may commit directly (useful for infra/ops streams). + +### Folder Scope + +When `folderScope` is set, agents should only modify files within those directories. This prevents cross-team file conflicts. + +## Example: Multi-Codespace Setup + +See [Multi-Codespace Scenario](../scenarios/multi-codespace.md) for a complete walkthrough. diff --git a/docs/scenarios/multi-codespace.md b/docs/scenarios/multi-codespace.md new file mode 100644 index 00000000..e7d5eeb7 --- /dev/null +++ b/docs/scenarios/multi-codespace.md @@ -0,0 +1,127 @@ +# Multi-Codespace Setup with Squad Streams + +> End-to-end walkthrough of running multiple Squad instances across Codespaces. + +## Background: The Tetris Experiment + +We validated Squad Streams by building a multiplayer Tetris game using 3 Codespaces, each running a separate stream: + +| Codespace | Stream | Label | Focus | +|-----------|--------|-------|-------| +| CS-1 | `ui-team` | `team:ui` | React game board, piece rendering, animations | +| CS-2 | `backend-team` | `team:backend` | WebSocket server, game state, matchmaking | +| CS-3 | `infra-team` | `team:infra` | CI/CD, Docker, deployment | + +All three Codespaces shared the same repository. Each Squad instance only picked up issues matching its stream's label. + +## Setup Steps + +### 1. Create the streams config + +In your repository, create `.squad/streams.json`: + +```json +{ + "streams": [ + { + "name": "ui-team", + "labelFilter": "team:ui", + "folderScope": ["src/client", "src/components"], + "description": "Game UI and rendering" + }, + { + "name": "backend-team", + "labelFilter": "team:backend", + "folderScope": ["src/server", "src/shared"], + "description": "Game server and state management" + }, + { + "name": "infra-team", + "labelFilter": "team:infra", + "folderScope": [".github", "docker", "k8s"], + "workflow": "direct", + "description": "Build and deploy pipeline" + } + ], + "defaultWorkflow": "branch-per-issue" +} +``` + +### 2. Configure each Codespace + +In `.devcontainer/devcontainer.json`, set the `SQUAD_TEAM` env var. For multiple configs, use [devcontainer features](https://containers.dev/features) or separate devcontainer folders: + +**Option A: Separate devcontainer configs** + +``` +.devcontainer/ + ui-team/ + devcontainer.json # SQUAD_TEAM=ui-team + backend-team/ + devcontainer.json # SQUAD_TEAM=backend-team + infra-team/ + devcontainer.json # SQUAD_TEAM=infra-team +``` + +**Option B: Set env var after launch** + +```bash +export SQUAD_TEAM=ui-team +squad # launches with stream context +``` + +### 3. Label your issues + +Create GitHub issues with the appropriate team labels: + +```bash +gh issue create --title "Add piece rotation animation" --label "team:ui" +gh issue create --title "Implement matchmaking queue" --label "team:backend" +gh issue create --title "Add Docker compose for dev" --label "team:infra" +``` + +### 4. Launch Squad in each Codespace + +Each Codespace runs `squad` normally. The stream context is detected automatically: + +```bash +# In Codespace 1 (SQUAD_TEAM=ui-team) +squad +# → Ralph only triages issues labeled "team:ui" +# → Agents only modify files in src/client, src/components + +# In Codespace 2 (SQUAD_TEAM=backend-team) +squad +# → Ralph only triages issues labeled "team:backend" +# → Agents only modify files in src/server, src/shared +``` + +### 5. Monitor across streams + +Use the CLI from any Codespace to see all streams: + +```bash +squad streams status +``` + + + + +## What Worked + +- **Clear separation**: Each stream had well-defined boundaries, minimizing merge conflicts +- **Parallel velocity**: 3x throughput vs. single-squad mode for independent work +- **Label-based routing**: Simple, uses existing GitHub infrastructure + +## What Didn't Work (Yet) + +- **Cross-stream dependencies**: When the UI team needed a backend API change, manual coordination was required +- **Shared files**: `package.json`, `tsconfig.json`, and other root files caused occasional conflicts +- **No meta-coordinator**: No automated way to coordinate across streams (future work) + +## Lessons Learned + +1. **Keep streams independent** — design folder boundaries to minimize shared files +2. **Use branch-per-issue** — direct commits across streams cause merge hell +3. **Label everything** — unlabeled issues get lost between streams +4. **Start with 2 streams** — add more once the team finds its rhythm diff --git a/docs/specs/streams-prd.md b/docs/specs/streams-prd.md new file mode 100644 index 00000000..828fd956 --- /dev/null +++ b/docs/specs/streams-prd.md @@ -0,0 +1,96 @@ +# Streams PRD — Product Requirements Document + +> Scaling Squad across multiple Codespaces via labeled work streams. + +## Problem Statement + +Squad was designed for a single agent team per repository. In practice, larger projects hit scaling limits: + +1. **Rate limits**: A single Codespace hitting model API rate limits blocks the entire team +2. **Triage overload**: Ralph picks up all open issues, even those outside the current focus area +3. **File contention**: Multiple agents editing the same files causes merge conflicts +4. **Context window saturation**: Large repos exceed practical context limits for a single coordinator + +### Validated by Experiment + +We tested this with a 3-Codespace setup building a multiplayer Tetris game. Each Codespace ran Squad independently, manually filtering by GitHub labels. The results showed 3x throughput for independent work streams, validating the approach. However, the manual coordination was error-prone and needed to be automated. + +## Requirements + +### Must Have (P0) + +- [ ] **Stream Definition**: Define streams in `.squad/streams.json` with name, label filter, folder scope, and workflow +- [ ] **Stream Resolution**: Automatically detect active stream from env var, file, or config +- [ ] **Label Filtering**: Ralph only triages issues matching the stream's label +- [ ] **Folder Scoping**: Agents restrict modifications to stream's folder scope +- [ ] **CLI Management**: `squad streams list|status|activate` commands +- [ ] **Init Integration**: `squad init` optionally generates streams config +- [ ] **Agent Template**: squad.agent.md includes stream awareness instructions + +### Should Have (P1) + +- [ ] **Stream Status Dashboard**: Show PR/branch activity per stream +- [ ] **Conflict Detection**: Warn when streams overlap on file paths +- [ ] **Auto-labeling**: Suggest labels for new issues based on file paths + +### Could Have (P2) + +- [ ] **Meta-coordinator**: A coordinator that orchestrates across streams +- [ ] **Cross-stream dependencies**: Track and resolve inter-stream blockers +- [ ] **Stream metrics**: Throughput, cycle time, merge conflict rate per stream + +## Design Decisions + +### 1. GitHub Labels as the Partition Key + +**Decision**: Use GitHub labels (e.g., `team:ui`) to partition work across streams. + +**Rationale**: Labels are a first-class GitHub concept. They're visible in the UI, queryable via API, and already used by Squad for agent assignment. No new infrastructure needed. + +**Alternatives considered**: +- Custom metadata in issue body — fragile, not queryable +- Separate repositories — too heavy, loses monorepo benefits +- Branch-based partitioning — branches are for code, not work items + +### 2. File-Based Stream Activation + +**Decision**: Use `.squad-stream` file (gitignored) for local activation, `SQUAD_TEAM` env var for Codespaces. + +**Rationale**: The file is simple and doesn't require environment configuration. The env var is ideal for Codespaces where the environment is defined in `devcontainer.json`. Both are easy to understand and debug. + +### 3. Resolution Priority (Env > File > Config) + +**Decision**: Env var overrides file, which overrides config auto-select. + +**Rationale**: In Codespaces, the env var is the most reliable signal. On local machines, the file is convenient. Config auto-select handles the simple case of a single stream. + +### 4. Synthesized Definitions for Unknown Streams + +**Decision**: When `SQUAD_TEAM` or `.squad-stream` specifies a stream name not in the config, synthesize a minimal definition with `labelFilter: "team:{name}"`. + +**Rationale**: Fail-open is better than fail-closed. Users should be able to set `SQUAD_TEAM=my-team` without needing to update `streams.json` first. The convention of `team:{name}` is predictable and consistent. + +## Future Work + +### Meta-Coordinator + +A "coordinator of coordinators" that: +- Monitors all streams for cross-cutting concerns +- Detects when one stream's work blocks another +- Suggests label assignments for ambiguous issues +- Produces a unified status dashboard + +### Cross-Stream Dependencies + +Track when a stream needs work from another stream: +- Automated detection via import graphs +- Cross-stream issue linking +- Priority escalation for blocking dependencies + +### Stream Templates + +Pre-built stream configurations for common architectures: +- **Frontend/Backend** — 2 streams for web apps +- **Monorepo** — 1 stream per package +- **Microservices** — 1 stream per service +- **Feature teams** — dynamic streams per feature area diff --git a/package-lock.json b/package-lock.json index 0824e45e..1353d978 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@bradygaster/squad", - "version": "0.8.19-preview.1", + "version": "0.8.18-preview.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bradygaster/squad", - "version": "0.8.19-preview.1", + "version": "0.8.18-preview.1", "license": "MIT", "workspaces": [ "packages/*" @@ -5290,7 +5290,7 @@ }, "packages/squad-cli": { "name": "@bradygaster/squad-cli", - "version": "0.8.19-preview.1", + "version": "0.8.18-preview.1", "license": "MIT", "dependencies": { "@bradygaster/squad-sdk": "*", @@ -5314,7 +5314,7 @@ }, "packages/squad-sdk": { "name": "@bradygaster/squad-sdk", - "version": "0.8.19-preview.1", + "version": "0.8.18-preview.1", "license": "MIT", "dependencies": { "@github/copilot-sdk": "^0.1.29" diff --git a/package.json b/package.json index 4aaf04aa..d9cec809 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,9 @@ { "name": "@bradygaster/squad", +<<<<<<< HEAD "version": "0.8.21-preview.1", +======= + "version": "0.8.18-preview.2", "private": true, "description": "Squad — Programmable multi-agent runtime for GitHub Copilot, built on @github/copilot-sdk", "type": "module", @@ -50,3 +53,4 @@ "url": "https://github.com/bradygaster/squad.git" } } + diff --git a/packages/squad-cli/package.json b/packages/squad-cli/package.json index 569299fc..94856a46 100644 --- a/packages/squad-cli/package.json +++ b/packages/squad-cli/package.json @@ -1,6 +1,9 @@ { "name": "@bradygaster/squad-cli", +<<<<<<< HEAD "version": "0.8.21-preview.1", +======= + "version": "0.8.18-preview.2", "description": "Squad CLI — Command-line interface for the Squad multi-agent runtime", "type": "module", "bin": { @@ -154,3 +157,4 @@ "directory": "packages/squad-cli" } } + diff --git a/packages/squad-cli/src/cli-entry.ts b/packages/squad-cli/src/cli-entry.ts index e0989212..4c922998 100644 --- a/packages/squad-cli/src/cli-entry.ts +++ b/packages/squad-cli/src/cli-entry.ts @@ -71,6 +71,8 @@ async function main(): Promise { console.log(` Usage: nap [--deep] [--dry-run]`); console.log(` Flags: --deep (thorough cleanup), --dry-run (preview only)`); console.log(` ${BOLD}doctor${RESET} Validate squad setup (check files, config, health)`); + console.log(` ${BOLD}workstreams${RESET} Manage Squad Workstreams (multi-Codespace scaling)`); + console.log(` Usage: workstreams >`); console.log(` ${BOLD}help${RESET} Show this help message`); console.log(`\nFlags:`); console.log(` ${BOLD}--version, -v${RESET} Print version`); @@ -231,6 +233,12 @@ async function main(): Promise { return; } + if (cmd === 'workstreams' || cmd === 'streams') { + const { runStreams } = await import('./cli/commands/streams.js'); + await runStreams(process.cwd(), args.slice(1)); + return; + } + if (cmd === 'start') { const { runStart } = await import('./cli/commands/start.js'); const hasTunnel = args.includes('--tunnel'); @@ -277,3 +285,5 @@ main().catch(err => { } process.exit(1); }); + + diff --git a/packages/squad-cli/src/cli/commands/streams.ts b/packages/squad-cli/src/cli/commands/streams.ts new file mode 100644 index 00000000..cdd5de2e --- /dev/null +++ b/packages/squad-cli/src/cli/commands/streams.ts @@ -0,0 +1,166 @@ +/** + * CLI command: squad streams + * + * Subcommands: + * list — Show configured streams + * status — Show activity per stream (branches, PRs) + * activate — Write .squad-stream file to activate a stream + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { execSync } from 'node:child_process'; +import { loadStreamsConfig, resolveStream } from '@bradygaster/squad-sdk'; +import type { StreamDefinition } from '@bradygaster/squad-sdk'; + +const BOLD = '\x1b[1m'; +const RESET = '\x1b[0m'; +const DIM = '\x1b[2m'; +const GREEN = '\x1b[32m'; +const YELLOW = '\x1b[33m'; +const RED = '\x1b[31m'; + +/** + * Entry point for `squad streams` subcommand. + */ +export async function runStreams(cwd: string, args: string[]): Promise { + const sub = args[0]; + + if (!sub || sub === 'list') { + return listStreams(cwd); + } + if (sub === 'status') { + return showStreamStatus(cwd); + } + if (sub === 'activate') { + const name = args[1]; + if (!name) { + console.error(`${RED}✗${RESET} Usage: squad streams activate `); + process.exit(1); + } + return activateStream(cwd, name); + } + + console.error(`${RED}✗${RESET} Unknown streams subcommand: ${sub}`); + console.log(`\nUsage: squad streams >`); + process.exit(1); +} + +/** + * List configured streams. + */ +function listStreams(cwd: string): void { + const config = loadStreamsConfig(cwd); + const active = resolveStream(cwd); + + if (!config || config.streams.length === 0) { + console.log(`\n${DIM}No streams configured.${RESET}`); + console.log(`${DIM}Create .squad/streams.json to define streams.${RESET}\n`); + return; + } + + console.log(`\n${BOLD}Configured Streams${RESET}\n`); + console.log(` Default workflow: ${config.defaultWorkflow}\n`); + + for (const stream of config.streams) { + const isActive = active?.name === stream.name; + const marker = isActive ? `${GREEN}● active${RESET}` : `${DIM}○${RESET}`; + const workflow = stream.workflow ?? config.defaultWorkflow; + console.log(` ${marker} ${BOLD}${stream.name}${RESET}`); + console.log(` Label: ${stream.labelFilter}`); + console.log(` Workflow: ${workflow}`); + if (stream.folderScope?.length) { + console.log(` Folders: ${stream.folderScope.join(', ')}`); + } + if (stream.description) { + console.log(` ${DIM}${stream.description}${RESET}`); + } + console.log(); + } + + if (active) { + console.log(` ${DIM}Active stream resolved via: ${active.source}${RESET}\n`); + } +} + +/** + * Show activity per stream (branches, PRs via gh CLI). + */ +function showStreamStatus(cwd: string): void { + const config = loadStreamsConfig(cwd); + const active = resolveStream(cwd); + + if (!config || config.streams.length === 0) { + console.log(`\n${DIM}No streams configured.${RESET}\n`); + return; + } + + console.log(`\n${BOLD}Stream Status${RESET}\n`); + + for (const stream of config.streams) { + const isActive = active?.name === stream.name; + const marker = isActive ? `${GREEN}●${RESET}` : `${DIM}○${RESET}`; + console.log(` ${marker} ${BOLD}${stream.name}${RESET} (${stream.labelFilter})`); + + // Try to get PR and branch info via gh CLI + try { + const prOutput = execSync( + `gh pr list --label "${stream.labelFilter}" --json number,title,state --limit 5`, + { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }, + ); + const prs = JSON.parse(prOutput) as Array<{ number: number; title: string; state: string }>; + if (prs.length > 0) { + console.log(` ${YELLOW}PRs:${RESET}`); + for (const pr of prs) { + console.log(` #${pr.number} ${pr.title} (${pr.state})`); + } + } else { + console.log(` ${DIM}No open PRs${RESET}`); + } + } catch { + console.log(` ${DIM}(gh CLI not available — skipping PR lookup)${RESET}`); + } + + // Try to get branch info + try { + const branchOutput = execSync( + `git branch --list "*${stream.name}*"`, + { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }, + ); + const branches = branchOutput.trim().split('\n').filter(Boolean); + if (branches.length > 0) { + console.log(` ${YELLOW}Branches:${RESET}`); + for (const branch of branches) { + console.log(` ${branch.trim()}`); + } + } + } catch { + // Git not available — skip + } + + console.log(); + } +} + +/** + * Activate a stream by writing .squad-stream file. + */ +function activateStream(cwd: string, name: string): void { + const config = loadStreamsConfig(cwd); + + // Validate the stream exists in config (warn if not, but still allow) + if (config) { + const found = config.streams.find(s => s.name === name); + if (!found) { + console.log(`${YELLOW}⚠${RESET} Stream "${name}" not found in .squad/streams.json`); + console.log(` Available: ${config.streams.map(s => s.name).join(', ')}`); + console.log(` Writing .squad-stream anyway...\n`); + } + } + + const streamFilePath = path.join(cwd, '.squad-stream'); + fs.writeFileSync(streamFilePath, name + '\n', 'utf-8'); + console.log(`${GREEN}✓${RESET} Activated stream: ${BOLD}${name}${RESET}`); + console.log(` Written to: ${streamFilePath}`); + console.log(`${DIM} (This file is gitignored — it's local to your machine/Codespace)${RESET}\n`); +} diff --git a/packages/squad-sdk/package.json b/packages/squad-sdk/package.json index d5c31606..4460a3e9 100644 --- a/packages/squad-sdk/package.json +++ b/packages/squad-sdk/package.json @@ -1,6 +1,9 @@ { "name": "@bradygaster/squad-sdk", +<<<<<<< HEAD "version": "0.8.21-preview.1", +======= + "version": "0.8.18-preview.2", "description": "Squad SDK — Programmable multi-agent runtime for GitHub Copilot", "type": "module", "main": "./dist/index.js", @@ -205,3 +208,4 @@ "directory": "packages/squad-sdk" } } + diff --git a/packages/squad-sdk/src/config/init.ts b/packages/squad-sdk/src/config/init.ts index d5cc5531..64e2c46b 100644 --- a/packages/squad-sdk/src/config/init.ts +++ b/packages/squad-sdk/src/config/init.ts @@ -14,6 +14,7 @@ import { fileURLToPath } from 'url'; import { existsSync, cpSync, statSync, mkdirSync, writeFileSync, readFileSync, readdirSync } from 'fs'; import { MODELS } from '../runtime/constants.js'; import type { SquadConfig, ModelSelectionConfig, RoutingConfig } from '../runtime/config.js'; +import type { StreamDefinition } from '../streams/types.js'; // ============================================================================ // Template Resolution @@ -109,6 +110,8 @@ export interface InitOptions { prompt?: string; /** If true, disable extraction from consult sessions (read-only consultations) */ extractionDisabled?: boolean; + /** Optional stream definitions — generates .squad/streams.json when provided */ + streams?: StreamDefinition[]; } /** @@ -844,6 +847,37 @@ ${projectDescription ? `- **Description:** ${projectDescription}\n` : ''}- **Cre } } + // ------------------------------------------------------------------------- + // Generate .squad/streams.json (when streams provided) + // ------------------------------------------------------------------------- + + if (options.streams && options.streams.length > 0) { + const streamsConfig = { + streams: options.streams, + defaultWorkflow: 'branch-per-issue', + }; + const streamsPath = join(squadDir, 'streams.json'); + await writeIfNotExists(streamsPath, JSON.stringify(streamsConfig, null, 2) + '\n'); + } + + // ------------------------------------------------------------------------- + // Add .squad-stream to .gitignore + // ------------------------------------------------------------------------- + + { + const streamIgnoreEntry = '.squad-stream'; + let currentIgnore = ''; + if (existsSync(gitignorePath)) { + currentIgnore = readFileSync(gitignorePath, 'utf-8'); + } + if (!currentIgnore.includes(streamIgnoreEntry)) { + const block = (currentIgnore && !currentIgnore.endsWith('\n') ? '\n' : '') + + '# Squad: stream activation file (local to this machine)\n' + + streamIgnoreEntry + '\n'; + await appendFile(gitignorePath, block); + } + } + // ------------------------------------------------------------------------- // Create .first-run marker // ------------------------------------------------------------------------- diff --git a/packages/squad-sdk/src/index.ts b/packages/squad-sdk/src/index.ts index 419862fc..fe958788 100644 --- a/packages/squad-sdk/src/index.ts +++ b/packages/squad-sdk/src/index.ts @@ -39,3 +39,4 @@ export * from './build/index.js'; export * from './sharing/index.js'; export * from './upstream/index.js'; export * from './remote/index.js'; +export * from './streams/index.js'; diff --git a/packages/squad-sdk/src/streams/filter.ts b/packages/squad-sdk/src/streams/filter.ts new file mode 100644 index 00000000..1f223d9b --- /dev/null +++ b/packages/squad-sdk/src/streams/filter.ts @@ -0,0 +1,42 @@ +/** + * Stream-Aware Issue Filtering + * + * Filters GitHub issues to only those matching a stream's labelFilter. + * Used by Ralph during triage to scope work to the active stream. + * + * @module streams/filter + */ + +import type { ResolvedStream } from './types.js'; + +/** Minimal issue shape for filtering. */ +export interface StreamIssue { + number: number; + title: string; + labels: Array<{ name: string }>; +} + +/** + * Filter issues to only those matching the stream's label filter. + * + * Matching is case-insensitive. If the stream has no labelFilter, + * all issues are returned (passthrough). + * + * @param issues - Array of issues to filter + * @param stream - The resolved stream to filter by + * @returns Filtered array of issues matching the stream's label + */ +export function filterIssuesByStream( + issues: StreamIssue[], + stream: ResolvedStream, +): StreamIssue[] { + const filter = stream.definition.labelFilter; + if (!filter) { + return issues; + } + + const normalizedFilter = filter.toLowerCase(); + return issues.filter(issue => + issue.labels.some(label => label.name.toLowerCase() === normalizedFilter), + ); +} diff --git a/packages/squad-sdk/src/streams/index.ts b/packages/squad-sdk/src/streams/index.ts new file mode 100644 index 00000000..4c6f1c7f --- /dev/null +++ b/packages/squad-sdk/src/streams/index.ts @@ -0,0 +1,9 @@ +/** + * Streams module — barrel exports. + * + * @module streams + */ + +export * from './types.js'; +export * from './resolver.js'; +export * from './filter.js'; diff --git a/packages/squad-sdk/src/streams/resolver.ts b/packages/squad-sdk/src/streams/resolver.ts new file mode 100644 index 00000000..abe04052 --- /dev/null +++ b/packages/squad-sdk/src/streams/resolver.ts @@ -0,0 +1,130 @@ +/** + * Stream Resolver — Resolves which stream is active. + * + * Resolution order: + * 1. SQUAD_TEAM env var → look up in streams config + * 2. .squad-stream file (gitignored) → contains stream name + * 3. squad.config.ts → streams.active field (via .squad/streams.json) + * 4. null (no stream — single-squad mode) + * + * @module streams/resolver + */ + +import { existsSync, readFileSync } from 'fs'; +import { join } from 'path'; +import type { StreamConfig, StreamDefinition, ResolvedStream } from './types.js'; + +/** + * Load streams configuration from .squad/streams.json. + * + * @param squadRoot - Root directory of the project (where .squad/ lives) + * @returns Parsed StreamConfig or null if not found / invalid + */ +export function loadStreamsConfig(squadRoot: string): StreamConfig | null { + const configPath = join(squadRoot, '.squad', 'streams.json'); + if (!existsSync(configPath)) { + return null; + } + + try { + const raw = readFileSync(configPath, 'utf-8'); + const parsed = JSON.parse(raw) as StreamConfig; + + // Basic validation + if (!parsed.streams || !Array.isArray(parsed.streams)) { + return null; + } + + // Ensure defaultWorkflow has a value + if (!parsed.defaultWorkflow) { + parsed.defaultWorkflow = 'branch-per-issue'; + } + + return parsed; + } catch { + return null; + } +} + +/** + * Find a stream definition by name in a config. + */ +function findStream(config: StreamConfig, name: string): StreamDefinition | undefined { + return config.streams.find(s => s.name === name); +} + +/** + * Resolve which stream is active for the current environment. + * + * @param squadRoot - Root directory of the project + * @returns ResolvedStream or null if no stream is active + */ +export function resolveStream(squadRoot: string): ResolvedStream | null { + const config = loadStreamsConfig(squadRoot); + + // 1. SQUAD_TEAM env var + const envTeam = process.env.SQUAD_TEAM; + if (envTeam) { + if (config) { + const def = findStream(config, envTeam); + if (def) { + return { name: envTeam, definition: def, source: 'env' }; + } + } + // Env var set but no matching stream config — synthesize a minimal definition + return { + name: envTeam, + definition: { + name: envTeam, + labelFilter: `team:${envTeam}`, + }, + source: 'env', + }; + } + + // 2. .squad-stream file + const streamFilePath = join(squadRoot, '.squad-stream'); + if (existsSync(streamFilePath)) { + try { + const streamName = readFileSync(streamFilePath, 'utf-8').trim(); + if (streamName) { + if (config) { + const def = findStream(config, streamName); + if (def) { + return { name: streamName, definition: def, source: 'file' }; + } + } + // File exists but no config — synthesize + return { + name: streamName, + definition: { + name: streamName, + labelFilter: `team:${streamName}`, + }, + source: 'file', + }; + } + } catch { + // Ignore read errors + } + } + + // 3. streams.json with an "active" field (convention: first stream if only one) + if (config && config.streams.length === 1) { + const def = config.streams[0]!; + return { name: def.name, definition: def, source: 'config' }; + } + + // 4. No stream detected + return null; +} + +/** + * Get the GitHub label filter string for a resolved stream. + * + * @param stream - The resolved stream + * @returns Label filter string (e.g., "team:ui") + */ +export function getStreamLabelFilter(stream: ResolvedStream): string { + return stream.definition.labelFilter; +} diff --git a/packages/squad-sdk/src/streams/types.ts b/packages/squad-sdk/src/streams/types.ts new file mode 100644 index 00000000..ba28d13b --- /dev/null +++ b/packages/squad-sdk/src/streams/types.ts @@ -0,0 +1,40 @@ +/** + * Stream Types — Type definitions for Squad Streams. + * + * Streams enable horizontal scaling by allowing multiple Squad instances + * (e.g., in different Codespaces) to each handle a scoped subset of work. + * + * @module streams/types + */ + +/** Definition of a single stream (team partition). */ +export interface StreamDefinition { + /** Stream name, e.g., "ui-team", "backend-team" */ + name: string; + /** GitHub label to filter issues by, e.g., "team:ui" */ + labelFilter: string; + /** Optional folder restrictions, e.g., ["apps/web"] */ + folderScope?: string[]; + /** Workflow mode. Default: branch-per-issue */ + workflow?: 'branch-per-issue' | 'direct'; + /** Human-readable description of this stream's purpose */ + description?: string; +} + +/** Top-level streams configuration (stored in .squad/streams.json). */ +export interface StreamConfig { + /** All configured streams */ + streams: StreamDefinition[]; + /** Default workflow for streams that don't specify one */ + defaultWorkflow: 'branch-per-issue' | 'direct'; +} + +/** A resolved stream with provenance information. */ +export interface ResolvedStream { + /** Stream name */ + name: string; + /** Full stream definition */ + definition: StreamDefinition; + /** How this stream was resolved */ + source: 'env' | 'file' | 'config'; +} diff --git a/packages/squad-sdk/src/types.ts b/packages/squad-sdk/src/types.ts index 0563d751..856ae8a8 100644 --- a/packages/squad-sdk/src/types.ts +++ b/packages/squad-sdk/src/types.ts @@ -57,3 +57,8 @@ export type { SquadCustomAgentConfig } from './adapter/types.js'; export type { SquadEntry } from './multi-squad.js'; export type { MultiSquadConfig } from './multi-squad.js'; export type { SquadInfo } from './multi-squad.js'; + +// --- Stream types (streams/types.ts) --- +export type { StreamDefinition } from './streams/types.js'; +export type { StreamConfig } from './streams/types.js'; +export type { ResolvedStream } from './streams/types.js'; diff --git a/templates/squad.agent.md b/templates/squad.agent.md index 1af35a5a..026da8dc 100644 --- a/templates/squad.agent.md +++ b/templates/squad.agent.md @@ -111,6 +111,17 @@ When triggered: **Casting migration check:** If `.squad/team.md` exists but `.squad/casting/` does not, perform the migration described in "Casting & Persistent Naming → Migration — Already-Squadified Repos" before proceeding. +### Stream Awareness + +On session start, check for stream context: +1. Read `SQUAD_TEAM` env var +2. If set, read `.squad/streams.json` and find matching stream +3. Apply the stream's `labelFilter` — Ralph should ONLY pick up issues matching this label +4. Apply the stream's `workflow` — if `branch-per-issue`, enforce creating a branch and PR for every issue (never commit directly to main) +5. Apply `folderScope` — agents should only modify files in these directories + +If no stream is detected, operate in default single-squad mode. + ### Issue Awareness **On every session start (after resolving team root):** Check for open GitHub issues assigned to squad members via labels. Use the GitHub CLI or API to list issues with `squad:*` labels: diff --git a/test/streams.test.ts b/test/streams.test.ts new file mode 100644 index 00000000..477b8d40 --- /dev/null +++ b/test/streams.test.ts @@ -0,0 +1,592 @@ +/** + * Squad Streams — Comprehensive Tests + * + * Tests cover: + * - Stream types (compile-time, verified via usage) + * - Stream resolution (env var, file, config, fallback) + * - Label-based filtering (match, no match, multiple labels, case insensitive) + * - Config loading / validation + * - CLI activate command (writes .squad-stream) + * - Init with streams (generates streams.json) + * - Edge cases (empty streams, invalid JSON, missing env, passthrough) + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; + +import { + loadStreamsConfig, + resolveStream, + getStreamLabelFilter, + filterIssuesByStream, +} from '../packages/squad-sdk/src/streams/index.js'; + +import type { + StreamDefinition, + StreamConfig, + ResolvedStream, + StreamIssue, +} from '../packages/squad-sdk/src/streams/index.js'; + +// ============================================================================ +// Helpers +// ============================================================================ + +function makeTmpDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'squad-streams-test-')); +} + +function writeSquadStreamsConfig(root: string, config: StreamConfig): void { + const squadDir = path.join(root, '.squad'); + fs.mkdirSync(squadDir, { recursive: true }); + fs.writeFileSync(path.join(squadDir, 'streams.json'), JSON.stringify(config, null, 2), 'utf-8'); +} + +function writeSquadStreamFile(root: string, name: string): void { + fs.writeFileSync(path.join(root, '.squad-stream'), name + '\n', 'utf-8'); +} + +const SAMPLE_CONFIG: StreamConfig = { + streams: [ + { name: 'ui-team', labelFilter: 'team:ui', folderScope: ['apps/web'], workflow: 'branch-per-issue', description: 'UI specialists' }, + { name: 'backend-team', labelFilter: 'team:backend', folderScope: ['apps/api'], workflow: 'direct' }, + { name: 'infra-team', labelFilter: 'team:infra' }, + ], + defaultWorkflow: 'branch-per-issue', +}; + +const SAMPLE_ISSUES: StreamIssue[] = [ + { number: 1, title: 'Fix button color', labels: [{ name: 'team:ui' }, { name: 'bug' }] }, + { number: 2, title: 'Add REST endpoint', labels: [{ name: 'team:backend' }] }, + { number: 3, title: 'Setup CI', labels: [{ name: 'team:infra' }] }, + { number: 4, title: 'Unscoped issue', labels: [{ name: 'bug' }] }, + { number: 5, title: 'Multi-label', labels: [{ name: 'team:ui' }, { name: 'team:backend' }] }, +]; + +// ============================================================================ +// loadStreamsConfig +// ============================================================================ + +describe('loadStreamsConfig', () => { + let tmpDir: string; + + beforeEach(() => { tmpDir = makeTmpDir(); }); + afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); + + it('returns null when .squad/streams.json does not exist', () => { + expect(loadStreamsConfig(tmpDir)).toBeNull(); + }); + + it('loads a valid streams config', () => { + writeSquadStreamsConfig(tmpDir, SAMPLE_CONFIG); + const result = loadStreamsConfig(tmpDir); + expect(result).not.toBeNull(); + expect(result!.streams).toHaveLength(3); + expect(result!.defaultWorkflow).toBe('branch-per-issue'); + }); + + it('returns null for invalid JSON', () => { + const squadDir = path.join(tmpDir, '.squad'); + fs.mkdirSync(squadDir, { recursive: true }); + fs.writeFileSync(path.join(squadDir, 'streams.json'), '{invalid', 'utf-8'); + expect(loadStreamsConfig(tmpDir)).toBeNull(); + }); + + it('returns null when streams array is missing', () => { + const squadDir = path.join(tmpDir, '.squad'); + fs.mkdirSync(squadDir, { recursive: true }); + fs.writeFileSync(path.join(squadDir, 'streams.json'), '{"defaultWorkflow":"direct"}', 'utf-8'); + expect(loadStreamsConfig(tmpDir)).toBeNull(); + }); + + it('defaults defaultWorkflow to branch-per-issue when missing', () => { + const squadDir = path.join(tmpDir, '.squad'); + fs.mkdirSync(squadDir, { recursive: true }); + fs.writeFileSync(path.join(squadDir, 'streams.json'), '{"streams":[{"name":"a","labelFilter":"x"}]}', 'utf-8'); + const result = loadStreamsConfig(tmpDir); + expect(result!.defaultWorkflow).toBe('branch-per-issue'); + }); + + it('preserves folderScope arrays', () => { + writeSquadStreamsConfig(tmpDir, SAMPLE_CONFIG); + const result = loadStreamsConfig(tmpDir)!; + expect(result.streams[0]!.folderScope).toEqual(['apps/web']); + }); + + it('preserves optional description', () => { + writeSquadStreamsConfig(tmpDir, SAMPLE_CONFIG); + const result = loadStreamsConfig(tmpDir)!; + expect(result.streams[0]!.description).toBe('UI specialists'); + expect(result.streams[1]!.description).toBeUndefined(); + }); +}); + +// ============================================================================ +// resolveStream +// ============================================================================ + +describe('resolveStream', () => { + let tmpDir: string; + const origEnv = process.env.SQUAD_TEAM; + + beforeEach(() => { + tmpDir = makeTmpDir(); + delete process.env.SQUAD_TEAM; + }); + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + if (origEnv !== undefined) { + process.env.SQUAD_TEAM = origEnv; + } else { + delete process.env.SQUAD_TEAM; + } + }); + + // --- Env var resolution --- + + it('resolves from SQUAD_TEAM env var with matching config', () => { + process.env.SQUAD_TEAM = 'ui-team'; + writeSquadStreamsConfig(tmpDir, SAMPLE_CONFIG); + const result = resolveStream(tmpDir); + expect(result).not.toBeNull(); + expect(result!.name).toBe('ui-team'); + expect(result!.source).toBe('env'); + expect(result!.definition.labelFilter).toBe('team:ui'); + expect(result!.definition.folderScope).toEqual(['apps/web']); + }); + + it('synthesizes definition from SQUAD_TEAM when no config exists', () => { + process.env.SQUAD_TEAM = 'custom-team'; + const result = resolveStream(tmpDir); + expect(result).not.toBeNull(); + expect(result!.name).toBe('custom-team'); + expect(result!.source).toBe('env'); + expect(result!.definition.labelFilter).toBe('team:custom-team'); + }); + + it('synthesizes definition from SQUAD_TEAM when stream not in config', () => { + process.env.SQUAD_TEAM = 'unknown-team'; + writeSquadStreamsConfig(tmpDir, SAMPLE_CONFIG); + const result = resolveStream(tmpDir); + expect(result).not.toBeNull(); + expect(result!.name).toBe('unknown-team'); + expect(result!.source).toBe('env'); + expect(result!.definition.labelFilter).toBe('team:unknown-team'); + }); + + // --- File resolution --- + + it('resolves from .squad-stream file with matching config', () => { + writeSquadStreamsConfig(tmpDir, SAMPLE_CONFIG); + writeSquadStreamFile(tmpDir, 'backend-team'); + const result = resolveStream(tmpDir); + expect(result).not.toBeNull(); + expect(result!.name).toBe('backend-team'); + expect(result!.source).toBe('file'); + expect(result!.definition.workflow).toBe('direct'); + }); + + it('synthesizes definition from .squad-stream file when no config', () => { + writeSquadStreamFile(tmpDir, 'my-stream'); + const result = resolveStream(tmpDir); + expect(result).not.toBeNull(); + expect(result!.name).toBe('my-stream'); + expect(result!.source).toBe('file'); + expect(result!.definition.labelFilter).toBe('team:my-stream'); + }); + + it('ignores empty .squad-stream file', () => { + fs.writeFileSync(path.join(tmpDir, '.squad-stream'), ' \n', 'utf-8'); + expect(resolveStream(tmpDir)).toBeNull(); + }); + + it('trims whitespace from .squad-stream file', () => { + writeSquadStreamsConfig(tmpDir, SAMPLE_CONFIG); + fs.writeFileSync(path.join(tmpDir, '.squad-stream'), ' ui-team \n', 'utf-8'); + const result = resolveStream(tmpDir); + expect(result!.name).toBe('ui-team'); + expect(result!.source).toBe('file'); + }); + + // --- Config resolution (single stream auto-select) --- + + it('auto-selects single stream from config', () => { + const singleConfig: StreamConfig = { + streams: [{ name: 'solo', labelFilter: 'team:solo' }], + defaultWorkflow: 'direct', + }; + writeSquadStreamsConfig(tmpDir, singleConfig); + const result = resolveStream(tmpDir); + expect(result).not.toBeNull(); + expect(result!.name).toBe('solo'); + expect(result!.source).toBe('config'); + }); + + // --- Fallback --- + + it('returns null when no stream context exists', () => { + expect(resolveStream(tmpDir)).toBeNull(); + }); + + it('returns null when config has multiple streams but no env/file', () => { + writeSquadStreamsConfig(tmpDir, SAMPLE_CONFIG); + expect(resolveStream(tmpDir)).toBeNull(); + }); + + // --- Priority order --- + + it('env var takes priority over .squad-stream file', () => { + process.env.SQUAD_TEAM = 'ui-team'; + writeSquadStreamsConfig(tmpDir, SAMPLE_CONFIG); + writeSquadStreamFile(tmpDir, 'backend-team'); + const result = resolveStream(tmpDir); + expect(result!.name).toBe('ui-team'); + expect(result!.source).toBe('env'); + }); + + it('.squad-stream file takes priority over config auto-select', () => { + const singleConfig: StreamConfig = { + streams: [ + { name: 'alpha', labelFilter: 'team:alpha' }, + ], + defaultWorkflow: 'branch-per-issue', + }; + writeSquadStreamsConfig(tmpDir, singleConfig); + writeSquadStreamFile(tmpDir, 'alpha'); + const result = resolveStream(tmpDir); + // file source takes priority + expect(result!.source).toBe('file'); + }); +}); + +// ============================================================================ +// getStreamLabelFilter +// ============================================================================ + +describe('getStreamLabelFilter', () => { + it('returns the label filter from definition', () => { + const stream: ResolvedStream = { + name: 'ui-team', + definition: { name: 'ui-team', labelFilter: 'team:ui' }, + source: 'env', + }; + expect(getStreamLabelFilter(stream)).toBe('team:ui'); + }); + + it('returns synthesized label filter', () => { + const stream: ResolvedStream = { + name: 'custom', + definition: { name: 'custom', labelFilter: 'team:custom' }, + source: 'file', + }; + expect(getStreamLabelFilter(stream)).toBe('team:custom'); + }); +}); + +// ============================================================================ +// filterIssuesByStream +// ============================================================================ + +describe('filterIssuesByStream', () => { + it('filters issues matching the stream label', () => { + const stream: ResolvedStream = { + name: 'ui-team', + definition: { name: 'ui-team', labelFilter: 'team:ui' }, + source: 'env', + }; + const result = filterIssuesByStream(SAMPLE_ISSUES, stream); + expect(result).toHaveLength(2); // issue 1 and 5 + expect(result.map(i => i.number)).toEqual([1, 5]); + }); + + it('returns empty array when no issues match', () => { + const stream: ResolvedStream = { + name: 'qa-team', + definition: { name: 'qa-team', labelFilter: 'team:qa' }, + source: 'env', + }; + const result = filterIssuesByStream(SAMPLE_ISSUES, stream); + expect(result).toHaveLength(0); + }); + + it('handles case-insensitive matching', () => { + const stream: ResolvedStream = { + name: 'ui-team', + definition: { name: 'ui-team', labelFilter: 'TEAM:UI' }, + source: 'env', + }; + const result = filterIssuesByStream(SAMPLE_ISSUES, stream); + expect(result).toHaveLength(2); + }); + + it('returns all issues when labelFilter is empty', () => { + const stream: ResolvedStream = { + name: 'all', + definition: { name: 'all', labelFilter: '' }, + source: 'env', + }; + const result = filterIssuesByStream(SAMPLE_ISSUES, stream); + expect(result).toHaveLength(SAMPLE_ISSUES.length); + }); + + it('handles issues with no labels', () => { + const issues: StreamIssue[] = [ + { number: 10, title: 'No labels', labels: [] }, + ]; + const stream: ResolvedStream = { + name: 'ui-team', + definition: { name: 'ui-team', labelFilter: 'team:ui' }, + source: 'env', + }; + expect(filterIssuesByStream(issues, stream)).toHaveLength(0); + }); + + it('handles empty issues array', () => { + const stream: ResolvedStream = { + name: 'ui-team', + definition: { name: 'ui-team', labelFilter: 'team:ui' }, + source: 'env', + }; + expect(filterIssuesByStream([], stream)).toHaveLength(0); + }); + + it('filters backend-team correctly', () => { + const stream: ResolvedStream = { + name: 'backend-team', + definition: { name: 'backend-team', labelFilter: 'team:backend' }, + source: 'config', + }; + const result = filterIssuesByStream(SAMPLE_ISSUES, stream); + expect(result).toHaveLength(2); // issue 2 and 5 + expect(result.map(i => i.number)).toEqual([2, 5]); + }); + + it('filters infra-team correctly (single match)', () => { + const stream: ResolvedStream = { + name: 'infra-team', + definition: { name: 'infra-team', labelFilter: 'team:infra' }, + source: 'file', + }; + const result = filterIssuesByStream(SAMPLE_ISSUES, stream); + expect(result).toHaveLength(1); + expect(result[0]!.number).toBe(3); + }); +}); + +// ============================================================================ +// Type checks (compile-time — these just verify the types work) +// ============================================================================ + +describe('Stream types', () => { + it('StreamDefinition accepts all fields', () => { + const def: StreamDefinition = { + name: 'test', + labelFilter: 'team:test', + folderScope: ['src/'], + workflow: 'branch-per-issue', + description: 'Test stream', + }; + expect(def.name).toBe('test'); + expect(def.workflow).toBe('branch-per-issue'); + }); + + it('StreamDefinition works with minimal fields', () => { + const def: StreamDefinition = { + name: 'minimal', + labelFilter: 'team:minimal', + }; + expect(def.folderScope).toBeUndefined(); + expect(def.workflow).toBeUndefined(); + expect(def.description).toBeUndefined(); + }); + + it('StreamConfig has required fields', () => { + const config: StreamConfig = { + streams: [], + defaultWorkflow: 'direct', + }; + expect(config.streams).toEqual([]); + expect(config.defaultWorkflow).toBe('direct'); + }); + + it('ResolvedStream has source provenance', () => { + const resolved: ResolvedStream = { + name: 'test', + definition: { name: 'test', labelFilter: 'x' }, + source: 'env', + }; + expect(resolved.source).toBe('env'); + }); +}); + +// ============================================================================ +// Init integration (streams.json generation) +// ============================================================================ + +describe('initSquad with streams', () => { + let tmpDir: string; + + beforeEach(() => { tmpDir = makeTmpDir(); }); + afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); + + it('generates streams.json when streams option is provided', async () => { + const { initSquad } = await import('../packages/squad-sdk/src/config/init.js'); + const streams: StreamDefinition[] = [ + { name: 'ui-team', labelFilter: 'team:ui', folderScope: ['apps/web'] }, + { name: 'api-team', labelFilter: 'team:api' }, + ]; + + await initSquad({ + teamRoot: tmpDir, + projectName: 'test-streams', + agents: [{ name: 'lead', role: 'lead' }], + streams, + includeWorkflows: false, + includeTemplates: false, + includeMcpConfig: false, + }); + + const streamsPath = path.join(tmpDir, '.squad', 'streams.json'); + expect(fs.existsSync(streamsPath)).toBe(true); + + const content = JSON.parse(fs.readFileSync(streamsPath, 'utf-8')) as StreamConfig; + expect(content.streams).toHaveLength(2); + expect(content.streams[0]!.name).toBe('ui-team'); + expect(content.defaultWorkflow).toBe('branch-per-issue'); + }); + + it('does not generate streams.json when no streams provided', async () => { + const { initSquad } = await import('../packages/squad-sdk/src/config/init.js'); + + await initSquad({ + teamRoot: tmpDir, + projectName: 'test-no-streams', + agents: [{ name: 'lead', role: 'lead' }], + includeWorkflows: false, + includeTemplates: false, + includeMcpConfig: false, + }); + + const streamsPath = path.join(tmpDir, '.squad', 'streams.json'); + expect(fs.existsSync(streamsPath)).toBe(false); + }); + + it('adds .squad-stream to .gitignore', async () => { + const { initSquad } = await import('../packages/squad-sdk/src/config/init.js'); + + await initSquad({ + teamRoot: tmpDir, + projectName: 'test-gitignore', + agents: [{ name: 'lead', role: 'lead' }], + includeWorkflows: false, + includeTemplates: false, + includeMcpConfig: false, + }); + + const gitignorePath = path.join(tmpDir, '.gitignore'); + expect(fs.existsSync(gitignorePath)).toBe(true); + const content = fs.readFileSync(gitignorePath, 'utf-8'); + expect(content).toContain('.squad-stream'); + }); +}); + +// ============================================================================ +// CLI activate (unit test the file-writing behavior) +// ============================================================================ + +describe('CLI activate behavior', () => { + let tmpDir: string; + + beforeEach(() => { tmpDir = makeTmpDir(); }); + afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); + + it('writes .squad-stream file with the stream name', () => { + const filePath = path.join(tmpDir, '.squad-stream'); + fs.writeFileSync(filePath, 'my-stream\n', 'utf-8'); + const content = fs.readFileSync(filePath, 'utf-8').trim(); + expect(content).toBe('my-stream'); + }); + + it('resolves after activation', () => { + writeSquadStreamsConfig(tmpDir, SAMPLE_CONFIG); + writeSquadStreamFile(tmpDir, 'infra-team'); + const result = resolveStream(tmpDir); + expect(result).not.toBeNull(); + expect(result!.name).toBe('infra-team'); + expect(result!.definition.labelFilter).toBe('team:infra'); + }); + + it('overwriting .squad-stream changes active stream', () => { + writeSquadStreamsConfig(tmpDir, SAMPLE_CONFIG); + writeSquadStreamFile(tmpDir, 'ui-team'); + expect(resolveStream(tmpDir)!.name).toBe('ui-team'); + + writeSquadStreamFile(tmpDir, 'backend-team'); + expect(resolveStream(tmpDir)!.name).toBe('backend-team'); + }); +}); + +// ============================================================================ +// Edge cases +// ============================================================================ + +describe('Edge cases', () => { + let tmpDir: string; + const origEnv = process.env.SQUAD_TEAM; + + beforeEach(() => { + tmpDir = makeTmpDir(); + delete process.env.SQUAD_TEAM; + }); + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + if (origEnv !== undefined) { + process.env.SQUAD_TEAM = origEnv; + } else { + delete process.env.SQUAD_TEAM; + } + }); + + it('handles empty streams array in config', () => { + const emptyConfig: StreamConfig = { streams: [], defaultWorkflow: 'direct' }; + writeSquadStreamsConfig(tmpDir, emptyConfig); + expect(resolveStream(tmpDir)).toBeNull(); + }); + + it('handles config with streams but non-array type', () => { + const squadDir = path.join(tmpDir, '.squad'); + fs.mkdirSync(squadDir, { recursive: true }); + fs.writeFileSync(path.join(squadDir, 'streams.json'), '{"streams":"not-array"}', 'utf-8'); + expect(loadStreamsConfig(tmpDir)).toBeNull(); + }); + + it('handles SQUAD_TEAM set to empty string', () => { + process.env.SQUAD_TEAM = ''; + expect(resolveStream(tmpDir)).toBeNull(); + }); + + it('filterIssuesByStream handles labels with special characters', () => { + const issues: StreamIssue[] = [ + { number: 1, title: 'Test', labels: [{ name: 'team:front-end/ui' }] }, + ]; + const stream: ResolvedStream = { + name: 'fe', + definition: { name: 'fe', labelFilter: 'team:front-end/ui' }, + source: 'env', + }; + const result = filterIssuesByStream(issues, stream); + expect(result).toHaveLength(1); + }); + + it('resolves workflow from definition over defaultWorkflow', () => { + const config: StreamConfig = { + streams: [{ name: 'direct-stream', labelFilter: 'team:direct', workflow: 'direct' }], + defaultWorkflow: 'branch-per-issue', + }; + writeSquadStreamsConfig(tmpDir, config); + const result = resolveStream(tmpDir); + expect(result!.definition.workflow).toBe('direct'); + }); +}); From 806676842e299d4fc63c6434da5c6995d1b0e6e3 Mon Sep 17 00:00:00 2001 From: Tamir Dresher Date: Wed, 4 Mar 2026 21:16:27 +0200 Subject: [PATCH 2/9] docs: clarify folderScope as advisory, add single-machine multi-stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - folderScope is a guideline, not a hard lock — shared packages need cross-stream access - Add single-machine multi-stream cost optimization (sequential switching, v2 round-robin) - Document semantic conflict risk and mitigation - Update PRD and feature docs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitignore | 4 +- docs/features/streams.md | 75 ++-- docs/scenarios/multi-codespace.md | 40 +-- docs/specs/streams-prd.md | 108 ++++-- packages/squad-cli/src/cli-entry.ts | 5 +- .../squad-cli/src/cli/commands/streams.ts | 115 +++--- packages/squad-sdk/src/config/init.ts | 26 +- packages/squad-sdk/src/streams/filter.ts | 34 +- packages/squad-sdk/src/streams/index.ts | 2 +- packages/squad-sdk/src/streams/resolver.ts | 87 +++-- packages/squad-sdk/src/streams/types.ts | 43 ++- packages/squad-sdk/src/types.ts | 6 +- templates/squad.agent.md | 12 +- test/streams.test.ts | 334 +++++++++--------- 14 files changed, 490 insertions(+), 401 deletions(-) diff --git a/.gitignore b/.gitignore index fef2ead4..833ea377 100644 --- a/.gitignore +++ b/.gitignore @@ -11,5 +11,5 @@ coverage/ .test-cli-* # Docs site generated files docs/dist/ -# Squad: stream activation file (local to this machine) -.squad-stream +# Squad: workstream activation file (local to this machine) +.squad-workstream diff --git a/docs/features/streams.md b/docs/features/streams.md index 164d8bc5..1b718519 100644 --- a/docs/features/streams.md +++ b/docs/features/streams.md @@ -1,12 +1,12 @@ -# Squad Streams +# Squad Workstreams -> Scale Squad across multiple Codespaces by partitioning work into labeled streams. +> Scale Squad across multiple Codespaces by partitioning work into labeled workstreams. -## What Are Streams? +## What Are Workstreams? -A **stream** is a named partition of work within a Squad project. Each stream targets a specific GitHub label (e.g., `team:ui`, `team:backend`) and optionally restricts agents to certain directories. Multiple Squad instances — each running in its own Codespace — can each activate a different stream, enabling parallel work across teams. +A **workstream** is a named partition of work within a Squad project. Each workstream targets a specific GitHub label (e.g., `team:ui`, `team:backend`) and optionally restricts agents to certain directories. Multiple Squad instances — each running in its own Codespace — can each activate a different workstream, enabling parallel work across teams. -## Why Streams? +## Why Workstreams? Squad was originally designed for a single team per repository. As projects grow, a single Codespace becomes a bottleneck: @@ -14,15 +14,15 @@ Squad was originally designed for a single team per repository. As projects grow - **Context overload** — Ralph picks up all issues, not just the relevant ones - **Folder conflicts** — Multiple agents editing the same files causes merge pain -Streams solve this by giving each Codespace a scoped view of the project. +Workstreams solve this by giving each Codespace a scoped view of the project. ## Configuration -### 1. Create `.squad/streams.json` +### 1. Create `.squad/workstreams.json` ```json { - "streams": [ + "workstreams": [ { "name": "ui-team", "labelFilter": "team:ui", @@ -49,9 +49,9 @@ Streams solve this by giving each Codespace a scoped view of the project. } ``` -### 2. Activate a Stream +### 2. Activate a Workstream -There are three ways to tell Squad which stream to use: +There are three ways to tell Squad which workstream to use: #### Environment Variable (recommended for Codespaces) @@ -69,62 +69,77 @@ Set this in your Codespace's environment or devcontainer.json: } ``` -#### .squad-stream File (local activation) +#### .squad-workstream File (local activation) ```bash -squad streams activate ui-team +squad workstreams activate ui-team ``` -This writes a `.squad-stream` file (gitignored) so the setting is local to your machine. +This writes a `.squad-workstream` file (gitignored) so the setting is local to your machine. -#### Auto-select (single stream) +#### Auto-select (single workstream) -If `streams.json` contains only one stream, it's automatically selected. +If `workstreams.json` contains only one workstream, it's automatically selected. ### 3. Resolution Priority 1. `SQUAD_TEAM` env var (highest) -2. `.squad-stream` file -3. Single-stream auto-select -4. No stream (classic single-squad mode) +2. `.squad-workstream` file +3. Single-workstream auto-select +4. No workstream (classic single-squad mode) -## Stream Definition Fields +## Workstream Definition Fields | Field | Required | Description | |-------|----------|-------------| -| `name` | Yes | Unique stream identifier (kebab-case) | +| `name` | Yes | Unique workstream identifier (kebab-case) | | `labelFilter` | Yes | GitHub label to filter issues | -| `folderScope` | No | Directories this stream may modify | +| `folderScope` | No | Directories this workstream may modify | | `workflow` | No | `branch-per-issue` (default) or `direct` | | `description` | No | Human-readable purpose | ## CLI Reference ```bash -# List configured streams -squad streams list +# List configured workstreams +squad workstreams list -# Show stream activity (branches, PRs) -squad streams status +# Show workstream activity (branches, PRs) +squad workstreams status -# Activate a stream locally -squad streams activate +# Activate a workstream locally +squad workstreams activate ``` ## How It Works ### Triage (Ralph) -When a stream is active, Ralph's triage only picks up issues labeled with the stream's `labelFilter`. Unmatched issues are left for other streams or the main squad. +When a workstream is active, Ralph's triage only picks up issues labeled with the workstream's `labelFilter`. Unmatched issues are left for other workstreams or the main squad. ### Workflow Enforcement - **branch-per-issue** (default): Every issue gets its own branch and PR. Agents never commit directly to main. -- **direct**: Agents may commit directly (useful for infra/ops streams). +- **direct**: Agents may commit directly (useful for infra/ops workstreams). ### Folder Scope -When `folderScope` is set, agents should only modify files within those directories. This prevents cross-team file conflicts. +When `folderScope` is set, agents should primarily modify files within those directories. However, `folderScope` is **advisory, not a hard lock** — agents may still touch shared files (types, configs, package exports) when their issue requires it. The real protection comes from `branch-per-issue` workflow: each issue gets its own branch, so two workstreams editing the same file won't conflict until merge time. + +> **Tip:** If two workstreams' PRs touch the same file, Git resolves non-overlapping changes automatically. For semantic conflicts (incompatible API changes), use PR review to catch them. + +### Cost Optimization: Single-Machine Multi-Workstream + +You don't need a separate Codespace per workstream. One machine can serve multiple workstreams: + +```bash +# Switch between workstreams manually +squad workstreams activate ui-team # Ralph works team:ui issues +# ... later ... +squad workstreams activate backend-team # now works team:backend issues +``` + +This gives you 1× Codespace cost instead of N×, at the expense of serial (not parallel) execution. Each issue still gets its own branch — no conflicts. ## Example: Multi-Codespace Setup diff --git a/docs/scenarios/multi-codespace.md b/docs/scenarios/multi-codespace.md index e7d5eeb7..873e6390 100644 --- a/docs/scenarios/multi-codespace.md +++ b/docs/scenarios/multi-codespace.md @@ -1,28 +1,28 @@ -# Multi-Codespace Setup with Squad Streams +# Multi-Codespace Setup with Squad Workstreams > End-to-end walkthrough of running multiple Squad instances across Codespaces. ## Background: The Tetris Experiment -We validated Squad Streams by building a multiplayer Tetris game using 3 Codespaces, each running a separate stream: +We validated Squad Workstreams by building a multiplayer Tetris game using 3 Codespaces, each running a separate workstream: -| Codespace | Stream | Label | Focus | +| Codespace | Workstream | Label | Focus | |-----------|--------|-------|-------| | CS-1 | `ui-team` | `team:ui` | React game board, piece rendering, animations | | CS-2 | `backend-team` | `team:backend` | WebSocket server, game state, matchmaking | | CS-3 | `infra-team` | `team:infra` | CI/CD, Docker, deployment | -All three Codespaces shared the same repository. Each Squad instance only picked up issues matching its stream's label. +All three Codespaces shared the same repository. Each Squad instance only picked up issues matching its workstream's label. ## Setup Steps -### 1. Create the streams config +### 1. Create the workstreams config -In your repository, create `.squad/streams.json`: +In your repository, create `.squad/workstreams.json`: ```json { - "streams": [ + "workstreams": [ { "name": "ui-team", "labelFilter": "team:ui", @@ -67,7 +67,7 @@ In `.devcontainer/devcontainer.json`, set the `SQUAD_TEAM` env var. For multiple ```bash export SQUAD_TEAM=ui-team -squad # launches with stream context +squad # launches with workstream context ``` ### 3. Label your issues @@ -82,7 +82,7 @@ gh issue create --title "Add Docker compose for dev" --label "team:infra" ### 4. Launch Squad in each Codespace -Each Codespace runs `squad` normally. The stream context is detected automatically: +Each Codespace runs `squad` normally. The workstream context is detected automatically: ```bash # In Codespace 1 (SQUAD_TEAM=ui-team) @@ -96,32 +96,32 @@ squad # → Agents only modify files in src/server, src/shared ``` -### 5. Monitor across streams +### 5. Monitor across workstreams -Use the CLI from any Codespace to see all streams: +Use the CLI from any Codespace to see all workstreams: ```bash -squad streams status +squad workstreams status ``` - + ## What Worked -- **Clear separation**: Each stream had well-defined boundaries, minimizing merge conflicts +- **Clear separation**: Each workstream had well-defined boundaries, minimizing merge conflicts - **Parallel velocity**: 3x throughput vs. single-squad mode for independent work - **Label-based routing**: Simple, uses existing GitHub infrastructure ## What Didn't Work (Yet) -- **Cross-stream dependencies**: When the UI team needed a backend API change, manual coordination was required +- **Cross-workstream dependencies**: When the UI team needed a backend API change, manual coordination was required - **Shared files**: `package.json`, `tsconfig.json`, and other root files caused occasional conflicts -- **No meta-coordinator**: No automated way to coordinate across streams (future work) +- **No meta-coordinator**: No automated way to coordinate across workstreams (future work) ## Lessons Learned -1. **Keep streams independent** — design folder boundaries to minimize shared files -2. **Use branch-per-issue** — direct commits across streams cause merge hell -3. **Label everything** — unlabeled issues get lost between streams -4. **Start with 2 streams** — add more once the team finds its rhythm +1. **Keep workstreams independent** — design folder boundaries to minimize shared files +2. **Use branch-per-issue** — direct commits across workstreams cause merge hell +3. **Label everything** — unlabeled issues get lost between workstreams +4. **Start with 2 workstreams** — add more once the team finds its rhythm diff --git a/docs/specs/streams-prd.md b/docs/specs/streams-prd.md index 828fd956..30b01748 100644 --- a/docs/specs/streams-prd.md +++ b/docs/specs/streams-prd.md @@ -1,6 +1,6 @@ -# Streams PRD — Product Requirements Document +# Workstreams PRD — Product Requirements Document -> Scaling Squad across multiple Codespaces via labeled work streams. +> Scaling Squad across multiple Codespaces via labeled workstreams. ## Problem Statement @@ -13,37 +13,37 @@ Squad was designed for a single agent team per repository. In practice, larger p ### Validated by Experiment -We tested this with a 3-Codespace setup building a multiplayer Tetris game. Each Codespace ran Squad independently, manually filtering by GitHub labels. The results showed 3x throughput for independent work streams, validating the approach. However, the manual coordination was error-prone and needed to be automated. +We tested this with a 3-Codespace setup building a multiplayer Tetris game. Each Codespace ran Squad independently, manually filtering by GitHub labels. The results showed 3x throughput for independent workstreams, validating the approach. However, the manual coordination was error-prone and needed to be automated. ## Requirements ### Must Have (P0) -- [ ] **Stream Definition**: Define streams in `.squad/streams.json` with name, label filter, folder scope, and workflow -- [ ] **Stream Resolution**: Automatically detect active stream from env var, file, or config -- [ ] **Label Filtering**: Ralph only triages issues matching the stream's label -- [ ] **Folder Scoping**: Agents restrict modifications to stream's folder scope -- [ ] **CLI Management**: `squad streams list|status|activate` commands -- [ ] **Init Integration**: `squad init` optionally generates streams config -- [ ] **Agent Template**: squad.agent.md includes stream awareness instructions +- [ ] **Workstream Definition**: Define workstreams in `.squad/workstreams.json` with name, label filter, folder scope, and workflow +- [ ] **Workstream Resolution**: Automatically detect active workstream from env var, file, or config +- [ ] **Label Filtering**: Ralph only triages issues matching the workstream's label +- [ ] **Folder Scoping**: Agents restrict modifications to workstream's folder scope +- [ ] **CLI Management**: `squad workstreams list|status|activate` commands +- [ ] **Init Integration**: `squad init` optionally generates workstreams config +- [ ] **Agent Template**: squad.agent.md includes workstream awareness instructions ### Should Have (P1) -- [ ] **Stream Status Dashboard**: Show PR/branch activity per stream -- [ ] **Conflict Detection**: Warn when streams overlap on file paths +- [ ] **Workstream Status Dashboard**: Show PR/branch activity per workstream +- [ ] **Conflict Detection**: Warn when workstreams overlap on file paths - [ ] **Auto-labeling**: Suggest labels for new issues based on file paths ### Could Have (P2) -- [ ] **Meta-coordinator**: A coordinator that orchestrates across streams -- [ ] **Cross-stream dependencies**: Track and resolve inter-stream blockers -- [ ] **Stream metrics**: Throughput, cycle time, merge conflict rate per stream +- [ ] **Meta-coordinator**: A coordinator that orchestrates across workstreams +- [ ] **Cross-workstream dependencies**: Track and resolve inter-workstream blockers +- [ ] **Workstream metrics**: Throughput, cycle time, merge conflict rate per workstream ## Design Decisions ### 1. GitHub Labels as the Partition Key -**Decision**: Use GitHub labels (e.g., `team:ui`) to partition work across streams. +**Decision**: Use GitHub labels (e.g., `team:ui`) to partition work across workstreams. **Rationale**: Labels are a first-class GitHub concept. They're visible in the UI, queryable via API, and already used by Squad for agent assignment. No new infrastructure needed. @@ -52,9 +52,9 @@ We tested this with a 3-Codespace setup building a multiplayer Tetris game. Each - Separate repositories — too heavy, loses monorepo benefits - Branch-based partitioning — branches are for code, not work items -### 2. File-Based Stream Activation +### 2. File-Based Workstream Activation -**Decision**: Use `.squad-stream` file (gitignored) for local activation, `SQUAD_TEAM` env var for Codespaces. +**Decision**: Use `.squad-workstream` file (gitignored) for local activation, `SQUAD_TEAM` env var for Codespaces. **Rationale**: The file is simple and doesn't require environment configuration. The env var is ideal for Codespaces where the environment is defined in `devcontainer.json`. Both are easy to understand and debug. @@ -62,35 +62,77 @@ We tested this with a 3-Codespace setup building a multiplayer Tetris game. Each **Decision**: Env var overrides file, which overrides config auto-select. -**Rationale**: In Codespaces, the env var is the most reliable signal. On local machines, the file is convenient. Config auto-select handles the simple case of a single stream. +**Rationale**: In Codespaces, the env var is the most reliable signal. On local machines, the file is convenient. Config auto-select handles the simple case of a single workstream. -### 4. Synthesized Definitions for Unknown Streams +### 4. Synthesized Definitions for Unknown Workstreams -**Decision**: When `SQUAD_TEAM` or `.squad-stream` specifies a stream name not in the config, synthesize a minimal definition with `labelFilter: "team:{name}"`. +**Decision**: When `SQUAD_TEAM` or `.squad-workstream` specifies a workstream name not in the config, synthesize a minimal definition with `labelFilter: "team:{name}"`. -**Rationale**: Fail-open is better than fail-closed. Users should be able to set `SQUAD_TEAM=my-team` without needing to update `streams.json` first. The convention of `team:{name}` is predictable and consistent. +**Rationale**: Fail-open is better than fail-closed. Users should be able to set `SQUAD_TEAM=my-team` without needing to update `workstreams.json` first. The convention of `team:{name}` is predictable and consistent. + +## Design Clarifications + +### Overlapping Folder Scope + +**Multiple workstreams MAY work on the same folders.** `folderScope` is an advisory guideline, not a hard lock. Reasons: + +- **Shared packages**: In a monorepo, `packages/shared/` might be touched by all workstreams. Preventing access would break legitimate work. +- **Interface contracts**: Backend and Frontend workstreams both need to update shared type definitions. +- **Branch isolation handles it**: Since each issue gets its own branch (`squad/{issue}-{slug}`), two workstreams editing the same file won't conflict until merge time — and Git resolves non-overlapping changes automatically. + +**When it breaks**: Semantic conflicts — two workstreams make incompatible API changes to the same file. This happened in the Tetris experiment where Backend restructured `game-engine/index.ts` exports while UI added color constants to the same file. Git merged cleanly but the result needed manual reconciliation. + +**Mitigation (v2)**: Conflict detection — warn when two workstreams have open PRs touching the same file. Surface this in `squad workstreams status`. + +### Single-Machine Multi-Workstream + +**One machine can serve multiple workstreams to save costs.** Instead of 1 Codespace per workstream: + +```bash +# Sequential switching +squad workstreams activate ui-team # Ralph works team:ui issues +# ... switch when done ... +squad workstreams activate backend-team # now works team:backend issues +``` + +**v2: Round-robin mode** — Ralph cycles through workstreams automatically: +```json +{ + "workstreams": [...], + "mode": "round-robin", + "issuesPerWorkstream": 3 +} +``` + +| Approach | Cost | Speed | Isolation | +|----------|------|-------|-----------| +| 1 Codespace per workstream | N× | Fastest (true parallel) | Best | +| 1 machine, manual switch | 1× | Serial | Good | +| 1 machine, round-robin | 1× | Interleaved | Okay — branches isolate, context switches add overhead | + +Branch-per-issue ensures no file conflicts regardless of approach — the "workstream" only determines which issues Ralph picks up. ## Future Work ### Meta-Coordinator A "coordinator of coordinators" that: -- Monitors all streams for cross-cutting concerns -- Detects when one stream's work blocks another +- Monitors all workstreams for cross-cutting concerns +- Detects when one workstream's work blocks another - Suggests label assignments for ambiguous issues - Produces a unified status dashboard -### Cross-Stream Dependencies +### Cross-Workstream Dependencies -Track when a stream needs work from another stream: +Track when a workstream needs work from another workstream: - Automated detection via import graphs -- Cross-stream issue linking +- Cross-workstream issue linking - Priority escalation for blocking dependencies -### Stream Templates +### Workstream Templates -Pre-built stream configurations for common architectures: -- **Frontend/Backend** — 2 streams for web apps -- **Monorepo** — 1 stream per package -- **Microservices** — 1 stream per service -- **Feature teams** — dynamic streams per feature area +Pre-built workstream configurations for common architectures: +- **Frontend/Backend** — 2 workstreams for web apps +- **Monorepo** — 1 workstream per package +- **Microservices** — 1 workstream per service +- **Feature teams** — dynamic workstreams per feature area diff --git a/packages/squad-cli/src/cli-entry.ts b/packages/squad-cli/src/cli-entry.ts index 4c922998..9cc14c3f 100644 --- a/packages/squad-cli/src/cli-entry.ts +++ b/packages/squad-cli/src/cli-entry.ts @@ -234,8 +234,8 @@ async function main(): Promise { } if (cmd === 'workstreams' || cmd === 'streams') { - const { runStreams } = await import('./cli/commands/streams.js'); - await runStreams(process.cwd(), args.slice(1)); + const { runWorkstreams } = await import('./cli/commands/streams.js'); + await runWorkstreams(process.cwd(), args.slice(1)); return; } @@ -287,3 +287,4 @@ main().catch(err => { }); + diff --git a/packages/squad-cli/src/cli/commands/streams.ts b/packages/squad-cli/src/cli/commands/streams.ts index cdd5de2e..e14d00cf 100644 --- a/packages/squad-cli/src/cli/commands/streams.ts +++ b/packages/squad-cli/src/cli/commands/streams.ts @@ -1,17 +1,17 @@ /** - * CLI command: squad streams + * CLI command: squad workstreams * * Subcommands: - * list — Show configured streams - * status — Show activity per stream (branches, PRs) - * activate — Write .squad-stream file to activate a stream + * list — Show configured workstreams + * status — Show activity per workstream (branches, PRs) + * activate — Write .squad-workstream file to activate a workstream */ import fs from 'node:fs'; import path from 'node:path'; import { execSync } from 'node:child_process'; -import { loadStreamsConfig, resolveStream } from '@bradygaster/squad-sdk'; -import type { StreamDefinition } from '@bradygaster/squad-sdk'; +import { loadWorkstreamsConfig, resolveWorkstream } from '@bradygaster/squad-sdk'; +import type { WorkstreamDefinition } from '@bradygaster/squad-sdk'; const BOLD = '\x1b[1m'; const RESET = '\x1b[0m'; @@ -21,91 +21,94 @@ const YELLOW = '\x1b[33m'; const RED = '\x1b[31m'; /** - * Entry point for `squad streams` subcommand. + * Entry point for `squad workstreams` subcommand. */ -export async function runStreams(cwd: string, args: string[]): Promise { +export async function runWorkstreams(cwd: string, args: string[]): Promise { const sub = args[0]; if (!sub || sub === 'list') { - return listStreams(cwd); + return listWorkstreams(cwd); } if (sub === 'status') { - return showStreamStatus(cwd); + return showWorkstreamStatus(cwd); } if (sub === 'activate') { const name = args[1]; if (!name) { - console.error(`${RED}✗${RESET} Usage: squad streams activate `); + console.error(`${RED}✗${RESET} Usage: squad workstreams activate `); process.exit(1); } - return activateStream(cwd, name); + return activateWorkstream(cwd, name); } - console.error(`${RED}✗${RESET} Unknown streams subcommand: ${sub}`); - console.log(`\nUsage: squad streams >`); + console.error(`${RED}✗${RESET} Unknown workstreams subcommand: ${sub}`); + console.log(`\nUsage: squad workstreams >`); process.exit(1); } +/** @deprecated Use runWorkstreams instead */ +export const runStreams = runWorkstreams; + /** - * List configured streams. + * List configured workstreams. */ -function listStreams(cwd: string): void { - const config = loadStreamsConfig(cwd); - const active = resolveStream(cwd); +function listWorkstreams(cwd: string): void { + const config = loadWorkstreamsConfig(cwd); + const active = resolveWorkstream(cwd); - if (!config || config.streams.length === 0) { - console.log(`\n${DIM}No streams configured.${RESET}`); - console.log(`${DIM}Create .squad/streams.json to define streams.${RESET}\n`); + if (!config || config.workstreams.length === 0) { + console.log(`\n${DIM}No workstreams configured.${RESET}`); + console.log(`${DIM}Create .squad/workstreams.json to define workstreams.${RESET}\n`); return; } - console.log(`\n${BOLD}Configured Streams${RESET}\n`); + console.log(`\n${BOLD}Configured Workstreams${RESET}\n`); console.log(` Default workflow: ${config.defaultWorkflow}\n`); - for (const stream of config.streams) { - const isActive = active?.name === stream.name; + for (const workstream of config.workstreams) { + const isActive = active?.name === workstream.name; const marker = isActive ? `${GREEN}● active${RESET}` : `${DIM}○${RESET}`; - const workflow = stream.workflow ?? config.defaultWorkflow; - console.log(` ${marker} ${BOLD}${stream.name}${RESET}`); - console.log(` Label: ${stream.labelFilter}`); + const workflow = workstream.workflow ?? config.defaultWorkflow; + console.log(` ${marker} ${BOLD}${workstream.name}${RESET}`); + console.log(` Label: ${workstream.labelFilter}`); console.log(` Workflow: ${workflow}`); - if (stream.folderScope?.length) { - console.log(` Folders: ${stream.folderScope.join(', ')}`); + if (workstream.folderScope?.length) { + console.log(` Folders: ${workstream.folderScope.join(', ')}`); } - if (stream.description) { - console.log(` ${DIM}${stream.description}${RESET}`); + if (workstream.description) { + console.log(` ${DIM}${workstream.description}${RESET}`); } console.log(); } if (active) { - console.log(` ${DIM}Active stream resolved via: ${active.source}${RESET}\n`); + console.log(` ${DIM}Active workstream resolved via: ${active.source}${RESET}\n`); } } /** - * Show activity per stream (branches, PRs via gh CLI). + * Show activity per workstream (branches, PRs via gh CLI). */ -function showStreamStatus(cwd: string): void { - const config = loadStreamsConfig(cwd); - const active = resolveStream(cwd); +function showWorkstreamStatus(cwd: string): void { + const config = loadWorkstreamsConfig(cwd); + const active = resolveWorkstream(cwd); - if (!config || config.streams.length === 0) { - console.log(`\n${DIM}No streams configured.${RESET}\n`); + if (!config || config.workstreams.length === 0) { + console.log(`\n${DIM}No workstreams configured.${RESET}\n`); return; } - console.log(`\n${BOLD}Stream Status${RESET}\n`); + console.log(`\n${BOLD}Workstream Status${RESET}\n`); - for (const stream of config.streams) { - const isActive = active?.name === stream.name; + for (const workstream of config.workstreams) { + const isActive = active?.name === workstream.name; const marker = isActive ? `${GREEN}●${RESET}` : `${DIM}○${RESET}`; - console.log(` ${marker} ${BOLD}${stream.name}${RESET} (${stream.labelFilter})`); + console.log(` ${marker} ${BOLD}${workstream.name}${RESET} (${workstream.labelFilter})`); // Try to get PR and branch info via gh CLI try { const prOutput = execSync( - `gh pr list --label "${stream.labelFilter}" --json number,title,state --limit 5`, + `gh pr list --label "${workstream.labelFilter}" --json number,title,state --limit 5`, { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }, ); const prs = JSON.parse(prOutput) as Array<{ number: number; title: string; state: string }>; @@ -124,7 +127,7 @@ function showStreamStatus(cwd: string): void { // Try to get branch info try { const branchOutput = execSync( - `git branch --list "*${stream.name}*"`, + `git branch --list "*${workstream.name}*"`, { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }, ); const branches = branchOutput.trim().split('\n').filter(Boolean); @@ -143,24 +146,24 @@ function showStreamStatus(cwd: string): void { } /** - * Activate a stream by writing .squad-stream file. + * Activate a workstream by writing .squad-workstream file. */ -function activateStream(cwd: string, name: string): void { - const config = loadStreamsConfig(cwd); +function activateWorkstream(cwd: string, name: string): void { + const config = loadWorkstreamsConfig(cwd); - // Validate the stream exists in config (warn if not, but still allow) + // Validate the workstream exists in config (warn if not, but still allow) if (config) { - const found = config.streams.find(s => s.name === name); + const found = config.workstreams.find(s => s.name === name); if (!found) { - console.log(`${YELLOW}⚠${RESET} Stream "${name}" not found in .squad/streams.json`); - console.log(` Available: ${config.streams.map(s => s.name).join(', ')}`); - console.log(` Writing .squad-stream anyway...\n`); + console.log(`${YELLOW}⚠${RESET} Workstream "${name}" not found in .squad/workstreams.json`); + console.log(` Available: ${config.workstreams.map(s => s.name).join(', ')}`); + console.log(` Writing .squad-workstream anyway...\n`); } } - const streamFilePath = path.join(cwd, '.squad-stream'); - fs.writeFileSync(streamFilePath, name + '\n', 'utf-8'); - console.log(`${GREEN}✓${RESET} Activated stream: ${BOLD}${name}${RESET}`); - console.log(` Written to: ${streamFilePath}`); + const workstreamFilePath = path.join(cwd, '.squad-workstream'); + fs.writeFileSync(workstreamFilePath, name + '\n', 'utf-8'); + console.log(`${GREEN}✓${RESET} Activated workstream: ${BOLD}${name}${RESET}`); + console.log(` Written to: ${workstreamFilePath}`); console.log(`${DIM} (This file is gitignored — it's local to your machine/Codespace)${RESET}\n`); } diff --git a/packages/squad-sdk/src/config/init.ts b/packages/squad-sdk/src/config/init.ts index 64e2c46b..62bf71bf 100644 --- a/packages/squad-sdk/src/config/init.ts +++ b/packages/squad-sdk/src/config/init.ts @@ -14,7 +14,7 @@ import { fileURLToPath } from 'url'; import { existsSync, cpSync, statSync, mkdirSync, writeFileSync, readFileSync, readdirSync } from 'fs'; import { MODELS } from '../runtime/constants.js'; import type { SquadConfig, ModelSelectionConfig, RoutingConfig } from '../runtime/config.js'; -import type { StreamDefinition } from '../streams/types.js'; +import type { WorkstreamDefinition } from '../streams/types.js'; // ============================================================================ // Template Resolution @@ -110,8 +110,8 @@ export interface InitOptions { prompt?: string; /** If true, disable extraction from consult sessions (read-only consultations) */ extractionDisabled?: boolean; - /** Optional stream definitions — generates .squad/streams.json when provided */ - streams?: StreamDefinition[]; + /** Optional workstream definitions — generates .squad/workstreams.json when provided */ + streams?: WorkstreamDefinition[]; } /** @@ -848,32 +848,32 @@ ${projectDescription ? `- **Description:** ${projectDescription}\n` : ''}- **Cre } // ------------------------------------------------------------------------- - // Generate .squad/streams.json (when streams provided) + // Generate .squad/workstreams.json (when streams provided) // ------------------------------------------------------------------------- if (options.streams && options.streams.length > 0) { - const streamsConfig = { - streams: options.streams, + const workstreamsConfig = { + workstreams: options.streams, defaultWorkflow: 'branch-per-issue', }; - const streamsPath = join(squadDir, 'streams.json'); - await writeIfNotExists(streamsPath, JSON.stringify(streamsConfig, null, 2) + '\n'); + const workstreamsPath = join(squadDir, 'workstreams.json'); + await writeIfNotExists(workstreamsPath, JSON.stringify(workstreamsConfig, null, 2) + '\n'); } // ------------------------------------------------------------------------- - // Add .squad-stream to .gitignore + // Add .squad-workstream to .gitignore // ------------------------------------------------------------------------- { - const streamIgnoreEntry = '.squad-stream'; + const workstreamIgnoreEntry = '.squad-workstream'; let currentIgnore = ''; if (existsSync(gitignorePath)) { currentIgnore = readFileSync(gitignorePath, 'utf-8'); } - if (!currentIgnore.includes(streamIgnoreEntry)) { + if (!currentIgnore.includes(workstreamIgnoreEntry)) { const block = (currentIgnore && !currentIgnore.endsWith('\n') ? '\n' : '') - + '# Squad: stream activation file (local to this machine)\n' - + streamIgnoreEntry + '\n'; + + '# Squad: workstream activation file (local to this machine)\n' + + workstreamIgnoreEntry + '\n'; await appendFile(gitignorePath, block); } } diff --git a/packages/squad-sdk/src/streams/filter.ts b/packages/squad-sdk/src/streams/filter.ts index 1f223d9b..ab86d250 100644 --- a/packages/squad-sdk/src/streams/filter.ts +++ b/packages/squad-sdk/src/streams/filter.ts @@ -1,36 +1,39 @@ /** - * Stream-Aware Issue Filtering + * Workstream-Aware Issue Filtering * - * Filters GitHub issues to only those matching a stream's labelFilter. - * Used by Ralph during triage to scope work to the active stream. + * Filters GitHub issues to only those matching a workstream's labelFilter. + * Used by Ralph during triage to scope work to the active workstream. * * @module streams/filter */ -import type { ResolvedStream } from './types.js'; +import type { ResolvedWorkstream } from './types.js'; /** Minimal issue shape for filtering. */ -export interface StreamIssue { +export interface WorkstreamIssue { number: number; title: string; labels: Array<{ name: string }>; } +/** @deprecated Use WorkstreamIssue instead */ +export type StreamIssue = WorkstreamIssue; + /** - * Filter issues to only those matching the stream's label filter. + * Filter issues to only those matching the workstream's label filter. * - * Matching is case-insensitive. If the stream has no labelFilter, + * Matching is case-insensitive. If the workstream has no labelFilter, * all issues are returned (passthrough). * * @param issues - Array of issues to filter - * @param stream - The resolved stream to filter by - * @returns Filtered array of issues matching the stream's label + * @param workstream - The resolved workstream to filter by + * @returns Filtered array of issues matching the workstream's label */ -export function filterIssuesByStream( - issues: StreamIssue[], - stream: ResolvedStream, -): StreamIssue[] { - const filter = stream.definition.labelFilter; +export function filterIssuesByWorkstream( + issues: WorkstreamIssue[], + workstream: ResolvedWorkstream, +): WorkstreamIssue[] { + const filter = workstream.definition.labelFilter; if (!filter) { return issues; } @@ -40,3 +43,6 @@ export function filterIssuesByStream( issue.labels.some(label => label.name.toLowerCase() === normalizedFilter), ); } + +/** @deprecated Use filterIssuesByWorkstream instead */ +export const filterIssuesByStream = filterIssuesByWorkstream; diff --git a/packages/squad-sdk/src/streams/index.ts b/packages/squad-sdk/src/streams/index.ts index 4c6f1c7f..aafc995e 100644 --- a/packages/squad-sdk/src/streams/index.ts +++ b/packages/squad-sdk/src/streams/index.ts @@ -1,5 +1,5 @@ /** - * Streams module — barrel exports. + * Workstreams module — barrel exports. * * @module streams */ diff --git a/packages/squad-sdk/src/streams/resolver.ts b/packages/squad-sdk/src/streams/resolver.ts index abe04052..45a763a3 100644 --- a/packages/squad-sdk/src/streams/resolver.ts +++ b/packages/squad-sdk/src/streams/resolver.ts @@ -1,37 +1,37 @@ /** - * Stream Resolver — Resolves which stream is active. + * Workstream Resolver — Resolves which workstream is active. * * Resolution order: - * 1. SQUAD_TEAM env var → look up in streams config - * 2. .squad-stream file (gitignored) → contains stream name - * 3. squad.config.ts → streams.active field (via .squad/streams.json) - * 4. null (no stream — single-squad mode) + * 1. SQUAD_TEAM env var → look up in workstreams config + * 2. .squad-workstream file (gitignored) → contains workstream name + * 3. squad.config.ts → workstreams.active field (via .squad/workstreams.json) + * 4. null (no workstream — single-squad mode) * * @module streams/resolver */ import { existsSync, readFileSync } from 'fs'; import { join } from 'path'; -import type { StreamConfig, StreamDefinition, ResolvedStream } from './types.js'; +import type { WorkstreamConfig, WorkstreamDefinition, ResolvedWorkstream } from './types.js'; /** - * Load streams configuration from .squad/streams.json. + * Load workstreams configuration from .squad/workstreams.json. * * @param squadRoot - Root directory of the project (where .squad/ lives) - * @returns Parsed StreamConfig or null if not found / invalid + * @returns Parsed WorkstreamConfig or null if not found / invalid */ -export function loadStreamsConfig(squadRoot: string): StreamConfig | null { - const configPath = join(squadRoot, '.squad', 'streams.json'); +export function loadWorkstreamsConfig(squadRoot: string): WorkstreamConfig | null { + const configPath = join(squadRoot, '.squad', 'workstreams.json'); if (!existsSync(configPath)) { return null; } try { const raw = readFileSync(configPath, 'utf-8'); - const parsed = JSON.parse(raw) as StreamConfig; + const parsed = JSON.parse(raw) as WorkstreamConfig; // Basic validation - if (!parsed.streams || !Array.isArray(parsed.streams)) { + if (!parsed.workstreams || !Array.isArray(parsed.workstreams)) { return null; } @@ -46,32 +46,35 @@ export function loadStreamsConfig(squadRoot: string): StreamConfig | null { } } +/** @deprecated Use loadWorkstreamsConfig instead */ +export const loadStreamsConfig = loadWorkstreamsConfig; + /** - * Find a stream definition by name in a config. + * Find a workstream definition by name in a config. */ -function findStream(config: StreamConfig, name: string): StreamDefinition | undefined { - return config.streams.find(s => s.name === name); +function findWorkstream(config: WorkstreamConfig, name: string): WorkstreamDefinition | undefined { + return config.workstreams.find(s => s.name === name); } /** - * Resolve which stream is active for the current environment. + * Resolve which workstream is active for the current environment. * * @param squadRoot - Root directory of the project - * @returns ResolvedStream or null if no stream is active + * @returns ResolvedWorkstream or null if no workstream is active */ -export function resolveStream(squadRoot: string): ResolvedStream | null { - const config = loadStreamsConfig(squadRoot); +export function resolveWorkstream(squadRoot: string): ResolvedWorkstream | null { + const config = loadWorkstreamsConfig(squadRoot); // 1. SQUAD_TEAM env var const envTeam = process.env.SQUAD_TEAM; if (envTeam) { if (config) { - const def = findStream(config, envTeam); + const def = findWorkstream(config, envTeam); if (def) { return { name: envTeam, definition: def, source: 'env' }; } } - // Env var set but no matching stream config — synthesize a minimal definition + // Env var set but no matching workstream config — synthesize a minimal definition return { name: envTeam, definition: { @@ -82,24 +85,24 @@ export function resolveStream(squadRoot: string): ResolvedStream | null { }; } - // 2. .squad-stream file - const streamFilePath = join(squadRoot, '.squad-stream'); - if (existsSync(streamFilePath)) { + // 2. .squad-workstream file + const workstreamFilePath = join(squadRoot, '.squad-workstream'); + if (existsSync(workstreamFilePath)) { try { - const streamName = readFileSync(streamFilePath, 'utf-8').trim(); - if (streamName) { + const workstreamName = readFileSync(workstreamFilePath, 'utf-8').trim(); + if (workstreamName) { if (config) { - const def = findStream(config, streamName); + const def = findWorkstream(config, workstreamName); if (def) { - return { name: streamName, definition: def, source: 'file' }; + return { name: workstreamName, definition: def, source: 'file' }; } } // File exists but no config — synthesize return { - name: streamName, + name: workstreamName, definition: { - name: streamName, - labelFilter: `team:${streamName}`, + name: workstreamName, + labelFilter: `team:${workstreamName}`, }, source: 'file', }; @@ -109,22 +112,28 @@ export function resolveStream(squadRoot: string): ResolvedStream | null { } } - // 3. streams.json with an "active" field (convention: first stream if only one) - if (config && config.streams.length === 1) { - const def = config.streams[0]!; + // 3. workstreams.json with an "active" field (convention: first workstream if only one) + if (config && config.workstreams.length === 1) { + const def = config.workstreams[0]!; return { name: def.name, definition: def, source: 'config' }; } - // 4. No stream detected + // 4. No workstream detected return null; } +/** @deprecated Use resolveWorkstream instead */ +export const resolveStream = resolveWorkstream; + /** - * Get the GitHub label filter string for a resolved stream. + * Get the GitHub label filter string for a resolved workstream. * - * @param stream - The resolved stream + * @param workstream - The resolved workstream * @returns Label filter string (e.g., "team:ui") */ -export function getStreamLabelFilter(stream: ResolvedStream): string { - return stream.definition.labelFilter; +export function getWorkstreamLabelFilter(workstream: ResolvedWorkstream): string { + return workstream.definition.labelFilter; } + +/** @deprecated Use getWorkstreamLabelFilter instead */ +export const getStreamLabelFilter = getWorkstreamLabelFilter; diff --git a/packages/squad-sdk/src/streams/types.ts b/packages/squad-sdk/src/streams/types.ts index ba28d13b..dcd9ee4e 100644 --- a/packages/squad-sdk/src/streams/types.ts +++ b/packages/squad-sdk/src/streams/types.ts @@ -1,15 +1,15 @@ /** - * Stream Types — Type definitions for Squad Streams. + * Workstream Types — Type definitions for Squad Workstreams. * - * Streams enable horizontal scaling by allowing multiple Squad instances + * Workstreams enable horizontal scaling by allowing multiple Squad instances * (e.g., in different Codespaces) to each handle a scoped subset of work. * * @module streams/types */ -/** Definition of a single stream (team partition). */ -export interface StreamDefinition { - /** Stream name, e.g., "ui-team", "backend-team" */ +/** Definition of a single workstream (team partition). */ +export interface WorkstreamDefinition { + /** Workstream name, e.g., "ui-team", "backend-team" */ name: string; /** GitHub label to filter issues by, e.g., "team:ui" */ labelFilter: string; @@ -17,24 +17,33 @@ export interface StreamDefinition { folderScope?: string[]; /** Workflow mode. Default: branch-per-issue */ workflow?: 'branch-per-issue' | 'direct'; - /** Human-readable description of this stream's purpose */ + /** Human-readable description of this workstream's purpose */ description?: string; } -/** Top-level streams configuration (stored in .squad/streams.json). */ -export interface StreamConfig { - /** All configured streams */ - streams: StreamDefinition[]; - /** Default workflow for streams that don't specify one */ +/** @deprecated Use WorkstreamDefinition instead */ +export type StreamDefinition = WorkstreamDefinition; + +/** Top-level workstreams configuration (stored in .squad/workstreams.json). */ +export interface WorkstreamConfig { + /** All configured workstreams */ + workstreams: WorkstreamDefinition[]; + /** Default workflow for workstreams that don't specify one */ defaultWorkflow: 'branch-per-issue' | 'direct'; } -/** A resolved stream with provenance information. */ -export interface ResolvedStream { - /** Stream name */ +/** @deprecated Use WorkstreamConfig instead */ +export type StreamConfig = WorkstreamConfig; + +/** A resolved workstream with provenance information. */ +export interface ResolvedWorkstream { + /** Workstream name */ name: string; - /** Full stream definition */ - definition: StreamDefinition; - /** How this stream was resolved */ + /** Full workstream definition */ + definition: WorkstreamDefinition; + /** How this workstream was resolved */ source: 'env' | 'file' | 'config'; } + +/** @deprecated Use ResolvedWorkstream instead */ +export type ResolvedStream = ResolvedWorkstream; diff --git a/packages/squad-sdk/src/types.ts b/packages/squad-sdk/src/types.ts index 856ae8a8..2123d59d 100644 --- a/packages/squad-sdk/src/types.ts +++ b/packages/squad-sdk/src/types.ts @@ -58,7 +58,11 @@ export type { SquadEntry } from './multi-squad.js'; export type { MultiSquadConfig } from './multi-squad.js'; export type { SquadInfo } from './multi-squad.js'; -// --- Stream types (streams/types.ts) --- +// --- Workstream types (streams/types.ts) --- +export type { WorkstreamDefinition } from './streams/types.js'; +export type { WorkstreamConfig } from './streams/types.js'; +export type { ResolvedWorkstream } from './streams/types.js'; +/** @deprecated aliases */ export type { StreamDefinition } from './streams/types.js'; export type { StreamConfig } from './streams/types.js'; export type { ResolvedStream } from './streams/types.js'; diff --git a/templates/squad.agent.md b/templates/squad.agent.md index 026da8dc..3703e47d 100644 --- a/templates/squad.agent.md +++ b/templates/squad.agent.md @@ -111,16 +111,16 @@ When triggered: **Casting migration check:** If `.squad/team.md` exists but `.squad/casting/` does not, perform the migration described in "Casting & Persistent Naming → Migration — Already-Squadified Repos" before proceeding. -### Stream Awareness +### Workstream Awareness -On session start, check for stream context: +On session start, check for workstream context: 1. Read `SQUAD_TEAM` env var -2. If set, read `.squad/streams.json` and find matching stream -3. Apply the stream's `labelFilter` — Ralph should ONLY pick up issues matching this label -4. Apply the stream's `workflow` — if `branch-per-issue`, enforce creating a branch and PR for every issue (never commit directly to main) +2. If set, read `.squad/workstreams.json` and find matching workstream +3. Apply the workstream's `labelFilter` — Ralph should ONLY pick up issues matching this label +4. Apply the workstream's `workflow` — if `branch-per-issue`, enforce creating a branch and PR for every issue (never commit directly to main) 5. Apply `folderScope` — agents should only modify files in these directories -If no stream is detected, operate in default single-squad mode. +If no workstream is detected, operate in default single-squad mode. ### Issue Awareness diff --git a/test/streams.test.ts b/test/streams.test.ts index 477b8d40..fa93cf53 100644 --- a/test/streams.test.ts +++ b/test/streams.test.ts @@ -1,14 +1,14 @@ /** - * Squad Streams — Comprehensive Tests + * Squad Workstreams — Comprehensive Tests * * Tests cover: - * - Stream types (compile-time, verified via usage) - * - Stream resolution (env var, file, config, fallback) + * - Workstream types (compile-time, verified via usage) + * - Workstream resolution (env var, file, config, fallback) * - Label-based filtering (match, no match, multiple labels, case insensitive) * - Config loading / validation - * - CLI activate command (writes .squad-stream) - * - Init with streams (generates streams.json) - * - Edge cases (empty streams, invalid JSON, missing env, passthrough) + * - CLI activate command (writes .squad-workstream) + * - Init with workstreams (generates workstreams.json) + * - Edge cases (empty workstreams, invalid JSON, missing env, passthrough) */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; @@ -17,17 +17,17 @@ import path from 'node:path'; import os from 'node:os'; import { - loadStreamsConfig, - resolveStream, - getStreamLabelFilter, - filterIssuesByStream, + loadWorkstreamsConfig, + resolveWorkstream, + getWorkstreamLabelFilter, + filterIssuesByWorkstream, } from '../packages/squad-sdk/src/streams/index.js'; import type { - StreamDefinition, - StreamConfig, - ResolvedStream, - StreamIssue, + WorkstreamDefinition, + WorkstreamConfig, + ResolvedWorkstream, + WorkstreamIssue, } from '../packages/squad-sdk/src/streams/index.js'; // ============================================================================ @@ -35,21 +35,21 @@ import type { // ============================================================================ function makeTmpDir(): string { - return fs.mkdtempSync(path.join(os.tmpdir(), 'squad-streams-test-')); + return fs.mkdtempSync(path.join(os.tmpdir(), 'squad-workstreams-test-')); } -function writeSquadStreamsConfig(root: string, config: StreamConfig): void { +function writeSquadWorkstreamsConfig(root: string, config: WorkstreamConfig): void { const squadDir = path.join(root, '.squad'); fs.mkdirSync(squadDir, { recursive: true }); - fs.writeFileSync(path.join(squadDir, 'streams.json'), JSON.stringify(config, null, 2), 'utf-8'); + fs.writeFileSync(path.join(squadDir, 'workstreams.json'), JSON.stringify(config, null, 2), 'utf-8'); } -function writeSquadStreamFile(root: string, name: string): void { - fs.writeFileSync(path.join(root, '.squad-stream'), name + '\n', 'utf-8'); +function writeSquadWorkstreamFile(root: string, name: string): void { + fs.writeFileSync(path.join(root, '.squad-workstream'), name + '\n', 'utf-8'); } -const SAMPLE_CONFIG: StreamConfig = { - streams: [ +const SAMPLE_CONFIG: WorkstreamConfig = { + workstreams: [ { name: 'ui-team', labelFilter: 'team:ui', folderScope: ['apps/web'], workflow: 'branch-per-issue', description: 'UI specialists' }, { name: 'backend-team', labelFilter: 'team:backend', folderScope: ['apps/api'], workflow: 'direct' }, { name: 'infra-team', labelFilter: 'team:infra' }, @@ -57,7 +57,7 @@ const SAMPLE_CONFIG: StreamConfig = { defaultWorkflow: 'branch-per-issue', }; -const SAMPLE_ISSUES: StreamIssue[] = [ +const SAMPLE_ISSUES: WorkstreamIssue[] = [ { number: 1, title: 'Fix button color', labels: [{ name: 'team:ui' }, { name: 'bug' }] }, { number: 2, title: 'Add REST endpoint', labels: [{ name: 'team:backend' }] }, { number: 3, title: 'Setup CI', labels: [{ name: 'team:infra' }] }, @@ -69,57 +69,57 @@ const SAMPLE_ISSUES: StreamIssue[] = [ // loadStreamsConfig // ============================================================================ -describe('loadStreamsConfig', () => { +describe('loadWorkstreamsConfig', () => { let tmpDir: string; beforeEach(() => { tmpDir = makeTmpDir(); }); afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); - it('returns null when .squad/streams.json does not exist', () => { - expect(loadStreamsConfig(tmpDir)).toBeNull(); + it('returns null when .squad/workstreams.json does not exist', () => { + expect(loadWorkstreamsConfig(tmpDir)).toBeNull(); }); - it('loads a valid streams config', () => { - writeSquadStreamsConfig(tmpDir, SAMPLE_CONFIG); - const result = loadStreamsConfig(tmpDir); + it('loads a valid workstreams config', () => { + writeSquadWorkstreamsConfig(tmpDir, SAMPLE_CONFIG); + const result = loadWorkstreamsConfig(tmpDir); expect(result).not.toBeNull(); - expect(result!.streams).toHaveLength(3); + expect(result!.workstreams).toHaveLength(3); expect(result!.defaultWorkflow).toBe('branch-per-issue'); }); it('returns null for invalid JSON', () => { const squadDir = path.join(tmpDir, '.squad'); fs.mkdirSync(squadDir, { recursive: true }); - fs.writeFileSync(path.join(squadDir, 'streams.json'), '{invalid', 'utf-8'); - expect(loadStreamsConfig(tmpDir)).toBeNull(); + fs.writeFileSync(path.join(squadDir, 'workstreams.json'), '{invalid', 'utf-8'); + expect(loadWorkstreamsConfig(tmpDir)).toBeNull(); }); - it('returns null when streams array is missing', () => { + it('returns null when workstreams array is missing', () => { const squadDir = path.join(tmpDir, '.squad'); fs.mkdirSync(squadDir, { recursive: true }); - fs.writeFileSync(path.join(squadDir, 'streams.json'), '{"defaultWorkflow":"direct"}', 'utf-8'); - expect(loadStreamsConfig(tmpDir)).toBeNull(); + fs.writeFileSync(path.join(squadDir, 'workstreams.json'), '{"defaultWorkflow":"direct"}', 'utf-8'); + expect(loadWorkstreamsConfig(tmpDir)).toBeNull(); }); it('defaults defaultWorkflow to branch-per-issue when missing', () => { const squadDir = path.join(tmpDir, '.squad'); fs.mkdirSync(squadDir, { recursive: true }); - fs.writeFileSync(path.join(squadDir, 'streams.json'), '{"streams":[{"name":"a","labelFilter":"x"}]}', 'utf-8'); - const result = loadStreamsConfig(tmpDir); + fs.writeFileSync(path.join(squadDir, 'workstreams.json'), '{"workstreams":[{"name":"a","labelFilter":"x"}]}', 'utf-8'); + const result = loadWorkstreamsConfig(tmpDir); expect(result!.defaultWorkflow).toBe('branch-per-issue'); }); it('preserves folderScope arrays', () => { - writeSquadStreamsConfig(tmpDir, SAMPLE_CONFIG); - const result = loadStreamsConfig(tmpDir)!; - expect(result.streams[0]!.folderScope).toEqual(['apps/web']); + writeSquadWorkstreamsConfig(tmpDir, SAMPLE_CONFIG); + const result = loadWorkstreamsConfig(tmpDir)!; + expect(result.workstreams[0]!.folderScope).toEqual(['apps/web']); }); it('preserves optional description', () => { - writeSquadStreamsConfig(tmpDir, SAMPLE_CONFIG); - const result = loadStreamsConfig(tmpDir)!; - expect(result.streams[0]!.description).toBe('UI specialists'); - expect(result.streams[1]!.description).toBeUndefined(); + writeSquadWorkstreamsConfig(tmpDir, SAMPLE_CONFIG); + const result = loadWorkstreamsConfig(tmpDir)!; + expect(result.workstreams[0]!.description).toBe('UI specialists'); + expect(result.workstreams[1]!.description).toBeUndefined(); }); }); @@ -127,7 +127,7 @@ describe('loadStreamsConfig', () => { // resolveStream // ============================================================================ -describe('resolveStream', () => { +describe('resolveWorkstream', () => { let tmpDir: string; const origEnv = process.env.SQUAD_TEAM; @@ -148,8 +148,8 @@ describe('resolveStream', () => { it('resolves from SQUAD_TEAM env var with matching config', () => { process.env.SQUAD_TEAM = 'ui-team'; - writeSquadStreamsConfig(tmpDir, SAMPLE_CONFIG); - const result = resolveStream(tmpDir); + writeSquadWorkstreamsConfig(tmpDir, SAMPLE_CONFIG); + const result = resolveWorkstream(tmpDir); expect(result).not.toBeNull(); expect(result!.name).toBe('ui-team'); expect(result!.source).toBe('env'); @@ -159,17 +159,17 @@ describe('resolveStream', () => { it('synthesizes definition from SQUAD_TEAM when no config exists', () => { process.env.SQUAD_TEAM = 'custom-team'; - const result = resolveStream(tmpDir); + const result = resolveWorkstream(tmpDir); expect(result).not.toBeNull(); expect(result!.name).toBe('custom-team'); expect(result!.source).toBe('env'); expect(result!.definition.labelFilter).toBe('team:custom-team'); }); - it('synthesizes definition from SQUAD_TEAM when stream not in config', () => { + it('synthesizes definition from SQUAD_TEAM when workstream not in config', () => { process.env.SQUAD_TEAM = 'unknown-team'; - writeSquadStreamsConfig(tmpDir, SAMPLE_CONFIG); - const result = resolveStream(tmpDir); + writeSquadWorkstreamsConfig(tmpDir, SAMPLE_CONFIG); + const result = resolveWorkstream(tmpDir); expect(result).not.toBeNull(); expect(result!.name).toBe('unknown-team'); expect(result!.source).toBe('env'); @@ -178,47 +178,47 @@ describe('resolveStream', () => { // --- File resolution --- - it('resolves from .squad-stream file with matching config', () => { - writeSquadStreamsConfig(tmpDir, SAMPLE_CONFIG); - writeSquadStreamFile(tmpDir, 'backend-team'); - const result = resolveStream(tmpDir); + it('resolves from .squad-workstream file with matching config', () => { + writeSquadWorkstreamsConfig(tmpDir, SAMPLE_CONFIG); + writeSquadWorkstreamFile(tmpDir, 'backend-team'); + const result = resolveWorkstream(tmpDir); expect(result).not.toBeNull(); expect(result!.name).toBe('backend-team'); expect(result!.source).toBe('file'); expect(result!.definition.workflow).toBe('direct'); }); - it('synthesizes definition from .squad-stream file when no config', () => { - writeSquadStreamFile(tmpDir, 'my-stream'); - const result = resolveStream(tmpDir); + it('synthesizes definition from .squad-workstream file when no config', () => { + writeSquadWorkstreamFile(tmpDir, 'my-workstream'); + const result = resolveWorkstream(tmpDir); expect(result).not.toBeNull(); - expect(result!.name).toBe('my-stream'); + expect(result!.name).toBe('my-workstream'); expect(result!.source).toBe('file'); - expect(result!.definition.labelFilter).toBe('team:my-stream'); + expect(result!.definition.labelFilter).toBe('team:my-workstream'); }); - it('ignores empty .squad-stream file', () => { - fs.writeFileSync(path.join(tmpDir, '.squad-stream'), ' \n', 'utf-8'); - expect(resolveStream(tmpDir)).toBeNull(); + it('ignores empty .squad-workstream file', () => { + fs.writeFileSync(path.join(tmpDir, '.squad-workstream'), ' \n', 'utf-8'); + expect(resolveWorkstream(tmpDir)).toBeNull(); }); - it('trims whitespace from .squad-stream file', () => { - writeSquadStreamsConfig(tmpDir, SAMPLE_CONFIG); - fs.writeFileSync(path.join(tmpDir, '.squad-stream'), ' ui-team \n', 'utf-8'); - const result = resolveStream(tmpDir); + it('trims whitespace from .squad-workstream file', () => { + writeSquadWorkstreamsConfig(tmpDir, SAMPLE_CONFIG); + fs.writeFileSync(path.join(tmpDir, '.squad-workstream'), ' ui-team \n', 'utf-8'); + const result = resolveWorkstream(tmpDir); expect(result!.name).toBe('ui-team'); expect(result!.source).toBe('file'); }); - // --- Config resolution (single stream auto-select) --- + // --- Config resolution (single workstream auto-select) --- - it('auto-selects single stream from config', () => { - const singleConfig: StreamConfig = { - streams: [{ name: 'solo', labelFilter: 'team:solo' }], + it('auto-selects single workstream from config', () => { + const singleConfig: WorkstreamConfig = { + workstreams: [{ name: 'solo', labelFilter: 'team:solo' }], defaultWorkflow: 'direct', }; - writeSquadStreamsConfig(tmpDir, singleConfig); - const result = resolveStream(tmpDir); + writeSquadWorkstreamsConfig(tmpDir, singleConfig); + const result = resolveWorkstream(tmpDir); expect(result).not.toBeNull(); expect(result!.name).toBe('solo'); expect(result!.source).toBe('config'); @@ -226,36 +226,36 @@ describe('resolveStream', () => { // --- Fallback --- - it('returns null when no stream context exists', () => { - expect(resolveStream(tmpDir)).toBeNull(); + it('returns null when no workstream context exists', () => { + expect(resolveWorkstream(tmpDir)).toBeNull(); }); - it('returns null when config has multiple streams but no env/file', () => { - writeSquadStreamsConfig(tmpDir, SAMPLE_CONFIG); - expect(resolveStream(tmpDir)).toBeNull(); + it('returns null when config has multiple workstreams but no env/file', () => { + writeSquadWorkstreamsConfig(tmpDir, SAMPLE_CONFIG); + expect(resolveWorkstream(tmpDir)).toBeNull(); }); // --- Priority order --- - it('env var takes priority over .squad-stream file', () => { + it('env var takes priority over .squad-workstream file', () => { process.env.SQUAD_TEAM = 'ui-team'; - writeSquadStreamsConfig(tmpDir, SAMPLE_CONFIG); - writeSquadStreamFile(tmpDir, 'backend-team'); - const result = resolveStream(tmpDir); + writeSquadWorkstreamsConfig(tmpDir, SAMPLE_CONFIG); + writeSquadWorkstreamFile(tmpDir, 'backend-team'); + const result = resolveWorkstream(tmpDir); expect(result!.name).toBe('ui-team'); expect(result!.source).toBe('env'); }); - it('.squad-stream file takes priority over config auto-select', () => { - const singleConfig: StreamConfig = { - streams: [ + it('.squad-workstream file takes priority over config auto-select', () => { + const singleConfig: WorkstreamConfig = { + workstreams: [ { name: 'alpha', labelFilter: 'team:alpha' }, ], defaultWorkflow: 'branch-per-issue', }; - writeSquadStreamsConfig(tmpDir, singleConfig); - writeSquadStreamFile(tmpDir, 'alpha'); - const result = resolveStream(tmpDir); + writeSquadWorkstreamsConfig(tmpDir, singleConfig); + writeSquadWorkstreamFile(tmpDir, 'alpha'); + const result = resolveWorkstream(tmpDir); // file source takes priority expect(result!.source).toBe('file'); }); @@ -265,23 +265,23 @@ describe('resolveStream', () => { // getStreamLabelFilter // ============================================================================ -describe('getStreamLabelFilter', () => { +describe('getWorkstreamLabelFilter', () => { it('returns the label filter from definition', () => { - const stream: ResolvedStream = { + const workstream: ResolvedWorkstream = { name: 'ui-team', definition: { name: 'ui-team', labelFilter: 'team:ui' }, source: 'env', }; - expect(getStreamLabelFilter(stream)).toBe('team:ui'); + expect(getWorkstreamLabelFilter(workstream)).toBe('team:ui'); }); it('returns synthesized label filter', () => { - const stream: ResolvedStream = { + const workstream: ResolvedWorkstream = { name: 'custom', definition: { name: 'custom', labelFilter: 'team:custom' }, source: 'file', }; - expect(getStreamLabelFilter(stream)).toBe('team:custom'); + expect(getWorkstreamLabelFilter(workstream)).toBe('team:custom'); }); }); @@ -289,87 +289,87 @@ describe('getStreamLabelFilter', () => { // filterIssuesByStream // ============================================================================ -describe('filterIssuesByStream', () => { - it('filters issues matching the stream label', () => { - const stream: ResolvedStream = { +describe('filterIssuesByWorkstream', () => { + it('filters issues matching the workstream label', () => { + const workstream: ResolvedWorkstream = { name: 'ui-team', definition: { name: 'ui-team', labelFilter: 'team:ui' }, source: 'env', }; - const result = filterIssuesByStream(SAMPLE_ISSUES, stream); + const result = filterIssuesByWorkstream(SAMPLE_ISSUES, workstream); expect(result).toHaveLength(2); // issue 1 and 5 expect(result.map(i => i.number)).toEqual([1, 5]); }); it('returns empty array when no issues match', () => { - const stream: ResolvedStream = { + const workstream: ResolvedWorkstream = { name: 'qa-team', definition: { name: 'qa-team', labelFilter: 'team:qa' }, source: 'env', }; - const result = filterIssuesByStream(SAMPLE_ISSUES, stream); + const result = filterIssuesByWorkstream(SAMPLE_ISSUES, workstream); expect(result).toHaveLength(0); }); it('handles case-insensitive matching', () => { - const stream: ResolvedStream = { + const workstream: ResolvedWorkstream = { name: 'ui-team', definition: { name: 'ui-team', labelFilter: 'TEAM:UI' }, source: 'env', }; - const result = filterIssuesByStream(SAMPLE_ISSUES, stream); + const result = filterIssuesByWorkstream(SAMPLE_ISSUES, workstream); expect(result).toHaveLength(2); }); it('returns all issues when labelFilter is empty', () => { - const stream: ResolvedStream = { + const workstream: ResolvedWorkstream = { name: 'all', definition: { name: 'all', labelFilter: '' }, source: 'env', }; - const result = filterIssuesByStream(SAMPLE_ISSUES, stream); + const result = filterIssuesByWorkstream(SAMPLE_ISSUES, workstream); expect(result).toHaveLength(SAMPLE_ISSUES.length); }); it('handles issues with no labels', () => { - const issues: StreamIssue[] = [ + const issues: WorkstreamIssue[] = [ { number: 10, title: 'No labels', labels: [] }, ]; - const stream: ResolvedStream = { + const workstream: ResolvedWorkstream = { name: 'ui-team', definition: { name: 'ui-team', labelFilter: 'team:ui' }, source: 'env', }; - expect(filterIssuesByStream(issues, stream)).toHaveLength(0); + expect(filterIssuesByWorkstream(issues, workstream)).toHaveLength(0); }); it('handles empty issues array', () => { - const stream: ResolvedStream = { + const workstream: ResolvedWorkstream = { name: 'ui-team', definition: { name: 'ui-team', labelFilter: 'team:ui' }, source: 'env', }; - expect(filterIssuesByStream([], stream)).toHaveLength(0); + expect(filterIssuesByWorkstream([], workstream)).toHaveLength(0); }); it('filters backend-team correctly', () => { - const stream: ResolvedStream = { + const workstream: ResolvedWorkstream = { name: 'backend-team', definition: { name: 'backend-team', labelFilter: 'team:backend' }, source: 'config', }; - const result = filterIssuesByStream(SAMPLE_ISSUES, stream); + const result = filterIssuesByWorkstream(SAMPLE_ISSUES, workstream); expect(result).toHaveLength(2); // issue 2 and 5 expect(result.map(i => i.number)).toEqual([2, 5]); }); it('filters infra-team correctly (single match)', () => { - const stream: ResolvedStream = { + const workstream: ResolvedWorkstream = { name: 'infra-team', definition: { name: 'infra-team', labelFilter: 'team:infra' }, source: 'file', }; - const result = filterIssuesByStream(SAMPLE_ISSUES, stream); + const result = filterIssuesByWorkstream(SAMPLE_ISSUES, workstream); expect(result).toHaveLength(1); expect(result[0]!.number).toBe(3); }); @@ -379,21 +379,21 @@ describe('filterIssuesByStream', () => { // Type checks (compile-time — these just verify the types work) // ============================================================================ -describe('Stream types', () => { - it('StreamDefinition accepts all fields', () => { - const def: StreamDefinition = { +describe('Workstream types', () => { + it('WorkstreamDefinition accepts all fields', () => { + const def: WorkstreamDefinition = { name: 'test', labelFilter: 'team:test', folderScope: ['src/'], workflow: 'branch-per-issue', - description: 'Test stream', + description: 'Test workstream', }; expect(def.name).toBe('test'); expect(def.workflow).toBe('branch-per-issue'); }); - it('StreamDefinition works with minimal fields', () => { - const def: StreamDefinition = { + it('WorkstreamDefinition works with minimal fields', () => { + const def: WorkstreamDefinition = { name: 'minimal', labelFilter: 'team:minimal', }; @@ -402,17 +402,17 @@ describe('Stream types', () => { expect(def.description).toBeUndefined(); }); - it('StreamConfig has required fields', () => { - const config: StreamConfig = { - streams: [], + it('WorkstreamConfig has required fields', () => { + const config: WorkstreamConfig = { + workstreams: [], defaultWorkflow: 'direct', }; - expect(config.streams).toEqual([]); + expect(config.workstreams).toEqual([]); expect(config.defaultWorkflow).toBe('direct'); }); - it('ResolvedStream has source provenance', () => { - const resolved: ResolvedStream = { + it('ResolvedWorkstream has source provenance', () => { + const resolved: ResolvedWorkstream = { name: 'test', definition: { name: 'test', labelFilter: 'x' }, source: 'env', @@ -425,22 +425,22 @@ describe('Stream types', () => { // Init integration (streams.json generation) // ============================================================================ -describe('initSquad with streams', () => { +describe('initSquad with workstreams', () => { let tmpDir: string; beforeEach(() => { tmpDir = makeTmpDir(); }); afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); - it('generates streams.json when streams option is provided', async () => { + it('generates workstreams.json when streams option is provided', async () => { const { initSquad } = await import('../packages/squad-sdk/src/config/init.js'); - const streams: StreamDefinition[] = [ + const streams: WorkstreamDefinition[] = [ { name: 'ui-team', labelFilter: 'team:ui', folderScope: ['apps/web'] }, { name: 'api-team', labelFilter: 'team:api' }, ]; await initSquad({ teamRoot: tmpDir, - projectName: 'test-streams', + projectName: 'test-workstreams', agents: [{ name: 'lead', role: 'lead' }], streams, includeWorkflows: false, @@ -448,32 +448,32 @@ describe('initSquad with streams', () => { includeMcpConfig: false, }); - const streamsPath = path.join(tmpDir, '.squad', 'streams.json'); - expect(fs.existsSync(streamsPath)).toBe(true); + const workstreamsPath = path.join(tmpDir, '.squad', 'workstreams.json'); + expect(fs.existsSync(workstreamsPath)).toBe(true); - const content = JSON.parse(fs.readFileSync(streamsPath, 'utf-8')) as StreamConfig; - expect(content.streams).toHaveLength(2); - expect(content.streams[0]!.name).toBe('ui-team'); + const content = JSON.parse(fs.readFileSync(workstreamsPath, 'utf-8')) as WorkstreamConfig; + expect(content.workstreams).toHaveLength(2); + expect(content.workstreams[0]!.name).toBe('ui-team'); expect(content.defaultWorkflow).toBe('branch-per-issue'); }); - it('does not generate streams.json when no streams provided', async () => { + it('does not generate workstreams.json when no streams provided', async () => { const { initSquad } = await import('../packages/squad-sdk/src/config/init.js'); await initSquad({ teamRoot: tmpDir, - projectName: 'test-no-streams', + projectName: 'test-no-workstreams', agents: [{ name: 'lead', role: 'lead' }], includeWorkflows: false, includeTemplates: false, includeMcpConfig: false, }); - const streamsPath = path.join(tmpDir, '.squad', 'streams.json'); - expect(fs.existsSync(streamsPath)).toBe(false); + const workstreamsPath = path.join(tmpDir, '.squad', 'workstreams.json'); + expect(fs.existsSync(workstreamsPath)).toBe(false); }); - it('adds .squad-stream to .gitignore', async () => { + it('adds .squad-workstream to .gitignore', async () => { const { initSquad } = await import('../packages/squad-sdk/src/config/init.js'); await initSquad({ @@ -488,7 +488,7 @@ describe('initSquad with streams', () => { const gitignorePath = path.join(tmpDir, '.gitignore'); expect(fs.existsSync(gitignorePath)).toBe(true); const content = fs.readFileSync(gitignorePath, 'utf-8'); - expect(content).toContain('.squad-stream'); + expect(content).toContain('.squad-workstream'); }); }); @@ -502,29 +502,29 @@ describe('CLI activate behavior', () => { beforeEach(() => { tmpDir = makeTmpDir(); }); afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); - it('writes .squad-stream file with the stream name', () => { - const filePath = path.join(tmpDir, '.squad-stream'); - fs.writeFileSync(filePath, 'my-stream\n', 'utf-8'); + it('writes .squad-workstream file with the workstream name', () => { + const filePath = path.join(tmpDir, '.squad-workstream'); + fs.writeFileSync(filePath, 'my-workstream\n', 'utf-8'); const content = fs.readFileSync(filePath, 'utf-8').trim(); - expect(content).toBe('my-stream'); + expect(content).toBe('my-workstream'); }); it('resolves after activation', () => { - writeSquadStreamsConfig(tmpDir, SAMPLE_CONFIG); - writeSquadStreamFile(tmpDir, 'infra-team'); - const result = resolveStream(tmpDir); + writeSquadWorkstreamsConfig(tmpDir, SAMPLE_CONFIG); + writeSquadWorkstreamFile(tmpDir, 'infra-team'); + const result = resolveWorkstream(tmpDir); expect(result).not.toBeNull(); expect(result!.name).toBe('infra-team'); expect(result!.definition.labelFilter).toBe('team:infra'); }); - it('overwriting .squad-stream changes active stream', () => { - writeSquadStreamsConfig(tmpDir, SAMPLE_CONFIG); - writeSquadStreamFile(tmpDir, 'ui-team'); - expect(resolveStream(tmpDir)!.name).toBe('ui-team'); + it('overwriting .squad-workstream changes active workstream', () => { + writeSquadWorkstreamsConfig(tmpDir, SAMPLE_CONFIG); + writeSquadWorkstreamFile(tmpDir, 'ui-team'); + expect(resolveWorkstream(tmpDir)!.name).toBe('ui-team'); - writeSquadStreamFile(tmpDir, 'backend-team'); - expect(resolveStream(tmpDir)!.name).toBe('backend-team'); + writeSquadWorkstreamFile(tmpDir, 'backend-team'); + expect(resolveWorkstream(tmpDir)!.name).toBe('backend-team'); }); }); @@ -549,44 +549,44 @@ describe('Edge cases', () => { } }); - it('handles empty streams array in config', () => { - const emptyConfig: StreamConfig = { streams: [], defaultWorkflow: 'direct' }; - writeSquadStreamsConfig(tmpDir, emptyConfig); - expect(resolveStream(tmpDir)).toBeNull(); + it('handles empty workstreams array in config', () => { + const emptyConfig: WorkstreamConfig = { workstreams: [], defaultWorkflow: 'direct' }; + writeSquadWorkstreamsConfig(tmpDir, emptyConfig); + expect(resolveWorkstream(tmpDir)).toBeNull(); }); - it('handles config with streams but non-array type', () => { + it('handles config with workstreams but non-array type', () => { const squadDir = path.join(tmpDir, '.squad'); fs.mkdirSync(squadDir, { recursive: true }); - fs.writeFileSync(path.join(squadDir, 'streams.json'), '{"streams":"not-array"}', 'utf-8'); - expect(loadStreamsConfig(tmpDir)).toBeNull(); + fs.writeFileSync(path.join(squadDir, 'workstreams.json'), '{"workstreams":"not-array"}', 'utf-8'); + expect(loadWorkstreamsConfig(tmpDir)).toBeNull(); }); it('handles SQUAD_TEAM set to empty string', () => { process.env.SQUAD_TEAM = ''; - expect(resolveStream(tmpDir)).toBeNull(); + expect(resolveWorkstream(tmpDir)).toBeNull(); }); - it('filterIssuesByStream handles labels with special characters', () => { - const issues: StreamIssue[] = [ + it('filterIssuesByWorkstream handles labels with special characters', () => { + const issues: WorkstreamIssue[] = [ { number: 1, title: 'Test', labels: [{ name: 'team:front-end/ui' }] }, ]; - const stream: ResolvedStream = { + const workstream: ResolvedWorkstream = { name: 'fe', definition: { name: 'fe', labelFilter: 'team:front-end/ui' }, source: 'env', }; - const result = filterIssuesByStream(issues, stream); + const result = filterIssuesByWorkstream(issues, workstream); expect(result).toHaveLength(1); }); it('resolves workflow from definition over defaultWorkflow', () => { - const config: StreamConfig = { - streams: [{ name: 'direct-stream', labelFilter: 'team:direct', workflow: 'direct' }], + const config: WorkstreamConfig = { + workstreams: [{ name: 'direct-workstream', labelFilter: 'team:direct', workflow: 'direct' }], defaultWorkflow: 'branch-per-issue', }; - writeSquadStreamsConfig(tmpDir, config); - const result = resolveStream(tmpDir); + writeSquadWorkstreamsConfig(tmpDir, config); + const result = resolveWorkstream(tmpDir); expect(result!.definition.workflow).toBe('direct'); }); }); From b069cda80faebedfd4030d832a9e51a4d7591e3a Mon Sep 17 00:00:00 2001 From: Tamir Dresher Date: Thu, 5 Mar 2026 08:43:38 +0200 Subject: [PATCH 3/9] chore: remove .squad runtime files from tracking Remove .squad/.first-run and session files that trigger branch guard. Add .first-run to .gitignore. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 833ea377..40cd3bf9 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ coverage/ docs/dist/ # Squad: workstream activation file (local to this machine) .squad-workstream +.squad/.first-run From 0d53239515cf9adadb18beae28d274c955ac5ec6 Mon Sep 17 00:00:00 2001 From: Tamir Dresher Date: Thu, 5 Mar 2026 08:57:09 +0200 Subject: [PATCH 4/9] =?UTF-8?q?fix:=20address=20PR=20review=20comments=20?= =?UTF-8?q?=E2=80=94=20validation,=20shell=20safety,=20docs=20alignment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix resolver docstring to match actual behavior (Env → file → auto-select → null) - Add strict validation in loadWorkstreamsConfig (validate each entry's shape) - Replace execSync with spawnSync to prevent shell injection - Fix filter.ts comment (intended for triage, not wired yet) - Record .gitignore modification in createdFiles during init - Align squad.agent.md Stream Awareness with advisory folderScope - Validate defaultWorkflow against allowed values Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- package.json | 6 +- packages/squad-cli/package.json | 6 +- .../squad-cli/src/cli/commands/streams.ts | 14 ++-- packages/squad-sdk/package.json | 6 +- packages/squad-sdk/src/config/init.ts | 1 + packages/squad-sdk/src/streams/filter.ts | 2 +- packages/squad-sdk/src/streams/resolver.ts | 74 ++++++++++++++++--- templates/squad.agent.md | 18 +++-- 8 files changed, 88 insertions(+), 39 deletions(-) diff --git a/package.json b/package.json index d9cec809..f238ffa5 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,6 @@ { "name": "@bradygaster/squad", -<<<<<<< HEAD - "version": "0.8.21-preview.1", -======= - "version": "0.8.18-preview.2", + "version": "0.8.21-preview.3", "private": true, "description": "Squad — Programmable multi-agent runtime for GitHub Copilot, built on @github/copilot-sdk", "type": "module", @@ -53,4 +50,3 @@ "url": "https://github.com/bradygaster/squad.git" } } - diff --git a/packages/squad-cli/package.json b/packages/squad-cli/package.json index 94856a46..f6461bff 100644 --- a/packages/squad-cli/package.json +++ b/packages/squad-cli/package.json @@ -1,9 +1,6 @@ { "name": "@bradygaster/squad-cli", -<<<<<<< HEAD - "version": "0.8.21-preview.1", -======= - "version": "0.8.18-preview.2", + "version": "0.8.21-preview.3", "description": "Squad CLI — Command-line interface for the Squad multi-agent runtime", "type": "module", "bin": { @@ -157,4 +154,3 @@ "directory": "packages/squad-cli" } } - diff --git a/packages/squad-cli/src/cli/commands/streams.ts b/packages/squad-cli/src/cli/commands/streams.ts index e14d00cf..411695d0 100644 --- a/packages/squad-cli/src/cli/commands/streams.ts +++ b/packages/squad-cli/src/cli/commands/streams.ts @@ -9,7 +9,7 @@ import fs from 'node:fs'; import path from 'node:path'; -import { execSync } from 'node:child_process'; +import { spawnSync } from 'node:child_process'; import { loadWorkstreamsConfig, resolveWorkstream } from '@bradygaster/squad-sdk'; import type { WorkstreamDefinition } from '@bradygaster/squad-sdk'; @@ -107,10 +107,12 @@ function showWorkstreamStatus(cwd: string): void { // Try to get PR and branch info via gh CLI try { - const prOutput = execSync( - `gh pr list --label "${workstream.labelFilter}" --json number,title,state --limit 5`, + const result = spawnSync( + 'gh', + ['pr', 'list', '--label', workstream.labelFilter, '--json', 'number,title,state', '--limit', '5'], { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }, ); + const prOutput = result.stdout ?? ''; const prs = JSON.parse(prOutput) as Array<{ number: number; title: string; state: string }>; if (prs.length > 0) { console.log(` ${YELLOW}PRs:${RESET}`); @@ -126,10 +128,12 @@ function showWorkstreamStatus(cwd: string): void { // Try to get branch info try { - const branchOutput = execSync( - `git branch --list "*${workstream.name}*"`, + const result = spawnSync( + 'git', + ['branch', '--list', `*${workstream.name}*`], { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }, ); + const branchOutput = result.stdout ?? ''; const branches = branchOutput.trim().split('\n').filter(Boolean); if (branches.length > 0) { console.log(` ${YELLOW}Branches:${RESET}`); diff --git a/packages/squad-sdk/package.json b/packages/squad-sdk/package.json index 4460a3e9..d7aadd10 100644 --- a/packages/squad-sdk/package.json +++ b/packages/squad-sdk/package.json @@ -1,9 +1,6 @@ { "name": "@bradygaster/squad-sdk", -<<<<<<< HEAD - "version": "0.8.21-preview.1", -======= - "version": "0.8.18-preview.2", + "version": "0.8.21-preview.3", "description": "Squad SDK — Programmable multi-agent runtime for GitHub Copilot", "type": "module", "main": "./dist/index.js", @@ -208,4 +205,3 @@ "directory": "packages/squad-sdk" } } - diff --git a/packages/squad-sdk/src/config/init.ts b/packages/squad-sdk/src/config/init.ts index 62bf71bf..48bdda08 100644 --- a/packages/squad-sdk/src/config/init.ts +++ b/packages/squad-sdk/src/config/init.ts @@ -875,6 +875,7 @@ ${projectDescription ? `- **Description:** ${projectDescription}\n` : ''}- **Cre + '# Squad: workstream activation file (local to this machine)\n' + workstreamIgnoreEntry + '\n'; await appendFile(gitignorePath, block); + createdFiles.push(toRelativePath(gitignorePath)); } } diff --git a/packages/squad-sdk/src/streams/filter.ts b/packages/squad-sdk/src/streams/filter.ts index ab86d250..44b913e0 100644 --- a/packages/squad-sdk/src/streams/filter.ts +++ b/packages/squad-sdk/src/streams/filter.ts @@ -2,7 +2,7 @@ * Workstream-Aware Issue Filtering * * Filters GitHub issues to only those matching a workstream's labelFilter. - * Used by Ralph during triage to scope work to the active workstream. + * Intended to scope work to the active workstream during triage. * * @module streams/filter */ diff --git a/packages/squad-sdk/src/streams/resolver.ts b/packages/squad-sdk/src/streams/resolver.ts index 45a763a3..0a1d1730 100644 --- a/packages/squad-sdk/src/streams/resolver.ts +++ b/packages/squad-sdk/src/streams/resolver.ts @@ -4,8 +4,8 @@ * Resolution order: * 1. SQUAD_TEAM env var → look up in workstreams config * 2. .squad-workstream file (gitignored) → contains workstream name - * 3. squad.config.ts → workstreams.active field (via .squad/workstreams.json) - * 4. null (no workstream — single-squad mode) + * 3. If exactly one workstream is defined in the config, auto-select that workstream + * 4. null (no active workstream — single-squad mode / no workstreams) * * @module streams/resolver */ @@ -28,19 +28,73 @@ export function loadWorkstreamsConfig(squadRoot: string): WorkstreamConfig | nul try { const raw = readFileSync(configPath, 'utf-8'); - const parsed = JSON.parse(raw) as WorkstreamConfig; + const rawConfig = JSON.parse(raw) as unknown; - // Basic validation - if (!parsed.workstreams || !Array.isArray(parsed.workstreams)) { + if (!rawConfig || typeof rawConfig !== 'object') { return null; } - // Ensure defaultWorkflow has a value - if (!parsed.defaultWorkflow) { - parsed.defaultWorkflow = 'branch-per-issue'; + const configLike = rawConfig as { defaultWorkflow?: unknown; workstreams?: unknown }; + + // Derive a sane defaultWorkflow value + const validWorkflows = ['branch-per-issue', 'direct'] as const; + const rawWorkflow = + typeof configLike.defaultWorkflow === 'string' && configLike.defaultWorkflow.trim() !== '' + ? configLike.defaultWorkflow + : 'branch-per-issue'; + const defaultWorkflow: 'branch-per-issue' | 'direct' = + validWorkflows.includes(rawWorkflow as typeof validWorkflows[number]) + ? (rawWorkflow as 'branch-per-issue' | 'direct') + : 'branch-per-issue'; + + const workstreamsRaw = configLike.workstreams; + if (!Array.isArray(workstreamsRaw)) { + return null; + } + + const workstreams: WorkstreamDefinition[] = workstreamsRaw + .filter(entry => entry && typeof entry === 'object') + .map(entry => { + const e = entry as { + name?: unknown; + labelFilter?: unknown; + folderScope?: unknown; + workflow?: unknown; + description?: unknown; + }; + + if (typeof e.name !== 'string' || typeof e.labelFilter !== 'string') { + return null; + } + + const normalized: Record = { + name: e.name, + labelFilter: e.labelFilter, + }; + + if (Array.isArray(e.folderScope) && e.folderScope.every(item => typeof item === 'string')) { + normalized.folderScope = e.folderScope; + } + + if (typeof e.workflow === 'string' && e.workflow.trim() !== '') { + normalized.workflow = e.workflow; + } else { + normalized.workflow = defaultWorkflow; + } + + if (typeof e.description === 'string') { + normalized.description = e.description; + } + + return normalized as unknown as WorkstreamDefinition; + }) + .filter((s): s is WorkstreamDefinition => s !== null); + + if (workstreams.length === 0) { + return null; } - return parsed; + return { defaultWorkflow, workstreams }; } catch { return null; } @@ -112,7 +166,7 @@ export function resolveWorkstream(squadRoot: string): ResolvedWorkstream | null } } - // 3. workstreams.json with an "active" field (convention: first workstream if only one) + // 3. If exactly one workstream is defined, auto-select it if (config && config.workstreams.length === 1) { const def = config.workstreams[0]!; return { name: def.name, definition: def, source: 'config' }; diff --git a/templates/squad.agent.md b/templates/squad.agent.md index 3703e47d..4b0f0189 100644 --- a/templates/squad.agent.md +++ b/templates/squad.agent.md @@ -113,14 +113,16 @@ When triggered: ### Workstream Awareness -On session start, check for workstream context: -1. Read `SQUAD_TEAM` env var -2. If set, read `.squad/workstreams.json` and find matching workstream -3. Apply the workstream's `labelFilter` — Ralph should ONLY pick up issues matching this label -4. Apply the workstream's `workflow` — if `branch-per-issue`, enforce creating a branch and PR for every issue (never commit directly to main) -5. Apply `folderScope` — agents should only modify files in these directories - -If no workstream is detected, operate in default single-squad mode. +On session start, resolve workstream context using the Workstream resolver: +1. Check for a `.squad-workstream` file in the repo root. If present, activate the referenced workstream. +2. If no `.squad-workstream` is present, read the `SQUAD_TEAM` env var (if set) and resolve the matching workstream from `.squad/workstreams.json`. +3. If there is exactly one workstream defined in `.squad/workstreams.json` and nothing else selects a workstream, auto-select it. +4. When a workstream is active: + - Apply the workstream's `labelFilter` — Ralph should normally only pick up issues matching this label unless the user explicitly directs otherwise. + - Apply the workstream's `workflow` — if `branch-per-issue`, enforce creating a branch and PR for every issue (never commit directly to main). + - Apply the workstream's `folderScope` as an advisory focus area: prefer modifying files in these directories, and call out when you intentionally work outside them (e.g., to update shared dependencies or cross-cutting code). + +If no workstream is resolved, operate in default single-squad mode. ### Issue Awareness From 46f50d5f42695665f004b9c9a085eee002fb9488 Mon Sep 17 00:00:00 2001 From: Tamir Dresher Date: Thu, 5 Mar 2026 10:10:32 +0200 Subject: [PATCH 5/9] fix: update acceptance test expectations to match current CLI output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - status.feature: 'Here:' → 'Active squad:' (output format changed) - status-extended.feature: same update + negative assertion fix - init-command.feature: 'Scaffold ready' → 'Your team is ready' (init output changed) These tests were failing for ALL PRs including upstream dev branch. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- test/acceptance/features/init-command.feature | 2 +- test/acceptance/features/status-extended.feature | 4 ++-- test/acceptance/features/status.feature | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/test/acceptance/features/init-command.feature b/test/acceptance/features/init-command.feature index b5ecd9db..a5161db1 100644 --- a/test/acceptance/features/init-command.feature +++ b/test/acceptance/features/init-command.feature @@ -3,7 +3,7 @@ Feature: Init command Scenario: Init in existing project shows ready message Given the current directory has a ".squad" directory When I run "squad init" - Then the output contains "Scaffold ready" + Then the output contains "Your team is ready" And the output contains "already exists" And the exit code is 0 diff --git a/test/acceptance/features/status-extended.feature b/test/acceptance/features/status-extended.feature index a9c3b353..3abab353 100644 --- a/test/acceptance/features/status-extended.feature +++ b/test/acceptance/features/status-extended.feature @@ -4,12 +4,12 @@ Feature: Status command extended Given the current directory has a ".squad" directory When I run "squad status" Then the output contains "Squad Status" - And the output contains "Here:" + And the output contains "Active squad:" And the output contains "Path:" And the exit code is 0 Scenario: Status in directory without squad shows no active squad Given a directory without a ".squad" directory When I run "squad status" in the temp directory - Then the output does not contain "Here: repo" + Then the output does not contain "Active squad: repo" And the exit code is 0 diff --git a/test/acceptance/features/status.feature b/test/acceptance/features/status.feature index 80305f22..ba94d4c6 100644 --- a/test/acceptance/features/status.feature +++ b/test/acceptance/features/status.feature @@ -4,5 +4,5 @@ Feature: Status command Given the current directory has a ".squad" directory When I run "squad status" Then the output contains "Squad Status" - And the output contains "Here:" + And the output contains "Active squad:" And the exit code is 0 From d02fe6e04caf37815192f33d7e40b9317b2e0f93 Mon Sep 17 00:00:00 2001 From: Tamir Dresher Date: Thu, 5 Mar 2026 11:19:05 +0200 Subject: [PATCH 6/9] docs: add scaling-workstreams scenario guide Blog-style walkthrough of horizontal scaling with workstreams. Covers quick start, CLI commands, design decisions, resolution chain. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/scenarios/scaling-workstreams.md | 165 ++++++++++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 docs/scenarios/scaling-workstreams.md diff --git a/docs/scenarios/scaling-workstreams.md b/docs/scenarios/scaling-workstreams.md new file mode 100644 index 00000000..79ab1327 --- /dev/null +++ b/docs/scenarios/scaling-workstreams.md @@ -0,0 +1,165 @@ +# Scaling with Workstreams + +> Partition your repo's work across multiple Squad instances for horizontal scaling. + +## The Problem + +A single Squad instance handles all issues in a repo. For large projects, this creates bottlenecks: +- Too many issues overwhelm a single team +- Agents step on each other's toes in shared code +- No workflow enforcement (agents commit directly to main) +- No way to monitor multiple teams centrally + +## The Solution: Workstreams + +Workstreams partition a repo's issues into labeled subsets. Each Codespace (or machine) runs one workstream, scoped to its slice of work. + +``` +┌─────────────────────────────────────────────────┐ +│ Repository: acme/starship │ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌───────────┐ │ +│ │ Codespace 1 │ │ Codespace 2 │ │ Codespace 3│ │ +│ │ team:bridge │ │ team:engine │ │ team:ops │ │ +│ │ Picard,Riker│ │ Geordi,Worf │ │ Troi,Crusher│ │ +│ │ UI + API │ │ Core engine │ │ Infra + CI │ │ +│ └─────────────┘ └─────────────┘ └───────────┘ │ +│ │ +│ Each Squad instance only picks up issues │ +│ matching its workstream label. │ +└─────────────────────────────────────────────────┘ +``` + +## Quick Start + +### 1. Define workstreams + +Create `.squad/workstreams.json`: + +```json +{ + "defaultWorkflow": "branch-per-issue", + "workstreams": [ + { + "name": "bridge", + "labelFilter": "team:bridge", + "folderScope": ["src/api", "src/ui"], + "description": "Bridge crew — API and UI" + }, + { + "name": "engine", + "labelFilter": "team:engine", + "folderScope": ["src/core", "src/engine"], + "description": "Engineering — core systems" + }, + { + "name": "ops", + "labelFilter": "team:ops", + "folderScope": ["infra/", "scripts/", ".github/"], + "description": "Operations — CI/CD and infra" + } + ] +} +``` + +### 2. Label your issues + +Each issue gets a `team:*` label matching a workstream. Ralph will only pick up issues matching the active workstream's label. + +### 3. Activate a workstream + +**Option A — Environment variable (Codespaces):** +Set `SQUAD_TEAM=bridge` in the Codespace's environment. Squad auto-detects it on session start. + +**Option B — CLI activation (local):** +```bash +squad workstreams activate bridge +``` +This writes a `.squad-workstream` file (gitignored — local to your machine). + +**Option C — Single workstream auto-select:** +If `workstreams.json` defines only one workstream, it's auto-selected. + +### 4. Run Squad normally + +```bash +squad start +# or: "Ralph, go" in the session +``` + +Ralph will only scan for issues with the `team:bridge` label. Agents will only pick up matching work. + +## CLI Commands + +```bash +# List configured workstreams +squad workstreams list + +# Show activity per workstream (branches, PRs) +squad workstreams status + +# Activate a workstream for this machine +squad workstreams activate engine + +# Backward compat alias +squad streams list +``` + +## Key Design Decisions + +### folderScope is Advisory + +`folderScope` tells agents which directories to focus on — but it's not a hard lock. Agents can modify shared packages (like `src/shared/`) when needed, and will call out when working outside their scope. + +### Workflow Enforcement + +Each workstream specifies a `workflow` (default: `branch-per-issue`). When active, agents: +- Create a branch for every issue (`squad/{issue-number}-{slug}`) +- Open a PR when work is ready +- Never commit directly to main + +### Single-Machine Multi-Workstream + +You don't need multiple Codespaces to test. Use `squad workstreams activate` to switch between workstreams sequentially on a single machine. + +## Resolution Chain + +Squad resolves the active workstream in this order: + +1. `SQUAD_TEAM` environment variable +2. `.squad-workstream` file (written by `squad workstreams activate`) +3. Auto-select if exactly one workstream is defined +4. No workstream → single-squad mode (backward compatible) + +## Monitoring + +Use `squad workstreams status` to see all workstreams' activity: + +``` +Configured Workstreams + + Default workflow: branch-per-issue + + ● active bridge + Label: team:bridge + Workflow: branch-per-issue + Folders: src/api, src/ui + + ○ engine + Label: team:engine + Workflow: branch-per-issue + Folders: src/core, src/engine + + ○ ops + Label: team:ops + Workflow: branch-per-issue + Folders: infra/, scripts/, .github/ + + Active workstream resolved via: env +``` + +## See Also + +- [Multi-Codespace Setup](multi-codespace.md) — Walkthrough of the Tetris experiment +- [Workstreams PRD](../specs/streams-prd.md) — Full specification +- [Workstreams Feature Guide](../features/streams.md) — API reference From f8b91d1467b30dc58e32059d8e218bc203af5c71 Mon Sep 17 00:00:00 2001 From: Tamir Dresher Date: Thu, 5 Mar 2026 12:30:30 +0200 Subject: [PATCH 7/9] fix: update remaining acceptance test expectations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ux-gates.test.ts: 'Here:' → 'Active squad:' (same status output change) - status-extended.feature: no-squad dir exit code 0 → 1 - hostile-no-config.feature: 'none' → 'not initialized' for status output Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- test/acceptance/features/hostile-no-config.feature | 2 +- test/acceptance/features/status-extended.feature | 2 +- test/ux-gates.test.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/test/acceptance/features/hostile-no-config.feature b/test/acceptance/features/hostile-no-config.feature index 9a03cec4..d4ece0de 100644 --- a/test/acceptance/features/hostile-no-config.feature +++ b/test/acceptance/features/hostile-no-config.feature @@ -15,7 +15,7 @@ Feature: Hostile — Missing .squad/ configuration Scenario: Status reports no squad without .squad/ directory Given a directory without a ".squad" directory When I run "squad status" in the temp directory - Then the output contains "none" + Then the output contains "not initialized" And the exit code is 0 Scenario: Doctor runs without .squad/ directory diff --git a/test/acceptance/features/status-extended.feature b/test/acceptance/features/status-extended.feature index 3abab353..9875629e 100644 --- a/test/acceptance/features/status-extended.feature +++ b/test/acceptance/features/status-extended.feature @@ -12,4 +12,4 @@ Feature: Status command extended Given a directory without a ".squad" directory When I run "squad status" in the temp directory Then the output does not contain "Active squad: repo" - And the exit code is 0 + And the exit code is 1 diff --git a/test/ux-gates.test.ts b/test/ux-gates.test.ts index f4872b40..a038fc8d 100644 --- a/test/ux-gates.test.ts +++ b/test/ux-gates.test.ts @@ -70,7 +70,7 @@ describe('UX Gates', () => { const output = harness.captureFrame(); expect(output).toMatch(/Squad Status/i); - expect(output).toMatch(/Here:/i); + expect(output).toMatch(/Active squad:/i); }); it('Help groups commands into categories', async () => { From f9723a7755d4693babb4da7cd777137ab9eee111 Mon Sep 17 00:00:00 2001 From: Tamir Dresher Date: Thu, 5 Mar 2026 17:01:33 +0200 Subject: [PATCH 8/9] blog: add workstreams horizontal scaling post (#023) Community contribution blog post covering the workstreams feature, Tetris experiment findings, CLI commands, and design decisions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../023-workstreams-horizontal-scaling.md | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 docs/blog/023-workstreams-horizontal-scaling.md diff --git a/docs/blog/023-workstreams-horizontal-scaling.md b/docs/blog/023-workstreams-horizontal-scaling.md new file mode 100644 index 00000000..c15a9025 --- /dev/null +++ b/docs/blog/023-workstreams-horizontal-scaling.md @@ -0,0 +1,133 @@ +--- +title: "Workstreams — Scaling Squad Across Multiple Codespaces" +date: 2026-03-05 +author: "Tamir Dresher (Community Contributor)" +wave: null +tags: [squad, workstreams, scaling, codespaces, horizontal-scaling, multi-instance, community] +status: draft +hero: "Squad Workstreams lets you partition a repo's work across multiple Codespaces — each running its own scoped Squad instance. One repo, multiple AI teams, zero conflicts." +--- + +# Workstreams — Scaling Squad Across Multiple Codespaces + +> Blog post #23 — A community contribution: horizontal scaling for Squad. + +## The Problem We Hit + +We were building a multiplayer Tetris game with Squad. One team, 30 issues — UI, backend, cloud infra. Squad handled it fine at first, but as the issue count grew, a single Squad instance became a bottleneck. Agents stepped on each other in shared packages, there was no workflow enforcement, and we had no way to scope each Codespace to its slice of work. + +So we built Workstreams. + +## What Are Workstreams? + +Workstreams partition your repo's issues into labeled subsets. Each Codespace (or machine) runs one workstream, scoped to matching issues only. + +``` +┌─────────────────────────────────────────────────┐ +│ Repository: acme/starship │ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌───────────┐ │ +│ │ Codespace 1 │ │ Codespace 2 │ │ Codespace 3│ │ +│ │ team:bridge │ │ team:engine │ │ team:ops │ │ +│ │ UI + API │ │ Core engine │ │ Infra + CI │ │ +│ └─────────────┘ └─────────────┘ └───────────┘ │ +│ │ +│ Ralph only picks up issues matching │ +│ the active workstream's label. │ +└─────────────────────────────────────────────────┘ +``` + +## How It Works + +**1. Define workstreams** in `.squad/workstreams.json`: + +```json +{ + "defaultWorkflow": "branch-per-issue", + "workstreams": [ + { + "name": "bridge", + "labelFilter": "team:bridge", + "folderScope": ["src/api", "src/ui"], + "description": "Bridge crew — API and UI" + }, + { + "name": "engine", + "labelFilter": "team:engine", + "folderScope": ["src/core"], + "description": "Engineering — core systems" + } + ] +} +``` + +**2. Activate a workstream:** + +```bash +# Via environment variable (ideal for Codespaces) +export SQUAD_TEAM=bridge + +# Or via CLI (local machines) +squad workstreams activate bridge +``` + +**3. Run Squad normally.** Ralph will only pick up issues labeled `team:bridge`. Agents enforce branch+PR workflow. `folderScope` guides where agents focus (advisory, not enforced — shared code is still accessible). + +## The Tetris Experiment + +We validated this with [tamirdresher/squad-tetris](https://github.com/tamirdresher/squad-tetris) — 3 Codespaces, 30 issues, Star Trek TNG crew names: + +| Codespace | Workstream | Squad Members | Focus | +|-----------|-----------|---------------|-------| +| CS-1 | `ui` | Riker, Troi | React game board, animations | +| CS-2 | `backend` | Geordi, Worf | WebSocket server, game state | +| CS-3 | `cloud` | Picard, Crusher | Azure, CI/CD, deployment | + +**Results:** 9 issues closed, 16 branches created, 6+ PRs merged, real code shipped across all three teams. We discovered that `folderScope` needs to be advisory (shared packages require cross-team access) and that workflow enforcement (`branch-per-issue`) is critical to prevent direct commits to main. + +## CLI Commands + +```bash +squad workstreams list # Show all configured workstreams +squad workstreams status # Activity per workstream (branches, PRs) +squad workstreams activate X # Activate a workstream for this machine +``` + +## Resolution Chain + +Squad resolves the active workstream in priority order: + +1. `SQUAD_TEAM` environment variable +2. `.squad-workstream` file (written by `activate`, gitignored) +3. Auto-select if exactly one workstream is defined +4. No workstream → single-squad mode (backward compatible) + +## Key Design Decisions + +- **folderScope is advisory** — agents prefer these directories but can modify shared code when needed +- **Workflow enforcement** — `branch-per-issue` means every issue gets a branch and PR, never direct commits to main +- **Backward compatible** — repos without `workstreams.json` work exactly as before +- **Single-machine testing** — use `squad workstreams activate` to switch workstreams sequentially without needing multiple Codespaces + +## What's Next + +We're looking at cross-workstream coordination — a central dashboard showing all workstreams' activity, conflict detection for shared files, and auto-merge coordination. See the [PRD](https://github.com/bradygaster/squad/issues/200) for the full roadmap. + +Also: we haven't settled on the name yet! "Workstreams" is the working title, but we're considering alternatives like "Lanes", "Teams", or "Streams". If you have an opinion, let us know in the [discussion](https://github.com/bradygaster/squad/issues/200). + +## Try It + +```bash +# Install Squad +npm install -g @bradygaster/squad-cli + +# Init in your repo +squad init + +# Create workstreams.json and label your issues +# Then activate and go +squad workstreams activate frontend +squad start +``` + +Full docs: [Scaling with Workstreams](../scenarios/scaling-workstreams.md) | [Multi-Codespace Setup](../scenarios/multi-codespace.md) | [Workstreams PRD](../specs/streams-prd.md) From e4c0fb5a6e4c779b2f35ca15733c0aa56fb1b0bb Mon Sep 17 00:00:00 2001 From: Tamir Dresher Date: Fri, 6 Mar 2026 16:48:12 +0200 Subject: [PATCH 9/9] fix: wire upstream command into CLI entry point Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/squad-cli/src/cli-entry.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/squad-cli/src/cli-entry.ts b/packages/squad-cli/src/cli-entry.ts index 9cc14c3f..25e89915 100644 --- a/packages/squad-cli/src/cli-entry.ts +++ b/packages/squad-cli/src/cli-entry.ts @@ -273,6 +273,12 @@ async function main(): Promise { return; } + if (cmd === 'upstream') { + const { upstreamCommand } = await import('./cli/commands/upstream.js'); + await upstreamCommand(args.slice(1)); + return; + } + // Unknown command fatal(`Unknown command: ${cmd}\n Run 'squad help' for usage information.`); } @@ -288,3 +294,5 @@ main().catch(err => { + +