diff --git a/.squad/decisions/inbox/fenster-template-audit.md b/.squad/decisions/inbox/fenster-template-audit.md new file mode 100644 index 000000000..baf4e12ae --- /dev/null +++ b/.squad/decisions/inbox/fenster-template-audit.md @@ -0,0 +1,21 @@ +# Decision: Template directory sync enforcement + +**Author:** Fenster (Core Dev) +**Date:** 2026-07-16 +**Issue:** #461 +**PR:** #462 + +## Context + +PR #460 fixed universe count in some but not all template copies, revealing that we had 5 locations with duplicated files and no automated enforcement. + +## Decision + +1. `.squad-templates/` is the **canonical source** for all template files. +2. All copies in `templates/`, `packages/squad-cli/templates/`, `packages/squad-sdk/templates/`, and `.github/agents/` must match canonical. +3. `test/template-sync.test.ts` enforces byte-for-byte parity for casting-policy.json, universe count parity for squad.agent.md, and cross-file count validation. +4. Any future template changes must update ALL locations and pass the sync tests. + +## Impact + +All team members editing template files. diff --git a/package.json b/package.json index 26d093058..d2595739a 100644 --- a/package.json +++ b/package.json @@ -8,9 +8,9 @@ "packages/*" ], "scripts": { - "prebuild": "node scripts/bump-build.mjs && node scripts/sync-skill-templates.mjs", + "prebuild": "node scripts/bump-build.mjs && node scripts/sync-skill-templates.mjs && node scripts/sync-templates.mjs", "build": "npm run build -w packages/squad-sdk && npm run build -w packages/squad-cli", - "sync-templates": "node scripts/sync-skill-templates.mjs", + "sync-templates": "node scripts/sync-templates.mjs", "test": "vitest run", "test:watch": "vitest", "lint": "tsc --noEmit -p packages/squad-sdk/tsconfig.json && tsc --noEmit -p packages/squad-cli/tsconfig.json", diff --git a/packages/squad-cli/templates/casting-policy.json b/packages/squad-cli/templates/casting-policy.json index b3858c78d..12a57cca8 100644 --- a/packages/squad-cli/templates/casting-policy.json +++ b/packages/squad-cli/templates/casting-policy.json @@ -14,7 +14,8 @@ "Breaking Bad", "Lost", "Marvel Cinematic Universe", - "DC Universe" + "DC Universe", + "Futurama" ], "universe_capacity": { "The Usual Suspects": 6, @@ -30,6 +31,7 @@ "Breaking Bad": 12, "Lost": 18, "Marvel Cinematic Universe": 25, - "DC Universe": 18 + "DC Universe": 18, + "Futurama": 12 } } diff --git a/packages/squad-cli/templates/orchestration-log.md b/packages/squad-cli/templates/orchestration-log.md index 08e131565..37d94d193 100644 --- a/packages/squad-cli/templates/orchestration-log.md +++ b/packages/squad-cli/templates/orchestration-log.md @@ -20,7 +20,7 @@ ## Rules -1. **One file per agent spawn.** Named `{timestamp}-{agent-name}.md`. Timestamps must be filename-safe (replace colons with hyphens, e.g., `2026-02-23T20-16-27Z`). +1. **One file per agent spawn.** Named `{timestamp}-{agent-name}.md`. 2. **Log BEFORE spawning.** The entry must exist before the agent runs. 3. **Update outcome AFTER the agent completes.** Fill in the Outcome field. 4. **Never delete or edit past entries.** Append-only. diff --git a/packages/squad-cli/templates/scribe-charter.md b/packages/squad-cli/templates/scribe-charter.md index 35e333ea4..9082faa45 100644 --- a/packages/squad-cli/templates/scribe-charter.md +++ b/packages/squad-cli/templates/scribe-charter.md @@ -22,7 +22,7 @@ After every substantial work session: -1. **Log the session** to `.squad/log/{timestamp}-{topic}.md` (use filename-safe timestamps — replace colons with hyphens, e.g., `2026-02-23T20-16-27Z` not `2026-02-23T20:16:27Z`, for Windows compatibility): +1. **Log the session** to `.squad/log/{timestamp}-{topic}.md`: - Who worked - What was done - Decisions made diff --git a/packages/squad-cli/templates/skills/nap/SKILL.md b/packages/squad-cli/templates/skills/nap/SKILL.md new file mode 100644 index 000000000..5973b1cf2 --- /dev/null +++ b/packages/squad-cli/templates/skills/nap/SKILL.md @@ -0,0 +1,24 @@ +# Skill: nap + +> Context hygiene — compress, prune, archive .squad/ state + +## What It Does + +Reclaims context window budget by compressing agent histories, pruning old logs, +archiving stale decisions, and cleaning orphaned inbox files. + +## When To Use + +- Before heavy fan-out work (many agents will spawn) +- When history.md files exceed 15KB +- When .squad/ total size exceeds 1MB +- After long-running sessions or sprints + +## Invocation + +- CLI: `squad nap` / `squad nap --deep` / `squad nap --dry-run` +- REPL: `/nap` / `/nap --dry-run` / `/nap --deep` + +## Confidence + +medium — Confirmed by team vote (4-1) and initial implementation diff --git a/packages/squad-sdk/templates/casting-policy.json b/packages/squad-sdk/templates/casting-policy.json index b3858c78d..12a57cca8 100644 --- a/packages/squad-sdk/templates/casting-policy.json +++ b/packages/squad-sdk/templates/casting-policy.json @@ -14,7 +14,8 @@ "Breaking Bad", "Lost", "Marvel Cinematic Universe", - "DC Universe" + "DC Universe", + "Futurama" ], "universe_capacity": { "The Usual Suspects": 6, @@ -30,6 +31,7 @@ "Breaking Bad": 12, "Lost": 18, "Marvel Cinematic Universe": 25, - "DC Universe": 18 + "DC Universe": 18, + "Futurama": 12 } } diff --git a/packages/squad-sdk/templates/mcp-config.md b/packages/squad-sdk/templates/mcp-config.md index 6293bed11..8ecabecf5 100644 --- a/packages/squad-sdk/templates/mcp-config.md +++ b/packages/squad-sdk/templates/mcp-config.md @@ -4,14 +4,6 @@ MCP (Model Context Protocol) servers extend Squad with tools for external servic > **Full patterns:** Read `.copilot/skills/mcp-tool-discovery/SKILL.md` for discovery patterns, domain-specific usage, and graceful degradation. -## Security Considerations - -> ⚠️ **Important:** The sample configs below use `npx -y` to run MCP server packages without version pinning. For production use: -> - **Pin versions:** Use `npx -y @trello/mcp-server@1.2.3` instead of bare package names -> - **Audit packages:** Review MCP server source code before granting access to credentials -> - **Use least-privilege tokens:** Create tokens with minimal required scopes -> - **Consider local installs:** Install packages locally (`npm install`) rather than fetching on each run - ## Config File Locations Users configure MCP servers at these locations (checked in priority order): diff --git a/packages/squad-sdk/templates/package.json b/packages/squad-sdk/templates/package.json new file mode 100644 index 000000000..140154e8d --- /dev/null +++ b/packages/squad-sdk/templates/package.json @@ -0,0 +1,3 @@ +{ + "type": "commonjs" +} diff --git a/packages/squad-sdk/templates/ralph-triage.js b/packages/squad-sdk/templates/ralph-triage.js new file mode 100644 index 000000000..cf3023961 --- /dev/null +++ b/packages/squad-sdk/templates/ralph-triage.js @@ -0,0 +1,543 @@ +#!/usr/bin/env node +/** + * Ralph Triage Script — Standalone CJS implementation + * + * ⚠️ SYNC NOTICE: This file ports triage logic from the SDK source: + * packages/squad-sdk/src/ralph/triage.ts + * + * Any changes to routing/triage logic MUST be applied to BOTH files. + * The SDK module is the canonical implementation; this script exists + * for zero-dependency use in GitHub Actions workflows. + * + * To verify parity: npm test -- test/ralph-triage.test.ts + */ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const https = require('node:https'); +const { execSync } = require('node:child_process'); + +function parseArgs(argv) { + let squadDir = '.squad'; + let output = 'triage-results.json'; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '--squad-dir') { + squadDir = argv[i + 1]; + i += 1; + continue; + } + if (arg === '--output') { + output = argv[i + 1]; + i += 1; + continue; + } + if (arg === '--help' || arg === '-h') { + printUsage(); + process.exit(0); + } + throw new Error(`Unknown argument: ${arg}`); + } + + if (!squadDir) throw new Error('--squad-dir requires a value'); + if (!output) throw new Error('--output requires a value'); + + return { squadDir, output }; +} + +function printUsage() { + console.log('Usage: node .squad/templates/ralph-triage.js --squad-dir .squad --output triage-results.json'); +} + +function normalizeEol(content) { + return content.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); +} + +function parseRoutingRules(routingMd) { + const table = parseTableSection(routingMd, /^##\s*work\s*type\s*(?:→|->)\s*agent\b/i); + if (!table) return []; + + const workTypeIndex = findColumnIndex(table.headers, ['work type', 'type']); + const agentIndex = findColumnIndex(table.headers, ['agent', 'route to', 'route']); + const examplesIndex = findColumnIndex(table.headers, ['examples', 'example']); + + if (workTypeIndex < 0 || agentIndex < 0) return []; + + const rules = []; + for (const row of table.rows) { + const workType = cleanCell(row[workTypeIndex] || ''); + const agentName = cleanCell(row[agentIndex] || ''); + const keywords = splitKeywords(examplesIndex >= 0 ? row[examplesIndex] : ''); + if (!workType || !agentName) continue; + rules.push({ workType, agentName, keywords }); + } + + return rules; +} + +function parseModuleOwnership(routingMd) { + const table = parseTableSection(routingMd, /^##\s*module\s*ownership\b/i); + if (!table) return []; + + const moduleIndex = findColumnIndex(table.headers, ['module', 'path']); + const primaryIndex = findColumnIndex(table.headers, ['primary']); + const secondaryIndex = findColumnIndex(table.headers, ['secondary']); + + if (moduleIndex < 0 || primaryIndex < 0) return []; + + const modules = []; + for (const row of table.rows) { + const modulePath = normalizeModulePath(row[moduleIndex] || ''); + const primary = cleanCell(row[primaryIndex] || ''); + const secondaryRaw = cleanCell(secondaryIndex >= 0 ? row[secondaryIndex] || '' : ''); + const secondary = normalizeOptionalOwner(secondaryRaw); + + if (!modulePath || !primary) continue; + modules.push({ modulePath, primary, secondary }); + } + + return modules; +} + +function parseRoster(teamMd) { + const table = + parseTableSection(teamMd, /^##\s*members\b/i) || + parseTableSection(teamMd, /^##\s*team\s*roster\b/i); + + if (!table) return []; + + const nameIndex = findColumnIndex(table.headers, ['name']); + const roleIndex = findColumnIndex(table.headers, ['role']); + if (nameIndex < 0 || roleIndex < 0) return []; + + const excluded = new Set(['scribe', 'ralph']); + const members = []; + + for (const row of table.rows) { + const name = cleanCell(row[nameIndex] || ''); + const role = cleanCell(row[roleIndex] || ''); + if (!name || !role) continue; + if (excluded.has(name.toLowerCase())) continue; + + members.push({ + name, + role, + label: `squad:${name.toLowerCase()}`, + }); + } + + return members; +} + +function triageIssue(issue, rules, modules, roster) { + const issueText = `${issue.title}\n${issue.body || ''}`.toLowerCase(); + const normalizedIssueText = normalizeTextForPathMatch(issueText); + + const bestModule = findBestModuleMatch(normalizedIssueText, modules); + if (bestModule) { + const primaryMember = findMember(bestModule.primary, roster); + if (primaryMember) { + return { + agent: primaryMember, + reason: `Matched module path "${bestModule.modulePath}" to primary owner "${bestModule.primary}"`, + source: 'module-ownership', + confidence: 'high', + }; + } + + if (bestModule.secondary) { + const secondaryMember = findMember(bestModule.secondary, roster); + if (secondaryMember) { + return { + agent: secondaryMember, + reason: `Matched module path "${bestModule.modulePath}" to secondary owner "${bestModule.secondary}"`, + source: 'module-ownership', + confidence: 'medium', + }; + } + } + } + + const bestRule = findBestRuleMatch(issueText, rules); + if (bestRule) { + const agent = findMember(bestRule.rule.agentName, roster); + if (agent) { + return { + agent, + reason: `Matched routing keyword(s): ${bestRule.matchedKeywords.join(', ')}`, + source: 'routing-rule', + confidence: bestRule.matchedKeywords.length >= 2 ? 'high' : 'medium', + }; + } + } + + const roleMatch = findRoleKeywordMatch(issueText, roster); + if (roleMatch) { + return { + agent: roleMatch.agent, + reason: roleMatch.reason, + source: 'role-keyword', + confidence: 'medium', + }; + } + + const lead = findLeadFallback(roster); + if (!lead) return null; + + return { + agent: lead, + reason: 'No module, routing, or role keyword match — routed to Lead/Architect', + source: 'lead-fallback', + confidence: 'low', + }; +} + +function parseTableSection(markdown, sectionHeader) { + const lines = normalizeEol(markdown).split('\n'); + let inSection = false; + const tableLines = []; + + for (const line of lines) { + const trimmed = line.trim(); + if (!inSection && sectionHeader.test(trimmed)) { + inSection = true; + continue; + } + if (inSection && /^##\s+/.test(trimmed)) break; + if (inSection && trimmed.startsWith('|')) tableLines.push(trimmed); + } + + if (tableLines.length === 0) return null; + + let headers = null; + const rows = []; + + for (const line of tableLines) { + const cells = parseTableLine(line); + if (cells.length === 0) continue; + if (cells.every((cell) => /^:?-{2,}:?$/.test(cell))) continue; + + if (!headers) { + headers = cells; + continue; + } + + rows.push(cells); + } + + if (!headers) return null; + return { headers, rows }; +} + +function parseTableLine(line) { + return line + .replace(/^\|/, '') + .replace(/\|$/, '') + .split('|') + .map((cell) => cell.trim()); +} + +function findColumnIndex(headers, candidates) { + const normalizedHeaders = headers.map((header) => cleanCell(header).toLowerCase()); + for (const candidate of candidates) { + const index = normalizedHeaders.findIndex((header) => header.includes(candidate)); + if (index >= 0) return index; + } + return -1; +} + +function cleanCell(value) { + return value + .replace(/`/g, '') + .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') + .trim(); +} + +function splitKeywords(examplesCell) { + if (!examplesCell) return []; + return examplesCell + .split(',') + .map((keyword) => cleanCell(keyword)) + .filter((keyword) => keyword.length > 0); +} + +function normalizeOptionalOwner(owner) { + if (!owner) return null; + if (/^[-—–]+$/.test(owner)) return null; + return owner; +} + +function normalizeModulePath(modulePath) { + return cleanCell(modulePath).replace(/\\/g, '/').toLowerCase(); +} + +function normalizeTextForPathMatch(text) { + return text.replace(/\\/g, '/').replace(/`/g, ''); +} + +function normalizeName(value) { + return cleanCell(value) + .toLowerCase() + .replace(/[^\w@\s-]/g, '') + .replace(/\s+/g, ' ') + .trim(); +} + +function findMember(target, roster) { + const normalizedTarget = normalizeName(target); + if (!normalizedTarget) return null; + + for (const member of roster) { + if (normalizeName(member.name) === normalizedTarget) return member; + } + + for (const member of roster) { + if (normalizeName(member.role) === normalizedTarget) return member; + } + + for (const member of roster) { + const memberName = normalizeName(member.name); + if (normalizedTarget.includes(memberName) || memberName.includes(normalizedTarget)) { + return member; + } + } + + for (const member of roster) { + const memberRole = normalizeName(member.role); + if (normalizedTarget.includes(memberRole) || memberRole.includes(normalizedTarget)) { + return member; + } + } + + return null; +} + +function findBestModuleMatch(issueText, modules) { + let best = null; + let bestLength = -1; + + for (const module of modules) { + const modulePath = normalizeModulePath(module.modulePath); + if (!modulePath) continue; + if (!issueText.includes(modulePath)) continue; + + if (modulePath.length > bestLength) { + best = module; + bestLength = modulePath.length; + } + } + + return best; +} + +function findBestRuleMatch(issueText, rules) { + let best = null; + let bestScore = 0; + + for (const rule of rules) { + const matchedKeywords = rule.keywords + .map((keyword) => keyword.toLowerCase()) + .filter((keyword) => keyword.length > 0 && issueText.includes(keyword)); + + if (matchedKeywords.length === 0) continue; + + const score = + matchedKeywords.length * 100 + matchedKeywords.reduce((sum, keyword) => sum + keyword.length, 0); + if (score > bestScore) { + best = { rule, matchedKeywords }; + bestScore = score; + } + } + + return best; +} + +function findRoleKeywordMatch(issueText, roster) { + for (const member of roster) { + const role = member.role.toLowerCase(); + + if ( + (role.includes('frontend') || role.includes('ui')) && + (issueText.includes('ui') || issueText.includes('frontend') || issueText.includes('css')) + ) { + return { agent: member, reason: 'Matched frontend/UI role keywords' }; + } + + if ( + (role.includes('backend') || role.includes('api') || role.includes('server')) && + (issueText.includes('api') || issueText.includes('backend') || issueText.includes('database')) + ) { + return { agent: member, reason: 'Matched backend/API role keywords' }; + } + + if ( + (role.includes('test') || role.includes('qa')) && + (issueText.includes('test') || issueText.includes('bug') || issueText.includes('fix')) + ) { + return { agent: member, reason: 'Matched testing/QA role keywords' }; + } + } + + return null; +} + +function findLeadFallback(roster) { + return ( + roster.find((member) => { + const role = member.role.toLowerCase(); + return role.includes('lead') || role.includes('architect'); + }) || null + ); +} + +function parseOwnerRepoFromRemote(remoteUrl) { + const sshMatch = remoteUrl.match(/^git@[^:]+:([^/]+)\/(.+?)(?:\.git)?$/); + if (sshMatch) return { owner: sshMatch[1], repo: sshMatch[2] }; + + if (remoteUrl.startsWith('http://') || remoteUrl.startsWith('https://') || remoteUrl.startsWith('ssh://')) { + const parsed = new URL(remoteUrl); + const parts = parsed.pathname.replace(/^\/+/, '').replace(/\.git$/, '').split('/'); + if (parts.length >= 2) { + return { owner: parts[0], repo: parts[1] }; + } + } + + throw new Error(`Unable to parse owner/repo from remote URL: ${remoteUrl}`); +} + +function getOwnerRepoFromGit() { + const remoteUrl = execSync('git remote get-url origin', { encoding: 'utf8' }).trim(); + return parseOwnerRepoFromRemote(remoteUrl); +} + +function githubRequestJson(pathname, token) { + return new Promise((resolve, reject) => { + const req = https.request( + { + hostname: 'api.github.com', + method: 'GET', + path: pathname, + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${token}`, + 'User-Agent': 'squad-ralph-triage', + 'X-GitHub-Api-Version': '2022-11-28', + }, + }, + (res) => { + let body = ''; + res.setEncoding('utf8'); + res.on('data', (chunk) => { + body += chunk; + }); + res.on('end', () => { + if ((res.statusCode || 500) >= 400) { + reject(new Error(`GitHub API ${res.statusCode}: ${body}`)); + return; + } + try { + resolve(JSON.parse(body)); + } catch (error) { + reject(new Error(`Failed to parse GitHub response: ${error.message}`)); + } + }); + }, + ); + req.on('error', reject); + req.end(); + }); +} + +async function fetchSquadIssues(owner, repo, token) { + const all = []; + let page = 1; + const perPage = 100; + + for (;;) { + const query = new URLSearchParams({ + state: 'open', + labels: 'squad', + per_page: String(perPage), + page: String(page), + }); + const issues = await githubRequestJson(`/repos/${owner}/${repo}/issues?${query.toString()}`, token); + if (!Array.isArray(issues) || issues.length === 0) break; + all.push(...issues); + if (issues.length < perPage) break; + page += 1; + } + + return all; +} + +function issueHasLabel(issue, labelName) { + const target = labelName.toLowerCase(); + return (issue.labels || []).some((label) => { + if (!label) return false; + const name = typeof label === 'string' ? label : label.name; + return typeof name === 'string' && name.toLowerCase() === target; + }); +} + +function isUntriagedIssue(issue, memberLabels) { + if (issue.pull_request) return false; + if (!issueHasLabel(issue, 'squad')) return false; + return !memberLabels.some((label) => issueHasLabel(issue, label)); +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const token = process.env.GITHUB_TOKEN; + if (!token) { + throw new Error('GITHUB_TOKEN is required'); + } + + const squadDir = path.resolve(process.cwd(), args.squadDir); + const teamMd = fs.readFileSync(path.join(squadDir, 'team.md'), 'utf8'); + const routingMd = fs.readFileSync(path.join(squadDir, 'routing.md'), 'utf8'); + + const roster = parseRoster(teamMd); + const rules = parseRoutingRules(routingMd); + const modules = parseModuleOwnership(routingMd); + + const { owner, repo } = getOwnerRepoFromGit(); + const openSquadIssues = await fetchSquadIssues(owner, repo, token); + + const memberLabels = roster.map((member) => member.label); + const untriaged = openSquadIssues.filter((issue) => isUntriagedIssue(issue, memberLabels)); + + const results = []; + for (const issue of untriaged) { + const decision = triageIssue( + { + number: issue.number, + title: issue.title || '', + body: issue.body || '', + labels: [], + }, + rules, + modules, + roster, + ); + + if (!decision) continue; + results.push({ + issueNumber: issue.number, + assignTo: decision.agent.name, + label: decision.agent.label, + reason: decision.reason, + source: decision.source, + }); + } + + const outputPath = path.resolve(process.cwd(), args.output); + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + fs.writeFileSync(outputPath, `${JSON.stringify(results, null, 2)}\n`, 'utf8'); +} + +main().catch((error) => { + console.error(error.message); + process.exit(1); +}); diff --git a/packages/squad-sdk/templates/scribe-charter.md b/packages/squad-sdk/templates/scribe-charter.md index bff56af13..9082faa45 100644 --- a/packages/squad-sdk/templates/scribe-charter.md +++ b/packages/squad-sdk/templates/scribe-charter.md @@ -63,7 +63,7 @@ After every substantial work session: - Write the commit message to a temp file, then commit with `-F`: ``` $msg = @" - docs(squad): {brief summary} + docs(ai-team): {brief summary} Session: {timestamp}-{topic} Requested by: {user name} diff --git a/packages/squad-sdk/templates/skills/nap/SKILL.md b/packages/squad-sdk/templates/skills/nap/SKILL.md new file mode 100644 index 000000000..5973b1cf2 --- /dev/null +++ b/packages/squad-sdk/templates/skills/nap/SKILL.md @@ -0,0 +1,24 @@ +# Skill: nap + +> Context hygiene — compress, prune, archive .squad/ state + +## What It Does + +Reclaims context window budget by compressing agent histories, pruning old logs, +archiving stale decisions, and cleaning orphaned inbox files. + +## When To Use + +- Before heavy fan-out work (many agents will spawn) +- When history.md files exceed 15KB +- When .squad/ total size exceeds 1MB +- After long-running sessions or sprints + +## Invocation + +- CLI: `squad nap` / `squad nap --deep` / `squad nap --dry-run` +- REPL: `/nap` / `/nap --dry-run` / `/nap --deep` + +## Confidence + +medium — Confirmed by team vote (4-1) and initial implementation diff --git a/packages/squad-sdk/templates/workflows/squad-insider-release.yml b/packages/squad-sdk/templates/workflows/squad-insider-release.yml index a3124d194..1ea4f6500 100644 --- a/packages/squad-sdk/templates/workflows/squad-insider-release.yml +++ b/packages/squad-sdk/templates/workflows/squad-insider-release.yml @@ -50,7 +50,7 @@ jobs: run: | gh release create "${{ steps.version.outputs.insider_tag }}" \ --title "${{ steps.version.outputs.insider_tag }}" \ - --notes "This is an insider/development build of Squad. Install with:\`\`\`bash\nnpx github:bradygaster/squad#${{ steps.version.outputs.insider_tag }}\n\`\`\`\n\n**Note:** Insider builds may be unstable and are intended for early adopters and testing only." \ + --notes "This is an insider/development build of Squad. Install with:\`\`\`bash\nnpm install -g @bradygaster/squad-cli@${{ steps.version.outputs.insider_tag }}\n\`\`\`\n\n**Note:** Insider builds may be unstable and are intended for early adopters and testing only." \ --prerelease - name: Verify release diff --git a/scripts/sync-templates.mjs b/scripts/sync-templates.mjs new file mode 100644 index 000000000..dd7a46971 --- /dev/null +++ b/scripts/sync-templates.mjs @@ -0,0 +1,108 @@ +#!/usr/bin/env node +/** + * sync-templates.mjs — Copy canonical templates from .squad-templates/ + * to every target directory that needs them. + * + * Targets: + * templates/ (root mirror) + * packages/squad-cli/templates/ (CLI package) + * packages/squad-sdk/templates/ (SDK package) + * .github/agents/squad.agent.md (GitHub agent — squad.agent.md only) + * + * Only copies files that exist in .squad-templates/. Target directories + * that don't exist are skipped with a warning. + */ + +import { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync, statSync } from 'node:fs'; +import { join, dirname, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(__dirname, '..'); + +const SOURCE = join(ROOT, '.squad-templates'); + +const MIRROR_TARGETS = [ + join(ROOT, 'templates'), + join(ROOT, 'packages', 'squad-cli', 'templates'), + join(ROOT, 'packages', 'squad-sdk', 'templates'), +]; + +// squad.agent.md also goes to .github/agents/ +const AGENT_MD_TARGET = join(ROOT, '.github', 'agents'); +const AGENT_MD_FILE = 'squad.agent.md'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Recursively collect all file paths relative to `dir`. */ +function collectFiles(dir, base = '') { + const entries = readdirSync(dir, { withFileTypes: true }); + const files = []; + for (const entry of entries) { + const rel = base ? join(base, entry.name) : entry.name; + if (entry.isDirectory()) { + files.push(...collectFiles(join(dir, entry.name), rel)); + } else { + files.push(rel); + } + } + return files; +} + +/** Copy a single file, creating parent dirs as needed. Returns true if written. */ +function copyFile(src, dest) { + const content = readFileSync(src); + const destDir = dirname(dest); + if (!existsSync(destDir)) { + mkdirSync(destDir, { recursive: true }); + } + writeFileSync(dest, content); + return true; +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +if (!existsSync(SOURCE)) { + console.log('⏭️ .squad-templates/ not found — nothing to sync'); + process.exit(0); +} + +const sourceFiles = collectFiles(SOURCE); +let totalCopied = 0; + +for (const relFile of sourceFiles) { + const srcPath = join(SOURCE, relFile); + const targets = []; + + // Mirror to each target directory + for (const targetDir of MIRROR_TARGETS) { + if (!existsSync(targetDir)) { + // Skip targets whose root doesn't exist (e.g., package not checked out) + continue; + } + targets.push(join(targetDir, relFile)); + } + + // Special case: squad.agent.md also goes to .github/agents/ + if (relFile === AGENT_MD_FILE && existsSync(AGENT_MD_TARGET)) { + targets.push(join(AGENT_MD_TARGET, AGENT_MD_FILE)); + } + + if (targets.length === 0) continue; + + for (const dest of targets) { + copyFile(srcPath, dest); + } + + totalCopied++; + const label = targets.length === 1 + ? `1 target` + : `${targets.length} targets`; + console.log(` ✅ ${relFile} → ${label}`); +} + +console.log(`\n📋 Synced ${totalCopied} file(s) from .squad-templates/`); diff --git a/templates/scribe-charter.md b/templates/scribe-charter.md index bff56af13..9082faa45 100644 --- a/templates/scribe-charter.md +++ b/templates/scribe-charter.md @@ -63,7 +63,7 @@ After every substantial work session: - Write the commit message to a temp file, then commit with `-F`: ``` $msg = @" - docs(squad): {brief summary} + docs(ai-team): {brief summary} Session: {timestamp}-{topic} Requested by: {user name} diff --git a/templates/skills/nap/SKILL.md b/templates/skills/nap/SKILL.md new file mode 100644 index 000000000..5973b1cf2 --- /dev/null +++ b/templates/skills/nap/SKILL.md @@ -0,0 +1,24 @@ +# Skill: nap + +> Context hygiene — compress, prune, archive .squad/ state + +## What It Does + +Reclaims context window budget by compressing agent histories, pruning old logs, +archiving stale decisions, and cleaning orphaned inbox files. + +## When To Use + +- Before heavy fan-out work (many agents will spawn) +- When history.md files exceed 15KB +- When .squad/ total size exceeds 1MB +- After long-running sessions or sprints + +## Invocation + +- CLI: `squad nap` / `squad nap --deep` / `squad nap --dry-run` +- REPL: `/nap` / `/nap --dry-run` / `/nap --deep` + +## Confidence + +medium — Confirmed by team vote (4-1) and initial implementation diff --git a/test/template-sync.test.ts b/test/template-sync.test.ts index 9e66048ae..e97daab0b 100644 --- a/test/template-sync.test.ts +++ b/test/template-sync.test.ts @@ -1,104 +1,229 @@ /** - * Template Sync & Casting Parity Tests (Issue #459) + * Template sync tests — ensures all template directories stay in sync. * - * Prevents the casting universe mismatch from recurring by verifying: - * - Universe count in squad.agent.md matches casting-policy.json - * - All 3 copies of squad.agent.md agree on universe count - * - Referenced casting-reference.md files exist - * - casting-policy.json is identical across template dirs - * - Every allowlisted universe has a capacity entry and vice versa + * Canonical source: .squad-templates/ + * Mirror targets: templates/, packages/squad-cli/templates/, packages/squad-sdk/templates/ + * Special target: .github/agents/ (squad.agent.md only) + * + * Coverage strategy: + * 1. Dynamic enumeration — every file in .squad-templates/ must be byte-for-byte + * identical across all mirror targets (and .github/agents/ for squad.agent.md). + * 2. Script execution — `node scripts/sync-templates.mjs` must exit 0. + * 3. Negative guard — .github/agents/ must not contain stray synced files. + * 4. Semantic checks — universe counts, casting-policy internal consistency. */ import { describe, it, expect } from 'vitest'; -import { readFileSync, existsSync } from 'node:fs'; -import { join } from 'node:path'; +import { readFileSync, existsSync, readdirSync } from 'node:fs'; +import { resolve, dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { execSync } from 'node:child_process'; -const ROOT = process.cwd(); +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(__dirname, '..'); -const SQUAD_TEMPLATES = join(ROOT, '.squad-templates'); -const TEMPLATES = join(ROOT, 'templates'); -const GITHUB_AGENTS = join(ROOT, '.github', 'agents'); +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- -/** Extract the "N universes available" number from a squad.agent.md file. */ -function extractUniverseCount(filePath: string): number { - const content = readFileSync(filePath, 'utf-8'); - const match = content.match(/(\d+)\s+universes?\s+available/i); - if (!match) throw new Error(`No "N universes available" found in ${filePath}`); - return parseInt(match[1], 10); +function readFile(relPath: string): string { + return readFileSync(resolve(ROOT, relPath), 'utf-8'); } -describe('Template sync & casting parity (issue #459)', () => { - // --- Test 1: Universe count in squad.agent.md matches casting-policy.json --- +function readFileBytes(relPath: string): Buffer { + return readFileSync(resolve(ROOT, relPath)); +} - describe('universe count matches casting-policy.json', () => { - const policy = JSON.parse(readFileSync(join(SQUAD_TEMPLATES, 'casting-policy.json'), 'utf-8')); - const policyCount = policy.allowlist_universes.length; +function fileExists(relPath: string): boolean { + return existsSync(resolve(ROOT, relPath)); +} - it('.squad-templates/squad.agent.md matches policy', () => { - const claimed = extractUniverseCount(join(SQUAD_TEMPLATES, 'squad.agent.md')); - expect(claimed).toBe(policyCount); - }); +/** Recursively collect all file paths relative to `dir`. */ +function collectFiles(dir: string, base = ''): string[] { + const entries = readdirSync(resolve(ROOT, dir), { withFileTypes: true }); + const files: string[] = []; + for (const entry of entries) { + const rel = base ? join(base, entry.name) : entry.name; + if (entry.isDirectory()) { + files.push(...collectFiles(join(dir, entry.name), rel)); + } else { + files.push(rel); + } + } + return files; +} - it('templates/squad.agent.md matches policy', () => { - const claimed = extractUniverseCount(join(TEMPLATES, 'squad.agent.md')); - expect(claimed).toBe(policyCount); - }); +/** Extract the universe count from a squad.agent.md file (anchored to list item). */ +function extractUniverseCount(content: string): number | null { + const m = content.match(/^-\s+(\d+)\s+universes?\s+available/im); + return m ? Number(m[1]) : null; +} + +/** Parse casting-policy.json and return universe names from the allowlist. */ +function parsePolicyUniverses(relPath: string): string[] { + const json = JSON.parse(readFile(relPath)); + return json.allowlist_universes as string[]; +} + +/** Parse casting-policy.json and return the capacity map. */ +function parsePolicyCapacity(relPath: string): Record { + const json = JSON.parse(readFile(relPath)); + return json.universe_capacity as Record; +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const SOURCE_DIR = '.squad-templates'; + +const MIRROR_TARGETS = [ + 'templates', + 'packages/squad-cli/templates', + 'packages/squad-sdk/templates', +] as const; + +const AGENT_MD_FILE = 'squad.agent.md'; +const AGENT_MD_EXTRA_TARGET = '.github/agents'; + +const SQUAD_AGENT_LOCATIONS = [ + `${SOURCE_DIR}/${AGENT_MD_FILE}`, + 'templates/squad.agent.md', + '.github/agents/squad.agent.md', + 'packages/squad-cli/templates/squad.agent.md', + 'packages/squad-sdk/templates/squad.agent.md', +] as const; + +const CASTING_POLICY_LOCATIONS = [ + `${SOURCE_DIR}/casting-policy.json`, + 'templates/casting-policy.json', + 'packages/squad-cli/templates/casting-policy.json', + 'packages/squad-sdk/templates/casting-policy.json', +] as const; + +// --------------------------------------------------------------------------- +// 1. Dynamic enumeration — byte-for-byte parity for ALL synced files +// --------------------------------------------------------------------------- + +describe('dynamic template enumeration (all synced files)', () => { + const sourceFiles = collectFiles(SOURCE_DIR); + + it('.squad-templates/ contains files to sync', () => { + expect(sourceFiles.length).toBeGreaterThan(0); + }); + + for (const relFile of sourceFiles) { + const canonicalPath = `${SOURCE_DIR}/${relFile}`; + + for (const target of MIRROR_TARGETS) { + const targetPath = `${target}/${relFile}`; + + it(`${targetPath} is byte-for-byte identical to ${canonicalPath}`, () => { + expect(fileExists(targetPath), `${targetPath} should exist`).toBe(true); + const src = readFileBytes(canonicalPath); + const dst = readFileBytes(targetPath); + expect(Buffer.compare(src, dst), `${targetPath} content mismatch`).toBe(0); + }); + } + + // squad.agent.md also lives in .github/agents/ + if (relFile === AGENT_MD_FILE) { + const agentPath = `${AGENT_MD_EXTRA_TARGET}/${AGENT_MD_FILE}`; + + it(`${agentPath} is byte-for-byte identical to ${canonicalPath}`, () => { + expect(fileExists(agentPath), `${agentPath} should exist`).toBe(true); + const src = readFileBytes(canonicalPath); + const dst = readFileBytes(agentPath); + expect(Buffer.compare(src, dst), `${agentPath} content mismatch`).toBe(0); + }); + } + } +}); - it('.github/agents/squad.agent.md matches policy', () => { - const claimed = extractUniverseCount(join(GITHUB_AGENTS, 'squad.agent.md')); - expect(claimed).toBe(policyCount); +// --------------------------------------------------------------------------- +// 2. Script execution — sync-templates.mjs must exit cleanly +// --------------------------------------------------------------------------- + +describe('sync-templates.mjs script execution', () => { + it('exits with code 0 (no syntax errors, no crashes)', () => { + // execSync throws on non-zero exit codes + const output = execSync('node scripts/sync-templates.mjs', { + cwd: ROOT, + encoding: 'utf-8', + timeout: 30_000, }); + expect(output).toContain('Synced'); }); +}); - // --- Test 2: All 3 copies agree on universe count --- +// --------------------------------------------------------------------------- +// 3. Negative guard — .github/agents/ should only have squad.agent.md +// --------------------------------------------------------------------------- - it('all 3 squad.agent.md copies claim the same universe count', () => { - const counts = [ - extractUniverseCount(join(SQUAD_TEMPLATES, 'squad.agent.md')), - extractUniverseCount(join(TEMPLATES, 'squad.agent.md')), - extractUniverseCount(join(GITHUB_AGENTS, 'squad.agent.md')), - ]; - expect(counts[0]).toBe(counts[1]); - expect(counts[1]).toBe(counts[2]); +describe('.github/agents/ contains only squad.agent.md', () => { + it('has no files beyond squad.agent.md from the sync', () => { + const agentDir = resolve(ROOT, AGENT_MD_EXTRA_TARGET); + expect(existsSync(agentDir), '.github/agents/ should exist').toBe(true); + const files = readdirSync(agentDir); + expect(files).toEqual([AGENT_MD_FILE]); }); +}); - // --- Test 3: casting-reference.md exists where referenced --- +// --------------------------------------------------------------------------- +// 4. squad.agent.md — universe count consistency +// --------------------------------------------------------------------------- - describe('casting-reference.md exists in template dirs', () => { - // casting-reference.md is a template-only file that gets copied to .squad/templates/ - // during squad init. It belongs in .squad-templates/ and templates/ (both template dirs), - // but NOT in .github/agents/ (which only contains squad.agent.md, the live governance file). - it('.squad-templates/casting-reference.md exists', () => { - expect(existsSync(join(SQUAD_TEMPLATES, 'casting-reference.md'))).toBe(true); - }); +describe('squad.agent.md universe count', () => { + const canonicalPath = SQUAD_AGENT_LOCATIONS[0]; + const canonicalContent = readFile(canonicalPath); + const expectedCount = extractUniverseCount(canonicalContent); - it('templates/casting-reference.md exists', () => { - expect(existsSync(join(TEMPLATES, 'casting-reference.md'))).toBe(true); - }); + it('canonical file has a parseable universe count', () => { + expect(expectedCount).not.toBeNull(); + expect(expectedCount).toBeGreaterThan(0); }); - // --- Test 4: casting-policy.json is identical across template dirs --- + for (const loc of SQUAD_AGENT_LOCATIONS) { + it(`${loc} matches canonical universe count (${expectedCount})`, () => { + const content = readFile(loc); + const count = extractUniverseCount(content); + expect(count).toBe(expectedCount); + }); + } - it('casting-policy.json is identical in .squad-templates and templates', () => { - const a = JSON.parse(readFileSync(join(SQUAD_TEMPLATES, 'casting-policy.json'), 'utf-8')); - const b = JSON.parse(readFileSync(join(TEMPLATES, 'casting-policy.json'), 'utf-8')); - expect(a).toEqual(b); + it('universe count matches casting-policy allowlist length', () => { + const policyUniverses = parsePolicyUniverses(CASTING_POLICY_LOCATIONS[0]); + expect(expectedCount).toBe(policyUniverses.length); }); +}); - // --- Test 5: Every allowlisted universe has a capacity entry and vice versa --- +// --------------------------------------------------------------------------- +// 5. casting-policy.json — content parity & internal consistency +// --------------------------------------------------------------------------- - it('allowlist_universes and universe_capacity are in sync', () => { - const policy = JSON.parse(readFileSync(join(SQUAD_TEMPLATES, 'casting-policy.json'), 'utf-8')); - const allowlist: string[] = policy.allowlist_universes; - const capacityKeys = Object.keys(policy.universe_capacity); +describe('casting-policy.json content parity', () => { + const canonicalContent = readFile(CASTING_POLICY_LOCATIONS[0]); - // Every allowlisted universe has a capacity entry - for (const universe of allowlist) { - expect(capacityKeys).toContain(universe); - } - // No orphaned capacity entries - for (const key of capacityKeys) { - expect(allowlist).toContain(key); + for (const loc of CASTING_POLICY_LOCATIONS) { + it(`${loc} matches canonical casting-policy.json`, () => { + const content = readFile(loc); + expect(content).toBe(canonicalContent); + }); + } + + it('allowlist and capacity map have the same universes', () => { + const allowlist = parsePolicyUniverses(CASTING_POLICY_LOCATIONS[0]); + const capacity = parsePolicyCapacity(CASTING_POLICY_LOCATIONS[0]); + const capacityNames = Object.keys(capacity); + + expect(allowlist.sort()).toEqual(capacityNames.sort()); + }); + + it('all capacities are positive integers', () => { + const capacity = parsePolicyCapacity(CASTING_POLICY_LOCATIONS[0]); + for (const [name, cap] of Object.entries(capacity)) { + expect(cap, `${name} capacity`).toBeGreaterThan(0); + expect(Number.isInteger(cap), `${name} capacity is integer`).toBe(true); } }); });