From 6b0e225f1cc8263c0d0c28904f8713ad920bf0b4 Mon Sep 17 00:00:00 2001 From: brady gaster Date: Fri, 21 Aug 2026 01:19:56 -0700 Subject: [PATCH 1/4] fix(ci): force LF on *.mjs so shebanged scripts stay loadable by vitest scripts/check-changeset-drift.mjs starts with `#!/usr/bin/env node`. No .gitattributes rule covered *.mjs, so with core.autocrlf=true the file checks out CRLF on Windows. Vite's shebang stripping does not survive the \r, leaving a bare `#` as the module's first token: SyntaxError: Invalid or unexpected token. test/scripts/check-changeset-drift.test.ts then loads ZERO of its 8 tests -- silently. Linux CI checks out LF, so dev stayed green and this has been dead since #1481. Node strips CRLF shebangs itself, which is why `node scripts/...` and a plain dynamic import both succeed and mask the defect. Only Vite's transform trips. .gitattributes already encodes this exact lesson for shell scripts (`*.sh text eol=lf` -- "CRLF breaks the shebang"); it was never extended to .mjs. This adds that rule and lands `git add --renormalize -- "*.mjs"` in the same commit so existing checkouts converge. 9 of 40 tracked .mjs blobs stored CRLF in the repo, so the renormalize was not a no-op. The shebang is kept -- squad-ci.yml invokes `node scripts/check-changeset-drift.mjs` directly. Adds test/scripts/mjs-shebang-loadable.test.ts, which imports the real shebanged module through the bundler pipeline and replays every tracked shebang line (15 files) as a fixture. Proven red against the pre-fix tree: 16/16 failed with the production error SyntaxError: Invalid or unexpected token. test/scripts/ goes from 3 passed | 1 failed (33 tests) to 5 passed (58 tests). Closes #1788 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 70370e36-33b0-4786-bd72-4cf15518daa6 --- .gitattributes | 6 + docs/playwright.config.mjs | 26 +- docs/src/plugins/rehype-pagefind-attrs.mjs | 42 +- docs/tests/api-reference.spec.mjs | 254 ++-- docs/tests/astro-features.test.mjs | 2 +- docs/tests/search.spec.mjs | 460 +++---- scripts/capture-pr-screenshots.mjs | 84 +- scripts/generate-api-docs.mjs | 320 ++--- scripts/pr-readiness.mjs | 1308 ++++++++++---------- scripts/sync-templates.mjs | 252 ++-- test/scripts/mjs-shebang-loadable.test.ts | 94 ++ 11 files changed, 1474 insertions(+), 1374 deletions(-) create mode 100644 test/scripts/mjs-shebang-loadable.test.ts diff --git a/.gitattributes b/.gitattributes index be741a10c..59c731373 100644 --- a/.gitattributes +++ b/.gitattributes @@ -21,6 +21,12 @@ # 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: CRLF breaks RUN line-continuations when the image builds on Linux. Dockerfile text eol=lf Dockerfile.* text eol=lf 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/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/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} statuses — combined status entries - * @returns {{ pass: boolean, detail: string }} - */ -export function checkCIStatus(checkRuns, statuses) { - const otherChecks = (checkRuns || []).filter( - (cr) => !SELF_CHECK_NAMES.includes(cr.name), - ); - const failedChecks = otherChecks.filter( - (cr) => cr.conclusion === 'failure' || cr.conclusion === 'cancelled', - ); - const pendingChecks = otherChecks.filter( - (cr) => cr.status === 'in_progress' || cr.status === 'queued', - ); - - if (failedChecks.length > 0) { - return { - pass: false, - detail: `${failedChecks.length} check(s) failing: ${failedChecks.map((c) => c.name).join(', ')}`, - }; - } - if (pendingChecks.length > 0) { - return { - pass: false, - detail: `${pendingChecks.length} check(s) still running`, - }; - } - if (otherChecks.length === 0 && (statuses || []).length === 0) { - return { pass: false, detail: 'No CI checks have run yet' }; - } - // Check legacy combined statuses for failure/error states - const failedStatuses = (statuses || []).filter( - (s) => s.state === 'failure' || s.state === 'error', - ); - if (failedStatuses.length > 0) { - return { - pass: false, - detail: `${failedStatuses.length} status(es) failing: ${failedStatuses.map((s) => s.context).join(', ')}`, - }; - } - return { pass: true, detail: 'All checks passing' }; -} - -// --------------------------------------------------------------------------- -// Scope classification -// --------------------------------------------------------------------------- - -/** - * Classify the PR scope based on changed files. - * @param {Array<{ filename: string }>} files - * @returns {{ label: string, emoji: string }} - */ -export function classifyScope(files) { - const hasProduct = (files || []).some((f) => SOURCE_PATTERN.test(f.filename)); - const hasInfra = (files || []).some((f) => !SOURCE_PATTERN.test(f.filename)); - - if (hasProduct && hasInfra) { - return { label: 'Mixed (product + infrastructure)', emoji: '📦🔧' }; - } - if (hasProduct) { - return { label: 'Product', emoji: '📦' }; - } - return { label: 'Infrastructure', emoji: '🔧' }; -} - -// --------------------------------------------------------------------------- -// File list builder -// --------------------------------------------------------------------------- - -/** Maximum files shown in the file list before truncation. */ -export const MAX_FILE_LIST = 50; - -/** - * Sanitize a filename for safe inclusion in a markdown table cell. - * Escapes pipe characters, replaces backticks, and collapses newlines. - * @param {string} name - * @returns {string} - */ -export function sanitizeFilename(name) { - return name - .replace(/\|/g, '\\|') - .replace(/`/g, "'") - .replace(/[\r\n]+/g, ' '); -} - -/** - * Build a markdown section listing changed files with per-file line stats. - * @param {Array<{ filename: string, additions?: number, deletions?: number }>} files - * @returns {string} - */ -export function buildFileList(files) { - if (!files || files.length === 0) { - return ''; - } - - const totalAdded = files.reduce((sum, f) => sum + (f.additions || 0), 0); - const totalDeleted = files.reduce((sum, f) => sum + (f.deletions || 0), 0); - - const displayed = files.slice(0, MAX_FILE_LIST); - const truncated = files.length > MAX_FILE_LIST; - - const rows = displayed.map((f) => { - const added = f.additions || 0; - const deleted = f.deletions || 0; - return `| \`${sanitizeFilename(f.filename)}\` | +${added} −${deleted} |`; - }); - - if (truncated) { - const remaining = files.length - MAX_FILE_LIST; - rows.push(`| ... | **+${remaining} more files** |`); - } - - return [ - `### Files Changed (${files.length} file${files.length === 1 ? '' : 's'}, +${totalAdded} −${totalDeleted})`, - '', - '| File | +/− |', - '|------|-----|', - ...rows, - '', - `**Total: +${totalAdded} −${totalDeleted}**`, - ].join('\n'); -} - -// --------------------------------------------------------------------------- -// Checklist markdown builder -// --------------------------------------------------------------------------- - -/** - * Build the PR readiness comment body. - * @param {Array<{ name: string, pass: boolean, detail: string }>} checks - * @param {string} owner - * @param {string} repo - * @param {string} baseRef - * @param {string} [headSha] — commit SHA that triggered the check - * @param {Array<{ filename: string, additions?: number, deletions?: number }>} [files] - * @returns {string} - */ -export function buildChecklist(checks, owner, repo, baseRef, headSha, files) { - const allPass = checks.every((c) => c.pass); - const passCount = checks.filter((c) => c.pass).length; - - const status = allPass - ? '### ✅ PR is ready for review' - : `### ⚠️ ${checks.length - passCount} item(s) to address before review`; - - const rows = checks.map((c) => { - const icon = c.pass ? '✅' : '❌'; - return `| ${icon} | **${c.name}** | ${c.detail} |`; - }); - - const scope = classifyScope(files); - - const sections = [ - COMMENT_MARKER, - '## 🛫 PR Readiness Check', - ...(headSha - ? [`> ℹ️ This comment updates on each push. Last checked: commit \`${headSha.slice(0, 7)}\``] - : []), - '', - `**PR Scope:** ${scope.emoji} ${scope.label}`, - '', - status, - '', - '| Status | Check | Details |', - '|--------|-------|---------|', - ...rows, - ]; - - const fileList = buildFileList(files); - if (fileList) { - sections.push('', fileList); - } - - sections.push( - '', - '---', - '*This check runs automatically on every push. Fix any ❌ items and push again.*', - `*See [CONTRIBUTING.md](https://github.com/${owner}/${repo}/blob/${baseRef}/CONTRIBUTING.md#pr-readiness-checklist) and [PR Requirements](https://github.com/${owner}/${repo}/blob/${baseRef}/.github/PR_REQUIREMENTS.md) for details.*`, - ); - - return sections.join('\n'); -} - -// --------------------------------------------------------------------------- -// API helpers -// --------------------------------------------------------------------------- - -/** - * Paginate a GitHub REST list endpoint. - * @param {typeof globalThis.fetch} fetchFn - * @param {string} url — initial URL (with per_page param) - * @param {Record} headers - * @returns {Promise} - */ -export async function paginate(fetchFn, url, headers) { - const items = []; - let nextUrl = url; - while (nextUrl) { - const res = await fetchFn(nextUrl, { headers }); - if (!res.ok) throw new Error(`GitHub API ${res.status}: ${nextUrl}`); - const data = await res.json(); - items.push(...(Array.isArray(data) ? data : data.check_runs || [])); - // Parse Link header for next page - const link = res.headers.get('link') || ''; - const match = link.match(/<([^>]+)>;\s*rel="next"/); - nextUrl = match ? match[1] : null; - } - return items; -} - -// --------------------------------------------------------------------------- -// Orchestration -// --------------------------------------------------------------------------- - -/** - * Full PR readiness orchestrator. Reads context from env vars, calls - * GitHub API, runs all checks, and upserts the readiness comment. - * - * Dependencies (`env` and `fetchFn`) are injectable for testing. - * - * @param {object} [opts] - * @param {Record} [opts.env] — defaults to process.env - * @param {typeof globalThis.fetch} [opts.fetchFn] — defaults to global fetch - * @returns {Promise<{ checks: Array<{name:string,pass:boolean,detail:string}>, action: string }>} - */ -export async function run({ env = process.env, fetchFn = globalThis.fetch } = {}) { - const token = env.GITHUB_TOKEN; - const prNumber = env.PR_NUMBER; - const prDraft = env.PR_DRAFT === 'true'; - const prHeadSha = env.PR_HEAD_SHA; - const prBaseRef = env.PR_BASE_REF; - const owner = env.REPO_OWNER; - const repo = env.REPO_NAME; - const runName = env.RUN_NAME || ''; - // Labels come as a JSON array string when set, or empty - const prLabelsRaw = env.PR_LABELS || '[]'; - - let prLabels = []; - try { - const parsed = JSON.parse(prLabelsRaw); - prLabels = Array.isArray(parsed) ? parsed : []; - } catch { - prLabels = []; - } - - const apiHeaders = { - Authorization: `token ${token}`, - Accept: 'application/vnd.github+json', - 'User-Agent': 'squad-pr-readiness', - }; - - const apiBase = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; - const checks = []; - - // 1. Commit count - const commits = await paginate( - fetchFn, - `${apiBase}/pulls/${prNumber}/commits?per_page=100`, - apiHeaders, - ); - checks.push({ name: 'Single commit', ...checkCommitCount(commits.length) }); - - // Fetch PR data once (used for draft status + mergeability) - let prData = null; - for (let attempt = 0; attempt < 2; attempt++) { - const prRes = await fetchFn(`${apiBase}/pulls/${prNumber}`, { - headers: apiHeaders, - }); - if (prRes.ok) { - prData = await prRes.json(); - if (prData.mergeable === null && attempt === 0) { - await new Promise((r) => setTimeout(r, 3000)); - continue; - } - } - break; - } - - // 2. Draft status — prefer API truth over env var (workflow_run can't provide it) - const isDraft = prData?.draft ?? prDraft; - checks.push({ name: 'Not in draft', ...checkDraftStatus(isDraft) }); - - // 3. Branch freshness - let behindBy = null; - try { - const compRes = await fetchFn( - `${apiBase}/compare/${encodeURIComponent(prBaseRef)}...${prHeadSha}`, - { headers: apiHeaders }, - ); - if (compRes.ok) { - const comp = await compRes.json(); - behindBy = comp.behind_by; - } - } catch { - // leave behindBy as null - } - checks.push({ name: 'Branch up to date', ...checkBranchFreshness(behindBy, prBaseRef) }); - - // 4. Copilot review - const reviews = await paginate( - fetchFn, - `${apiBase}/pulls/${prNumber}/reviews?per_page=100`, - apiHeaders, - ); - checks.push({ name: 'Copilot review', ...checkCopilotReview(reviews) }); - - // 5. Changeset present - const files = await paginate( - fetchFn, - `${apiBase}/pulls/${prNumber}/files?per_page=100`, - apiHeaders, - ); - checks.push({ name: 'Changeset present', ...checkChangeset(files, prLabels) }); - - // 6. Scope cleanliness - checks.push({ name: 'Scope clean', ...checkScopeClean(files) }); - - // 7. Mergeability (uses PR data fetched above) - const mergeable = prData?.mergeable ?? null; - checks.push({ name: 'No merge conflicts', ...checkMergeability(mergeable) }); - - // 8. Copilot review threads resolved (via GraphQL) - let reviewThreadNodes = []; - try { - const graphqlBody = JSON.stringify({ - query: `query($owner: String!, $repo: String!, $number: Int!) { - repository(owner: $owner, name: $repo) { - pullRequest(number: $number) { - reviewThreads(first: 100) { - nodes { - isResolved - isOutdated - comments(first: 1) { - nodes { - author { login } - } - } - } - } - } - } - }`, - variables: { owner, repo, number: parseInt(prNumber, 10) }, - }); - const threadsRes = await fetchFn('https://api.github.com/graphql', { - method: 'POST', - headers: { ...apiHeaders, 'Content-Type': 'application/json' }, - body: graphqlBody, - }); - if (threadsRes.ok) { - const threadsData = await threadsRes.json(); - reviewThreadNodes = - threadsData.data?.repository?.pullRequest?.reviewThreads?.nodes || []; - } - } catch { - // leave empty — will show "No Copilot review threads" - } - checks.push({ - name: 'Copilot threads resolved', - ...checkCopilotThreads(reviewThreadNodes), - }); - - // 9. CI status - let checkRuns = []; - let statusEntries = []; - try { - checkRuns = await paginate( - fetchFn, - `${apiBase}/commits/${prHeadSha}/check-runs?per_page=100`, - apiHeaders, - ); - const statusRes = await fetchFn( - `${apiBase}/commits/${prHeadSha}/status`, - { headers: apiHeaders }, - ); - if (statusRes.ok) { - const statusData = await statusRes.json(); - statusEntries = statusData.statuses || []; - } - } catch { - // leave empty - } - checks.push({ name: 'CI passing', ...checkCIStatus(checkRuns, statusEntries) }); - - // ── Build checklist and upsert comment ── - const body = buildChecklist(checks, owner, repo, prBaseRef, prHeadSha, files); - - // Find existing comment - const existingComments = await paginate( - fetchFn, - `${apiBase}/issues/${prNumber}/comments?per_page=100`, - apiHeaders, - ); - const existing = existingComments.find( - (c) => c.body && c.body.includes(COMMENT_MARKER), - ); - - let action; - if (existing) { - await fetchFn(`${apiBase}/issues/comments/${existing.id}`, { - method: 'PATCH', - headers: { ...apiHeaders, 'Content-Type': 'application/json' }, - body: JSON.stringify({ body }), - }); - action = 'updated'; - } else { - await fetchFn(`${apiBase}/issues/${prNumber}/comments`, { - method: 'POST', - headers: { ...apiHeaders, 'Content-Type': 'application/json' }, - body: JSON.stringify({ body }), - }); - action = 'created'; - } - - // Log summary - const passCount = checks.filter((c) => c.pass).length; - console.log(`PR Readiness: ${passCount}/${checks.length} checks passing`); - if (passCount < checks.length) { - const failing = checks.filter((c) => !c.pass).map((c) => c.name); - console.log(`Failing: ${failing.join(', ')}`); - } - - return { checks, action }; -} - -// --------------------------------------------------------------------------- -// CLI entry point -// --------------------------------------------------------------------------- - -if (process.argv[1] === fileURLToPath(import.meta.url)) { - run() - .then((result) => { - console.log(`Comment ${result.action}.`); - }) - .catch((err) => { - console.error('PR readiness script failed:', err); - process.exitCode = 1; - }); -} +/** + * 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} statuses — combined status entries + * @returns {{ pass: boolean, detail: string }} + */ +export function checkCIStatus(checkRuns, statuses) { + const otherChecks = (checkRuns || []).filter( + (cr) => !SELF_CHECK_NAMES.includes(cr.name), + ); + const failedChecks = otherChecks.filter( + (cr) => cr.conclusion === 'failure' || cr.conclusion === 'cancelled', + ); + const pendingChecks = otherChecks.filter( + (cr) => cr.status === 'in_progress' || cr.status === 'queued', + ); + + if (failedChecks.length > 0) { + return { + pass: false, + detail: `${failedChecks.length} check(s) failing: ${failedChecks.map((c) => c.name).join(', ')}`, + }; + } + if (pendingChecks.length > 0) { + return { + pass: false, + detail: `${pendingChecks.length} check(s) still running`, + }; + } + if (otherChecks.length === 0 && (statuses || []).length === 0) { + return { pass: false, detail: 'No CI checks have run yet' }; + } + // Check legacy combined statuses for failure/error states + const failedStatuses = (statuses || []).filter( + (s) => s.state === 'failure' || s.state === 'error', + ); + if (failedStatuses.length > 0) { + return { + pass: false, + detail: `${failedStatuses.length} status(es) failing: ${failedStatuses.map((s) => s.context).join(', ')}`, + }; + } + return { pass: true, detail: 'All checks passing' }; +} + +// --------------------------------------------------------------------------- +// Scope classification +// --------------------------------------------------------------------------- + +/** + * Classify the PR scope based on changed files. + * @param {Array<{ filename: string }>} files + * @returns {{ label: string, emoji: string }} + */ +export function classifyScope(files) { + const hasProduct = (files || []).some((f) => SOURCE_PATTERN.test(f.filename)); + const hasInfra = (files || []).some((f) => !SOURCE_PATTERN.test(f.filename)); + + if (hasProduct && hasInfra) { + return { label: 'Mixed (product + infrastructure)', emoji: '📦🔧' }; + } + if (hasProduct) { + return { label: 'Product', emoji: '📦' }; + } + return { label: 'Infrastructure', emoji: '🔧' }; +} + +// --------------------------------------------------------------------------- +// File list builder +// --------------------------------------------------------------------------- + +/** Maximum files shown in the file list before truncation. */ +export const MAX_FILE_LIST = 50; + +/** + * Sanitize a filename for safe inclusion in a markdown table cell. + * Escapes pipe characters, replaces backticks, and collapses newlines. + * @param {string} name + * @returns {string} + */ +export function sanitizeFilename(name) { + return name + .replace(/\|/g, '\\|') + .replace(/`/g, "'") + .replace(/[\r\n]+/g, ' '); +} + +/** + * Build a markdown section listing changed files with per-file line stats. + * @param {Array<{ filename: string, additions?: number, deletions?: number }>} files + * @returns {string} + */ +export function buildFileList(files) { + if (!files || files.length === 0) { + return ''; + } + + const totalAdded = files.reduce((sum, f) => sum + (f.additions || 0), 0); + const totalDeleted = files.reduce((sum, f) => sum + (f.deletions || 0), 0); + + const displayed = files.slice(0, MAX_FILE_LIST); + const truncated = files.length > MAX_FILE_LIST; + + const rows = displayed.map((f) => { + const added = f.additions || 0; + const deleted = f.deletions || 0; + return `| \`${sanitizeFilename(f.filename)}\` | +${added} −${deleted} |`; + }); + + if (truncated) { + const remaining = files.length - MAX_FILE_LIST; + rows.push(`| ... | **+${remaining} more files** |`); + } + + return [ + `### Files Changed (${files.length} file${files.length === 1 ? '' : 's'}, +${totalAdded} −${totalDeleted})`, + '', + '| File | +/− |', + '|------|-----|', + ...rows, + '', + `**Total: +${totalAdded} −${totalDeleted}**`, + ].join('\n'); +} + +// --------------------------------------------------------------------------- +// Checklist markdown builder +// --------------------------------------------------------------------------- + +/** + * Build the PR readiness comment body. + * @param {Array<{ name: string, pass: boolean, detail: string }>} checks + * @param {string} owner + * @param {string} repo + * @param {string} baseRef + * @param {string} [headSha] — commit SHA that triggered the check + * @param {Array<{ filename: string, additions?: number, deletions?: number }>} [files] + * @returns {string} + */ +export function buildChecklist(checks, owner, repo, baseRef, headSha, files) { + const allPass = checks.every((c) => c.pass); + const passCount = checks.filter((c) => c.pass).length; + + const status = allPass + ? '### ✅ PR is ready for review' + : `### ⚠️ ${checks.length - passCount} item(s) to address before review`; + + const rows = checks.map((c) => { + const icon = c.pass ? '✅' : '❌'; + return `| ${icon} | **${c.name}** | ${c.detail} |`; + }); + + const scope = classifyScope(files); + + const sections = [ + COMMENT_MARKER, + '## 🛫 PR Readiness Check', + ...(headSha + ? [`> ℹ️ This comment updates on each push. Last checked: commit \`${headSha.slice(0, 7)}\``] + : []), + '', + `**PR Scope:** ${scope.emoji} ${scope.label}`, + '', + status, + '', + '| Status | Check | Details |', + '|--------|-------|---------|', + ...rows, + ]; + + const fileList = buildFileList(files); + if (fileList) { + sections.push('', fileList); + } + + sections.push( + '', + '---', + '*This check runs automatically on every push. Fix any ❌ items and push again.*', + `*See [CONTRIBUTING.md](https://github.com/${owner}/${repo}/blob/${baseRef}/CONTRIBUTING.md#pr-readiness-checklist) and [PR Requirements](https://github.com/${owner}/${repo}/blob/${baseRef}/.github/PR_REQUIREMENTS.md) for details.*`, + ); + + return sections.join('\n'); +} + +// --------------------------------------------------------------------------- +// API helpers +// --------------------------------------------------------------------------- + +/** + * Paginate a GitHub REST list endpoint. + * @param {typeof globalThis.fetch} fetchFn + * @param {string} url — initial URL (with per_page param) + * @param {Record} headers + * @returns {Promise} + */ +export async function paginate(fetchFn, url, headers) { + const items = []; + let nextUrl = url; + while (nextUrl) { + const res = await fetchFn(nextUrl, { headers }); + if (!res.ok) throw new Error(`GitHub API ${res.status}: ${nextUrl}`); + const data = await res.json(); + items.push(...(Array.isArray(data) ? data : data.check_runs || [])); + // Parse Link header for next page + const link = res.headers.get('link') || ''; + const match = link.match(/<([^>]+)>;\s*rel="next"/); + nextUrl = match ? match[1] : null; + } + return items; +} + +// --------------------------------------------------------------------------- +// Orchestration +// --------------------------------------------------------------------------- + +/** + * Full PR readiness orchestrator. Reads context from env vars, calls + * GitHub API, runs all checks, and upserts the readiness comment. + * + * Dependencies (`env` and `fetchFn`) are injectable for testing. + * + * @param {object} [opts] + * @param {Record} [opts.env] — defaults to process.env + * @param {typeof globalThis.fetch} [opts.fetchFn] — defaults to global fetch + * @returns {Promise<{ checks: Array<{name:string,pass:boolean,detail:string}>, action: string }>} + */ +export async function run({ env = process.env, fetchFn = globalThis.fetch } = {}) { + const token = env.GITHUB_TOKEN; + const prNumber = env.PR_NUMBER; + const prDraft = env.PR_DRAFT === 'true'; + const prHeadSha = env.PR_HEAD_SHA; + const prBaseRef = env.PR_BASE_REF; + const owner = env.REPO_OWNER; + const repo = env.REPO_NAME; + const runName = env.RUN_NAME || ''; + // Labels come as a JSON array string when set, or empty + const prLabelsRaw = env.PR_LABELS || '[]'; + + let prLabels = []; + try { + const parsed = JSON.parse(prLabelsRaw); + prLabels = Array.isArray(parsed) ? parsed : []; + } catch { + prLabels = []; + } + + const apiHeaders = { + Authorization: `token ${token}`, + Accept: 'application/vnd.github+json', + 'User-Agent': 'squad-pr-readiness', + }; + + const apiBase = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; + const checks = []; + + // 1. Commit count + const commits = await paginate( + fetchFn, + `${apiBase}/pulls/${prNumber}/commits?per_page=100`, + apiHeaders, + ); + checks.push({ name: 'Single commit', ...checkCommitCount(commits.length) }); + + // Fetch PR data once (used for draft status + mergeability) + let prData = null; + for (let attempt = 0; attempt < 2; attempt++) { + const prRes = await fetchFn(`${apiBase}/pulls/${prNumber}`, { + headers: apiHeaders, + }); + if (prRes.ok) { + prData = await prRes.json(); + if (prData.mergeable === null && attempt === 0) { + await new Promise((r) => setTimeout(r, 3000)); + continue; + } + } + break; + } + + // 2. Draft status — prefer API truth over env var (workflow_run can't provide it) + const isDraft = prData?.draft ?? prDraft; + checks.push({ name: 'Not in draft', ...checkDraftStatus(isDraft) }); + + // 3. Branch freshness + let behindBy = null; + try { + const compRes = await fetchFn( + `${apiBase}/compare/${encodeURIComponent(prBaseRef)}...${prHeadSha}`, + { headers: apiHeaders }, + ); + if (compRes.ok) { + const comp = await compRes.json(); + behindBy = comp.behind_by; + } + } catch { + // leave behindBy as null + } + checks.push({ name: 'Branch up to date', ...checkBranchFreshness(behindBy, prBaseRef) }); + + // 4. Copilot review + const reviews = await paginate( + fetchFn, + `${apiBase}/pulls/${prNumber}/reviews?per_page=100`, + apiHeaders, + ); + checks.push({ name: 'Copilot review', ...checkCopilotReview(reviews) }); + + // 5. Changeset present + const files = await paginate( + fetchFn, + `${apiBase}/pulls/${prNumber}/files?per_page=100`, + apiHeaders, + ); + checks.push({ name: 'Changeset present', ...checkChangeset(files, prLabels) }); + + // 6. Scope cleanliness + checks.push({ name: 'Scope clean', ...checkScopeClean(files) }); + + // 7. Mergeability (uses PR data fetched above) + const mergeable = prData?.mergeable ?? null; + checks.push({ name: 'No merge conflicts', ...checkMergeability(mergeable) }); + + // 8. Copilot review threads resolved (via GraphQL) + let reviewThreadNodes = []; + try { + const graphqlBody = JSON.stringify({ + query: `query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + reviewThreads(first: 100) { + nodes { + isResolved + isOutdated + comments(first: 1) { + nodes { + author { login } + } + } + } + } + } + } + }`, + variables: { owner, repo, number: parseInt(prNumber, 10) }, + }); + const threadsRes = await fetchFn('https://api.github.com/graphql', { + method: 'POST', + headers: { ...apiHeaders, 'Content-Type': 'application/json' }, + body: graphqlBody, + }); + if (threadsRes.ok) { + const threadsData = await threadsRes.json(); + reviewThreadNodes = + threadsData.data?.repository?.pullRequest?.reviewThreads?.nodes || []; + } + } catch { + // leave empty — will show "No Copilot review threads" + } + checks.push({ + name: 'Copilot threads resolved', + ...checkCopilotThreads(reviewThreadNodes), + }); + + // 9. CI status + let checkRuns = []; + let statusEntries = []; + try { + checkRuns = await paginate( + fetchFn, + `${apiBase}/commits/${prHeadSha}/check-runs?per_page=100`, + apiHeaders, + ); + const statusRes = await fetchFn( + `${apiBase}/commits/${prHeadSha}/status`, + { headers: apiHeaders }, + ); + if (statusRes.ok) { + const statusData = await statusRes.json(); + statusEntries = statusData.statuses || []; + } + } catch { + // leave empty + } + checks.push({ name: 'CI passing', ...checkCIStatus(checkRuns, statusEntries) }); + + // ── Build checklist and upsert comment ── + const body = buildChecklist(checks, owner, repo, prBaseRef, prHeadSha, files); + + // Find existing comment + const existingComments = await paginate( + fetchFn, + `${apiBase}/issues/${prNumber}/comments?per_page=100`, + apiHeaders, + ); + const existing = existingComments.find( + (c) => c.body && c.body.includes(COMMENT_MARKER), + ); + + let action; + if (existing) { + await fetchFn(`${apiBase}/issues/comments/${existing.id}`, { + method: 'PATCH', + headers: { ...apiHeaders, 'Content-Type': 'application/json' }, + body: JSON.stringify({ body }), + }); + action = 'updated'; + } else { + await fetchFn(`${apiBase}/issues/${prNumber}/comments`, { + method: 'POST', + headers: { ...apiHeaders, 'Content-Type': 'application/json' }, + body: JSON.stringify({ body }), + }); + action = 'created'; + } + + // Log summary + const passCount = checks.filter((c) => c.pass).length; + console.log(`PR Readiness: ${passCount}/${checks.length} checks passing`); + if (passCount < checks.length) { + const failing = checks.filter((c) => !c.pass).map((c) => c.name); + console.log(`Failing: ${failing.join(', ')}`); + } + + return { checks, action }; +} + +// --------------------------------------------------------------------------- +// CLI entry point +// --------------------------------------------------------------------------- + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + run() + .then((result) => { + console.log(`Comment ${result.action}.`); + }) + .catch((err) => { + console.error('PR readiness script failed:', err); + process.exitCode = 1; + }); +} diff --git a/scripts/sync-templates.mjs b/scripts/sync-templates.mjs index 2df04c103..8979a730e 100644 --- a/scripts/sync-templates.mjs +++ b/scripts/sync-templates.mjs @@ -1,126 +1,126 @@ -#!/usr/bin/env node -/** - * sync-templates.mjs — Copy canonical templates from .squad-templates/ - * to every target directory that needs them. - * - * Targets: - * templates/ (root mirror) - * packages/squad-cli/templates/ (CLI package) - * packages/squad-sdk/templates/ (SDK package) - * .github/agents/squad.agent.md (GitHub agent — squad.agent.md only) - * - * Only copies files that exist in .squad-templates/. Target directories - * that don't exist are skipped with a warning. - */ - -import { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync, statSync } from 'node:fs'; -import { join, dirname, relative } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const ROOT = join(__dirname, '..'); - -// --------------------------------------------------------------------------- -// Guard: require explicit invocation to prevent accidental auto-triggering -// during agent work (e.g., file watchers, git hooks). -// Pass --sync flag or set SQUAD_SYNC_TEMPLATES=1 env var. -// --------------------------------------------------------------------------- -const explicitFlag = process.argv.includes('--sync'); -const envFlag = process.env.SQUAD_SYNC_TEMPLATES === '1'; -const directInvocation = process.argv.length <= 2; -if (!directInvocation && !explicitFlag && !envFlag) { - console.log('⛔ sync-templates requires explicit invocation.'); - console.log(' Use: node scripts/sync-templates.mjs --sync'); - console.log(' Or: SQUAD_SYNC_TEMPLATES=1 node scripts/sync-templates.mjs'); - process.exit(0); -} - -const SOURCE = join(ROOT, '.squad-templates'); - -const MIRROR_TARGETS = [ - join(ROOT, 'templates'), - join(ROOT, 'packages', 'squad-cli', 'templates'), - join(ROOT, 'packages', 'squad-sdk', 'templates'), -]; - -// squad.agent.md also goes to .github/agents/ -const AGENT_MD_TARGET = join(ROOT, '.github', 'agents'); -const AGENT_MD_FILE = 'squad.agent.md'; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/** Recursively collect all file paths relative to `dir`. */ -function collectFiles(dir, base = '') { - const entries = readdirSync(dir, { withFileTypes: true }); - const files = []; - for (const entry of entries) { - const rel = base ? join(base, entry.name) : entry.name; - if (entry.isDirectory()) { - files.push(...collectFiles(join(dir, entry.name), rel)); - } else { - files.push(rel); - } - } - return files; -} - -/** Copy a single file, creating parent dirs as needed. Returns true if written. */ -function copyFile(src, dest) { - const content = readFileSync(src); - const destDir = dirname(dest); - if (!existsSync(destDir)) { - mkdirSync(destDir, { recursive: true }); - } - writeFileSync(dest, content); - return true; -} - -// --------------------------------------------------------------------------- -// Main -// --------------------------------------------------------------------------- - -if (!existsSync(SOURCE)) { - console.log('⏭️ .squad-templates/ not found — nothing to sync'); - process.exit(0); -} - -const sourceFiles = collectFiles(SOURCE); -let totalCopied = 0; - -for (const relFile of sourceFiles) { - const srcPath = join(SOURCE, relFile); - const targets = []; - - // Mirror to each target directory - // Rename squad.agent.md → squad.agent.md.template in mirror targets - // so Copilot CLI 1.0.11 doesn't discover template copies as *.agent.md - for (const targetDir of MIRROR_TARGETS) { - if (!existsSync(targetDir)) { - // Skip targets whose root doesn't exist (e.g., package not checked out) - continue; - } - const destName = relFile === AGENT_MD_FILE ? AGENT_MD_FILE + '.template' : relFile; - targets.push(join(targetDir, destName)); - } - - // Special case: squad.agent.md also goes to .github/agents/ - if (relFile === AGENT_MD_FILE && existsSync(AGENT_MD_TARGET)) { - targets.push(join(AGENT_MD_TARGET, AGENT_MD_FILE)); - } - - if (targets.length === 0) continue; - - for (const dest of targets) { - copyFile(srcPath, dest); - } - - totalCopied++; - const label = targets.length === 1 - ? `1 target` - : `${targets.length} targets`; - console.log(` ✅ ${relFile} → ${label}`); -} - -console.log(`\n📋 Synced ${totalCopied} file(s) from .squad-templates/`); +#!/usr/bin/env node +/** + * sync-templates.mjs — Copy canonical templates from .squad-templates/ + * to every target directory that needs them. + * + * Targets: + * templates/ (root mirror) + * packages/squad-cli/templates/ (CLI package) + * packages/squad-sdk/templates/ (SDK package) + * .github/agents/squad.agent.md (GitHub agent — squad.agent.md only) + * + * Only copies files that exist in .squad-templates/. Target directories + * that don't exist are skipped with a warning. + */ + +import { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync, statSync } from 'node:fs'; +import { join, dirname, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(__dirname, '..'); + +// --------------------------------------------------------------------------- +// Guard: require explicit invocation to prevent accidental auto-triggering +// during agent work (e.g., file watchers, git hooks). +// Pass --sync flag or set SQUAD_SYNC_TEMPLATES=1 env var. +// --------------------------------------------------------------------------- +const explicitFlag = process.argv.includes('--sync'); +const envFlag = process.env.SQUAD_SYNC_TEMPLATES === '1'; +const directInvocation = process.argv.length <= 2; +if (!directInvocation && !explicitFlag && !envFlag) { + console.log('⛔ sync-templates requires explicit invocation.'); + console.log(' Use: node scripts/sync-templates.mjs --sync'); + console.log(' Or: SQUAD_SYNC_TEMPLATES=1 node scripts/sync-templates.mjs'); + process.exit(0); +} + +const SOURCE = join(ROOT, '.squad-templates'); + +const MIRROR_TARGETS = [ + join(ROOT, 'templates'), + join(ROOT, 'packages', 'squad-cli', 'templates'), + join(ROOT, 'packages', 'squad-sdk', 'templates'), +]; + +// squad.agent.md also goes to .github/agents/ +const AGENT_MD_TARGET = join(ROOT, '.github', 'agents'); +const AGENT_MD_FILE = 'squad.agent.md'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Recursively collect all file paths relative to `dir`. */ +function collectFiles(dir, base = '') { + const entries = readdirSync(dir, { withFileTypes: true }); + const files = []; + for (const entry of entries) { + const rel = base ? join(base, entry.name) : entry.name; + if (entry.isDirectory()) { + files.push(...collectFiles(join(dir, entry.name), rel)); + } else { + files.push(rel); + } + } + return files; +} + +/** Copy a single file, creating parent dirs as needed. Returns true if written. */ +function copyFile(src, dest) { + const content = readFileSync(src); + const destDir = dirname(dest); + if (!existsSync(destDir)) { + mkdirSync(destDir, { recursive: true }); + } + writeFileSync(dest, content); + return true; +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +if (!existsSync(SOURCE)) { + console.log('⏭️ .squad-templates/ not found — nothing to sync'); + process.exit(0); +} + +const sourceFiles = collectFiles(SOURCE); +let totalCopied = 0; + +for (const relFile of sourceFiles) { + const srcPath = join(SOURCE, relFile); + const targets = []; + + // Mirror to each target directory + // Rename squad.agent.md → squad.agent.md.template in mirror targets + // so Copilot CLI 1.0.11 doesn't discover template copies as *.agent.md + for (const targetDir of MIRROR_TARGETS) { + if (!existsSync(targetDir)) { + // Skip targets whose root doesn't exist (e.g., package not checked out) + continue; + } + const destName = relFile === AGENT_MD_FILE ? AGENT_MD_FILE + '.template' : relFile; + targets.push(join(targetDir, destName)); + } + + // Special case: squad.agent.md also goes to .github/agents/ + if (relFile === AGENT_MD_FILE && existsSync(AGENT_MD_TARGET)) { + targets.push(join(AGENT_MD_TARGET, AGENT_MD_FILE)); + } + + if (targets.length === 0) continue; + + for (const dest of targets) { + copyFile(srcPath, dest); + } + + totalCopied++; + const label = targets.length === 1 + ? `1 target` + : `${targets.length} targets`; + console.log(` ✅ ${relFile} → ${label}`); +} + +console.log(`\n📋 Synced ${totalCopied} file(s) from .squad-templates/`); diff --git a/test/scripts/mjs-shebang-loadable.test.ts b/test/scripts/mjs-shebang-loadable.test.ts new file mode 100644 index 000000000..2630ceae7 --- /dev/null +++ b/test/scripts/mjs-shebang-loadable.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { randomBytes } from 'node:crypto'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// Regression coverage for #1788. +// +// A `.mjs` that starts with a shebang must be checked out LF. Vite's shebang +// stripping does not survive a `\r`, so a CRLF shebang leaves a bare `#` as the +// module's first token: any suite importing that file dies with +// `SyntaxError: Invalid or unexpected token` and loads ZERO tests — which reads +// like environment noise rather than a failure. Linux CI checks out LF, so this +// stays invisible on `dev` while being permanently broken on Windows. +// +// These assertions go through the real vitest/Vite import pipeline. Node strips +// CRLF shebangs correctly on its own, so anything that bypasses the bundler +// (`node script.mjs`, `import()` of a file:// URL) cannot detect this and must +// not be used here. + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(__dirname, '..', '..'); + +const fixtureDirName = `.mjs-shebang-fixtures-${process.pid}-${randomBytes(4).toString('hex')}`; +const fixtureDir = join(__dirname, fixtureDirName); + +/** Every tracked `*.mjs` that carries a shebang, with its raw first-line bytes. */ +function shebangModules(): Array<{ file: string; header: Buffer }> { + const tracked = execFileSync('git', ['ls-files', '*.mjs'], { + cwd: repoRoot, + encoding: 'utf-8', + }) + .split(/\r?\n/) + .filter(Boolean); + + const found: Array<{ file: string; header: Buffer }> = []; + for (const file of tracked) { + const bytes = readFileSync(join(repoRoot, file)); + if (bytes[0] !== 0x23 || bytes[1] !== 0x21) continue; // not '#!' + const newline = bytes.indexOf(0x0a); + // Keep the terminator bytes verbatim — CRLF vs LF is the entire defect. + found.push({ file, header: newline === -1 ? bytes : bytes.subarray(0, newline + 1) }); + } + return found; +} + +const modules = shebangModules(); + +beforeAll(() => { + mkdirSync(fixtureDir, { recursive: true }); +}); + +afterAll(() => { + rmSync(fixtureDir, { recursive: true, force: true }); +}); + +describe('shebanged .mjs files stay loadable by the test runner (#1788)', () => { + it('finds shebanged .mjs files to check', () => { + // Guards against the sweep silently matching nothing and passing vacuously. + expect(modules.length).toBeGreaterThan(0); + }); + + it('loads the real check-changeset-drift.mjs through the bundler pipeline', async () => { + // The full, unmodified file — this is the module whose CRLF shebang took the + // changeset-drift suite down to zero tests. + const module = await import('../../scripts/check-changeset-drift.mjs'); + + expect(typeof module.evaluateDrift).toBe('function'); + expect(typeof module.listFragments).toBe('function'); + }); + + for (const { file, header } of modules) { + it(`parses the shebang line of ${file}`, async () => { + // Reproduce the exact failure condition without executing the script: + // the real header bytes (shebang + its real terminator) plus an inert body. + const fixture = `${file.replace(/[^a-zA-Z0-9]/g, '-')}.mjs`; + writeFileSync( + join(fixtureDir, fixture), + Buffer.concat([header, Buffer.from('export const loaded = true;\n', 'utf-8')]), + ); + + // Deliberately non-analyzable so vite:dynamic-import-vars leaves it as a + // runtime import through Vite's module runner. Keeping the extension in + // the static part would make the plugin pre-glob candidates, which cannot + // see fixtures written during the run. A file:// URL is also wrong here: + // Node strips CRLF shebangs itself and would mask the defect. + const specifier = `./${fixtureDirName}/${fixture}`; + const module = (await import(specifier)) as { loaded?: boolean }; + + expect(module.loaded).toBe(true); + }); + } +}); From 1ef5b75f1e300989a200bec6b246b22d068cc034 Mon Sep 17 00:00:00 2001 From: brady gaster Date: Fri, 21 Aug 2026 01:36:09 -0700 Subject: [PATCH 2/4] ci: add shebang EOL lint and generalize the eol=lf rule (#1788 follow-up) .gitattributes encoded "CRLF breaks the shebang" for *.sh in one PR and for *.mjs in another, and never generalized. We paid for that lesson twice. This adds scripts/check-shebang-eol.mjs so we do not pay for it a third time. The lint enumerates every tracked file whose blob starts with `#!` (45 today) and checks two things independently: UNPINNED git check-attr says eol != lf -- no rule covers the path CRLF-BLOB the staged blob's first line ends in \r -- a rule was added but `git add --renormalize` never ran, so the repo is still broken The second check matters because a rule alone is a no-op on existing blobs. That is not hypothetical: the scan found 7 violations on a tree that already had the rules, including samples/storage-provider-azure/scripts/*.sh, which `*.sh text eol=lf` has covered for ages. Those two files showed as modified in every worktree and `git restore` never made it stick -- git was normalizing the working tree to LF while the blob stayed CRLF, so the diff could never close. Renormalizing them fixes that permanently. docs/pagefind.yml had the identical condition under `*.yml text eol=lf` and is renormalized for the same reason. Also renormalized packages/squad-cli/src/cli-entry.ts, which stored a CRLF shebang. tsc happens to emit LF today, so the published bin is fine by accident rather than by construction. Rule coverage is extended by measured blast radius, not reflex: *.js (6 blobs), *.cjs (0) and *.ps1 (0) are cheap, but a blanket *.ts rule would renormalize 95 CRLF-storing blobs, so the 3 shebanged .ts entrypoints are pinned by path. The lint reads the index rather than HEAD, so it is correct both as a pre-commit check and as a CI gate (a fresh checkout has index == HEAD), and batches every blob through a single `git cat-file --batch` -- per-file spawns cost 70s on Windows and blew vitest's hook timeout. Proven red before green, in the real repo: staging a shebanged scripts/ _probe-deploy.zsh (matched by no rule) produced Shebang EOL check FAILED: 1 problem(s) across 1 of 46 shebanged file(s). UNPINNED scripts/_probe-deploy.zsh -- git check-attr eol = unspecified and exit 1; removing it returned exit 0. The suite also drives both violation kinds through real `git check-attr` / `git cat-file` in throwaway repos rather than hand-built fixture maps. test/scripts/: 6 files, 67 tests. Refs #1788 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 70370e36-33b0-4786-bd72-4cf15518daa6 --- .gitattributes | 14 + .github/workflows/squad-ci.yml | 17 + .squad-templates/ralph-triage.js | 1134 ++++---- docs/pagefind.yml | 24 +- packages/squad-cli/src/cli-entry.ts | 2312 ++++++++--------- packages/squad-cli/templates/ralph-triage.js | 1134 ++++---- packages/squad-sdk/templates/ralph-triage.js | 1134 ++++---- .../scripts/create-storage.sh | 134 +- .../scripts/delete-storage.sh | 56 +- scripts/check-shebang-eol.mjs | 179 ++ templates/ralph-triage.js | 1134 ++++---- test/scripts/check-shebang-eol.test.ts | 157 ++ 12 files changed, 3898 insertions(+), 3531 deletions(-) create mode 100644 scripts/check-shebang-eol.mjs create mode 100644 test/scripts/check-shebang-eol.test.ts diff --git a/.gitattributes b/.gitattributes index 59c731373..894c01211 100644 --- a/.gitattributes +++ b/.gitattributes @@ -27,6 +27,20 @@ # 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 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/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/check-shebang-eol.mjs b/scripts/check-shebang-eol.mjs new file mode 100644 index 000000000..a6e71a8e9 --- /dev/null +++ b/scripts/check-shebang-eol.mjs @@ -0,0 +1,179 @@ +#!/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. +// +// 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/templates/ralph-triage.js b/templates/ralph-triage.js index 76edc90d1..863da3d7d 100644 --- a/templates/ralph-triage.js +++ b/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/test/scripts/check-shebang-eol.test.ts b/test/scripts/check-shebang-eol.test.ts new file mode 100644 index 000000000..ff2b6b463 --- /dev/null +++ b/test/scripts/check-shebang-eol.test.ts @@ -0,0 +1,157 @@ +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { + eolAttributes, + findViolations, + listShebangFiles, + scan, +} from '../../scripts/check-shebang-eol.mjs'; + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); + +/** + * Build a throwaway git repo so the lint can be exercised against real + * `git check-attr` / `git cat-file` output. Asserting against a hand-built + * fixture map would only prove the pure function, not that the git plumbing + * is being read correctly. + */ +function makeRepo(files: Record, followUp?: Record): string { + const dir = mkdtempSync(join(tmpdir(), 'shebang-eol-')); + execFileSync('git', ['init', '-q'], { cwd: dir }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: dir }); + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: dir }); + // Force the hostile case: without this, git may not store CRLF at all. + execFileSync('git', ['config', 'core.autocrlf', 'false'], { cwd: dir }); + + const commit = (batch: Record, message: string) => { + for (const [name, content] of Object.entries(batch)) { + const full = join(dir, name); + mkdirSync(dirname(full), { recursive: true }); + writeFileSync(full, content); + } + // Stage only the named paths, never `-A`: once the rule exists, `git add -A` + // re-examines every file and would silently renormalize the very blob this + // fixture needs to keep as CRLF. + execFileSync('git', ['add', '--', ...Object.keys(batch)], { cwd: dir }); + execFileSync('git', ['commit', '-qm', message, '--no-gpg-sign'], { cwd: dir }); + }; + + commit(files, 'fixture'); + // A second commit lets a test add the .gitattributes rule *after* the blob is + // already stored, which is the only way to reproduce "rule present, blob + // never renormalized" -- git normalizes on add once the rule is in place. + if (followUp) commit(followUp, 'follow-up'); + return dir; +} + +const repos: string[] = []; +function repo(files: Record, followUp?: Record): string { + const dir = makeRepo(files, followUp); + repos.push(dir); + return dir; +} + +afterAll(() => { + for (const dir of repos) rmSync(dir, { recursive: true, force: true }); +}); + +describe('check-shebang-eol lint (#1788)', () => { + it('flags a shebanged file that no .gitattributes rule pins to LF', () => { + const dir = repo({ + '.gitattributes': '*.mjs text eol=lf\n', + // .zsh is covered by nothing -- this is the "deliberately unmatched" case. + 'tools/deploy.zsh': '#!/usr/bin/env zsh\necho hi\n', + 'tools/ok.mjs': '#!/usr/bin/env node\nexport const a = 1;\n', + }); + + const { violations } = scan(dir); + const files = violations.map((v) => v.file); + + expect(files).toContain('tools/deploy.zsh'); + expect(files).not.toContain('tools/ok.mjs'); + expect(violations.find((v) => v.file === 'tools/deploy.zsh')?.kind).toBe('unpinned'); + }); + + it('flags a committed CRLF shebang even when a rule exists, because a rule alone does not rewrite blobs', () => { + const dir = repo( + { + // Committed with no rule in place, so the CRLF actually reaches the blob. + 'tools/stale.mjs': Buffer.from('#!/usr/bin/env node\r\nexport const a = 1;\r\n', 'utf-8'), + }, + // Rule added afterwards, blob never renormalized -- exactly the state of + // this repo between "someone adds *.mjs text eol=lf" and "someone runs + // git add --renormalize". + { '.gitattributes': '*.mjs text eol=lf\n' }, + ); + + const { violations } = scan(dir); + const stale = violations.filter((v) => v.file === 'tools/stale.mjs'); + + expect(stale.map((v) => v.kind)).toContain('crlf-blob'); + // The rule *is* present, so this must NOT be reported as unpinned -- the + // two checks have to be independent or the lint gives a false all-clear. + expect(stale.map((v) => v.kind)).not.toContain('unpinned'); + }); + + it('passes a repo where every shebanged file is both pinned and stored as LF', () => { + const dir = repo({ + '.gitattributes': '*.mjs text eol=lf\n*.sh text eol=lf\n', + 'tools/a.mjs': '#!/usr/bin/env node\nexport const a = 1;\n', + 'tools/b.sh': '#!/bin/sh\necho hi\n', + 'docs/notes.md': 'no shebang here\n', + }); + + expect(scan(dir).violations).toEqual([]); + }); + + it('ignores files that merely contain "#!" somewhere other than byte 0', () => { + const dir = repo({ + '.gitattributes': '', + 'docs/notes.md': 'Use #!/usr/bin/env node to start a script.\n', + }); + + expect(listShebangFiles(dir).map((f) => f.file)).toEqual([]); + expect(scan(dir).violations).toEqual([]); + }); + + it('reads the eol attribute through real git check-attr, not a guess', () => { + const dir = repo({ + '.gitattributes': 'exact/path.tool text eol=lf\n', + 'exact/path.tool': '#!/usr/bin/env node\n', + 'other/path.tool': '#!/usr/bin/env node\n', + }); + + const attrs = eolAttributes(dir, ['exact/path.tool', 'other/path.tool']); + expect(attrs.get('exact/path.tool')).toBe('lf'); + expect(attrs.get('other/path.tool')).not.toBe('lf'); + }); + + it('reports both problems for a file that is unpinned AND stored with CRLF', () => { + const violations = findViolations( + [{ file: 'a.zsh', crlf: true }], + new Map([['a.zsh', 'unspecified']]), + ); + expect(violations.map((v) => v.kind).sort()).toEqual(['crlf-blob', 'unpinned']); + }); +}); + +describe('this repository satisfies the lint', () => { + let result: ReturnType; + + beforeAll(() => { + result = scan(REPO_ROOT); + }); + + it('finds a non-trivial number of shebanged files (guards against a vacuous pass)', () => { + expect(result.shebangFiles.length).toBeGreaterThan(20); + }); + + it('has zero shebang EOL violations', () => { + expect(result.violations).toEqual([]); + }); +}); From 7a148b385201da150bdb675fbf031050f058c1d8 Mon Sep 17 00:00:00 2001 From: brady gaster Date: Fri, 21 Aug 2026 01:38:21 -0700 Subject: [PATCH 3/4] test: reword fixture comment that tripped the unsafe-git detector The comment quoted a repo-wide staging flag verbatim, which security-review.mjs correctly flags as an unsafe-git finding. The code was never doing it -- the fixture stages explicit paths -- so this is a false positive created by prose, and prose is the cheaper thing to change. Worth recording why it surfaced: the finding message quotes the offending command in backticks, so on dev (which does not yet carry #1786) the reporter died with `SyntaxError: Unexpected identifier 'git'` instead of posting the finding. That is #1770 reproducing in the field, on this PR, from an ordinary code comment -- independent confirmation that the #1786 boundary fix is addressing a live defect rather than a theoretical one. Refs #1788 Refs #1770 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 70370e36-33b0-4786-bd72-4cf15518daa6 --- test/scripts/check-shebang-eol.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/scripts/check-shebang-eol.test.ts b/test/scripts/check-shebang-eol.test.ts index ff2b6b463..b7d6f2b6b 100644 --- a/test/scripts/check-shebang-eol.test.ts +++ b/test/scripts/check-shebang-eol.test.ts @@ -34,9 +34,9 @@ function makeRepo(files: Record, followUp?: Record Date: Fri, 21 Aug 2026 01:56:31 -0700 Subject: [PATCH 4/4] build: pin *.snap to LF and document the lint's scope boundary Vitest writes snapshot files with LF unconditionally. `*.snap` had no .gitattributes rule, so it checks out CRLF on Windows and `npm test` dirties a tracked file on every run -- `test/__snapshots__/parser-contracts.test.ts.snap`, found by EECOM. Same CRLF class as the *.mjs rule, different symptom. The .mjs case was a strict parse failure: the suite loaded zero tests. This one loads and passes (16/16) and leaves the tree dirty, which is the more dangerous shape -- a broad `git add` commits pure line-ending noise, or sweeps in an unrelated real change sitting in the same working tree. Reproduced before fixing: clean tree, run the suite, ` M ...parser-contracts. test.ts.snap`. With the rule, the same run leaves the tree clean. Note this does NOT ride the renormalize, contrary to first assumption. The blob is already LF (0 CRLF, 2991 bytes); only the checkout is CRLF (151 CRLF, 3142 bytes). `git add --renormalize -- "*.snap"` stages nothing. What an existing Windows checkout needs is for the file to be rewritten as LF -- which the first `npm test` after this merges does by itself. No manual remediation. Also states the shebang lint's scope boundary in its own header, because a green run should not be read as "no EOL bugs". It covers the strict-parse class, where `#!` is an exact static signature. It does not cover the tool-rewrite class, which has no cheap static signature; guessing at it would trade a sound check for an unsound one. New tool-written file types get pinned by hand. Closes #1788 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 70370e36-33b0-4786-bd72-4cf15518daa6 --- .gitattributes | 7 +++++++ scripts/check-shebang-eol.mjs | 17 +++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/.gitattributes b/.gitattributes index 894c01211..1a581426f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -45,3 +45,10 @@ test-fixtures/dogfood/node-monorepo/packages/cli/src/cli.ts text eol=lf 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/scripts/check-shebang-eol.mjs b/scripts/check-shebang-eol.mjs index a6e71a8e9..ed107be84 100644 --- a/scripts/check-shebang-eol.mjs +++ b/scripts/check-shebang-eol.mjs @@ -13,6 +13,23 @@ // 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';