diff --git a/.gitignore b/.gitignore index 862a7cfa..40cd3bf9 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ coverage/ .test-cli-* # Docs site generated files docs/dist/ +# Squad: workstream activation file (local to this machine) +.squad-workstream +.squad/.first-run 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) diff --git a/docs/features/streams.md b/docs/features/streams.md new file mode 100644 index 00000000..1b718519 --- /dev/null +++ b/docs/features/streams.md @@ -0,0 +1,146 @@ +# Squad Workstreams + +> Scale Squad across multiple Codespaces by partitioning work into labeled workstreams. + +## What Are Workstreams? + +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 Workstreams? + +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 + +Workstreams solve this by giving each Codespace a scoped view of the project. + +## Configuration + +### 1. Create `.squad/workstreams.json` + +```json +{ + "workstreams": [ + { + "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 Workstream + +There are three ways to tell Squad which workstream 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-workstream File (local activation) + +```bash +squad workstreams activate ui-team +``` + +This writes a `.squad-workstream` file (gitignored) so the setting is local to your machine. + +#### Auto-select (single workstream) + +If `workstreams.json` contains only one workstream, it's automatically selected. + +### 3. Resolution Priority + +1. `SQUAD_TEAM` env var (highest) +2. `.squad-workstream` file +3. Single-workstream auto-select +4. No workstream (classic single-squad mode) + +## Workstream Definition Fields + +| Field | Required | Description | +|-------|----------|-------------| +| `name` | Yes | Unique workstream identifier (kebab-case) | +| `labelFilter` | Yes | GitHub label to filter issues | +| `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 workstreams +squad workstreams list + +# Show workstream activity (branches, PRs) +squad workstreams status + +# Activate a workstream locally +squad workstreams activate +``` + +## How It Works + +### Triage (Ralph) + +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 workstreams). + +### Folder Scope + +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 + +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..873e6390 --- /dev/null +++ b/docs/scenarios/multi-codespace.md @@ -0,0 +1,127 @@ +# Multi-Codespace Setup with Squad Workstreams + +> End-to-end walkthrough of running multiple Squad instances across Codespaces. + +## Background: The Tetris Experiment + +We validated Squad Workstreams by building a multiplayer Tetris game using 3 Codespaces, each running a separate workstream: + +| 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 workstream's label. + +## Setup Steps + +### 1. Create the workstreams config + +In your repository, create `.squad/workstreams.json`: + +```json +{ + "workstreams": [ + { + "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 workstream 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 workstream 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 workstreams + +Use the CLI from any Codespace to see all workstreams: + +```bash +squad workstreams status +``` + + + + +## What Worked + +- **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-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 workstreams (future work) + +## Lessons Learned + +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/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 diff --git a/docs/specs/streams-prd.md b/docs/specs/streams-prd.md new file mode 100644 index 00000000..30b01748 --- /dev/null +++ b/docs/specs/streams-prd.md @@ -0,0 +1,138 @@ +# Workstreams PRD — Product Requirements Document + +> Scaling Squad across multiple Codespaces via labeled workstreams. + +## 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 workstreams, validating the approach. However, the manual coordination was error-prone and needed to be automated. + +## Requirements + +### Must Have (P0) + +- [ ] **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) + +- [ ] **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 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 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. + +**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 Workstream Activation + +**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. + +### 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 workstream. + +### 4. Synthesized Definitions for Unknown Workstreams + +**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 `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 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-Workstream Dependencies + +Track when a workstream needs work from another workstream: +- Automated detection via import graphs +- Cross-workstream issue linking +- Priority escalation for blocking dependencies + +### Workstream Templates + +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/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..f238ffa5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bradygaster/squad", - "version": "0.8.21-preview.1", + "version": "0.8.21-preview.3", "private": true, "description": "Squad — Programmable multi-agent runtime for GitHub Copilot, built on @github/copilot-sdk", "type": "module", diff --git a/packages/squad-cli/package.json b/packages/squad-cli/package.json index 569299fc..f6461bff 100644 --- a/packages/squad-cli/package.json +++ b/packages/squad-cli/package.json @@ -1,6 +1,6 @@ { "name": "@bradygaster/squad-cli", - "version": "0.8.21-preview.1", + "version": "0.8.21-preview.3", "description": "Squad CLI — Command-line interface for the Squad multi-agent runtime", "type": "module", "bin": { diff --git a/packages/squad-cli/src/cli-entry.ts b/packages/squad-cli/src/cli-entry.ts index e0989212..25e89915 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 { runWorkstreams } = await import('./cli/commands/streams.js'); + await runWorkstreams(process.cwd(), args.slice(1)); + return; + } + if (cmd === 'start') { const { runStart } = await import('./cli/commands/start.js'); const hasTunnel = args.includes('--tunnel'); @@ -265,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.`); } @@ -277,3 +291,8 @@ 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..411695d0 --- /dev/null +++ b/packages/squad-cli/src/cli/commands/streams.ts @@ -0,0 +1,173 @@ +/** + * CLI command: squad workstreams + * + * Subcommands: + * 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 { spawnSync } from 'node:child_process'; +import { loadWorkstreamsConfig, resolveWorkstream } from '@bradygaster/squad-sdk'; +import type { WorkstreamDefinition } 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 workstreams` subcommand. + */ +export async function runWorkstreams(cwd: string, args: string[]): Promise { + const sub = args[0]; + + if (!sub || sub === 'list') { + return listWorkstreams(cwd); + } + if (sub === 'status') { + return showWorkstreamStatus(cwd); + } + if (sub === 'activate') { + const name = args[1]; + if (!name) { + console.error(`${RED}✗${RESET} Usage: squad workstreams activate `); + process.exit(1); + } + return activateWorkstream(cwd, name); + } + + 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 workstreams. + */ +function listWorkstreams(cwd: string): void { + const config = loadWorkstreamsConfig(cwd); + const active = resolveWorkstream(cwd); + + 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 Workstreams${RESET}\n`); + console.log(` Default workflow: ${config.defaultWorkflow}\n`); + + for (const workstream of config.workstreams) { + const isActive = active?.name === workstream.name; + const marker = isActive ? `${GREEN}● active${RESET}` : `${DIM}○${RESET}`; + const workflow = workstream.workflow ?? config.defaultWorkflow; + console.log(` ${marker} ${BOLD}${workstream.name}${RESET}`); + console.log(` Label: ${workstream.labelFilter}`); + console.log(` Workflow: ${workflow}`); + if (workstream.folderScope?.length) { + console.log(` Folders: ${workstream.folderScope.join(', ')}`); + } + if (workstream.description) { + console.log(` ${DIM}${workstream.description}${RESET}`); + } + console.log(); + } + + if (active) { + console.log(` ${DIM}Active workstream resolved via: ${active.source}${RESET}\n`); + } +} + +/** + * Show activity per workstream (branches, PRs via gh CLI). + */ +function showWorkstreamStatus(cwd: string): void { + const config = loadWorkstreamsConfig(cwd); + const active = resolveWorkstream(cwd); + + if (!config || config.workstreams.length === 0) { + console.log(`\n${DIM}No workstreams configured.${RESET}\n`); + return; + } + + console.log(`\n${BOLD}Workstream Status${RESET}\n`); + + for (const workstream of config.workstreams) { + const isActive = active?.name === workstream.name; + const marker = isActive ? `${GREEN}●${RESET}` : `${DIM}○${RESET}`; + console.log(` ${marker} ${BOLD}${workstream.name}${RESET} (${workstream.labelFilter})`); + + // Try to get PR and branch info via gh CLI + try { + 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}`); + 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 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}`); + for (const branch of branches) { + console.log(` ${branch.trim()}`); + } + } + } catch { + // Git not available — skip + } + + console.log(); + } +} + +/** + * Activate a workstream by writing .squad-workstream file. + */ +function activateWorkstream(cwd: string, name: string): void { + const config = loadWorkstreamsConfig(cwd); + + // Validate the workstream exists in config (warn if not, but still allow) + if (config) { + const found = config.workstreams.find(s => s.name === name); + if (!found) { + 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 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/package.json b/packages/squad-sdk/package.json index d5c31606..d7aadd10 100644 --- a/packages/squad-sdk/package.json +++ b/packages/squad-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@bradygaster/squad-sdk", - "version": "0.8.21-preview.1", + "version": "0.8.21-preview.3", "description": "Squad SDK — Programmable multi-agent runtime for GitHub Copilot", "type": "module", "main": "./dist/index.js", diff --git a/packages/squad-sdk/src/config/init.ts b/packages/squad-sdk/src/config/init.ts index d5cc5531..48bdda08 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 { WorkstreamDefinition } 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 workstream definitions — generates .squad/workstreams.json when provided */ + streams?: WorkstreamDefinition[]; } /** @@ -844,6 +847,38 @@ ${projectDescription ? `- **Description:** ${projectDescription}\n` : ''}- **Cre } } + // ------------------------------------------------------------------------- + // Generate .squad/workstreams.json (when streams provided) + // ------------------------------------------------------------------------- + + if (options.streams && options.streams.length > 0) { + const workstreamsConfig = { + workstreams: options.streams, + defaultWorkflow: 'branch-per-issue', + }; + const workstreamsPath = join(squadDir, 'workstreams.json'); + await writeIfNotExists(workstreamsPath, JSON.stringify(workstreamsConfig, null, 2) + '\n'); + } + + // ------------------------------------------------------------------------- + // Add .squad-workstream to .gitignore + // ------------------------------------------------------------------------- + + { + const workstreamIgnoreEntry = '.squad-workstream'; + let currentIgnore = ''; + if (existsSync(gitignorePath)) { + currentIgnore = readFileSync(gitignorePath, 'utf-8'); + } + if (!currentIgnore.includes(workstreamIgnoreEntry)) { + const block = (currentIgnore && !currentIgnore.endsWith('\n') ? '\n' : '') + + '# Squad: workstream activation file (local to this machine)\n' + + workstreamIgnoreEntry + '\n'; + await appendFile(gitignorePath, block); + createdFiles.push(toRelativePath(gitignorePath)); + } + } + // ------------------------------------------------------------------------- // 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..44b913e0 --- /dev/null +++ b/packages/squad-sdk/src/streams/filter.ts @@ -0,0 +1,48 @@ +/** + * Workstream-Aware Issue Filtering + * + * Filters GitHub issues to only those matching a workstream's labelFilter. + * Intended to scope work to the active workstream during triage. + * + * @module streams/filter + */ + +import type { ResolvedWorkstream } from './types.js'; + +/** Minimal issue shape for filtering. */ +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 workstream's label filter. + * + * Matching is case-insensitive. If the workstream has no labelFilter, + * all issues are returned (passthrough). + * + * @param issues - Array of issues to filter + * @param workstream - The resolved workstream to filter by + * @returns Filtered array of issues matching the workstream's label + */ +export function filterIssuesByWorkstream( + issues: WorkstreamIssue[], + workstream: ResolvedWorkstream, +): WorkstreamIssue[] { + const filter = workstream.definition.labelFilter; + if (!filter) { + return issues; + } + + const normalizedFilter = filter.toLowerCase(); + return issues.filter(issue => + 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 new file mode 100644 index 00000000..aafc995e --- /dev/null +++ b/packages/squad-sdk/src/streams/index.ts @@ -0,0 +1,9 @@ +/** + * Workstreams 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..0a1d1730 --- /dev/null +++ b/packages/squad-sdk/src/streams/resolver.ts @@ -0,0 +1,193 @@ +/** + * Workstream Resolver — Resolves which workstream is active. + * + * Resolution order: + * 1. SQUAD_TEAM env var → look up in workstreams config + * 2. .squad-workstream file (gitignored) → contains workstream name + * 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 + */ + +import { existsSync, readFileSync } from 'fs'; +import { join } from 'path'; +import type { WorkstreamConfig, WorkstreamDefinition, ResolvedWorkstream } from './types.js'; + +/** + * Load workstreams configuration from .squad/workstreams.json. + * + * @param squadRoot - Root directory of the project (where .squad/ lives) + * @returns Parsed WorkstreamConfig or null if not found / invalid + */ +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 rawConfig = JSON.parse(raw) as unknown; + + if (!rawConfig || typeof rawConfig !== 'object') { + return null; + } + + 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 { defaultWorkflow, workstreams }; + } catch { + return null; + } +} + +/** @deprecated Use loadWorkstreamsConfig instead */ +export const loadStreamsConfig = loadWorkstreamsConfig; + +/** + * Find a workstream definition by name in a config. + */ +function findWorkstream(config: WorkstreamConfig, name: string): WorkstreamDefinition | undefined { + return config.workstreams.find(s => s.name === name); +} + +/** + * Resolve which workstream is active for the current environment. + * + * @param squadRoot - Root directory of the project + * @returns ResolvedWorkstream or null if no workstream is active + */ +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 = findWorkstream(config, envTeam); + if (def) { + return { name: envTeam, definition: def, source: 'env' }; + } + } + // Env var set but no matching workstream config — synthesize a minimal definition + return { + name: envTeam, + definition: { + name: envTeam, + labelFilter: `team:${envTeam}`, + }, + source: 'env', + }; + } + + // 2. .squad-workstream file + const workstreamFilePath = join(squadRoot, '.squad-workstream'); + if (existsSync(workstreamFilePath)) { + try { + const workstreamName = readFileSync(workstreamFilePath, 'utf-8').trim(); + if (workstreamName) { + if (config) { + const def = findWorkstream(config, workstreamName); + if (def) { + return { name: workstreamName, definition: def, source: 'file' }; + } + } + // File exists but no config — synthesize + return { + name: workstreamName, + definition: { + name: workstreamName, + labelFilter: `team:${workstreamName}`, + }, + source: 'file', + }; + } + } catch { + // Ignore read errors + } + } + + // 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' }; + } + + // 4. No workstream detected + return null; +} + +/** @deprecated Use resolveWorkstream instead */ +export const resolveStream = resolveWorkstream; + +/** + * Get the GitHub label filter string for a resolved workstream. + * + * @param workstream - The resolved workstream + * @returns Label filter string (e.g., "team:ui") + */ +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 new file mode 100644 index 00000000..dcd9ee4e --- /dev/null +++ b/packages/squad-sdk/src/streams/types.ts @@ -0,0 +1,49 @@ +/** + * Workstream Types — Type definitions for Squad Workstreams. + * + * 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 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; + /** 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 workstream's purpose */ + description?: string; +} + +/** @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'; +} + +/** @deprecated Use WorkstreamConfig instead */ +export type StreamConfig = WorkstreamConfig; + +/** A resolved workstream with provenance information. */ +export interface ResolvedWorkstream { + /** Workstream name */ + name: string; + /** 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 0563d751..2123d59d 100644 --- a/packages/squad-sdk/src/types.ts +++ b/packages/squad-sdk/src/types.ts @@ -57,3 +57,12 @@ 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'; + +// --- 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 1af35a5a..4b0f0189 100644 --- a/templates/squad.agent.md +++ b/templates/squad.agent.md @@ -111,6 +111,19 @@ 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. +### Workstream Awareness + +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 **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/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/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..9875629e 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" - And the exit code is 0 + Then the output does not contain "Active squad: repo" + And the exit code is 1 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 diff --git a/test/streams.test.ts b/test/streams.test.ts new file mode 100644 index 00000000..fa93cf53 --- /dev/null +++ b/test/streams.test.ts @@ -0,0 +1,592 @@ +/** + * Squad Workstreams — Comprehensive Tests + * + * Tests cover: + * - 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-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'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; + +import { + loadWorkstreamsConfig, + resolveWorkstream, + getWorkstreamLabelFilter, + filterIssuesByWorkstream, +} from '../packages/squad-sdk/src/streams/index.js'; + +import type { + WorkstreamDefinition, + WorkstreamConfig, + ResolvedWorkstream, + WorkstreamIssue, +} from '../packages/squad-sdk/src/streams/index.js'; + +// ============================================================================ +// Helpers +// ============================================================================ + +function makeTmpDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'squad-workstreams-test-')); +} + +function writeSquadWorkstreamsConfig(root: string, config: WorkstreamConfig): void { + const squadDir = path.join(root, '.squad'); + fs.mkdirSync(squadDir, { recursive: true }); + fs.writeFileSync(path.join(squadDir, 'workstreams.json'), JSON.stringify(config, null, 2), 'utf-8'); +} + +function writeSquadWorkstreamFile(root: string, name: string): void { + fs.writeFileSync(path.join(root, '.squad-workstream'), name + '\n', 'utf-8'); +} + +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' }, + ], + defaultWorkflow: 'branch-per-issue', +}; + +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' }] }, + { number: 4, title: 'Unscoped issue', labels: [{ name: 'bug' }] }, + { number: 5, title: 'Multi-label', labels: [{ name: 'team:ui' }, { name: 'team:backend' }] }, +]; + +// ============================================================================ +// loadStreamsConfig +// ============================================================================ + +describe('loadWorkstreamsConfig', () => { + let tmpDir: string; + + beforeEach(() => { tmpDir = makeTmpDir(); }); + afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); + + it('returns null when .squad/workstreams.json does not exist', () => { + expect(loadWorkstreamsConfig(tmpDir)).toBeNull(); + }); + + it('loads a valid workstreams config', () => { + writeSquadWorkstreamsConfig(tmpDir, SAMPLE_CONFIG); + const result = loadWorkstreamsConfig(tmpDir); + expect(result).not.toBeNull(); + 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, 'workstreams.json'), '{invalid', 'utf-8'); + expect(loadWorkstreamsConfig(tmpDir)).toBeNull(); + }); + + it('returns null when workstreams array is missing', () => { + const squadDir = path.join(tmpDir, '.squad'); + fs.mkdirSync(squadDir, { recursive: true }); + 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, 'workstreams.json'), '{"workstreams":[{"name":"a","labelFilter":"x"}]}', 'utf-8'); + const result = loadWorkstreamsConfig(tmpDir); + expect(result!.defaultWorkflow).toBe('branch-per-issue'); + }); + + it('preserves folderScope arrays', () => { + writeSquadWorkstreamsConfig(tmpDir, SAMPLE_CONFIG); + const result = loadWorkstreamsConfig(tmpDir)!; + expect(result.workstreams[0]!.folderScope).toEqual(['apps/web']); + }); + + it('preserves optional description', () => { + writeSquadWorkstreamsConfig(tmpDir, SAMPLE_CONFIG); + const result = loadWorkstreamsConfig(tmpDir)!; + expect(result.workstreams[0]!.description).toBe('UI specialists'); + expect(result.workstreams[1]!.description).toBeUndefined(); + }); +}); + +// ============================================================================ +// resolveStream +// ============================================================================ + +describe('resolveWorkstream', () => { + 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'; + writeSquadWorkstreamsConfig(tmpDir, SAMPLE_CONFIG); + const result = resolveWorkstream(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 = 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 workstream not in config', () => { + process.env.SQUAD_TEAM = 'unknown-team'; + writeSquadWorkstreamsConfig(tmpDir, SAMPLE_CONFIG); + const result = resolveWorkstream(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-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-workstream file when no config', () => { + writeSquadWorkstreamFile(tmpDir, 'my-workstream'); + const result = resolveWorkstream(tmpDir); + expect(result).not.toBeNull(); + expect(result!.name).toBe('my-workstream'); + expect(result!.source).toBe('file'); + expect(result!.definition.labelFilter).toBe('team:my-workstream'); + }); + + 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-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 workstream auto-select) --- + + it('auto-selects single workstream from config', () => { + const singleConfig: WorkstreamConfig = { + workstreams: [{ name: 'solo', labelFilter: 'team:solo' }], + defaultWorkflow: 'direct', + }; + writeSquadWorkstreamsConfig(tmpDir, singleConfig); + const result = resolveWorkstream(tmpDir); + expect(result).not.toBeNull(); + expect(result!.name).toBe('solo'); + expect(result!.source).toBe('config'); + }); + + // --- Fallback --- + + it('returns null when no workstream context exists', () => { + expect(resolveWorkstream(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-workstream file', () => { + process.env.SQUAD_TEAM = 'ui-team'; + 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-workstream file takes priority over config auto-select', () => { + const singleConfig: WorkstreamConfig = { + workstreams: [ + { name: 'alpha', labelFilter: 'team:alpha' }, + ], + defaultWorkflow: 'branch-per-issue', + }; + writeSquadWorkstreamsConfig(tmpDir, singleConfig); + writeSquadWorkstreamFile(tmpDir, 'alpha'); + const result = resolveWorkstream(tmpDir); + // file source takes priority + expect(result!.source).toBe('file'); + }); +}); + +// ============================================================================ +// getStreamLabelFilter +// ============================================================================ + +describe('getWorkstreamLabelFilter', () => { + it('returns the label filter from definition', () => { + const workstream: ResolvedWorkstream = { + name: 'ui-team', + definition: { name: 'ui-team', labelFilter: 'team:ui' }, + source: 'env', + }; + expect(getWorkstreamLabelFilter(workstream)).toBe('team:ui'); + }); + + it('returns synthesized label filter', () => { + const workstream: ResolvedWorkstream = { + name: 'custom', + definition: { name: 'custom', labelFilter: 'team:custom' }, + source: 'file', + }; + expect(getWorkstreamLabelFilter(workstream)).toBe('team:custom'); + }); +}); + +// ============================================================================ +// filterIssuesByStream +// ============================================================================ + +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 = 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 workstream: ResolvedWorkstream = { + name: 'qa-team', + definition: { name: 'qa-team', labelFilter: 'team:qa' }, + source: 'env', + }; + const result = filterIssuesByWorkstream(SAMPLE_ISSUES, workstream); + expect(result).toHaveLength(0); + }); + + it('handles case-insensitive matching', () => { + const workstream: ResolvedWorkstream = { + name: 'ui-team', + definition: { name: 'ui-team', labelFilter: 'TEAM:UI' }, + source: 'env', + }; + const result = filterIssuesByWorkstream(SAMPLE_ISSUES, workstream); + expect(result).toHaveLength(2); + }); + + it('returns all issues when labelFilter is empty', () => { + const workstream: ResolvedWorkstream = { + name: 'all', + definition: { name: 'all', labelFilter: '' }, + source: 'env', + }; + const result = filterIssuesByWorkstream(SAMPLE_ISSUES, workstream); + expect(result).toHaveLength(SAMPLE_ISSUES.length); + }); + + it('handles issues with no labels', () => { + const issues: WorkstreamIssue[] = [ + { number: 10, title: 'No labels', labels: [] }, + ]; + const workstream: ResolvedWorkstream = { + name: 'ui-team', + definition: { name: 'ui-team', labelFilter: 'team:ui' }, + source: 'env', + }; + expect(filterIssuesByWorkstream(issues, workstream)).toHaveLength(0); + }); + + it('handles empty issues array', () => { + const workstream: ResolvedWorkstream = { + name: 'ui-team', + definition: { name: 'ui-team', labelFilter: 'team:ui' }, + source: 'env', + }; + expect(filterIssuesByWorkstream([], workstream)).toHaveLength(0); + }); + + it('filters backend-team correctly', () => { + const workstream: ResolvedWorkstream = { + name: 'backend-team', + definition: { name: 'backend-team', labelFilter: 'team:backend' }, + source: 'config', + }; + 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 workstream: ResolvedWorkstream = { + name: 'infra-team', + definition: { name: 'infra-team', labelFilter: 'team:infra' }, + source: 'file', + }; + const result = filterIssuesByWorkstream(SAMPLE_ISSUES, workstream); + expect(result).toHaveLength(1); + expect(result[0]!.number).toBe(3); + }); +}); + +// ============================================================================ +// Type checks (compile-time — these just verify the types work) +// ============================================================================ + +describe('Workstream types', () => { + it('WorkstreamDefinition accepts all fields', () => { + const def: WorkstreamDefinition = { + name: 'test', + labelFilter: 'team:test', + folderScope: ['src/'], + workflow: 'branch-per-issue', + description: 'Test workstream', + }; + expect(def.name).toBe('test'); + expect(def.workflow).toBe('branch-per-issue'); + }); + + it('WorkstreamDefinition works with minimal fields', () => { + const def: WorkstreamDefinition = { + name: 'minimal', + labelFilter: 'team:minimal', + }; + expect(def.folderScope).toBeUndefined(); + expect(def.workflow).toBeUndefined(); + expect(def.description).toBeUndefined(); + }); + + it('WorkstreamConfig has required fields', () => { + const config: WorkstreamConfig = { + workstreams: [], + defaultWorkflow: 'direct', + }; + expect(config.workstreams).toEqual([]); + expect(config.defaultWorkflow).toBe('direct'); + }); + + it('ResolvedWorkstream has source provenance', () => { + const resolved: ResolvedWorkstream = { + name: 'test', + definition: { name: 'test', labelFilter: 'x' }, + source: 'env', + }; + expect(resolved.source).toBe('env'); + }); +}); + +// ============================================================================ +// Init integration (streams.json generation) +// ============================================================================ + +describe('initSquad with workstreams', () => { + let tmpDir: string; + + beforeEach(() => { tmpDir = makeTmpDir(); }); + afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); + + it('generates workstreams.json when streams option is provided', async () => { + const { initSquad } = await import('../packages/squad-sdk/src/config/init.js'); + const streams: WorkstreamDefinition[] = [ + { name: 'ui-team', labelFilter: 'team:ui', folderScope: ['apps/web'] }, + { name: 'api-team', labelFilter: 'team:api' }, + ]; + + await initSquad({ + teamRoot: tmpDir, + projectName: 'test-workstreams', + agents: [{ name: 'lead', role: 'lead' }], + streams, + includeWorkflows: false, + includeTemplates: false, + includeMcpConfig: false, + }); + + const workstreamsPath = path.join(tmpDir, '.squad', 'workstreams.json'); + expect(fs.existsSync(workstreamsPath)).toBe(true); + + 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 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-workstreams', + agents: [{ name: 'lead', role: 'lead' }], + includeWorkflows: false, + includeTemplates: false, + includeMcpConfig: false, + }); + + const workstreamsPath = path.join(tmpDir, '.squad', 'workstreams.json'); + expect(fs.existsSync(workstreamsPath)).toBe(false); + }); + + it('adds .squad-workstream 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-workstream'); + }); +}); + +// ============================================================================ +// 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-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-workstream'); + }); + + it('resolves after activation', () => { + 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-workstream changes active workstream', () => { + writeSquadWorkstreamsConfig(tmpDir, SAMPLE_CONFIG); + writeSquadWorkstreamFile(tmpDir, 'ui-team'); + expect(resolveWorkstream(tmpDir)!.name).toBe('ui-team'); + + writeSquadWorkstreamFile(tmpDir, 'backend-team'); + expect(resolveWorkstream(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 workstreams array in config', () => { + const emptyConfig: WorkstreamConfig = { workstreams: [], defaultWorkflow: 'direct' }; + writeSquadWorkstreamsConfig(tmpDir, emptyConfig); + expect(resolveWorkstream(tmpDir)).toBeNull(); + }); + + 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, 'workstreams.json'), '{"workstreams":"not-array"}', 'utf-8'); + expect(loadWorkstreamsConfig(tmpDir)).toBeNull(); + }); + + it('handles SQUAD_TEAM set to empty string', () => { + process.env.SQUAD_TEAM = ''; + expect(resolveWorkstream(tmpDir)).toBeNull(); + }); + + it('filterIssuesByWorkstream handles labels with special characters', () => { + const issues: WorkstreamIssue[] = [ + { number: 1, title: 'Test', labels: [{ name: 'team:front-end/ui' }] }, + ]; + const workstream: ResolvedWorkstream = { + name: 'fe', + definition: { name: 'fe', labelFilter: 'team:front-end/ui' }, + source: 'env', + }; + const result = filterIssuesByWorkstream(issues, workstream); + expect(result).toHaveLength(1); + }); + + it('resolves workflow from definition over defaultWorkflow', () => { + const config: WorkstreamConfig = { + workstreams: [{ name: 'direct-workstream', labelFilter: 'team:direct', workflow: 'direct' }], + defaultWorkflow: 'branch-per-issue', + }; + writeSquadWorkstreamsConfig(tmpDir, config); + const result = resolveWorkstream(tmpDir); + expect(result!.definition.workflow).toBe('direct'); + }); +}); 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 () => {