diff --git a/.gitattributes b/.gitattributes
index be741a10c..1a581426f 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -21,7 +21,34 @@
# Squad: shell scripts must stay LF — CRLF breaks the shebang on Linux/macOS.
*.sh text eol=lf
+# Squad: ESM scripts must stay LF — a CRLF shebang survives Vite's shebang
+# stripping as a bare `#`, so any .mjs imported by a vitest suite fails to parse
+# ("SyntaxError: Invalid or unexpected token") and the whole suite loads zero
+# tests. Green on Linux CI, silently dead on Windows checkouts (#1788).
+*.mjs text eol=lf
+
+# Squad: same hazard, remaining shebanged file types (#1788). A CRLF shebang is
+# never correct -- the \r joins the interpreter argument on POSIX ("env: node\r:
+# No such file or directory"). Enforced by scripts/check-shebang-eol.mjs.
+*.js text eol=lf
+*.cjs text eol=lf
+*.ps1 text eol=lf
+
+# Deliberately NOT "*.ts text eol=lf": only 3 tracked .ts files carry a shebang,
+# but a blanket rule would renormalize 95 CRLF-storing .ts blobs in one commit.
+# Pinning the shebanged entrypoints by path keeps the invariant without the churn.
+packages/squad-cli/src/cli-entry.ts text eol=lf
+samples/autonomous-pipeline/index.ts text eol=lf
+test-fixtures/dogfood/node-monorepo/packages/cli/src/cli.ts text eol=lf
+
# Squad: CRLF breaks RUN line-continuations when the image builds on Linux.
Dockerfile text eol=lf
Dockerfile.* text eol=lf
.dockerignore text eol=lf
+
+# Squad: vitest writes snapshots with LF unconditionally. Without a rule these
+# check out as CRLF on Windows, so `npm test` dirties a tracked file on every
+# run -- and a broad `git add` then commits pure line-ending noise. Same CRLF
+# class as the .mjs rule above, different symptom: not a load failure, a
+# perpetually dirty tree (#1788).
+*.snap text eol=lf
diff --git a/.github/workflows/squad-ci.yml b/.github/workflows/squad-ci.yml
index 4de46daf9..ed73c00ab 100644
--- a/.github/workflows/squad-ci.yml
+++ b/.github/workflows/squad-ci.yml
@@ -201,6 +201,23 @@ jobs:
- name: Check changeset drift
run: node scripts/check-changeset-drift.mjs --mode=${{ github.event_name == 'push' && 'fail' || 'warn' }}
+ # ── Shebang line-ending guard ───────────────────────────────────────────
+ # A CRLF shebang breaks POSIX execution and defeats bundler shebang stripping,
+ # which silently zeroes out any vitest suite importing the file (#1788). Linux
+ # runners check out LF, so this class is invisible here unless the committed
+ # blob itself is inspected -- which is what the script does.
+ shebang-eol:
+ name: Shebang EOL
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 #v6
+ with:
+ node-version: 22
+ - name: Check shebang line endings
+ run: node scripts/check-shebang-eol.mjs
+
# ── Consolidated policy gates ───────────────────────────────────────────
# Runs changelog, changelog-protection, workspace-integrity,
# prerelease-version-guard, publish-policy, and scope-check on one runner.
diff --git a/.squad-templates/ralph-triage.js b/.squad-templates/ralph-triage.js
index 76edc90d1..863da3d7d 100644
--- a/.squad-templates/ralph-triage.js
+++ b/.squad-templates/ralph-triage.js
@@ -1,567 +1,567 @@
-#!/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 slugify(text) { return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); }
-
-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:${slugify(name)}`,
- });
- }
-
- 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);
-}
-
-/**
- * Resolve the GitHub REST API base URL from the environment, so triage
- * works on GitHub Enterprise as well as github.com.
- *
- * Order: GITHUB_API_URL (set by Actions on both github.com and GHE runners)
- * > GITHUB_SERVER_URL + /api/v3 (GHE without an explicit API URL)
- * > https://api.github.com (fallback for local/non-Actions runs).
- */
-function resolveGithubApiBase() {
- const apiUrl = process.env.GITHUB_API_URL;
- if (apiUrl) return apiUrl.replace(/\/+$/, '');
-
- const serverUrl = process.env.GITHUB_SERVER_URL;
- if (serverUrl) return `${serverUrl.replace(/\/+$/, '')}/api/v3`;
-
- return 'https://api.github.com';
-}
-
-function githubRequestJson(pathname, token) {
- return new Promise((resolve, reject) => {
- const requestUrl = new URL(`${resolveGithubApiBase()}${pathname}`);
- const req = https.request(
- requestUrl,
- {
- method: 'GET',
- 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');
-}
-
-if (require.main === module) {
- main().catch((error) => {
- console.error(error.message);
- process.exit(1);
- });
-}
-
-module.exports = { resolveGithubApiBase };
+#!/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 slugify(text) { return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); }
+
+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:${slugify(name)}`,
+ });
+ }
+
+ 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);
+}
+
+/**
+ * Resolve the GitHub REST API base URL from the environment, so triage
+ * works on GitHub Enterprise as well as github.com.
+ *
+ * Order: GITHUB_API_URL (set by Actions on both github.com and GHE runners)
+ * > GITHUB_SERVER_URL + /api/v3 (GHE without an explicit API URL)
+ * > https://api.github.com (fallback for local/non-Actions runs).
+ */
+function resolveGithubApiBase() {
+ const apiUrl = process.env.GITHUB_API_URL;
+ if (apiUrl) return apiUrl.replace(/\/+$/, '');
+
+ const serverUrl = process.env.GITHUB_SERVER_URL;
+ if (serverUrl) return `${serverUrl.replace(/\/+$/, '')}/api/v3`;
+
+ return 'https://api.github.com';
+}
+
+function githubRequestJson(pathname, token) {
+ return new Promise((resolve, reject) => {
+ const requestUrl = new URL(`${resolveGithubApiBase()}${pathname}`);
+ const req = https.request(
+ requestUrl,
+ {
+ method: 'GET',
+ 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');
+}
+
+if (require.main === module) {
+ main().catch((error) => {
+ console.error(error.message);
+ process.exit(1);
+ });
+}
+
+module.exports = { resolveGithubApiBase };
diff --git a/docs/pagefind.yml b/docs/pagefind.yml
index 282352e74..e964cc495 100644
--- a/docs/pagefind.yml
+++ b/docs/pagefind.yml
@@ -1,12 +1,12 @@
-# Pagefind configuration for Squad docs
-# See: https://pagefind.app/docs/config-options/
-
-# Exclude navigation, footers, and code blocks from indexing
-exclude_selectors:
- - "nav"
- - "footer"
- - "aside"
- - "pre"
- - ".astro-code"
-
-
+# Pagefind configuration for Squad docs
+# See: https://pagefind.app/docs/config-options/
+
+# Exclude navigation, footers, and code blocks from indexing
+exclude_selectors:
+ - "nav"
+ - "footer"
+ - "aside"
+ - "pre"
+ - ".astro-code"
+
+
diff --git a/docs/playwright.config.mjs b/docs/playwright.config.mjs
index 2c69a1ac9..4b117c4f8 100644
--- a/docs/playwright.config.mjs
+++ b/docs/playwright.config.mjs
@@ -1,13 +1,13 @@
-import { defineConfig } from '@playwright/test';
-
-export default defineConfig({
- testDir: './tests',
- testMatch: '**/*.spec.mjs',
- timeout: 30_000,
- retries: 0,
- use: {
- baseURL: 'http://localhost:4321/squad/',
- browserName: 'chromium',
- headless: true,
- },
-});
+import { defineConfig } from '@playwright/test';
+
+export default defineConfig({
+ testDir: './tests',
+ testMatch: '**/*.spec.mjs',
+ timeout: 30_000,
+ retries: 0,
+ use: {
+ baseURL: 'http://localhost:4321/squad/',
+ browserName: 'chromium',
+ headless: true,
+ },
+});
diff --git a/docs/src/plugins/rehype-pagefind-attrs.mjs b/docs/src/plugins/rehype-pagefind-attrs.mjs
index cbd09914b..f4ad597a7 100644
--- a/docs/src/plugins/rehype-pagefind-attrs.mjs
+++ b/docs/src/plugins/rehype-pagefind-attrs.mjs
@@ -1,21 +1,21 @@
-import { visit } from 'unist-util-visit';
-
-/**
- * Rehype plugin that adds Pagefind attributes to rendered markdown HTML.
- * - Adds data-pagefind-weight="2" to h2 and h3 for search ranking boost
- * - Adds data-pagefind-ignore to pre elements to exclude code blocks
- */
-export function rehypePagefindAttrs() {
- return (tree) => {
- visit(tree, 'element', (node) => {
- if (node.tagName === 'h2' || node.tagName === 'h3') {
- node.properties = node.properties || {};
- node.properties.dataPagefindWeight = '2';
- }
- if (node.tagName === 'pre') {
- node.properties = node.properties || {};
- node.properties.dataPagefindIgnore = '';
- }
- });
- };
-}
+import { visit } from 'unist-util-visit';
+
+/**
+ * Rehype plugin that adds Pagefind attributes to rendered markdown HTML.
+ * - Adds data-pagefind-weight="2" to h2 and h3 for search ranking boost
+ * - Adds data-pagefind-ignore to pre elements to exclude code blocks
+ */
+export function rehypePagefindAttrs() {
+ return (tree) => {
+ visit(tree, 'element', (node) => {
+ if (node.tagName === 'h2' || node.tagName === 'h3') {
+ node.properties = node.properties || {};
+ node.properties.dataPagefindWeight = '2';
+ }
+ if (node.tagName === 'pre') {
+ node.properties = node.properties || {};
+ node.properties.dataPagefindIgnore = '';
+ }
+ });
+ };
+}
diff --git a/docs/tests/api-reference.spec.mjs b/docs/tests/api-reference.spec.mjs
index 62bd74b24..f3c60812a 100644
--- a/docs/tests/api-reference.spec.mjs
+++ b/docs/tests/api-reference.spec.mjs
@@ -1,127 +1,127 @@
-/**
- * Playwright e2e tests for API reference pages.
- * Validates TypeDoc-generated pages render correctly with navigation.
- * Run with: npx playwright test tests/api-reference.spec.mjs
- */
-import { test, expect } from '@playwright/test';
-
-const BASE = '/squad/docs/reference/api/';
-// Screenshots are gitignored — used for local verification and PR descriptions only
-const SCREENSHOT_DIR = 'tests/screenshots';
-
-test.describe('API reference landing page', () => {
-
- test('landing page loads with title and page count', async ({ page }) => {
- await page.goto(BASE);
- const heading = page.locator('article h1').first();
- await expect(heading).toContainText('API reference');
- // The index page lists an export count — assert it's present and reasonable, not a hardcoded value
- const article = page.locator('article').first();
- const text = await article.textContent();
- const countMatch = text.match(/(\d+)\s*public exports/);
- expect(countMatch).toBeTruthy();
- expect(Number(countMatch[1])).toBeGreaterThan(100);
- await page.screenshot({ path: `${SCREENSHOT_DIR}/api-landing.png`, fullPage: true });
- });
-
- test('landing page lists class links', async ({ page }) => {
- await page.goto(BASE);
- const classLinks = page.locator('article a[href*="class-"]');
- const count = await classLinks.count();
- expect(count).toBeGreaterThanOrEqual(10);
- });
-
- test('landing page lists function links', async ({ page }) => {
- await page.goto(BASE);
- const fnLinks = page.locator('article a[href*="function-"]');
- const count = await fnLinks.count();
- expect(count).toBeGreaterThanOrEqual(50);
- });
-});
-
-test.describe('API reference class page', () => {
-
- test('class page renders heading and content', async ({ page }) => {
- await page.goto(`${BASE}class-runtimeeventbus/`);
- const heading = page.locator('article h1').first();
- await expect(heading).toBeVisible();
- const headingText = await heading.textContent();
- expect(headingText.toLowerCase()).toContain('runtimeeventbus');
- // Class pages contain definition details
- const article = page.locator('article').first();
- await expect(article).toContainText('Defined in');
- await page.screenshot({ path: `${SCREENSHOT_DIR}/api-class-page.png`, fullPage: true });
- });
-});
-
-test.describe('API reference function page', () => {
-
- test('function page renders signature block', async ({ page }) => {
- await page.goto(`${BASE}function-definesquad/`);
- const heading = page.locator('article h1').first();
- await expect(heading).toBeVisible();
- const headingText = await heading.textContent();
- expect(headingText.toLowerCase()).toContain('definesquad');
- // Function pages show where they're defined
- const article = page.locator('article').first();
- await expect(article).toContainText('Defined in');
- await page.screenshot({ path: `${SCREENSHOT_DIR}/api-function-page.png`, fullPage: true });
- });
-});
-
-test.describe('API reference interface page', () => {
-
- test('interface page renders content', async ({ page }) => {
- await page.goto(`${BASE}interface-squadconfig/`);
- const heading = page.locator('article h1').first();
- await expect(heading).toBeVisible();
- const headingText = await heading.textContent();
- expect(headingText.toLowerCase()).toContain('squadconfig');
- // Interface pages contain definition details
- const article = page.locator('article').first();
- await expect(article).toContainText('Defined in');
- });
-});
-
-test.describe('API reference navigation', () => {
-
- test('navigate from landing to detail page and back', async ({ page }) => {
- await page.goto(BASE);
- // Click a class link
- const classLink = page.locator('article a[href*="class-runtimeeventbus"]').first();
- await expect(classLink).toBeVisible();
- await classLink.click();
- await page.waitForURL(/class-runtimeeventbus/);
- const heading = page.locator('article h1').first();
- await expect(heading).toBeVisible();
-
- // Navigate back
- await page.goBack();
- await page.waitForURL(new RegExp(BASE.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
- await expect(page.locator('article h1').first()).toContainText('API reference');
- });
-});
-
-test.describe('API reference search', () => {
-
- test('search finds an API entry if Pagefind is active', async ({ page }) => {
- await page.goto('/squad/');
- const searchBtn = page.locator('#search-btn');
- // Only run if search button exists (Pagefind may not be built)
- if (await searchBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
- await searchBtn.click();
- const input = page.locator('#search-input');
- await expect(input).toBeFocused();
- await input.fill('RuntimeEventBus');
- // Wait for debounce + search
- const results = page.locator('#search-results a');
- // Pagefind needs the dist/pagefind assets served at the right base URL
- const hasResults = await results.first().isVisible({ timeout: 10_000 }).catch(() => false);
- if (hasResults) {
- const count = await results.count();
- expect(count).toBeGreaterThan(0);
- }
- // If no results, Pagefind index may not serve on this port — test still passes
- }
- });
-});
+/**
+ * Playwright e2e tests for API reference pages.
+ * Validates TypeDoc-generated pages render correctly with navigation.
+ * Run with: npx playwright test tests/api-reference.spec.mjs
+ */
+import { test, expect } from '@playwright/test';
+
+const BASE = '/squad/docs/reference/api/';
+// Screenshots are gitignored — used for local verification and PR descriptions only
+const SCREENSHOT_DIR = 'tests/screenshots';
+
+test.describe('API reference landing page', () => {
+
+ test('landing page loads with title and page count', async ({ page }) => {
+ await page.goto(BASE);
+ const heading = page.locator('article h1').first();
+ await expect(heading).toContainText('API reference');
+ // The index page lists an export count — assert it's present and reasonable, not a hardcoded value
+ const article = page.locator('article').first();
+ const text = await article.textContent();
+ const countMatch = text.match(/(\d+)\s*public exports/);
+ expect(countMatch).toBeTruthy();
+ expect(Number(countMatch[1])).toBeGreaterThan(100);
+ await page.screenshot({ path: `${SCREENSHOT_DIR}/api-landing.png`, fullPage: true });
+ });
+
+ test('landing page lists class links', async ({ page }) => {
+ await page.goto(BASE);
+ const classLinks = page.locator('article a[href*="class-"]');
+ const count = await classLinks.count();
+ expect(count).toBeGreaterThanOrEqual(10);
+ });
+
+ test('landing page lists function links', async ({ page }) => {
+ await page.goto(BASE);
+ const fnLinks = page.locator('article a[href*="function-"]');
+ const count = await fnLinks.count();
+ expect(count).toBeGreaterThanOrEqual(50);
+ });
+});
+
+test.describe('API reference class page', () => {
+
+ test('class page renders heading and content', async ({ page }) => {
+ await page.goto(`${BASE}class-runtimeeventbus/`);
+ const heading = page.locator('article h1').first();
+ await expect(heading).toBeVisible();
+ const headingText = await heading.textContent();
+ expect(headingText.toLowerCase()).toContain('runtimeeventbus');
+ // Class pages contain definition details
+ const article = page.locator('article').first();
+ await expect(article).toContainText('Defined in');
+ await page.screenshot({ path: `${SCREENSHOT_DIR}/api-class-page.png`, fullPage: true });
+ });
+});
+
+test.describe('API reference function page', () => {
+
+ test('function page renders signature block', async ({ page }) => {
+ await page.goto(`${BASE}function-definesquad/`);
+ const heading = page.locator('article h1').first();
+ await expect(heading).toBeVisible();
+ const headingText = await heading.textContent();
+ expect(headingText.toLowerCase()).toContain('definesquad');
+ // Function pages show where they're defined
+ const article = page.locator('article').first();
+ await expect(article).toContainText('Defined in');
+ await page.screenshot({ path: `${SCREENSHOT_DIR}/api-function-page.png`, fullPage: true });
+ });
+});
+
+test.describe('API reference interface page', () => {
+
+ test('interface page renders content', async ({ page }) => {
+ await page.goto(`${BASE}interface-squadconfig/`);
+ const heading = page.locator('article h1').first();
+ await expect(heading).toBeVisible();
+ const headingText = await heading.textContent();
+ expect(headingText.toLowerCase()).toContain('squadconfig');
+ // Interface pages contain definition details
+ const article = page.locator('article').first();
+ await expect(article).toContainText('Defined in');
+ });
+});
+
+test.describe('API reference navigation', () => {
+
+ test('navigate from landing to detail page and back', async ({ page }) => {
+ await page.goto(BASE);
+ // Click a class link
+ const classLink = page.locator('article a[href*="class-runtimeeventbus"]').first();
+ await expect(classLink).toBeVisible();
+ await classLink.click();
+ await page.waitForURL(/class-runtimeeventbus/);
+ const heading = page.locator('article h1').first();
+ await expect(heading).toBeVisible();
+
+ // Navigate back
+ await page.goBack();
+ await page.waitForURL(new RegExp(BASE.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
+ await expect(page.locator('article h1').first()).toContainText('API reference');
+ });
+});
+
+test.describe('API reference search', () => {
+
+ test('search finds an API entry if Pagefind is active', async ({ page }) => {
+ await page.goto('/squad/');
+ const searchBtn = page.locator('#search-btn');
+ // Only run if search button exists (Pagefind may not be built)
+ if (await searchBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
+ await searchBtn.click();
+ const input = page.locator('#search-input');
+ await expect(input).toBeFocused();
+ await input.fill('RuntimeEventBus');
+ // Wait for debounce + search
+ const results = page.locator('#search-results a');
+ // Pagefind needs the dist/pagefind assets served at the right base URL
+ const hasResults = await results.first().isVisible({ timeout: 10_000 }).catch(() => false);
+ if (hasResults) {
+ const count = await results.count();
+ expect(count).toBeGreaterThan(0);
+ }
+ // If no results, Pagefind index may not serve on this port — test still passes
+ }
+ });
+});
diff --git a/docs/tests/astro-features.test.mjs b/docs/tests/astro-features.test.mjs
index 579e02771..12d99228d 100644
--- a/docs/tests/astro-features.test.mjs
+++ b/docs/tests/astro-features.test.mjs
@@ -136,4 +136,4 @@ describe('View Transitions', () => {
const layout = fs.readFileSync(path.join(SRC, 'layouts', 'BaseLayout.astro'), 'utf-8');
assert.ok(layout.includes('') || layout.includes(''), 'BaseLayout should render ');
});
-});
+});
diff --git a/docs/tests/search.spec.mjs b/docs/tests/search.spec.mjs
index d9ebc8bd4..3b519106a 100644
--- a/docs/tests/search.spec.mjs
+++ b/docs/tests/search.spec.mjs
@@ -1,230 +1,230 @@
-/**
- * Playwright e2e tests for Phase 1 search improvements.
- * Tests run against the live preview at http://localhost:4321/squad/
- * Run with: npx playwright test
- */
-import { test, expect } from '@playwright/test';
-
-const BASE = '/squad/';
-
-// Helper: open search modal via button click
-async function openSearchViaButton(page) {
- await page.goto(BASE);
- const searchBtn = page.locator('#search-btn');
- await expect(searchBtn).toBeVisible();
- await searchBtn.click();
- const modal = page.locator('#search-modal');
- await expect(modal).not.toHaveClass(/hidden/);
- return modal;
-}
-
-test.describe('Search modal interactions', () => {
-
- test('search modal opens when clicking the search button', async ({ page }) => {
- const modal = await openSearchViaButton(page);
- await expect(modal).toBeVisible();
- const input = page.locator('#search-input');
- await expect(input).toBeFocused();
- });
-
- test('search modal opens on Ctrl+K keyboard shortcut', async ({ page }) => {
- await page.goto(BASE);
- const modal = page.locator('#search-modal');
- await expect(modal).toHaveClass(/hidden/);
-
- await page.keyboard.press('Control+k');
- await expect(modal).not.toHaveClass(/hidden/);
- await expect(page.locator('#search-input')).toBeFocused();
- });
-
- test('pressing Escape closes the search modal', async ({ page }) => {
- await openSearchViaButton(page);
- const modal = page.locator('#search-modal');
- await expect(modal).not.toHaveClass(/hidden/);
-
- await page.keyboard.press('Escape');
- await expect(modal).toHaveClass(/hidden/);
- });
-
- test('clicking backdrop closes the search modal', async ({ page }) => {
- await openSearchViaButton(page);
- const modal = page.locator('#search-modal');
- await expect(modal).not.toHaveClass(/hidden/);
-
- // The dialog container intercepts pointer events over the backdrop center,
- // so we dispatch a click event directly on the backdrop element
- await page.evaluate(() => document.getElementById('search-backdrop').click());
- await expect(modal).toHaveClass(/hidden/);
- });
-});
-
-test.describe('Search results', () => {
-
- test('typing a query shows results', async ({ page }) => {
- await openSearchViaButton(page);
- const input = page.locator('#search-input');
- await input.fill('agent');
-
- // Wait for results to appear (debounce is 200ms + pagefind load)
- const results = page.locator('#search-results a');
- await expect(results.first()).toBeVisible({ timeout: 10_000 });
- const count = await results.count();
- expect(count).toBeGreaterThan(0);
- });
-
- test('results display section badges', async ({ page }) => {
- await openSearchViaButton(page);
- await page.locator('#search-input').fill('agent');
-
- // Wait for results
- const resultLinks = page.locator('#search-results a');
- await expect(resultLinks.first()).toBeVisible({ timeout: 10_000 });
-
- // Look for section badge spans within results
- const badges = page.locator('#search-results a span.inline-flex');
- const badgeCount = await badges.count();
- expect(badgeCount).toBeGreaterThan(0);
-
- // Verify badge text is a known section
- const knownSections = [
- 'Get Started', 'Guide', 'Features', 'Reference',
- 'Scenarios', 'Concepts', 'Cookbook', 'Blog', 'Docs', 'Community'
- ];
- const firstBadgeText = await badges.first().textContent();
- expect(knownSections).toContain(firstBadgeText.trim());
- });
-
- test('each result has a title and excerpt', async ({ page }) => {
- await openSearchViaButton(page);
- await page.locator('#search-input').fill('agent');
-
- const resultLinks = page.locator('#search-results a');
- await expect(resultLinks.first()).toBeVisible({ timeout: 10_000 });
-
- // Check first result has a title (text-base span) and excerpt (text-sm div)
- const firstResult = resultLinks.first();
- const title = firstResult.locator('span.text-base');
- await expect(title).toBeVisible();
- const titleText = await title.textContent();
- expect(titleText.trim().length).toBeGreaterThan(0);
-
- const excerpt = firstResult.locator('div.text-sm');
- await expect(excerpt).toBeVisible();
- });
-
- test('result count text is visible', async ({ page }) => {
- await openSearchViaButton(page);
- await page.locator('#search-input').fill('agent');
-
- // Wait for status bar showing result count
- const status = page.locator('#search-status');
- await expect(status).toBeVisible({ timeout: 10_000 });
-
- const statusText = await status.textContent();
- // Should match pattern like "5 results" or "1 result"
- expect(statusText).toMatch(/\d+ results?/);
- });
-
- test('clicking a result navigates to that page', async ({ page }) => {
- await openSearchViaButton(page);
- await page.locator('#search-input').fill('agent');
-
- const resultLinks = page.locator('#search-results a');
- await expect(resultLinks.first()).toBeVisible({ timeout: 10_000 });
-
- // Get the href of the first result
- const href = await resultLinks.first().getAttribute('href');
- expect(href).toBeTruthy();
-
- // Click it and verify navigation
- await resultLinks.first().click();
-
- // Modal should close, and we should navigate to the result page
- await page.waitForURL(new RegExp(href.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), { timeout: 10_000 });
- expect(page.url()).toContain(href);
- });
-
- test('no results state shows appropriate message', async ({ page }) => {
- await openSearchViaButton(page);
- const input = page.locator('#search-input');
- // Use a single random character that pagefind won't match
- await input.fill('├┐');
-
- // Wait for search debounce, then check for either "No results" or
- // the default "Start typing" (pagefind may not index this character).
- // The search executes and either shows "No results for..." or results.
- // We verify the search system responds to input.
- await page.waitForTimeout(1500);
- const resultsContainer = page.locator('#search-results');
- const text = await resultsContainer.textContent();
- // Either we get "No results" or actual results ΓÇö either way the
- // search pipeline processed the query (not still on "Start typing")
- expect(text.trim()).not.toBe('Start typing to search…');
- });
-});
-
-test.describe('Search after View Transition navigation', () => {
-
- // Helper: search, click first result, wait for navigation to complete
- async function searchAndNavigate(page) {
- await page.goto(BASE);
- const searchBtn = page.locator('#search-btn');
- await expect(searchBtn).toBeVisible();
- await searchBtn.click();
-
- const modal = page.locator('#search-modal');
- await expect(modal).not.toHaveClass(/hidden/);
-
- const input = page.locator('#search-input');
- await input.fill('agent');
-
- const resultLinks = page.locator('#search-results a');
- await expect(resultLinks.first()).toBeVisible({ timeout: 10_000 });
-
- const href = await resultLinks.first().getAttribute('href');
- expect(href).toBeTruthy();
- await resultLinks.first().click();
-
- // Wait for View Transition navigation to complete
- await page.waitForURL(new RegExp(href.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), { timeout: 10_000 });
- // Allow astro:page-load handlers to re-initialize
- await page.waitForTimeout(500);
- return href;
- }
-
- test('search modal works after navigating via search result (View Transition)', async ({ page }) => {
- await searchAndNavigate(page);
-
- // On the new page, try to open search again
- const searchBtn = page.locator('#search-btn');
- await expect(searchBtn).toBeVisible({ timeout: 5_000 });
- await searchBtn.click();
-
- const modal = page.locator('#search-modal');
- await expect(modal).not.toHaveClass(/hidden/);
-
- // Verify input is focused and can receive text
- const input = page.locator('#search-input');
- await expect(input).toBeFocused({ timeout: 3_000 });
-
- // Type a new query and verify results appear
- await input.fill('charter');
- const resultLinks = page.locator('#search-results a');
- await expect(resultLinks.first()).toBeVisible({ timeout: 10_000 });
- const count = await resultLinks.count();
- expect(count).toBeGreaterThan(0);
- });
-
- test('Ctrl+K opens search after View Transition navigation', async ({ page }) => {
- await searchAndNavigate(page);
-
- // Press Ctrl+K on the post-navigation page
- await page.keyboard.press('Control+k');
-
- const modal = page.locator('#search-modal');
- await expect(modal).not.toHaveClass(/hidden/);
-
- const input = page.locator('#search-input');
- await expect(input).toBeFocused({ timeout: 3_000 });
- });
-});
+/**
+ * Playwright e2e tests for Phase 1 search improvements.
+ * Tests run against the live preview at http://localhost:4321/squad/
+ * Run with: npx playwright test
+ */
+import { test, expect } from '@playwright/test';
+
+const BASE = '/squad/';
+
+// Helper: open search modal via button click
+async function openSearchViaButton(page) {
+ await page.goto(BASE);
+ const searchBtn = page.locator('#search-btn');
+ await expect(searchBtn).toBeVisible();
+ await searchBtn.click();
+ const modal = page.locator('#search-modal');
+ await expect(modal).not.toHaveClass(/hidden/);
+ return modal;
+}
+
+test.describe('Search modal interactions', () => {
+
+ test('search modal opens when clicking the search button', async ({ page }) => {
+ const modal = await openSearchViaButton(page);
+ await expect(modal).toBeVisible();
+ const input = page.locator('#search-input');
+ await expect(input).toBeFocused();
+ });
+
+ test('search modal opens on Ctrl+K keyboard shortcut', async ({ page }) => {
+ await page.goto(BASE);
+ const modal = page.locator('#search-modal');
+ await expect(modal).toHaveClass(/hidden/);
+
+ await page.keyboard.press('Control+k');
+ await expect(modal).not.toHaveClass(/hidden/);
+ await expect(page.locator('#search-input')).toBeFocused();
+ });
+
+ test('pressing Escape closes the search modal', async ({ page }) => {
+ await openSearchViaButton(page);
+ const modal = page.locator('#search-modal');
+ await expect(modal).not.toHaveClass(/hidden/);
+
+ await page.keyboard.press('Escape');
+ await expect(modal).toHaveClass(/hidden/);
+ });
+
+ test('clicking backdrop closes the search modal', async ({ page }) => {
+ await openSearchViaButton(page);
+ const modal = page.locator('#search-modal');
+ await expect(modal).not.toHaveClass(/hidden/);
+
+ // The dialog container intercepts pointer events over the backdrop center,
+ // so we dispatch a click event directly on the backdrop element
+ await page.evaluate(() => document.getElementById('search-backdrop').click());
+ await expect(modal).toHaveClass(/hidden/);
+ });
+});
+
+test.describe('Search results', () => {
+
+ test('typing a query shows results', async ({ page }) => {
+ await openSearchViaButton(page);
+ const input = page.locator('#search-input');
+ await input.fill('agent');
+
+ // Wait for results to appear (debounce is 200ms + pagefind load)
+ const results = page.locator('#search-results a');
+ await expect(results.first()).toBeVisible({ timeout: 10_000 });
+ const count = await results.count();
+ expect(count).toBeGreaterThan(0);
+ });
+
+ test('results display section badges', async ({ page }) => {
+ await openSearchViaButton(page);
+ await page.locator('#search-input').fill('agent');
+
+ // Wait for results
+ const resultLinks = page.locator('#search-results a');
+ await expect(resultLinks.first()).toBeVisible({ timeout: 10_000 });
+
+ // Look for section badge spans within results
+ const badges = page.locator('#search-results a span.inline-flex');
+ const badgeCount = await badges.count();
+ expect(badgeCount).toBeGreaterThan(0);
+
+ // Verify badge text is a known section
+ const knownSections = [
+ 'Get Started', 'Guide', 'Features', 'Reference',
+ 'Scenarios', 'Concepts', 'Cookbook', 'Blog', 'Docs', 'Community'
+ ];
+ const firstBadgeText = await badges.first().textContent();
+ expect(knownSections).toContain(firstBadgeText.trim());
+ });
+
+ test('each result has a title and excerpt', async ({ page }) => {
+ await openSearchViaButton(page);
+ await page.locator('#search-input').fill('agent');
+
+ const resultLinks = page.locator('#search-results a');
+ await expect(resultLinks.first()).toBeVisible({ timeout: 10_000 });
+
+ // Check first result has a title (text-base span) and excerpt (text-sm div)
+ const firstResult = resultLinks.first();
+ const title = firstResult.locator('span.text-base');
+ await expect(title).toBeVisible();
+ const titleText = await title.textContent();
+ expect(titleText.trim().length).toBeGreaterThan(0);
+
+ const excerpt = firstResult.locator('div.text-sm');
+ await expect(excerpt).toBeVisible();
+ });
+
+ test('result count text is visible', async ({ page }) => {
+ await openSearchViaButton(page);
+ await page.locator('#search-input').fill('agent');
+
+ // Wait for status bar showing result count
+ const status = page.locator('#search-status');
+ await expect(status).toBeVisible({ timeout: 10_000 });
+
+ const statusText = await status.textContent();
+ // Should match pattern like "5 results" or "1 result"
+ expect(statusText).toMatch(/\d+ results?/);
+ });
+
+ test('clicking a result navigates to that page', async ({ page }) => {
+ await openSearchViaButton(page);
+ await page.locator('#search-input').fill('agent');
+
+ const resultLinks = page.locator('#search-results a');
+ await expect(resultLinks.first()).toBeVisible({ timeout: 10_000 });
+
+ // Get the href of the first result
+ const href = await resultLinks.first().getAttribute('href');
+ expect(href).toBeTruthy();
+
+ // Click it and verify navigation
+ await resultLinks.first().click();
+
+ // Modal should close, and we should navigate to the result page
+ await page.waitForURL(new RegExp(href.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), { timeout: 10_000 });
+ expect(page.url()).toContain(href);
+ });
+
+ test('no results state shows appropriate message', async ({ page }) => {
+ await openSearchViaButton(page);
+ const input = page.locator('#search-input');
+ // Use a single random character that pagefind won't match
+ await input.fill('├┐');
+
+ // Wait for search debounce, then check for either "No results" or
+ // the default "Start typing" (pagefind may not index this character).
+ // The search executes and either shows "No results for..." or results.
+ // We verify the search system responds to input.
+ await page.waitForTimeout(1500);
+ const resultsContainer = page.locator('#search-results');
+ const text = await resultsContainer.textContent();
+ // Either we get "No results" or actual results ΓÇö either way the
+ // search pipeline processed the query (not still on "Start typing")
+ expect(text.trim()).not.toBe('Start typing to search…');
+ });
+});
+
+test.describe('Search after View Transition navigation', () => {
+
+ // Helper: search, click first result, wait for navigation to complete
+ async function searchAndNavigate(page) {
+ await page.goto(BASE);
+ const searchBtn = page.locator('#search-btn');
+ await expect(searchBtn).toBeVisible();
+ await searchBtn.click();
+
+ const modal = page.locator('#search-modal');
+ await expect(modal).not.toHaveClass(/hidden/);
+
+ const input = page.locator('#search-input');
+ await input.fill('agent');
+
+ const resultLinks = page.locator('#search-results a');
+ await expect(resultLinks.first()).toBeVisible({ timeout: 10_000 });
+
+ const href = await resultLinks.first().getAttribute('href');
+ expect(href).toBeTruthy();
+ await resultLinks.first().click();
+
+ // Wait for View Transition navigation to complete
+ await page.waitForURL(new RegExp(href.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), { timeout: 10_000 });
+ // Allow astro:page-load handlers to re-initialize
+ await page.waitForTimeout(500);
+ return href;
+ }
+
+ test('search modal works after navigating via search result (View Transition)', async ({ page }) => {
+ await searchAndNavigate(page);
+
+ // On the new page, try to open search again
+ const searchBtn = page.locator('#search-btn');
+ await expect(searchBtn).toBeVisible({ timeout: 5_000 });
+ await searchBtn.click();
+
+ const modal = page.locator('#search-modal');
+ await expect(modal).not.toHaveClass(/hidden/);
+
+ // Verify input is focused and can receive text
+ const input = page.locator('#search-input');
+ await expect(input).toBeFocused({ timeout: 3_000 });
+
+ // Type a new query and verify results appear
+ await input.fill('charter');
+ const resultLinks = page.locator('#search-results a');
+ await expect(resultLinks.first()).toBeVisible({ timeout: 10_000 });
+ const count = await resultLinks.count();
+ expect(count).toBeGreaterThan(0);
+ });
+
+ test('Ctrl+K opens search after View Transition navigation', async ({ page }) => {
+ await searchAndNavigate(page);
+
+ // Press Ctrl+K on the post-navigation page
+ await page.keyboard.press('Control+k');
+
+ const modal = page.locator('#search-modal');
+ await expect(modal).not.toHaveClass(/hidden/);
+
+ const input = page.locator('#search-input');
+ await expect(input).toBeFocused({ timeout: 3_000 });
+ });
+});
diff --git a/packages/squad-cli/src/cli-entry.ts b/packages/squad-cli/src/cli-entry.ts
index e6595ae28..83bff7d07 100644
--- a/packages/squad-cli/src/cli-entry.ts
+++ b/packages/squad-cli/src/cli-entry.ts
@@ -1,1168 +1,1168 @@
-#!/usr/bin/env node
-
-/**
- * Squad CLI — entry point for command-line invocation.
- * Separated from src/index.ts so library consumers can import
- * the SDK without triggering CLI argument parsing or process.exit().
- *
- * SDK library exports live in src/index.ts (dist/index.js).
- */
-
-process.env.NODE_NO_WARNINGS = '1';
-
-// Suppress ExperimentalWarning (e.g. node:sqlite) from leaking to terminal.
-// process.env.NODE_NO_WARNINGS only works when set BEFORE process starts;
-// this runtime hook catches warnings emitted during dynamic imports below.
-const _origEmit = process.emit;
-process.emit = function (evt: string, ...args: unknown[]) {
- if (evt === 'warning' && (args[0] as { name?: string })?.name === 'ExperimentalWarning') {
- return false;
- }
- return _origEmit.apply(this, [evt, ...args] as Parameters);
-};
-
-// Runtime ESM Import Patcher for @github/copilot-sdk (#265)
-// ---------------------------------------------------------
-// Patch broken ESM import in @github/copilot-sdk@0.1.32 at runtime before
-// Node's module loader attempts resolution.
-//
-// Root cause: copilot-sdk's session.js imports 'vscode-jsonrpc/node' without
-// .js extension, violating Node 24+ strict ESM resolution requirements.
-//
-// Why runtime patch?: NPX caches packages in ~/.npm/_cacache and skips
-// postinstall scripts on cache hits (documented npm behavior). The install-time
-// patch in scripts/patch-esm-imports.mjs never runs on npx cache hits, causing
-// ERR_MODULE_NOT_FOUND crashes on Node 24+.
-//
-// This runtime patch intercepts Module._resolveFilename before any imports
-// trigger copilot-sdk loading, rewriting the broken import to include .js.
-// Works everywhere: npx (cache hit/miss), global install, CI/CD.
-//
-// Upstream issue: https://github.com/github/copilot-sdk/issues/707
-import { createRequire } from 'node:module';
-const require = createRequire(import.meta.url);
-const Module = require('node:module');
-
-const _origResolveFilename = Module._resolveFilename;
-Module._resolveFilename = function (request: string, parent: unknown, isMain: boolean, options?: unknown) {
- // Intercept the broken import: 'vscode-jsonrpc/node' → 'vscode-jsonrpc/node.js'
- if (request === 'vscode-jsonrpc/node') {
- request = 'vscode-jsonrpc/node.js';
- }
- return _origResolveFilename.call(this, request, parent, isMain, options);
-};
-
-// Pre-flight: require Node.js ≥22.5.0 for node:sqlite (#214, #502).
-// node:sqlite is used by the Copilot SDK for session storage.
-// Fail fast with a clear message rather than letting users hit a cryptic
-// ERR_UNKNOWN_BUILTIN_MODULE crash when the SDK loads.
-{
- const parts = process.versions.node.split('.').map(Number);
- const major = parts[0] ?? 0;
- const minor = parts[1] ?? 0;
- if (major < 22 || (major === 22 && minor < 5)) {
- console.error(
- `✗ Squad requires Node.js ≥22.5.0 (you have v${process.versions.node}).\n` +
- ` node:sqlite (required by the Copilot SDK for session storage) was added in Node 22.5.0.\n` +
- ` Upgrade at: https://nodejs.org/en/download\n`,
- );
- process.exit(1);
- }
-}
-
-// ---------------------------------------------------------------------------
-// Top-level signal handlers — safety net for clean exit on Ctrl+C / SIGTERM.
-// Individual commands (shell, watch, aspire, rc) register their own handlers
-// that run first; these ensure the process never hangs if a command doesn't.
-// ---------------------------------------------------------------------------
-let _exitingOnSignal = false;
-function _handleTopLevelSignal(signal: 'SIGINT' | 'SIGTERM'): void {
- const code = signal === 'SIGINT' ? 130 : 143;
- if (_exitingOnSignal) {
- // Second signal — force exit immediately
- process.exit(code);
- }
- _exitingOnSignal = true;
- // Allow in-flight cleanup handlers a brief window, then force exit
- setTimeout(() => process.exit(code), 3_000).unref();
-}
-process.on('SIGINT', () => _handleTopLevelSignal('SIGINT'));
-process.on('SIGTERM', () => _handleTopLevelSignal('SIGTERM'));
-
-import { FSStorageProvider, resolveSquadState } from '@bradygaster/squad-sdk';
-import type { SquadStateContext, StateBackendType } 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';
-import { runInit } from './cli/core/init.js';
-import { runCost } from './cli/commands/cost.js';
-import { getPackageVersion } from './cli/core/version.js';
-import { printCommandHelp, printGenericCommandHelp } from './cli/core/command-help.js';
-
-// Lazy-load squad-sdk to avoid triggering @github/copilot-sdk import on Node 24+
-// (Issue: copilot-sdk has broken ESM imports - vscode-jsonrpc/node without .js extension)
-const lazySquadSdk = () => import('@bradygaster/squad-sdk');
-const lazyRunShell = () => import('./cli/shell/index.js');
-
-// Use local version resolver instead of importing VERSION from squad-sdk
-const VERSION = getPackageVersion();
-
-/**
- * Return the starting directory for squad resolution.
- * Respects --team-root / SQUAD_TEAM_ROOT env var so that subprocesses
- * (e.g. Copilot CLI bang commands) can locate .squad/ even when their
- * working directory differs from the interactive shell. (#734)
- */
-function getSquadStartDir(): string {
- return process.env['SQUAD_TEAM_ROOT'] || process.cwd();
-}
-
-async function main(): Promise {
- const args = process.argv.slice(2);
-
- // --team-root flag: override team root for resolution
- const teamRootIdx = args.indexOf('--team-root');
- if (teamRootIdx !== -1 && args[teamRootIdx + 1]) {
- process.env['SQUAD_TEAM_ROOT'] = args[teamRootIdx + 1]!;
- // Remove --team-root and its value from args
- args.splice(teamRootIdx, 2);
- }
-
- const hasGlobal = args.includes('--global');
- // --economy activates economy mode for this session (sets env var for spawner)
- const hasEconomy = args.includes('--economy');
- if (hasEconomy) {
- process.env['SQUAD_ECONOMY_MODE'] = '1';
- }
- const rawCmd = args[0];
- const cmd = rawCmd?.trim() || '';
-
- // --version / -v / version
- // Investigated: routing is correct — cmd matches 'version' directly.
- // "Unknown command: version" reports may be shell-specific (e.g. alias/wrapper
- // prepending flags so args[0] is no longer 'version'). No intercepting router found.
- if (cmd === '--version' || cmd === '-v' || cmd === 'version') {
- console.log(VERSION);
- return;
- }
-
- // --help / -h / help
- if (cmd === '--help' || cmd === '-h' || cmd === 'help') {
- console.log(`\n${BOLD}squad${RESET} v${VERSION} — Add an AI agent team to any project\n`);
- console.log(`Usage: squad [command] [options]\n`);
- console.log(`Commands:`);
- console.log(` ${BOLD}(default)${RESET} Launch interactive shell (no args)`);
- console.log(` Flags: --global (init in personal squad directory)`);
- console.log(` ${BOLD}init${RESET} Initialize Squad (markdown-only, default)`);
- console.log(` Flags: --sdk (SDK builder syntax)`);
+#!/usr/bin/env node
+
+/**
+ * Squad CLI — entry point for command-line invocation.
+ * Separated from src/index.ts so library consumers can import
+ * the SDK without triggering CLI argument parsing or process.exit().
+ *
+ * SDK library exports live in src/index.ts (dist/index.js).
+ */
+
+process.env.NODE_NO_WARNINGS = '1';
+
+// Suppress ExperimentalWarning (e.g. node:sqlite) from leaking to terminal.
+// process.env.NODE_NO_WARNINGS only works when set BEFORE process starts;
+// this runtime hook catches warnings emitted during dynamic imports below.
+const _origEmit = process.emit;
+process.emit = function (evt: string, ...args: unknown[]) {
+ if (evt === 'warning' && (args[0] as { name?: string })?.name === 'ExperimentalWarning') {
+ return false;
+ }
+ return _origEmit.apply(this, [evt, ...args] as Parameters);
+};
+
+// Runtime ESM Import Patcher for @github/copilot-sdk (#265)
+// ---------------------------------------------------------
+// Patch broken ESM import in @github/copilot-sdk@0.1.32 at runtime before
+// Node's module loader attempts resolution.
+//
+// Root cause: copilot-sdk's session.js imports 'vscode-jsonrpc/node' without
+// .js extension, violating Node 24+ strict ESM resolution requirements.
+//
+// Why runtime patch?: NPX caches packages in ~/.npm/_cacache and skips
+// postinstall scripts on cache hits (documented npm behavior). The install-time
+// patch in scripts/patch-esm-imports.mjs never runs on npx cache hits, causing
+// ERR_MODULE_NOT_FOUND crashes on Node 24+.
+//
+// This runtime patch intercepts Module._resolveFilename before any imports
+// trigger copilot-sdk loading, rewriting the broken import to include .js.
+// Works everywhere: npx (cache hit/miss), global install, CI/CD.
+//
+// Upstream issue: https://github.com/github/copilot-sdk/issues/707
+import { createRequire } from 'node:module';
+const require = createRequire(import.meta.url);
+const Module = require('node:module');
+
+const _origResolveFilename = Module._resolveFilename;
+Module._resolveFilename = function (request: string, parent: unknown, isMain: boolean, options?: unknown) {
+ // Intercept the broken import: 'vscode-jsonrpc/node' → 'vscode-jsonrpc/node.js'
+ if (request === 'vscode-jsonrpc/node') {
+ request = 'vscode-jsonrpc/node.js';
+ }
+ return _origResolveFilename.call(this, request, parent, isMain, options);
+};
+
+// Pre-flight: require Node.js ≥22.5.0 for node:sqlite (#214, #502).
+// node:sqlite is used by the Copilot SDK for session storage.
+// Fail fast with a clear message rather than letting users hit a cryptic
+// ERR_UNKNOWN_BUILTIN_MODULE crash when the SDK loads.
+{
+ const parts = process.versions.node.split('.').map(Number);
+ const major = parts[0] ?? 0;
+ const minor = parts[1] ?? 0;
+ if (major < 22 || (major === 22 && minor < 5)) {
+ console.error(
+ `✗ Squad requires Node.js ≥22.5.0 (you have v${process.versions.node}).\n` +
+ ` node:sqlite (required by the Copilot SDK for session storage) was added in Node 22.5.0.\n` +
+ ` Upgrade at: https://nodejs.org/en/download\n`,
+ );
+ process.exit(1);
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Top-level signal handlers — safety net for clean exit on Ctrl+C / SIGTERM.
+// Individual commands (shell, watch, aspire, rc) register their own handlers
+// that run first; these ensure the process never hangs if a command doesn't.
+// ---------------------------------------------------------------------------
+let _exitingOnSignal = false;
+function _handleTopLevelSignal(signal: 'SIGINT' | 'SIGTERM'): void {
+ const code = signal === 'SIGINT' ? 130 : 143;
+ if (_exitingOnSignal) {
+ // Second signal — force exit immediately
+ process.exit(code);
+ }
+ _exitingOnSignal = true;
+ // Allow in-flight cleanup handlers a brief window, then force exit
+ setTimeout(() => process.exit(code), 3_000).unref();
+}
+process.on('SIGINT', () => _handleTopLevelSignal('SIGINT'));
+process.on('SIGTERM', () => _handleTopLevelSignal('SIGTERM'));
+
+import { FSStorageProvider, resolveSquadState } from '@bradygaster/squad-sdk';
+import type { SquadStateContext, StateBackendType } 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';
+import { runInit } from './cli/core/init.js';
+import { runCost } from './cli/commands/cost.js';
+import { getPackageVersion } from './cli/core/version.js';
+import { printCommandHelp, printGenericCommandHelp } from './cli/core/command-help.js';
+
+// Lazy-load squad-sdk to avoid triggering @github/copilot-sdk import on Node 24+
+// (Issue: copilot-sdk has broken ESM imports - vscode-jsonrpc/node without .js extension)
+const lazySquadSdk = () => import('@bradygaster/squad-sdk');
+const lazyRunShell = () => import('./cli/shell/index.js');
+
+// Use local version resolver instead of importing VERSION from squad-sdk
+const VERSION = getPackageVersion();
+
+/**
+ * Return the starting directory for squad resolution.
+ * Respects --team-root / SQUAD_TEAM_ROOT env var so that subprocesses
+ * (e.g. Copilot CLI bang commands) can locate .squad/ even when their
+ * working directory differs from the interactive shell. (#734)
+ */
+function getSquadStartDir(): string {
+ return process.env['SQUAD_TEAM_ROOT'] || process.cwd();
+}
+
+async function main(): Promise {
+ const args = process.argv.slice(2);
+
+ // --team-root flag: override team root for resolution
+ const teamRootIdx = args.indexOf('--team-root');
+ if (teamRootIdx !== -1 && args[teamRootIdx + 1]) {
+ process.env['SQUAD_TEAM_ROOT'] = args[teamRootIdx + 1]!;
+ // Remove --team-root and its value from args
+ args.splice(teamRootIdx, 2);
+ }
+
+ const hasGlobal = args.includes('--global');
+ // --economy activates economy mode for this session (sets env var for spawner)
+ const hasEconomy = args.includes('--economy');
+ if (hasEconomy) {
+ process.env['SQUAD_ECONOMY_MODE'] = '1';
+ }
+ const rawCmd = args[0];
+ const cmd = rawCmd?.trim() || '';
+
+ // --version / -v / version
+ // Investigated: routing is correct — cmd matches 'version' directly.
+ // "Unknown command: version" reports may be shell-specific (e.g. alias/wrapper
+ // prepending flags so args[0] is no longer 'version'). No intercepting router found.
+ if (cmd === '--version' || cmd === '-v' || cmd === 'version') {
+ console.log(VERSION);
+ return;
+ }
+
+ // --help / -h / help
+ if (cmd === '--help' || cmd === '-h' || cmd === 'help') {
+ console.log(`\n${BOLD}squad${RESET} v${VERSION} — Add an AI agent team to any project\n`);
+ console.log(`Usage: squad [command] [options]\n`);
+ console.log(`Commands:`);
+ console.log(` ${BOLD}(default)${RESET} Launch interactive shell (no args)`);
+ console.log(` Flags: --global (init in personal squad directory)`);
+ console.log(` ${BOLD}init${RESET} Initialize Squad (markdown-only, default)`);
+ console.log(` Flags: --sdk (SDK builder syntax)`);
console.log(` --roles (use base roles)`);
console.log(` --global (personal squad dir)`);
- console.log(` --no-workflows (skip CI setup)`);
+ console.log(` --no-workflows (skip CI setup)`);
console.log(` --no-vscode-default (skip .vscode/settings.json update)`);
console.log(` --preset (apply a preset after init)`);
- console.log(` --state-backend (local|orphan|two-layer)`);
- console.log(` Usage: init --mode remote `);
- console.log(` Creates .squad/config.json pointing to an external team root`);
- console.log(` ${BOLD}upgrade${RESET} Update Squad-owned files to latest version`);
- console.log(` Overwrites: squad.agent.md, templates dir (.squad/templates/)`);
- console.log(` Never touches: .squad/ or .ai-team/ (your team state)`);
- console.log(` Flags: --global (upgrade personal squad)`);
- console.log(` --migrate-directory (rename .ai-team/ → .squad/)`);
- console.log(` --state-backend (migrate to orphan|two-layer)`);
- console.log(` ${BOLD}update-check${RESET} Report cached CLI update status (for tooling/CI)`);
- console.log(` Flags: --json (structured output), --refresh (bypass cache)`);
- console.log(` ${BOLD}migrate${RESET} Convert between markdown and SDK-First squad formats`);
- console.log(` Flags: --to sdk|markdown, --from ai-team, --dry-run`);
- console.log(` ${BOLD}sync${RESET} Sync squad-state branch(es) with remote (push/pull/both)`);
- console.log(` Flags: --push, --pull, --remote , --quiet`);
- console.log(` No-op for local/worktree backends. Invoked by git hooks.`);
- console.log(` ${BOLD}status${RESET} Show which squad is active and why`);
- console.log(` ${BOLD}roles${RESET} List built-in Squad roles`);
- console.log(` Usage: roles [--category ] [--search ]`);
- console.log(` ${BOLD}cost${RESET} Report token usage from orchestration logs`);
- console.log(` Flags: --all, --agent `);
- console.log(` ${BOLD}triage${RESET} Scan for work and categorize issues`);
- console.log(` Usage: triage [--interval ] [--execute]`);
- console.log(` Default: checks every 10 minutes (Ctrl+C to stop)`);
- console.log(` Core flags:`);
- console.log(` --execute (spawn agents to work on issues)`);
- console.log(` --copilot-flags "..." (extra copilot CLI flags)`);
- console.log(` --max-concurrent N (parallel issue limit, default 1)`);
- console.log(` --timeout N (max minutes per issue, default 30)`);
- console.log(` Capabilities (opt-in via -- or config.json):`);
- console.log(` --self-pull git fetch/pull at round start`);
- console.log(` --board project board lifecycle + reconciliation`);
- console.log(` --board-project N project number (default 1)`);
- console.log(` --monitor-teams scan Teams for actionable messages`);
- console.log(` --monitor-email scan email for actionable items`);
- console.log(` --two-pass lightweight list then hydrate actionable`);
- console.log(` --wave-dispatch wave-based parallel sub-task dispatch`);
- console.log(` --retro enforce retrospective checks`);
- console.log(` --decision-hygiene auto-merge decision inbox`);
- console.log(` Disable: --no- overrides config.json`);
- console.log(` Logging: --log-file tee output to file with timestamps`);
- console.log(` ${BOLD}loop${RESET} Prompt-driven continuous work loop`);
- console.log(` Usage: loop [--init] [--file ] [--interval ]`);
- console.log(` Reads loop.md and runs it each cycle (no issues needed)`);
- console.log(` Flags: --init (generate boilerplate loop.md)`);
- console.log(` --file (custom loop file)`);
- console.log(` --monitor-email, --monitor-teams (add monitoring)`);
- console.log(` ${BOLD}cast${RESET} Show current session cast (project + personal agents)`);
- console.log(` Usage: cast [--name ] [--role ] (alias: hire)`);
- console.log(` ${BOLD}copilot${RESET} Add/remove the Copilot coding agent (@copilot)`);
- console.log(` Usage: copilot [--off] [--auto-assign]`);
- console.log(` ${BOLD}plugin${RESET} Manage plugin marketplaces`);
- console.log(` Usage: plugin marketplace add|remove|list|browse`);
- console.log(` ${BOLD}export${RESET} Export squad to a portable JSON snapshot`);
- console.log(` Default: squad-export.json (use --out to override)`);
- console.log(` ${BOLD}import${RESET} Import squad from an export file`);
- console.log(` Usage: import [--force]`);
- console.log(` ${BOLD}scrub-emails${RESET} Remove email addresses from Squad state files`);
- console.log(` Usage: scrub-emails [directory] (default: .ai-team/)`);
- console.log(` ${BOLD}start${RESET} Start Copilot with remote access from phone/browser`);
- console.log(` Usage: start [--tunnel] [--port ] [--command ]`);
- console.log(` [copilot flags...]`);
- console.log(` Examples: start --tunnel --yolo`);
- console.log(` start --tunnel --model claude-sonnet-4.6`);
- console.log(` start --tunnel --command "gh copilot"`);
- console.log(` ${BOLD}nap${RESET} Context hygiene (compress, prune, archive .squad/ state)`);
- console.log(` Usage: nap [--deep] [--dry-run]`);
- console.log(` Flags: --deep (thorough cleanup), --dry-run (preview only)`);
+ console.log(` --state-backend (local|orphan|two-layer)`);
+ console.log(` Usage: init --mode remote `);
+ console.log(` Creates .squad/config.json pointing to an external team root`);
+ console.log(` ${BOLD}upgrade${RESET} Update Squad-owned files to latest version`);
+ console.log(` Overwrites: squad.agent.md, templates dir (.squad/templates/)`);
+ console.log(` Never touches: .squad/ or .ai-team/ (your team state)`);
+ console.log(` Flags: --global (upgrade personal squad)`);
+ console.log(` --migrate-directory (rename .ai-team/ → .squad/)`);
+ console.log(` --state-backend (migrate to orphan|two-layer)`);
+ console.log(` ${BOLD}update-check${RESET} Report cached CLI update status (for tooling/CI)`);
+ console.log(` Flags: --json (structured output), --refresh (bypass cache)`);
+ console.log(` ${BOLD}migrate${RESET} Convert between markdown and SDK-First squad formats`);
+ console.log(` Flags: --to sdk|markdown, --from ai-team, --dry-run`);
+ console.log(` ${BOLD}sync${RESET} Sync squad-state branch(es) with remote (push/pull/both)`);
+ console.log(` Flags: --push, --pull, --remote , --quiet`);
+ console.log(` No-op for local/worktree backends. Invoked by git hooks.`);
+ console.log(` ${BOLD}status${RESET} Show which squad is active and why`);
+ console.log(` ${BOLD}roles${RESET} List built-in Squad roles`);
+ console.log(` Usage: roles [--category ] [--search ]`);
+ console.log(` ${BOLD}cost${RESET} Report token usage from orchestration logs`);
+ console.log(` Flags: --all, --agent `);
+ console.log(` ${BOLD}triage${RESET} Scan for work and categorize issues`);
+ console.log(` Usage: triage [--interval ] [--execute]`);
+ console.log(` Default: checks every 10 minutes (Ctrl+C to stop)`);
+ console.log(` Core flags:`);
+ console.log(` --execute (spawn agents to work on issues)`);
+ console.log(` --copilot-flags "..." (extra copilot CLI flags)`);
+ console.log(` --max-concurrent N (parallel issue limit, default 1)`);
+ console.log(` --timeout N (max minutes per issue, default 30)`);
+ console.log(` Capabilities (opt-in via -- or config.json):`);
+ console.log(` --self-pull git fetch/pull at round start`);
+ console.log(` --board project board lifecycle + reconciliation`);
+ console.log(` --board-project N project number (default 1)`);
+ console.log(` --monitor-teams scan Teams for actionable messages`);
+ console.log(` --monitor-email scan email for actionable items`);
+ console.log(` --two-pass lightweight list then hydrate actionable`);
+ console.log(` --wave-dispatch wave-based parallel sub-task dispatch`);
+ console.log(` --retro enforce retrospective checks`);
+ console.log(` --decision-hygiene auto-merge decision inbox`);
+ console.log(` Disable: --no- overrides config.json`);
+ console.log(` Logging: --log-file tee output to file with timestamps`);
+ console.log(` ${BOLD}loop${RESET} Prompt-driven continuous work loop`);
+ console.log(` Usage: loop [--init] [--file ] [--interval ]`);
+ console.log(` Reads loop.md and runs it each cycle (no issues needed)`);
+ console.log(` Flags: --init (generate boilerplate loop.md)`);
+ console.log(` --file (custom loop file)`);
+ console.log(` --monitor-email, --monitor-teams (add monitoring)`);
+ console.log(` ${BOLD}cast${RESET} Show current session cast (project + personal agents)`);
+ console.log(` Usage: cast [--name ] [--role ] (alias: hire)`);
+ console.log(` ${BOLD}copilot${RESET} Add/remove the Copilot coding agent (@copilot)`);
+ console.log(` Usage: copilot [--off] [--auto-assign]`);
+ console.log(` ${BOLD}plugin${RESET} Manage plugin marketplaces`);
+ console.log(` Usage: plugin marketplace add|remove|list|browse`);
+ console.log(` ${BOLD}export${RESET} Export squad to a portable JSON snapshot`);
+ console.log(` Default: squad-export.json (use --out to override)`);
+ console.log(` ${BOLD}import${RESET} Import squad from an export file`);
+ console.log(` Usage: import [--force]`);
+ console.log(` ${BOLD}scrub-emails${RESET} Remove email addresses from Squad state files`);
+ console.log(` Usage: scrub-emails [directory] (default: .ai-team/)`);
+ console.log(` ${BOLD}start${RESET} Start Copilot with remote access from phone/browser`);
+ console.log(` Usage: start [--tunnel] [--port ] [--command ]`);
+ console.log(` [copilot flags...]`);
+ console.log(` Examples: start --tunnel --yolo`);
+ console.log(` start --tunnel --model claude-sonnet-4.6`);
+ console.log(` start --tunnel --command "gh copilot"`);
+ console.log(` ${BOLD}nap${RESET} Context hygiene (compress, prune, archive .squad/ state)`);
+ console.log(` Usage: nap [--deep] [--dry-run]`);
+ console.log(` Flags: --deep (thorough cleanup), --dry-run (preview only)`);
console.log(` ${BOLD}memory${RESET} Governed memory operations`);
- console.log(` Usage: memory write --content "..." --class LOCAL`);
- console.log(` Diagnostics: --log-level info|debug or --verbose`);
+ console.log(` Usage: memory write --content "..." --class LOCAL`);
+ console.log(` Diagnostics: --log-level info|debug or --verbose`);
console.log(` ${BOLD}state-mcp${RESET} MCP bridge exposing Squad runtime state tools`);
- console.log(` ${BOLD}doctor${RESET} Validate squad setup (check files, config, health)`);
- console.log(` ${BOLD}consult${RESET} Enter consult mode with your personal squad`);
- console.log(` Flags: --status, --check`);
- console.log(` ${BOLD}extract${RESET} Extract learnings from consult mode session`);
- console.log(` Flags: --dry-run, --clean, --yes, --accept-risks`);
- console.log(` ${BOLD}subsquads${RESET} Manage Squad SubSquads (multi-Codespace scaling)`);
- console.log(` Usage: subsquads >`);
- console.log(` Aliases: workstreams, streams (deprecated)`);
- console.log(` ${BOLD}link${RESET} Link project to a remote team root`);
- console.log(` Usage: link `);
- console.log(` ${BOLD}externalize${RESET} Move local squad state to an external team root`);
- console.log(` ${BOLD}internalize${RESET} Pull an external team root back into the project`);
- console.log(` ${BOLD}build${RESET} Compile squad.config.ts into .squad/ markdown`);
- console.log(` Flags: --check (validate only), --dry-run (preview)`);
- console.log(` --watch (rebuild on change)`);
- console.log(` ${BOLD}aspire${RESET} Launch Aspire dashboard for observability`);
- console.log(` Flags: --docker (force Docker), --port (dashboard port)`);
- console.log(` ${BOLD}schedule${RESET} Manage scheduled tasks`);
- console.log(` Usage: schedule list | run | init | status`);
- console.log(` ${BOLD}personal${RESET} Manage your personal squad (ambient agents)`);
- console.log(` Usage: personal init | list | add `);
- console.log(` --role | remove `);
- console.log(` ${BOLD}preset${RESET} Manage squad presets (curated agent collections)`);
- console.log(` Usage: preset list | show `);
- console.log(` apply [--force] | save `);
- console.log(` init [--remote]`);
- console.log(` ${BOLD}rc${RESET} Start Remote Control bridge (phone/browser → Copilot)`);
- console.log(` Usage: rc [--tunnel] [--port ] [--path ]`);
- console.log(` ${BOLD}copilot-bridge${RESET} Check Copilot ACP stdio compatibility`);
- console.log(` ${BOLD}init-remote${RESET} Link project to remote team root (shorthand)`);
- console.log(` Usage: init-remote `);
- console.log(` ${BOLD}rc-tunnel${RESET} Check devtunnel CLI availability`);
- console.log(` ${BOLD}discover${RESET} List known squads and their capabilities`);
- console.log(` ${BOLD}delegate${RESET} Create work in another squad`);
- console.log(` Usage: delegate `);
- console.log(` ${BOLD}registry${RESET} Manage peer squads for cross-squad discovery (no inheritance)`);
- console.log(` Usage: registry add `);
- console.log(` registry list`);
- console.log(` registry remove `);
- console.log(` ${BOLD}upstream${RESET} Manage upstream Squad sources`);
- console.log(` Usage: upstream add [--name ] [--ref ]`);
- console.log(` upstream remove `);
- console.log(` upstream list`);
- console.log(` upstream sync [name]`);
- console.log(` ${BOLD}economy${RESET} Toggle economy mode (cost-conscious model selection)`);
- console.log(` Usage: economy [on|off]`);
- console.log(` ${BOLD}externalize${RESET} Move .squad/ state out of the working tree`);
- console.log(` Stores state in platform-local storage`);
- console.log(` Flags: --key (explicit project key)`);
- console.log(` ${BOLD}internalize${RESET} Restore externalized state into the working tree`);
-
- console.log(` ${BOLD}version${RESET} Print installed version`);
- console.log(` ${BOLD}help${RESET} Show this help message`);
- console.log(`\nFlags:`);
- console.log(` ${BOLD}--version, -v${RESET} Print version`);
- console.log(` ${BOLD}--help, -h${RESET} Show help`);
- console.log(` ${BOLD}--global${RESET} Use personal (global) squad path (for init, upgrade)`);
- console.log(` ${BOLD}--economy${RESET} Activate economy mode for this session (cheaper models)`);
- console.log(` ${BOLD}--team-root${RESET} Override team root path for resolution`);
- console.log(`\nInstallation:`);
- console.log(` npm install --save-dev @bradygaster/squad-cli`);
- console.log(` npm install --save-dev @bradygaster/squad-cli@insider\n`);
- return;
- }
-
- // --help / -h on a subcommand → print command-specific help and exit.
- // Without this intercept the flag was silently dropped and the command
- // would execute for real (sometimes with destructive side effects, e.g.
- // `squad init --help` scaffolding files, or `squad triage --help` starting
- // the polling loop). See #1201.
- if (
- cmd &&
- cmd !== 'help' &&
- cmd !== '--help' &&
- cmd !== '-h' &&
- cmd !== 'version' &&
- cmd !== '--version' &&
- cmd !== '-v' &&
- (args.includes('--help') || args.includes('-h'))
- ) {
- if (!printCommandHelp(cmd, VERSION)) {
- printGenericCommandHelp(cmd);
- }
- return;
- }
-
- // No args → launch interactive shell; whitespace-only arg → show help
- if (rawCmd === undefined) {
- // Fire-and-forget update check — non-blocking, never delays shell startup
- import('./cli/self-update.js').then(m => m.notifyIfUpdateAvailable(VERSION)).catch(() => {});
- const { runShell } = await lazyRunShell();
- await runShell();
- return;
- }
- if (!cmd) {
- // Whitespace-only arg — show help and exit cleanly
- console.log(`\n${BOLD}squad${RESET} v${VERSION} — Add an AI agent team to any project\n`);
- console.log(`Usage: squad [command] [options]`);
- console.log(`Run 'squad help' for the full command list.\n`);
- return;
- }
-
- // Route subcommands
- if (cmd === 'init') {
- const modeIdx = args.indexOf('--mode');
- const mode = (modeIdx !== -1 && args[modeIdx + 1]) ? args[modeIdx + 1] : undefined;
-
- if (mode === 'remote') {
- const teamPath = args[modeIdx + 2];
- if (!teamPath) {
- fatal('Usage: squad init --mode remote ');
- }
- const { writeRemoteConfig } = await import('./cli/commands/init-remote.js');
- const dest = process.cwd();
- writeRemoteConfig(dest, teamPath);
- await runInit(dest);
- return;
- }
-
- const sdkMod = hasGlobal ? await lazySquadSdk() : null;
- const dest = hasGlobal ? sdkMod!.resolveGlobalSquadPath() : process.cwd();
- const noWorkflows = args.includes('--no-workflows');
+ console.log(` ${BOLD}doctor${RESET} Validate squad setup (check files, config, health)`);
+ console.log(` ${BOLD}consult${RESET} Enter consult mode with your personal squad`);
+ console.log(` Flags: --status, --check`);
+ console.log(` ${BOLD}extract${RESET} Extract learnings from consult mode session`);
+ console.log(` Flags: --dry-run, --clean, --yes, --accept-risks`);
+ console.log(` ${BOLD}subsquads${RESET} Manage Squad SubSquads (multi-Codespace scaling)`);
+ console.log(` Usage: subsquads >`);
+ console.log(` Aliases: workstreams, streams (deprecated)`);
+ console.log(` ${BOLD}link${RESET} Link project to a remote team root`);
+ console.log(` Usage: link `);
+ console.log(` ${BOLD}externalize${RESET} Move local squad state to an external team root`);
+ console.log(` ${BOLD}internalize${RESET} Pull an external team root back into the project`);
+ console.log(` ${BOLD}build${RESET} Compile squad.config.ts into .squad/ markdown`);
+ console.log(` Flags: --check (validate only), --dry-run (preview)`);
+ console.log(` --watch (rebuild on change)`);
+ console.log(` ${BOLD}aspire${RESET} Launch Aspire dashboard for observability`);
+ console.log(` Flags: --docker (force Docker), --port (dashboard port)`);
+ console.log(` ${BOLD}schedule${RESET} Manage scheduled tasks`);
+ console.log(` Usage: schedule list | run | init | status`);
+ console.log(` ${BOLD}personal${RESET} Manage your personal squad (ambient agents)`);
+ console.log(` Usage: personal init | list | add `);
+ console.log(` --role | remove `);
+ console.log(` ${BOLD}preset${RESET} Manage squad presets (curated agent collections)`);
+ console.log(` Usage: preset list | show `);
+ console.log(` apply [--force] | save `);
+ console.log(` init [--remote]`);
+ console.log(` ${BOLD}rc${RESET} Start Remote Control bridge (phone/browser → Copilot)`);
+ console.log(` Usage: rc [--tunnel] [--port ] [--path ]`);
+ console.log(` ${BOLD}copilot-bridge${RESET} Check Copilot ACP stdio compatibility`);
+ console.log(` ${BOLD}init-remote${RESET} Link project to remote team root (shorthand)`);
+ console.log(` Usage: init-remote `);
+ console.log(` ${BOLD}rc-tunnel${RESET} Check devtunnel CLI availability`);
+ console.log(` ${BOLD}discover${RESET} List known squads and their capabilities`);
+ console.log(` ${BOLD}delegate${RESET} Create work in another squad`);
+ console.log(` Usage: delegate `);
+ console.log(` ${BOLD}registry${RESET} Manage peer squads for cross-squad discovery (no inheritance)`);
+ console.log(` Usage: registry add `);
+ console.log(` registry list`);
+ console.log(` registry remove `);
+ console.log(` ${BOLD}upstream${RESET} Manage upstream Squad sources`);
+ console.log(` Usage: upstream add [--name ] [--ref ]`);
+ console.log(` upstream remove `);
+ console.log(` upstream list`);
+ console.log(` upstream sync [name]`);
+ console.log(` ${BOLD}economy${RESET} Toggle economy mode (cost-conscious model selection)`);
+ console.log(` Usage: economy [on|off]`);
+ console.log(` ${BOLD}externalize${RESET} Move .squad/ state out of the working tree`);
+ console.log(` Stores state in platform-local storage`);
+ console.log(` Flags: --key (explicit project key)`);
+ console.log(` ${BOLD}internalize${RESET} Restore externalized state into the working tree`);
+
+ console.log(` ${BOLD}version${RESET} Print installed version`);
+ console.log(` ${BOLD}help${RESET} Show this help message`);
+ console.log(`\nFlags:`);
+ console.log(` ${BOLD}--version, -v${RESET} Print version`);
+ console.log(` ${BOLD}--help, -h${RESET} Show help`);
+ console.log(` ${BOLD}--global${RESET} Use personal (global) squad path (for init, upgrade)`);
+ console.log(` ${BOLD}--economy${RESET} Activate economy mode for this session (cheaper models)`);
+ console.log(` ${BOLD}--team-root${RESET} Override team root path for resolution`);
+ console.log(`\nInstallation:`);
+ console.log(` npm install --save-dev @bradygaster/squad-cli`);
+ console.log(` npm install --save-dev @bradygaster/squad-cli@insider\n`);
+ return;
+ }
+
+ // --help / -h on a subcommand → print command-specific help and exit.
+ // Without this intercept the flag was silently dropped and the command
+ // would execute for real (sometimes with destructive side effects, e.g.
+ // `squad init --help` scaffolding files, or `squad triage --help` starting
+ // the polling loop). See #1201.
+ if (
+ cmd &&
+ cmd !== 'help' &&
+ cmd !== '--help' &&
+ cmd !== '-h' &&
+ cmd !== 'version' &&
+ cmd !== '--version' &&
+ cmd !== '-v' &&
+ (args.includes('--help') || args.includes('-h'))
+ ) {
+ if (!printCommandHelp(cmd, VERSION)) {
+ printGenericCommandHelp(cmd);
+ }
+ return;
+ }
+
+ // No args → launch interactive shell; whitespace-only arg → show help
+ if (rawCmd === undefined) {
+ // Fire-and-forget update check — non-blocking, never delays shell startup
+ import('./cli/self-update.js').then(m => m.notifyIfUpdateAvailable(VERSION)).catch(() => {});
+ const { runShell } = await lazyRunShell();
+ await runShell();
+ return;
+ }
+ if (!cmd) {
+ // Whitespace-only arg — show help and exit cleanly
+ console.log(`\n${BOLD}squad${RESET} v${VERSION} — Add an AI agent team to any project\n`);
+ console.log(`Usage: squad [command] [options]`);
+ console.log(`Run 'squad help' for the full command list.\n`);
+ return;
+ }
+
+ // Route subcommands
+ if (cmd === 'init') {
+ const modeIdx = args.indexOf('--mode');
+ const mode = (modeIdx !== -1 && args[modeIdx + 1]) ? args[modeIdx + 1] : undefined;
+
+ if (mode === 'remote') {
+ const teamPath = args[modeIdx + 2];
+ if (!teamPath) {
+ fatal('Usage: squad init --mode remote ');
+ }
+ const { writeRemoteConfig } = await import('./cli/commands/init-remote.js');
+ const dest = process.cwd();
+ writeRemoteConfig(dest, teamPath);
+ await runInit(dest);
+ return;
+ }
+
+ const sdkMod = hasGlobal ? await lazySquadSdk() : null;
+ const dest = hasGlobal ? sdkMod!.resolveGlobalSquadPath() : process.cwd();
+ const noWorkflows = args.includes('--no-workflows');
const noVscodeDefault = args.includes('--no-vscode-default');
const mcpFrontmatter = args.includes('--mcp-frontmatter');
const sdk = args.includes('--sdk');
- const roles = args.includes('--roles');
- const presetIdx = args.indexOf('--preset');
- const presetName = (presetIdx !== -1 && args[presetIdx + 1]) ? args[presetIdx + 1] : undefined;
- // Parse --state-backend flag for init
- const sbIdx = args.indexOf('--state-backend');
- const initStateBackend = (sbIdx !== -1 && args[sbIdx + 1]) ? args[sbIdx + 1] : undefined;
- // Global init: suppress workflows (no GitHub CI in ~/.config/squad/) and bootstrap personal squad
+ const roles = args.includes('--roles');
+ const presetIdx = args.indexOf('--preset');
+ const presetName = (presetIdx !== -1 && args[presetIdx + 1]) ? args[presetIdx + 1] : undefined;
+ // Parse --state-backend flag for init
+ const sbIdx = args.indexOf('--state-backend');
+ const initStateBackend = (sbIdx !== -1 && args[sbIdx + 1]) ? args[sbIdx + 1] : undefined;
+ // Global init: suppress workflows (no GitHub CI in ~/.config/squad/) and bootstrap personal squad
runInit(dest, { includeWorkflows: !noWorkflows && !hasGlobal, sdk, roles, isGlobal: hasGlobal, stateBackend: initStateBackend, mcpFrontmatter, includeVscodeDefault: !noVscodeDefault }).then(async () => {
- if (presetName) {
- const { seedBuiltinPresets, applyPreset } = await import('@bradygaster/squad-sdk/presets');
- const { resolvePresetsDir, ensureSquadHome } = await import('@bradygaster/squad-sdk/resolution');
- const nodePath = await import('node:path');
-
- // Auto-initialize squad home + presets if they don't exist yet
- if (!resolvePresetsDir()) {
- console.log(`\n⚙️ No presets found — setting up squad home...`);
- ensureSquadHome();
- seedBuiltinPresets();
- console.log(`✅ Squad home initialized at ${ensureSquadHome()}`);
- console.log(` Built-in presets ready. Run 'squad preset init --remote' to back with a GitHub repo.\n`);
- } else {
- seedBuiltinPresets();
- }
-
- const targetAgentsDir = nodePath.join(dest, '.squad', 'agents');
- const results = applyPreset(presetName, targetAgentsDir);
- const installed = results.filter(r => r.status === 'installed');
- const skipped = results.filter(r => r.status === 'skipped');
- const errors = results.filter(r => r.status === 'error');
- if (installed.length > 0) {
- console.log(`✅ Applied preset '${presetName}': ${installed.length} agents installed`);
- }
- if (skipped.length > 0) {
- console.log(` ${skipped.length} agents skipped (already exist)`);
- }
- if (errors.length > 0 && installed.length === 0) {
- console.error(`❌ Preset '${presetName}' not found. Run 'squad preset list' to see available presets.`);
- }
- }
- }).catch(err => {
- fatal(err.message);
- });
- return;
- }
-
- if (cmd === 'upgrade') {
- const { runUpgrade, selfUpgradeCli } = await import('./cli/core/upgrade.js');
- const { migrateDirectory } = await import('./cli/core/migrate-directory.js');
-
- const migrateDir = args.includes('--migrate-directory');
- const selfUpgrade = args.includes('--self');
- const forceUpgrade = args.includes('--force');
- const insider = args.includes('--insider');
- const dryRun = args.includes('--dry-run');
- const dest = hasGlobal ? (await lazySquadSdk()).resolveGlobalSquadPath() : getSquadStartDir();
-
- // Parse --state-backend for backend migration
- const sbIdx = args.indexOf('--state-backend');
- const upgradeStateBackend = (sbIdx !== -1 && args[sbIdx + 1]) ? args[sbIdx + 1] : undefined;
-
- // Warn when --insider is used without --self (it has no effect on project upgrades)
- if (insider && !selfUpgrade) {
- console.warn('⚠️ --insider only applies with --self (squad upgrade --self --insider). Ignoring.');
- }
-
- // Handle --migrate-directory flag
- if (migrateDir) {
- await migrateDirectory(dest);
- // Continue with regular upgrade after migration
- }
-
- // Handle --self: upgrade the CLI package itself.
- //
- // UPGRADE-EPERM-FALSE-SUCCESS fix (iter-2): surface a failed self-upgrade
- // instead of printing "✅ Upgraded" after a warning.
- //
- // Iter-4 hardening: when BOTH --self and --state-backend are passed and
- // the self-upgrade fails (e.g. EPERM on a globally-installed CLI that
- // can't be replaced by the current user), still run the state-backend
- // migration. The two operations are independent — failing the npm
- // install must not block the user from upgrading their existing project's
- // on-disk state layout. Failures are tracked and we exit non-zero at the
- // end if either step failed.
- let selfUpgradeFailed: string | null = null;
- if (selfUpgrade) {
- try {
- await selfUpgradeCli({ insider, force: forceUpgrade });
- } catch (err) {
- const msg = err instanceof Error ? err.message : String(err);
- selfUpgradeFailed = msg;
- if (upgradeStateBackend) {
- // Defer the failure: still attempt the state-backend migration so
- // the user gets at least one of the two operations they asked for.
- console.error(`⚠️ Self-upgrade failed: ${msg}`);
- console.error(' Continuing with --state-backend migration. Self-upgrade can be retried separately.');
- } else {
- console.error(`❌ Self-upgrade failed: ${msg}`);
- process.exit(1);
- }
- }
- if (!selfUpgradeFailed && !upgradeStateBackend) {
- console.log('✅ Upgraded. Please restart your terminal for changes to take effect.');
- return;
- }
- if (!selfUpgradeFailed) {
- console.log('✅ Self-upgrade complete. Running --state-backend migration next…');
- }
- }
-
- // Run upgrade (skip when --self was successful AND no state-backend asked —
- // that case returned above). Otherwise we always run a project upgrade so
- // hooks/templates are refreshed alongside the backend migration.
- if (!selfUpgrade || upgradeStateBackend) {
- await runUpgrade(dest, {
- migrateDirectory: migrateDir,
- self: selfUpgrade,
- force: forceUpgrade,
- dryRun,
- });
- }
-
- // Handle --state-backend: migrate backend after upgrade
- if (upgradeStateBackend) {
- const { migrateStateBackend } = await import('./cli/commands/migrate-backend.js');
- try {
- await migrateStateBackend(dest, upgradeStateBackend);
- } catch (err) {
- const msg = err instanceof Error ? err.message : String(err);
- console.error(`❌ State-backend migration failed: ${msg}`);
- process.exit(1);
- }
- } else {
- // Ensure hooks are installed for existing orphan/two-layer backends
- const { ensureHooksForBackend } = await import('./cli/commands/install-hooks.js');
- ensureHooksForBackend(dest);
- }
-
- if (selfUpgradeFailed) {
- // Partial success — state-backend migration completed but self-upgrade
- // did not. Exit non-zero so callers (CI, wrapper scripts) can detect it.
- console.error(`❌ Self-upgrade failed earlier: ${selfUpgradeFailed}`);
- console.error(' The project upgrade and state-backend migration succeeded; retry the self-upgrade manually.');
- process.exit(1);
- }
-
- return;
- }
-
- if (cmd === 'update-check') {
- const { runUpdateCheckCommand } = await import('./cli/commands/update-check.js');
- const exitCode = await runUpdateCheckCommand(args.slice(1));
- process.exit(exitCode);
- }
-
- if (cmd === 'memory') {
- const { runMemoryCommand } = await import('./cli/commands/memory.js');
- await runMemoryCommand(getSquadStartDir(), args.slice(1));
- return;
- }
-
- if (cmd === 'state-mcp') {
- const { runStateMcp } = await import('./cli/commands/state-mcp.js');
- await runStateMcp(getSquadStartDir());
- return;
+ if (presetName) {
+ const { seedBuiltinPresets, applyPreset } = await import('@bradygaster/squad-sdk/presets');
+ const { resolvePresetsDir, ensureSquadHome } = await import('@bradygaster/squad-sdk/resolution');
+ const nodePath = await import('node:path');
+
+ // Auto-initialize squad home + presets if they don't exist yet
+ if (!resolvePresetsDir()) {
+ console.log(`\n⚙️ No presets found — setting up squad home...`);
+ ensureSquadHome();
+ seedBuiltinPresets();
+ console.log(`✅ Squad home initialized at ${ensureSquadHome()}`);
+ console.log(` Built-in presets ready. Run 'squad preset init --remote' to back with a GitHub repo.\n`);
+ } else {
+ seedBuiltinPresets();
+ }
+
+ const targetAgentsDir = nodePath.join(dest, '.squad', 'agents');
+ const results = applyPreset(presetName, targetAgentsDir);
+ const installed = results.filter(r => r.status === 'installed');
+ const skipped = results.filter(r => r.status === 'skipped');
+ const errors = results.filter(r => r.status === 'error');
+ if (installed.length > 0) {
+ console.log(`✅ Applied preset '${presetName}': ${installed.length} agents installed`);
+ }
+ if (skipped.length > 0) {
+ console.log(` ${skipped.length} agents skipped (already exist)`);
+ }
+ if (errors.length > 0 && installed.length === 0) {
+ console.error(`❌ Preset '${presetName}' not found. Run 'squad preset list' to see available presets.`);
+ }
+ }
+ }).catch(err => {
+ fatal(err.message);
+ });
+ return;
+ }
+
+ if (cmd === 'upgrade') {
+ const { runUpgrade, selfUpgradeCli } = await import('./cli/core/upgrade.js');
+ const { migrateDirectory } = await import('./cli/core/migrate-directory.js');
+
+ const migrateDir = args.includes('--migrate-directory');
+ const selfUpgrade = args.includes('--self');
+ const forceUpgrade = args.includes('--force');
+ const insider = args.includes('--insider');
+ const dryRun = args.includes('--dry-run');
+ const dest = hasGlobal ? (await lazySquadSdk()).resolveGlobalSquadPath() : getSquadStartDir();
+
+ // Parse --state-backend for backend migration
+ const sbIdx = args.indexOf('--state-backend');
+ const upgradeStateBackend = (sbIdx !== -1 && args[sbIdx + 1]) ? args[sbIdx + 1] : undefined;
+
+ // Warn when --insider is used without --self (it has no effect on project upgrades)
+ if (insider && !selfUpgrade) {
+ console.warn('⚠️ --insider only applies with --self (squad upgrade --self --insider). Ignoring.');
+ }
+
+ // Handle --migrate-directory flag
+ if (migrateDir) {
+ await migrateDirectory(dest);
+ // Continue with regular upgrade after migration
+ }
+
+ // Handle --self: upgrade the CLI package itself.
+ //
+ // UPGRADE-EPERM-FALSE-SUCCESS fix (iter-2): surface a failed self-upgrade
+ // instead of printing "✅ Upgraded" after a warning.
+ //
+ // Iter-4 hardening: when BOTH --self and --state-backend are passed and
+ // the self-upgrade fails (e.g. EPERM on a globally-installed CLI that
+ // can't be replaced by the current user), still run the state-backend
+ // migration. The two operations are independent — failing the npm
+ // install must not block the user from upgrading their existing project's
+ // on-disk state layout. Failures are tracked and we exit non-zero at the
+ // end if either step failed.
+ let selfUpgradeFailed: string | null = null;
+ if (selfUpgrade) {
+ try {
+ await selfUpgradeCli({ insider, force: forceUpgrade });
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : String(err);
+ selfUpgradeFailed = msg;
+ if (upgradeStateBackend) {
+ // Defer the failure: still attempt the state-backend migration so
+ // the user gets at least one of the two operations they asked for.
+ console.error(`⚠️ Self-upgrade failed: ${msg}`);
+ console.error(' Continuing with --state-backend migration. Self-upgrade can be retried separately.');
+ } else {
+ console.error(`❌ Self-upgrade failed: ${msg}`);
+ process.exit(1);
+ }
+ }
+ if (!selfUpgradeFailed && !upgradeStateBackend) {
+ console.log('✅ Upgraded. Please restart your terminal for changes to take effect.');
+ return;
+ }
+ if (!selfUpgradeFailed) {
+ console.log('✅ Self-upgrade complete. Running --state-backend migration next…');
+ }
+ }
+
+ // Run upgrade (skip when --self was successful AND no state-backend asked —
+ // that case returned above). Otherwise we always run a project upgrade so
+ // hooks/templates are refreshed alongside the backend migration.
+ if (!selfUpgrade || upgradeStateBackend) {
+ await runUpgrade(dest, {
+ migrateDirectory: migrateDir,
+ self: selfUpgrade,
+ force: forceUpgrade,
+ dryRun,
+ });
+ }
+
+ // Handle --state-backend: migrate backend after upgrade
+ if (upgradeStateBackend) {
+ const { migrateStateBackend } = await import('./cli/commands/migrate-backend.js');
+ try {
+ await migrateStateBackend(dest, upgradeStateBackend);
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : String(err);
+ console.error(`❌ State-backend migration failed: ${msg}`);
+ process.exit(1);
+ }
+ } else {
+ // Ensure hooks are installed for existing orphan/two-layer backends
+ const { ensureHooksForBackend } = await import('./cli/commands/install-hooks.js');
+ ensureHooksForBackend(dest);
+ }
+
+ if (selfUpgradeFailed) {
+ // Partial success — state-backend migration completed but self-upgrade
+ // did not. Exit non-zero so callers (CI, wrapper scripts) can detect it.
+ console.error(`❌ Self-upgrade failed earlier: ${selfUpgradeFailed}`);
+ console.error(' The project upgrade and state-backend migration succeeded; retry the self-upgrade manually.');
+ process.exit(1);
+ }
+
+ return;
+ }
+
+ if (cmd === 'update-check') {
+ const { runUpdateCheckCommand } = await import('./cli/commands/update-check.js');
+ const exitCode = await runUpdateCheckCommand(args.slice(1));
+ process.exit(exitCode);
+ }
+
+ if (cmd === 'memory') {
+ const { runMemoryCommand } = await import('./cli/commands/memory.js');
+ await runMemoryCommand(getSquadStartDir(), args.slice(1));
+ return;
+ }
+
+ if (cmd === 'state-mcp') {
+ const { runStateMcp } = await import('./cli/commands/state-mcp.js');
+ await runStateMcp(getSquadStartDir());
+ return;
+ }
+
+ if (cmd === 'sync') {
+ const { runSync } = await import('./cli/commands/sync.js');
+ const quiet = args.includes('--quiet');
+ const remoteIdx = args.indexOf('--remote');
+ const remote = (remoteIdx !== -1 && args[remoteIdx + 1]) ? args[remoteIdx + 1] : undefined;
+ let direction: 'push' | 'pull' | 'both' = 'both';
+ if (args.includes('--push') && !args.includes('--pull')) direction = 'push';
+ else if (args.includes('--pull') && !args.includes('--push')) direction = 'pull';
+ try {
+ await runSync({ direction, remote, cwd: getSquadStartDir(), quiet });
+ } catch (err: unknown) {
+ if (!quiet) {
+ const msg = err instanceof Error ? err.message : String(err);
+ console.error(`squad sync failed: ${msg}`);
+ }
+ process.exit(1);
+ }
+ return;
+ }
+
+ if (cmd === 'migrate') {
+ const { runMigrate } = await import('./cli/commands/migrate.js');
+ const toIdx = args.indexOf('--to');
+ const to = (toIdx !== -1 && args[toIdx + 1]) ? args[toIdx + 1] as 'sdk' | 'markdown' : undefined;
+ const fromIdx = args.indexOf('--from');
+ const from = (fromIdx !== -1 && args[fromIdx + 1]) ? args[fromIdx + 1] : undefined;
+ const dryRun = args.includes('--dry-run');
+ await runMigrate(getSquadStartDir(), { to, from: from as 'ai-team' | undefined, dryRun });
+ return;
+ }
+
+ // --health flag: show watch instance status and exit
+ if (cmd === 'watch' && args.includes('--health')) {
+ const { getWatchHealth } = await import('./cli/commands/watch/health.js');
+ console.log(getWatchHealth(getSquadStartDir()));
+ return;
+ }
+
+ if (cmd === 'triage' || cmd === 'watch') {
+ const { runWatch, loadWatchConfig, createDefaultRegistry } = await import('./cli/commands/watch/index.js');
+
+ // Parse core flags
+ const intervalIdx = args.indexOf('--interval');
+ const interval = (intervalIdx !== -1 && args[intervalIdx + 1])
+ ? parseInt(args[intervalIdx + 1]!, 10)
+ : undefined;
+
+ const execute = args.includes('--execute') ? true : undefined;
+
+ const verbose = args.includes('--verbose') || args.includes('-v');
+
+ const copilotFlagsIdx = args.indexOf('--copilot-flags');
+ const copilotFlags = (copilotFlagsIdx !== -1 && args[copilotFlagsIdx + 1])
+ ? args[copilotFlagsIdx + 1]
+ : undefined;
+
+ const agentCmdIdx = args.indexOf('--agent-cmd');
+ const agentCmd = (agentCmdIdx !== -1 && args[agentCmdIdx + 1])
+ ? args[agentCmdIdx + 1]
+ : undefined;
+
+ const maxConcurrentIdx = args.indexOf('--max-concurrent');
+ const maxConcurrent = (maxConcurrentIdx !== -1 && args[maxConcurrentIdx + 1])
+ ? parseInt(args[maxConcurrentIdx + 1]!, 10)
+ : undefined;
+
+ const timeoutIdx = args.indexOf('--timeout');
+ const timeout = (timeoutIdx !== -1 && args[timeoutIdx + 1])
+ ? parseInt(args[timeoutIdx + 1]!, 10)
+ : undefined;
+
+ // --dispatch-mode runtime validation: rejects invalid values with a clear error message
+ const dispatchModeIdx = args.indexOf('--dispatch-mode');
+ const rawDispatchMode = (dispatchModeIdx !== -1 && args[dispatchModeIdx + 1])
+ ? args[dispatchModeIdx + 1]
+ : undefined;
+ const validModes = ['task', 'fleet', 'hybrid'] as const;
+ const dispatchMode = rawDispatchMode && validModes.includes(rawDispatchMode as any)
+ ? rawDispatchMode as 'fleet' | 'task' | 'hybrid'
+ : rawDispatchMode
+ ? (console.error(`⚠️ Invalid --dispatch-mode "${rawDispatchMode}". Valid: task, fleet, hybrid. Defaulting to task.`), undefined)
+ : undefined;
+
+ const logFileIdx = args.indexOf('--log-file');
+ const logFile = (logFileIdx !== -1 && args[logFileIdx + 1])
+ ? args[logFileIdx + 1]
+ : undefined;
+
+ const authUserIdx = args.indexOf('--auth-user');
+ const authUser = (authUserIdx !== -1 && args[authUserIdx + 1])
+ ? args[authUserIdx + 1]
+ : undefined;
+
+ // --notify-level runtime validation
+ const notifyLevelIdx = args.indexOf('--notify-level');
+ const rawNotifyLevel = (notifyLevelIdx !== -1 && args[notifyLevelIdx + 1])
+ ? args[notifyLevelIdx + 1]
+ : undefined;
+ const validNotifyLevels = ['all', 'important', 'none'] as const;
+ const notifyLevel = rawNotifyLevel && (validNotifyLevels as readonly string[]).includes(rawNotifyLevel)
+ ? rawNotifyLevel as typeof validNotifyLevels[number]
+ : rawNotifyLevel
+ ? (console.error(`\u26a0\ufe0f Invalid --notify-level "${rawNotifyLevel}". Valid: all, important, none.`), undefined)
+ : undefined;
+
+ const overnightStartIdx = args.indexOf('--overnight-start');
+ const overnightStart = (overnightStartIdx !== -1 && args[overnightStartIdx + 1])
+ ? args[overnightStartIdx + 1]
+ : undefined;
+
+ const overnightEndIdx = args.indexOf('--overnight-end');
+ const overnightEnd = (overnightEndIdx !== -1 && args[overnightEndIdx + 1])
+ ? args[overnightEndIdx + 1]
+ : undefined;
+
+ const sentinelFileIdx = args.indexOf('--sentinel-file');
+ const sentinelFile = (sentinelFileIdx !== -1 && args[sentinelFileIdx + 1])
+ ? args[sentinelFileIdx + 1]
+ : undefined;
+
+ // --state-backend runtime validation: reject invalid values upfront
+ const stateBackendIdx = args.indexOf('--state-backend');
+ const rawStateBackend = (stateBackendIdx !== -1 && args[stateBackendIdx + 1])
+ ? args[stateBackendIdx + 1]
+ : undefined;
+ const validBackends = ['local', 'orphan', 'two-layer', 'external', 'external-stub'] as const;
+ if (rawStateBackend && !(validBackends as readonly string[]).includes(rawStateBackend)) {
+ console.error(`\u26a0\ufe0f Invalid --state-backend "${rawStateBackend}". Valid: ${validBackends.join(', ')}.`);
+ process.exit(1);
+ }
+ // Legacy 'external' is normalized (with a deprecation warning) inside resolveStateBackend.
+ const mappedBackend = rawStateBackend as StateBackendType | undefined;
+
+ // Resolve the full state context (paths + backend) once at entry.
+ // Commands can thread this through instead of re-resolving independently.
+ const stateContext: SquadStateContext | null = resolveSquadState(getSquadStartDir(), mappedBackend);
+
+ // Build capability overrides from CLI flags and --no-{cap} flags
+ const capabilities: Record> = {};
+ const registry = createDefaultRegistry();
+ for (const cap of registry.all()) {
+ if (args.includes(`--${cap.name}`)) capabilities[cap.name] = true;
+ if (args.includes(`--no-${cap.name}`)) capabilities[cap.name] = false;
+ }
+
+ // Legacy flag compat: --board-project sets board sub-option
+ const boardProjectIdx = args.indexOf('--board-project');
+ if (boardProjectIdx !== -1 && args[boardProjectIdx + 1]) {
+ const existing = capabilities['board'];
+ capabilities['board'] = typeof existing === 'object' && existing !== null
+ ? { ...existing, projectNumber: parseInt(args[boardProjectIdx + 1]!, 10) }
+ : { projectNumber: parseInt(args[boardProjectIdx + 1]!, 10) };
+ }
+
+ // --board-owner sets the project owner (org or user login)
+ const boardOwnerIdx = args.indexOf('--board-owner');
+ if (boardOwnerIdx !== -1 && args[boardOwnerIdx + 1]) {
+ const existing = capabilities['board'];
+ capabilities['board'] = typeof existing === 'object' && existing !== null
+ ? { ...existing, owner: args[boardOwnerIdx + 1]! }
+ : { owner: args[boardOwnerIdx + 1]! };
+ }
+
+ // Load config: .squad/config.json merged with CLI overrides
+ const config = loadWatchConfig(getSquadStartDir(), {
+ interval,
+ execute,
+ maxConcurrent,
+ timeout,
+ copilotFlags,
+ agentCmd,
+ verbose,
+ dispatchMode,
+ logFile,
+ authUser,
+ notifyLevel,
+ overnightStart,
+ overnightEnd,
+ sentinelFile,
+ stateBackend: mappedBackend,
+ stateContext,
+ capabilities: Object.keys(capabilities).length > 0 ? capabilities : undefined,
+ });
+
+ // After parsing all flags, check for positional args that look like prompts.
+ // Skip values that follow known value-flags (e.g. "--interval 5" → "5" is not positional).
+ const knownValueFlags = new Set([
+ '--interval', '--copilot-flags', '--agent-cmd', '--max-concurrent', '--timeout', '--board-project', '--board-owner', '--auth-user',
+ '--dispatch-mode', '--log-file', '--notify-level', '--overnight-start', '--overnight-end', '--sentinel-file', '--state-backend',
+ ]);
+ const watchArgStart = args.indexOf(cmd) + 1;
+ const watchArgs = args.slice(watchArgStart);
+ const positionalArgs: string[] = [];
+ for (let i = 0; i < watchArgs.length; i++) {
+ const arg = watchArgs[i]!;
+ if (knownValueFlags.has(arg)) { i++; continue; }
+ if (arg.startsWith('-')) continue;
+ positionalArgs.push(arg);
+ }
+ if (positionalArgs.length > 0 && config.verbose) {
+ console.log(`[verbose] ⚠️ Positional args ignored by watch: "${positionalArgs.join(' ')}". Use --execute to process issues.`);
+ }
+
+ await runWatch(getSquadStartDir(), config);
+ return;
+ }
+
+ if (cmd === 'loop') {
+ const { runLoop, generateLoopFile } = await import('./cli/commands/loop.js');
+
+ // --init: scaffold a boilerplate loop.md
+ if (args.includes('--init')) {
+ const fileIdx = args.indexOf('--file');
+ const filePath = (fileIdx !== -1 && args[fileIdx + 1]) ? args[fileIdx + 1]! : 'loop.md';
+ const { FSStorageProvider } = await import('@bradygaster/squad-sdk');
+ const storage = new FSStorageProvider();
+ const pathMod = await import('node:path');
+ const absPath = pathMod.default.resolve(getSquadStartDir(), filePath);
+ if (storage.existsSync(absPath)) {
+ console.log(`⚠️ ${filePath} already exists. Remove it first to regenerate.`);
+ } else {
+ storage.writeSync(absPath, generateLoopFile());
+ console.log(`✅ Created ${filePath} — open it and set \`configured: true\` to activate.`);
+ }
+ return;
+ }
+
+ // Parse flags
+ const fileIdx = args.indexOf('--file');
+ const filePath = (fileIdx !== -1 && args[fileIdx + 1]) ? args[fileIdx + 1] : undefined;
+
+ const intervalIdx = args.indexOf('--interval');
+ const interval = (intervalIdx !== -1 && args[intervalIdx + 1])
+ ? parseInt(args[intervalIdx + 1]!, 10)
+ : undefined;
+
+ const timeoutIdx = args.indexOf('--timeout');
+ const timeout = (timeoutIdx !== -1 && args[timeoutIdx + 1])
+ ? parseInt(args[timeoutIdx + 1]!, 10)
+ : undefined;
+
+ const copilotFlagsIdx = args.indexOf('--copilot-flags');
+ const copilotFlags = (copilotFlagsIdx !== -1 && args[copilotFlagsIdx + 1])
+ ? args[copilotFlagsIdx + 1]
+ : undefined;
+
+ const agentCmdIdx = args.indexOf('--agent-cmd');
+ const agentCmd = (agentCmdIdx !== -1 && args[agentCmdIdx + 1])
+ ? args[agentCmdIdx + 1]
+ : undefined;
+
+ // Capability flags
+ const { createDefaultRegistry: createReg } = await import('./cli/commands/watch/index.js');
+ const reg = createReg();
+ const capabilities: Record> = {};
+ for (const cap of reg.all()) {
+ if (args.includes(`--${cap.name}`)) capabilities[cap.name] = true;
+ if (args.includes(`--no-${cap.name}`)) capabilities[cap.name] = false;
+ }
+
+ await runLoop(getSquadStartDir(), {
+ filePath,
+ interval,
+ timeout,
+ copilotFlags,
+ agentCmd,
+ capabilities,
+ });
+ return;
+ }
+
+ if (cmd === 'cast' || cmd === 'hire') {
+ const nameIdx = args.indexOf('--name');
+ const name = (nameIdx !== -1 && args[nameIdx + 1]) ? args[nameIdx + 1] : undefined;
+ const roleIdx = args.indexOf('--role');
+ const role = (roleIdx !== -1 && args[roleIdx + 1]) ? args[roleIdx + 1] : undefined;
+
+ // `squad cast` with no wizard flags shows the roster; `squad hire` always runs the wizard
+ if (cmd === 'cast' && !name && !role) {
+ const { runCast } = await import('./cli/commands/cast.js');
+ await runCast(getSquadStartDir());
+ return;
+ }
+
+ console.log('🎬 Squad cast — team creation wizard starting... (full implementation pending)');
+ if (name) {
+ console.log(` Name: ${name}`);
+ }
+ if (role) {
+ console.log(` Role: ${role}`);
+ }
+ return;
+ }
+
+ if (cmd === 'export') {
+ const { runExport } = await import('./cli/commands/export.js');
+ const outIdx = args.indexOf('--out');
+ const outPath = (outIdx !== -1 && args[outIdx + 1]) ? args[outIdx + 1] : undefined;
+ const repoIdx = args.indexOf('--repo');
+ const repoArg = (repoIdx !== -1 && args[repoIdx + 1]) ? args[repoIdx + 1] : undefined;
+ const branchIdx = args.indexOf('--branch');
+ const branchArg = (branchIdx !== -1 && args[branchIdx + 1]) ? args[branchIdx + 1] : undefined;
+ const repoOptions = repoArg ? { repo: repoArg, branch: branchArg } : undefined;
+ await runExport(getSquadStartDir(), outPath, repoOptions);
+ return;
+ }
+
+ if (cmd === 'import') {
+ const { runImport } = await import('./cli/commands/import.js');
+ const repoIdx = args.indexOf('--repo');
+ const repoArg = (repoIdx !== -1 && args[repoIdx + 1]) ? args[repoIdx + 1] : undefined;
+ const branchIdx = args.indexOf('--branch');
+ const branchArg = (branchIdx !== -1 && args[branchIdx + 1]) ? args[branchIdx + 1] : undefined;
+ const hasForce = args.includes('--force');
+ if (repoArg) {
+ const repoOptions = { repo: repoArg, branch: branchArg };
+ await runImport(getSquadStartDir(), '', hasForce, repoOptions);
+ } else {
+ const importFile = args[1];
+ if (!importFile) {
+ fatal('Usage: squad import [--force] or squad import --repo owner/repo [--branch branch] [--force]');
+ }
+ await runImport(getSquadStartDir(), importFile, hasForce);
+ }
+ return;
+ }
+
+ if (cmd === 'plugin') {
+ const { runPlugin } = await import('./cli/commands/plugin.js');
+ await runPlugin(getSquadStartDir(), args.slice(1));
+ return;
+ }
+
+ if (cmd === 'copilot') {
+ const { runCopilot } = await import('./cli/commands/copilot.js');
+ const isOff = args.includes('--off');
+ const autoAssign = args.includes('--auto-assign');
+ await runCopilot(getSquadStartDir(), { off: isOff, autoAssign });
+ return;
+ }
+
+ if (cmd === 'scrub-emails') {
+ const { scrubEmails } = await import('./cli/core/email-scrub.js');
+ const targetDir = args[1] || '.ai-team';
+ const count = await scrubEmails(targetDir);
+ if (count > 0) {
+ console.log(`Scrubbed ${count} email address(es).`);
+ } else {
+ console.log('No email addresses found.');
+ }
+ return;
+ }
+
+ if (cmd === 'status') {
+ const sdk = await lazySquadSdk();
+ const repoSquad = sdk.resolveSquad(getSquadStartDir());
+ const globalPath = sdk.resolveGlobalSquadPath();
+ const globalSquadDir = path.join(globalPath, '.squad');
+ const storage = new FSStorageProvider();
+ const globalExists = await storage.exists(globalSquadDir);
+
+ console.log(`\n${BOLD}Squad Status${RESET}\n`);
+
+ if (repoSquad) {
+ console.log(` Active squad: ${BOLD}repo${RESET}`);
+ console.log(` Path: ${repoSquad}`);
+ console.log(` Reason: Found .squad/ in repository tree`);
+ } else if (globalExists) {
+ console.log(` Active squad: ${BOLD}personal (global)${RESET}`);
+ console.log(` Path: ${globalSquadDir}`);
+ console.log(` Reason: No repo .squad/ found; personal squad exists at global path`);
+ } else {
+ console.log(` Active squad: ${DIM}none${RESET}`);
+ console.log(` Reason: No .squad/ found in repo tree or at global path`);
+ }
+
+ console.log();
+ console.log(` ${DIM}Repo resolution: ${repoSquad ?? 'not found'}${RESET}`);
+ console.log(` ${DIM}Global path: ${globalPath}${RESET}`);
+ console.log(` ${DIM}Global squad: ${globalExists ? globalSquadDir : 'not initialized'}${RESET}`);
+ console.log();
+
+ return;
+ }
+
+ if (cmd === 'roles') {
+ const { runRoles } = await import('./cli/commands/roles.js');
+ await runRoles(args.slice(1));
+ return;
+ }
+
+ if (cmd === 'cost') {
+ const sdk = await lazySquadSdk();
+ const localSquad = sdk.resolveSquad(getSquadStartDir());
+ const globalPath = sdk.resolveGlobalSquadPath();
+ const globalSquadDir = path.join(globalPath, '.squad');
+ const storage = new FSStorageProvider();
+ const teamRoot = localSquad
+ ? path.resolve(localSquad, '..')
+ : (await storage.exists(globalSquadDir) ? globalPath : null);
+
+ if (!teamRoot) {
+ fatal('No squad found. Run "squad init" first.');
+ }
+
+ await runCost(args.slice(1), teamRoot);
+ return;
+ }
+
+ if (cmd === 'build') {
+ const { runBuild } = await import('./cli/commands/build.js');
+ const hasCheck = args.includes('--check');
+ const hasDryRun = args.includes('--dry-run');
+ const hasWatch = args.includes('--watch');
+ await runBuild(getSquadStartDir(), { check: hasCheck, dryRun: hasDryRun, watch: hasWatch });
+ return;
+ }
+
+ if (cmd === 'subsquads' || cmd === 'workstreams' || cmd === 'streams') {
+ const { runSubSquads } = await import('./cli/commands/streams.js');
+ await runSubSquads(getSquadStartDir(), args.slice(1));
+ return;
+ }
+
+ if (cmd === 'start') {
+ console.log(`\n${YELLOW}⚠ DEPRECATED:${RESET} "squad start" is deprecated and will be removed in a future release.`);
+ console.log(` Use the GitHub Copilot CLI directly: ${BOLD}gh copilot${RESET}\n`);
+ const { runStart } = await import('./cli/commands/start.js');
+ const hasTunnel = args.includes('--tunnel');
+ const portIdx = args.indexOf('--port');
+ const port = (portIdx !== -1 && args[portIdx + 1]) ? parseInt(args[portIdx + 1]!, 10) : 0;
+ // Collect all remaining args to pass through to copilot
+ const cmdIdx = args.indexOf('--command');
+ const customCmd = (cmdIdx !== -1 && args[cmdIdx + 1]) ? args[cmdIdx + 1] : undefined;
+ const squadFlags = ['start', '--tunnel', '--port', port.toString(), '--command', customCmd || ''].filter(Boolean);
+ const copilotArgs = args.slice(1).filter(a => !squadFlags.includes(a));
+ await runStart(getSquadStartDir(), { tunnel: hasTunnel, port, copilotArgs, command: customCmd });
+ return;
+ }
+
+ if (cmd === 'nap') {
+ const { runNap, formatNapReport } = await import('./cli/core/nap.js');
+ const sdk = await lazySquadSdk();
+ const startDir = getSquadStartDir();
+ // resolveSquad() returns the .squad/ directory itself — use it directly (#207)
+ const squadDir = sdk.resolveSquad(startDir);
+ if (!squadDir) {
+ fatal(`No squad found (searched from ${startDir}). Run "squad init" first, or use --team-root to specify the project directory.`);
+ }
+ const deep = args.includes('--deep');
+ const dryRun = args.includes('--dry-run');
+ const result = await runNap({ squadDir, deep, dryRun });
+ console.log(formatNapReport(result, !!process.env['NO_COLOR']));
+ return;
+ }
+
+ if (cmd === 'doctor') {
+ const { doctorCommand } = await import('./cli/commands/doctor.js');
+ await doctorCommand();
+ return;
+ }
+
+ if (cmd === 'consult') {
+ const { runConsult } = await import('./cli/commands/consult.js');
+ await runConsult(getSquadStartDir(), args.slice(1));
+ return;
+ }
+
+ if (cmd === 'extract') {
+ const { runExtract } = await import('./cli/commands/extract.js');
+ await runExtract(getSquadStartDir(), args.slice(1));
+ return;
+ }
+
+ if (cmd === 'aspire') {
+ const { runAspire } = await import('./cli/commands/aspire.js');
+ const useDocker = args.includes('--docker');
+ const portIdx = args.indexOf('--port');
+ const port = (portIdx !== -1 && args[portIdx + 1]) ? parseInt(args[portIdx + 1]!, 10) : undefined;
+ await runAspire({ docker: useDocker, port });
+ return;
}
- if (cmd === 'sync') {
- const { runSync } = await import('./cli/commands/sync.js');
- const quiet = args.includes('--quiet');
- const remoteIdx = args.indexOf('--remote');
- const remote = (remoteIdx !== -1 && args[remoteIdx + 1]) ? args[remoteIdx + 1] : undefined;
- let direction: 'push' | 'pull' | 'both' = 'both';
- if (args.includes('--push') && !args.includes('--pull')) direction = 'push';
- else if (args.includes('--pull') && !args.includes('--push')) direction = 'pull';
- try {
- await runSync({ direction, remote, cwd: getSquadStartDir(), quiet });
- } catch (err: unknown) {
- if (!quiet) {
- const msg = err instanceof Error ? err.message : String(err);
- console.error(`squad sync failed: ${msg}`);
- }
- process.exit(1);
- }
- return;
- }
-
- if (cmd === 'migrate') {
- const { runMigrate } = await import('./cli/commands/migrate.js');
- const toIdx = args.indexOf('--to');
- const to = (toIdx !== -1 && args[toIdx + 1]) ? args[toIdx + 1] as 'sdk' | 'markdown' : undefined;
- const fromIdx = args.indexOf('--from');
- const from = (fromIdx !== -1 && args[fromIdx + 1]) ? args[fromIdx + 1] : undefined;
- const dryRun = args.includes('--dry-run');
- await runMigrate(getSquadStartDir(), { to, from: from as 'ai-team' | undefined, dryRun });
- return;
- }
-
- // --health flag: show watch instance status and exit
- if (cmd === 'watch' && args.includes('--health')) {
- const { getWatchHealth } = await import('./cli/commands/watch/health.js');
- console.log(getWatchHealth(getSquadStartDir()));
- return;
- }
-
- if (cmd === 'triage' || cmd === 'watch') {
- const { runWatch, loadWatchConfig, createDefaultRegistry } = await import('./cli/commands/watch/index.js');
-
- // Parse core flags
- const intervalIdx = args.indexOf('--interval');
- const interval = (intervalIdx !== -1 && args[intervalIdx + 1])
- ? parseInt(args[intervalIdx + 1]!, 10)
- : undefined;
-
- const execute = args.includes('--execute') ? true : undefined;
-
- const verbose = args.includes('--verbose') || args.includes('-v');
-
- const copilotFlagsIdx = args.indexOf('--copilot-flags');
- const copilotFlags = (copilotFlagsIdx !== -1 && args[copilotFlagsIdx + 1])
- ? args[copilotFlagsIdx + 1]
- : undefined;
-
- const agentCmdIdx = args.indexOf('--agent-cmd');
- const agentCmd = (agentCmdIdx !== -1 && args[agentCmdIdx + 1])
- ? args[agentCmdIdx + 1]
- : undefined;
-
- const maxConcurrentIdx = args.indexOf('--max-concurrent');
- const maxConcurrent = (maxConcurrentIdx !== -1 && args[maxConcurrentIdx + 1])
- ? parseInt(args[maxConcurrentIdx + 1]!, 10)
- : undefined;
-
- const timeoutIdx = args.indexOf('--timeout');
- const timeout = (timeoutIdx !== -1 && args[timeoutIdx + 1])
- ? parseInt(args[timeoutIdx + 1]!, 10)
- : undefined;
-
- // --dispatch-mode runtime validation: rejects invalid values with a clear error message
- const dispatchModeIdx = args.indexOf('--dispatch-mode');
- const rawDispatchMode = (dispatchModeIdx !== -1 && args[dispatchModeIdx + 1])
- ? args[dispatchModeIdx + 1]
- : undefined;
- const validModes = ['task', 'fleet', 'hybrid'] as const;
- const dispatchMode = rawDispatchMode && validModes.includes(rawDispatchMode as any)
- ? rawDispatchMode as 'fleet' | 'task' | 'hybrid'
- : rawDispatchMode
- ? (console.error(`⚠️ Invalid --dispatch-mode "${rawDispatchMode}". Valid: task, fleet, hybrid. Defaulting to task.`), undefined)
- : undefined;
-
- const logFileIdx = args.indexOf('--log-file');
- const logFile = (logFileIdx !== -1 && args[logFileIdx + 1])
- ? args[logFileIdx + 1]
- : undefined;
-
- const authUserIdx = args.indexOf('--auth-user');
- const authUser = (authUserIdx !== -1 && args[authUserIdx + 1])
- ? args[authUserIdx + 1]
- : undefined;
-
- // --notify-level runtime validation
- const notifyLevelIdx = args.indexOf('--notify-level');
- const rawNotifyLevel = (notifyLevelIdx !== -1 && args[notifyLevelIdx + 1])
- ? args[notifyLevelIdx + 1]
- : undefined;
- const validNotifyLevels = ['all', 'important', 'none'] as const;
- const notifyLevel = rawNotifyLevel && (validNotifyLevels as readonly string[]).includes(rawNotifyLevel)
- ? rawNotifyLevel as typeof validNotifyLevels[number]
- : rawNotifyLevel
- ? (console.error(`\u26a0\ufe0f Invalid --notify-level "${rawNotifyLevel}". Valid: all, important, none.`), undefined)
- : undefined;
-
- const overnightStartIdx = args.indexOf('--overnight-start');
- const overnightStart = (overnightStartIdx !== -1 && args[overnightStartIdx + 1])
- ? args[overnightStartIdx + 1]
- : undefined;
-
- const overnightEndIdx = args.indexOf('--overnight-end');
- const overnightEnd = (overnightEndIdx !== -1 && args[overnightEndIdx + 1])
- ? args[overnightEndIdx + 1]
- : undefined;
-
- const sentinelFileIdx = args.indexOf('--sentinel-file');
- const sentinelFile = (sentinelFileIdx !== -1 && args[sentinelFileIdx + 1])
- ? args[sentinelFileIdx + 1]
- : undefined;
-
- // --state-backend runtime validation: reject invalid values upfront
- const stateBackendIdx = args.indexOf('--state-backend');
- const rawStateBackend = (stateBackendIdx !== -1 && args[stateBackendIdx + 1])
- ? args[stateBackendIdx + 1]
- : undefined;
- const validBackends = ['local', 'orphan', 'two-layer', 'external', 'external-stub'] as const;
- if (rawStateBackend && !(validBackends as readonly string[]).includes(rawStateBackend)) {
- console.error(`\u26a0\ufe0f Invalid --state-backend "${rawStateBackend}". Valid: ${validBackends.join(', ')}.`);
- process.exit(1);
- }
- // Legacy 'external' is normalized (with a deprecation warning) inside resolveStateBackend.
- const mappedBackend = rawStateBackend as StateBackendType | undefined;
-
- // Resolve the full state context (paths + backend) once at entry.
- // Commands can thread this through instead of re-resolving independently.
- const stateContext: SquadStateContext | null = resolveSquadState(getSquadStartDir(), mappedBackend);
-
- // Build capability overrides from CLI flags and --no-{cap} flags
- const capabilities: Record> = {};
- const registry = createDefaultRegistry();
- for (const cap of registry.all()) {
- if (args.includes(`--${cap.name}`)) capabilities[cap.name] = true;
- if (args.includes(`--no-${cap.name}`)) capabilities[cap.name] = false;
- }
-
- // Legacy flag compat: --board-project sets board sub-option
- const boardProjectIdx = args.indexOf('--board-project');
- if (boardProjectIdx !== -1 && args[boardProjectIdx + 1]) {
- const existing = capabilities['board'];
- capabilities['board'] = typeof existing === 'object' && existing !== null
- ? { ...existing, projectNumber: parseInt(args[boardProjectIdx + 1]!, 10) }
- : { projectNumber: parseInt(args[boardProjectIdx + 1]!, 10) };
- }
-
- // --board-owner sets the project owner (org or user login)
- const boardOwnerIdx = args.indexOf('--board-owner');
- if (boardOwnerIdx !== -1 && args[boardOwnerIdx + 1]) {
- const existing = capabilities['board'];
- capabilities['board'] = typeof existing === 'object' && existing !== null
- ? { ...existing, owner: args[boardOwnerIdx + 1]! }
- : { owner: args[boardOwnerIdx + 1]! };
- }
-
- // Load config: .squad/config.json merged with CLI overrides
- const config = loadWatchConfig(getSquadStartDir(), {
- interval,
- execute,
- maxConcurrent,
- timeout,
- copilotFlags,
- agentCmd,
- verbose,
- dispatchMode,
- logFile,
- authUser,
- notifyLevel,
- overnightStart,
- overnightEnd,
- sentinelFile,
- stateBackend: mappedBackend,
- stateContext,
- capabilities: Object.keys(capabilities).length > 0 ? capabilities : undefined,
- });
-
- // After parsing all flags, check for positional args that look like prompts.
- // Skip values that follow known value-flags (e.g. "--interval 5" → "5" is not positional).
- const knownValueFlags = new Set([
- '--interval', '--copilot-flags', '--agent-cmd', '--max-concurrent', '--timeout', '--board-project', '--board-owner', '--auth-user',
- '--dispatch-mode', '--log-file', '--notify-level', '--overnight-start', '--overnight-end', '--sentinel-file', '--state-backend',
- ]);
- const watchArgStart = args.indexOf(cmd) + 1;
- const watchArgs = args.slice(watchArgStart);
- const positionalArgs: string[] = [];
- for (let i = 0; i < watchArgs.length; i++) {
- const arg = watchArgs[i]!;
- if (knownValueFlags.has(arg)) { i++; continue; }
- if (arg.startsWith('-')) continue;
- positionalArgs.push(arg);
- }
- if (positionalArgs.length > 0 && config.verbose) {
- console.log(`[verbose] ⚠️ Positional args ignored by watch: "${positionalArgs.join(' ')}". Use --execute to process issues.`);
- }
-
- await runWatch(getSquadStartDir(), config);
- return;
- }
-
- if (cmd === 'loop') {
- const { runLoop, generateLoopFile } = await import('./cli/commands/loop.js');
-
- // --init: scaffold a boilerplate loop.md
- if (args.includes('--init')) {
- const fileIdx = args.indexOf('--file');
- const filePath = (fileIdx !== -1 && args[fileIdx + 1]) ? args[fileIdx + 1]! : 'loop.md';
- const { FSStorageProvider } = await import('@bradygaster/squad-sdk');
- const storage = new FSStorageProvider();
- const pathMod = await import('node:path');
- const absPath = pathMod.default.resolve(getSquadStartDir(), filePath);
- if (storage.existsSync(absPath)) {
- console.log(`⚠️ ${filePath} already exists. Remove it first to regenerate.`);
- } else {
- storage.writeSync(absPath, generateLoopFile());
- console.log(`✅ Created ${filePath} — open it and set \`configured: true\` to activate.`);
- }
- return;
- }
-
- // Parse flags
- const fileIdx = args.indexOf('--file');
- const filePath = (fileIdx !== -1 && args[fileIdx + 1]) ? args[fileIdx + 1] : undefined;
-
- const intervalIdx = args.indexOf('--interval');
- const interval = (intervalIdx !== -1 && args[intervalIdx + 1])
- ? parseInt(args[intervalIdx + 1]!, 10)
- : undefined;
-
- const timeoutIdx = args.indexOf('--timeout');
- const timeout = (timeoutIdx !== -1 && args[timeoutIdx + 1])
- ? parseInt(args[timeoutIdx + 1]!, 10)
- : undefined;
-
- const copilotFlagsIdx = args.indexOf('--copilot-flags');
- const copilotFlags = (copilotFlagsIdx !== -1 && args[copilotFlagsIdx + 1])
- ? args[copilotFlagsIdx + 1]
- : undefined;
-
- const agentCmdIdx = args.indexOf('--agent-cmd');
- const agentCmd = (agentCmdIdx !== -1 && args[agentCmdIdx + 1])
- ? args[agentCmdIdx + 1]
- : undefined;
-
- // Capability flags
- const { createDefaultRegistry: createReg } = await import('./cli/commands/watch/index.js');
- const reg = createReg();
- const capabilities: Record> = {};
- for (const cap of reg.all()) {
- if (args.includes(`--${cap.name}`)) capabilities[cap.name] = true;
- if (args.includes(`--no-${cap.name}`)) capabilities[cap.name] = false;
- }
-
- await runLoop(getSquadStartDir(), {
- filePath,
- interval,
- timeout,
- copilotFlags,
- agentCmd,
- capabilities,
- });
- return;
- }
-
- if (cmd === 'cast' || cmd === 'hire') {
- const nameIdx = args.indexOf('--name');
- const name = (nameIdx !== -1 && args[nameIdx + 1]) ? args[nameIdx + 1] : undefined;
- const roleIdx = args.indexOf('--role');
- const role = (roleIdx !== -1 && args[roleIdx + 1]) ? args[roleIdx + 1] : undefined;
-
- // `squad cast` with no wizard flags shows the roster; `squad hire` always runs the wizard
- if (cmd === 'cast' && !name && !role) {
- const { runCast } = await import('./cli/commands/cast.js');
- await runCast(getSquadStartDir());
- return;
- }
-
- console.log('🎬 Squad cast — team creation wizard starting... (full implementation pending)');
- if (name) {
- console.log(` Name: ${name}`);
- }
- if (role) {
- console.log(` Role: ${role}`);
- }
- return;
- }
-
- if (cmd === 'export') {
- const { runExport } = await import('./cli/commands/export.js');
- const outIdx = args.indexOf('--out');
- const outPath = (outIdx !== -1 && args[outIdx + 1]) ? args[outIdx + 1] : undefined;
- const repoIdx = args.indexOf('--repo');
- const repoArg = (repoIdx !== -1 && args[repoIdx + 1]) ? args[repoIdx + 1] : undefined;
- const branchIdx = args.indexOf('--branch');
- const branchArg = (branchIdx !== -1 && args[branchIdx + 1]) ? args[branchIdx + 1] : undefined;
- const repoOptions = repoArg ? { repo: repoArg, branch: branchArg } : undefined;
- await runExport(getSquadStartDir(), outPath, repoOptions);
- return;
- }
-
- if (cmd === 'import') {
- const { runImport } = await import('./cli/commands/import.js');
- const repoIdx = args.indexOf('--repo');
- const repoArg = (repoIdx !== -1 && args[repoIdx + 1]) ? args[repoIdx + 1] : undefined;
- const branchIdx = args.indexOf('--branch');
- const branchArg = (branchIdx !== -1 && args[branchIdx + 1]) ? args[branchIdx + 1] : undefined;
- const hasForce = args.includes('--force');
- if (repoArg) {
- const repoOptions = { repo: repoArg, branch: branchArg };
- await runImport(getSquadStartDir(), '', hasForce, repoOptions);
- } else {
- const importFile = args[1];
- if (!importFile) {
- fatal('Usage: squad import [--force] or squad import --repo owner/repo [--branch branch] [--force]');
- }
- await runImport(getSquadStartDir(), importFile, hasForce);
- }
- return;
- }
-
- if (cmd === 'plugin') {
- const { runPlugin } = await import('./cli/commands/plugin.js');
- await runPlugin(getSquadStartDir(), args.slice(1));
- return;
- }
-
- if (cmd === 'copilot') {
- const { runCopilot } = await import('./cli/commands/copilot.js');
- const isOff = args.includes('--off');
- const autoAssign = args.includes('--auto-assign');
- await runCopilot(getSquadStartDir(), { off: isOff, autoAssign });
- return;
- }
-
- if (cmd === 'scrub-emails') {
- const { scrubEmails } = await import('./cli/core/email-scrub.js');
- const targetDir = args[1] || '.ai-team';
- const count = await scrubEmails(targetDir);
- if (count > 0) {
- console.log(`Scrubbed ${count} email address(es).`);
- } else {
- console.log('No email addresses found.');
- }
- return;
- }
-
- if (cmd === 'status') {
- const sdk = await lazySquadSdk();
- const repoSquad = sdk.resolveSquad(getSquadStartDir());
- const globalPath = sdk.resolveGlobalSquadPath();
- const globalSquadDir = path.join(globalPath, '.squad');
- const storage = new FSStorageProvider();
- const globalExists = await storage.exists(globalSquadDir);
-
- console.log(`\n${BOLD}Squad Status${RESET}\n`);
-
- if (repoSquad) {
- console.log(` Active squad: ${BOLD}repo${RESET}`);
- console.log(` Path: ${repoSquad}`);
- console.log(` Reason: Found .squad/ in repository tree`);
- } else if (globalExists) {
- console.log(` Active squad: ${BOLD}personal (global)${RESET}`);
- console.log(` Path: ${globalSquadDir}`);
- console.log(` Reason: No repo .squad/ found; personal squad exists at global path`);
- } else {
- console.log(` Active squad: ${DIM}none${RESET}`);
- console.log(` Reason: No .squad/ found in repo tree or at global path`);
- }
-
- console.log();
- console.log(` ${DIM}Repo resolution: ${repoSquad ?? 'not found'}${RESET}`);
- console.log(` ${DIM}Global path: ${globalPath}${RESET}`);
- console.log(` ${DIM}Global squad: ${globalExists ? globalSquadDir : 'not initialized'}${RESET}`);
- console.log();
-
- return;
- }
-
- if (cmd === 'roles') {
- const { runRoles } = await import('./cli/commands/roles.js');
- await runRoles(args.slice(1));
- return;
- }
-
- if (cmd === 'cost') {
- const sdk = await lazySquadSdk();
- const localSquad = sdk.resolveSquad(getSquadStartDir());
- const globalPath = sdk.resolveGlobalSquadPath();
- const globalSquadDir = path.join(globalPath, '.squad');
- const storage = new FSStorageProvider();
- const teamRoot = localSquad
- ? path.resolve(localSquad, '..')
- : (await storage.exists(globalSquadDir) ? globalPath : null);
-
- if (!teamRoot) {
- fatal('No squad found. Run "squad init" first.');
- }
-
- await runCost(args.slice(1), teamRoot);
- return;
- }
-
- if (cmd === 'build') {
- const { runBuild } = await import('./cli/commands/build.js');
- const hasCheck = args.includes('--check');
- const hasDryRun = args.includes('--dry-run');
- const hasWatch = args.includes('--watch');
- await runBuild(getSquadStartDir(), { check: hasCheck, dryRun: hasDryRun, watch: hasWatch });
- return;
- }
-
- if (cmd === 'subsquads' || cmd === 'workstreams' || cmd === 'streams') {
- const { runSubSquads } = await import('./cli/commands/streams.js');
- await runSubSquads(getSquadStartDir(), args.slice(1));
- return;
- }
-
- if (cmd === 'start') {
- console.log(`\n${YELLOW}⚠ DEPRECATED:${RESET} "squad start" is deprecated and will be removed in a future release.`);
- console.log(` Use the GitHub Copilot CLI directly: ${BOLD}gh copilot${RESET}\n`);
- const { runStart } = await import('./cli/commands/start.js');
- const hasTunnel = args.includes('--tunnel');
- const portIdx = args.indexOf('--port');
- const port = (portIdx !== -1 && args[portIdx + 1]) ? parseInt(args[portIdx + 1]!, 10) : 0;
- // Collect all remaining args to pass through to copilot
- const cmdIdx = args.indexOf('--command');
- const customCmd = (cmdIdx !== -1 && args[cmdIdx + 1]) ? args[cmdIdx + 1] : undefined;
- const squadFlags = ['start', '--tunnel', '--port', port.toString(), '--command', customCmd || ''].filter(Boolean);
- const copilotArgs = args.slice(1).filter(a => !squadFlags.includes(a));
- await runStart(getSquadStartDir(), { tunnel: hasTunnel, port, copilotArgs, command: customCmd });
- return;
- }
-
- if (cmd === 'nap') {
- const { runNap, formatNapReport } = await import('./cli/core/nap.js');
- const sdk = await lazySquadSdk();
- const startDir = getSquadStartDir();
- // resolveSquad() returns the .squad/ directory itself — use it directly (#207)
- const squadDir = sdk.resolveSquad(startDir);
- if (!squadDir) {
- fatal(`No squad found (searched from ${startDir}). Run "squad init" first, or use --team-root to specify the project directory.`);
- }
- const deep = args.includes('--deep');
- const dryRun = args.includes('--dry-run');
- const result = await runNap({ squadDir, deep, dryRun });
- console.log(formatNapReport(result, !!process.env['NO_COLOR']));
- return;
- }
-
- if (cmd === 'doctor') {
- const { doctorCommand } = await import('./cli/commands/doctor.js');
- await doctorCommand();
- return;
- }
-
- if (cmd === 'consult') {
- const { runConsult } = await import('./cli/commands/consult.js');
- await runConsult(getSquadStartDir(), args.slice(1));
- return;
- }
-
- if (cmd === 'extract') {
- const { runExtract } = await import('./cli/commands/extract.js');
- await runExtract(getSquadStartDir(), args.slice(1));
- return;
- }
-
- if (cmd === 'aspire') {
- const { runAspire } = await import('./cli/commands/aspire.js');
- const useDocker = args.includes('--docker');
- const portIdx = args.indexOf('--port');
- const port = (portIdx !== -1 && args[portIdx + 1]) ? parseInt(args[portIdx + 1]!, 10) : undefined;
- await runAspire({ docker: useDocker, port });
- return;
- }
-
- if (cmd === 'link') {
- const { runLink } = await import('./cli/commands/link.js');
- const teamPath = args[1];
- if (!teamPath) {
- fatal('Usage: squad link ');
- }
- runLink(getSquadStartDir(), teamPath);
- return;
- }
-
- if (cmd === 'externalize') {
- const { runExternalize } = await import('./cli/commands/externalize.js');
- const rawKey = args.includes('--key') ? args[args.indexOf('--key') + 1] : undefined;
- const projectKey = rawKey ? rawKey.replace(/[\/\\\.]/g, '_') : undefined;
- runExternalize(process.cwd(), projectKey);
- return;
- }
-
- if (cmd === 'internalize') {
- const { runInternalize } = await import('./cli/commands/externalize.js');
- runInternalize(process.cwd());
- return;
- }
-
- if (cmd === 'rc' || cmd === 'remote-control') {
- console.log(`\n${YELLOW}⚠ DEPRECATED:${RESET} "squad rc" is deprecated and will be removed in a future release.`);
- console.log(` Use the GitHub Copilot CLI directly: ${BOLD}gh copilot${RESET}\n`);
- const { runRC } = await import('./cli/commands/rc.js');
- const hasTunnel = args.includes('--tunnel');
- const portIdx = args.indexOf('--port');
- const port = (portIdx !== -1 && args[portIdx + 1]) ? parseInt(args[portIdx + 1]!, 10) : 0;
- const pathIdx = args.indexOf('--path');
- const rcPath = (pathIdx !== -1 && args[pathIdx + 1]) ? args[pathIdx + 1] : undefined;
- await runRC(rcPath || getSquadStartDir(), { tunnel: hasTunnel, port });
- return;
- }
-
- if (cmd === 'copilot-bridge') {
- const { CopilotBridge } = await import('./cli/commands/copilot-bridge.js');
- const result = await CopilotBridge.checkCompatibility();
- if (result.compatible) {
- console.log(`${GREEN}✓${RESET} ${result.message}`);
- } else {
- console.log(`${YELLOW}⚠${RESET} ${result.message}`);
- }
- return;
- }
-
- if (cmd === 'init-remote') {
- const { writeRemoteConfig } = await import('./cli/commands/init-remote.js');
- const teamPath = args[1];
- if (!teamPath) {
- fatal('Usage: squad init-remote ');
- }
- const dest = process.cwd();
- writeRemoteConfig(dest, teamPath);
- await runInit(dest);
- return;
- }
-
- if (cmd === 'rc-tunnel') {
- const { isDevtunnelAvailable } = await import('./cli/commands/rc-tunnel.js');
- if (isDevtunnelAvailable()) {
- console.log(`${GREEN}✓${RESET} devtunnel CLI is available`);
- } else {
- console.log(`${YELLOW}⚠${RESET} devtunnel CLI not found. Install with: winget install Microsoft.devtunnel`);
- }
- return;
- }
-
- if (cmd === 'schedule') {
- const { runSchedule } = await import('./cli/commands/schedule.js');
- const subcommand = args[1] || 'list';
- await runSchedule(getSquadStartDir(), subcommand, args.slice(2));
- return;
- }
-
- if (cmd === 'personal') {
- const { runPersonal } = await import('./cli/commands/personal.js');
- const subcommand = args[1] || 'list';
- await runPersonal(getSquadStartDir(), subcommand, args.slice(2));
- return;
- }
-
- if (cmd === 'preset') {
- const { runPreset } = await import('./cli/commands/preset.js');
- const subcommand = args[1] || 'list';
- await runPreset(getSquadStartDir(), subcommand, args.slice(2));
- return;
- }
-
- if (cmd === 'skill') {
- const { runSkill } = await import('./cli/commands/skill.js');
- await runSkill(getSquadStartDir(), args.slice(1));
- return;
- }
-
- if (cmd === 'upstream') {
- const { upstreamCommand } = await import('./cli/commands/upstream.js');
- await upstreamCommand(args.slice(1));
- return;
- }
-
- if (cmd === 'discover') {
- const { discoverCommand } = await import('./cli/commands/cross-squad.js');
- await discoverCommand();
- return;
- }
-
- if (cmd === 'delegate') {
- const { delegateCommand } = await import('./cli/commands/cross-squad.js');
- await delegateCommand(args.slice(1));
- return;
- }
-
- if (cmd === 'registry') {
- const { registryCommand } = await import('./cli/commands/cross-squad.js');
- await registryCommand(args.slice(1));
- return;
- }
-
- if (cmd === 'economy') {
- const { runEconomy } = await import('./cli/commands/economy.js');
- await runEconomy(getSquadStartDir(), args.slice(1));
- return;
- }
-
- if (cmd === 'models') {
- const { runModels } = await import('./cli/commands/models.js');
- await runModels(getSquadStartDir(), args.slice(1));
- return;
- }
-
- if (cmd === 'notes') {
- const { runNotes } = await import('./cli/commands/notes.js');
- await runNotes(getSquadStartDir(), args.slice(1));
- return;
- }
-
- if (cmd === 'config') {
- const { runConfig } = await import('./cli/commands/config.js');
- await runConfig(getSquadStartDir(), args.slice(1));
- return;
- }
-
- // Unknown command
- fatal(`Unknown command: ${cmd}\n Run 'squad doctor' to check your setup, or 'squad help' for usage information.`);
-}
-
-main().catch(err => {
- if (err instanceof SquadError) {
- console.error(`${RED}✗${RESET} ${err.message}`);
- } else {
- console.error(err);
- }
- process.exit(1);
-});
-
-
-
+ if (cmd === 'link') {
+ const { runLink } = await import('./cli/commands/link.js');
+ const teamPath = args[1];
+ if (!teamPath) {
+ fatal('Usage: squad link ');
+ }
+ runLink(getSquadStartDir(), teamPath);
+ return;
+ }
+
+ if (cmd === 'externalize') {
+ const { runExternalize } = await import('./cli/commands/externalize.js');
+ const rawKey = args.includes('--key') ? args[args.indexOf('--key') + 1] : undefined;
+ const projectKey = rawKey ? rawKey.replace(/[\/\\\.]/g, '_') : undefined;
+ runExternalize(process.cwd(), projectKey);
+ return;
+ }
+
+ if (cmd === 'internalize') {
+ const { runInternalize } = await import('./cli/commands/externalize.js');
+ runInternalize(process.cwd());
+ return;
+ }
+
+ if (cmd === 'rc' || cmd === 'remote-control') {
+ console.log(`\n${YELLOW}⚠ DEPRECATED:${RESET} "squad rc" is deprecated and will be removed in a future release.`);
+ console.log(` Use the GitHub Copilot CLI directly: ${BOLD}gh copilot${RESET}\n`);
+ const { runRC } = await import('./cli/commands/rc.js');
+ const hasTunnel = args.includes('--tunnel');
+ const portIdx = args.indexOf('--port');
+ const port = (portIdx !== -1 && args[portIdx + 1]) ? parseInt(args[portIdx + 1]!, 10) : 0;
+ const pathIdx = args.indexOf('--path');
+ const rcPath = (pathIdx !== -1 && args[pathIdx + 1]) ? args[pathIdx + 1] : undefined;
+ await runRC(rcPath || getSquadStartDir(), { tunnel: hasTunnel, port });
+ return;
+ }
+
+ if (cmd === 'copilot-bridge') {
+ const { CopilotBridge } = await import('./cli/commands/copilot-bridge.js');
+ const result = await CopilotBridge.checkCompatibility();
+ if (result.compatible) {
+ console.log(`${GREEN}✓${RESET} ${result.message}`);
+ } else {
+ console.log(`${YELLOW}⚠${RESET} ${result.message}`);
+ }
+ return;
+ }
+
+ if (cmd === 'init-remote') {
+ const { writeRemoteConfig } = await import('./cli/commands/init-remote.js');
+ const teamPath = args[1];
+ if (!teamPath) {
+ fatal('Usage: squad init-remote ');
+ }
+ const dest = process.cwd();
+ writeRemoteConfig(dest, teamPath);
+ await runInit(dest);
+ return;
+ }
+
+ if (cmd === 'rc-tunnel') {
+ const { isDevtunnelAvailable } = await import('./cli/commands/rc-tunnel.js');
+ if (isDevtunnelAvailable()) {
+ console.log(`${GREEN}✓${RESET} devtunnel CLI is available`);
+ } else {
+ console.log(`${YELLOW}⚠${RESET} devtunnel CLI not found. Install with: winget install Microsoft.devtunnel`);
+ }
+ return;
+ }
+
+ if (cmd === 'schedule') {
+ const { runSchedule } = await import('./cli/commands/schedule.js');
+ const subcommand = args[1] || 'list';
+ await runSchedule(getSquadStartDir(), subcommand, args.slice(2));
+ return;
+ }
+
+ if (cmd === 'personal') {
+ const { runPersonal } = await import('./cli/commands/personal.js');
+ const subcommand = args[1] || 'list';
+ await runPersonal(getSquadStartDir(), subcommand, args.slice(2));
+ return;
+ }
+
+ if (cmd === 'preset') {
+ const { runPreset } = await import('./cli/commands/preset.js');
+ const subcommand = args[1] || 'list';
+ await runPreset(getSquadStartDir(), subcommand, args.slice(2));
+ return;
+ }
+
+ if (cmd === 'skill') {
+ const { runSkill } = await import('./cli/commands/skill.js');
+ await runSkill(getSquadStartDir(), args.slice(1));
+ return;
+ }
+
+ if (cmd === 'upstream') {
+ const { upstreamCommand } = await import('./cli/commands/upstream.js');
+ await upstreamCommand(args.slice(1));
+ return;
+ }
+
+ if (cmd === 'discover') {
+ const { discoverCommand } = await import('./cli/commands/cross-squad.js');
+ await discoverCommand();
+ return;
+ }
+
+ if (cmd === 'delegate') {
+ const { delegateCommand } = await import('./cli/commands/cross-squad.js');
+ await delegateCommand(args.slice(1));
+ return;
+ }
+
+ if (cmd === 'registry') {
+ const { registryCommand } = await import('./cli/commands/cross-squad.js');
+ await registryCommand(args.slice(1));
+ return;
+ }
+
+ if (cmd === 'economy') {
+ const { runEconomy } = await import('./cli/commands/economy.js');
+ await runEconomy(getSquadStartDir(), args.slice(1));
+ return;
+ }
+
+ if (cmd === 'models') {
+ const { runModels } = await import('./cli/commands/models.js');
+ await runModels(getSquadStartDir(), args.slice(1));
+ return;
+ }
+
+ if (cmd === 'notes') {
+ const { runNotes } = await import('./cli/commands/notes.js');
+ await runNotes(getSquadStartDir(), args.slice(1));
+ return;
+ }
+
+ if (cmd === 'config') {
+ const { runConfig } = await import('./cli/commands/config.js');
+ await runConfig(getSquadStartDir(), args.slice(1));
+ return;
+ }
+
+ // Unknown command
+ fatal(`Unknown command: ${cmd}\n Run 'squad doctor' to check your setup, or 'squad help' for usage information.`);
+}
+
+main().catch(err => {
+ if (err instanceof SquadError) {
+ console.error(`${RED}✗${RESET} ${err.message}`);
+ } else {
+ console.error(err);
+ }
+ process.exit(1);
+});
+
+
+
diff --git a/packages/squad-cli/templates/ralph-triage.js b/packages/squad-cli/templates/ralph-triage.js
index 76edc90d1..863da3d7d 100644
--- a/packages/squad-cli/templates/ralph-triage.js
+++ b/packages/squad-cli/templates/ralph-triage.js
@@ -1,567 +1,567 @@
-#!/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 slugify(text) { return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); }
-
-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:${slugify(name)}`,
- });
- }
-
- 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);
-}
-
-/**
- * Resolve the GitHub REST API base URL from the environment, so triage
- * works on GitHub Enterprise as well as github.com.
- *
- * Order: GITHUB_API_URL (set by Actions on both github.com and GHE runners)
- * > GITHUB_SERVER_URL + /api/v3 (GHE without an explicit API URL)
- * > https://api.github.com (fallback for local/non-Actions runs).
- */
-function resolveGithubApiBase() {
- const apiUrl = process.env.GITHUB_API_URL;
- if (apiUrl) return apiUrl.replace(/\/+$/, '');
-
- const serverUrl = process.env.GITHUB_SERVER_URL;
- if (serverUrl) return `${serverUrl.replace(/\/+$/, '')}/api/v3`;
-
- return 'https://api.github.com';
-}
-
-function githubRequestJson(pathname, token) {
- return new Promise((resolve, reject) => {
- const requestUrl = new URL(`${resolveGithubApiBase()}${pathname}`);
- const req = https.request(
- requestUrl,
- {
- method: 'GET',
- 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');
-}
-
-if (require.main === module) {
- main().catch((error) => {
- console.error(error.message);
- process.exit(1);
- });
-}
-
-module.exports = { resolveGithubApiBase };
+#!/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 slugify(text) { return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); }
+
+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:${slugify(name)}`,
+ });
+ }
+
+ 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);
+}
+
+/**
+ * Resolve the GitHub REST API base URL from the environment, so triage
+ * works on GitHub Enterprise as well as github.com.
+ *
+ * Order: GITHUB_API_URL (set by Actions on both github.com and GHE runners)
+ * > GITHUB_SERVER_URL + /api/v3 (GHE without an explicit API URL)
+ * > https://api.github.com (fallback for local/non-Actions runs).
+ */
+function resolveGithubApiBase() {
+ const apiUrl = process.env.GITHUB_API_URL;
+ if (apiUrl) return apiUrl.replace(/\/+$/, '');
+
+ const serverUrl = process.env.GITHUB_SERVER_URL;
+ if (serverUrl) return `${serverUrl.replace(/\/+$/, '')}/api/v3`;
+
+ return 'https://api.github.com';
+}
+
+function githubRequestJson(pathname, token) {
+ return new Promise((resolve, reject) => {
+ const requestUrl = new URL(`${resolveGithubApiBase()}${pathname}`);
+ const req = https.request(
+ requestUrl,
+ {
+ method: 'GET',
+ 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');
+}
+
+if (require.main === module) {
+ main().catch((error) => {
+ console.error(error.message);
+ process.exit(1);
+ });
+}
+
+module.exports = { resolveGithubApiBase };
diff --git a/packages/squad-sdk/templates/ralph-triage.js b/packages/squad-sdk/templates/ralph-triage.js
index 76edc90d1..863da3d7d 100644
--- a/packages/squad-sdk/templates/ralph-triage.js
+++ b/packages/squad-sdk/templates/ralph-triage.js
@@ -1,567 +1,567 @@
-#!/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 slugify(text) { return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); }
-
-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:${slugify(name)}`,
- });
- }
-
- 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);
-}
-
-/**
- * Resolve the GitHub REST API base URL from the environment, so triage
- * works on GitHub Enterprise as well as github.com.
- *
- * Order: GITHUB_API_URL (set by Actions on both github.com and GHE runners)
- * > GITHUB_SERVER_URL + /api/v3 (GHE without an explicit API URL)
- * > https://api.github.com (fallback for local/non-Actions runs).
- */
-function resolveGithubApiBase() {
- const apiUrl = process.env.GITHUB_API_URL;
- if (apiUrl) return apiUrl.replace(/\/+$/, '');
-
- const serverUrl = process.env.GITHUB_SERVER_URL;
- if (serverUrl) return `${serverUrl.replace(/\/+$/, '')}/api/v3`;
-
- return 'https://api.github.com';
-}
-
-function githubRequestJson(pathname, token) {
- return new Promise((resolve, reject) => {
- const requestUrl = new URL(`${resolveGithubApiBase()}${pathname}`);
- const req = https.request(
- requestUrl,
- {
- method: 'GET',
- 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');
-}
-
-if (require.main === module) {
- main().catch((error) => {
- console.error(error.message);
- process.exit(1);
- });
-}
-
-module.exports = { resolveGithubApiBase };
+#!/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 slugify(text) { return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); }
+
+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:${slugify(name)}`,
+ });
+ }
+
+ 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);
+}
+
+/**
+ * Resolve the GitHub REST API base URL from the environment, so triage
+ * works on GitHub Enterprise as well as github.com.
+ *
+ * Order: GITHUB_API_URL (set by Actions on both github.com and GHE runners)
+ * > GITHUB_SERVER_URL + /api/v3 (GHE without an explicit API URL)
+ * > https://api.github.com (fallback for local/non-Actions runs).
+ */
+function resolveGithubApiBase() {
+ const apiUrl = process.env.GITHUB_API_URL;
+ if (apiUrl) return apiUrl.replace(/\/+$/, '');
+
+ const serverUrl = process.env.GITHUB_SERVER_URL;
+ if (serverUrl) return `${serverUrl.replace(/\/+$/, '')}/api/v3`;
+
+ return 'https://api.github.com';
+}
+
+function githubRequestJson(pathname, token) {
+ return new Promise((resolve, reject) => {
+ const requestUrl = new URL(`${resolveGithubApiBase()}${pathname}`);
+ const req = https.request(
+ requestUrl,
+ {
+ method: 'GET',
+ 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');
+}
+
+if (require.main === module) {
+ main().catch((error) => {
+ console.error(error.message);
+ process.exit(1);
+ });
+}
+
+module.exports = { resolveGithubApiBase };
diff --git a/samples/storage-provider-azure/scripts/create-storage.sh b/samples/storage-provider-azure/scripts/create-storage.sh
index a1161d903..a65a6a16f 100644
--- a/samples/storage-provider-azure/scripts/create-storage.sh
+++ b/samples/storage-provider-azure/scripts/create-storage.sh
@@ -1,67 +1,67 @@
-#!/usr/bin/env bash
-# Creates an Azure Storage account for the StorageProvider demo.
-# Returns the connection string for use with the demo scripts.
-#
-# Usage:
-# ./scripts/create-storage.sh [resource-group] [location]
-#
-# Defaults:
-# resource-group: squad-storage-demo-rg
-# location: eastus2
-
-set -euo pipefail
-
-RG="${1:-squad-storage-demo-rg}"
-LOCATION="${2:-eastus2}"
-# Generate a unique storage account name (max 24 chars, lowercase alphanumeric)
-ACCOUNT="squaddemo$(date +%s | tail -c 8)"
-
-echo "══════════════════════════════════════════════"
-echo " Azure Storage — Create for Squad Demo"
-echo "══════════════════════════════════════════════"
-echo ""
-echo " Resource Group: $RG"
-echo " Location: $LOCATION"
-echo " Storage Account: $ACCOUNT"
-echo ""
-
-# Create resource group
-echo "── Creating resource group ────────────────────"
-az group create --name "$RG" --location "$LOCATION" --output none
-echo " ✓ Resource group: $RG"
-
-# Create storage account (Standard_LRS, blob public access disabled for security)
-echo ""
-echo "── Creating storage account ─────────────────────"
-az storage account create \
- --name "$ACCOUNT" \
- --resource-group "$RG" \
- --location "$LOCATION" \
- --sku Standard_LRS \
- --kind StorageV2 \
- --allow-blob-public-access false \
- --output none
-echo " ✓ Storage account: $ACCOUNT"
-
-# Get connection string
-echo ""
-echo "── Retrieving connection string ─────────────────"
-CONNECTION_STRING=$(az storage account show-connection-string \
- --name "$ACCOUNT" \
- --resource-group "$RG" \
- --query connectionString \
- --output tsv)
-
-echo " ✓ Connection string retrieved"
-echo ""
-echo "══════════════════════════════════════════════"
-echo " DONE! Set this environment variable:"
-echo ""
-echo " export AZURE_STORAGE_CONNECTION_STRING=\"$CONNECTION_STRING\""
-echo ""
-echo " Then run the demo:"
-echo " npm run demo"
-echo ""
-echo " To clean up later:"
-echo " ./scripts/delete-storage.sh $RG"
-echo "══════════════════════════════════════════════"
+#!/usr/bin/env bash
+# Creates an Azure Storage account for the StorageProvider demo.
+# Returns the connection string for use with the demo scripts.
+#
+# Usage:
+# ./scripts/create-storage.sh [resource-group] [location]
+#
+# Defaults:
+# resource-group: squad-storage-demo-rg
+# location: eastus2
+
+set -euo pipefail
+
+RG="${1:-squad-storage-demo-rg}"
+LOCATION="${2:-eastus2}"
+# Generate a unique storage account name (max 24 chars, lowercase alphanumeric)
+ACCOUNT="squaddemo$(date +%s | tail -c 8)"
+
+echo "══════════════════════════════════════════════"
+echo " Azure Storage — Create for Squad Demo"
+echo "══════════════════════════════════════════════"
+echo ""
+echo " Resource Group: $RG"
+echo " Location: $LOCATION"
+echo " Storage Account: $ACCOUNT"
+echo ""
+
+# Create resource group
+echo "── Creating resource group ────────────────────"
+az group create --name "$RG" --location "$LOCATION" --output none
+echo " ✓ Resource group: $RG"
+
+# Create storage account (Standard_LRS, blob public access disabled for security)
+echo ""
+echo "── Creating storage account ─────────────────────"
+az storage account create \
+ --name "$ACCOUNT" \
+ --resource-group "$RG" \
+ --location "$LOCATION" \
+ --sku Standard_LRS \
+ --kind StorageV2 \
+ --allow-blob-public-access false \
+ --output none
+echo " ✓ Storage account: $ACCOUNT"
+
+# Get connection string
+echo ""
+echo "── Retrieving connection string ─────────────────"
+CONNECTION_STRING=$(az storage account show-connection-string \
+ --name "$ACCOUNT" \
+ --resource-group "$RG" \
+ --query connectionString \
+ --output tsv)
+
+echo " ✓ Connection string retrieved"
+echo ""
+echo "══════════════════════════════════════════════"
+echo " DONE! Set this environment variable:"
+echo ""
+echo " export AZURE_STORAGE_CONNECTION_STRING=\"$CONNECTION_STRING\""
+echo ""
+echo " Then run the demo:"
+echo " npm run demo"
+echo ""
+echo " To clean up later:"
+echo " ./scripts/delete-storage.sh $RG"
+echo "══════════════════════════════════════════════"
diff --git a/samples/storage-provider-azure/scripts/delete-storage.sh b/samples/storage-provider-azure/scripts/delete-storage.sh
index 7dd29a96f..77ae8bbc0 100644
--- a/samples/storage-provider-azure/scripts/delete-storage.sh
+++ b/samples/storage-provider-azure/scripts/delete-storage.sh
@@ -1,28 +1,28 @@
-#!/usr/bin/env bash
-# Deletes the Azure resource group and all resources within it.
-#
-# Usage:
-# ./scripts/delete-storage.sh [resource-group]
-#
-# Default:
-# resource-group: squad-storage-demo-rg
-
-set -euo pipefail
-
-RG="${1:-squad-storage-demo-rg}"
-
-echo "══════════════════════════════════════════════"
-echo " Azure Storage — Delete Squad Demo Resources"
-echo "══════════════════════════════════════════════"
-echo ""
-echo " Resource Group: $RG"
-echo ""
-
-echo "── Deleting resource group (and all contents) ──"
-az group delete --name "$RG" --yes --no-wait
-echo " ✓ Deletion started (--no-wait). Resources will be removed in the background."
-echo ""
-echo " To verify deletion:"
-echo " az group show --name $RG 2>/dev/null || echo 'Deleted'"
-echo ""
-echo "══════════════════════════════════════════════"
+#!/usr/bin/env bash
+# Deletes the Azure resource group and all resources within it.
+#
+# Usage:
+# ./scripts/delete-storage.sh [resource-group]
+#
+# Default:
+# resource-group: squad-storage-demo-rg
+
+set -euo pipefail
+
+RG="${1:-squad-storage-demo-rg}"
+
+echo "══════════════════════════════════════════════"
+echo " Azure Storage — Delete Squad Demo Resources"
+echo "══════════════════════════════════════════════"
+echo ""
+echo " Resource Group: $RG"
+echo ""
+
+echo "── Deleting resource group (and all contents) ──"
+az group delete --name "$RG" --yes --no-wait
+echo " ✓ Deletion started (--no-wait). Resources will be removed in the background."
+echo ""
+echo " To verify deletion:"
+echo " az group show --name $RG 2>/dev/null || echo 'Deleted'"
+echo ""
+echo "══════════════════════════════════════════════"
diff --git a/scripts/capture-pr-screenshots.mjs b/scripts/capture-pr-screenshots.mjs
index cea989a5c..851f8b1c1 100644
--- a/scripts/capture-pr-screenshots.mjs
+++ b/scripts/capture-pr-screenshots.mjs
@@ -1,42 +1,42 @@
-import { chromium } from 'playwright';
-import { mkdirSync } from 'node:fs';
-
-const BASE = 'http://localhost:4321/squad/docs/reference/api';
-const SCREENSHOT_DIR = 'docs/tests/screenshots';
-
-const pages = [
- { slug: '', name: 'api-landing', label: 'API Reference Landing' },
- { slug: '/class-runtimeeventbus', name: 'api-class', label: 'Class — RuntimeEventBus' },
- { slug: '/interface-agentcapability', name: 'api-interface', label: 'Interface — AgentCapability' },
- { slug: '/function-definesquad', name: 'api-function', label: 'Function — defineSquad' },
- { slug: '/typealias-agentref', name: 'api-typealias', label: 'Type Alias — AgentRef' },
- { slug: '/variable-default_fallback_chains', name: 'api-variable', label: 'Variable — DEFAULT_FALLBACK_CHAINS' },
-];
-
-try {
- // Ensure screenshot output directory exists
- mkdirSync(SCREENSHOT_DIR, { recursive: true });
-
- // Verify dev server is running before attempting screenshots
- const healthCheck = await fetch(BASE).catch(() => null);
- if (!healthCheck || !healthCheck.ok) {
- console.error(`❌ Dev server not reachable at ${BASE}. Start it with: npm run docs:dev`);
- process.exit(1);
- }
-
- const browser = await chromium.launch();
- const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
-
- for (const { slug, name, label } of pages) {
- const url = `${BASE}${slug}`;
- console.log(`📸 ${label}: ${url}`);
- await page.goto(url, { waitUntil: 'networkidle', timeout: 60000 });
- await page.screenshot({ path: `${SCREENSHOT_DIR}/${name}.png` });
- }
-
- await browser.close();
- console.log(`\n✅ Captured ${pages.length} screenshots`);
-} catch (err) {
- console.error('❌ Screenshot capture failed:', err.message);
- process.exit(1);
-}
+import { chromium } from 'playwright';
+import { mkdirSync } from 'node:fs';
+
+const BASE = 'http://localhost:4321/squad/docs/reference/api';
+const SCREENSHOT_DIR = 'docs/tests/screenshots';
+
+const pages = [
+ { slug: '', name: 'api-landing', label: 'API Reference Landing' },
+ { slug: '/class-runtimeeventbus', name: 'api-class', label: 'Class — RuntimeEventBus' },
+ { slug: '/interface-agentcapability', name: 'api-interface', label: 'Interface — AgentCapability' },
+ { slug: '/function-definesquad', name: 'api-function', label: 'Function — defineSquad' },
+ { slug: '/typealias-agentref', name: 'api-typealias', label: 'Type Alias — AgentRef' },
+ { slug: '/variable-default_fallback_chains', name: 'api-variable', label: 'Variable — DEFAULT_FALLBACK_CHAINS' },
+];
+
+try {
+ // Ensure screenshot output directory exists
+ mkdirSync(SCREENSHOT_DIR, { recursive: true });
+
+ // Verify dev server is running before attempting screenshots
+ const healthCheck = await fetch(BASE).catch(() => null);
+ if (!healthCheck || !healthCheck.ok) {
+ console.error(`❌ Dev server not reachable at ${BASE}. Start it with: npm run docs:dev`);
+ process.exit(1);
+ }
+
+ const browser = await chromium.launch();
+ const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
+
+ for (const { slug, name, label } of pages) {
+ const url = `${BASE}${slug}`;
+ console.log(`📸 ${label}: ${url}`);
+ await page.goto(url, { waitUntil: 'networkidle', timeout: 60000 });
+ await page.screenshot({ path: `${SCREENSHOT_DIR}/${name}.png` });
+ }
+
+ await browser.close();
+ console.log(`\n✅ Captured ${pages.length} screenshots`);
+} catch (err) {
+ console.error('❌ Screenshot capture failed:', err.message);
+ process.exit(1);
+}
diff --git a/scripts/check-shebang-eol.mjs b/scripts/check-shebang-eol.mjs
new file mode 100644
index 000000000..ed107be84
--- /dev/null
+++ b/scripts/check-shebang-eol.mjs
@@ -0,0 +1,196 @@
+#!/usr/bin/env node
+// check-shebang-eol.mjs -- Every tracked file starting with `#!` must be pinned to LF.
+//
+// A CRLF shebang is never correct. The trailing \r becomes part of the
+// interpreter argument on POSIX ("env: node\r: No such file or directory"), and
+// it defeats bundler shebang stripping -- Vite leaves a bare `#` as the module's
+// first token, so a vitest suite importing the file loads ZERO tests while still
+// looking like ordinary noise (#1788).
+//
+// This lint exists because .gitattributes encoded that lesson for `*.sh` in one
+// PR and for `*.mjs` in another without ever generalizing, so we paid for it
+// twice. It checks the RULE (git check-attr says eol=lf) and the REALITY (the
+// committed blob's first line does not end in \r) independently -- a rule that
+// was added without renormalizing is still a broken repo.
+//
+// SCOPE BOUNDARY -- read this before assuming a green run means "no EOL bugs".
+// There are two CRLF failure classes and this lint covers exactly one:
+//
+// 1. STRICT PARSE (covered). The file fails to load. `#!` is a cheap, exact,
+// static signature, so the check is sound and has no false negatives.
+//
+// 2. TOOL REWRITE (NOT covered). A tool writes the file with LF, the checkout
+// has CRLF, and the tree is perpetually dirty -- so a broad `git add`
+// commits line-ending noise, or worse, sweeps in an unrelated real change
+// sitting in the same file. Vitest snapshots (`*.snap`) are the known case;
+// pinned by an explicit .gitattributes rule, NOT found by this lint.
+//
+// Class 2 has no cheap static signature -- "files some tool writes with LF" is
+// not detectable by reading the file. Extending this lint to guess would trade a
+// sound check for an unsound one, so the boundary is deliberate. New generated
+// or tool-written file types must be pinned in .gitattributes by hand.
+//
+// Uses only Node.js built-ins (child_process, path, url).
+
+import { execFileSync } from 'node:child_process';
+import { pathToFileURL } from 'node:url';
+
+const NUL = '\0';
+
+/** Run git and return stdout as a Buffer, or throw with the stderr attached. */
+function git(args, cwd, encoding = 'utf8') {
+ return execFileSync('git', args, {
+ cwd,
+ encoding,
+ maxBuffer: 1 << 28,
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+}
+
+/** Every path tracked by git in `cwd`. */
+export function listTrackedFiles(cwd) {
+ return git(['ls-files', '-z'], cwd).split(NUL).filter(Boolean);
+}
+
+/**
+ * Read many blobs with a single `git cat-file --batch` process.
+ *
+ * The obvious implementation spawns one `git cat-file` per tracked file, which
+ * costs ~70s on Windows for a repo this size and blows vitest's hook timeout.
+ * `--batch` preserves input order, so results correlate by index.
+ */
+function batchReadBlobs(cwd, paths) {
+ if (paths.length === 0) return [];
+ const out = execFileSync('git', ['cat-file', '--batch', '--buffer'], {
+ cwd,
+ input: `${paths.map((p) => `:${p}`).join('\n')}\n`,
+ maxBuffer: 1 << 30,
+ stdio: ['pipe', 'pipe', 'pipe'],
+ });
+
+ const blobs = [];
+ let offset = 0;
+ while (offset < out.length && blobs.length < paths.length) {
+ const nl = out.indexOf(0x0a, offset);
+ if (nl === -1) break;
+ const header = out.subarray(offset, nl).toString('utf8');
+ offset = nl + 1;
+ const match = /^\S+ \S+ (\d+)$/.exec(header);
+ if (!match) {
+ blobs.push(null); // "missing" / "ambiguous" -- header line only
+ continue;
+ }
+ const size = Number(match[1]);
+ blobs.push(out.subarray(offset, offset + size));
+ offset += size + 1; // skip the blob's trailing LF
+ }
+ while (blobs.length < paths.length) blobs.push(null);
+ return blobs;
+}
+
+/**
+ * Paths whose staged blob begins with `#!`, each flagged if its first line ends
+ * in CR.
+ *
+ * Reads the index (`git cat-file :path`) rather than the working tree or HEAD.
+ * The working tree is whatever the local checkout smudged it into; HEAD lags a
+ * pending `git add --renormalize`. The index is what is actually about to be
+ * committed, and in a fresh CI checkout it equals HEAD -- so this is correct
+ * both as a pre-commit check and as a CI gate.
+ */
+export function listShebangFiles(cwd, files = listTrackedFiles(cwd)) {
+ const blobs = batchReadBlobs(cwd, files);
+ const found = [];
+ for (let i = 0; i < files.length; i += 1) {
+ const blob = blobs[i];
+ if (!blob || blob.length < 2 || blob[0] !== 0x23 || blob[1] !== 0x21) continue;
+ const lf = blob.indexOf(0x0a);
+ const firstLine = lf === -1 ? blob : blob.subarray(0, lf);
+ found.push({
+ file: files[i],
+ crlf: firstLine.length > 0 && firstLine[firstLine.length - 1] === 0x0d,
+ });
+ }
+ return found;
+}
+
+/** `git check-attr eol` for each path, as a Map. */
+export function eolAttributes(cwd, files) {
+ if (files.length === 0) return new Map();
+ const out = git(['check-attr', '-z', 'eol', '--', ...files], cwd).split(NUL);
+ const map = new Map();
+ // -z output is a flat stream of (path, attr, value) triples.
+ for (let i = 0; i + 2 < out.length; i += 3) {
+ map.set(out[i], out[i + 2]);
+ }
+ return map;
+}
+
+/**
+ * Pure decision step, separated so tests can drive it with synthetic input.
+ * Returns one violation per problem, not per file, so a file can report both.
+ */
+export function findViolations(shebangFiles, attrs) {
+ const violations = [];
+ for (const { file, crlf } of shebangFiles) {
+ const eol = attrs.get(file);
+ if (eol !== 'lf') {
+ violations.push({
+ file,
+ kind: 'unpinned',
+ detail: `no .gitattributes rule pins it to LF (git check-attr eol = ${eol ?? 'unspecified'})`,
+ });
+ }
+ if (crlf) {
+ violations.push({
+ file,
+ kind: 'crlf-blob',
+ detail: 'the committed blob stores a CRLF shebang line',
+ });
+ }
+ }
+ return violations;
+}
+
+/** Full scan of a repository. */
+export function scan(cwd) {
+ const shebangFiles = listShebangFiles(cwd);
+ const attrs = eolAttributes(cwd, shebangFiles.map((entry) => entry.file));
+ return { shebangFiles, violations: findViolations(shebangFiles, attrs) };
+}
+
+function main() {
+ const cwd = process.cwd();
+ const { shebangFiles, violations } = scan(cwd);
+
+ if (violations.length === 0) {
+ console.log(`Shebang EOL check passed: all ${shebangFiles.length} shebanged files are pinned to LF.`);
+ process.exit(0);
+ }
+
+ const unpinned = violations.filter((v) => v.kind === 'unpinned');
+ const crlfBlobs = violations.filter((v) => v.kind === 'crlf-blob');
+
+ console.error(
+ `Shebang EOL check FAILED: ${violations.length} problem(s) across ${new Set(violations.map((v) => v.file)).size} of ${shebangFiles.length} shebanged file(s).`,
+ );
+ console.error('A CRLF shebang breaks POSIX execution and bundler shebang stripping.\n');
+ for (const { file, kind, detail } of violations) {
+ console.error(` ${kind.toUpperCase().padEnd(10)} ${file} -- ${detail}`);
+ }
+ console.error('\nTo fix:');
+ if (unpinned.length > 0) {
+ console.error(' 1. Add an eol=lf rule to .gitattributes covering the paths listed as UNPINNED.');
+ console.error(' Prefer an extension rule (e.g. "*.mjs text eol=lf"); use an explicit path when');
+ console.error(' a blanket rule would renormalize files unrelated to the shebang.');
+ }
+ if (crlfBlobs.length > 0) {
+ console.error(` ${unpinned.length > 0 ? '2' : '1'}. Run: git add --renormalize -- (a rule alone does not rewrite committed blobs)`);
+ }
+ console.error('\nDo NOT "fix" this by deleting the shebang -- these files are invoked directly.');
+ process.exit(1);
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ main();
+}
diff --git a/scripts/generate-api-docs.mjs b/scripts/generate-api-docs.mjs
index 4b8023465..ef1890015 100644
--- a/scripts/generate-api-docs.mjs
+++ b/scripts/generate-api-docs.mjs
@@ -1,160 +1,160 @@
-/**
- * Generate API reference markdown from the Squad SDK source using TypeDoc.
- *
- * Usage:
- * node scripts/generate-api-docs.mjs
- *
- * Outputs markdown to docs/src/content/docs/reference/api/.
- * Runs TypeDoc, then normalizes filenames for Astro compatibility and
- * replaces the generated index with a curated landing page.
- */
-
-import { execSync } from 'node:child_process';
-import { writeFileSync, readdirSync, readFileSync, renameSync, unlinkSync, mkdirSync, existsSync } from 'node:fs';
-import { join, dirname } from 'node:path';
-import { fileURLToPath } from 'node:url';
-
-const __dirname = dirname(fileURLToPath(import.meta.url));
-const ROOT = join(__dirname, '..');
-const SDK_DIR = join(ROOT, 'packages', 'squad-sdk');
-const API_OUT = join(ROOT, 'docs', 'src', 'content', 'docs', 'reference', 'api');
-
-/** Convert a TypeDoc filename to an Astro-safe slug (lowercase, dots → dashes). */
-function toSafeFilename(original) {
- // Class.RuntimeEventBus.md → class-runtimeeventbus.md
- return original.toLowerCase().replace(/\./g, '-').replace(/-md$/, '.md');
-}
-
-// Ensure output directory exists (fresh clones may not have it)
-mkdirSync(API_OUT, { recursive: true });
-
-// Resolve TypeDoc from either the workspace package or the hoisted root install.
-const typedocExecutable = process.platform === 'win32' ? 'typedoc.cmd' : 'typedoc';
-const typedocBin = [
- join(SDK_DIR, 'node_modules', '.bin', typedocExecutable),
- join(ROOT, 'node_modules', '.bin', typedocExecutable),
-].find(existsSync);
-
-if (!typedocBin) {
- console.error('❌ TypeDoc not found. Run npm install before generating API docs.');
- process.exit(1);
-}
-
-// Step 1 — Run TypeDoc
-console.log('⚙️ Running TypeDoc…');
-try {
- execSync(typedocBin, { cwd: SDK_DIR, stdio: 'inherit' });
-} catch (err) {
- console.error(`❌ TypeDoc failed. Command: ${typedocBin} (cwd: ${SDK_DIR})`);
- console.error(err.message);
- process.exit(1);
-}
-
-// Step 2 — Rename files and build rename map
-const rawFiles = readdirSync(API_OUT).filter(f => f.endsWith('.md') && f !== 'index.md');
-const renameMap = new Map(); // old filename → new filename
-
-for (const oldName of rawFiles) {
- const newName = toSafeFilename(oldName);
- renameMap.set(oldName, newName);
- if (oldName !== newName) {
- renameSync(join(API_OUT, oldName), join(API_OUT, newName));
- }
-}
-
-// Step 3 — Rewrite internal links in all generated files
-const renamedFiles = readdirSync(API_OUT).filter(f => f.endsWith('.md') && f !== 'index.md');
-for (const file of renamedFiles) {
- const filePath = join(API_OUT, file);
- let content = readFileSync(filePath, 'utf-8');
- let changed = false;
-
- for (const [oldName, newName] of renameMap) {
- if (content.includes(oldName)) {
- content = content.replaceAll(oldName, newName);
- changed = true;
- }
- }
-
- if (changed) {
- writeFileSync(filePath, content);
- }
-}
-
-// Step 4 — Collect files by category
-const categories = {
- Classes: [],
- Interfaces: [],
- Functions: [],
- 'Type aliases': [],
- Variables: [],
-};
-
-for (const file of renamedFiles) {
- const content = readFileSync(join(API_OUT, file), 'utf-8');
- const headingMatch = content.match(/^#\s+\w+:\s+(.+)$/m);
- const name = headingMatch ? headingMatch[1] : file.replace(/-/g, '.').replace(/\.md$/, '');
-
- if (file.startsWith('class-')) categories.Classes.push({ name, file });
- else if (file.startsWith('interface-')) categories.Interfaces.push({ name, file });
- else if (file.startsWith('function-')) categories.Functions.push({ name, file });
- else if (file.startsWith('typealias-')) categories['Type aliases'].push({ name, file });
- else if (file.startsWith('variable-')) categories.Variables.push({ name, file });
-}
-
-for (const cat of Object.values(categories)) {
- cat.sort((a, b) => a.name.localeCompare(b.name));
-}
-
-// Step 5 — Write curated landing page
-const classCount = categories.Classes.length;
-const ifaceCount = categories.Interfaces.length;
-const fnCount = categories.Functions.length;
-const typeCount = categories['Type aliases'].length;
-const varCount = categories.Variables.length;
-const total = classCount + ifaceCount + fnCount + typeCount + varCount;
-
-let landing = `---
-title: SDK API reference
-description: Auto-generated TypeScript API reference for @bradygaster/squad-sdk.
----
-
-# SDK API reference
-
-> ⚠️ **Experimental** — Squad is alpha software. APIs may change between releases.
-
-Auto-generated from the \`@bradygaster/squad-sdk\` TypeScript source using [TypeDoc](https://typedoc.org/).
-This reference covers **${total}** public exports: ${classCount} classes, ${ifaceCount} interfaces, ${fnCount} functions, ${typeCount} type aliases, and ${varCount} variables.
-
-To regenerate, run:
-
-\`\`\`bash
-npm run docs:api
-\`\`\`
-
----
-
-`;
-
-for (const [title, items] of Object.entries(categories)) {
- if (items.length === 0) continue;
- landing += `## ${title}\n\n`;
- for (const { name, file } of items) {
- landing += `- [${name}](${file})\n`;
- }
- landing += '\n';
-}
-
-landing += `---
-
-## See also
-
-- [SDK quick reference](/squad/docs/reference/sdk) — curated examples for common SDK usage
-- [Tools & hooks](/squad/docs/reference/tools-and-hooks) — custom tools and hook pipeline
-- [Config reference](/squad/docs/reference/config) — configuration file options
-`;
-
-// Remove the TypeDoc-generated index.md before writing ours
-try { unlinkSync(join(API_OUT, 'index.md')); } catch { /* noop */ }
-writeFileSync(join(API_OUT, 'index.md'), landing);
-console.log(`✅ API reference generated — ${total} exports across ${renamedFiles.length} pages`);
+/**
+ * Generate API reference markdown from the Squad SDK source using TypeDoc.
+ *
+ * Usage:
+ * node scripts/generate-api-docs.mjs
+ *
+ * Outputs markdown to docs/src/content/docs/reference/api/.
+ * Runs TypeDoc, then normalizes filenames for Astro compatibility and
+ * replaces the generated index with a curated landing page.
+ */
+
+import { execSync } from 'node:child_process';
+import { writeFileSync, readdirSync, readFileSync, renameSync, unlinkSync, mkdirSync, existsSync } from 'node:fs';
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const ROOT = join(__dirname, '..');
+const SDK_DIR = join(ROOT, 'packages', 'squad-sdk');
+const API_OUT = join(ROOT, 'docs', 'src', 'content', 'docs', 'reference', 'api');
+
+/** Convert a TypeDoc filename to an Astro-safe slug (lowercase, dots → dashes). */
+function toSafeFilename(original) {
+ // Class.RuntimeEventBus.md → class-runtimeeventbus.md
+ return original.toLowerCase().replace(/\./g, '-').replace(/-md$/, '.md');
+}
+
+// Ensure output directory exists (fresh clones may not have it)
+mkdirSync(API_OUT, { recursive: true });
+
+// Resolve TypeDoc from either the workspace package or the hoisted root install.
+const typedocExecutable = process.platform === 'win32' ? 'typedoc.cmd' : 'typedoc';
+const typedocBin = [
+ join(SDK_DIR, 'node_modules', '.bin', typedocExecutable),
+ join(ROOT, 'node_modules', '.bin', typedocExecutable),
+].find(existsSync);
+
+if (!typedocBin) {
+ console.error('❌ TypeDoc not found. Run npm install before generating API docs.');
+ process.exit(1);
+}
+
+// Step 1 — Run TypeDoc
+console.log('⚙️ Running TypeDoc…');
+try {
+ execSync(typedocBin, { cwd: SDK_DIR, stdio: 'inherit' });
+} catch (err) {
+ console.error(`❌ TypeDoc failed. Command: ${typedocBin} (cwd: ${SDK_DIR})`);
+ console.error(err.message);
+ process.exit(1);
+}
+
+// Step 2 — Rename files and build rename map
+const rawFiles = readdirSync(API_OUT).filter(f => f.endsWith('.md') && f !== 'index.md');
+const renameMap = new Map(); // old filename → new filename
+
+for (const oldName of rawFiles) {
+ const newName = toSafeFilename(oldName);
+ renameMap.set(oldName, newName);
+ if (oldName !== newName) {
+ renameSync(join(API_OUT, oldName), join(API_OUT, newName));
+ }
+}
+
+// Step 3 — Rewrite internal links in all generated files
+const renamedFiles = readdirSync(API_OUT).filter(f => f.endsWith('.md') && f !== 'index.md');
+for (const file of renamedFiles) {
+ const filePath = join(API_OUT, file);
+ let content = readFileSync(filePath, 'utf-8');
+ let changed = false;
+
+ for (const [oldName, newName] of renameMap) {
+ if (content.includes(oldName)) {
+ content = content.replaceAll(oldName, newName);
+ changed = true;
+ }
+ }
+
+ if (changed) {
+ writeFileSync(filePath, content);
+ }
+}
+
+// Step 4 — Collect files by category
+const categories = {
+ Classes: [],
+ Interfaces: [],
+ Functions: [],
+ 'Type aliases': [],
+ Variables: [],
+};
+
+for (const file of renamedFiles) {
+ const content = readFileSync(join(API_OUT, file), 'utf-8');
+ const headingMatch = content.match(/^#\s+\w+:\s+(.+)$/m);
+ const name = headingMatch ? headingMatch[1] : file.replace(/-/g, '.').replace(/\.md$/, '');
+
+ if (file.startsWith('class-')) categories.Classes.push({ name, file });
+ else if (file.startsWith('interface-')) categories.Interfaces.push({ name, file });
+ else if (file.startsWith('function-')) categories.Functions.push({ name, file });
+ else if (file.startsWith('typealias-')) categories['Type aliases'].push({ name, file });
+ else if (file.startsWith('variable-')) categories.Variables.push({ name, file });
+}
+
+for (const cat of Object.values(categories)) {
+ cat.sort((a, b) => a.name.localeCompare(b.name));
+}
+
+// Step 5 — Write curated landing page
+const classCount = categories.Classes.length;
+const ifaceCount = categories.Interfaces.length;
+const fnCount = categories.Functions.length;
+const typeCount = categories['Type aliases'].length;
+const varCount = categories.Variables.length;
+const total = classCount + ifaceCount + fnCount + typeCount + varCount;
+
+let landing = `---
+title: SDK API reference
+description: Auto-generated TypeScript API reference for @bradygaster/squad-sdk.
+---
+
+# SDK API reference
+
+> ⚠️ **Experimental** — Squad is alpha software. APIs may change between releases.
+
+Auto-generated from the \`@bradygaster/squad-sdk\` TypeScript source using [TypeDoc](https://typedoc.org/).
+This reference covers **${total}** public exports: ${classCount} classes, ${ifaceCount} interfaces, ${fnCount} functions, ${typeCount} type aliases, and ${varCount} variables.
+
+To regenerate, run:
+
+\`\`\`bash
+npm run docs:api
+\`\`\`
+
+---
+
+`;
+
+for (const [title, items] of Object.entries(categories)) {
+ if (items.length === 0) continue;
+ landing += `## ${title}\n\n`;
+ for (const { name, file } of items) {
+ landing += `- [${name}](${file})\n`;
+ }
+ landing += '\n';
+}
+
+landing += `---
+
+## See also
+
+- [SDK quick reference](/squad/docs/reference/sdk) — curated examples for common SDK usage
+- [Tools & hooks](/squad/docs/reference/tools-and-hooks) — custom tools and hook pipeline
+- [Config reference](/squad/docs/reference/config) — configuration file options
+`;
+
+// Remove the TypeDoc-generated index.md before writing ours
+try { unlinkSync(join(API_OUT, 'index.md')); } catch { /* noop */ }
+writeFileSync(join(API_OUT, 'index.md'), landing);
+console.log(`✅ API reference generated — ${total} exports across ${renamedFiles.length} pages`);
diff --git a/scripts/pr-readiness.mjs b/scripts/pr-readiness.mjs
index be3ea4b1c..15b275bee 100644
--- a/scripts/pr-readiness.mjs
+++ b/scripts/pr-readiness.mjs
@@ -1,654 +1,654 @@
-/**
- * PR Readiness checks — pure check functions + orchestration.
- *
- * Each check is a pure function that returns { pass, detail }.
- * The `run()` function is the orchestrator: it reads PR context from
- * environment variables, calls the GitHub API via `fetchFn`, runs all
- * checks, builds the checklist markdown, and upserts the PR comment.
- *
- * The workflow invokes this script via `node scripts/pr-readiness.mjs`.
- *
- * Issue: #750
- */
-
-import { fileURLToPath } from 'node:url';
-
-// ---------------------------------------------------------------------------
-// Constants
-// ---------------------------------------------------------------------------
-
-export const COMMENT_MARKER = '';
-
-/** Check-run names belonging to this workflow (filtered from CI checks). */
-export const SELF_CHECK_NAMES = ['readiness', 'PR Readiness Check'];
-
-/** Regex for source files that require a changeset. */
-export const SOURCE_PATTERN = /^packages\/squad-(sdk|cli)\/src\//;
-
-// ---------------------------------------------------------------------------
-// Pure check functions
-// ---------------------------------------------------------------------------
-
-/**
- * Check 1: Single commit.
- * @param {number} commitCount
- * @returns {{ pass: boolean, detail: string }}
- */
-export function checkCommitCount(commitCount) {
- return {
- pass: commitCount === 1,
- detail: commitCount === 1
- ? '1 commit — clean history'
- : `${commitCount} commits — consider squashing before review`,
- };
-}
-
-/**
- * Check 2: Not in draft.
- * @param {boolean} isDraft
- * @returns {{ pass: boolean, detail: string }}
- */
-export function checkDraftStatus(isDraft) {
- return {
- pass: !isDraft,
- detail: isDraft
- ? 'PR is still in draft — mark as ready for review when done'
- : 'Ready for review',
- };
-}
-
-/**
- * Check 3: Branch freshness (up to date with base).
- * @param {number|null} behindBy — null when comparison failed
- * @param {string} baseRef
- * @returns {{ pass: boolean, detail: string }}
- */
-export function checkBranchFreshness(behindBy, baseRef) {
- if (behindBy === null) {
- return { pass: false, detail: 'Could not determine — check manually' };
- }
- const upToDate = behindBy === 0;
- return {
- pass: upToDate,
- detail: upToDate
- ? `Up to date with ${baseRef}`
- : `${baseRef} is ${behindBy} commit(s) ahead — rebase recommended`,
- };
-}
-
-/**
- * Check 4: Copilot review.
- * @param {Array<{ user?: { login?: string }, state?: string, submitted_at?: string, created_at?: string }>} reviews
- * @returns {{ pass: boolean, detail: string }}
- */
-export function checkCopilotReview(reviews) {
- const copilotReviews = (reviews || []).filter(
- (r) => r.user && r.user.login === 'copilot-pull-request-reviewer',
- );
- const latest = copilotReviews.length
- ? copilotReviews.reduce((a, b) => {
- const aTime = new Date(a.submitted_at || a.created_at || 0).getTime();
- const bTime = new Date(b.submitted_at || b.created_at || 0).getTime();
- return bTime > aTime ? b : a;
- })
- : null;
- const approved = latest && latest.state === 'APPROVED';
- return {
- pass: !!approved,
- detail: approved
- ? 'Copilot reviewed and approved'
- : latest
- ? `Copilot reviewed (state: ${latest.state})`
- : 'No Copilot review yet — it may still be processing',
- };
-}
-
-/**
- * Check 5: Changeset present.
- * @param {Array<{ filename: string }>} files — files changed in the PR
- * @param {Array<{ name: string }>} labels — PR labels
- * @returns {{ pass: boolean, detail: string }}
- */
-export function checkChangeset(files, labels) {
- const hasChangeset = files.some(
- (f) => f.filename.startsWith('.changeset/') && f.filename.endsWith('.md'),
- );
- const hasChangelogEdit = files.some((f) => f.filename === 'CHANGELOG.md');
- const hasChangelogArtifact = hasChangeset || hasChangelogEdit;
- const touchesSource = files.some((f) => SOURCE_PATTERN.test(f.filename));
- const hasSkipLabel = (labels || []).some((l) => l.name === 'skip-changelog');
-
- let pass = hasChangelogArtifact || !touchesSource;
- let detail = '';
-
- if (hasChangeset) {
- detail = 'Changeset file found';
- } else if (hasChangelogEdit) {
- detail = 'CHANGELOG.md edit found';
- } else if (!touchesSource) {
- detail = 'No source files changed — changeset not required';
- } else {
- detail =
- 'Missing `.changeset/*.md` or `CHANGELOG.md` edit — run `npx changeset add` (or add `skip-changelog` label)';
- }
-
- if (hasSkipLabel && !hasChangelogArtifact) {
- pass = true;
- detail = 'Changeset skipped via `skip-changelog` label';
- }
-
- return { pass, detail };
-}
-
-/**
- * Check 6: No merge conflicts (mergeability).
- * @param {boolean|null} mergeable — true/false/null
- * @returns {{ pass: boolean, detail: string }}
- */
-export function checkMergeability(mergeable) {
- if (mergeable === true) {
- return { pass: true, detail: 'No merge conflicts' };
- }
- if (mergeable === false) {
- return { pass: false, detail: 'Merge conflicts detected — resolve before review' };
- }
- // null / unknown — don't penalize
- return { pass: true, detail: 'Merge status unknown — GitHub is still computing' };
-}
-
-/**
- * Check 7: Scope cleanliness — warn when PR includes `.squad/` or `docs/proposals/` files.
- * Informational only (always passes); helps flag accidental scope creep.
- * @param {Array<{ filename: string }>} files — files changed in the PR
- * @returns {{ pass: boolean, detail: string }}
- */
-export function checkScopeClean(files) {
- const squadFiles = (files || []).filter((f) => f.filename.startsWith('.squad/'));
- const proposalFiles = (files || []).filter((f) => f.filename.startsWith('docs/proposals/'));
- const squadCount = squadFiles.length;
- const proposalCount = proposalFiles.length;
-
- if (squadCount === 0 && proposalCount === 0) {
- return { pass: true, detail: 'No .squad/ or docs/proposals/ files' };
- }
-
- const parts = [];
- if (squadCount > 0) parts.push(`${squadCount} .squad/ file(s)`);
- if (proposalCount > 0) parts.push(`${proposalCount} docs/proposals/ file(s)`);
- return {
- pass: true,
- detail: `⚠️ PR includes ${parts.join(' and ')} — ensure these are intentional`,
- };
-}
-
-/**
- * Check 8: All Copilot review threads resolved.
- * @param {Array<{ isResolved: boolean, isOutdated: boolean, comments: { nodes: Array<{ author: { login: string } }> } }>} threads
- * @returns {{ pass: boolean, detail: string }}
- */
-export function checkCopilotThreads(threads) {
- const copilotThreads = (threads || []).filter(
- (t) =>
- t.comments &&
- t.comments.nodes &&
- t.comments.nodes[0]?.author?.login === 'copilot-pull-request-reviewer',
- );
- const unresolved = copilotThreads.filter((t) => !t.isResolved && !t.isOutdated);
- const outdatedCount = copilotThreads.filter((t) => t.isOutdated).length;
- const activeCount = copilotThreads.length - outdatedCount;
-
- let detail;
- if (unresolved.length > 0) {
- detail = `${unresolved.length} unresolved Copilot thread(s) — fix and resolve before merging`;
- } else if (copilotThreads.length === 0) {
- detail = 'No Copilot review threads';
- } else if (outdatedCount > 0) {
- detail = `${activeCount} active Copilot thread(s) resolved (${outdatedCount} outdated skipped)`;
- } else {
- detail = `All ${copilotThreads.length} Copilot thread(s) resolved`;
- }
-
- return { pass: unresolved.length === 0, detail };
-}
-
-/**
- * Check 9: CI passing.
- * @param {Array<{ name: string, conclusion: string|null, status: string }>} checkRuns
- * @param {Array