diff --git a/.changeset/v008-viability-gate.md b/.changeset/v008-viability-gate.md new file mode 100644 index 000000000..4b8d9d6ab --- /dev/null +++ b/.changeset/v008-viability-gate.md @@ -0,0 +1,13 @@ +--- +"@bradygaster/squad-sdk": minor +"@bradygaster/squad-cli": minor +--- + +feat(export): v008 quality-first viability gate + +- Add viability pre-flight check that assesses whether a squad can produce a valuable coordinator export within the character budget +- Configurable character limit (default 30K, adjustable via `--char-limit`) +- Honest refusal messaging with `--force` escape hatch for squads that are too complex +- Fix roster parsing to filter out category headers, placeholder rows, and non-member entries +- Distill dispatch rules for large rule sets (>20 rules) by grouping routes to same target +- Thresholds scale with configured character limit diff --git a/packages/squad-cli/src/cli-entry.ts b/packages/squad-cli/src/cli-entry.ts index 71ea5c36c..7fd7abc61 100644 --- a/packages/squad-cli/src/cli-entry.ts +++ b/packages/squad-cli/src/cli-entry.ts @@ -731,6 +731,12 @@ async function main(): Promise { } if (cmd === 'export') { + // Subcommand: squad export agent [options] + if (args[1] === 'agent') { + const { runCoordinatorExport } = await import('./cli/commands/export-coordinator.js'); + await runCoordinatorExport(getSquadStartDir(), args.slice(2)); + return; + } const { runExport } = await import('./cli/commands/export.js'); const outIdx = args.indexOf('--out'); const outPath = (outIdx !== -1 && args[outIdx + 1]) ? args[outIdx + 1] : undefined; diff --git a/packages/squad-cli/src/cli/commands/export-coordinator.ts b/packages/squad-cli/src/cli/commands/export-coordinator.ts new file mode 100644 index 000000000..696066616 --- /dev/null +++ b/packages/squad-cli/src/cli/commands/export-coordinator.ts @@ -0,0 +1,268 @@ +/** + * Export coordinator command — generates .github/agents/squad.md + * from .squad/ state using the repo-native prompt compiler. + * + * v008: Quality-first philosophy with viability gate. + */ + +import path from 'node:path'; +import { detectSquadDir } from '../core/detect-squad-dir.js'; +import { success, warn } from '../core/output.js'; +import { fatal } from '../core/errors.js'; + +import type { CoordinatorExportOptions } from '@bradygaster/squad-sdk'; + +const PROTECTED_AGENT_FILE = 'squad.agent.md'; + +/** Patterns that indicate the export references files CCA can't read */ +const SELF_CONTAINMENT_VIOLATIONS = [ + /\*\*\[.*?archived in .*?\]\*\*/g, + /\[.*?decisions-archive.*?\]/g, + /See `\.squad\/.*?`/g, + /\(see .*?\.md\)/gi, + /Refer to `.*?\.md`/gi, + /documented in `.*?`/gi, +]; + +function findSelfContainmentViolations(text: string): string[] { + const violations: string[] = []; + for (const pattern of SELF_CONTAINMENT_VIOLATIONS) { + const matches = text.match(pattern); + if (matches) violations.push(...matches); + } + return violations; +} + +/** + * Parse CLI args into coordinator export options. + */ +export function parseCoordinatorOptions(args: string[]): CoordinatorExportOptions { + const options: CoordinatorExportOptions = { + outPath: '.github/agents/squad.md', + skills: 'baseline', + check: false, + watch: false, + dryRun: false, + force: false, + cleanLegacyAgent: false, + maxPromptTokens: 14_000, + compact: false, + charLimit: 30_000, + }; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + switch (arg) { + case '--out': + options.outPath = args[++i] || options.outPath; + break; + case '--model': + options.model = args[++i]; + break; + case '--description': + options.description = args[++i]; + break; + case '--skills': + const skillsVal = args[++i]; + if (skillsVal === 'baseline' || skillsVal === 'all' || skillsVal === 'none') { + options.skills = skillsVal; + } else if (skillsVal) { + options.skills = skillsVal.split(',').map(s => s.trim()); + } + break; + case '--check': + options.check = true; + break; + case '--watch': + options.watch = true; + break; + case '--dry-run': + options.dryRun = true; + break; + case '--force': + options.force = true; + break; + case '--clean-legacy-agent': + options.cleanLegacyAgent = true; + break; + case '--max-prompt-tokens': + options.maxPromptTokens = parseInt(args[++i] ?? '', 10) || 14_000; + break; + case '--compact': + options.compact = true; + break; + case '--char-limit': + options.charLimit = parseInt(args[++i] ?? '', 10) || 30_000; + break; + } + } + + return options; +} + +/** + * Run the coordinator agent export. + */ +export async function runCoordinatorExport(dest: string, args: string[]): Promise { + const options = parseCoordinatorOptions(args); + const squadInfo = detectSquadDir(dest); + + const teamMdPath = path.join(squadInfo.path, 'team.md'); + const { existsSync } = await import('node:fs'); + if (!existsSync(teamMdPath)) { + fatal('No squad found — run init first'); + } + + const resolvedOutPath = path.resolve(dest, options.outPath); + if (path.basename(resolvedOutPath) === PROTECTED_AGENT_FILE) { + fatal( + "Cannot write to squad.agent.md — this file is the project's coordinator governance file and must not be modified by export. Use a different output path (default: squad.md)." + ); + } + + // Lazy import to avoid loading the SDK modules until needed + const { loadExportContext, compileCoordinatorPrompt, renderFrontmatter, writeCoordinatorAgent, startWatchExport, checkViability } = + await import('@bradygaster/squad-sdk/repo-native'); + + // Load context for viability check and export + const context = await loadExportContext(dest, squadInfo.path, { + outputPath: options.outPath, + generatedAt: new Date().toISOString(), + modelOverride: options.model, + descriptionOverride: options.description, + skillMode: options.skills, + }); + + // v008: Viability gate — refuse to export squads that are too complex + // unless --force is specified + const viability = checkViability(context, { + charLimit: options.charLimit, + safetyMargin: 1_000, + force: options.force, + }); + + if (!viability.viable) { + fatal(viability.summary); + } + + if (viability.issues.length > 0 && !options.check) { + warn(viability.summary); + } + + const charTarget = options.charLimit - 1_000; + + const doExport = async () => { + const prompt = compileCoordinatorPrompt(context, { + softLimit: options.maxPromptTokens, + hardLimit: 20_000, + compact: options.compact, + charHardLimit: options.charLimit, + charTarget, + }); + + const frontmatter = renderFrontmatter(context.coordinator); + const output = `${frontmatter}\n\n${prompt.markdown}\n`; + + // Final self-containment validation: ensure no stray external references + const violations = findSelfContainmentViolations(output); + if (violations.length > 0) { + // Auto-fix remaining violations by stripping them + let cleanedOutput = output; + for (const v of violations) { + cleanedOutput = cleanedOutput.replace(v, ''); + } + // Re-collapse blank lines + const finalOutput = cleanedOutput.replace(/\n{3,}/g, '\n\n'); + + const result = writeCoordinatorAgent({ + root: dest, + outputPath: options.outPath, + output: finalOutput, + check: options.check, + dryRun: options.dryRun, + force: options.force, + cleanLegacyAgent: options.cleanLegacyAgent, + }); + + return { result, prompt }; + } + + const result = writeCoordinatorAgent({ + root: dest, + outputPath: options.outPath, + output, + check: options.check, + dryRun: options.dryRun, + force: options.force, + cleanLegacyAgent: options.cleanLegacyAgent, + }); + + return { result, prompt }; + }; + + // Handle check mode + if (options.check) { + const { result } = await doExport(); + if (result.userOwned) { + fatal( + `${options.outPath} exists but was not generated by squad export. ` + + `Use --force to overwrite or remove it manually.` + ); + } + if (result.driftDetected) { + fatal('Coordinator export drift detected. Run: squad export agent'); + } + success('Coordinator export is up to date'); + return; + } + + // Handle watch mode + if (options.watch) { + // Do initial export + const { result, prompt } = await doExport(); + if (result.written) { + const displayPath = path.relative(dest, result.outputPath) || path.basename(result.outputPath); + success(`Exported coordinator to ${displayPath} (~${prompt.estimatedTokens} tokens, mode: ${prompt.mode})`); + } + + console.log('Watching .squad/ for changes... (Ctrl+C to stop)'); + startWatchExport({ + root: dest, + squadRoot: squadInfo.path, + onRebuild: async () => { + try { + const { result: r, prompt: p } = await doExport(); + if (r.written) { + const ts = new Date().toLocaleTimeString(); + success(`[${ts}] Re-exported (~${p.estimatedTokens} tokens, mode: ${p.mode})`); + } + } catch (err) { + console.error(`Rebuild failed: ${(err as Error).message}`); + } + }, + }); + + // Keep process alive + await new Promise(() => {}); + return; + } + + // Standard export + try { + const { result, prompt } = await doExport(); + + if (result.written) { + const displayPath = path.relative(dest, result.outputPath) || path.basename(result.outputPath); + success(`Exported coordinator to ${displayPath}`); + console.log(` Characters: ${prompt.charCount}/${options.charLimit} (mode: ${prompt.mode})`); + if (prompt.appliedCompactions.length > 0) { + console.log(` Compactions: ${prompt.appliedCompactions.join(', ')}`); + } + if (viability.complexityScore > 50) { + console.log(` Complexity: ${viability.complexityScore}/100 — output quality may be reduced`); + } + } + } catch (err) { + fatal((err as Error).message); + } +} diff --git a/packages/squad-sdk/package.json b/packages/squad-sdk/package.json index e59c60fde..119b4f060 100644 --- a/packages/squad-sdk/package.json +++ b/packages/squad-sdk/package.json @@ -1,7 +1,7 @@ { "name": "@bradygaster/squad-sdk", "version": "0.9.6", - "description": "Squad SDK — Programmable multi-agent runtime for GitHub Copilot", + "description": "Squad SDK \u2014 Programmable multi-agent runtime for GitHub Copilot", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -221,6 +221,10 @@ "./upstream": { "types": "./dist/upstream/index.d.ts", "import": "./dist/upstream/index.js" + }, + "./repo-native": { + "types": "./dist/repo-native/index.d.ts", + "import": "./dist/repo-native/index.js" } }, "files": [ diff --git a/packages/squad-sdk/src/index.ts b/packages/squad-sdk/src/index.ts index a06142f6a..45ead31e1 100644 --- a/packages/squad-sdk/src/index.ts +++ b/packages/squad-sdk/src/index.ts @@ -63,6 +63,9 @@ export * from './upstream/index.js'; export * from './remote/index.js'; export * from './streams/index.js'; +// Repo-native coordinator export types (implementation in ./repo-native subpath) +export type { CoordinatorExportOptions, CompileCoordinatorPromptOptions } from './repo-native/types.js'; + // Builder functions (SDK-First Squad Mode) export { defineTeam, diff --git a/packages/squad-sdk/src/repo-native/compile-coordinator-prompt.ts b/packages/squad-sdk/src/repo-native/compile-coordinator-prompt.ts new file mode 100644 index 000000000..8eedc28fc --- /dev/null +++ b/packages/squad-sdk/src/repo-native/compile-coordinator-prompt.ts @@ -0,0 +1,530 @@ +/** + * Coordinator prompt compiler — transforms the export context IR + * into a coordinator prompt markdown string within character budget. + * + * v007: Character-based budgeting, self-containment enforcement, + * intelligent decision distillation, and "Export IS the Product" philosophy. + */ + +import type { + SquadExportContext, + CompileCoordinatorPromptOptions, + CompiledCoordinatorPrompt, + SquadMemberSummary, +} from './types.js'; +import { distillDecisions } from './distill-decisions.js'; + +const LAZY_LOAD_MEMBER_THRESHOLD = 8; +const LAZY_LOAD_ROSTER_TOKEN_THRESHOLD = 3000; + +/** + * Default CCA limits. These are overridable via CompileCoordinatorPromptOptions + * to support future increases beyond 30K. + */ +const DEFAULT_CCA_CHAR_HARD_LIMIT = 30_000; +const DEFAULT_CCA_CHAR_TARGET = 29_000; + +/** + * Approximate token count using char/4 heuristic. + */ +export function estimateTokens(text: string): number { + return Math.ceil(text.length / 4); +} + +function measureSections(markdown: string): Array<{ name: string; tokens: number }> { + const sections: Array<{ name: string; tokens: number }> = []; + const parts = markdown.split(/^## /m); + + // First part is preamble (identity/comments) + if (parts[0]) { + sections.push({ name: 'preamble', tokens: estimateTokens(parts[0]) }); + } + + for (const part of parts.slice(1)) { + const nameMatch = part.match(/^(.+)\n/); + const name = nameMatch?.[1]?.trim() ?? 'unknown'; + sections.push({ name, tokens: estimateTokens(part) }); + } + + return sections; +} + +/** + * Strip content that wastes characters or violates self-containment. + * - Image placeholders (e.g., [📷 copilot-image-abc123.png]) + * - External file references CCA cannot access + * - Archive references + * - Blank lines left behind after stripping + */ +function sanitizeExportContent(text: string): string { + return text + .replace(/\[📷[^\]]*\]/g, '') + .replace(/!\[copilot-image[^\]]*\]\([^)]*\)/g, '') + // Strip archive/external file references (self-containment) + .replace(/\*\*\[.*?archived in .*?\]\*\*/g, '') + .replace(/\[.*?decisions-archive.*?\]/g, '') + .replace(/See `\.squad\/.*?`/g, '') + .replace(/\(see .*?\.md\)/gi, '') + .replace(/Refer to `.*?\.md`/gi, '') + .replace(/documented in `.*?`/gi, '') + .replace(/\n{3,}/g, '\n\n'); +} + +function renderRosterFull(members: SquadMemberSummary[]): string { + return members.map(m => { + const summary = m.charterSummary ? ` ${m.charterSummary}` : ''; + return `- **${m.displayName}** (\`${m.slug}\`) — ${m.role}.${summary ? ` Use for${summary.startsWith(' ') ? summary : ' ' + summary}.` : ''}`; + }).join('\n'); +} + +function renderRosterCompact(members: SquadMemberSummary[]): string { + return members.map(m => + `- **${m.displayName}** (\`${m.slug}\`) — ${m.role}.` + ).join('\n'); +} + +function renderRosterLazyLoad(members: SquadMemberSummary[]): string { + const roster = members.map(m => + `- **${m.displayName}** (\`${m.slug}\`) — ${m.role}. Charter: \`${m.charterPath}\`` + ).join('\n'); + + return roster + '\n\n> **Note:** Before dispatching any specialist, read their charter file for detailed scope and boundaries.'; +} + +/** + * Resolve a routeTo value against team members. Matches by slug or displayName, + * returning the member's slug. Falls back to slugifying the raw value. + */ +function resolveRouteTarget(routeTo: string, members: SquadMemberSummary[]): string { + const normalized = routeTo.trim().toLowerCase(); + const bySlug = members.find(m => m.slug === normalized); + if (bySlug) return bySlug.slug; + const byName = members.find(m => m.displayName.toLowerCase() === normalized); + if (byName) return byName.slug; + // Fallback: slugify the routeTo value using the same logic as member slug generation + return normalized.replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); +} + +function renderDispatchRules(context: SquadExportContext): string { + const lines: string[] = []; + const rules = context.routing.rules; + + // v008: Distill dispatch rules for large rule sets + // If >20 rules, group by target to reduce repetition + if (rules.length > 20) { + const grouped = new Map(); + for (const rule of rules) { + const target = resolveRouteTarget(rule.routeTo, context.team.members); + if (!grouped.has(target)) grouped.set(target, []); + grouped.get(target)!.push(rule.workType.toLowerCase()); + } + for (const [target, workTypes] of grouped) { + if (workTypes.length === 1) { + lines.push(`- If the request is mainly about ${workTypes[0]}, route to \`${target}\`.`); + } else { + lines.push(`- Route to \`${target}\` for: ${workTypes.join(', ')}.`); + } + } + } else { + for (const rule of rules) { + const target = resolveRouteTarget(rule.routeTo, context.team.members); + lines.push(`- If the request is mainly about ${rule.workType.toLowerCase()}, route to \`${target}\`.`); + } + } + + if (context.routing.fallback) { + lines.push(`- For ambiguous or multi-domain requests, ${context.routing.fallback}.`); + } else if (context.team.members.length > 0) { + // Default fallback to first member (typically the lead) + const lead = context.team.members[0]!; + lines.push(`- If the request is multi-domain, ambiguous, or strategic, route through \`${lead.slug}\` first.`); + } + + // Add principles as dispatch hints (limit to top 5 for budget) + const principles = context.routing.principles.slice(0, 5); + for (const principle of principles) { + lines.push(`- ${principle}`); + } + + return lines.join('\n'); +} + +function renderCeremonies(context: SquadExportContext): string { + if (context.ceremonies.length === 0) return ''; + + const lines = context.ceremonies.map(c => { + let desc = `- **${c.name}** — trigger ${c.trigger.toLowerCase().startsWith('when') ? '' : 'when '}${c.trigger.toLowerCase()}.`; + return desc; + }); + + return lines.join('\n'); +} + +function renderDispatchProtocol(context: SquadExportContext): string { + return context.dispatch.protocol + .map((step, i) => `${i + 1}. ${step}`) + .join('\n'); +} + +function renderFullPrompt(context: SquadExportContext): string { + const sections: string[] = []; + + // Provenance marker (used by CLI for collision detection — do not remove) + sections.push([ + ``, + '', + `You are **Squad (Coordinator)** — the exported coordinator for this repository.`, + ].join('\n')); + + // Mission + sections.push([ + '## Mission', + '', + '- Represent the project\'s Squad roster inside Copilot custom-agent surfaces.', + '- Coordinate specialists using the repository\'s `.squad/` configuration.', + '- Preserve routing, ceremony triggers, and validation behavior during delegation.', + '- Produce assembled results for the user after delegated work completes.', + ].join('\n')); + + // Operating mode + sections.push([ + '## Operating mode', + '', + '- You are a **dispatcher first**.', + '- Prefer specialist delegation over doing domain work inline.', + '- Use direct answers only for trivial factual questions that do not benefit from dispatch.', + '- Launch parallel work when tasks are independent.', + '- Preserve validation and fact-check gates for recommendations and externally visible documents.', + ].join('\n')); + + // Team roster + const rosterContent = renderRosterFull(context.team.members); + sections.push([ + '## Team roster', + '', + rosterContent, + ].join('\n')); + + // Dispatch rules + const dispatchRules = renderDispatchRules(context); + if (dispatchRules) { + sections.push([ + '## Dispatch rules', + '', + dispatchRules, + ].join('\n')); + } + + // Ceremony triggers + const ceremonies = renderCeremonies(context); + if (ceremonies) { + sections.push([ + '## Ceremony triggers', + '', + ceremonies, + ].join('\n')); + } + + // Decisions snapshot — distilled for self-containment and budget + if (context.decisions) { + // Calculate remaining budget for decisions + // (We'll refine this in the compile pass, but give distiller a generous initial budget) + const decisionsDistilled = distillDecisions(context.decisions, { + charBudget: 8000, // Initial generous budget; compile pass will tighten if needed + }); + sections.push([ + '## Decisions', + '', + decisionsDistilled.markdown, + ].join('\n')); + } + + // Dispatch protocol + sections.push([ + '## Dispatch protocol', + '', + 'When delegating:', + '', + renderDispatchProtocol(context), + ].join('\n')); + + // Output rules + sections.push([ + '## Output rules', + '', + '- Acknowledge work in human terms before launching specialists.', + '- Say who is working on what.', + '- Preserve fact-check and review gates when the task includes recommendations or externally visible artifacts.', + '- Present final answers as assembled outcomes, not raw delegated fragments.', + ].join('\n')); + + return sections.join('\n\n'); +} + +function renderCompactPrompt(context: SquadExportContext): string { + // Same structure but with compact roster + const sections: string[] = []; + + sections.push([ + ``, + '', + `You are **Squad (Coordinator)** — the exported coordinator for this repository.`, + ].join('\n')); + + sections.push([ + '## Mission', + '', + '- Coordinate specialists using the repository\'s `.squad/` configuration.', + '- Route work to the right specialist. Preserve ceremonies and reviewer gates.', + ].join('\n')); + + sections.push([ + '## Operating mode', + '', + '- Dispatcher first. Prefer delegation over inline work.', + '- Launch parallel work when tasks are independent.', + ].join('\n')); + + const rosterContent = renderRosterCompact(context.team.members); + sections.push([ + '## Team roster', + '', + rosterContent, + ].join('\n')); + + const dispatchRules = renderDispatchRules(context); + if (dispatchRules) { + sections.push(['## Dispatch rules', '', dispatchRules].join('\n')); + } + + const ceremonies = renderCeremonies(context); + if (ceremonies) { + sections.push(['## Ceremony triggers', '', ceremonies].join('\n')); + } + + if (context.decisions) { + const decisionsDistilled = distillDecisions(context.decisions, { + charBudget: 5000, // Tighter budget in compact mode + }); + sections.push(['## Decisions', '', decisionsDistilled.markdown].join('\n')); + } + + sections.push([ + '## Dispatch protocol', + '', + 'When delegating:', + '', + renderDispatchProtocol(context), + ].join('\n')); + + sections.push([ + '## Output rules', + '', + '- Acknowledge work before dispatching. Say who is working on what.', + '- Preserve review gates. Present final assembled outcomes.', + ].join('\n')); + + return sections.join('\n\n'); +} + +function renderLazyLoadPrompt(context: SquadExportContext): string { + const sections: string[] = []; + + sections.push([ + ``, + '', + `You are **Squad (Coordinator)** — the exported coordinator for this repository.`, + ].join('\n')); + + sections.push([ + '## Mission', + '', + '- Coordinate specialists using the repository\'s `.squad/` configuration.', + '- Route work to the right specialist. Preserve ceremonies and reviewer gates.', + ].join('\n')); + + sections.push([ + '## Operating mode', + '', + '- Dispatcher first. Prefer delegation over inline work.', + '- Before dispatching, read the target agent\'s charter from `.squad/agents//charter.md`.', + '- Launch parallel work when tasks are independent.', + ].join('\n')); + + const rosterContent = renderRosterLazyLoad(context.team.members); + sections.push(['## Team roster', '', rosterContent].join('\n')); + + const dispatchRules = renderDispatchRules(context); + if (dispatchRules) { + sections.push(['## Dispatch rules', '', dispatchRules].join('\n')); + } + + const ceremonies = renderCeremonies(context); + if (ceremonies) { + sections.push(['## Ceremony triggers', '', ceremonies].join('\n')); + } + + if (context.decisions) { + const decisionsDistilled = distillDecisions(context.decisions, { + charBudget: 4000, // Tightest budget in lazy-load mode + }); + sections.push(['## Decisions', '', decisionsDistilled.markdown].join('\n')); + } + + sections.push([ + '## Dispatch protocol', + '', + 'When delegating:', + '', + '1. Read the target agent\'s charter file for scope and boundaries.', + '2. Pass the user\'s goal, repository context, and relevant findings.', + '3. Ask for concrete deliverables.', + '4. Launch independent work in parallel.', + '5. Synthesize the returned work into one response for the user.', + ].join('\n')); + + sections.push([ + '## Output rules', + '', + '- Acknowledge work before dispatching. Say who is working on what.', + '- Preserve review gates. Present final assembled outcomes.', + ].join('\n')); + + return sections.join('\n\n'); +} + +function shouldUseLazyLoad(context: SquadExportContext): boolean { + if (context.team.members.length > LAZY_LOAD_MEMBER_THRESHOLD) return true; + const rosterText = renderRosterFull(context.team.members); + return estimateTokens(rosterText) > LAZY_LOAD_ROSTER_TOKEN_THRESHOLD; +} + +function buildBudgetFailureMessage( + charCount: number, + hardLimit: number, + sections: Array<{ name: string; tokens: number }>, + appliedCompactions: string[], +): string { + const top5 = [...sections].sort((a, b) => b.tokens - a.tokens).slice(0, 5); + const topList = top5.map(s => ` - ${s.name}: ~${s.tokens * 4} chars`).join('\n'); + const compactList = appliedCompactions.length > 0 + ? `Compaction passes applied: ${appliedCompactions.join(', ')}` + : 'No compaction passes applied.'; + + return [ + `Export aborted: generated coordinator prompt is ${charCount} chars (hard cap: ${hardLimit}).`, + `Largest sections:`, + topList, + compactList, + `Try: --compact, --skills none, or split this team into multiple exported coordinators.`, + ].join('\n'); +} + +/** + * Compile the coordinator prompt from export context. + * + * v007 strategy: "Greedy fill, then smart shrink" + * 1. Render full prompt with distilled decisions + * 2. If over char budget, progressively compact (compact mode → lazy-load → tighter decisions) + * 3. Sanitize for self-containment (strip external refs) + * 4. Final character validation — hard fail if still over + * + * Budget is CHARACTER-based (CCA limit = 30,000 chars), not token-based. + */ +export function compileCoordinatorPrompt( + context: SquadExportContext, + options: CompileCoordinatorPromptOptions, +): CompiledCoordinatorPrompt { + const appliedCompactions: string[] = []; + let draft: string; + + // Use character limit: prefer configurable options, fall back to defaults + const charTarget = options.charTarget ?? Math.min(options.softLimit * 4, DEFAULT_CCA_CHAR_TARGET); + const charHardLimit = options.charHardLimit ?? Math.min(options.hardLimit * 4, DEFAULT_CCA_CHAR_HARD_LIMIT); + + if (options.compact) { + draft = renderCompactPrompt(context); + appliedCompactions.push('forced-compact'); + } else { + draft = renderFullPrompt(context); + } + + // Sanitize early for accurate measurement + draft = sanitizeExportContent(draft); + let charCount = draft.length; + + // Compaction pass 1: switch to compact mode + if (charCount > charTarget && !options.compact) { + draft = sanitizeExportContent(renderCompactPrompt(context)); + appliedCompactions.push('compact-charters'); + charCount = draft.length; + } + + // Compaction pass 2: lazy-load mode for large teams + if (charCount > charTarget && shouldUseLazyLoad(context)) { + draft = sanitizeExportContent(renderLazyLoadPrompt(context)); + appliedCompactions.push('lazy-load-roster'); + charCount = draft.length; + } + + // Compaction pass 3: re-distill decisions with tighter budget + if (charCount > charTarget && context.decisions) { + const overage = charCount - charTarget; + // Find the decisions section and measure it + const decisionsMatch = draft.match(/## Decisions\n\n([\s\S]*?)(?=\n## |\n*$)/); + if (decisionsMatch?.[1]) { + const currentDecisionsLen = decisionsMatch[1].length; + const newBudget = Math.max(1000, currentDecisionsLen - overage - 500); + + const tighterDistill = distillDecisions(context.decisions, { + charBudget: newBudget, + }); + + draft = draft.replace(decisionsMatch[1], tighterDistill.markdown); + appliedCompactions.push(`decisions-retightened(${newBudget})`); + charCount = draft.length; + } + } + + // Compaction pass 4: if STILL over, aggressively trim decisions to minimum + if (charCount > charTarget && context.decisions) { + const decisionsMatch = draft.match(/## Decisions\n\n([\s\S]*?)(?=\n## |\n*$)/); + if (decisionsMatch?.[1]) { + const minDistill = distillDecisions(context.decisions, { + charBudget: 800, + }); + draft = draft.replace(decisionsMatch[1], minDistill.markdown); + appliedCompactions.push('decisions-minimized'); + charCount = draft.length; + } + } + + // Compaction pass 5: nuclear option — drop decisions entirely + if (charCount > charHardLimit) { + draft = draft.replace(/## Decisions\n\n[\s\S]*?(?=\n## |\n*$)/, ''); + appliedCompactions.push('decisions-dropped'); + charCount = draft.length; + } + + // Hard fail — even without decisions we're over + if (charCount > charHardLimit) { + const sections = measureSections(draft); + throw new Error(buildBudgetFailureMessage(charCount, charHardLimit, sections, appliedCompactions)); + } + + const estimate = estimateTokens(draft); + const mode = appliedCompactions.includes('lazy-load-roster') + ? 'lazy-load' as const + : appliedCompactions.includes('compact-charters') || appliedCompactions.includes('forced-compact') + ? 'compact' as const + : 'full' as const; + + return { + markdown: draft, + estimatedTokens: estimate, + charCount, + appliedCompactions, + sectionSizes: measureSections(draft), + mode, + }; +} diff --git a/packages/squad-sdk/src/repo-native/distill-decisions.ts b/packages/squad-sdk/src/repo-native/distill-decisions.ts new file mode 100644 index 000000000..3394a6fa0 --- /dev/null +++ b/packages/squad-sdk/src/repo-native/distill-decisions.ts @@ -0,0 +1,405 @@ +/** + * v007 — Intelligent decision distillation for export. + * + * Transforms verbose, chronological .squad/decisions.md content into + * a compact, priority-driven, self-contained representation optimized + * for CCA consumption within tight character budgets. + * + * Design principles: + * - Storage format ≠ delivery format + * - Non-chronological: grouped by concern, not date + * - Priority-driven: mission-critical > nice-to-have + * - Self-contained: NO external file references + * - CCA already has the code — don't repeat what's obvious from source + */ + +export interface ExportDecision { + title: string; + what: string; + why?: string; + category: DecisionCategory; + priority: number; // 1 = highest +} + +export type DecisionCategory = + | 'mission' + | 'architecture' + | 'workflow' + | 'quality' + | 'deployment' + | 'naming' + | 'directive' + | 'operational'; + +export interface DistillOptions { + /** Maximum characters allowed for the decisions section */ + charBudget: number; + /** If true, attempt LLM distillation (requires SquadClient) */ + useLlm?: boolean; +} + +export interface DistillResult { + /** The distilled decisions markdown */ + markdown: string; + /** Character count of the output */ + charCount: number; + /** How many source decisions were processed */ + sourceCount: number; + /** How many decisions made it into output */ + retainedCount: number; + /** Compression ratio (output/input) */ + ratio: number; +} + +// Patterns that reference external files CCA can't access +const EXTERNAL_REF_PATTERNS = [ + /\*\*\[.*?archived in .*?\]\*\*/g, + /\[.*?decisions-archive.*?\]/g, + /See `\.squad\/.*?`/g, + /\(see .*?\.md\)/gi, + /Refer to `.*?\.md`/gi, + /documented in `.*?`/gi, +]; + +// File path patterns that describe implementation changes (CCA can see the code) +const IMPL_DETAIL_PATTERNS = [ + /^\s*[-*]\s*(?:Created|Updated|Renamed|Added to|Moved|Deleted):?\s*$/m, + /^\s*[-*]\s*`\.squad\/.*?`.*$/gm, + /^\s*[-*]\s*`\.copilot\/.*?`.*$/gm, + /^\s*[-*]\s*`\.github\/.*?`.*$/gm, + /^\s*\d+\.\s*`\.squad\/.*$/gm, +]; + +/** + * Parse raw decisions.md into individual decision entries. + */ +export function parseExportDecisions(raw: string): ExportDecision[] { + const decisions: ExportDecision[] = []; + + // Split on ### headings (individual decisions) + const sections = raw.split(/^### /m).slice(1); + + for (const section of sections) { + const lines = section.split('\n'); + const titleLine = lines[0]?.trim() || ''; + + // Extract the what/why fields + let what = ''; + let why = ''; + + for (const line of lines.slice(1)) { + const whatMatch = line.match(/^\*\*What:\*\*\s*(.+)/); + const whyMatch = line.match(/^\*\*Why:\*\*\s*(.+)/); + const decisionMatch = line.match(/^\*\*Decision:\*\*\s*(.+)/); + + if (whatMatch?.[1]) what = whatMatch[1].trim(); + if (decisionMatch?.[1]) what = what || decisionMatch[1].trim(); + if (whyMatch?.[1]) why = whyMatch[1].trim(); + } + + // If no explicit What field, grab the first substantive paragraph + if (!what) { + // Filter out metadata lines and find prose content + const metaPatterns = /^\*\*(By|Author|Requested|Scope|Date|Status|Timestamp|Changes Applied|Patterns Ported|Implemented Architecture|Rationale|Consequences|Key Points):/i; + const bodyLines = lines.slice(1) + .filter(l => { + const trimmed = l.trim(); + if (!trimmed) return false; + if (metaPatterns.test(trimmed)) return false; + if (trimmed.startsWith('---')) return false; + if (trimmed.startsWith('- `') || trimmed.startsWith('- `.squad')) return false; + return true; + }) + .slice(0, 3); + + // Look for **Decision:** in body + const decisionSection = section.match(/\*\*Decision:\*\*\s*([\s\S]*?)(?=\n\*\*(?:Rationale|Consequences|Changes)|\n###|$)/); + if (decisionSection?.[1]) { + what = decisionSection[1].trim().split('\n').slice(0, 3).join(' ').trim(); + } else { + what = bodyLines.join(' ').replace(/\*\*/g, '').trim(); + } + } + + if (!what) continue; + + const category = categorize(titleLine, what); + const priority = scorePriority(category, titleLine, what); + + decisions.push({ title: titleLine, what, why, category, priority }); + } + + return decisions; +} + +/** + * Categorize a decision by its domain. + */ +function categorize(title: string, what: string): DecisionCategory { + const text = `${title} ${what}`.toLowerCase(); + + if (text.includes('mission') || text.includes('foundational') || text.includes('purpose')) + return 'mission'; + if (text.includes('architecture') || text.includes('deployment') || text.includes('infra') || text.includes('bicep') || text.includes('terraform')) + return 'architecture'; + if (text.includes('deploy') || text.includes('azd') || text.includes('container app') || text.includes('aca')) + return 'deployment'; + if (text.includes('workflow') || text.includes('assess') || text.includes('plan') || text.includes('implement') || text.includes('phase')) + return 'workflow'; + if (text.includes('quality') || text.includes('review') || text.includes('gate') || text.includes('metric')) + return 'quality'; + if (text.includes('naming') || text.includes('rename') || text.includes('prefix')) + return 'naming'; + if (text.includes('directive') || title.includes('User directive')) + return 'directive'; + + return 'operational'; +} + +/** + * Score priority (lower = more important). + */ +function scorePriority(category: DecisionCategory, title: string, what: string): number { + const base: Record = { + mission: 1, + architecture: 2, + deployment: 3, + workflow: 3, + quality: 4, + directive: 4, + naming: 6, + operational: 7, + }; + + let score = base[category]; + + // Boost for explicit strategic language + const text = `${title} ${what}`.toLowerCase(); + if (text.includes('non-negotiable') || text.includes('must') || text.includes('always')) + score -= 0.5; + if (text.includes('foundational') || text.includes('core')) + score -= 0.5; + + return score; +} + +/** + * Strip external file references and implementation details. + */ +function stripNonSelfContainedContent(text: string): string { + let result = text; + + // Remove external file references + for (const pattern of EXTERNAL_REF_PATTERNS) { + result = result.replace(pattern, ''); + } + + // Remove implementation detail lines (file changes CCA can see in code) + for (const pattern of IMPL_DETAIL_PATTERNS) { + result = result.replace(pattern, ''); + } + + // Clean up empty list sections + result = result.replace(/\*\*Changes Applied:\*\*\s*\n(\s*\n)+/g, ''); + result = result.replace(/\*\*Patterns Ported:\*\*\s*\n(\s*\n)+/g, ''); + result = result.replace(/\*\*Implemented Architecture:\*\*\s*\n(\s*\n)+/g, ''); + + // Collapse multiple blank lines + result = result.replace(/\n{3,}/g, '\n\n'); + + return result.trim(); +} + +/** + * Merge overlapping decisions in the same category into consolidated entries. + */ +function mergeOverlapping(decisions: ExportDecision[]): ExportDecision[] { + const grouped = new Map(); + for (const d of decisions) { + const arr = grouped.get(d.category) || []; + arr.push(d); + grouped.set(d.category, arr); + } + + const merged: ExportDecision[] = []; + for (const [category, group] of grouped) { + if (group.length <= 2) { + merged.push(...group); + continue; + } + + // For larger groups, keep the highest-priority ones and merge the rest + const sorted = [...group].sort((a, b) => a.priority - b.priority); + const keep = sorted.slice(0, 2); + const rest = sorted.slice(2); + + merged.push(...keep); + + // Merge remaining into a summary entry + if (rest.length > 0) { + const summaryPoints = rest.map(d => extractCoreSentence(d.what)); + const consolidatedWhat = summaryPoints.join('. '); + merged.push({ + title: `Additional ${category} decisions`, + what: consolidatedWhat, + category, + priority: Math.max(...rest.map(d => d.priority)), + }); + } + } + + return merged; +} + +/** + * Extract the core actionable sentence from a decision's what field. + */ +function extractCoreSentence(what: string): string { + // Take first sentence, cap at 150 chars + const firstSentence = what.split(/\.\s/)[0] || what; + if (firstSentence.length <= 150) return firstSentence; + return firstSentence.slice(0, 147) + '...'; +} + +/** + * Render decisions in compact indexed format, grouped by concern. + */ +function renderCompactIndex(decisions: ExportDecision[]): string { + const grouped = new Map(); + for (const d of decisions) { + const arr = grouped.get(d.category) || []; + arr.push(d); + grouped.set(d.category, arr); + } + + // Render order: mission → architecture → deployment → workflow → quality → directive → naming → operational + const order: DecisionCategory[] = ['mission', 'architecture', 'deployment', 'workflow', 'quality', 'directive', 'naming', 'operational']; + const categoryLabels: Record = { + mission: 'Mission & Strategy', + architecture: 'Architecture', + deployment: 'Deployment', + workflow: 'Workflow & Process', + quality: 'Quality & Review', + directive: 'Team Directives', + naming: 'Naming & Identity', + operational: 'Operational', + }; + + const sections: string[] = []; + for (const cat of order) { + const entries = grouped.get(cat); + if (!entries?.length) continue; + + const sorted = [...entries].sort((a, b) => a.priority - b.priority); + const lines = sorted.map(d => { + const core = extractCoreSentence(d.what); + return `- ${core}`; + }); + + sections.push(`**${categoryLabels[cat]}**\n${lines.join('\n')}`); + } + + return sections.join('\n\n'); +} + +/** + * Distill decisions to fit within a character budget. + * + * Strategy: parse → categorize → prioritize → merge → render compact → trim by priority + * Runs iteratively, dropping lowest-priority content until under budget. + */ +export function distillDecisions(raw: string, options: DistillOptions): DistillResult { + const { charBudget } = options; + + // Strip external references first (always) + const cleaned = stripNonSelfContainedContent(raw); + + // If raw content already fits, just use cleaned version + if (cleaned.length <= charBudget) { + return { + markdown: cleaned, + charCount: cleaned.length, + sourceCount: parseExportDecisions(raw).length, + retainedCount: parseExportDecisions(raw).length, + ratio: cleaned.length / raw.length, + }; + } + + // Parse into structured decisions + const parsed = parseExportDecisions(raw); + if (parsed.length === 0) { + // Fallback: just truncate + const truncated = cleaned.slice(0, charBudget); + return { + markdown: truncated, + charCount: truncated.length, + sourceCount: 0, + retainedCount: 0, + ratio: truncated.length / raw.length, + }; + } + + // Merge overlapping decisions + let working = mergeOverlapping(parsed); + + // Sort by priority (ascending = most important first) + working.sort((a, b) => a.priority - b.priority); + + // Iteratively render and trim until under budget + let rendered = renderCompactIndex(working); + let iterations = 0; + + while (rendered.length > charBudget && working.length > 1 && iterations < 20) { + // Drop the lowest-priority entry + working.pop(); + rendered = renderCompactIndex(working); + iterations++; + } + + // Final safety: hard truncate if still over (shouldn't happen) + if (rendered.length > charBudget) { + rendered = rendered.slice(0, charBudget - 3) + '...'; + } + + return { + markdown: rendered, + charCount: rendered.length, + sourceCount: parsed.length, + retainedCount: working.length, + ratio: rendered.length / raw.length, + }; +} + +/** + * LLM-powered distillation prompt. + * Used when algorithmic distillation still can't fit or when quality + * can be significantly improved by semantic understanding. + */ +export function buildDistillationPrompt(decisionsRaw: string, charBudget: number): string { + return `You are a technical writer compressing a squad's decisions for an AI coding agent. + +CONSTRAINTS: +- Output MUST be ≤ ${charBudget} characters total +- Output MUST be completely self-contained — NO references to external files +- Output MUST NOT reference .squad/, decisions-archive.md, or any file paths the agent can't read +- Group by concern (not chronologically) +- Prioritize: mission-critical > architectural > workflow > cosmetic +- Use bullet points, not prose paragraphs +- Strip timestamps, "By:" fields, implementation file lists +- The AI agent already HAS the source code — don't repeat what's obvious from reading it +- Merge overlapping decisions into single consolidated points +- Every bullet must be actionable guidance, not historical record + +FORMAT: +**[Category Name]** +- [Concise actionable directive] +- [Another directive] + +**[Next Category]** +- ... + +SOURCE DECISIONS: +${decisionsRaw} + +Distill these into the most useful, compact, self-contained guidance possible within ${charBudget} characters.`; +} diff --git a/packages/squad-sdk/src/repo-native/index.ts b/packages/squad-sdk/src/repo-native/index.ts new file mode 100644 index 000000000..ff281b42c --- /dev/null +++ b/packages/squad-sdk/src/repo-native/index.ts @@ -0,0 +1,16 @@ +/** + * Repo-native coordinator export — public API. + */ + +export * from './types.js'; +export { loadExportContext } from './load-export-context.js'; +export { compileCoordinatorPrompt, estimateTokens } from './compile-coordinator-prompt.js'; +export { renderFrontmatter } from './render-frontmatter.js'; +export { writeCoordinatorAgent } from './write-coordinator-agent.js'; +export type { WriteOptions, WriteResult } from './write-coordinator-agent.js'; +export { startWatchExport } from './watch-export.js'; +export type { WatchExportOptions } from './watch-export.js'; +export { distillDecisions, parseExportDecisions, buildDistillationPrompt } from './distill-decisions.js'; +export type { DistillOptions, DistillResult, ExportDecision, DecisionCategory } from './distill-decisions.js'; +export { checkViability } from './viability-check.js'; +export type { ViabilityConfig, ViabilityIssue, ViabilityResult } from './viability-check.js'; diff --git a/packages/squad-sdk/src/repo-native/load-export-context.ts b/packages/squad-sdk/src/repo-native/load-export-context.ts new file mode 100644 index 000000000..e70bf9212 --- /dev/null +++ b/packages/squad-sdk/src/repo-native/load-export-context.ts @@ -0,0 +1,370 @@ +/** + * Export context loader — reads .squad/ state into a typed IR + * for coordinator prompt compilation. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import type { + SquadExportContext, + LoadExportContextOptions, + TeamMeta, + SquadMemberSummary, + RoutingMeta, + RoutingRuleSummary, + CeremonySummary, + CoordinatorMeta, + MemoryBootstrapPlan, + DispatchPlan, +} from './types.js'; + +const BASELINE_SKILLS = [ + 'squad-conventions', + 'agent-collaboration', + 'reviewer-protocol', + 'test-discipline', + 'secret-handling', + 'session-recovery', + 'git-workflow', +]; + +function readIfExists(filePath: string): string | undefined { + try { + return fs.readFileSync(filePath, 'utf-8'); + } catch { + return undefined; + } +} + +function readJsonIfExists(filePath: string): Record | undefined { + const raw = readIfExists(filePath); + if (!raw) return undefined; + try { + return JSON.parse(raw); + } catch { + return undefined; + } +} + +function listDirNames(dirPath: string): string[] { + try { + return fs.readdirSync(dirPath, { withFileTypes: true }) + .filter(d => d.isDirectory()) + .map(d => d.name); + } catch { + return []; + } +} + +function listSkillNames(skillsDir: string): string[] { + return listDirNames(skillsDir); +} + +/** + * Parse team.md into team metadata. + * Expects a table under ## Members with columns: Name | Role | Charter | Status + */ +function parseTeamMd(content: string | undefined): Omit & { memberRows: Array<{ name: string; role: string; charterPath: string }> } { + const result: { name: string; mission?: string; user?: string; memberRows: Array<{ name: string; role: string; charterPath: string }> } = { + name: 'Squad', + memberRows: [], + }; + + if (!content) return result; + + // Extract squad name from H1 + const h1Match = content.match(/^#\s+(.+)$/m); + if (h1Match?.[1]) result.name = h1Match[1].trim(); + + // Extract mission from blockquote or ## Project Context + const bqMatch = content.match(/^>\s+(.+)$/m); + if (bqMatch?.[1]) result.mission = bqMatch[1].trim(); + + // Extract user from **User:** pattern + const userMatch = content.match(/\*\*User:\*\*\s*(.+)/); + if (userMatch?.[1]) result.user = userMatch[1].trim(); + + // Parse members table + const tableRegex = /\|\s*(.+?)\s*\|\s*(.+?)\s*\|\s*(.+?)\s*\|\s*(.+?)\s*\|/g; + let match: RegExpExecArray | null; + let headerSkipped = false; + let separatorSkipped = false; + + while ((match = tableRegex.exec(content)) !== null) { + const col1 = match[1] ?? ''; + const col2 = match[2] ?? ''; + const col3 = match[3] ?? ''; + // Skip header row + if (!headerSkipped && /name/i.test(col1)) { + headerSkipped = true; + continue; + } + // Skip separator row + if (!separatorSkipped && /^[-:]+$/.test(col1.trim())) { + separatorSkipped = true; + continue; + } + if (/^[-:]+$/.test(col1.trim())) continue; + + // v008: Filter out non-member rows (category headers, placeholders, empty) + const name = col1.trim(); + const role = col2.trim(); + if (!name || !role) continue; + // Skip rows where name/role are just dashes, "N/A", or markdown emphasis only + if (/^[-—]+$/.test(name) || /^[-—]+$/.test(role)) continue; + if (/^n\/?a$/i.test(name) || /^n\/?a$/i.test(role)) continue; + // Skip if col1 looks like a category header (bold-only with no slug-worthy content) + if (/^\*\*[^*]+\*\*$/.test(name) && (!col3.trim() || col3.trim() === '—' || col3.trim() === '-')) continue; + + result.memberRows.push({ + name, + role, + charterPath: col3.trim(), + }); + } + + return result; +} + +/** + * Parse routing.md into routing metadata. + */ +function parseRoutingMd(content: string | undefined): RoutingMeta { + const result: RoutingMeta = { rules: [], principles: [] }; + if (!content) return result; + + // Parse routing table + const tableRegex = /\|\s*(.+?)\s*\|\s*(.+?)\s*\|\s*(.+?)\s*\|/g; + let match: RegExpExecArray | null; + let headerSkipped = false; + let separatorSkipped = false; + + while ((match = tableRegex.exec(content)) !== null) { + const col1 = match[1] ?? ''; + const col2 = match[2] ?? ''; + const col3 = match[3] ?? ''; + if (!headerSkipped && /work\s*type/i.test(col1)) { + headerSkipped = true; + continue; + } + if (!separatorSkipped && /^[-:]+$/.test(col1.trim())) { + separatorSkipped = true; + continue; + } + if (/^[-:]+$/.test(col1.trim())) continue; + + result.rules.push({ + workType: col1.trim(), + routeTo: col2.trim(), + examples: col3.trim() || undefined, + }); + } + + // Parse numbered rules/principles + const rulesSection = content.match(/## Rules\s*\n([\s\S]*?)(?=\n##|$)/); + if (rulesSection?.[1]) { + const lines = rulesSection[1].split('\n'); + for (const line of lines) { + const ruleMatch = line.match(/^\d+\.\s+(.+)/); + if (ruleMatch?.[1]) result.principles.push(ruleMatch[1].trim()); + } + } + + return result; +} + +/** + * Parse ceremonies.md into ceremony summaries. + */ +function parseCeremoniesMd(content: string | undefined): CeremonySummary[] { + if (!content) return []; + const ceremonies: CeremonySummary[] = []; + + // Split by ## headings + const sections = content.split(/^## /m).slice(1); + for (const section of sections) { + const lines = section.split('\n'); + const name = lines[0]?.trim() || ''; + if (!name) continue; + + const ceremony: CeremonySummary = { name, trigger: '' }; + + for (const line of lines.slice(1)) { + const triggerMatch = line.match(/[-*]\s*(?:\*\*)?Trigger(?:\*\*)?:\s*(.+)/i); + const facilMatch = line.match(/[-*]\s*(?:\*\*)?Facilitator(?:\*\*)?:\s*(.+)/i); + const partMatch = line.match(/[-*]\s*(?:\*\*)?Participants(?:\*\*)?:\s*(.+)/i); + + if (triggerMatch?.[1]) ceremony.trigger = triggerMatch[1].trim(); + if (facilMatch?.[1]) ceremony.facilitator = facilMatch[1].trim(); + if (partMatch?.[1]) ceremony.participants = partMatch[1].split(/[,+]/).map(p => p.trim()); + } + + if (ceremony.trigger) ceremonies.push(ceremony); + } + + return ceremonies; +} + +/** + * Derive charter summary from charter.md content. + */ +function summarizeCharter(content: string | undefined): string { + if (!content) return ''; + + // Extract identity/role lines + const lines: string[] = []; + const roleMatch = content.match(/(?:##\s*(?:Role|Identity|What I Own))\s*\n([\s\S]*?)(?=\n##|$)/i); + if (roleMatch?.[1]) { + const bullets = roleMatch[1].split('\n') + .filter(l => l.match(/^[-*]\s+/)) + .slice(0, 4) + .map(l => l.replace(/^[-*]\s+/, '').trim()); + lines.push(...bullets); + } + + return lines.join('; ') || content.split('\n').find(l => l.trim() && !l.startsWith('#'))?.trim() || ''; +} + +/** + * Load export context from .squad/ state. + */ +export async function loadExportContext( + root: string, + squadRoot: string, + options: LoadExportContextOptions, +): Promise { + const sourceFiles: string[] = []; + + // Read source files + const teamMdPath = path.join(squadRoot, 'team.md'); + const routingMdPath = path.join(squadRoot, 'routing.md'); + const ceremoniesMdPath = path.join(squadRoot, 'ceremonies.md'); + const decisionsMdPath = path.join(squadRoot, 'decisions.md'); + const configJsonPath = path.join(squadRoot, 'config.json'); + + const teamMd = readIfExists(teamMdPath); + const routingMd = readIfExists(routingMdPath); + const ceremoniesMd = readIfExists(ceremoniesMdPath); + const decisionsMd = readIfExists(decisionsMdPath); + const configJson = readJsonIfExists(configJsonPath); + + if (teamMd !== undefined) sourceFiles.push('.squad/team.md'); + if (routingMd !== undefined) sourceFiles.push('.squad/routing.md'); + if (ceremoniesMd !== undefined) sourceFiles.push('.squad/ceremonies.md'); + if (decisionsMd !== undefined) sourceFiles.push('.squad/decisions.md'); + if (configJson !== undefined) sourceFiles.push('.squad/config.json'); + + // Parse team + const teamParsed = parseTeamMd(teamMd); + + // Load member details + const members: SquadMemberSummary[] = []; + for (const row of teamParsed.memberRows) { + const slug = row.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); + const charterPath = row.charterPath || `.squad/agents/${slug}/charter.md`; + + // Validate charter path stays within repo root (prevent path traversal) + const charterFullPath = path.resolve(root, charterPath); + const normalizedRoot = path.resolve(root); + if (!charterFullPath.startsWith(normalizedRoot + path.sep) && charterFullPath !== normalizedRoot) { + throw new Error( + `Charter path "${charterPath}" resolves outside the repository root. ` + + `Paths must be relative and stay within the repo.` + ); + } + const charterContent = readIfExists(charterFullPath); + + if (charterContent !== undefined) { + sourceFiles.push(charterPath); + } + + members.push({ + slug, + displayName: row.name, + role: row.role, + charterPath, + charterSummary: summarizeCharter(charterContent), + inlineMode: 'full-summary', + }); + } + + // Resolve skills + const skillsDir = path.join(root, '.copilot', 'skills'); + const allSkillNames = listSkillNames(skillsDir); + let resolvedSkills: string[]; + + if (options.skillMode === 'none') { + resolvedSkills = []; + } else if (options.skillMode === 'all') { + resolvedSkills = allSkillNames; + } else if (Array.isArray(options.skillMode)) { + resolvedSkills = options.skillMode; + } else { + // baseline: include only known coordination skills that actually exist + resolvedSkills = BASELINE_SKILLS.filter(s => allSkillNames.includes(s)); + } + + // Resolve model + const model = options.modelOverride + || (configJson?.coordinator as Record)?.model as string | undefined + || undefined; + + // Derive description + const description = options.descriptionOverride + || teamParsed.mission + || `Repository-native squad coordinator exported from .squad/`; + + const coordinator: CoordinatorMeta = { + displayName: 'Squad', + description, + model, + tools: '*', + skills: resolvedSkills, + }; + + const team: TeamMeta = { + name: teamParsed.name, + mission: teamParsed.mission, + user: teamParsed.user, + members, + }; + + const routing = parseRoutingMd(routingMd); + const ceremonies = parseCeremoniesMd(ceremoniesMd); + + const memoryBootstrap: MemoryBootstrapPlan = { + steps: [ + 'Read `.squad/team.md` for roster and mission.', + 'Read `.squad/routing.md` for routing rules.', + 'Read `.squad/ceremonies.md` for ceremony triggers.', + 'Read `.squad/decisions.md` only when repository conventions or prior decisions matter.', + 'Before dispatching a specialist, read that specialist\'s charter file from `.squad/agents//charter.md` if detailed boundaries or tone matter.', + ], + }; + + const dispatch: DispatchPlan = { + protocol: [ + 'Choose the smallest responsible set of specialists.', + 'Read the target charter(s) only when needed for higher-fidelity dispatch.', + 'Pass the user\'s goal, repository context, and relevant source findings.', + 'Ask each specialist for concrete deliverables.', + 'Launch independent work in parallel.', + 'Synthesize the returned work into one response for the user.', + ], + }; + + return { + repoRoot: root, + squadRoot, + outputPath: options.outputPath, + generatedAt: options.generatedAt, + coordinator, + team, + routing, + ceremonies, + decisions: decisionsMd, + memoryBootstrap, + dispatch, + sourceFiles, + }; +} diff --git a/packages/squad-sdk/src/repo-native/render-frontmatter.ts b/packages/squad-sdk/src/repo-native/render-frontmatter.ts new file mode 100644 index 000000000..621553855 --- /dev/null +++ b/packages/squad-sdk/src/repo-native/render-frontmatter.ts @@ -0,0 +1,57 @@ +/** + * YAML frontmatter renderer for the coordinator agent file. + */ + +import type { CoordinatorMeta } from './types.js'; + +/** + * Quote a YAML scalar value safely. Always double-quotes to handle + * special characters like :, #, leading/trailing whitespace, and newlines. + */ +function quoteYamlScalar(value: string): string { + const escaped = value + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/\t/g, '\\t'); + return `"${escaped}"`; +} + +/** + * Render YAML frontmatter for the coordinator agent. + * Produces minimal, valid, deterministic, surface-safe YAML. + * All scalar values are quoted to prevent YAML injection. + */ +export function renderFrontmatter(coordinator: CoordinatorMeta): string { + const lines: string[] = ['---']; + + lines.push(`name: ${quoteYamlScalar(coordinator.displayName)}`); + lines.push(`description: ${quoteYamlScalar(coordinator.description)}`); + + if (coordinator.tools === '*') { + lines.push(`tools: "*"`); + } else { + lines.push(`tools:`); + for (const tool of coordinator.tools) { + lines.push(` - ${quoteYamlScalar(tool)}`); + } + } + + if (coordinator.model) { + lines.push(`model: ${quoteYamlScalar(coordinator.model)}`); + } + + if (coordinator.skills.length > 0) { + lines.push(`skills:`); + for (const skill of coordinator.skills) { + lines.push(` - ${quoteYamlScalar(skill)}`); + } + } + + lines.push(`user-invocable: true`); + lines.push(`deferred-tool-loading: true`); + lines.push('---'); + + return lines.join('\n'); +} diff --git a/packages/squad-sdk/src/repo-native/types.ts b/packages/squad-sdk/src/repo-native/types.ts new file mode 100644 index 000000000..700da941d --- /dev/null +++ b/packages/squad-sdk/src/repo-native/types.ts @@ -0,0 +1,115 @@ +/** + * Coordinator-as-Agent export types. + * Typed intermediate representation (IR) for compiling .squad/ state + * into a repository-native custom agent at .github/agents/squad.md. + */ + +export interface SquadExportContext { + repoRoot: string; + squadRoot: string; + outputPath: string; + generatedAt: string; + coordinator: CoordinatorMeta; + team: TeamMeta; + routing: RoutingMeta; + ceremonies: CeremonySummary[]; + decisions?: string; + memoryBootstrap: MemoryBootstrapPlan; + dispatch: DispatchPlan; + sourceFiles: string[]; +} + +export interface CoordinatorMeta { + displayName: string; + description: string; + model?: string; + tools: '*' | string[]; + skills: string[]; +} + +export interface TeamMeta { + name: string; + mission?: string; + user?: string; + members: SquadMemberSummary[]; +} + +export interface SquadMemberSummary { + slug: string; + displayName: string; + role: string; + charterPath: string; + charterSummary: string; + inlineMode: 'full-summary' | 'compact-summary' | 'name-role-only'; +} + +export interface RoutingMeta { + rules: RoutingRuleSummary[]; + fallback?: string; + principles: string[]; +} + +export interface RoutingRuleSummary { + workType: string; + routeTo: string; + examples?: string; +} + +export interface CeremonySummary { + name: string; + trigger: string; + facilitator?: string; + participants?: string[]; +} + +export interface MemoryBootstrapPlan { + steps: string[]; +} + +export interface DispatchPlan { + protocol: string[]; +} + +export interface CoordinatorExportOptions { + outPath: string; + model?: string; + description?: string; + skills: 'baseline' | 'all' | 'none' | string[]; + check: boolean; + watch: boolean; + dryRun: boolean; + force: boolean; + cleanLegacyAgent: boolean; + maxPromptTokens: number; + compact: boolean; + /** Configurable character limit for the output (default: 30000) */ + charLimit: number; +} + +export interface CompileCoordinatorPromptOptions { + softLimit: number; + hardLimit: number; + compact: boolean; + /** Override default CCA character hard limit (default: 30000) */ + charHardLimit?: number; + /** Override default CCA character target (default: 29000) */ + charTarget?: number; +} + +export interface CompiledCoordinatorPrompt { + markdown: string; + estimatedTokens: number; + /** Actual character count of the compiled markdown */ + charCount: number; + appliedCompactions: string[]; + sectionSizes: Array<{ name: string; tokens: number }>; + mode: 'full' | 'compact' | 'lazy-load'; +} + +export interface LoadExportContextOptions { + outputPath: string; + generatedAt: string; + modelOverride?: string; + descriptionOverride?: string; + skillMode: 'baseline' | 'all' | 'none' | string[]; +} diff --git a/packages/squad-sdk/src/repo-native/viability-check.ts b/packages/squad-sdk/src/repo-native/viability-check.ts new file mode 100644 index 000000000..6c30d1f09 --- /dev/null +++ b/packages/squad-sdk/src/repo-native/viability-check.ts @@ -0,0 +1,201 @@ +/** + * Viability gate — pre-flight assessment of whether a squad can produce + * a valuable coordinator export within the character budget. + * + * v008: Quality-first philosophy. We'd rather refuse with honest advice + * than produce a compressed artifact that lacks enough context to be useful. + * Use --force to override if you know what you're doing. + */ + +import type { SquadExportContext } from './types.js'; + +export interface ViabilityConfig { + /** Hard character limit for CCA agent file. Default: 30000 */ + charLimit: number; + /** Safety margin subtracted from charLimit to get the target. Default: 1000 */ + safetyMargin: number; + /** Force export even when viability check fails. Default: false */ + force: boolean; +} + +export interface ViabilityIssue { + severity: 'warn' | 'error'; + code: string; + message: string; + detail?: string; +} + +export interface ViabilityResult { + viable: boolean; + issues: ViabilityIssue[]; + /** Human-readable summary for CLI output */ + summary: string; + /** Estimated content complexity score (higher = harder to fit) */ + complexityScore: number; +} + +const DEFAULT_CONFIG: ViabilityConfig = { + charLimit: 30_000, + safetyMargin: 1_000, + force: false, +}; + +/** + * Thresholds that scale with the configured character limit. + * At 30K chars, agent count >25 is a warning, >50 is an error. + * These scale linearly if the limit changes. + */ +function getThresholds(charLimit: number) { + const scale = charLimit / 30_000; + return { + agentWarn: Math.floor(25 * scale), + agentError: Math.floor(50 * scale), + routingRulesWarn: Math.floor(60 * scale), + routingRulesError: Math.floor(100 * scale), + coverageWarn: 0.50, + coverageError: 0.25, + }; +} + +/** + * Estimate how many characters the roster alone would consume. + */ +function estimateRosterChars(context: SquadExportContext): number { + return context.team.members.reduce((sum, m) => { + const line = `- **${m.displayName}** (\`${m.slug}\`) — ${m.role}. ${m.charterSummary}`; + return sum + line.length + 1; + }, 0); +} + +/** + * Estimate how many characters the dispatch rules would consume. + */ +function estimateDispatchChars(context: SquadExportContext): number { + return context.routing.rules.reduce((sum, r) => { + const line = `- If the request is mainly about ${r.workType.toLowerCase()}, route to \`${r.routeTo}\`.`; + return sum + line.length + 1; + }, 0); +} + +/** + * Calculate coverage: what fraction of the budget would be + * consumed by structural content alone (roster + dispatch), + * leaving the remainder for mission, ceremonies, decisions, etc. + */ +function calculateCoverage(context: SquadExportContext, charTarget: number): number { + const structural = estimateRosterChars(context) + estimateDispatchChars(context); + const remaining = charTarget - structural; + return remaining / charTarget; +} + +/** + * Run the viability pre-flight check. + * + * Returns a ViabilityResult. If viable=false and force=false, the export + * should be refused with the summary message as guidance. + */ +export function checkViability( + context: SquadExportContext, + config: Partial = {}, +): ViabilityResult { + const cfg = { ...DEFAULT_CONFIG, ...config }; + const charTarget = cfg.charLimit - cfg.safetyMargin; + const thresholds = getThresholds(cfg.charLimit); + const issues: ViabilityIssue[] = []; + + const memberCount = context.team.members.length; + const ruleCount = context.routing.rules.length; + const coverage = calculateCoverage(context, charTarget); + + // Agent count checks + if (memberCount > thresholds.agentError) { + issues.push({ + severity: 'error', + code: 'AGENTS_TOO_MANY', + message: `This squad has ${memberCount} specialists — even in compact mode, the roster alone may consume most of the ${cfg.charLimit}-char budget.`, + detail: `Threshold: >${thresholds.agentError} agents triggers this check. Consider splitting into sub-squads or using lazy-load mode.`, + }); + } else if (memberCount > thresholds.agentWarn) { + issues.push({ + severity: 'warn', + code: 'AGENTS_LARGE', + message: `${memberCount} specialists is a large team. The export will likely require compact or lazy-load mode.`, + }); + } + + // Routing rules checks + if (ruleCount > thresholds.routingRulesError) { + issues.push({ + severity: 'error', + code: 'RULES_TOO_MANY', + message: `${ruleCount} routing rules is very dense. Even distilled, this many rules may produce a dispatch section that dominates the budget.`, + detail: `Threshold: >${thresholds.routingRulesError} rules triggers this check. Consider grouping related rules or using broader categories.`, + }); + } else if (ruleCount > thresholds.routingRulesWarn) { + issues.push({ + severity: 'warn', + code: 'RULES_DENSE', + message: `${ruleCount} routing rules will require significant distillation to fit.`, + }); + } + + // Coverage checks + if (coverage < thresholds.coverageError) { + issues.push({ + severity: 'error', + code: 'COVERAGE_INSUFFICIENT', + message: `Structural content (roster + dispatch) would consume ${Math.round((1 - coverage) * 100)}% of the budget, leaving too little room for mission context, decisions, and ceremony triggers.`, + detail: `The exported coordinator needs at least 25% of the budget for contextual sections to be useful.`, + }); + } else if (coverage < thresholds.coverageWarn) { + issues.push({ + severity: 'warn', + code: 'COVERAGE_TIGHT', + message: `Structural content may consume ${Math.round((1 - coverage) * 100)}% of the budget. Decisions and ceremonies will be heavily trimmed.`, + }); + } + + // Compute complexity score (0-100, higher = harder) + const complexityScore = Math.min(100, Math.round( + (memberCount / thresholds.agentError) * 40 + + (ruleCount / thresholds.routingRulesError) * 30 + + ((1 - coverage) / (1 - thresholds.coverageError)) * 30 + )); + + const hasErrors = issues.some(i => i.severity === 'error'); + const viable = !hasErrors || cfg.force; + + const summary = hasErrors + ? buildRefusalMessage(issues, cfg) + : issues.length > 0 + ? buildWarningMessage(issues) + : 'Squad viability check passed — export should produce a useful coordinator.'; + + return { viable, issues, summary, complexityScore }; +} + +function buildRefusalMessage(issues: ViabilityIssue[], config: ViabilityConfig): string { + const errors = issues.filter(i => i.severity === 'error'); + const lines: string[] = [ + `We're pretty sure this squad is too complex to produce a valuable export within the ${config.charLimit}-char limit.`, + '', + ]; + + for (const err of errors) { + lines.push(`• ${err.message}`); + if (err.detail) lines.push(` ${err.detail}`); + } + + lines.push(''); + lines.push(`Use --force if you want to attempt it anyway — we'll do our best, but the output may lack enough context to be useful for an AI agent.`); + + return lines.join('\n'); +} + +function buildWarningMessage(issues: ViabilityIssue[]): string { + const lines = ['Viability check passed with notes:']; + for (const issue of issues) { + lines.push(` ⚠ ${issue.message}`); + } + return lines.join('\n'); +} diff --git a/packages/squad-sdk/src/repo-native/watch-export.ts b/packages/squad-sdk/src/repo-native/watch-export.ts new file mode 100644 index 000000000..102f9e533 --- /dev/null +++ b/packages/squad-sdk/src/repo-native/watch-export.ts @@ -0,0 +1,138 @@ +/** + * Watch mode for coordinator export. + * Re-exports when .squad/ source files change. + * Watches the .squad/ directory (not individual files) so that file + * creations, renames, and deletions are also detected. + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +export interface WatchExportOptions { + root: string; + squadRoot: string; + onRebuild: () => Promise; + debounceMs?: number; +} + +const DEFAULT_DEBOUNCE_MS = 400; + +/** + * Relevant file patterns within .squad/ that should trigger a rebuild. + */ +const RELEVANT_EXTENSIONS = new Set(['.md', '.json']); + +function isRelevantFile(filename: string): boolean { + const ext = path.extname(filename).toLowerCase(); + return RELEVANT_EXTENSIONS.has(ext); +} + +/** + * Watch a directory non-recursively (cross-platform safe). + * Returns a watcher or null if the directory doesn't exist. + */ +function watchDir( + dirPath: string, + onChange: (filename: string | null) => void, +): fs.FSWatcher | null { + try { + if (!fs.existsSync(dirPath)) return null; + return fs.watch(dirPath, { persistent: true }, (_event, filename) => { + onChange(filename as string | null); + }); + } catch { + return null; + } +} + +/** + * Watch a directory and its immediate subdirectories (one level deep). + * Handles platforms where `{ recursive: true }` is unsupported. + */ +function watchDirRecursive( + dirPath: string, + onChange: (filename: string | null) => void, +): fs.FSWatcher[] { + const watchers: fs.FSWatcher[] = []; + + // Watch the directory itself + const rootWatcher = watchDir(dirPath, onChange); + if (rootWatcher) watchers.push(rootWatcher); + + // Watch immediate subdirectories + try { + const entries = fs.readdirSync(dirPath, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory()) { + const subWatcher = watchDir(path.join(dirPath, entry.name), onChange); + if (subWatcher) watchers.push(subWatcher); + } + } + } catch { + // Directory may not be readable + } + + return watchers; +} + +/** + * Start watching .squad/ source files for changes. + * Watches directories instead of individual files, so new file creations + * and renames are detected. Uses non-recursive watching with manual + * subdirectory enumeration for cross-platform compatibility. + * Returns a cleanup function to stop watching. + */ +export function startWatchExport(options: WatchExportOptions): () => void { + const { root, squadRoot, onRebuild, debounceMs = DEFAULT_DEBOUNCE_MS } = options; + + let debounceTimer: ReturnType | null = null; + const watchers: fs.FSWatcher[] = []; + + const scheduleRebuild = (filename: string | null) => { + // Filter out irrelevant file changes + if (filename && !isRelevantFile(filename)) return; + + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(async () => { + try { + await onRebuild(); + } catch (err) { + console.error(`Watch rebuild failed: ${(err as Error).message}`); + } + }, debounceMs); + }; + + // Watch the .squad/ directory and subdirectories (agents/*) + const squadWatchers = watchDirRecursive(squadRoot, scheduleRebuild); + watchers.push(...squadWatchers); + + // Watch the agents subdirectories (one more level deep for charter files) + const agentsDir = path.join(squadRoot, 'agents'); + try { + if (fs.existsSync(agentsDir)) { + const agentDirs = fs.readdirSync(agentsDir, { withFileTypes: true }); + for (const entry of agentDirs) { + if (entry.isDirectory()) { + const subAgentDir = path.join(agentsDir, entry.name); + const watcher = watchDir(subAgentDir, scheduleRebuild); + if (watcher) watchers.push(watcher); + } + } + } + } catch { + // Agents dir structure may not exist + } + + // Watch skills directory + const skillsDir = path.join(root, '.copilot', 'skills'); + const skillWatchers = watchDirRecursive(skillsDir, scheduleRebuild); + watchers.push(...skillWatchers); + + // Return cleanup function + return () => { + if (debounceTimer) clearTimeout(debounceTimer); + for (const watcher of watchers) { + watcher.close(); + } + }; +} diff --git a/packages/squad-sdk/src/repo-native/write-coordinator-agent.ts b/packages/squad-sdk/src/repo-native/write-coordinator-agent.ts new file mode 100644 index 000000000..115591928 --- /dev/null +++ b/packages/squad-sdk/src/repo-native/write-coordinator-agent.ts @@ -0,0 +1,125 @@ +/** + * File writer for the coordinator agent export. + * Handles write, check, dry-run, and legacy collision detection. + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +export interface WriteOptions { + root: string; + outputPath: string; + output: string; + check: boolean; + dryRun: boolean; + force: boolean; + cleanLegacyAgent: boolean; +} + +export interface WriteResult { + written: boolean; + driftDetected: boolean; + /** The target file exists but was not generated by squad export */ + userOwned: boolean; + legacyCollision: boolean; + legacyCleaned: boolean; + outputPath: string; +} + +const GENERATED_MARKER = ''; +const PROTECTED_AGENT_FILE = 'squad.agent.md'; + +function isProtectedAgentFile(filePath: string): boolean { + return path.basename(filePath) === PROTECTED_AGENT_FILE; +} + +/** + * Check if an existing file was generated by squad export. + */ +function isSquadGenerated(filePath: string): boolean { + try { + const content = fs.readFileSync(filePath, 'utf-8'); + return content.includes(GENERATED_MARKER); + } catch { + return false; + } +} + +/** + * Reserved for future legacy detection. + */ +function detectLegacyAgentFile(_root: string): string | null { + return null; +} + +/** + * Write the coordinator agent file with safety checks. + */ +export function writeCoordinatorAgent(options: WriteOptions): WriteResult { + const { root, outputPath, output, check, dryRun, force } = options; + const fullOutputPath = path.isAbsolute(outputPath) + ? outputPath + : path.join(root, outputPath); + + if (isProtectedAgentFile(fullOutputPath)) { + throw new Error( + "Cannot write to squad.agent.md — this file is the project's coordinator governance file and must not be modified by export. Use a different output path (default: squad.md)." + ); + } + + const result: WriteResult = { + written: false, + driftDetected: false, + userOwned: false, + legacyCollision: false, + legacyCleaned: false, + outputPath: fullOutputPath, + }; + + // Reserved for future legacy file handling. + const legacyPath = detectLegacyAgentFile(root); + if (legacyPath) { + result.legacyCollision = true; + } + + // Check mode: compare without writing + if (check) { + try { + const existing = fs.readFileSync(fullOutputPath, 'utf-8'); + if (!existing.includes(GENERATED_MARKER)) { + // File exists but is user-owned — distinct from drift + result.userOwned = true; + return result; + } + result.driftDetected = existing !== output; + } catch { + // File doesn't exist = drift + result.driftDetected = true; + } + return result; + } + + // Dry-run mode: print but don't write + if (dryRun) { + process.stdout.write(output); + return result; + } + + // Safety: don't overwrite user-owned files unless --force + if (fs.existsSync(fullOutputPath) && !isSquadGenerated(fullOutputPath) && !force) { + throw new Error( + `${fullOutputPath} exists and was not generated by squad export. ` + + `Use --force to overwrite or remove it manually.` + ); + } + + // Ensure directory exists + const dir = path.dirname(fullOutputPath); + fs.mkdirSync(dir, { recursive: true }); + + // Write the file + fs.writeFileSync(fullOutputPath, output, 'utf-8'); + result.written = true; + + return result; +} diff --git a/test/repo-native/roster-parsing.test.ts b/test/repo-native/roster-parsing.test.ts new file mode 100644 index 000000000..c0109b477 --- /dev/null +++ b/test/repo-native/roster-parsing.test.ts @@ -0,0 +1,95 @@ +/** + * Tests for roster parsing bug fix — v008. + * Ensures category headers and placeholder rows are filtered out. + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { loadExportContext } from '../../packages/squad-sdk/src/repo-native/load-export-context.js'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; + +function setupSquadDir(teamMdContent: string): string { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'squad-test-')); + const squadDir = path.join(tmp, '.squad'); + fs.mkdirSync(squadDir, { recursive: true }); + fs.writeFileSync(path.join(squadDir, 'team.md'), teamMdContent); + return tmp; +} + +describe('roster parsing', () => { + it('filters category header rows', async () => { + const teamMd = `# My Squad + +## Members + +| Name | Role | Charter | Status | +|------|------|---------|--------| +| **Backend** | — | — | — | +| Alice | Backend Engineer | .squad/agents/alice/charter.md | active | +| Bob | API Developer | .squad/agents/bob/charter.md | active | +`; + const root = setupSquadDir(teamMd); + const ctx = await loadExportContext(root, path.join(root, '.squad'), { + outputPath: '.github/agents/squad.md', + generatedAt: new Date().toISOString(), + skillMode: 'none', + }); + + assert.equal(ctx.team.members.length, 2); + assert.equal(ctx.team.members[0]!.displayName, 'Alice'); + assert.equal(ctx.team.members[1]!.displayName, 'Bob'); + + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('filters rows with dash-only roles', async () => { + const teamMd = `# Squad + +## Members + +| Name | Role | Charter | Status | +|------|------|---------|--------| +| Charlie | Lead | .squad/agents/charlie/charter.md | active | +| --- | --- | --- | --- | +| Dana | Designer | .squad/agents/dana/charter.md | active | +`; + const root = setupSquadDir(teamMd); + const ctx = await loadExportContext(root, path.join(root, '.squad'), { + outputPath: '.github/agents/squad.md', + generatedAt: new Date().toISOString(), + skillMode: 'none', + }); + + assert.equal(ctx.team.members.length, 2); + assert.equal(ctx.team.members[0]!.displayName, 'Charlie'); + assert.equal(ctx.team.members[1]!.displayName, 'Dana'); + + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('keeps valid members with real names and roles', async () => { + const teamMd = `# Squad + +## Members + +| Name | Role | Charter | Status | +|------|------|---------|--------| +| Eve | Security Specialist | .squad/agents/eve/charter.md | active | +| Frank | DevOps Lead | .squad/agents/frank/charter.md | active | +`; + const root = setupSquadDir(teamMd); + const ctx = await loadExportContext(root, path.join(root, '.squad'), { + outputPath: '.github/agents/squad.md', + generatedAt: new Date().toISOString(), + skillMode: 'none', + }); + + assert.equal(ctx.team.members.length, 2); + assert.equal(ctx.team.members[0]!.slug, 'eve'); + assert.equal(ctx.team.members[1]!.slug, 'frank'); + + fs.rmSync(root, { recursive: true, force: true }); + }); +}); diff --git a/test/repo-native/viability-check.test.ts b/test/repo-native/viability-check.test.ts new file mode 100644 index 000000000..288629a77 --- /dev/null +++ b/test/repo-native/viability-check.test.ts @@ -0,0 +1,110 @@ +/** + * Tests for the viability gate — v008 quality-first philosophy. + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { checkViability } from '../../packages/squad-sdk/src/repo-native/viability-check.js'; +import type { SquadExportContext, SquadMemberSummary } from '../../packages/squad-sdk/src/repo-native/types.js'; + +function makeMember(slug: string): SquadMemberSummary { + return { + slug, + displayName: slug.charAt(0).toUpperCase() + slug.slice(1), + role: 'Specialist', + charterPath: `.squad/agents/${slug}/charter.md`, + charterSummary: 'Does things.', + inlineMode: 'full-summary', + }; +} + +function makeContext(memberCount: number, ruleCount: number): SquadExportContext { + const members = Array.from({ length: memberCount }, (_, i) => makeMember(`agent-${i}`)); + const rules = Array.from({ length: ruleCount }, (_, i) => ({ + workType: `work-type-${i}`, + routeTo: members[i % memberCount]!.slug, + })); + + return { + repoRoot: '/tmp/test', + squadRoot: '/tmp/test/.squad', + outputPath: '.github/agents/squad.md', + generatedAt: new Date().toISOString(), + coordinator: { + displayName: 'Squad', + description: 'Test coordinator', + tools: '*', + skills: [], + }, + team: { + name: 'Test Squad', + mission: 'Test mission', + members, + }, + routing: { + rules, + principles: [], + }, + ceremonies: [], + memoryBootstrap: { steps: [] }, + dispatch: { protocol: ['Step 1', 'Step 2'] }, + sourceFiles: [], + }; +} + +describe('viability gate', () => { + it('passes for small squads', () => { + const ctx = makeContext(5, 10); + const result = checkViability(ctx); + assert.equal(result.viable, true); + assert.equal(result.issues.length, 0); + }); + + it('warns for large squads (>25 agents)', () => { + const ctx = makeContext(30, 30); + const result = checkViability(ctx); + assert.equal(result.viable, true); + const warns = result.issues.filter(i => i.severity === 'warn'); + assert.ok(warns.length > 0); + assert.ok(warns.some(w => w.code === 'AGENTS_LARGE')); + }); + + it('errors for very large squads (>50 agents)', () => { + const ctx = makeContext(55, 55); + const result = checkViability(ctx); + assert.equal(result.viable, false); + const errors = result.issues.filter(i => i.severity === 'error'); + assert.ok(errors.some(e => e.code === 'AGENTS_TOO_MANY')); + assert.ok(result.summary.includes('--force')); + }); + + it('passes with --force even when viability fails', () => { + const ctx = makeContext(55, 110); + const result = checkViability(ctx, { force: true }); + assert.equal(result.viable, true); + // Still reports the issues + assert.ok(result.issues.length > 0); + }); + + it('errors for excessive routing rules (>100)', () => { + const ctx = makeContext(10, 110); + const result = checkViability(ctx); + const errors = result.issues.filter(i => i.severity === 'error'); + assert.ok(errors.some(e => e.code === 'RULES_TOO_MANY')); + }); + + it('respects configurable charLimit', () => { + // At 60K limit, thresholds double — 55 agents should only warn, not error + const ctx = makeContext(55, 55); + const result = checkViability(ctx, { charLimit: 60_000 }); + const errors = result.issues.filter(i => i.severity === 'error'); + assert.ok(!errors.some(e => e.code === 'AGENTS_TOO_MANY')); + }); + + it('includes complexity score', () => { + const ctx = makeContext(5, 5); + const result = checkViability(ctx); + assert.ok(typeof result.complexityScore === 'number'); + assert.ok(result.complexityScore >= 0 && result.complexityScore <= 100); + }); +});