Skip to content

feat: Squad Workstreams — horizontal scaling via Codespaces#189

Open
tamirdresher wants to merge 9 commits intobradygaster:mainfrom
tamirdresher:feature/squad-streams
Open

feat: Squad Workstreams — horizontal scaling via Codespaces#189
tamirdresher wants to merge 9 commits intobradygaster:mainfrom
tamirdresher:feature/squad-streams

Conversation

@tamirdresher
Copy link
Contributor

@tamirdresher tamirdresher commented Mar 4, 2026

Squad Workstreams — Horizontal Scaling via Codespaces

PRD: #200

Problem

Squad currently runs as a single team per repo. When scaling to multiple Codespaces (each running its own Squad instance), there's no built-in way to scope each instance to a subset of issues, enforce branch+PR workflow, or monitor workstreams centrally.

Evidence

Validated via a multi-Codespace experiment with tamirdresher/squad-tetris — 3 Codespaces building a multiplayer Tetris game, each scoped to team:ui/team:backend/team:cloud issues. Findings:

  • Backend squad pushed a branch but no PR (no workflow enforcement)
  • Other squads didn't push branches (no branch-per-issue discipline)
  • Manual verbal directives needed per Codespace to set scope
  • No cross-workstream monitoring

Solution

Add workstreams as a first-class concept:

SDK (packages/squad-sdk/src/streams/)

  • WorkstreamDefinition type: name, labelFilter, folderScope (advisory), workflow
  • resolveWorkstream() — auto-detect from SQUAD_TEAM env var, .squad-workstream file, or single-workstream auto-select
  • filterIssuesByWorkstream() — filter issues by workstream's label
  • loadWorkstreamsConfig() — parse and validate workstreams.json with strict entry validation
  • Init support generates workstreams.json

CLI (packages/squad-cli/src/cli/commands/streams.ts)

  • squad workstreams list — show configured workstreams
  • squad workstreams status — show per-workstream activity (branches, PRs)
  • squad workstreams activate <name> — write .squad-workstream file
  • Backward compat: squad streams alias still works

Coordinator (templates/squad.agent.md)

  • Workstream Awareness section: auto-detect, enforce branch+PR workflow, advisory folderScope

Tests: 44 new tests covering resolution, filtering, config validation, CLI behavior

Docs: Feature guide, multi-Codespace scenario walkthrough, PRD with experiment findings

Key Design Decisions

  • folderScope is advisory — not a hard lock. Agents prefer these directories but can modify shared code with callout
  • Single-machine multi-workstreamsquad workstreams activate allows sequential testing on one machine
  • spawnSync over execSync — prevents shell injection from user-controlled config values
  • Strict config validation — each workstream entry validated for shape, invalid entries filtered out
  • Backward compatible — repos without workstreams.json work exactly as before; streams CLI alias preserved

Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com

williamhallatt pushed a commit to williamhallatt/squad that referenced this pull request Mar 4, 2026
CLI functions now throw errors or return results instead of calling process.exit().
fatal() throws SquadError instead of process.exit(1).
runWatch() uses Promise resolution for graceful shutdown.
runShell() closes readline on SIGINT instead of process.exit().
process.exit() is confined to the CLI entry point only.
VS Code extensions can safely import these functions.

Closes bradygaster#189

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
williamhallatt pushed a commit to williamhallatt/squad that referenced this pull request Mar 4, 2026
…on merge

Session: 2026-02-22T020714Z-epic181-complete
Orchestration: 5 agents (Fenster, Edie, Kujan, Hockney, McManus)

