diff --git a/packages/squad-cli/bradygaster-squad-cli-0.9.1-build.4.tgz b/packages/squad-cli/bradygaster-squad-cli-0.9.1-build.4.tgz new file mode 100644 index 000000000..0b6144247 Binary files /dev/null and b/packages/squad-cli/bradygaster-squad-cli-0.9.1-build.4.tgz differ diff --git a/packages/squad-cli/src/cli-entry.ts b/packages/squad-cli/src/cli-entry.ts index c503accbd..1c487c20a 100644 --- a/packages/squad-cli/src/cli-entry.ts +++ b/packages/squad-cli/src/cli-entry.ts @@ -90,7 +90,7 @@ function _handleTopLevelSignal(signal: 'SIGINT' | 'SIGTERM'): void { process.on('SIGINT', () => _handleTopLevelSignal('SIGINT')); process.on('SIGTERM', () => _handleTopLevelSignal('SIGTERM')); -import fs from 'node:fs'; +import { FSStorageProvider } from '@bradygaster/squad-sdk'; import path from 'node:path'; import { fatal, SquadError } from './cli/core/errors.js'; import { BOLD, RESET, DIM, RED, GREEN, YELLOW } from './cli/core/output.js'; @@ -409,7 +409,8 @@ async function main(): Promise { const repoSquad = sdk.resolveSquad(process.cwd()); const globalPath = sdk.resolveGlobalSquadPath(); const globalSquadDir = path.join(globalPath, '.squad'); - const globalExists = fs.existsSync(globalSquadDir); + const storage = new FSStorageProvider(); + const globalExists = await storage.exists(globalSquadDir); console.log(`\n${BOLD}Squad Status${RESET}\n`); @@ -446,9 +447,10 @@ async function main(): Promise { const localSquad = sdk.resolveSquad(process.cwd()); const globalPath = sdk.resolveGlobalSquadPath(); const globalSquadDir = path.join(globalPath, '.squad'); + const storage = new FSStorageProvider(); const teamRoot = localSquad ? path.resolve(localSquad, '..') - : (fs.existsSync(globalSquadDir) ? globalPath : null); + : (await storage.exists(globalSquadDir) ? globalPath : null); if (!teamRoot) { fatal('No squad found. Run "squad init" first.'); diff --git a/packages/squad-cli/src/cli/commands/build.ts b/packages/squad-cli/src/cli/commands/build.ts index dee7b1c3d..6980091c2 100644 --- a/packages/squad-cli/src/cli/commands/build.ts +++ b/packages/squad-cli/src/cli/commands/build.ts @@ -17,9 +17,9 @@ * @module cli/commands/build */ -import fs from 'node:fs'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; +import { FSStorageProvider } from '@bradygaster/squad-sdk'; import { success, warn, info, dim, BOLD, RESET, YELLOW, GREEN, RED } from '../core/output.js'; import { fatal } from '../core/errors.js'; @@ -66,6 +66,7 @@ interface LoadedConfig { * 3. squad.config.js */ async function loadSquadConfig(cwd: string): Promise { + const storage = new FSStorageProvider(); const candidates = [ path.join(cwd, 'squad', 'index.ts'), path.join(cwd, 'squad.config.ts'), @@ -73,7 +74,7 @@ async function loadSquadConfig(cwd: string): Promise { ]; for (const candidate of candidates) { - if (fs.existsSync(candidate)) { + if (storage.existsSync(candidate)) { try { const url = pathToFileURL(candidate).href; const mod = await import(url); @@ -430,6 +431,7 @@ interface BuildResult { } function writeFiles(cwd: string, files: GeneratedFile[]): BuildResult { + const storage = new FSStorageProvider(); let written = 0; let skipped = 0; const drifted: string[] = []; @@ -441,13 +443,8 @@ function writeFiles(cwd: string, files: GeneratedFile[]): BuildResult { } const absPath = path.join(cwd, file.relPath); - const dir = path.dirname(absPath); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - - fs.writeFileSync(absPath, file.content, 'utf-8'); + storage.writeSync(absPath, file.content); written++; } @@ -455,6 +452,7 @@ function writeFiles(cwd: string, files: GeneratedFile[]): BuildResult { } function checkDrift(cwd: string, files: GeneratedFile[]): { drifted: string[]; clean: string[] } { + const storage = new FSStorageProvider(); const drifted: string[] = []; const clean: string[] = []; @@ -462,12 +460,12 @@ function checkDrift(cwd: string, files: GeneratedFile[]): { drifted: string[]; c if (isProtected(file.relPath)) continue; const absPath = path.join(cwd, file.relPath); - if (!fs.existsSync(absPath)) { + const existing = storage.readSync(absPath); + if (existing === undefined) { drifted.push(file.relPath); continue; } - const existing = fs.readFileSync(absPath, 'utf-8'); if (existing !== file.content) { drifted.push(file.relPath); } else { @@ -532,9 +530,10 @@ export async function runBuild(cwd: string, options: BuildOptions = {}): Promise // --dry-run mode if (options.dryRun) { + const dryRunStorage = new FSStorageProvider(); info(`\n${BOLD}Dry run${RESET} — would generate ${files.length} file(s):\n`); for (const file of files) { - const exists = fs.existsSync(path.join(cwd, file.relPath)); + const exists = dryRunStorage.existsSync(path.join(cwd, file.relPath)); const label = exists ? `${YELLOW}overwrite${RESET}` : `${GREEN}create${RESET}`; console.log(` ${label} ${file.relPath}`); } diff --git a/packages/squad-cli/src/cli/commands/export.ts b/packages/squad-cli/src/cli/commands/export.ts index 9cb64bd11..d79c04c78 100644 --- a/packages/squad-cli/src/cli/commands/export.ts +++ b/packages/squad-cli/src/cli/commands/export.ts @@ -3,8 +3,9 @@ * Exports squad to squad-export.json */ -import fs from 'node:fs'; +import { statSync } from 'node:fs'; // TODO: statSync().isDirectory() not in StorageProvider import path from 'node:path'; +import { FSStorageProvider } from '@bradygaster/squad-sdk'; import { detectSquadDir } from '../core/detect-squad-dir.js'; import { success, warn } from '../core/output.js'; import { fatal } from '../core/errors.js'; @@ -22,10 +23,11 @@ interface ExportManifest { * Export squad to JSON */ export async function runExport(dest: string, outPath?: string): Promise { + const storage = new FSStorageProvider(); const squadInfo = detectSquadDir(dest); const teamMd = path.join(squadInfo.path, 'team.md'); - if (!fs.existsSync(teamMd)) { + if (!storage.existsSync(teamMd)) { fatal('No squad found — run init first'); } @@ -43,8 +45,9 @@ export async function runExport(dest: string, outPath?: string): Promise { for (const file of ['registry.json', 'policy.json', 'history.json']) { const filePath = path.join(castingDir, file); try { - if (fs.existsSync(filePath)) { - manifest.casting[file.replace('.json', '')] = JSON.parse(fs.readFileSync(filePath, 'utf8')); + const raw = storage.readSync(filePath); + if (raw !== undefined) { + manifest.casting[file.replace('.json', '')] = JSON.parse(raw); } } catch (err) { console.error(`Warning: could not read casting/${file}: ${(err as Error).message}`); @@ -54,15 +57,18 @@ export async function runExport(dest: string, outPath?: string): Promise { // Read agents const agentsDir = path.join(squadInfo.path, 'agents'); try { - if (fs.existsSync(agentsDir)) { - for (const entry of fs.readdirSync(agentsDir)) { + if (storage.existsSync(agentsDir)) { + for (const entry of storage.listSync(agentsDir)) { const agentDir = path.join(agentsDir, entry); - if (!fs.statSync(agentDir).isDirectory()) continue; + // TODO: statSync().isDirectory() not in StorageProvider + if (!statSync(agentDir).isDirectory()) continue; const agent: { charter?: string; history?: string } = {}; const charterPath = path.join(agentDir, 'charter.md'); const historyPath = path.join(agentDir, 'history.md'); - if (fs.existsSync(charterPath)) agent.charter = fs.readFileSync(charterPath, 'utf8'); - if (fs.existsSync(historyPath)) agent.history = fs.readFileSync(historyPath, 'utf8'); + const charterContent = storage.readSync(charterPath); + if (charterContent !== undefined) agent.charter = charterContent; + const historyContent = storage.readSync(historyPath); + if (historyContent !== undefined) agent.history = historyContent; manifest.agents[entry] = agent; } } @@ -76,15 +82,16 @@ export async function runExport(dest: string, outPath?: string): Promise { { dir: path.join(squadInfo.path, 'skills'), layout: 'nested' as const }, { dir: path.join(dest, '.ai-team', 'skills'), layout: 'flat' as const }, ]; - const skillsSource = skillSources.find(({ dir }) => fs.existsSync(dir)); + const skillsSource = skillSources.find(({ dir }) => storage.existsSync(dir)); try { if (skillsSource) { - for (const entry of fs.readdirSync(skillsSource.dir)) { + for (const entry of storage.listSync(skillsSource.dir)) { const skillFile = skillsSource.layout === 'nested' ? path.join(skillsSource.dir, entry, 'SKILL.md') : path.join(skillsSource.dir, entry); - if (fs.existsSync(skillFile)) { - manifest.skills.push(fs.readFileSync(skillFile, 'utf8')); + const skillContent = storage.readSync(skillFile); + if (skillContent !== undefined) { + manifest.skills.push(skillContent); } } } @@ -96,7 +103,7 @@ export async function runExport(dest: string, outPath?: string): Promise { const finalOutPath = outPath || path.join(dest, 'squad-export.json'); try { - fs.writeFileSync(finalOutPath, JSON.stringify(manifest, null, 2) + '\n'); + storage.writeSync(finalOutPath, JSON.stringify(manifest, null, 2) + '\n'); } catch (err) { fatal(`Failed to write export file: ${(err as Error).message}`); } diff --git a/packages/squad-cli/src/cli/commands/import.ts b/packages/squad-cli/src/cli/commands/import.ts index f9009f7c0..6290bf5e9 100644 --- a/packages/squad-cli/src/cli/commands/import.ts +++ b/packages/squad-cli/src/cli/commands/import.ts @@ -3,8 +3,9 @@ * Imports squad from squad-export.json */ -import fs from 'node:fs'; import path from 'node:path'; +import { mkdirSync, renameSync } from 'node:fs'; // TODO: mkdirSync (empty dir scaffolding) and renameSync not in StorageProvider +import { FSStorageProvider } from '@bradygaster/squad-sdk'; import { detectSquadDir } from '../core/detect-squad-dir.js'; import { success, warn, info } from '../core/output.js'; import { fatal } from '../core/errors.js'; @@ -23,15 +24,20 @@ interface ImportManifest { * Import squad from JSON */ export async function runImport(dest: string, importPath: string, force: boolean): Promise { + const storage = new FSStorageProvider(); const resolvedPath = path.resolve(importPath); - if (!fs.existsSync(resolvedPath)) { + if (!storage.existsSync(resolvedPath)) { fatal(`Import file not found: ${importPath}`); } let manifest: ImportManifest; try { - manifest = JSON.parse(fs.readFileSync(resolvedPath, 'utf8')); + const raw = storage.readSync(resolvedPath); + if (raw === undefined) { + fatal(`Import file not found: ${importPath}`); + } + manifest = JSON.parse(raw); } catch (err) { fatal(`Invalid JSON in import file: ${(err as Error).message}`); } @@ -54,31 +60,33 @@ export async function runImport(dest: string, importPath: string, force: boolean const squadDir = squadInfo.path; // Conflict detection - if (fs.existsSync(squadDir)) { + if (storage.existsSync(squadDir)) { if (!force) { fatal('A squad already exists here. Use --force to replace (current squad will be archived).'); } // Archive existing squad const ts = new Date().toISOString().replace(/:/g, '-').replace(/\./g, '-'); const archiveDir = path.join(dest, `${squadInfo.name}-archive-${ts}`); - fs.renameSync(squadDir, archiveDir); + // TODO: renameSync not in StorageProvider — requires filesystem-level move + renameSync(squadDir, archiveDir); info(`Archived existing squad to ${path.basename(archiveDir)}`); } // Create directory structure - fs.mkdirSync(path.join(squadDir, 'casting'), { recursive: true }); - fs.mkdirSync(path.join(squadDir, 'decisions', 'inbox'), { recursive: true }); - fs.mkdirSync(path.join(squadDir, 'orchestration-log'), { recursive: true }); - fs.mkdirSync(path.join(squadDir, 'log'), { recursive: true }); - fs.mkdirSync(path.join(dest, '.copilot', 'skills'), { recursive: true }); + // TODO: mkdirSync for empty directory scaffolding not in StorageProvider + mkdirSync(path.join(squadDir, 'casting'), { recursive: true }); + mkdirSync(path.join(squadDir, 'decisions', 'inbox'), { recursive: true }); + mkdirSync(path.join(squadDir, 'orchestration-log'), { recursive: true }); + mkdirSync(path.join(squadDir, 'log'), { recursive: true }); + mkdirSync(path.join(dest, '.copilot', 'skills'), { recursive: true }); // Write empty project-specific files - fs.writeFileSync(path.join(squadDir, 'decisions.md'), ''); - fs.writeFileSync(path.join(squadDir, 'team.md'), ''); + storage.writeSync(path.join(squadDir, 'decisions.md'), ''); + storage.writeSync(path.join(squadDir, 'team.md'), ''); // Write casting state for (const [key, value] of Object.entries(manifest.casting)) { - fs.writeFileSync( + storage.writeSync( path.join(squadDir, 'casting', `${key}.json`), JSON.stringify(value, null, 2) + '\n' ); @@ -93,10 +101,9 @@ export async function runImport(dest: string, importPath: string, force: boolean for (const name of agentNames) { const agent = manifest.agents[name]!; const agentDir = path.join(squadDir, 'agents', name); - fs.mkdirSync(agentDir, { recursive: true }); if (agent.charter) { - fs.writeFileSync(path.join(agentDir, 'charter.md'), agent.charter); + storage.writeSync(path.join(agentDir, 'charter.md'), agent.charter); } // History split: separate portable knowledge from project learnings @@ -105,7 +112,7 @@ export async function runImport(dest: string, importPath: string, force: boolean historyContent = splitHistory(agent.history, sourceProject); } historyContent = `📌 Imported from ${sourceProject} on ${importDate}. Portable knowledge carried over; project learnings from previous project preserved below.\n\n` + historyContent; - fs.writeFileSync(path.join(agentDir, 'history.md'), historyContent); + storage.writeSync(path.join(agentDir, 'history.md'), historyContent); } // Write skills @@ -115,8 +122,7 @@ export async function runImport(dest: string, importPath: string, force: boolean ? nameMatch[1]!.trim().toLowerCase().replace(/\s+/g, '-') : `skill-${manifest.skills.indexOf(skillContent)}`; const skillDir = path.join(dest, '.copilot', 'skills', skillName); - fs.mkdirSync(skillDir, { recursive: true }); - fs.writeFileSync(path.join(skillDir, 'SKILL.md'), skillContent); + storage.writeSync(path.join(skillDir, 'SKILL.md'), skillContent); } // Determine universe for messaging diff --git a/packages/squad-cli/src/cli/core/cast.ts b/packages/squad-cli/src/cli/core/cast.ts index 0664067c6..21d8d7b67 100644 --- a/packages/squad-cli/src/cli/core/cast.ts +++ b/packages/squad-cli/src/cli/core/cast.ts @@ -3,9 +3,8 @@ * @module cli/core/cast */ -import { mkdir, writeFile, readFile } from 'node:fs/promises'; -import { existsSync } from 'node:fs'; import { join } from 'node:path'; +import { FSStorageProvider } from '@bradygaster/squad-sdk'; import { getRoleById, generateCharterFromRole, @@ -487,6 +486,7 @@ function buildRoutingTable(members: CastMember[]): string { * teamRoot is the project root (parent of .squad/). */ export async function createTeam(teamRoot: string, proposal: CastProposal): Promise { + const storage = new FSStorageProvider(); const squadDir = join(teamRoot, '.squad'); const agentsDir = join(squadDir, 'agents'); const castingDir = join(squadDir, 'casting'); @@ -494,10 +494,6 @@ export async function createTeam(teamRoot: string, proposal: CastProposal): Prom const membersCreated: string[] = []; const now = new Date().toISOString(); - // Ensure directories exist - await mkdir(agentsDir, { recursive: true }); - await mkdir(castingDir, { recursive: true }); - // Collect all members (proposal + built-ins) const allMembers = [...proposal.members]; @@ -511,7 +507,6 @@ export async function createTeam(teamRoot: string, proposal: CastProposal): Prom for (const member of allMembers) { const nameLower = member.name.toLowerCase(); const agentDir = join(agentsDir, nameLower); - await mkdir(agentDir, { recursive: true }); const charterPath = join(agentDir, 'charter.md'); let charter: string; @@ -522,11 +517,11 @@ export async function createTeam(teamRoot: string, proposal: CastProposal): Prom } else { charter = generateCharter(member); } - await writeFile(charterPath, charter); + await storage.write(charterPath, charter); filesCreated.push(charterPath); const historyPath = join(agentDir, 'history.md'); - await writeFile(historyPath, generateHistory(member, proposal.projectDescription)); + await storage.write(historyPath, generateHistory(member, proposal.projectDescription)); filesCreated.push(historyPath); membersCreated.push(member.name); @@ -534,9 +529,9 @@ export async function createTeam(teamRoot: string, proposal: CastProposal): Prom // Create or update team.md const teamPath = join(squadDir, 'team.md'); - if (existsSync(teamPath)) { + if (storage.existsSync(teamPath)) { // Update existing — preserve content before and after ## Members - const content = await readFile(teamPath, 'utf8'); + const content = await storage.read(teamPath) ?? ''; const membersIdx = content.indexOf('## Members'); if (membersIdx !== -1) { const before = content.slice(0, membersIdx); @@ -548,7 +543,7 @@ export async function createTeam(teamRoot: string, proposal: CastProposal): Prom ? afterMembers.slice(afterMembers.indexOf(nextHeader)) : ''; const newContent = before + buildMembersTable(allMembers) + '\n' + after; - await writeFile(teamPath, newContent); + await storage.write(teamPath, newContent); filesCreated.push(teamPath); } } else { @@ -574,17 +569,17 @@ export async function createTeam(teamRoot: string, proposal: CastProposal): Prom `- **Created:** ${new Date().toISOString().split('T')[0]}`, '', ].join('\n'); - await writeFile(teamPath, freshContent); + await storage.write(teamPath, freshContent); filesCreated.push(teamPath); } // Create or update routing.md const routingPath = join(squadDir, 'routing.md'); const routingTable = buildRoutingTable(allMembers); - if (existsSync(routingPath)) { + if (storage.existsSync(routingPath)) { // Update existing — append routing table - const content = await readFile(routingPath, 'utf8'); - await writeFile(routingPath, content.trimEnd() + '\n\n' + routingTable + '\n'); + const content = await storage.read(routingPath) ?? ''; + await storage.write(routingPath, content.trimEnd() + '\n\n' + routingTable + '\n'); filesCreated.push(routingPath); } else { // Create from scratch @@ -603,7 +598,7 @@ export async function createTeam(teamRoot: string, proposal: CastProposal): Prom '', routingTable, ].join('\n'); - await writeFile(routingPath, freshRouting); + await storage.write(routingPath, freshRouting); filesCreated.push(routingPath); } @@ -622,7 +617,7 @@ export async function createTeam(teamRoot: string, proposal: CastProposal): Prom } const registry = { agents: registryAgents }; - await writeFile(join(castingDir, 'registry.json'), JSON.stringify(registry, null, 2) + '\n'); + await storage.write(join(castingDir, 'registry.json'), JSON.stringify(registry, null, 2) + '\n'); filesCreated.push(join(castingDir, 'registry.json')); const history = { @@ -637,11 +632,11 @@ export async function createTeam(teamRoot: string, proposal: CastProposal): Prom { universe: proposal.universe, used_at: now }, ], }; - await writeFile(join(castingDir, 'history.json'), JSON.stringify(history, null, 2) + '\n'); + await storage.write(join(castingDir, 'history.json'), JSON.stringify(history, null, 2) + '\n'); filesCreated.push(join(castingDir, 'history.json')); const policy = { universe_allowlist: ['*'], max_capacity: 25 }; - await writeFile(join(castingDir, 'policy.json'), JSON.stringify(policy, null, 2) + '\n'); + await storage.write(join(castingDir, 'policy.json'), JSON.stringify(policy, null, 2) + '\n'); filesCreated.push(join(castingDir, 'policy.json')); // Sync new agents into squad.config.ts (if present) diff --git a/packages/squad-cli/src/cli/shell/coordinator.ts b/packages/squad-cli/src/cli/shell/coordinator.ts index a7a1d49ad..675ad7662 100644 --- a/packages/squad-cli/src/cli/shell/coordinator.ts +++ b/packages/squad-cli/src/cli/shell/coordinator.ts @@ -1,6 +1,5 @@ -import { readFileSync } from 'node:fs'; import { join } from 'node:path'; -import { listRoles, searchRoles } from '@bradygaster/squad-sdk'; +import { listRoles, searchRoles, FSStorageProvider } from '@bradygaster/squad-sdk'; import type { ShellMessage } from './types.js'; @@ -38,6 +37,18 @@ export interface CoordinatorConfig { useBaseRoles?: boolean; } +/** Fallback text when team.md is missing or has no roster entries. */ +const noTeamFallback = `⚠️ NO TEAM CONFIGURED + +This project doesn't have a Squad team yet. + +**You MUST NOT do any project work.** Instead, tell the user: +1. "This project doesn't have a Squad team yet." +2. Suggest running \`squad init\` or the \`/init\` command to set one up. +3. Politely refuse any work requests until init is done. + +Do not answer coding questions, route to agents, or perform any project tasks.`; + /** * Build an Init Mode system prompt for team casting. * Used when team.md exists but has no roster entries. @@ -214,46 +225,38 @@ PROJECT: A React and Node.js web application /** * Build the coordinator system prompt from team.md + routing.md. * This prompt tells the LLM how to route user requests to agents. + * + * Reads via FSStorageProvider so all file access is routed through the + * StorageProvider abstraction (Phase 3 migration). */ -export function buildCoordinatorPrompt(config: CoordinatorConfig): string { +export async function buildCoordinatorPrompt(config: CoordinatorConfig): Promise { const squadRoot = config.teamRoot; + const storage = new FSStorageProvider(); // Load team.md for roster const teamPath = config.teamPath ?? join(squadRoot, '.squad', 'team.md'); let teamContent = ''; try { - teamContent = readFileSync(teamPath, 'utf-8'); - if (!hasRosterEntries(teamContent)) { - teamContent = `⚠️ NO TEAM CONFIGURED - -This project doesn't have a Squad team yet. - -**You MUST NOT do any project work.** Instead, tell the user: -1. "This project doesn't have a Squad team yet." -2. Suggest running \`squad init\` or the \`/init\` command to set one up. -3. Politely refuse any work requests until init is done. - -Do not answer coding questions, route to agents, or perform any project tasks.`; + const raw = await storage.read(teamPath); + if (raw === undefined) { + teamContent = noTeamFallback; + } else { + teamContent = raw; + if (!hasRosterEntries(teamContent)) { + teamContent = noTeamFallback; + } } } catch (err) { debugLog('buildCoordinatorPrompt: failed to read team.md at', teamPath, err); - teamContent = `⚠️ NO TEAM CONFIGURED - -This project doesn't have a Squad team yet. - -**You MUST NOT do any project work.** Instead, tell the user: -1. "This project doesn't have a Squad team yet." -2. Suggest running \`squad init\` or the \`/init\` command to set one up. -3. Politely refuse any work requests until init is done. - -Do not answer coding questions, route to agents, or perform any project tasks.`; + teamContent = noTeamFallback; } // Load routing.md for routing rules const routingPath = config.routingPath ?? join(squadRoot, '.squad', 'routing.md'); let routingContent = ''; try { - routingContent = readFileSync(routingPath, 'utf-8'); + const raw = await storage.read(routingPath); + routingContent = raw ?? '(No routing.md found — run `squad init` to create one)'; } catch (err) { debugLog('buildCoordinatorPrompt: failed to read routing.md at', routingPath, err); routingContent = '(No routing.md found — run `squad init` to create one)'; diff --git a/packages/squad-cli/src/cli/shell/index.ts b/packages/squad-cli/src/cli/shell/index.ts index e8be47775..877f3cb4a 100644 --- a/packages/squad-cli/src/cli/shell/index.ts +++ b/packages/squad-cli/src/cli/shell/index.ts @@ -256,7 +256,7 @@ export async function runShell(): Promise { (async () => { try { debugLog('eager warm-up: creating coordinator session'); - const systemPrompt = buildCoordinatorPrompt({ teamRoot }); + const systemPrompt = await buildCoordinatorPrompt({ teamRoot }); coordinatorSession = await client.createSession({ streaming: true, systemMessage: { mode: 'append', content: systemPrompt }, @@ -396,7 +396,7 @@ export async function runShell(): Promise { shellApi?.setAgentActivity(agentName, 'connecting...'); // Give React a tick to render the connection hint before blocking on SDK await new Promise(resolve => setImmediate(resolve)); - const charter = loadAgentCharter(agentName, teamRoot); + const charter = await loadAgentCharter(agentName, teamRoot); const systemPrompt = buildAgentPrompt(charter); if (!registry.get(agentName)) { @@ -583,7 +583,7 @@ export async function runShell(): Promise { shellApi?.setActivityHint('Connecting to SDK...'); // Give React a tick to render the connection hint before blocking on SDK await new Promise(resolve => setImmediate(resolve)); - const systemPrompt = buildCoordinatorPrompt({ teamRoot }); + const systemPrompt = await buildCoordinatorPrompt({ teamRoot }); coordinatorSession = await client.createSession({ streaming: true, systemMessage: { mode: 'append', content: systemPrompt }, diff --git a/packages/squad-cli/src/cli/shell/lifecycle.ts b/packages/squad-cli/src/cli/shell/lifecycle.ts index 355b28cb7..71033635e 100644 --- a/packages/squad-cli/src/cli/shell/lifecycle.ts +++ b/packages/squad-cli/src/cli/shell/lifecycle.ts @@ -7,8 +7,9 @@ * @module cli/shell/lifecycle */ -import fs from 'node:fs'; +import { unlinkSync } from 'node:fs'; import path from 'node:path'; +import { FSStorageProvider } from '@bradygaster/squad-sdk'; import { SessionRegistry } from './sessions.js'; import { ShellRenderer } from './render.js'; import type { ShellState, ShellMessage } from './types.js'; @@ -54,12 +55,18 @@ export class ShellLifecycle { }; } - /** Initialize the shell — verify .squad/, load team.md, discover agents. */ + /** + * Initialize the shell — verify .squad/, load team.md, discover agents. + * + * Reads via FSStorageProvider so all file access is routed through the + * StorageProvider abstraction (Phase 3 migration). + */ async initialize(): Promise { this.state.status = 'initializing'; + const storage = new FSStorageProvider(); const squadDir = path.resolve(this.options.teamRoot, '.squad'); - if (!fs.existsSync(squadDir) || !fs.statSync(squadDir).isDirectory()) { + if (!await storage.exists(squadDir)) { this.state.status = 'error'; const err = new Error( `No team found. Run \`squad init\` to create one.` @@ -69,7 +76,8 @@ export class ShellLifecycle { } const teamPath = path.join(squadDir, 'team.md'); - if (!fs.existsSync(teamPath)) { + const teamContent = await storage.read(teamPath); + if (teamContent === undefined) { this.state.status = 'error'; const err = new Error( `No team manifest found. The .squad/ directory exists but has no team.md. Run \`squad init\` to fix.` @@ -78,12 +86,11 @@ export class ShellLifecycle { throw err; } - const teamContent = fs.readFileSync(teamPath, 'utf-8'); this.discoveredAgents = parseTeamManifest(teamContent); if (this.discoveredAgents.length === 0) { const initPromptPath = path.join(squadDir, '.init-prompt'); - if (!fs.existsSync(initPromptPath)) { + if (!await storage.exists(initPromptPath)) { console.warn('⚠ No agents found in team.md. Run `squad init "describe your project"` to cast a team.'); } // Auto-cast message is shown inside the Ink UI (index.ts handleInitCast) @@ -279,12 +286,19 @@ export interface WelcomeData { isFirstRun: boolean; } -/** Load welcome screen data from .squad/ directory. */ +/** + * Load welcome screen data from .squad/ directory. + * + * Uses FSStorageProvider (sync) so all reads are routed through the + * StorageProvider abstraction. Kept synchronous to preserve the React + * useState initializer contract in App.tsx (Phase 3 migration). + */ export function loadWelcomeData(teamRoot: string): WelcomeData | null { try { + const storage = new FSStorageProvider(); const teamPath = path.join(teamRoot, '.squad', 'team.md'); - if (!fs.existsSync(teamPath)) return null; - const content = fs.readFileSync(teamPath, 'utf-8'); + const content = storage.readSync(teamPath); + if (content === undefined) return null; const titleMatch = content.match(/^#\s+Squad Team\s+—\s+(.+)$/m); const projectName = titleMatch?.[1] ?? 'Squad'; @@ -297,8 +311,8 @@ export function loadWelcomeData(teamRoot: string): WelcomeData | null { let focus: string | null = null; const nowPath = path.join(teamRoot, '.squad', 'identity', 'now.md'); - if (fs.existsSync(nowPath)) { - const nowContent = fs.readFileSync(nowPath, 'utf-8'); + const nowContent = storage.readSync(nowPath); + if (nowContent !== undefined) { const focusMatch = nowContent.match(/focus_area:\s*(.+)/); focus = focusMatch?.[1]?.trim() ?? null; } @@ -306,9 +320,9 @@ export function loadWelcomeData(teamRoot: string): WelcomeData | null { // Detect and consume first-run marker from `squad init` const firstRunPath = path.join(teamRoot, '.squad', '.first-run'); let isFirstRun = false; - if (fs.existsSync(firstRunPath)) { + if (storage.existsSync(firstRunPath)) { isFirstRun = true; - try { fs.unlinkSync(firstRunPath); } catch { /* non-fatal */ } + try { unlinkSync(firstRunPath); } catch { /* non-fatal */ } } return { projectName, description, agents, focus, isFirstRun }; diff --git a/packages/squad-cli/src/cli/shell/spawn.ts b/packages/squad-cli/src/cli/shell/spawn.ts index 1dffa4b82..4712fcfb4 100644 --- a/packages/squad-cli/src/cli/shell/spawn.ts +++ b/packages/squad-cli/src/cli/shell/spawn.ts @@ -7,9 +7,9 @@ import { resolveSquad } from '@bradygaster/squad-sdk/resolution'; import { SquadClient } from '@bradygaster/squad-sdk/client'; import type { SquadSession } from '@bradygaster/squad-sdk/client'; +import { SquadState, FSStorageProvider } from '@bradygaster/squad-sdk'; import { SessionRegistry } from './sessions.js'; -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; +import { dirname } from 'node:path'; /** Debug logger — writes to stderr only when SQUAD_DEBUG=1. */ function debugLog(...args: unknown[]): void { @@ -46,18 +46,36 @@ export interface SpawnResult { /** * Load agent charter from .squad/agents/{name}/charter.md + * + * Reads via SquadState → AgentHandle.charter() so all file access is + * routed through the StorageProvider abstraction. */ -export function loadAgentCharter(agentName: string, teamRoot?: string): string { - const squadDir = teamRoot ? join(teamRoot, '.squad') : resolveSquad(); - if (!squadDir) { - debugLog('loadAgentCharter: no .squad/ directory found'); +export async function loadAgentCharter(agentName: string, teamRoot?: string): Promise { + let rootDir: string; + if (teamRoot) { + rootDir = teamRoot; + } else { + const squadDir = resolveSquad(); + if (!squadDir) { + debugLog('loadAgentCharter: no .squad/ directory found'); + throw new Error('No team found. Run `squad init` to set up your project.'); + } + rootDir = dirname(squadDir); + } + + const storage = new FSStorageProvider(); + let state: SquadState; + try { + state = await SquadState.create(storage, rootDir); + } catch { + debugLog('loadAgentCharter: no .squad/ directory at', rootDir); throw new Error('No team found. Run `squad init` to set up your project.'); } - const charterPath = join(squadDir, 'agents', agentName.toLowerCase(), 'charter.md'); + try { - return readFileSync(charterPath, 'utf-8'); + return await state.agents.get(agentName.toLowerCase()).charter(); } catch (err) { - debugLog('loadAgentCharter: failed to read charter at', charterPath, err); + debugLog('loadAgentCharter: failed to read charter for', agentName, err); throw new Error(`No charter found for "${agentName}". Check that .squad/agents/${agentName.toLowerCase()}/charter.md exists.`); } } @@ -87,7 +105,7 @@ export async function spawnAgent( options: SpawnOptions = { mode: 'sync' } ): Promise { const teamRoot = options.teamRoot ?? process.cwd(); - const charter = loadAgentCharter(name, teamRoot); + const charter = await loadAgentCharter(name, teamRoot); const roleMatch = charter.match(/^#\s+\w+\s+—\s+(.+)$/m); const role = roleMatch?.[1] ?? 'Agent'; diff --git a/packages/squad-cli/templates/skills/activity-aggregator/SKILL.md b/packages/squad-cli/templates/skills/activity-aggregator/SKILL.md new file mode 100644 index 000000000..3dcd0180c --- /dev/null +++ b/packages/squad-cli/templates/skills/activity-aggregator/SKILL.md @@ -0,0 +1,264 @@ +--- +name: "activity-aggregator" +description: "Unified personal activity digest across configured GitHub repositories" +domain: "workload-management, personal-activity, github-orchestration" +confidence: "low" +source: "manual (Flight workload-management proposal synthesized by Procedures)" +tools: + - name: "github-mcp-server-search_issues" + description: "Search issues across each configured repository for authored, assigned, or mentioned work" + when: "Use as the primary retrieval path for user-centric issue slices inside the configured lookback window" + - name: "github-mcp-server-search_pull_requests" + description: "Search pull requests across each configured repository for authored, review-requested, or mentioned work" + when: "Use as the primary retrieval path for user-centric pull-request slices inside the configured lookback window" + - name: "github-mcp-server-issue_read" + description: "Read issue details, labels, comments, and related metadata" + when: "Use after search results when you need labels, assignment state, or comment evidence for blocked flags and mention validation" + - name: "github-mcp-server-pull_request_read" + description: "Read pull request details, reviews, comments, files, and checks" + when: "Use after search results when you need review state, mergeability clues, labels, or blocker evidence" + - name: "github-mcp-server-list_issues" + description: "List recent issues in a configured repository" + when: "Use as a fallback inventory pass when search results need repo-local verification or broad recent coverage" + - name: "github-mcp-server-list_pull_requests" + description: "List recent pull requests in a configured repository" + when: "Use as a fallback inventory pass when search results need repo-local verification or broad recent coverage" +--- + +## Context + +`activity-aggregator` is the personal workload complement to `doc-intelligence`. + +Use it when Ralph or an interactive command needs a single answer to: **what across my configured repos needs my attention right now?** The skill should scan multiple GitHub repositories, deduplicate overlapping signals, and return a compact digest organized around the user's next action instead of raw issue lists. + +This skill should **reuse the local config contract** already established by `doc-intelligence`: + +```text +.squad/doc-intelligence.local.json +``` + +Read the configured `sources.github[]` list from that file instead of hardcoding owners, repo names, or maintaining a second repo registry. The skill may accept an explicit repo subset, but the default scope comes from local config. + +The default lookback window is the same as `doc-intelligence`: **24 hours** unless the caller overrides it. + +## Patterns + +### Config Reuse Pattern + +1. Load `.squad/doc-intelligence.local.json` +2. Read `defaults.lookbackHours` unless the caller supplies a narrower or wider window +3. Enumerate enabled `sources.github[]` +4. Treat that repo list as the canonical monitoring surface for cross-repo activity + +**Rule:** never copy local config values into `SKILL.md`, checked-in templates, decision logs, or dashboard snapshots. + +### Personal Signal Retrieval Pattern + +For each configured repository, retrieve six slices for the target user: + +1. **Issues authored by the user** +2. **Issues assigned to the user** +3. **Issues mentioning the user** +4. **PRs authored by the user** +5. **PRs requesting the user's review** +6. **PRs mentioning the user** + +Prefer search-first retrieval because the query is user-centric, not repo-centric. + +#### Issue query shape + +For each repo, search for open issues that match combinations like: + +- `author:{user}` +- `assignee:{user}` +- `mentions:{user}` +- constrained to the configured lookback window when the question is about recent activity + +Then read details only for candidate items that need labels, comments, assignment verification, or blocker extraction. + +#### Pull request query shape + +For each repo, search for open PRs that match combinations like: + +- `author:{user}` +- `review-requested:{user}` +- `mentions:{user}` +- constrained to the configured lookback window when the question is about recent activity + +Then read PR details, reviews, and comments to classify review state and blockers. + +### Classification Pattern + +Normalize every result into one primary bucket so the digest reads like a queue, not a data dump. + +#### 1. Needs your review + +Use when the PR is open and the user is a requested reviewer, explicit approver, or de facto reviewer based on direct mention plus pending review discussion. + +Include: +- repo + PR number +- age +- author +- labels +- latest review state summary +- blocked flags (failing checks, merge conflict, stale branch, requested changes) + +#### 2. Your PRs awaiting merge + +Use when the PR author is the target user and the PR is still open. + +Distinguish between: +- **ready to merge** +- **awaiting reviewer approval** +- **awaiting CI** +- **blocked / needs attention** + +Include review state, latest check conclusion, and whether the branch appears mergeable. + +#### 3. Your open issues + +Use for issues authored by the user or assigned to the user that are still open and actionable. + +Include: +- repo + issue number +- age +- labels +- assignee state +- blocked flag if comments indicate dependency, waiting state, or external blocker + +#### 4. Mentions + +Use for issue or PR threads where the user is mentioned but the item does not belong in a higher-priority bucket. + +Examples: +- FYI mention with no explicit action +- routing question that does not request review +- cross-repo reference to related work + +### Deduplication Pattern + +The same PR can appear in multiple slices: authored by user, review-requested to user, and mentioning user. Collapse duplicates into a single normalized record with: + +- stable identity: `{owner}/{repo}#{number}` + item type +- accumulated reasons: `authored`, `assigned`, `mentioned`, `review-requested` +- one primary bucket chosen by action priority + +Use this priority order when collisions happen: + +1. `needs your review` +2. `your PRs awaiting merge` +3. `your open issues` +4. `mentions` + +Keep secondary reasons in metadata so the dashboard or CLI can explain why the item surfaced. + +### Blocker Extraction Pattern + +Add a `blocked` or `needs-attention` flag when evidence shows one of these conditions: + +- requested changes still open +- failing or missing check runs +- merge conflict / non-mergeable state +- stale branch against base +- explicit dependency noted in comments or labels (`blocked`, `needs-info`, `waiting`, similar conventions) + +Do not invent blockers from silence. Only elevate when the issue, PR, review state, checks, or labels support it. + +### Output Pattern + +Emit a structured digest that can drive both Ralph's overnight briefing and an on-demand `squad activity` view. + +```markdown +# Personal Activity Digest — {user} + +**Lookback:** last {N} hours +**Config:** `.squad/doc-intelligence.local.json` + +## Coverage +- Repos scanned: {count} +- Repos: {owner/repo list} + +## Needs Your Review +- {repo}#{pr} — {age} + - Author: {author} + - Labels: {labels} + - Review state: {requested | approved by X | changes requested by Y} + - Flags: {blocked | stale | ci-failing | clean} + +## Your PRs Awaiting Merge +- {repo}#{pr} — {age} + - Status: {ready to merge | waiting for review | waiting for CI | blocked} + - Review state: {summary} + - Flags: {flags} + +## Your Open Issues +- {repo}#{issue} — {age} + - Labels: {labels} + - Assignees: {assignees} + - Flags: {blocked | waiting | active} + +## Mentions +- {repo}#{item} — {age} + - Why surfaced: {mention reason} + - Action: {none | reply | inspect | route} +``` + +### Ralph Integration Pattern + +Ralph should treat this as a **daily personal workload heartbeat**. + +1. Load the configured repos from local config +2. Scan the last 24 hours by default +3. Produce the digest before the morning briefing is rendered +4. Feed the output into the dashboard concept as the **Personal Activity** section +5. Stay compact: if there is no actionable personal signal, emit a quiet `nothing urgent` result instead of noise + +### Owner Routing Pattern + +- **EECOM** owns runtime wiring, data fetching, and digest generation +- **INCO** owns dashboard/CLI phrasing, grouping, and scan-friendly presentation + +Route implementation questions accordingly. + +## Examples + +### Example 1: Overnight briefing slice + +Ralph scans the configured repos and finds: +- two PRs requesting the user's review +- one user-authored PR with all approvals in but CI still pending +- one assigned issue waiting on another team + +**Result:** +- two items in `Needs Your Review` +- one item in `Your PRs Awaiting Merge` with `waiting for CI` +- one item in `Your Open Issues` with a `blocked` flag + +### Example 2: Mention should not outrank requested review + +A PR both mentions the user and explicitly requests their review. + +**Result:** +- emit one record +- primary bucket = `Needs Your Review` +- secondary reasons include `mentioned` + +### Example 3: On-demand personal filter + +The user runs `squad activity --my-mentions --last-24h`. + +**Result:** +- reuse the same repo scope from `.squad/doc-intelligence.local.json` +- return only the `Mentions` bucket +- keep dedupe logic so the same thread does not surface twice + +## Anti-Patterns + +- ❌ Hardcoding the repo list instead of reading `.squad/doc-intelligence.local.json` +- ❌ Maintaining a second config file for the same GitHub monitoring surface +- ❌ Treating every mention as equal priority to a requested review +- ❌ Emitting duplicate records because the same item matched multiple filters +- ❌ Mixing closed items into an active-work digest unless the caller explicitly asks for history +- ❌ Inventing blocked state without evidence from labels, comments, reviews, or checks +- ❌ Dumping raw search output when the caller needs an action-oriented queue +- ❌ Copying local repo notes or sensitive operational context into checked-in artifacts diff --git a/packages/squad-cli/templates/skills/content-triage/SKILL.md b/packages/squad-cli/templates/skills/content-triage/SKILL.md new file mode 100644 index 000000000..a4283b5ae --- /dev/null +++ b/packages/squad-cli/templates/skills/content-triage/SKILL.md @@ -0,0 +1,276 @@ +--- +name: "content-triage" +description: "Boundary heuristic for external content adoption — Squad docs vs IRL tracking" +domain: "documentation, content-strategy, scope-analysis" +confidence: "high" +source: "earned (PR #331 — Tamir Dresher blog analysis)" +--- + +## Context + +Squad grows through community adoption — blog posts, sample repos, conference talks, videos. When someone identifies external content that helps Squad adoption, the team must **triage** that content to determine: + +1. **What belongs in Squad's public docs?** (generalizable patterns, feature guides, configuration options) +2. **What belongs in the IRL tracking repo?** (external references, infrastructure patterns, operational content) +3. **What is out of scope?** (infrastructure around Squad, not Squad itself) + +This skill codifies the boundary heuristic from PR #331 and defines a repeatable workflow for content triage. + +## Patterns + +### Boundary Heuristic: "Squad Ships It" + +**Litmus test:** If Squad doesn't ship the code/configuration, it's IRL content. + +| Content Type | Squad Docs? | IRL Tracking? | Rationale | +|--------------|-------------|---------------|-----------| +| Squad CLI commands | ✅ Yes | No | Squad ships the CLI | +| Squad config options (`.squad/` files) | ✅ Yes | No | Squad defines the schema | +| User behavior within Squad features | ✅ Yes | No | Documents Squad's API surface | +| GitHub features configured *for* Squad | ⚠️ Reframe | Reference | Clarify it's GitHub, not Squad (e.g., "GitHub issue templates work with Squad labels") | +| Infrastructure *around* Squad | ❌ No | ✅ Yes | Squad doesn't own CI/CD, deployment, monitoring setup | +| Operational patterns *using* Squad | ❌ No | ✅ Yes | Squad doesn't ship the ops model | +| External samples/examples | ❌ No | ✅ Yes | Reference, not ownership | +| Blog posts, talks, videos | ❌ No | ✅ Yes | External authorship, not Squad's docs responsibility | + +### Triage Workflow + +**Trigger:** Issue labeled `content-triage` or issue body references external content (blog post, sample repo, video, talk). + +**Steps:** + +1. **Ralph detects the trigger** (label or content reference) +2. **Ralph routes to Flight** for boundary analysis +3. **Flight triages the content:** + - Read the external content (blog post, repo README, talk outline) + - Apply the "Squad Ships It" litmus test to each concept/pattern/feature + - Categorize each piece as: + - **Squad docs** — generalizable pattern, Squad owns the code/config + - **IRL tracking** — external reference, infrastructure pattern, operational content + - **Out of scope** — not Squad-related +4. **Flight produces triage output** (see format below) +5. **PAO creates sub-issues** for Squad doc extraction +6. **FIDO verifies** docs-test sync when PAO opens PR +7. **Scribe records** IRL references in tracking repo + +### Triage Output Format + +Flight writes the triage analysis to: +``` +.squad/decisions/inbox/flight-content-triage-{source-slug}.md +``` + +**Template:** + +```markdown +# Content Triage: {Source Name} + +**Source:** {URL or reference} +**Type:** {blog post | sample repo | video | conference talk} +**Author:** {name} +**Date:** {YYYY-MM-DD} + +--- + +## Boundary Analysis + +### ✅ Squad Docs (Extractable Patterns) + +{List of concepts/patterns that belong in Squad's public docs} + +**Rationale:** {Why these belong in Squad's docs — apply "Squad Ships It" test} + +**Proposed doc pages:** +- `docs/{section}/{page}.md` — {brief description} +- `docs/{section}/{page}.md` — {brief description} + +**Assigned to:** PAO (doc extraction) + +--- + +### 📚 IRL Tracking (External Reference) + +{List of concepts/patterns that are external infrastructure/operational content} + +**Rationale:** {Why these are IRL content — apply "Squad Ships It" test} + +**Tracking entry:** +```yaml +# .github/irl/references.yml +- title: "{Source Name}" + url: "{URL}" + type: "{blog | sample | video | talk}" + author: "{name}" + date: "{YYYY-MM-DD}" + topics: + - "{topic1}" + - "{topic2}" + highlights: + - "{key takeaway 1}" + - "{key takeaway 2}" +``` + +**Assigned to:** Scribe (IRL tracking) + +--- + +### ❌ Out of Scope + +{List of content that is not Squad-related} + +**Rationale:** {Why these are out of scope} + +--- + +## Sub-Issues + +- [ ] **PAO:** Extract Squad patterns → `docs/{section}/{page}.md` +- [ ] **FIDO:** Verify docs-test sync when PAO opens PR +- [ ] **Scribe:** Add IRL reference to tracking repo +``` + +### Label Convention + +**Recommended labels:** +- `content-triage` — primary trigger (requires Flight analysis) +- `content:blog` — external blog post +- `content:sample` — external sample repo +- `content:video` — external video/screencast +- `content:talk` — conference talk or presentation + +**Label workflow:** +1. User or Ralph applies `content-triage` label to issue +2. Ralph routes to Flight +3. Flight removes `content-triage`, applies specific type label (`content:blog`, etc.) +4. Flight adds `status:triaged` when analysis is complete + +### IRL Tracking Repository + +**Location:** `.github/irl/references.yml` (or separate repo if external) + +**Schema:** + +```yaml +- title: "Blog post title" + url: "https://..." + type: "blog | sample | video | talk" + author: "Name" + date: "YYYY-MM-DD" + topics: + - "feature-name" + - "pattern-name" + highlights: + - "Key takeaway 1" + - "Key takeaway 2" + squad_docs: + - "docs/guides/feature-name.md" # Links to any docs extracted from this source +``` + +**Why YAML?** Machine-readable, supports aggregation/reporting, enables future IRL showcase generation. + +### Ralph Integration + +**Ralph's work-check cycle:** + +1. Ralph monitors issue labels (including `content-triage`) +2. On detection, Ralph: + - Reads the issue body for external content URL + - Routes to Flight: "New content triage request: {URL} — please analyze boundary" +3. Flight performs triage, writes decision file +4. Ralph sees `status:triaged` label: + - Creates sub-issues for PAO (doc extraction) + - Notifies Scribe (IRL tracking) + - Links sub-issues back to parent issue + +## Examples + +### Example 1: Tamir Dresher Blog Analysis (PR #331) + +**Source:** Tamir's blog post on Squad adoption in production environment + +**Triage:** + +**✅ Squad Docs (Extracted):** +- Scenario-based guides (authentication squad, data migration squad) +- Issue template integration (how to configure GitHub issue templates for Squad labels) +- Reviewer protocol trust levels (documents user choice within Squad's review system) + +**Rationale:** These document Squad's features (scenarios, config, behavior). Squad ships the review system and scenario patterns. + +**📚 IRL Tracking:** +- Ralph operations guide (monitoring, auto-merge, quality gates) +- Proactive communication patterns (Slack notifications, daily summaries) + +**Rationale:** These document infrastructure *around* Squad (CI/CD, monitoring, ops patterns). Squad doesn't ship the CI/CD or Slack bot — those are external integrations. + +**❌ Out of Scope:** None (all content was either extracted or tracked) + +**Result:** +- PAO extracted 3 doc pages (scenario-based-guides.md, issue-templates.md, reviewer-protocol.md) +- Flight removed 2 infrastructure guides (ralph-operations.md, proactive-communication.md) +- FIDO verified docs-test sync (no new tests needed — docs describe existing features) + +### Example 2: Sample Repo with Custom Ops Patterns + +**Source:** `example-corp/squad-deployment` (GitHub repo) + +**Triage:** + +**✅ Squad Docs:** +- `.squad/team.md` configuration examples (agent roles, routing rules) +- Decision file patterns (what decisions to document, format conventions) + +**Rationale:** Squad ships the team.md schema and decision.md format. These are configuration guides. + +**📚 IRL Tracking:** +- Terraform modules for Squad infrastructure (container orchestration, state persistence) +- Kubernetes deployment manifests +- Monitoring dashboards (Grafana config for Ralph health checks) + +**Rationale:** Squad doesn't ship Terraform or K8s configs. These are operational patterns around Squad. + +**❌ Out of Scope:** +- Internal company conventions unrelated to Squad (team naming, approval workflows) + +**Result:** +- PAO: Create `docs/guides/team-configuration.md` (generalized patterns) +- Scribe: Add IRL reference with link to `example-corp/squad-deployment` +- No extraction of Terraform/K8s — link only + +### Example 3: Conference Talk on Squad Patterns + +**Source:** "Building AI Teams with Squad" — DevOps Summit 2026 + +**Triage:** + +**✅ Squad Docs:** +- Agent specialization patterns (when to split roles, when to merge) +- Work routing strategies (label-based, domain-based, skill-based) + +**Rationale:** Squad ships the routing system and agent framework. These are architectural patterns within Squad's API. + +**📚 IRL Tracking:** +- Talk recording (YouTube link) +- Slide deck (Speaker Deck link) +- Case study: Company X's 10-agent team structure + +**Rationale:** External authorship, specific case study. Reference, not ownership. + +**❌ Out of Scope:** +- Generic DevOps practices not specific to Squad + +**Result:** +- PAO: Enhance `docs/architecture/agent-specialization.md` with patterns from talk +- Scribe: Add IRL reference (video + slides + case study note) + +## Anti-Patterns + +- ❌ Documenting infrastructure *around* Squad in Squad's public docs (belongs in IRL) +- ❌ Copying external content verbatim without generalization (extract patterns, not implementations) +- ❌ Listing external repos in Squad's docs without consent (privacy violation per decisions.md) +- ❌ Skipping triage and directly adding content to docs (boundary analysis required) +- ❌ Treating IRL content as "less valuable" (it's just a different category — references are important) +- ❌ Creating doc pages for every blog post (extract generalizable patterns only) +- ❌ Ignoring external content because "we didn't write it" (community content accelerates adoption) +- ❌ PAO extracting content before Flight completes boundary analysis (wait for triage) +- ❌ Mixing Squad features and GitHub features without clarification (reframe to distinguish platform from product) diff --git a/packages/squad-cli/templates/skills/cross-machine-coordination/SKILL.md b/packages/squad-cli/templates/skills/cross-machine-coordination/SKILL.md new file mode 100644 index 000000000..79c4bc7ea --- /dev/null +++ b/packages/squad-cli/templates/skills/cross-machine-coordination/SKILL.md @@ -0,0 +1,434 @@ +# Skill: Cross-Machine Coordination Pattern + +**Skill ID:** `cross-machine-coordination` +**Owner:** Ralph (Work Monitor) +**Squad Integration:** All agents +**Status:** Specification (ready for implementation) + +--- + +## Overview + +Enables squad agents running on different machines (laptop, DevBox, Azure VM) to securely share work, coordinate execution, and pass results without manual intervention. + +**Pattern:** Git-based task queuing + GitHub Issues supplement + +--- + +## Usage + +### For Task Sources (Orchestrating Machine) + +**To assign work to DevBox:** + +```bash +# Create task file +cat > .squad/cross-machine/tasks/2026-03-14T1530Z-laptop-gpu-voice-clone.yaml << 'EOF' +id: gpu-voice-clone-001 +source_machine: laptop-machine +target_machine: devbox +priority: high +created_at: 2026-03-14T15:30:00Z +task_type: gpu_workload +payload: + command: "python scripts/voice-clone.py --input voice.wav --output cloned.wav" + expected_duration_min: 15 + resources: + gpu: true + memory_gb: 8 +status: pending +EOF + +# Commit & push +git add .squad/cross-machine/tasks/ +git commit -m "Cross-machine task: GPU voice cloning [squad:machine-devbox]" +git push origin main +``` + +Ralph on DevBox will: +1. Pull the task on next cycle (5-10 min) +2. Validate schema & command whitelist +3. Execute the GPU workload +4. Write result to `.squad/cross-machine/results/gpu-voice-clone-001.yaml` +5. Commit & push the result + +--- + +### For Task Executors (DevBox, Azure VMs) + +Ralph automatically watches `.squad/cross-machine/tasks/` for work targeted at this machine. + +**On each cycle (5-10 min):** + +```python +# Pseudo-code (Ralph implementation) +1. git pull origin main +2. Load all .yaml files in .squad/cross-machine/tasks/ +3. Filter for status=pending AND target_machine=HOSTNAME +4. For each task: + a. Validate schema (must have: id, source_machine, target_machine, payload) + b. Validate command against whitelist + c. Execute task (with timeout) + d. Write result to .squad/cross-machine/results/{id}.yaml + e. Commit & push result +``` + +--- + +### For Urgent/Ad-Hoc Tasks + +**Use GitHub Issues with `squad:machine-{name}` label:** + +```bash +# Create issue +gh issue create \ + --title "GPU: Clone voice profile from sample.wav" \ + --body "Execute voice cloning on DevBox. Input: /path/to/voice-input.wav" \ + --label "squad:machine-devbox" \ + --label "urgent" +``` + +Ralph on DevBox will: +1. Detect issue with `squad:machine-devbox` label +2. Parse task from issue body +3. Execute task +4. Comment with result +5. Close issue + +--- + +## File Formats + +### Task File (YAML) + +**Location:** `.squad/cross-machine/tasks/{timestamp}-{machine}-{task-id}.yaml` + +**Required Fields:** +```yaml +id: {task-id} # Unique identifier (alphanumeric + dash) +source_machine: {hostname} # Where task was created +target_machine: {hostname} # Where task will execute +priority: high|normal|low # Execution priority +created_at: 2026-03-14T15:30:00Z # ISO 8601 timestamp +task_type: gpu_workload|script|... # Category +payload: + command: "..." # Shell command to execute + expected_duration_min: 15 # Timeout (minutes) + resources: + gpu: true|false + memory_gb: 8 + cpu_cores: 4 +status: pending|executing|completed|failed +``` + +**Optional Fields:** +```yaml +description: "Human-readable task description" +timeout_override_min: 120 # Override default timeout +retry_count: 3 # Retry failed tasks +``` + +### Result File (YAML) + +**Location:** `.squad/cross-machine/results/{task-id}.yaml` + +```yaml +id: {task-id} # Links back to task +target_machine: devbox # Executed on +completed_at: 2026-03-14T15:45:00Z # When it finished +status: completed|failed|timeout # Outcome +exit_code: 0 # Shell exit code +stdout: "..." # Captured output +stderr: "..." # Captured errors +duration_seconds: 900 # How long it took +artifacts: + - path: "/path/to/artifacts/..." # Location of results + type: audio|text|model|... + size_mb: 2.5 +``` + +--- + +## Security Model + +### Validation Pipeline + +All tasks go through: + +1. **Schema Validation** + - YAML structure matches spec + - Required fields present + - No unexpected fields (reject) + +2. **Command Whitelist** + - Only approved commands allowed + - Path validation (no `../../` escapes) + - Environment variable sanitization + - No inline shell operators (`&&`, `|`, `>`) + +3. **Resource Limits** + - Timeout enforced (default: 60 min) + - Memory cap: 16GB (adjustable) + - CPU threads: 4 (adjustable) + - Disk write: 100GB (adjustable) + +4. **Execution Isolation** + - Runs as unprivileged user + - Temp directory cleaned after execution + - Network access: read-only (no outbound writes) + +5. **Audit Trail** + - All executions logged to git + - Commit signed with Ralph's key + - Result stored immutably + +### Threat Mitigations + +| Threat | Mitigation | +|--------|-----------| +| **Malicious task injection** | Branch protection + PR review before merge | +| **Credential leakage** | Pre-commit secret scan + environment scrubbing | +| **Resource exhaustion** | Timeout + memory limits | +| **Code injection** | Command whitelist + no shell evaluation | +| **Result tampering** | Git commit history is immutable | + +--- + +## Configuration + +Ralph reads config from `.squad/config.json`: + +```json +{ + "cross_machine": { + "enabled": true, + "poll_interval_seconds": 300, + "this_machine": "devbox", + "max_concurrent_tasks": 2, + "task_timeout_minutes": 60, + "command_whitelist": [ + "python scripts/voice-clone.py", + "python scripts/data-process.py", + "bash scripts/cleanup.sh" + ], + "result_ttl_days": 30 + } +} +``` + +--- + +## Examples + +### Example 1: GPU Voice Cloning (Laptop → DevBox) + +**1. Laptop creates task:** + +```yaml +# .squad/cross-machine/tasks/2026-03-14T1530Z-laptop-gpu-001.yaml +id: gpu-voice-clone-001 +source_machine: laptop-machine +target_machine: devbox +priority: high +created_at: 2026-03-14T15:30:00Z +task_type: gpu_workload +payload: + command: "python scripts/voice-clone.py --input voice.wav --output cloned.wav" + expected_duration_min: 15 + resources: + gpu: true + memory_gb: 8 +status: pending +``` + +**2. Laptop commits & pushes:** + +```bash +git add .squad/cross-machine/tasks/ +git commit -m "Task: GPU voice cloning [squad:machine-devbox]" +git push origin main +``` + +**3. DevBox Ralph (5 min later):** + +``` +[Ralph Watch Cycle] +- Pulled origin/main +- Detected: gpu-voice-clone-001 (status: pending, target: devbox) +- Validation: ✅ Schema OK, command whitelisted +- Executing: python scripts/voice-clone.py ... +- [15 minutes of processing] +- Completed: exit code 0 +- Writing result... +- Committing & pushing... +``` + +**4. Laptop Ralph (next cycle) sees result:** + +```yaml +# .squad/cross-machine/results/gpu-voice-clone-001.yaml +id: gpu-voice-clone-001 +target_machine: devbox +completed_at: 2026-03-14T15:45:00Z +status: completed +exit_code: 0 +stdout: "Voice cloning completed. Output written to /tmp/cloned.wav" +stderr: "" +duration_seconds: 900 +artifacts: + - path: "/path/to/artifacts/voice-clone-001/output.wav" + type: audio + size_mb: 2.5 +``` + +--- + +### Example 2: Urgent Debug Request (Human → DevBox via Issue) + +**Create issue:** + +```bash +gh issue create \ + --title "DevBox: Debug voice model failure" \ + --body "Error: Model failed to load on last run. Please check /tmp/model.log and report findings." \ + --label "squad:machine-devbox" \ + --label "urgent" +``` + +**DevBox Ralph detects → executes → comments:** + +``` +✅ Executed on devbox at 2026-03-14 15:47:00 +Command: python scripts/debug-model.py + +Result: +------ +Model file: /tmp/model-v2.bin (OK) +Checksum: a1b2c3d4e5f6 (matches expected) +Memory available: 12 GB (sufficient) + +ERROR FOUND: Config file permission issue + - File: ~/.config/voice/model.yaml + - Permissions: -rw------- (owner-only) + - Expected: -rw-r--r-- (world-readable for service) + +FIX: Run: chmod 644 ~/.config/voice/model.yaml +``` + +--- + +## Error Handling + +### Task Execution Failures + +If a task fails (exit code != 0): + +1. Result written with `status: failed` + exit code +2. stderr captured in result +3. Committed to git for audit +4. Source machine can retry by re-pushing task with `status: pending` + +### Stalled Tasks + +If a task doesn't complete within timeout: + +1. Process killed +2. Result written with `status: timeout` +3. stderr: "Execution exceeded X minutes" +4. Source can investigate or retry + +### Network Failures + +If git push/pull fails: + +- Ralph retries on next cycle +- Tasks queue locally until connectivity restored +- No tasks lost (stored in local repo) + +--- + +## Monitoring & Debugging + +### Check Task Queue + +```bash +ls -la .squad/cross-machine/tasks/ +cat .squad/cross-machine/tasks/*.yaml | grep -E "^(id|status|target_machine):" +``` + +### Check Results + +```bash +ls -la .squad/cross-machine/results/ +cat .squad/cross-machine/results/{task-id}.yaml +``` + +### View Execution History + +```bash +git log --oneline .squad/cross-machine/ | head -20 +``` + +### Monitor Ralph Cycles + +```bash +tail -f .squad/log/ralph-watch.log | grep "cross-machine" +``` + +--- + +## Integration with Ralph Watch + +Ralph automatically includes this pattern in its watch loop: + +``` +Ralph Watch Cycle (every 5-10 min): +1. Fetch GitHub issues with squad:machine-* labels +2. Poll .squad/cross-machine/tasks/ +3. For each matching task: + - Validate + - Execute + - Write result + - Commit & push +4. Update status in issue (if applicable) +5. Sleep until next cycle +``` + +No manual Ralph configuration needed — just create task files or issues with the right labels. + +--- + +## Migration from Manual Handoff + +**Before (today):** +- Laptop → user manually copies file to Teams chat +- user pastes into target terminal +- user copies output back +- user pastes result manually + +**After (with this pattern):** +- Laptop Ralph writes task file → git push +- DevBox Ralph auto-executes → git push result +- Laptop Ralph auto-reads result +- 0 human intervention needed + +--- + +## Future Enhancements + +Potential expansions (Phase 2+): + +1. **Task Priorities:** Execution order based on priority field +2. **Serial Pipelines:** Machine A → B → C task chains +3. **GPU Availability Polling:** Query DevBox before submitting work +4. **Cost Tracking:** Log resource usage per task +5. **Notification Webhooks:** Alert on task completion +6. **Web Dashboard:** Real-time task status visualization + +--- + +## Questions? + +Refer to research report: `research/active/cross-machine-agents/README.md` + +Contact: Seven (Research & Docs) or Ralph (Work Monitor) diff --git a/packages/squad-cli/templates/skills/docs-catalog-audit/SKILL.md b/packages/squad-cli/templates/skills/docs-catalog-audit/SKILL.md new file mode 100644 index 000000000..a1517ef51 --- /dev/null +++ b/packages/squad-cli/templates/skills/docs-catalog-audit/SKILL.md @@ -0,0 +1,96 @@ +--- +name: "docs-catalog-audit" +description: "Systematic methodology for auditing Astro-based docs sites: catalog, navigation, quality, gaps" +domain: "documentation" +confidence: "low" +source: "earned — PAO docs audit session 2026-03-22" +--- + +## Context + +Use this skill when reviewing the docs site for quality, completeness, and navigation integrity — especially after periods of high PR churn where pages get added, moved, or partially updated. The audit is read-only and produces an actionable report. + +The docs site uses Astro with content collections at `docs/src/content/docs/` and `docs/src/content/blog/`. Navigation is defined in `docs/src/navigation.ts`. + +## Patterns + +### 1. Catalog every page first + +Read all `.md` files under `docs/src/content/docs/` and `docs/src/content/blog/`. For each, record: +- File path +- Frontmatter title and sidebar label +- Section grouping (from directory structure) +- Approximate length (stub / partial / complete) +- Whether it appears in `navigation.ts` + +### 2. Navigation cross-reference + +Parse `docs/src/navigation.ts` to extract the full sidebar tree (slugs and labels). Then: +- **Dead links:** Nav entries whose slug has no matching content file +- **Orphaned pages:** Content files not referenced in any nav entry +- **Title mismatches:** Nav label differs from the page's H1 or frontmatter title + +### 3. Per-page quality checklist + +For each page, evaluate: + +| Dimension | What to check | +|-----------|--------------| +| **Completeness** | Stub (placeholder only), partial (missing sections), or complete | +| **Freshness** | Version references current? Install commands current? API names current? | +| **Tone** | Microsoft Style Guide: sentence-case headings, active voice, "you", present tense | +| **Scannability** | Paragraphs for narrative, bullets for scannable lists, tables for comparisons | +| **Cross-linking** | Links to related pages? Links valid (no broken anchors)? | +| **Duplicate headings** | Merge artifacts often leave duplicate `##` headings — check for these | + +### 4. Duplicate and overlap detection + +Flag any concept explained in multiple places. Common patterns: +- Root-level legacy files that were superseded by reorganized section pages +- "Choosing X" pages that cover the same ground with different titles +- FAQ answers that duplicate content from feature pages + +### 5. Gap analysis + +Ask: "What would a new user expect to find that isn't here?" Common gaps: +- FAQ or "I'm stuck" page not in sidebar +- Role/feature reference pages written but not linked +- Troubleshooting for major platforms (VS Code, CLI) not discoverable +- Changelog or "what's new" that's stale + +### 6. Report format + +Produce a structured report with: +- **Page catalog table:** Path, status (✅/🟡/🔴), notes +- **Navigation findings:** Dead links, orphans, ordering concerns +- **Duplicate conflicts:** Which pages overlap and which to keep +- **Gap analysis:** Missing content a new user would expect +- **Top 5 urgent issues:** Ranked by user impact +- **Action table:** Numbered, specific, with file paths and priority + +### 7. Automated guard (recommended follow-up) + +After an audit, recommend adding a nav-gap test to `test/docs-build.test.ts`: +- Every `.md` file in the content tree should either appear in `navigation.ts` OR be explicitly listed in an allowlist of standalone pages +- This prevents future orphans from accumulating silently + +## Examples + +Audit trigger scenarios: +- "Review the docs catalog" — full audit +- "Check for orphaned pages" — nav cross-reference only (steps 1-2) +- "Are the docs stale?" — freshness check only (step 3, freshness dimension) +- After a batch of doc PRs — quick re-audit focusing on changed files + +Status ratings: +- ✅ Good — complete, current, well-formatted, in nav +- 🟡 Needs work — minor issues (formatting, missing cross-links, not in nav but not critical) +- 🔴 Problem — stale version refs, broken install commands, high-value page invisible from nav + +## Anti-Patterns + +- **Don't make changes during an audit.** This is a read-only assessment. Fixes come after the report is reviewed. +- **Don't audit blog posts for nav presence.** Blog posts live in their own collection and aren't expected in the sidebar. +- **Don't flag every missing cross-link.** Focus on pages that are conceptually related and would benefit a user navigating between them. +- **Don't treat all orphans equally.** A legacy duplicate at root is a 🔴 (confusing); a niche guide not yet linked is a 🟡 (low urgency). +- **Don't skip the root-level legacy check.** Root-level `.md` files outside section folders are the most common source of stale content after reorganizations. diff --git a/packages/squad-cli/templates/skills/docs-nav-audit/SKILL.md b/packages/squad-cli/templates/skills/docs-nav-audit/SKILL.md new file mode 100644 index 000000000..65b6145fd --- /dev/null +++ b/packages/squad-cli/templates/skills/docs-nav-audit/SKILL.md @@ -0,0 +1,97 @@ +# Skill: Docs Nav Audit + +**Domain:** Documentation +**Trigger phrases:** docs audit, nav audit, orphaned pages, navigation gap, docs catalog +**Agent roles:** pao, scribe +**Confidence:** High (pattern validated across full docs catalog audit) + +--- + +## What This Skill Does + +Performs a systematic cross-reference between a navigation config file and actual content files to identify: + +1. **Dead nav links** — nav entries with no backing content file +2. **Orphaned pages** — content files with no nav entry +3. **Stale content** — pages with outdated version numbers, deprecated commands, or old directory names +4. **Duplicate/overlap** — concepts explained in multiple places (violates "one canonical page per concept") +5. **Format violations** — pages that don't follow the established format standard + +## What This Skill Does NOT Produce + +- Does not make file changes (read-only audit pattern) +- Does not rewrite content (separate task) +- Does not update navigation.ts (separate task requiring docs-test-sync) + +--- + +## How to Run This Audit + +### Step 1: Map nav slugs + +Read the navigation config (e.g., `docs/src/navigation.ts`). Extract all slugs from `NAV_SECTIONS` and `STANDALONE_PAGES` into a flat list. + +### Step 2: Map content files + +List all `.md` files under `docs/src/content/docs/` recursively. Normalize each to a slug by removing the `.md` extension and the base path prefix. + +### Step 3: Cross-reference + +``` +orphaned = content_files - nav_slugs # files with no nav entry +dead_links = nav_slugs - content_files # nav entries with no file +``` + +### Step 4: Spot-check stale content + +For each page, check for: +- Version numbers in H2 headings vs. current release (read `CHANGELOG.md` or `whatsnew.md`) +- Deprecated install commands: `npx github:bradygaster/squad`, `@bradygaster/create-squad` +- Old directory names: `.ai-team/`, `.ai-team-templates/` +- Deprecated distribution paths: `npx github:...` + +### Step 5: Check format standards + +Each page should have: +- H1 title (no frontmatter in Squad docs) +- Experimental warning callout immediately after H1 +- "Try this" code block early on +- Horizontal rule (`---`) before content sections +- H2 headings (sentence case) + +### Step 6: Identify duplicates + +Flag pairs where: +- Two pages cover the same concept (check H1, intro paragraph, and first H2 section) +- A root-level file duplicates a file in a subdirectory +- A legacy "tour" page duplicates a current "get-started" page + +--- + +## Report Format + +Produce a report with: + +1. **Page catalog table** — title, path, nav status, word count (~), status badge +2. **Orphaned pages list** — grouped by directory +3. **Dead nav links list** — with suggested fix +4. **Top issues** — ordered by user impact +5. **Recommended actions** — specific, actionable, ordered + +### Status badge legend + +| Badge | Meaning | +|-------|---------| +| ✅ Good | Complete, current, in nav, format-correct | +| 🟡 Needs work | Minor issues: orphaned, format gap, minor staleness | +| 🔴 Problem | Urgent: stale commands, dead link, high-value orphan | + +--- + +## Known Patterns in This Codebase + +- Root-level files in `docs/src/content/docs/` that predate the Astro rewrite are legacy artifacts; treat as 🔴 unless recently added +- `insider-program.md` historically drifts stale (uses deprecated install paths) +- `whatsnew.md` drifts after each release — check H2 version against `CHANGELOG.md` +- "Tour" pages (`tour-*.md`) are legacy walkthroughs superseded by the `get-started/` section +- The `features/` directory accumulates pages faster than nav gets updated — check for unlisted features on every audit diff --git a/packages/squad-cli/templates/skills/pandoc-markdown-to-word/SKILL.md b/packages/squad-cli/templates/skills/pandoc-markdown-to-word/SKILL.md new file mode 100644 index 000000000..5ea153562 --- /dev/null +++ b/packages/squad-cli/templates/skills/pandoc-markdown-to-word/SKILL.md @@ -0,0 +1,142 @@ +--- +name: "pandoc-markdown-to-word" +description: "Convert Markdown files to Word (.docx) using pandoc" +domain: "document-conversion" +confidence: "medium" +source: "manual — pandoc installed on Dina's Windows laptop, available from bash" +--- + +## Context + +Use this skill when you need to produce Word documents from Markdown sources — for sharing with stakeholders who prefer .docx, generating review documents, or creating polished deliverables from content drafts. Pandoc is installed locally and available from bash shell. + +## Patterns + +### Basic Markdown to Word + +```bash +pandoc input.md -o output.docx +``` + +### With better formatting + +```bash +# Standalone with metadata (title, author, date from YAML front matter) +pandoc input.md -o output.docx --standalone + +# With table of contents +pandoc input.md -o output.docx --toc --toc-depth=3 + +# With a custom reference template (preserves corporate styling) +pandoc input.md -o output.docx --reference-doc=template.docx +``` + +### Creating a reference template + +Extract the default template, customize in Word, then reuse: + +```bash +# Generate default reference doc +pandoc -o custom-reference.docx --print-default-data-file reference.docx + +# Then open custom-reference.docx in Word, modify styles (Heading 1, Body Text, etc.) +# Use it for all future conversions: +pandoc input.md -o output.docx --reference-doc=custom-reference.docx +``` + +### Multiple files → single Word doc + +```bash +# Combine several markdown files into one document +pandoc chapter1.md chapter2.md chapter3.md -o combined.docx --toc + +# With file list +pandoc $(cat filelist.txt) -o combined.docx --toc +``` + +### From PowerShell + +```powershell +# Single file +bash -c "pandoc input.md -o output.docx --toc" + +# With reference template +bash -c "pandoc input.md -o output.docx --reference-doc=template.docx" +``` + +### Batch conversion (all .md files in a directory) + +```bash +for f in *.md; do + pandoc "$f" -o "${f%.md}.docx" --standalone +done +``` + +## Recommended Options + +| Option | Purpose | When to use | +|--------|---------|-------------| +| `--standalone` | Includes document metadata | Always — ensures title/author render properly | +| `--toc` | Generates table of contents | Documents longer than 3 sections | +| `--toc-depth=N` | TOC heading depth (default: 3) | Adjust for deeply/shallowly nested docs | +| `--reference-doc=X.docx` | Apply custom Word styles | When you need corporate branding or consistent formatting | +| `--highlight-style=tango` | Syntax highlighting for code blocks | Documents with code samples | +| `--metadata title="X"` | Set title without YAML front matter | Quick one-offs without editing the .md | +| `--wrap=none` | No line wrapping in source | Prevents odd paragraph breaks | +| `-f gfm` | Input is GitHub Flavored Markdown | When source uses GFM extensions (task lists, tables, strikethrough) | + +## YAML Front Matter + +Pandoc reads YAML front matter for document metadata: + +```markdown +--- +title: "Deep Dive: Content Strategy" +author: "Dina Berry" +date: "March 2026" +abstract: "Overview of the content strategy for Azure developer documentation." +--- + +# Section 1 +... +``` + +This renders as the Word document's title page, author field, and abstract. + +## Word-Specific Tips + +1. **Styles map to headings** — `# H1` → Word "Heading 1", `## H2` → "Heading 2", etc. Customize these in the reference doc. +2. **Tables render natively** — Markdown tables become real Word tables (not images). +3. **Code blocks** — Rendered with monospace font and optional syntax highlighting. +4. **Images** — Local image paths are embedded. Use relative paths. +5. **Links** — Become clickable hyperlinks in Word. +6. **Footnotes** — Pandoc footnote syntax (`[^1]`) becomes Word footnotes. + +## Common Workflows + +### Content review cycle +```bash +# Author writes in markdown, generates Word for reviewer +pandoc article.md -o article-review.docx --toc --reference-doc=msft-template.docx +# Reviewer comments in Word, author incorporates back into markdown +``` + +### Presentation prep → Word handout +```bash +# Convert presentation notes to Word handout +pandoc presentation-notes.md -o handout.docx --toc --metadata title="Deep Dive Handout" +``` + +### Multi-source report +```bash +# Combine analysis files into single report +pandoc executive-summary.md findings.md recommendations.md -o report.docx \ + --toc --metadata title="Q3 Content Strategy Report" +``` + +## Anti-Patterns + +- **Don't expect pixel-perfect layout.** Word conversion prioritizes structure over visual design. Use `--reference-doc` for styling. +- **Don't use HTML in markdown for Word output.** Raw HTML blocks are often ignored or poorly rendered. Stick to pure Markdown/Pandoc syntax. +- **Don't forget `-f gfm` for GitHub-style markdown.** Without it, task lists (`- [ ]`) and strikethrough (`~~text~~`) won't convert properly. +- **Don't skip `--standalone` for formal documents.** Without it, metadata (title, author) won't appear in the Word document properties. diff --git a/packages/squad-cli/templates/skills/pandoc-pdf-to-markdown/SKILL.md b/packages/squad-cli/templates/skills/pandoc-pdf-to-markdown/SKILL.md new file mode 100644 index 000000000..262241585 --- /dev/null +++ b/packages/squad-cli/templates/skills/pandoc-pdf-to-markdown/SKILL.md @@ -0,0 +1,94 @@ +--- +name: "pandoc-pdf-to-markdown" +description: "Convert PDF files to clean Markdown using pandoc" +domain: "document-conversion" +confidence: "medium" +source: "manual — pandoc installed on Dina's Windows laptop, available from bash" +--- + +## Context + +Use this skill when you need to extract content from PDF files into Markdown for editing, analysis, or content workflows. Pandoc is installed locally and available from bash shell on the Windows laptop. + +## Patterns + +### Basic PDF to Markdown + +```bash +pandoc input.pdf -o output.md +``` + +### With better formatting options + +```bash +# Wrap lines at 80 chars, extract media to subfolder +pandoc input.pdf -o output.md --wrap=none --extract-media=./media + +# ATX-style headers (# instead of underline style) +pandoc input.pdf -o output.md --wrap=none --atx-headers +``` + +### Batch conversion (all PDFs in a directory) + +```bash +for f in *.pdf; do + pandoc "$f" -o "${f%.pdf}.md" --wrap=none --atx-headers --extract-media=./media +done +``` + +### From PowerShell (calling bash) + +```powershell +# Single file +bash -c "pandoc input.pdf -o output.md --wrap=none --atx-headers" + +# Batch +bash -c 'for f in *.pdf; do pandoc "$f" -o "${f%.pdf}.md" --wrap=none --atx-headers; done' +``` + +## Recommended Options + +| Option | Purpose | When to use | +|--------|---------|-------------| +| `--wrap=none` | Prevents artificial line breaks | Always — preserves paragraph structure | +| `--atx-headers` | Uses `#` headers instead of underline | Always — standard Markdown style | +| `--extract-media=./media` | Pulls embedded images to a folder | When PDF has images/diagrams | +| `--standalone` | Includes YAML metadata block | When you want title/author/date extracted | +| `-t gfm` | Output GitHub Flavored Markdown | When targeting GitHub repos or docs | +| `--shift-heading-level-by=-1` | Promotes headings up one level | When PDF has deeply nested structure | + +## Post-Conversion Cleanup + +PDF-to-Markdown output typically needs cleanup: + +1. **Tables** — Pandoc often mangles complex PDF tables. Review and reformat manually. +2. **Headers** — Check hierarchy; PDFs may use visual formatting (bold/large text) instead of semantic headers. +3. **Images** — Verify extracted images are correct and paths are valid. +4. **Page numbers/footers** — Remove repeated headers, footers, page numbers. +5. **Line breaks** — Remove orphaned line breaks mid-sentence (common from PDF column layouts). +6. **Special characters** — Fix encoding artifacts (curly quotes, em dashes, ligatures). + +## Other Useful Conversions + +Pandoc handles many formats beyond PDF→Markdown: + +```bash +# DOCX to Markdown +pandoc input.docx -o output.md --wrap=none --atx-headers --extract-media=./media + +# Markdown to DOCX +pandoc input.md -o output.docx + +# Markdown to PowerPoint (useful for deep dive presentations!) +pandoc input.md -o output.pptx + +# HTML to Markdown +pandoc input.html -o output.md -t gfm +``` + +## Anti-Patterns + +- **Don't assume perfect conversion.** PDF is a visual format, not semantic. Complex layouts (multi-column, sidebars, callout boxes) will produce messy output. +- **Don't skip cleanup.** Always review output before using in content workflows. +- **Don't use pandoc for scanned PDFs.** Pandoc reads text-layer PDFs only. For scanned/image PDFs, you need OCR (Tesseract) first. +- **Don't forget `--wrap=none`.** Without it, pandoc inserts hard line breaks at ~72 chars, splitting paragraphs. diff --git a/packages/squad-cli/templates/skills/power-bi-mcp/SKILL.md b/packages/squad-cli/templates/skills/power-bi-mcp/SKILL.md new file mode 100644 index 000000000..7ad6c02f2 --- /dev/null +++ b/packages/squad-cli/templates/skills/power-bi-mcp/SKILL.md @@ -0,0 +1,85 @@ +# Power BI Remote MCP Server — Setup & Usage + +## Purpose +Use the remote Power BI MCP server to query Power BI reports directly from Copilot (VS Code or CLI). Provides access to all reports you normally access via the web — way better than wrestling with web views. + +## Confidence: high +Source: Karl Erickson (Teams, 2026-03-13 8:27 AM), validated by Dina Berry same day and 2026-03-16. + +## Source +- **Karl Erickson** shared setup instructions and tips in Teams on 2026-03-13 +- **Jon Burchel** asked about write capabilities (Karl: "Could you elaborate? Do you mean build a web-based dashboard?") +- **Dina Berry** confirmed success after auth troubleshooting + +## Installation + +### Step 1: Install in VS Code first +1. Go to the Microsoft Learn article: [Get started with the remote Power BI MCP server](https://learn.microsoft.com/power-bi/mcp-server) +2. Use the **"quick installation" button** on that page to install to VS Code +3. Complete auth in VS Code — you MUST see the **explicit web-based auth dialogue** (if you don't see it, it's not working) + +### Step 2: Install in Copilot CLI +1. After VS Code auth succeeds, use the config from VS Code to install to Copilot CLI via `/mcp add` +2. Use the same JSON config info that VS Code generated + +**⚠️ CRITICAL ORDER:** You MUST do VS Code first, then CLI. Karl: "FWIW I couldn't get it to work in Copilot CLI at all until I first did the one-click install in VS Code, and then get it to auth in VS Code. Then I could add the MCP server to Copilot CLI using the same info from the JSON config, and it worked." + +## Auth Troubleshooting + +| Problem | Solution | +|---------|----------| +| Auth expiration errors in Copilot CLI | Exit and resume Copilot to force re-auth | +| MCP server not connecting | Try: Ctrl+Shift+P → MCP: List Servers → powerbi-remote → Start Server (or Restart Server, or Disconnect Account then Start Server) | +| Not clear why it's not working | If you don't see the explicit web-based auth dialogue, it's NOT working. Must see the browser auth popup. | +| "Error fetching authorization server metadata" 404 | This is the OAuth metadata discovery failing — restart the MCP server and re-auth | + +## Usage — Example Prompts + +After auth, you can access any report you normally access via the web: + +- **Content Quality Report queries:** + ``` + Using the Content Quality Report, show me all articles where the Author is '' + that are past their freshness date. Show the Days til Stale, Title, and the raw GitHub URL. + ``` + +- **Content Engagement Report queries:** + ``` + Using the Content Engagement Report, show me engagement metrics for articles authored by ''. + ``` + +- **Report by URL:** Start by pasting a report URL and the MCP server figures out the report name and structure. This is a great way to discover what's available. + +- **Report by name:** After initial auth, you can reference reports by display name (e.g., "Content Quality Report", "Content Engagement Report") — no URL needed. + +- **General access:** After auth, you should be able to get anything that you normally have access to via the web + +## Capabilities + +- ✅ **READ reports** — query any Power BI report you have web access to +- ❓ **WRITE reports** — unclear. Jon Burchel asked; Karl asked for clarification ("Do you mean build a web-based dashboard?"). Status: unresolved as of 2026-03-13. + +## Configuration + +The MCP server config lives in `.copilot/mcp-config.json` (for CLI) or VS Code MCP settings. The server identifier is `powerbi-remote`. + +## Known Reports + +These Power BI reports are confirmed accessible and useful for content work: + +| Report Name | Use Case | +|-------------|----------| +| Content Quality Report | Freshness dates, stale articles, author lookup, GitHub URLs | +| Content Engagement Report | Page views, engagement metrics, content performance | + +> Tip: If you know a report exists but aren't sure of the exact name, paste the report URL first. The MCP server resolves it to the report name + schema automatically. + +## Related +- Microsoft Learn article: "Get started with the remote Power BI MCP server" +- This is useful for the daily content metrics workflow — can query Content Quality Report and Content Engagement Report directly +- Potential integration with `.squad/skills/pbi-report-analysis/SKILL.md` if that skill exists + +## People +- **Karl Erickson** — original tip, setup expertise, troubleshooting knowledge +- **Jon Burchel** — interested in write capabilities +- **Dina Berry** — confirmed working after setup diff --git a/packages/squad-cli/templates/skills/pr-merge-workflow/SKILL.md b/packages/squad-cli/templates/skills/pr-merge-workflow/SKILL.md new file mode 100644 index 000000000..96c46dd1b --- /dev/null +++ b/packages/squad-cli/templates/skills/pr-merge-workflow/SKILL.md @@ -0,0 +1,227 @@ +--- +name: "pr-merge-workflow" +description: "Structured merge-readiness gate for pull requests before running gh pr merge" +domain: "merge-gates, pull-request-quality, workflow-orchestration" +confidence: "low" +source: "manual (Flight workload-management proposal synthesized by Procedures)" +tools: + - name: "github-mcp-server-pull_request_read" + description: "Read pull request metadata, status, files, reviews, and check runs" + when: "Use as the primary merge-readiness evidence source for one PR" +--- + +## Context + +`pr-merge-workflow` is the structured gate that sits **before** `gh pr merge`. + +Use it when a human or Ralph needs a clear answer to: **is this PR actually merge-ready, and if not, what exactly is blocking it?** The skill should turn scattered PR metadata into a single verdict with explicit blockers, a concise change summary, and a recommended next action. + +This skill is primarily **single-PR and repo-aware**. When invoked from a cross-repo digest, the repo coordinates may come from `.squad/doc-intelligence.local.json` or the activity digest that referenced the PR. When invoked directly (`squad merge #N` or `squad merge #N --dry-run`), use the current repo context or explicit owner/repo input. + +The skill is a **gate, not an auto-merge policy**. It evaluates readiness, reports blockers, and only hands off to merge execution after a clear green verdict and explicit user intent. + +## Patterns + +### Invocation Pattern + +Support two modes: + +1. **Dry-run status mode** + - Example: `squad merge #N --dry-run` + - Purpose: show merge readiness without changing GitHub state +2. **Execution-prep mode** + - Example: `squad merge #N` + - Purpose: run the same checks first, then allow merge execution only if the verdict is green + +Both modes use the same evaluation logic. Execution mode simply adds the final human-triggered merge step. + +### Evidence Collection Pattern + +For the target PR, gather five views from `github-mcp-server-pull_request_read`: + +1. `get` — baseline metadata, base/head branches, mergeability clues, author, labels, draft state +2. `get_status` — combined commit status for quick pass/fail signal +3. `get_files` — file count and change footprint +4. `get_reviews` — approvals, change requests, review churn +5. `get_check_runs` — granular CI state across jobs + +If MCP access is unavailable, fall back to CLI inspection: + +- `gh pr view {N}` +- `gh pr checks {N}` + +Treat the fallback as evidence gathering, not a separate policy path. The same verdict structure still applies. + +### Required Gate Pattern + +A PR is only `ready to merge` when all required gates are green. + +#### Gate 1: Review completeness + +Check: +- required reviewers approved +- no unresolved `changes requested` state from required reviewers +- no obvious missing approval when the repo policy or PR metadata signals review is still pending + +Output should identify: +- who approved +- who is still requested / pending +- who requested changes + +#### Gate 2: CI health + +Check: +- all required check runs are passing +- no in-progress required jobs remain +- no failed or cancelled required jobs remain + +If checks are mixed, say so explicitly: +- `waiting for CI` +- `failing CI` +- `partial / unknown CI state` + +#### Gate 3: Mergeability + +Check: +- no merge conflicts +- PR is not draft +- GitHub reports mergeable or does not indicate a conflict block + +If mergeability is unknown, do not silently pass the gate. Mark it `needs attention` and explain what is missing. + +#### Gate 4: Freshness against base + +Check whether the branch is stale relative to the base branch. + +Use evidence such as: +- base/head divergence in PR metadata +- stale branch indicators in checks or status +- repo policy clues that the branch must be updated before merge + +If stale: +- verdict is not `ready to merge` +- suggested action should be `update branch and rerun checks` + +### Change Summary Pattern + +Summarize the PR in a form that supports a fast merge decision. + +Always include: +- file count +- additions/deletions when available +- notable file paths or hotspots if the diff is large +- semantic intent: one short sentence describing what changed in user or system terms + +The semantic intent should describe the change category, for example: +- CLI behavior change +- prompt/skill contract update +- runtime bug fix +- docs-only clarification +- config surface change + +Do not reproduce the whole diff. Compress it into a decision-ready snapshot. + +### Verdict Pattern + +Return one of three top-level actions: + +#### `ready to merge` +Use when reviews, CI, mergeability, and freshness are all green. + +#### `waiting for X` +Use when the PR appears healthy but is still pending a specific dependency such as: +- one reviewer approval +- CI completion +- branch update + +#### `needs attention` +Use when there is an actual blocker or risk, such as: +- requested changes +- failing checks +- merge conflicts +- unknown mergeability +- stale branch with required update + +### Output Pattern + +Emit a structured pre-merge report. + +```markdown +# PR Merge Check — {owner}/{repo}#{number} + +## Verdict +- **Action:** ready to merge | waiting for X | needs attention +- **Why:** {one-line reason} + +## Review Gate +- Approvals: {names} +- Pending reviewers: {names or none} +- Changes requested: {names or none} + +## CI Gate +- Combined status: {success | pending | failure | mixed} +- Blocking jobs: + - {job name} — {state} + +## Mergeability Gate +- Draft: yes | no +- Conflicts: yes | no | unknown +- Branch freshness: current | stale | unknown + +## Change Summary +- Files changed: {count} +- Lines changed: +{adds} / -{dels} +- Intent: {semantic summary} + +## Suggested Next Step +- {merge now | wait for reviewer X | rerun failing job | update branch | resolve conflicts} +``` + +### Dry-Run Pattern + +`--dry-run` must produce the full report without triggering merge execution. That output should be stable enough for dashboards, REPL previews, and overnight summaries. + +### Owner Routing Pattern + +- **FIDO** owns gate semantics, blocker language, and review policy interpretation +- **Booster** owns CI/check integration and any workflow-specific status nuances + +If the merge gate needs deeper runtime or CLI wiring, hand implementation to EECOM after the gate contract is stable. + +## Examples + +### Example 1: Green merge + +The PR has two approvals, all check runs are green, the branch is current, and GitHub reports no conflicts. + +**Result:** +- verdict = `ready to merge` +- suggested next step = `merge now` + +### Example 2: One missing approval + +CI is green and the branch is current, but one requested reviewer has not approved yet. + +**Result:** +- verdict = `waiting for Alice's review` +- do not mark as blocked unless there is an actual negative signal + +### Example 3: Stale branch with red CI + +The PR is behind base and one required check run is failing. + +**Result:** +- verdict = `needs attention` +- blockers = `update branch` and `fix failing CI` +- do not suggest merge until both are cleared + +## Anti-Patterns + +- ❌ Running `gh pr merge` without first producing a structured gate verdict +- ❌ Treating one green combined status as enough when individual required checks are failing +- ❌ Ignoring `changes requested` because approvals also exist +- ❌ Hiding merge conflicts behind a generic `not ready` message +- ❌ Calling a stale branch merge-ready when the base branch has moved materially +- ❌ Using dry-run mode as a shallow summary that omits blockers +- ❌ Auto-merging without explicit user intent after the gate passes +- ❌ Reducing the change summary to file count only without semantic intent diff --git a/packages/squad-cli/templates/skills/pr-quality-checkpoint/SKILL.md b/packages/squad-cli/templates/skills/pr-quality-checkpoint/SKILL.md new file mode 100644 index 000000000..74ecafdca --- /dev/null +++ b/packages/squad-cli/templates/skills/pr-quality-checkpoint/SKILL.md @@ -0,0 +1,291 @@ +--- +name: "pr-quality-checkpoint" +description: "Pre-flight PR readiness scan that summarizes review state, CI health, change risk, and the next action" +domain: "quality-gates, pull-request-triage, orchestration" +confidence: "low" +source: "manual (Procedures designed the first-pass PR checkpoint workflow for Ralph and reviewer handoff)" +tools: + - name: "github-mcp-server-list_pull_requests" + description: "List candidate PRs awaiting review" + when: "Use during Ralph's overnight sweep to enumerate open PRs that need a checkpoint summary" + - name: "github-mcp-server-pull_request_read" + description: "Read PR metadata, files, reviews, review comments, status, and check runs" + when: "Use for the primary checkpoint pass on a specific PR number" + - name: "powershell" + description: "Run gh fallback commands when GitHub MCP data is incomplete" + when: "Use `gh pr view --json ...` and `gh pr checks` as the fallback path when MCP fields are missing or ambiguous" +--- + +## Context + +`pr-quality-checkpoint` is a pre-review quality gate, not a replacement for code review. + +Use it when Ralph is sweeping open PRs before Brady starts the day, or when someone asks for an on-demand readiness summary like: + +```text +squad pr review #123 --summarize +``` + +The skill answers one question fast: **is this PR worth human review time right now, and if not, what should happen next?** + +It should surface review state, CI health, change shape, staleness, conflicts, and doc/test risk in one compact summary. Owner routing is split by failure mode: + +- **FIDO** owns quality signals: failing tests, missing tests, weak coverage indicators, stale review state +- **EECOM** owns runtime and mergeability signals: branch health, PR wiring, conflict state, broken automation surfaces + +## Patterns + +### Invocation Modes + +Run the same checkpoint routine in two modes: + +1. **Ralph overnight sweep** + - list open PRs awaiting Brady's review + - run one checkpoint per PR + - feed the result into the morning dashboard or digest +2. **On-demand PR summary** + - accept a concrete PR number + - return the checkpoint only for that PR + +The routine is deterministic. Same inputs, same summary shape. + +### PR Checkpoint Routine + +Run these checks for every PR, in this order. + +#### 1. Load the PR envelope + +Use `github-mcp-server-pull_request_read` with `method: "get"` to capture: + +- title, author, draft state +- created/update timestamps +- mergeability / merge-state fields +- additions, deletions, changed files count +- base branch vs head branch +- requested reviewers / review decision fields when available +- latest commit metadata, including commit age +- branch divergence from base when available (behind/ahead) + +If any critical field is missing, fall back to: + +```text +gh pr view --json number,title,author,isDraft,createdAt,updatedAt,mergeStateStatus,reviewDecision,additions,deletions,changedFiles,files,commits,latestReviews,statusCheckRollup +``` + +#### 2. Review state summary + +Use: + +- `method: "get_reviews"` for approval / change-request / comment history +- `method: "get_review_comments"` for unresolved review-thread pressure + +Normalize review state into: + +- **approvals** — count latest effective approvals +- **requested changes** — count latest effective change requests +- **pending reviewers** — requested reviewers and still-required reviewers not yet satisfied + +Rules: + +- collapse each reviewer's latest effective state instead of double-counting history +- treat active change requests as blocking unless clearly superseded +- if required-review metadata is unavailable, use requested reviewers plus review decision as the best approximation +- unresolved blocking threads should downgrade readiness even when formal approvals exist + +#### 3. CI and automation state + +Use both: + +- `method: "get_check_runs"` +- `method: "get_status"` + +Summarize counts for: + +- passing +- failing +- pending / in progress +- cancelled or neutral when they affect readiness + +Rules: + +- any failure is a blocking signal +- any pending required check means **wait for CI** +- distinguish flaky or optional noise from required failures when the payload makes that obvious +- if check metadata is incomplete, fall back to `gh pr checks ` + +#### 4. Change summary and semantic classification + +Use `method: "get_files"` to compute: + +- file count +- total lines added / removed +- dominant path clusters (`src/`, `test/`, `docs/`, `.squad/`, `templates/`, config files) +- semantic classification + +Classify the PR as one primary type, with secondary tags when needed: + +- **bugfix** — targeted behavior correction, tests often near production files +- **feature** — new capability, command, workflow, or user-visible path +- **refactor** — structural rework with little user-facing change +- **docs** — documentation-only or docs-dominant edits +- **config** — workflow, CI, templates, package, or environment contract changes + +Prefer semantic classification over raw path counting. A small `src/` change plus a new command should still classify as a feature. + +#### 5. Staleness and branch health + +Compute and report: + +- PR age +- last commit age +- whether the branch is behind base +- whether the PR is draft + +Flag as stale when any of these hold: + +- PR has been open long enough to suggest review context drift +- last commit is old and CI/review state is still unresolved +- branch is materially behind base + +The exact age threshold can be repo-tuned later. For the first pass, treat multi-day inactivity as a warning, not an automatic block, unless it combines with conflicts or failing CI. + +#### 6. Conflict check + +Use the PR mergeability fields from `get`. + +- merge conflicts or blocked merge state => **resolve conflicts** +- unknown mergeability should be called out explicitly, not silently treated as mergeable +- if behind-base state requires rebase before merge, include that in the action recommendation + +#### 7. Test coverage signal + +Inspect `get_files` output for test-path changes (`test/`, `tests/`, `__tests__/`, `*.test.*`, `*.spec.*`). + +Flag **missing tests** when: + +- behavior changed in runtime, CLI, workflow, or prompt contracts +- but no new or updated tests appear + +Do not flag missing tests for: + +- docs-only changes +- comment-only changes +- clearly mechanical config edits with no behavior impact + +This is a heuristic signal, not proof of missing coverage. + +#### 8. Docs impact signal + +Flag **missing docs** when the PR appears to change user-facing or operator-facing behavior but does not touch likely doc surfaces: + +- `README.md` +- `docs/` +- `.squad/` +- `templates/` + +Use the repo's existing rule: every PR should be evaluated for doc impact. A missing-docs flag is especially important for feature, config, workflow, or prompt-contract changes. + +### Badge and Recommendation Mapping + +Emit exactly one badge and one recommended action. + +#### Badge mapping + +- **✅ Ready to merge** + - no active change requests + - required checks passing + - mergeable + - no blocking flags +- **⚠️ Needs attention** + - non-blocking warnings exist (stale, missing tests, missing docs, pending reviewers with otherwise healthy CI) +- **🔴 Blocked** + - failing checks, active change requests, draft state, merge conflicts, or clearly blocked automation + +#### Recommended action mapping + +- `merge` — approvals satisfied, checks pass, no blockers remain +- `review` — healthy enough for human review, but not yet in a merge-ready state +- `wait for CI` — pending required checks +- `address feedback` — active change requests or unresolved blocking review pressure +- `resolve conflicts` — merge conflicts or behind-base state that blocks merge + +### Output Shape + +Return one compact checkpoint block per PR: + +```markdown +## PR # — +Status: ✅ Ready to merge | ⚠️ Needs attention | 🔴 Blocked +Recommended action: merge | review | wait for CI | address feedback | resolve conflicts + +- Review summary: <approvals>, <requested changes>, <pending reviewers> +- CI summary: <pass>/<fail>/<pending> +- Change summary: <files> files, +<additions> / -<deletions>, <type> +- Flags: <stale?>, <conflicts?>, <missing tests?>, <missing docs?> +- Notes: <one-sentence explanation of the main driver> +``` + +Keep the explanation short, decisive, and reviewer-oriented. + +### Ralph Integration and Routing + +For Ralph's overnight sweep: + +- run this skill for every PR awaiting Brady's review +- store the summary in the overnight dashboard input +- surface blocked PRs first, then attention items, then ready items + +Route follow-up ownership by dominant issue: + +- **FIDO** when the checkpoint is blocked by tests, CI quality, or missing coverage signal +- **EECOM** when the checkpoint is blocked by runtime plumbing, mergeability, or branch health +- mixed signals can name both, but still produce one action recommendation + +## Examples + +### Example 1: Healthy PR ready for review or merge + +A PR has two approvals, no change requests, all checks pass, and no merge conflicts. + +**Result:** +- Badge: `✅ Ready to merge` +- Recommended action: `merge` +- Flags: none + +### Example 2: CI still running + +A PR is mergeable and already approved, but two required checks are still pending. + +**Result:** +- Badge: `⚠️ Needs attention` +- Recommended action: `wait for CI` +- Notes call out that reviewer time can wait until checks settle + +### Example 3: Behavior changed without tests or docs + +A PR adds a new CLI behavior under `src/` but does not touch `test/`, `README.md`, `docs/`, `.squad/`, or `templates/`. + +**Result:** +- Badge: `⚠️ Needs attention` +- Recommended action: `review` +- Flags: `missing tests`, `missing docs` + +### Example 4: Review blocked by feedback and conflicts + +A PR has an active change request and GitHub reports merge conflicts. + +**Result:** +- Badge: `🔴 Blocked` +- Recommended action: `resolve conflicts` +- Notes mention that conflict resolution comes before another human pass + +## Anti-Patterns + +- ❌ Treating this checkpoint as a substitute for code review +- ❌ Reporting raw GitHub data without collapsing it into one action recommendation +- ❌ Counting every historical review event instead of each reviewer's latest effective state +- ❌ Calling a PR ready when required checks are still pending +- ❌ Ignoring unresolved review pressure because formal approvals exist +- ❌ Flagging missing tests for docs-only or comment-only changes +- ❌ Flagging missing docs for pure refactors with no user-facing effect +- ❌ Hiding unknown mergeability; if it is unknown, say it is unknown diff --git a/packages/squad-cli/templates/skills/session-setup/SKILL.md b/packages/squad-cli/templates/skills/session-setup/SKILL.md new file mode 100644 index 000000000..7d2e0c6bb --- /dev/null +++ b/packages/squad-cli/templates/skills/session-setup/SKILL.md @@ -0,0 +1,115 @@ +--- +name: "session-setup" +description: "Discovers all project repos, ensures they are clean, synced to main, and on fresh session branches before starting work" +domain: "workflow" +confidence: "high" +source: "manual" +--- + +## Context + +Every new squad session must start with all repositories in a clean, synced state on fresh branches. This prevents cross-session contamination and ensures PRs are clean. + +## Repository Discovery + +At the start of every session, discover the project's repos dynamically: + +1. **Identify the squad repo** — The repo you are currently in, which contains the `.squad/` directory. +2. **Find the project directory** — Go one level up from the squad repo (`..`). This is the project root that groups all related repos together. +3. **Scan for sibling repos** — List all directories in the project root that are git repos (contain a `.git/` directory or folder). Each one is a project repo managed by this session. +4. **Classify repos:** + - **Squad repo** (`[squad]`) — The repo containing `.squad/`. Gets squad-only changes (`.squad/` files, decisions, charters, skills, session summaries). + - **Content repos** (`[content]`) — All other sibling git repos. Get code and content changes (source code, docs, prompts, scripts, templates, infra). + +> **Key rule:** Squad state (`.squad/`) lives ONLY in the squad repo. Never create `.squad/` in content repos. + +Present the discovered repos to the user: + +``` +📁 Project directory: ../ + [squad] <squad-repo-dir>/ — squad config + [content] <content-repo-1>/ — code/content + [content] <content-repo-2>/ — code/content + ... +``` + +If only the squad repo is found (no sibling content repos), that is valid — some projects are squad-only. + +## Procedure + +Run these steps at the start of every new session, BEFORE any work begins. + +### Step 0 — Resume context from last session + +Check if `<squad-repo>/.squad/sessions/latest.md` exists. If it does: +- Read it and present the summary to the user +- Highlight "Where we stopped" and "Open items for next session" +- Note any open PRs that may need attention +- Ask: "Want to continue this work, or start something new?" + +If it doesn't exist, skip this step. + +### Step 1 — Check for uncommitted work + +For **each** discovered repo, run `git status`. If there are uncommitted changes: +- Show the user which repo has uncommitted work and what changed +- Ask the user what to do: commit, stash, or discard +- Do NOT proceed until every working tree is clean + +### Step 2 — Sync main to remote + +For **each** discovered repo: +```bash +cd ../<repo-dir> +git checkout main +git fetch origin +git reset --hard origin/main +``` + +### Step 3 — Create session branches + +Branch naming: `squad/<session-description>` (e.g., `squad/fix-branding-quality`) + +For **each** discovered repo: +```bash +cd ../<repo-dir> +git checkout -b squad/<branch-name> +``` + +Use the SAME branch name in all repos so they are easy to correlate. + +### Step 4 — Verify + +Confirm **every** discovered repo shows: +- Clean working tree (`git status` shows nothing to commit) +- On the new session branch +- Main is at the same commit as remote + +Report the starting state to the user: +``` +✅ Session ready + [squad] <squad-repo>/ — on squad/<branch> from main@<short-sha> + [content] <content-repo-1>/ — on squad/<branch> from main@<short-sha> + [content] <content-repo-2>/ — on squad/<branch> from main@<short-sha> + ... +``` + +## Rules + +- **Never skip this procedure** — even for "quick fixes" +- **Never commit directly to main** — always use session branches +- **Same branch name in all repos** — keeps correlation obvious +- **Ask before discarding uncommitted work** — the user may want to stash or commit first +- **Squad repo gets squad-only changes** — `.squad/` files, decisions, agent history, skills +- **Content repos get code/content changes** — source code, docs, prompts, scripts, templates, infra +- **Never create `.squad/` in content repos** — squad state lives only in the squad repo + +## Anti-Patterns + +- Starting work on a stale main (forgot to fetch/reset) +- Using different branch names across repos +- Committing code changes to the squad repo +- Committing squad config to a content repo +- Working directly on main without a session branch +- Creating `.squad/` directories in content repos +- Hardcoding repo paths instead of using relative discovery diff --git a/packages/squad-cli/templates/skills/session-teardown/SKILL.md b/packages/squad-cli/templates/skills/session-teardown/SKILL.md new file mode 100644 index 000000000..c8a772182 --- /dev/null +++ b/packages/squad-cli/templates/skills/session-teardown/SKILL.md @@ -0,0 +1,130 @@ +--- +name: "session-teardown" +description: "End-of-session: commit all work across discovered repos, record progress, optionally create PRs, and save state for next session" +domain: "workflow" +confidence: "high" +source: "manual" +--- + +## Context + +Every session ends with a structured teardown that commits work, records progress, and saves state so the next session can pick up exactly where we left off. + +## Repository Discovery + +Use the same discovery process as `session-setup`: + +1. **Identify the squad repo** — The repo containing `.squad/`. +2. **Find the project directory** — One level up from the squad repo (`..`). +3. **Scan for sibling repos** — All directories in the project root that are git repos (contain `.git/`). +4. **Classify repos:** + - **Squad repo** (`[squad]`) — Gets squad-only commits. + - **Content repos** (`[content]`) — Get code/content commits. + +> **Key rule:** Squad state (`.squad/`) lives ONLY in the squad repo. Never write `.squad/` files to content repos. + +## Procedure + +### Step 1 — Commit uncommitted work + +For **each** discovered repo, check `git status`. If there are changes: + +**Squad repo** — commit squad-only changes (decisions, agent history, skills, sessions): +```bash +cd ../<squad-repo-dir> +git add -A +git commit -m "session: <summary of squad changes>" +git push origin <branch> +``` + +**Each content repo** — commit code/content changes: +```bash +cd ../<content-repo-dir> +git add -A +git commit -m "session: <summary of code/content changes>" +git push origin <branch> +``` + +If a repo has no changes, skip it and note "no changes." + +### Step 2 — Record session summary + +Write a session summary to `<squad-repo>/.squad/sessions/latest.md` with this format: + +```markdown +# Session Summary — <date> + +## Branches +<!-- List ALL discovered repos and their current branch --> +- <squad-repo>: `<branch-name>` +- <content-repo-1>: `<branch-name>` +- <content-repo-2>: `<branch-name>` + +## What we worked on +- <bullet summary of tasks completed> +- <bullet summary of tasks in progress> + +## Where we stopped +- <specific description of the exact stopping point> +- <any pending decisions or questions> + +## Open items for next session +- <what needs to happen next> +- <any blocked items and why> + +## PRs +<!-- List ALL discovered repos and their PR status --> +- <squad-repo>: <PR URL or "no PR"> +- <content-repo-1>: <PR URL or "no PR"> +- <content-repo-2>: <PR URL or "no PR"> +``` + +The session summary always lives in the squad repo. Create the `.squad/sessions/` directory if it does not exist. + +### Step 3 — Ask about PR merge + +For **each** repo that has a pushed branch, ask the user: + +> "Is the work on `<branch>` in `<repo>` ready to merge to main?" + +Options: +- **Yes — merge now**: Create PR (if not exists), merge, delete branch +- **No — keep the branch open**: Leave for next session +- **Create PR but don't merge**: Push and create PR for review + +### Step 4 — Verify final state + +Report the ending state: +``` +📋 Session closed + [squad] <squad-repo>/ — <branch>@<sha> — <clean/pushed/merged> + [content] <content-repo-1>/ — <branch>@<sha> — <clean/pushed/merged> + [content] <content-repo-2>/ — <branch>@<sha> — <clean/pushed/merged> + ... + Summary saved to <squad-repo>/.squad/sessions/latest.md +``` + +## Session File Location + +- Current session: `<squad-repo>/.squad/sessions/latest.md` (overwritten each session) +- The `session-setup` skill reads `latest.md` to remind the user where they left off +- Session files live ONLY in the squad repo — never in content repos + +## Rules + +- **Always commit before closing** — never leave uncommitted work +- **Always push** — local-only commits can be lost +- **Always write the session summary** — the next session depends on it +- **Ask before merging** — never auto-merge without user confirmation +- **Include PR URLs** — makes it easy to find open work +- **Session summary goes in the squad repo only** — never write `.squad/` files to content repos + +## Anti-Patterns + +- Closing a session with uncommitted changes +- Forgetting to push (commits stuck locally) +- No session summary (next session starts blind) +- Auto-merging without asking +- Vague summaries like "worked on stuff" — be specific about stopping point +- Writing session files to content repos instead of the squad repo +- Hardcoding repo paths instead of using relative discovery diff --git a/packages/squad-cli/templates/skills/teams-decision-capture/SKILL.md b/packages/squad-cli/templates/skills/teams-decision-capture/SKILL.md new file mode 100644 index 000000000..44b18db38 --- /dev/null +++ b/packages/squad-cli/templates/skills/teams-decision-capture/SKILL.md @@ -0,0 +1,174 @@ +--- +name: "teams-decision-capture" +description: "Capture durable team decisions from configured Teams channels into generalized decision inbox files" +domain: "prompt-architecture, decision-intelligence, teams-monitoring" +confidence: "low" +source: "manual (Procedures skill design from Flight's workload management proposal)" +tools: + - name: "workiq-ask_work_iq" + description: "Ask Microsoft 365 Copilot for a synthesized summary of recent Teams discussion" + when: "Use first for the configured channel to identify decisions, conventions, repeated questions, workarounds, and policy changes from the last 24 hours" + - name: "office-mcp-teams_channel" + description: "Read Teams channel metadata and messages directly" + when: "Use after the WorkIQ pass when you need precise verification that a decision, workaround, dissent, or convention change actually appeared in the configured channel" + - name: "office-mcp-search" + description: "Search related Microsoft 365 documents, emails, or meetings" + when: "Use when a thread references files, email, or meetings that clarify the rationale, follow-up, or durable pattern before writing the inbox decision file" +--- + +## Context + +`teams-decision-capture` turns ephemeral Teams channel discussion into durable, auditable decision intake for Squad. + +Use it when: + +- a configured Teams channel likely contains a team decision, policy statement, workflow change, workaround, or repeated question worth preserving +- an operator says `squad decisions capture` or `capture Teams decisions` +- Ralph runs the daily digest cycle and needs a compact queue of merge-ready decision notes for Scribe + +This skill does **not** archive chat. It extracts the reusable pattern and writes generalized markdown files to: + +```text +.squad/decisions/inbox/ +``` + +Reuse the existing local config pattern from `doc-intelligence`: + +- load `.squad/doc-intelligence.local.json` +- reuse the configured `sources.teams[]` entries already maintained for internal monitoring +- do not add a second local config file for this skill +- treat `.squad/skills/doc-intelligence/config.template.json` as the checked-in contract for the Teams source shape + +This skill complements `doc-intelligence`. + +- `doc-intelligence` detects documentation gaps and follow-up work +- `teams-decision-capture` preserves the generalized decision that created the gap or changed the rule + +## Patterns + +### 1. Retrieval Loop + +1. Load `.squad/doc-intelligence.local.json` and resolve the enabled Teams sources plus routing metadata. +2. Run a **WorkIQ summary pass** for the last 24 hours. Ask specifically for decisions, rationale, dissent, repeated questions, workarounds, policy changes, and convention changes. Ignore social chatter, standup updates, and staffing discussion. +3. Use `office-mcp-teams_channel` only when precise verification is needed — for example, to confirm that a workaround was framed as temporary, that dissent existed, or that a convention change was explicitly agreed. +4. Use `office-mcp-search` when the thread points at a document, email, or meeting that contains the rationale or next step needed to generalize the decision safely. +5. Dedupe overlapping signals so one durable decision produces one inbox file. + +### 2. Promotion Heuristic + +Promote a signal when it reflects one or more of these: + +- **Decision points** — what was decided, why, dissent, and next steps +- **Recurring questions** — the same question asked 2+ times, which makes it a FAQ candidate +- **Workarounds** — temporary fixes that should become a documented pattern or migration note +- **Policy statements** — new team rules, guardrails, or process changes +- **Convention changes** — naming changes, workflow changes, or new tool adoption + +Do **not** promote: + +- raw transcripts or message-by-message summaries +- social chatter or standup updates +- one-off individual preferences with no team impact +- private staffing or personnel discussion + +### 3. Confidentiality Filter + +Apply the same confidentiality test used by `doc-intelligence`. + +- Never quote internal chat verbatim. +- Replace names and raw message text with a generalized summary of the pattern. +- If the evidence cannot be described without exposing private conversation, do not emit a decision file. +- Source references should identify the channel and time window in generalized terms, not raw transcript content. + +### 4. Output Contract + +Each captured decision becomes a file at: + +```text +.squad/decisions/inbox/{source}-{brief-slug}.md +``` + +Use this structure for every emitted file: + +```markdown +# Decision Intake — {short title} + +**Captured from:** {generalized Teams source} +**Date:** {YYYY-MM-DD} +**Review route:** Procedures | PAO | Flight + +## Decision point +{what was decided} + +## Rationale +{why the team chose it} + +## Dissent +{material disagreement, trade-off, or "None noted"} + +## Next steps +- {action item} +- {action item} + +## Source reference +Generalized summary of the channel and time window. Do not include raw quotes, raw message links, or private transcript text. +``` + +### 5. Routing Pattern + +Default review ownership is split by durable impact: + +- **Procedures** — prompt contracts, charters, coordinator logic, skills, respawn prompts, and other agent-behavior rules +- **PAO** — messaging, FAQ follow-up, contributor-facing wording, and process explanations that should become docs or guidance +- **Flight** — architecture decisions, repo-boundary changes, or system-shape consequences that need architectural review + +If a captured decision creates a documentation gap, hand the follow-up documentation work to `doc-intelligence` rather than duplicating that analysis here. + +### 6. Ralph Integration + +Ralph can run this skill as part of the daily digest cycle: + +1. Load `.squad/doc-intelligence.local.json` +2. Scan configured Teams sources for the last 24 hours +3. Apply the retrieval loop and confidentiality filter +4. Write only high-signal inbox files +5. Stay quiet when no durable decisions were found + +## Examples + +### Example 1: Prompt contract change + +A Teams discussion settles a new handoff rule for agent review routing. + +**Result:** +- Write `.squad/decisions/inbox/{source}-agent-review-routing.md` +- Summarize the new rule, rationale, any dissent, and next steps without quoting the chat +- Route review to **Procedures** because it changes prompt contracts + +### Example 2: Repeated onboarding question + +Two people ask the same channel how a naming convention should be applied, and the team converges on one answer. + +**Result:** +- Write `.squad/decisions/inbox/{source}-naming-convention.md` +- Capture the convention as a team decision and flag it as a FAQ candidate +- Route follow-up wording/docs to **PAO** and let `doc-intelligence` handle the doc-gap suggestion + +### Example 3: Temporary workaround with architecture impact + +A channel agrees on a stopgap workflow while a broader platform change is still under review. + +**Result:** +- Write `.squad/decisions/inbox/{source}-temporary-workaround.md` +- Record the workaround as temporary, note the trade-off, and list the trigger for revisiting it +- Route review to **Flight** if the workaround changes architecture expectations + +## Anti-Patterns + +- ❌ Creating a new local config file instead of reusing `.squad/doc-intelligence.local.json` +- ❌ Leading with raw Office message reads before a WorkIQ summary pass when a high-level signal check would do +- ❌ Copying internal chat text verbatim into inbox files, PRs, or decision logs +- ❌ Treating every recent thread as a decision worth preserving +- ❌ Logging standups, social chatter, or staffing discussion as durable team decisions +- ❌ Mixing documentation-gap analysis into this skill instead of handing that follow-up to `doc-intelligence` +- ❌ Forgetting to capture dissent or next steps when they materially affect the decision diff --git a/packages/squad-sdk/bradygaster-squad-sdk-0.9.1.tgz b/packages/squad-sdk/bradygaster-squad-sdk-0.9.1.tgz new file mode 100644 index 000000000..d1b7ec31d Binary files /dev/null and b/packages/squad-sdk/bradygaster-squad-sdk-0.9.1.tgz differ diff --git a/packages/squad-sdk/package.json b/packages/squad-sdk/package.json index fccbe3427..56de57c60 100644 --- a/packages/squad-sdk/package.json +++ b/packages/squad-sdk/package.json @@ -213,10 +213,12 @@ "@opentelemetry/sdk-trace-base": "^1.30.0", "@opentelemetry/sdk-trace-node": "^1.30.0", "@opentelemetry/semantic-conventions": "^1.28.0", + "sql.js": "^1.14.1", "ws": "^8.18.0" }, "devDependencies": { "@types/node": "^22.0.0", + "@types/sql.js": "^1.4.10", "@types/ws": "^8.5.13", "typescript": "^5.7.0" }, diff --git a/packages/squad-sdk/src/agents/history-shadow.ts b/packages/squad-sdk/src/agents/history-shadow.ts index 696940d52..507ac8027 100644 --- a/packages/squad-sdk/src/agents/history-shadow.ts +++ b/packages/squad-sdk/src/agents/history-shadow.ts @@ -7,7 +7,9 @@ * Shadows live at: .squad/agents/{name}/history.md */ -import * as fs from 'fs/promises'; +import type { StorageProvider } from '../storage/storage-provider.js'; +import { FSStorageProvider } from '../storage/fs-storage-provider.js'; +import type { SquadState } from '../state/squad-state.js'; import * as path from 'path'; import { ConfigurationError } from '../adapter/errors.js'; @@ -67,23 +69,17 @@ async function withFileLock<T>( } /** - * Write a file atomically by writing to a temp file then renaming. - * Prevents concurrent readers from seeing partial content. + * Write file content via the StorageProvider abstraction. + * Previously used temp-file + rename for atomicity; now delegates to + * StorageProvider.write(). Race condition tracked in #479. * @private */ async function atomicWriteFile( + storage: StorageProvider, filePath: string, content: string, ): Promise<void> { - const tmpPath = `${filePath}.${process.pid}.tmp`; - try { - await fs.writeFile(tmpPath, content, 'utf-8'); - await fs.rename(tmpPath, filePath); - } catch (err) { - // Clean up temp file on failure - await fs.unlink(tmpPath).catch(() => {}); - throw err; - } + await storage.write(filePath, content); } /** @@ -132,22 +128,18 @@ export interface ParsedHistory { export async function createHistoryShadow( teamRoot: string, agentName: string, - initialContext?: string + initialContext?: string, + storage: StorageProvider = new FSStorageProvider(), ): Promise<string> { try { const shadowDir = path.join(teamRoot, '.squad', 'agents', agentName); const shadowPath = path.join(shadowDir, 'history.md'); - // Ensure directory exists - await fs.mkdir(shadowDir, { recursive: true }); - // Check if shadow already exists - try { - await fs.access(shadowPath); + // StorageProvider.write() creates parent dirs, so no explicit mkdir needed + if (await storage.exists(shadowPath)) { // Shadow exists, return path without overwriting return shadowPath; - } catch { - // Shadow doesn't exist, create it } // Create initial shadow content @@ -184,7 +176,7 @@ ${initialContext || 'No initial context provided.'} <!-- Important files, documentation, or external resources --> `; - await fs.writeFile(shadowPath, initialContent, 'utf-8'); + await storage.write(shadowPath, initialContent); return shadowPath; @@ -217,7 +209,8 @@ export async function appendToHistory( teamRoot: string, agentName: string, section: HistorySection, - content: string + content: string, + storage: StorageProvider = new FSStorageProvider(), ): Promise<void> { try { const shadowPath = path.join(teamRoot, '.squad', 'agents', agentName, 'history.md'); @@ -225,10 +218,8 @@ export async function appendToHistory( // Acquire file lock before the read-modify-write cycle (#479) await withFileLock(shadowPath, async () => { // Read existing history (inside lock to prevent races) - let historyContent: string; - try { - historyContent = await fs.readFile(shadowPath, 'utf-8'); - } catch (error) { + const historyContent = await storage.read(shadowPath); + if (historyContent === undefined) { throw new ConfigurationError( `History shadow not found for agent '${agentName}'. Create it first with createHistoryShadow().`, { @@ -237,7 +228,6 @@ export async function appendToHistory( timestamp: new Date(), metadata: { shadowPath }, }, - error instanceof Error ? error : undefined ); } @@ -263,7 +253,7 @@ export async function appendToHistory( } // Atomic write: temp file + rename prevents partial reads - await atomicWriteFile(shadowPath, updatedContent); + await atomicWriteFile(storage, shadowPath, updatedContent); }); } catch (error) { @@ -293,15 +283,30 @@ export async function appendToHistory( */ export async function readHistory( teamRoot: string, - agentName: string + agentName: string, + storage: StorageProvider = new FSStorageProvider(), + state?: SquadState, ): Promise<ParsedHistory> { try { const shadowPath = path.join(teamRoot, '.squad', 'agents', agentName, 'history.md'); - let historyContent: string; - try { - historyContent = await fs.readFile(shadowPath, 'utf-8'); - } catch (error) { + // Use SquadState agents collection when available + let historyContent: string | undefined; + if (state) { + try { + const entries = await state.agents.get(agentName).history(); + // If entries were returned, the file exists; read raw content via SP + // (ParsedHistory needs the full content, not just entries) + historyContent = await storage.read(shadowPath); + } catch { + // Agent not found via state — fall through to raw SP + historyContent = await storage.read(shadowPath); + } + } else { + historyContent = await storage.read(shadowPath); + } + + if (historyContent === undefined) { // Shadow doesn't exist yet, return empty return { fullContent: '', @@ -356,15 +361,11 @@ export async function readHistory( */ export async function shadowExists( teamRoot: string, - agentName: string + agentName: string, + storage: StorageProvider = new FSStorageProvider(), ): Promise<boolean> { - try { - const shadowPath = path.join(teamRoot, '.squad', 'agents', agentName, 'history.md'); - await fs.access(shadowPath); - return true; - } catch { - return false; - } + const shadowPath = path.join(teamRoot, '.squad', 'agents', agentName, 'history.md'); + return storage.exists(shadowPath); } /** @@ -375,11 +376,12 @@ export async function shadowExists( */ export async function deleteHistoryShadow( teamRoot: string, - agentName: string + agentName: string, + storage: StorageProvider = new FSStorageProvider(), ): Promise<void> { try { const shadowPath = path.join(teamRoot, '.squad', 'agents', agentName, 'history.md'); - await fs.unlink(shadowPath); + await storage.delete(shadowPath); } catch (error) { throw new ConfigurationError( `Failed to delete history shadow for agent '${agentName}': ${error instanceof Error ? error.message : String(error)}`, diff --git a/packages/squad-sdk/src/agents/index.ts b/packages/squad-sdk/src/agents/index.ts index ff14e4e09..7e8410fc1 100644 --- a/packages/squad-sdk/src/agents/index.ts +++ b/packages/squad-sdk/src/agents/index.ts @@ -6,8 +6,10 @@ * Injects dynamic context via session hooks instead of string templates. */ -import { readFile, readdir } from 'node:fs/promises'; import { join, dirname, basename } from 'node:path'; +import { FSStorageProvider } from '../storage/fs-storage-provider.js'; +import type { StorageProvider } from '../storage/storage-provider.js'; +import type { SquadState } from '../state/squad-state.js'; import { randomUUID } from 'node:crypto'; import { parseCharterMarkdown } from './charter-compiler.js'; import { EventBus } from '../client/event-bus.js'; @@ -125,12 +127,23 @@ export interface AgentSessionInfo { // --- Charter Compiler --- export class CharterCompiler { + private storage: StorageProvider; + private state?: SquadState; + + constructor(storage: StorageProvider = new FSStorageProvider(), state?: SquadState) { + this.storage = storage; + this.state = state; + } + /** * Load and compile a charter.md file into an AgentCharter. * Parses identity/model sections from markdown. */ async compile(charterPath: string): Promise<AgentCharter> { - const content = await readFile(charterPath, 'utf-8'); + const content = await this.storage.read(charterPath); + if (content === undefined) { + throw new Error(`Charter file not found: ${charterPath}`); + } const parsed = parseCharterMarkdown(content); const name = parsed.identity.name ?? basename(dirname(charterPath)); @@ -150,20 +163,69 @@ export class CharterCompiler { }; } + /** + * Compile a charter from an agent name using SquadState. + * Reads the charter via the typed agents collection. + */ + async compileByName(agentName: string): Promise<AgentCharter> { + if (!this.state) { + throw new Error('compileByName requires SquadState — pass state to CharterCompiler constructor'); + } + const content = await this.state.agents.get(agentName).charter(); + const parsed = parseCharterMarkdown(content); + + const name = parsed.identity.name ?? agentName; + const role = parsed.identity.role ?? ''; + const expertise = parsed.identity.expertise ?? []; + const style = parsed.identity.style ?? ''; + const displayName = `${name} — ${role}`; + + return { + name: name.toLowerCase(), + displayName, + role, + expertise, + style, + prompt: content, + modelPreference: parsed.modelPreference, + }; + } + /** * Load all charters from the team directory. - * Scans .squad/agents/{name}/charter.md, skipping scribe and _alumni. + * When SquadState is available, uses the typed agents collection. + * Otherwise scans .squad/agents/{name}/charter.md, skipping scribe and _alumni. */ async compileAll(teamRoot: string): Promise<AgentCharter[]> { + // Use SquadState agents collection when available + if (this.state) { + const names = await this.state.agents.list(); + const charters: AgentCharter[] = []; + + for (const name of names) { + if (name === 'scribe' || name.startsWith('_')) continue; + try { + charters.push(await this.compileByName(name)); + } catch { + // Skip agents without a valid charter.md + } + } + + return charters; + } + + // Fallback: raw StorageProvider scan const agentsDir = join(teamRoot, '.squad', 'agents'); - const entries = await readdir(agentsDir, { withFileTypes: true }); + if (!await this.storage.exists(agentsDir)) { + throw new Error(`Agents directory not found: ${agentsDir}`); + } + const entries = await this.storage.list(agentsDir); const charters: AgentCharter[] = []; - for (const entry of entries) { - if (!entry.isDirectory()) continue; - if (entry.name === 'scribe' || entry.name.startsWith('_')) continue; + for (const name of entries) { + if (name === 'scribe' || name.startsWith('_')) continue; - const charterPath = join(agentsDir, entry.name, 'charter.md'); + const charterPath = join(agentsDir, name, 'charter.md'); try { charters.push(await this.compile(charterPath)); } catch { diff --git a/packages/squad-sdk/src/agents/lifecycle.ts b/packages/squad-sdk/src/agents/lifecycle.ts index dc7166b15..e7956ebc8 100644 --- a/packages/squad-sdk/src/agents/lifecycle.ts +++ b/packages/squad-sdk/src/agents/lifecycle.ts @@ -12,8 +12,9 @@ import type { SquadSession, SquadSessionConfig } from '../adapter/types.js'; import { compileCharter, type CharterCompileOptions } from './charter-compiler.js'; import { resolveModel, type ModelResolutionOptions, type TaskType } from './model-selector.js'; import { ConfigurationError, SessionLifecycleError } from '../adapter/errors.js'; -import * as fs from 'fs/promises'; import * as path from 'path'; +import { FSStorageProvider } from '../storage/fs-storage-provider.js'; +import type { StorageProvider } from '../storage/storage-provider.js'; import { trace, SpanStatusCode } from '../runtime/otel-api.js'; import { recordAgentSpawn, recordAgentDestroy, recordAgentError } from '../runtime/otel-metrics.js'; @@ -94,6 +95,9 @@ export interface LifecycleManagerConfig { /** Path to team root directory */ teamRoot: string; + /** Storage provider for file I/O (default: FSStorageProvider) */ + storage?: StorageProvider; + /** Default idle timeout (default: 5 minutes) */ defaultIdleTimeout?: number; } @@ -107,6 +111,7 @@ export interface LifecycleManagerConfig { export class AgentLifecycleManager { private client: SquadClientWithPool; private teamRoot: string; + private storage: StorageProvider; private defaultIdleTimeout: number; private agents: Map<string, AgentHandleImpl> = new Map(); private idleCheckTimer: NodeJS.Timeout | null = null; @@ -114,6 +119,7 @@ export class AgentLifecycleManager { constructor(config: LifecycleManagerConfig) { this.client = config.client; this.teamRoot = config.teamRoot; + this.storage = config.storage ?? new FSStorageProvider(); this.defaultIdleTimeout = config.defaultIdleTimeout ?? 300_000; // 5 minutes // Start idle timeout checker @@ -152,11 +158,9 @@ export class AgentLifecycleManager { try { // Step 1: Read charter.md const charterPath = path.join(this.teamRoot, '.ai-team', 'agents', agentName, 'charter.md'); - let charterContent: string; - - try { - charterContent = await fs.readFile(charterPath, 'utf-8'); - } catch (error) { + const charterContent = await this.storage.read(charterPath); + + if (charterContent === undefined) { throw new ConfigurationError( `Charter not found for agent '${agentName}' at ${charterPath}`, { @@ -164,8 +168,7 @@ export class AgentLifecycleManager { operation: 'spawnAgent', timestamp: new Date(), metadata: { charterPath }, - }, - error instanceof Error ? error : undefined + } ); } diff --git a/packages/squad-sdk/src/agents/onboarding.ts b/packages/squad-sdk/src/agents/onboarding.ts index 20fd60e31..09f0df661 100644 --- a/packages/squad-sdk/src/agents/onboarding.ts +++ b/packages/squad-sdk/src/agents/onboarding.ts @@ -7,9 +7,10 @@ * @module agents/onboarding */ -import { mkdir, writeFile, readFile } from 'fs/promises'; import { join } from 'path'; -import { existsSync } from 'fs'; +import type { StorageProvider } from '../storage/storage-provider.js'; +import { FSStorageProvider } from '../storage/fs-storage-provider.js'; +import type { SquadState } from '../state/squad-state.js'; // ============================================================================ // Onboarding Types @@ -318,7 +319,11 @@ function titleCase(str: string): string { * @param options - Onboarding options * @returns Result with created file paths */ -export async function onboardAgent(options: OnboardOptions): Promise<OnboardResult> { +export async function onboardAgent( + options: OnboardOptions, + storage: StorageProvider = new FSStorageProvider(), + state?: SquadState, +): Promise<OnboardResult> { const { teamRoot, agentName, @@ -347,11 +352,11 @@ export async function onboardAgent(options: OnboardOptions): Promise<OnboardResu // Create agent directory const agentDir = join(teamRoot, '.squad', 'agents', normalizedName); - if (existsSync(agentDir)) { + if (await storage.exists(agentDir)) { throw new Error(`Agent directory already exists: ${agentDir}`); } - await mkdir(agentDir, { recursive: true }); + // Write charter.md (storage.write auto-creates parent directories) // Determine display name const effectiveDisplayName = displayName || titleCase(normalizedName); @@ -369,11 +374,6 @@ export async function onboardAgent(options: OnboardOptions): Promise<OnboardResu } } - // Write charter.md - const charterPath = join(agentDir, 'charter.md'); - await writeFile(charterPath, charterContent, 'utf-8'); - createdFiles.push(charterPath); - // Generate history const historyContent = generateHistory( effectiveDisplayName, @@ -381,11 +381,21 @@ export async function onboardAgent(options: OnboardOptions): Promise<OnboardResu projectContext, userName ); - - // Write history.md + + // Write agent files — use SquadState when available, raw storage otherwise + const charterPath = join(agentDir, 'charter.md'); const historyPath = join(agentDir, 'history.md'); - await writeFile(historyPath, historyContent, 'utf-8'); - createdFiles.push(historyPath); + + if (state) { + // SquadState.agents.create() writes charter + generic history. + // We write charter via state, then overwrite history with our richer template. + await state.agents.create(normalizedName, charterContent); + await storage.write(historyPath, historyContent); + } else { + await storage.write(charterPath, charterContent); + await storage.write(historyPath, historyContent); + } + createdFiles.push(charterPath, historyPath); return { createdFiles, @@ -409,16 +419,20 @@ export async function onboardAgent(options: OnboardOptions): Promise<OnboardResu export async function addAgentToConfig( teamRoot: string, agentName: string, - role: string + role: string, + storage: StorageProvider = new FSStorageProvider() ): Promise<boolean> { const configPath = join(teamRoot, 'squad.config.ts'); - if (!existsSync(configPath)) { + if (!await storage.exists(configPath)) { return false; // No TypeScript config to update } try { - const content = await readFile(configPath, 'utf-8'); + const content = await storage.read(configPath); + if (content === undefined) { + return false; + } // Simple heuristic: add routing rule if role matches common work types const workTypeMap: Record<string, string> = { @@ -460,7 +474,7 @@ export async function addAgentToConfig( `rules: [\n${updatedRules}\n ]` ); - await writeFile(configPath, updatedContent, 'utf-8'); + await storage.write(configPath, updatedContent); return true; } catch (error) { // Silently fail if we can't parse/update the config diff --git a/packages/squad-sdk/src/agents/personal.ts b/packages/squad-sdk/src/agents/personal.ts index e200f4974..caaa011e0 100644 --- a/packages/squad-sdk/src/agents/personal.ts +++ b/packages/squad-sdk/src/agents/personal.ts @@ -8,10 +8,11 @@ * @module agents/personal */ -import fs from 'node:fs'; import path from 'node:path'; import { resolvePersonalSquadDir } from '../resolution.js'; import { AgentManifest } from '../config/agent-source.js'; +import { FSStorageProvider } from '../storage/fs-storage-provider.js'; +import type { StorageProvider } from '../storage/storage-provider.js'; /** Metadata tag for personal agents in a session cast */ export interface PersonalAgentMeta { @@ -32,32 +33,30 @@ export type PersonalAgentManifest = AgentManifest & { * Discover personal agents from the user's personal squad directory. * Returns empty array if personal squad is disabled or doesn't exist. */ -export async function resolvePersonalAgents(): Promise<PersonalAgentManifest[]> { - const personalDir = resolvePersonalSquadDir(); +export async function resolvePersonalAgents( + storage: StorageProvider = new FSStorageProvider(), +): Promise<PersonalAgentManifest[]> { + const personalDir = resolvePersonalSquadDir(storage); if (!personalDir) return []; const agentsDir = path.join(personalDir, 'agents'); - if (!fs.existsSync(agentsDir)) return []; - - const entries = await fs.promises.readdir(agentsDir, { withFileTypes: true }); + const entries = await storage.list(agentsDir); const agents: PersonalAgentManifest[] = []; - for (const entry of entries) { - if (!entry.isDirectory()) continue; - - const charterPath = path.join(agentsDir, entry.name, 'charter.md'); - if (!fs.existsSync(charterPath)) continue; + for (const name of entries) { + const charterPath = path.join(agentsDir, name, 'charter.md'); + const charterContent = await storage.read(charterPath); + if (!charterContent) continue; - const charterContent = await fs.promises.readFile(charterPath, 'utf-8'); const meta = parseCharterMetadataBasic(charterContent); agents.push({ - name: entry.name, + name, role: meta.role || 'personal', source: 'personal', personal: { origin: 'personal', - sourceDir: path.join(agentsDir, entry.name), + sourceDir: path.join(agentsDir, name), ghostProtocol: true, }, }); diff --git a/packages/squad-sdk/src/build/bundle.ts b/packages/squad-sdk/src/build/bundle.ts index f7d6340f5..92e16ac92 100644 --- a/packages/squad-sdk/src/build/bundle.ts +++ b/packages/squad-sdk/src/build/bundle.ts @@ -3,8 +3,11 @@ * Defines bundling strategy for the SDK: ESM output, tree-shakeable */ -import { existsSync, readdirSync, statSync } from 'node:fs'; +// TODO: readdirSync/statSync still use raw fs — StorageProvider needs sync list() and isDirectory() methods +import { readdirSync, statSync } from 'node:fs'; import { join, resolve } from 'node:path'; +import type { StorageProvider } from '../storage/storage-provider.js'; +import { FSStorageProvider } from '../storage/fs-storage-provider.js'; export type BundleFormat = 'esm' | 'cjs'; @@ -76,14 +79,14 @@ export interface BundleValidationResult { /** * Check that the output directory contains expected build artifacts. */ -export function validateBundleOutput(outDir: string): BundleValidationResult { +export function validateBundleOutput(outDir: string, storage: StorageProvider = new FSStorageProvider()): BundleValidationResult { const errors: string[] = []; const warnings: string[] = []; const files: string[] = []; const resolvedDir = resolve(outDir); - if (!existsSync(resolvedDir)) { + if (!storage.existsSync(resolvedDir)) { return { valid: false, errors: [`Output directory does not exist: ${resolvedDir}`], warnings, files }; } diff --git a/packages/squad-sdk/src/build/release.ts b/packages/squad-sdk/src/build/release.ts index 1ef10022b..1dbf1a66e 100644 --- a/packages/squad-sdk/src/build/release.ts +++ b/packages/squad-sdk/src/build/release.ts @@ -6,7 +6,10 @@ */ import { createHash } from 'node:crypto'; -import { existsSync, readFileSync, statSync } from 'node:fs'; +// TODO: statSync still uses raw fs — StorageProvider needs a stat/size method +import { statSync } from 'node:fs'; +import type { StorageProvider } from '../storage/storage-provider.js'; +import { FSStorageProvider } from '../storage/fs-storage-provider.js'; import type { CommitInfo } from './versioning.js'; import { parseConventionalCommit } from './versioning.js'; @@ -102,10 +105,15 @@ export function computeSha256(data: Buffer | string): string { * Build a ReleaseArtifact from a file path. * Returns null if the file does not exist. */ -export function buildArtifact(name: string, filePath: string): ReleaseArtifact | null { - if (!existsSync(filePath)) return null; - - const content = readFileSync(filePath); +export function buildArtifact( + name: string, + filePath: string, + storage: StorageProvider = new FSStorageProvider(), +): ReleaseArtifact | null { + if (!storage.existsSync(filePath)) return null; + + const content = storage.readSync(filePath); + if (content === undefined) return null; const stat = statSync(filePath); return { @@ -126,12 +134,13 @@ export function buildArtifact(name: string, filePath: string): ReleaseArtifact | export function createRelease( config: ReleaseConfig, artifactPaths?: Record<string, string>, + storage: StorageProvider = new FSStorageProvider(), ): ReleaseManifest { const artifacts: ReleaseArtifact[] = []; if (artifactPaths) { for (const [name, filePath] of Object.entries(artifactPaths)) { - const artifact = buildArtifact(name, filePath); + const artifact = buildArtifact(name, filePath, storage); if (artifact) { artifacts.push(artifact); } diff --git a/packages/squad-sdk/src/casting/index.ts b/packages/squad-sdk/src/casting/index.ts index b3bf464eb..399bb1821 100644 --- a/packages/squad-sdk/src/casting/index.ts +++ b/packages/squad-sdk/src/casting/index.ts @@ -8,8 +8,9 @@ * Legacy API: CastingRegistry (filesystem-backed, stub) */ -import * as fs from 'node:fs'; import * as path from 'node:path'; +import { FSStorageProvider } from '../storage/fs-storage-provider.js'; +import type { StorageProvider } from '../storage/storage-provider.js'; export { CastingEngine, type CastMember, @@ -60,16 +61,18 @@ export interface CastingRegistryConfig { export class CastingRegistry { private entries: Map<string, CastingEntry> = new Map(); private config: CastingRegistryConfig; + private storage: StorageProvider; - constructor(config: CastingRegistryConfig) { + constructor(config: CastingRegistryConfig, storage: StorageProvider = new FSStorageProvider()) { this.config = config; + this.storage = storage; } async load(): Promise<void> { const registryPath = path.join(this.config.castingDir, 'registry.json'); - if (!fs.existsSync(registryPath)) return; + const raw = this.storage.readSync(registryPath); + if (!raw) return; - const raw = fs.readFileSync(registryPath, 'utf-8'); const entries = JSON.parse(raw) as CastingEntry[]; for (const entry of entries) { this.entries.set(entry.role, entry); diff --git a/packages/squad-sdk/src/config/agent-source.ts b/packages/squad-sdk/src/config/agent-source.ts index f2806818d..0763e4c4b 100644 --- a/packages/squad-sdk/src/config/agent-source.ts +++ b/packages/squad-sdk/src/config/agent-source.ts @@ -3,8 +3,10 @@ * Pluggable agent discovery and loading */ -import * as fs from 'fs/promises'; import * as path from 'path'; +import type { StorageProvider } from '../storage/index.js'; +import { FSStorageProvider } from '../storage/index.js'; +import type { SquadState } from '../state/squad-state.js'; export interface AgentSource { readonly name: string; @@ -83,7 +85,11 @@ export class LocalAgentSource implements AgentSource { readonly name = 'local'; readonly type = 'local' as const; - constructor(private basePath: string) {} + constructor( + private basePath: string, + private storage: StorageProvider = new FSStorageProvider(), + private state?: SquadState, + ) {} /** * Resolve the agents directory, preferring .squad/agents over .ai-team/agents. @@ -91,68 +97,109 @@ export class LocalAgentSource implements AgentSource { private async resolveAgentsDir(): Promise<string | null> { for (const dir of AGENT_DIRS) { const fullPath = path.join(this.basePath, dir); - try { - const stat = await fs.stat(fullPath); - if (stat.isDirectory()) return fullPath; - } catch { - // directory doesn't exist, try next - } + // TODO: storage.exists() cannot distinguish files from directories; + // original used fs.stat().isDirectory(). Acceptable here because + // AGENT_DIRS entries are always directories in practice. + if (await this.storage.exists(fullPath)) return fullPath; } return null; } async listAgents(): Promise<AgentManifest[]> { + // Use SquadState agents collection when available + if (this.state) { + try { + const names = await this.state.agents.list(); + const manifests: AgentManifest[] = []; + + for (const entryName of names) { + try { + const content = await this.state.agents.get(entryName).charter(); + const meta = parseCharterMetadata(content); + manifests.push({ + name: meta.name || entryName, + role: meta.role || 'agent', + source: 'local', + }); + } catch { + continue; + } + } + + return manifests; + } catch { + // Fall through to raw StorageProvider path + } + } + + // Fallback: raw StorageProvider scan const agentsDir = await this.resolveAgentsDir(); if (!agentsDir) return []; const manifests: AgentManifest[] = []; - let entries: import('fs').Dirent[]; + let entries: string[]; try { - entries = await fs.readdir(agentsDir, { withFileTypes: true }); + entries = await this.storage.list(agentsDir); } catch { return []; } - for (const entry of entries) { - if (!entry.isDirectory()) continue; - const charterPath = path.join(agentsDir, entry.name, 'charter.md'); + for (const entryName of entries) { + const charterPath = path.join(agentsDir, entryName, 'charter.md'); + const content = await this.storage.read(charterPath); + if (!content) continue; + const meta = parseCharterMetadata(content); + manifests.push({ + name: meta.name || entryName, + role: meta.role || 'agent', + source: 'local', + }); + } + + return manifests; + } + + async getAgent(name: string): Promise<AgentDefinition | null> { + // Use SquadState agents collection when available + if (this.state) { try { - const content = await fs.readFile(charterPath, 'utf-8'); - const meta = parseCharterMetadata(content); - manifests.push({ - name: meta.name || entry.name, + const handle = this.state.agents.get(name); + const charter = await handle.charter(); + const meta = parseCharterMetadata(charter); + + // Read history via raw storage (history parsing is more granular in shadow module) + const agentsDir = await this.resolveAgentsDir(); + const history = agentsDir + ? await this.storage.read(path.join(agentsDir, name, 'history.md')) + : undefined; + + return { + name: meta.name || name, role: meta.role || 'agent', source: 'local', - }); + charter, + model: meta.model, + tools: meta.tools, + skills: meta.skills, + history, + }; } catch { - // Skip agents with missing/unreadable charter + // Agent not found via state, fall through } } - return manifests; - } - - async getAgent(name: string): Promise<AgentDefinition | null> { + // Fallback: raw StorageProvider const agentsDir = await this.resolveAgentsDir(); if (!agentsDir) return null; const charterPath = path.join(agentsDir, name, 'charter.md'); - let charter: string; - try { - charter = await fs.readFile(charterPath, 'utf-8'); - } catch { - return null; - } + const charter = await this.storage.read(charterPath); + if (!charter) return null; const meta = parseCharterMetadata(charter); // Optionally read history.md - let history: string | undefined; - try { - history = await fs.readFile(path.join(agentsDir, name, 'history.md'), 'utf-8'); - } catch { - // history is optional - } + const history = await this.storage.read(path.join(agentsDir, name, 'history.md')); return { name: meta.name || name, @@ -167,14 +214,20 @@ export class LocalAgentSource implements AgentSource { } async getCharter(name: string): Promise<string | null> { + // Use SquadState agents collection when available + if (this.state) { + try { + return await this.state.agents.get(name).charter(); + } catch { + return null; + } + } + + // Fallback: raw StorageProvider const agentsDir = await this.resolveAgentsDir(); if (!agentsDir) return null; - try { - return await fs.readFile(path.join(agentsDir, name, 'charter.md'), 'utf-8'); - } catch { - return null; - } + return await this.storage.read(path.join(agentsDir, name, 'charter.md')) ?? null; } } diff --git a/packages/squad-sdk/src/config/init.ts b/packages/squad-sdk/src/config/init.ts index ee83ea8af..4a3a4f0b7 100644 --- a/packages/squad-sdk/src/config/init.ts +++ b/packages/squad-sdk/src/config/init.ts @@ -8,10 +8,12 @@ * @module config/init */ -import { mkdir, writeFile, readFile, copyFile, readdir, appendFile, unlink } from 'fs/promises'; +import { mkdir } from 'fs/promises'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; -import { existsSync, cpSync, statSync, mkdirSync, writeFileSync, readFileSync, readdirSync } from 'fs'; +import { cpSync, statSync, mkdirSync } from 'fs'; +import type { StorageProvider } from '../storage/index.js'; +import { FSStorageProvider } from '../storage/index.js'; import { execFileSync } from 'node:child_process'; import { MODELS } from '../runtime/constants.js'; import type { SquadConfig, ModelSelectionConfig, RoutingConfig } from '../runtime/config.js'; @@ -26,19 +28,19 @@ import { getRoleById } from '../roles/index.js'; /** * Get the SDK templates directory path. */ -export function getSDKTemplatesDir(): string | null { +export function getSDKTemplatesDir(storage: StorageProvider = new FSStorageProvider()): string | null { // Use fileURLToPath for cross-platform compatibility (handles Windows drive letters, URL encoding) const currentDir = dirname(fileURLToPath(import.meta.url)); // Try relative to this file (in dist/) const distPath = join(currentDir, '../../templates'); - if (existsSync(distPath)) { + if (storage.existsSync(distPath)) { return distPath; } // Try relative to package root (for dev) const pkgPath = join(currentDir, '../../../templates'); - if (existsSync(pkgPath)) { + if (storage.existsSync(pkgPath)) { return pkgPath; } @@ -48,18 +50,22 @@ export function getSDKTemplatesDir(): string | null { /** * Copy a directory recursively. */ -function copyRecursiveSync(src: string, dest: string): void { - if (!existsSync(dest)) { +function copyRecursiveSync(src: string, dest: string, storage: StorageProvider = new FSStorageProvider()): void { + if (!storage.existsSync(dest)) { + // TODO: mkdirSync for empty dirs has no StorageProvider equivalent (#481) mkdirSync(dest, { recursive: true }); } - for (const entry of statSync(src).isDirectory() ? readdirSync(src) : []) { + // TODO: statSync has no StorageProvider equivalent (#481) + for (const entry of statSync(src).isDirectory() ? storage.listSync(src) : []) { const srcPath = join(src, entry); const destPath = join(dest, entry); + // TODO: statSync has no StorageProvider equivalent (#481) if (statSync(srcPath).isDirectory()) { - copyRecursiveSync(srcPath, destPath); + copyRecursiveSync(srcPath, destPath, storage); } else { + // TODO: cpSync has no StorageProvider equivalent (#481) cpSync(srcPath, destPath); } } @@ -607,7 +613,7 @@ const FRAMEWORK_WORKFLOWS = [ 'sync-squad-labels.yml', ]; -export async function initSquad(options: InitOptions): Promise<InitResult> { +export async function initSquad(options: InitOptions, storage: StorageProvider = new FSStorageProvider()): Promise<InitResult> { const { teamRoot, projectName, @@ -656,23 +662,23 @@ export async function initSquad(options: InitOptions): Promise<InitResult> { // Helper to write file (respects skipExisting) const writeIfNotExists = async (filePath: string, content: string): Promise<boolean> => { - if (existsSync(filePath) && skipExisting) { + if (storage.existsSync(filePath) && skipExisting) { skippedFiles.push(toRelativePath(filePath)); return false; } - await mkdir(dirname(filePath), { recursive: true }); - await writeFile(filePath, content, 'utf-8'); + await storage.write(filePath, content); createdFiles.push(toRelativePath(filePath)); return true; }; // Helper to copy file (respects skipExisting) const copyIfNotExists = async (src: string, dest: string): Promise<boolean> => { - if (existsSync(dest) && skipExisting) { + if (storage.existsSync(dest) && skipExisting) { skippedFiles.push(toRelativePath(dest)); return false; } await mkdir(dirname(dest), { recursive: true }); + // TODO: cpSync has no StorageProvider equivalent (#481) cpSync(src, dest); createdFiles.push(toRelativePath(dest)); return true; @@ -696,7 +702,8 @@ export async function initSquad(options: InitOptions): Promise<InitResult> { ]; for (const dir of directories) { - if (!existsSync(dir)) { + if (!storage.existsSync(dir)) { + // TODO: mkdir for empty dirs has no StorageProvider equivalent (#481) await mkdir(dir, { recursive: true }); } } @@ -714,13 +721,13 @@ export async function initSquad(options: InitOptions): Promise<InitResult> { for (const cf of castingFiles) { const dest = join(castingDir, cf.name); - if (!existsSync(dest)) { + if (!storage.existsSync(dest)) { // Try to copy from SDK templates first, fall back to inline defaults const templateSrc = templatesDir ? join(templatesDir, cf.templateName) : null; - if (templateSrc && existsSync(templateSrc)) { + if (templateSrc && storage.existsSync(templateSrc)) { cpSync(templateSrc, dest); } else { - await writeFile(dest, cf.fallback, 'utf-8'); + await storage.write(dest, cf.fallback); } createdFiles.push(toRelativePath(dest)); } else { @@ -733,7 +740,7 @@ export async function initSquad(options: InitOptions): Promise<InitResult> { // ------------------------------------------------------------------------- const squadConfigPath = join(squadDir, 'config.json'); - if (!existsSync(squadConfigPath)) { + if (!storage.existsSync(squadConfigPath)) { // Detect platform from git remote for config let detectedPlatform: string | undefined; try { @@ -797,7 +804,7 @@ export async function initSquad(options: InitOptions): Promise<InitResult> { if (options.extractionDisabled) { squadConfig.extractionDisabled = true; } - await writeFile(squadConfigPath, JSON.stringify(squadConfig, null, 2), 'utf-8'); + await storage.write(squadConfigPath, JSON.stringify(squadConfig, null, 2)); createdFiles.push(toRelativePath(squadConfigPath)); } @@ -829,7 +836,6 @@ export async function initSquad(options: InitOptions): Promise<InitResult> { const agentsDir = join(squadDir, 'agents'); for (const agent of agents) { const agentDir = join(agentsDir, agent.name); - await mkdir(agentDir, { recursive: true }); agentDirs.push(agentDir); // Create charter.md @@ -883,7 +889,7 @@ Reusable patterns and heuristics learned through work. NOT transcripts — each // ------------------------------------------------------------------------- const ceremoniesDest = join(squadDir, 'ceremonies.md'); - if (templatesDir && existsSync(join(templatesDir, 'ceremonies.md'))) { + if (templatesDir && storage.existsSync(join(templatesDir, 'ceremonies.md'))) { await copyIfNotExists(join(templatesDir, 'ceremonies.md'), ceremoniesDest); } @@ -940,7 +946,7 @@ ${projectDescription ? `- **Description:** ${projectDescription}\n` : ''}- **Cre // ------------------------------------------------------------------------- const routingPath = join(squadDir, 'routing.md'); - if (templatesDir && existsSync(join(templatesDir, 'routing.md'))) { + if (templatesDir && storage.existsSync(join(templatesDir, 'routing.md'))) { await copyIfNotExists(join(templatesDir, 'routing.md'), routingPath); } else { const routingContent = `# Squad Routing @@ -963,10 +969,11 @@ ${projectDescription ? `- **Description:** ${projectDescription}\n` : ''}- **Cre // ------------------------------------------------------------------------- const skillsDir = join(teamRoot, '.copilot', 'skills'); - if (templatesDir && existsSync(join(templatesDir, 'skills'))) { + if (templatesDir && storage.existsSync(join(templatesDir, 'skills'))) { const skillsSrc = join(templatesDir, 'skills'); - const existingSkills = existsSync(skillsDir) ? readdirSync(skillsDir) : []; + const existingSkills = storage.existsSync(skillsDir) ? storage.listSync(skillsDir) : []; if (existingSkills.length === 0) { + // TODO: cpSync has no StorageProvider equivalent (#481) cpSync(skillsSrc, skillsDir, { recursive: true }); createdFiles.push('.copilot/skills'); } @@ -985,8 +992,8 @@ ${projectDescription ? `- **Description:** ${projectDescription}\n` : ''}- **Cre ]; let existingAttrs = ''; - if (existsSync(gitattributesPath)) { - existingAttrs = readFileSync(gitattributesPath, 'utf-8'); + if (storage.existsSync(gitattributesPath)) { + existingAttrs = storage.readSync(gitattributesPath) ?? ''; } const missingRules = unionRules.filter(rule => !existingAttrs.includes(rule)); @@ -994,7 +1001,7 @@ ${projectDescription ? `- **Description:** ${projectDescription}\n` : ''}- **Cre const block = (existingAttrs && !existingAttrs.endsWith('\n') ? '\n' : '') + '# Squad: union merge for append-only team state files\n' + missingRules.join('\n') + '\n'; - await appendFile(gitattributesPath, block); + await storage.append(gitattributesPath, block); createdFiles.push(toRelativePath(gitattributesPath)); } @@ -1013,8 +1020,8 @@ ${projectDescription ? `- **Description:** ${projectDescription}\n` : ''}- **Cre ]; let existingIgnore = ''; - if (existsSync(gitignorePath)) { - existingIgnore = readFileSync(gitignorePath, 'utf-8'); + if (storage.existsSync(gitignorePath)) { + existingIgnore = storage.readSync(gitignorePath) ?? ''; } const missingIgnore = ignoreEntries.filter(entry => !existingIgnore.includes(entry)); @@ -1022,7 +1029,7 @@ ${projectDescription ? `- **Description:** ${projectDescription}\n` : ''}- **Cre const block = (existingIgnore && !existingIgnore.endsWith('\n') ? '\n' : '') + '# Squad: ignore runtime state (logs, inbox, sessions)\n' + missingIgnore.join('\n') + '\n'; - await appendFile(gitignorePath, block); + await storage.append(gitignorePath, block); createdFiles.push(toRelativePath(gitignorePath)); } @@ -1031,12 +1038,11 @@ ${projectDescription ? `- **Description:** ${projectDescription}\n` : ''}- **Cre // ------------------------------------------------------------------------- const agentFile = join(teamRoot, '.github', 'agents', 'squad.agent.md'); - if (!existsSync(agentFile) || !skipExisting) { - if (templatesDir && existsSync(join(templatesDir, 'squad.agent.md'))) { - let agentContent = readFileSync(join(templatesDir, 'squad.agent.md'), 'utf-8'); + if (!storage.existsSync(agentFile) || !skipExisting) { + if (templatesDir && storage.existsSync(join(templatesDir, 'squad.agent.md'))) { + let agentContent = storage.readSync(join(templatesDir, 'squad.agent.md')) ?? ''; agentContent = stampVersionInContent(agentContent, version); - await mkdir(dirname(agentFile), { recursive: true }); - await writeFile(agentFile, agentContent, 'utf-8'); + await storage.write(agentFile, agentContent); createdFiles.push(toRelativePath(agentFile)); } } else { @@ -1049,7 +1055,8 @@ ${projectDescription ? `- **Description:** ${projectDescription}\n` : ''}- **Cre if (includeTemplates && templatesDir) { const templatesDest = join(teamRoot, '.squad', 'templates'); - if (!existsSync(templatesDest)) { + if (!storage.existsSync(templatesDest)) { + // TODO: cpSync has no StorageProvider equivalent (#481) cpSync(templatesDir, templatesDest, { recursive: true }); createdFiles.push(toRelativePath(templatesDest)); } else { @@ -1076,18 +1083,20 @@ ${projectDescription ? `- **Description:** ${projectDescription}\n` : ''}- **Cre // Copy workflows (optional) — skip for ADO repos // ------------------------------------------------------------------------- - if (includeWorkflows && isGitHub && templatesDir && existsSync(join(templatesDir, 'workflows'))) { + if (includeWorkflows && isGitHub && templatesDir && storage.existsSync(join(templatesDir, 'workflows'))) { const workflowsSrc = join(templatesDir, 'workflows'); const workflowsDest = join(teamRoot, '.github', 'workflows'); + // TODO: statSync has no StorageProvider equivalent (#481) if (statSync(workflowsSrc).isDirectory()) { - const allWorkflowFiles = readdirSync(workflowsSrc).filter(f => f.endsWith('.yml')); + const allWorkflowFiles = storage.listSync(workflowsSrc).filter(f => f.endsWith('.yml')); const workflowFiles = allWorkflowFiles.filter(f => FRAMEWORK_WORKFLOWS.includes(f)); await mkdir(workflowsDest, { recursive: true }); for (const file of workflowFiles) { const destFile = join(workflowsDest, file); - if (!existsSync(destFile) || !skipExisting) { + if (!storage.existsSync(destFile) || !skipExisting) { + // TODO: cpSync has no StorageProvider equivalent (#481) cpSync(join(workflowsSrc, file), destFile); createdFiles.push(toRelativePath(destFile)); } else { @@ -1103,7 +1112,7 @@ ${projectDescription ? `- **Description:** ${projectDescription}\n` : ''}- **Cre if (includeMcpConfig) { const mcpConfigPath = join(teamRoot, '.copilot', 'mcp-config.json'); - if (!existsSync(mcpConfigPath)) { + if (!storage.existsSync(mcpConfigPath)) { const mcpSample = isGitHub ? { mcpServers: { @@ -1128,8 +1137,7 @@ ${projectDescription ? `- **Description:** ${projectDescription}\n` : ''}- **Cre } } }; - await mkdir(dirname(mcpConfigPath), { recursive: true }); - await writeFile(mcpConfigPath, JSON.stringify(mcpSample, null, 2) + '\n', 'utf-8'); + await storage.write(mcpConfigPath, JSON.stringify(mcpSample, null, 2) + '\n'); createdFiles.push(toRelativePath(mcpConfigPath)); } else { skippedFiles.push(toRelativePath(mcpConfigPath)); @@ -1156,14 +1164,14 @@ ${projectDescription ? `- **Description:** ${projectDescription}\n` : ''}- **Cre { const workstreamIgnoreEntry = '.squad-workstream'; let currentIgnore = ''; - if (existsSync(gitignorePath)) { - currentIgnore = readFileSync(gitignorePath, 'utf-8'); + if (storage.existsSync(gitignorePath)) { + currentIgnore = storage.readSync(gitignorePath) ?? ''; } if (!currentIgnore.includes(workstreamIgnoreEntry)) { const block = (currentIgnore && !currentIgnore.endsWith('\n') ? '\n' : '') + '# Squad: SubSquad activation file (local to this machine)\n' + workstreamIgnoreEntry + '\n'; - await appendFile(gitignorePath, block); + await storage.append(gitignorePath, block); createdFiles.push(toRelativePath(gitignorePath)); } } @@ -1173,8 +1181,8 @@ ${projectDescription ? `- **Description:** ${projectDescription}\n` : ''}- **Cre // ------------------------------------------------------------------------- const firstRunMarker = join(squadDir, '.first-run'); - if (!existsSync(firstRunMarker)) { - await writeFile(firstRunMarker, new Date().toISOString() + '\n', 'utf-8'); + if (!storage.existsSync(firstRunMarker)) { + await storage.write(firstRunMarker, new Date().toISOString() + '\n'); createdFiles.push(toRelativePath(firstRunMarker)); } @@ -1184,7 +1192,7 @@ ${projectDescription ? `- **Description:** ${projectDescription}\n` : ''}- **Cre if (options.prompt) { const promptFile = join(squadDir, '.init-prompt'); - await writeFile(promptFile, options.prompt, 'utf-8'); + await storage.write(promptFile, options.prompt); createdFiles.push(toRelativePath(promptFile)); } @@ -1204,9 +1212,9 @@ ${projectDescription ? `- **Description:** ${projectDescription}\n` : ''}- **Cre * * @param squadDir - Path to the .squad directory */ -export async function cleanupOrphanInitPrompt(squadDir: string): Promise<void> { +export async function cleanupOrphanInitPrompt(squadDir: string, storage: StorageProvider = new FSStorageProvider()): Promise<void> { const promptFile = join(squadDir, '.init-prompt'); - if (existsSync(promptFile)) { - await unlink(promptFile); + if (storage.existsSync(promptFile)) { + await storage.delete(promptFile); } } diff --git a/packages/squad-sdk/src/config/legacy-fallback.ts b/packages/squad-sdk/src/config/legacy-fallback.ts index e81af5595..6c058b4ab 100644 --- a/packages/squad-sdk/src/config/legacy-fallback.ts +++ b/packages/squad-sdk/src/config/legacy-fallback.ts @@ -8,8 +8,9 @@ * @module config/legacy-fallback */ -import { existsSync, readFileSync } from 'fs'; import { join } from 'path'; +import type { StorageProvider } from '../storage/index.js'; +import { FSStorageProvider } from '../storage/index.js'; import type { SquadConfig, RoutingConfig, @@ -90,13 +91,13 @@ const LEGACY_TEAM_PATHS = [ * @param dir - Project root directory * @returns true if legacy format is detected */ -export function detectLegacySetup(dir: string): boolean { +export function detectLegacySetup(dir: string, storage: StorageProvider = new FSStorageProvider()): boolean { for (const relPath of LEGACY_AGENT_PATHS) { - if (existsSync(join(dir, relPath))) return true; + if (storage.existsSync(join(dir, relPath))) return true; } // Also detect bare .ai-team/ directories with team or routing files for (const relPath of [...LEGACY_ROUTING_PATHS, ...LEGACY_TEAM_PATHS]) { - if (existsSync(join(dir, relPath))) return true; + if (storage.existsSync(join(dir, relPath))) return true; } return false; } @@ -117,16 +118,16 @@ export function detectLegacySetup(dir: string): boolean { * @param dir - Project root directory * @returns Parsed LegacyConfig, or undefined if no legacy file found */ -export function loadLegacyAgentMd(dir: string): LegacyConfig | undefined { +export function loadLegacyAgentMd(dir: string, storage: StorageProvider = new FSStorageProvider()): LegacyConfig | undefined { // Find the agent doc let agentMdPath: string | undefined; let agentMdContent: string | undefined; for (const relPath of LEGACY_AGENT_PATHS) { const fullPath = join(dir, relPath); - if (existsSync(fullPath)) { + if (storage.existsSync(fullPath)) { agentMdPath = fullPath; - agentMdContent = readFileSync(fullPath, 'utf-8'); + agentMdContent = storage.readSync(fullPath); break; } } @@ -145,19 +146,21 @@ export function loadLegacyAgentMd(dir: string): LegacyConfig | undefined { let routingRules: RoutingRule[] = []; for (const relPath of LEGACY_ROUTING_PATHS) { const fullPath = join(dir, relPath); - if (existsSync(fullPath)) { - const routingContent = readFileSync(fullPath, 'utf-8'); - const routingConfig = parseRoutingMarkdown(routingContent); - routingRules = routingConfig.rules; + if (storage.existsSync(fullPath)) { + const routingContent = storage.readSync(fullPath); + if (routingContent !== undefined) { + const routingConfig = parseRoutingMarkdown(routingContent); + routingRules = routingConfig.rules; + } break; } } // Check for .ai-team directory const hasAiTeamDir = - existsSync(join(dir, '.ai-team')) && - (existsSync(join(dir, '.ai-team', 'team.md')) || - existsSync(join(dir, '.ai-team', 'routing.md'))); + storage.existsSync(join(dir, '.ai-team')) && + (storage.existsSync(join(dir, '.ai-team', 'team.md')) || + storage.existsSync(join(dir, '.ai-team', 'routing.md'))); return { systemPrompt: agentMdContent, diff --git a/packages/squad-sdk/src/config/models.ts b/packages/squad-sdk/src/config/models.ts index cac32e4a8..bf2e933fe 100644 --- a/packages/squad-sdk/src/config/models.ts +++ b/packages/squad-sdk/src/config/models.ts @@ -7,8 +7,9 @@ * @module config/models */ -import { readFileSync, writeFileSync, existsSync } from 'fs'; import { join } from 'path'; +import type { StorageProvider } from '../storage/index.js'; +import { FSStorageProvider } from '../storage/index.js'; import type { ModelId, ModelTier } from '../runtime/config.js'; /** @@ -555,13 +556,14 @@ export interface ModelPreferenceConfig { * @param squadDir - Path to the `.squad/` directory * @returns True if economyMode is enabled, false otherwise */ -export function readEconomyMode(squadDir: string): boolean { +export function readEconomyMode(squadDir: string, storage: StorageProvider = new FSStorageProvider()): boolean { const configPath = join(squadDir, 'config.json'); - if (!existsSync(configPath)) { + if (!storage.existsSync(configPath)) { return false; } try { - const raw = readFileSync(configPath, 'utf-8'); + const raw = storage.readSync(configPath); + if (raw === undefined) return false; const parsed = JSON.parse(raw); return parsed !== null && typeof parsed === 'object' && @@ -578,12 +580,13 @@ export function readEconomyMode(squadDir: string): boolean { * @param squadDir - Path to the `.squad/` directory * @param enabled - Whether economy mode should be enabled */ -export function writeEconomyMode(squadDir: string, enabled: boolean): void { +export function writeEconomyMode(squadDir: string, enabled: boolean, storage: StorageProvider = new FSStorageProvider()): void { const configPath = join(squadDir, 'config.json'); let config: Record<string, unknown> = {}; - if (existsSync(configPath)) { + if (storage.existsSync(configPath)) { try { - config = JSON.parse(readFileSync(configPath, 'utf-8')); + const raw = storage.readSync(configPath); + config = raw !== undefined ? JSON.parse(raw) : { version: 1 }; } catch { config = { version: 1 }; } @@ -597,7 +600,7 @@ export function writeEconomyMode(squadDir: string, enabled: boolean): void { delete config.economyMode; } - writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8'); + storage.writeSync(configPath, JSON.stringify(config, null, 2) + '\n'); } /** @@ -606,13 +609,14 @@ export function writeEconomyMode(squadDir: string, enabled: boolean): void { * @param squadDir - Path to the `.squad/` directory * @returns The defaultModel string if set, or null */ -export function readModelPreference(squadDir: string): string | null { +export function readModelPreference(squadDir: string, storage: StorageProvider = new FSStorageProvider()): string | null { const configPath = join(squadDir, 'config.json'); - if (!existsSync(configPath)) { + if (!storage.existsSync(configPath)) { return null; } try { - const raw = readFileSync(configPath, 'utf-8'); + const raw = storage.readSync(configPath); + if (raw === undefined) return null; const parsed = JSON.parse(raw); if ( parsed !== null && @@ -634,13 +638,14 @@ export function readModelPreference(squadDir: string): string | null { * @param squadDir - Path to the `.squad/` directory * @returns Record of agent name → model ID, or empty object */ -export function readAgentModelOverrides(squadDir: string): Record<string, string> { +export function readAgentModelOverrides(squadDir: string, storage: StorageProvider = new FSStorageProvider()): Record<string, string> { const configPath = join(squadDir, 'config.json'); - if (!existsSync(configPath)) { + if (!storage.existsSync(configPath)) { return {}; } try { - const raw = readFileSync(configPath, 'utf-8'); + const raw = storage.readSync(configPath); + if (raw === undefined) return {}; const parsed = JSON.parse(raw); if ( parsed !== null && @@ -669,12 +674,13 @@ export function readAgentModelOverrides(squadDir: string): Record<string, string * @param squadDir - Path to the `.squad/` directory * @param model - Model ID to persist, or null to clear */ -export function writeModelPreference(squadDir: string, model: string | null): void { +export function writeModelPreference(squadDir: string, model: string | null, storage: StorageProvider = new FSStorageProvider()): void { const configPath = join(squadDir, 'config.json'); let config: Record<string, unknown> = {}; - if (existsSync(configPath)) { + if (storage.existsSync(configPath)) { try { - config = JSON.parse(readFileSync(configPath, 'utf-8')); + const raw = storage.readSync(configPath); + config = raw !== undefined ? JSON.parse(raw) : { version: 1 }; } catch { config = { version: 1 }; } @@ -688,7 +694,7 @@ export function writeModelPreference(squadDir: string, model: string | null): vo config.defaultModel = model; } - writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8'); + storage.writeSync(configPath, JSON.stringify(config, null, 2) + '\n'); } /** @@ -700,13 +706,15 @@ export function writeModelPreference(squadDir: string, model: string | null): vo */ export function writeAgentModelOverrides( squadDir: string, - overrides: Record<string, string> | null + overrides: Record<string, string> | null, + storage: StorageProvider = new FSStorageProvider() ): void { const configPath = join(squadDir, 'config.json'); let config: Record<string, unknown> = {}; - if (existsSync(configPath)) { + if (storage.existsSync(configPath)) { try { - config = JSON.parse(readFileSync(configPath, 'utf-8')); + const raw = storage.readSync(configPath); + config = raw !== undefined ? JSON.parse(raw) : { version: 1 }; } catch { config = { version: 1 }; } @@ -720,7 +728,7 @@ export function writeAgentModelOverrides( config.agentModelOverrides = overrides; } - writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8'); + storage.writeSync(configPath, JSON.stringify(config, null, 2) + '\n'); } /** @@ -748,12 +756,15 @@ export function resolveModel(options: { taskModel?: string | null; /** When true, apply economy mode substitution at Layer 3/4. Overrides config. */ economyMode?: boolean; + /** Storage provider for config file access. */ + storage?: StorageProvider; }): string { const { agentName, squadDir, sessionDirective, charterPreference, taskModel } = options; + const storage = options.storage ?? new FSStorageProvider(); // Layer 0a: Per-agent persistent override (explicit — economy does not apply) if (squadDir && agentName) { - const agentOverrides = readAgentModelOverrides(squadDir); + const agentOverrides = readAgentModelOverrides(squadDir, storage); if (agentOverrides[agentName]) { return agentOverrides[agentName]!; } @@ -761,7 +772,7 @@ export function resolveModel(options: { // Layer 0b: Global persistent config (explicit — economy does not apply) if (squadDir) { - const persistedModel = readModelPreference(squadDir); + const persistedModel = readModelPreference(squadDir, storage); if (persistedModel) { return persistedModel; } @@ -781,7 +792,7 @@ export function resolveModel(options: { const isEconomy = options.economyMode !== undefined ? options.economyMode - : (squadDir ? readEconomyMode(squadDir) : false); + : (squadDir ? readEconomyMode(squadDir, storage) : false); // Layer 3: Task-aware auto-selection (economy mode applies) if (taskModel) { diff --git a/packages/squad-sdk/src/index.ts b/packages/squad-sdk/src/index.ts index 993af10e9..185a192a2 100644 --- a/packages/squad-sdk/src/index.ts +++ b/packages/squad-sdk/src/index.ts @@ -101,3 +101,45 @@ export type { // Base Roles (built-in role catalog) export * from './roles/index.js'; export * from './platform/index.js'; +export * from './storage/index.js'; + +// State facade (Phase 2) — namespaced to avoid conflicts with existing config/sharing exports +export { + // Error classes + StateError, + NotFoundError, + ParseError, + WriteConflictError, + ProviderError, + // Schema + COLLECTION_PATHS, + resolveCollectionPath, + // Handle factory + createAgentHandle, + // Collection facades + AgentsCollection, + ConfigCollection, + DecisionsCollection, + LogCollection, + RoutingCollection, + SkillsCollection, + TeamCollection, + TemplatesCollection, + // Top-level facade + SquadState, +} from './state/index.js'; +export type { ConfigFileData } from './state/index.js'; +export type { + Agent, + Decision, + LogEntry, + RoutingConfigRule, + SquadStateConfig, + StateErrorKind, + TeamMember, + Template, + CollectionEntityMap, + CollectionName, + AgentHandle, + CollectionPathResolver, +} from './state/index.js'; diff --git a/packages/squad-sdk/src/marketplace/packaging.ts b/packages/squad-sdk/src/marketplace/packaging.ts index 455363d6f..1a97c1c06 100644 --- a/packages/squad-sdk/src/marketplace/packaging.ts +++ b/packages/squad-sdk/src/marketplace/packaging.ts @@ -3,8 +3,11 @@ * Issue #108 (M4-8) */ -import * as fs from 'node:fs'; +// TODO: readdirSync/statSync still use raw fs — StorageProvider needs sync list(), isDirectory(), isFile(), and size methods +import { readdirSync, statSync } from 'node:fs'; import * as path from 'node:path'; +import type { StorageProvider } from '../storage/storage-provider.js'; +import { FSStorageProvider } from '../storage/fs-storage-provider.js'; import type { MarketplaceManifest } from './index.js'; // --- PackageResult --- @@ -33,22 +36,23 @@ const REQUIRED_PATHS = ['icon', 'dist/']; export function packageForMarketplace( projectDir: string, manifest: MarketplaceManifest, + storage: StorageProvider = new FSStorageProvider(), ): PackageResult { const warnings: string[] = []; const files: string[] = []; - if (!fs.existsSync(projectDir)) { + if (!storage.existsSync(projectDir)) { throw new Error(`Project directory not found: ${projectDir}`); } // Write manifest.json const manifestPath = path.join(projectDir, 'manifest.json'); - fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf-8'); + storage.writeSync(manifestPath, JSON.stringify(manifest, null, 2)); files.push('manifest.json'); // Collect README const readmePath = path.join(projectDir, 'README.md'); - if (fs.existsSync(readmePath)) { + if (storage.existsSync(readmePath)) { files.push('README.md'); } else { warnings.push('README.md not found — marketplace listings require a README'); @@ -56,7 +60,7 @@ export function packageForMarketplace( // Collect icon const iconPath = path.join(projectDir, manifest.icon); - if (fs.existsSync(iconPath)) { + if (storage.existsSync(iconPath)) { files.push(manifest.icon); } else { warnings.push(`Icon file not found: ${manifest.icon}`); @@ -64,7 +68,7 @@ export function packageForMarketplace( // Collect dist/ const distDir = path.join(projectDir, 'dist'); - if (fs.existsSync(distDir) && fs.statSync(distDir).isDirectory()) { + if (storage.existsSync(distDir) && statSync(distDir).isDirectory()) { const distFiles = collectFiles(distDir, 'dist'); files.push(...distFiles); } else { @@ -75,8 +79,8 @@ export function packageForMarketplace( let totalSize = 0; for (const file of files) { const fullPath = path.join(projectDir, file); - if (fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) { - totalSize += fs.statSync(fullPath).size; + if (storage.existsSync(fullPath) && statSync(fullPath).isFile()) { + totalSize += statSync(fullPath).size; } } @@ -93,11 +97,14 @@ export function packageForMarketplace( /** * Validate that a package directory contains all required files. */ -export function validatePackageContents(packagePath: string): MarketplacePackageValidationResult { +export function validatePackageContents( + packagePath: string, + storage: StorageProvider = new FSStorageProvider(), +): MarketplacePackageValidationResult { const errors: string[] = []; const missingFiles: string[] = []; - if (!fs.existsSync(packagePath)) { + if (!storage.existsSync(packagePath)) { return { valid: false, errors: [`Package path not found: ${packagePath}`], @@ -107,7 +114,7 @@ export function validatePackageContents(packagePath: string): MarketplacePackage for (const file of REQUIRED_FILES) { const filePath = path.join(packagePath, file); - if (!fs.existsSync(filePath)) { + if (!storage.existsSync(filePath)) { missingFiles.push(file); errors.push(`Required file missing: ${file}`); } @@ -115,7 +122,7 @@ export function validatePackageContents(packagePath: string): MarketplacePackage // Check dist/ directory exists const distDir = path.join(packagePath, 'dist'); - if (!fs.existsSync(distDir) || !fs.statSync(distDir).isDirectory()) { + if (!storage.existsSync(distDir) || !statSync(distDir).isDirectory()) { missingFiles.push('dist/'); errors.push('Required directory missing: dist/'); } @@ -123,7 +130,7 @@ export function validatePackageContents(packagePath: string): MarketplacePackage // Check icon — look for any common image file const iconCandidates = ['icon.png', 'icon.svg', 'icon.jpg']; const hasIcon = iconCandidates.some((ic) => - fs.existsSync(path.join(packagePath, ic)), + storage.existsSync(path.join(packagePath, ic)), ); if (!hasIcon) { missingFiles.push('icon'); @@ -141,7 +148,7 @@ export function validatePackageContents(packagePath: string): MarketplacePackage function collectFiles(dir: string, prefix: string): string[] { const results: string[] = []; - const entries = fs.readdirSync(dir, { withFileTypes: true }); + const entries = readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { const rel = path.join(prefix, entry.name); if (entry.isDirectory()) { diff --git a/packages/squad-sdk/src/multi-squad.ts b/packages/squad-sdk/src/multi-squad.ts index 5c44f43a0..0dbab7b2a 100644 --- a/packages/squad-sdk/src/multi-squad.ts +++ b/packages/squad-sdk/src/multi-squad.ts @@ -15,8 +15,11 @@ * @module multi-squad */ -import fs from 'node:fs'; +// TODO: mkdirSync/rmSync/statSync still use raw fs — StorageProvider needs sync mkdir(), deleteDir(), and isDirectory() +import { mkdirSync, rmSync, statSync } from 'node:fs'; import path from 'node:path'; +import type { StorageProvider } from './storage/storage-provider.js'; +import { FSStorageProvider } from './storage/fs-storage-provider.js'; import { resolveGlobalSquadPath } from './resolution.js'; // ============================================================================ @@ -67,13 +70,13 @@ function squadsJsonPath(): string { } /** Read and parse squads.json, returning null if missing or malformed. */ -function loadSquadsConfig(): MultiSquadConfig | null { +function loadSquadsConfig(storage: StorageProvider): MultiSquadConfig | null { const configPath = squadsJsonPath(); - if (!fs.existsSync(configPath)) { + const raw = storage.readSync(configPath); + if (raw === undefined) { return null; } try { - const raw = fs.readFileSync(configPath, 'utf-8'); const parsed: unknown = JSON.parse(raw); if ( parsed !== null && @@ -92,9 +95,9 @@ function loadSquadsConfig(): MultiSquadConfig | null { } /** Write squads.json atomically. */ -function saveSquadsConfig(config: MultiSquadConfig): void { +function saveSquadsConfig(config: MultiSquadConfig, storage: StorageProvider): void { const configPath = squadsJsonPath(); - fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8'); + storage.writeSync(configPath, JSON.stringify(config, null, 2) + '\n'); } /** Validate a squad name: non-empty, no slashes, no dots-only. */ @@ -112,8 +115,8 @@ function validateName(name: string): void { * Returns the platform-appropriate global config directory for squads. * Delegates to resolveGlobalSquadPath() which handles Windows/macOS/Linux. */ -export function getSquadRoot(): string { - return resolveGlobalSquadPath(); +export function getSquadRoot(storage: StorageProvider = new FSStorageProvider()): string { + return resolveGlobalSquadPath(storage); } /** @@ -128,17 +131,17 @@ export function getSquadRoot(): string { * * Triggers auto-migration on first call if a legacy layout is detected. */ -export function resolveSquadPath(name?: string): string { +export function resolveSquadPath(name?: string, storage: StorageProvider = new FSStorageProvider()): string { // Auto-migrate legacy layout if needed - migrateIfNeeded(); + migrateIfNeeded(storage); const resolved = name ?? process.env['SQUAD_NAME'] ?? - loadSquadsConfig()?.active ?? + loadSquadsConfig(storage)?.active ?? DEFAULT_SQUAD; - const config = loadSquadsConfig(); + const config = loadSquadsConfig(storage); // Look up registered path if (config) { @@ -149,15 +152,15 @@ export function resolveSquadPath(name?: string): string { } // Fallback: derive path inside global config dir - const root = getSquadRoot(); + const root = getSquadRoot(storage); return path.join(root, SQUADS_DIR, resolved); } /** * List all registered squads with their active status. */ -export function listSquads(): SquadInfo[] { - const config = loadSquadsConfig(); +export function listSquads(storage: StorageProvider = new FSStorageProvider()): SquadInfo[] { + const config = loadSquadsConfig(storage); if (!config) { return []; } @@ -174,12 +177,12 @@ export function listSquads(): SquadInfo[] { * * @throws If a squad with the given name already exists. */ -export function createSquad(name: string): string { +export function createSquad(name: string, storage: StorageProvider = new FSStorageProvider()): string { validateName(name); // Ensure squads.json exists (migrate or bootstrap) - migrateIfNeeded(); - let config = loadSquadsConfig(); + migrateIfNeeded(storage); + let config = loadSquadsConfig(storage); if (!config) { config = { squads: [], active: DEFAULT_SQUAD }; } @@ -188,15 +191,15 @@ export function createSquad(name: string): string { throw new Error(`Squad "${name}" already exists.`); } - const squadDir = path.join(getSquadRoot(), SQUADS_DIR, name); - fs.mkdirSync(squadDir, { recursive: true }); + const squadDir = path.join(getSquadRoot(storage), SQUADS_DIR, name); + mkdirSync(squadDir, { recursive: true }); config.squads.push({ name, path: squadDir, created_at: new Date().toISOString(), }); - saveSquadsConfig(config); + saveSquadsConfig(config, storage); return squadDir; } @@ -206,10 +209,10 @@ export function createSquad(name: string): string { * * @throws If the squad is the currently active one, or if it doesn't exist. */ -export function deleteSquad(name: string): void { +export function deleteSquad(name: string, storage: StorageProvider = new FSStorageProvider()): void { validateName(name); - const config = loadSquadsConfig(); + const config = loadSquadsConfig(storage); if (!config) { throw new Error(`Squad "${name}" not found.`); } @@ -224,12 +227,12 @@ export function deleteSquad(name: string): void { } const entry = config.squads[idx]; - if (entry && fs.existsSync(entry.path)) { - fs.rmSync(entry.path, { recursive: true, force: true }); + if (entry && storage.existsSync(entry.path)) { + rmSync(entry.path, { recursive: true, force: true }); } config.squads.splice(idx, 1); - saveSquadsConfig(config); + saveSquadsConfig(config, storage); } /** @@ -237,10 +240,10 @@ export function deleteSquad(name: string): void { * * @throws If the named squad is not registered. */ -export function switchSquad(name: string): void { +export function switchSquad(name: string, storage: StorageProvider = new FSStorageProvider()): void { validateName(name); - const config = loadSquadsConfig(); + const config = loadSquadsConfig(storage); if (!config) { throw new Error(`No squads configured. Cannot switch to "${name}".`); } @@ -250,7 +253,7 @@ export function switchSquad(name: string): void { } config.active = name; - saveSquadsConfig(config); + saveSquadsConfig(config, storage); } /** @@ -261,12 +264,12 @@ export function switchSquad(name: string): void { * * @returns `true` if migration was performed, `false` if not needed. */ -export function migrateIfNeeded(): boolean { - const root = getSquadRoot(); +export function migrateIfNeeded(storage: StorageProvider = new FSStorageProvider()): boolean { + const root = getSquadRoot(storage); const configPath = path.join(root, SQUADS_JSON); // If squads.json already exists, no migration needed - if (fs.existsSync(configPath)) { + if (storage.existsSync(configPath)) { return false; } @@ -274,13 +277,13 @@ export function migrateIfNeeded(): boolean { const home = process.env['HOME'] ?? process.env['USERPROFILE'] ?? ''; const legacyDir = path.join(home, LEGACY_DIR); - if (!home || !fs.existsSync(legacyDir) || !fs.statSync(legacyDir).isDirectory()) { + if (!home || !storage.existsSync(legacyDir) || !statSync(legacyDir).isDirectory()) { // No legacy layout — bootstrap empty config const config: MultiSquadConfig = { squads: [], active: DEFAULT_SQUAD, }; - saveSquadsConfig(config); + saveSquadsConfig(config, storage); return false; } @@ -295,6 +298,6 @@ export function migrateIfNeeded(): boolean { ], active: DEFAULT_SQUAD, }; - saveSquadsConfig(config); + saveSquadsConfig(config, storage); return true; } diff --git a/packages/squad-sdk/src/platform/comms-file-log.ts b/packages/squad-sdk/src/platform/comms-file-log.ts index cf7fe885e..2b8d6c0e1 100644 --- a/packages/squad-sdk/src/platform/comms-file-log.ts +++ b/packages/squad-sdk/src/platform/comms-file-log.ts @@ -8,8 +8,10 @@ * @module platform/comms-file-log */ -import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; +import { mkdirSync } from 'node:fs'; +import { FSStorageProvider } from '../storage/fs-storage-provider.js'; +import type { StorageProvider } from '../storage/storage-provider.js'; import { safeTimestamp } from '../utils/safe-timestamp.js'; import type { CommunicationAdapter, CommunicationChannel, CommunicationReply } from './types.js'; @@ -17,11 +19,13 @@ export class FileLogCommunicationAdapter implements CommunicationAdapter { readonly channel: CommunicationChannel = 'file-log'; private readonly commsDir: string; - constructor(private readonly squadRoot: string) { + constructor( + private readonly squadRoot: string, + private readonly storage: StorageProvider = new FSStorageProvider(), + ) { this.commsDir = join(squadRoot, '.squad', 'comms'); - if (!existsSync(this.commsDir)) { - mkdirSync(this.commsDir, { recursive: true }); - } + // TODO: StorageProvider lacks mkdirSync — residual fs mkdirSync (#481) + mkdirSync(this.commsDir, { recursive: true }); } async postUpdate(options: { @@ -52,7 +56,7 @@ export class FileLogCommunicationAdapter implements CommunicationAdapter { '', ].join('\n'); - writeFileSync(filepath, content, 'utf-8'); + await this.storage.write(filepath, content); return { id: filename.replace(/\.md$/, ''), url: undefined }; } @@ -62,9 +66,8 @@ export class FileLogCommunicationAdapter implements CommunicationAdapter { since: Date; }): Promise<CommunicationReply[]> { const filepath = join(this.commsDir, `${options.threadId}.md`); - if (!existsSync(filepath)) return []; - - const content = readFileSync(filepath, 'utf-8'); + const content = await this.storage.read(filepath); + if (!content) return []; const replyMarker = '<!-- Replies: add your response below this line -->'; const markerIdx = content.indexOf(replyMarker); if (markerIdx === -1) return []; diff --git a/packages/squad-sdk/src/platform/comms.ts b/packages/squad-sdk/src/platform/comms.ts index eae950836..94f10ada2 100644 --- a/packages/squad-sdk/src/platform/comms.ts +++ b/packages/squad-sdk/src/platform/comms.ts @@ -7,22 +7,24 @@ * @module platform/comms */ -import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import type { CommunicationAdapter, CommunicationChannel, CommunicationConfig } from './types.js'; import { FileLogCommunicationAdapter } from './comms-file-log.js'; import { GitHubDiscussionsCommunicationAdapter } from './comms-github-discussions.js'; import { ADODiscussionCommunicationAdapter } from './comms-ado-discussions.js'; import { detectPlatform, getRemoteUrl, parseGitHubRemote, parseAzureDevOpsRemote } from './detect.js'; +import type { StorageProvider } from '../storage/storage-provider.js'; +import { FSStorageProvider } from '../storage/fs-storage-provider.js'; /** * Read communication config from `.squad/config.json`. */ -function readCommsConfig(repoRoot: string): CommunicationConfig | undefined { +function readCommsConfig(repoRoot: string, storage: StorageProvider): CommunicationConfig | undefined { const configPath = join(repoRoot, '.squad', 'config.json'); - if (!existsSync(configPath)) return undefined; + if (!storage.existsSync(configPath)) return undefined; try { - const raw = readFileSync(configPath, 'utf-8'); + const raw = storage.readSync(configPath); + if (raw === undefined) return undefined; const parsed = JSON.parse(raw) as Record<string, unknown>; if (parsed.communications && typeof parsed.communications === 'object') { return parsed.communications as CommunicationConfig; @@ -39,8 +41,8 @@ function readCommsConfig(repoRoot: string): CommunicationConfig | undefined { * 2. Auto-detect from platform: GitHub → GitHubDiscussions, ADO → ADOWorkItemDiscussions * 3. Fallback: FileLog (always works) */ -export function createCommunicationAdapter(repoRoot: string): CommunicationAdapter { - const config = readCommsConfig(repoRoot); +export function createCommunicationAdapter(repoRoot: string, storage: StorageProvider = new FSStorageProvider()): CommunicationAdapter { + const config = readCommsConfig(repoRoot, storage); // Explicit config wins if (config?.channel) { @@ -65,9 +67,10 @@ export function createCommunicationAdapter(repoRoot: string): CommunicationAdapt const configPath = join(repoRoot, '.squad', 'config.json'); let adoOrg = info.org; let adoProject = info.project; - if (existsSync(configPath)) { + if (storage.existsSync(configPath)) { try { - const raw = readFileSync(configPath, 'utf-8'); + const raw = storage.readSync(configPath); + if (raw === undefined) throw new Error('file removed'); const parsed = JSON.parse(raw) as Record<string, unknown>; const ado = parsed.ado as Record<string, unknown> | undefined; if (ado?.org && typeof ado.org === 'string') adoOrg = ado.org; diff --git a/packages/squad-sdk/src/platform/index.ts b/packages/squad-sdk/src/platform/index.ts index 479a0f473..0e0636360 100644 --- a/packages/squad-sdk/src/platform/index.ts +++ b/packages/squad-sdk/src/platform/index.ts @@ -19,22 +19,24 @@ export { GitHubDiscussionsCommunicationAdapter } from './comms-github-discussion export { ADODiscussionCommunicationAdapter } from './comms-ado-discussions.js'; export { createCommunicationAdapter } from './comms.js'; -import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import type { PlatformAdapter } from './types.js'; import { detectPlatform, getRemoteUrl, parseGitHubRemote, parseAzureDevOpsRemote } from './detect.js'; import { GitHubAdapter } from './github.js'; import { AzureDevOpsAdapter } from './azure-devops.js'; import type { AdoWorkItemConfig } from './azure-devops.js'; +import { FSStorageProvider } from '../storage/fs-storage-provider.js'; +import type { StorageProvider } from '../storage/storage-provider.js'; /** * Read ADO work item config from .squad/config.json if present. */ -function readAdoConfig(repoRoot: string): AdoWorkItemConfig | undefined { +function readAdoConfig(repoRoot: string, storage: StorageProvider): AdoWorkItemConfig | undefined { const configPath = join(repoRoot, '.squad', 'config.json'); - if (!existsSync(configPath)) return undefined; + if (!storage.existsSync(configPath)) return undefined; try { - const raw = readFileSync(configPath, 'utf-8'); + const raw = storage.readSync(configPath); + if (!raw) return undefined; const parsed = JSON.parse(raw) as Record<string, unknown>; if (parsed.ado && typeof parsed.ado === 'object') { return parsed.ado as AdoWorkItemConfig; @@ -47,7 +49,7 @@ function readAdoConfig(repoRoot: string): AdoWorkItemConfig | undefined { * Create a platform adapter by auto-detecting the platform from the repo's git remote. * Throws if required remote info cannot be parsed. */ -export function createPlatformAdapter(repoRoot: string): PlatformAdapter { +export function createPlatformAdapter(repoRoot: string, storage: StorageProvider = new FSStorageProvider()): PlatformAdapter { const platform = detectPlatform(repoRoot); const remoteUrl = getRemoteUrl(repoRoot); @@ -60,7 +62,7 @@ export function createPlatformAdapter(repoRoot: string): PlatformAdapter { if (!info) { throw new Error(`Could not parse Azure DevOps remote URL: ${remoteUrl}`); } - const adoConfig = readAdoConfig(repoRoot); + const adoConfig = readAdoConfig(repoRoot, storage); return new AzureDevOpsAdapter(info.org, info.project, info.repo, adoConfig); } diff --git a/packages/squad-sdk/src/ralph/index.ts b/packages/squad-sdk/src/ralph/index.ts index 36afd67a3..2ac8a2adb 100644 --- a/packages/squad-sdk/src/ralph/index.ts +++ b/packages/squad-sdk/src/ralph/index.ts @@ -11,7 +11,8 @@ * 3. Cloud heartbeat: External health signal (future) */ -import { writeFile, readFile } from 'node:fs/promises'; +import type { StorageProvider } from '../storage/storage-provider.js'; +import { FSStorageProvider } from '../storage/fs-storage-provider.js'; import type { EventBus, SquadEvent } from '../runtime/event-bus.js'; // --- Monitor Types --- @@ -53,12 +54,14 @@ export interface MonitorState { export class RalphMonitor { private config: MonitorConfig; private state: MonitorState; + private storage: StorageProvider; private eventBus: EventBus | null = null; private unsubscribers: (() => void)[] = []; private healthCheckTimer: ReturnType<typeof setInterval> | null = null; - constructor(config: MonitorConfig) { + constructor(config: MonitorConfig, storage: StorageProvider = new FSStorageProvider()) { this.config = config; + this.storage = storage; this.state = { lastHealthCheck: null, agents: new Map(), @@ -177,7 +180,7 @@ export class RalphMonitor { agents: Array.from(this.state.agents.entries()), observations: this.state.observations, }; - await writeFile(this.config.statePath, JSON.stringify(serializable, null, 2), 'utf-8'); + await this.storage.write(this.config.statePath, JSON.stringify(serializable, null, 2)); } this.eventBus = null; diff --git a/packages/squad-sdk/src/ralph/rate-limiting.ts b/packages/squad-sdk/src/ralph/rate-limiting.ts index be97c9bca..5d30f97bd 100644 --- a/packages/squad-sdk/src/ralph/rate-limiting.ts +++ b/packages/squad-sdk/src/ralph/rate-limiting.ts @@ -9,10 +9,10 @@ * @see https://github.com/bradygaster/squad/issues/515 */ -import { readFile } from 'node:fs/promises'; -import { existsSync } from 'node:fs'; import path from 'node:path'; import os from 'node:os'; +import type { StorageProvider } from '../storage/storage-provider.js'; +import { FSStorageProvider } from '../storage/fs-storage-provider.js'; /** A rate limit sample from API response headers */ export interface RateSample { @@ -187,7 +187,7 @@ export function consumeQuota(pool: RatePool, agentName: string): void { /** * Load rate pool state from the shared file. */ -export async function loadRatePool(teamRoot?: string): Promise<RatePool | null> { +export async function loadRatePool(teamRoot?: string, storage: StorageProvider = new FSStorageProvider()): Promise<RatePool | null> { const candidates: string[] = []; if (teamRoot) { @@ -196,9 +196,10 @@ export async function loadRatePool(teamRoot?: string): Promise<RatePool | null> candidates.push(path.join(os.homedir(), '.squad', 'rate-pool.json')); for (const candidate of candidates) { - if (existsSync(candidate)) { + if (storage.existsSync(candidate)) { try { - const raw = await readFile(candidate, 'utf8'); + const raw = await storage.read(candidate); + if (raw === undefined) continue; return JSON.parse(raw) as RatePool; } catch { // Malformed — skip diff --git a/packages/squad-sdk/src/remote/bridge.ts b/packages/squad-sdk/src/remote/bridge.ts index b2df08324..2ad8f1eb9 100644 --- a/packages/squad-sdk/src/remote/bridge.ts +++ b/packages/squad-sdk/src/remote/bridge.ts @@ -7,7 +7,6 @@ import { WebSocketServer, WebSocket } from 'ws'; import http from 'node:http'; -import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { randomUUID } from 'node:crypto'; @@ -22,6 +21,8 @@ import { type RCClientCommand, } from './protocol.js'; import type { RemoteBridgeConfig, RemoteConnection, ConnectionState } from './types.js'; +import type { StorageProvider } from '../storage/storage-provider.js'; +import { FSStorageProvider } from '../storage/fs-storage-provider.js'; export class RemoteBridge { private server: http.Server | null = null; @@ -40,12 +41,11 @@ export class RemoteBridge { private tickets = new Map<string, { expires: number }>(); // F-02: one-time WS tickets private readonly SESSION_TTL = 4 * 60 * 60 * 1000; // F-18: 4-hour session TTL private readonly sessionCreatedAt = Date.now(); + // TODO: audit log directory was previously created with mode 0o700; StorageProvider does not support directory permissions private auditLogDir: string = path.join(os.homedir(), '.cli-tunnel', 'audit'); private auditLogPath: string = path.join(this.auditLogDir, `squad-audit-${Date.now()}.jsonl`); - private auditLog = (() => { fs.mkdirSync(this.auditLogDir, { recursive: true, mode: 0o700 }); return fs.createWriteStream(this.auditLogPath, { flags: 'a' }); })(); - constructor(private config: RemoteBridgeConfig) { - this.auditLog.on('error', (err) => { console.error('Audit log error:', err.message); }); + constructor(private config: RemoteBridgeConfig, private storage: StorageProvider = new FSStorageProvider()) { // #30: Ticket GC — clean expired tickets every 30s setInterval(() => { const now = Date.now(); @@ -538,7 +538,7 @@ export class RemoteBridge { try { const parsed = JSON.parse(raw); if (parsed.type === 'pty_input') { - this.auditLog.write(JSON.stringify({ ts: new Date().toISOString(), addr: info.remoteAddress, type: 'pty_input', data: this.redactSecrets(JSON.stringify(parsed.data)) }) + '\n'); + void this.storage.append(this.auditLogPath, JSON.stringify({ ts: new Date().toISOString(), addr: info.remoteAddress, type: 'pty_input', data: this.redactSecrets(JSON.stringify(parsed.data)) }) + '\n').catch((err) => console.error('Audit log error:', err.message)); } // CRITICAL-5: ACP JSON-RPC method allowlist @@ -553,7 +553,7 @@ export class RemoteBridge { } } catch { // Log non-JSON input - this.auditLog.write(JSON.stringify({ ts: new Date().toISOString(), addr: info.remoteAddress, type: 'raw', data: raw }) + '\n'); + void this.storage.append(this.auditLogPath, JSON.stringify({ ts: new Date().toISOString(), addr: info.remoteAddress, type: 'raw', data: raw }) + '\n').catch((err) => console.error('Audit log error:', err.message)); } // Intercept session/new to inject correct cwd diff --git a/packages/squad-sdk/src/resolution.ts b/packages/squad-sdk/src/resolution.ts index 957deb912..bd1dd638a 100644 --- a/packages/squad-sdk/src/resolution.ts +++ b/packages/squad-sdk/src/resolution.ts @@ -12,9 +12,12 @@ * @module resolution */ -import fs from 'node:fs'; +// TODO: statSync/mkdirSync still use raw fs — StorageProvider needs sync isDirectory() and mkdir() +import fs, { statSync, mkdirSync } from 'node:fs'; import path from 'node:path'; import os from 'node:os'; +import type { StorageProvider } from './storage/storage-provider.js'; +import { FSStorageProvider } from './storage/fs-storage-provider.js'; // ============================================================================ // Dual-root path resolution types (Issue #311) @@ -65,9 +68,11 @@ export interface ResolvedSquadPaths { * * @returns Absolute path to the main working tree, or `null` if resolution fails. */ -function getMainWorktreePath(worktreeDir: string, gitFilePath: string): string | null { +function getMainWorktreePath(worktreeDir: string, gitFilePath: string, storage: StorageProvider): string | null { try { - const content = fs.readFileSync(gitFilePath, 'utf-8').trim(); + const raw = storage.readSync(gitFilePath); + if (raw === undefined) return null; + const content = raw.trim(); const match = content.match(/^gitdir:\s*(.+)$/m); if (!match || !match[1]) return null; // worktreeGitDir = /main/.git/worktrees/name @@ -77,7 +82,7 @@ function getMainWorktreePath(worktreeDir: string, gitFilePath: string): string | // mainCheckout = /main (dirname of mainGitDir) const mainCheckout = path.dirname(mainGitDir); // Verify the derived main checkout is a real git repo - if (!fs.existsSync(mainGitDir) || !fs.statSync(mainGitDir).isDirectory()) { + if (!storage.existsSync(mainGitDir) || !statSync(mainGitDir).isDirectory()) { return null; } return mainCheckout; @@ -101,29 +106,29 @@ function getMainWorktreePath(worktreeDir: string, gitFilePath: string): string | * @param startDir - Directory to start searching from. Defaults to `process.cwd()`. * @returns Absolute path to `.squad/` or `null`. */ -export function resolveSquad(startDir?: string): string | null { +export function resolveSquad(startDir?: string, storage: StorageProvider = new FSStorageProvider()): string | null { let current = path.resolve(startDir ?? process.cwd()); // eslint-disable-next-line no-constant-condition while (true) { const candidate = path.join(current, '.squad'); - if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) { + if (storage.existsSync(candidate) && statSync(candidate).isDirectory()) { return candidate; } const gitMarker = path.join(current, '.git'); - if (fs.existsSync(gitMarker)) { - if (fs.statSync(gitMarker).isDirectory()) { + if (storage.existsSync(gitMarker)) { + if (statSync(gitMarker).isDirectory()) { // Real repo root — stop walking, no .squad/ found in this checkout return null; } // .git is a file — this is a git worktree // Worktree-local .squad/ was already checked above; fall back to main checkout - const mainCheckout = getMainWorktreePath(current, gitMarker); + const mainCheckout = getMainWorktreePath(current, gitMarker, storage); if (mainCheckout) { const mainCandidate = path.join(mainCheckout, '.squad'); - if (fs.existsSync(mainCandidate) && fs.statSync(mainCandidate).isDirectory()) { + if (storage.existsSync(mainCandidate) && statSync(mainCandidate).isDirectory()) { return mainCandidate; } } @@ -157,30 +162,30 @@ const SQUAD_DIR_NAMES = ['.squad', '.ai-team'] as const; * * Returns the absolute path and the directory name used. */ -function findSquadDir(startDir: string): { dir: string; name: '.squad' | '.ai-team' } | null { +function findSquadDir(startDir: string, storage: StorageProvider): { dir: string; name: '.squad' | '.ai-team' } | null { let current = path.resolve(startDir); // eslint-disable-next-line no-constant-condition while (true) { for (const name of SQUAD_DIR_NAMES) { const candidate = path.join(current, name); - if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) { + if (storage.existsSync(candidate) && statSync(candidate).isDirectory()) { return { dir: candidate, name }; } } const gitMarker = path.join(current, '.git'); - if (fs.existsSync(gitMarker)) { - if (fs.statSync(gitMarker).isDirectory()) { + if (storage.existsSync(gitMarker)) { + if (statSync(gitMarker).isDirectory()) { // Real repo root — stop, no squad dir found in this checkout return null; } // .git is a file — this is a git worktree; fall back to main checkout - const mainCheckout = getMainWorktreePath(current, gitMarker); + const mainCheckout = getMainWorktreePath(current, gitMarker, storage); if (mainCheckout) { for (const name of SQUAD_DIR_NAMES) { const candidate = path.join(mainCheckout, name); - if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) { + if (storage.existsSync(candidate) && statSync(candidate).isDirectory()) { return { dir: candidate, name }; } } @@ -200,13 +205,14 @@ function findSquadDir(startDir: string): { dir: string; name: '.squad' | '.ai-te * Try to read and parse `.squad/config.json` (or `.ai-team/config.json`). * Returns null for missing file, unreadable file, or malformed JSON. */ -export function loadDirConfig(squadDir: string): SquadDirConfig | null { +export function loadDirConfig(squadDir: string, storage: StorageProvider = new FSStorageProvider()): SquadDirConfig | null { const configPath = path.join(squadDir, 'config.json'); - if (!fs.existsSync(configPath)) { + if (!storage.existsSync(configPath)) { return null; } try { - const raw = fs.readFileSync(configPath, 'utf-8'); + const raw = storage.readSync(configPath); + if (raw === undefined) return null; const parsed = JSON.parse(raw); if ( parsed !== null && @@ -246,15 +252,15 @@ export function isConsultMode(config: SquadDirConfig | null): boolean { * @param startDir - Directory to start searching from. Defaults to `process.cwd()`. * @returns Resolved paths, or `null` if no squad directory is found. */ -export function resolveSquadPaths(startDir?: string): ResolvedSquadPaths | null { - const resolved = findSquadDir(startDir ?? process.cwd()); +export function resolveSquadPaths(startDir?: string, storage: StorageProvider = new FSStorageProvider()): ResolvedSquadPaths | null { + const resolved = findSquadDir(startDir ?? process.cwd(), storage); if (!resolved) { return null; } const { dir: projectDir, name } = resolved; const isLegacy = name === '.ai-team'; - const config = loadDirConfig(projectDir); + const config = loadDirConfig(projectDir, storage); if (config && config.teamRoot) { // Remote mode: teamDir resolved relative to the project root (parent of .squad/) @@ -264,7 +270,7 @@ export function resolveSquadPaths(startDir?: string): ResolvedSquadPaths | null mode: 'remote', projectDir, teamDir, - personalDir: resolvePersonalSquadDir(), + personalDir: resolvePersonalSquadDir(storage), config, name, isLegacy, @@ -276,7 +282,7 @@ export function resolveSquadPaths(startDir?: string): ResolvedSquadPaths | null mode: 'local', projectDir, teamDir: projectDir, - personalDir: resolvePersonalSquadDir(), + personalDir: resolvePersonalSquadDir(storage), config, name, isLegacy, @@ -296,7 +302,7 @@ export function resolveSquadPaths(startDir?: string): ResolvedSquadPaths | null * * @returns Absolute path to the global squad config directory. */ -export function resolveGlobalSquadPath(): string { +export function resolveGlobalSquadPath(storage: StorageProvider = new FSStorageProvider()): string { const platform = process.platform; let base: string; @@ -314,8 +320,8 @@ export function resolveGlobalSquadPath(): string { const globalDir = path.join(base, 'squad'); - if (!fs.existsSync(globalDir)) { - fs.mkdirSync(globalDir, { recursive: true }); + if (!storage.existsSync(globalDir)) { + mkdirSync(globalDir, { recursive: true }); } return globalDir; @@ -330,13 +336,13 @@ export function resolveGlobalSquadPath(): string { * - macOS: ~/Library/Application Support/squad/personal-squad * - Linux: $XDG_CONFIG_HOME/squad/personal-squad or ~/.config/squad/personal-squad */ -export function resolvePersonalSquadDir(): string | null { +export function resolvePersonalSquadDir(storage: StorageProvider = new FSStorageProvider()): string | null { if (process.env['SQUAD_NO_PERSONAL']) return null; - const globalDir = resolveGlobalSquadPath(); + const globalDir = resolveGlobalSquadPath(storage); const personalDir = path.join(globalDir, 'personal-squad'); - if (!fs.existsSync(personalDir)) return null; + if (!storage.existsSync(personalDir)) return null; return personalDir; } diff --git a/packages/squad-sdk/src/runtime/config.ts b/packages/squad-sdk/src/runtime/config.ts index e313abd00..fa4ddc783 100644 --- a/packages/squad-sdk/src/runtime/config.ts +++ b/packages/squad-sdk/src/runtime/config.ts @@ -7,11 +7,12 @@ * @module runtime/config */ -import { readFileSync, existsSync } from 'fs'; import { join, resolve, dirname } from 'path'; import { pathToFileURL } from 'url'; import { MODELS } from './constants.js'; import type { AgentRole } from './constants.js'; +import type { StorageProvider } from '../storage/storage-provider.js'; +import { FSStorageProvider } from '../storage/fs-storage-provider.js'; // ============================================================================ // Configuration Types (from spike #72) @@ -425,7 +426,7 @@ export class ConfigValidationError extends Error { * @param cwd - Starting directory for upward search * @returns Path to config file if found, undefined otherwise */ -export function discoverConfigFile(cwd: string = process.cwd()): string | undefined { +export function discoverConfigFile(cwd: string = process.cwd(), storage: StorageProvider = new FSStorageProvider()): string | undefined { let currentDir = resolve(cwd); const root = resolve(dirname(currentDir), '..'); @@ -439,7 +440,7 @@ export function discoverConfigFile(cwd: string = process.cwd()): string | undefi while (true) { for (const configFile of configFiles) { const configPath = join(currentDir, configFile); - if (existsSync(configPath)) { + if (storage.existsSync(configPath)) { return configPath; } } @@ -470,7 +471,7 @@ export function discoverConfigFile(cwd: string = process.cwd()): string | undefi * @returns Configuration load result with validation * @throws {ConfigValidationError} If config validation fails */ -export async function loadConfig(cwd: string = process.cwd()): Promise<ConfigLoadResult> { +export async function loadConfig(cwd: string = process.cwd(), storage: StorageProvider = new FSStorageProvider()): Promise<ConfigLoadResult> { const resolvedCwd = resolve(cwd); // Try local configs first @@ -482,7 +483,7 @@ export async function loadConfig(cwd: string = process.cwd()): Promise<ConfigLoa ]; for (const { path: configPath, type } of localConfigs) { - if (existsSync(configPath)) { + if (storage.existsSync(configPath)) { try { if (type === 'ts' || type === 'js') { const configUrl = pathToFileURL(configPath).href; @@ -500,7 +501,10 @@ export async function loadConfig(cwd: string = process.cwd()): Promise<ConfigLoa isDefault: false }; } else { - const content = readFileSync(configPath, 'utf-8'); + const content = storage.readSync(configPath); + if (content === undefined) { + throw new Error(`Config file not found: ${configPath}`); + } const config = JSON.parse(content); const validated = validateConfig(config); @@ -520,7 +524,7 @@ export async function loadConfig(cwd: string = process.cwd()): Promise<ConfigLoa } // Walk up directory tree - const discoveredPath = discoverConfigFile(dirname(resolvedCwd)); + const discoveredPath = discoverConfigFile(dirname(resolvedCwd), storage); if (discoveredPath) { try { if (discoveredPath.endsWith('.ts') || discoveredPath.endsWith('.js')) { @@ -539,7 +543,10 @@ export async function loadConfig(cwd: string = process.cwd()): Promise<ConfigLoa isDefault: false }; } else { - const content = readFileSync(discoveredPath, 'utf-8'); + const content = storage.readSync(discoveredPath); + if (content === undefined) { + throw new Error(`Config file not found: ${discoveredPath}`); + } const config = JSON.parse(content); const validated = validateConfig(config); @@ -788,14 +795,17 @@ export function validateConfig(config: unknown): SquadConfig { * @returns Configuration load result with validation * @throws {ConfigValidationError} If config validation fails */ -export function loadConfigSync(cwd: string = process.cwd()): ConfigLoadResult { +export function loadConfigSync(cwd: string = process.cwd(), storage: StorageProvider = new FSStorageProvider()): ConfigLoadResult { const resolvedCwd = resolve(cwd); // Only check squad.config.json (sync loading of .ts not supported) const jsonConfigPath = join(resolvedCwd, 'squad.config.json'); - if (existsSync(jsonConfigPath)) { + if (storage.existsSync(jsonConfigPath)) { try { - const content = readFileSync(jsonConfigPath, 'utf-8'); + const content = storage.readSync(jsonConfigPath); + if (content === undefined) { + throw new Error(`Config file not found: ${jsonConfigPath}`); + } const config = JSON.parse(content); const validated = validateConfig(config); diff --git a/packages/squad-sdk/src/runtime/cross-squad.ts b/packages/squad-sdk/src/runtime/cross-squad.ts index 5eed68f43..e2e6cdf79 100644 --- a/packages/squad-sdk/src/runtime/cross-squad.ts +++ b/packages/squad-sdk/src/runtime/cross-squad.ts @@ -7,8 +7,9 @@ * @module runtime/cross-squad */ -import { readFileSync, existsSync } from 'fs'; import { join } from 'path'; +import type { StorageProvider } from '../storage/index.js'; +import { FSStorageProvider } from '../storage/index.js'; // ============================================================================ // Types @@ -116,11 +117,15 @@ export function validateManifest(data: unknown): data is SquadManifest { * Read and parse a squad manifest from a directory path. * Looks for `.squad/manifest.json` relative to the given root. */ -export function readManifest(repoPath: string): SquadManifest | null { +export function readManifest( + repoPath: string, + storage: StorageProvider = new FSStorageProvider(), +): SquadManifest | null { const manifestPath = join(repoPath, '.squad', 'manifest.json'); - if (!existsSync(manifestPath)) return null; + if (!storage.existsSync(manifestPath)) return null; try { - const raw = readFileSync(manifestPath, 'utf8'); + const raw = storage.readSync(manifestPath); + if (raw === undefined) return null; const parsed: unknown = JSON.parse(raw); if (!validateManifest(parsed)) return null; return parsed; @@ -151,13 +156,18 @@ interface UpstreamJsonFile { * Discover squads from upstream sources. * Reads `.squad/upstream.json` and checks each upstream for a manifest. */ -export function discoverFromUpstreams(squadDir: string): DiscoveredSquad[] { +export function discoverFromUpstreams( + squadDir: string, + storage: StorageProvider = new FSStorageProvider(), +): DiscoveredSquad[] { const upstreamPath = join(squadDir, 'upstream.json'); - if (!existsSync(upstreamPath)) return []; + if (!storage.existsSync(upstreamPath)) return []; let config: UpstreamJsonFile; try { - config = JSON.parse(readFileSync(upstreamPath, 'utf8')) as UpstreamJsonFile; + const raw = storage.readSync(upstreamPath); + if (raw === undefined) return []; + config = JSON.parse(raw) as UpstreamJsonFile; } catch { return []; } @@ -167,7 +177,7 @@ export function discoverFromUpstreams(squadDir: string): DiscoveredSquad[] { const discovered: DiscoveredSquad[] = []; for (const upstream of config.upstreams) { if (upstream.type === 'local' && upstream.source) { - const manifest = readManifest(upstream.source); + const manifest = readManifest(upstream.source, storage); if (manifest) { discovered.push({ manifest, @@ -178,7 +188,7 @@ export function discoverFromUpstreams(squadDir: string): DiscoveredSquad[] { } else if (upstream.type === 'git') { // For git upstreams, check the cached clone directory const cloneDir = join(squadDir, '_upstream_repos', upstream.name); - const manifest = readManifest(cloneDir); + const manifest = readManifest(cloneDir, storage); if (manifest) { discovered.push({ manifest, @@ -196,12 +206,16 @@ export function discoverFromUpstreams(squadDir: string): DiscoveredSquad[] { * Discover squads from a registry file. * A registry is a JSON file listing repo paths to check for manifests. */ -export function discoverFromRegistry(registryPath: string): DiscoveredSquad[] { - if (!existsSync(registryPath)) return []; +export function discoverFromRegistry( + registryPath: string, + storage: StorageProvider = new FSStorageProvider(), +): DiscoveredSquad[] { + if (!storage.existsSync(registryPath)) return []; let entries: Array<{ name: string; path: string }>; try { - const raw = readFileSync(registryPath, 'utf8'); + const raw = storage.readSync(registryPath); + if (raw === undefined) return []; const parsed: unknown = JSON.parse(raw); if (!Array.isArray(parsed)) return []; entries = parsed as Array<{ name: string; path: string }>; @@ -212,7 +226,7 @@ export function discoverFromRegistry(registryPath: string): DiscoveredSquad[] { const discovered: DiscoveredSquad[] = []; for (const entry of entries) { if (typeof entry.path === 'string') { - const manifest = readManifest(entry.path); + const manifest = readManifest(entry.path, storage); if (manifest) { discovered.push({ manifest, @@ -230,10 +244,13 @@ export function discoverFromRegistry(registryPath: string): DiscoveredSquad[] { * Discover all squads from all available sources. * Checks upstreams first, then a registry file if present. */ -export function discoverSquads(squadDir: string): DiscoveredSquad[] { - const fromUpstreams = discoverFromUpstreams(squadDir); +export function discoverSquads( + squadDir: string, + storage: StorageProvider = new FSStorageProvider(), +): DiscoveredSquad[] { + const fromUpstreams = discoverFromUpstreams(squadDir, storage); const registryPath = join(squadDir, 'squad-registry.json'); - const fromRegistry = discoverFromRegistry(registryPath); + const fromRegistry = discoverFromRegistry(registryPath, storage); // Deduplicate by manifest name (upstreams take priority) const seen = new Set(fromUpstreams.map(d => d.manifest.name)); diff --git a/packages/squad-sdk/src/runtime/scheduler.ts b/packages/squad-sdk/src/runtime/scheduler.ts index ece2b0538..34949e338 100644 --- a/packages/squad-sdk/src/runtime/scheduler.ts +++ b/packages/squad-sdk/src/runtime/scheduler.ts @@ -11,9 +11,9 @@ * - Custom providers via ScheduleProvider interface */ -import { readFile, writeFile } from 'node:fs/promises'; -import fs from 'node:fs'; import path from 'node:path'; +import type { StorageProvider } from '../storage/index.js'; +import { FSStorageProvider } from '../storage/index.js'; // ============================================================================ // Schedule Schema Types @@ -236,11 +236,21 @@ function validateEntry(entry: unknown, index: number, seenIds: Set<string>): voi /** * Parse and validate a schedule.json file from disk. */ -export async function parseSchedule(filePath: string): Promise<ScheduleManifest> { +export async function parseSchedule( + filePath: string, + storage: StorageProvider = new FSStorageProvider(), +): Promise<ScheduleManifest> { let raw: string; try { - raw = await readFile(filePath, 'utf8'); + const content = await storage.read(filePath); + if (content === undefined) { + throw new ScheduleValidationError( + `Cannot read schedule file: ${filePath} — ENOENT: no such file or directory`, + ); + } + raw = content; } catch (err) { + if (err instanceof ScheduleValidationError) throw err; throw new ScheduleValidationError( `Cannot read schedule file: ${filePath} — ${(err as Error).message}`, ); @@ -398,9 +408,13 @@ export async function executeTask( /** * Load schedule state from disk. Returns empty state if file doesn't exist. */ -export async function loadState(statePath: string): Promise<ScheduleState> { +export async function loadState( + statePath: string, + storage: StorageProvider = new FSStorageProvider(), +): Promise<ScheduleState> { try { - const raw = await readFile(statePath, 'utf8'); + const raw = await storage.read(statePath); + if (raw === undefined) return { runs: {} }; return JSON.parse(raw) as ScheduleState; } catch { return { runs: {} }; @@ -410,8 +424,12 @@ export async function loadState(statePath: string): Promise<ScheduleState> { /** * Save schedule state to disk. */ -export async function saveState(statePath: string, state: ScheduleState): Promise<void> { - await writeFile(statePath, JSON.stringify(state, null, 2) + '\n', 'utf8'); +export async function saveState( + statePath: string, + state: ScheduleState, + storage: StorageProvider = new FSStorageProvider(), +): Promise<void> { + await storage.write(statePath, JSON.stringify(state, null, 2) + '\n'); } // ============================================================================ @@ -476,6 +494,11 @@ export class LocalPollingProvider implements ScheduleProvider { */ export class GitHubActionsProvider implements ScheduleProvider { readonly name = 'github-actions'; + private readonly storage: StorageProvider; + + constructor(storage: StorageProvider = new FSStorageProvider()) { + this.storage = storage; + } async execute(entry: ScheduleEntry): Promise<TaskResult> { // GitHub Actions execution is handled by the platform itself. @@ -520,11 +543,8 @@ export class GitHubActionsProvider implements ScheduleProvider { ` run: echo "Executing ${entry.id} — ${entry.task.type}:${entry.task.ref}"`, ].join('\n') + '\n'; - const dir = path.dirname(workflowPath); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - fs.writeFileSync(workflowPath, yaml, 'utf8'); + // StorageProvider.write() creates parent dirs automatically + await this.storage.write(workflowPath, yaml); generated.push(workflowPath); } diff --git a/packages/squad-sdk/src/runtime/squad-observer.ts b/packages/squad-sdk/src/runtime/squad-observer.ts index a14cf416d..2ec6bb313 100644 --- a/packages/squad-sdk/src/runtime/squad-observer.ts +++ b/packages/squad-sdk/src/runtime/squad-observer.ts @@ -13,6 +13,8 @@ import path from 'node:path'; import { SpanStatusCode } from './otel-api.js'; import { getTracer } from './otel.js'; import { EventBus, type SquadEvent } from './event-bus.js'; +import type { StorageProvider } from '../storage/index.js'; +import { FSStorageProvider } from '../storage/index.js'; // ============================================================================ // Types @@ -49,6 +51,8 @@ export interface SquadObserverConfig { eventBus?: EventBus; /** Debounce interval in ms (default: 200) */ debounceMs?: number; + /** Storage provider for file I/O (default: FSStorageProvider rooted at squadDir) */ + storage?: StorageProvider; } // ============================================================================ @@ -88,6 +92,8 @@ export function classifyFile(relativePath: string): SquadFileCategory { */ export class SquadObserver { private config: Required<Pick<SquadObserverConfig, 'squadDir' | 'debounceMs'>> & Pick<SquadObserverConfig, 'eventBus'>; + private storage: StorageProvider; + // TODO: fs.FSWatcher has no StorageProvider equivalent — keep raw fs until StorageProvider supports file watching private watcher: fs.FSWatcher | undefined; private debounceTimers: Map<string, ReturnType<typeof setTimeout>> = new Map(); private running = false; @@ -98,6 +104,7 @@ export class SquadObserver { eventBus: config.eventBus, debounceMs: config.debounceMs ?? 200, }; + this.storage = config.storage ?? new FSStorageProvider(); } /** @@ -106,7 +113,7 @@ export class SquadObserver { */ start(): void { if (this.running) return; - if (!fs.existsSync(this.config.squadDir)) { + if (!this.storage.existsSync(this.config.squadDir)) { throw new Error(`Squad directory not found: ${this.config.squadDir}`); } @@ -119,6 +126,7 @@ export class SquadObserver { }); try { + // TODO: fs.watch has no StorageProvider equivalent — keep raw fs until StorageProvider supports file watching this.watcher = fs.watch(this.config.squadDir, { recursive: true }, (eventType, filename) => { if (!filename) return; // Skip high-churn directories that don't affect squad state @@ -191,7 +199,7 @@ export class SquadObserver { private processChange(filename: string): void { const absolutePath = path.join(this.config.squadDir, filename); const category = classifyFile(filename); - const exists = fs.existsSync(absolutePath); + const exists = this.storage.existsSync(absolutePath); // Determine change type — basic heuristic since fs.watch doesn't tell us const changeType: SquadFileChange['changeType'] = exists ? 'modified' : 'deleted'; diff --git a/packages/squad-sdk/src/sharing/consult.ts b/packages/squad-sdk/src/sharing/consult.ts index 2bd2d4fbf..0e2afda9c 100644 --- a/packages/squad-sdk/src/sharing/consult.ts +++ b/packages/squad-sdk/src/sharing/consult.ts @@ -15,12 +15,14 @@ * @module sharing/consult */ -import fs from 'node:fs'; +import { cpSync, readdirSync, mkdirSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { execSync } from 'node:child_process'; import type { AgentHistory, HistoryEntry } from './history-split.js'; import { resolveGlobalSquadPath } from '../resolution.js'; +import type { StorageProvider } from '../storage/storage-provider.js'; +import { FSStorageProvider } from '../storage/fs-storage-provider.js'; // Re-export types for convenience export type { AgentHistory, HistoryEntry } from './history-split.js'; @@ -85,19 +87,19 @@ This project is in **consult mode**. Your personal squad has been copied into \` * Get the full squad.agent.md template path. * Looks in the SDK package's templates directory. */ -function getSquadAgentTemplatePath(): string | null { +function getSquadAgentTemplatePath(storage: StorageProvider): string | null { // Use fileURLToPath for cross-platform compatibility (handles Windows drive letters, URL encoding) const currentDir = path.dirname(fileURLToPath(import.meta.url)); // Try relative to this file (in dist/) const distPath = path.resolve(currentDir, '../../templates/squad.agent.md'); - if (fs.existsSync(distPath)) { + if (storage.existsSync(distPath)) { return distPath; } // Try relative to package root const pkgPath = path.resolve(currentDir, '../../../templates/squad.agent.md'); - if (fs.existsSync(pkgPath)) { + if (storage.existsSync(pkgPath)) { return pkgPath; } @@ -136,11 +138,11 @@ function getGitRemoteUrl(projectRoot: string): string | undefined { * Generate squad.agent.md for consult mode. * Uses the full template with consult mode preamble injected. */ -function getConsultAgentContent(projectName: string): string { - const templatePath = getSquadAgentTemplatePath(); +function getConsultAgentContent(projectName: string, storage: StorageProvider): string { + const templatePath = getSquadAgentTemplatePath(storage); - if (templatePath && fs.existsSync(templatePath)) { - const template = fs.readFileSync(templatePath, 'utf-8'); + if (templatePath && storage.existsSync(templatePath)) { + const template = storage.readSync(templatePath) ?? ''; // Find the end of frontmatter (second ---) const frontmatterEnd = template.indexOf('---', template.indexOf('---') + 3); @@ -227,37 +229,38 @@ Run \`squad extract\` to review and merge these to your personal squad. /** * Patch the Scribe charter in the copied squad with consult mode instructions. */ -function patchScribeCharterForConsultMode(squadDir: string): void { +function patchScribeCharterForConsultMode(squadDir: string, storage: StorageProvider): void { const charterPath = path.join(squadDir, 'agents', 'scribe', 'charter.md'); - if (!fs.existsSync(charterPath)) { + if (!storage.existsSync(charterPath)) { // No scribe charter to patch — skip silently return; } - const existing = fs.readFileSync(charterPath, 'utf-8'); + const existing = storage.readSync(charterPath) ?? ''; // Don't patch if already patched if (existing.includes('Consult Mode Extraction')) { return; } - fs.appendFileSync(charterPath, CONSULT_MODE_SCRIBE_PATCH); + storage.writeSync(charterPath, existing + CONSULT_MODE_SCRIBE_PATCH); } /** * List files recursively in a directory. */ -function listFilesInDir(dir: string, basePath = ''): string[] { - if (!fs.existsSync(dir)) return []; +function listFilesInDir(dir: string, storage: StorageProvider, basePath = ''): string[] { + if (!storage.existsSync(dir)) return []; const files: string[] = []; - const entries = fs.readdirSync(dir, { withFileTypes: true }); + // readdirSync with withFileTypes needed for Dirent objects (not in StorageProvider) + const entries = readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { const relativePath = basePath ? path.join(basePath, entry.name) : entry.name; if (entry.isDirectory()) { - files.push(...listFilesInDir(path.join(dir, entry.name), relativePath)); + files.push(...listFilesInDir(path.join(dir, entry.name), storage, relativePath)); } else { files.push(relativePath); } @@ -346,6 +349,7 @@ export function resolveGitExcludePath(cwd: string): string { */ export async function setupConsultMode( options: SetupConsultModeOptions = {}, + storage: StorageProvider = new FSStorageProvider(), ): Promise<SetupConsultModeResult> { const projectRoot = options.projectRoot || process.cwd(); const personalSquadRoot = options.personalSquadRoot || getPersonalSquadRoot(); @@ -357,7 +361,7 @@ export async function setupConsultMode( // Check if we're in a git repository (handle worktrees/submodules where .git is a file) const gitPath = path.resolve(projectRoot, '.git'); - if (!fs.existsSync(gitPath)) { + if (!storage.existsSync(gitPath)) { throw new Error('Not a git repository. Consult mode requires git.'); } @@ -369,7 +373,7 @@ export async function setupConsultMode( })(); // Check if personal squad exists - if (!fs.existsSync(personalSquadRoot)) { + if (!storage.existsSync(personalSquadRoot)) { throw new PersonalSquadNotFoundError(); } @@ -377,9 +381,9 @@ export async function setupConsultMode( // Option takes precedence, then fall back to source config let extractionDisabled = options.extractionDisabled ?? false; const sourceConfigPath = path.join(personalSquadRoot, 'config.json'); - if (fs.existsSync(sourceConfigPath)) { + if (storage.existsSync(sourceConfigPath)) { try { - const sourceConfig = JSON.parse(fs.readFileSync(sourceConfigPath, 'utf-8')); + const sourceConfig = JSON.parse(storage.readSync(sourceConfigPath) ?? '{}'); // Inherit from source unless explicitly overridden in options if (options.extractionDisabled === undefined && sourceConfig.extractionDisabled) { extractionDisabled = true; @@ -390,19 +394,19 @@ export async function setupConsultMode( } // Check if project already has .squad/ - if (fs.existsSync(squadDir)) { + if (storage.existsSync(squadDir)) { throw new Error( 'This project already has a .squad/ directory. Cannot use consult mode on squadified projects.', ); } // List files in personal squad (for dry run preview or later count) - const sourceFiles = listFilesInDir(personalSquadRoot); + const sourceFiles = listFilesInDir(personalSquadRoot, storage); if (!dryRun) { // Copy personal squad contents into project's .squad/ // This isolates changes during the consult session - fs.cpSync(personalSquadRoot, squadDir, { recursive: true }); + cpSync(personalSquadRoot, squadDir, { recursive: true }); // Write/overwrite config.json with consult: true // Include SquadDirConfig fields so loadDirConfig() can read it @@ -416,39 +420,38 @@ export async function setupConsultMode( createdAt: new Date().toISOString(), extractionDisabled, }; - fs.writeFileSync( + storage.writeSync( path.join(squadDir, 'config.json'), JSON.stringify(config, null, 2), - 'utf-8', ); // Create sessions directory for tracking (if not copied) const sessionsDir = path.join(squadDir, 'sessions'); - if (!fs.existsSync(sessionsDir)) { - fs.mkdirSync(sessionsDir, { recursive: true }); + if (!storage.existsSync(sessionsDir)) { + mkdirSync(sessionsDir, { recursive: true }); } // Create extract/ directory for staging generic learnings const extractDir = path.join(squadDir, 'extract'); - fs.mkdirSync(extractDir, { recursive: true }); + mkdirSync(extractDir, { recursive: true }); // Patch scribe-charter.md with consult mode extraction instructions - patchScribeCharterForConsultMode(squadDir); + patchScribeCharterForConsultMode(squadDir, storage); // Create .github/agents/squad.agent.md for `gh copilot --agent squad` const agentDir = path.dirname(agentFile); - if (!fs.existsSync(agentDir)) { - fs.mkdirSync(agentDir, { recursive: true }); + if (!storage.existsSync(agentDir)) { + mkdirSync(agentDir, { recursive: true }); } - fs.writeFileSync(agentFile, getConsultAgentContent(projectName), 'utf-8'); + storage.writeSync(agentFile, getConsultAgentContent(projectName, storage)); // Add .squad/ and .github/agents/squad.agent.md to .git/info/exclude const excludeDir = path.dirname(gitExclude); - if (!fs.existsSync(excludeDir)) { - fs.mkdirSync(excludeDir, { recursive: true }); + if (!storage.existsSync(excludeDir)) { + mkdirSync(excludeDir, { recursive: true }); } - const excludeContent = fs.existsSync(gitExclude) - ? fs.readFileSync(gitExclude, 'utf-8') + const excludeContent = storage.existsSync(gitExclude) + ? (storage.readSync(gitExclude) ?? '') : ''; const excludeLines: string[] = []; if (!excludeContent.includes('.squad/')) { @@ -458,12 +461,12 @@ export async function setupConsultMode( excludeLines.push('.github/agents/squad.agent.md'); } if (excludeLines.length > 0) { - fs.appendFileSync(gitExclude, '\n# Squad consult mode (local only)\n' + excludeLines.join('\n') + '\n'); + storage.writeSync(gitExclude, excludeContent + '\n# Squad consult mode (local only)\n' + excludeLines.join('\n') + '\n'); } } // List files created (from squad dir after copy, or from source for dry run) - const createdFiles = dryRun ? sourceFiles : listFilesInDir(squadDir); + const createdFiles = dryRun ? sourceFiles : listFilesInDir(squadDir, storage); return { squadDir, @@ -525,21 +528,23 @@ export interface ExtractLearningsResult extends ExtractionResult { * @param squadDir - Path to project .squad/ directory * @returns AgentHistory with entries from session files */ -export function loadSessionHistory(squadDir: string): AgentHistory { +export function loadSessionHistory(squadDir: string, storage: StorageProvider = new FSStorageProvider()): AgentHistory { const sessionsDir = path.join(squadDir, 'sessions'); const entries: HistoryEntry[] = []; - if (!fs.existsSync(sessionsDir)) { + if (!storage.existsSync(sessionsDir)) { return { entries }; } - const files = fs.readdirSync(sessionsDir) + // readdirSync needed here (no sync list in StorageProvider) + const files = readdirSync(sessionsDir) .filter(f => f.endsWith('.json')) .sort(); for (const file of files) { try { - const content = fs.readFileSync(path.join(sessionsDir, file), 'utf-8'); + const content = storage.readSync(path.join(sessionsDir, file)); + if (content === undefined) continue; const session = JSON.parse(content); // Extract learnings from session data @@ -587,6 +592,7 @@ export function loadSessionHistory(squadDir: string): AgentHistory { */ export async function extractLearnings( options: ExtractLearningsOptions = {}, + storage: StorageProvider = new FSStorageProvider(), ): Promise<ExtractLearningsResult> { const projectRoot = options.projectRoot || process.cwd(); const personalSquadRoot = options.personalSquadRoot || getPersonalSquadRoot(); @@ -599,16 +605,16 @@ export async function extractLearnings( const projectName = options.projectName || path.basename(projectRoot); // Check if we're in consult mode - if (!fs.existsSync(squadDir)) { + if (!storage.existsSync(squadDir)) { throw new Error('Not in consult mode. No .squad/ directory found.'); } const configPath = path.join(squadDir, 'config.json'); - if (!fs.existsSync(configPath)) { + if (!storage.existsSync(configPath)) { throw new Error('Invalid consult mode: missing config.json'); } - const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + const config = JSON.parse(storage.readSync(configPath) ?? '{}'); if (!config.consult) { throw new Error( 'This project has a .squad/ but is not in consult mode. Use normal squad commands.', @@ -622,8 +628,8 @@ export async function extractLearnings( // Detect license const licensePath = path.join(projectRoot, 'LICENSE'); - const licenseContent = fs.existsSync(licensePath) - ? fs.readFileSync(licensePath, 'utf-8') + const licenseContent = storage.existsSync(licensePath) + ? (storage.readSync(licensePath) ?? '') : ''; const license = detectLicense(licenseContent); @@ -650,7 +656,7 @@ export async function extractLearnings( } // Load staged learnings from .squad/extract/ - let staged = loadStagedLearnings(squadDir); + let staged = loadStagedLearnings(squadDir, storage); // If interactive selection callback provided, let user choose let skipped: StagedLearning[] = []; @@ -678,22 +684,22 @@ export async function extractLearnings( if (!dryRun && staged.length > 0) { // Merge to personal squad - const mergeResult = await mergeToPersonalSquad(staged, personalSquadRoot); + const mergeResult = await mergeToPersonalSquad(staged, personalSquadRoot, storage); decisionsMerged = mergeResult.decisions; skillsCreated = mergeResult.skills; // Log consultation - consultationLogPath = await logConsultation(personalSquadRoot, result); + consultationLogPath = await logConsultation(personalSquadRoot, result, storage); // Remove extracted files from .squad/extract/ for (const learning of staged) { - fs.rmSync(learning.filepath, { force: true }); + await storage.delete(learning.filepath); } } // Clean up entire .squad/ if requested if (clean && !dryRun) { - fs.rmSync(squadDir, { recursive: true, force: true }); + await storage.deleteDir(squadDir); cleaned = true; } @@ -834,20 +840,22 @@ export interface StagedLearning { * @param squadDir - Path to project .squad/ directory * @returns Array of staged learnings */ -export function loadStagedLearnings(squadDir: string): StagedLearning[] { +export function loadStagedLearnings(squadDir: string, storage: StorageProvider = new FSStorageProvider()): StagedLearning[] { const extractDir = path.join(squadDir, 'extract'); const learnings: StagedLearning[] = []; - if (!fs.existsSync(extractDir)) { + if (!storage.existsSync(extractDir)) { return learnings; } - const files = fs.readdirSync(extractDir).filter(f => f.endsWith('.md')); + // readdirSync needed here (no sync list in StorageProvider) + const files = readdirSync(extractDir).filter(f => f.endsWith('.md')); for (const file of files) { const filepath = path.join(extractDir, file); try { - const content = fs.readFileSync(filepath, 'utf-8'); + const content = storage.readSync(filepath); + if (content === undefined) continue; learnings.push({ filename: file, filepath, @@ -902,20 +910,21 @@ export interface ExtractionResult { export async function logConsultation( personalSquadRoot: string, result: ExtractionResult, + storage: StorageProvider = new FSStorageProvider(), ): Promise<string> { const consultDir = path.join(personalSquadRoot, 'consultations'); const logPath = path.join(consultDir, `${result.projectName}.md`); // Create consultations directory if needed - if (!fs.existsSync(consultDir)) { - fs.mkdirSync(consultDir, { recursive: true }); + if (!storage.existsSync(consultDir)) { + mkdirSync(consultDir, { recursive: true }); } const today = result.timestamp.split('T')[0] ?? new Date().toISOString().split('T')[0]!; // YYYY-MM-DD format - if (fs.existsSync(logPath)) { + if (storage.existsSync(logPath)) { // Append to existing log — update "Last session" and add new entry - let content = fs.readFileSync(logPath, 'utf-8'); + let content = storage.readSync(logPath) ?? ''; // Update "Last session" date content = content.replace( @@ -927,12 +936,12 @@ export async function logConsultation( const sessionEntry = formatSessionEntry(result, today); // Append to file - fs.writeFileSync(logPath, content + sessionEntry, 'utf-8'); + storage.writeSync(logPath, content + sessionEntry); } else { // Create new consultation log with full header const header = formatLogHeader(result, today); const sessionEntry = formatSessionEntry(result, today); - fs.writeFileSync(logPath, header + sessionEntry, 'utf-8'); + storage.writeSync(logPath, header + sessionEntry); } return logPath; @@ -1015,6 +1024,7 @@ function extractSkillName(content: string): string | null { export async function mergeToPersonalSquad( learnings: StagedLearning[], personalSquadRoot: string, + storage: StorageProvider = new FSStorageProvider(), ): Promise<{ decisions: number; skills: number }> { if (learnings.length === 0) { return { decisions: 0, skills: 0 }; @@ -1042,14 +1052,14 @@ export async function mergeToPersonalSquad( const skillDir = path.join(skillsDir, skillName); // Create skill directory if needed - if (!fs.existsSync(skillDir)) { - fs.mkdirSync(skillDir, { recursive: true }); + if (!storage.existsSync(skillDir)) { + mkdirSync(skillDir, { recursive: true }); } const skillPath = path.join(skillDir, 'SKILL.md'); // Write skill (overwrites if exists — newer extraction wins) - fs.writeFileSync(skillPath, skill.content, 'utf-8'); + storage.writeSync(skillPath, skill.content); skillsAdded++; } @@ -1058,8 +1068,8 @@ export async function mergeToPersonalSquad( const decisionsPath = path.join(personalSquadRoot, 'decisions.md'); const newContent = decisions.map(d => d.content.trim()).join('\n\n'); - if (fs.existsSync(decisionsPath)) { - const existing = fs.readFileSync(decisionsPath, 'utf-8'); + if (storage.existsSync(decisionsPath)) { + const existing = storage.readSync(decisionsPath) ?? ''; // Check if we already have an "Extracted from Consultations" section if (existing.includes('## Extracted from Consultations')) { @@ -1074,7 +1084,7 @@ export async function mergeToPersonalSquad( // Insert before next section const sectionContent = afterSection.slice(0, nextSectionMatch.index); const rest = afterSection.slice(nextSectionMatch.index); - fs.writeFileSync( + storage.writeSync( decisionsPath, beforeSection + '## Extracted from Consultations' + @@ -1083,30 +1093,26 @@ export async function mergeToPersonalSquad( newContent + '\n' + rest, - 'utf-8', ); } else { // No next section — append to end - fs.writeFileSync( + storage.writeSync( decisionsPath, existing.trimEnd() + '\n\n' + newContent + '\n', - 'utf-8', ); } } else { // No extraction section yet — create one - fs.writeFileSync( + storage.writeSync( decisionsPath, existing.trimEnd() + '\n\n## Extracted from Consultations\n\n' + newContent + '\n', - 'utf-8', ); } } else { // Create new decisions file - fs.writeFileSync( + storage.writeSync( decisionsPath, `# Squad Decisions\n\n## Extracted from Consultations\n\n${newContent}\n`, - 'utf-8', ); } decisionsAdded = decisions.length; diff --git a/packages/squad-sdk/src/sharing/export.ts b/packages/squad-sdk/src/sharing/export.ts index 2829bb33f..aab952677 100644 --- a/packages/squad-sdk/src/sharing/export.ts +++ b/packages/squad-sdk/src/sharing/export.ts @@ -3,8 +3,9 @@ * Exports Squad configuration as a portable bundle. */ -import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; -import { join, basename, relative } from 'node:path'; +import { join, basename } from 'node:path'; +import type { StorageProvider } from '../storage/storage-provider.js'; +import { FSStorageProvider } from '../storage/fs-storage-provider.js'; export interface ExportOptions { includeHistory?: boolean; @@ -73,32 +74,35 @@ export function anonymizeContent(content: string): string { return result; } -function readTeamConfig(projectDir: string): Record<string, unknown> { +function readTeamConfig(projectDir: string, storage: StorageProvider): Record<string, unknown> { const teamFile = join(projectDir, '.ai-team', 'team.md'); - if (existsSync(teamFile)) { - return { teamFile: readFileSync(teamFile, 'utf-8') }; + const content = storage.readSync(teamFile); + if (content !== undefined) { + return { teamFile: content }; } return {}; } -function readAgents(projectDir: string): AgentCharter[] { +function readAgents(projectDir: string, storage: StorageProvider): AgentCharter[] { const agentsDir = join(projectDir, '.github', 'agents'); - if (!existsSync(agentsDir)) return []; - - return readdirSync(agentsDir) - .filter(f => f.endsWith('.md')) - .map(f => { - const content = readFileSync(join(agentsDir, f), 'utf-8'); - const name = basename(f, '.md').replace('.agent', ''); - return { name, role: name, content }; - }); + if (!storage.existsSync(agentsDir)) return []; + + const files = storage.listSync(agentsDir).filter(f => f.endsWith('.md')); + const agents: AgentCharter[] = []; + for (const f of files) { + const content = storage.readSync(join(agentsDir, f)); + if (content === undefined) continue; + const name = basename(f, '.md').replace('.agent', ''); + agents.push({ name, role: name, content }); + } + return agents; } -function readRoutingRules(projectDir: string): ExportRoutingRule[] { +function readRoutingRules(projectDir: string, storage: StorageProvider): ExportRoutingRule[] { const routingFile = join(projectDir, '.ai-team', 'routing.md'); - if (!existsSync(routingFile)) return []; + const content = storage.readSync(routingFile); + if (content === undefined) return []; - const content = readFileSync(routingFile, 'utf-8'); const rules: ExportRoutingRule[] = []; const lines = content.split('\n'); for (const line of lines) { @@ -113,7 +117,11 @@ function readRoutingRules(projectDir: string): ExportRoutingRule[] { /** * Export a Squad project configuration as a bundle. */ -export function exportSquadConfig(projectDir: string, options?: ExportOptions): ExportBundle { +export function exportSquadConfig( + projectDir: string, + options?: ExportOptions, + storage: StorageProvider = new FSStorageProvider(), +): ExportBundle { const opts: Required<ExportOptions> = { includeHistory: options?.includeHistory ?? false, includeSkills: options?.includeSkills ?? true, @@ -121,9 +129,9 @@ export function exportSquadConfig(projectDir: string, options?: ExportOptions): anonymize: options?.anonymize ?? false, }; - const config = readTeamConfig(projectDir); - let agents = readAgents(projectDir); - let routingRules = readRoutingRules(projectDir); + const config = readTeamConfig(projectDir, storage); + let agents = readAgents(projectDir, storage); + let routingRules = readRoutingRules(projectDir, storage); const skills: string[] = []; if (opts.includeSkills) { @@ -132,15 +140,23 @@ export function exportSquadConfig(projectDir: string, options?: ExportOptions): { dir: join(projectDir, '.squad', 'skills'), layout: 'nested' as const }, { dir: join(projectDir, '.ai-team', 'skills'), layout: 'flat' as const }, ]; - const source = skillSources.find(({ dir }) => existsSync(dir)); + let source: typeof skillSources[number] | undefined; + for (const s of skillSources) { + if (storage.existsSync(s.dir)) { + source = s; + break; + } + } if (source) { if (source.layout === 'nested') { - const skillDirs = readdirSync(source.dir, { withFileTypes: true }) - .filter(entry => entry.isDirectory() && existsSync(join(source.dir, entry.name, 'SKILL.md'))) - .map(entry => entry.name); - skills.push(...skillDirs); + const entries = storage.listSync(source.dir); + for (const name of entries) { + if (storage.existsSync(join(source.dir, name, 'SKILL.md'))) { + skills.push(name); + } + } } else { - const skillFiles = readdirSync(source.dir).filter(f => f.endsWith('.md')); + const skillFiles = storage.listSync(source.dir).filter(f => f.endsWith('.md')); skills.push(...skillFiles.map(f => basename(f, '.md'))); } } diff --git a/packages/squad-sdk/src/sharing/import.ts b/packages/squad-sdk/src/sharing/import.ts index 8a18a0319..2e8e6fb15 100644 --- a/packages/squad-sdk/src/sharing/import.ts +++ b/packages/squad-sdk/src/sharing/import.ts @@ -3,9 +3,10 @@ * Imports a Squad configuration bundle into a target project. */ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { join, dirname } from 'node:path'; +import { join } from 'node:path'; import type { ExportBundle, ExportRoutingRule } from './export.js'; +import type { StorageProvider } from '../storage/storage-provider.js'; +import { FSStorageProvider } from '../storage/fs-storage-provider.js'; export interface ImportOptions { merge?: boolean; @@ -88,6 +89,7 @@ export function importSquadConfig( bundlePath: string, targetDir: string, options?: ImportOptions, + storage: StorageProvider = new FSStorageProvider(), ): ImportResult { const opts: Required<ImportOptions> = { merge: options?.merge ?? true, @@ -99,13 +101,16 @@ export function importSquadConfig( const changes: ImportChange[] = []; // Read and parse bundle - if (!existsSync(bundlePath)) { + if (!storage.existsSync(bundlePath)) { return { success: false, changes: [], warnings: [`Bundle file not found: ${bundlePath}`] }; } let bundle: ExportBundle; try { - const content = readFileSync(bundlePath, 'utf-8'); + const content = storage.readSync(bundlePath); + if (content === undefined) { + return { success: false, changes: [], warnings: [`Bundle file not found: ${bundlePath}`] }; + } bundle = deserializeBundle(content); } catch (err) { return { success: false, changes: [], warnings: [`Failed to parse bundle: ${(err as Error).message}`] }; @@ -129,20 +134,19 @@ export function importSquadConfig( const agentPath = join(agentsDir, `${agent.name}.agent.md`); const relativePath = `.github/agents/${agent.name}.agent.md`; - if (existsSync(agentPath) && !opts.merge) { + if (storage.existsSync(agentPath) && !opts.merge) { changes.push({ type: 'skipped', path: relativePath, reason: 'File exists and merge is disabled' }); continue; } - if (existsSync(agentPath) && opts.merge) { + if (storage.existsSync(agentPath) && opts.merge) { if (!opts.dryRun) { - writeFileSync(agentPath, agent.content, 'utf-8'); + storage.writeSync(agentPath, agent.content); } changes.push({ type: 'modified', path: relativePath }); } else { if (!opts.dryRun) { - mkdirSync(dirname(agentPath), { recursive: true }); - writeFileSync(agentPath, agent.content, 'utf-8'); + storage.writeSync(agentPath, agent.content); } changes.push({ type: 'added', path: relativePath }); } @@ -154,14 +158,13 @@ export function importSquadConfig( const relativePath = '.ai-team/routing.md'; const routingContent = formatRoutingRules(bundle.routingRules); - if (existsSync(routingPath) && !opts.merge) { + if (storage.existsSync(routingPath) && !opts.merge) { changes.push({ type: 'skipped', path: relativePath, reason: 'File exists and merge is disabled' }); } else { if (!opts.dryRun) { - mkdirSync(dirname(routingPath), { recursive: true }); - writeFileSync(routingPath, routingContent, 'utf-8'); + storage.writeSync(routingPath, routingContent); } - changes.push({ type: existsSync(routingPath) ? 'modified' : 'added', path: relativePath }); + changes.push({ type: storage.existsSync(routingPath) ? 'modified' : 'added', path: relativePath }); } } diff --git a/packages/squad-sdk/src/skills/skill-loader.ts b/packages/squad-sdk/src/skills/skill-loader.ts index e5ec26aac..4893cb816 100644 --- a/packages/squad-sdk/src/skills/skill-loader.ts +++ b/packages/squad-sdk/src/skills/skill-loader.ts @@ -16,8 +16,10 @@ * ``` */ -import * as fs from 'node:fs'; import * as path from 'node:path'; +import { readdirSync } from 'node:fs'; +import { FSStorageProvider } from '../storage/fs-storage-provider.js'; +import type { StorageProvider } from '../storage/storage-provider.js'; import { normalizeEol } from '../utils/normalize-eol.js'; // --- Types --- @@ -87,26 +89,28 @@ export function parseFrontmatter( * * Malformed or missing files are silently skipped. */ -export function loadSkillsFromDirectory(dir: string): SkillDefinition[] { - if (!fs.existsSync(dir)) return []; +export function loadSkillsFromDirectory( + dir: string, + storage: StorageProvider = new FSStorageProvider(), +): SkillDefinition[] { + if (!storage.existsSync(dir)) return []; const skills: SkillDefinition[] = []; - let entries: fs.Dirent[]; + let entries: string[]; try { - entries = fs.readdirSync(dir, { withFileTypes: true }); + // TODO: readdirSync with withFileTypes needed — listSync() exists but lacks Dirent support (#481) + entries = readdirSync(dir, { withFileTypes: true }).filter(d => d.isDirectory()).map(d => d.name); } catch { return []; } for (const entry of entries) { - if (!entry.isDirectory()) continue; - - const skillFile = path.join(dir, entry.name, 'SKILL.md'); - if (!fs.existsSync(skillFile)) continue; + const skillFile = path.join(dir, entry, 'SKILL.md'); try { - const raw = fs.readFileSync(skillFile, 'utf-8'); - const skill = parseSkillFile(entry.name, raw); + const raw = storage.readSync(skillFile); + if (raw === undefined) continue; + const skill = parseSkillFile(entry, raw); if (skill) skills.push(skill); } catch { // Malformed file — skip gracefully diff --git a/packages/squad-sdk/src/skills/skill-script-loader.ts b/packages/squad-sdk/src/skills/skill-script-loader.ts index 6a8a683f5..6adbdbc1c 100644 --- a/packages/squad-sdk/src/skills/skill-script-loader.ts +++ b/packages/squad-sdk/src/skills/skill-script-loader.ts @@ -12,10 +12,12 @@ * - Partial implementations (missing handlers are silently skipped) */ -import { existsSync, readdirSync, realpathSync } from 'node:fs'; +import { realpathSync } from 'node:fs'; import * as path from 'node:path'; import { pathToFileURL } from 'node:url'; import { trace, SpanStatusCode } from '../runtime/otel-api.js'; +import type { StorageProvider } from '../storage/storage-provider.js'; +import { FSStorageProvider } from '../storage/fs-storage-provider.js'; import type { LoadResult, SkillHandler, @@ -148,9 +150,14 @@ export function resolveSkillPath( // --- SkillScriptLoader --- export class SkillScriptLoader { + private storage: StorageProvider; + constructor( private getToolSchema: (toolName: string) => { description: string; parameters: Record<string, unknown> } | undefined, - ) {} + storage: StorageProvider = new FSStorageProvider(), + ) { + this.storage = storage; + } /** * Load handler scripts from a backend skill directory by scanning `scripts/` for `.js` files. @@ -183,12 +190,13 @@ export class SkillScriptLoader { ): Promise<LoadResult | null> { // 1. Check for scripts/ directory const scriptsDir = path.join(skillPath, 'scripts'); - if (!existsSync(scriptsDir)) { + if (!(await this.storage.exists(scriptsDir))) { return null; // Triggers markdown fallback } // 2. Scan scripts/ for handler files — everything except lifecycle.js - const scriptFiles = readdirSync(scriptsDir).filter( + const allFiles = await this.storage.list(scriptsDir); + const scriptFiles = allFiles.filter( (f) => f.endsWith('.js') && f !== 'lifecycle.js', ); @@ -236,7 +244,7 @@ export class SkillScriptLoader { // 4. Load lifecycle.js if present let lifecycle: HandlerLifecycle | undefined; const lifecyclePath = path.join(scriptsDir, 'lifecycle.js'); - if (existsSync(lifecyclePath)) { + if (await this.storage.exists(lifecyclePath)) { try { const lifecycleUrl = toFileUrl(lifecyclePath); const lifecycleModule = await import(lifecycleUrl); diff --git a/packages/squad-sdk/src/skills/skill-source.ts b/packages/squad-sdk/src/skills/skill-source.ts index 6cfdc196f..59f004a61 100644 --- a/packages/squad-sdk/src/skills/skill-source.ts +++ b/packages/squad-sdk/src/skills/skill-source.ts @@ -4,10 +4,11 @@ * Pluggable skill discovery from local filesystem or GitHub repos. */ -import * as fs from 'node:fs'; import * as path from 'node:path'; import { parseFrontmatter, type SkillDefinition } from './skill-loader.js'; import type { GitHubFetcher } from '../config/agent-source.js'; +import type { StorageProvider } from '../storage/storage-provider.js'; +import { FSStorageProvider } from '../storage/fs-storage-provider.js'; // --- Interface --- @@ -33,38 +34,39 @@ export class LocalSkillSource implements SkillSource { readonly name = 'local'; readonly type = 'local' as const; readonly priority: number; + private storage: StorageProvider; - constructor(private basePath: string, priority = 0) { + constructor(private basePath: string, priority = 0, storage: StorageProvider = new FSStorageProvider()) { this.priority = priority; + this.storage = storage; } private get skillsDir(): string { const copilotDir = path.join(this.basePath, '.copilot', 'skills'); - if (fs.existsSync(copilotDir)) return copilotDir; + if (this.storage.existsSync(copilotDir)) return copilotDir; // Backward compat: fall back to legacy location return path.join(this.basePath, '.squad', 'skills'); } async listSkills(): Promise<SkillManifest[]> { - if (!fs.existsSync(this.skillsDir)) return []; - let entries: fs.Dirent[]; + const dir = this.skillsDir; + let entries: string[]; try { - entries = fs.readdirSync(this.skillsDir, { withFileTypes: true }); + entries = await this.storage.list(dir); } catch { return []; } const manifests: SkillManifest[] = []; - for (const entry of entries) { - if (!entry.isDirectory()) continue; - const skillFile = path.join(this.skillsDir, entry.name, 'SKILL.md'); - if (!fs.existsSync(skillFile)) continue; + for (const entryName of entries) { + const skillFile = path.join(dir, entryName, 'SKILL.md'); try { - const raw = fs.readFileSync(skillFile, 'utf-8'); + const raw = await this.storage.read(skillFile); + if (!raw) continue; const { meta } = parseFrontmatter(raw); manifests.push({ - id: entry.name, - name: typeof meta.name === 'string' ? meta.name : entry.name, + id: entryName, + name: typeof meta.name === 'string' ? meta.name : entryName, domain: typeof meta.domain === 'string' ? meta.domain : 'general', source: 'local', }); @@ -77,9 +79,9 @@ export class LocalSkillSource implements SkillSource { async getSkill(id: string): Promise<SkillDefinition | null> { const skillFile = path.join(this.skillsDir, id, 'SKILL.md'); - if (!fs.existsSync(skillFile)) return null; try { - const raw = fs.readFileSync(skillFile, 'utf-8'); + const raw = await this.storage.read(skillFile); + if (!raw) return null; const { meta, body } = parseFrontmatter(raw); if (!body) return null; return { @@ -97,9 +99,9 @@ export class LocalSkillSource implements SkillSource { async getContent(id: string): Promise<string | null> { const skillFile = path.join(this.skillsDir, id, 'SKILL.md'); - if (!fs.existsSync(skillFile)) return null; try { - const raw = fs.readFileSync(skillFile, 'utf-8'); + const raw = await this.storage.read(skillFile); + if (!raw) return null; const { body } = parseFrontmatter(raw); return body || null; } catch { diff --git a/packages/squad-sdk/src/state/collection-map.ts b/packages/squad-sdk/src/state/collection-map.ts new file mode 100644 index 000000000..fc01ec92c --- /dev/null +++ b/packages/squad-sdk/src/state/collection-map.ts @@ -0,0 +1,60 @@ +/** + * State Module — Collection Entity Map + * + * Compiler-enforced registry that maps collection names to their + * domain entity types. Any `SquadState.get<C>(collection)` call + * is type-checked against this map at compile time. + */ + +import type { + Agent, + Decision, + HistoryEntry, + HistorySection, + LogEntry, + RoutingConfig, + SquadStateConfig, + TeamConfig, + Template, +} from './domain-types.js'; +import type { SkillDefinition } from '../skills/skill-loader.js'; + +/** Compiler-enforced mapping from collection name → entity type. */ +export interface CollectionEntityMap { + agents: Agent; + decisions: Decision; + routing: RoutingConfig; + team: TeamConfig; + skills: SkillDefinition; + templates: Template; + log: LogEntry; + config: SquadStateConfig; +} + +/** Union of all valid collection names. */ +export type CollectionName = keyof CollectionEntityMap; + +/** + * Ergonomic handle for interacting with a single agent's state. + * + * Returned by `SquadState.agent(name)`. Provides scoped access to + * the agent's charter, history, and mutable properties without + * requiring the caller to know file paths. + */ +export interface AgentHandle { + /** Agent name (immutable). */ + readonly name: string; + + /** Read the full charter markdown. */ + charter(): Promise<string>; + + /** Read all history entries, or entries for a specific section. */ + history(): Promise<HistoryEntry[]>; + history(section: HistorySection): Promise<HistoryEntry[]>; + + /** Append a new entry to a history section. */ + appendHistory(section: HistorySection, entry: HistoryEntry): Promise<void>; + + /** Apply a partial update to the agent entity. */ + update(updates: Partial<Agent>): Promise<void>; +} diff --git a/packages/squad-sdk/src/state/collections.ts b/packages/squad-sdk/src/state/collections.ts new file mode 100644 index 000000000..818aa9391 --- /dev/null +++ b/packages/squad-sdk/src/state/collections.ts @@ -0,0 +1,369 @@ +/** + * State Module — Typed Collection Facades + * + * Each class provides a typed, ergonomic interface over a specific + * `.squad/` collection, backed by StorageProvider + IO layer. + * + * @module state/collections + */ + +import type { StorageProvider } from '../storage/storage-provider.js'; +import type { AgentHandle } from './collection-map.js'; +import type { + Decision, + RoutingConfig, + RoutingConfigRule, + TeamConfig, + TeamMember, +} from './domain-types.js'; +import type { SkillDefinition } from '../skills/skill-loader.js'; +import { NotFoundError, ParseError } from './domain-types.js'; +import { resolveCollectionPath } from './schema.js'; +import { createAgentHandle } from './handles.js'; +import { parseDecisions, serializeDecision, serializeDecisions } from './io/decisions-io.js'; +import { parseRouting, serializeRouting, type ParsedRouting } from './io/routing-io.js'; +import { parseTeam, serializeTeam, type ParsedTeam } from './io/team-io.js'; +import { parseSkillFile } from '../skills/skill-loader.js'; +import type { ParsedDecision } from './io/decisions-io.js'; +import type { ParsedRoutingRule } from './io/routing-io.js'; +import type { ParsedAgent } from './io/team-io.js'; + +// ── AgentsCollection ─────────────────────────────────────────────────────── + +export class AgentsCollection { + constructor( + private readonly storage: StorageProvider, + private readonly rootDir: string, + ) {} + + /** Return a handle for interacting with a specific agent's state. */ + get(name: string): AgentHandle { + return createAgentHandle(name, this.storage, this.rootDir); + } + + /** List agent names from `.squad/agents/`. */ + async list(): Promise<string[]> { + const agentsDir = `${this.rootDir}/.squad/agents`; + return this.storage.list(agentsDir); + } + + /** Create a new agent with charter and empty history. */ + async create(name: string, charter: string): Promise<void> { + const agentDir = `${this.rootDir}/.squad/agents/${name}`; + await this.storage.write(`${agentDir}/charter.md`, charter); + await this.storage.write(`${agentDir}/history.md`, `# ${name}\n\n## Learnings\n\n## Context\n`); + } + + /** Soft-delete an agent by removing its directory. */ + async delete(name: string): Promise<void> { + const agentDir = `${this.rootDir}/.squad/agents/${name}`; + const exists = await this.storage.exists(agentDir); + if (!exists) { + throw new NotFoundError('agents', name); + } + await this.storage.deleteDir(agentDir); + } +} + +// ── DecisionsCollection ──────────────────────────────────────────────────── + +/** Map a ParsedDecision to the domain Decision type. */ +function toDomainDecision(pd: ParsedDecision): Decision { + return { + date: pd.date ?? '', + title: pd.title, + author: pd.author ?? '', + body: pd.body, + configRelevant: pd.configRelevant, + }; +} + +/** Map a domain Decision to ParsedDecision for serialization. */ +function toParsedDecision(d: Decision): ParsedDecision { + return { + title: d.title, + body: d.body, + configRelevant: d.configRelevant, + date: d.date || undefined, + author: d.author || undefined, + }; +} + +export class DecisionsCollection { + constructor( + private readonly storage: StorageProvider, + private readonly rootDir: string, + ) {} + + /** Parse and return all decisions from `decisions.md`. */ + async list(): Promise<Decision[]> { + const filePath = `${this.rootDir}/${resolveCollectionPath('decisions')}`; + const content = await this.storage.read(filePath); + if (content === undefined) { + return []; + } + try { + return parseDecisions(content).map(toDomainDecision); + } catch (err) { + throw new ParseError('decisions', err instanceof Error ? err.message : String(err), { cause: err }); + } + } + + /** Append a new decision. Date is auto-generated if not provided. */ + async add(decision: Omit<Decision, 'date'>): Promise<void> { + const filePath = `${this.rootDir}/${resolveCollectionPath('decisions')}`; + const date = new Date().toISOString().split('T')[0]!; + const full: Decision = { ...decision, date }; + const parsed = toParsedDecision(full); + + const existing = await this.storage.read(filePath); + if (existing === undefined || existing.trim().length === 0) { + await this.storage.write(filePath, serializeDecisions([parsed])); + } else { + const fragment = '\n\n' + serializeDecision(parsed) + '\n'; + await this.storage.append(filePath, fragment); + } + } +} + +// ── RoutingCollection ────────────────────────────────────────────────────── + +/** Map ParsedRouting to RoutingConfig. */ +function toRoutingConfig(parsed: ParsedRouting): RoutingConfig { + return { + rules: parsed.rules.map( + (r): RoutingConfigRule => ({ + workType: r.workType, + agents: r.agents, + examples: r.examples ?? [], + }), + ), + moduleOwnership: parsed.moduleOwnership, + principles: [], + }; +} + +/** Map RoutingConfig rules back to ParsedRoutingRule[]. */ +function toParsedRoutingRules(config: RoutingConfig): ParsedRoutingRule[] { + return config.rules.map( + (r): ParsedRoutingRule => ({ + workType: r.workType, + agents: [...r.agents], + examples: [...r.examples], + }), + ); +} + +export class RoutingCollection { + constructor( + private readonly storage: StorageProvider, + private readonly rootDir: string, + ) {} + + /** Parse and return the routing configuration. */ + async get(): Promise<RoutingConfig> { + const filePath = `${this.rootDir}/${resolveCollectionPath('routing')}`; + const content = await this.storage.read(filePath); + if (content === undefined) { + throw new NotFoundError('routing'); + } + try { + return toRoutingConfig(parseRouting(content)); + } catch (err) { + throw new ParseError('routing', err instanceof Error ? err.message : String(err), { cause: err }); + } + } + + /** Write back a full routing configuration. */ + async update(config: RoutingConfig): Promise<void> { + const filePath = `${this.rootDir}/${resolveCollectionPath('routing')}`; + const rules = toParsedRoutingRules(config); + await this.storage.write(filePath, serializeRouting(rules)); + } +} + +// ── TeamCollection ───────────────────────────────────────────────────────── + +/** Map ParsedTeam to TeamConfig. */ +function toTeamConfig(parsed: ParsedTeam): TeamConfig { + return { + projectContext: parsed.projectContext, + members: parsed.agents.map( + (a): TeamMember => ({ + name: a.name, + role: a.role, + status: a.status, + }), + ), + }; +} + +/** Map TeamConfig members back to ParsedAgent[]. */ +function toParsedAgents(config: TeamConfig): ParsedAgent[] { + return config.members.map( + (m): ParsedAgent => ({ + name: m.name, + role: m.role, + skills: [], + status: m.status, + }), + ); +} + +export class TeamCollection { + constructor( + private readonly storage: StorageProvider, + private readonly rootDir: string, + ) {} + + /** Parse and return the team configuration. */ + async get(): Promise<TeamConfig> { + const filePath = `${this.rootDir}/${resolveCollectionPath('team')}`; + const content = await this.storage.read(filePath); + if (content === undefined) { + throw new NotFoundError('team'); + } + try { + return toTeamConfig(parseTeam(content)); + } catch (err) { + throw new ParseError('team', err instanceof Error ? err.message : String(err), { cause: err }); + } + } + + /** Write back a full team configuration. */ + async update(config: TeamConfig): Promise<void> { + const filePath = `${this.rootDir}/${resolveCollectionPath('team')}`; + const agents = toParsedAgents(config); + await this.storage.write(filePath, serializeTeam(agents)); + } +} + +// ── SkillsCollection ────────────────────────────────────────────────────── + +export class SkillsCollection { + constructor( + private readonly storage: StorageProvider, + private readonly rootDir: string, + ) {} + + /** List all skill IDs (directory names under .squad/skills/). */ + async list(): Promise<string[]> { + const skillsDir = `${this.rootDir}/.squad/skills`; + return this.storage.list(skillsDir); + } + + /** Get a skill definition by ID. Returns undefined if not found or unparseable. */ + async get(id: string): Promise<SkillDefinition | undefined> { + const skillFile = `${this.rootDir}/${resolveCollectionPath('skills', id)}/SKILL.md`; + const content = await this.storage.read(skillFile); + if (content === undefined) return undefined; + return parseSkillFile(id, content); + } + + /** Check if a skill exists. */ + async exists(id: string): Promise<boolean> { + const skillFile = `${this.rootDir}/${resolveCollectionPath('skills', id)}/SKILL.md`; + return this.storage.exists(skillFile); + } +} + +// ── TemplatesCollection ─────────────────────────────────────────────────── + +export class TemplatesCollection { + constructor( + private readonly storage: StorageProvider, + private readonly rootDir: string, + ) {} + + /** List template filenames under .squad/templates/. */ + async list(): Promise<string[]> { + const templatesDir = `${this.rootDir}/.squad/templates`; + return this.storage.list(templatesDir); + } + + /** Get raw template content by ID. Returns undefined if not found. */ + async get(id: string): Promise<string | undefined> { + const filePath = `${this.rootDir}/${resolveCollectionPath('templates', id)}`; + return this.storage.read(filePath); + } + + /** Check if a template exists. */ + async exists(id: string): Promise<boolean> { + const filePath = `${this.rootDir}/${resolveCollectionPath('templates', id)}`; + return this.storage.exists(filePath); + } +} + +// ── ConfigCollection ────────────────────────────────────────────────────── + +/** Serializable subset of config stored in `.squad/config.json`. */ +export interface ConfigFileData { + cacheEnabled?: boolean; + cacheTtlMs?: number; +} + +const DEFAULT_CONFIG: Required<ConfigFileData> = { + cacheEnabled: false, + cacheTtlMs: 300_000, +}; + +export class ConfigCollection { + constructor( + private readonly storage: StorageProvider, + private readonly rootDir: string, + ) {} + + /** Read and parse `.squad/config.json`. Returns defaults when file is missing or invalid. */ + async get(): Promise<ConfigFileData> { + const filePath = `${this.rootDir}/${resolveCollectionPath('config')}`; + const content = await this.storage.read(filePath); + if (content === undefined) { + return { ...DEFAULT_CONFIG }; + } + try { + const parsed = JSON.parse(content) as ConfigFileData; + return { ...DEFAULT_CONFIG, ...parsed }; + } catch { + return { ...DEFAULT_CONFIG }; + } + } + + /** Write config to `.squad/config.json`. */ + async update(config: ConfigFileData): Promise<void> { + const filePath = `${this.rootDir}/${resolveCollectionPath('config')}`; + await this.storage.write(filePath, JSON.stringify(config, null, 2) + '\n'); + } + + /** Check if `.squad/config.json` exists. */ + async exists(): Promise<boolean> { + const filePath = `${this.rootDir}/${resolveCollectionPath('config')}`; + return this.storage.exists(filePath); + } +} + +// ── LogCollection ───────────────────────────────────────────────────────── + +export class LogCollection { + constructor( + private readonly storage: StorageProvider, + private readonly rootDir: string, + ) {} + + /** List log entry filenames under .squad/log/. */ + async list(): Promise<string[]> { + const logDir = `${this.rootDir}/${resolveCollectionPath('log')}`; + return this.storage.list(logDir); + } + + /** Read a specific log entry. Returns undefined if not found. */ + async get(id: string): Promise<string | undefined> { + const filePath = `${this.rootDir}/${resolveCollectionPath('log')}/${id}`; + return this.storage.read(filePath); + } + + /** Write a new log entry. */ + async write(id: string, content: string): Promise<void> { + const filePath = `${this.rootDir}/${resolveCollectionPath('log')}/${id}`; + await this.storage.write(filePath, content); + } +} diff --git a/packages/squad-sdk/src/state/domain-types.ts b/packages/squad-sdk/src/state/domain-types.ts new file mode 100644 index 000000000..52463f30f --- /dev/null +++ b/packages/squad-sdk/src/state/domain-types.ts @@ -0,0 +1,159 @@ +/** + * State Module — Canonical Domain Types + * + * Phase 2 of StorageProvider PRD (#481). + * Types that already exist elsewhere are re-exported here as the + * canonical import point for the state layer. New domain types are + * defined inline. + */ + +export type { AgentStatus } from '../agents/lifecycle.js'; +export type { HistorySection } from '../agents/history-shadow.js'; +export type { ModelTier, WorkType, RoutingRule } from '../runtime/config.js'; +export type { SkillDefinition } from '../skills/skill-loader.js'; +export type { StorageProvider } from '../storage/storage-provider.js'; + +import type { AgentStatus } from '../agents/lifecycle.js'; +import type { HistorySection } from '../agents/history-shadow.js'; +import type { ModelTier } from '../runtime/config.js'; +import type { StorageProvider } from '../storage/storage-provider.js'; + +/** Full agent entity persisted under `.squad/agents/<name>/`. */ +export interface Agent { + readonly name: string; + readonly role: string; + readonly emoji?: string; + readonly status: AgentStatus; + readonly charterPath: string; + readonly modelPreference?: ModelTier; + readonly modelFallback?: string; + readonly skills: readonly string[]; + readonly aliases: readonly string[]; + readonly autoAssign: boolean; +} + +/** Structured decision entry parsed from `decisions.md`. */ +export interface Decision { + readonly date: string; + readonly title: string; + readonly author: string; + readonly body: string; + readonly configRelevant: boolean; +} + +/** Timestamped history entry within an agent's `history.md`. */ +export interface HistoryEntry { + readonly section: HistorySection; + readonly content: string; + readonly timestamp: string; +} + +/** Parsed `team.md` structure. */ +export interface TeamConfig { + readonly projectContext: string; + readonly members: readonly TeamMember[]; +} + +/** A single row from the team.md members table. */ +export interface TeamMember { + readonly name: string; + readonly role: string; + readonly emoji?: string; + readonly status?: string; +} + +/** Parsed `routing.md` structure. */ +export interface RoutingConfig { + readonly rules: readonly RoutingConfigRule[]; + readonly moduleOwnership: ReadonlyMap<string, string>; + readonly principles: readonly string[]; +} + +/** A single routing rule within RoutingConfig. */ +export interface RoutingConfigRule { + readonly workType: string; + readonly agents: readonly string[]; + readonly examples: readonly string[]; +} + +/** Template file content from `.squad/templates/`. */ +export type Template = { + readonly id: string; + readonly name: string; + readonly content: string; +}; + +/** Log entry for orchestration / session logging. */ +export interface LogEntry { + readonly timestamp: string; + readonly level: 'info' | 'warn' | 'error' | 'debug'; + readonly message: string; + readonly agent?: string; + readonly metadata?: Readonly<Record<string, unknown>>; +} + +/** Options for SquadState initialization. */ +export interface SquadStateConfig { + readonly provider: StorageProvider; + readonly rootDir: string; + readonly cacheEnabled?: boolean; + readonly cacheTtlMs?: number; +} + +/** + * Discriminant for state-layer storage errors. + * Distinct from the low-level `StorageError` in `storage/storage-error.ts` + * which wraps Node.js `ErrnoException`. + */ +export type StateErrorKind = 'not-found' | 'parse-error' | 'write-conflict' | 'provider-error'; + +/** + * Base error class for the state layer. + * + * Named `StateError` (not `StorageError`) to avoid collision with the + * low-level FS error wrapper in `storage/storage-error.ts`. Uses a + * `kind` discriminant so callers can switch on error type. + */ +export class StateError extends Error { + readonly kind: StateErrorKind; + + constructor(kind: StateErrorKind, message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'StateError'; + this.kind = kind; + } +} + +/** Thrown when a requested entity or path does not exist. */ +export class NotFoundError extends StateError { + constructor(collection: string, id?: string, options?: ErrorOptions) { + const target = id ? `${collection}/${id}` : collection; + super('not-found', `Not found: ${target}`, options); + this.name = 'NotFoundError'; + } +} + +/** Thrown when file content cannot be parsed into the expected type. */ +export class ParseError extends StateError { + constructor(collection: string, detail: string, options?: ErrorOptions) { + super('parse-error', `Parse error in ${collection}: ${detail}`, options); + this.name = 'ParseError'; + } +} + +/** Thrown on concurrent write conflicts (future optimistic locking). */ +export class WriteConflictError extends StateError { + constructor(collection: string, id?: string, options?: ErrorOptions) { + const target = id ? `${collection}/${id}` : collection; + super('write-conflict', `Write conflict: ${target}`, options); + this.name = 'WriteConflictError'; + } +} + +/** Thrown when the underlying StorageProvider operation fails. */ +export class ProviderError extends StateError { + constructor(operation: string, detail: string, options?: ErrorOptions) { + super('provider-error', `Provider ${operation} failed: ${detail}`, options); + this.name = 'ProviderError'; + } +} diff --git a/packages/squad-sdk/src/state/handles.ts b/packages/squad-sdk/src/state/handles.ts new file mode 100644 index 000000000..26bf352d9 --- /dev/null +++ b/packages/squad-sdk/src/state/handles.ts @@ -0,0 +1,195 @@ +/** + * State Module — Agent Handle Implementation + * + * Concrete implementation of the `AgentHandle` interface from collection-map.ts. + * Wires up to StorageProvider + IO layer for reading/writing agent state. + * + * @module state/handles + */ + +import type { StorageProvider } from '../storage/storage-provider.js'; +import type { AgentHandle } from './collection-map.js'; +import type { Agent, HistoryEntry, HistorySection } from './domain-types.js'; +import type { ParsedHistory } from '../agents/history-shadow.js'; +import { parseHistory, serializeHistoryAppend } from './io/history-io.js'; +import { parseTeam, serializeTeam } from './io/team-io.js'; +import { NotFoundError, ParseError } from './domain-types.js'; +import { resolveCollectionPath } from './schema.js'; + +// ── History Section Mapping ──────────────────────────────────────────────── + +const SECTION_KEYS: Array<{ section: HistorySection; key: keyof ParsedHistory }> = [ + { section: 'Context', key: 'context' }, + { section: 'Learnings', key: 'learnings' }, + { section: 'Decisions', key: 'decisions' }, + { section: 'Patterns', key: 'patterns' }, + { section: 'Issues', key: 'issues' }, + { section: 'References', key: 'references' }, +]; + +/** + * Convert a ParsedHistory into an array of HistoryEntry objects. + * + * Splits each section's content by `### date/title` sub-headers into + * individual entries with extracted timestamps. + */ +function parsedHistoryToEntries( + parsed: ParsedHistory, + filterSection?: HistorySection, +): HistoryEntry[] { + const entries: HistoryEntry[] = []; + const sections = filterSection + ? SECTION_KEYS.filter((s) => s.section === filterSection) + : SECTION_KEYS; + + for (const { section, key } of sections) { + const content = parsed[key] as string | undefined; + if (!content) continue; + + const subHeaderRegex = /^###\s+(.+)$/gm; + const subHeaders: Array<{ title: string; start: number; contentStart: number }> = []; + let m: RegExpExecArray | null; + while ((m = subHeaderRegex.exec(content)) !== null) { + subHeaders.push({ + title: m[1]!, + start: m.index, + contentStart: m.index + m[0].length, + }); + } + + if (subHeaders.length === 0) { + entries.push({ section, content: content.trim(), timestamp: '' }); + } else { + for (let i = 0; i < subHeaders.length; i++) { + const hdr = subHeaders[i]!; + const nextHdr = subHeaders[i + 1]; + const start = hdr.contentStart; + const end = nextHdr ? nextHdr.start : content.length; + const entryContent = content.substring(start, end).trim(); + const dateMatch = hdr.title.match(/(\d{4}-\d{2}-\d{2})/); + const timestamp = dateMatch?.[1] ?? ''; + entries.push({ section, content: entryContent, timestamp }); + } + } + } + + return entries; +} + +// ── Factory ──────────────────────────────────────────────────────────────── + +/** + * Create a concrete AgentHandle bound to a specific agent, storage provider, + * and root directory. + */ +export function createAgentHandle( + name: string, + storage: StorageProvider, + rootDir: string, +): AgentHandle { + const agentDir = `${rootDir}/.squad/agents/${name}`; + const charterPath = `${agentDir}/charter.md`; + const historyPath = `${agentDir}/history.md`; + const teamPath = `${rootDir}/${resolveCollectionPath('team')}`; + + const handle: AgentHandle = { + name, + + async charter(): Promise<string> { + const content = await storage.read(charterPath); + if (content === undefined) { + throw new NotFoundError('agents', name); + } + return content; + }, + + history(section?: HistorySection): Promise<HistoryEntry[]> { + return (async () => { + const content = await storage.read(historyPath); + if (content === undefined) { + return []; + } + let parsed; + try { + parsed = parseHistory(content); + } catch (err) { + throw new ParseError('history', err instanceof Error ? err.message : String(err), { cause: err }); + } + return parsedHistoryToEntries(parsed, section); + })(); + }, + + async appendHistory(section: HistorySection, entry: HistoryEntry): Promise<void> { + const content = await storage.read(historyPath); + const fragment = serializeHistoryAppend( + section, + entry.content, + entry.timestamp || undefined, + ); + + if (content === undefined) { + const newContent = `# ${name}\n\n## ${section}\n\n${fragment}`; + await storage.write(historyPath, newContent); + return; + } + + const sectionRegex = new RegExp(`^## ${section}\\s*$`, 'm'); + const sectionMatch = sectionRegex.exec(content); + + if (sectionMatch) { + // Find next ## header after this section to determine insertion point + const afterHeader = sectionMatch.index + sectionMatch[0].length; + const rest = content.substring(afterHeader); + const nextSectionMatch = /^## /m.exec(rest.length > 1 ? rest.substring(1) : ''); + + const insertPos = nextSectionMatch + ? afterHeader + 1 + nextSectionMatch.index + : content.length; + + const before = content.substring(0, insertPos).trimEnd(); + const after = content.substring(insertPos); + const newContent = + before + '\n\n' + fragment + (after.trim() ? '\n' + after.trimStart() : ''); + await storage.write(historyPath, newContent.trimEnd() + '\n'); + } else { + // Section doesn't exist yet — append at end + const newContent = content.trimEnd() + `\n\n## ${section}\n\n${fragment}`; + await storage.write(historyPath, newContent.trimEnd() + '\n'); + } + }, + + async update(updates: Partial<Agent>): Promise<void> { + const teamContent = await storage.read(teamPath); + if (teamContent === undefined) { + throw new NotFoundError('team'); + } + + let agents; + try { + agents = parseTeam(teamContent).agents; + } catch (err) { + throw new ParseError('team', err instanceof Error ? err.message : String(err), { cause: err }); + } + const lowerName = name.toLowerCase(); + const idx = agents.findIndex((a) => a.name.toLowerCase() === lowerName); + if (idx === -1) { + throw new NotFoundError('agents', name); + } + + const current = agents[idx]!; + const updated = { + name: updates.name ?? current.name, + role: updates.role ?? current.role, + skills: updates.skills ? [...updates.skills] : current.skills, + model: updates.modelPreference ?? current.model, + status: updates.status !== undefined ? String(updates.status) : current.status, + }; + agents[idx] = updated; + + const serialized = serializeTeam(agents); + await storage.write(teamPath, serialized); + }, + }; + + return handle; +} diff --git a/packages/squad-sdk/src/state/index.ts b/packages/squad-sdk/src/state/index.ts new file mode 100644 index 000000000..059603227 --- /dev/null +++ b/packages/squad-sdk/src/state/index.ts @@ -0,0 +1,68 @@ +/** + * State Module — Barrel Export + * + * Phase 2 of StorageProvider PRD (#481). + * Typed facade layer over `.squad/` file-based state. + */ + +// Domain types (new + re-exported canonical types) +export type { + Agent, + Decision, + HistoryEntry, + HistorySection, + LogEntry, + ModelTier, + RoutingConfig, + RoutingConfigRule, + RoutingRule, + SkillDefinition, + SquadStateConfig, + StateErrorKind, + StorageProvider, + TeamConfig, + TeamMember, + Template, + WorkType, +} from './domain-types.js'; + +export type { AgentStatus } from './domain-types.js'; + +export { + StateError, + NotFoundError, + ParseError, + WriteConflictError, + ProviderError, +} from './domain-types.js'; + +// Collection map +export type { + CollectionEntityMap, + CollectionName, + AgentHandle, +} from './collection-map.js'; + +// Schema / path resolution +export type { CollectionPathResolver } from './schema.js'; +export { COLLECTION_PATHS, resolveCollectionPath } from './schema.js'; + +// Agent handle factory +export { createAgentHandle } from './handles.js'; + +// Collection facades +export { + AgentsCollection, + ConfigCollection, + DecisionsCollection, + LogCollection, + RoutingCollection, + SkillsCollection, + TeamCollection, + TemplatesCollection, +} from './collections.js'; + +export type { ConfigFileData } from './collections.js'; + +// SquadState facade +export { SquadState } from './squad-state.js'; diff --git a/packages/squad-sdk/src/state/io/charter-io.ts b/packages/squad-sdk/src/state/io/charter-io.ts new file mode 100644 index 000000000..82a14566d --- /dev/null +++ b/packages/squad-sdk/src/state/io/charter-io.ts @@ -0,0 +1,106 @@ +/** + * Charter Markdown I/O — parse and serialize charter.md files. + * + * Wraps the existing `parseCharterMarkdown()` from charter-compiler.ts + * and adds serialization for round-trip support. + * + * @module state/io/charter-io + */ + +import { parseCharterMarkdown, type ParsedCharter } from '../../agents/charter-compiler.js'; + +export type { ParsedCharter }; + +/** + * Parse charter markdown into a typed structure. + * Delegates to the existing `parseCharterMarkdown()`. + */ +export function parseCharter(markdown: string): ParsedCharter { + return parseCharterMarkdown(markdown); +} + +/** + * Serialize a ParsedCharter back to markdown. + * + * Produces the canonical charter.md format: + * ``` + * # {Name} — {Role} + * > {style quote} + * ## Identity + * ... + * ``` + */ +export function serializeCharter(charter: ParsedCharter): string { + const lines: string[] = []; + + // Title line + const name = charter.identity.name ?? 'Agent'; + const role = charter.identity.role ?? 'Developer'; + lines.push(`# ${name} — ${role}`); + lines.push(''); + + // Style quote + if (charter.identity.style) { + lines.push(`> ${charter.identity.style}`); + lines.push(''); + } + + // Identity section + lines.push('## Identity'); + lines.push(''); + if (charter.identity.name) { + lines.push(`- **Name:** ${charter.identity.name}`); + } + if (charter.identity.role) { + lines.push(`- **Role:** ${charter.identity.role}`); + } + if (charter.identity.expertise && charter.identity.expertise.length > 0) { + lines.push(`- **Expertise:** ${charter.identity.expertise.join(', ')}`); + } + if (charter.identity.style) { + lines.push(`- **Style:** ${charter.identity.style}`); + } + lines.push(''); + + // What I Own section + if (charter.ownership !== undefined) { + lines.push('## What I Own'); + lines.push(''); + lines.push(charter.ownership); + lines.push(''); + } + + // Boundaries section + if (charter.boundaries !== undefined) { + lines.push('## Boundaries'); + lines.push(''); + lines.push(charter.boundaries); + lines.push(''); + } + + // Model section + if (charter.modelPreference || charter.modelRationale || charter.modelFallback) { + lines.push('## Model'); + lines.push(''); + if (charter.modelPreference) { + lines.push(`**Preferred:** ${charter.modelPreference}`); + } + if (charter.modelRationale) { + lines.push(`**Rationale:** ${charter.modelRationale}`); + } + if (charter.modelFallback) { + lines.push(`**Fallback:** ${charter.modelFallback}`); + } + lines.push(''); + } + + // Collaboration section + if (charter.collaboration !== undefined) { + lines.push('## Collaboration'); + lines.push(''); + lines.push(charter.collaboration); + lines.push(''); + } + + return lines.join('\n').trimEnd() + '\n'; +} diff --git a/packages/squad-sdk/src/state/io/decisions-io.ts b/packages/squad-sdk/src/state/io/decisions-io.ts new file mode 100644 index 000000000..bd3447e93 --- /dev/null +++ b/packages/squad-sdk/src/state/io/decisions-io.ts @@ -0,0 +1,74 @@ +/** + * Decisions Markdown I/O — parse and serialize decisions.md files. + * + * Wraps the existing `parseDecisionsMarkdown()` from markdown-migration.ts + * and adds serialization for round-trip support. + * + * @module state/io/decisions-io + */ + +import { parseDecisionsMarkdown, type ParsedDecision } from '../../config/markdown-migration.js'; + +export type { ParsedDecision }; + +/** + * Parse decisions markdown into typed decision entries. + * Delegates to the existing `parseDecisionsMarkdown()`. + */ +export function parseDecisions(markdown: string): ParsedDecision[] { + const { decisions } = parseDecisionsMarkdown(markdown); + return decisions; +} + +/** + * Serialize a single decision entry to markdown. + * + * Format: + * ``` + * ### YYYY-MM-DD: Title + * **By:** author + * body content + * ``` + */ +export function serializeDecision(decision: ParsedDecision): string { + const level = decision.headingLevel ?? 3; + const hashes = '#'.repeat(level); + + // Heading with optional date prefix + const datePrefix = decision.date ? `${decision.date}: ` : ''; + const lines: string[] = [`${hashes} ${datePrefix}${decision.title}`]; + + // Body — author line is already embedded in body from the parser, + // but if body doesn't contain **By:** and author is set, prepend it + const bodyHasAuthor = /\*\*By:\*\*/i.test(decision.body); + if (decision.author && !bodyHasAuthor) { + lines.push(`**By:** ${decision.author}`); + } + if (decision.body) { + lines.push(decision.body); + } + + return lines.join('\n'); +} + +/** + * Serialize an array of decisions to a full decisions.md file. + * + * Produces: + * ``` + * # Decisions + * + * ### 2026-01-15: First Decision + * ... + * + * ### 2026-02-01: Second Decision + * ... + * ``` + */ +export function serializeDecisions(decisions: ParsedDecision[]): string { + if (decisions.length === 0) { + return '# Decisions\n'; + } + const sections = decisions.map((d) => serializeDecision(d)); + return `# Decisions\n\n${sections.join('\n\n')}\n`; +} diff --git a/packages/squad-sdk/src/state/io/history-io.ts b/packages/squad-sdk/src/state/io/history-io.ts new file mode 100644 index 000000000..26b1c8268 --- /dev/null +++ b/packages/squad-sdk/src/state/io/history-io.ts @@ -0,0 +1,122 @@ +/** + * History Markdown I/O — parse and serialize history.md files. + * + * Wraps the existing section-parsing logic from history-shadow.ts + * and adds serialization for round-trip support. + * + * @module state/io/history-io + */ + +import type { ParsedHistory, HistorySection } from '../../agents/history-shadow.js'; +import { normalizeEol } from '../../utils/normalize-eol.js'; + +export type { ParsedHistory, HistorySection }; + +/** Standard sections in order of appearance. */ +const SECTION_ORDER: Array<{ header: HistorySection; key: keyof ParsedHistory }> = [ + { header: 'Context', key: 'context' }, + { header: 'Learnings', key: 'learnings' }, + { header: 'Decisions', key: 'decisions' }, + { header: 'Patterns', key: 'patterns' }, + { header: 'Issues', key: 'issues' }, + { header: 'References', key: 'references' }, +]; + +/** + * Parse history markdown into typed sections. + * + * Mirrors the section-extraction logic in `readHistory()` from + * history-shadow.ts but operates purely on a string (no filesystem). + * + * Note: the original regex uses `\Z` which is not a valid JS anchor. + * We use an explicit section-split approach to correctly handle the + * last section in the file. + */ +export function parseHistory(markdown: string): ParsedHistory { + const content = normalizeEol(markdown); + const parsed: ParsedHistory = { fullContent: content } as ParsedHistory; + + if (!content || content.trim().length === 0) { + return parsed; + } + + // Build a map of h2 section name → content by splitting at ## headers. + // Uses header start positions as boundaries (avoids the broken \Z anchor + // in the original readHistory regex). + const headerRegex = /^##\s+(.+?)\s*$/gm; + const headers: Array<{ name: string; start: number; contentStart: number }> = []; + let m: RegExpExecArray | null; + while ((m = headerRegex.exec(content)) !== null) { + headers.push({ name: m[1]!, start: m.index, contentStart: m.index + m[0].length }); + } + + const sectionMap = new Map<string, string>(); + for (let i = 0; i < headers.length; i++) { + const hdr = headers[i]!; + const nextHdr = headers[i + 1]; + const start = hdr.contentStart; + const end = nextHdr ? nextHdr.start : content.length; + const sectionContent = content.substring(start, end).trim(); + if (sectionContent.length > 0) { + sectionMap.set(hdr.name, sectionContent); + } + } + + for (const { header, key } of SECTION_ORDER) { + const value = sectionMap.get(header); + if (value !== undefined) { + (parsed as unknown as Record<string, unknown>)[key] = value; + } + } + + return parsed; +} + +/** + * Serialize a ParsedHistory back to markdown. + * + * Reconstructs the history.md with `# AgentName` header and `## Section` blocks. + */ +export function serializeHistory(history: ParsedHistory): string { + // If fullContent has a title line, extract it; otherwise use a generic header + const titleMatch = history.fullContent.match(/^#\s+(.+)$/m); + const lines: string[] = []; + + if (titleMatch) { + lines.push(titleMatch[0]); + lines.push(''); + } + + for (const { header, key } of SECTION_ORDER) { + const value = history[key] as string | undefined; + if (value !== undefined && value.length > 0) { + lines.push(`## ${header}`); + lines.push(''); + lines.push(value); + lines.push(''); + } + } + + if (lines.length === 0) { + return ''; + } + + return lines.join('\n').trimEnd() + '\n'; +} + +/** + * Produce a string fragment for appending a new entry to a history section. + * + * @param section - Target section name (e.g., 'Learnings') + * @param entry - Content to append (without date header) + * @param date - Optional ISO date string (defaults to today) + * @returns Formatted entry string including `### YYYY-MM-DD` sub-header + */ +export function serializeHistoryAppend( + section: HistorySection, + entry: string, + date?: string, +): string { + const dateStr = date ?? new Date().toISOString().split('T')[0]; + return `### ${dateStr}\n\n${entry}\n`; +} diff --git a/packages/squad-sdk/src/state/io/index.ts b/packages/squad-sdk/src/state/io/index.ts new file mode 100644 index 000000000..550adae6a --- /dev/null +++ b/packages/squad-sdk/src/state/io/index.ts @@ -0,0 +1,28 @@ +/** + * State I/O barrel — Markdown parsers and serializers for .squad/ domain files. + * + * Each module handles round-trip I/O for a single document type: + * parse(markdown) → typed object → serialize(object) → markdown. + * + * @module state/io + */ + +// Charter I/O +export { parseCharter, serializeCharter } from './charter-io.js'; +export type { ParsedCharter } from './charter-io.js'; + +// History I/O +export { parseHistory, serializeHistory, serializeHistoryAppend } from './history-io.js'; +export type { ParsedHistory, HistorySection } from './history-io.js'; + +// Decisions I/O +export { parseDecisions, serializeDecision, serializeDecisions } from './decisions-io.js'; +export type { ParsedDecision } from './decisions-io.js'; + +// Routing I/O +export { parseRouting, serializeRouting } from './routing-io.js'; +export type { ParsedRoutingRule, ParsedRouting } from './routing-io.js'; + +// Team I/O +export { parseTeam, serializeTeam } from './team-io.js'; +export type { ParsedAgent, TeamMetadata, ParsedTeam } from './team-io.js'; diff --git a/packages/squad-sdk/src/state/io/routing-io.ts b/packages/squad-sdk/src/state/io/routing-io.ts new file mode 100644 index 000000000..5d2852c24 --- /dev/null +++ b/packages/squad-sdk/src/state/io/routing-io.ts @@ -0,0 +1,118 @@ +/** + * Routing Markdown I/O — parse and serialize routing.md files. + * + * Wraps the existing `parseRoutingRulesMarkdown()` from markdown-migration.ts + * and adds serialization for round-trip support. + * + * @module state/io/routing-io + */ + +import { parseRoutingRulesMarkdown, type ParsedRoutingRule } from '../../config/markdown-migration.js'; +import { normalizeEol } from '../../utils/normalize-eol.js'; + +export type { ParsedRoutingRule }; + +/** Result of parsing a routing.md file. */ +export interface ParsedRouting { + rules: ParsedRoutingRule[]; + moduleOwnership: Map<string, string>; +} + +/** + * Parse a `## Module Ownership` table into a Map<module, owner>. + * + * Expected format: + * ```markdown + * ## Module Ownership + * + * | Module | Owner | + * |--------|-------| + * | src/storage/ | EECOM | + * ``` + */ +function parseModuleOwnership(markdown: string): Map<string, string> { + const map = new Map<string, string>(); + const lines = normalizeEol(markdown).split('\n'); + let inSection = false; + let headerPassed = false; + + for (const line of lines) { + const trimmed = line.trim(); + + if (/^##\s+module\s+ownership/i.test(trimmed)) { + inSection = true; + headerPassed = false; + continue; + } + + // Another ## heading ends the section + if (inSection && /^##\s+/.test(trimmed) && !/module\s+ownership/i.test(trimmed)) { + break; + } + + if (!inSection || !trimmed.startsWith('|')) continue; + + // Detect header row (contains "module" or "owner") + if (!headerPassed && /module|owner/i.test(trimmed)) { + headerPassed = true; + continue; + } + + // Skip separator row + if (/^[|:\-\s]+$/.test(trimmed)) continue; + + if (!headerPassed) continue; + + const cells = trimmed.split('|').map((c) => c.trim()).filter(Boolean); + if (cells.length >= 2 && cells[0] && cells[1]) { + map.set(cells[0], cells[1]); + } + } + + return map; +} + +/** + * Parse routing markdown into typed routing rules and module ownership. + * Delegates rule parsing to `parseRoutingRulesMarkdown()` and also + * extracts the optional `## Module Ownership` section. + */ +export function parseRouting(markdown: string): ParsedRouting { + const { rules } = parseRoutingRulesMarkdown(markdown); + const moduleOwnership = parseModuleOwnership(markdown); + return { rules, moduleOwnership }; +} + +/** + * Serialize routing rules to a full routing.md file. + * + * Produces: + * ``` + * # Routing Rules + * + * ## Routing Table + * + * | Work Type | Agent | Examples | + * |-----------|-------|----------| + * | feature-dev | Lead | New features | + * ``` + */ +export function serializeRouting(rules: ParsedRoutingRule[]): string { + const lines: string[] = [ + '# Routing Rules', + '', + '## Routing Table', + '', + '| Work Type | Agent | Examples |', + '|-----------|-------|----------|', + ]; + + for (const rule of rules) { + const agents = rule.agents.join(', '); + const examples = rule.examples ? rule.examples.join(', ') : ''; + lines.push(`| ${rule.workType} | ${agents} | ${examples} |`); + } + + lines.push(''); + return lines.join('\n'); +} diff --git a/packages/squad-sdk/src/state/io/team-io.ts b/packages/squad-sdk/src/state/io/team-io.ts new file mode 100644 index 000000000..075653681 --- /dev/null +++ b/packages/squad-sdk/src/state/io/team-io.ts @@ -0,0 +1,113 @@ +/** + * Team Markdown I/O — parse and serialize team.md files. + * + * Wraps the existing `parseTeamMarkdown()` from markdown-migration.ts + * and adds serialization for round-trip support. + * + * @module state/io/team-io + */ + +import { parseTeamMarkdown, type ParsedAgent } from '../../config/markdown-migration.js'; +import { normalizeEol } from '../../utils/normalize-eol.js'; + +export type { ParsedAgent }; + +/** + * Optional metadata for the team.md file header. + */ +export interface TeamMetadata { + /** Team name displayed in the title */ + teamName?: string; + /** Tagline shown below the title */ + tagline?: string; +} + +/** Result of parsing a team.md file. */ +export interface ParsedTeam { + agents: ParsedAgent[]; + projectContext: string; +} + +/** + * Extract project context: everything between the `# Title` line and + * the `## Members` (or first `##`) section, excluding the title and + * any blockquote tagline on the line immediately after the title. + */ +function extractProjectContext(markdown: string): string { + const lines = normalizeEol(markdown).split('\n'); + const contextLines: string[] = []; + let pastTitle = false; + + for (const line of lines) { + const trimmed = line.trim(); + + // Skip the title line + if (!pastTitle) { + if (/^#\s+/.test(trimmed)) { + pastTitle = true; + } + continue; + } + + // Stop at ## Members or any ## heading that signals the table section + if (/^##\s+members/i.test(trimmed)) { + break; + } + + contextLines.push(line); + } + + return contextLines.join('\n').trim(); +} + +/** + * Parse team markdown into typed agent entries and project context. + * Delegates agent parsing to `parseTeamMarkdown()` and also extracts + * the project context section above the members table. + */ +export function parseTeam(markdown: string): ParsedTeam { + const { agents } = parseTeamMarkdown(markdown); + const projectContext = extractProjectContext(markdown); + return { agents, projectContext }; +} + +/** + * Serialize agents to a full team.md file. + * + * Produces the canonical table format: + * ``` + * # Team Name + * + * ## Members + * + * | Name | Role | Charter | Status | + * |------|------|---------|--------| + * | Agent | Developer | `.squad/agents/agent/charter.md` | ✅ Active | + * ``` + */ +export function serializeTeam(agents: ParsedAgent[], metadata?: TeamMetadata): string { + const teamName = metadata?.teamName ?? 'Team'; + const lines: string[] = [`# ${teamName}`]; + + if (metadata?.tagline) { + lines.push(''); + lines.push(`> ${metadata.tagline}`); + } + + lines.push(''); + lines.push('## Members'); + lines.push(''); + lines.push('| Name | Role | Charter | Status |'); + lines.push('|------|------|---------|--------|'); + + for (const agent of agents) { + const name = agent.name; + const role = agent.role; + const charter = `.squad/agents/${agent.name}/charter.md`; + const status = agent.status ?? '✅ Active'; + lines.push(`| ${name} | ${role} | \`${charter}\` | ${status} |`); + } + + lines.push(''); + return lines.join('\n'); +} diff --git a/packages/squad-sdk/src/state/schema.ts b/packages/squad-sdk/src/state/schema.ts new file mode 100644 index 000000000..831546876 --- /dev/null +++ b/packages/squad-sdk/src/state/schema.ts @@ -0,0 +1,49 @@ +/** + * State Module — Storage Layout Schema + * + * Maps collection names to their `.squad/` file paths. + * Single-entity collections use a static string; multi-entity + * collections use a function that derives the path from an entity id. + */ + +import type { CollectionName } from './collection-map.js'; + +/** Either a static path or a function deriving a path from an entity id. */ +export type CollectionPathResolver = string | ((id: string) => string); + +// ── Collection → Path Mapping ────────────────────────────────────────────── + +/** + * Canonical mapping from collection names to `.squad/` relative paths. + * + * Static paths point to a single file (e.g. `decisions.md`). + * Function paths resolve per-entity directories (e.g. `agents/<name>`). + */ +export const COLLECTION_PATHS: Record<CollectionName, CollectionPathResolver> = { + agents: (id: string) => `.squad/agents/${id}`, + decisions: '.squad/decisions.md', + routing: '.squad/routing.md', + team: '.squad/team.md', + skills: (id: string) => `.squad/skills/${id}`, + templates: (id: string) => `.squad/templates/${id}`, + log: '.squad/log', + config: '.squad/config.json', +}; + +// ── Helpers ──────────────────────────────────────────────────────────────── + +/** + * Resolve the storage path for a collection, optionally with an entity id. + * + * @throws {Error} if the collection requires an id but none was provided. + */ +export function resolveCollectionPath(collection: CollectionName, id?: string): string { + const resolver = COLLECTION_PATHS[collection]; + if (typeof resolver === 'function') { + if (!id) { + throw new Error(`Collection "${collection}" requires an entity id to resolve its path`); + } + return resolver(id); + } + return resolver; +} diff --git a/packages/squad-sdk/src/state/squad-state.ts b/packages/squad-sdk/src/state/squad-state.ts new file mode 100644 index 000000000..6216d6ead --- /dev/null +++ b/packages/squad-sdk/src/state/squad-state.ts @@ -0,0 +1,90 @@ +/** + * State Module — SquadState Facade + * + * Top-level typed API for reading and writing `.squad/` state. + * Composes the collection facades (agents, decisions, routing, team) + * over a pluggable StorageProvider. + * + * @module state/squad-state + */ + +import type { StorageProvider } from '../storage/storage-provider.js'; +import { + AgentsCollection, + ConfigCollection, + DecisionsCollection, + LogCollection, + RoutingCollection, + SkillsCollection, + TeamCollection, + TemplatesCollection, +} from './collections.js'; +import { NotFoundError } from './domain-types.js'; + +export class SquadState { + readonly agents: AgentsCollection; + readonly config: ConfigCollection; + readonly decisions: DecisionsCollection; + readonly routing: RoutingCollection; + readonly team: TeamCollection; + readonly skills: SkillsCollection; + readonly templates: TemplatesCollection; + readonly log: LogCollection; + + private constructor( + private readonly storage: StorageProvider, + private readonly _rootDir: string, + ) { + this.agents = new AgentsCollection(storage, _rootDir); + this.config = new ConfigCollection(storage, _rootDir); + this.decisions = new DecisionsCollection(storage, _rootDir); + this.routing = new RoutingCollection(storage, _rootDir); + this.team = new TeamCollection(storage, _rootDir); + this.skills = new SkillsCollection(storage, _rootDir); + this.templates = new TemplatesCollection(storage, _rootDir); + this.log = new LogCollection(storage, _rootDir); + } + + /** The project root directory this SquadState is bound to. */ + get root(): string { + return this._rootDir; + } + + /** The underlying StorageProvider used by this instance. */ + get provider(): StorageProvider { + return this.storage; + } + + /** + * Factory method — validates that `rootDir/.squad/` exists before + * returning a new SquadState instance. + */ + static async create( + storage: StorageProvider, + rootDir: string, + ): Promise<SquadState> { + const squadDir = `${rootDir}/.squad`; + const exists = await storage.exists(squadDir); + if (!exists) { + throw new NotFoundError('squad', rootDir); + } + return new SquadState(storage, rootDir); + } + + /** + * Synchronous factory — creates SquadState without validating `.squad/` + * existence. For internal SDK use where the caller already knows the + * root is initialized (e.g., modules operating on an existing project). + */ + static fromStorage( + storage: StorageProvider, + rootDir: string, + ): SquadState { + return new SquadState(storage, rootDir); + } + + /** Check if a `.squad/` directory exists at the root. */ + async isInitialized(): Promise<boolean> { + return this.storage.exists(`${this._rootDir}/.squad`); + } +} diff --git a/packages/squad-sdk/src/storage/fs-storage-provider.ts b/packages/squad-sdk/src/storage/fs-storage-provider.ts new file mode 100644 index 000000000..2096bdd3d --- /dev/null +++ b/packages/squad-sdk/src/storage/fs-storage-provider.ts @@ -0,0 +1,234 @@ +import { readFile, writeFile, appendFile, access, readdir, unlink, mkdir, realpath, rm } from 'fs/promises'; +import { readFileSync, writeFileSync, existsSync as fsExistsSync, mkdirSync, realpathSync, readdirSync } from 'fs'; +import { dirname, resolve, sep } from 'path'; +import type { StorageProvider } from './storage-provider.js'; +import { StorageError } from './storage-error.js'; + +/** + * FSStorageProvider — Node.js `fs` implementation of StorageProvider. + * + * - ENOENT reads return `undefined` (no throw). + * - Writes create parent directories recursively. + * - Appends create the file (and parent dirs) if missing. + * - delete is a no-op when the file does not exist. + * - list returns an empty array for a missing directory. + * - Optional `rootDir` confines all operations to a specific directory tree. + */ +export class FSStorageProvider implements StorageProvider { + private static readonly CASE_INSENSITIVE = process.platform === 'win32' || process.platform === 'darwin'; + private readonly rootDir?: string; + + constructor(rootDir?: string) { + this.rootDir = rootDir ? realpathSync(resolve(rootDir)) : undefined; + } + + private pathStartsWith(fullPath: string, prefix: string): boolean { + if (FSStorageProvider.CASE_INSENSITIVE) { + return fullPath.toLowerCase().startsWith(prefix.toLowerCase()); + } + return fullPath.startsWith(prefix); + } + + private pathEquals(a: string, b: string): boolean { + if (FSStorageProvider.CASE_INSENSITIVE) { + return a.toLowerCase() === b.toLowerCase(); + } + return a === b; + } + + /** + * Validates that filePath resolves within rootDir and does not escape + * via path traversal or symlinks. + * + * ⚠️ TOCTOU: This is a user-space check. Between validation and the + * subsequent fs operation, a path component could theoretically be + * swapped for a symlink. This is an inherent limitation of user-space + * path validation on POSIX systems. For defense-in-depth, callers + * operating in hostile environments should use OS-level confinement + * (chroot, namespaces, or sandboxing) in addition to this check. + */ + private async assertSafePath(filePath: string): Promise<string> { + if (!this.rootDir) return filePath; + + const resolved = resolve(this.rootDir, filePath); + + // Check if resolved path is within rootDir + if (!this.pathStartsWith(resolved, this.rootDir + sep) && !this.pathEquals(resolved, this.rootDir)) { + throw new Error(`Path traversal blocked: ${filePath}`); + } + + // Check for symlink traversal + try { + const real = await realpath(resolved); + if (!this.pathStartsWith(real, this.rootDir + sep) && !this.pathEquals(real, this.rootDir)) { + throw new Error(`Symlink traversal blocked: ${filePath}`); + } + return real; + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + // Walk up to nearest existing ancestor and verify it resolves within rootDir + let checkPath = resolved; + while (!this.pathEquals(checkPath, this.rootDir)) { + checkPath = dirname(checkPath); + try { + const realAncestor = await realpath(checkPath); + if (!this.pathStartsWith(realAncestor, this.rootDir + sep) && !this.pathEquals(realAncestor, this.rootDir)) { + throw new Error(`Symlink traversal blocked: ${filePath}`); + } + return resolved; + } catch (ancestorErr: unknown) { + if ((ancestorErr as NodeJS.ErrnoException).code === 'ENOENT') continue; + throw ancestorErr; + } + } + return resolved; + } + throw err; + } + } + + private assertSafePathSync(filePath: string): string { + if (!this.rootDir) return filePath; + + const resolved = resolve(this.rootDir, filePath); + + // Check if resolved path is within rootDir + if (!this.pathStartsWith(resolved, this.rootDir + sep) && !this.pathEquals(resolved, this.rootDir)) { + throw new Error(`Path traversal blocked: ${filePath}`); + } + + // Check for symlink traversal + try { + const real = realpathSync(resolved); + if (!this.pathStartsWith(real, this.rootDir + sep) && !this.pathEquals(real, this.rootDir)) { + throw new Error(`Symlink traversal blocked: ${filePath}`); + } + return real; + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + // Walk up to nearest existing ancestor and verify it resolves within rootDir + let checkPath = resolved; + while (!this.pathEquals(checkPath, this.rootDir)) { + checkPath = dirname(checkPath); + try { + const realAncestor = realpathSync(checkPath); + if (!this.pathStartsWith(realAncestor, this.rootDir + sep) && !this.pathEquals(realAncestor, this.rootDir)) { + throw new Error(`Symlink traversal blocked: ${filePath}`); + } + return resolved; + } catch (ancestorErr: unknown) { + if ((ancestorErr as NodeJS.ErrnoException).code === 'ENOENT') continue; + throw ancestorErr; + } + } + return resolved; + } + throw err; + } + } + + async read(filePath: string): Promise<string | undefined> { + const safePath = await this.assertSafePath(filePath); + try { + return await readFile(safePath, 'utf-8'); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + throw new StorageError('read', filePath, err as NodeJS.ErrnoException); + } + } + + async write(filePath: string, data: string): Promise<void> { + const safePath = await this.assertSafePath(filePath); + try { + await mkdir(dirname(safePath), { recursive: true }); + await writeFile(safePath, data, 'utf-8'); + } catch (err: unknown) { + throw new StorageError('write', filePath, err as NodeJS.ErrnoException); + } + } + + async append(filePath: string, data: string): Promise<void> { + const safePath = await this.assertSafePath(filePath); + try { + await mkdir(dirname(safePath), { recursive: true }); + await appendFile(safePath, data, 'utf-8'); + } catch (err: unknown) { + throw new StorageError('append', filePath, err as NodeJS.ErrnoException); + } + } + + async exists(filePath: string): Promise<boolean> { + const safePath = await this.assertSafePath(filePath); + try { + await access(safePath); + return true; + } catch { + return false; + } + } + + async list(dirPath: string): Promise<string[]> { + const safePath = await this.assertSafePath(dirPath); + try { + return await readdir(safePath); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw new StorageError('list', dirPath, err as NodeJS.ErrnoException); + } + } + + async delete(filePath: string): Promise<void> { + const safePath = await this.assertSafePath(filePath); + try { + await unlink(safePath); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return; + throw new StorageError('delete', filePath, err as NodeJS.ErrnoException); + } + } + + readSync(filePath: string): string | undefined { + const safePath = this.assertSafePathSync(filePath); + try { + return readFileSync(safePath, 'utf-8'); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + throw new StorageError('read', filePath, err as NodeJS.ErrnoException); + } + } + + writeSync(filePath: string, data: string): void { + const safePath = this.assertSafePathSync(filePath); + try { + mkdirSync(dirname(safePath), { recursive: true }); + writeFileSync(safePath, data, 'utf-8'); + } catch (err: unknown) { + throw new StorageError('write', filePath, err as NodeJS.ErrnoException); + } + } + + existsSync(filePath: string): boolean { + const safePath = this.assertSafePathSync(filePath); + return fsExistsSync(safePath); + } + + listSync(dirPath: string): string[] { + const safePath = this.assertSafePathSync(dirPath); + try { + return readdirSync(safePath); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw new StorageError('list', dirPath, err as NodeJS.ErrnoException); + } + } + + async deleteDir(dirPath: string): Promise<void> { + const safePath = await this.assertSafePath(dirPath); + try { + await rm(safePath, { recursive: true, force: true }); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return; + throw new StorageError('deleteDir', dirPath, err as NodeJS.ErrnoException); + } + } +} diff --git a/packages/squad-sdk/src/storage/in-memory-storage-provider.ts b/packages/squad-sdk/src/storage/in-memory-storage-provider.ts new file mode 100644 index 000000000..5a1eac79a --- /dev/null +++ b/packages/squad-sdk/src/storage/in-memory-storage-provider.ts @@ -0,0 +1,100 @@ +import { posix } from 'path'; +import type { StorageProvider } from './storage-provider.js'; + +/** + * InMemoryStorageProvider — test-friendly StorageProvider backed by a Map. + * + * No filesystem access. All paths are normalized to forward-slash POSIX form. + * Useful for unit tests and DI scenarios where real I/O is undesirable. + */ +export class InMemoryStorageProvider implements StorageProvider { + private files = new Map<string, string>(); + + private norm(p: string): string { + // Convert Windows backslashes to forward slashes before POSIX normalization + // so that paths from path.join() on Windows match stored POSIX keys. + return posix.normalize(p.replace(/\\/g, '/')).replace(/\/+$/, ''); + } + + async read(filePath: string): Promise<string | undefined> { + return this.readSync(filePath); + } + + async write(filePath: string, data: string): Promise<void> { + this.writeSync(filePath, data); + } + + async append(filePath: string, data: string): Promise<void> { + const key = this.norm(filePath); + const existing = this.files.get(key) ?? ''; + this.files.set(key, existing + data); + } + + async exists(filePath: string): Promise<boolean> { + return this.existsSync(filePath); + } + + async list(dirPath: string): Promise<string[]> { + return this.listSync(dirPath); + } + + async delete(filePath: string): Promise<void> { + this.files.delete(this.norm(filePath)); + } + + async deleteDir(dirPath: string): Promise<void> { + const prefix = this.norm(dirPath) + '/'; + for (const key of [...this.files.keys()]) { + if (key === this.norm(dirPath) || key.startsWith(prefix)) { + this.files.delete(key); + } + } + } + + // ── Synchronous variants ──────────────────────────────────────────────── + + readSync(filePath: string): string | undefined { + return this.files.get(this.norm(filePath)); + } + + writeSync(filePath: string, data: string): void { + this.files.set(this.norm(filePath), data); + } + + existsSync(filePath: string): boolean { + const key = this.norm(filePath); + if (this.files.has(key)) return true; + // Check if any key has this as a directory prefix + const prefix = key + '/'; + for (const k of this.files.keys()) { + if (k.startsWith(prefix)) return true; + } + return false; + } + + listSync(dirPath: string): string[] { + const dir = this.norm(dirPath); + const prefix = dir + '/'; + const entries = new Set<string>(); + for (const key of this.files.keys()) { + if (key.startsWith(prefix)) { + const rest = key.slice(prefix.length); + const name = rest.split('/')[0]!; + entries.add(name); + } + } + return [...entries]; + } + + // ── Test helpers ──────────────────────────────────────────────────────── + + /** Return a shallow copy of the internal state for test assertions. */ + snapshot(): Map<string, string> { + return new Map(this.files); + } + + /** Reset all stored files. */ + clear(): void { + this.files.clear(); + } +} diff --git a/packages/squad-sdk/src/storage/index.ts b/packages/squad-sdk/src/storage/index.ts new file mode 100644 index 000000000..a3f74609f --- /dev/null +++ b/packages/squad-sdk/src/storage/index.ts @@ -0,0 +1,5 @@ +export type { StorageProvider } from './storage-provider.js'; +export { FSStorageProvider } from './fs-storage-provider.js'; +export { InMemoryStorageProvider } from './in-memory-storage-provider.js'; +export { SQLiteStorageProvider } from './sqlite-storage-provider.js'; +export { StorageError } from './storage-error.js'; diff --git a/packages/squad-sdk/src/storage/sqlite-storage-provider.ts b/packages/squad-sdk/src/storage/sqlite-storage-provider.ts new file mode 100644 index 000000000..fea367ad0 --- /dev/null +++ b/packages/squad-sdk/src/storage/sqlite-storage-provider.ts @@ -0,0 +1,218 @@ +import { posix } from 'path'; +import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from 'fs'; +import { dirname } from 'path'; +import type { StorageProvider } from './storage-provider.js'; + +// sql.js types — loaded dynamically +type SqlJsStatic = typeof import('sql.js'); +type Database = import('sql.js').Database; + +const DEFAULT_DB_PATH = '.squad/squad.db'; + +/** + * SQLiteStorageProvider — cross-platform SQLite-backed StorageProvider using sql.js (WASM). + * + * Schema: `files(path TEXT PRIMARY KEY, content TEXT, updated_at TEXT)` + * + * sql.js runs SQLite entirely in WASM — no native compilation required. + * Works on win/linux/mac/mac-silicon without platform-specific binaries. + * + * The WASM bundle is loaded lazily via dynamic `import('sql.js')` so it + * only impacts startup when this provider is actually instantiated. + * + * Sync methods work because sql.js operations are synchronous under the + * hood (WASM, not network). The DB must be initialized before sync calls. + */ +export class SQLiteStorageProvider implements StorageProvider { + private readonly dbPath: string; + private db: Database | null = null; + private initPromise: Promise<void> | null = null; + + constructor(dbPath: string = DEFAULT_DB_PATH) { + this.dbPath = dbPath; + } + + // ── Initialization ────────────────────────────────────────────────────── + + /** + * Lazily initialize the database. Safe to call multiple times; + * subsequent calls return the same promise. + */ + async init(): Promise<void> { + if (this.db) return; + if (this.initPromise) return this.initPromise; + this.initPromise = this.doInit(); + return this.initPromise; + } + + private async doInit(): Promise<void> { + const initSqlJs: SqlJsStatic = (await import('sql.js')).default; + const SQL = await initSqlJs(); + + if (existsSync(this.dbPath)) { + const fileBuffer = readFileSync(this.dbPath); + this.db = new SQL.Database(fileBuffer); + } else { + this.db = new SQL.Database(); + } + + this.db.run(` + CREATE TABLE IF NOT EXISTS files ( + path TEXT PRIMARY KEY, + content TEXT, + updated_at TEXT + ) + `); + } + + /** Throws if the DB has not been initialized (for sync methods). */ + private ensureDb(): Database { + if (!this.db) { + throw new Error( + 'SQLiteStorageProvider is not initialized. Call init() before using sync methods.', + ); + } + return this.db; + } + + /** Ensure the DB is ready (for async methods). */ + private async ready(): Promise<Database> { + await this.init(); + return this.ensureDb(); + } + + // ── Helpers ───────────────────────────────────────────────────────────── + + /** Normalize a path to forward-slash POSIX form with no trailing slash. */ + private norm(p: string): string { + return posix.normalize(p.replace(/\\/g, '/')).replace(/\/+$/, ''); + } + + /** Escape SQL LIKE wildcards so user-supplied paths are matched literally. */ + private escapeLike(value: string): string { + return value.replace(/[%_]/g, char => `\\${char}`); + } + + /** Persist the in-memory DB to disk (atomic write-then-rename). */ + private persist(): void { + const db = this.ensureDb(); + const data = db.export(); + const buffer = Buffer.from(data); + mkdirSync(dirname(this.dbPath), { recursive: true }); + const tmpPath = this.dbPath + '.tmp'; + writeFileSync(tmpPath, buffer); + renameSync(tmpPath, this.dbPath); + } + + private now(): string { + return new Date().toISOString(); + } + + // ── Async interface ───────────────────────────────────────────────────── + + async read(filePath: string): Promise<string | undefined> { + await this.ready(); + return this.readSync(filePath); + } + + async write(filePath: string, data: string): Promise<void> { + await this.ready(); + this.writeSync(filePath, data); + } + + async append(filePath: string, data: string): Promise<void> { + await this.ready(); + const key = this.norm(filePath); + const existing = this.readSync(filePath) ?? ''; + this.internalWrite(key, existing + data); + } + + async exists(filePath: string): Promise<boolean> { + await this.ready(); + return this.existsSync(filePath); + } + + async list(dirPath: string): Promise<string[]> { + await this.ready(); + return this.listSync(dirPath); + } + + async delete(filePath: string): Promise<void> { + await this.ready(); + const db = this.ensureDb(); + const key = this.norm(filePath); + db.run('DELETE FROM files WHERE path = ?', [key]); + this.persist(); + } + + async deleteDir(dirPath: string): Promise<void> { + await this.ready(); + const db = this.ensureDb(); + const dir = this.norm(dirPath); + db.run('DELETE FROM files WHERE path = ? OR path LIKE ? ESCAPE \'\\\'', [dir, `${this.escapeLike(dir)}/%`]); + this.persist(); + } + + // ── Sync interface ────────────────────────────────────────────────────── + + readSync(filePath: string): string | undefined { + const db = this.ensureDb(); + const key = this.norm(filePath); + const stmt = db.prepare('SELECT content FROM files WHERE path = ?'); + stmt.bind([key]); + if (stmt.step()) { + const row = stmt.getAsObject() as { content: string }; + stmt.free(); + return row.content; + } + stmt.free(); + return undefined; + } + + writeSync(filePath: string, data: string): void { + const key = this.norm(filePath); + this.internalWrite(key, data); + } + + existsSync(filePath: string): boolean { + const db = this.ensureDb(); + const key = this.norm(filePath); + // Exact file match OR directory prefix match + const stmt = db.prepare( + 'SELECT 1 FROM files WHERE path = ? OR path LIKE ? ESCAPE \'\\\' LIMIT 1', + ); + stmt.bind([key, `${this.escapeLike(key)}/%`]); + const found = stmt.step(); + stmt.free(); + return found; + } + + listSync(dirPath: string): string[] { + const db = this.ensureDb(); + const dir = this.norm(dirPath); + const prefix = dir + '/'; + const stmt = db.prepare('SELECT path FROM files WHERE path LIKE ? ESCAPE \'\\\''); + stmt.bind([`${this.escapeLike(dir)}/%`]); + const entries = new Set<string>(); + while (stmt.step()) { + const row = stmt.getAsObject() as { path: string }; + const rest = row.path.slice(prefix.length); + const name = rest.split('/')[0]!; + entries.add(name); + } + stmt.free(); + return [...entries]; + } + + // ── Internal ──────────────────────────────────────────────────────────── + + private internalWrite(normalizedPath: string, data: string): void { + const db = this.ensureDb(); + db.run( + `INSERT INTO files (path, content, updated_at) VALUES (?, ?, ?) + ON CONFLICT(path) DO UPDATE SET content = excluded.content, updated_at = excluded.updated_at`, + [normalizedPath, data, this.now()], + ); + this.persist(); + } +} diff --git a/packages/squad-sdk/src/storage/storage-error.ts b/packages/squad-sdk/src/storage/storage-error.ts new file mode 100644 index 000000000..00951a376 --- /dev/null +++ b/packages/squad-sdk/src/storage/storage-error.ts @@ -0,0 +1,18 @@ +import { basename } from 'path'; + +/** + * Sanitized storage error — strips internal filesystem paths from the public message. + * Callers see the operation that failed and the storage-relative path, not the absolute OS path. + */ +export class StorageError extends Error { + readonly code: string; + readonly operation: string; + + constructor(operation: string, filePath: string, cause: NodeJS.ErrnoException) { + super(`Storage ${operation} failed for "${basename(filePath)}": ${cause.code}`); + this.name = 'StorageError'; + this.code = cause.code ?? 'UNKNOWN'; + this.operation = operation; + this.cause = cause; + } +} diff --git a/packages/squad-sdk/src/storage/storage-provider.ts b/packages/squad-sdk/src/storage/storage-provider.ts new file mode 100644 index 000000000..f70de52f9 --- /dev/null +++ b/packages/squad-sdk/src/storage/storage-provider.ts @@ -0,0 +1,88 @@ +/** + * StorageProvider — abstract I/O contract for squad-sdk. + * + * All persistent storage operations flow through this interface so that + * runtimes (Node fs, in-memory, cloud) can be swapped without touching + * business logic. + * + * Wave 1 ships FSStorageProvider (Node.js fs wrapper). + * Wave 2 will remove the synchronous methods; callers should migrate now. + */ +export interface StorageProvider { + /** + * Read the full contents of a file as a UTF-8 string. + * Returns `undefined` if the file does not exist (ENOENT). + */ + read(filePath: string): Promise<string | undefined>; + + /** + * Write data to a file, creating parent directories as needed. + * Overwrites any existing content. + */ + write(filePath: string, data: string): Promise<void>; + + /** + * Append data to a file, creating it (and parent dirs) if it does not exist. + */ + append(filePath: string, data: string): Promise<void>; + + /** + * Return `true` if the path exists (file or directory). + * + * ⚠️ TOCTOU WARNING: Do not use exists() as a guard before read/write. + * Between the exists() check and the subsequent operation, the file can + * be deleted, replaced, or swapped by another process. Instead, call + * read() directly and handle `undefined` (ENOENT) in the result. + */ + exists(filePath: string): Promise<boolean>; + + /** + * Return the names (not full paths) of entries directly inside `dirPath`. + * Returns an empty array if the directory is empty or does not exist. + */ + list(dirPath: string): Promise<string[]>; + + /** + * Delete a file. No-op if the file does not exist (ENOENT). + */ + delete(filePath: string): Promise<void>; + + /** + * Recursively delete a directory and all its contents. + * No-op if the directory does not exist (ENOENT). + */ + deleteDir(dirPath: string): Promise<void>; + + // ── Synchronous variants (Wave 1 compat) ──────────────────────────────── + // These exist so callers that cannot be made async in Wave 1 still work. + // They will be removed in Wave 2; migrate to the async versions now. + + /** + * Synchronous read. Returns `undefined` on ENOENT. + * @deprecated Prefer `read()`. Will be removed in Wave 2. + */ + readSync(filePath: string): string | undefined; + + /** + * Synchronous write with recursive mkdir. + * @deprecated Prefer `write()`. Will be removed in Wave 2. + */ + writeSync(filePath: string, data: string): void; + + /** + * Synchronous exists check. + * @deprecated Prefer `exists()`. Will be removed in Wave 2. + * + * ⚠️ TOCTOU: Same race condition warning as exists(). Prefer read() + * with undefined check over exists() → read() patterns. + */ + existsSync(filePath: string): boolean; + + /** + * Synchronous directory listing. + * Returns entry names directly inside dirPath. + * Returns empty array if directory does not exist. + * @deprecated Prefer `list()`. Will be removed in Wave 2. + */ + listSync(dirPath: string): string[]; +} diff --git a/packages/squad-sdk/src/streams/resolver.ts b/packages/squad-sdk/src/streams/resolver.ts index 09059f227..ac6ce8518 100644 --- a/packages/squad-sdk/src/streams/resolver.ts +++ b/packages/squad-sdk/src/streams/resolver.ts @@ -10,8 +10,9 @@ * @module streams/resolver */ -import { existsSync, readFileSync } from 'fs'; import { join } from 'path'; +import type { StorageProvider } from '../storage/storage-provider.js'; +import { FSStorageProvider } from '../storage/fs-storage-provider.js'; import type { SubSquadConfig, SubSquadDefinition, ResolvedSubSquad } from './types.js'; /** @@ -20,14 +21,14 @@ import type { SubSquadConfig, SubSquadDefinition, ResolvedSubSquad } from './typ * @param squadRoot - Root directory of the project (where .squad/ lives) * @returns Parsed SubSquadConfig or null if not found / invalid */ -export function loadSubSquadsConfig(squadRoot: string): SubSquadConfig | null { +export function loadSubSquadsConfig(squadRoot: string, storage: StorageProvider = new FSStorageProvider()): SubSquadConfig | null { const configPath = join(squadRoot, '.squad', 'workstreams.json'); - if (!existsSync(configPath)) { + if (!storage.existsSync(configPath)) { return null; } try { - const raw = readFileSync(configPath, 'utf-8'); + const raw = storage.readSync(configPath)!; const rawConfig = JSON.parse(raw) as unknown; if (!rawConfig || typeof rawConfig !== 'object') { @@ -119,8 +120,8 @@ function findSubSquad(config: SubSquadConfig, name: string): SubSquadDefinition * @param squadRoot - Root directory of the project * @returns ResolvedSubSquad or null if no SubSquad is active */ -export function resolveSubSquad(squadRoot: string): ResolvedSubSquad | null { - const config = loadSubSquadsConfig(squadRoot); +export function resolveSubSquad(squadRoot: string, storage: StorageProvider = new FSStorageProvider()): ResolvedSubSquad | null { + const config = loadSubSquadsConfig(squadRoot, storage); // 1. SQUAD_TEAM env var const envTeam = process.env.SQUAD_TEAM; @@ -144,9 +145,9 @@ export function resolveSubSquad(squadRoot: string): ResolvedSubSquad | null { // 2. .squad-workstream file const workstreamFilePath = join(squadRoot, '.squad-workstream'); - if (existsSync(workstreamFilePath)) { + if (storage.existsSync(workstreamFilePath)) { try { - const subsquadName = readFileSync(workstreamFilePath, 'utf-8').trim(); + const subsquadName = storage.readSync(workstreamFilePath)!.trim(); if (subsquadName) { if (config) { const def = findSubSquad(config, subsquadName); diff --git a/packages/squad-sdk/src/tools/index.ts b/packages/squad-sdk/src/tools/index.ts index 52ccce7ff..1bca6c83b 100644 --- a/packages/squad-sdk/src/tools/index.ts +++ b/packages/squad-sdk/src/tools/index.ts @@ -10,11 +10,13 @@ * - squad_skill: Read/write agent skills */ -import * as fs from 'node:fs'; import * as path from 'node:path'; import { randomUUID } from 'node:crypto'; import type { SquadTool, SquadToolResult } from '../adapter/types.js'; import { trace, SpanStatusCode } from '../runtime/otel-api.js'; +import type { StorageProvider } from '../storage/storage-provider.js'; +import { FSStorageProvider } from '../storage/fs-storage-provider.js'; +import type { SquadState } from '../state/squad-state.js'; const tracer = trace.getTracer('squad-sdk'); @@ -173,10 +175,14 @@ export class ToolRegistry { private tools: Map<string, SquadTool<any>> = new Map(); private squadRoot: string; private sessionPoolGetter?: () => any; + private storage: StorageProvider; + private state?: SquadState; - constructor(squadRoot = '.squad', sessionPoolGetter?: () => any) { + constructor(squadRoot = '.squad', sessionPoolGetter?: () => any, storage: StorageProvider = new FSStorageProvider(), state?: SquadState) { this.squadRoot = squadRoot; this.sessionPoolGetter = sessionPoolGetter; + this.storage = storage; + this.state = state; this.registerSquadTools(); } @@ -268,7 +274,6 @@ export class ToolRegistry { } try { const inboxDir = path.join(this.squadRoot, 'decisions', 'inbox'); - fs.mkdirSync(inboxDir, { recursive: true }); const decisionId = randomUUID(); const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); @@ -292,7 +297,7 @@ export class ToolRegistry { '', ].filter(Boolean).join('\n'); - fs.writeFileSync(filename, content, 'utf-8'); + this.storage.writeSync(filename, content); return { textResultForLlm: `Decision written: ${args.author}-${slug}.md (ID: ${decisionId})`, @@ -337,9 +342,36 @@ export class ToolRegistry { return { textResultForLlm: 'Invalid agent name: must contain only letters, numbers, hyphens, and underscores', resultType: 'failure', error: 'Invalid agent name' }; } try { + // Use SquadState agents collection when available + if (this.state) { + const handle = this.state.agents.get(args.agent); + // Verify the agent exists by attempting to read charter + try { + await handle.charter(); + } catch { + return { + textResultForLlm: `Agent history file not found: agents/${args.agent}/history.md`, + resultType: 'failure', + error: 'History file does not exist', + }; + } + const sectionName = args.section.charAt(0).toUpperCase() + args.section.slice(1); + const timestamp = new Date().toISOString(); + await handle.appendHistory( + sectionName as any, + { section: sectionName as any, content: args.content, timestamp }, + ); + return { + textResultForLlm: `Appended to ${args.agent} history (${args.section})`, + resultType: 'success', + toolTelemetry: { agent: args.agent, section: args.section }, + }; + } + + // Fallback: raw StorageProvider const historyFile = path.join(this.squadRoot, 'agents', args.agent, 'history.md'); - if (!fs.existsSync(historyFile)) { + if (!this.storage.existsSync(historyFile)) { return { textResultForLlm: `Agent history file not found: agents/${args.agent}/history.md`, resultType: 'failure', @@ -351,7 +383,14 @@ export class ToolRegistry { const timestamp = new Date().toISOString(); const entry = `\n### ${timestamp}\n${args.content}\n`; - let content = fs.readFileSync(historyFile, 'utf-8'); + let content = this.storage.readSync(historyFile); + if (content === undefined) { + return { + textResultForLlm: `Agent history file not readable: agents/${args.agent}/history.md`, + resultType: 'failure', + error: 'History file could not be read', + }; + } // Find section and append const sectionIndex = content.indexOf(sectionHeader); @@ -365,7 +404,7 @@ export class ToolRegistry { content += `\n${sectionHeader}\n${entry}`; } - fs.writeFileSync(historyFile, content, 'utf-8'); + this.storage.writeSync(historyFile, content); return { textResultForLlm: `Appended to ${args.agent} history (${args.section})`, @@ -525,13 +564,14 @@ export class ToolRegistry { const copilotSkillDir = path.join(projectRoot, '.copilot', 'skills', args.skillName); const skillDir = args.operation === 'write' ? copilotSkillDir - : fs.existsSync(path.join(copilotSkillDir, 'SKILL.md')) + : this.storage.existsSync(path.join(copilotSkillDir, 'SKILL.md')) ? copilotSkillDir : legacySkillDir; const skillFile = path.join(skillDir, 'SKILL.md'); if (args.operation === 'read') { - if (!fs.existsSync(skillFile)) { + const content = this.storage.readSync(skillFile); + if (content === undefined) { return { textResultForLlm: `Skill not found: ${args.skillName}`, resultType: 'failure', @@ -539,7 +579,6 @@ export class ToolRegistry { }; } - const content = fs.readFileSync(skillFile, 'utf-8'); return { textResultForLlm: `Skill: ${args.skillName}\n\n${content}`, resultType: 'success', @@ -555,8 +594,6 @@ export class ToolRegistry { }; } - fs.mkdirSync(skillDir, { recursive: true }); - const skillContent = [ `# ${args.skillName}`, '', @@ -566,7 +603,7 @@ export class ToolRegistry { args.content, ].join('\n'); - fs.writeFileSync(skillFile, skillContent, 'utf-8'); + this.storage.writeSync(skillFile, skillContent); return { textResultForLlm: `Skill written: ${args.skillName} (.copilot/skills/${args.skillName}/SKILL.md)`, diff --git a/packages/squad-sdk/src/upstream/resolver.ts b/packages/squad-sdk/src/upstream/resolver.ts index a57720f05..8b0215771 100644 --- a/packages/squad-sdk/src/upstream/resolver.ts +++ b/packages/squad-sdk/src/upstream/resolver.ts @@ -9,8 +9,9 @@ * @module upstream/resolver */ -import fs from 'node:fs'; import path from 'node:path'; +import type { StorageProvider } from '../storage/storage-provider.js'; +import { FSStorageProvider } from '../storage/fs-storage-provider.js'; import type { UpstreamConfig, UpstreamResolution, @@ -21,12 +22,13 @@ import type { * Read and parse upstream.json from a squad directory. * Returns null if the file doesn't exist or is invalid. */ -export function readUpstreamConfig(squadDir: string): UpstreamConfig | null { +export function readUpstreamConfig(squadDir: string, storage: StorageProvider = new FSStorageProvider()): UpstreamConfig | null { const configPath = path.join(squadDir, 'upstream.json'); - if (!fs.existsSync(configPath)) return null; + if (!storage.existsSync(configPath)) return null; try { - const raw = fs.readFileSync(configPath, 'utf8'); + const raw = storage.readSync(configPath); + if (!raw) return null; const parsed = JSON.parse(raw) as UpstreamConfig; if (!parsed.upstreams || !Array.isArray(parsed.upstreams)) return null; return parsed; @@ -39,12 +41,12 @@ export function readUpstreamConfig(squadDir: string): UpstreamConfig | null { * Find the .squad/ directory inside a source path. * Checks for .squad/ first, falls back to .ai-team/. */ -function findSquadDir(sourcePath: string): string | null { +function findSquadDir(sourcePath: string, storage: StorageProvider): string | null { const squadDir = path.join(sourcePath, '.squad'); - if (fs.existsSync(squadDir)) return squadDir; + if (storage.existsSync(squadDir)) return squadDir; const aiTeamDir = path.join(sourcePath, '.ai-team'); - if (fs.existsSync(aiTeamDir)) return aiTeamDir; + if (storage.existsSync(aiTeamDir)) return aiTeamDir; return null; } @@ -52,25 +54,26 @@ function findSquadDir(sourcePath: string): string | null { /** * Read all skills from a squad directory's skills/ folder. */ -function readSkills(squadDir: string): Array<{ name: string; content: string }> { +function readSkills(squadDir: string, storage: StorageProvider): Array<{ name: string; content: string }> { const projectDir = path.dirname(squadDir); const candidateDirs = [ { dir: path.join(projectDir, '.copilot', 'skills'), layout: 'nested' as const }, { dir: path.join(squadDir, 'skills'), layout: 'nested' as const }, { dir: path.join(projectDir, '.ai-team', 'skills'), layout: 'flat' as const }, ]; - const source = candidateDirs.find(({ dir }) => fs.existsSync(dir)); + const source = candidateDirs.find(({ dir }) => storage.existsSync(dir)); if (!source) return []; const skills: Array<{ name: string; content: string }> = []; try { - for (const entry of fs.readdirSync(source.dir)) { + for (const entry of storage.listSync(source.dir)) { const skillFile = source.layout === 'nested' ? path.join(source.dir, entry, 'SKILL.md') : path.join(source.dir, entry); - if (fs.existsSync(skillFile)) { + if (storage.existsSync(skillFile)) { const name = source.layout === 'nested' ? entry : path.basename(entry, '.md'); - skills.push({ name, content: fs.readFileSync(skillFile, 'utf8') }); + const content = storage.readSync(skillFile); + if (content) skills.push({ name, content }); } } } catch { @@ -82,10 +85,10 @@ function readSkills(squadDir: string): Array<{ name: string; content: string }> /** * Read a text file if it exists, otherwise return null. */ -function readOptionalFile(filePath: string): string | null { - if (!fs.existsSync(filePath)) return null; +function readOptionalFile(filePath: string, storage: StorageProvider): string | null { + if (!storage.existsSync(filePath)) return null; try { - return fs.readFileSync(filePath, 'utf8'); + return storage.readSync(filePath) ?? null; } catch { return null; } @@ -94,8 +97,8 @@ function readOptionalFile(filePath: string): string | null { /** * Read and parse a JSON file if it exists, otherwise return null. */ -function readOptionalJson(filePath: string): Record<string, unknown> | null { - const raw = readOptionalFile(filePath); +function readOptionalJson(filePath: string, storage: StorageProvider): Record<string, unknown> | null { + const raw = readOptionalFile(filePath, storage); if (!raw) return null; try { return JSON.parse(raw) as Record<string, unknown>; @@ -107,22 +110,22 @@ function readOptionalJson(filePath: string): Record<string, unknown> | null { /** * Resolve content from a single upstream's .squad/ directory. */ -function resolveFromSquadDir(name: string, type: 'local' | 'git' | 'export', upstreamSquadDir: string): ResolvedUpstream { +function resolveFromSquadDir(name: string, type: 'local' | 'git' | 'export', upstreamSquadDir: string, storage: StorageProvider): ResolvedUpstream { return { name, type, - skills: readSkills(upstreamSquadDir), - decisions: readOptionalFile(path.join(upstreamSquadDir, 'decisions.md')), - wisdom: readOptionalFile(path.join(upstreamSquadDir, 'identity', 'wisdom.md')), - castingPolicy: readOptionalJson(path.join(upstreamSquadDir, 'casting', 'policy.json')), - routing: readOptionalFile(path.join(upstreamSquadDir, 'routing.md')), + skills: readSkills(upstreamSquadDir, storage), + decisions: readOptionalFile(path.join(upstreamSquadDir, 'decisions.md'), storage), + wisdom: readOptionalFile(path.join(upstreamSquadDir, 'identity', 'wisdom.md'), storage), + castingPolicy: readOptionalJson(path.join(upstreamSquadDir, 'casting', 'policy.json'), storage), + routing: readOptionalFile(path.join(upstreamSquadDir, 'routing.md'), storage), }; } /** * Resolve content from an export JSON file. */ -function resolveFromExport(name: string, exportPath: string): ResolvedUpstream { +function resolveFromExport(name: string, exportPath: string, storage: StorageProvider): ResolvedUpstream { const resolved: ResolvedUpstream = { name, type: 'export', @@ -134,7 +137,8 @@ function resolveFromExport(name: string, exportPath: string): ResolvedUpstream { }; try { - const raw = fs.readFileSync(exportPath, 'utf8'); + const raw = storage.readSync(exportPath); + if (!raw) return resolved; const manifest = JSON.parse(raw) as { version?: string; skills?: string[]; @@ -169,17 +173,17 @@ function resolveFromExport(name: string, exportPath: string): ResolvedUpstream { * @param squadDir - The .squad/ directory of the current repo * @returns Resolved upstream content, or null if no upstream.json exists */ -export function resolveUpstreams(squadDir: string): UpstreamResolution | null { - const config = readUpstreamConfig(squadDir); +export function resolveUpstreams(squadDir: string, storage: StorageProvider = new FSStorageProvider()): UpstreamResolution | null { + const config = readUpstreamConfig(squadDir, storage); if (!config) return null; const results: ResolvedUpstream[] = []; for (const upstream of config.upstreams) { if (upstream.type === 'local') { - const upstreamSquadDir = findSquadDir(upstream.source); + const upstreamSquadDir = findSquadDir(upstream.source, storage); if (upstreamSquadDir) { - results.push(resolveFromSquadDir(upstream.name, 'local', upstreamSquadDir)); + results.push(resolveFromSquadDir(upstream.name, 'local', upstreamSquadDir, storage)); } else { // Source not found — push empty result results.push({ name: upstream.name, type: 'local', skills: [], decisions: null, wisdom: null, castingPolicy: null, routing: null }); @@ -187,14 +191,14 @@ export function resolveUpstreams(squadDir: string): UpstreamResolution | null { } else if (upstream.type === 'git') { // Read from cached clone const cloneDir = path.join(squadDir, '_upstream_repos', upstream.name); - const cloneSquadDir = findSquadDir(cloneDir); + const cloneSquadDir = findSquadDir(cloneDir, storage); if (cloneSquadDir) { - results.push(resolveFromSquadDir(upstream.name, 'git', cloneSquadDir)); + results.push(resolveFromSquadDir(upstream.name, 'git', cloneSquadDir, storage)); } else { results.push({ name: upstream.name, type: 'git', skills: [], decisions: null, wisdom: null, castingPolicy: null, routing: null }); } } else if (upstream.type === 'export') { - results.push(resolveFromExport(upstream.name, upstream.source)); + results.push(resolveFromExport(upstream.name, upstream.source, storage)); } } diff --git a/packages/squad-sdk/templates/skills/activity-aggregator/SKILL.md b/packages/squad-sdk/templates/skills/activity-aggregator/SKILL.md new file mode 100644 index 000000000..3dcd0180c --- /dev/null +++ b/packages/squad-sdk/templates/skills/activity-aggregator/SKILL.md @@ -0,0 +1,264 @@ +--- +name: "activity-aggregator" +description: "Unified personal activity digest across configured GitHub repositories" +domain: "workload-management, personal-activity, github-orchestration" +confidence: "low" +source: "manual (Flight workload-management proposal synthesized by Procedures)" +tools: + - name: "github-mcp-server-search_issues" + description: "Search issues across each configured repository for authored, assigned, or mentioned work" + when: "Use as the primary retrieval path for user-centric issue slices inside the configured lookback window" + - name: "github-mcp-server-search_pull_requests" + description: "Search pull requests across each configured repository for authored, review-requested, or mentioned work" + when: "Use as the primary retrieval path for user-centric pull-request slices inside the configured lookback window" + - name: "github-mcp-server-issue_read" + description: "Read issue details, labels, comments, and related metadata" + when: "Use after search results when you need labels, assignment state, or comment evidence for blocked flags and mention validation" + - name: "github-mcp-server-pull_request_read" + description: "Read pull request details, reviews, comments, files, and checks" + when: "Use after search results when you need review state, mergeability clues, labels, or blocker evidence" + - name: "github-mcp-server-list_issues" + description: "List recent issues in a configured repository" + when: "Use as a fallback inventory pass when search results need repo-local verification or broad recent coverage" + - name: "github-mcp-server-list_pull_requests" + description: "List recent pull requests in a configured repository" + when: "Use as a fallback inventory pass when search results need repo-local verification or broad recent coverage" +--- + +## Context + +`activity-aggregator` is the personal workload complement to `doc-intelligence`. + +Use it when Ralph or an interactive command needs a single answer to: **what across my configured repos needs my attention right now?** The skill should scan multiple GitHub repositories, deduplicate overlapping signals, and return a compact digest organized around the user's next action instead of raw issue lists. + +This skill should **reuse the local config contract** already established by `doc-intelligence`: + +```text +.squad/doc-intelligence.local.json +``` + +Read the configured `sources.github[]` list from that file instead of hardcoding owners, repo names, or maintaining a second repo registry. The skill may accept an explicit repo subset, but the default scope comes from local config. + +The default lookback window is the same as `doc-intelligence`: **24 hours** unless the caller overrides it. + +## Patterns + +### Config Reuse Pattern + +1. Load `.squad/doc-intelligence.local.json` +2. Read `defaults.lookbackHours` unless the caller supplies a narrower or wider window +3. Enumerate enabled `sources.github[]` +4. Treat that repo list as the canonical monitoring surface for cross-repo activity + +**Rule:** never copy local config values into `SKILL.md`, checked-in templates, decision logs, or dashboard snapshots. + +### Personal Signal Retrieval Pattern + +For each configured repository, retrieve six slices for the target user: + +1. **Issues authored by the user** +2. **Issues assigned to the user** +3. **Issues mentioning the user** +4. **PRs authored by the user** +5. **PRs requesting the user's review** +6. **PRs mentioning the user** + +Prefer search-first retrieval because the query is user-centric, not repo-centric. + +#### Issue query shape + +For each repo, search for open issues that match combinations like: + +- `author:{user}` +- `assignee:{user}` +- `mentions:{user}` +- constrained to the configured lookback window when the question is about recent activity + +Then read details only for candidate items that need labels, comments, assignment verification, or blocker extraction. + +#### Pull request query shape + +For each repo, search for open PRs that match combinations like: + +- `author:{user}` +- `review-requested:{user}` +- `mentions:{user}` +- constrained to the configured lookback window when the question is about recent activity + +Then read PR details, reviews, and comments to classify review state and blockers. + +### Classification Pattern + +Normalize every result into one primary bucket so the digest reads like a queue, not a data dump. + +#### 1. Needs your review + +Use when the PR is open and the user is a requested reviewer, explicit approver, or de facto reviewer based on direct mention plus pending review discussion. + +Include: +- repo + PR number +- age +- author +- labels +- latest review state summary +- blocked flags (failing checks, merge conflict, stale branch, requested changes) + +#### 2. Your PRs awaiting merge + +Use when the PR author is the target user and the PR is still open. + +Distinguish between: +- **ready to merge** +- **awaiting reviewer approval** +- **awaiting CI** +- **blocked / needs attention** + +Include review state, latest check conclusion, and whether the branch appears mergeable. + +#### 3. Your open issues + +Use for issues authored by the user or assigned to the user that are still open and actionable. + +Include: +- repo + issue number +- age +- labels +- assignee state +- blocked flag if comments indicate dependency, waiting state, or external blocker + +#### 4. Mentions + +Use for issue or PR threads where the user is mentioned but the item does not belong in a higher-priority bucket. + +Examples: +- FYI mention with no explicit action +- routing question that does not request review +- cross-repo reference to related work + +### Deduplication Pattern + +The same PR can appear in multiple slices: authored by user, review-requested to user, and mentioning user. Collapse duplicates into a single normalized record with: + +- stable identity: `{owner}/{repo}#{number}` + item type +- accumulated reasons: `authored`, `assigned`, `mentioned`, `review-requested` +- one primary bucket chosen by action priority + +Use this priority order when collisions happen: + +1. `needs your review` +2. `your PRs awaiting merge` +3. `your open issues` +4. `mentions` + +Keep secondary reasons in metadata so the dashboard or CLI can explain why the item surfaced. + +### Blocker Extraction Pattern + +Add a `blocked` or `needs-attention` flag when evidence shows one of these conditions: + +- requested changes still open +- failing or missing check runs +- merge conflict / non-mergeable state +- stale branch against base +- explicit dependency noted in comments or labels (`blocked`, `needs-info`, `waiting`, similar conventions) + +Do not invent blockers from silence. Only elevate when the issue, PR, review state, checks, or labels support it. + +### Output Pattern + +Emit a structured digest that can drive both Ralph's overnight briefing and an on-demand `squad activity` view. + +```markdown +# Personal Activity Digest — {user} + +**Lookback:** last {N} hours +**Config:** `.squad/doc-intelligence.local.json` + +## Coverage +- Repos scanned: {count} +- Repos: {owner/repo list} + +## Needs Your Review +- {repo}#{pr} — {age} + - Author: {author} + - Labels: {labels} + - Review state: {requested | approved by X | changes requested by Y} + - Flags: {blocked | stale | ci-failing | clean} + +## Your PRs Awaiting Merge +- {repo}#{pr} — {age} + - Status: {ready to merge | waiting for review | waiting for CI | blocked} + - Review state: {summary} + - Flags: {flags} + +## Your Open Issues +- {repo}#{issue} — {age} + - Labels: {labels} + - Assignees: {assignees} + - Flags: {blocked | waiting | active} + +## Mentions +- {repo}#{item} — {age} + - Why surfaced: {mention reason} + - Action: {none | reply | inspect | route} +``` + +### Ralph Integration Pattern + +Ralph should treat this as a **daily personal workload heartbeat**. + +1. Load the configured repos from local config +2. Scan the last 24 hours by default +3. Produce the digest before the morning briefing is rendered +4. Feed the output into the dashboard concept as the **Personal Activity** section +5. Stay compact: if there is no actionable personal signal, emit a quiet `nothing urgent` result instead of noise + +### Owner Routing Pattern + +- **EECOM** owns runtime wiring, data fetching, and digest generation +- **INCO** owns dashboard/CLI phrasing, grouping, and scan-friendly presentation + +Route implementation questions accordingly. + +## Examples + +### Example 1: Overnight briefing slice + +Ralph scans the configured repos and finds: +- two PRs requesting the user's review +- one user-authored PR with all approvals in but CI still pending +- one assigned issue waiting on another team + +**Result:** +- two items in `Needs Your Review` +- one item in `Your PRs Awaiting Merge` with `waiting for CI` +- one item in `Your Open Issues` with a `blocked` flag + +### Example 2: Mention should not outrank requested review + +A PR both mentions the user and explicitly requests their review. + +**Result:** +- emit one record +- primary bucket = `Needs Your Review` +- secondary reasons include `mentioned` + +### Example 3: On-demand personal filter + +The user runs `squad activity --my-mentions --last-24h`. + +**Result:** +- reuse the same repo scope from `.squad/doc-intelligence.local.json` +- return only the `Mentions` bucket +- keep dedupe logic so the same thread does not surface twice + +## Anti-Patterns + +- ❌ Hardcoding the repo list instead of reading `.squad/doc-intelligence.local.json` +- ❌ Maintaining a second config file for the same GitHub monitoring surface +- ❌ Treating every mention as equal priority to a requested review +- ❌ Emitting duplicate records because the same item matched multiple filters +- ❌ Mixing closed items into an active-work digest unless the caller explicitly asks for history +- ❌ Inventing blocked state without evidence from labels, comments, reviews, or checks +- ❌ Dumping raw search output when the caller needs an action-oriented queue +- ❌ Copying local repo notes or sensitive operational context into checked-in artifacts diff --git a/packages/squad-sdk/templates/skills/content-triage/SKILL.md b/packages/squad-sdk/templates/skills/content-triage/SKILL.md new file mode 100644 index 000000000..a4283b5ae --- /dev/null +++ b/packages/squad-sdk/templates/skills/content-triage/SKILL.md @@ -0,0 +1,276 @@ +--- +name: "content-triage" +description: "Boundary heuristic for external content adoption — Squad docs vs IRL tracking" +domain: "documentation, content-strategy, scope-analysis" +confidence: "high" +source: "earned (PR #331 — Tamir Dresher blog analysis)" +--- + +## Context + +Squad grows through community adoption — blog posts, sample repos, conference talks, videos. When someone identifies external content that helps Squad adoption, the team must **triage** that content to determine: + +1. **What belongs in Squad's public docs?** (generalizable patterns, feature guides, configuration options) +2. **What belongs in the IRL tracking repo?** (external references, infrastructure patterns, operational content) +3. **What is out of scope?** (infrastructure around Squad, not Squad itself) + +This skill codifies the boundary heuristic from PR #331 and defines a repeatable workflow for content triage. + +## Patterns + +### Boundary Heuristic: "Squad Ships It" + +**Litmus test:** If Squad doesn't ship the code/configuration, it's IRL content. + +| Content Type | Squad Docs? | IRL Tracking? | Rationale | +|--------------|-------------|---------------|-----------| +| Squad CLI commands | ✅ Yes | No | Squad ships the CLI | +| Squad config options (`.squad/` files) | ✅ Yes | No | Squad defines the schema | +| User behavior within Squad features | ✅ Yes | No | Documents Squad's API surface | +| GitHub features configured *for* Squad | ⚠️ Reframe | Reference | Clarify it's GitHub, not Squad (e.g., "GitHub issue templates work with Squad labels") | +| Infrastructure *around* Squad | ❌ No | ✅ Yes | Squad doesn't own CI/CD, deployment, monitoring setup | +| Operational patterns *using* Squad | ❌ No | ✅ Yes | Squad doesn't ship the ops model | +| External samples/examples | ❌ No | ✅ Yes | Reference, not ownership | +| Blog posts, talks, videos | ❌ No | ✅ Yes | External authorship, not Squad's docs responsibility | + +### Triage Workflow + +**Trigger:** Issue labeled `content-triage` or issue body references external content (blog post, sample repo, video, talk). + +**Steps:** + +1. **Ralph detects the trigger** (label or content reference) +2. **Ralph routes to Flight** for boundary analysis +3. **Flight triages the content:** + - Read the external content (blog post, repo README, talk outline) + - Apply the "Squad Ships It" litmus test to each concept/pattern/feature + - Categorize each piece as: + - **Squad docs** — generalizable pattern, Squad owns the code/config + - **IRL tracking** — external reference, infrastructure pattern, operational content + - **Out of scope** — not Squad-related +4. **Flight produces triage output** (see format below) +5. **PAO creates sub-issues** for Squad doc extraction +6. **FIDO verifies** docs-test sync when PAO opens PR +7. **Scribe records** IRL references in tracking repo + +### Triage Output Format + +Flight writes the triage analysis to: +``` +.squad/decisions/inbox/flight-content-triage-{source-slug}.md +``` + +**Template:** + +```markdown +# Content Triage: {Source Name} + +**Source:** {URL or reference} +**Type:** {blog post | sample repo | video | conference talk} +**Author:** {name} +**Date:** {YYYY-MM-DD} + +--- + +## Boundary Analysis + +### ✅ Squad Docs (Extractable Patterns) + +{List of concepts/patterns that belong in Squad's public docs} + +**Rationale:** {Why these belong in Squad's docs — apply "Squad Ships It" test} + +**Proposed doc pages:** +- `docs/{section}/{page}.md` — {brief description} +- `docs/{section}/{page}.md` — {brief description} + +**Assigned to:** PAO (doc extraction) + +--- + +### 📚 IRL Tracking (External Reference) + +{List of concepts/patterns that are external infrastructure/operational content} + +**Rationale:** {Why these are IRL content — apply "Squad Ships It" test} + +**Tracking entry:** +```yaml +# .github/irl/references.yml +- title: "{Source Name}" + url: "{URL}" + type: "{blog | sample | video | talk}" + author: "{name}" + date: "{YYYY-MM-DD}" + topics: + - "{topic1}" + - "{topic2}" + highlights: + - "{key takeaway 1}" + - "{key takeaway 2}" +``` + +**Assigned to:** Scribe (IRL tracking) + +--- + +### ❌ Out of Scope + +{List of content that is not Squad-related} + +**Rationale:** {Why these are out of scope} + +--- + +## Sub-Issues + +- [ ] **PAO:** Extract Squad patterns → `docs/{section}/{page}.md` +- [ ] **FIDO:** Verify docs-test sync when PAO opens PR +- [ ] **Scribe:** Add IRL reference to tracking repo +``` + +### Label Convention + +**Recommended labels:** +- `content-triage` — primary trigger (requires Flight analysis) +- `content:blog` — external blog post +- `content:sample` — external sample repo +- `content:video` — external video/screencast +- `content:talk` — conference talk or presentation + +**Label workflow:** +1. User or Ralph applies `content-triage` label to issue +2. Ralph routes to Flight +3. Flight removes `content-triage`, applies specific type label (`content:blog`, etc.) +4. Flight adds `status:triaged` when analysis is complete + +### IRL Tracking Repository + +**Location:** `.github/irl/references.yml` (or separate repo if external) + +**Schema:** + +```yaml +- title: "Blog post title" + url: "https://..." + type: "blog | sample | video | talk" + author: "Name" + date: "YYYY-MM-DD" + topics: + - "feature-name" + - "pattern-name" + highlights: + - "Key takeaway 1" + - "Key takeaway 2" + squad_docs: + - "docs/guides/feature-name.md" # Links to any docs extracted from this source +``` + +**Why YAML?** Machine-readable, supports aggregation/reporting, enables future IRL showcase generation. + +### Ralph Integration + +**Ralph's work-check cycle:** + +1. Ralph monitors issue labels (including `content-triage`) +2. On detection, Ralph: + - Reads the issue body for external content URL + - Routes to Flight: "New content triage request: {URL} — please analyze boundary" +3. Flight performs triage, writes decision file +4. Ralph sees `status:triaged` label: + - Creates sub-issues for PAO (doc extraction) + - Notifies Scribe (IRL tracking) + - Links sub-issues back to parent issue + +## Examples + +### Example 1: Tamir Dresher Blog Analysis (PR #331) + +**Source:** Tamir's blog post on Squad adoption in production environment + +**Triage:** + +**✅ Squad Docs (Extracted):** +- Scenario-based guides (authentication squad, data migration squad) +- Issue template integration (how to configure GitHub issue templates for Squad labels) +- Reviewer protocol trust levels (documents user choice within Squad's review system) + +**Rationale:** These document Squad's features (scenarios, config, behavior). Squad ships the review system and scenario patterns. + +**📚 IRL Tracking:** +- Ralph operations guide (monitoring, auto-merge, quality gates) +- Proactive communication patterns (Slack notifications, daily summaries) + +**Rationale:** These document infrastructure *around* Squad (CI/CD, monitoring, ops patterns). Squad doesn't ship the CI/CD or Slack bot — those are external integrations. + +**❌ Out of Scope:** None (all content was either extracted or tracked) + +**Result:** +- PAO extracted 3 doc pages (scenario-based-guides.md, issue-templates.md, reviewer-protocol.md) +- Flight removed 2 infrastructure guides (ralph-operations.md, proactive-communication.md) +- FIDO verified docs-test sync (no new tests needed — docs describe existing features) + +### Example 2: Sample Repo with Custom Ops Patterns + +**Source:** `example-corp/squad-deployment` (GitHub repo) + +**Triage:** + +**✅ Squad Docs:** +- `.squad/team.md` configuration examples (agent roles, routing rules) +- Decision file patterns (what decisions to document, format conventions) + +**Rationale:** Squad ships the team.md schema and decision.md format. These are configuration guides. + +**📚 IRL Tracking:** +- Terraform modules for Squad infrastructure (container orchestration, state persistence) +- Kubernetes deployment manifests +- Monitoring dashboards (Grafana config for Ralph health checks) + +**Rationale:** Squad doesn't ship Terraform or K8s configs. These are operational patterns around Squad. + +**❌ Out of Scope:** +- Internal company conventions unrelated to Squad (team naming, approval workflows) + +**Result:** +- PAO: Create `docs/guides/team-configuration.md` (generalized patterns) +- Scribe: Add IRL reference with link to `example-corp/squad-deployment` +- No extraction of Terraform/K8s — link only + +### Example 3: Conference Talk on Squad Patterns + +**Source:** "Building AI Teams with Squad" — DevOps Summit 2026 + +**Triage:** + +**✅ Squad Docs:** +- Agent specialization patterns (when to split roles, when to merge) +- Work routing strategies (label-based, domain-based, skill-based) + +**Rationale:** Squad ships the routing system and agent framework. These are architectural patterns within Squad's API. + +**📚 IRL Tracking:** +- Talk recording (YouTube link) +- Slide deck (Speaker Deck link) +- Case study: Company X's 10-agent team structure + +**Rationale:** External authorship, specific case study. Reference, not ownership. + +**❌ Out of Scope:** +- Generic DevOps practices not specific to Squad + +**Result:** +- PAO: Enhance `docs/architecture/agent-specialization.md` with patterns from talk +- Scribe: Add IRL reference (video + slides + case study note) + +## Anti-Patterns + +- ❌ Documenting infrastructure *around* Squad in Squad's public docs (belongs in IRL) +- ❌ Copying external content verbatim without generalization (extract patterns, not implementations) +- ❌ Listing external repos in Squad's docs without consent (privacy violation per decisions.md) +- ❌ Skipping triage and directly adding content to docs (boundary analysis required) +- ❌ Treating IRL content as "less valuable" (it's just a different category — references are important) +- ❌ Creating doc pages for every blog post (extract generalizable patterns only) +- ❌ Ignoring external content because "we didn't write it" (community content accelerates adoption) +- ❌ PAO extracting content before Flight completes boundary analysis (wait for triage) +- ❌ Mixing Squad features and GitHub features without clarification (reframe to distinguish platform from product) diff --git a/packages/squad-sdk/templates/skills/cross-machine-coordination/SKILL.md b/packages/squad-sdk/templates/skills/cross-machine-coordination/SKILL.md new file mode 100644 index 000000000..79c4bc7ea --- /dev/null +++ b/packages/squad-sdk/templates/skills/cross-machine-coordination/SKILL.md @@ -0,0 +1,434 @@ +# Skill: Cross-Machine Coordination Pattern + +**Skill ID:** `cross-machine-coordination` +**Owner:** Ralph (Work Monitor) +**Squad Integration:** All agents +**Status:** Specification (ready for implementation) + +--- + +## Overview + +Enables squad agents running on different machines (laptop, DevBox, Azure VM) to securely share work, coordinate execution, and pass results without manual intervention. + +**Pattern:** Git-based task queuing + GitHub Issues supplement + +--- + +## Usage + +### For Task Sources (Orchestrating Machine) + +**To assign work to DevBox:** + +```bash +# Create task file +cat > .squad/cross-machine/tasks/2026-03-14T1530Z-laptop-gpu-voice-clone.yaml << 'EOF' +id: gpu-voice-clone-001 +source_machine: laptop-machine +target_machine: devbox +priority: high +created_at: 2026-03-14T15:30:00Z +task_type: gpu_workload +payload: + command: "python scripts/voice-clone.py --input voice.wav --output cloned.wav" + expected_duration_min: 15 + resources: + gpu: true + memory_gb: 8 +status: pending +EOF + +# Commit & push +git add .squad/cross-machine/tasks/ +git commit -m "Cross-machine task: GPU voice cloning [squad:machine-devbox]" +git push origin main +``` + +Ralph on DevBox will: +1. Pull the task on next cycle (5-10 min) +2. Validate schema & command whitelist +3. Execute the GPU workload +4. Write result to `.squad/cross-machine/results/gpu-voice-clone-001.yaml` +5. Commit & push the result + +--- + +### For Task Executors (DevBox, Azure VMs) + +Ralph automatically watches `.squad/cross-machine/tasks/` for work targeted at this machine. + +**On each cycle (5-10 min):** + +```python +# Pseudo-code (Ralph implementation) +1. git pull origin main +2. Load all .yaml files in .squad/cross-machine/tasks/ +3. Filter for status=pending AND target_machine=HOSTNAME +4. For each task: + a. Validate schema (must have: id, source_machine, target_machine, payload) + b. Validate command against whitelist + c. Execute task (with timeout) + d. Write result to .squad/cross-machine/results/{id}.yaml + e. Commit & push result +``` + +--- + +### For Urgent/Ad-Hoc Tasks + +**Use GitHub Issues with `squad:machine-{name}` label:** + +```bash +# Create issue +gh issue create \ + --title "GPU: Clone voice profile from sample.wav" \ + --body "Execute voice cloning on DevBox. Input: /path/to/voice-input.wav" \ + --label "squad:machine-devbox" \ + --label "urgent" +``` + +Ralph on DevBox will: +1. Detect issue with `squad:machine-devbox` label +2. Parse task from issue body +3. Execute task +4. Comment with result +5. Close issue + +--- + +## File Formats + +### Task File (YAML) + +**Location:** `.squad/cross-machine/tasks/{timestamp}-{machine}-{task-id}.yaml` + +**Required Fields:** +```yaml +id: {task-id} # Unique identifier (alphanumeric + dash) +source_machine: {hostname} # Where task was created +target_machine: {hostname} # Where task will execute +priority: high|normal|low # Execution priority +created_at: 2026-03-14T15:30:00Z # ISO 8601 timestamp +task_type: gpu_workload|script|... # Category +payload: + command: "..." # Shell command to execute + expected_duration_min: 15 # Timeout (minutes) + resources: + gpu: true|false + memory_gb: 8 + cpu_cores: 4 +status: pending|executing|completed|failed +``` + +**Optional Fields:** +```yaml +description: "Human-readable task description" +timeout_override_min: 120 # Override default timeout +retry_count: 3 # Retry failed tasks +``` + +### Result File (YAML) + +**Location:** `.squad/cross-machine/results/{task-id}.yaml` + +```yaml +id: {task-id} # Links back to task +target_machine: devbox # Executed on +completed_at: 2026-03-14T15:45:00Z # When it finished +status: completed|failed|timeout # Outcome +exit_code: 0 # Shell exit code +stdout: "..." # Captured output +stderr: "..." # Captured errors +duration_seconds: 900 # How long it took +artifacts: + - path: "/path/to/artifacts/..." # Location of results + type: audio|text|model|... + size_mb: 2.5 +``` + +--- + +## Security Model + +### Validation Pipeline + +All tasks go through: + +1. **Schema Validation** + - YAML structure matches spec + - Required fields present + - No unexpected fields (reject) + +2. **Command Whitelist** + - Only approved commands allowed + - Path validation (no `../../` escapes) + - Environment variable sanitization + - No inline shell operators (`&&`, `|`, `>`) + +3. **Resource Limits** + - Timeout enforced (default: 60 min) + - Memory cap: 16GB (adjustable) + - CPU threads: 4 (adjustable) + - Disk write: 100GB (adjustable) + +4. **Execution Isolation** + - Runs as unprivileged user + - Temp directory cleaned after execution + - Network access: read-only (no outbound writes) + +5. **Audit Trail** + - All executions logged to git + - Commit signed with Ralph's key + - Result stored immutably + +### Threat Mitigations + +| Threat | Mitigation | +|--------|-----------| +| **Malicious task injection** | Branch protection + PR review before merge | +| **Credential leakage** | Pre-commit secret scan + environment scrubbing | +| **Resource exhaustion** | Timeout + memory limits | +| **Code injection** | Command whitelist + no shell evaluation | +| **Result tampering** | Git commit history is immutable | + +--- + +## Configuration + +Ralph reads config from `.squad/config.json`: + +```json +{ + "cross_machine": { + "enabled": true, + "poll_interval_seconds": 300, + "this_machine": "devbox", + "max_concurrent_tasks": 2, + "task_timeout_minutes": 60, + "command_whitelist": [ + "python scripts/voice-clone.py", + "python scripts/data-process.py", + "bash scripts/cleanup.sh" + ], + "result_ttl_days": 30 + } +} +``` + +--- + +## Examples + +### Example 1: GPU Voice Cloning (Laptop → DevBox) + +**1. Laptop creates task:** + +```yaml +# .squad/cross-machine/tasks/2026-03-14T1530Z-laptop-gpu-001.yaml +id: gpu-voice-clone-001 +source_machine: laptop-machine +target_machine: devbox +priority: high +created_at: 2026-03-14T15:30:00Z +task_type: gpu_workload +payload: + command: "python scripts/voice-clone.py --input voice.wav --output cloned.wav" + expected_duration_min: 15 + resources: + gpu: true + memory_gb: 8 +status: pending +``` + +**2. Laptop commits & pushes:** + +```bash +git add .squad/cross-machine/tasks/ +git commit -m "Task: GPU voice cloning [squad:machine-devbox]" +git push origin main +``` + +**3. DevBox Ralph (5 min later):** + +``` +[Ralph Watch Cycle] +- Pulled origin/main +- Detected: gpu-voice-clone-001 (status: pending, target: devbox) +- Validation: ✅ Schema OK, command whitelisted +- Executing: python scripts/voice-clone.py ... +- [15 minutes of processing] +- Completed: exit code 0 +- Writing result... +- Committing & pushing... +``` + +**4. Laptop Ralph (next cycle) sees result:** + +```yaml +# .squad/cross-machine/results/gpu-voice-clone-001.yaml +id: gpu-voice-clone-001 +target_machine: devbox +completed_at: 2026-03-14T15:45:00Z +status: completed +exit_code: 0 +stdout: "Voice cloning completed. Output written to /tmp/cloned.wav" +stderr: "" +duration_seconds: 900 +artifacts: + - path: "/path/to/artifacts/voice-clone-001/output.wav" + type: audio + size_mb: 2.5 +``` + +--- + +### Example 2: Urgent Debug Request (Human → DevBox via Issue) + +**Create issue:** + +```bash +gh issue create \ + --title "DevBox: Debug voice model failure" \ + --body "Error: Model failed to load on last run. Please check /tmp/model.log and report findings." \ + --label "squad:machine-devbox" \ + --label "urgent" +``` + +**DevBox Ralph detects → executes → comments:** + +``` +✅ Executed on devbox at 2026-03-14 15:47:00 +Command: python scripts/debug-model.py + +Result: +------ +Model file: /tmp/model-v2.bin (OK) +Checksum: a1b2c3d4e5f6 (matches expected) +Memory available: 12 GB (sufficient) + +ERROR FOUND: Config file permission issue + - File: ~/.config/voice/model.yaml + - Permissions: -rw------- (owner-only) + - Expected: -rw-r--r-- (world-readable for service) + +FIX: Run: chmod 644 ~/.config/voice/model.yaml +``` + +--- + +## Error Handling + +### Task Execution Failures + +If a task fails (exit code != 0): + +1. Result written with `status: failed` + exit code +2. stderr captured in result +3. Committed to git for audit +4. Source machine can retry by re-pushing task with `status: pending` + +### Stalled Tasks + +If a task doesn't complete within timeout: + +1. Process killed +2. Result written with `status: timeout` +3. stderr: "Execution exceeded X minutes" +4. Source can investigate or retry + +### Network Failures + +If git push/pull fails: + +- Ralph retries on next cycle +- Tasks queue locally until connectivity restored +- No tasks lost (stored in local repo) + +--- + +## Monitoring & Debugging + +### Check Task Queue + +```bash +ls -la .squad/cross-machine/tasks/ +cat .squad/cross-machine/tasks/*.yaml | grep -E "^(id|status|target_machine):" +``` + +### Check Results + +```bash +ls -la .squad/cross-machine/results/ +cat .squad/cross-machine/results/{task-id}.yaml +``` + +### View Execution History + +```bash +git log --oneline .squad/cross-machine/ | head -20 +``` + +### Monitor Ralph Cycles + +```bash +tail -f .squad/log/ralph-watch.log | grep "cross-machine" +``` + +--- + +## Integration with Ralph Watch + +Ralph automatically includes this pattern in its watch loop: + +``` +Ralph Watch Cycle (every 5-10 min): +1. Fetch GitHub issues with squad:machine-* labels +2. Poll .squad/cross-machine/tasks/ +3. For each matching task: + - Validate + - Execute + - Write result + - Commit & push +4. Update status in issue (if applicable) +5. Sleep until next cycle +``` + +No manual Ralph configuration needed — just create task files or issues with the right labels. + +--- + +## Migration from Manual Handoff + +**Before (today):** +- Laptop → user manually copies file to Teams chat +- user pastes into target terminal +- user copies output back +- user pastes result manually + +**After (with this pattern):** +- Laptop Ralph writes task file → git push +- DevBox Ralph auto-executes → git push result +- Laptop Ralph auto-reads result +- 0 human intervention needed + +--- + +## Future Enhancements + +Potential expansions (Phase 2+): + +1. **Task Priorities:** Execution order based on priority field +2. **Serial Pipelines:** Machine A → B → C task chains +3. **GPU Availability Polling:** Query DevBox before submitting work +4. **Cost Tracking:** Log resource usage per task +5. **Notification Webhooks:** Alert on task completion +6. **Web Dashboard:** Real-time task status visualization + +--- + +## Questions? + +Refer to research report: `research/active/cross-machine-agents/README.md` + +Contact: Seven (Research & Docs) or Ralph (Work Monitor) diff --git a/packages/squad-sdk/templates/skills/docs-catalog-audit/SKILL.md b/packages/squad-sdk/templates/skills/docs-catalog-audit/SKILL.md new file mode 100644 index 000000000..a1517ef51 --- /dev/null +++ b/packages/squad-sdk/templates/skills/docs-catalog-audit/SKILL.md @@ -0,0 +1,96 @@ +--- +name: "docs-catalog-audit" +description: "Systematic methodology for auditing Astro-based docs sites: catalog, navigation, quality, gaps" +domain: "documentation" +confidence: "low" +source: "earned — PAO docs audit session 2026-03-22" +--- + +## Context + +Use this skill when reviewing the docs site for quality, completeness, and navigation integrity — especially after periods of high PR churn where pages get added, moved, or partially updated. The audit is read-only and produces an actionable report. + +The docs site uses Astro with content collections at `docs/src/content/docs/` and `docs/src/content/blog/`. Navigation is defined in `docs/src/navigation.ts`. + +## Patterns + +### 1. Catalog every page first + +Read all `.md` files under `docs/src/content/docs/` and `docs/src/content/blog/`. For each, record: +- File path +- Frontmatter title and sidebar label +- Section grouping (from directory structure) +- Approximate length (stub / partial / complete) +- Whether it appears in `navigation.ts` + +### 2. Navigation cross-reference + +Parse `docs/src/navigation.ts` to extract the full sidebar tree (slugs and labels). Then: +- **Dead links:** Nav entries whose slug has no matching content file +- **Orphaned pages:** Content files not referenced in any nav entry +- **Title mismatches:** Nav label differs from the page's H1 or frontmatter title + +### 3. Per-page quality checklist + +For each page, evaluate: + +| Dimension | What to check | +|-----------|--------------| +| **Completeness** | Stub (placeholder only), partial (missing sections), or complete | +| **Freshness** | Version references current? Install commands current? API names current? | +| **Tone** | Microsoft Style Guide: sentence-case headings, active voice, "you", present tense | +| **Scannability** | Paragraphs for narrative, bullets for scannable lists, tables for comparisons | +| **Cross-linking** | Links to related pages? Links valid (no broken anchors)? | +| **Duplicate headings** | Merge artifacts often leave duplicate `##` headings — check for these | + +### 4. Duplicate and overlap detection + +Flag any concept explained in multiple places. Common patterns: +- Root-level legacy files that were superseded by reorganized section pages +- "Choosing X" pages that cover the same ground with different titles +- FAQ answers that duplicate content from feature pages + +### 5. Gap analysis + +Ask: "What would a new user expect to find that isn't here?" Common gaps: +- FAQ or "I'm stuck" page not in sidebar +- Role/feature reference pages written but not linked +- Troubleshooting for major platforms (VS Code, CLI) not discoverable +- Changelog or "what's new" that's stale + +### 6. Report format + +Produce a structured report with: +- **Page catalog table:** Path, status (✅/🟡/🔴), notes +- **Navigation findings:** Dead links, orphans, ordering concerns +- **Duplicate conflicts:** Which pages overlap and which to keep +- **Gap analysis:** Missing content a new user would expect +- **Top 5 urgent issues:** Ranked by user impact +- **Action table:** Numbered, specific, with file paths and priority + +### 7. Automated guard (recommended follow-up) + +After an audit, recommend adding a nav-gap test to `test/docs-build.test.ts`: +- Every `.md` file in the content tree should either appear in `navigation.ts` OR be explicitly listed in an allowlist of standalone pages +- This prevents future orphans from accumulating silently + +## Examples + +Audit trigger scenarios: +- "Review the docs catalog" — full audit +- "Check for orphaned pages" — nav cross-reference only (steps 1-2) +- "Are the docs stale?" — freshness check only (step 3, freshness dimension) +- After a batch of doc PRs — quick re-audit focusing on changed files + +Status ratings: +- ✅ Good — complete, current, well-formatted, in nav +- 🟡 Needs work — minor issues (formatting, missing cross-links, not in nav but not critical) +- 🔴 Problem — stale version refs, broken install commands, high-value page invisible from nav + +## Anti-Patterns + +- **Don't make changes during an audit.** This is a read-only assessment. Fixes come after the report is reviewed. +- **Don't audit blog posts for nav presence.** Blog posts live in their own collection and aren't expected in the sidebar. +- **Don't flag every missing cross-link.** Focus on pages that are conceptually related and would benefit a user navigating between them. +- **Don't treat all orphans equally.** A legacy duplicate at root is a 🔴 (confusing); a niche guide not yet linked is a 🟡 (low urgency). +- **Don't skip the root-level legacy check.** Root-level `.md` files outside section folders are the most common source of stale content after reorganizations. diff --git a/packages/squad-sdk/templates/skills/docs-nav-audit/SKILL.md b/packages/squad-sdk/templates/skills/docs-nav-audit/SKILL.md new file mode 100644 index 000000000..65b6145fd --- /dev/null +++ b/packages/squad-sdk/templates/skills/docs-nav-audit/SKILL.md @@ -0,0 +1,97 @@ +# Skill: Docs Nav Audit + +**Domain:** Documentation +**Trigger phrases:** docs audit, nav audit, orphaned pages, navigation gap, docs catalog +**Agent roles:** pao, scribe +**Confidence:** High (pattern validated across full docs catalog audit) + +--- + +## What This Skill Does + +Performs a systematic cross-reference between a navigation config file and actual content files to identify: + +1. **Dead nav links** — nav entries with no backing content file +2. **Orphaned pages** — content files with no nav entry +3. **Stale content** — pages with outdated version numbers, deprecated commands, or old directory names +4. **Duplicate/overlap** — concepts explained in multiple places (violates "one canonical page per concept") +5. **Format violations** — pages that don't follow the established format standard + +## What This Skill Does NOT Produce + +- Does not make file changes (read-only audit pattern) +- Does not rewrite content (separate task) +- Does not update navigation.ts (separate task requiring docs-test-sync) + +--- + +## How to Run This Audit + +### Step 1: Map nav slugs + +Read the navigation config (e.g., `docs/src/navigation.ts`). Extract all slugs from `NAV_SECTIONS` and `STANDALONE_PAGES` into a flat list. + +### Step 2: Map content files + +List all `.md` files under `docs/src/content/docs/` recursively. Normalize each to a slug by removing the `.md` extension and the base path prefix. + +### Step 3: Cross-reference + +``` +orphaned = content_files - nav_slugs # files with no nav entry +dead_links = nav_slugs - content_files # nav entries with no file +``` + +### Step 4: Spot-check stale content + +For each page, check for: +- Version numbers in H2 headings vs. current release (read `CHANGELOG.md` or `whatsnew.md`) +- Deprecated install commands: `npx github:bradygaster/squad`, `@bradygaster/create-squad` +- Old directory names: `.ai-team/`, `.ai-team-templates/` +- Deprecated distribution paths: `npx github:...` + +### Step 5: Check format standards + +Each page should have: +- H1 title (no frontmatter in Squad docs) +- Experimental warning callout immediately after H1 +- "Try this" code block early on +- Horizontal rule (`---`) before content sections +- H2 headings (sentence case) + +### Step 6: Identify duplicates + +Flag pairs where: +- Two pages cover the same concept (check H1, intro paragraph, and first H2 section) +- A root-level file duplicates a file in a subdirectory +- A legacy "tour" page duplicates a current "get-started" page + +--- + +## Report Format + +Produce a report with: + +1. **Page catalog table** — title, path, nav status, word count (~), status badge +2. **Orphaned pages list** — grouped by directory +3. **Dead nav links list** — with suggested fix +4. **Top issues** — ordered by user impact +5. **Recommended actions** — specific, actionable, ordered + +### Status badge legend + +| Badge | Meaning | +|-------|---------| +| ✅ Good | Complete, current, in nav, format-correct | +| 🟡 Needs work | Minor issues: orphaned, format gap, minor staleness | +| 🔴 Problem | Urgent: stale commands, dead link, high-value orphan | + +--- + +## Known Patterns in This Codebase + +- Root-level files in `docs/src/content/docs/` that predate the Astro rewrite are legacy artifacts; treat as 🔴 unless recently added +- `insider-program.md` historically drifts stale (uses deprecated install paths) +- `whatsnew.md` drifts after each release — check H2 version against `CHANGELOG.md` +- "Tour" pages (`tour-*.md`) are legacy walkthroughs superseded by the `get-started/` section +- The `features/` directory accumulates pages faster than nav gets updated — check for unlisted features on every audit diff --git a/packages/squad-sdk/templates/skills/pandoc-markdown-to-word/SKILL.md b/packages/squad-sdk/templates/skills/pandoc-markdown-to-word/SKILL.md new file mode 100644 index 000000000..5ea153562 --- /dev/null +++ b/packages/squad-sdk/templates/skills/pandoc-markdown-to-word/SKILL.md @@ -0,0 +1,142 @@ +--- +name: "pandoc-markdown-to-word" +description: "Convert Markdown files to Word (.docx) using pandoc" +domain: "document-conversion" +confidence: "medium" +source: "manual — pandoc installed on Dina's Windows laptop, available from bash" +--- + +## Context + +Use this skill when you need to produce Word documents from Markdown sources — for sharing with stakeholders who prefer .docx, generating review documents, or creating polished deliverables from content drafts. Pandoc is installed locally and available from bash shell. + +## Patterns + +### Basic Markdown to Word + +```bash +pandoc input.md -o output.docx +``` + +### With better formatting + +```bash +# Standalone with metadata (title, author, date from YAML front matter) +pandoc input.md -o output.docx --standalone + +# With table of contents +pandoc input.md -o output.docx --toc --toc-depth=3 + +# With a custom reference template (preserves corporate styling) +pandoc input.md -o output.docx --reference-doc=template.docx +``` + +### Creating a reference template + +Extract the default template, customize in Word, then reuse: + +```bash +# Generate default reference doc +pandoc -o custom-reference.docx --print-default-data-file reference.docx + +# Then open custom-reference.docx in Word, modify styles (Heading 1, Body Text, etc.) +# Use it for all future conversions: +pandoc input.md -o output.docx --reference-doc=custom-reference.docx +``` + +### Multiple files → single Word doc + +```bash +# Combine several markdown files into one document +pandoc chapter1.md chapter2.md chapter3.md -o combined.docx --toc + +# With file list +pandoc $(cat filelist.txt) -o combined.docx --toc +``` + +### From PowerShell + +```powershell +# Single file +bash -c "pandoc input.md -o output.docx --toc" + +# With reference template +bash -c "pandoc input.md -o output.docx --reference-doc=template.docx" +``` + +### Batch conversion (all .md files in a directory) + +```bash +for f in *.md; do + pandoc "$f" -o "${f%.md}.docx" --standalone +done +``` + +## Recommended Options + +| Option | Purpose | When to use | +|--------|---------|-------------| +| `--standalone` | Includes document metadata | Always — ensures title/author render properly | +| `--toc` | Generates table of contents | Documents longer than 3 sections | +| `--toc-depth=N` | TOC heading depth (default: 3) | Adjust for deeply/shallowly nested docs | +| `--reference-doc=X.docx` | Apply custom Word styles | When you need corporate branding or consistent formatting | +| `--highlight-style=tango` | Syntax highlighting for code blocks | Documents with code samples | +| `--metadata title="X"` | Set title without YAML front matter | Quick one-offs without editing the .md | +| `--wrap=none` | No line wrapping in source | Prevents odd paragraph breaks | +| `-f gfm` | Input is GitHub Flavored Markdown | When source uses GFM extensions (task lists, tables, strikethrough) | + +## YAML Front Matter + +Pandoc reads YAML front matter for document metadata: + +```markdown +--- +title: "Deep Dive: Content Strategy" +author: "Dina Berry" +date: "March 2026" +abstract: "Overview of the content strategy for Azure developer documentation." +--- + +# Section 1 +... +``` + +This renders as the Word document's title page, author field, and abstract. + +## Word-Specific Tips + +1. **Styles map to headings** — `# H1` → Word "Heading 1", `## H2` → "Heading 2", etc. Customize these in the reference doc. +2. **Tables render natively** — Markdown tables become real Word tables (not images). +3. **Code blocks** — Rendered with monospace font and optional syntax highlighting. +4. **Images** — Local image paths are embedded. Use relative paths. +5. **Links** — Become clickable hyperlinks in Word. +6. **Footnotes** — Pandoc footnote syntax (`[^1]`) becomes Word footnotes. + +## Common Workflows + +### Content review cycle +```bash +# Author writes in markdown, generates Word for reviewer +pandoc article.md -o article-review.docx --toc --reference-doc=msft-template.docx +# Reviewer comments in Word, author incorporates back into markdown +``` + +### Presentation prep → Word handout +```bash +# Convert presentation notes to Word handout +pandoc presentation-notes.md -o handout.docx --toc --metadata title="Deep Dive Handout" +``` + +### Multi-source report +```bash +# Combine analysis files into single report +pandoc executive-summary.md findings.md recommendations.md -o report.docx \ + --toc --metadata title="Q3 Content Strategy Report" +``` + +## Anti-Patterns + +- **Don't expect pixel-perfect layout.** Word conversion prioritizes structure over visual design. Use `--reference-doc` for styling. +- **Don't use HTML in markdown for Word output.** Raw HTML blocks are often ignored or poorly rendered. Stick to pure Markdown/Pandoc syntax. +- **Don't forget `-f gfm` for GitHub-style markdown.** Without it, task lists (`- [ ]`) and strikethrough (`~~text~~`) won't convert properly. +- **Don't skip `--standalone` for formal documents.** Without it, metadata (title, author) won't appear in the Word document properties. diff --git a/packages/squad-sdk/templates/skills/pandoc-pdf-to-markdown/SKILL.md b/packages/squad-sdk/templates/skills/pandoc-pdf-to-markdown/SKILL.md new file mode 100644 index 000000000..262241585 --- /dev/null +++ b/packages/squad-sdk/templates/skills/pandoc-pdf-to-markdown/SKILL.md @@ -0,0 +1,94 @@ +--- +name: "pandoc-pdf-to-markdown" +description: "Convert PDF files to clean Markdown using pandoc" +domain: "document-conversion" +confidence: "medium" +source: "manual — pandoc installed on Dina's Windows laptop, available from bash" +--- + +## Context + +Use this skill when you need to extract content from PDF files into Markdown for editing, analysis, or content workflows. Pandoc is installed locally and available from bash shell on the Windows laptop. + +## Patterns + +### Basic PDF to Markdown + +```bash +pandoc input.pdf -o output.md +``` + +### With better formatting options + +```bash +# Wrap lines at 80 chars, extract media to subfolder +pandoc input.pdf -o output.md --wrap=none --extract-media=./media + +# ATX-style headers (# instead of underline style) +pandoc input.pdf -o output.md --wrap=none --atx-headers +``` + +### Batch conversion (all PDFs in a directory) + +```bash +for f in *.pdf; do + pandoc "$f" -o "${f%.pdf}.md" --wrap=none --atx-headers --extract-media=./media +done +``` + +### From PowerShell (calling bash) + +```powershell +# Single file +bash -c "pandoc input.pdf -o output.md --wrap=none --atx-headers" + +# Batch +bash -c 'for f in *.pdf; do pandoc "$f" -o "${f%.pdf}.md" --wrap=none --atx-headers; done' +``` + +## Recommended Options + +| Option | Purpose | When to use | +|--------|---------|-------------| +| `--wrap=none` | Prevents artificial line breaks | Always — preserves paragraph structure | +| `--atx-headers` | Uses `#` headers instead of underline | Always — standard Markdown style | +| `--extract-media=./media` | Pulls embedded images to a folder | When PDF has images/diagrams | +| `--standalone` | Includes YAML metadata block | When you want title/author/date extracted | +| `-t gfm` | Output GitHub Flavored Markdown | When targeting GitHub repos or docs | +| `--shift-heading-level-by=-1` | Promotes headings up one level | When PDF has deeply nested structure | + +## Post-Conversion Cleanup + +PDF-to-Markdown output typically needs cleanup: + +1. **Tables** — Pandoc often mangles complex PDF tables. Review and reformat manually. +2. **Headers** — Check hierarchy; PDFs may use visual formatting (bold/large text) instead of semantic headers. +3. **Images** — Verify extracted images are correct and paths are valid. +4. **Page numbers/footers** — Remove repeated headers, footers, page numbers. +5. **Line breaks** — Remove orphaned line breaks mid-sentence (common from PDF column layouts). +6. **Special characters** — Fix encoding artifacts (curly quotes, em dashes, ligatures). + +## Other Useful Conversions + +Pandoc handles many formats beyond PDF→Markdown: + +```bash +# DOCX to Markdown +pandoc input.docx -o output.md --wrap=none --atx-headers --extract-media=./media + +# Markdown to DOCX +pandoc input.md -o output.docx + +# Markdown to PowerPoint (useful for deep dive presentations!) +pandoc input.md -o output.pptx + +# HTML to Markdown +pandoc input.html -o output.md -t gfm +``` + +## Anti-Patterns + +- **Don't assume perfect conversion.** PDF is a visual format, not semantic. Complex layouts (multi-column, sidebars, callout boxes) will produce messy output. +- **Don't skip cleanup.** Always review output before using in content workflows. +- **Don't use pandoc for scanned PDFs.** Pandoc reads text-layer PDFs only. For scanned/image PDFs, you need OCR (Tesseract) first. +- **Don't forget `--wrap=none`.** Without it, pandoc inserts hard line breaks at ~72 chars, splitting paragraphs. diff --git a/packages/squad-sdk/templates/skills/power-bi-mcp/SKILL.md b/packages/squad-sdk/templates/skills/power-bi-mcp/SKILL.md new file mode 100644 index 000000000..7ad6c02f2 --- /dev/null +++ b/packages/squad-sdk/templates/skills/power-bi-mcp/SKILL.md @@ -0,0 +1,85 @@ +# Power BI Remote MCP Server — Setup & Usage + +## Purpose +Use the remote Power BI MCP server to query Power BI reports directly from Copilot (VS Code or CLI). Provides access to all reports you normally access via the web — way better than wrestling with web views. + +## Confidence: high +Source: Karl Erickson (Teams, 2026-03-13 8:27 AM), validated by Dina Berry same day and 2026-03-16. + +## Source +- **Karl Erickson** shared setup instructions and tips in Teams on 2026-03-13 +- **Jon Burchel** asked about write capabilities (Karl: "Could you elaborate? Do you mean build a web-based dashboard?") +- **Dina Berry** confirmed success after auth troubleshooting + +## Installation + +### Step 1: Install in VS Code first +1. Go to the Microsoft Learn article: [Get started with the remote Power BI MCP server](https://learn.microsoft.com/power-bi/mcp-server) +2. Use the **"quick installation" button** on that page to install to VS Code +3. Complete auth in VS Code — you MUST see the **explicit web-based auth dialogue** (if you don't see it, it's not working) + +### Step 2: Install in Copilot CLI +1. After VS Code auth succeeds, use the config from VS Code to install to Copilot CLI via `/mcp add` +2. Use the same JSON config info that VS Code generated + +**⚠️ CRITICAL ORDER:** You MUST do VS Code first, then CLI. Karl: "FWIW I couldn't get it to work in Copilot CLI at all until I first did the one-click install in VS Code, and then get it to auth in VS Code. Then I could add the MCP server to Copilot CLI using the same info from the JSON config, and it worked." + +## Auth Troubleshooting + +| Problem | Solution | +|---------|----------| +| Auth expiration errors in Copilot CLI | Exit and resume Copilot to force re-auth | +| MCP server not connecting | Try: Ctrl+Shift+P → MCP: List Servers → powerbi-remote → Start Server (or Restart Server, or Disconnect Account then Start Server) | +| Not clear why it's not working | If you don't see the explicit web-based auth dialogue, it's NOT working. Must see the browser auth popup. | +| "Error fetching authorization server metadata" 404 | This is the OAuth metadata discovery failing — restart the MCP server and re-auth | + +## Usage — Example Prompts + +After auth, you can access any report you normally access via the web: + +- **Content Quality Report queries:** + ``` + Using the Content Quality Report, show me all articles where the Author is '<ms-alias>' + that are past their freshness date. Show the Days til Stale, Title, and the raw GitHub URL. + ``` + +- **Content Engagement Report queries:** + ``` + Using the Content Engagement Report, show me engagement metrics for articles authored by '<ms-alias>'. + ``` + +- **Report by URL:** Start by pasting a report URL and the MCP server figures out the report name and structure. This is a great way to discover what's available. + +- **Report by name:** After initial auth, you can reference reports by display name (e.g., "Content Quality Report", "Content Engagement Report") — no URL needed. + +- **General access:** After auth, you should be able to get anything that you normally have access to via the web + +## Capabilities + +- ✅ **READ reports** — query any Power BI report you have web access to +- ❓ **WRITE reports** — unclear. Jon Burchel asked; Karl asked for clarification ("Do you mean build a web-based dashboard?"). Status: unresolved as of 2026-03-13. + +## Configuration + +The MCP server config lives in `.copilot/mcp-config.json` (for CLI) or VS Code MCP settings. The server identifier is `powerbi-remote`. + +## Known Reports + +These Power BI reports are confirmed accessible and useful for content work: + +| Report Name | Use Case | +|-------------|----------| +| Content Quality Report | Freshness dates, stale articles, author lookup, GitHub URLs | +| Content Engagement Report | Page views, engagement metrics, content performance | + +> Tip: If you know a report exists but aren't sure of the exact name, paste the report URL first. The MCP server resolves it to the report name + schema automatically. + +## Related +- Microsoft Learn article: "Get started with the remote Power BI MCP server" +- This is useful for the daily content metrics workflow — can query Content Quality Report and Content Engagement Report directly +- Potential integration with `.squad/skills/pbi-report-analysis/SKILL.md` if that skill exists + +## People +- **Karl Erickson** — original tip, setup expertise, troubleshooting knowledge +- **Jon Burchel** — interested in write capabilities +- **Dina Berry** — confirmed working after setup diff --git a/packages/squad-sdk/templates/skills/pr-merge-workflow/SKILL.md b/packages/squad-sdk/templates/skills/pr-merge-workflow/SKILL.md new file mode 100644 index 000000000..96c46dd1b --- /dev/null +++ b/packages/squad-sdk/templates/skills/pr-merge-workflow/SKILL.md @@ -0,0 +1,227 @@ +--- +name: "pr-merge-workflow" +description: "Structured merge-readiness gate for pull requests before running gh pr merge" +domain: "merge-gates, pull-request-quality, workflow-orchestration" +confidence: "low" +source: "manual (Flight workload-management proposal synthesized by Procedures)" +tools: + - name: "github-mcp-server-pull_request_read" + description: "Read pull request metadata, status, files, reviews, and check runs" + when: "Use as the primary merge-readiness evidence source for one PR" +--- + +## Context + +`pr-merge-workflow` is the structured gate that sits **before** `gh pr merge`. + +Use it when a human or Ralph needs a clear answer to: **is this PR actually merge-ready, and if not, what exactly is blocking it?** The skill should turn scattered PR metadata into a single verdict with explicit blockers, a concise change summary, and a recommended next action. + +This skill is primarily **single-PR and repo-aware**. When invoked from a cross-repo digest, the repo coordinates may come from `.squad/doc-intelligence.local.json` or the activity digest that referenced the PR. When invoked directly (`squad merge #N` or `squad merge #N --dry-run`), use the current repo context or explicit owner/repo input. + +The skill is a **gate, not an auto-merge policy**. It evaluates readiness, reports blockers, and only hands off to merge execution after a clear green verdict and explicit user intent. + +## Patterns + +### Invocation Pattern + +Support two modes: + +1. **Dry-run status mode** + - Example: `squad merge #N --dry-run` + - Purpose: show merge readiness without changing GitHub state +2. **Execution-prep mode** + - Example: `squad merge #N` + - Purpose: run the same checks first, then allow merge execution only if the verdict is green + +Both modes use the same evaluation logic. Execution mode simply adds the final human-triggered merge step. + +### Evidence Collection Pattern + +For the target PR, gather five views from `github-mcp-server-pull_request_read`: + +1. `get` — baseline metadata, base/head branches, mergeability clues, author, labels, draft state +2. `get_status` — combined commit status for quick pass/fail signal +3. `get_files` — file count and change footprint +4. `get_reviews` — approvals, change requests, review churn +5. `get_check_runs` — granular CI state across jobs + +If MCP access is unavailable, fall back to CLI inspection: + +- `gh pr view {N}` +- `gh pr checks {N}` + +Treat the fallback as evidence gathering, not a separate policy path. The same verdict structure still applies. + +### Required Gate Pattern + +A PR is only `ready to merge` when all required gates are green. + +#### Gate 1: Review completeness + +Check: +- required reviewers approved +- no unresolved `changes requested` state from required reviewers +- no obvious missing approval when the repo policy or PR metadata signals review is still pending + +Output should identify: +- who approved +- who is still requested / pending +- who requested changes + +#### Gate 2: CI health + +Check: +- all required check runs are passing +- no in-progress required jobs remain +- no failed or cancelled required jobs remain + +If checks are mixed, say so explicitly: +- `waiting for CI` +- `failing CI` +- `partial / unknown CI state` + +#### Gate 3: Mergeability + +Check: +- no merge conflicts +- PR is not draft +- GitHub reports mergeable or does not indicate a conflict block + +If mergeability is unknown, do not silently pass the gate. Mark it `needs attention` and explain what is missing. + +#### Gate 4: Freshness against base + +Check whether the branch is stale relative to the base branch. + +Use evidence such as: +- base/head divergence in PR metadata +- stale branch indicators in checks or status +- repo policy clues that the branch must be updated before merge + +If stale: +- verdict is not `ready to merge` +- suggested action should be `update branch and rerun checks` + +### Change Summary Pattern + +Summarize the PR in a form that supports a fast merge decision. + +Always include: +- file count +- additions/deletions when available +- notable file paths or hotspots if the diff is large +- semantic intent: one short sentence describing what changed in user or system terms + +The semantic intent should describe the change category, for example: +- CLI behavior change +- prompt/skill contract update +- runtime bug fix +- docs-only clarification +- config surface change + +Do not reproduce the whole diff. Compress it into a decision-ready snapshot. + +### Verdict Pattern + +Return one of three top-level actions: + +#### `ready to merge` +Use when reviews, CI, mergeability, and freshness are all green. + +#### `waiting for X` +Use when the PR appears healthy but is still pending a specific dependency such as: +- one reviewer approval +- CI completion +- branch update + +#### `needs attention` +Use when there is an actual blocker or risk, such as: +- requested changes +- failing checks +- merge conflicts +- unknown mergeability +- stale branch with required update + +### Output Pattern + +Emit a structured pre-merge report. + +```markdown +# PR Merge Check — {owner}/{repo}#{number} + +## Verdict +- **Action:** ready to merge | waiting for X | needs attention +- **Why:** {one-line reason} + +## Review Gate +- Approvals: {names} +- Pending reviewers: {names or none} +- Changes requested: {names or none} + +## CI Gate +- Combined status: {success | pending | failure | mixed} +- Blocking jobs: + - {job name} — {state} + +## Mergeability Gate +- Draft: yes | no +- Conflicts: yes | no | unknown +- Branch freshness: current | stale | unknown + +## Change Summary +- Files changed: {count} +- Lines changed: +{adds} / -{dels} +- Intent: {semantic summary} + +## Suggested Next Step +- {merge now | wait for reviewer X | rerun failing job | update branch | resolve conflicts} +``` + +### Dry-Run Pattern + +`--dry-run` must produce the full report without triggering merge execution. That output should be stable enough for dashboards, REPL previews, and overnight summaries. + +### Owner Routing Pattern + +- **FIDO** owns gate semantics, blocker language, and review policy interpretation +- **Booster** owns CI/check integration and any workflow-specific status nuances + +If the merge gate needs deeper runtime or CLI wiring, hand implementation to EECOM after the gate contract is stable. + +## Examples + +### Example 1: Green merge + +The PR has two approvals, all check runs are green, the branch is current, and GitHub reports no conflicts. + +**Result:** +- verdict = `ready to merge` +- suggested next step = `merge now` + +### Example 2: One missing approval + +CI is green and the branch is current, but one requested reviewer has not approved yet. + +**Result:** +- verdict = `waiting for Alice's review` +- do not mark as blocked unless there is an actual negative signal + +### Example 3: Stale branch with red CI + +The PR is behind base and one required check run is failing. + +**Result:** +- verdict = `needs attention` +- blockers = `update branch` and `fix failing CI` +- do not suggest merge until both are cleared + +## Anti-Patterns + +- ❌ Running `gh pr merge` without first producing a structured gate verdict +- ❌ Treating one green combined status as enough when individual required checks are failing +- ❌ Ignoring `changes requested` because approvals also exist +- ❌ Hiding merge conflicts behind a generic `not ready` message +- ❌ Calling a stale branch merge-ready when the base branch has moved materially +- ❌ Using dry-run mode as a shallow summary that omits blockers +- ❌ Auto-merging without explicit user intent after the gate passes +- ❌ Reducing the change summary to file count only without semantic intent diff --git a/packages/squad-sdk/templates/skills/pr-quality-checkpoint/SKILL.md b/packages/squad-sdk/templates/skills/pr-quality-checkpoint/SKILL.md new file mode 100644 index 000000000..74ecafdca --- /dev/null +++ b/packages/squad-sdk/templates/skills/pr-quality-checkpoint/SKILL.md @@ -0,0 +1,291 @@ +--- +name: "pr-quality-checkpoint" +description: "Pre-flight PR readiness scan that summarizes review state, CI health, change risk, and the next action" +domain: "quality-gates, pull-request-triage, orchestration" +confidence: "low" +source: "manual (Procedures designed the first-pass PR checkpoint workflow for Ralph and reviewer handoff)" +tools: + - name: "github-mcp-server-list_pull_requests" + description: "List candidate PRs awaiting review" + when: "Use during Ralph's overnight sweep to enumerate open PRs that need a checkpoint summary" + - name: "github-mcp-server-pull_request_read" + description: "Read PR metadata, files, reviews, review comments, status, and check runs" + when: "Use for the primary checkpoint pass on a specific PR number" + - name: "powershell" + description: "Run gh fallback commands when GitHub MCP data is incomplete" + when: "Use `gh pr view --json ...` and `gh pr checks` as the fallback path when MCP fields are missing or ambiguous" +--- + +## Context + +`pr-quality-checkpoint` is a pre-review quality gate, not a replacement for code review. + +Use it when Ralph is sweeping open PRs before Brady starts the day, or when someone asks for an on-demand readiness summary like: + +```text +squad pr review #123 --summarize +``` + +The skill answers one question fast: **is this PR worth human review time right now, and if not, what should happen next?** + +It should surface review state, CI health, change shape, staleness, conflicts, and doc/test risk in one compact summary. Owner routing is split by failure mode: + +- **FIDO** owns quality signals: failing tests, missing tests, weak coverage indicators, stale review state +- **EECOM** owns runtime and mergeability signals: branch health, PR wiring, conflict state, broken automation surfaces + +## Patterns + +### Invocation Modes + +Run the same checkpoint routine in two modes: + +1. **Ralph overnight sweep** + - list open PRs awaiting Brady's review + - run one checkpoint per PR + - feed the result into the morning dashboard or digest +2. **On-demand PR summary** + - accept a concrete PR number + - return the checkpoint only for that PR + +The routine is deterministic. Same inputs, same summary shape. + +### PR Checkpoint Routine + +Run these checks for every PR, in this order. + +#### 1. Load the PR envelope + +Use `github-mcp-server-pull_request_read` with `method: "get"` to capture: + +- title, author, draft state +- created/update timestamps +- mergeability / merge-state fields +- additions, deletions, changed files count +- base branch vs head branch +- requested reviewers / review decision fields when available +- latest commit metadata, including commit age +- branch divergence from base when available (behind/ahead) + +If any critical field is missing, fall back to: + +```text +gh pr view <N> --json number,title,author,isDraft,createdAt,updatedAt,mergeStateStatus,reviewDecision,additions,deletions,changedFiles,files,commits,latestReviews,statusCheckRollup +``` + +#### 2. Review state summary + +Use: + +- `method: "get_reviews"` for approval / change-request / comment history +- `method: "get_review_comments"` for unresolved review-thread pressure + +Normalize review state into: + +- **approvals** — count latest effective approvals +- **requested changes** — count latest effective change requests +- **pending reviewers** — requested reviewers and still-required reviewers not yet satisfied + +Rules: + +- collapse each reviewer's latest effective state instead of double-counting history +- treat active change requests as blocking unless clearly superseded +- if required-review metadata is unavailable, use requested reviewers plus review decision as the best approximation +- unresolved blocking threads should downgrade readiness even when formal approvals exist + +#### 3. CI and automation state + +Use both: + +- `method: "get_check_runs"` +- `method: "get_status"` + +Summarize counts for: + +- passing +- failing +- pending / in progress +- cancelled or neutral when they affect readiness + +Rules: + +- any failure is a blocking signal +- any pending required check means **wait for CI** +- distinguish flaky or optional noise from required failures when the payload makes that obvious +- if check metadata is incomplete, fall back to `gh pr checks <N>` + +#### 4. Change summary and semantic classification + +Use `method: "get_files"` to compute: + +- file count +- total lines added / removed +- dominant path clusters (`src/`, `test/`, `docs/`, `.squad/`, `templates/`, config files) +- semantic classification + +Classify the PR as one primary type, with secondary tags when needed: + +- **bugfix** — targeted behavior correction, tests often near production files +- **feature** — new capability, command, workflow, or user-visible path +- **refactor** — structural rework with little user-facing change +- **docs** — documentation-only or docs-dominant edits +- **config** — workflow, CI, templates, package, or environment contract changes + +Prefer semantic classification over raw path counting. A small `src/` change plus a new command should still classify as a feature. + +#### 5. Staleness and branch health + +Compute and report: + +- PR age +- last commit age +- whether the branch is behind base +- whether the PR is draft + +Flag as stale when any of these hold: + +- PR has been open long enough to suggest review context drift +- last commit is old and CI/review state is still unresolved +- branch is materially behind base + +The exact age threshold can be repo-tuned later. For the first pass, treat multi-day inactivity as a warning, not an automatic block, unless it combines with conflicts or failing CI. + +#### 6. Conflict check + +Use the PR mergeability fields from `get`. + +- merge conflicts or blocked merge state => **resolve conflicts** +- unknown mergeability should be called out explicitly, not silently treated as mergeable +- if behind-base state requires rebase before merge, include that in the action recommendation + +#### 7. Test coverage signal + +Inspect `get_files` output for test-path changes (`test/`, `tests/`, `__tests__/`, `*.test.*`, `*.spec.*`). + +Flag **missing tests** when: + +- behavior changed in runtime, CLI, workflow, or prompt contracts +- but no new or updated tests appear + +Do not flag missing tests for: + +- docs-only changes +- comment-only changes +- clearly mechanical config edits with no behavior impact + +This is a heuristic signal, not proof of missing coverage. + +#### 8. Docs impact signal + +Flag **missing docs** when the PR appears to change user-facing or operator-facing behavior but does not touch likely doc surfaces: + +- `README.md` +- `docs/` +- `.squad/` +- `templates/` + +Use the repo's existing rule: every PR should be evaluated for doc impact. A missing-docs flag is especially important for feature, config, workflow, or prompt-contract changes. + +### Badge and Recommendation Mapping + +Emit exactly one badge and one recommended action. + +#### Badge mapping + +- **✅ Ready to merge** + - no active change requests + - required checks passing + - mergeable + - no blocking flags +- **⚠️ Needs attention** + - non-blocking warnings exist (stale, missing tests, missing docs, pending reviewers with otherwise healthy CI) +- **🔴 Blocked** + - failing checks, active change requests, draft state, merge conflicts, or clearly blocked automation + +#### Recommended action mapping + +- `merge` — approvals satisfied, checks pass, no blockers remain +- `review` — healthy enough for human review, but not yet in a merge-ready state +- `wait for CI` — pending required checks +- `address feedback` — active change requests or unresolved blocking review pressure +- `resolve conflicts` — merge conflicts or behind-base state that blocks merge + +### Output Shape + +Return one compact checkpoint block per PR: + +```markdown +## PR #<N> — <title> +Status: ✅ Ready to merge | ⚠️ Needs attention | 🔴 Blocked +Recommended action: merge | review | wait for CI | address feedback | resolve conflicts + +- Review summary: <approvals>, <requested changes>, <pending reviewers> +- CI summary: <pass>/<fail>/<pending> +- Change summary: <files> files, +<additions> / -<deletions>, <type> +- Flags: <stale?>, <conflicts?>, <missing tests?>, <missing docs?> +- Notes: <one-sentence explanation of the main driver> +``` + +Keep the explanation short, decisive, and reviewer-oriented. + +### Ralph Integration and Routing + +For Ralph's overnight sweep: + +- run this skill for every PR awaiting Brady's review +- store the summary in the overnight dashboard input +- surface blocked PRs first, then attention items, then ready items + +Route follow-up ownership by dominant issue: + +- **FIDO** when the checkpoint is blocked by tests, CI quality, or missing coverage signal +- **EECOM** when the checkpoint is blocked by runtime plumbing, mergeability, or branch health +- mixed signals can name both, but still produce one action recommendation + +## Examples + +### Example 1: Healthy PR ready for review or merge + +A PR has two approvals, no change requests, all checks pass, and no merge conflicts. + +**Result:** +- Badge: `✅ Ready to merge` +- Recommended action: `merge` +- Flags: none + +### Example 2: CI still running + +A PR is mergeable and already approved, but two required checks are still pending. + +**Result:** +- Badge: `⚠️ Needs attention` +- Recommended action: `wait for CI` +- Notes call out that reviewer time can wait until checks settle + +### Example 3: Behavior changed without tests or docs + +A PR adds a new CLI behavior under `src/` but does not touch `test/`, `README.md`, `docs/`, `.squad/`, or `templates/`. + +**Result:** +- Badge: `⚠️ Needs attention` +- Recommended action: `review` +- Flags: `missing tests`, `missing docs` + +### Example 4: Review blocked by feedback and conflicts + +A PR has an active change request and GitHub reports merge conflicts. + +**Result:** +- Badge: `🔴 Blocked` +- Recommended action: `resolve conflicts` +- Notes mention that conflict resolution comes before another human pass + +## Anti-Patterns + +- ❌ Treating this checkpoint as a substitute for code review +- ❌ Reporting raw GitHub data without collapsing it into one action recommendation +- ❌ Counting every historical review event instead of each reviewer's latest effective state +- ❌ Calling a PR ready when required checks are still pending +- ❌ Ignoring unresolved review pressure because formal approvals exist +- ❌ Flagging missing tests for docs-only or comment-only changes +- ❌ Flagging missing docs for pure refactors with no user-facing effect +- ❌ Hiding unknown mergeability; if it is unknown, say it is unknown diff --git a/packages/squad-sdk/templates/skills/session-setup/SKILL.md b/packages/squad-sdk/templates/skills/session-setup/SKILL.md new file mode 100644 index 000000000..7d2e0c6bb --- /dev/null +++ b/packages/squad-sdk/templates/skills/session-setup/SKILL.md @@ -0,0 +1,115 @@ +--- +name: "session-setup" +description: "Discovers all project repos, ensures they are clean, synced to main, and on fresh session branches before starting work" +domain: "workflow" +confidence: "high" +source: "manual" +--- + +## Context + +Every new squad session must start with all repositories in a clean, synced state on fresh branches. This prevents cross-session contamination and ensures PRs are clean. + +## Repository Discovery + +At the start of every session, discover the project's repos dynamically: + +1. **Identify the squad repo** — The repo you are currently in, which contains the `.squad/` directory. +2. **Find the project directory** — Go one level up from the squad repo (`..`). This is the project root that groups all related repos together. +3. **Scan for sibling repos** — List all directories in the project root that are git repos (contain a `.git/` directory or folder). Each one is a project repo managed by this session. +4. **Classify repos:** + - **Squad repo** (`[squad]`) — The repo containing `.squad/`. Gets squad-only changes (`.squad/` files, decisions, charters, skills, session summaries). + - **Content repos** (`[content]`) — All other sibling git repos. Get code and content changes (source code, docs, prompts, scripts, templates, infra). + +> **Key rule:** Squad state (`.squad/`) lives ONLY in the squad repo. Never create `.squad/` in content repos. + +Present the discovered repos to the user: + +``` +📁 Project directory: ../ + [squad] <squad-repo-dir>/ — squad config + [content] <content-repo-1>/ — code/content + [content] <content-repo-2>/ — code/content + ... +``` + +If only the squad repo is found (no sibling content repos), that is valid — some projects are squad-only. + +## Procedure + +Run these steps at the start of every new session, BEFORE any work begins. + +### Step 0 — Resume context from last session + +Check if `<squad-repo>/.squad/sessions/latest.md` exists. If it does: +- Read it and present the summary to the user +- Highlight "Where we stopped" and "Open items for next session" +- Note any open PRs that may need attention +- Ask: "Want to continue this work, or start something new?" + +If it doesn't exist, skip this step. + +### Step 1 — Check for uncommitted work + +For **each** discovered repo, run `git status`. If there are uncommitted changes: +- Show the user which repo has uncommitted work and what changed +- Ask the user what to do: commit, stash, or discard +- Do NOT proceed until every working tree is clean + +### Step 2 — Sync main to remote + +For **each** discovered repo: +```bash +cd ../<repo-dir> +git checkout main +git fetch origin +git reset --hard origin/main +``` + +### Step 3 — Create session branches + +Branch naming: `squad/<session-description>` (e.g., `squad/fix-branding-quality`) + +For **each** discovered repo: +```bash +cd ../<repo-dir> +git checkout -b squad/<branch-name> +``` + +Use the SAME branch name in all repos so they are easy to correlate. + +### Step 4 — Verify + +Confirm **every** discovered repo shows: +- Clean working tree (`git status` shows nothing to commit) +- On the new session branch +- Main is at the same commit as remote + +Report the starting state to the user: +``` +✅ Session ready + [squad] <squad-repo>/ — on squad/<branch> from main@<short-sha> + [content] <content-repo-1>/ — on squad/<branch> from main@<short-sha> + [content] <content-repo-2>/ — on squad/<branch> from main@<short-sha> + ... +``` + +## Rules + +- **Never skip this procedure** — even for "quick fixes" +- **Never commit directly to main** — always use session branches +- **Same branch name in all repos** — keeps correlation obvious +- **Ask before discarding uncommitted work** — the user may want to stash or commit first +- **Squad repo gets squad-only changes** — `.squad/` files, decisions, agent history, skills +- **Content repos get code/content changes** — source code, docs, prompts, scripts, templates, infra +- **Never create `.squad/` in content repos** — squad state lives only in the squad repo + +## Anti-Patterns + +- Starting work on a stale main (forgot to fetch/reset) +- Using different branch names across repos +- Committing code changes to the squad repo +- Committing squad config to a content repo +- Working directly on main without a session branch +- Creating `.squad/` directories in content repos +- Hardcoding repo paths instead of using relative discovery diff --git a/packages/squad-sdk/templates/skills/session-teardown/SKILL.md b/packages/squad-sdk/templates/skills/session-teardown/SKILL.md new file mode 100644 index 000000000..c8a772182 --- /dev/null +++ b/packages/squad-sdk/templates/skills/session-teardown/SKILL.md @@ -0,0 +1,130 @@ +--- +name: "session-teardown" +description: "End-of-session: commit all work across discovered repos, record progress, optionally create PRs, and save state for next session" +domain: "workflow" +confidence: "high" +source: "manual" +--- + +## Context + +Every session ends with a structured teardown that commits work, records progress, and saves state so the next session can pick up exactly where we left off. + +## Repository Discovery + +Use the same discovery process as `session-setup`: + +1. **Identify the squad repo** — The repo containing `.squad/`. +2. **Find the project directory** — One level up from the squad repo (`..`). +3. **Scan for sibling repos** — All directories in the project root that are git repos (contain `.git/`). +4. **Classify repos:** + - **Squad repo** (`[squad]`) — Gets squad-only commits. + - **Content repos** (`[content]`) — Get code/content commits. + +> **Key rule:** Squad state (`.squad/`) lives ONLY in the squad repo. Never write `.squad/` files to content repos. + +## Procedure + +### Step 1 — Commit uncommitted work + +For **each** discovered repo, check `git status`. If there are changes: + +**Squad repo** — commit squad-only changes (decisions, agent history, skills, sessions): +```bash +cd ../<squad-repo-dir> +git add -A +git commit -m "session: <summary of squad changes>" +git push origin <branch> +``` + +**Each content repo** — commit code/content changes: +```bash +cd ../<content-repo-dir> +git add -A +git commit -m "session: <summary of code/content changes>" +git push origin <branch> +``` + +If a repo has no changes, skip it and note "no changes." + +### Step 2 — Record session summary + +Write a session summary to `<squad-repo>/.squad/sessions/latest.md` with this format: + +```markdown +# Session Summary — <date> + +## Branches +<!-- List ALL discovered repos and their current branch --> +- <squad-repo>: `<branch-name>` +- <content-repo-1>: `<branch-name>` +- <content-repo-2>: `<branch-name>` + +## What we worked on +- <bullet summary of tasks completed> +- <bullet summary of tasks in progress> + +## Where we stopped +- <specific description of the exact stopping point> +- <any pending decisions or questions> + +## Open items for next session +- <what needs to happen next> +- <any blocked items and why> + +## PRs +<!-- List ALL discovered repos and their PR status --> +- <squad-repo>: <PR URL or "no PR"> +- <content-repo-1>: <PR URL or "no PR"> +- <content-repo-2>: <PR URL or "no PR"> +``` + +The session summary always lives in the squad repo. Create the `.squad/sessions/` directory if it does not exist. + +### Step 3 — Ask about PR merge + +For **each** repo that has a pushed branch, ask the user: + +> "Is the work on `<branch>` in `<repo>` ready to merge to main?" + +Options: +- **Yes — merge now**: Create PR (if not exists), merge, delete branch +- **No — keep the branch open**: Leave for next session +- **Create PR but don't merge**: Push and create PR for review + +### Step 4 — Verify final state + +Report the ending state: +``` +📋 Session closed + [squad] <squad-repo>/ — <branch>@<sha> — <clean/pushed/merged> + [content] <content-repo-1>/ — <branch>@<sha> — <clean/pushed/merged> + [content] <content-repo-2>/ — <branch>@<sha> — <clean/pushed/merged> + ... + Summary saved to <squad-repo>/.squad/sessions/latest.md +``` + +## Session File Location + +- Current session: `<squad-repo>/.squad/sessions/latest.md` (overwritten each session) +- The `session-setup` skill reads `latest.md` to remind the user where they left off +- Session files live ONLY in the squad repo — never in content repos + +## Rules + +- **Always commit before closing** — never leave uncommitted work +- **Always push** — local-only commits can be lost +- **Always write the session summary** — the next session depends on it +- **Ask before merging** — never auto-merge without user confirmation +- **Include PR URLs** — makes it easy to find open work +- **Session summary goes in the squad repo only** — never write `.squad/` files to content repos + +## Anti-Patterns + +- Closing a session with uncommitted changes +- Forgetting to push (commits stuck locally) +- No session summary (next session starts blind) +- Auto-merging without asking +- Vague summaries like "worked on stuff" — be specific about stopping point +- Writing session files to content repos instead of the squad repo +- Hardcoding repo paths instead of using relative discovery diff --git a/packages/squad-sdk/templates/skills/teams-decision-capture/SKILL.md b/packages/squad-sdk/templates/skills/teams-decision-capture/SKILL.md new file mode 100644 index 000000000..44b18db38 --- /dev/null +++ b/packages/squad-sdk/templates/skills/teams-decision-capture/SKILL.md @@ -0,0 +1,174 @@ +--- +name: "teams-decision-capture" +description: "Capture durable team decisions from configured Teams channels into generalized decision inbox files" +domain: "prompt-architecture, decision-intelligence, teams-monitoring" +confidence: "low" +source: "manual (Procedures skill design from Flight's workload management proposal)" +tools: + - name: "workiq-ask_work_iq" + description: "Ask Microsoft 365 Copilot for a synthesized summary of recent Teams discussion" + when: "Use first for the configured channel to identify decisions, conventions, repeated questions, workarounds, and policy changes from the last 24 hours" + - name: "office-mcp-teams_channel" + description: "Read Teams channel metadata and messages directly" + when: "Use after the WorkIQ pass when you need precise verification that a decision, workaround, dissent, or convention change actually appeared in the configured channel" + - name: "office-mcp-search" + description: "Search related Microsoft 365 documents, emails, or meetings" + when: "Use when a thread references files, email, or meetings that clarify the rationale, follow-up, or durable pattern before writing the inbox decision file" +--- + +## Context + +`teams-decision-capture` turns ephemeral Teams channel discussion into durable, auditable decision intake for Squad. + +Use it when: + +- a configured Teams channel likely contains a team decision, policy statement, workflow change, workaround, or repeated question worth preserving +- an operator says `squad decisions capture` or `capture Teams decisions` +- Ralph runs the daily digest cycle and needs a compact queue of merge-ready decision notes for Scribe + +This skill does **not** archive chat. It extracts the reusable pattern and writes generalized markdown files to: + +```text +.squad/decisions/inbox/ +``` + +Reuse the existing local config pattern from `doc-intelligence`: + +- load `.squad/doc-intelligence.local.json` +- reuse the configured `sources.teams[]` entries already maintained for internal monitoring +- do not add a second local config file for this skill +- treat `.squad/skills/doc-intelligence/config.template.json` as the checked-in contract for the Teams source shape + +This skill complements `doc-intelligence`. + +- `doc-intelligence` detects documentation gaps and follow-up work +- `teams-decision-capture` preserves the generalized decision that created the gap or changed the rule + +## Patterns + +### 1. Retrieval Loop + +1. Load `.squad/doc-intelligence.local.json` and resolve the enabled Teams sources plus routing metadata. +2. Run a **WorkIQ summary pass** for the last 24 hours. Ask specifically for decisions, rationale, dissent, repeated questions, workarounds, policy changes, and convention changes. Ignore social chatter, standup updates, and staffing discussion. +3. Use `office-mcp-teams_channel` only when precise verification is needed — for example, to confirm that a workaround was framed as temporary, that dissent existed, or that a convention change was explicitly agreed. +4. Use `office-mcp-search` when the thread points at a document, email, or meeting that contains the rationale or next step needed to generalize the decision safely. +5. Dedupe overlapping signals so one durable decision produces one inbox file. + +### 2. Promotion Heuristic + +Promote a signal when it reflects one or more of these: + +- **Decision points** — what was decided, why, dissent, and next steps +- **Recurring questions** — the same question asked 2+ times, which makes it a FAQ candidate +- **Workarounds** — temporary fixes that should become a documented pattern or migration note +- **Policy statements** — new team rules, guardrails, or process changes +- **Convention changes** — naming changes, workflow changes, or new tool adoption + +Do **not** promote: + +- raw transcripts or message-by-message summaries +- social chatter or standup updates +- one-off individual preferences with no team impact +- private staffing or personnel discussion + +### 3. Confidentiality Filter + +Apply the same confidentiality test used by `doc-intelligence`. + +- Never quote internal chat verbatim. +- Replace names and raw message text with a generalized summary of the pattern. +- If the evidence cannot be described without exposing private conversation, do not emit a decision file. +- Source references should identify the channel and time window in generalized terms, not raw transcript content. + +### 4. Output Contract + +Each captured decision becomes a file at: + +```text +.squad/decisions/inbox/{source}-{brief-slug}.md +``` + +Use this structure for every emitted file: + +```markdown +# Decision Intake — {short title} + +**Captured from:** {generalized Teams source} +**Date:** {YYYY-MM-DD} +**Review route:** Procedures | PAO | Flight + +## Decision point +{what was decided} + +## Rationale +{why the team chose it} + +## Dissent +{material disagreement, trade-off, or "None noted"} + +## Next steps +- {action item} +- {action item} + +## Source reference +Generalized summary of the channel and time window. Do not include raw quotes, raw message links, or private transcript text. +``` + +### 5. Routing Pattern + +Default review ownership is split by durable impact: + +- **Procedures** — prompt contracts, charters, coordinator logic, skills, respawn prompts, and other agent-behavior rules +- **PAO** — messaging, FAQ follow-up, contributor-facing wording, and process explanations that should become docs or guidance +- **Flight** — architecture decisions, repo-boundary changes, or system-shape consequences that need architectural review + +If a captured decision creates a documentation gap, hand the follow-up documentation work to `doc-intelligence` rather than duplicating that analysis here. + +### 6. Ralph Integration + +Ralph can run this skill as part of the daily digest cycle: + +1. Load `.squad/doc-intelligence.local.json` +2. Scan configured Teams sources for the last 24 hours +3. Apply the retrieval loop and confidentiality filter +4. Write only high-signal inbox files +5. Stay quiet when no durable decisions were found + +## Examples + +### Example 1: Prompt contract change + +A Teams discussion settles a new handoff rule for agent review routing. + +**Result:** +- Write `.squad/decisions/inbox/{source}-agent-review-routing.md` +- Summarize the new rule, rationale, any dissent, and next steps without quoting the chat +- Route review to **Procedures** because it changes prompt contracts + +### Example 2: Repeated onboarding question + +Two people ask the same channel how a naming convention should be applied, and the team converges on one answer. + +**Result:** +- Write `.squad/decisions/inbox/{source}-naming-convention.md` +- Capture the convention as a team decision and flag it as a FAQ candidate +- Route follow-up wording/docs to **PAO** and let `doc-intelligence` handle the doc-gap suggestion + +### Example 3: Temporary workaround with architecture impact + +A channel agrees on a stopgap workflow while a broader platform change is still under review. + +**Result:** +- Write `.squad/decisions/inbox/{source}-temporary-workaround.md` +- Record the workaround as temporary, note the trade-off, and list the trigger for revisiting it +- Route review to **Flight** if the workaround changes architecture expectations + +## Anti-Patterns + +- ❌ Creating a new local config file instead of reusing `.squad/doc-intelligence.local.json` +- ❌ Leading with raw Office message reads before a WorkIQ summary pass when a high-level signal check would do +- ❌ Copying internal chat text verbatim into inbox files, PRs, or decision logs +- ❌ Treating every recent thread as a decision worth preserving +- ❌ Logging standups, social chatter, or staffing discussion as durable team decisions +- ❌ Mixing documentation-gap analysis into this skill instead of handing that follow-up to `doc-intelligence` +- ❌ Forgetting to capture dissent or next steps when they materially affect the decision diff --git a/test/shell.test.ts b/test/shell.test.ts index 5872a8deb..db7f51782 100644 --- a/test/shell.test.ts +++ b/test/shell.test.ts @@ -101,21 +101,22 @@ describe('SessionRegistry', () => { describe('Spawn infrastructure', () => { describe('loadAgentCharter', () => { - it('loads charter from test-fixtures/.squad/agents/{name}', () => { - const charter = loadAgentCharter('hockney', FIXTURES); + it('loads charter from test-fixtures/.squad/agents/{name}', async () => { + const charter = await loadAgentCharter('hockney', FIXTURES); expect(charter).toContain('Hockney'); expect(charter).toContain('Tester'); }); - it('lowercases the agent name for path resolution', () => { - const charter = loadAgentCharter('Fenster', FIXTURES); + it('lowercases the agent name for path resolution', async () => { + const charter = await loadAgentCharter('Fenster', FIXTURES); expect(charter).toContain('Core Dev'); }); - it('throws for a missing charter', () => { - expect(() => loadAgentCharter('nobody', FIXTURES)).toThrow( - /No charter found for "nobody"/, - ); + it('throws for a missing charter', async () => { + let caught: Error | undefined; + try { await loadAgentCharter('nobody', FIXTURES); } catch (err) { caught = err as Error; } + expect(caught).toBeDefined(); + expect(caught!.message).toMatch(/No charter found for "nobody"/); }); }); @@ -145,8 +146,8 @@ describe('Spawn infrastructure', () => { describe('Coordinator', () => { describe('buildCoordinatorPrompt', () => { - it('includes team.md content', () => { - const prompt = buildCoordinatorPrompt({ + it('includes team.md content', async () => { + const prompt = await buildCoordinatorPrompt({ teamRoot: FIXTURES, teamPath: join(FIXTURES, '.squad', 'team.md'), }); @@ -154,24 +155,24 @@ describe('Coordinator', () => { expect(prompt).toContain('Fenster'); }); - it('includes routing.md content', () => { - const prompt = buildCoordinatorPrompt({ + it('includes routing.md content', async () => { + const prompt = await buildCoordinatorPrompt({ teamRoot: FIXTURES, routingPath: join(FIXTURES, '.squad', 'routing.md'), }); expect(prompt).toContain('Tests → Hockney'); }); - it('falls back gracefully when team.md is missing', () => { - const prompt = buildCoordinatorPrompt({ + it('falls back gracefully when team.md is missing', async () => { + const prompt = await buildCoordinatorPrompt({ teamRoot: join(FIXTURES, 'nonexistent'), teamPath: join(FIXTURES, 'nonexistent', 'team.md'), }); expect(prompt).toContain('NO TEAM CONFIGURED'); }); - it('falls back gracefully when routing.md is missing', () => { - const prompt = buildCoordinatorPrompt({ + it('falls back gracefully when routing.md is missing', async () => { + const prompt = await buildCoordinatorPrompt({ teamRoot: join(FIXTURES, 'nonexistent'), routingPath: join(FIXTURES, 'nonexistent', 'routing.md'), }); diff --git a/test/state/squad-state-gaps.test.ts b/test/state/squad-state-gaps.test.ts new file mode 100644 index 000000000..be6fe84e9 --- /dev/null +++ b/test/state/squad-state-gaps.test.ts @@ -0,0 +1,876 @@ +/** + * SquadState Coverage Gaps — Tests for edge cases and error paths. + * + * Identified during FIDO's Phase 2 coverage audit. + * Ensures error classes, schema helpers, and IO round-trips are fully tested. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { + StateError, + NotFoundError, + ParseError, + WriteConflictError, + ProviderError, +} from '../../packages/squad-sdk/src/state/domain-types.js'; +import { resolveCollectionPath } from '../../packages/squad-sdk/src/state/schema.js'; +import { + parseDecisions, + serializeDecision, + serializeDecisions, +} from '../../packages/squad-sdk/src/state/io/decisions-io.js'; +import { + parseRouting, + serializeRouting, +} from '../../packages/squad-sdk/src/state/io/routing-io.js'; +import { + parseTeam, + serializeTeam, +} from '../../packages/squad-sdk/src/state/io/team-io.js'; +import { createAgentHandle } from '../../packages/squad-sdk/src/state/handles.js'; +import { parseHistory } from '../../packages/squad-sdk/src/state/io/history-io.js'; +import { InMemoryStorageProvider } from '../../packages/squad-sdk/src/storage/in-memory-storage-provider.js'; +import { + DecisionsCollection, + RoutingCollection, + TeamCollection, +} from '../../packages/squad-sdk/src/state/collections.js'; + +// Mock IO parsers so we can force throws to test ParseError wrapping. +// Each mock delegates to the real implementation by default. +vi.mock('../../packages/squad-sdk/src/state/io/decisions-io.js', async (importOriginal) => { + const mod = await importOriginal<typeof import('../../packages/squad-sdk/src/state/io/decisions-io.js')>(); + return { ...mod, parseDecisions: vi.fn(mod.parseDecisions) }; +}); +vi.mock('../../packages/squad-sdk/src/state/io/routing-io.js', async (importOriginal) => { + const mod = await importOriginal<typeof import('../../packages/squad-sdk/src/state/io/routing-io.js')>(); + return { ...mod, parseRouting: vi.fn(mod.parseRouting) }; +}); +vi.mock('../../packages/squad-sdk/src/state/io/team-io.js', async (importOriginal) => { + const mod = await importOriginal<typeof import('../../packages/squad-sdk/src/state/io/team-io.js')>(); + return { ...mod, parseTeam: vi.fn(mod.parseTeam) }; +}); +vi.mock('../../packages/squad-sdk/src/state/io/history-io.js', async (importOriginal) => { + const mod = await importOriginal<typeof import('../../packages/squad-sdk/src/state/io/history-io.js')>(); + return { ...mod, parseHistory: vi.fn(mod.parseHistory) }; +}); + +// ── StateError Hierarchy Tests ──────────────────────────────────────────── + +describe('StateError Hierarchy', () => { + describe('StateError base class', () => { + it('has correct name and kind', () => { + const error = new StateError('parse-error', 'test message'); + expect(error.name).toBe('StateError'); + expect(error.kind).toBe('parse-error'); + expect(error.message).toBe('test message'); + expect(error).toBeInstanceOf(Error); + }); + + it('preserves cause via ErrorOptions', () => { + const cause = new Error('underlying'); + const error = new StateError('provider-error', 'wrapper', { cause }); + expect(error.cause).toBe(cause); + }); + + it('supports all StateErrorKind values', () => { + const kinds: Array<import('../../packages/squad-sdk/src/state/domain-types.js').StateErrorKind> = [ + 'not-found', + 'parse-error', + 'write-conflict', + 'provider-error', + ]; + for (const kind of kinds) { + const e = new StateError(kind, 'test'); + expect(e.kind).toBe(kind); + } + }); + }); + + describe('NotFoundError', () => { + it('has correct name and kind', () => { + const error = new NotFoundError('agents', 'ghost'); + expect(error.name).toBe('NotFoundError'); + expect(error.kind).toBe('not-found'); + expect(error).toBeInstanceOf(StateError); + }); + + it('formats message with collection and id', () => { + const error = new NotFoundError('agents', 'ghost'); + expect(error.message).toBe('Not found: agents/ghost'); + }); + + it('formats message with collection only', () => { + const error = new NotFoundError('team'); + expect(error.message).toBe('Not found: team'); + }); + + it('supports instanceof checks', () => { + const error = new NotFoundError('agents', 'ghost'); + expect(error instanceof NotFoundError).toBe(true); + expect(error instanceof StateError).toBe(true); + expect(error instanceof Error).toBe(true); + }); + }); + + describe('ParseError', () => { + it('has correct name and kind', () => { + const error = new ParseError('decisions', 'invalid YAML'); + expect(error.name).toBe('ParseError'); + expect(error.kind).toBe('parse-error'); + expect(error).toBeInstanceOf(StateError); + }); + + it('formats message with collection and detail', () => { + const error = new ParseError('decisions', 'invalid YAML'); + expect(error.message).toBe('Parse error in decisions: invalid YAML'); + }); + + it('handles Unicode in detail string', () => { + const error = new ParseError('team', 'Invalid emoji: 🔥💥'); + expect(error.message).toContain('Invalid emoji: 🔥💥'); + }); + }); + + describe('WriteConflictError', () => { + it('has correct name and kind', () => { + const error = new WriteConflictError('team', 'eecom'); + expect(error.name).toBe('WriteConflictError'); + expect(error.kind).toBe('write-conflict'); + expect(error).toBeInstanceOf(StateError); + }); + + it('formats message with collection and id', () => { + const error = new WriteConflictError('team', 'eecom'); + expect(error.message).toBe('Write conflict: team/eecom'); + }); + + it('formats message with collection only', () => { + const error = new WriteConflictError('routing'); + expect(error.message).toBe('Write conflict: routing'); + }); + }); + + describe('ProviderError', () => { + it('has correct name and kind', () => { + const error = new ProviderError('read', 'disk full'); + expect(error.name).toBe('ProviderError'); + expect(error.kind).toBe('provider-error'); + expect(error).toBeInstanceOf(StateError); + }); + + it('formats message with operation and detail', () => { + const error = new ProviderError('write', 'permission denied'); + expect(error.message).toBe('Provider write failed: permission denied'); + }); + }); +}); + +// ── Schema resolveCollectionPath Tests ─────────────────────────────────── + +describe('resolveCollectionPath', () => { + it('resolves static paths without id', () => { + expect(resolveCollectionPath('decisions')).toBe('.squad/decisions.md'); + expect(resolveCollectionPath('routing')).toBe('.squad/routing.md'); + expect(resolveCollectionPath('team')).toBe('.squad/team.md'); + expect(resolveCollectionPath('log')).toBe('.squad/log'); + expect(resolveCollectionPath('config')).toBe('.squad/config.json'); + }); + + it('resolves function paths with id', () => { + expect(resolveCollectionPath('agents', 'eecom')).toBe('.squad/agents/eecom'); + expect(resolveCollectionPath('skills', 'typescript-testing')).toBe('.squad/skills/typescript-testing'); + expect(resolveCollectionPath('templates', 'charter.md')).toBe('.squad/templates/charter.md'); + }); + + it('throws when function path called without id', () => { + expect(() => resolveCollectionPath('agents')).toThrow( + 'Collection "agents" requires an entity id to resolve its path', + ); + expect(() => resolveCollectionPath('skills')).toThrow( + 'Collection "skills" requires an entity id to resolve its path', + ); + }); + + it('handles Unicode ids', () => { + expect(resolveCollectionPath('agents', '文件')).toBe('.squad/agents/文件'); + expect(resolveCollectionPath('skills', 'résumé-writing')).toBe('.squad/skills/résumé-writing'); + }); + + it('handles ids with special characters', () => { + expect(resolveCollectionPath('agents', 'agent-007')).toBe('.squad/agents/agent-007'); + expect(resolveCollectionPath('templates', 'issue.template.md')).toBe('.squad/templates/issue.template.md'); + }); + + it('does not throw when static path called with id', () => { + // Static paths ignore the id parameter + expect(resolveCollectionPath('team', 'ignored')).toBe('.squad/team.md'); + }); +}); + +// ── IO Round-Trip Tests ─────────────────────────────────────────────────── + +describe('IO Round-Trip', () => { + describe('decisions-io', () => { + it('round-trips a single decision', () => { + const decision = { + title: 'Use TypeScript', + body: 'TypeScript provides type safety.', + configRelevant: true, + date: '2026-07-20', + author: 'EECOM', + }; + const serialized = serializeDecision(decision); + const fullDoc = `# Decisions\n\n${serialized}\n`; + const parsed = parseDecisions(fullDoc); + expect(parsed.length).toBe(1); + expect(parsed[0]!.title).toBe('Use TypeScript'); + expect(parsed[0]!.date).toBe('2026-07-20'); + }); + + it('round-trips multiple decisions', () => { + const decisions = [ + { + title: 'First Decision', + body: 'Body one.', + configRelevant: true, + date: '2026-07-20', + author: 'Alice', + }, + { + title: 'Second Decision', + body: 'Body two.', + configRelevant: false, + date: '2026-07-21', + author: 'Bob', + }, + ]; + const serialized = serializeDecisions(decisions); + const parsed = parseDecisions(serialized); + expect(parsed.length).toBe(2); + expect(parsed[0]!.title).toBe('First Decision'); + expect(parsed[1]!.title).toBe('Second Decision'); + }); + + it('handles decisions without date', () => { + const decision = { + title: 'No Date Decision', + body: 'This has no date.', + configRelevant: false, + }; + const serialized = serializeDecision(decision); + expect(serialized).toContain('### No Date Decision'); + expect(serialized).not.toContain(': No Date Decision'); + }); + + it('handles decisions without author', () => { + const decision = { + title: 'Anonymous', + body: 'No author.', + configRelevant: false, + }; + const serialized = serializeDecision(decision); + expect(serialized).toContain('Anonymous'); + }); + + it('handles empty decisions array', () => { + const serialized = serializeDecisions([]); + expect(serialized).toBe('# Decisions\n'); + }); + + it('preserves Unicode in decision content', () => { + const decision = { + title: 'Support 日本語', + body: 'Content with émojis 🚀 and Ελληνικά.', + configRelevant: false, + date: '2026-07-20', + author: 'Ιωάννης', + }; + const serialized = serializeDecision(decision); + const parsed = parseDecisions(`# Decisions\n\n${serialized}\n`); + expect(parsed[0]!.title).toContain('日本語'); + expect(parsed[0]!.body).toContain('🚀'); + }); + }); + + describe('routing-io', () => { + it('round-trips routing rules', () => { + const rules = [ + { + workType: 'feature-dev', + agents: ['EECOM', 'NEWBIE'], + examples: ['New features', 'Refactors'], + }, + { + workType: 'docs', + agents: ['RETRO'], + examples: ['API docs'], + }, + ]; + const serialized = serializeRouting(rules); + const parsed = parseRouting(serialized); + expect(parsed.rules.length).toBe(2); + expect(parsed.rules[0]!.workType).toBe('feature-dev'); + expect(parsed.rules[0]!.agents).toEqual(['EECOM', 'NEWBIE']); + expect(parsed.rules[1]!.workType).toBe('docs'); + }); + + it('handles rules without examples', () => { + const rules = [ + { + workType: 'testing', + agents: ['FIDO'], + examples: [], + }, + ]; + const serialized = serializeRouting(rules); + const parsed = parseRouting(serialized); + // Parser returns undefined for empty examples column + expect(parsed.rules[0]!.examples).toBeUndefined(); + }); + + it('handles empty rules array', () => { + const serialized = serializeRouting([]); + expect(serialized).toContain('# Routing Rules'); + expect(serialized).toContain('| Work Type | Agent | Examples |'); + }); + + it('preserves Unicode in routing rules', () => { + const rules = [ + { + workType: 'internationalization', + agents: ['多言語チーム'], + examples: ['Support 中文', 'Translate to Español'], + }, + ]; + const serialized = serializeRouting(rules); + const parsed = parseRouting(serialized); + expect(parsed.rules[0]!.agents[0]).toBe('多言語チーム'); + }); + }); + + describe('team-io', () => { + it('round-trips team members', () => { + const agents = [ + { name: 'eecom', role: 'Core Dev', skills: [], status: '✅ Active' }, + { name: 'retro', role: 'Docs Lead', skills: [], status: '✅ Active' }, + ]; + const serialized = serializeTeam(agents); + const parsed = parseTeam(serialized); + expect(parsed.agents.length).toBe(2); + expect(parsed.agents[0]!.name).toBe('eecom'); + expect(parsed.agents[0]!.role).toBe('Core Dev'); + expect(parsed.agents[1]!.name).toBe('retro'); + }); + + it('handles team metadata', () => { + const agents = [{ name: 'agent', role: 'Dev', skills: [] }]; + const serialized = serializeTeam(agents, { + teamName: 'Alpha Squad', + tagline: 'First to fight.', + }); + expect(serialized).toContain('# Alpha Squad'); + expect(serialized).toContain('> First to fight.'); + }); + + it('uses default team name when not provided', () => { + const agents = [{ name: 'agent', role: 'Dev', skills: [] }]; + const serialized = serializeTeam(agents); + expect(serialized).toContain('# Team'); + }); + + it('handles empty agents array', () => { + const serialized = serializeTeam([]); + expect(serialized).toContain('# Team'); + expect(serialized).toContain('## Members'); + expect(serialized).toContain('| Name | Role | Charter | Status |'); + }); + + it('preserves Unicode in agent names and roles', () => { + const agents = [ + { name: 'Αλέξανδρος', role: 'Architect 建筑师', skills: [], status: '✅ Active' }, + ]; + const serialized = serializeTeam(agents); + const parsed = parseTeam(serialized); + expect(parsed.agents[0]!.name).toBe('αλέξανδρος'); // parseTeam lowercases + expect(parsed.agents[0]!.role).toContain('建筑师'); + }); + }); +}); + +// ── createAgentHandle Edge Cases ────────────────────────────────────────── + +describe('createAgentHandle edge cases', () => { + it('handles agent name with spaces', async () => { + const storage = new InMemoryStorageProvider(); + const rootDir = '/test'; + storage.writeSync(`${rootDir}/.squad/agents/Agent Smith/charter.md`, '# Agent Smith\nTest'); + storage.writeSync(`${rootDir}/.squad/agents/Agent Smith/history.md`, '# Agent Smith\n'); + storage.writeSync(`${rootDir}/.squad/team.md`, `# Team\n\n## Members\n\n| Name | Role | Charter | Status |\n|------|------|---------|--------|\n| Agent Smith | Dev | \`.squad/agents/Agent Smith/charter.md\` | ✅ Active |\n`); + + const handle = createAgentHandle('Agent Smith', storage, rootDir); + const charter = await handle.charter(); + expect(charter).toContain('Agent Smith'); + }); + + it('handles agent name with Unicode', async () => { + const storage = new InMemoryStorageProvider(); + const rootDir = '/test'; + const name = '文件管理员'; + storage.writeSync(`${rootDir}/.squad/agents/${name}/charter.md`, `# ${name}\nTest`); + storage.writeSync(`${rootDir}/.squad/agents/${name}/history.md`, `# ${name}\n`); + storage.writeSync(`${rootDir}/.squad/team.md`, `# Team\n\n## Members\n\n| Name | Role | Charter | Status |\n|------|------|---------|--------|\n| ${name} | Dev | \`.squad/agents/${name}/charter.md\` | ✅ Active |\n`); + + const handle = createAgentHandle(name, storage, rootDir); + const charter = await handle.charter(); + expect(charter).toContain(name); + }); + + it('appendHistory handles empty timestamp', async () => { + const storage = new InMemoryStorageProvider(); + const rootDir = '/test'; + storage.writeSync(`${rootDir}/.squad/agents/test/charter.md`, '# test'); + storage.writeSync(`${rootDir}/.squad/agents/test/history.md`, '# test\n\n## Learnings\n'); + storage.writeSync(`${rootDir}/.squad/team.md`, `# Team\n\n## Members\n\n| Name | Role | Charter | Status |\n|------|------|---------|--------|\n| test | Dev | \`.squad/agents/test/charter.md\` | ✅ Active |\n`); + + const handle = createAgentHandle('test', storage, rootDir); + await handle.appendHistory('Learnings', { + section: 'Learnings', + content: 'New learning.', + timestamp: '', // Empty timestamp + }); + + const entries = await handle.history('Learnings'); + expect(entries.length).toBe(1); + expect(entries[0]!.content).toContain('New learning.'); + }); + + it('history() returns empty array when section has no content', async () => { + const storage = new InMemoryStorageProvider(); + const rootDir = '/test'; + storage.writeSync(`${rootDir}/.squad/agents/test/charter.md`, '# test'); + storage.writeSync(`${rootDir}/.squad/agents/test/history.md`, '# test\n\n## Learnings\n\n## Decisions\n\nSome decision.'); + storage.writeSync(`${rootDir}/.squad/team.md`, `# Team\n\n## Members\n\n| Name | Role | Charter | Status |\n|------|------|---------|--------|\n| test | Dev | \`.squad/agents/test/charter.md\` | ✅ Active |\n`); + + const handle = createAgentHandle('test', storage, rootDir); + const learnings = await handle.history('Learnings'); + expect(learnings).toEqual([]); + }); +}); + +// ── Adversarial Markdown Tests — appendHistory() ───────────────────────── +// +// Covers hostile inputs flagged by Flight: code fences, empty sections, +// excessive whitespace, sub-headers, missing sections, unicode, large +// sections, and duplicate headers. + +describe('appendHistory adversarial markdown', () => { + const ROOT = '/adversarial'; + const AGENT = 'adversary'; + const HISTORY = `${ROOT}/.squad/agents/${AGENT}/history.md`; + const CHARTER = `${ROOT}/.squad/agents/${AGENT}/charter.md`; + const TEAM = `${ROOT}/.squad/team.md`; + const TEAM_MD = [ + '# Team', '', '## Members', '', + '| Name | Role | Charter | Status |', + '|------|------|---------|--------|', + `| ${AGENT} | Dev | ... | ✅ Active |`, '', + ].join('\n'); + + function setup(historyContent: string) { + const storage = new InMemoryStorageProvider(); + storage.writeSync(CHARTER, `# ${AGENT}`); + storage.writeSync(HISTORY, historyContent); + storage.writeSync(TEAM, TEAM_MD); + return { storage, handle: createAgentHandle(AGENT, storage, ROOT) }; + } + + it('code block with ## in a preceding section does not confuse append', async () => { + // The fenced code block contains "## Fake Header" which the regex + // could match. Placing it BEFORE the target section verifies that + // the section-name regex finds the correct header. + const content = [ + '# adversary', '', + '## Context', '', + 'Example:', '', + '```markdown', + '## Fake Header', + '```', '', + '## Learnings', '', + 'Existing learning.', '', + ].join('\n'); + + const { handle, storage } = setup(content); + await handle.appendHistory('Learnings', { + section: 'Learnings', + content: 'New learning from test.', + timestamp: '2026-01-15', + }); + + const result = storage.readSync(HISTORY)!; + expect(result).toContain('## Learnings'); + expect(result).toContain('### 2026-01-15'); + expect(result).toContain('New learning from test.'); + // Code block still intact + expect(result).toContain('```markdown'); + expect(result).toContain('## Fake Header'); + }); + + it('empty section — append inserts between adjacent headers', async () => { + const content = [ + '# adversary', '', + '## Learnings', '', + '## Patterns', '', + 'Some pattern.', + ].join('\n'); + + const { handle, storage } = setup(content); + await handle.appendHistory('Learnings', { + section: 'Learnings', + content: 'Injected into empty section.', + timestamp: '2026-02-01', + }); + + const result = storage.readSync(HISTORY)!; + const learningsIdx = result.indexOf('## Learnings'); + const patternsIdx = result.indexOf('## Patterns'); + const entryIdx = result.indexOf('Injected into empty section.'); + expect(entryIdx).toBeGreaterThan(learningsIdx); + expect(entryIdx).toBeLessThan(patternsIdx); + }); + + it('consecutive sections with excessive whitespace', async () => { + const content = [ + '# adversary', '', + '## Learnings', '', + 'Existing.', '', '', '', '', + '## Patterns', '', + 'Pattern.', + ].join('\n'); + + const { handle, storage } = setup(content); + await handle.appendHistory('Learnings', { + section: 'Learnings', + content: 'Whitespace test entry.', + timestamp: '2026-03-01', + }); + + const result = storage.readSync(HISTORY)!; + expect(result).toContain('Whitespace test entry.'); + const learningsIdx = result.indexOf('## Learnings'); + const patternsIdx = result.indexOf('## Patterns'); + const entryIdx = result.indexOf('Whitespace test entry.'); + expect(entryIdx).toBeGreaterThan(learningsIdx); + expect(entryIdx).toBeLessThan(patternsIdx); + }); + + it('section with sub-headers — new entry appended after existing entries', async () => { + const content = [ + '# adversary', '', + '## Learnings', '', + '### 2026-01-01', '', 'First learning.', '', + '### 2026-01-02', '', 'Second learning.', '', + '## Patterns', '', + 'A pattern.', + ].join('\n'); + + const { handle, storage } = setup(content); + await handle.appendHistory('Learnings', { + section: 'Learnings', + content: 'Third learning.', + timestamp: '2026-01-03', + }); + + const result = storage.readSync(HISTORY)!; + expect(result).toContain('### 2026-01-03'); + expect(result).toContain('Third learning.'); + // All entries before Patterns + const patternsIdx = result.indexOf('## Patterns'); + expect(result.indexOf('Third learning.')).toBeLessThan(patternsIdx); + // Originals preserved + expect(result).toContain('First learning.'); + expect(result).toContain('Second learning.'); + }); + + it('missing target section — creates it at the end', async () => { + const content = [ + '# adversary', '', + '## Learnings', '', + 'Existing learning.', + ].join('\n'); + + const { handle, storage } = setup(content); + await handle.appendHistory('Patterns', { + section: 'Patterns', + content: 'Brand new pattern.', + timestamp: '2026-04-01', + }); + + const result = storage.readSync(HISTORY)!; + expect(result).toContain('## Patterns'); + expect(result).toContain('### 2026-04-01'); + expect(result).toContain('Brand new pattern.'); + // Original preserved + expect(result).toContain('## Learnings'); + expect(result).toContain('Existing learning.'); + // New section after existing content + expect(result.indexOf('## Patterns')).toBeGreaterThan(result.indexOf('## Learnings')); + }); + + it('unicode content — emoji, CJK, RTL text preserved', async () => { + const content = [ + '# adversary', '', + '## Learnings', '', + '### 2026-01-01', '', + '🚀 Learned about 日本語 processing.', '', + '### 2026-01-02', '', + 'مرحبا — RTL text with diacritics: café, naïve.', + ].join('\n'); + + const { handle, storage } = setup(content); + await handle.appendHistory('Learnings', { + section: 'Learnings', + content: '新しい学び 🎯 with Ελληνικά and العربية.', + timestamp: '2026-01-03', + }); + + const result = storage.readSync(HISTORY)!; + expect(result).toContain('🚀 Learned about 日本語 processing.'); + expect(result).toContain('مرحبا — RTL text with diacritics: café, naïve.'); + expect(result).toContain('新しい学び 🎯 with Ελληνικά and العربية.'); + }); + + it('very long section with 50+ entries — append at correct position', async () => { + const lines = ['# adversary', '', '## Learnings', '']; + for (let i = 1; i <= 55; i++) { + const mm = String(Math.ceil(i / 28)).padStart(2, '0'); + const dd = String((i % 28) + 1).padStart(2, '0'); + lines.push(`### 2026-${mm}-${dd}`, '', `Learning entry number ${i}.`, ''); + } + lines.push('## Patterns', '', 'A pattern.'); + const content = lines.join('\n'); + + const { handle, storage } = setup(content); + await handle.appendHistory('Learnings', { + section: 'Learnings', + content: 'Entry number 56.', + timestamp: '2026-06-01', + }); + + const result = storage.readSync(HISTORY)!; + expect(result).toContain('Entry number 56.'); + expect(result).toContain('### 2026-06-01'); + // New entry before Patterns section + const patternsIdx = result.indexOf('## Patterns'); + expect(result.indexOf('Entry number 56.')).toBeLessThan(patternsIdx); + // First and last originals preserved + expect(result).toContain('Learning entry number 1.'); + expect(result).toContain('Learning entry number 55.'); + }); + + it('duplicate section headers — appends to first occurrence', async () => { + // Degenerate case: two ## Learnings sections. The regex finds the + // first, then the "next ##" search finds the second — so the entry + // lands between the two blocks. + const content = [ + '# adversary', '', + '## Learnings', '', + 'First block content.', '', + '## Learnings', '', + 'Second block content.', + ].join('\n'); + + const { handle, storage } = setup(content); + await handle.appendHistory('Learnings', { + section: 'Learnings', + content: 'Appended entry.', + timestamp: '2026-05-01', + }); + + const result = storage.readSync(HISTORY)!; + expect(result).toContain('Appended entry.'); + const firstIdx = result.indexOf('## Learnings'); + const secondIdx = result.indexOf('## Learnings', firstIdx + 1); + const entryIdx = result.indexOf('Appended entry.'); + expect(entryIdx).toBeGreaterThan(firstIdx); + expect(entryIdx).toBeLessThan(secondIdx); + }); +}); + +// ── Adversarial Markdown Tests — parseHistory() ────────────────────────── +// +// Edge cases for the regex-based section parser: empty input, title-only +// files, and malformed h2 headers that should be ignored. + +describe('parseHistory adversarial markdown', () => { + it('empty string returns empty parsed result', () => { + const result = parseHistory(''); + expect(result.fullContent).toBe(''); + expect(result.context).toBeUndefined(); + expect(result.learnings).toBeUndefined(); + expect(result.decisions).toBeUndefined(); + expect(result.patterns).toBeUndefined(); + expect(result.issues).toBeUndefined(); + expect(result.references).toBeUndefined(); + }); + + it('whitespace-only string returns empty parsed result', () => { + const result = parseHistory(' \n\n \n'); + expect(result.context).toBeUndefined(); + expect(result.learnings).toBeUndefined(); + }); + + it('only title with no ## sections', () => { + const md = '# Agent Name\n\nSome introductory text with no sections.\n'; + const result = parseHistory(md); + expect(result.fullContent).toContain('# Agent Name'); + expect(result.context).toBeUndefined(); + expect(result.learnings).toBeUndefined(); + expect(result.decisions).toBeUndefined(); + expect(result.patterns).toBeUndefined(); + expect(result.issues).toBeUndefined(); + expect(result.references).toBeUndefined(); + }); + + it('malformed header ##NoSpace is ignored', () => { + const md = '# Agent\n\n##NoSpace content here\n\n## Learnings\n\nReal content.\n'; + const result = parseHistory(md); + expect(result.learnings).toBe('Real content.'); + expect(result.context).toBeUndefined(); + }); + + it('malformed header "## " (trailing space) — cross-line regex consumption', () => { + // BUG DOCUMENTED: headerRegex /^##\s+(.+?)\s*$/gm lets \s+ consume + // newlines, so "## \n\n## Learnings" matches as a SINGLE header with + // captured name "## Learnings". The real section is consumed and lost. + const md = '# Agent\n\n## \n\n## Learnings\n\nReal content.\n'; + const result = parseHistory(md); + expect(result.learnings).toBeUndefined(); + }); + + it('malformed header "##" alone — cross-line regex consumption', () => { + // Same bug: "##" followed by \n lets \s+ match \n\n, then (.+?) + // consumes the next header line text, destroying the section boundary. + const md = '# Agent\n\n##\n\n## Learnings\n\nReal content.\n'; + const result = parseHistory(md); + expect(result.learnings).toBeUndefined(); + }); + + it('unrecognized section names are not mapped to known fields', () => { + const md = [ + '# Agent', '', + '## Custom Section', '', 'Custom stuff.', '', + '## Learnings', '', 'A learning.', '', + ].join('\n'); + const result = parseHistory(md); + expect(result.learnings).toBe('A learning.'); + // Custom Section parsed but not mapped to any known field + expect(result.context).toBeUndefined(); + expect(result.decisions).toBeUndefined(); + }); +}); + +// ── ParseError Wrapping at Facade Boundary ─────────────────────────────── + +import { parseHistory } from '../../packages/squad-sdk/src/state/io/history-io.js'; + +const mockedParseDecisions = vi.mocked(parseDecisions); +const mockedParseRouting = vi.mocked(parseRouting); +const mockedParseTeam = vi.mocked(parseTeam); +const mockedParseHistory = vi.mocked(parseHistory); + +describe('ParseError wrapping at facade boundary', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('DecisionsCollection.list() wraps parser errors with ParseError', async () => { + const storage = new InMemoryStorageProvider(); + const rootDir = '/test'; + storage.writeSync(`${rootDir}/.squad/decisions.md`, 'malformed content'); + const col = new DecisionsCollection(storage, rootDir); + + const cause = new Error('unexpected token'); + mockedParseDecisions.mockImplementationOnce(() => { throw cause; }); + + const err = await col.list().catch((e: unknown) => e); + expect(err).toBeInstanceOf(ParseError); + expect((err as ParseError).message).toContain('decisions'); + expect((err as ParseError).cause).toBe(cause); + }); + + it('RoutingCollection.get() wraps parser errors with ParseError', async () => { + const storage = new InMemoryStorageProvider(); + const rootDir = '/test'; + storage.writeSync(`${rootDir}/.squad/routing.md`, 'malformed content'); + const col = new RoutingCollection(storage, rootDir); + + const cause = new Error('bad table'); + mockedParseRouting.mockImplementationOnce(() => { throw cause; }); + + const err = await col.get().catch((e: unknown) => e); + expect(err).toBeInstanceOf(ParseError); + expect((err as ParseError).message).toContain('routing'); + expect((err as ParseError).cause).toBe(cause); + }); + + it('TeamCollection.get() wraps parser errors with ParseError', async () => { + const storage = new InMemoryStorageProvider(); + const rootDir = '/test'; + storage.writeSync(`${rootDir}/.squad/team.md`, 'malformed content'); + const col = new TeamCollection(storage, rootDir); + + const cause = new Error('missing members table'); + mockedParseTeam.mockImplementationOnce(() => { throw cause; }); + + const err = await col.get().catch((e: unknown) => e); + expect(err).toBeInstanceOf(ParseError); + expect((err as ParseError).message).toContain('team'); + expect((err as ParseError).cause).toBe(cause); + }); + + it('AgentHandle.history() wraps parser errors with ParseError', async () => { + const storage = new InMemoryStorageProvider(); + const rootDir = '/test'; + storage.writeSync(`${rootDir}/.squad/agents/test/charter.md`, '# test'); + storage.writeSync(`${rootDir}/.squad/agents/test/history.md`, 'malformed content'); + const handle = createAgentHandle('test', storage, rootDir); + + const cause = new Error('corrupt history'); + mockedParseHistory.mockImplementationOnce(() => { throw cause; }); + + const err = await handle.history().catch((e: unknown) => e); + expect(err).toBeInstanceOf(ParseError); + expect((err as ParseError).message).toContain('history'); + expect((err as ParseError).cause).toBe(cause); + }); + + it('AgentHandle.update() wraps team parser errors with ParseError', async () => { + const storage = new InMemoryStorageProvider(); + const rootDir = '/test'; + storage.writeSync(`${rootDir}/.squad/team.md`, 'malformed content'); + const handle = createAgentHandle('test', storage, rootDir); + + const cause = new Error('team parse failure'); + mockedParseTeam.mockImplementationOnce(() => { throw cause; }); + + const err = await handle.update({ role: 'Tester' }).catch((e: unknown) => e); + expect(err).toBeInstanceOf(ParseError); + expect((err as ParseError).message).toContain('team'); + expect((err as ParseError).cause).toBe(cause); + }); + + it('preserves non-Error cause as string in message', async () => { + const storage = new InMemoryStorageProvider(); + const rootDir = '/test'; + storage.writeSync(`${rootDir}/.squad/decisions.md`, 'malformed content'); + const col = new DecisionsCollection(storage, rootDir); + + mockedParseDecisions.mockImplementationOnce(() => { throw 'raw string error'; }); + + const err = await col.list().catch((e: unknown) => e); + expect(err).toBeInstanceOf(ParseError); + expect((err as ParseError).message).toContain('raw string error'); + expect((err as ParseError).cause).toBe('raw string error'); + }); +}); diff --git a/test/state/squad-state-wiring.test.ts b/test/state/squad-state-wiring.test.ts new file mode 100644 index 000000000..4cf2039dd --- /dev/null +++ b/test/state/squad-state-wiring.test.ts @@ -0,0 +1,319 @@ +/** + * SquadState SDK Wiring Tests — Phase 2 + * + * Verifies that SDK modules (CharterCompiler, LocalAgentSource, onboardAgent, + * ToolRegistry) correctly use SquadState typed collections when wired. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { InMemoryStorageProvider } from '../../packages/squad-sdk/src/storage/in-memory-storage-provider.js'; +import { SquadState } from '../../packages/squad-sdk/src/state/squad-state.js'; +import { CharterCompiler } from '../../packages/squad-sdk/src/agents/index.js'; +import { LocalAgentSource } from '../../packages/squad-sdk/src/config/agent-source.js'; +import { onboardAgent } from '../../packages/squad-sdk/src/agents/onboarding.js'; +import { ToolRegistry } from '../../packages/squad-sdk/src/tools/index.js'; + +// ── Sample data ──────────────────────────────────────────────────────────── + +const ROOT = '/project'; + +const CHARTER_EECOM = `# EECOM — Core Dev + +> Practical, thorough, makes it work then makes it right. + +## Identity + +- **Name:** EECOM +- **Role:** Core Dev +- **Expertise:** Runtime implementation, spawning +- **Style:** Practical, thorough, makes it work then makes it right. +`; + +const CHARTER_RETRO = `# RETRO — Docs Lead + +> Precise, structured, docs-first. + +## Identity + +- **Name:** RETRO +- **Role:** Docs Lead +- **Expertise:** Documentation, API references +- **Style:** Precise, structured, docs-first. +`; + +const HISTORY_EECOM = `# EECOM + +## Learnings + +### 2026-07-24 + +Built the IO layer for state module. + +## Context + +Project uses TypeScript with vitest for testing. +`; + +const TEAM_MD = `# Project Squad + +## Members + +| Name | Role | Charter | Status | +|------|------|---------|--------| +| eecom | Core Dev | \`.squad/agents/eecom/charter.md\` | ✅ Active | +| retro | Docs Lead | \`.squad/agents/retro/charter.md\` | ✅ Active | +`; + +// ── Helper ───────────────────────────────────────────────────────────────── + +function seedStorage(): InMemoryStorageProvider { + const sp = new InMemoryStorageProvider(); + sp.writeSync(`${ROOT}/.squad/agents/eecom/charter.md`, CHARTER_EECOM); + sp.writeSync(`${ROOT}/.squad/agents/eecom/history.md`, HISTORY_EECOM); + sp.writeSync(`${ROOT}/.squad/agents/retro/charter.md`, CHARTER_RETRO); + sp.writeSync(`${ROOT}/.squad/team.md`, TEAM_MD); + sp.writeSync(`${ROOT}/.squad/decisions.md`, '# Decisions\n'); + sp.writeSync(`${ROOT}/.squad/routing.md`, '# Routing\n'); + sp.writeSync(`${ROOT}/.squad/config.json`, '{}'); + return sp; +} + +// ── SquadState.fromStorage ───────────────────────────────────────────────── + +describe('SquadState.fromStorage', () => { + it('creates SquadState synchronously without validation', () => { + const sp = new InMemoryStorageProvider(); + // No .squad/ dir seeded — fromStorage should NOT throw + const state = SquadState.fromStorage(sp, '/empty'); + expect(state).toBeDefined(); + expect(state.root).toBe('/empty'); + expect(state.provider).toBe(sp); + }); + + it('exposes root and provider getters', () => { + const sp = seedStorage(); + const state = SquadState.fromStorage(sp, ROOT); + expect(state.root).toBe(ROOT); + expect(state.provider).toBe(sp); + }); +}); + +// ── CharterCompiler with SquadState ──────────────────────────────────────── + +describe('CharterCompiler with SquadState', () => { + let sp: InMemoryStorageProvider; + let state: SquadState; + + beforeEach(() => { + sp = seedStorage(); + state = SquadState.fromStorage(sp, ROOT); + }); + + it('compileAll uses state.agents when SquadState provided', async () => { + const compiler = new CharterCompiler(sp, state); + const charters = await compiler.compileAll(ROOT); + + expect(charters.length).toBe(2); + const names = charters.map(c => c.name).sort(); + expect(names).toEqual(['eecom', 'retro']); + }); + + it('compileAll skips scribe and _ prefixed agents', async () => { + sp.writeSync(`${ROOT}/.squad/agents/scribe/charter.md`, '# Scribe\n## Identity\n- **Name:** Scribe\n'); + sp.writeSync(`${ROOT}/.squad/agents/_alumni/charter.md`, '# Alumni\n'); + const compiler = new CharterCompiler(sp, state); + const charters = await compiler.compileAll(ROOT); + + const names = charters.map(c => c.name); + expect(names).not.toContain('scribe'); + expect(names).not.toContain('_alumni'); + }); + + it('compileByName returns typed charter for known agent', async () => { + const compiler = new CharterCompiler(sp, state); + const charter = await compiler.compileByName('eecom'); + + expect(charter.name).toBe('eecom'); + expect(charter.role).toBe('Core Dev'); + expect(charter.prompt).toContain('Practical, thorough'); + }); + + it('compileByName throws for unknown agent', async () => { + const compiler = new CharterCompiler(sp, state); + await expect(compiler.compileByName('ghost')).rejects.toThrow(); + }); + + it('compileByName throws if no SquadState provided', async () => { + const compiler = new CharterCompiler(sp); // no state + await expect(compiler.compileByName('eecom')).rejects.toThrow( + /compileByName requires SquadState/, + ); + }); + + it('compileAll falls back to StorageProvider when no state', async () => { + const compiler = new CharterCompiler(sp); // no state + const charters = await compiler.compileAll(ROOT); + + expect(charters.length).toBe(2); + const names = charters.map(c => c.name).sort(); + expect(names).toEqual(['eecom', 'retro']); + }); +}); + +// ── LocalAgentSource with SquadState ─────────────────────────────────────── + +describe('LocalAgentSource with SquadState', () => { + let sp: InMemoryStorageProvider; + let state: SquadState; + + beforeEach(() => { + sp = seedStorage(); + state = SquadState.fromStorage(sp, ROOT); + }); + + it('listAgents returns manifests via SquadState', async () => { + const source = new LocalAgentSource(ROOT, sp, state); + const agents = await source.listAgents(); + + expect(agents.length).toBe(2); + const names = agents.map(a => a.name).sort(); + expect(names).toEqual(['EECOM', 'RETRO']); + }); + + it('getAgent returns full definition via SquadState', async () => { + const source = new LocalAgentSource(ROOT, sp, state); + const agent = await source.getAgent('eecom'); + + expect(agent).not.toBeNull(); + expect(agent!.charter).toContain('Core Dev'); + expect(agent!.source).toBe('local'); + }); + + it('getCharter returns charter content via SquadState', async () => { + const source = new LocalAgentSource(ROOT, sp, state); + const charter = await source.getCharter('eecom'); + + expect(charter).not.toBeNull(); + expect(charter).toContain('EECOM'); + }); + + it('getCharter returns null for unknown agent', async () => { + const source = new LocalAgentSource(ROOT, sp, state); + const charter = await source.getCharter('ghost'); + + expect(charter).toBeNull(); + }); + + it('listAgents falls back to StorageProvider when no state', async () => { + const source = new LocalAgentSource(ROOT, sp); // no state + const agents = await source.listAgents(); + + expect(agents.length).toBe(2); + }); +}); + +// ── onboardAgent with SquadState ─────────────────────────────────────────── + +describe('onboardAgent with SquadState', () => { + let sp: InMemoryStorageProvider; + let state: SquadState; + + beforeEach(() => { + sp = seedStorage(); + state = SquadState.fromStorage(sp, ROOT); + }); + + it('creates agent files via SquadState', async () => { + const result = await onboardAgent( + { teamRoot: ROOT, agentName: 'new-agent', role: 'tester' }, + sp, + state, + ); + + expect(result.createdFiles.length).toBe(2); + + // Verify charter exists via SquadState + const charter = await state.agents.get('new-agent').charter(); + expect(charter).toContain('New Agent'); + + // Verify history was written (overwritten from generic SquadState template) + const history = sp.readSync(`${ROOT}/.squad/agents/new-agent/history.md`); + expect(history).toBeDefined(); + }); + + it('creates agent files without SquadState (backward compat)', async () => { + const result = await onboardAgent( + { teamRoot: ROOT, agentName: 'plain-agent', role: 'developer' }, + sp, + ); + + expect(result.createdFiles.length).toBe(2); + const charter = sp.readSync(result.charterPath); + expect(charter).toBeDefined(); + }); + + it('rejects duplicate agent', async () => { + await expect( + onboardAgent( + { teamRoot: ROOT, agentName: 'eecom', role: 'developer' }, + sp, + state, + ), + ).rejects.toThrow(/already exists/); + }); +}); + +// ── ToolRegistry with SquadState ─────────────────────────────────────────── + +describe('ToolRegistry with SquadState', () => { + let sp: InMemoryStorageProvider; + let state: SquadState; + + beforeEach(() => { + sp = seedStorage(); + state = SquadState.fromStorage(sp, ROOT); + }); + + it('constructs with SquadState parameter', () => { + const registry = new ToolRegistry(`${ROOT}/.squad`, undefined, sp, state); + expect(registry.getTools().length).toBeGreaterThan(0); + }); + + it('constructs without SquadState (backward compat)', () => { + const registry = new ToolRegistry(`${ROOT}/.squad`, undefined, sp); + expect(registry.getTools().length).toBeGreaterThan(0); + }); + + it('squad_memory appends via SquadState when available', async () => { + const registry = new ToolRegistry(`${ROOT}/.squad`, undefined, sp, state); + const memoryTool = registry.getTool('squad_memory'); + expect(memoryTool).toBeDefined(); + + const result = await memoryTool!.handler({ + agent: 'eecom', + section: 'learnings', + content: 'Wired SquadState into tools module.', + }); + + expect(result.resultType).toBe('success'); + expect(result.textResultForLlm).toContain('Appended to eecom'); + + // Verify content was written + const history = sp.readSync(`${ROOT}/.squad/agents/eecom/history.md`); + expect(history).toContain('Wired SquadState into tools module.'); + }); + + it('squad_memory returns failure for unknown agent via SquadState', async () => { + const registry = new ToolRegistry(`${ROOT}/.squad`, undefined, sp, state); + const memoryTool = registry.getTool('squad_memory'); + + const result = await memoryTool!.handler({ + agent: 'ghost', + section: 'learnings', + content: 'This should fail.', + }); + + expect(result.resultType).toBe('failure'); + expect(result.textResultForLlm).toContain('not found'); + }); +}); diff --git a/test/state/squad-state.test.ts b/test/state/squad-state.test.ts new file mode 100644 index 000000000..f969bc3ff --- /dev/null +++ b/test/state/squad-state.test.ts @@ -0,0 +1,667 @@ +/** + * SquadState integration tests. + * + * Uses InMemoryStorageProvider seeded with sample .squad/ files + * to verify the full facade: SquadState → Collections → Handles → IO. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { InMemoryStorageProvider } from '../../packages/squad-sdk/src/storage/in-memory-storage-provider.js'; +import { SquadState } from '../../packages/squad-sdk/src/state/squad-state.js'; +import { NotFoundError } from '../../packages/squad-sdk/src/state/domain-types.js'; + +// ── Sample data ──────────────────────────────────────────────────────────── + +const ROOT = '/project'; + +const CHARTER_EECOM = `# EECOM — Core Dev + +> Practical, thorough, makes it work then makes it right. + +## Identity + +- **Name:** EECOM +- **Role:** Core Dev +- **Expertise:** Runtime implementation, spawning +- **Style:** Practical, thorough, makes it work then makes it right. +`; + +const CHARTER_RETRO = `# RETRO — Docs Lead + +> Precise, structured, docs-first. + +## Identity + +- **Name:** RETRO +- **Role:** Docs Lead +- **Expertise:** Documentation, API references +- **Style:** Precise, structured, docs-first. +`; + +const HISTORY_EECOM = `# EECOM + +## Context + +Project uses TypeScript with vitest for testing. + +## Learnings + +### 2026-07-24 + +Built the IO layer for state module. + +### 2026-07-15 + +Fixed squad version subcommand. + +## Decisions + +### 2026-07-20 + +Use InMemoryStorageProvider for tests. +`; + +const TEAM_MD = `# Project Squad + +## Members + +| Name | Role | Charter | Status | +|------|------|---------|--------| +| EECOM | Core Dev | \`.squad/agents/EECOM/charter.md\` | ✅ Active | +| RETRO | Docs Lead | \`.squad/agents/RETRO/charter.md\` | ✅ Active | +`; + +const TEAM_MD_WITH_CONTEXT = `# Apollo 13 Mission Control + +> High-stakes systems under pressure + +## Project Context +Squad SDK — TypeScript monorepo for AI team orchestration. + +## Members + +| Name | Role | Charter | Status | +|------|------|---------|--------| +| EECOM | Core Dev | \`.squad/agents/EECOM/charter.md\` | ✅ Active | +| RETRO | Docs Lead | \`.squad/agents/RETRO/charter.md\` | ✅ Active | +`; + +const DECISIONS_MD = `# Decisions + +### 2026-07-20: Use StorageProvider abstraction +**By:** EECOM +All file I/O goes through StorageProvider for testability. + +### 2026-07-18: Markdown-first state +**By:** Dina +State files stay as markdown for human readability. +`; + +const ROUTING_MD = `# Routing Rules + +## Routing Table + +| Work Type | Agent | Examples | +|-----------|-------|----------| +| feature-dev | EECOM | New features, refactors | +| docs | RETRO | Documentation updates | +`; + +const ROUTING_MD_WITH_OWNERSHIP = `# Routing Rules + +## Routing Table + +| Work Type | Agent | Examples | +|-----------|-------|----------| +| feature-dev | EECOM | New features, refactors | +| docs | RETRO | Documentation updates | + +## Module Ownership + +| Module | Owner | +|--------|-------| +| src/storage/ | EECOM | +| src/state/ | CONTROL | +`; + +const SKILL_TYPESCRIPT_TESTING = `--- +name: TypeScript Testing +domain: testing +triggers: [vitest, jest, test, spec] +roles: [tester, developer] +--- +Guidelines for writing TypeScript tests with vitest. +`; + +const SKILL_CODE_REVIEW = `--- +name: Code Review +domain: quality +triggers: [review, pr, pull-request] +roles: [reviewer] +--- +Best practices for code review. +`; + +const TEMPLATE_CHARTER = `# {{name}} — {{role}} + +> {{tagline}} + +## Identity + +- **Name:** {{name}} +- **Role:** {{role}} +`; + +const TEMPLATE_DECISION = `### {{date}}: {{title}} +**By:** {{author}} +{{body}} +`; + +// ── Helpers ──────────────────────────────────────────────────────────────── + +function seedStorage(storage: InMemoryStorageProvider): void { + storage.writeSync(`${ROOT}/.squad/agents/EECOM/charter.md`, CHARTER_EECOM); + storage.writeSync(`${ROOT}/.squad/agents/EECOM/history.md`, HISTORY_EECOM); + storage.writeSync(`${ROOT}/.squad/agents/RETRO/charter.md`, CHARTER_RETRO); + storage.writeSync(`${ROOT}/.squad/agents/RETRO/history.md`, `# RETRO\n\n## Learnings\n`); + storage.writeSync(`${ROOT}/.squad/team.md`, TEAM_MD); + storage.writeSync(`${ROOT}/.squad/decisions.md`, DECISIONS_MD); + storage.writeSync(`${ROOT}/.squad/routing.md`, ROUTING_MD); + // Skills + storage.writeSync(`${ROOT}/.squad/skills/typescript-testing/SKILL.md`, SKILL_TYPESCRIPT_TESTING); + storage.writeSync(`${ROOT}/.squad/skills/code-review/SKILL.md`, SKILL_CODE_REVIEW); + // Templates + storage.writeSync(`${ROOT}/.squad/templates/charter.md`, TEMPLATE_CHARTER); + storage.writeSync(`${ROOT}/.squad/templates/decision.md`, TEMPLATE_DECISION); + // Log entries + storage.writeSync(`${ROOT}/.squad/log/2026-07-24-session.md`, '# Session Log\nSpawned EECOM for feature work.'); + storage.writeSync(`${ROOT}/.squad/log/2026-07-25-review.md`, '# Review Log\nCode review completed.'); +} + +// ── Tests ────────────────────────────────────────────────────────────────── + +describe('SquadState', () => { + let storage: InMemoryStorageProvider; + let state: SquadState; + + beforeEach(async () => { + storage = new InMemoryStorageProvider(); + seedStorage(storage); + state = await SquadState.create(storage, ROOT); + }); + + // ── Factory ──────────────────────────────────────────────────────────── + + describe('create()', () => { + it('succeeds when .squad/ exists', async () => { + expect(state).toBeInstanceOf(SquadState); + }); + + it('throws NotFoundError when .squad/ is missing', async () => { + const empty = new InMemoryStorageProvider(); + await expect(SquadState.create(empty, '/empty')).rejects.toThrow( + NotFoundError, + ); + }); + }); + + describe('isInitialized()', () => { + it('returns true when .squad/ exists', async () => { + expect(await state.isInitialized()).toBe(true); + }); + + it('returns false when .squad/ is missing', async () => { + const empty = new InMemoryStorageProvider(); + // Bypass create() validation with a direct instantiation trick + // by testing on a state that was created then had squad/ removed + await storage.deleteDir(`${ROOT}/.squad`); + expect(await state.isInitialized()).toBe(false); + }); + }); + + // ── AgentsCollection ─────────────────────────────────────────────────── + + describe('agents', () => { + describe('list()', () => { + it('returns agent names', async () => { + const names = await state.agents.list(); + expect(names).toContain('EECOM'); + expect(names).toContain('RETRO'); + expect(names).toHaveLength(2); + }); + }); + + describe('get().charter()', () => { + it('reads charter content', async () => { + const handle = state.agents.get('EECOM'); + const charter = await handle.charter(); + expect(charter).toContain('EECOM — Core Dev'); + expect(charter).toContain('Practical, thorough'); + }); + + it('throws NotFoundError for missing agent', async () => { + const handle = state.agents.get('GHOST'); + await expect(handle.charter()).rejects.toThrow(NotFoundError); + }); + }); + + describe('get().history()', () => { + it('returns all parsed history entries', async () => { + const entries = await state.agents.get('EECOM').history(); + expect(entries.length).toBeGreaterThan(0); + // Should have entries from Context, Learnings, and Decisions sections + const sections = new Set(entries.map((e) => e.section)); + expect(sections.has('Learnings')).toBe(true); + expect(sections.has('Decisions')).toBe(true); + }); + + it('filters by section', async () => { + const entries = await state.agents.get('EECOM').history('Learnings'); + expect(entries.length).toBe(2); + expect(entries.every((e) => e.section === 'Learnings')).toBe(true); + }); + + it('returns empty array for agent with no history file', async () => { + await storage.delete(`${ROOT}/.squad/agents/RETRO/history.md`); + const entries = await state.agents.get('RETRO').history(); + expect(entries).toEqual([]); + }); + + it('extracts timestamps from sub-headers', async () => { + const entries = await state.agents.get('EECOM').history('Learnings'); + expect(entries[0]!.timestamp).toBe('2026-07-24'); + expect(entries[1]!.timestamp).toBe('2026-07-15'); + }); + }); + + describe('get().appendHistory()', () => { + it('appends a new entry to an existing section', async () => { + const handle = state.agents.get('EECOM'); + await handle.appendHistory('Learnings', { + section: 'Learnings', + content: 'New learning about testing.', + timestamp: '2026-07-26', + }); + + const entries = await handle.history('Learnings'); + expect(entries.length).toBe(3); + expect(entries.some((e) => e.content.includes('New learning about testing.'))).toBe(true); + }); + + it('creates section if it does not exist', async () => { + const handle = state.agents.get('EECOM'); + await handle.appendHistory('Issues', { + section: 'Issues', + content: 'Found a bug in parsing.', + timestamp: '2026-07-26', + }); + + const entries = await handle.history('Issues'); + expect(entries.length).toBe(1); + expect(entries[0]!.content).toContain('Found a bug in parsing.'); + }); + + it('creates history file if missing', async () => { + await storage.delete(`${ROOT}/.squad/agents/RETRO/history.md`); + const handle = state.agents.get('RETRO'); + await handle.appendHistory('Learnings', { + section: 'Learnings', + content: 'First learning.', + timestamp: '2026-07-26', + }); + + const entries = await handle.history('Learnings'); + expect(entries.length).toBe(1); + }); + }); + + describe('get().update()', () => { + it('updates agent role in team.md', async () => { + const handle = state.agents.get('EECOM'); + await handle.update({ role: 'Lead Dev' } as Partial<import('../../packages/squad-sdk/src/state/domain-types.js').Agent>); + + const teamConfig = await state.team.get(); + const member = teamConfig.members.find((m) => m.name === 'eecom'); + expect(member?.role).toBe('Lead Dev'); + }); + + it('throws NotFoundError when agent not in team.md', async () => { + const handle = state.agents.get('GHOST'); + await expect( + handle.update({ role: 'Nobody' } as Partial<import('../../packages/squad-sdk/src/state/domain-types.js').Agent>), + ).rejects.toThrow(NotFoundError); + }); + }); + + describe('create()', () => { + it('creates agent directory with charter and history', async () => { + await state.agents.create('NEWBIE', '# NEWBIE — Intern\n'); + const handle = state.agents.get('NEWBIE'); + const charter = await handle.charter(); + expect(charter).toContain('NEWBIE — Intern'); + + const names = await state.agents.list(); + expect(names).toContain('NEWBIE'); + }); + }); + + describe('delete()', () => { + it('removes the agent directory', async () => { + await state.agents.delete('RETRO'); + const names = await state.agents.list(); + expect(names).not.toContain('RETRO'); + }); + + it('throws NotFoundError for missing agent', async () => { + await expect(state.agents.delete('GHOST')).rejects.toThrow( + NotFoundError, + ); + }); + }); + }); + + // ── DecisionsCollection ──────────────────────────────────────────────── + + describe('decisions', () => { + describe('list()', () => { + it('returns parsed decisions', async () => { + const decisions = await state.decisions.list(); + expect(decisions.length).toBe(2); + expect(decisions[0]!.title).toContain('Use StorageProvider abstraction'); + expect(decisions[1]!.title).toContain('Markdown-first state'); + }); + + it('returns empty array when file missing', async () => { + await storage.delete(`${ROOT}/.squad/decisions.md`); + const decisions = await state.decisions.list(); + expect(decisions).toEqual([]); + }); + }); + + describe('add()', () => { + it('appends a new decision', async () => { + await state.decisions.add({ + title: 'Use typed facades', + author: 'EECOM', + body: 'SquadState provides typed access to all collections.', + configRelevant: false, + }); + + const decisions = await state.decisions.list(); + expect(decisions.length).toBe(3); + expect(decisions.some((d) => d.title.includes('Use typed facades'))).toBe(true); + }); + }); + }); + + // ── RoutingCollection ────────────────────────────────────────────────── + + describe('routing', () => { + describe('get()', () => { + it('returns parsed routing config', async () => { + const config = await state.routing.get(); + expect(config.rules.length).toBe(2); + expect(config.rules[0]!.workType).toBe('feature-dev'); + expect(config.rules[0]!.agents).toContain('EECOM'); + expect(config.rules[1]!.workType).toBe('docs'); + }); + + it('returns empty moduleOwnership when section missing', async () => { + const config = await state.routing.get(); + expect(config.moduleOwnership.size).toBe(0); + }); + + it('populates moduleOwnership from Module Ownership section', async () => { + await storage.write(`${ROOT}/.squad/routing.md`, ROUTING_MD_WITH_OWNERSHIP); + const fresh = await SquadState.create(storage, ROOT); + const config = await fresh.routing.get(); + expect(config.moduleOwnership.size).toBe(2); + expect(config.moduleOwnership.get('src/storage/')).toBe('EECOM'); + expect(config.moduleOwnership.get('src/state/')).toBe('CONTROL'); + }); + + it('throws NotFoundError when file missing', async () => { + await storage.delete(`${ROOT}/.squad/routing.md`); + await expect(state.routing.get()).rejects.toThrow(NotFoundError); + }); + }); + + describe('update()', () => { + it('writes updated routing config', async () => { + const config = await state.routing.get(); + const updated = { + ...config, + rules: [ + ...config.rules, + { workType: 'testing', agents: ['EECOM'], examples: ['Unit tests'] }, + ], + }; + await state.routing.update(updated); + + const reloaded = await state.routing.get(); + expect(reloaded.rules.length).toBe(3); + expect(reloaded.rules[2]!.workType).toBe('testing'); + }); + }); + }); + + // ── TeamCollection ───────────────────────────────────────────────────── + + describe('team', () => { + describe('get()', () => { + it('returns parsed team config', async () => { + const config = await state.team.get(); + expect(config.members.length).toBe(2); + // parseTeam kebab-cases names, so EECOM → eecom + expect(config.members[0]!.name).toBe('eecom'); + expect(config.members[0]!.role).toBe('Core Dev'); + expect(config.members[1]!.name).toBe('retro'); + }); + + it('returns empty projectContext when section missing', async () => { + const config = await state.team.get(); + expect(config.projectContext).toBe(''); + }); + + it('populates projectContext from content above Members', async () => { + await storage.write(`${ROOT}/.squad/team.md`, TEAM_MD_WITH_CONTEXT); + const fresh = await SquadState.create(storage, ROOT); + const config = await fresh.team.get(); + expect(config.projectContext).toContain('High-stakes systems under pressure'); + expect(config.projectContext).toContain('Squad SDK'); + }); + + it('throws NotFoundError when file missing', async () => { + await storage.delete(`${ROOT}/.squad/team.md`); + await expect(state.team.get()).rejects.toThrow(NotFoundError); + }); + }); + + describe('update()', () => { + it('writes updated team config', async () => { + const config = await state.team.get(); + const updated = { + ...config, + members: [ + ...config.members, + { name: 'NEWBIE', role: 'Intern' }, + ], + }; + await state.team.update(updated); + + const reloaded = await state.team.get(); + expect(reloaded.members.length).toBe(3); + // Names survive round-trip as-is since we don't go through parseTeam on write + expect(reloaded.members[2]!.name).toBe('newbie'); + }); + }); + }); + + // ── SkillsCollection ────────────────────────────────────────────────── + + describe('skills', () => { + describe('list()', () => { + it('returns skill IDs', async () => { + const ids = await state.skills.list(); + expect(ids).toContain('typescript-testing'); + expect(ids).toContain('code-review'); + expect(ids).toHaveLength(2); + }); + + it('returns empty array when skills directory is missing', async () => { + const empty = new InMemoryStorageProvider(); + empty.writeSync(`${ROOT}/.squad/team.md`, TEAM_MD); + const s = await SquadState.create(empty, ROOT); + const ids = await s.skills.list(); + expect(ids).toEqual([]); + }); + }); + + describe('get()', () => { + it('returns SkillDefinition for existing skill', async () => { + const skill = await state.skills.get('typescript-testing'); + expect(skill).toBeDefined(); + expect(skill!.id).toBe('typescript-testing'); + expect(skill!.name).toBe('TypeScript Testing'); + expect(skill!.domain).toBe('testing'); + expect(skill!.triggers).toEqual(['vitest', 'jest', 'test', 'spec']); + expect(skill!.agentRoles).toEqual(['tester', 'developer']); + expect(skill!.content).toContain('Guidelines for writing TypeScript tests'); + }); + + it('returns undefined for missing skill', async () => { + const skill = await state.skills.get('nonexistent'); + expect(skill).toBeUndefined(); + }); + }); + + describe('exists()', () => { + it('returns true for existing skill', async () => { + expect(await state.skills.exists('code-review')).toBe(true); + }); + + it('returns false for missing skill', async () => { + expect(await state.skills.exists('nonexistent')).toBe(false); + }); + }); + }); + + // ── TemplatesCollection ─────────────────────────────────────────────── + + describe('templates', () => { + describe('list()', () => { + it('returns template filenames', async () => { + const names = await state.templates.list(); + expect(names).toContain('charter.md'); + expect(names).toContain('decision.md'); + expect(names).toHaveLength(2); + }); + }); + + describe('get()', () => { + it('returns raw template content', async () => { + const content = await state.templates.get('charter.md'); + expect(content).toBeDefined(); + expect(content).toContain('{{name}}'); + expect(content).toContain('{{role}}'); + }); + + it('returns undefined for missing template', async () => { + const content = await state.templates.get('nonexistent.md'); + expect(content).toBeUndefined(); + }); + }); + + describe('exists()', () => { + it('returns true for existing template', async () => { + expect(await state.templates.exists('charter.md')).toBe(true); + }); + + it('returns false for missing template', async () => { + expect(await state.templates.exists('nonexistent.md')).toBe(false); + }); + }); + }); + + // ── ConfigCollection ────────────────────────────────────────────────── + + describe('config', () => { + describe('get()', () => { + it('returns parsed config when file exists', async () => { + storage.writeSync( + `${ROOT}/.squad/config.json`, + JSON.stringify({ cacheEnabled: true, cacheTtlMs: 60_000 }), + ); + const s = await SquadState.create(storage, ROOT); + const config = await s.config.get(); + expect(config.cacheEnabled).toBe(true); + expect(config.cacheTtlMs).toBe(60_000); + }); + + it('returns defaults when file is missing', async () => { + const config = await state.config.get(); + expect(config.cacheEnabled).toBe(false); + expect(config.cacheTtlMs).toBe(300_000); + }); + }); + + describe('update()', () => { + it('persists config and is readable', async () => { + await state.config.update({ cacheEnabled: true, cacheTtlMs: 120_000 }); + const config = await state.config.get(); + expect(config.cacheEnabled).toBe(true); + expect(config.cacheTtlMs).toBe(120_000); + }); + }); + + describe('exists()', () => { + it('returns false when config.json is missing', async () => { + expect(await state.config.exists()).toBe(false); + }); + + it('returns true after config is written', async () => { + await state.config.update({ cacheEnabled: false }); + expect(await state.config.exists()).toBe(true); + }); + }); + }); + + // ── LogCollection ───────────────────────────────────────────────────── + + describe('log', () => { + describe('list()', () => { + it('returns log entry filenames', async () => { + const names = await state.log.list(); + expect(names).toContain('2026-07-24-session.md'); + expect(names).toContain('2026-07-25-review.md'); + expect(names).toHaveLength(2); + }); + }); + + describe('get()', () => { + it('reads a specific log entry', async () => { + const content = await state.log.get('2026-07-24-session.md'); + expect(content).toContain('Session Log'); + expect(content).toContain('Spawned EECOM'); + }); + + it('returns undefined for missing log entry', async () => { + const content = await state.log.get('nonexistent.md'); + expect(content).toBeUndefined(); + }); + }); + + describe('write()', () => { + it('persists a new log entry and is readable', async () => { + await state.log.write('2026-07-26-deploy.md', '# Deploy Log\nDeployed v1.2.0.'); + + const content = await state.log.get('2026-07-26-deploy.md'); + expect(content).toBe('# Deploy Log\nDeployed v1.2.0.'); + + const names = await state.log.list(); + expect(names).toContain('2026-07-26-deploy.md'); + expect(names).toHaveLength(3); + }); + }); + }); +}); diff --git a/test/storage-contract.ts b/test/storage-contract.ts new file mode 100644 index 000000000..184417ee2 --- /dev/null +++ b/test/storage-contract.ts @@ -0,0 +1,283 @@ +/** + * StorageProvider Contract Test Factory + * + * Runs the full conformance suite against ANY StorageProvider implementation. + * Each test is provider-agnostic — no fs-specific or in-memory-specific assertions. + * + * All 11 interface methods are covered: + * Async: read, write, append, exists, list, delete, deleteDir + * Sync: readSync, writeSync, existsSync, listSync + * + * Edge cases: empty content, paths with spaces, nested dirs, overwrite + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import type { StorageProvider } from '../packages/squad-sdk/src/storage/storage-provider.js'; + +export function runStorageProviderContractTests( + name: string, + factory: () => Promise<{ provider: StorageProvider; cleanup: () => Promise<void> }> +) { + describe(`StorageProvider contract: ${name}`, () => { + let provider: StorageProvider; + let cleanup: () => Promise<void>; + + beforeEach(async () => { + const ctx = await factory(); + provider = ctx.provider; + cleanup = ctx.cleanup; + }); + + afterEach(async () => { + await cleanup(); + }); + + // ── read ─────────────────────────────────────────────────────────────── + + describe('read', () => { + it('returns string content for an existing file', async () => { + await provider.write('contract/read.txt', 'hello'); + const result = await provider.read('contract/read.txt'); + expect(result).toBe('hello'); + }); + + it('returns undefined for a non-existent file (ENOENT)', async () => { + const result = await provider.read('contract/no-such-file.txt'); + expect(result).toBeUndefined(); + }); + }); + + // ── write ────────────────────────────────────────────────────────────── + + describe('write', () => { + it('creates a new file', async () => { + await provider.write('contract/new.txt', 'created'); + expect(await provider.read('contract/new.txt')).toBe('created'); + }); + + it('overwrites existing content', async () => { + await provider.write('contract/ow.txt', 'first'); + await provider.write('contract/ow.txt', 'second'); + expect(await provider.read('contract/ow.txt')).toBe('second'); + }); + + it('creates parent directories recursively', async () => { + await provider.write('contract/deep/nested/dir/file.txt', 'deep'); + expect(await provider.read('contract/deep/nested/dir/file.txt')).toBe('deep'); + }); + + it('handles empty string content', async () => { + await provider.write('contract/empty.txt', ''); + const result = await provider.read('contract/empty.txt'); + expect(result).toBe(''); + }); + }); + + // ── append ───────────────────────────────────────────────────────────── + + describe('append', () => { + it('creates file if missing', async () => { + await provider.append('contract/append-new.txt', 'first'); + expect(await provider.read('contract/append-new.txt')).toBe('first'); + }); + + it('appends to existing content', async () => { + await provider.write('contract/append-existing.txt', 'A'); + await provider.append('contract/append-existing.txt', 'B'); + expect(await provider.read('contract/append-existing.txt')).toBe('AB'); + }); + }); + + // ── exists ───────────────────────────────────────────────────────────── + + describe('exists', () => { + it('returns true for an existing file', async () => { + await provider.write('contract/exists.txt', 'data'); + expect(await provider.exists('contract/exists.txt')).toBe(true); + }); + + it('returns false for a missing path', async () => { + expect(await provider.exists('contract/ghost.txt')).toBe(false); + }); + }); + + // ── list ─────────────────────────────────────────────────────────────── + + describe('list', () => { + it('returns entry names in a directory', async () => { + await provider.write('contract/ls/a.txt', 'a'); + await provider.write('contract/ls/b.txt', 'b'); + const entries = await provider.list('contract/ls'); + expect(entries.sort()).toEqual(['a.txt', 'b.txt']); + }); + + it('returns empty array for a non-existent directory', async () => { + const entries = await provider.list('contract/no-such-dir'); + expect(entries).toEqual([]); + }); + + it('returns only direct children, not full paths', async () => { + await provider.write('contract/ls2/child.txt', 'x'); + const entries = await provider.list('contract/ls2'); + expect(entries).toContain('child.txt'); + }); + }); + + // ── delete ───────────────────────────────────────────────────────────── + + describe('delete', () => { + it('removes an existing file', async () => { + await provider.write('contract/del.txt', 'bye'); + await provider.delete('contract/del.txt'); + expect(await provider.exists('contract/del.txt')).toBe(false); + }); + + it('is a no-op when file does not exist (no throw)', async () => { + await expect(provider.delete('contract/never.txt')).resolves.toBeUndefined(); + }); + }); + + // ── deleteDir ────────────────────────────────────────────────────────── + + describe('deleteDir', () => { + it('removes directory and all children', async () => { + await provider.write('contract/rmdir/a.txt', 'a'); + await provider.write('contract/rmdir/sub/b.txt', 'b'); + await provider.deleteDir('contract/rmdir'); + expect(await provider.exists('contract/rmdir')).toBe(false); + expect(await provider.exists('contract/rmdir/a.txt')).toBe(false); + }); + + it('is a no-op when directory does not exist (no throw)', async () => { + await expect(provider.deleteDir('contract/void-dir')).resolves.toBeUndefined(); + }); + }); + + // ── readSync ─────────────────────────────────────────────────────────── + + describe('readSync', () => { + it('returns string content for an existing file', () => { + provider.writeSync('contract/rsync.txt', 'sync-data'); + expect(provider.readSync('contract/rsync.txt')).toBe('sync-data'); + }); + + it('returns undefined for a missing file', () => { + expect(provider.readSync('contract/missing-sync.txt')).toBeUndefined(); + }); + }); + + // ── writeSync ────────────────────────────────────────────────────────── + + describe('writeSync', () => { + it('creates a file and reads back', () => { + provider.writeSync('contract/wsync.txt', 'written'); + expect(provider.readSync('contract/wsync.txt')).toBe('written'); + }); + + it('creates parent directories recursively', () => { + provider.writeSync('contract/sync-deep/nested/file.txt', 'nested-sync'); + expect(provider.readSync('contract/sync-deep/nested/file.txt')).toBe('nested-sync'); + }); + }); + + // ── existsSync ───────────────────────────────────────────────────────── + + describe('existsSync', () => { + it('returns true for an existing file', () => { + provider.writeSync('contract/esync.txt', 'yes'); + expect(provider.existsSync('contract/esync.txt')).toBe(true); + }); + + it('returns false for a missing path', () => { + expect(provider.existsSync('contract/nope-sync.txt')).toBe(false); + }); + }); + + // ── listSync ─────────────────────────────────────────────────────────── + + describe('listSync', () => { + it('returns entry names in a directory', () => { + provider.writeSync('contract/lsync/x.txt', 'x'); + provider.writeSync('contract/lsync/y.txt', 'y'); + expect(provider.listSync('contract/lsync').sort()).toEqual(['x.txt', 'y.txt']); + }); + + it('returns empty array for a non-existent directory', () => { + expect(provider.listSync('contract/no-sync-dir')).toEqual([]); + }); + }); + + // ── Edge cases ───────────────────────────────────────────────────────── + + describe('edge cases', () => { + it('handles paths with spaces', async () => { + await provider.write('contract/path with spaces/file name.txt', 'spaced'); + expect(await provider.read('contract/path with spaces/file name.txt')).toBe('spaced'); + }); + + it('write + delete + read returns undefined', async () => { + await provider.write('contract/lifecycle.txt', 'alive'); + await provider.delete('contract/lifecycle.txt'); + expect(await provider.read('contract/lifecycle.txt')).toBeUndefined(); + }); + + it('overwrite preserves only latest content', async () => { + await provider.write('contract/multi.txt', 'v1'); + await provider.write('contract/multi.txt', 'v2'); + await provider.write('contract/multi.txt', 'v3'); + expect(await provider.read('contract/multi.txt')).toBe('v3'); + }); + }); + + // ── LIKE wildcard safety (%, _) ─────────────────────────────────────── + + describe('LIKE wildcard safety (%, _)', () => { + it('list() with % in path returns only correct entries', async () => { + await provider.write('contract/wc/dir/100%_done.txt', 'complete'); + await provider.write('contract/wc/dir/normal.txt', 'normal'); + const entries = await provider.list('contract/wc/dir'); + expect(entries.sort()).toEqual(['100%_done.txt', 'normal.txt']); + }); + + it('list() with _ in path returns only expected entries', async () => { + await provider.write('contract/wc/udir/file_v2.txt', 'versioned'); + await provider.write('contract/wc/udir/readme.txt', 'info'); + const entries = await provider.list('contract/wc/udir'); + expect(entries.sort()).toEqual(['file_v2.txt', 'readme.txt']); + }); + + it('list() does not treat % as wildcard — a% matches only literal a% dir', async () => { + await provider.write('contract/wc/a/b.txt', 'wrong'); + await provider.write('contract/wc/a%/c.txt', 'right'); + const entries = await provider.list('contract/wc/a%'); + expect(entries).toEqual(['c.txt']); + }); + + it('list() does not treat _ as single-char wildcard', async () => { + await provider.write('contract/wc/ab/x.txt', 'wrong'); + await provider.write('contract/wc/a_/y.txt', 'right'); + const entries = await provider.list('contract/wc/a_'); + expect(entries).toEqual(['y.txt']); + }); + + it('existsSync with % in path checks literally', () => { + provider.writeSync('contract/wc/pct%dir/test.txt', 'data'); + expect(provider.existsSync('contract/wc/pct%dir/test.txt')).toBe(true); + expect(provider.existsSync('contract/wc/pctXdir/test.txt')).toBe(false); + }); + + it('existsSync with _ in path checks literally', () => { + provider.writeSync('contract/wc/under_dir/test.txt', 'data'); + expect(provider.existsSync('contract/wc/under_dir/test.txt')).toBe(true); + expect(provider.existsSync('contract/wc/underXdir/test.txt')).toBe(false); + }); + + it('deleteDir with % only deletes literal match, not wildcard matches', async () => { + await provider.write('contract/wc/del%dir/target.txt', 'delete-me'); + await provider.write('contract/wc/delXdir/keep.txt', 'keep-me'); + await provider.deleteDir('contract/wc/del%dir'); + expect(await provider.exists('contract/wc/del%dir/target.txt')).toBe(false); + expect(await provider.exists('contract/wc/delXdir/keep.txt')).toBe(true); + }); + }); + }); +} diff --git a/test/storage-provider.test.ts b/test/storage-provider.test.ts new file mode 100644 index 000000000..1ab8a0247 --- /dev/null +++ b/test/storage-provider.test.ts @@ -0,0 +1,1221 @@ +/** + * StorageProvider Contract Tests — RED phase + * + * These tests define the complete contract that any StorageProvider + * implementation must satisfy. They are written against FSStorageProvider + * stubs, so every test fails until the implementation is wired in. + * + * Run these tests BEFORE implementation → all fail (RED). + * Run them AFTER implementation → all pass (GREEN). + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, rm } from 'fs/promises'; +import { mkdtempSync, rmSync, readFileSync, existsSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { FSStorageProvider } from '../packages/squad-sdk/src/storage/fs-storage-provider.js'; +import { InMemoryStorageProvider } from '../packages/squad-sdk/src/storage/in-memory-storage-provider.js'; +import type { StorageProvider } from '../packages/squad-sdk/src/storage/storage-provider.js'; +import { StorageError } from '../packages/squad-sdk/src/storage/storage-error.js'; +import { parseSkillFile } from '../packages/squad-sdk/src/skills/skill-loader.js'; +import { runStorageProviderContractTests } from './storage-contract.js'; + +let provider: StorageProvider; +let tmpDir: string; + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'squad-storage-test-')); + provider = new FSStorageProvider(); +}); + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); +}); + +// ── write / read ──────────────────────────────────────────────────────────── + +describe('write + read', () => { + it('writes a file and reads it back', async () => { + const file = join(tmpDir, 'hello.txt'); + await provider.write(file, 'hello world'); + const result = await provider.read(file); + expect(result).toBe('hello world'); + }); + + it('overwrites existing content on write', async () => { + const file = join(tmpDir, 'overwrite.txt'); + await provider.write(file, 'first'); + await provider.write(file, 'second'); + const result = await provider.read(file); + expect(result).toBe('second'); + }); + + it('write creates parent directories recursively', async () => { + const file = join(tmpDir, 'deep', 'nested', 'dir', 'file.txt'); + await provider.write(file, 'deep content'); + const result = await provider.read(file); + expect(result).toBe('deep content'); + }); + + it('read returns undefined for a missing file (ENOENT)', async () => { + const file = join(tmpDir, 'nonexistent.txt'); + const result = await provider.read(file); + expect(result).toBeUndefined(); + }); + + it('read returns empty string for a file written with empty string', async () => { + const file = join(tmpDir, 'empty.txt'); + await provider.write(file, ''); + const result = await provider.read(file); + expect(result).toBe(''); + }); +}); + +// ── append ─────────────────────────────────────────────────────────────────── + +describe('append', () => { + it('creates a file on first append', async () => { + const file = join(tmpDir, 'new-append.txt'); + await provider.append(file, 'line1\n'); + const result = await provider.read(file); + expect(result).toBe('line1\n'); + }); + + it('appends to an existing file', async () => { + const file = join(tmpDir, 'existing.txt'); + await provider.write(file, 'line1\n'); + await provider.append(file, 'line2\n'); + const result = await provider.read(file); + expect(result).toBe('line1\nline2\n'); + }); + + it('append creates parent directories', async () => { + const file = join(tmpDir, 'sub', 'append.log'); + await provider.append(file, 'entry'); + const result = await provider.read(file); + expect(result).toBe('entry'); + }); +}); + +// ── exists ─────────────────────────────────────────────────────────────────── + +describe('exists', () => { + it('returns true for an existing file', async () => { + const file = join(tmpDir, 'real.txt'); + await provider.write(file, 'data'); + expect(await provider.exists(file)).toBe(true); + }); + + it('returns false for a missing path', async () => { + const file = join(tmpDir, 'ghost.txt'); + expect(await provider.exists(file)).toBe(false); + }); + + it('returns true for a directory', async () => { + expect(await provider.exists(tmpDir)).toBe(true); + }); +}); + +// ── list ───────────────────────────────────────────────────────────────────── + +describe('list', () => { + it('returns file names in a directory', async () => { + await provider.write(join(tmpDir, 'a.txt'), 'a'); + await provider.write(join(tmpDir, 'b.txt'), 'b'); + const entries = await provider.list(tmpDir); + expect(entries.sort()).toEqual(['a.txt', 'b.txt']); + }); + + it('returns an empty array for an empty directory', async () => { + const entries = await provider.list(tmpDir); + expect(entries).toEqual([]); + }); + + it('returns an empty array for a non-existent directory', async () => { + const entries = await provider.list(join(tmpDir, 'no-such-dir')); + expect(entries).toEqual([]); + }); + + it('returns only direct children — not full paths', async () => { + await provider.write(join(tmpDir, 'file.txt'), 'x'); + const entries = await provider.list(tmpDir); + expect(entries).not.toContain(join(tmpDir, 'file.txt')); + expect(entries).toContain('file.txt'); + }); +}); + +// ── delete ─────────────────────────────────────────────────────────────────── + +describe('delete', () => { + it('removes an existing file', async () => { + const file = join(tmpDir, 'todelete.txt'); + await provider.write(file, 'bye'); + await provider.delete(file); + expect(await provider.exists(file)).toBe(false); + }); + + it('is a no-op when file does not exist (no throw)', async () => { + const file = join(tmpDir, 'never-existed.txt'); + await expect(provider.delete(file)).resolves.toBeUndefined(); + }); +}); + +// ── sync methods ───────────────────────────────────────────────────────────── + +describe('sync methods', () => { + it('writeSync + readSync round-trip', () => { + const file = join(tmpDir, 'sync.txt'); + provider.writeSync(file, 'sync data'); + expect(provider.readSync(file)).toBe('sync data'); + }); + + it('writeSync creates parent directories', () => { + const file = join(tmpDir, 'sync', 'nested', 'file.txt'); + provider.writeSync(file, 'nested sync'); + expect(provider.readSync(file)).toBe('nested sync'); + }); + + it('readSync returns undefined for missing file', () => { + const file = join(tmpDir, 'missing-sync.txt'); + expect(provider.readSync(file)).toBeUndefined(); + }); + + it('existsSync returns true for an existing file', () => { + const file = join(tmpDir, 'exists-sync.txt'); + provider.writeSync(file, 'yes'); + expect(provider.existsSync(file)).toBe(true); + }); + + it('existsSync returns false for a missing path', () => { + expect(provider.existsSync(join(tmpDir, 'nope.txt'))).toBe(false); + }); +}); + +// ── sync / async parity ────────────────────────────────────────────────────── + +describe('sync/async parity', () => { + it('readSync and read return the same content', async () => { + const file = join(tmpDir, 'parity.txt'); + await provider.write(file, 'parity check'); + const async_result = await provider.read(file); + const sync_result = provider.readSync(file); + expect(sync_result).toBe(async_result); + }); + + it('existsSync and exists agree on a present file', async () => { + const file = join(tmpDir, 'agree.txt'); + await provider.write(file, 'x'); + expect(provider.existsSync(file)).toBe(await provider.exists(file)); + }); + + it('existsSync and exists agree on a missing file', async () => { + const file = join(tmpDir, 'absent.txt'); + expect(provider.existsSync(file)).toBe(await provider.exists(file)); + }); +}); + +// ── path traversal protection ──────────────────────────────────────────────── + +describe('path traversal protection', () => { + let confinedProvider: StorageProvider; + let rootDir: string; + + beforeEach(async () => { + rootDir = await mkdtemp(join(tmpdir(), 'squad-confined-')); + confinedProvider = new FSStorageProvider(rootDir); + }); + + afterEach(async () => { + await rm(rootDir, { recursive: true, force: true }); + }); + + it('blocks relative path traversal with ../', async () => { + await expect(confinedProvider.read('../etc/passwd')).rejects.toThrow(/Path traversal blocked/); + }); + + it('blocks absolute path outside rootDir', async () => { + await expect(confinedProvider.write('/tmp/evil.txt', 'hack')).rejects.toThrow(/Path traversal blocked/); + }); + + it('allows normal operations within rootDir', async () => { + await confinedProvider.write('subdir/file.txt', 'safe'); + const content = await confinedProvider.read('subdir/file.txt'); + expect(content).toBe('safe'); + }); + + it('blocks traversal in write', async () => { + await expect(confinedProvider.write('../../etc/shadow', 'bad')).rejects.toThrow(/Path traversal blocked/); + }); + + it('blocks traversal in append', async () => { + await expect(confinedProvider.append('../outside.log', 'entry')).rejects.toThrow(/Path traversal blocked/); + }); + + it('blocks traversal in exists', async () => { + await expect(confinedProvider.exists('../../.env')).rejects.toThrow(/Path traversal blocked/); + }); + + it('blocks traversal in list', async () => { + await expect(confinedProvider.list('..')).rejects.toThrow(/Path traversal blocked/); + }); + + it('blocks traversal in delete', async () => { + await expect(confinedProvider.delete('../victim.txt')).rejects.toThrow(/Path traversal blocked/); + }); + + it('blocks traversal in sync methods', () => { + expect(() => confinedProvider.readSync('../../secret.txt')).toThrow(/Path traversal blocked/); + expect(() => confinedProvider.writeSync('../bad.txt', 'data')).toThrow(/Path traversal blocked/); + expect(() => confinedProvider.existsSync('../../.ssh/id_rsa')).toThrow(/Path traversal blocked/); + }); + + it('allows operations at rootDir itself', async () => { + await confinedProvider.write('root-file.txt', 'at root'); + expect(await confinedProvider.exists('root-file.txt')).toBe(true); + const entries = await confinedProvider.list('.'); + expect(entries).toContain('root-file.txt'); + }); +}); + +// ── symlink traversal protection ───────────────────────────────────────────── + +describe('symlink traversal protection', () => { + let confinedProvider: StorageProvider; + let rootDir: string; + let outsideDir: string; + + beforeEach(async () => { + rootDir = await mkdtemp(join(tmpdir(), 'squad-symlink-root-')); + outsideDir = await mkdtemp(join(tmpdir(), 'squad-symlink-outside-')); + confinedProvider = new FSStorageProvider(rootDir); + }); + + afterEach(async () => { + await rm(rootDir, { recursive: true, force: true }); + await rm(outsideDir, { recursive: true, force: true }); + }); + + // Skip symlink tests on Windows due to permission requirements + const isWindows = process.platform === 'win32'; + const testOrSkip = isWindows ? it.skip : it; + + testOrSkip('blocks read via symlink pointing outside rootDir', async () => { + const { symlink } = await import('fs/promises'); + const outsideFile = join(outsideDir, 'secret.txt'); + const symlinkPath = join(rootDir, 'link-to-outside'); + + await provider.write(outsideFile, 'secret data'); + await symlink(outsideFile, symlinkPath); + + await expect(confinedProvider.read('link-to-outside')).rejects.toThrow(/Symlink traversal blocked/); + }); + + testOrSkip('blocks write via symlink pointing outside rootDir', async () => { + const { symlink } = await import('fs/promises'); + const outsideFile = join(outsideDir, 'target.txt'); + const symlinkPath = join(rootDir, 'evil-link'); + + await provider.write(outsideFile, 'initial'); + await symlink(outsideFile, symlinkPath); + + await expect(confinedProvider.write('evil-link', 'overwrite')).rejects.toThrow(/Symlink traversal blocked/); + }); + + testOrSkip('blocks exists check via symlink', async () => { + const { symlink } = await import('fs/promises'); + const outsideFile = join(outsideDir, 'exists.txt'); + const symlinkPath = join(rootDir, 'link'); + + await provider.write(outsideFile, 'data'); + await symlink(outsideFile, symlinkPath); + + await expect(confinedProvider.exists('link')).rejects.toThrow(/Symlink traversal blocked/); + }); + + testOrSkip('allows symlinks within rootDir pointing to other paths within rootDir', async () => { + const { symlink } = await import('fs/promises'); + const targetPath = join(rootDir, 'target.txt'); + const linkPath = join(rootDir, 'link.txt'); + + await confinedProvider.write('target.txt', 'internal data'); + await symlink(targetPath, linkPath); + + const content = await confinedProvider.read('link.txt'); + expect(content).toBe('internal data'); + }); + + testOrSkip('blocks write through symlink directory to outside rootDir (ENOENT bypass)', async () => { + const { symlink, mkdir: mkdirFs } = await import('fs/promises'); + const { existsSync } = await import('fs'); + + // Create an outside target directory + const outsideTarget = join(outsideDir, 'escape-target'); + await mkdirFs(outsideTarget, { recursive: true }); + + // Create a symlink DIRECTORY inside rootDir pointing outside + const symlinkDir = join(rootDir, 'link-dir'); + await symlink(outsideTarget, symlinkDir, 'dir'); + + // Attempt to write a NEW file through the symlink directory. + // The file doesn't exist yet, so realpath on the full path throws ENOENT. + // The old code would blindly trust the resolved path and follow the symlink. + await expect(confinedProvider.write('link-dir/newfile.txt', 'malicious')) + .rejects.toThrow(/Symlink traversal blocked/); + + // Verify the file was NOT written outside rootDir + expect(existsSync(join(outsideTarget, 'newfile.txt'))).toBe(false); + }); + + testOrSkip('blocks writeSync through symlink directory to outside rootDir (ENOENT bypass)', () => { + const { symlinkSync, mkdirSync: mkdirSyncFs, existsSync } = require('fs'); + + // Create an outside target directory + const outsideTarget = join(outsideDir, 'escape-target-sync'); + mkdirSyncFs(outsideTarget, { recursive: true }); + + // Create a symlink DIRECTORY inside rootDir pointing outside + const symlinkDir = join(rootDir, 'link-dir-sync'); + symlinkSync(outsideTarget, symlinkDir, 'dir'); + + // Attempt writeSync through the symlink directory + expect(() => confinedProvider.writeSync('link-dir-sync/newfile.txt', 'malicious')) + .toThrow(/Symlink traversal blocked/); + + // Verify the file was NOT written outside rootDir + expect(existsSync(join(outsideTarget, 'newfile.txt'))).toBe(false); + }); +}); + +// ── cross-platform path handling ───────────────────────────────────────── + +describe('cross-platform path handling', () => { + it('allows access with different case on case-insensitive platforms', async () => { + if (process.platform !== 'win32' && process.platform !== 'darwin') { + return; // Only relevant on case-insensitive filesystems + } + const root = await mkdtemp(join(tmpdir(), 'squad-case-test-')); + const confinedProvider = new FSStorageProvider(root); + + await confinedProvider.write('test.txt', 'hello'); + + // Build an alternate-cased root path + const altCase = root.charAt(0) === root.charAt(0).toUpperCase() + ? root.charAt(0).toLowerCase() + root.slice(1) + : root.charAt(0).toUpperCase() + root.slice(1); + + const result = await confinedProvider.read(join(altCase, 'test.txt')); + expect(result).toBe('hello'); + + await rm(root, { recursive: true, force: true }); + }); +}); + +// ── deleteDir ──────────────────────────────────────────────────────────────── + +describe('deleteDir', () => { + it('recursively removes a directory and all contents', async () => { + const dir = join(tmpDir, 'to-delete'); + await provider.write(join(dir, 'file1.txt'), 'a'); + await provider.write(join(dir, 'subdir', 'file2.txt'), 'b'); + + await provider.deleteDir(dir); + + expect(await provider.exists(dir)).toBe(false); + expect(await provider.exists(join(dir, 'file1.txt'))).toBe(false); + expect(await provider.exists(join(dir, 'subdir', 'file2.txt'))).toBe(false); + }); + + it('is a no-op when directory does not exist', async () => { + const dir = join(tmpDir, 'nonexistent-dir'); + await expect(provider.deleteDir(dir)).resolves.toBeUndefined(); + }); + + it('removes nested directory structures', async () => { + const dir = join(tmpDir, 'deep'); + await provider.write(join(dir, 'a', 'b', 'c', 'file.txt'), 'nested'); + + await provider.deleteDir(dir); + + expect(await provider.exists(dir)).toBe(false); + }); + + it('blocks deleteDir traversal when rootDir is set', async () => { + const rootDir = await mkdtemp(join(tmpdir(), 'squad-delete-confined-')); + const confinedProvider = new FSStorageProvider(rootDir); + + await expect(confinedProvider.deleteDir('../outside')).rejects.toThrow(/Path traversal blocked/); + + await rm(rootDir, { recursive: true, force: true }); + }); +}); + +// ── StorageError ───────────────────────────────────────────────────────────── + +describe('StorageError', () => { + it('wraps permission errors without leaking paths', async () => { + const file = join(tmpDir, 'readonly.txt'); + await provider.write(file, 'data'); + const { chmod } = await import('fs/promises'); + await chmod(file, 0o444); + try { + await provider.write(file, 'overwrite'); + expect.fail('Expected StorageError to be thrown'); + } catch (err) { + expect(err).toBeInstanceOf(StorageError); + expect((err as StorageError).code).toBe('EPERM'); + expect((err as StorageError).message).not.toContain(tmpDir); + } finally { + await chmod(file, 0o644); + } + }); +}); + +// ── concurrent writes ──────────────────────────────────────────────────────── + +describe('concurrent writes', () => { + it('handles multiple simultaneous writes to different files', async () => { + const writes = Array.from({ length: 10 }, (_, i) => + provider.write(join(tmpDir, `concurrent-${i}.txt`), `data-${i}`) + ); + await Promise.all(writes); + for (let i = 0; i < 10; i++) { + const content = await provider.read(join(tmpDir, `concurrent-${i}.txt`)); + expect(content).toBe(`data-${i}`); + } + }); + + it('handles concurrent writes to the same file (last writer wins)', async () => { + const file = join(tmpDir, 'race.txt'); + const writes = Array.from({ length: 5 }, (_, i) => + provider.write(file, `writer-${i}`) + ); + await Promise.all(writes); + const content = await provider.read(file); + expect(content).toMatch(/^writer-[0-4]$/); + }); + + it('handles concurrent appends without data loss', async () => { + const file = join(tmpDir, 'append-race.txt'); + const appends = Array.from({ length: 10 }, (_, i) => + provider.append(file, `line-${i}\n`) + ); + await Promise.all(appends); + const content = await provider.read(file); + for (let i = 0; i < 10; i++) { + expect(content).toContain(`line-${i}`); + } + }); + + it('handles concurrent reads and writes', async () => { + const file = join(tmpDir, 'rw-race.txt'); + await provider.write(file, 'initial'); + + const ops = [ + provider.read(file), + provider.write(file, 'updated'), + provider.read(file), + provider.append(file, '-appended'), + provider.read(file), + ]; + const results = await Promise.all(ops); + expect(typeof results[0]).toBe('string'); + expect(typeof results[2]).toBe('string'); + expect(typeof results[4]).toBe('string'); + }); + + it('handles concurrent directory creation via writes', async () => { + const writes = Array.from({ length: 5 }, (_, i) => + provider.write(join(tmpDir, 'shared-parent', `file-${i}.txt`), `content-${i}`) + ); + await Promise.all(writes); + const entries = await provider.list(join(tmpDir, 'shared-parent')); + expect(entries.length).toBe(5); + }); +}); + +// ── listSync (FSStorageProvider) ───────────────────────────────────────────── + +describe('FSStorageProvider listSync', () => { + it('returns entry names for a populated directory', () => { + provider.writeSync(join(tmpDir, 'ls-dir', 'a.txt'), 'a'); + provider.writeSync(join(tmpDir, 'ls-dir', 'b.txt'), 'b'); + const entries = provider.listSync(join(tmpDir, 'ls-dir')); + expect(entries.sort()).toEqual(['a.txt', 'b.txt']); + }); + + it('returns empty array for ENOENT directory', () => { + expect(provider.listSync(join(tmpDir, 'no-such-dir'))).toEqual([]); + }); + + it('returns only direct children', () => { + provider.writeSync(join(tmpDir, 'ls2', 'file.txt'), 'x'); + provider.writeSync(join(tmpDir, 'ls2', 'sub', 'deep.txt'), 'y'); + const entries = provider.listSync(join(tmpDir, 'ls2')); + expect(entries.sort()).toEqual(['file.txt', 'sub']); + }); + + it('blocks traversal when rootDir is set', async () => { + const { mkdtemp: mkd } = await import('fs/promises'); + const root = await mkd(join(tmpdir(), 'squad-ls-confined-')); + const confined = new FSStorageProvider(root); + expect(() => confined.listSync('../')).toThrow(/traversal blocked/i); + await rm(root, { recursive: true, force: true }); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// InMemoryStorageProvider +// ═══════════════════════════════════════════════════════════════════════════════ + +describe('InMemoryStorageProvider', () => { + let mem: InMemoryStorageProvider; + + beforeEach(() => { + mem = new InMemoryStorageProvider(); + }); + + // ── Async methods ────────────────────────────────────────────────────────── + + describe('read()', () => { + it('returns undefined for missing key', async () => { + expect(await mem.read('missing.txt')).toBeUndefined(); + }); + + it('reads previously written content', async () => { + await mem.write('f.txt', 'hello'); + expect(await mem.read('f.txt')).toBe('hello'); + }); + }); + + describe('write()', () => { + it('stores content', async () => { + await mem.write('a.txt', 'A'); + expect(mem.snapshot().get('a.txt')).toBe('A'); + }); + + it('overwrites existing content', async () => { + await mem.write('a.txt', 'first'); + await mem.write('a.txt', 'second'); + expect(await mem.read('a.txt')).toBe('second'); + }); + }); + + describe('append()', () => { + it('creates file if missing', async () => { + await mem.append('new.txt', 'start'); + expect(await mem.read('new.txt')).toBe('start'); + }); + + it('appends to existing content', async () => { + await mem.write('log.txt', 'A'); + await mem.append('log.txt', 'B'); + expect(await mem.read('log.txt')).toBe('AB'); + }); + }); + + describe('exists()', () => { + it('returns false for missing key', async () => { + expect(await mem.exists('ghost')).toBe(false); + }); + + it('returns true for existing file', async () => { + await mem.write('present.txt', ''); + expect(await mem.exists('present.txt')).toBe(true); + }); + + it('returns true for implicit directory (prefix match)', async () => { + await mem.write('dir/child.txt', ''); + expect(await mem.exists('dir')).toBe(true); + }); + }); + + describe('list()', () => { + it('returns empty array for missing directory', async () => { + expect(await mem.list('empty')).toEqual([]); + }); + + it('returns direct children only', async () => { + await mem.write('d/a.txt', ''); + await mem.write('d/b.txt', ''); + await mem.write('d/sub/c.txt', ''); + const entries = await mem.list('d'); + expect(entries.sort()).toEqual(['a.txt', 'b.txt', 'sub']); + }); + }); + + describe('delete()', () => { + it('removes a file', async () => { + await mem.write('x.txt', 'val'); + await mem.delete('x.txt'); + expect(await mem.read('x.txt')).toBeUndefined(); + }); + + it('no-op for missing file', async () => { + await expect(mem.delete('nope')).resolves.toBeUndefined(); + }); + }); + + describe('deleteDir()', () => { + it('removes directory and all children', async () => { + await mem.write('rm/a.txt', ''); + await mem.write('rm/sub/b.txt', ''); + await mem.deleteDir('rm'); + expect(mem.snapshot().size).toBe(0); + }); + + it('no-op for missing directory', async () => { + await expect(mem.deleteDir('void')).resolves.toBeUndefined(); + }); + }); + + // ── Sync methods ─────────────────────────────────────────────────────────── + + describe('readSync()', () => { + it('returns undefined for missing key', () => { + expect(mem.readSync('nope')).toBeUndefined(); + }); + + it('reads content', () => { + mem.writeSync('s.txt', 'data'); + expect(mem.readSync('s.txt')).toBe('data'); + }); + }); + + describe('writeSync()', () => { + it('stores content', () => { + mem.writeSync('w.txt', 'val'); + expect(mem.snapshot().get('w.txt')).toBe('val'); + }); + + it('overwrites existing content', () => { + mem.writeSync('w.txt', 'first'); + mem.writeSync('w.txt', 'second'); + expect(mem.readSync('w.txt')).toBe('second'); + }); + }); + + describe('existsSync()', () => { + it('returns false for missing key', () => { + expect(mem.existsSync('no')).toBe(false); + }); + + it('returns true for file', () => { + mem.writeSync('yes.txt', ''); + expect(mem.existsSync('yes.txt')).toBe(true); + }); + + it('returns true for implicit directory', () => { + mem.writeSync('dir/child.txt', 'x'); + expect(mem.existsSync('dir')).toBe(true); + }); + }); + + describe('listSync()', () => { + it('returns empty array for missing directory', () => { + expect(mem.listSync('no-dir')).toEqual([]); + }); + + it('returns direct children', () => { + mem.writeSync('ls/alpha.txt', ''); + mem.writeSync('ls/beta.txt', ''); + mem.writeSync('ls/nested/gamma.txt', ''); + expect(mem.listSync('ls').sort()).toEqual(['alpha.txt', 'beta.txt', 'nested']); + }); + + it('deduplicates nested entries', () => { + mem.writeSync('d/sub/a.txt', ''); + mem.writeSync('d/sub/b.txt', ''); + expect(mem.listSync('d')).toEqual(['sub']); + }); + }); + + // ── Test helpers ─────────────────────────────────────────────────────────── + + describe('snapshot()', () => { + it('returns a copy of internal state', () => { + mem.writeSync('a', '1'); + const snap = mem.snapshot(); + mem.writeSync('b', '2'); + expect(snap.size).toBe(1); + }); + }); + + describe('clear()', () => { + it('removes all files', () => { + mem.writeSync('a', '1'); + mem.writeSync('b', '2'); + mem.clear(); + expect(mem.snapshot().size).toBe(0); + }); + }); + + // ── Edge cases ───────────────────────────────────────────────────────────── + + describe('edge cases', () => { + it('handles empty string content', async () => { + await mem.write('empty.txt', ''); + expect(await mem.read('empty.txt')).toBe(''); + expect(await mem.exists('empty.txt')).toBe(true); + }); + + it('normalizes trailing slashes', async () => { + await mem.write('dir/file.txt', 'ok'); + expect(mem.listSync('dir/')).toContain('file.txt'); + }); + + it('normalizes double slashes', async () => { + await mem.write('dir//file.txt', 'ok'); + expect(await mem.read('dir/file.txt')).toBe('ok'); + }); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// StorageError — extended +// ═══════════════════════════════════════════════════════════════════════════════ + +describe('StorageError path sanitization', () => { + it('strips absolute path, keeps basename', () => { + const cause = Object.assign(new Error('EACCES'), { code: 'EACCES' }) as NodeJS.ErrnoException; + const err = new StorageError('read', '/home/user/secret/.squad/config.json', cause); + expect(err.message).not.toContain('/home/user/secret'); + expect(err.message).toContain('config.json'); + }); + + it('preserves operation and code', () => { + const cause = Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) as NodeJS.ErrnoException; + const err = new StorageError('write', 'test.txt', cause); + expect(err.operation).toBe('write'); + expect(err.code).toBe('ENOENT'); + }); + + it('preserves original cause', () => { + const cause = Object.assign(new Error('EIO'), { code: 'EIO' }) as NodeJS.ErrnoException; + const err = new StorageError('delete', 'x', cause); + expect(err.cause).toBe(cause); + expect(err.name).toBe('StorageError'); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// DI injection — InMemoryStorageProvider as drop-in +// ═══════════════════════════════════════════════════════════════════════════════ + +describe('DI injection — InMemoryStorageProvider', () => { + it('satisfies StorageProvider contract (typed assignment)', () => { + const sp: StorageProvider = new InMemoryStorageProvider(); + sp.writeSync('config.json', '{"key":"value"}'); + expect(sp.readSync('config.json')).toBe('{"key":"value"}'); + expect(sp.existsSync('config.json')).toBe(true); + expect(sp.existsSync('missing.json')).toBe(false); + expect(sp.readSync('missing.json')).toBeUndefined(); + }); + + it('parseSkillFile works with InMemory-loaded content', () => { + const sp = new InMemoryStorageProvider(); + const skillContent = [ + '---', + 'name: Test Skill', + 'domain: testing', + 'triggers: [vitest, jest]', + 'roles: [tester]', + '---', + 'This is a test skill body.', + ].join('\n'); + + sp.writeSync('skills/my-skill/SKILL.md', skillContent); + const raw = sp.readSync('skills/my-skill/SKILL.md'); + expect(raw).toBeDefined(); + + const skill = parseSkillFile('my-skill', raw!); + expect(skill).toBeDefined(); + expect(skill!.id).toBe('my-skill'); + expect(skill!.name).toBe('Test Skill'); + expect(skill!.domain).toBe('testing'); + expect(skill!.triggers).toEqual(['vitest', 'jest']); + expect(skill!.agentRoles).toEqual(['tester']); + expect(skill!.content).toContain('test skill body'); + }); + + it('InMemory write+read+delete lifecycle matches FSStorageProvider semantics', async () => { + const sp: StorageProvider = new InMemoryStorageProvider(); + await sp.write('lifecycle.txt', 'created'); + expect(await sp.read('lifecycle.txt')).toBe('created'); + expect(await sp.exists('lifecycle.txt')).toBe(true); + await sp.delete('lifecycle.txt'); + expect(await sp.read('lifecycle.txt')).toBeUndefined(); + expect(await sp.exists('lifecycle.txt')).toBe(false); + }); + + it('InMemory list + listSync match async/sync behavior', async () => { + const sp = new InMemoryStorageProvider(); + sp.writeSync('dir/a.txt', ''); + sp.writeSync('dir/b.txt', ''); + sp.writeSync('dir/sub/c.txt', ''); + + const asyncList = (await sp.list('dir')).sort(); + const syncList = sp.listSync('dir').sort(); + expect(asyncList).toEqual(syncList); + expect(asyncList).toEqual(['a.txt', 'b.txt', 'sub']); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// Cross-provider contract — both providers behave identically +// ═══════════════════════════════════════════════════════════════════════════════ + +describe('cross-provider contract', () => { + let fsRoot: string; + + beforeEach(async () => { + fsRoot = await mkdtemp(join(tmpdir(), 'squad-xprovider-')); + }); + + afterEach(async () => { + await rm(fsRoot, { recursive: true, force: true }); + }); + + function providers(): Array<{ name: string; sp: StorageProvider }> { + return [ + { name: 'FSStorageProvider', sp: new FSStorageProvider(fsRoot) }, + { name: 'InMemoryStorageProvider', sp: new InMemoryStorageProvider() }, + ]; + } + + it('read returns undefined for missing files', async () => { + for (const { name, sp } of providers()) { + expect(await sp.read('missing.txt'), `${name}`).toBeUndefined(); + } + }); + + it('write + read roundtrip', async () => { + for (const { name, sp } of providers()) { + await sp.write('round.txt', 'trip'); + expect(await sp.read('round.txt'), `${name}`).toBe('trip'); + } + }); + + it('list returns empty for missing dir', async () => { + for (const { name, sp } of providers()) { + expect(await sp.list('no-dir'), `${name}`).toEqual([]); + } + }); + + it('listSync returns empty for missing dir', () => { + for (const { name, sp } of providers()) { + expect(sp.listSync('no-dir'), `${name}`).toEqual([]); + } + }); + + it('delete is no-op for missing file', async () => { + for (const { sp } of providers()) { + await expect(sp.delete('ghost.txt')).resolves.toBeUndefined(); + } + }); + + it('existsSync returns false for missing', () => { + for (const { sp } of providers()) { + expect(sp.existsSync('nope')).toBe(false); + } + }); + + it('readSync returns undefined for missing', () => { + for (const { sp } of providers()) { + expect(sp.readSync('nope')).toBeUndefined(); + } + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// Contract Test Factory — same conformance suite for every StorageProvider +// ═══════════════════════════════════════════════════════════════════════════════ + +runStorageProviderContractTests('FSStorageProvider', async () => { + const tmpDir = mkdtempSync(join(tmpdir(), 'sp-contract-')); + const provider = new FSStorageProvider(tmpDir); + return { provider, cleanup: async () => rmSync(tmpDir, { recursive: true, force: true }) }; +}); + +runStorageProviderContractTests('InMemoryStorageProvider', async () => { + const provider = new InMemoryStorageProvider(); + return { provider, cleanup: async () => provider.clear() }; +}); + +import { SQLiteStorageProvider } from '../packages/squad-sdk/src/storage/sqlite-storage-provider.js'; + +runStorageProviderContractTests('SQLiteStorageProvider', async () => { + const tmpDir = mkdtempSync(join(tmpdir(), 'squad-sqlite-test-')); + const dbPath = join(tmpDir, 'test.db'); + const provider = new SQLiteStorageProvider(dbPath); + await provider.init(); + return { provider, cleanup: async () => rmSync(tmpDir, { recursive: true, force: true }) }; +}); + +// ── SQLite-specific tests ───────────────────────────────────────────────────── + +describe('SQLiteStorageProvider — SQLite-specific', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'squad-sqlite-specific-')); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + // ── Persistence across instances ──────────────────────────────────────── + + it('persists data: write → close → reopen → read returns same data', async () => { + const dbPath = join(tmpDir, 'persist.db'); + + const p1 = new SQLiteStorageProvider(dbPath); + await p1.init(); + await p1.write('docs/readme.md', '# Hello'); + // p1 goes out of scope — no explicit close needed for sql.js + + const p2 = new SQLiteStorageProvider(dbPath); + await p2.init(); + expect(await p2.read('docs/readme.md')).toBe('# Hello'); + }); + + it('persists multiple files across reopen', async () => { + const dbPath = join(tmpDir, 'multi-persist.db'); + + const p1 = new SQLiteStorageProvider(dbPath); + await p1.init(); + await p1.write('a.txt', 'alpha'); + await p1.write('b.txt', 'bravo'); + await p1.write('sub/c.txt', 'charlie'); + + const p2 = new SQLiteStorageProvider(dbPath); + await p2.init(); + expect(await p2.read('a.txt')).toBe('alpha'); + expect(await p2.read('b.txt')).toBe('bravo'); + expect(await p2.read('sub/c.txt')).toBe('charlie'); + }); + + // ── init() idempotency ────────────────────────────────────────────────── + + it('calling init() twice is safe (idempotent)', async () => { + const dbPath = join(tmpDir, 'idempotent.db'); + const provider = new SQLiteStorageProvider(dbPath); + await provider.init(); + await provider.write('test.txt', 'first'); + await provider.init(); // second init — should be no-op + expect(await provider.read('test.txt')).toBe('first'); + }); + + it('calling init() concurrently is safe', async () => { + const dbPath = join(tmpDir, 'concurrent-init.db'); + const provider = new SQLiteStorageProvider(dbPath); + // Fire two init() calls concurrently — should not throw or corrupt + await Promise.all([provider.init(), provider.init()]); + await provider.write('ok.txt', 'ok'); + expect(await provider.read('ok.txt')).toBe('ok'); + }); + + // ── Large content handling ────────────────────────────────────────────── + + it('handles large content (100 KB)', async () => { + const dbPath = join(tmpDir, 'large.db'); + const provider = new SQLiteStorageProvider(dbPath); + await provider.init(); + + const largeContent = 'x'.repeat(100_000); + await provider.write('big.txt', largeContent); + expect(await provider.read('big.txt')).toBe(largeContent); + }); + + // ── Path normalization ────────────────────────────────────────────────── + + it('normalizes backslashes to forward slashes', async () => { + const dbPath = join(tmpDir, 'norm.db'); + const provider = new SQLiteStorageProvider(dbPath); + await provider.init(); + + await provider.write('dir\\sub\\file.txt', 'normalized'); + // Reading with forward slashes should find the same entry + expect(await provider.read('dir/sub/file.txt')).toBe('normalized'); + }); + + it('normalizes redundant slashes', async () => { + const dbPath = join(tmpDir, 'norm2.db'); + const provider = new SQLiteStorageProvider(dbPath); + await provider.init(); + + await provider.write('a//b///c.txt', 'clean'); + expect(await provider.read('a/b/c.txt')).toBe('clean'); + }); + + // ── DB file creation ──────────────────────────────────────────────────── + + it('creates DB file on disk when it does not exist yet', async () => { + const dbPath = join(tmpDir, 'brand-new.db'); + expect(existsSync(dbPath)).toBe(false); + + const provider = new SQLiteStorageProvider(dbPath); + await provider.init(); + await provider.write('hello.txt', 'world'); + + // After write + persist, the DB file should exist on disk + expect(existsSync(dbPath)).toBe(true); + }); + + it('creates parent directories for the DB file', async () => { + const dbPath = join(tmpDir, 'nested', 'dir', 'deep.db'); + const provider = new SQLiteStorageProvider(dbPath); + await provider.init(); + await provider.write('test.txt', 'data'); + expect(existsSync(dbPath)).toBe(true); + }); + + // ── updated_at column ────────────────────────────────────────────────── + + it('populates updated_at as an ISO 8601 timestamp on write', async () => { + const dbPath = join(tmpDir, 'timestamps.db'); + const provider = new SQLiteStorageProvider(dbPath); + await provider.init(); + + const before = new Date().toISOString(); + await provider.write('ts.txt', 'timestamped'); + const after = new Date().toISOString(); + + // Access the internal DB to verify updated_at + const p2 = new SQLiteStorageProvider(dbPath); + await p2.init(); + // Use readSync to confirm the file exists, then check timestamp via a second instance + // We verify by reopening and reading — the file should still be there + expect(await p2.read('ts.txt')).toBe('timestamped'); + + // To verify the timestamp, we need to read the raw DB + const initSqlJs = (await import('sql.js')).default; + const SQL = await initSqlJs(); + const fileBuffer = readFileSync(dbPath); + const db = new SQL.Database(fileBuffer); + const stmt = db.prepare('SELECT updated_at FROM files WHERE path = ?'); + stmt.bind(['ts.txt']); + expect(stmt.step()).toBe(true); + const row = stmt.getAsObject() as { updated_at: string }; + stmt.free(); + db.close(); + + // updated_at should be a valid ISO 8601 string between before and after + expect(row.updated_at).toBeTruthy(); + expect(row.updated_at >= before).toBe(true); + expect(row.updated_at <= after).toBe(true); + }); + + it('updates updated_at on overwrite', async () => { + const dbPath = join(tmpDir, 'ts-overwrite.db'); + const provider = new SQLiteStorageProvider(dbPath); + await provider.init(); + + await provider.write('file.txt', 'v1'); + + // Small delay to ensure timestamps differ + await new Promise((r) => setTimeout(r, 10)); + + await provider.write('file.txt', 'v2'); + + const initSqlJs = (await import('sql.js')).default; + const SQL = await initSqlJs(); + const fileBuffer = readFileSync(dbPath); + const db = new SQL.Database(fileBuffer); + const stmt = db.prepare('SELECT updated_at FROM files WHERE path = ?'); + stmt.bind(['file.txt']); + expect(stmt.step()).toBe(true); + const row = stmt.getAsObject() as { updated_at: string }; + stmt.free(); + db.close(); + + // The timestamp should reflect the second write + const ts = new Date(row.updated_at).getTime(); + expect(ts).toBeGreaterThan(0); + }); + + // ── Sync methods require init() ───────────────────────────────────────── + + it('throws if sync methods called before init()', () => { + const dbPath = join(tmpDir, 'no-init.db'); + const provider = new SQLiteStorageProvider(dbPath); + // No init() call — sync methods should throw + expect(() => provider.readSync('any.txt')).toThrow(/not initialized/i); + expect(() => provider.writeSync('any.txt', 'data')).toThrow(/not initialized/i); + expect(() => provider.existsSync('any.txt')).toThrow(/not initialized/i); + expect(() => provider.listSync('dir')).toThrow(/not initialized/i); + }); + + // ── LIKE wildcard regression (escapeLike) ─────────────────────────────── + + describe('LIKE wildcard regression — escapeLike()', () => { + it('list() with % in file path returns it correctly', async () => { + const dbPath = join(tmpDir, 'wc-pct-list.db'); + const provider = new SQLiteStorageProvider(dbPath); + await provider.init(); + await provider.write('dir/100%_done.txt', 'complete'); + await provider.write('dir/normal.txt', 'ok'); + const entries = await provider.list('dir'); + expect(entries.sort()).toEqual(['100%_done.txt', 'normal.txt']); + }); + + it('list() with _ in file path returns only expected entries', async () => { + const dbPath = join(tmpDir, 'wc-under-list.db'); + const provider = new SQLiteStorageProvider(dbPath); + await provider.init(); + await provider.write('dir/file_v2.txt', 'versioned'); + await provider.write('dir/readme.txt', 'info'); + const entries = await provider.list('dir'); + expect(entries.sort()).toEqual(['file_v2.txt', 'readme.txt']); + }); + + it('list("a%") returns only files under literal a% dir, not a/', async () => { + const dbPath = join(tmpDir, 'wc-pct-dir.db'); + const provider = new SQLiteStorageProvider(dbPath); + await provider.init(); + await provider.write('a/b.txt', 'wrong'); + await provider.write('a%/c.txt', 'right'); + const entries = await provider.list('a%'); + expect(entries).toEqual(['c.txt']); + }); + + it('list("a_") returns only files under literal a_ dir, not ab/', async () => { + const dbPath = join(tmpDir, 'wc-under-dir.db'); + const provider = new SQLiteStorageProvider(dbPath); + await provider.init(); + await provider.write('ab/x.txt', 'wrong'); + await provider.write('a_/y.txt', 'right'); + const entries = await provider.list('a_'); + expect(entries).toEqual(['y.txt']); + }); + + it('existsSync with % in path checks literally', async () => { + const dbPath = join(tmpDir, 'wc-pct-exists.db'); + const provider = new SQLiteStorageProvider(dbPath); + await provider.init(); + provider.writeSync('pct%dir/test.txt', 'data'); + expect(provider.existsSync('pct%dir/test.txt')).toBe(true); + expect(provider.existsSync('pctXdir/test.txt')).toBe(false); + }); + + it('existsSync with _ in path checks literally', async () => { + const dbPath = join(tmpDir, 'wc-under-exists.db'); + const provider = new SQLiteStorageProvider(dbPath); + await provider.init(); + provider.writeSync('under_dir/test.txt', 'data'); + expect(provider.existsSync('under_dir/test.txt')).toBe(true); + expect(provider.existsSync('underXdir/test.txt')).toBe(false); + }); + + it('deleteDir with % only deletes the literal directory', async () => { + const dbPath = join(tmpDir, 'wc-pct-deldir.db'); + const provider = new SQLiteStorageProvider(dbPath); + await provider.init(); + await provider.write('del%dir/target.txt', 'delete-me'); + await provider.write('delXdir/keep.txt', 'keep-me'); + await provider.deleteDir('del%dir'); + expect(await provider.exists('del%dir/target.txt')).toBe(false); + expect(await provider.exists('delXdir/keep.txt')).toBe(true); + }); + }); +});