From 1a1f956c697275305f6baa42ba4ed60e93100962 Mon Sep 17 00:00:00 2001 From: brady gaster Date: Tue, 25 Aug 2026 09:30:43 -0700 Subject: [PATCH 1/5] feat(agent-context): richer squad.agent.md generation for AW coordinator awareness (#1608) - Add SQUAD_TEAM_CONTEXT_BEGIN/END markers to squad.agent.md template - New agent-context.ts module: parseTeamMdMembers, parseRoutingMd, generateAWTeamContextBlock, injectTeamContext, refreshTeamContextInAgentFile - Sanitizes all untrusted metadata to prevent Markdown/HTML injection - Wire regeneration into cast.ts createTeam() and upgrade.ts writeAgentTemplate() - Focused tests: sanitization, parsing, generation, injection, prompt budget - Patch changeset for squad-cli Closes #1608 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .changeset/squad-agent-aw-context.md | 5 + .github/agents/squad.agent.md | 13 +- .squad-templates/squad.agent.md | 11 + .../squad-cli/src/cli/core/agent-context.ts | 409 +++++++++++++++++ packages/squad-cli/src/cli/core/cast.ts | 9 + packages/squad-cli/src/cli/core/upgrade.ts | 13 +- .../templates/squad.agent.md.template | 11 + .../templates/squad.agent.md.template | 11 + templates/squad.agent.md.template | 11 + test/squad-agent-aw-context.test.ts | 427 ++++++++++++++++++ 10 files changed, 917 insertions(+), 3 deletions(-) create mode 100644 .changeset/squad-agent-aw-context.md create mode 100644 packages/squad-cli/src/cli/core/agent-context.ts create mode 100644 test/squad-agent-aw-context.test.ts diff --git a/.changeset/squad-agent-aw-context.md b/.changeset/squad-agent-aw-context.md new file mode 100644 index 000000000..746d36ad8 --- /dev/null +++ b/.changeset/squad-agent-aw-context.md @@ -0,0 +1,5 @@ +--- +"@bradygaster/squad-cli": patch +--- + +Add AW coordinator team context to `squad.agent.md` — injected on `squad cast`/recast/retire and `squad upgrade`. New `agent-context.ts` module derives specialist roles, routing hints, and capability boundaries from `team.md` and `routing.md`. All untrusted text is sanitized to prevent Markdown injection. Fixes #1608. diff --git a/.github/agents/squad.agent.md b/.github/agents/squad.agent.md index 73a3dee15..1009dab6a 100644 --- a/.github/agents/squad.agent.md +++ b/.github/agents/squad.agent.md @@ -49,6 +49,17 @@ Check: Does `{TEAM_ROOT}/team.md` exist? (fall back to `.ai-team/team.md` for re --- +## Team Roster (AW Coordinator Reference) + +> Auto-generated by `squad cast` — do not edit manually. Run `squad cast` or `squad upgrade` to refresh. + + +> No team configured yet. Run `squad cast` to populate this section with your +> team's specialists, routing hints, and capability boundaries. + + +--- + ## Init Mode **Trigger:** No `.squad/team.md` exists in the resolved team root — i.e., this is a fresh repo or one that has never been squadified. @@ -354,7 +365,7 @@ After routing determines WHO handles work, select a **response MODE** (Direct / Resolve a model before every spawn. Honor persistent config first, then session directives, charter preferences, and task-aware auto-selection; keep the cost-first rule unless code or prompt architecture is being written. -Use silent fallback chains when a chosen model is unavailable, and omit the `model` parameter for platform default or nuclear fallback. +Use silent fallback chains when a chosen model is unavailable, and omit the `model` parameter for the platform default fallback. **On-demand reference:** Read `.squad/templates/model-selection-reference.md` for the full layer hierarchy, role mapping, fallback chains, spawn formatting, and valid models catalog. diff --git a/.squad-templates/squad.agent.md b/.squad-templates/squad.agent.md index 33a3f990a..1009dab6a 100644 --- a/.squad-templates/squad.agent.md +++ b/.squad-templates/squad.agent.md @@ -49,6 +49,17 @@ Check: Does `{TEAM_ROOT}/team.md` exist? (fall back to `.ai-team/team.md` for re --- +## Team Roster (AW Coordinator Reference) + +> Auto-generated by `squad cast` — do not edit manually. Run `squad cast` or `squad upgrade` to refresh. + + +> No team configured yet. Run `squad cast` to populate this section with your +> team's specialists, routing hints, and capability boundaries. + + +--- + ## Init Mode **Trigger:** No `.squad/team.md` exists in the resolved team root — i.e., this is a fresh repo or one that has never been squadified. diff --git a/packages/squad-cli/src/cli/core/agent-context.ts b/packages/squad-cli/src/cli/core/agent-context.ts new file mode 100644 index 000000000..be7d39c40 --- /dev/null +++ b/packages/squad-cli/src/cli/core/agent-context.ts @@ -0,0 +1,409 @@ +/** + * AW Coordinator Team Context — generates the + * section injected into squad.agent.md after cast/recast/retire operations. + * + * Design: + * - Derives specialist roles, task types, and routing hints from team.md and routing.md + * - Sanitizes all untrusted text so metadata cannot inject headings, code fences, + * hidden instructions, or structural Markdown + * - Deterministic: identical input produces identical output (sorted by name) + * - No hardcoded default-cast names; works for any team composition + * - Authority boundaries are grounded in the coordinator's own refusal rules + * (not aspirational permissions) — derived from static coordinator contract + * + * @module cli/core/agent-context + */ + +import { join } from 'node:path'; +import { FSStorageProvider } from '@bradygaster/squad-sdk'; + +const storage = new FSStorageProvider(); + +// ── Constants ────────────────────────────────────────────────────────────── + +export const TEAM_CONTEXT_BEGIN = ''; +export const TEAM_CONTEXT_END = ''; + +/** Default placeholder installed with the template — replaced on first cast. */ +export const TEAM_CONTEXT_DEFAULT = + '> No team configured yet. Run `squad cast` to populate this section with your\n' + + '> team\'s specialists, routing hints, and capability boundaries.\n'; + +// ── Sanitization ─────────────────────────────────────────────────────────── + +/** + * Sanitize a single text field for safe embedding in a Markdown table cell or + * inline text. Removes or escapes Markdown heading markers, code fences, HTML + * comment delimiters, and pipe characters that would break table alignment. + * + * Contract: + * - Output is a single line (no embedded newlines) + * - Maximum 200 characters after sanitization + * - Does NOT alter alphanumeric text, emoji, or normal punctuation + */ +export function sanitizeField(raw: string): string { + return raw + // Flatten to single line first (prevents multi-line injection) + .replace(/\r?\n/g, ' ') + // Strip Markdown heading markers (## Heading injection) + .replace(/^#{1,6}\s+/gm, '') + // Replace code-fence delimiters (``` and ~~~) to prevent code block injection + .replace(/`{3,}/g, '`') + .replace(/~{3,}/g, '~') + // Strip HTML comment syntax to prevent hidden instruction injection + .replace(//g, '--\u203a') + // Replace pipe characters (break table structure) with a safe lookalike + .replace(/\|/g, '\u2223') + // Collapse repeated whitespace + .replace(/\s{2,}/g, ' ') + .trim() + // Truncate to prevent excessively long cells + .slice(0, 200); +} + +/** + * Sanitize a free-text block (multi-line allowed, but structural injection is + * still prevented). Used for longer fields like the capability summary. + */ +export function sanitizeBlock(raw: string): string { + return raw + // Strip heading markers at start of lines + .replace(/^#{1,6}\s+/gm, '') + // Replace code-fence delimiters + .replace(/`{3,}/g, '`') + .replace(/~{3,}/g, '~') + // Strip HTML comment syntax + .replace(//g, '--\u203a') + .trim() + .slice(0, 2000); +} + +// ── Team member types ────────────────────────────────────────────────────── + +export interface ActiveMember { + /** Cast name, e.g. "EECOM" */ + name: string; + /** Role label, e.g. "Core Dev" */ + role: string; + /** Status string from team.md, e.g. "✅ Active" */ + status: string; +} + +export interface RoutingRow { + workType: string; + agent: string; + examples?: string; +} + +// ── Parsers ──────────────────────────────────────────────────────────────── + +/** + * Extract active members from a team.md `## Members` table. + * + * Parses lines of the form: + * | Name | Role | Charter | Status | + * + * Rules: + * - Only rows with status that includes "Active", "Silent", "Monitor", "RAI", + * or any emoji status are included (skips headers and separators) + * - Coordinator row ("Squad | Coordinator") is excluded + * - Rows are returned in the order they appear (deterministic) + */ +export function parseTeamMdMembers(content: string): ActiveMember[] { + const members: ActiveMember[] = []; + + const membersSection = content.split(/^##\s+Members\b/m)[1]; + if (!membersSection) return members; + + // Stop at the next ## heading + const section = membersSection.split(/^##\s+/m)[0] ?? membersSection; + + for (const line of section.split('\n')) { + const trimmed = line.trim(); + if (!trimmed.startsWith('|')) continue; + const cells = trimmed.split('|').map(c => c.trim()).filter(Boolean); + if (cells.length < 2) continue; + + // Skip header and separator rows + const firstCell = cells[0] ?? ''; + if (/^[-\s]+$/.test(firstCell)) continue; + if (/^name$/i.test(firstCell)) continue; + + // Skip coordinator row + if (/^squad$/i.test(firstCell) && /coordinator/i.test(cells[1] ?? '')) continue; + + const name = sanitizeField(firstCell); + const role = sanitizeField(cells[1] ?? ''); + const status = sanitizeField(cells[3] ?? cells[2] ?? '✅ Active'); + + if (name && role) { + members.push({ name, role, status }); + } + } + + return members; +} + +/** + * Extract routing rows from a routing.md `## Work Type → Agent` table. + * + * Parses lines of the form: + * | Work Type | Agent | Examples | + * + * The "Agent" cell often contains an emoji + name, e.g. "EECOM 🔧". + */ +export function parseRoutingMd(content: string): RoutingRow[] { + const rows: RoutingRow[] = []; + + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (!trimmed.startsWith('|')) continue; + const cells = trimmed.split('|').map(c => c.trim()).filter(Boolean); + if (cells.length < 2) continue; + + const firstCell = cells[0] ?? ''; + if (/^[-\s]+$/.test(firstCell)) continue; + if (/^work\s*type$/i.test(firstCell)) continue; + if (/^module$/i.test(firstCell)) continue; + if (/^signal$/i.test(firstCell)) continue; + + const workType = sanitizeField(firstCell); + const agent = sanitizeField(cells[1] ?? ''); + const examples = cells[2] ? sanitizeField(cells[2]) : undefined; + + if (workType && agent) { + rows.push({ workType, agent, examples }); + } + } + + return rows; +} + +// ── Context block generator ──────────────────────────────────────────────── + +/** + * Build routing hints table for AW coordinators from routing rows. + * Deduplicates by agent (keeps first occurrence of each agent name). + * Returns an empty string when no routing data is available. + */ +function buildRoutingHintsTable(rows: RoutingRow[]): string { + if (rows.length === 0) return ''; + + const lines: string[] = [ + '**Routing hints for outer coordinators:**', + '', + '| Task Type | Preferred Agent |', + '|-----------|----------------|', + ]; + + // Limit to 15 routing hints to keep the section concise + const shown = rows.slice(0, 15); + for (const row of shown) { + lines.push(`| ${row.workType} | ${row.agent} |`); + } + + return lines.join('\n'); +} + +/** + * Build the specialist table from active members. + */ +function buildSpecialistTable(members: ActiveMember[]): string { + if (members.length === 0) return ''; + + const lines: string[] = [ + `**${members.length} active specialist${members.length === 1 ? '' : 's'}:**`, + '', + '| Agent | Role | Status |', + '|-------|------|--------|', + ]; + + for (const m of members) { + lines.push(`| ${m.name} | ${m.role} | ${m.status} |`); + } + + return lines.join('\n'); +} + +/** + * Build the capability boundaries paragraph. + * These boundaries are grounded in the coordinator's refusal rules + * (static contract) — not derived from team composition. + */ +function buildCapabilityBoundaries(members: ActiveMember[]): string { + // Derive high-level capability labels from member roles + const roleLabels: string[] = []; + for (const m of members) { + const r = m.role.toLowerCase(); + if (/lead|architect/.test(r)) roleLabels.push('architecture planning'); + if (/test|qa|quality/.test(r)) roleLabels.push('test authoring'); + if (/security|auth/.test(r)) roleLabels.push('security audit'); + if (/docs|devrel|writer|handbook/.test(r)) roleLabels.push('documentation'); + if (/devops|infra|platform|ci|booster/.test(r)) roleLabels.push('CI/CD configuration'); + if (/sdk|capcom/.test(r)) roleLabels.push('SDK integration'); + if (/typescript|control/.test(r)) roleLabels.push('TypeScript engineering'); + if (/runtime|core|eecom/.test(r)) roleLabels.push('runtime implementation'); + if (/observ|telemetry|aspire/.test(r)) roleLabels.push('observability'); + if (/release|surgeon/.test(r)) roleLabels.push('release management'); + if (/distribution|network/.test(r)) roleLabels.push('package distribution'); + if (/ux|design|inco|tui|dsky|vox/.test(r)) roleLabels.push('UI/UX implementation'); + if (/review|flight/.test(r)) roleLabels.push('code review'); + } + + // Always include core capabilities (apply to any Squad team) + const baseCaps = ['PR review', 'code review', 'documentation']; + const allCaps = Array.from(new Set([...baseCaps, ...roleLabels])).sort().join(', '); + + const lines: string[] = [ + '**Capability boundaries:**', + '', + `- ✅ **Can:** ${allCaps}.`, + '- ❌ **Cannot:** Deploy to production, push directly to the default branch, bypass reviewer', + ' gates, access external systems without MCP tools, provision infrastructure, or make', + ' decisions without user confirmation on architecture changes.', + ]; + + return lines.join('\n'); +} + +/** + * Generate the full AW team context block — the content that goes between + * SQUAD_TEAM_CONTEXT_BEGIN and SQUAD_TEAM_CONTEXT_END markers. + * + * @param members - Active team members parsed from team.md + * @param routingRows - Routing table rows parsed from routing.md (optional) + * @param timestamp - ISO timestamp for the auto-generated comment + */ +export function generateAWTeamContextBlock( + members: ActiveMember[], + routingRows: RoutingRow[] = [], + timestamp: string = new Date().toISOString(), +): string { + if (members.length === 0) { + return TEAM_CONTEXT_DEFAULT; + } + + const parts: string[] = [ + ``, + '', + ]; + + const specialistTable = buildSpecialistTable(members); + if (specialistTable) { + parts.push(specialistTable); + parts.push(''); + } + + const routingTable = buildRoutingHintsTable(routingRows); + if (routingTable) { + parts.push(routingTable); + parts.push(''); + } + + const capBoundaries = buildCapabilityBoundaries(members); + if (capBoundaries) { + parts.push(capBoundaries); + parts.push(''); + } + + return parts.join('\n'); +} + +// ── Injection ────────────────────────────────────────────────────────────── + +/** + * Replace the content between SQUAD_TEAM_CONTEXT_BEGIN and SQUAD_TEAM_CONTEXT_END + * markers with the given block. + * + * If the markers are absent (legacy install without markers) the content is + * returned unchanged — callers must ensure the template has markers before + * calling inject. + * + * Preserves everything outside the markers (coordinator instructions, + * version stamp, user customizations outside the block). + */ +export function injectTeamContext(agentMd: string, block: string): string { + const beginIdx = agentMd.indexOf(TEAM_CONTEXT_BEGIN); + const endIdx = agentMd.indexOf(TEAM_CONTEXT_END); + + if (beginIdx === -1 || endIdx === -1 || beginIdx >= endIdx) { + // Markers not found — return unchanged (no silent data loss) + return agentMd; + } + + const before = agentMd.slice(0, beginIdx + TEAM_CONTEXT_BEGIN.length); + const after = agentMd.slice(endIdx); + + // Ensure block starts and ends with a newline for clean formatting + const normalizedBlock = '\n' + block.replace(/\n+$/, '') + '\n'; + + return before + normalizedBlock + after; +} + +// ── High-level API ───────────────────────────────────────────────────────── + +/** + * Read team.md and routing.md from squadDir, generate the AW team context + * block, and inject it into the given squad.agent.md content. + * + * Returns the updated content. Does not write to disk — callers are + * responsible for writing. + * + * @param squadDir - Path to the `.squad/` directory + * @param agentMd - Current content of squad.agent.md + */ +export function buildAndInjectTeamContext(squadDir: string, agentMd: string): string { + const teamMdPath = join(squadDir, 'team.md'); + const routingMdPath = join(squadDir, 'routing.md'); + + let members: ActiveMember[] = []; + let routingRows: RoutingRow[] = []; + + try { + if (storage.existsSync(teamMdPath)) { + const teamContent = storage.readSync(teamMdPath) ?? ''; + members = parseTeamMdMembers(teamContent); + } + } catch { + // If team.md is unreadable, leave members empty (produces default block) + } + + try { + if (storage.existsSync(routingMdPath)) { + const routingContent = storage.readSync(routingMdPath) ?? ''; + routingRows = parseRoutingMd(routingContent); + } + } catch { + // Routing is optional; proceed without it + } + + const block = generateAWTeamContextBlock(members, routingRows); + return injectTeamContext(agentMd, block); +} + +/** + * Read, update, and write the team context section of an existing + * squad.agent.md file. + * + * If the file does not exist or the markers are absent, this is a no-op + * (no file is created or corrupted). + * + * @param squadDir - Path to the `.squad/` directory + * @param agentMdPath - Full path to the squad.agent.md file to update + */ +export function refreshTeamContextInAgentFile( + squadDir: string, + agentMdPath: string, +): void { + if (!storage.existsSync(agentMdPath)) return; + + const current = storage.readSync(agentMdPath) ?? ''; + if (!current.includes(TEAM_CONTEXT_BEGIN)) return; + + const updated = buildAndInjectTeamContext(squadDir, current); + if (updated !== current) { + storage.writeSync(agentMdPath, updated); + } +} diff --git a/packages/squad-cli/src/cli/core/cast.ts b/packages/squad-cli/src/cli/core/cast.ts index eb3126a06..e816f4918 100644 --- a/packages/squad-cli/src/cli/core/cast.ts +++ b/packages/squad-cli/src/cli/core/cast.ts @@ -15,6 +15,7 @@ import { type CastMember as EngineCastMember, type AgentRole as EngineAgentRole, } from '@bradygaster/squad-sdk/casting'; +import { refreshTeamContextInAgentFile } from './agent-context.js'; // ── RAI Policy Template ──────────────────────────────────────────── @@ -855,6 +856,14 @@ export async function createTeam(teamRoot: string, proposal: CastProposal): Prom await addAgentToConfig(teamRoot, member.name.toLowerCase(), member.role); } + // Refresh the AW coordinator team context in squad.agent.md + const agentMdPath = join(teamRoot, '.github', 'agents', 'squad.agent.md'); + try { + refreshTeamContextInAgentFile(squadDir, agentMdPath); + } catch { + // Non-fatal: squad.agent.md may not exist in the project yet (e.g. pre-init) + } + return { teamRoot, membersCreated, filesCreated }; } diff --git a/packages/squad-cli/src/cli/core/upgrade.ts b/packages/squad-cli/src/cli/core/upgrade.ts index 4bd8c171e..e316e4c35 100644 --- a/packages/squad-cli/src/cli/core/upgrade.ts +++ b/packages/squad-cli/src/cli/core/upgrade.ts @@ -18,6 +18,7 @@ import { getPackageVersion, stampVersion, readInstalledVersion } from './version import { resolveSquadStateMcpSpec, type SquadStateMcpSpec } from './mcp-spec.js'; export { resolveSquadStateMcpSpec } from './mcp-spec.js'; import { ensureSquadStateMcpInRoot, tombstoneStaleSquadStateInProjectMcp } from './mcp-root.js'; +import { refreshTeamContextInAgentFile } from './agent-context.js'; const storage = new FSStorageProvider(); @@ -249,8 +250,14 @@ function writeAgentTemplate(agentSrc: string, agentDest: string, cliVersion: str const existing = (storage.readSync(agentDest) ?? '').replace(/\r\n/g, '\n'); // Strip the version stamp before comparing (version line changes every upgrade) const stripVersion = (s: string) => s.replace(/\n?/g, ''); - const normalizedExisting = stripVersion(existing); - const normalizedTemplate = stripVersion(agentContent.replace(/\r\n/g, '\n')); + // Strip auto-generated team context block before comparing (injected by cast/upgrade, + // differs from template by design — not a user customization) + const stripTeamContext = (s: string) => + s.replace(/[\s\S]*?/g, + ''); + const normalize = (s: string) => stripTeamContext(stripVersion(s)); + const normalizedExisting = normalize(existing); + const normalizedTemplate = normalize(agentContent.replace(/\r\n/g, '\n')); if (normalizedExisting !== normalizedTemplate && normalizedExisting.trim().length > 0) { const backupPath = agentDest + '.local-backup'; @@ -1164,6 +1171,7 @@ export async function runUpgrade(dest: string, options: UpgradeOptions = {}): Pr if (storage.existsSync(agentSrc)) { storage.mkdirSync(path.dirname(agentDest), { recursive: true }); writeAgentTemplate(agentSrc, agentDest, cliVersion, mcpConfigMode, isGitHubForMcp); + refreshTeamContextInAgentFile(squadDirInfo.path, agentDest); success('upgraded squad.agent.md'); filesUpdated.push('squad.agent.md'); } else { @@ -1191,6 +1199,7 @@ export async function runUpgrade(dest: string, options: UpgradeOptions = {}): Pr storage.mkdirSync(path.dirname(agentDest), { recursive: true }); writeAgentTemplate(agentSrc, agentDest, cliVersion, mcpConfigMode, isGitHubForMcp); + refreshTeamContextInAgentFile(squadDirInfo.path, agentDest); const fromLabel = oldVersion === '0.0.0' || !oldVersion ? 'unknown' : oldVersion; success(`upgraded coordinator from ${fromLabel} to ${cliVersion}`); diff --git a/packages/squad-cli/templates/squad.agent.md.template b/packages/squad-cli/templates/squad.agent.md.template index 33a3f990a..1009dab6a 100644 --- a/packages/squad-cli/templates/squad.agent.md.template +++ b/packages/squad-cli/templates/squad.agent.md.template @@ -49,6 +49,17 @@ Check: Does `{TEAM_ROOT}/team.md` exist? (fall back to `.ai-team/team.md` for re --- +## Team Roster (AW Coordinator Reference) + +> Auto-generated by `squad cast` — do not edit manually. Run `squad cast` or `squad upgrade` to refresh. + + +> No team configured yet. Run `squad cast` to populate this section with your +> team's specialists, routing hints, and capability boundaries. + + +--- + ## Init Mode **Trigger:** No `.squad/team.md` exists in the resolved team root — i.e., this is a fresh repo or one that has never been squadified. diff --git a/packages/squad-sdk/templates/squad.agent.md.template b/packages/squad-sdk/templates/squad.agent.md.template index 33a3f990a..1009dab6a 100644 --- a/packages/squad-sdk/templates/squad.agent.md.template +++ b/packages/squad-sdk/templates/squad.agent.md.template @@ -49,6 +49,17 @@ Check: Does `{TEAM_ROOT}/team.md` exist? (fall back to `.ai-team/team.md` for re --- +## Team Roster (AW Coordinator Reference) + +> Auto-generated by `squad cast` — do not edit manually. Run `squad cast` or `squad upgrade` to refresh. + + +> No team configured yet. Run `squad cast` to populate this section with your +> team's specialists, routing hints, and capability boundaries. + + +--- + ## Init Mode **Trigger:** No `.squad/team.md` exists in the resolved team root — i.e., this is a fresh repo or one that has never been squadified. diff --git a/templates/squad.agent.md.template b/templates/squad.agent.md.template index 33a3f990a..1009dab6a 100644 --- a/templates/squad.agent.md.template +++ b/templates/squad.agent.md.template @@ -49,6 +49,17 @@ Check: Does `{TEAM_ROOT}/team.md` exist? (fall back to `.ai-team/team.md` for re --- +## Team Roster (AW Coordinator Reference) + +> Auto-generated by `squad cast` — do not edit manually. Run `squad cast` or `squad upgrade` to refresh. + + +> No team configured yet. Run `squad cast` to populate this section with your +> team's specialists, routing hints, and capability boundaries. + + +--- + ## Init Mode **Trigger:** No `.squad/team.md` exists in the resolved team root — i.e., this is a fresh repo or one that has never been squadified. diff --git a/test/squad-agent-aw-context.test.ts b/test/squad-agent-aw-context.test.ts new file mode 100644 index 000000000..7b79f0c9f --- /dev/null +++ b/test/squad-agent-aw-context.test.ts @@ -0,0 +1,427 @@ +/** + * AW Coordinator Team Context — focused tests for agent-context.ts + * + * Covers: + * - sanitizeField / sanitizeBlock: heading injection, code-fence injection, + * HTML comment injection, pipe injection, multi-line flattening + * - parseTeamMdMembers: default cast, custom cast, minimal team, empty/malformed + * - parseRoutingMd: standard routing table, empty, malformed rows + * - generateAWTeamContextBlock: deterministic ordering, empty team, full team + * - injectTeamContext: idempotent injection, missing markers, stale-name removal + * - Authority boundaries: derived from roles, not aspirational + * - Prompt budget: generated block must not exceed a reasonable byte ceiling + */ + +import { describe, it, expect } from 'vitest'; +import { + sanitizeField, + sanitizeBlock, + parseTeamMdMembers, + parseRoutingMd, + generateAWTeamContextBlock, + injectTeamContext, + buildAndInjectTeamContext, + TEAM_CONTEXT_BEGIN, + TEAM_CONTEXT_END, + TEAM_CONTEXT_DEFAULT, + type ActiveMember, + type RoutingRow, +} from '../packages/squad-cli/src/cli/core/agent-context.js'; + +// ── Helper ──────────────────────────────────────────────────────────────── + +function makeAgentMdWithMarkers(content = TEAM_CONTEXT_DEFAULT): string { + return `# Squad Coordinator\n\n${TEAM_CONTEXT_BEGIN}\n${content}\n${TEAM_CONTEXT_END}\n\n## Init Mode\n`; +} + +// ── sanitizeField ───────────────────────────────────────────────────────── + +describe('sanitizeField', () => { + it('passes through plain text unchanged', () => { + expect(sanitizeField('EECOM')).toBe('EECOM'); + expect(sanitizeField('Core Dev 🔧')).toBe('Core Dev 🔧'); + }); + + it('strips Markdown heading markers', () => { + const injected = '## Inject Heading'; + const result = sanitizeField(injected); + expect(result).not.toContain('##'); + expect(result).toContain('Inject Heading'); + }); + + it('replaces code-fence backtick sequences to prevent code block injection', () => { + const result = sanitizeField('```js\nconsole.log("pwn")\n```'); + expect(result).not.toContain('```'); + }); + + it('escapes HTML comment delimiters to prevent hidden-instruction injection', () => { + const result = sanitizeField(''); + expect(result).not.toContain(''); + }); + + it('replaces pipe characters to protect table structure', () => { + const result = sanitizeField('cell | injection | here'); + expect(result).not.toContain('|'); + }); + + it('flattens multi-line input to a single line', () => { + const multiLine = 'line one\nline two\r\nline three'; + const result = sanitizeField(multiLine); + expect(result).not.toContain('\n'); + expect(result).not.toContain('\r'); + }); + + it('truncates to at most 200 characters', () => { + const long = 'a'.repeat(300); + expect(sanitizeField(long).length).toBeLessThanOrEqual(200); + }); +}); + +// ── sanitizeBlock ───────────────────────────────────────────────────────── + +describe('sanitizeBlock', () => { + it('strips heading markers at start of lines', () => { + const result = sanitizeBlock('## Security Advisory\nsome text'); + expect(result).not.toContain('## Security Advisory'); + expect(result).toContain('Security Advisory'); + }); + + it('replaces code-fence sequences', () => { + const result = sanitizeBlock('Some text\n```\npwn\n```\nMore text'); + expect(result).not.toContain('```'); + }); + + it('escapes HTML comment delimiters', () => { + const result = sanitizeBlock('\ntext end'); + expect(result).not.toContain(''); + }); + + it('truncates to at most 2000 characters', () => { + const long = 'x'.repeat(2500); + expect(sanitizeBlock(long).length).toBeLessThanOrEqual(2000); + }); +}); + +// ── parseTeamMdMembers ──────────────────────────────────────────────────── + +const TYPICAL_TEAM_MD = ` +# Squad Team + +> My Project + +## Coordinator + +| Name | Role | Notes | +|-------|-------------|-------| +| Squad | Coordinator | Routes work | + +## Members + +| Name | Role | Charter | Status | +|--------------|------------------|--------------------------------------|-----------| +| Flight 🏗️ | Lead | .squad/agents/flight/charter.md | ✅ Active | +| EECOM 🔧 | Core Dev | .squad/agents/eecom/charter.md | ✅ Active | +| CONTROL 👩‍💻 | TypeScript | .squad/agents/control/charter.md | ✅ Active | +| FIDO 🧪 | Tests & Quality | .squad/agents/fido/charter.md | ✅ Active | +| PAO 📣 | Docs & DevRel | .squad/agents/pao/charter.md | ✅ Active | +| Scribe 📋 | (silent) | .squad/agents/scribe/charter.md | ✅ Active | +| Ralph 👀 | (silent) | .squad/agents/ralph/charter.md | ✅ Active | +`; + +describe('parseTeamMdMembers', () => { + it('parses a typical default-cast team', () => { + const members = parseTeamMdMembers(TYPICAL_TEAM_MD); + expect(members.length).toBeGreaterThanOrEqual(5); + const names = members.map(m => m.name); + expect(names).toContain('Flight 🏗️'); + expect(names).toContain('EECOM 🔧'); + expect(names).toContain('CONTROL 👩‍💻'); + }); + + it('excludes the coordinator row', () => { + const members = parseTeamMdMembers(TYPICAL_TEAM_MD); + const names = members.map(m => m.name); + expect(names.some(n => /^Squad$/i.test(n))).toBe(false); + }); + + it('returns empty array for missing ## Members section', () => { + expect(parseTeamMdMembers('# Team\n\n> desc\n')).toEqual([]); + }); + + it('returns empty array for empty team.md', () => { + expect(parseTeamMdMembers('')).toEqual([]); + }); + + it('ignores header and separator rows', () => { + const md = `## Members\n\n| Name | Role | Charter | Status |\n|------|------|---------|--------|\n| Alice | Tester | x | ✅ Active |\n`; + const members = parseTeamMdMembers(md); + expect(members.length).toBe(1); + expect(members[0]?.name).toBe('Alice'); + }); + + it('handles malformed rows with fewer than 2 cells gracefully', () => { + const md = `## Members\n\n| only-one-cell |\n| Alice | Tester | x | ✅ Active |\n`; + const members = parseTeamMdMembers(md); + // "only-one-cell" has 1 cell — skipped; Alice has 4 — included + const names = members.map(m => m.name); + expect(names).not.toContain('only-one-cell'); + expect(names).toContain('Alice'); + }); + + it('sanitizes names that contain Markdown injection', () => { + const md = `## Members\n\n| ## Injected | Hacker | x | ✅ Active |\n`; + const members = parseTeamMdMembers(md); + if (members.length > 0) { + expect(members[0]?.name).not.toContain('##'); + } + }); + + it('handles a minimal single-member team', () => { + const md = `## Members\n\n| Solo | All-roles | x | ✅ Active |\n`; + const members = parseTeamMdMembers(md); + expect(members.length).toBe(1); + expect(members[0]?.name).toBe('Solo'); + expect(members[0]?.role).toBe('All-roles'); + }); + + it('handles unknown/custom role names deterministically', () => { + const md = `## Members\n\n| Bolt | Mystery Role | x | ✅ Active |\n| Anchor | Another | y | ✅ Active |\n`; + const members1 = parseTeamMdMembers(md); + const members2 = parseTeamMdMembers(md); + expect(members1.map(m => m.name)).toEqual(members2.map(m => m.name)); + }); +}); + +// ── parseRoutingMd ──────────────────────────────────────────────────────── + +const TYPICAL_ROUTING_MD = ` +# Squad Routing + +## Routing Table + +| Work Type | Agent | Examples | +|-----------|-------|---------| +| Core runtime | EECOM 🔧 | CopilotClient, adapter | +| TypeScript strictness | CONTROL 👩‍💻 | Discriminated unions | +| Security | RETRO 🔒 | Auth, secrets, PII | +| Tests & CI/CD | FIDO 🧪 | Vitest, coverage | +| Documentation | PAO 📣 | README, getting-started | +`; + +describe('parseRoutingMd', () => { + it('parses a typical routing table', () => { + const rows = parseRoutingMd(TYPICAL_ROUTING_MD); + expect(rows.length).toBeGreaterThanOrEqual(4); + const types = rows.map(r => r.workType); + expect(types).toContain('Core runtime'); + expect(types).toContain('TypeScript strictness'); + }); + + it('returns empty array for empty content', () => { + expect(parseRoutingMd('')).toEqual([]); + }); + + it('skips header rows', () => { + const rows = parseRoutingMd(TYPICAL_ROUTING_MD); + const types = rows.map(r => r.workType); + expect(types).not.toContain('Work Type'); + }); + + it('sanitizes cells containing pipe characters', () => { + const md = `| Work Type | Agent | Examples |\n|---|---|---|\n| do | this | a \u007C b |\n`; + const rows = parseRoutingMd(md); + if (rows.length > 0) { + expect(rows[0]?.examples).not.toContain('|'); + } + }); +}); + +// ── generateAWTeamContextBlock ──────────────────────────────────────────── + +describe('generateAWTeamContextBlock', () => { + it('returns the default placeholder when no members provided', () => { + const block = generateAWTeamContextBlock([]); + expect(block).toBe(TEAM_CONTEXT_DEFAULT); + }); + + it('includes all active members in specialist table', () => { + const members: ActiveMember[] = [ + { name: 'Alpha', role: 'Lead', status: '✅ Active' }, + { name: 'Beta', role: 'Tester', status: '✅ Active' }, + ]; + const block = generateAWTeamContextBlock(members); + expect(block).toContain('Alpha'); + expect(block).toContain('Beta'); + expect(block).toContain('Lead'); + }); + + it('is deterministic for identical input', () => { + const members: ActiveMember[] = [ + { name: 'X', role: 'Dev', status: '✅ Active' }, + { name: 'Y', role: 'QA', status: '✅ Active' }, + ]; + const a = generateAWTeamContextBlock(members, [], '2026-01-01T00:00:00.000Z'); + const b = generateAWTeamContextBlock(members, [], '2026-01-01T00:00:00.000Z'); + expect(a).toBe(b); + }); + + it('includes routing hints when routing rows provided', () => { + const members: ActiveMember[] = [{ name: 'A', role: 'Lead', status: '✅ Active' }]; + const rows: RoutingRow[] = [{ workType: 'TypeScript', agent: 'CONTROL' }]; + const block = generateAWTeamContextBlock(members, rows, '2026-01-01T00:00:00.000Z'); + expect(block).toContain('TypeScript'); + expect(block).toContain('CONTROL'); + }); + + it('includes capability boundaries section', () => { + const members: ActiveMember[] = [{ name: 'A', role: 'Lead', status: '✅ Active' }]; + const block = generateAWTeamContextBlock(members, [], '2026-01-01T00:00:00.000Z'); + expect(block).toContain('✅ **Can:**'); + expect(block).toContain('❌ **Cannot:**'); + }); + + it('authority boundaries always include refusal of direct default-branch push', () => { + const members: ActiveMember[] = [{ name: 'Anything', role: 'Any', status: '✅ Active' }]; + const block = generateAWTeamContextBlock(members, [], '2026-01-01T00:00:00.000Z'); + expect(block.toLowerCase()).toContain('default branch'); + }); + + it('authority boundaries always deny deploying to production', () => { + const members: ActiveMember[] = [{ name: 'Ops', role: 'DevOps', status: '✅ Active' }]; + const block = generateAWTeamContextBlock(members, [], '2026-01-01T00:00:00.000Z'); + expect(block.toLowerCase()).toContain('deploy to production'); + }); + + it('derived capabilities include documentation for a Docs role', () => { + const members: ActiveMember[] = [{ name: 'PAO', role: 'Docs & DevRel', status: '✅ Active' }]; + const block = generateAWTeamContextBlock(members, [], '2026-01-01T00:00:00.000Z'); + expect(block.toLowerCase()).toContain('documentation'); + }); + + it('limits routing hints table to at most 15 rows (prompt budget)', () => { + const members: ActiveMember[] = [{ name: 'X', role: 'Dev', status: '✅ Active' }]; + const manyRows: RoutingRow[] = Array.from({ length: 30 }, (_, i) => ({ + workType: `Type ${i}`, + agent: `Agent${i}`, + })); + const block = generateAWTeamContextBlock(members, manyRows, '2026-01-01T00:00:00.000Z'); + // Count how many | Type N | lines appear + const routingLines = block.split('\n').filter(l => l.startsWith('| Type ')); + expect(routingLines.length).toBeLessThanOrEqual(15); + }); + + it('sanitizes member names that contain Markdown injection', () => { + const members: ActiveMember[] = [ + { name: '## Injected Heading', role: 'Evil', status: '✅ Active' }, + ]; + const block = generateAWTeamContextBlock(members, [], '2026-01-01T00:00:00.000Z'); + expect(block).not.toContain('## Injected Heading'); + }); + + it('sanitizes member names that contain HTML comment injection', () => { + const members: ActiveMember[] = [ + { name: '', role: 'Hacker', status: '✅ Active' }, + ]; + const block = generateAWTeamContextBlock(members, [], '2026-01-01T00:00:00.000Z'); + expect(block).not.toContain(''); + }); + + it('sanitizes routing workType with code-fence injection attempt', () => { + const members: ActiveMember[] = [{ name: 'A', role: 'Dev', status: '✅ Active' }]; + const rows: RoutingRow[] = [{ workType: '```pwn```', agent: 'Malicious' }]; + const block = generateAWTeamContextBlock(members, rows, '2026-01-01T00:00:00.000Z'); + expect(block).not.toContain('```pwn```'); + }); +}); + +// ── injectTeamContext ───────────────────────────────────────────────────── + +describe('injectTeamContext', () => { + it('replaces the content between markers', () => { + const agentMd = makeAgentMdWithMarkers('OLD CONTENT\n'); + const newBlock = 'NEW CONTENT\n'; + const result = injectTeamContext(agentMd, newBlock); + expect(result).toContain('NEW CONTENT'); + expect(result).not.toContain('OLD CONTENT'); + }); + + it('preserves content before TEAM_CONTEXT_BEGIN', () => { + const agentMd = makeAgentMdWithMarkers('old\n'); + const result = injectTeamContext(agentMd, 'new\n'); + expect(result).toContain('# Squad Coordinator'); + }); + + it('preserves content after TEAM_CONTEXT_END', () => { + const agentMd = makeAgentMdWithMarkers('old\n'); + const result = injectTeamContext(agentMd, 'new\n'); + expect(result).toContain('## Init Mode'); + }); + + it('returns content unchanged when BEGIN marker is absent', () => { + const agentMd = '# No markers here\n'; + const result = injectTeamContext(agentMd, 'should not appear'); + expect(result).toBe(agentMd); + }); + + it('returns content unchanged when END marker is absent', () => { + const agentMd = `${TEAM_CONTEXT_BEGIN}\n# No end marker\n`; + const result = injectTeamContext(agentMd, 'should not appear'); + expect(result).toBe(agentMd); + }); + + it('is idempotent — injecting twice produces the same result as injecting once', () => { + const agentMd = makeAgentMdWithMarkers('initial\n'); + const block = 'stable block\n'; + const once = injectTeamContext(agentMd, block); + const twice = injectTeamContext(once, block); + expect(once).toBe(twice); + }); + + it('removes stale agent names when block is regenerated', () => { + const staleBlock = '| OldAgent | Retired | ✅ Active |\n'; + const agentMd = makeAgentMdWithMarkers(staleBlock); + const newBlock = '| NewAgent | Lead | ✅ Active |\n'; + const result = injectTeamContext(agentMd, newBlock); + expect(result).not.toContain('OldAgent'); + expect(result).toContain('NewAgent'); + }); +}); + +// ── buildAndInjectTeamContext (integration) ──────────────────────────────── + +describe('buildAndInjectTeamContext integration', () => { + it('produces default placeholder when squadDir has no team.md', () => { + // Pass a nonexistent path — the function handles missing files gracefully + const agentMd = makeAgentMdWithMarkers(); + const result = buildAndInjectTeamContext('/nonexistent/path/.squad', agentMd); + // With no team.md, result should contain the default placeholder text + expect(result).toContain(TEAM_CONTEXT_BEGIN); + expect(result).toContain(TEAM_CONTEXT_END); + }); +}); + +// ── Prompt budget ───────────────────────────────────────────────────────── + +describe('Prompt budget', () => { + it('full team context block does not exceed 4 KB', () => { + const members: ActiveMember[] = Array.from({ length: 20 }, (_, i) => ({ + name: `Agent${i}`, + role: `Role ${i}`, + status: '✅ Active', + })); + const rows: RoutingRow[] = Array.from({ length: 15 }, (_, i) => ({ + workType: `Task Type ${i}`, + agent: `Agent${i}`, + })); + const block = generateAWTeamContextBlock(members, rows, '2026-01-01T00:00:00.000Z'); + expect(Buffer.byteLength(block, 'utf8')).toBeLessThan(4096); + }); + + it('default placeholder is small', () => { + const block = generateAWTeamContextBlock([]); + expect(Buffer.byteLength(block, 'utf8')).toBeLessThan(300); + }); +}); From 5e22927a9da4218201cf24a6b3fb13c9b82996e8 Mon Sep 17 00:00:00 2001 From: brady gaster Date: Tue, 25 Aug 2026 10:10:19 -0700 Subject: [PATCH 2/5] fix(agent-context): address CONTROL+FIDO blocking review for #1608 - sanitizeField/sanitizeBlock: strip Markdown link destinations ([label](url) -> label, [label][ref] -> label) and Unicode bidi control characters (U+200E/F, U+202A-E, U+2066-9) and other dangerous invisible structural controls - sanitizeTimestamp: new internal helper that truncates at first --> (prevents HTML comment breakout) and strips (comment-close) to prevent comment + * breakout; strips to prevent HTML comment breakout — + // any text after --> would escape the comment into rendered output + const closeIdx = ts.indexOf('-->'); + const safe = closeIdx >= 0 ? ts.slice(0, closeIdx) : ts; + return safe + .replace(/`, + ``, '', ]; - const specialistTable = buildSpecialistTable(members); + const specialistTable = buildSpecialistTable(sortedMembers); if (specialistTable) { parts.push(specialistTable); parts.push(''); @@ -302,7 +347,7 @@ export function generateAWTeamContextBlock( parts.push(''); } - const capBoundaries = buildCapabilityBoundaries(members); + const capBoundaries = buildCapabilityBoundaries(sortedMembers); if (capBoundaries) { parts.push(capBoundaries); parts.push(''); @@ -353,8 +398,14 @@ export function injectTeamContext(agentMd: string, block: string): string { * * @param squadDir - Path to the `.squad/` directory * @param agentMd - Current content of squad.agent.md + * @param timestamp - Optional timestamp to embed; defaults to current time. + * Pass an existing timestamp to produce a stable output for comparison. */ -export function buildAndInjectTeamContext(squadDir: string, agentMd: string): string { +export function buildAndInjectTeamContext( + squadDir: string, + agentMd: string, + timestamp?: string, +): string { const teamMdPath = join(squadDir, 'team.md'); const routingMdPath = join(squadDir, 'routing.md'); @@ -379,16 +430,76 @@ export function buildAndInjectTeamContext(squadDir: string, agentMd: string): st // Routing is optional; proceed without it } - const block = generateAWTeamContextBlock(members, routingRows); + const block = generateAWTeamContextBlock(members, routingRows, timestamp); return injectTeamContext(agentMd, block); } +// ── Refresh helpers ──────────────────────────────────────────────────────── + +/** + * Extract the "last updated" timestamp string from an existing team context + * block embedded in squad.agent.md. + * + * Returns `undefined` if the timestamp comment is absent (e.g. new file or + * legacy template without the comment). + */ +export function extractBlockTimestamp(content: string): string | undefined { + const m = content.match( + //, + ); + return m?.[1]; +} + +/** + * Compute whether squad.agent.md needs to be rewritten and what the new + * content should be. Exported for direct unit testing without filesystem I/O. + * + * Logic: + * 1. If markers are absent → no-op (nothing to refresh) + * 2. Generate a candidate using the EXISTING timestamp (if any) so the + * comparison is timestamp-independent (semantic content only) + * 3. If candidate === current → no-op (team and routing are unchanged) + * 4. Otherwise → rebuild with a fresh timestamp and signal shouldWrite + * + * @param squadDir - `.squad/` directory used to load team.md / routing.md + * @param currentContent - Existing squad.agent.md content + * @param existingTimestamp - Timestamp extracted from the current block, or undefined + * @param nowTimestamp - Current time to use when a semantic change is detected + */ +export function _computeTeamContextRefresh( + squadDir: string, + currentContent: string, + existingTimestamp: string | undefined, + nowTimestamp: string, +): { shouldWrite: boolean; content: string } { + if (!currentContent.includes(TEAM_CONTEXT_BEGIN)) { + return { shouldWrite: false, content: currentContent }; + } + + // Use the existing timestamp for comparison so a time-only delta doesn't trigger a write + const tsForComparison = existingTimestamp ?? nowTimestamp; + const candidate = buildAndInjectTeamContext(squadDir, currentContent, tsForComparison); + + if (candidate === currentContent) { + // Semantic content unchanged — preserve existing file (no write) + return { shouldWrite: false, content: currentContent }; + } + + // Team or routing changed — rebuild with fresh timestamp + const updated = buildAndInjectTeamContext(squadDir, currentContent, nowTimestamp); + return { shouldWrite: true, content: updated }; +} + /** * Read, update, and write the team context section of an existing * squad.agent.md file. * - * If the file does not exist or the markers are absent, this is a no-op - * (no file is created or corrupted). + * No-op semantics: + * - Returns without writing when the file does not exist + * - Returns without writing when the markers are absent (legacy install) + * - Returns without writing when the semantic team content is unchanged + * (timestamp is excluded from the comparison so the file is not dirtied + * on every call) * * @param squadDir - Path to the `.squad/` directory * @param agentMdPath - Full path to the squad.agent.md file to update @@ -400,10 +511,17 @@ export function refreshTeamContextInAgentFile( if (!storage.existsSync(agentMdPath)) return; const current = storage.readSync(agentMdPath) ?? ''; - if (!current.includes(TEAM_CONTEXT_BEGIN)) return; - - const updated = buildAndInjectTeamContext(squadDir, current); - if (updated !== current) { - storage.writeSync(agentMdPath, updated); + const existingTs = extractBlockTimestamp(current); + const now = new Date().toISOString(); + + const { shouldWrite, content } = _computeTeamContextRefresh( + squadDir, + current, + existingTs, + now, + ); + + if (shouldWrite) { + storage.writeSync(agentMdPath, content); } } diff --git a/test/squad-agent-aw-context.test.ts b/test/squad-agent-aw-context.test.ts index 7b79f0c9f..f290e52c4 100644 --- a/test/squad-agent-aw-context.test.ts +++ b/test/squad-agent-aw-context.test.ts @@ -21,6 +21,9 @@ import { generateAWTeamContextBlock, injectTeamContext, buildAndInjectTeamContext, + extractBlockTimestamp, + _computeTeamContextRefresh, + refreshTeamContextInAgentFile, TEAM_CONTEXT_BEGIN, TEAM_CONTEXT_END, TEAM_CONTEXT_DEFAULT, @@ -76,6 +79,28 @@ describe('sanitizeField', () => { const long = 'a'.repeat(300); expect(sanitizeField(long).length).toBeLessThanOrEqual(200); }); + + it('strips Markdown inline link destination, preserving the label', () => { + const result = sanitizeField('[Visit Site](https://evil.example.com/inject)'); + expect(result).toBe('Visit Site'); + expect(result).not.toContain('https://'); + expect(result).not.toContain('evil.example.com'); + }); + + it('strips Markdown reference link destination, preserving the label', () => { + const result = sanitizeField('[My Label][ref1]'); + expect(result).toBe('My Label'); + expect(result).not.toContain('[ref1]'); + }); + + it('strips Unicode bidi control characters (LTR/RTL marks, overrides, isolates)', () => { + // U+200E LTR mark, U+200F RTL mark, U+202A LTR embedding, U+202E RTL override, U+2066 LTR isolate + const withBidi = 'EECOM\u200E\u200F\u202A\u202E\u2066 Core Dev'; + const result = sanitizeField(withBidi); + expect(result).not.toMatch(/[\u200E\u200F\u202A-\u202E\u2066-\u2069]/); + expect(result).toContain('EECOM'); + expect(result).toContain('Core Dev'); + }); }); // ── sanitizeBlock ───────────────────────────────────────────────────────── @@ -102,6 +127,19 @@ describe('sanitizeBlock', () => { const long = 'x'.repeat(2500); expect(sanitizeBlock(long).length).toBeLessThanOrEqual(2000); }); + + it('strips inline link destinations from blocks', () => { + const result = sanitizeBlock('See [the docs](https://evil.example.com) for details.'); + expect(result).toContain('the docs'); + expect(result).not.toContain('https://evil.example.com'); + }); + + it('strips Unicode bidi control characters from blocks', () => { + const withBidi = 'Normal\u202Etext\u200Fwith\u2066bidi'; + const result = sanitizeBlock(withBidi); + expect(result).not.toMatch(/[\u200E\u200F\u202A-\u202E\u2066-\u2069]/); + expect(result).toContain('Normal'); + }); }); // ── parseTeamMdMembers ──────────────────────────────────────────────────── @@ -325,8 +363,14 @@ describe('generateAWTeamContextBlock', () => { { name: '', role: 'Hacker', status: '✅ Active' }, ]; const block = generateAWTeamContextBlock(members, [], '2026-01-01T00:00:00.000Z'); - expect(block).not.toContain(''); + // The verbatim injection string must not appear + expect(block).not.toContain(''); + // The member table row specifically must not contain raw HTML comment syntax + // (the block's auto-generated timestamp comment legitimately uses ) + const memberLine = block.split('\n').find(l => l.includes('Hacker')); + expect(memberLine).toBeDefined(); + expect(memberLine).not.toContain(''); }); it('sanitizes routing workType with code-fence injection attempt', () => { @@ -335,6 +379,56 @@ describe('generateAWTeamContextBlock', () => { const block = generateAWTeamContextBlock(members, rows, '2026-01-01T00:00:00.000Z'); expect(block).not.toContain('```pwn```'); }); + + it('specialist table is sorted alphabetically by name regardless of input order', () => { + const members: ActiveMember[] = [ + { name: 'Zeta', role: 'Dev', status: '✅ Active' }, + { name: 'Alpha', role: 'QA', status: '✅ Active' }, + { name: 'Mango', role: 'Ops', status: '✅ Active' }, + ]; + const block = generateAWTeamContextBlock(members, [], '2026-01-01T00:00:00.000Z'); + const alphaIdx = block.indexOf('| Alpha'); + const mangoIdx = block.indexOf('| Mango'); + const zetaIdx = block.indexOf('| Zeta'); + expect(alphaIdx).toBeGreaterThan(-1); + expect(alphaIdx).toBeLessThan(mangoIdx); + expect(mangoIdx).toBeLessThan(zetaIdx); + }); + + it('routing hints table preserves source order (not sorted)', () => { + const members: ActiveMember[] = [{ name: 'A', role: 'Dev', status: '✅ Active' }]; + const rows: RoutingRow[] = [ + { workType: 'Zebra task', agent: 'EECOM' }, + { workType: 'Alpha task', agent: 'CONTROL' }, + ]; + const block = generateAWTeamContextBlock(members, rows, '2026-01-01T00:00:00.000Z'); + const zebraIdx = block.indexOf('Zebra task'); + const alphaIdx = block.indexOf('Alpha task'); + expect(zebraIdx).toBeGreaterThan(-1); + // Zebra appears before Alpha because routing.md source order is preserved + expect(zebraIdx).toBeLessThan(alphaIdx); + }); + + it('routing hints table shows multiple rows for the same agent (no dedup)', () => { + const members: ActiveMember[] = [{ name: 'A', role: 'Dev', status: '✅ Active' }]; + const rows: RoutingRow[] = [ + { workType: 'Core runtime', agent: 'EECOM' }, + { workType: 'CLI commands', agent: 'EECOM' }, + { workType: 'TypeScript', agent: 'CONTROL' }, + ]; + const block = generateAWTeamContextBlock(members, rows, '2026-01-01T00:00:00.000Z'); + expect(block).toContain('Core runtime'); + expect(block).toContain('CLI commands'); + expect(block).toContain('TypeScript'); + }); + + it('sanitizes a timestamp containing --> that would break the HTML comment', () => { + const members: ActiveMember[] = [{ name: 'A', role: 'Dev', status: '✅ Active' }]; + const maliciousTs = '2026-01-01 --> injected-attack-text'; + const block = generateAWTeamContextBlock(members, [], maliciousTs); + // The attack text following --> should have been stripped along with the --> + expect(block).not.toContain('injected-attack-text'); + }); }); // ── injectTeamContext ───────────────────────────────────────────────────── @@ -400,6 +494,20 @@ describe('buildAndInjectTeamContext integration', () => { // With no team.md, result should contain the default placeholder text expect(result).toContain(TEAM_CONTEXT_BEGIN); expect(result).toContain(TEAM_CONTEXT_END); + // Strengthen: assert the actual placeholder block is present + expect(result).toContain(TEAM_CONTEXT_DEFAULT.trim()); + expect(result).toContain('No team configured yet'); + expect(result).toContain('squad cast'); + }); + + it('accepts an explicit timestamp and embeds it stably', () => { + const agentMd = makeAgentMdWithMarkers(); + const ts = '2026-06-01T12:00:00.000Z'; + const result = buildAndInjectTeamContext('/nonexistent/path/.squad', agentMd, ts); + // With empty team.md the block is the default placeholder (no timestamp comment) + // and the result should be stable across calls with same timestamp + const result2 = buildAndInjectTeamContext('/nonexistent/path/.squad', agentMd, ts); + expect(result).toBe(result2); }); }); @@ -425,3 +533,89 @@ describe('Prompt budget', () => { expect(Buffer.byteLength(block, 'utf8')).toBeLessThan(300); }); }); + +// ── extractBlockTimestamp ────────────────────────────────────────────────── + +describe('extractBlockTimestamp', () => { + it('extracts the timestamp from a well-formed comment', () => { + const ts = '2026-01-15T08:30:00.000Z'; + const content = `# Squad\n\n${TEAM_CONTEXT_BEGIN}\n\nsome content\n${TEAM_CONTEXT_END}\n`; + expect(extractBlockTimestamp(content)).toBe(ts); + }); + + it('returns undefined when no timestamp comment is present', () => { + const content = `# Squad\n\n${TEAM_CONTEXT_BEGIN}\nsome content without comment\n${TEAM_CONTEXT_END}\n`; + expect(extractBlockTimestamp(content)).toBeUndefined(); + }); + + it('returns undefined for empty string', () => { + expect(extractBlockTimestamp('')).toBeUndefined(); + }); +}); + +// ── _computeTeamContextRefresh ───────────────────────────────────────────── + +describe('_computeTeamContextRefresh', () => { + const STABLE_TS = '2026-01-01T00:00:00.000Z'; + + it('no-op: shouldWrite=false when markers are absent', () => { + const content = '# Squad Coordinator\n\nNo markers here.\n'; + const result = _computeTeamContextRefresh('/nonexistent/.squad', content, undefined, STABLE_TS); + expect(result.shouldWrite).toBe(false); + expect(result.content).toBe(content); + }); + + it('no-op: shouldWrite=false when semantic content is unchanged', () => { + // Build a "stable" current content from empty squad dir (produces TEAM_CONTEXT_DEFAULT block) + const agentMd = makeAgentMdWithMarkers(); + const currentContent = buildAndInjectTeamContext('/nonexistent/.squad', agentMd, STABLE_TS); + // Refreshing with the same timestamp and same empty squad dir → no semantic change + const result = _computeTeamContextRefresh('/nonexistent/.squad', currentContent, STABLE_TS, STABLE_TS); + expect(result.shouldWrite).toBe(false); + }); + + it('no-op: shouldWrite=false even when nowTimestamp differs if semantic content is unchanged', () => { + const agentMd = makeAgentMdWithMarkers(); + const currentContent = buildAndInjectTeamContext('/nonexistent/.squad', agentMd, STABLE_TS); + // Different "now" timestamp, but semantic content identical — should not write + const result = _computeTeamContextRefresh( + '/nonexistent/.squad', + currentContent, + STABLE_TS, + '2099-12-31T23:59:59.000Z', + ); + expect(result.shouldWrite).toBe(false); + }); + + it('write: shouldWrite=true when existing block differs from freshly generated content', () => { + // Build stale content: markers with OldAgent (not what empty squadDir would generate) + const staleContent = makeAgentMdWithMarkers('| OldAgent | Retired Role | ✅ Active |\n'); + // With no team.md, buildAndInjectTeamContext generates TEAM_CONTEXT_DEFAULT + // which differs from the OldAgent table → semantic change detected + const result = _computeTeamContextRefresh('/nonexistent/.squad', staleContent, undefined, STABLE_TS); + expect(result.shouldWrite).toBe(true); + // The new content should contain the default placeholder, not OldAgent + expect(result.content).toContain('No team configured yet'); + expect(result.content).not.toContain('OldAgent'); + }); + + it('write: result content uses the provided nowTimestamp, not existingTimestamp', () => { + const staleContent = makeAgentMdWithMarkers('| OldAgent | Retired | ✅ Active |\n'); + const freshTs = '2026-08-25T10:00:00.000Z'; + const result = _computeTeamContextRefresh('/nonexistent/.squad', staleContent, STABLE_TS, freshTs); + expect(result.shouldWrite).toBe(true); + }); +}); + +// ── refreshTeamContextInAgentFile (file-level no-op paths) ──────────────── + +describe('refreshTeamContextInAgentFile', () => { + it('is a no-op and does not throw when file does not exist', () => { + expect(() => + refreshTeamContextInAgentFile( + '/nonexistent/.squad', + '/nonexistent/squad.agent.md', + ), + ).not.toThrow(); + }); +}); From c731bbbd2370a97af785f517c956bd2b56d808c9 Mon Sep 17 00:00:00 2001 From: brady gaster Date: Tue, 25 Aug 2026 10:31:12 -0700 Subject: [PATCH 3/5] fix(agent-context): correct extractBlockTimestamp regex and strengthen refresh tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix extractBlockTimestamp: replace [^.]+ with non-greedy (.+?) so ISO timestamps with fractional milliseconds (e.g. 2026-08-25T10:19:28.963Z) round-trip correctly. The old [^.]+ stopped at the first dot, causing the entire match to fail and extractBlockTimestamp to return undefined, which broke _computeTeamContextRefresh no-op semantics (the undefined existingTimestamp fell back to nowTimestamp, always producing a write). - Replace localeCompare with plain < / > string comparison in the generateAWTeamContextBlock sort so specialist table ordering is deterministic across all system locales. - Add extractBlockTimestamp round-trip tests (with and without fractional seconds) to guard against regex regressions. - Add filesystem round-trip tests for _computeTeamContextRefresh that write a real team.md, build content via buildAndInjectTeamContext, extract the embedded timestamp, and feed it back into refresh — proving the no-op path without pre-handing an already-known timestamp to a helper. - Add locale-independent sort stability test. Closes #1608 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../squad-cli/src/cli/core/agent-context.ts | 19 ++- test/squad-agent-aw-context.test.ts | 129 +++++++++++++++++- 2 files changed, 142 insertions(+), 6 deletions(-) diff --git a/packages/squad-cli/src/cli/core/agent-context.ts b/packages/squad-cli/src/cli/core/agent-context.ts index 3065fcac3..5f7cd8514 100644 --- a/packages/squad-cli/src/cli/core/agent-context.ts +++ b/packages/squad-cli/src/cli/core/agent-context.ts @@ -325,10 +325,14 @@ export function generateAWTeamContextBlock( return TEAM_CONTEXT_DEFAULT; } - // Sort alphabetically by name for deterministic output regardless of team.md order - const sortedMembers = [...members].sort((a, b) => - a.name.toLowerCase().localeCompare(b.name.toLowerCase()), - ); + // Sort alphabetically by name for deterministic output regardless of team.md order. + // Uses plain string comparison (not localeCompare) so ordering is locale-independent + // and identical on every machine regardless of system locale settings. + const sortedMembers = [...members].sort((a, b) => { + const an = a.name.toLowerCase(); + const bn = b.name.toLowerCase(); + return an < bn ? -1 : an > bn ? 1 : 0; + }); const parts: string[] = [ ``, @@ -444,8 +448,13 @@ export function buildAndInjectTeamContext( * legacy template without the comment). */ export function extractBlockTimestamp(content: string): string | undefined { + // Non-greedy (.+?) so the match extends past any dots in fractional seconds + // (e.g. "...T10:19:28.963Z") and stops only at the first ". Do not edit manually" suffix. + // The previous [^.]+ would stop at the first dot, causing the match to fail entirely + // for any ISO timestamp that includes milliseconds, making extractBlockTimestamp return + // undefined and breaking _computeTeamContextRefresh no-op semantics. const m = content.match( - //, + //, ); return m?.[1]; } diff --git a/test/squad-agent-aw-context.test.ts b/test/squad-agent-aw-context.test.ts index f290e52c4..a60112106 100644 --- a/test/squad-agent-aw-context.test.ts +++ b/test/squad-agent-aw-context.test.ts @@ -12,7 +12,10 @@ * - Prompt budget: generated block must not exceed a reasonable byte ceiling */ -import { describe, it, expect } from 'vitest'; +import { mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, describe, it, expect } from 'vitest'; import { sanitizeField, sanitizeBlock, @@ -37,6 +40,31 @@ function makeAgentMdWithMarkers(content = TEAM_CONTEXT_DEFAULT): string { return `# Squad Coordinator\n\n${TEAM_CONTEXT_BEGIN}\n${content}\n${TEAM_CONTEXT_END}\n\n## Init Mode\n`; } +// ── Filesystem helpers for refresh-path tests ───────────────────────────── +// These are used only in the filesystem round-trip test suite below. +// Files land in test/.agent-ctx-tmp/ and are cleaned up in afterAll. + +const _testTmpBase = join(dirname(fileURLToPath(import.meta.url)), '.agent-ctx-tmp'); +const _tmpDirs: string[] = []; + +afterAll(() => { + for (const d of _tmpDirs) { + rmSync(d, { recursive: true, force: true }); + } + rmSync(_testTmpBase, { recursive: true, force: true }); +}); + +function makeTempSquadDir(teamMd?: string, routingMd?: string): string { + mkdirSync(_testTmpBase, { recursive: true }); + const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`; + const dir = join(_testTmpBase, id); + mkdirSync(dir, { recursive: true }); + if (teamMd !== undefined) writeFileSync(join(dir, 'team.md'), teamMd, 'utf8'); + if (routingMd !== undefined) writeFileSync(join(dir, 'routing.md'), routingMd, 'utf8'); + _tmpDirs.push(dir); + return dir; +} + // ── sanitizeField ───────────────────────────────────────────────────────── describe('sanitizeField', () => { @@ -619,3 +647,102 @@ describe('refreshTeamContextInAgentFile', () => { ).not.toThrow(); }); }); + +// ── extractBlockTimestamp round-trip ────────────────────────────────────── +// These prove the regex correctly handles ISO timestamps with fractional +// milliseconds — the old [^.]+ pattern stopped at the first dot and caused +// the entire match to fail, making extractBlockTimestamp return undefined. + +describe('extractBlockTimestamp round-trip', () => { + it('round-trips an ISO timestamp with fractional milliseconds (.963Z)', () => { + const ts = '2026-08-25T10:19:28.963Z'; + const members: ActiveMember[] = [{ name: 'CONTROL', role: 'TypeScript', status: '✅ Active' }]; + const block = generateAWTeamContextBlock(members, [], ts); + const agentMd = makeAgentMdWithMarkers(block); + // Old regex [^.]+ would return undefined here; fixed regex (.+?) returns ts + expect(extractBlockTimestamp(agentMd)).toBe(ts); + }); + + it('round-trips an ISO timestamp without fractional seconds (no dot before Z)', () => { + const ts = '2026-01-01T00:00:00Z'; + const members: ActiveMember[] = [{ name: 'X', role: 'Dev', status: '✅ Active' }]; + const block = generateAWTeamContextBlock(members, [], ts); + const agentMd = makeAgentMdWithMarkers(block); + expect(extractBlockTimestamp(agentMd)).toBe(ts); + }); +}); + +// ── _computeTeamContextRefresh (filesystem round-trip with populated team) ─ +// These tests write a real team.md, build content via buildAndInjectTeamContext +// (which reads it from disk), then extract the timestamp from the built content +// and feed it back into _computeTeamContextRefresh — proving the full +// extract→compare path without pre-handing an already-known timestamp to a helper. + +describe('_computeTeamContextRefresh (filesystem round-trip with populated team)', () => { + const FRACTIONAL_TS = '2026-08-25T10:19:28.963Z'; + + it('no-op: populated team.md with fractional timestamp — extract+refresh proves no write', () => { + const squadDir = makeTempSquadDir(TYPICAL_TEAM_MD); + const initial = makeAgentMdWithMarkers(); + + // Build agent file content from real filesystem team.md + const built = buildAndInjectTeamContext(squadDir, initial, FRACTIONAL_TS); + + // The full ISO timestamp (including .963Z) must survive the round-trip + const extracted = extractBlockTimestamp(built); + expect(extracted).toBe(FRACTIONAL_TS); + + // Refresh with the extracted timestamp; different nowTimestamp but same team.md + // → semantic content unchanged → shouldWrite must be false + const result = _computeTeamContextRefresh(squadDir, built, extracted, '2099-12-31T23:59:59.000Z'); + expect(result.shouldWrite).toBe(false); + expect(result.content).toBe(built); + }); + + it('write: adding a member is detected; result contains new member and fresh timestamp', () => { + const oneMemMd = + `## Members\n\n| Name | Role | Charter | Status |\n|------|------|---------|--------|\n` + + `| EECOM | Core Dev | x | ✅ Active |\n`; + const squadDir = makeTempSquadDir(oneMemMd); + const initial = makeAgentMdWithMarkers(); + + const built = buildAndInjectTeamContext(squadDir, initial, FRACTIONAL_TS); + expect(built).toContain('EECOM'); + + // Mutate team.md to add a second member + writeFileSync( + join(squadDir, 'team.md'), + oneMemMd + `| CONTROL | TypeScript | y | ✅ Active |\n`, + 'utf8', + ); + + const newTs = '2099-01-01T00:00:00.000Z'; + const result = _computeTeamContextRefresh(squadDir, built, FRACTIONAL_TS, newTs); + + expect(result.shouldWrite).toBe(true); + expect(result.content).toContain('CONTROL'); + // The fresh timestamp (not the original fractional one) must be embedded + expect(extractBlockTimestamp(result.content)).toBe(newTs); + }); +}); + +// ── Locale-independent sort ─────────────────────────────────────────────── + +describe('generateAWTeamContextBlock sort stability (locale-independent)', () => { + it('specialist table order is stable with plain string comparison, not localeCompare', () => { + // Use lowercase names whose plain-string sort order matches obvious ASCII order: + // alpha < mango < zeta regardless of locale + const members: ActiveMember[] = [ + { name: 'zeta', role: 'Dev', status: '✅ Active' }, + { name: 'alpha', role: 'QA', status: '✅ Active' }, + { name: 'mango', role: 'Ops', status: '✅ Active' }, + ]; + const block = generateAWTeamContextBlock(members, [], '2026-01-01T00:00:00.000Z'); + const alphaIdx = block.indexOf('| alpha'); + const mangoIdx = block.indexOf('| mango'); + const zetaIdx = block.indexOf('| zeta'); + expect(alphaIdx).toBeGreaterThan(-1); + expect(alphaIdx).toBeLessThan(mangoIdx); + expect(mangoIdx).toBeLessThan(zetaIdx); + }); +}); From 5bb0a8c479390e7703712914ec3c2de03d3d1993 Mon Sep 17 00:00:00 2001 From: brady gaster Date: Tue, 25 Aug 2026 10:41:11 -0700 Subject: [PATCH 4/5] fix: sanitize member names and routing fields at embedding point in context builders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit generateAWTeamContextBlock delegates to buildSpecialistTable and buildRoutingHintsTable, which embedded ActiveMember.name/role/status and RoutingRow.workType/agent verbatim. sanitizeField was only called in the parsers (parseTeamMdMembers, parseRoutingMd), so callers that construct ActiveMember/RoutingRow objects directly — including tests and any future programmatic callers — bypassed sanitization. Fix: call sanitizeField at the embedding point in each builder. sanitizeField is idempotent for already-sanitized data, so the parsers' pre-sanitization is unaffected. Closes #1608 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- packages/squad-cli/src/cli/core/agent-context.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/squad-cli/src/cli/core/agent-context.ts b/packages/squad-cli/src/cli/core/agent-context.ts index 5f7cd8514..65697113e 100644 --- a/packages/squad-cli/src/cli/core/agent-context.ts +++ b/packages/squad-cli/src/cli/core/agent-context.ts @@ -240,7 +240,7 @@ function buildRoutingHintsTable(rows: RoutingRow[]): string { // Limit to 15 routing hints to keep the section concise const shown = rows.slice(0, 15); for (const row of shown) { - lines.push(`| ${row.workType} | ${row.agent} |`); + lines.push(`| ${sanitizeField(row.workType)} | ${sanitizeField(row.agent)} |`); } return lines.join('\n'); @@ -260,7 +260,7 @@ function buildSpecialistTable(members: ActiveMember[]): string { ]; for (const m of members) { - lines.push(`| ${m.name} | ${m.role} | ${m.status} |`); + lines.push(`| ${sanitizeField(m.name)} | ${sanitizeField(m.role)} | ${sanitizeField(m.status)} |`); } return lines.join('\n'); From ceeebd57b60226e81118987744942ed6954d6ef9 Mon Sep 17 00:00:00 2001 From: brady gaster Date: Tue, 25 Aug 2026 11:00:24 -0700 Subject: [PATCH 5/5] fix(agent-context): address Copilot review threads for #1608 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Thread PRRT_kwDORJ3-vM6cKFV5: remove hardcoded cast names (booster/capcom/control/eecom/flight/surgeon) from buildCapabilityBoundaries regexes; capability inference now uses only generic role keywords so any custom team gets correct output. - Thread PRRT_kwDORJ3-vM6cKFWZ: add blacklist-based status filtering to parseTeamMdMembers; rows with 'retired'/'disabled'/'inactive'/'alumni' status are excluded; all other statuses (including custom/unknown) are retained so non-standard teams are not silently dropped. Update JSDoc to reflect blacklist contract. Export isActiveStatus logic is documented. - Thread PRRT_kwDORJ3-vM6cKFWx: make parseTeamMdMembers sanitizer test unconditional — assert members.length===1 before asserting name content. - Thread PRRT_kwDORJ3-vM6cKFXM: make parseRoutingMd pipe-sanitizer test unconditional — assert rows.length===1 before asserting examples content. - Add parseTeamMdMembers status filtering describe block with it.each over all built-in status forms, known-inactive exclusions, custom/unknown retention, mixed-team counts, and determinism proof. - Add generateAWTeamContextBlock capability portability describe block proving generic role keywords (not cast names) drive derivation. Closes #1608 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../squad-cli/src/cli/core/agent-context.ts | 43 ++++-- test/squad-agent-aw-context.test.ts | 141 +++++++++++++++++- 2 files changed, 169 insertions(+), 15 deletions(-) diff --git a/packages/squad-cli/src/cli/core/agent-context.ts b/packages/squad-cli/src/cli/core/agent-context.ts index 65697113e..c06db6ecb 100644 --- a/packages/squad-cli/src/cli/core/agent-context.ts +++ b/packages/squad-cli/src/cli/core/agent-context.ts @@ -134,6 +134,28 @@ export interface RoutingRow { // ── Parsers ──────────────────────────────────────────────────────────────── +/** + * Normalized status values that explicitly signal a member is no longer active. + * Matched case-insensitively as whole words so "reactivated" is not excluded. + * + * Strategy: blacklist-based filtering. Only the patterns below are excluded; + * all other status values — including custom, unknown, or empty — are retained + * so that non-standard teams are not silently dropped from context generation. + */ +const INACTIVE_STATUS_RE = /\b(?:retired|disabled|inactive|alumni)\b/i; + +/** + * Returns `true` when a status value should be treated as active (member + * appears in the generated AW team context block). + * + * Unknown or custom statuses are intentionally retained — the contract is + * "exclude known-inactive" not "include only known-active", which prevents + * silently dropping valid members from custom teams. + */ +function isActiveStatus(status: string): boolean { + return !INACTIVE_STATUS_RE.test(status); +} + /** * Extract active members from a team.md `## Members` table. * @@ -141,8 +163,11 @@ export interface RoutingRow { * | Name | Role | Charter | Status | * * Rules: - * - Only rows with status that includes "Active", "Silent", "Monitor", "RAI", - * or any emoji status are included (skips headers and separators) + * - Rows whose status explicitly signals retirement, deactivation, or alumni + * status (case-insensitive: "retired", "disabled", "inactive", "alumni") are + * excluded; all other statuses — including the built-in active forms + * ("Active", "Silent", "Monitor", "RAI", "Coding Agent") and any custom or + * unknown status — are retained so non-standard teams are not silently dropped * - Coordinator row ("Squad | Coordinator") is excluded * - Rows are returned in the order they appear (source-stable); * generateAWTeamContextBlock applies alphabetical sort for deterministic output @@ -174,7 +199,7 @@ export function parseTeamMdMembers(content: string): ActiveMember[] { const role = sanitizeField(cells[1] ?? ''); const status = sanitizeField(cells[3] ?? cells[2] ?? '✅ Active'); - if (name && role) { + if (name && role && isActiveStatus(status)) { members.push({ name, role, status }); } } @@ -280,15 +305,15 @@ function buildCapabilityBoundaries(members: ActiveMember[]): string { if (/test|qa|quality/.test(r)) roleLabels.push('test authoring'); if (/security|auth/.test(r)) roleLabels.push('security audit'); if (/docs|devrel|writer|handbook/.test(r)) roleLabels.push('documentation'); - if (/devops|infra|platform|ci|booster/.test(r)) roleLabels.push('CI/CD configuration'); - if (/sdk|capcom/.test(r)) roleLabels.push('SDK integration'); - if (/typescript|control/.test(r)) roleLabels.push('TypeScript engineering'); - if (/runtime|core|eecom/.test(r)) roleLabels.push('runtime implementation'); + if (/devops|infra|platform|ci/.test(r)) roleLabels.push('CI/CD configuration'); + if (/sdk/.test(r)) roleLabels.push('SDK integration'); + if (/typescript/.test(r)) roleLabels.push('TypeScript engineering'); + if (/runtime|core/.test(r)) roleLabels.push('runtime implementation'); if (/observ|telemetry|aspire/.test(r)) roleLabels.push('observability'); - if (/release|surgeon/.test(r)) roleLabels.push('release management'); + if (/release/.test(r)) roleLabels.push('release management'); if (/distribution|network/.test(r)) roleLabels.push('package distribution'); if (/ux|design|inco|tui|dsky|vox/.test(r)) roleLabels.push('UI/UX implementation'); - if (/review|flight/.test(r)) roleLabels.push('code review'); + if (/review/.test(r)) roleLabels.push('code review'); } // Always include core capabilities (apply to any Squad team) diff --git a/test/squad-agent-aw-context.test.ts b/test/squad-agent-aw-context.test.ts index a60112106..7b27ec0e0 100644 --- a/test/squad-agent-aw-context.test.ts +++ b/test/squad-agent-aw-context.test.ts @@ -239,9 +239,8 @@ describe('parseTeamMdMembers', () => { it('sanitizes names that contain Markdown injection', () => { const md = `## Members\n\n| ## Injected | Hacker | x | ✅ Active |\n`; const members = parseTeamMdMembers(md); - if (members.length > 0) { - expect(members[0]?.name).not.toContain('##'); - } + expect(members.length).toBe(1); + expect(members[0]?.name).not.toContain('##'); }); it('handles a minimal single-member team', () => { @@ -260,6 +259,86 @@ describe('parseTeamMdMembers', () => { }); }); +// ── parseTeamMdMembers — status filtering ───────────────────────────────── +// Verifies blacklist-based exclusion of known-inactive statuses while +// retaining custom/unknown statuses and all built-in active forms. + +describe('parseTeamMdMembers status filtering', () => { + /** Build a minimal Members section with one row per entry. */ + function makeStatusMd(rows: Array<{ name: string; status: string }>): string { + const header = + '## Members\n\n| Name | Role | Charter | Status |\n|------|------|---------|--------|\n'; + const body = rows.map(r => `| ${r.name} | Dev | x | ${r.status} |\n`).join(''); + return header + body; + } + + it.each([ + ['✅ Active', 1], + ['Active', 1], + ['Silent', 1], + ['🔇 Silent', 1], + ['Monitor', 1], + ['RAI', 1], + ['Coding Agent', 1], + ['custom-status-xyz', 1], // unknown: retained, not silently dropped + ['Retired', 0], + ['Disabled', 0], + ['Inactive', 0], + ['Alumni', 0], + ])('status %j → member count %i', (status, expectedCount) => { + const md = makeStatusMd([{ name: 'Agent', status }]); + const members = parseTeamMdMembers(md); + expect(members.length).toBe(expectedCount); + }); + + it('excludes multiple retired/disabled members while keeping active ones', () => { + const md = makeStatusMd([ + { name: 'Active1', status: '✅ Active' }, + { name: 'Retired1', status: 'Retired' }, + { name: 'Active2', status: 'Silent' }, + { name: 'Disabled1', status: 'Disabled' }, + { name: 'Active3', status: 'Monitor' }, + { name: 'Inactive1', status: 'Inactive' }, + ]); + const members = parseTeamMdMembers(md); + expect(members.length).toBe(3); + const names = members.map(m => m.name); + expect(names).toContain('Active1'); + expect(names).toContain('Active2'); + expect(names).toContain('Active3'); + expect(names).not.toContain('Retired1'); + expect(names).not.toContain('Disabled1'); + expect(names).not.toContain('Inactive1'); + }); + + it('unknown custom status is retained (not silently dropped)', () => { + const md = makeStatusMd([{ name: 'CustomAgent', status: 'Provisioning' }]); + const members = parseTeamMdMembers(md); + expect(members.length).toBe(1); + expect(members[0]?.name).toBe('CustomAgent'); + }); + + it('rows with no status column default to included (treated as active)', () => { + // 2-cell rows have no status cell; status defaults to "✅ Active" + const md = `## Members\n\n| Solo | Dev |\n`; + const members = parseTeamMdMembers(md); + expect(members.length).toBe(1); + expect(members[0]?.name).toBe('Solo'); + }); + + it('filtering is deterministic: same input always yields same result', () => { + const md = makeStatusMd([ + { name: 'A', status: '✅ Active' }, + { name: 'B', status: 'Retired' }, + { name: 'C', status: 'Monitor' }, + ]); + const r1 = parseTeamMdMembers(md).map(m => m.name); + const r2 = parseTeamMdMembers(md).map(m => m.name); + expect(r1).toEqual(r2); + expect(r1).toEqual(['A', 'C']); + }); +}); + // ── parseRoutingMd ──────────────────────────────────────────────────────── const TYPICAL_ROUTING_MD = ` @@ -298,9 +377,8 @@ describe('parseRoutingMd', () => { it('sanitizes cells containing pipe characters', () => { const md = `| Work Type | Agent | Examples |\n|---|---|---|\n| do | this | a \u007C b |\n`; const rows = parseRoutingMd(md); - if (rows.length > 0) { - expect(rows[0]?.examples).not.toContain('|'); - } + expect(rows.length).toBe(1); + expect(rows[0]?.examples).not.toContain('|'); }); }); @@ -459,6 +537,57 @@ describe('generateAWTeamContextBlock', () => { }); }); +// ── Capability boundary portability ─────────────────────────────────────── +// Verifies that capability derivation uses only generic role keywords, never +// hardcoded default cast names, so any custom team gets correct output. + +describe('generateAWTeamContextBlock capability portability', () => { + it('custom/unknown role produces valid boundaries with static cannot-section intact', () => { + const members: ActiveMember[] = [ + { name: 'CustomAgent', role: 'Mystery Role', status: '✅ Active' }, + ]; + const block = generateAWTeamContextBlock(members, [], '2026-01-01T00:00:00.000Z'); + expect(block).toContain('✅ **Can:**'); + expect(block.toLowerCase()).toContain('deploy to production'); + expect(block.toLowerCase()).toContain('default branch'); + }); + + it('generic TypeScript role derives capability without requiring "control" in role text', () => { + const members: ActiveMember[] = [ + { name: 'AnyAgent', role: 'TypeScript engineer', status: '✅ Active' }, + ]; + const block = generateAWTeamContextBlock(members, [], '2026-01-01T00:00:00.000Z'); + expect(block.toLowerCase()).toContain('typescript engineering'); + }); + + it('generic SDK role derives capability without requiring "capcom" in role text', () => { + const members: ActiveMember[] = [ + { name: 'AnyAgent', role: 'SDK developer', status: '✅ Active' }, + ]; + const block = generateAWTeamContextBlock(members, [], '2026-01-01T00:00:00.000Z'); + expect(block.toLowerCase()).toContain('sdk integration'); + }); + + it('generic CI/CD role derives capability without requiring "booster" in role text', () => { + const members: ActiveMember[] = [ + { name: 'AnyAgent', role: 'CI/CD platform engineer', status: '✅ Active' }, + ]; + const block = generateAWTeamContextBlock(members, [], '2026-01-01T00:00:00.000Z'); + expect(block.toLowerCase()).toContain('ci/cd configuration'); + }); + + it('can-capabilities line contains none of the default cast names', () => { + const members: ActiveMember[] = [ + { name: 'X', role: 'Lead Developer', status: '✅ Active' }, + ]; + const block = generateAWTeamContextBlock(members, [], '2026-01-01T00:00:00.000Z'); + const capLine = block.split('\n').find(l => l.includes('✅ **Can:**')) ?? ''; + for (const castName of ['booster', 'capcom', 'control', 'eecom', 'flight', 'surgeon']) { + expect(capLine.toLowerCase()).not.toContain(castName); + } + }); +}); + // ── injectTeamContext ───────────────────────────────────────────────────── describe('injectTeamContext', () => {