Changes:
- Created 5 orchestration-log entries per agent spawn
- Created session log documenting epic completion and all closed issues
- Merged 4 inbox decisions into decisions.md (CRLF, CLI entry split, process.exit refactor, docs as you go)
- Normalized decision formatting (consistent ### heading style)
- Propagated team updates to affected agent history.md files (Fenster, Edie, Kujan, Hockney, McManus)
- Deleted 4 inbox files after merge

Issues closed: #220, #221, bradygaster#187, bradygaster#189, #228, bradygaster#181
Tests passing: 1683/1683

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@bradygaster bradygaster requested a review from Copilot March 4, 2026 23:41
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds a first-class Streams concept to Squad to support multi-Codespace / horizontally scaled workflows by scoping issue triage and local activation to a named stream.

Changes:

  • Introduces an SDK streams module (types + resolver + issue filtering) and exports it from the SDK.
  • Adds a new CLI command group: squad streams list|status|activate.
  • Adds init support for generating .squad/streams.json, updates templates/docs, and adds a comprehensive streams test suite.

Reviewed changes

Copilot reviewed 17 out of 19 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
test/streams.test.ts New vitest suite covering streams config loading, resolution, filtering, and init integration.
templates/squad.agent.md Adds “Stream Awareness” coordinator guidance for env-based stream context and scoping.
packages/squad-sdk/src/types.ts Re-exports stream-related public types from SDK types barrel.
packages/squad-sdk/src/streams/types.ts Defines StreamDefinition, StreamConfig, and ResolvedStream types.
packages/squad-sdk/src/streams/resolver.ts Implements loadStreamsConfig() + resolveStream() + getStreamLabelFilter().
packages/squad-sdk/src/streams/index.ts Streams barrel export.
packages/squad-sdk/src/streams/filter.ts Implements filterIssuesByStream() helper.
packages/squad-sdk/src/index.ts Exports the new streams module from the SDK root.
packages/squad-sdk/src/config/init.ts Adds optional streams config generation + .squad-stream gitignore entry during init.
packages/squad-sdk/package.json Bumps SDK version to 0.8.18-preview.2.
packages/squad-cli/src/cli/commands/streams.ts New squad streams subcommands (list/status/activate).
packages/squad-cli/src/cli-entry.ts Wires the streams command into the CLI entry + help output.
packages/squad-cli/package.json Bumps CLI version to 0.8.18-preview.2.
package.json Bumps workspace version to 0.8.18-preview.2.
package-lock.json Updates lockfile (deps + copilot/copilot-sdk versions), but versions are out of sync with package.json.
docs/specs/streams-prd.md Adds a Streams PRD documenting requirements and design decisions.
docs/scenarios/multi-codespace.md Adds a multi-Codespace walkthrough for running multiple streams in parallel.
docs/features/streams.md Adds user-facing feature documentation for configuration, activation, and CLI usage.
.gitignore Adds .squad-stream to ignores as a local activation file.

Comment on lines +7 to +8
* 3. squad.config.ts → streams.active field (via .squad/streams.json)
* 4. null (no stream — single-squad mode)
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

The resolver docstring claims a squad.config.ts → streams.active resolution step, but resolveStream() doesn’t read squad.config.ts or any active field. Please update the comment to reflect the actual behavior (Env → .squad-stream → single-stream auto-select → null), or implement the documented config-based activation.

Suggested change
* 3. squad.config.ts streams.active field (via .squad/streams.json)
* 4. null (no stream single-squad mode)
* 3. If exactly one stream is defined in the config, auto-select that stream
* 4. null (no active stream single-squad mode / no streams)

Copilot uses AI. Check for mistakes.
Comment on lines +31 to +43
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;
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

loadStreamsConfig() only validates that streams is an array, but it doesn’t validate each entry’s shape (e.g., name/labelFilter are strings, folderScope is string[], workflow is an allowed value) or that defaultWorkflow is one of the supported modes. Since other code assumes these fields exist and are well-typed, consider doing stricter validation (return null / filter invalid entries) and normalizing defaults without relying on a type cast.

Suggested change
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;
const rawConfig = JSON.parse(raw) as unknown;
if (!rawConfig || typeof rawConfig !== 'object') {
return null;
}
const configLike = rawConfig as { defaultWorkflow?: unknown; streams?: unknown };
// Derive a sane defaultWorkflow value
const defaultWorkflow =
typeof configLike.defaultWorkflow === 'string' && configLike.defaultWorkflow.trim() !== ''
? configLike.defaultWorkflow
: 'branch-per-issue';
const streamsRaw = configLike.streams;
if (!Array.isArray(streamsRaw)) {
return null;
}
const streams: StreamDefinition[] = streamsRaw
.filter(entry => entry && typeof entry === 'object')
.map(entry => {
const e = entry as {
name?: unknown;
labelFilter?: unknown;
folderScope?: unknown;
workflow?: unknown;
};
if (typeof e.name !== 'string' || typeof e.labelFilter !== 'string') {
return null;
}
const normalized: any = {
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;
}
return normalized as StreamDefinition;
})
.filter((s): s is StreamDefinition => s !== null);
if (streams.length === 0) {
return null;
}
const normalizedConfig: StreamConfig = {
defaultWorkflow,
streams,
};
return normalizedConfig;

Copilot uses AI. Check for mistakes.
const block = (currentIgnore && !currentIgnore.endsWith('\n') ? '\n' : '')
+ '# Squad: stream activation file (local to this machine)\n'
+ streamIgnoreEntry + '\n';
await appendFile(gitignorePath, block);
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

When appending the .squad-stream entry to .gitignore, this block doesn’t add the path to createdFiles (unlike the earlier .gitignore update logic above). This means squad init won’t report that it modified .gitignore. Consider recording the change consistently (and/or reusing the existing ignore-entry helper logic).

Suggested change
await appendFile(gitignorePath, block);
await appendFile(gitignorePath, block);
createdFiles.push(toRelativePath(gitignorePath));

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Already addressed — line 878 has \createdFiles.push(toRelativePath(gitignorePath)).

Comment on lines +116 to +123
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.
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

This “Stream Awareness” section only checks SQUAD_TEAM and doesn’t mention .squad-stream file activation or single-stream auto-select, which are part of the implemented resolver behavior. Also, it states agents should “only modify files” in folderScope, but the Streams docs/PRD describe folderScope as advisory rather than a hard lock—please align this guidance with the intended behavior.

Suggested change
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.
On session start, resolve stream context using the Streams resolver:
1. Check for a `.squad-stream` file in the repo or working directory. If present, activate the referenced stream.
2. If no `.squad-stream` is present, read the `SQUAD_TEAM` env var (if set) and resolve the matching stream from `.squad/streams.json`.
3. If there is exactly one stream defined in `.squad/streams.json` and nothing else selects a stream, auto-select that stream.
4. When a stream is active:
- Apply the stream's `labelFilter` — Ralph should normally only pick up issues matching this label unless the user explicitly directs otherwise.
- Apply the stream's `workflow` — if `branch-per-issue`, enforce creating a branch and PR for every issue (never commit directly to main).
- Apply the stream'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 stream is resolved, operate in default single-squad mode.

Copilot uses AI. Check for mistakes.
Comment on lines +124 to +129
// Try to get branch info
try {
const branchOutput = execSync(
`git branch --list "*${stream.name}*"`,
{ cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] },
);
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

execSync() is invoked with a shell command string that includes stream.name from configuration. This can be exploited for shell injection and can also behave unexpectedly with glob/special characters. Prefer execFileSync/spawnSync with argv (e.g., git, ['branch','--list',pattern]) and avoid invoking a shell.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Already addressed — the code uses \spawnSync('git', ['branch', '--list', pattern])\ with args array (line 131-134). No \�xecSync\ in streams.ts.

Comment on lines +26 to +47
export async function runStreams(cwd: string, args: string[]): Promise<void> {
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 <name>`);
process.exit(1);
}
return activateStream(cwd, name);
}

console.error(`${RED}✗${RESET} Unknown streams subcommand: ${sub}`);
console.log(`\nUsage: squad streams <list|status|activate <name>>`);
process.exit(1);
}
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

This new streams CLI surface isn’t covered by automated tests. The existing suite has extensive CLI coverage, but there are no tests exercising runStreams/subcommands (and the new test/streams.test.ts only writes .squad-stream directly). Add tests that invoke runStreams (and assert output / file creation / error paths) to prevent regressions.

Copilot uses AI. Check for mistakes.
* 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.
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

The module comment says this filter is “Used by Ralph during triage”, but there’s currently no usage of filterIssuesByStream() anywhere in the SDK/CLI codebase. Either wire this into the triage path (so the comment is true) or adjust the comment to avoid implying behavior that isn’t implemented yet.

Suggested change
* Used by Ralph during triage to scope work to the active stream.
* Intended to scope work to the active stream during triage.

Copilot uses AI. Check for mistakes.
Comment on lines +112 to +114
extractionDisabled?: boolean;
/** Optional stream definitions — generates .squad/streams.json when provided */
streams?: StreamDefinition[];
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

PR description mentions an init --stream flag that generates streams.json, but the only implementation here is a programmatic InitOptions.streams hook; there’s no CLI parsing for --stream in the repo. Either add the CLI flag wiring or adjust the PR description/docs so they match what’s actually shipped.

Copilot uses AI. Check for mistakes.
Comment on lines +106 to +110
try {
const prOutput = execSync(
`gh pr list --label "${stream.labelFilter}" --json number,title,state --limit 5`,
{ cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] },
);
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

execSync() is invoked with a shell command string that includes stream.labelFilter from configuration. Because this value is user-controlled, this is vulnerable to shell injection (and can also break when the label contains quotes). Prefer execFileSync/spawnSync with an argv array (no shell) and treat labelFilter as a plain argument.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Already addressed — the code uses \spawnSync('gh', ['pr', 'list', '--label', workstream.labelFilter, ...])\ with args array (line 110-113). No \�xecSync\ calls exist in streams.ts.

@tamirdresher tamirdresher force-pushed the feature/squad-streams branch from 4ffb720 to f16729e Compare March 5, 2026 01:24
tamirdresher and others added 3 commits March 5, 2026 08:47
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>
- 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>
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>
…nment

- 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>
@tamirdresher tamirdresher changed the title feat: Squad Streams — horizontal scaling via Codespaces feat: Squad Workstreams — horizontal scaling via Codespaces Mar 5, 2026
tamirdresher and others added 4 commits March 5, 2026 10:12
- 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>
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>
- 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>
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>
bradygaster added a commit that referenced this pull request Mar 5, 2026
Session: 2026-03-05T21:05Z-release-planning
Requested by: Copilot (Scribe role)

Changes:
- Logged orchestration entries for Keaton, McManus, Kobayashi, Fenster, Coordinator
- Created session log documenting v0.8.21 release planning outcomes
- Merged 3 decision inbox files into decisions.md
- Deleted inbox files (contributor page, PR merges, user directives)

Decision Merged:
- Every release MUST include contributor page update
- Workstreams MUST be included in v0.8.21

Outcomes documented:
- 4 PRs merged to dev (#204, #203, #198, #189)
- 2 issues fixed (#210, #195)
- Build passing, 98.8% test coverage
- Release scope: 18 unreleased commits
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
bradygaster added a commit that referenced this pull request Mar 7, 2026
Reviewed PR #189 (Workstreams) and PR #191 (ADO Adapter).
Both held for v0.8.22 — merge conflicts, no CI, missing tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
bradygaster added a commit that referenced this pull request Mar 7, 2026
… fix

Session: 2026-03-07T16-19-00Z-pre-release-triage
Requested by: Brady (Team Coordinator)

Changes:
- Merged decisions from 3 agent triage sessions (Keaton, Hockney, McManus)
- Brady directives: SDK-First v0.8.22 commitment, Actions-to-CLI strategic shift
- Updated agent history.md with cross-team context propagation
- Decisions logged: v0.8.21 release gate, PR holds for v0.8.22, docs readiness

Results:
- v0.8.21: GREEN LIGHT (pending #248 fix per Keaton override)
- v0.8.22 roadmap: 9 issues, 3 parallel streams
- Close: #194 (completed), #231 (duplicate)
- PRs #189/#191: Hold for v0.8.22 (rebase to dev)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants