diff --git a/docs/blog/023-squad-goes-enterprise-azure-devops.md b/docs/blog/023-squad-goes-enterprise-azure-devops.md new file mode 100644 index 000000000..1f9d77fbc --- /dev/null +++ b/docs/blog/023-squad-goes-enterprise-azure-devops.md @@ -0,0 +1,226 @@ +--- +title: "Squad Goes Enterprise — Azure DevOps, Area Paths, and Cross-Project Work Items" +date: 2026-03-07 +author: "Tamir Dresher" +wave: null +tags: [squad, azure-devops, enterprise, platform-adapter, work-items, area-paths, iteration-paths] +status: published +hero: "Squad now speaks Azure DevOps natively — auto-detection, configurable work item types, area/iteration paths, and cross-project support for enterprise environments." +--- + +# Squad Goes Enterprise — Azure DevOps, Area Paths, and Cross-Project Work Items + +> Blog post #23 — How Squad learned to work with enterprise ADO environments where nothing is "standard." + +## The Problem + +GitHub repos have issues. Simple. One repo, one issue tracker, one set of labels. + +Enterprise Azure DevOps? Not so much. Your code might live in one project, your work items in another. Your org might use "Scenario" instead of "User Story." Your team's backlog is scoped by area paths. Your sprints use iteration paths. And there's no PAT to manage — you authenticate via `az login`. + +Squad needed to understand all of this. Not just "detect ADO" — actually *work* in enterprise ADO environments where every project has its own rules. + +## What Shipped + +### Platform Auto-Detection + +Squad reads your git remote URL and figures out where you are: + +``` +https://dev.azure.com/myorg/myproject/_git/myrepo → azure-devops +git@ssh.dev.azure.com:v3/myorg/myproject/myrepo → azure-devops +https://myorg.visualstudio.com/myproject/_git/myrepo → azure-devops +``` + +No configuration needed. `squad init` detects ADO and: +- Skips `.github/workflows/` generation (those don't run in ADO) +- Writes `"platform": "azure-devops"` to `.squad/config.json` +- Generates ADO-appropriate MCP config examples + +### Configurable Work Item Types + +Not every ADO project uses "User Story." Some use "Scenario," "Bug," or custom types locked down by org policy. Now you can configure it: + +```json +{ + "version": 1, + "platform": "azure-devops", + "ado": { + "defaultWorkItemType": "Scenario" + } +} +``` + +Squad uses your configured type for all work item creation — Ralph triage, agent task creation, everything. + +### Area Paths — Route to the Right Team + +In enterprise ADO, area paths determine which team's backlog a work item appears in. A work item in `"MyProject\Frontend"` shows up on the Frontend team's board. One in `"MyProject\Platform"` goes to Platform. + +```json +{ + "ado": { + "areaPath": "MyProject\\Team Alpha" + } +} +``` + +Now when Squad creates work items, they land on the right team's board — not lost in the root backlog. + +### Iteration Paths — Sprint Placement + +Same story for sprints. Enterprise teams plan in iterations, and work items need to appear in the right sprint: + +```json +{ + "ado": { + "iterationPath": "MyProject\\Sprint 5" + } +} +``` + +### Cross-Project Work Items — The Enterprise Killer Feature + +Here's the one that matters most for large organizations: **your git repo and your work items might live in completely different ADO projects — or even different orgs.** + +Common pattern in enterprise: +- **Code** lives in `Engineering/my-service` (locked-down project with strict CI) +- **Work items** live in `Planning/team-backlog` (PM-managed project with custom process templates) + +Squad now supports this cleanly: + +```json +{ + "version": 1, + "platform": "azure-devops", + "ado": { + "org": "planning-org", + "project": "team-backlog", + "defaultWorkItemType": "Scenario", + "areaPath": "team-backlog\\Alpha Squad", + "iterationPath": "team-backlog\\2026-Q1\\Sprint 5" + } +} +``` + +When `ado.org` or `ado.project` are set, Squad uses them for all work item operations (create, query, tag, comment) while continuing to use the git remote's org/project for repo operations (branches, PRs, commits). + +The WIQL queries, `az boards` commands, and Ralph's triage loop all respect this split. + +## The Full Config Reference + +All fields are optional. Omit any field to use the default. + +| Field | Default | Description | +|-------|---------|-------------| +| `ado.org` | *(from git remote)* | ADO org for work items | +| `ado.project` | *(from git remote)* | ADO project for work items | +| `ado.defaultWorkItemType` | `"User Story"` | Type for new work items | +| `ado.areaPath` | *(project default)* | Team backlog routing | +| `ado.iterationPath` | *(project default)* | Sprint board placement | + +## Security — No PATs Needed + +Squad uses `az login` for authentication. No Personal Access Tokens to rotate, no secrets in config files. Your Azure CLI session handles everything. + +For environments where MCP tools are available, Squad also supports the Azure DevOps MCP server for richer API access: + +```json +{ + "mcpServers": { + "azure-devops": { + "command": "npx", + "args": ["-y", "@azure/devops-mcp-server"] + } + } +} +``` + +## Security Hardening + +The ADO adapter went through a thorough security review: + +- **Shell injection prevention** — All `execSync` calls replaced with `execFileSync` (args as arrays, not concatenated strings) +- **WIQL injection prevention** — `escapeWiql()` helper doubles single-quotes in all user-supplied values +- **Bearer token protection** — Planner adapter passes tokens via `curl --config stdin` instead of CLI args (invisible to `ps aux`) + +## What We Tested + +External integration testing against real ADO environments (WDATP, OS, SquadDemo projects): + +| Test | Result | +|------|--------| +| ADO project connectivity | ✅ | +| Repo discovery | ✅ | +| Branch creation | ✅ | +| Git clone + push | ✅ | +| Squad init (platform detection) | ✅ | +| PR creation + auto-complete | ✅ | +| PR read/list/comment | ✅ | +| Commit search | ✅ | +| Work item CRUD | ✅ | +| WIQL tag queries | ✅ | +| Cross-project work items | ✅ | + +The only blockers encountered were project-specific restrictions (locked-down work item types in WDATP) — not Squad bugs. + +## Ralph in ADO + +Ralph's coordinator prompt is now platform-aware. When running against ADO, Ralph uses WIQL queries instead of GitHub issue queries: + +```wiql +SELECT [System.Id] FROM WorkItems +WHERE [System.Tags] Contains 'squad' + AND [System.State] <> 'Closed' + AND [System.TeamProject] = 'team-backlog' +ORDER BY [System.CreatedDate] DESC +``` + +The full triage → assign → branch → PR → merge loop works end-to-end with ADO. + +## Ralph + ADO: The Governance Fix + +The coordinator prompt (`squad.agent.md`) is what tells Ralph *where* to look for work. Previously, it only had GitHub commands — `gh issue list`, `gh pr list`. Even if the ADO adapter was perfect, Ralph would still scan GitHub because that's what the governance file told it to do. + +We fixed this at every level: +- **MCP detection** — Added `azure-devops-*` to the tool prefix table so the coordinator recognizes ADO MCP tools +- **Platform Detection section** — New section in the governance file explaining how to detect GitHub vs ADO from the git remote +- **Issue Awareness** — Now shows both GitHub and ADO queries, with instructions to read `.squad/config.json` first +- **Ralph Step 1** — Platform-aware scan with both GitHub and ADO command blocks, plus the critical instruction: *"Read `.squad/config.json` for the `ado` section FIRST — do NOT guess the ADO project from the repo name"* + +This is the kind of bug that's invisible in unit tests — the code works, but the governance prompt doesn't tell the coordinator to use it. + +## Getting Started + +```bash +# 1. Install Squad +npm install -g @bradygaster/squad-cli + +# 2. Clone your ADO repo +git clone https://dev.azure.com/your-org/your-project/_git/your-repo +cd your-repo + +# 3. Make sure az CLI is set up +az login +az extension add --name azure-devops + +# 4. Init Squad (auto-detects ADO) +squad init + +# 5. Edit .squad/config.json if you need custom work item config +# 6. Start working! +``` + +Full documentation: [Enterprise Platforms Guide](../features/enterprise-platforms.md) + +## What's Next + +- **Process template introspection** — Auto-detect available work item types from the ADO process template (#240) +- **ADO webhook integration** — Real-time work item change notifications +- **Azure Pipelines scaffolding** — Generate pipeline YAML during `squad init` for ADO repos + +--- + +*The enterprise doesn't bend to your tools. Your tools bend to the enterprise. Squad now does.* + +PR: [#191 — Azure DevOps platform adapter](https://github.com/bradygaster/squad/pull/191) diff --git a/docs/features/enterprise-platforms.md b/docs/features/enterprise-platforms.md index 92d50f71d..527a8ab29 100644 --- a/docs/features/enterprise-platforms.md +++ b/docs/features/enterprise-platforms.md @@ -120,7 +120,7 @@ Squad prefers MCP tools when available, falling back to `az` CLI when not. To explicitly check which platform Squad detects: ```typescript -import { detectPlatform } from '@bradygaster/squad-sdk'; +import { detectPlatform } from '@bradygaster/squad/platform'; const platform = detectPlatform('/path/to/repo'); // Returns 'github', 'azure-devops', or 'planner' diff --git a/packages/squad-sdk/src/platform/comms-ado-discussions.ts b/packages/squad-sdk/src/platform/comms-ado-discussions.ts new file mode 100644 index 000000000..9c5728f55 --- /dev/null +++ b/packages/squad-sdk/src/platform/comms-ado-discussions.ts @@ -0,0 +1,114 @@ +/** + * Azure DevOps Work Item Discussion communication adapter. + * + * Posts updates as work item comments and reads replies via `az boards`. + * Phone-capable via ADO mobile app. + * + * @module platform/comms-ado-discussions + */ + +import { execFileSync } from 'node:child_process'; +import type { CommunicationAdapter, CommunicationChannel, CommunicationReply } from './types.js'; + +const EXEC_OPTS: { encoding: 'utf-8'; stdio: ['pipe', 'pipe', 'pipe'] } = { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }; + +/** Safely parse JSON output */ +function parseJson(raw: string): T { + try { + return JSON.parse(raw) as T; + } catch (err) { + throw new Error(`Failed to parse JSON: ${(err as Error).message}\nRaw: ${raw}`); + } +} + +export class ADODiscussionCommunicationAdapter implements CommunicationAdapter { + readonly channel: CommunicationChannel = 'ado-work-items'; + + constructor( + private readonly org: string, + private readonly project: string, + ) {} + + private get orgUrl(): string { + return `https://dev.azure.com/${this.org}`; + } + + async postUpdate(options: { + title: string; + body: string; + category?: string; + author?: string; + }): Promise<{ id: string; url?: string }> { + const prefix = options.author ? `**${options.author}:** ` : ''; + const categoryTag = options.category ? ` [${options.category}]` : ''; + const fullTitle = `[Squad${categoryTag}] ${options.title}`; + const comment = `${prefix}${options.body}`; + + // Create a work item to serve as the discussion thread + const output = execFileSync('az', [ + 'boards', 'work-item', 'create', + '--type', 'Task', + '--title', fullTitle, + '--fields', `System.Tags=squad; squad:comms`, + '--discussion', comment, + '--org', this.orgUrl, + '--project', this.project, + '--output', 'json', + ], EXEC_OPTS); + + const wi = parseJson<{ + id: number; + _links?: { html?: { href?: string } }; + url: string; + }>(output); + + return { + id: String(wi.id), + url: wi._links?.html?.href ?? wi.url, + }; + } + + async pollForReplies(options: { + threadId: string; + since: Date; + }): Promise { + // Read work item comments (discussion history) + const output = execFileSync('az', [ + 'boards', 'work-item', 'show', + '--id', options.threadId, + '--org', this.orgUrl, + '--project', this.project, + '--expand', 'all', + '--output', 'json', + ], EXEC_OPTS); + + const wi = parseJson<{ + id: number; + fields: Record; + comments?: Array<{ + id: number; + text: string; + createdDate: string; + createdBy: { displayName: string }; + }>; + }>(output); + + // ADO work item show doesn't include comments directly in basic output. + // The discussion is in the System.History field as HTML. + // For a production adapter, use the REST API for comments. + // This is a simplified implementation. + const history = wi.fields['System.History'] as string | undefined; + if (!history) return []; + + return [{ + author: 'ado-user', + body: history, + timestamp: new Date(), + id: `${options.threadId}-history`, + }]; + } + + getNotificationUrl(threadId: string): string | undefined { + return `${this.orgUrl}/${this.project}/_workitems/edit/${threadId}`; + } +} diff --git a/packages/squad-sdk/src/platform/comms-file-log.ts b/packages/squad-sdk/src/platform/comms-file-log.ts new file mode 100644 index 000000000..bcf45bb88 --- /dev/null +++ b/packages/squad-sdk/src/platform/comms-file-log.ts @@ -0,0 +1,86 @@ +/** + * File-based communication adapter — zero-config fallback. + * + * Writes updates to `.squad/comms/` as markdown files. + * Always available, no external dependencies. Works on every platform. + * Replies are read from the same directory (humans edit files manually or via git). + * + * @module platform/comms-file-log + */ + +import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import type { CommunicationAdapter, CommunicationChannel, CommunicationReply } from './types.js'; + +export class FileLogCommunicationAdapter implements CommunicationAdapter { + readonly channel: CommunicationChannel = 'file-log'; + private readonly commsDir: string; + + constructor(private readonly squadRoot: string) { + this.commsDir = join(squadRoot, '.squad', 'comms'); + if (!existsSync(this.commsDir)) { + mkdirSync(this.commsDir, { recursive: true }); + } + } + + async postUpdate(options: { + title: string; + body: string; + category?: string; + author?: string; + }): Promise<{ id: string; url?: string }> { + const timestamp = new Date().toISOString().replace(/:/g, '-').replace(/\.\d+Z$/, 'Z'); + const slug = options.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 40); + const filename = `${timestamp}-${slug}.md`; + const filepath = join(this.commsDir, filename); + + const content = [ + `# ${options.title}`, + '', + `**Posted by:** ${options.author ?? 'Squad'}`, + `**Category:** ${options.category ?? 'update'}`, + `**Timestamp:** ${new Date().toISOString()}`, + '', + '---', + '', + options.body, + '', + '---', + '', + '', + '', + ].join('\n'); + + writeFileSync(filepath, content, 'utf-8'); + + return { id: filename.replace(/\.md$/, ''), url: undefined }; + } + + async pollForReplies(options: { + threadId: string; + since: Date; + }): Promise { + const filepath = join(this.commsDir, `${options.threadId}.md`); + if (!existsSync(filepath)) return []; + + const content = readFileSync(filepath, 'utf-8'); + const replyMarker = ''; + const markerIdx = content.indexOf(replyMarker); + if (markerIdx === -1) return []; + + const repliesSection = content.slice(markerIdx + replyMarker.length).trim(); + if (!repliesSection) return []; + + // Parse simple reply format: lines after the marker are replies + return [{ + author: 'human', + body: repliesSection, + timestamp: new Date(), + id: `${options.threadId}-reply`, + }]; + } + + getNotificationUrl(_threadId: string): string | undefined { + return undefined; // File-based has no web UI + } +} diff --git a/packages/squad-sdk/src/platform/comms-github-discussions.ts b/packages/squad-sdk/src/platform/comms-github-discussions.ts new file mode 100644 index 000000000..1b4576e3c --- /dev/null +++ b/packages/squad-sdk/src/platform/comms-github-discussions.ts @@ -0,0 +1,95 @@ +/** + * GitHub Discussions communication adapter. + * + * Posts updates and reads replies via GitHub Discussions using `gh api`. + * Phone-capable (browser-based), not corp-only. + * + * @module platform/comms-github-discussions + */ + +import { execFileSync } from 'node:child_process'; +import type { CommunicationAdapter, CommunicationChannel, CommunicationReply } from './types.js'; + +const EXEC_OPTS: { encoding: 'utf-8'; stdio: ['pipe', 'pipe', 'pipe'] } = { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }; + +/** Safely parse JSON output */ +function parseJson(raw: string): T { + try { + return JSON.parse(raw) as T; + } catch (err) { + throw new Error(`Failed to parse JSON: ${(err as Error).message}\nRaw: ${raw}`); + } +} + +export class GitHubDiscussionsCommunicationAdapter implements CommunicationAdapter { + readonly channel: CommunicationChannel = 'github-discussions'; + + constructor( + private readonly owner: string, + private readonly repo: string, + ) {} + + async postUpdate(options: { + title: string; + body: string; + category?: string; + author?: string; + }): Promise<{ id: string; url?: string }> { + const category = options.category ?? 'General'; + + // Get category ID + const categoryQuery = `query { repository(owner: "${this.owner}", name: "${this.repo}") { discussionCategories(first: 25) { nodes { id name } } } }`; + const catOutput = execFileSync('gh', ['api', 'graphql', '-f', `query=${categoryQuery}`], EXEC_OPTS); + const catData = parseJson<{ + data: { repository: { discussionCategories: { nodes: Array<{ id: string; name: string }> } } } + }>(catOutput); + + const catNode = catData.data.repository.discussionCategories.nodes.find( + (n) => n.name.toLowerCase() === category.toLowerCase(), + ); + if (!catNode) { + throw new Error(`Discussion category "${category}" not found in ${this.owner}/${this.repo}. Available: ${catData.data.repository.discussionCategories.nodes.map((n) => n.name).join(', ')}`); + } + + // Get repo ID + const repoQuery = `query { repository(owner: "${this.owner}", name: "${this.repo}") { id } }`; + const repoOutput = execFileSync('gh', ['api', 'graphql', '-f', `query=${repoQuery}`], EXEC_OPTS); + const repoData = parseJson<{ data: { repository: { id: string } } }>(repoOutput); + + // Create discussion + const prefix = options.author ? `*Posted by ${options.author}*\n\n` : ''; + const mutation = `mutation { createDiscussion(input: { repositoryId: "${repoData.data.repository.id}", categoryId: "${catNode.id}", title: "${options.title.replace(/"/g, '\\"')}", body: "${(prefix + options.body).replace(/"/g, '\\"').replace(/\n/g, '\\n')}" }) { discussion { id number url } } }`; + const output = execFileSync('gh', ['api', 'graphql', '-f', `query=${mutation}`], EXEC_OPTS); + const result = parseJson<{ + data: { createDiscussion: { discussion: { id: string; number: number; url: string } } } + }>(output); + + const disc = result.data.createDiscussion.discussion; + return { id: String(disc.number), url: disc.url }; + } + + async pollForReplies(options: { + threadId: string; + since: Date; + }): Promise { + const query = `query { repository(owner: "${this.owner}", name: "${this.repo}") { discussion(number: ${options.threadId}) { comments(first: 50) { nodes { id body createdAt author { login } } } } } }`; + const output = execFileSync('gh', ['api', 'graphql', '-f', `query=${query}`], EXEC_OPTS); + const data = parseJson<{ + data: { repository: { discussion: { comments: { nodes: Array<{ id: string; body: string; createdAt: string; author: { login: string } }> } } } } + }>(output); + + const comments = data.data.repository.discussion.comments.nodes; + return comments + .filter((c) => new Date(c.createdAt) > options.since) + .map((c) => ({ + author: c.author.login, + body: c.body, + timestamp: new Date(c.createdAt), + id: c.id, + })); + } + + getNotificationUrl(threadId: string): string | undefined { + return `https://github.com/${this.owner}/${this.repo}/discussions/${threadId}`; + } +} diff --git a/packages/squad-sdk/src/platform/comms.ts b/packages/squad-sdk/src/platform/comms.ts new file mode 100644 index 000000000..eae950836 --- /dev/null +++ b/packages/squad-sdk/src/platform/comms.ts @@ -0,0 +1,110 @@ +/** + * Communication adapter factory — creates the right adapter based on config. + * + * Reads `.squad/config.json` for the `communications` section. + * Falls back to FileLog (always available) if nothing is configured. + * + * @module platform/comms + */ + +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import type { CommunicationAdapter, CommunicationChannel, CommunicationConfig } from './types.js'; +import { FileLogCommunicationAdapter } from './comms-file-log.js'; +import { GitHubDiscussionsCommunicationAdapter } from './comms-github-discussions.js'; +import { ADODiscussionCommunicationAdapter } from './comms-ado-discussions.js'; +import { detectPlatform, getRemoteUrl, parseGitHubRemote, parseAzureDevOpsRemote } from './detect.js'; + +/** + * Read communication config from `.squad/config.json`. + */ +function readCommsConfig(repoRoot: string): CommunicationConfig | undefined { + const configPath = join(repoRoot, '.squad', 'config.json'); + if (!existsSync(configPath)) return undefined; + try { + const raw = readFileSync(configPath, 'utf-8'); + const parsed = JSON.parse(raw) as Record; + if (parsed.communications && typeof parsed.communications === 'object') { + return parsed.communications as CommunicationConfig; + } + } catch { /* ignore */ } + return undefined; +} + +/** + * Create a communication adapter based on config or auto-detection. + * + * Priority: + * 1. Explicit config in `.squad/config.json` → `communications.channel` + * 2. Auto-detect from platform: GitHub → GitHubDiscussions, ADO → ADOWorkItemDiscussions + * 3. Fallback: FileLog (always works) + */ +export function createCommunicationAdapter(repoRoot: string): CommunicationAdapter { + const config = readCommsConfig(repoRoot); + + // Explicit config wins + if (config?.channel) { + return createAdapterByChannel(config.channel, repoRoot); + } + + // Auto-detect from platform + const platform = detectPlatform(repoRoot); + const remoteUrl = getRemoteUrl(repoRoot); + + if (platform === 'github' && remoteUrl) { + const info = parseGitHubRemote(remoteUrl); + if (info) { + return new GitHubDiscussionsCommunicationAdapter(info.owner, info.repo); + } + } + + if (platform === 'azure-devops' && remoteUrl) { + const info = parseAzureDevOpsRemote(remoteUrl); + if (info) { + // Read ADO config for org/project override + const configPath = join(repoRoot, '.squad', 'config.json'); + let adoOrg = info.org; + let adoProject = info.project; + if (existsSync(configPath)) { + try { + const raw = readFileSync(configPath, 'utf-8'); + const parsed = JSON.parse(raw) as Record; + const ado = parsed.ado as Record | undefined; + if (ado?.org && typeof ado.org === 'string') adoOrg = ado.org; + if (ado?.project && typeof ado.project === 'string') adoProject = ado.project; + } catch { /* ignore */ } + } + return new ADODiscussionCommunicationAdapter(adoOrg, adoProject); + } + } + + // Fallback: file-based logging (always available) + return new FileLogCommunicationAdapter(repoRoot); +} + +function createAdapterByChannel(channel: CommunicationChannel, repoRoot: string): CommunicationAdapter { + const remoteUrl = getRemoteUrl(repoRoot); + + switch (channel) { + case 'github-discussions': { + if (!remoteUrl) throw new Error('No git remote — cannot create GitHub Discussions adapter'); + const info = parseGitHubRemote(remoteUrl); + if (!info) throw new Error(`Cannot parse GitHub remote: ${remoteUrl}`); + return new GitHubDiscussionsCommunicationAdapter(info.owner, info.repo); + } + case 'ado-work-items': { + if (!remoteUrl) throw new Error('No git remote — cannot create ADO Discussions adapter'); + const info = parseAzureDevOpsRemote(remoteUrl); + if (!info) throw new Error(`Cannot parse ADO remote: ${remoteUrl}`); + return new ADODiscussionCommunicationAdapter(info.org, info.project); + } + case 'teams-webhook': + // Teams webhook adapter would go here — for now fall back to file log + console.warn('Teams webhook adapter not yet implemented — using file log fallback'); + return new FileLogCommunicationAdapter(repoRoot); + case 'file-log': + return new FileLogCommunicationAdapter(repoRoot); + default: + return new FileLogCommunicationAdapter(repoRoot); + } +} diff --git a/packages/squad-sdk/src/platform/index.ts b/packages/squad-sdk/src/platform/index.ts index e4743eb8f..6fa0933dc 100644 --- a/packages/squad-sdk/src/platform/index.ts +++ b/packages/squad-sdk/src/platform/index.ts @@ -4,7 +4,7 @@ * @module platform */ -export type { PlatformType, WorkItem, PullRequest, PlatformAdapter, WorkItemSource, HybridPlatformConfig } from './types.js'; +export type { PlatformType, WorkItem, PullRequest, PlatformAdapter, WorkItemSource, HybridPlatformConfig, CommunicationChannel, CommunicationReply, CommunicationConfig, CommunicationAdapter } from './types.js'; export type { GitHubRemoteInfo, AzureDevOpsRemoteInfo } from './detect.js'; export { detectPlatform, detectPlatformFromUrl, detectWorkItemSource, parseGitHubRemote, parseAzureDevOpsRemote, getRemoteUrl } from './detect.js'; export { GitHubAdapter } from './github.js'; @@ -13,6 +13,10 @@ export type { AdoWorkItemConfig } from './azure-devops.js'; export { PlannerAdapter, mapPlannerTaskToWorkItem } from './planner.js'; export { getRalphScanCommands } from './ralph-commands.js'; export type { RalphCommands } from './ralph-commands.js'; +export { FileLogCommunicationAdapter } from './comms-file-log.js'; +export { GitHubDiscussionsCommunicationAdapter } from './comms-github-discussions.js'; +export { ADODiscussionCommunicationAdapter } from './comms-ado-discussions.js'; +export { createCommunicationAdapter } from './comms.js'; import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; diff --git a/packages/squad-sdk/src/platform/types.ts b/packages/squad-sdk/src/platform/types.ts index 8371d57a1..1bc6ca639 100644 --- a/packages/squad-sdk/src/platform/types.ts +++ b/packages/squad-sdk/src/platform/types.ts @@ -63,3 +63,67 @@ export interface PlatformAdapter { // Branches createBranch(name: string, fromBranch?: string): Promise; } + +// ─── Communication Adapter ──────────────────────────────────────────── + +/** Where communication happens — which channel/service */ +export type CommunicationChannel = 'github-discussions' | 'ado-work-items' | 'teams-webhook' | 'file-log'; + +/** A reply from a human on a communication channel */ +export interface CommunicationReply { + author: string; + body: string; + timestamp: Date; + /** Platform-specific identifier for the reply */ + id: string; +} + +/** Configuration for a communication channel */ +export interface CommunicationConfig { + channel: CommunicationChannel; + /** Post session summaries after agent work */ + postAfterSession?: boolean; + /** Post decisions that need human review */ + postDecisions?: boolean; + /** Post escalations when agents are blocked */ + postEscalations?: boolean; +} + +/** + * Communication adapter interface — pluggable agent-human communication. + * + * Abstracts the communication channel so Squad can post updates and read + * replies from GitHub Discussions, ADO Work Item discussions, Teams, or + * plain log files — depending on what the user has configured. + */ +export interface CommunicationAdapter { + readonly channel: CommunicationChannel; + + /** + * Post an update to the communication channel. + * Used by Scribe (session summaries), Ralph (board status), and agents (escalations). + */ + postUpdate(options: { + title: string; + body: string; + category?: string; + /** Agent or role posting the update */ + author?: string; + }): Promise<{ id: string; url?: string }>; + + /** + * Poll for replies since a given timestamp. + * Returns new replies from humans on the channel. + */ + pollForReplies(options: { + /** Thread/discussion ID to check for replies */ + threadId: string; + since: Date; + }): Promise; + + /** + * Get a URL that humans can open on any device (phone, browser, desktop). + * Returns undefined if the channel has no web UI (e.g., file-log). + */ + getNotificationUrl(threadId: string): string | undefined; +} diff --git a/test/communication-adapter.test.ts b/test/communication-adapter.test.ts new file mode 100644 index 000000000..f9aaa8ef0 --- /dev/null +++ b/test/communication-adapter.test.ts @@ -0,0 +1,173 @@ +/** + * Communication adapter tests — interface contracts, factory, FileLog adapter. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdirSync, rmSync, existsSync, readFileSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import type { CommunicationAdapter, CommunicationChannel, CommunicationReply, CommunicationConfig } from '../packages/squad-sdk/src/platform/types.js'; +import { FileLogCommunicationAdapter } from '../packages/squad-sdk/src/platform/comms-file-log.js'; + +const TEST_ROOT = join(__dirname, '..', 'test-fixtures', 'comms-test'); + +describe('CommunicationAdapter interface', () => { + it('CommunicationChannel includes all expected values', () => { + const channels: CommunicationChannel[] = ['github-discussions', 'ado-work-items', 'teams-webhook', 'file-log']; + expect(channels).toHaveLength(4); + }); + + it('CommunicationReply has required fields', () => { + const reply: CommunicationReply = { + author: 'tamir', + body: 'Looks good!', + timestamp: new Date(), + id: 'reply-1', + }; + expect(reply.author).toBe('tamir'); + expect(reply.body).toBe('Looks good!'); + expect(reply.id).toBe('reply-1'); + }); + + it('CommunicationConfig has correct shape', () => { + const config: CommunicationConfig = { + channel: 'github-discussions', + postAfterSession: true, + postDecisions: true, + postEscalations: false, + }; + expect(config.channel).toBe('github-discussions'); + expect(config.postAfterSession).toBe(true); + }); +}); + +describe('FileLogCommunicationAdapter', () => { + let adapter: FileLogCommunicationAdapter; + + beforeEach(() => { + if (existsSync(TEST_ROOT)) rmSync(TEST_ROOT, { recursive: true }); + mkdirSync(join(TEST_ROOT, '.squad'), { recursive: true }); + adapter = new FileLogCommunicationAdapter(TEST_ROOT); + }); + + afterEach(() => { + if (existsSync(TEST_ROOT)) rmSync(TEST_ROOT, { recursive: true }); + }); + + it('has channel type file-log', () => { + expect(adapter.channel).toBe('file-log'); + }); + + it('creates comms directory on construction', () => { + expect(existsSync(join(TEST_ROOT, '.squad', 'comms'))).toBe(true); + }); + + it('postUpdate creates a markdown file', async () => { + const result = await adapter.postUpdate({ + title: 'Session Summary', + body: 'Completed auth module refactoring', + category: 'standup', + author: 'Scribe', + }); + + expect(result.id).toBeTruthy(); + expect(result.url).toBeUndefined(); // file-based has no URL + + const commsDir = join(TEST_ROOT, '.squad', 'comms'); + const files = require('fs').readdirSync(commsDir); + expect(files.length).toBe(1); + expect(files[0]).toMatch(/\.md$/); + + const content = readFileSync(join(commsDir, files[0]), 'utf-8'); + expect(content).toContain('# Session Summary'); + expect(content).toContain('Completed auth module refactoring'); + expect(content).toContain('Scribe'); + expect(content).toContain('standup'); + }); + + it('postUpdate uses default category and author when not provided', async () => { + await adapter.postUpdate({ + title: 'Quick Update', + body: 'Something happened', + }); + + const commsDir = join(TEST_ROOT, '.squad', 'comms'); + const files = require('fs').readdirSync(commsDir); + const content = readFileSync(join(commsDir, files[0]), 'utf-8'); + expect(content).toContain('Squad'); + expect(content).toContain('update'); + }); + + it('pollForReplies returns empty when no thread exists', async () => { + const replies = await adapter.pollForReplies({ + threadId: 'nonexistent', + since: new Date(), + }); + expect(replies).toEqual([]); + }); + + it('pollForReplies reads replies from thread file', async () => { + const result = await adapter.postUpdate({ + title: 'Need Input', + body: 'Should we use REST or GraphQL?', + }); + + // Simulate human reply by appending to the file + const commsDir = join(TEST_ROOT, '.squad', 'comms'); + const filepath = join(commsDir, `${result.id}.md`); + const content = readFileSync(filepath, 'utf-8'); + writeFileSync(filepath, content + '\nLet\'s go with GraphQL.\n', 'utf-8'); + + const replies = await adapter.pollForReplies({ + threadId: result.id, + since: new Date(0), + }); + + expect(replies.length).toBe(1); + expect(replies[0]!.body).toContain('GraphQL'); + }); + + it('getNotificationUrl returns undefined for file-log', () => { + expect(adapter.getNotificationUrl('any-id')).toBeUndefined(); + }); + + it('multiple postUpdates create separate files', async () => { + await adapter.postUpdate({ title: 'First', body: 'One' }); + // Small delay to ensure different timestamps + await new Promise((r) => setTimeout(r, 10)); + await adapter.postUpdate({ title: 'Second', body: 'Two' }); + + const commsDir = join(TEST_ROOT, '.squad', 'comms'); + const files = require('fs').readdirSync(commsDir); + expect(files.length).toBe(2); + }); +}); + +describe('CommunicationAdapter contract', () => { + it('FileLogCommunicationAdapter implements CommunicationAdapter', () => { + if (existsSync(TEST_ROOT)) rmSync(TEST_ROOT, { recursive: true }); + mkdirSync(join(TEST_ROOT, '.squad'), { recursive: true }); + + const adapter: CommunicationAdapter = new FileLogCommunicationAdapter(TEST_ROOT); + expect(adapter.channel).toBeDefined(); + expect(typeof adapter.postUpdate).toBe('function'); + expect(typeof adapter.pollForReplies).toBe('function'); + expect(typeof adapter.getNotificationUrl).toBe('function'); + + rmSync(TEST_ROOT, { recursive: true }); + }); + + it('GitHubDiscussionsCommunicationAdapter exports correctly', async () => { + const mod = await import('../packages/squad-sdk/src/platform/comms-github-discussions.js'); + expect(mod.GitHubDiscussionsCommunicationAdapter).toBeDefined(); + }); + + it('ADODiscussionCommunicationAdapter exports correctly', async () => { + const mod = await import('../packages/squad-sdk/src/platform/comms-ado-discussions.js'); + expect(mod.ADODiscussionCommunicationAdapter).toBeDefined(); + }); + + it('createCommunicationAdapter factory exports correctly', async () => { + const mod = await import('../packages/squad-sdk/src/platform/comms.js'); + expect(mod.createCommunicationAdapter).toBeDefined(); + }); +});