From 88d1696a46440db01a3df4d93166f4ae3f3e2731 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 13:58:27 -0700 Subject: [PATCH 1/7] fix(publish): pin exact SDK version + auto-pin in workflow + bump to 0.9.4-insider.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: CLI depended on SDK via '>=0.9.0' which npm resolves to the latest stable version (0.9.1) — a build that predates FSStorageProvider. npm semver rules prevent >=0.9.0 from matching prerelease versions, so users always got the stale SDK. Fixes: 1. Pin CLI SDK dep to exact '0.9.4-insider.1' 2. Add workflow step to auto-pin SDK version before every publish 3. Add registry verification wait between SDK and CLI publish 4. Bump both packages to 0.9.4-insider.1 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/squad-insider-publish.yml | 27 +++++++++++++++++++++ package-lock.json | 6 ++--- packages/squad-cli/package.json | 4 +-- packages/squad-sdk/package.json | 2 +- 4 files changed, 33 insertions(+), 6 deletions(-) diff --git a/.github/workflows/squad-insider-publish.yml b/.github/workflows/squad-insider-publish.yml index c3b6c86d7..6a6e148a0 100644 --- a/.github/workflows/squad-insider-publish.yml +++ b/.github/workflows/squad-insider-publish.yml @@ -109,11 +109,38 @@ jobs: - name: Build packages run: npm -w packages/squad-sdk run build && npm -w packages/squad-cli run build + - name: Pin CLI SDK dependency to exact workspace version + run: | + SDK_VERSION=$(node -p "require('./packages/squad-sdk/package.json').version") + echo "Pinning CLI SDK dep to exact version: $SDK_VERSION" + cd packages/squad-cli + node -e " + const pkg = require('./package.json'); + pkg.dependencies['@bradygaster/squad-sdk'] = '$SDK_VERSION'; + require('fs').writeFileSync('./package.json', JSON.stringify(pkg, null, 2) + '\n'); + " + echo "Verified: $(node -p "require('./package.json').dependencies['@bradygaster/squad-sdk']")" + - name: Publish squad-sdk with insider tag run: npm -w packages/squad-sdk publish --tag insider --access public env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Verify SDK is available on registry + run: | + SDK_VERSION=$(node -p "require('./packages/squad-sdk/package.json').version") + echo "Waiting for @bradygaster/squad-sdk@$SDK_VERSION on npm..." + for i in 1 2 3 4 5; do + if npm view "@bradygaster/squad-sdk@$SDK_VERSION" version 2>/dev/null; then + echo "✅ SDK $SDK_VERSION is live on npm" + exit 0 + fi + echo "Attempt $i/5 — not yet visible, waiting 10s..." + sleep 10 + done + echo "::error::SDK $SDK_VERSION not visible on npm after 50s" + exit 1 + - name: Publish squad-cli with insider tag run: npm -w packages/squad-cli publish --tag insider --access public env: diff --git a/package-lock.json b/package-lock.json index 3f358a711..ca2ea9a9c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8901,11 +8901,11 @@ }, "packages/squad-cli": { "name": "@bradygaster/squad-cli", - "version": "0.9.1", + "version": "0.9.4-insider.1", "hasInstallScript": true, "license": "MIT", "dependencies": { - "@bradygaster/squad-sdk": ">=0.9.0", + "@bradygaster/squad-sdk": "0.9.4-insider.1", "ink": "^6.8.0", "react": "^19.2.4", "vscode-jsonrpc": "^8.2.1" @@ -9411,7 +9411,7 @@ }, "packages/squad-sdk": { "name": "@bradygaster/squad-sdk", - "version": "0.9.1", + "version": "0.9.4-insider.1", "license": "MIT", "dependencies": { "@github/copilot-sdk": "^0.1.32", diff --git a/packages/squad-cli/package.json b/packages/squad-cli/package.json index f4bc9a92b..6b8f4f190 100644 --- a/packages/squad-cli/package.json +++ b/packages/squad-cli/package.json @@ -1,6 +1,6 @@ { "name": "@bradygaster/squad-cli", - "version": "0.9.1", + "version": "0.9.4-insider.1", "description": "Squad CLI — Command-line interface for the Squad multi-agent runtime", "type": "module", "bin": { @@ -181,7 +181,7 @@ "node": ">=22.5.0" }, "dependencies": { - "@bradygaster/squad-sdk": ">=0.9.0", + "@bradygaster/squad-sdk": "0.9.4-insider.1", "ink": "^6.8.0", "react": "^19.2.4", "vscode-jsonrpc": "^8.2.1" diff --git a/packages/squad-sdk/package.json b/packages/squad-sdk/package.json index 3e9e3e184..61ec2e69f 100644 --- a/packages/squad-sdk/package.json +++ b/packages/squad-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@bradygaster/squad-sdk", - "version": "0.9.1", + "version": "0.9.4-insider.1", "description": "Squad SDK — Programmable multi-agent runtime for GitHub Copilot", "type": "module", "main": "./dist/index.js", From 9206b8926ea7101f1cf2e2eecd7ae7b0ab190043 Mon Sep 17 00:00:00 2001 From: Tamir Dresher Date: Wed, 8 Apr 2026 11:41:56 +0300 Subject: [PATCH 2/7] test: add cross-package export smoke test to catch missing imports (#875) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ci): add file list with line stats, scope badge, and check subtitles to PR readiness (#813) - Add file list table with per-file +additions/-deletions stats - Add PR scope classification (Product/Infrastructure/Mixed) - Rename Architectural Review and Security Review checks with descriptive subtitles Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * ci: scope boundary enforcement for repo-health PRs (#826) Add CI check that fails when repo-health PRs include product source code changes under packages/*/src/. Prevents scope creep where infrastructure PRs accidentally touch product code. - Add squad-scope-check.yml workflow - Document PR scope rules in copilot-instructions.md - Fail loudly on git diff errors instead of silently passing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * ci: smart PR nudge for stale PRs (#827) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: add cross-package export smoke test to catch missing imports Validates every value import squad-cli uses from squad-sdk resolves to a defined export at runtime. Covers 15 SDK subpaths and 50+ named exports including FSStorageProvider, SquadClient, CastingEngine, RalphMonitor, and all resolution/config/platform helpers. Also verifies that every entry in the SDK package.json exports map points to a file that actually exists on disk. Motivation: v0.9.3-insider.1 shipped with FSStorageProvider missing from the SDK barrel — broke users at runtime while all TS-level tests passed (TypeScript resolves from source, not compiled output). Refs: #836 --------- Co-authored-by: Dina Berry (MSFT) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot --- .changeset/readiness-file-list.md | 7 + .changeset/scope-boundary-check.md | 8 + .changeset/smart-pr-nudge.md | 8 + .github/copilot-instructions.md | 4 + .github/workflows/squad-pr-nudge.yml | 184 +++++++++++++++ .github/workflows/squad-repo-health.yml | 4 +- .github/workflows/squad-scope-check.yml | 31 +++ CONTRIBUTING.md | 2 + scripts/pr-readiness.mjs | 103 ++++++++- test/cross-package-exports.test.ts | 287 ++++++++++++++++++++++++ test/pr-readiness.test.ts | 249 +++++++++++++++++++- 11 files changed, 880 insertions(+), 7 deletions(-) create mode 100644 .changeset/readiness-file-list.md create mode 100644 .changeset/scope-boundary-check.md create mode 100644 .changeset/smart-pr-nudge.md create mode 100644 .github/workflows/squad-pr-nudge.yml create mode 100644 .github/workflows/squad-scope-check.yml create mode 100644 test/cross-package-exports.test.ts diff --git a/.changeset/readiness-file-list.md b/.changeset/readiness-file-list.md new file mode 100644 index 000000000..5cc36c951 --- /dev/null +++ b/.changeset/readiness-file-list.md @@ -0,0 +1,7 @@ +--- +--- + +ci: add file list with line stats to PR readiness comment + +The PR readiness bot now shows changed files with per-file addition/deletion +counts, scope classification (Product/Infrastructure/Mixed), and totals. diff --git a/.changeset/scope-boundary-check.md b/.changeset/scope-boundary-check.md new file mode 100644 index 000000000..6f98a8bd3 --- /dev/null +++ b/.changeset/scope-boundary-check.md @@ -0,0 +1,8 @@ +--- +--- + +ci: scope boundary enforcement for repo-health PRs + +New CI check that fails repo-health PRs if they modify product source +code under packages/*/src/. Enforces separation between infrastructure +and product changes. diff --git a/.changeset/smart-pr-nudge.md b/.changeset/smart-pr-nudge.md new file mode 100644 index 000000000..2144528df --- /dev/null +++ b/.changeset/smart-pr-nudge.md @@ -0,0 +1,8 @@ +--- +--- + +ci: add smart PR nudge for stale PRs + +New workflow that runs on weekdays and posts actionable diagnoses on PRs +stale for 7+ days. Checks CI status, unresolved threads, missing reviews, +outdated branches, and draft status. Won't nudge the same PR twice per week. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 5271c6c53..a99f6ced6 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -146,6 +146,10 @@ Any PR that modifies files under `packages/squad-cli/src/` or `packages/squad-sd - The `changelog-gate` CI check will fail without this - Escape hatch: add the `skip-changelog` label (use sparingly) +## Automated PR Nudge + +The **PR Nudge** workflow (`.github/workflows/squad-pr-nudge.yml`) runs on weekdays at 2pm UTC and posts actionable comments on open PRs that have been stale for 7+ days. It diagnoses specific blockers — failing CI checks, unresolved review threads, missing approvals, outdated branches, and draft status — so PR authors know exactly what to do next. Draft PRs get a 14-day grace period. The workflow won't nudge the same PR more than once per week. + ## Decisions If you make a decision that affects other team members, write it to: diff --git a/.github/workflows/squad-pr-nudge.yml b/.github/workflows/squad-pr-nudge.yml new file mode 100644 index 000000000..5e7c55738 --- /dev/null +++ b/.github/workflows/squad-pr-nudge.yml @@ -0,0 +1,184 @@ +name: PR Nudge +on: + schedule: + - cron: '0 14 * * 1-5' # 2pm UTC weekdays (morning US Pacific) + workflow_dispatch: {} # manual trigger for testing + +permissions: + contents: read + pull-requests: write + checks: read + issues: read + +jobs: + nudge-stale-prs: + name: "Nudge Stale PRs" + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v7 + with: + script: | + const STALE_DAYS = 7; + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - STALE_DAYS); + + // Get all open PRs, oldest-updated first + const prs = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + sort: 'updated', + direction: 'asc', + per_page: 50 + }); + + for (const pr of prs.data) { + // Skip PRs updated recently + const lastPush = new Date(pr.updated_at); + if (lastPush > cutoff) continue; + + // Give draft PRs 14 days grace period instead of 7 + if (pr.draft) { + const created = new Date(pr.created_at); + const draftCutoff = new Date(); + draftCutoff.setDate(draftCutoff.getDate() - 14); + if (created > draftCutoff) continue; + } + + // Don't nudge the same PR more than once per week + const comments = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + per_page: 5, + sort: 'created', + direction: 'desc' + }); + const recentNudge = comments.data.find(c => + c.user.login === 'github-actions[bot]' && + c.body.includes('') && + new Date(c.created_at) > cutoff + ); + if (recentNudge) continue; + + // Build the diagnosis — collect actionable items + const actions = []; + const daysSinceUpdate = Math.floor((Date.now() - lastPush) / (1000 * 60 * 60 * 24)); + + // 1. Check if still in draft + if (pr.draft) { + actions.push('📝 **Still in draft** — mark as "Ready for review" when you\'re done, or close if abandoned.'); + } + + // 2. Check CI status for failures + const checks = await github.rest.checks.listForRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: pr.head.sha, + per_page: 50 + }); + const failedChecks = checks.data.check_runs.filter(c => + c.conclusion === 'failure' + ).map(c => c.name); + if (failedChecks.length > 0) { + actions.push(`🔴 **${failedChecks.length} CI check(s) failing:** ${failedChecks.join(', ')}. Fix these first.`); + } + + // 3. Check for unresolved review threads (Copilot vs human) + const threads = await github.graphql(` + query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + reviewThreads(first: 50) { + nodes { + isResolved + isOutdated + comments(first: 1) { + nodes { author { login } } + } + } + } + } + } + } + `, { + owner: context.repo.owner, + repo: context.repo.repo, + number: pr.number + }); + const unresolvedThreads = threads.repository.pullRequest.reviewThreads.nodes + .filter(t => !t.isResolved && !t.isOutdated); + if (unresolvedThreads.length > 0) { + const copilotThreads = unresolvedThreads.filter(t => + t.comments.nodes[0]?.author?.login?.includes('copilot') + ); + const humanThreads = unresolvedThreads.length - copilotThreads.length; + const parts = []; + if (copilotThreads.length > 0) parts.push(`${copilotThreads.length} from Copilot`); + if (humanThreads > 0) parts.push(`${humanThreads} from reviewers`); + actions.push(`💬 **${unresolvedThreads.length} unresolved review thread(s)** (${parts.join(', ')}). Address and resolve them.`); + } + + // 4. Check review state (changes requested vs approved vs none) + const reviews = await github.rest.pulls.listReviews({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number + }); + const latestByUser = {}; + for (const r of reviews.data) { + if (r.state === 'COMMENTED') continue; + latestByUser[r.user.login] = r; + } + const changesRequested = Object.values(latestByUser).filter(r => r.state === 'CHANGES_REQUESTED'); + const approvals = Object.values(latestByUser).filter(r => r.state === 'APPROVED'); + if (changesRequested.length > 0) { + const reviewers = changesRequested.map(r => `@${r.user.login}`).join(', '); + actions.push(`🔄 **Changes requested** by ${reviewers}. Address their feedback and request re-review.`); + } else if (approvals.length === 0 && !pr.draft) { + actions.push('👀 **No approving reviews yet.** Request a review from a teammate.'); + } + + // 5. Check if branch is behind base + const comparison = await github.rest.repos.compareCommits({ + owner: context.repo.owner, + repo: context.repo.repo, + base: pr.head.sha, + head: pr.base.ref + }); + if (comparison.data.ahead_by > 10) { + actions.push(`⬇️ **${comparison.data.ahead_by} commits behind ${pr.base.ref}.** Rebase to pick up latest changes.`); + } + + // 6. If everything looks good and approved — it's ready to merge + if (actions.length === 0 && approvals.length > 0) { + actions.push('✅ **Looks ready to merge!** All checks pass, approved — just needs someone to click merge.'); + } + + // Fallback if no specific blockers found + if (actions.length === 0) { + actions.push('🤔 **No obvious blockers found** — but this PR has been quiet. Is it still active?'); + } + + // Post the nudge comment + const body = [ + '', + `👋 **Friendly nudge** — this PR has had no activity for **${daysSinceUpdate} days**.`, + '', + '**What needs attention:**', + ...actions.map(a => `- ${a}`), + '', + '---', + '*If this PR is abandoned, please close it. If it\'s blocked on something external, leave a comment so the team knows.*', + '*This is an automated check that runs on weekdays. It won\'t nudge the same PR more than once per week.*' + ].join('\n'); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + body: body + }); + + core.info(`Nudged PR #${pr.number}: ${pr.title} (${daysSinceUpdate} days stale, ${actions.length} action items)`); + } diff --git a/.github/workflows/squad-repo-health.yml b/.github/workflows/squad-repo-health.yml index a559fc353..4fd722927 100644 --- a/.github/workflows/squad-repo-health.yml +++ b/.github/workflows/squad-repo-health.yml @@ -113,7 +113,7 @@ jobs: # ─── Architectural Review (INFORMATIONAL) ─────────────────────────── architectural-review: - name: Architectural Review + name: Architectural Review — Structure & Design Rules runs-on: ubuntu-latest timeout-minutes: 5 if: github.actor != 'dependabot[bot]' @@ -150,7 +150,7 @@ jobs: # ─── Security Review (INFORMATIONAL) ──────────────────────────────── security-review: - name: Security Review + name: Security Review — Permissions & Secrets runs-on: ubuntu-latest timeout-minutes: 5 if: github.actor != 'dependabot[bot]' diff --git a/.github/workflows/squad-scope-check.yml b/.github/workflows/squad-scope-check.yml new file mode 100644 index 000000000..477dc19c5 --- /dev/null +++ b/.github/workflows/squad-scope-check.yml @@ -0,0 +1,31 @@ +name: Scope Check +on: + pull_request: + types: [opened, synchronize, reopened, labeled] + +permissions: + pull-requests: read + contents: read + +jobs: + scope-boundary: + name: "Scope Boundary" + runs-on: ubuntu-latest + if: >- + startsWith(github.head_ref, 'repo-health/') || + contains(github.event.pull_request.labels.*.name, 'repo-health') + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Check for product code in repo-health PR + run: | + PRODUCT_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD -- 'packages/squad-cli/src/' 'packages/squad-sdk/src/') + if [ -n "$PRODUCT_FILES" ]; then + echo "::error::Repo-health PRs must not modify product source code." + echo "::error::The following product files were changed:" + echo "$PRODUCT_FILES" | while read -r f; do echo "::error:: - $f"; done + echo "::error::Move product changes to a separate PR." + exit 1 + fi + echo "✅ No product source files in this repo-health PR." diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d64591dbc..ff58fd677 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -170,6 +170,8 @@ An automated readiness check runs on every PR and posts a checklist comment. Add | **No merge conflicts** | Resolve any conflicts with the target branch | | **CI passing** | All CI checks (build, test, lint) must be green | +The readiness comment also includes a **file list with line stats** — each changed file is shown with per-file addition/deletion counts, a scope classification (Product/Infrastructure/Mixed), and totals. This helps reviewers quickly gauge PR size and impact. + The readiness check is **informational** — it helps you self-serve before a human reviewer looks at your PR. It automatically re-runs after Squad CI completes, so the checklist stays up to date without manual intervention. See `.github/PR_REQUIREMENTS.md` for the full requirements spec. ## Code Style & Conventions diff --git a/scripts/pr-readiness.mjs b/scripts/pr-readiness.mjs index 20ca9d851..7b49cbdd5 100644 --- a/scripts/pr-readiness.mjs +++ b/scripts/pr-readiness.mjs @@ -246,6 +246,86 @@ export function checkCIStatus(checkRuns, statuses) { 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 // --------------------------------------------------------------------------- @@ -257,9 +337,10 @@ export function checkCIStatus(checkRuns, statuses) { * @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) { +export function buildChecklist(checks, owner, repo, baseRef, headSha, files) { const allPass = checks.every((c) => c.pass); const passCount = checks.filter((c) => c.pass).length; @@ -272,23 +353,37 @@ export function buildChecklist(checks, owner, repo, baseRef, headSha) { return `| ${icon} | **${c.name}** | ${c.detail} |`; }); - return [ + 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.*`, - ].join('\n'); + ); + + return sections.join('\n'); } // --------------------------------------------------------------------------- @@ -492,7 +587,7 @@ export async function run({ env = process.env, fetchFn = globalThis.fetch } = {} checks.push({ name: 'CI passing', ...checkCIStatus(checkRuns, statusEntries) }); // ── Build checklist and upsert comment ── - const body = buildChecklist(checks, owner, repo, prBaseRef, prHeadSha); + const body = buildChecklist(checks, owner, repo, prBaseRef, prHeadSha, files); // Find existing comment const existingComments = await paginate( diff --git a/test/cross-package-exports.test.ts b/test/cross-package-exports.test.ts new file mode 100644 index 000000000..613aafe6a --- /dev/null +++ b/test/cross-package-exports.test.ts @@ -0,0 +1,287 @@ +/** + * Cross-package export smoke test + * + * Validates that every value import squad-cli uses from squad-sdk actually + * exists at runtime. TypeScript can resolve from source during development, + * but the compiled npm output may diverge (missing re-exports, renamed files, + * ESM/CJS mismatches). This test catches that class of bug. + * + * How it works: + * For each SDK subpath the CLI imports from, we dynamically import the + * module and assert every named export the CLI relies on is defined. + * + * Maintenance: + * When a new import from @bradygaster/squad-sdk is added to squad-cli, + * add a corresponding assertion here. The grep one-liner in the test + * description shows how to audit. + * + * Related incident: v0.9.3-insider.1 shipped with FSStorageProvider missing + * from the SDK barrel — broke users at runtime while tests passed locally. + */ + +import { describe, it, expect } from 'vitest'; +import { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; + +// ─── Helper ────────────────────────────────────────────────────────────── + +/** Assert a set of named exports exist on a module. */ +function expectExports(mod: Record, names: string[], subpath: string) { + for (const name of names) { + expect(mod[name], `"${name}" should be exported from ${subpath}`).toBeDefined(); + } +} + +// ─── Root barrel: @bradygaster/squad-sdk ───────────────────────────────── + +describe('cross-package exports — CLI → SDK', () => { + describe('@bradygaster/squad-sdk (root barrel)', () => { + it('exports FSStorageProvider and core runtime symbols', async () => { + const sdk = await import('@bradygaster/squad-sdk'); + expectExports(sdk, [ + 'FSStorageProvider', + 'SquadState', + 'TIMEOUTS', + 'StreamingPipeline', + 'RuntimeEventBus', + 'resolveSquad', + 'resolveGlobalSquadPath', + 'initSquadTelemetry', + 'recordAgentSpawn', + 'recordAgentDuration', + 'recordAgentError', + 'recordAgentDestroy', + 'safeTimestamp', + 'getMeter', + ], '@bradygaster/squad-sdk'); + }); + + it('exports role helpers', async () => { + const sdk = await import('@bradygaster/squad-sdk'); + expectExports(sdk, [ + 'listRoles', + 'searchRoles', + 'getCategories', + 'getRoleById', + 'generateCharterFromRole', + 'addAgentToConfig', + ], '@bradygaster/squad-sdk'); + }); + + it('exports init / personal-squad helpers', async () => { + const sdk = await import('@bradygaster/squad-sdk'); + expectExports(sdk, [ + 'initSquad', + 'cleanupOrphanInitPrompt', + 'ensurePersonalSquadDir', + 'resolvePersonalSquadDir', + ], '@bradygaster/squad-sdk'); + }); + + it('exports external-state helpers', async () => { + const sdk = await import('@bradygaster/squad-sdk'); + expectExports(sdk, [ + 'resolveExternalStateDir', + 'deriveProjectKey', + ], '@bradygaster/squad-sdk'); + }); + + it('exports consult-mode helpers', async () => { + const sdk = await import('@bradygaster/squad-sdk'); + expectExports(sdk, [ + 'setupConsultMode', + 'isConsultMode', + 'PersonalSquadNotFoundError', + 'detectLicense', + 'loadStagedLearnings', + 'logConsultation', + 'mergeToPersonalSquad', + 'getPersonalSquadRoot', + ], '@bradygaster/squad-sdk'); + }); + + it('exports cross-squad helpers', async () => { + const sdk = await import('@bradygaster/squad-sdk'); + expectExports(sdk, [ + 'discoverSquads', + 'formatDiscoveryTable', + 'findSquadByName', + 'buildDelegationArgs', + 'loadSubSquadsConfig', + 'resolveSubSquad', + ], '@bradygaster/squad-sdk'); + }); + + it('exports RemoteBridge', async () => { + const sdk = await import('@bradygaster/squad-sdk'); + expectExports(sdk, ['RemoteBridge'], '@bradygaster/squad-sdk'); + }); + }); + + // ─── Subpath: /config ────────────────────────────────────────────────── + + describe('@bradygaster/squad-sdk/config', () => { + it('exports config helpers used by CLI', async () => { + const mod = await import('@bradygaster/squad-sdk/config'); + expectExports(mod, [ + 'initSquad', + 'MigrationRegistry', + 'writeEconomyMode', + 'readEconomyMode', + ], '@bradygaster/squad-sdk/config'); + }); + }); + + // ─── Subpath: /config/agent-source ───────────────────────────────────── + + describe('@bradygaster/squad-sdk/config/agent-source', () => { + it('exports LocalAgentSource', async () => { + const mod = await import('@bradygaster/squad-sdk/config/agent-source'); + expectExports(mod, ['LocalAgentSource'], '@bradygaster/squad-sdk/config/agent-source'); + }); + }); + + // ─── Subpath: /resolution ────────────────────────────────────────────── + + describe('@bradygaster/squad-sdk/resolution', () => { + it('exports resolution helpers', async () => { + const mod = await import('@bradygaster/squad-sdk/resolution'); + expectExports(mod, [ + 'resolveSquad', + 'resolveSquadPaths', + 'resolveGlobalSquadPath', + 'resolvePersonalSquadDir', + 'ensurePersonalSquadDir', + ], '@bradygaster/squad-sdk/resolution'); + }); + }); + + // ─── Subpath: /client ────────────────────────────────────────────────── + + describe('@bradygaster/squad-sdk/client', () => { + it('exports SquadClient', async () => { + const mod = await import('@bradygaster/squad-sdk/client'); + expectExports(mod, ['SquadClient'], '@bradygaster/squad-sdk/client'); + }); + }); + + // ─── Subpath: /adapter/errors ────────────────────────────────────────── + + describe('@bradygaster/squad-sdk/adapter/errors', () => { + it('exports RateLimitError', async () => { + const mod = await import('@bradygaster/squad-sdk/adapter/errors'); + expectExports(mod, ['RateLimitError'], '@bradygaster/squad-sdk/adapter/errors'); + }); + }); + + // ─── Subpath: /agents/personal ───────────────────────────────────────── + + describe('@bradygaster/squad-sdk/agents/personal', () => { + it('exports personal-agent helpers', async () => { + const mod = await import('@bradygaster/squad-sdk/agents/personal'); + expectExports(mod, [ + 'resolvePersonalAgents', + 'mergeSessionCast', + ], '@bradygaster/squad-sdk/agents/personal'); + }); + }); + + // ─── Subpath: /casting ───────────────────────────────────────────────── + + describe('@bradygaster/squad-sdk/casting', () => { + it('exports CastingEngine', async () => { + const mod = await import('@bradygaster/squad-sdk/casting'); + expectExports(mod, ['CastingEngine'], '@bradygaster/squad-sdk/casting'); + }); + }); + + // ─── Subpath: /platform ──────────────────────────────────────────────── + + describe('@bradygaster/squad-sdk/platform', () => { + it('exports createPlatformAdapter', async () => { + const mod = await import('@bradygaster/squad-sdk/platform'); + expectExports(mod, ['createPlatformAdapter'], '@bradygaster/squad-sdk/platform'); + }); + }); + + // ─── Subpath: /ralph ─────────────────────────────────────────────────── + + describe('@bradygaster/squad-sdk/ralph', () => { + it('exports RalphMonitor', async () => { + const mod = await import('@bradygaster/squad-sdk/ralph'); + expectExports(mod, ['RalphMonitor'], '@bradygaster/squad-sdk/ralph'); + }); + }); + + describe('@bradygaster/squad-sdk/ralph/triage', () => { + it('exports triage helpers', async () => { + const mod = await import('@bradygaster/squad-sdk/ralph/triage'); + expectExports(mod, [ + 'parseRoster', + 'parseRoutingRules', + 'parseModuleOwnership', + 'triageIssue', + ], '@bradygaster/squad-sdk/ralph/triage'); + }); + }); + + describe('@bradygaster/squad-sdk/ralph/rate-limiting', () => { + it('exports rate-limiting helpers', async () => { + const mod = await import('@bradygaster/squad-sdk/ralph/rate-limiting'); + expectExports(mod, [ + 'PredictiveCircuitBreaker', + 'getTrafficLight', + ], '@bradygaster/squad-sdk/ralph/rate-limiting'); + }); + }); + + // ─── Subpath: /runtime/* ─────────────────────────────────────────────── + + describe('@bradygaster/squad-sdk/runtime/event-bus', () => { + it('exports EventBus', async () => { + const mod = await import('@bradygaster/squad-sdk/runtime/event-bus'); + expectExports(mod, ['EventBus'], '@bradygaster/squad-sdk/runtime/event-bus'); + }); + }); + + // ─── SDK exports-map file resolution ─────────────────────────────────── + + describe('SDK package.json exports map → file existence', () => { + it('every exports-map entry points to an existing file', async () => { + const fs = await import('node:fs'); + + // In a workspace monorepo the SDK lives at packages/squad-sdk. + // In CI / installed scenarios, find it under node_modules. + const candidates = [ + resolve(process.cwd(), 'packages', 'squad-sdk'), + resolve(process.cwd(), 'node_modules', '@bradygaster', 'squad-sdk'), + ]; + const sdkRoot = candidates.find( + (p) => existsSync(resolve(p, 'package.json')), + ); + expect(sdkRoot, 'Could not locate SDK package.json').toBeDefined(); + + const pkg = JSON.parse( + fs.readFileSync(resolve(sdkRoot!, 'package.json'), 'utf8'), + ); + const exportsMap = pkg.exports as Record>; + const missing: string[] = []; + + for (const [subpath, targets] of Object.entries(exportsMap)) { + if (typeof targets === 'string') { + if (!existsSync(resolve(sdkRoot!, targets))) { + missing.push(`${subpath} → ${targets}`); + } + continue; + } + for (const [condition, file] of Object.entries(targets)) { + if (!existsSync(resolve(sdkRoot!, file))) { + missing.push(`${subpath}[${condition}] → ${file}`); + } + } + } + + expect(missing, `Missing files in SDK exports map:\n${missing.join('\n')}`).toEqual([]); + }); + }); +}); diff --git a/test/pr-readiness.test.ts b/test/pr-readiness.test.ts index 05333c723..c78425cfe 100644 --- a/test/pr-readiness.test.ts +++ b/test/pr-readiness.test.ts @@ -17,6 +17,10 @@ import { checkCopilotThreads, checkCIStatus, buildChecklist, + buildFileList, + sanitizeFilename, + MAX_FILE_LIST, + classifyScope, paginate, run, COMMENT_MARKER, @@ -495,6 +499,229 @@ describe('buildChecklist', () => { expect(body).toContain('| ✅ | **A** | OK |'); expect(body).toContain('| ❌ | **B** | Bad |'); }); + + it('includes file list when files are provided', () => { + const checks = [{ name: 'Test', pass: true, detail: 'OK' }]; + const files = [ + { filename: 'src/index.ts', additions: 10, deletions: 3 }, + { filename: 'README.md', additions: 2, deletions: 1 }, + ]; + const body = buildChecklist(checks, 'o', 'r', 'dev', undefined, files); + expect(body).toContain('### Files Changed (2 files, +12 −4)'); + expect(body).toContain('| `src/index.ts` | +10 −3 |'); + expect(body).toContain('| `README.md` | +2 −1 |'); + expect(body).toContain('**Total: +12 −4**'); + }); + + it('omits file list when files are undefined', () => { + const checks = [{ name: 'Test', pass: true, detail: 'OK' }]; + const body = buildChecklist(checks, 'o', 'r', 'dev'); + expect(body).not.toContain('Files Changed'); + }); + + it('omits file list when files array is empty', () => { + const checks = [{ name: 'Test', pass: true, detail: 'OK' }]; + const body = buildChecklist(checks, 'o', 'r', 'dev', undefined, []); + expect(body).not.toContain('Files Changed'); + }); +}); + +// --------------------------------------------------------------------------- +// buildFileList +// --------------------------------------------------------------------------- + +describe('buildFileList', () => { + it('returns empty string for null/undefined input', () => { + expect(buildFileList(null)).toBe(''); + expect(buildFileList(undefined)).toBe(''); + }); + + it('returns empty string for empty array', () => { + expect(buildFileList([])).toBe(''); + }); + + it('renders a single file with correct stats', () => { + const files = [{ filename: 'scripts/moderate-spam.mjs', additions: 142, deletions: 0 }]; + const result = buildFileList(files); + expect(result).toContain('### Files Changed (1 file, +142 −0)'); + expect(result).toContain('| `scripts/moderate-spam.mjs` | +142 −0 |'); + expect(result).toContain('**Total: +142 −0**'); + }); + + it('renders multiple files with totals', () => { + const files = [ + { filename: 'scripts/moderate-spam.mjs', additions: 142, deletions: 0 }, + { filename: 'test/scripts/moderate-spam.test.ts', additions: 98, deletions: 0 }, + { filename: '.github/workflows/squad-comment-moderation.yml', additions: 45, deletions: 0 }, + ]; + const result = buildFileList(files); + expect(result).toContain('### Files Changed (3 files, +285 −0)'); + expect(result).toContain('| `scripts/moderate-spam.mjs` | +142 −0 |'); + expect(result).toContain('| `test/scripts/moderate-spam.test.ts` | +98 −0 |'); + expect(result).toContain('| `.github/workflows/squad-comment-moderation.yml` | +45 −0 |'); + expect(result).toContain('**Total: +285 −0**'); + }); + + it('handles files with 0 additions and 0 deletions', () => { + const files = [{ filename: 'empty-change.ts', additions: 0, deletions: 0 }]; + const result = buildFileList(files); + expect(result).toContain('| `empty-change.ts` | +0 −0 |'); + expect(result).toContain('**Total: +0 −0**'); + }); + + it('handles files with both additions and deletions', () => { + const files = [ + { filename: 'src/refactored.ts', additions: 50, deletions: 30 }, + { filename: 'src/old.ts', additions: 0, deletions: 100 }, + ]; + const result = buildFileList(files); + expect(result).toContain('### Files Changed (2 files, +50 −130)'); + expect(result).toContain('| `src/refactored.ts` | +50 −30 |'); + expect(result).toContain('| `src/old.ts` | +0 −100 |'); + expect(result).toContain('**Total: +50 −130**'); + }); + + it('treats missing additions/deletions as 0', () => { + const files = [{ filename: 'binary-file.png' }]; + const result = buildFileList(files); + expect(result).toContain('| `binary-file.png` | +0 −0 |'); + expect(result).toContain('**Total: +0 −0**'); + }); + + it('uses singular "file" for single file', () => { + const files = [{ filename: 'one.ts', additions: 1, deletions: 0 }]; + const result = buildFileList(files); + expect(result).toContain('1 file,'); + expect(result).not.toContain('1 files,'); + }); + + it('includes table headers', () => { + const files = [{ filename: 'a.ts', additions: 1, deletions: 0 }]; + const result = buildFileList(files); + expect(result).toContain('| File | +/− |'); + expect(result).toContain('|------|-----|'); + }); + + it('truncates file list beyond MAX_FILE_LIST and shows summary row', () => { + const files = Array.from({ length: 60 }, (_, i) => ({ + filename: `src/file-${i}.ts`, + additions: 1, + deletions: 0, + })); + const result = buildFileList(files); + // Header should show the total count (60), not the truncated count + expect(result).toContain('### Files Changed (60 files, +60 −0)'); + // First 50 files should be present + expect(result).toContain('`src/file-0.ts`'); + expect(result).toContain('`src/file-49.ts`'); + // File 50 should NOT be present + expect(result).not.toContain('`src/file-50.ts`'); + // Summary row + expect(result).toContain('**+10 more files**'); + // Totals should include ALL files + expect(result).toContain('**Total: +60 −0**'); + }); + + it('does not truncate when file count equals MAX_FILE_LIST', () => { + const files = Array.from({ length: MAX_FILE_LIST }, (_, i) => ({ + filename: `src/file-${i}.ts`, + additions: 1, + deletions: 0, + })); + const result = buildFileList(files); + expect(result).not.toContain('more files'); + expect(result).toContain(`${MAX_FILE_LIST} files`); + }); + + it('sanitizes pipe characters in filenames', () => { + const files = [{ filename: 'path/with|pipe.ts', additions: 1, deletions: 0 }]; + const result = buildFileList(files); + expect(result).toContain("path/with\\|pipe.ts"); + expect(result).not.toContain('| path/with|pipe.ts'); + }); + + it('sanitizes backticks in filenames', () => { + const files = [{ filename: 'file`name.ts', additions: 1, deletions: 0 }]; + const result = buildFileList(files); + expect(result).toContain("file'name.ts"); + }); + + it('sanitizes newlines in filenames', () => { + const files = [{ filename: 'file\nname.ts', additions: 1, deletions: 0 }]; + const result = buildFileList(files); + expect(result).toContain('file name.ts'); + // The sanitized filename should not contain the literal newline + expect(result).not.toContain('file\nname.ts'); + }); +}); + +// --------------------------------------------------------------------------- +// sanitizeFilename +// --------------------------------------------------------------------------- + +describe('sanitizeFilename', () => { + it('escapes pipe characters', () => { + expect(sanitizeFilename('a|b|c')).toBe('a\\|b\\|c'); + }); + + it('replaces backticks with single quotes', () => { + expect(sanitizeFilename('file`name`test')).toBe("file'name'test"); + }); + + it('replaces newlines with spaces', () => { + expect(sanitizeFilename('line1\nline2\r\nline3')).toBe('line1 line2 line3'); + }); + + it('handles all special characters together', () => { + expect(sanitizeFilename('a|b`c\nd')).toBe("a\\|b'c d"); + }); + + it('returns normal filenames unchanged', () => { + expect(sanitizeFilename('src/components/App.tsx')).toBe('src/components/App.tsx'); + }); +}); + +// --------------------------------------------------------------------------- +// classifyScope +// --------------------------------------------------------------------------- + +describe('classifyScope', () => { + it('returns Infrastructure for only infrastructure files', () => { + const files = [ + { filename: '.github/workflows/ci.yml' }, + { filename: 'scripts/build.mjs' }, + { filename: 'test/foo.test.ts' }, + ]; + const result = classifyScope(files); + expect(result.label).toBe('Infrastructure'); + expect(result.emoji).toBe('🔧'); + }); + + it('returns Product for only product source files', () => { + const files = [ + { filename: 'packages/squad-sdk/src/index.ts' }, + { filename: 'packages/squad-cli/src/main.ts' }, + ]; + const result = classifyScope(files); + expect(result.label).toBe('Product'); + expect(result.emoji).toBe('📦'); + }); + + it('returns Mixed for both product and infrastructure files', () => { + const files = [ + { filename: 'packages/squad-sdk/src/index.ts' }, + { filename: 'scripts/build.mjs' }, + ]; + const result = classifyScope(files); + expect(result.label).toBe('Mixed (product + infrastructure)'); + expect(result.emoji).toBe('📦🔧'); + }); + + it('returns Infrastructure for empty array', () => { + const result = classifyScope([]); + expect(result.label).toBe('Infrastructure'); + expect(result.emoji).toBe('🔧'); + }); }); // --------------------------------------------------------------------------- @@ -596,7 +823,7 @@ describe('run()', () => { commits: [{ sha: 'abc123' }], compare: { behind_by: 0 }, reviews: [{ user: { login: 'copilot-pull-request-reviewer' }, state: 'APPROVED', submitted_at: '2025-01-01T00:00:00Z' }], - files: [{ filename: '.changeset/feat.md' }], + files: [{ filename: '.changeset/feat.md', additions: 5, deletions: 0 }], pr: { mergeable: true }, checkRuns: { check_runs: [{ name: 'build', conclusion: 'success', status: 'completed' }] }, status: { statuses: [{ state: 'success' }] }, @@ -861,4 +1088,24 @@ describe('run()', () => { expect(draftCheck.pass).toBe(false); expect(draftCheck.detail).toContain('draft'); }); + + it('includes file list with line stats in upserted comment', async () => { + const mockFetch = createMockFetch({ + files: [ + { filename: 'src/index.ts', additions: 25, deletions: 10 }, + { filename: 'test/index.test.ts', additions: 50, deletions: 0 }, + ], + }); + await run({ env: baseEnv, fetchFn: mockFetch }); + + const postCall = mockFetch.mock.calls.find( + ([url, opts]) => opts && opts.method === 'POST' && url.includes('/comments'), + ); + expect(postCall).toBeDefined(); + const body = JSON.parse(postCall[1].body).body; + expect(body).toContain('### Files Changed (2 files, +75 −10)'); + expect(body).toContain('| `src/index.ts` | +25 −10 |'); + expect(body).toContain('| `test/index.test.ts` | +50 −0 |'); + expect(body).toContain('**Total: +75 −10**'); + }); }); From 9451e66c8d6cb7dfe835c3ecbb2f0af6f7ae30ed Mon Sep 17 00:00:00 2001 From: Tamir Dresher Date: Wed, 8 Apr 2026 12:01:50 +0300 Subject: [PATCH 3/7] feat(cli): deprecation warnings for tunnel, rc, and REPL commands (#900) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(cli): add deprecation warnings for tunnel, rc, and REPL commands Adds visible deprecation warnings to: - Interactive REPL shell (squad with no args) - squad start (and --tunnel flag) - squad rc / remote-control - squad rc-tunnel Phase 1: warnings only — no behavior changes. Commands still work but now emit a yellow deprecation notice pointing users to the GitHub Copilot CLI as the replacement. Help text updated to show [DEPRECATED] tags on affected commands. Closes #899 Related: #665 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore: add changeset for tunnel/rc/REPL deprecation warnings Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: shorten deprecation hint to fit 80-char UX gate Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .changeset/deprecate-tunnel-rc-repl.md | 5 +++++ packages/squad-cli/src/cli-entry.ts | 22 +++++++++++++++------- 2 files changed, 20 insertions(+), 7 deletions(-) create mode 100644 .changeset/deprecate-tunnel-rc-repl.md diff --git a/.changeset/deprecate-tunnel-rc-repl.md b/.changeset/deprecate-tunnel-rc-repl.md new file mode 100644 index 000000000..93b3e3614 --- /dev/null +++ b/.changeset/deprecate-tunnel-rc-repl.md @@ -0,0 +1,5 @@ +--- +"@bradygaster/squad-cli": patch +--- + +Add deprecation warnings for tunnel, rc, and REPL commands. The interactive shell (no-args), `squad start`, `squad start --tunnel`, `squad rc`, and `squad rc-tunnel` now emit yellow deprecation notices pointing users to the GitHub Copilot CLI. No behavior changes — all commands still work. diff --git a/packages/squad-cli/src/cli-entry.ts b/packages/squad-cli/src/cli-entry.ts index 421baca46..c20d92c00 100644 --- a/packages/squad-cli/src/cli-entry.ts +++ b/packages/squad-cli/src/cli-entry.ts @@ -150,7 +150,7 @@ async function main(): Promise { 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(` ${BOLD}(default)${RESET} Launch interactive shell (no args) ${YELLOW}[DEPRECATED]${RESET}`); 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)`); @@ -209,12 +209,10 @@ async function main(): Promise { 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(` ${BOLD}start${RESET} Start Copilot with remote access from phone/browser ${YELLOW}[DEPRECATED]${RESET}`); 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`); - console.log(` start --tunnel --command "gh copilot"`); + console.log(` ${DIM}⚠ Deprecated: will be removed in a future release.${RESET}`); 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)`); @@ -239,12 +237,12 @@ async function main(): Promise { console.log(` Usage: personal init | list | add `); console.log(` --role | remove `); console.log(` ${BOLD}cast${RESET} Show current session cast (project + personal agents)`); - console.log(` ${BOLD}rc${RESET} Start Remote Control bridge (phone/browser → Copilot)`); + console.log(` ${BOLD}rc${RESET} Start Remote Control bridge (phone/browser → Copilot) ${YELLOW}[DEPRECATED]${RESET}`); 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}rc-tunnel${RESET} Check devtunnel CLI availability ${YELLOW}[DEPRECATED]${RESET}`); 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 `); @@ -273,6 +271,8 @@ async function main(): Promise { // No args → launch interactive shell; whitespace-only arg → show help if (rawCmd === undefined) { + console.log(`\n${YELLOW}⚠ DEPRECATED:${RESET} The interactive REPL shell is deprecated and will be removed in a future release.`); + console.log(` Use the GitHub Copilot CLI instead: ${BOLD}gh copilot${RESET} (squad.agent.md is picked up automatically)\n`); // 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(); @@ -708,8 +708,13 @@ async function main(): Promise { } 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}copilot${RESET} or ${BOLD}gh copilot${RESET}\n`); const { runStart } = await import('./cli/commands/start.js'); const hasTunnel = args.includes('--tunnel'); + if (hasTunnel) { + console.log(`${YELLOW}⚠ DEPRECATED:${RESET} --tunnel is deprecated and will be removed in a future release.\n`); + } 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 @@ -789,6 +794,8 @@ async function main(): Promise { } 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}copilot${RESET} or ${BOLD}gh copilot${RESET}\n`); const { runRC } = await import('./cli/commands/rc.js'); const hasTunnel = args.includes('--tunnel'); const portIdx = args.indexOf('--port'); @@ -823,6 +830,7 @@ async function main(): Promise { } if (cmd === 'rc-tunnel') { + console.log(`\n${YELLOW}⚠ DEPRECATED:${RESET} "squad rc-tunnel" is deprecated and will be removed in a future release.\n`); const { isDevtunnelAvailable } = await import('./cli/commands/rc-tunnel.js'); if (isDevtunnelAvailable()) { console.log(`${GREEN}✓${RESET} devtunnel CLI is available`); From 6e72c8a596b4da36b99cad7d4fcc8d8347bb0758 Mon Sep 17 00:00:00 2001 From: Tamir Dresher Date: Wed, 8 Apr 2026 12:07:29 +0300 Subject: [PATCH 4/7] =?UTF-8?q?feat:=20APM=20integration=20=E2=80=94=20squ?= =?UTF-8?q?ad=20skill=20publish/install=20+=20apm.yml=20in=20init=20(#876)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add enforcement wiring step to hiring process + workflow wiring guide (#592) Fixes #591 - Added step 7 (Wire enforcement) to Adding Team Members in squad.agent.md - Added workflow-wiring-guide.md with configuration surface area, wiring instructions, common mistakes, and verification checklist - Added appendix walkthroughs for code reviewer (gate pattern) and documenter (follow-up trigger pattern) Co-authored-by: Jonathan Ben Ami Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(agents): add Challenger / Devil's Advocate agent template + fact-checking skill (#603) * feat(skills): add fact-checking skill\n\nAdds challenger/fact-checking review pattern.\nVerified against 200+ issues in production squads.\nCloses #598 * feat(agents): add challenger agent charter template\n\nGeneric Devil's Advocate / Challenger template.\nProvides auto-spawn integration pattern for coordinators.\nCloses #598 * feat: add APM integration for skill publishing and installation Closes bradygaster/squad#824 ## Changes ### New command: squad skill - squad skill publish [] — exports skill(s) to APM format, generating/updating apm.yml - squad skill install — installs a skill from APM registry - Supports owner/repo, owner/repo/skill-name, and direct URLs - Uses GitHub CLI to fetch from repos that have apm.yml or .squad/skills/ - Writes .apm-source.json metadata to track skill origin - squad skill list — lists installed skills with source provenance ### Updated: squad init - Now generates pm.yml at project root alongside .squad/ - Follows skipExisting semantics (safe to re-run) - apm.yml includes skills, instructions, and prompts sections ### Updated: squad help - Added skill command to help text with usage examples ## APM format apm.yml is the Agent Package Manager manifest — package.json for AI agent context. See: https://github.com/microsoft/apm The manifest declares skills, instructions, and prompts in a portable format that pm install can deploy to .github/, .claude/, .cursor/ etc. * chore: add changeset for APM integration * docs: update CHANGELOG.md with APM integration entry * fix(skill): use .copilot/skills/ as primary path per #430 The skills unification in #430 migrated skills from .squad/skills/ to .copilot/skills/. This updates the APM skill command to: - Check .copilot/skills/ first, fall back to .squad/skills/ (backward compat) - Use resolveSkillsDir() helper matching LocalSkillSource pattern - Update all user-facing messages and apm.yml template paths - Fix installSkillsFromSquadDir candidate order Addresses review feedback from @Meir017. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: align CHANGELOG.md with dev branch Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: add missing fs import in init.ts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: joniba Co-authored-by: Jonathan Ben Ami Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot Co-authored-by: Copilot --- .changeset/apm-integration.md | 7 + .squad-templates/squad.agent.md | 3 +- ...orkflow-wiring-appendix-a-code-reviewer.md | 131 ++++ .../workflow-wiring-appendix-b-documenter.md | 140 +++++ .squad-templates/workflow-wiring-guide.md | 276 +++++++++ .squad/skills/fact-checking/SKILL.md | 61 ++ .squad/templates/agents/challenger.md | 72 +++ packages/squad-cli/src/cli-entry.ts | 10 + packages/squad-cli/src/cli/commands/skill.ts | 568 ++++++++++++++++++ packages/squad-cli/src/cli/core/init.ts | 56 ++ packages/squad-cli/src/cli/index.ts | 2 + 11 files changed, 1325 insertions(+), 1 deletion(-) create mode 100644 .changeset/apm-integration.md create mode 100644 .squad-templates/workflow-wiring-appendix-a-code-reviewer.md create mode 100644 .squad-templates/workflow-wiring-appendix-b-documenter.md create mode 100644 .squad-templates/workflow-wiring-guide.md create mode 100644 .squad/skills/fact-checking/SKILL.md create mode 100644 .squad/templates/agents/challenger.md create mode 100644 packages/squad-cli/src/cli/commands/skill.ts diff --git a/.changeset/apm-integration.md b/.changeset/apm-integration.md new file mode 100644 index 000000000..4d8169003 --- /dev/null +++ b/.changeset/apm-integration.md @@ -0,0 +1,7 @@ +--- +"squad": minor +--- + +feat: APM integration — squad skill publish/install + apm.yml in init + +Implements #824. Adds `squad skill publish/install/list` commands and generates `apm.yml` on `squad init`. diff --git a/.squad-templates/squad.agent.md b/.squad-templates/squad.agent.md index 1a3813a33..df1b1347a 100644 --- a/.squad-templates/squad.agent.md +++ b/.squad-templates/squad.agent.md @@ -915,7 +915,8 @@ If the user says "I need a designer" or "add someone for DevOps": 4. **Update `.squad/casting/registry.json`** with the new agent entry. 5. Add to team.md roster. 6. Add routing entries to routing.md. -7. Say: *"✅ {CastName} joined the team as {Role}."* +7. **Wire enforcement (if applicable).** If the new member's role involves gating other agents' work (reviewer, design approver, quality gate), add a numbered enforcement rule to `routing.md` → Rules section. A routing table entry (step 6) only handles explicit requests — enforcement rules are required for automatic gates. Read `.squad/templates/workflow-wiring-guide.md` for the full wiring process, including walkthroughs for common role types (code reviewer, documenter). Check `.squad/templates/issue-lifecycle.md` for lifecycle integration if the project uses PR-gated workflows. +8. Say: *"✅ {CastName} joined the team as {Role}."* ### Removing Team Members diff --git a/.squad-templates/workflow-wiring-appendix-a-code-reviewer.md b/.squad-templates/workflow-wiring-appendix-a-code-reviewer.md new file mode 100644 index 000000000..324e0ef60 --- /dev/null +++ b/.squad-templates/workflow-wiring-appendix-a-code-reviewer.md @@ -0,0 +1,131 @@ +# Appendix A: Wiring a Code Reviewer — Complete Walkthrough + +> End-to-end example of adding a code reviewer to your squad and wiring their gate so it actually gets enforced. This walkthrough addresses a common failure: a reviewer is on the roster but never reviews a single PR because the gate wasn't wired. + +## The Problem This Solves + +Adding a reviewer to `team.md` gives them an identity. It does NOT: +- Tell the coordinator to route PRs to them +- Prevent PRs from being merged without their approval +- Prevent issues from being closed before review happens + +**What goes wrong without enforcement:** A reviewer can be on the roster as "Reviewer" from day one. Their charter says they review PRs. The routing table says "PR code review → {ReviewerName}." But PRs get merged and issues get closed without them ever being spawned. Why? + +Because the routing table says WHO handles what — it's for incoming requests ("review PR #42"). It does NOT say "after every agent completes work, route their output to {ReviewerName}." The coordinator routes work TO agents, but nothing tells it to route COMPLETED work to a reviewer. The "After Agent Work" flow in `squad.agent.md` says: collect results → present → spawn Scribe. No review step. + +**The fix has three layers:** + +| Layer | What it does | Where it lives | +|-------|-------------|----------------| +| Identity | Reviewer exists and knows how to review | `team.md` roster + `charter.md` | +| Routing | User can explicitly request "review this" | `routing.md` routing table | +| **Enforcement** | Coordinator MUST route every PR to reviewer before merge | `routing.md` Rules section + `issue-lifecycle.md` post-work steps | + +Most squads get layers 1 and 2 right. Layer 3 — enforcement — is what's usually missing. + +## Step-by-Step Walkthrough + +### Step 1: Create the reviewer's identity + +Create `.squad/agents/{name}/charter.md`: + +```markdown +# {Name} — Code Reviewer + +## Identity +- **Name:** {Name} +- **Role:** Code Reviewer +- **Expertise:** Code quality, correctness, test coverage, security, patterns +- **Style:** Thorough, fair, specific. Provides actionable feedback. + +## What I Own +- Reviewing PRs for code quality, correctness, and test coverage +- Identifying bugs, security issues, and design problems +- Providing specific, actionable feedback (not vague suggestions) + +## How I Review +1. Read the PR diff completely +2. Check: does it do what the issue asked for? +3. Check: are there tests? Do they cover the important cases? +4. Check: are there bugs, edge cases, or security issues? +5. Check: does it follow project patterns and conventions? +6. Verdict: APPROVE or REJECT with specific feedback + +## Boundaries +**I handle:** Code review, PR review, quality gates +**I don't handle:** Implementation, design, research, documentation + +## On REJECT +I provide specific feedback: what's wrong, why, and what to do instead. +The original author fixes their work. I re-review after fixes. +``` + +Create `.squad/agents/{name}/history.md` seeded with project context. + +### Step 2: Add to team.md roster + +```markdown +| 👑 {Name} | Code Reviewer | `.squad/agents/{name}/charter.md` | ✅ Active | +``` + +### Step 3: Add routing table entry + +In `routing.md` → routing table: + +```markdown +| PR code review | 👑 {Name} | — | "Review PR #42", code quality, finding reports | +``` + +**⚠️ This is necessary but NOT sufficient.** This only handles explicit review requests. It does NOT enforce automatic review of every PR. + +### Step 4: Add enforcement rule (THIS IS THE CRITICAL STEP) + +In `routing.md` → `## Rules` section, add a numbered rule: + +```markdown +N. **{Name} PR Gate** — every PR created by any agent MUST be reviewed by {Name} + before merge. The coordinator spawns {Name} (sync) with the PR diff after + the author pushes and creates the PR. On REJECT, the original author addresses + feedback. On APPROVE, the coordinator merges via `gh pr merge`. No PR merges + without {Name}'s approval. +``` + +**Why this works when the routing table alone didn't:** The routing table is for matching incoming work to agents. Rules are behavioral constraints the coordinator must follow AFTER work completes. The rule says "after a PR exists, you MUST do X before proceeding." The routing table says "if someone asks for a review, route to X." + +### Step 5: Wire into issue-lifecycle.md + +In `.squad/templates/issue-lifecycle.md`, the "Coordinator Post-Work Steps" section should reference your reviewer by name: + +```markdown +4. **Route to reviewer.** Spawn {Name} (sync) with the PR diff for code review. +``` + +This is the operational detail — the step-by-step instructions the coordinator follows after an agent completes issue work. The routing rule (Step 4) is the mandate; the lifecycle template is the procedure. + +### Step 6: Add to casting registry + +Update `.squad/casting/registry.json` with the new entry. + +### Step 7: Verify + +Ask yourself these questions: + +- [ ] If a clean session coordinator reads `routing.md` Rules, will it know to route PRs to this reviewer? → Check rule N exists. +- [ ] If an agent completes work and pushes a PR, does the coordinator's post-work flow include a review step? → Check `issue-lifecycle.md` step 4. +- [ ] Can the coordinator merge a PR without the reviewer's approval? → The rule should say "No PR merges without {Name}'s approval." +- [ ] Can the coordinator close an issue without a merged PR? → Check the issue closure rule exists. + +If any answer is wrong, you have a gap. + +## What Each File Controls (Summary) + +| File | What it contributes to the reviewer gate | +|------|----------------------------------------| +| `charter.md` | WHO the reviewer is and HOW they review | +| `team.md` | That the reviewer EXISTS on the team | +| `routing.md` routing table | That explicit review requests go to this reviewer | +| `routing.md` Rules section | That the coordinator MUST route EVERY PR to this reviewer (enforcement) | +| `issue-lifecycle.md` | The step-by-step procedure for the post-work review flow | +| `casting/registry.json` | Persistent name tracking | + +**Remove any one of these and the gate has a hole.** The most commonly missed piece is the Rules section entry (Step 4). diff --git a/.squad-templates/workflow-wiring-appendix-b-documenter.md b/.squad-templates/workflow-wiring-appendix-b-documenter.md new file mode 100644 index 000000000..870e61ccb --- /dev/null +++ b/.squad-templates/workflow-wiring-appendix-b-documenter.md @@ -0,0 +1,140 @@ +# Appendix B: Wiring a Documenter/Librarian — Complete Walkthrough + +> End-to-end example of adding a documenter role that ensures significant changes are documented. This is a FOLLOW-UP TRIGGER pattern — not a gate (which blocks), but an automatic downstream task that fires after work completes. + +## The Problem This Solves + +Your project has agents building features, fixing bugs, and writing tools. But nobody documents what was built, how to use it, or what changed. Documentation happens only when someone explicitly asks — and by then, the context is lost. + +A documenter/librarian role solves this by automatically evaluating whether completed work needs documentation and producing it if so. + +## Gate vs Follow-Up Trigger + +| Pattern | Blocks work? | When it runs | Example | +|---------|-------------|-------------|---------| +| **Gate** (Appendix A) | Yes — work cannot proceed without approval | Before merge | Code reviewer must approve PR | +| **Follow-up trigger** | No — work proceeds, documentation happens in parallel | After merge | Documenter evaluates if docs are needed | + +A documenter is typically a follow-up trigger, not a gate. You don't want documentation review to block a hotfix from merging. But you DO want documentation to happen automatically after significant changes. + +## Step-by-Step Walkthrough + +### Step 1: Create the documenter's identity + +Create `.squad/agents/{name}/charter.md`: + +```markdown +# {Name} — Documenter + +## Identity +- **Name:** {Name} +- **Role:** Documenter / Librarian +- **Expertise:** Documentation, guides, READMEs, changelogs, knowledge management +- **Style:** Clear, thorough, user-focused. Makes complex things understandable. + +## What I Own +- Evaluating whether completed work needs documentation +- Writing/updating READMEs, guides, and runbooks +- Maintaining a docs index so nothing gets lost +- Summarizing design decisions and architectural changes + +## How I Work +1. Read the PR diff or agent output +2. Assess: does this change user-facing behavior? Add a new feature? Change configuration? +3. If yes: write or update the relevant documentation +4. If no: report "no docs needed" with brief justification + +## Boundaries +**I handle:** Documentation, guides, READMEs, summaries, knowledge management +**I don't handle:** Code implementation, code review, research, operations +``` + +Create `.squad/agents/{name}/history.md` seeded with project context. + +### Step 2: Add to team.md roster + +```markdown +| 📝 {Name} | Documenter | `.squad/agents/{name}/charter.md` | ✅ Active | +``` + +### Step 3: Add routing table entry + +In `routing.md` → routing table: + +```markdown +| Documentation, reports, summaries | 📝 {Name} | `docs/` | "Write docs for X", "Summarize this", guides, READMEs | +``` + +### Step 4: Add follow-up trigger rule + +In `routing.md` → `## Rules` section, add a numbered rule: + +```markdown +N. **Documentation follow-up** — after any PR is merged that adds or modifies + user-facing features, scripts, tools, or configuration, the coordinator + spawns {Name} (background) to evaluate whether documentation is needed. + {Name} reads the merged PR diff and either writes/updates docs or reports + "no docs needed." This is a follow-up, not a gate — it does not block + the merge. +``` + +**Why a rule and not a ceremony:** Ceremonies are structured multi-participant meetings. This is a single-agent follow-up task. A routing rule is simpler and more appropriate. + +**Why background, not sync:** Documentation doesn't block other work. The documenter runs in parallel with whatever comes next. + +### Step 5: Wire into the coordinator's post-merge flow + +This is the trickiest part. The coordinator's After Agent Work flow doesn't currently have a "post-merge" hook. You wire this through the issue-lifecycle template. + +In `.squad/templates/issue-lifecycle.md`, after the merge step, add: + +```markdown +8. **Documentation follow-up.** After merge, check routing.md Rules for + documentation follow-up rule. If present, spawn the documenter (background) + with the merged PR diff to evaluate whether docs are needed. +``` + +Alternatively, you can wire this as an `after` ceremony in `ceremonies.md`: + +```yaml +- name: "Documentation Check" + when: "after" + condition: "PR merged that adds features, scripts, tools, or config changes" + facilitator: "{DocumenterName}" + participants: ["{DocumenterName}"] + output: "Docs written/updated, or 'no docs needed' with justification" +``` + +### Step 6: Worktree for doc changes + +If the documenter produces files, they need a worktree — docs are files too. The coordinator should: +1. Create a worktree for the doc update (e.g., `squad/{issue}-docs`) +2. The documenter commits and pushes +3. A PR is created for the docs +4. The docs PR goes through the normal review flow (including the code reviewer if you have one) + +This means doc changes also get reviewed. The documenter is not exempt from the review gate. + +### Step 7: Add to casting registry + +Update `.squad/casting/registry.json` with the new entry. + +### Step 8: Verify + +- [ ] After a feature PR merges, does the coordinator spawn the documenter? → Check the routing rule exists. +- [ ] Does the documenter get a worktree for their work? → Check the worktree rule covers docs. +- [ ] Do doc changes go through the review gate? → They should — docs are files, files need PRs, PRs need review. +- [ ] Is the follow-up non-blocking? → The documenter should be background, not sync. + +## What Each File Controls (Summary) + +| File | What it contributes | +|------|-------------------| +| `charter.md` | WHO the documenter is and HOW they evaluate | +| `team.md` | That the documenter EXISTS | +| `routing.md` routing table | That explicit doc requests go to this member | +| `routing.md` Rules section | That the coordinator MUST spawn docs evaluation after merges (enforcement) | +| `issue-lifecycle.md` or `ceremonies.md` | The procedural hook: when exactly the follow-up fires | +| `casting/registry.json` | Persistent name tracking | + +**The most commonly missed piece:** The Rules section entry (Step 4). Without it, the documenter only runs when someone explicitly says "write docs for X." The whole point is that it runs automatically. diff --git a/.squad-templates/workflow-wiring-guide.md b/.squad-templates/workflow-wiring-guide.md new file mode 100644 index 000000000..e6b775ae1 --- /dev/null +++ b/.squad-templates/workflow-wiring-guide.md @@ -0,0 +1,276 @@ +# Squad Workflow Wiring Guide + +> How to wire up new team members, reviewer gates, and custom workflows so they actually get enforced by the coordinator — even in a clean session with no prior memory. + +## Why This Guide Exists + +The Squad framework (`squad.agent.md`) provides generic orchestration primitives. **It does not prescribe a specific workflow.** Your project's workflow — whether that's "all code goes through PRs and reviews" or "just commit to main" — must be wired into project-level configuration files. + +If a workflow rule exists only in someone's memory, in a chat transcript, or in `decisions.md` but NOT in a configuration file the coordinator reads at decision time — **it will not be followed in a clean session.** + +### Why Existing Patterns Aren't Enough + +The Squad framework already has concepts for routing tables, reviewer roles, and ceremonies. But having these concepts does NOT mean they work automatically: + +- **Adding a reviewer to the roster ≠ enforcing reviews.** A reviewer can be on the roster with "Reviewer" as their role and never review a single PR — because no RULE in `routing.md` tells the coordinator to route PRs to them. The roster says WHO exists. Rules say WHAT they enforce. + +- **Capturing a decision ≠ enforcing it.** `decisions.md` may contain "every change must go through a PR" and "only {ReviewerName} closes PRs." These can get buried in a large file that the coordinator reads for context but doesn't treat as enforcement rules. A decision is a historical record. A routing rule is an enforceable constraint. + +- **Describing a lifecycle ≠ wiring it.** `squad.agent.md` describes issue→branch→PR→review→merge. But if the After Agent Work section (the flow the coordinator actually follows after every agent completes) has no push/PR/review step, the lifecycle is described conceptually but never connected to the coordinator's actual decision flow. + +**The pattern that works:** A numbered rule in `routing.md` → Rules section. The coordinator reads this section, treats each rule as a constraint, and follows them. If your workflow isn't a numbered rule, it's a suggestion. + +--- + +## Configuration Surface Area + +The coordinator reads these files to decide how to behave. If your workflow isn't encoded in one of these, it doesn't exist. + +| File | What It Controls | Read When | +|------|-----------------|-----------| +| `routing.md` | WHO handles what, behavioral RULES, reviewer GATES | Every session start, before every routing decision | +| `ceremonies.md` | Auto-triggered ceremonies (before/after work batches) | Before spawning work batches, after completion | +| `templates/issue-lifecycle.md` | Git workflow: push, PR, review, merge, issue closure | When spawning agents for issue-linked work | +| Agent `charter.md` | Per-agent identity, boundaries, behavior | Inlined into every spawn prompt | +| `team.md` | Roster, member capabilities | Session start | +| `decisions.md` | Captured decisions and directives | Read by agents at spawn time | + +### How They Interact + +``` +User request arrives + → Coordinator reads routing.md (WHO handles this?) + → Coordinator checks ceremonies.md (any auto-triggered "before" ceremony?) + → Coordinator reads agent charter.md (inline into spawn prompt) + → If issue-linked: coordinator reads issue-lifecycle.md (add ISSUE CONTEXT to spawn prompt) + → Agent works + → Coordinator follows After Agent Work flow + → Coordinator checks ceremonies.md (any auto-triggered "after" ceremony?) + → Coordinator checks routing.md Rules section (any post-work rules to enforce?) +``` + +**The critical insight:** `routing.md` Rules section and `ceremonies.md` are the two enforcement mechanisms. If a rule isn't in one of these, the coordinator has no way to know about it. + +--- + +## How to Wire Up a New Team Member + +### Step 1: Create the member (files) + +``` +.squad/agents/{name}/ + charter.md ← Identity, role, boundaries, what they own + history.md ← Seeded with project context from team.md +``` + +### Step 2: Add to roster (`team.md`) + +Add a row to the `## Members` table: +``` +| {emoji} {Name} | {Role} | `.squad/agents/{name}/charter.md` | ✅ Active | +``` + +### Step 3: Add routing entry (`routing.md`) + +Add a row to the routing table: +``` +| {Work Type} | {emoji} {Name} | {Output Location} | {Examples} | +``` + +### Step 4: Add issue routing (if applicable) + +Add to the Issue Routing table in `routing.md`: +``` +| squad:{name} | {Description of work} | {emoji} {Name} | +``` + +### Step 5: Add to casting registry + +Update `.squad/casting/registry.json` with the new entry. + +### Step 6: Wire any gates (if this member is a reviewer/gate) + +**This is the step most people miss.** If the new member should review or gate other members' work, you need to wire enforcement. See "How to Wire Up a Reviewer Gate" below. + +--- + +## How to Wire Up a Reviewer Gate + +A reviewer gate means: "Agent X must review Agent Y's output before it proceeds." The framework supports this but does NOT automatically enforce it. You must wire it. + +### Option A: Routing Rule (recommended for simple gates) + +Add to `routing.md` → `## Rules` section: + +```markdown +N. **{GateName} Gate** — Every {output type} from {Author} MUST be reviewed by {ReviewerName} before {next step}. The coordinator routes {Author}'s output to {ReviewerName} (sync spawn), collects the verdict, and only proceeds if approved. On rejection, {Author} revises based on {ReviewerName}'s feedback. +``` + +**Example — reviewer for all PRs:** +```markdown +9. **{ReviewerName} PR Gate** — Every PR created by any agent MUST be reviewed by {ReviewerName} before merge. The coordinator spawns {ReviewerName} (sync) with the PR diff, collects APPROVE/REJECT verdict. On rejection, the original author addresses feedback. +``` + +**Example — design review gate:** +```markdown +10. **{DesignReviewer} Design Gate** — Every design doc produced by the architect MUST be reviewed by {DesignReviewer} before implementation begins. {DesignReviewer} always rejects the first draft on concept/approach. Implementation is BLOCKED until {DesignReviewer} approves. +``` + +**Why this works:** The coordinator reads the Rules section before and after every work batch. Rules are behavioral constraints the coordinator must follow. + +### Option B: Ceremony (recommended for multi-participant gates) + +Add to `ceremonies.md` using the Markdown table format the file uses: + +```markdown +## Design Review + +| Field | Value | +|-------|-------| +| **Trigger** | auto | +| **When** | before | +| **Condition** | task involves implementing a design doc | +| **Facilitator** | {DesignReviewer} | +| **Participants** | Architect, {DesignReviewer} | +| **Time budget** | focused | +| **Enabled** | ✅ yes | + +**Agenda:** +1. Read the design doc +2. Challenge the premise and approach +3. Demand alternatives and evidence +4. Verdict: APPROVE or REJECT +``` + +**Why this works:** The coordinator checks ceremonies.md for `before` ceremonies whose condition matches the current task. If matched, the ceremony runs before work begins. + +### Option A vs Option B + +| Use Case | Use Routing Rule | Use Ceremony | +|----------|-----------------|--------------| +| Simple 1-on-1 review (reviewer → author) | ✅ | Overkill | +| Multi-participant alignment (3+ agents) | Too simple | ✅ | +| Needs structured facilitation | No | ✅ | +| Must run automatically before specific work | Either works | ✅ | +| One-line behavioral constraint | ✅ | Overkill | + +--- + +## How to Wire Up an Issue Lifecycle (Git Workflow) + +This is where you define what happens after an agent completes work on a GitHub issue. The framework references `.squad/templates/issue-lifecycle.md` but does NOT create it — you must create it yourself. + +> **⚠️ This file is required if your project uses GitHub Issues Mode.** Without it, the coordinator has no post-work steps for push/PR/review and will treat agent commit as "done." + +See `.squad/templates/issue-lifecycle.md` for the full template if your project already has one. If not, create it following the pattern below. + +### Step 1: Create `templates/issue-lifecycle.md` + +Create `.squad/templates/issue-lifecycle.md` with your project's git workflow. At minimum it should include: + +- An ISSUE CONTEXT block template (for spawn prompts) +- Coordinator post-work steps (verify push → verify PR → route to reviewer → merge on approval) +- Issue closure rules (PR merge auto-close vs manual close) +- Worktree requirements (if applicable) + +### Step 2: Add enforcement rules to `routing.md` + +Add numbered rules to the `## Rules` section that reference the lifecycle: + +```markdown +N. **Issue lifecycle enforcement** — all issue-linked work follows the lifecycle + in `.squad/templates/issue-lifecycle.md`. The coordinator adds the ISSUE CONTEXT + block to spawn prompts and follows the post-work steps (verify push → verify PR + → route to reviewer → merge on approval). Read `issue-lifecycle.md` before + spawning any agent for issue work. + +N+1. **{ReviewerName} PR Gate** — every PR created by any agent MUST be reviewed + by {ReviewerName} before merge. The coordinator spawns {ReviewerName} (sync) + with the PR diff. On REJECT, the original author addresses feedback. On APPROVE, + the coordinator merges. No PR merges without {ReviewerName}'s approval. + +N+2. **Issue closure restriction** — issues that produced files (code, docs, scripts, + designs, tests) close ONLY via PR merge auto-close ("Closes #N" in PR body). + Never use `gh issue close` for file-producing work. Exception: tracking/strategic + issues and superseded issues may be closed with a comment. + +N+3. **Worktree for all file-producing work** — every task that creates or modifies + files (including documentation) requires a worktree. Exceptions: read-only queries, + Scribe (.squad/ state), pure analysis producing no files. +``` + +### Step 3: Verify your wiring + +After creating both files, run the verification checklist (below) to confirm a clean session coordinator would follow the lifecycle. + +--- + +## How to Wire Up a Custom Workflow Step + +If you need something that isn't a reviewer gate or issue lifecycle — for example, "always run tests before pushing" or "docs must be reviewed by the author before merge" — here's where to put it: + +### If it's a behavioral rule the coordinator should always follow: +→ Add to `routing.md` → `## Rules` section + +### If it should trigger automatically before/after specific work: +→ Add to `ceremonies.md` as a `before` or `after` ceremony + +### If it's something agents should do as part of their work: +→ Add to the agent's `charter.md` under a new section + +### If it's something that applies only to issue-linked work: +→ Add to `templates/issue-lifecycle.md` + +### If it's a team-wide constraint that should be visible to all agents: +→ Capture as a decision in `decisions.md` (via directive or decision inbox) + +--- + +## Verification Checklist + +After wiring any new member, gate, or workflow, verify: + +- [ ] **Clean session test:** Start a new session (no memory). Give a task. Does the coordinator follow the new rule? +- [ ] **File completeness:** Is the rule/gate/workflow encoded in a file the coordinator reads? (routing.md, ceremonies.md, issue-lifecycle.md, charter.md) +- [ ] **No verbal-only rules:** Is there anything the coordinator should do that's only in chat history or your memory? If yes, it will be lost on session restart. +- [ ] **Gate enforcement:** If you added a reviewer gate, does the routing.md Rules section or ceremonies.md explicitly say the coordinator must route to the reviewer? "Having a reviewer on the roster" is not the same as "enforcing that they review." +- [ ] **Issue lifecycle:** If your project uses PRs, does `templates/issue-lifecycle.md` exist? Does routing.md reference it? + +--- + +## Common Mistakes + +1. **Adding a reviewer to the roster but not wiring a gate.** Having a reviewer on the team doesn't mean they review anything. You must add a rule in routing.md that says "route PRs to {ReviewerName}." + +2. **Closing issues via `gh issue close` instead of PR merge.** If your project uses PRs, issue closure should happen via "Closes #N" in the PR body. Wire this in issue-lifecycle.md. + +3. **Writing docs/scripts directly on main.** If your project requires branches for all changes, the worktree gate must apply to ALL file-producing work — including docs. Make this explicit in routing.md Rules. + +4. **Assuming the coordinator remembers verbal instructions.** Each session starts fresh. If you told the coordinator "always use opus" in session 1, session 2 won't know unless it's in decisions.md or routing.md. + +5. **Not creating `issue-lifecycle.md`.** The framework references it but doesn't create it. If your project uses GitHub Issues Mode, create this template. + +6. **Capturing a decision but never encoding it as a rule.** `decisions.md` is a historical record. The coordinator reads it for context but doesn't treat entries as enforceable constraints. If a decision should be enforced, it must become a numbered rule in `routing.md` Rules section. + +--- + +## Decisions Audit + +Periodically scan `decisions.md` for directives that should be routing rules but aren't: + +1. Search for phrases like "always", "never", "must", "every", "required" +2. For each match, ask: "Is this enforced by a numbered rule in routing.md?" +3. If no → either add a rule, or accept that it's advisory-only +4. If yes → verify the rule text matches the decision + +This prevents `decisions.md` from becoming a graveyard of good intentions that the coordinator reads but doesn't act on. + +--- + +## Appendices + +For detailed end-to-end walkthroughs of specific wiring scenarios, see: + +- **[Appendix A: Wiring a Code Reviewer](workflow-wiring-appendix-a-code-reviewer.md)** — Full walkthrough of adding a code reviewer member and wiring their gate so it actually gets enforced. Includes every file that needs modification with exact content. + +- **[Appendix B: Wiring a Documenter/Librarian](workflow-wiring-appendix-b-documenter.md)** — Full walkthrough of adding a documenter role that ensures all significant changes are documented. Shows a follow-up trigger pattern rather than a gate pattern. diff --git a/.squad/skills/fact-checking/SKILL.md b/.squad/skills/fact-checking/SKILL.md new file mode 100644 index 000000000..f7f7873e5 --- /dev/null +++ b/.squad/skills/fact-checking/SKILL.md @@ -0,0 +1,61 @@ +--- +name: fact-checking +description: Review and validate claims using counter-hypothesis testing. Use when verifying technical content, checking references, validating API endpoints, or performing quality assurance on deliverables. +license: MIT +compatibility: Requires access to external references and APIs +metadata: + author: squad + version: "1.0.0" + domain: quality, verification + confidence: low + last_validated: "2026-03-01" +--- + +# Skill: Fact Checking + +## Context +Codifies the challenger agent review output format and methodology so any agent performing fact-checking or review produces consistent, structured output. + +## Pattern + +### Review Methodology + +For every claim or deliverable under review: +1. Ask: "What evidence supports this? What would disprove it?" +2. Generate counter-hypotheses and test them against available data +3. Verify URLs, package names, API endpoints, and external references actually exist +4. Flag confidence levels: ✅ Verified, ⚠️ Unverified, ❌ Contradicted + +### Review Output Format + +When reviewing another agent's work, use this template: + +``` +### Fact Check — {deliverable name} +**Claims verified:** {count} +**Issues found:** {count} + +| # | Claim | Status | Evidence/Notes | +|---|-------|--------|---------------| +| 1 | {claim} | ✅/⚠️/❌ | {supporting or contradicting evidence} | + +**Counter-hypotheses tested:** +- {alternative explanation + result} + +**Verdict:** {PASS / PASS WITH NOTES / NEEDS REVISION} +``` + +### Confidence Levels + +- ✅ **Verified** — evidence confirms the claim +- ⚠️ **Unverified** — cannot confirm or deny; suggest verification method +- ❌ **Contradicted** — evidence disproves the claim + +### Ceremony Integration + +Auto-trigger this skill before any architecture decision, or when an agent claim contains superlatives or percentage thresholds (e.g., "saves 75%", "always", "never"). The coordinator spawns the challenger agent with: + +``` +Challenger — fact-check {agent}'s claim: "{claim}" +Cite evidence for every verdict. Max 3 investigation cycles. +``` \ No newline at end of file diff --git a/.squad/templates/agents/challenger.md b/.squad/templates/agents/challenger.md new file mode 100644 index 000000000..8477841e0 --- /dev/null +++ b/.squad/templates/agents/challenger.md @@ -0,0 +1,72 @@ +# Challenger — Devil's Advocate & Fact Checker + +> The trial never ends. Every claim deserves scrutiny. + +## Identity + +- **Name:** Challenger (customize: e.g., "Q", "Advocate", "Auditor") +- **Role:** Devil's Advocate & Fact Checker +- **Expertise:** Counter-hypothesis generation, fact verification, assumption challenging, hallucination detection +- **Style:** Incisive, rigorous, constructively contrarian — questions everything to strengthen, not obstruct + +## What I Own + +- Fact-checking claims, research outputs, and agent deliverables +- Running counter-hypotheses against team assumptions +- Verifying external references and sources +- Challenging decisions before they are locked in +- Detecting hallucinated facts or unsupported claims + +## How I Work + +- Read `.squad/decisions.md` before starting +- For every claim: "What evidence supports this? What would disprove it?" +- Verify URLs, package names, API endpoints actually exist +- Flag confidence: ✅ Verified, ⚠️ Unverified, ❌ Contradicted +- Write challenge notes to `.squad/decisions/inbox/challenger-{brief-slug}.md` + +## Skills + +- Review output format & methodology: `.squad/skills/fact-checking/SKILL.md` + +## Boundaries + +**I handle:** Fact-checking, counter-hypothesis testing, verification, constructive challenge +**I do not handle:** Implementation, code writing, architecture design — I review, not build +**On rejection:** Specific items needing correction + verification methods provided + +## Iterative Retrieval + +When called by the coordinator or another agent: + +1. **Max 3 investigation cycles.** Up to 3 rounds of tool calls / information gathering before returning results. Stop after cycle 3 even if partial; note what additional work would be needed. +2. **Return objective context.** Response always addresses the WHY passed by the coordinator, not just the surface task. +3. **Self-evaluate before returning.** Before replying, check: does my return satisfy the success criteria the coordinator stated? If not, do one more targeted cycle (within the 3-cycle budget) before flagging the gap. + +## Ceremony Integration + +The coordinator may auto-spawn Challenger before: +- Any architecture decision +- Claims containing superlatives or quantified savings (e.g., "saves 75%", "always", "never fails") +- Final deliverables before external publication + +**Spawn prompt pattern:** +``` +Challenger — fact-check {agent}'s claim: "{claim}" +Deliverable: {file or summary} +Cite evidence for every verdict. Flag confidence levels. Max 3 investigation cycles. +Success criteria: verdict table with ✅/⚠️/❌ for each claim. +``` + +## Prior Art + +Field-tested across 200+ issues. Caught: inflated metrics (claimed 75–90% savings, actual 20–55%), fabricated config references, wrong bottleneck assumptions. False positive rate ~15%. + +## Model + +- **Preferred:** auto (coordinator selects based on task type) +- **Rationale:** Fact-checking requires analytical depth + +## Voice + +The trial never ends. Every claim deserves scrutiny. The truth is always worth finding. \ No newline at end of file diff --git a/packages/squad-cli/src/cli-entry.ts b/packages/squad-cli/src/cli-entry.ts index c20d92c00..345ddde95 100644 --- a/packages/squad-cli/src/cli-entry.ts +++ b/packages/squad-cli/src/cli-entry.ts @@ -203,6 +203,10 @@ async function main(): Promise { 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}skill${RESET} APM (Agent Package Manager) integration`); + console.log(` Usage: skill publish [] — export to APM format`); + console.log(` skill install — install from APM registry`); + console.log(` skill list — list installed skills`); 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`); @@ -616,6 +620,12 @@ async function main(): Promise { return; } + if (cmd === 'skill') { + const { runSkill } = await import('./cli/commands/skill.js'); + await runSkill(process.cwd(), args.slice(1)); + return; + } + if (cmd === 'copilot') { const { runCopilot } = await import('./cli/commands/copilot.js'); const isOff = args.includes('--off'); diff --git a/packages/squad-cli/src/cli/commands/skill.ts b/packages/squad-cli/src/cli/commands/skill.ts new file mode 100644 index 000000000..cd527c0e5 --- /dev/null +++ b/packages/squad-cli/src/cli/commands/skill.ts @@ -0,0 +1,568 @@ +/** + * Skill command — APM (Agent Package Manager) integration + * + * squad skill publish [] — export a skill to APM format + * squad skill install — install a skill from APM registry + * squad skill list — list installed skills + * + * APM is package.json for AI agent context: https://github.com/microsoft/apm + */ +import { readFile, writeFile, mkdir, readdir, stat } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { join, basename } from 'node:path'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { success, warn, info, dim, bold, DIM, BOLD, RESET } from '../core/output.js'; +import { fatal } from '../core/errors.js'; +import { detectSquadDir } from '../core/detect-squad-dir.js'; +import { ghAvailable } from '../core/gh-cli.js'; + +const execFileAsync = promisify(execFile); + +// ── Types ───────────────────────────────────────────────────────────────────── + +/** A skill entry inside apm.yml */ +export interface ApmSkill { + name: string; + description?: string; + path: string; + version?: string; + source?: string; +} + +/** Root apm.yml structure */ +export interface ApmManifest { + name: string; + version: string; + description?: string; + skills?: ApmSkill[]; + instructions?: Array<{ path: string; target: string }>; + prompts?: Array<{ path: string; target: string }>; +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/** Parse `---\nkey: value\n---` front-matter from a Markdown file. */ +function parseFrontMatter(content: string): Record { + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (!match) return {}; + const result: Record = {}; + for (const line of match[1]!.split('\n')) { + const colon = line.indexOf(':'); + if (colon === -1) continue; + const key = line.slice(0, colon).trim(); + const value = line.slice(colon + 1).trim().replace(/^["']|["']$/g, ''); + result[key] = value; + } + return result; +} + +/** Read the project name from package.json, falling back to directory name. */ +async function readProjectName(dest: string): Promise { + const pkgPath = join(dest, 'package.json'); + if (existsSync(pkgPath)) { + try { + const pkg = JSON.parse(await readFile(pkgPath, 'utf8')); + if (typeof pkg.name === 'string' && pkg.name) return pkg.name; + } catch { + // ignore + } + } + return basename(dest); +} + +/** + * Resolve the skills directory, preferring .copilot/skills/ over .squad/skills/. + * Returns { dir, relPrefix } where relPrefix is the display path (e.g. '.copilot/skills'). + */ +function resolveSkillsDir(dest: string): { dir: string; relPrefix: string } { + const copilotSkills = join(dest, '.copilot', 'skills'); + if (existsSync(copilotSkills)) { + return { dir: copilotSkills, relPrefix: '.copilot/skills' }; + } + const squadDirInfo = detectSquadDir(dest); + const squadSkills = join(squadDirInfo.path, 'skills'); + if (existsSync(squadSkills)) { + return { dir: squadSkills, relPrefix: `${squadDirInfo.name}/skills` }; + } + // Default to .copilot/skills/ for new installs + return { dir: copilotSkills, relPrefix: '.copilot/skills' }; +} + +/** Collect all skills from the skills directory. */ +async function collectSkills(skillsDir: string, relPrefix: string): Promise { + if (!existsSync(skillsDir)) return []; + const skills: ApmSkill[] = []; + try { + const entries = await readdir(skillsDir); + for (const entry of entries) { + const skillFile = join(skillsDir, entry, 'skill.md'); + if (!existsSync(skillFile)) continue; + const content = await readFile(skillFile, 'utf8'); + const fm = parseFrontMatter(content); + skills.push({ + name: fm['name'] ?? entry, + description: fm['description'], + path: `${relPrefix}/${entry}/skill.md`, + version: fm['version'], + source: fm['source'], + }); + } + } catch { + // ignore read errors + } + return skills; +} + +// ── Sub-commands ────────────────────────────────────────────────────────────── + +/** + * squad skill publish [] + * + * Exports a skill (or all skills) to APM-compatible format. + * Creates/updates apm.yml at the project root. + */ +async function publish(dest: string, skillName?: string): Promise { + const { dir: skillsDir, relPrefix } = resolveSkillsDir(dest); + + if (!existsSync(skillsDir)) { + fatal('No skills directory found. Create .copilot/skills/ first.'); + } + + const projectName = await readProjectName(dest); + + if (skillName) { + // Publish a single named skill + const skillFile = join(skillsDir, skillName, 'skill.md'); + if (!existsSync(skillFile)) { + fatal(`Skill '${skillName}' not found at ${relPrefix}/${skillName}/skill.md`); + } + + const content = await readFile(skillFile, 'utf8'); + const fm = parseFrontMatter(content); + + // Build the skill's own apm.yml inside its directory + const apmSkillPath = join(skillsDir, skillName, 'apm.yml'); + const skillApm = [ + `name: ${fm['name'] ?? skillName}`, + `version: ${fm['version'] ?? '1.0.0'}`, + fm['description'] ? `description: "${fm['description']}"` : null, + ``, + `skills:`, + ` - name: ${fm['name'] ?? skillName}`, + ` path: skill.md`, + fm['description'] ? ` description: "${fm['description']}"` : null, + ] + .filter(l => l !== null) + .join('\n'); + + await writeFile(apmSkillPath, skillApm + '\n', 'utf8'); + success(`Published skill '${skillName}' to ${relPrefix}/${skillName}/apm.yml`); + info(`${DIM}APM format ready — push to GitHub to share via APM registry${RESET}`); + return; + } + + // Publish all skills → update project-level apm.yml + const skills = await collectSkills(skillsDir, relPrefix); + const apmPath = join(dest, 'apm.yml'); + + // Read existing apm.yml to preserve manually-added fields + let existing: Partial = {}; + if (existsSync(apmPath)) { + try { + // Simple YAML parse — only top-level fields we care about + const raw = await readFile(apmPath, 'utf8'); + if (raw.includes('instructions:')) { + // Preserve instructions block (just keep existing file, re-emit skills section) + info(`${DIM}Updating skills section in existing apm.yml${RESET}`); + } + existing = { name: projectName }; + } catch { + existing = {}; + } + } + + const lines = [ + `# apm.yml — Agent Package Manager manifest`, + `# See: https://github.com/microsoft/apm`, + ``, + `name: ${existing.name ?? projectName}`, + `version: 1.0.0`, + ``, + `# Skills exported from ${relPrefix}/`, + `skills:`, + ...skills.map(s => + [ + ` - name: ${s.name}`, + s.description ? ` description: "${s.description}"` : null, + ` path: ${s.path}`, + s.version ? ` version: ${s.version}` : null, + ] + .filter(l => l !== null) + .join('\n') + ), + ``, + `# Instruction files deployed by 'apm install'`, + `instructions:`, + ` - path: .squad/copilot-instructions.md`, + ` target: .github/copilot-instructions.md`, + ``, + `# Prompts deployed by 'apm install'`, + `prompts:`, + ` - path: ${relPrefix}/*/skill.md`, + ` target: .github/prompts/`, + ]; + + await writeFile(apmPath, lines.join('\n') + '\n', 'utf8'); + + if (skills.length > 0) { + success(`Published ${skills.length} skill(s) to apm.yml`); + for (const s of skills) { + info(` ${DIM}• ${s.name}${RESET}`); + } + } else { + success(`Created apm.yml (no skills found yet — add skills to ${relPrefix}/)`); + } + info(`${DIM}Run 'apm publish' to push to the APM registry${RESET}`); +} + +/** + * squad skill install + * + * Installs a skill from an APM source. + * Source formats: + * owner/repo — install all skills from a GitHub repo + * owner/repo/skill-name — install a specific skill + * https://... — URL to a raw skill.md + */ +async function install(dest: string, source: string): Promise { + if (!source) { + fatal('Usage: squad skill install [/] | '); + } + + const { dir: skillsDir, relPrefix } = resolveSkillsDir(dest); + + if (!existsSync(skillsDir)) { + await mkdir(skillsDir, { recursive: true }); + } + + // URL-based install + if (source.startsWith('http://') || source.startsWith('https://')) { + await installFromUrl(source, skillsDir, relPrefix); + return; + } + + // GitHub-based: owner/repo or owner/repo/skill-name + const parts = source.split('/'); + if (parts.length < 2) { + fatal('Invalid source. Use: owner/repo, owner/repo/skill-name, or a URL'); + } + + const owner = parts[0]!; + const repo = parts[1]!; + const skillFilter = parts.length >= 3 ? parts.slice(2).join('/') : undefined; + + if (!ghAvailable()) { + fatal('GitHub CLI (gh) is required for APM install. Install from https://cli.github.com/'); + } + + info(`${DIM}Fetching skill(s) from ${owner}/${repo}...${RESET}`); + + // Try to find skills via gh api — look for apm.yml or .copilot/skills/ + await installFromGitHub(owner, repo, skillFilter, skillsDir, dest); +} + +async function installFromUrl(url: string, skillsDir: string, relPrefix: string): Promise { + let content: string; + try { + // Node 18+ has built-in fetch + const res = await fetch(url); + if (!res.ok) { + fatal(`Failed to fetch ${url}: ${res.status} ${res.statusText}`); + } + content = await res.text(); + } catch (err: any) { + fatal(`Failed to fetch ${url}: ${err.message}`); + return; + } + + // Derive skill name from URL + const urlName = url + .split('/') + .filter(Boolean) + .slice(-2, -1)[0] ?? + 'imported-skill'; + const skillName = urlName.replace(/[^a-z0-9-_]/gi, '-').toLowerCase(); + const skillDir = join(skillsDir, skillName); + + await mkdir(skillDir, { recursive: true }); + await writeFile(join(skillDir, 'skill.md'), content, 'utf8'); + + success(`Installed skill '${skillName}' from URL`); + info(` ${DIM}Location: ${relPrefix}/${skillName}/skill.md${RESET}`); +} + +async function installFromGitHub( + owner: string, + repo: string, + skillFilter: string | undefined, + skillsDir: string, + dest: string, +): Promise { + // First, try to read the apm.yml from the repo to discover skills + let apmContent: string | null = null; + try { + const { stdout } = await execFileAsync('gh', [ + 'api', + `repos/${owner}/${repo}/contents/apm.yml`, + '--jq', '.content', + ]); + apmContent = Buffer.from(stdout.trim(), 'base64').toString('utf8'); + } catch { + // No apm.yml — fall back to scanning skills directories + } + + if (!apmContent) { + // Fall back: try skills directories via API + await installSkillsFromSquadDir(owner, repo, skillFilter, skillsDir); + return; + } + + // Parse the apm.yml to find skill paths + const skillPaths: Array<{ name: string; path: string }> = []; + const lines = apmContent.split('\n'); + let inSkills = false; + let currentSkill: { name?: string; path?: string } = {}; + + for (const line of lines) { + if (line.trim() === 'skills:') { + inSkills = true; + continue; + } + if (inSkills && line.match(/^[a-z]/i) && !line.startsWith(' ')) { + // New top-level key — exit skills section + if (currentSkill.name && currentSkill.path) skillPaths.push(currentSkill as { name: string; path: string }); + currentSkill = {}; + inSkills = false; + } + if (inSkills) { + const nameMatch = line.match(/^\s+- name:\s*(.+)$/); + const pathMatch = line.match(/^\s+path:\s*(.+)$/); + if (nameMatch) { + if (currentSkill.name && currentSkill.path) skillPaths.push(currentSkill as { name: string; path: string }); + currentSkill = { name: nameMatch[1]!.trim() }; + } + if (pathMatch) currentSkill.path = pathMatch[1]!.trim(); + } + } + if (currentSkill.name && currentSkill.path) skillPaths.push(currentSkill as { name: string; path: string }); + + // Filter by skill name if specified + const toInstall = skillFilter + ? skillPaths.filter(s => s.name === skillFilter || s.path.includes(skillFilter)) + : skillPaths; + + if (toInstall.length === 0) { + if (skillFilter) { + fatal(`Skill '${skillFilter}' not found in ${owner}/${repo}'s apm.yml`); + } else { + warn(`No skills declared in ${owner}/${repo}'s apm.yml`); + return; + } + } + + let installed = 0; + for (const skill of toInstall) { + try { + const { stdout: rawContent } = await execFileAsync('gh', [ + 'api', + `repos/${owner}/${repo}/contents/${skill.path}`, + '--jq', '.content', + ]); + const content = Buffer.from(rawContent.trim(), 'base64').toString('utf8'); + const skillDir = join(skillsDir, skill.name); + await mkdir(skillDir, { recursive: true }); + await writeFile(join(skillDir, 'skill.md'), content, 'utf8'); + + // Write a source metadata file so we can track origin + const meta = { + source: `${owner}/${repo}`, + path: skill.path, + installed_at: new Date().toISOString(), + }; + await writeFile(join(skillDir, '.apm-source.json'), JSON.stringify(meta, null, 2) + '\n', 'utf8'); + + success(`Installed skill '${skill.name}'`); + info(` ${DIM}Source: ${owner}/${repo}${skill.path}${RESET}`); + installed++; + } catch (err: any) { + warn(`Failed to install '${skill.name}': ${err.message}`); + } + } + + if (installed > 0) { + info(`\n${DIM}Run 'squad skill publish' to refresh apm.yml with newly installed skills${RESET}`); + } +} + +async function installSkillsFromSquadDir( + owner: string, + repo: string, + skillFilter: string | undefined, + skillsDir: string, +): Promise { + // Try .copilot/skills/ (standard) then .squad/skills/ (legacy) then bare skills/ + const candidates = ['.copilot/skills', '.squad/skills', 'skills']; + let entries: Array<{ name: string; path: string; type: string }> = []; + + for (const candidate of candidates) { + try { + const { stdout } = await execFileAsync('gh', [ + 'api', + `repos/${owner}/${repo}/contents/${candidate}`, + '--jq', '[.[] | {name: .name, path: .path, type: .type}]', + ]); + entries = JSON.parse(stdout); + break; + } catch { + continue; + } + } + + if (entries.length === 0) { + fatal(`No skills directory found in ${owner}/${repo}. The repo may not use APM or Squad conventions.`); + } + + const dirs = entries.filter(e => e.type === 'dir'); + const toInstall = skillFilter ? dirs.filter(d => d.name === skillFilter) : dirs; + + if (toInstall.length === 0) { + if (skillFilter) { + fatal(`Skill '${skillFilter}' not found in ${owner}/${repo}`); + } else { + warn(`No skill directories found in ${owner}/${repo}`); + return; + } + } + + let installed = 0; + for (const dir of toInstall) { + const skillFilePath = `${dir.path}/skill.md`; + try { + const { stdout: rawContent } = await execFileAsync('gh', [ + 'api', + `repos/${owner}/${repo}/contents/${skillFilePath}`, + '--jq', '.content', + ]); + const content = Buffer.from(rawContent.trim(), 'base64').toString('utf8'); + const skillDir = join(skillsDir, dir.name); + await mkdir(skillDir, { recursive: true }); + await writeFile(join(skillDir, 'skill.md'), content, 'utf8'); + + const meta = { + source: `${owner}/${repo}`, + path: skillFilePath, + installed_at: new Date().toISOString(), + }; + await writeFile(join(skillDir, '.apm-source.json'), JSON.stringify(meta, null, 2) + '\n', 'utf8'); + + success(`Installed skill '${dir.name}'`); + info(` ${DIM}Source: ${owner}/${repo}/${skillFilePath}${RESET}`); + installed++; + } catch { + warn(`Skipped '${dir.name}' — no skill.md found`); + } + } + + if (installed > 0) { + info(`\n${DIM}Run 'squad skill publish' to refresh apm.yml${RESET}`); + } +} + +/** + * squad skill list + * + * Lists installed skills from .copilot/skills/ (or legacy .squad/skills/). + */ +async function listSkills(dest: string): Promise { + const { dir: skillsDir, relPrefix } = resolveSkillsDir(dest); + + if (!existsSync(skillsDir)) { + info('No skills directory found. Run "squad init" first or create .copilot/skills/'); + return; + } + + const skills = await collectSkills(skillsDir, relPrefix); + + if (skills.length === 0) { + info(`${DIM}No skills installed yet.${RESET}`); + info(`Install a skill: squad skill install `); + return; + } + + console.log(`\n${BOLD}Installed Skills${RESET}\n`); + for (const skill of skills) { + const metaPath = join(skillsDir, skill.name, '.apm-source.json'); + let sourceNote = ''; + if (existsSync(metaPath)) { + try { + const meta = JSON.parse(await readFile(metaPath, 'utf8')); + sourceNote = ` ${DIM}(from ${meta.source})${RESET}`; + } catch { + // ignore + } + } + console.log(` ${BOLD}${skill.name}${RESET}${sourceNote}`); + if (skill.description) { + console.log(` ${DIM}${skill.description}${RESET}`); + } + } + console.log(); +} + +// ── Main export ─────────────────────────────────────────────────────────────── + +/** + * Entry point for `squad skill` command. + * + * @param dest - Working directory (usually process.cwd()) + * @param args - Remaining args after `squad skill` + */ +export async function runSkill(dest: string, args: string[]): Promise { + const subCmd = args[0]; + + if (!subCmd || subCmd === 'help' || subCmd === '--help') { + console.log(`\n${BOLD}squad skill${RESET} — APM (Agent Package Manager) integration\n`); + console.log(`Usage:`); + console.log(` squad skill publish [] Export skill(s) to APM format (apm.yml)`); + console.log(` squad skill install Install from APM registry`); + console.log(` squad skill list List installed skills`); + console.log(`\nInstall sources:`); + console.log(` owner/repo All skills from a GitHub repo`); + console.log(` owner/repo/skill-name A specific skill from a GitHub repo`); + console.log(` https://... A direct URL to a skill.md file`); + console.log(`\nAPM registry: https://github.com/microsoft/apm\n`); + return; + } + + switch (subCmd) { + case 'publish': { + const skillName = args[1]; + await publish(dest, skillName); + break; + } + case 'install': { + const source = args[1]; + if (!source) { + fatal('Usage: squad skill install [/] | '); + } + await install(dest, source); + break; + } + case 'list': + await listSkills(dest); + break; + default: + fatal(`Unknown skill subcommand: ${subCmd}\nRun 'squad skill help' for usage.`); + } +} diff --git a/packages/squad-cli/src/cli/core/init.ts b/packages/squad-cli/src/cli/core/init.ts index 8c73e0ae5..22ec44af3 100644 --- a/packages/squad-cli/src/cli/core/init.ts +++ b/packages/squad-cli/src/cli/core/init.ts @@ -3,6 +3,7 @@ * Scaffolds a new Squad project with templates, workflows, and directory structure */ +import fs from 'node:fs'; import path from 'node:path'; import { execFileSync } from 'node:child_process'; import { FSStorageProvider } from '@bradygaster/squad-sdk'; @@ -17,6 +18,48 @@ const storage = new FSStorageProvider(); const CYAN = '\x1b[36m'; +// ── APM manifest generation ─────────────────────────────────────────────────── + +/** + * Generate a starter apm.yml at the project root. + * + * Only creates the file if it doesn't already exist, so repeat `squad init` + * invocations are safe (skipExisting semantics mirror the SDK's behaviour). + * + * APM (Agent Package Manager) is package.json for AI agent context. + * See: https://github.com/microsoft/apm + */ +export function generateApmYml(dest: string, projectName: string): void { + const apmPath = path.join(dest, 'apm.yml'); + if (fs.existsSync(apmPath)) return; // skip if already present + + const content = [ + `# apm.yml — Agent Package Manager manifest`, + `# See: https://github.com/microsoft/apm`, + `#`, + `# This file makes your Squad skills versioned, portable, and community-shareable.`, + `# Run 'squad skill publish' to populate the skills section after adding skills.`, + ``, + `name: ${projectName}`, + `version: 1.0.0`, + ``, + `# Skills — add entries here or run 'squad skill publish' to auto-populate`, + `skills: []`, + ``, + `# Instruction files deployed by 'apm install'`, + `instructions:`, + ` - path: .squad/copilot-instructions.md`, + ` target: .github/copilot-instructions.md`, + ``, + `# Prompts deployed by 'apm install'`, + `prompts:`, + ` - path: .squad/skills/*/skill.md`, + ` target: .github/prompts/`, + ].join('\n') + '\n'; + + fs.writeFileSync(apmPath, content, 'utf8'); +} + /** * Detect if the target directory is inside a parent git repo. * Returns the normalized git root path if a parent repo is detected, @@ -287,6 +330,19 @@ export async function runInit(dest: string, options: RunInitOptions = {}): Promi console.log(`${DIM}${file} already exists — skipping${RESET}`); } + // ── APM manifest ───────────────────────────────────────────────────────────── + // Generate apm.yml so skills are ready for APM publishing/installation. + // Uses the project name derived from the directory basename. + const projectName = path.basename(dest) || 'my-project'; + const apmPath = path.join(dest, 'apm.yml'); + const apmAlreadyExisted = fs.existsSync(apmPath); + generateApmYml(dest, projectName); + if (!apmAlreadyExisted) { + success('apm.yml'); + } else { + console.log(`${DIM}apm.yml already exists — skipping${RESET}`); + } + // ── Celebration ceremony ────────────────────────────────────────── console.log(); await typewrite(`${CYAN}${BOLD}◆ SQUAD${RESET}`, 10); diff --git a/packages/squad-cli/src/cli/index.ts b/packages/squad-cli/src/cli/index.ts index b56cb21a0..191439e85 100644 --- a/packages/squad-cli/src/cli/index.ts +++ b/packages/squad-cli/src/cli/index.ts @@ -40,3 +40,5 @@ export { runExport } from './commands/export.js'; export { runImport } from './commands/import.js'; export { splitHistory } from './core/history-split.js'; export { discoverCommand, delegateCommand } from './commands/cross-squad.js'; +export { runSkill, type ApmSkill, type ApmManifest } from './commands/skill.js'; +export { generateApmYml } from './core/init.js'; From 35dd82426326311802667f49c90633931d4452a6 Mon Sep 17 00:00:00 2001 From: Copilot Date: Wed, 8 Apr 2026 19:23:13 +0300 Subject: [PATCH 5/7] fix: address post-merge review findings (#876, #900, #875) - Add YAML value escaping helper for skill metadata - Replace catch(err: any) with catch(err: unknown) + narrowing - Add type guards to replace unsafe type assertions - Standardize deprecation messages on `gh copilot` - Fix unsafe exports type cast in cross-package test Closes #924, #925, #926 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/squad-cli/src/cli-entry.ts | 4 +- packages/squad-cli/src/cli/commands/plugin.ts | 5 +- packages/squad-cli/src/cli/commands/skill.ts | 54 +++++++++++++------ packages/squad-cli/src/cli/core/init.ts | 8 +-- test/cross-package-exports.test.ts | 19 +++++-- 5 files changed, 63 insertions(+), 27 deletions(-) diff --git a/packages/squad-cli/src/cli-entry.ts b/packages/squad-cli/src/cli-entry.ts index 345ddde95..4841d1877 100644 --- a/packages/squad-cli/src/cli-entry.ts +++ b/packages/squad-cli/src/cli-entry.ts @@ -719,7 +719,7 @@ async function main(): Promise { 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}copilot${RESET} or ${BOLD}gh copilot${RESET}\n`); + 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'); if (hasTunnel) { @@ -805,7 +805,7 @@ async function main(): Promise { 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}copilot${RESET} or ${BOLD}gh copilot${RESET}\n`); + 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'); diff --git a/packages/squad-cli/src/cli/commands/plugin.ts b/packages/squad-cli/src/cli/commands/plugin.ts index 691f8dd28..b51a7d26f 100644 --- a/packages/squad-cli/src/cli/commands/plugin.ts +++ b/packages/squad-cli/src/cli/commands/plugin.ts @@ -156,8 +156,9 @@ export async function runPlugin(dest: string, args: string[]): Promise { { timeout: TIMEOUTS.PLUGIN_FETCH_MS } ); entries = JSON.parse(stdout.trim()); - } catch (err: any) { - fatal(`Could not browse ${marketplace.source} — ${err.message}`); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + fatal(`Could not browse ${marketplace.source} — ${message}`); } if (!entries || entries.length === 0) { diff --git a/packages/squad-cli/src/cli/commands/skill.ts b/packages/squad-cli/src/cli/commands/skill.ts index cd527c0e5..1e7885fe3 100644 --- a/packages/squad-cli/src/cli/commands/skill.ts +++ b/packages/squad-cli/src/cli/commands/skill.ts @@ -42,6 +42,26 @@ export interface ApmManifest { // ── Helpers ─────────────────────────────────────────────────────────────────── +/** Wrap a YAML scalar in single quotes if it contains special characters. */ +export function escapeYamlValue(val: string): string { + if (/[:#'"\n\r]/.test(val)) { + return `'${val.replace(/'/g, "''")}'`; + } + return val; +} + +/** Type guard for objects with string `name` and `path` properties. */ +function isNamedPath(obj: unknown): obj is { name: string; path: string } { + return ( + typeof obj === 'object' && + obj !== null && + 'name' in obj && + 'path' in obj && + typeof (obj as Record).name === 'string' && + typeof (obj as Record).path === 'string' + ); +} + /** Parse `---\nkey: value\n---` front-matter from a Markdown file. */ function parseFrontMatter(content: string): Record { const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); @@ -144,14 +164,14 @@ async function publish(dest: string, skillName?: string): Promise { // Build the skill's own apm.yml inside its directory const apmSkillPath = join(skillsDir, skillName, 'apm.yml'); const skillApm = [ - `name: ${fm['name'] ?? skillName}`, - `version: ${fm['version'] ?? '1.0.0'}`, - fm['description'] ? `description: "${fm['description']}"` : null, + `name: ${escapeYamlValue(fm['name'] ?? skillName)}`, + `version: ${escapeYamlValue(fm['version'] ?? '1.0.0')}`, + fm['description'] ? `description: ${escapeYamlValue(fm['description'])}` : null, ``, `skills:`, - ` - name: ${fm['name'] ?? skillName}`, + ` - name: ${escapeYamlValue(fm['name'] ?? skillName)}`, ` path: skill.md`, - fm['description'] ? ` description: "${fm['description']}"` : null, + fm['description'] ? ` description: ${escapeYamlValue(fm['description'])}` : null, ] .filter(l => l !== null) .join('\n'); @@ -186,17 +206,17 @@ async function publish(dest: string, skillName?: string): Promise { `# apm.yml — Agent Package Manager manifest`, `# See: https://github.com/microsoft/apm`, ``, - `name: ${existing.name ?? projectName}`, + `name: ${escapeYamlValue(existing.name ?? projectName)}`, `version: 1.0.0`, ``, `# Skills exported from ${relPrefix}/`, `skills:`, ...skills.map(s => [ - ` - name: ${s.name}`, - s.description ? ` description: "${s.description}"` : null, + ` - name: ${escapeYamlValue(s.name)}`, + s.description ? ` description: ${escapeYamlValue(s.description)}` : null, ` path: ${s.path}`, - s.version ? ` version: ${s.version}` : null, + s.version ? ` version: ${escapeYamlValue(s.version)}` : null, ] .filter(l => l !== null) .join('\n') @@ -281,8 +301,9 @@ async function installFromUrl(url: string, skillsDir: string, relPrefix: string) fatal(`Failed to fetch ${url}: ${res.status} ${res.statusText}`); } content = await res.text(); - } catch (err: any) { - fatal(`Failed to fetch ${url}: ${err.message}`); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + fatal(`Failed to fetch ${url}: ${message}`); return; } @@ -341,7 +362,7 @@ async function installFromGitHub( } if (inSkills && line.match(/^[a-z]/i) && !line.startsWith(' ')) { // New top-level key — exit skills section - if (currentSkill.name && currentSkill.path) skillPaths.push(currentSkill as { name: string; path: string }); + if (isNamedPath(currentSkill)) skillPaths.push(currentSkill); currentSkill = {}; inSkills = false; } @@ -349,13 +370,13 @@ async function installFromGitHub( const nameMatch = line.match(/^\s+- name:\s*(.+)$/); const pathMatch = line.match(/^\s+path:\s*(.+)$/); if (nameMatch) { - if (currentSkill.name && currentSkill.path) skillPaths.push(currentSkill as { name: string; path: string }); + if (isNamedPath(currentSkill)) skillPaths.push(currentSkill); currentSkill = { name: nameMatch[1]!.trim() }; } if (pathMatch) currentSkill.path = pathMatch[1]!.trim(); } } - if (currentSkill.name && currentSkill.path) skillPaths.push(currentSkill as { name: string; path: string }); + if (isNamedPath(currentSkill)) skillPaths.push(currentSkill); // Filter by skill name if specified const toInstall = skillFilter @@ -395,8 +416,9 @@ async function installFromGitHub( success(`Installed skill '${skill.name}'`); info(` ${DIM}Source: ${owner}/${repo}${skill.path}${RESET}`); installed++; - } catch (err: any) { - warn(`Failed to install '${skill.name}': ${err.message}`); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + warn(`Failed to install '${skill.name}': ${message}`); } } diff --git a/packages/squad-cli/src/cli/core/init.ts b/packages/squad-cli/src/cli/core/init.ts index 22ec44af3..708a1a2b6 100644 --- a/packages/squad-cli/src/cli/core/init.ts +++ b/packages/squad-cli/src/cli/core/init.ts @@ -13,6 +13,7 @@ import { fatal } from './errors.js'; import { detectProjectType } from './project-type.js'; import { getPackageVersion, stampVersion } from './version.js'; import { initSquad as sdkInitSquad, cleanupOrphanInitPrompt, ensurePersonalSquadDir, resolvePersonalSquadDir, type InitOptions } from '@bradygaster/squad-sdk'; +import { escapeYamlValue } from '../commands/skill.js'; const storage = new FSStorageProvider(); @@ -40,7 +41,7 @@ export function generateApmYml(dest: string, projectName: string): void { `# This file makes your Squad skills versioned, portable, and community-shareable.`, `# Run 'squad skill publish' to populate the skills section after adding skills.`, ``, - `name: ${projectName}`, + `name: ${escapeYamlValue(projectName)}`, `version: 1.0.0`, ``, `# Skills — add entries here or run 'squad skill publish' to auto-populate`, @@ -292,9 +293,10 @@ export async function runInit(dest: string, options: RunInitOptions = {}): Promi let result; try { result = await sdkInitSquad(initOptions); - } catch (err: any) { + } catch (err: unknown) { process.off('SIGINT', sigintHandler); - fatal(`Failed to initialize squad: ${err.message}`); + const message = err instanceof Error ? err.message : String(err); + fatal(`Failed to initialize squad: ${message}`); return; // Unreachable but makes TS happy } diff --git a/test/cross-package-exports.test.ts b/test/cross-package-exports.test.ts index 613aafe6a..081fd2579 100644 --- a/test/cross-package-exports.test.ts +++ b/test/cross-package-exports.test.ts @@ -15,6 +15,8 @@ * add a corresponding assertion here. The grep one-liner in the test * description shows how to audit. * + * grep -rn "from '@bradygaster/squad-sdk" packages/squad-cli/src/ + * * Related incident: v0.9.3-insider.1 shipped with FSStorageProvider missing * from the SDK barrel — broke users at runtime while tests passed locally. */ @@ -264,7 +266,13 @@ describe('cross-package exports — CLI → SDK', () => { const pkg = JSON.parse( fs.readFileSync(resolve(sdkRoot!, 'package.json'), 'utf8'), ); - const exportsMap = pkg.exports as Record>; + expect(pkg.exports, 'SDK package.json should have an exports field').toBeDefined(); + expect( + typeof pkg.exports === 'object' && pkg.exports !== null, + 'exports should be an object', + ).toBe(true); + + const exportsMap = pkg.exports as Record; const missing: string[] = []; for (const [subpath, targets] of Object.entries(exportsMap)) { @@ -274,9 +282,12 @@ describe('cross-package exports — CLI → SDK', () => { } continue; } - for (const [condition, file] of Object.entries(targets)) { - if (!existsSync(resolve(sdkRoot!, file))) { - missing.push(`${subpath}[${condition}] → ${file}`); + if (typeof targets === 'object' && targets !== null) { + for (const [condition, file] of Object.entries(targets as Record)) { + if (typeof file !== 'string') continue; + if (!existsSync(resolve(sdkRoot!, file))) { + missing.push(`${subpath}[${condition}] → ${file}`); + } } } } From 489c73ba188bd2bf2f11a6b9f713a10fb9b162c9 Mon Sep 17 00:00:00 2001 From: Copilot Date: Wed, 8 Apr 2026 19:24:58 +0300 Subject: [PATCH 6/7] chore: add changeset for review findings fix Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .changeset/review-findings-fix.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/review-findings-fix.md diff --git a/.changeset/review-findings-fix.md b/.changeset/review-findings-fix.md new file mode 100644 index 000000000..3e50d8330 --- /dev/null +++ b/.changeset/review-findings-fix.md @@ -0,0 +1,6 @@ +--- +'@bradygaster/squad-cli': patch +--- + +fix: address post-merge review findings — YAML escaping, type safety, deprecation messages + From 4aa6c4ef1d6b908efb79d5c5ff553310289e0444 Mon Sep 17 00:00:00 2001 From: Copilot Date: Thu, 9 Apr 2026 07:53:55 +0300 Subject: [PATCH 7/7] chore: trigger CI Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .changeset/review-findings-fix.md | 1 - 1 file changed, 1 deletion(-) diff --git a/.changeset/review-findings-fix.md b/.changeset/review-findings-fix.md index 3e50d8330..e557bf007 100644 --- a/.changeset/review-findings-fix.md +++ b/.changeset/review-findings-fix.md @@ -3,4 +3,3 @@ --- fix: address post-merge review findings — YAML escaping, type safety, deprecation messages -